Skip to content

Commit fd9d2b7

Browse files
feat(autocomplete): introduce JavaScript API
1 parent 50b4879 commit fd9d2b7

File tree

11 files changed

+422
-15
lines changed

11 files changed

+422
-15
lines changed

bundlesize.config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
"path": "packages/autocomplete-preset-algolia/dist/umd/index.js",
99
"maxSize": "1.25 kB"
1010
},
11+
{
12+
"path": "packages/autocomplete-js/dist/umd/index.js",
13+
"maxSize": "6.5 kB"
14+
},
1115
{
1216
"path": "packages/autocomplete-react/dist/umd/index.js",
1317
"maxSize": "11 kB"

packages/autocomplete-core/src/types/api.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,19 @@ export type GetSources<TItem> = (
110110
params: GetSourcesParams<TItem>
111111
) => Promise<Array<AutocompleteSource<TItem>>>;
112112

113-
export interface Environment {
114-
[prop: string]: unknown;
115-
addEventListener: Window['addEventListener'];
116-
removeEventListener: Window['removeEventListener'];
117-
setTimeout: Window['setTimeout'];
118-
document: Window['document'];
119-
location: {
120-
assign: Location['assign'];
121-
};
122-
open: Window['open'];
123-
}
113+
export type Environment =
114+
| Window
115+
| {
116+
[prop: string]: unknown;
117+
addEventListener: Window['addEventListener'];
118+
removeEventListener: Window['removeEventListener'];
119+
setTimeout: Window['setTimeout'];
120+
document: Window['document'];
121+
location: {
122+
assign: Location['assign'];
123+
};
124+
open: Window['open'];
125+
};
124126

125127
interface Navigator<TItem> {
126128
/**
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@francoischalifour/autocomplete-js",
3+
"description": "Fast and fully-featured autocomplete JavaScript library.",
4+
"version": "1.0.0-alpha.27",
5+
"license": "MIT",
6+
"source": "src/index.ts",
7+
"types": "dist/esm/index.d.ts",
8+
"module": "dist/esm/index.js",
9+
"main": "dist/umd/index.js",
10+
"umd:main": "dist/umd/index.js",
11+
"unpkg": "dist/umd/index.js",
12+
"jsdelivr": "dist/umd/index.js",
13+
"homepage": "https://github.com/algolia/autocomplete.js",
14+
"repository": "algolia/autocomplete.js",
15+
"scripts": {
16+
"build": "rm -rf ./dist && yarn build:umd && yarn build:esm && yarn build:types",
17+
"build:esm": "babel src --root-mode upward --extensions '.ts,.tsx' --out-dir dist/esm",
18+
"build:esm:watch": "yarn build:esm --watch",
19+
"build:umd": "rollup --config",
20+
"build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/esm",
21+
"build:clean": "rm -rf ./dist",
22+
"on:change": "concurrently \"yarn build:esm\" \"yarn build:types\"",
23+
"watch": "watch \"yarn on:change\" --ignoreDirectoryPattern \"/dist/\""
24+
},
25+
"author": {
26+
"name": "Algolia, Inc.",
27+
"url": "https://www.algolia.com"
28+
},
29+
"sideEffects": false,
30+
"files": [
31+
"dist/"
32+
],
33+
"dependencies": {
34+
"@francoischalifour/autocomplete-core": "^1.0.0-alpha.27",
35+
"@francoischalifour/autocomplete-preset-algolia": "^1.0.0-alpha.27"
36+
}
37+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { plugins } from '../../rollup.base.config';
2+
3+
import { name } from './package.json';
4+
5+
if (process.env.NODE_ENV === 'production' && !process.env.VERSION) {
6+
throw new Error(
7+
`You need to specify a valid semver environment variable 'VERSION' to run the build process (received: ${JSON.stringify(
8+
process.env.VERSION
9+
)}).`
10+
);
11+
}
12+
13+
export default {
14+
input: 'src/index.ts',
15+
output: {
16+
file: 'dist/umd/index.js',
17+
format: 'umd',
18+
sourcemap: true,
19+
name,
20+
},
21+
plugins,
22+
};
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import {
2+
createAutocomplete,
3+
AutocompleteOptions as AutocompleteCoreOptions,
4+
AutocompleteSource as AutocompleteCoreSource,
5+
AutocompleteState,
6+
GetSourcesParams,
7+
} from '@francoischalifour/autocomplete-core';
8+
9+
import { getHTMLElement } from './getHTMLElement';
10+
import { setProperties } from './setProperties';
11+
12+
function renderTemplate(template: string | void, root: HTMLElement) {
13+
if (typeof template === 'string') {
14+
root.innerHTML = template;
15+
}
16+
}
17+
18+
function defaultRender({ root, sections }) {
19+
for (const section of sections) {
20+
root.appendChild(section);
21+
}
22+
}
23+
24+
type Template<TParams> = (params: TParams) => string | void;
25+
26+
type AutocompleteSource<TItem> = AutocompleteCoreSource<TItem> & {
27+
templates: {
28+
item: Template<{
29+
root: HTMLElement;
30+
item: TItem;
31+
state: AutocompleteState<TItem>;
32+
}>;
33+
header?: Template<{ root: HTMLElement; state: AutocompleteState<TItem> }>;
34+
footer?: Template<{ root: HTMLElement; state: AutocompleteState<TItem> }>;
35+
};
36+
};
37+
38+
type GetSources<TItem> = (
39+
params: GetSourcesParams<TItem>
40+
) => Promise<Array<AutocompleteSource<TItem>>>;
41+
42+
export interface AutocompleteOptions<TItem>
43+
extends AutocompleteCoreOptions<TItem> {
44+
container: string | HTMLElement;
45+
render(params: { root: HTMLElement; sections: HTMLElement[] }): void;
46+
getSources: GetSources<TItem>;
47+
}
48+
49+
export function autocomplete<TItem>({
50+
container,
51+
render: renderDropdown = defaultRender,
52+
...props
53+
}: AutocompleteOptions<TItem>) {
54+
const containerElement = getHTMLElement(container, props.environment);
55+
const inputWrapper = document.createElement('div');
56+
const input = document.createElement('input');
57+
const root = document.createElement('div');
58+
const form = document.createElement('form');
59+
const label = document.createElement('label');
60+
const resetButton = document.createElement('button');
61+
const dropdown = document.createElement('div');
62+
63+
const autocomplete = createAutocomplete({
64+
onStateChange(options) {
65+
const { state } = options;
66+
render(state as any);
67+
68+
if (props.onStateChange) {
69+
props.onStateChange(options);
70+
}
71+
},
72+
...props,
73+
});
74+
75+
const environmentProps = autocomplete.getEnvironmentProps({
76+
searchBoxElement: form,
77+
dropdownElement: dropdown,
78+
inputElement: input,
79+
});
80+
81+
setProperties(window, environmentProps);
82+
83+
const rootProps = autocomplete.getRootProps();
84+
setProperties(root, rootProps);
85+
86+
const formProps = autocomplete.getFormProps({ inputElement: input });
87+
setProperties(form, formProps);
88+
form.setAttribute('action', '');
89+
form.setAttribute('role', 'search');
90+
form.setAttribute('no-validate', '');
91+
form.classList.add('algolia-autocomplete-form');
92+
93+
const labelProps = autocomplete.getLabelProps();
94+
setProperties(label, labelProps);
95+
label.textContent = 'Search items';
96+
97+
inputWrapper.classList.add('autocomplete-input-wrapper');
98+
99+
const inputProps = autocomplete.getInputProps({ inputElement: input });
100+
setProperties(input, inputProps);
101+
102+
const completion = document.createElement('span');
103+
completion.classList.add('autocomplete-completion');
104+
105+
resetButton.setAttribute('type', 'reset');
106+
resetButton.textContent = 'x';
107+
resetButton.addEventListener('click', formProps.onReset);
108+
109+
const dropdownProps = autocomplete.getDropdownProps({});
110+
setProperties(dropdown, dropdownProps);
111+
dropdown.classList.add('autocomplete-dropdown');
112+
dropdown.setAttribute('hidden', '');
113+
114+
function render(state: AutocompleteState<TItem>) {
115+
input.value = state.query;
116+
117+
if (props.showCompletion) {
118+
completion.textContent = state.completion;
119+
}
120+
121+
dropdown.innerHTML = '';
122+
123+
if (state.isOpen) {
124+
dropdown.removeAttribute('hidden');
125+
} else {
126+
dropdown.setAttribute('hidden', '');
127+
return;
128+
}
129+
130+
if (state.status === 'stalled') {
131+
dropdown.classList.add('autocomplete-dropdown--stalled');
132+
} else {
133+
dropdown.classList.remove('autocomplete-dropdown--stalled');
134+
}
135+
136+
const sections = state.suggestions.map((suggestion) => {
137+
const items = suggestion.items;
138+
const source = suggestion.source as AutocompleteSource<TItem>;
139+
140+
const section = document.createElement('section');
141+
142+
if (source.templates.header) {
143+
const header = document.createElement('header');
144+
renderTemplate(
145+
source.templates.header({ root: header, state }),
146+
header
147+
);
148+
section.appendChild(header);
149+
}
150+
151+
if (items.length > 0) {
152+
const menu = document.createElement('ul');
153+
const menuProps = autocomplete.getMenuProps();
154+
setProperties(menu, menuProps);
155+
156+
const menuItems = items.map((item) => {
157+
const li = document.createElement('li');
158+
const itemProps = autocomplete.getItemProps({ item, source });
159+
setProperties(li, itemProps);
160+
161+
renderTemplate(source.templates.item({ root: li, item, state }), li);
162+
163+
return li;
164+
});
165+
166+
for (const menuItem of menuItems) {
167+
menu.appendChild(menuItem);
168+
}
169+
170+
section.appendChild(menu);
171+
}
172+
173+
if (source.templates.footer) {
174+
const footer = document.createElement('footer');
175+
renderTemplate(
176+
source.templates.footer({ root: footer, state }),
177+
footer
178+
);
179+
section.appendChild(footer);
180+
}
181+
182+
return section;
183+
});
184+
185+
renderDropdown({ root: dropdown, sections });
186+
}
187+
188+
form.appendChild(label);
189+
if (props.showCompletion) {
190+
inputWrapper.appendChild(completion);
191+
}
192+
inputWrapper.appendChild(input);
193+
inputWrapper.appendChild(resetButton);
194+
form.appendChild(inputWrapper);
195+
root.appendChild(form);
196+
root.appendChild(dropdown);
197+
containerElement.appendChild(root);
198+
199+
return autocomplete;
200+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { AutocompleteOptions } from '@francoischalifour/autocomplete-core';
2+
3+
export function getHTMLElement(
4+
value: string | HTMLElement,
5+
environment: AutocompleteOptions<any>['environment']
6+
): HTMLElement {
7+
if (typeof value === 'string') {
8+
return environment.document.querySelector<HTMLElement>(value)!;
9+
}
10+
11+
return value;
12+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
parseHighlightedAttribute,
3+
parseReverseHighlightedAttribute,
4+
} from '@francoischalifour/autocomplete-preset-algolia';
5+
6+
type HighlightItemParams = {
7+
item: any;
8+
attribute: string;
9+
highlightPreTag?: string;
10+
highlightPostTag?: string;
11+
};
12+
13+
export function highlightItem({
14+
item,
15+
attribute,
16+
highlightPreTag = '<mark>',
17+
highlightPostTag = '</mark>',
18+
}: HighlightItemParams) {
19+
return parseHighlightedAttribute({
20+
hit: item,
21+
attribute,
22+
highlightPreTag,
23+
highlightPostTag,
24+
}).reduce((acc, current) => {
25+
return (
26+
acc +
27+
(current.isHighlighted
28+
? current.value
29+
: `${highlightPreTag}${current.value}${highlightPostTag}`)
30+
);
31+
}, '');
32+
}
33+
34+
export function reverseHighlightItem({
35+
item,
36+
attribute,
37+
highlightPreTag = '<mark>',
38+
highlightPostTag = '</mark>',
39+
}: HighlightItemParams) {
40+
return parseReverseHighlightedAttribute({
41+
hit: item,
42+
attribute,
43+
highlightPreTag,
44+
highlightPostTag,
45+
}).reduce((acc, current) => {
46+
return (
47+
acc +
48+
(current.isHighlighted
49+
? current.value
50+
: `${highlightPreTag}${current.value}${highlightPostTag}`)
51+
);
52+
}, '');
53+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from './autocomplete';
2+
export * from './highlight';
3+
export {
4+
getAlgoliaResults,
5+
getAlgoliaHits,
6+
} from '@francoischalifour/autocomplete-preset-algolia';

0 commit comments

Comments
 (0)