diff --git a/bundlesize.config.json b/bundlesize.config.json index 18840c3ca..b3d2850ad 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -8,6 +8,10 @@ "path": "packages/autocomplete-preset-algolia/dist/umd/index.js", "maxSize": "1.25 kB" }, + { + "path": "packages/autocomplete-js/dist/umd/index.js", + "maxSize": "6.5 kB" + }, { "path": "packages/autocomplete-react/dist/umd/index.js", "maxSize": "11 kB" diff --git a/packages/autocomplete-core/src/types/api.ts b/packages/autocomplete-core/src/types/api.ts index aede74f34..8139504dd 100644 --- a/packages/autocomplete-core/src/types/api.ts +++ b/packages/autocomplete-core/src/types/api.ts @@ -110,17 +110,19 @@ export type GetSources = ( params: GetSourcesParams ) => Promise>>; -export interface Environment { - [prop: string]: unknown; - addEventListener: Window['addEventListener']; - removeEventListener: Window['removeEventListener']; - setTimeout: Window['setTimeout']; - document: Window['document']; - location: { - assign: Location['assign']; - }; - open: Window['open']; -} +export type Environment = + | Window + | { + [prop: string]: unknown; + addEventListener: Window['addEventListener']; + removeEventListener: Window['removeEventListener']; + setTimeout: Window['setTimeout']; + document: Window['document']; + location: { + assign: Location['assign']; + }; + open: Window['open']; + }; interface Navigator { /** diff --git a/packages/autocomplete-js/package.json b/packages/autocomplete-js/package.json new file mode 100644 index 000000000..42cc0f4a6 --- /dev/null +++ b/packages/autocomplete-js/package.json @@ -0,0 +1,37 @@ +{ + "name": "@francoischalifour/autocomplete-js", + "description": "Fast and fully-featured autocomplete JavaScript library.", + "version": "1.0.0-alpha.27", + "license": "MIT", + "source": "src/index.ts", + "types": "dist/esm/index.d.ts", + "module": "dist/esm/index.js", + "main": "dist/umd/index.js", + "umd:main": "dist/umd/index.js", + "unpkg": "dist/umd/index.js", + "jsdelivr": "dist/umd/index.js", + "homepage": "https://github.com/algolia/autocomplete.js", + "repository": "algolia/autocomplete.js", + "scripts": { + "build": "rm -rf ./dist && yarn build:umd && yarn build:esm && yarn build:types", + "build:esm": "babel src --root-mode upward --extensions '.ts,.tsx' --out-dir dist/esm", + "build:esm:watch": "yarn build:esm --watch", + "build:umd": "rollup --config", + "build:types": "tsc -p ./tsconfig.declaration.json --outDir ./dist/esm", + "build:clean": "rm -rf ./dist", + "on:change": "concurrently \"yarn build:esm\" \"yarn build:types\"", + "watch": "watch \"yarn on:change\" --ignoreDirectoryPattern \"/dist/\"" + }, + "author": { + "name": "Algolia, Inc.", + "url": "https://www.algolia.com" + }, + "sideEffects": false, + "files": [ + "dist/" + ], + "dependencies": { + "@francoischalifour/autocomplete-core": "^1.0.0-alpha.27", + "@francoischalifour/autocomplete-preset-algolia": "^1.0.0-alpha.27" + } +} diff --git a/packages/autocomplete-js/rollup.config.js b/packages/autocomplete-js/rollup.config.js new file mode 100644 index 000000000..095cec2ae --- /dev/null +++ b/packages/autocomplete-js/rollup.config.js @@ -0,0 +1,22 @@ +import { plugins } from '../../rollup.base.config'; + +import { name } from './package.json'; + +if (process.env.NODE_ENV === 'production' && !process.env.VERSION) { + throw new Error( + `You need to specify a valid semver environment variable 'VERSION' to run the build process (received: ${JSON.stringify( + process.env.VERSION + )}).` + ); +} + +export default { + input: 'src/index.ts', + output: { + file: 'dist/umd/index.js', + format: 'umd', + sourcemap: true, + name, + }, + plugins, +}; diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts new file mode 100644 index 000000000..1265f1fd3 --- /dev/null +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -0,0 +1,200 @@ +import { + createAutocomplete, + AutocompleteOptions as AutocompleteCoreOptions, + AutocompleteSource as AutocompleteCoreSource, + AutocompleteState, + GetSourcesParams, +} from '@francoischalifour/autocomplete-core'; + +import { getHTMLElement } from './getHTMLElement'; +import { setProperties } from './setProperties'; + +function renderTemplate(template: string | void, root: HTMLElement) { + if (typeof template === 'string') { + root.innerHTML = template; + } +} + +function defaultRender({ root, sections }) { + for (const section of sections) { + root.appendChild(section); + } +} + +type Template = (params: TParams) => string | void; + +type AutocompleteSource = AutocompleteCoreSource & { + templates: { + item: Template<{ + root: HTMLElement; + item: TItem; + state: AutocompleteState; + }>; + header?: Template<{ root: HTMLElement; state: AutocompleteState }>; + footer?: Template<{ root: HTMLElement; state: AutocompleteState }>; + }; +}; + +type GetSources = ( + params: GetSourcesParams +) => Promise>>; + +export interface AutocompleteOptions + extends AutocompleteCoreOptions { + container: string | HTMLElement; + render(params: { root: HTMLElement; sections: HTMLElement[] }): void; + getSources: GetSources; +} + +export function autocomplete({ + container, + render: renderDropdown = defaultRender, + ...props +}: AutocompleteOptions) { + const containerElement = getHTMLElement(container, props.environment); + const inputWrapper = document.createElement('div'); + const input = document.createElement('input'); + const root = document.createElement('div'); + const form = document.createElement('form'); + const label = document.createElement('label'); + const resetButton = document.createElement('button'); + const dropdown = document.createElement('div'); + + const autocomplete = createAutocomplete({ + onStateChange(options) { + const { state } = options; + render(state as any); + + if (props.onStateChange) { + props.onStateChange(options); + } + }, + ...props, + }); + + const environmentProps = autocomplete.getEnvironmentProps({ + searchBoxElement: form, + dropdownElement: dropdown, + inputElement: input, + }); + + setProperties(window, environmentProps); + + const rootProps = autocomplete.getRootProps(); + setProperties(root, rootProps); + + const formProps = autocomplete.getFormProps({ inputElement: input }); + setProperties(form, formProps); + form.setAttribute('action', ''); + form.setAttribute('role', 'search'); + form.setAttribute('no-validate', ''); + form.classList.add('algolia-autocomplete-form'); + + const labelProps = autocomplete.getLabelProps(); + setProperties(label, labelProps); + label.textContent = 'Search items'; + + inputWrapper.classList.add('autocomplete-input-wrapper'); + + const inputProps = autocomplete.getInputProps({ inputElement: input }); + setProperties(input, inputProps); + + const completion = document.createElement('span'); + completion.classList.add('autocomplete-completion'); + + resetButton.setAttribute('type', 'reset'); + resetButton.textContent = 'x'; + resetButton.addEventListener('click', formProps.onReset); + + const dropdownProps = autocomplete.getDropdownProps({}); + setProperties(dropdown, dropdownProps); + dropdown.classList.add('autocomplete-dropdown'); + dropdown.setAttribute('hidden', ''); + + function render(state: AutocompleteState) { + input.value = state.query; + + if (props.showCompletion) { + completion.textContent = state.completion; + } + + dropdown.innerHTML = ''; + + if (state.isOpen) { + dropdown.removeAttribute('hidden'); + } else { + dropdown.setAttribute('hidden', ''); + return; + } + + if (state.status === 'stalled') { + dropdown.classList.add('autocomplete-dropdown--stalled'); + } else { + dropdown.classList.remove('autocomplete-dropdown--stalled'); + } + + const sections = state.suggestions.map((suggestion) => { + const items = suggestion.items; + const source = suggestion.source as AutocompleteSource; + + const section = document.createElement('section'); + + if (source.templates.header) { + const header = document.createElement('header'); + renderTemplate( + source.templates.header({ root: header, state }), + header + ); + section.appendChild(header); + } + + if (items.length > 0) { + const menu = document.createElement('ul'); + const menuProps = autocomplete.getMenuProps(); + setProperties(menu, menuProps); + + const menuItems = items.map((item) => { + const li = document.createElement('li'); + const itemProps = autocomplete.getItemProps({ item, source }); + setProperties(li, itemProps); + + renderTemplate(source.templates.item({ root: li, item, state }), li); + + return li; + }); + + for (const menuItem of menuItems) { + menu.appendChild(menuItem); + } + + section.appendChild(menu); + } + + if (source.templates.footer) { + const footer = document.createElement('footer'); + renderTemplate( + source.templates.footer({ root: footer, state }), + footer + ); + section.appendChild(footer); + } + + return section; + }); + + renderDropdown({ root: dropdown, sections }); + } + + form.appendChild(label); + if (props.showCompletion) { + inputWrapper.appendChild(completion); + } + inputWrapper.appendChild(input); + inputWrapper.appendChild(resetButton); + form.appendChild(inputWrapper); + root.appendChild(form); + root.appendChild(dropdown); + containerElement.appendChild(root); + + return autocomplete; +} diff --git a/packages/autocomplete-js/src/getHTMLElement.ts b/packages/autocomplete-js/src/getHTMLElement.ts new file mode 100644 index 000000000..2fb00f862 --- /dev/null +++ b/packages/autocomplete-js/src/getHTMLElement.ts @@ -0,0 +1,12 @@ +import { AutocompleteOptions } from '@francoischalifour/autocomplete-core'; + +export function getHTMLElement( + value: string | HTMLElement, + environment: AutocompleteOptions['environment'] +): HTMLElement { + if (typeof value === 'string') { + return environment.document.querySelector(value)!; + } + + return value; +} diff --git a/packages/autocomplete-js/src/highlight.ts b/packages/autocomplete-js/src/highlight.ts new file mode 100644 index 000000000..24d417860 --- /dev/null +++ b/packages/autocomplete-js/src/highlight.ts @@ -0,0 +1,53 @@ +import { + parseHighlightedAttribute, + parseReverseHighlightedAttribute, +} from '@francoischalifour/autocomplete-preset-algolia'; + +type HighlightItemParams = { + item: any; + attribute: string; + highlightPreTag?: string; + highlightPostTag?: string; +}; + +export function highlightItem({ + item, + attribute, + highlightPreTag = '', + highlightPostTag = '', +}: HighlightItemParams) { + return parseHighlightedAttribute({ + hit: item, + attribute, + highlightPreTag, + highlightPostTag, + }).reduce((acc, current) => { + return ( + acc + + (current.isHighlighted + ? current.value + : `${highlightPreTag}${current.value}${highlightPostTag}`) + ); + }, ''); +} + +export function reverseHighlightItem({ + item, + attribute, + highlightPreTag = '', + highlightPostTag = '', +}: HighlightItemParams) { + return parseReverseHighlightedAttribute({ + hit: item, + attribute, + highlightPreTag, + highlightPostTag, + }).reduce((acc, current) => { + return ( + acc + + (current.isHighlighted + ? current.value + : `${highlightPreTag}${current.value}${highlightPostTag}`) + ); + }, ''); +} diff --git a/packages/autocomplete-js/src/index.ts b/packages/autocomplete-js/src/index.ts new file mode 100644 index 000000000..8b795d994 --- /dev/null +++ b/packages/autocomplete-js/src/index.ts @@ -0,0 +1,6 @@ +export * from './autocomplete'; +export * from './highlight'; +export { + getAlgoliaResults, + getAlgoliaHits, +} from '@francoischalifour/autocomplete-preset-algolia'; diff --git a/packages/autocomplete-js/src/setProperties.ts b/packages/autocomplete-js/src/setProperties.ts new file mode 100644 index 000000000..07d73e411 --- /dev/null +++ b/packages/autocomplete-js/src/setProperties.ts @@ -0,0 +1,68 @@ +// Taken from Preact +// https://github.com/preactjs/preact/blob/6ab49d9020740127577bf4af66bf63f4af7f9fee/src/diff/props.js#L58-L151 +function setProperty(element: HTMLElement | Window, name: string, value: any) { + let useCapture: boolean; + let nameLower: string; + + // Benchmark for comparison: https://esbench.com/bench/574c954bdb965b9a00965ac6 + if (name[0] === 'o' && name[1] === 'n') { + useCapture = name !== (name = name.replace(/Capture$/, '')); + nameLower = name.toLowerCase(); + if (nameLower in element) name = nameLower; + name = name.slice(2); + + if (value) { + element.addEventListener(name, value, useCapture); + } else { + element.removeEventListener(name, value, useCapture); + } + } else if ( + name !== 'list' && + name !== 'tagName' && + // HTMLButtonElement.form and HTMLInputElement.form are read-only but can be set using + // setAttribute + name !== 'form' && + name !== 'type' && + name !== 'size' && + name !== 'download' && + name !== 'href' && + name in element + ) { + element[name] = value === null ? '' : value; + } else if ( + typeof value !== 'function' && + name !== 'dangerouslySetInnerHTML' + ) { + if ( + value === null || + (value === false && + // ARIA-attributes have a different notion of boolean values. + // The value `false` is different from the attribute not + // existing on the DOM, so we can't remove it. For non-boolean + // ARIA-attributes we could treat false as a removal, but the + // amount of exceptions would cost us too many bytes. On top of + // that other VDOM frameworks also always stringify `false`. + !/^ar/.test(name)) + ) { + (element as HTMLElement).removeAttribute(name); + } else { + (element as HTMLElement).setAttribute(name, value); + } + } +} + +function getNormalizedName(name: string): string { + switch (name) { + case 'onChange': + return 'onInput'; + default: + return name; + } +} + +export function setProperties(dom: HTMLElement | Window, props: object): void { + // eslint-disable-next-line guard-for-in + for (const name in props) { + setProperty(dom, getNormalizedName(name), props[name]); + } +} diff --git a/packages/autocomplete-js/tsconfig.declaration.json b/packages/autocomplete-js/tsconfig.declaration.json new file mode 100644 index 000000000..1e0c6449f --- /dev/null +++ b/packages/autocomplete-js/tsconfig.declaration.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.declaration" +} diff --git a/packages/autocomplete-preset-algolia/src/formatting.ts b/packages/autocomplete-preset-algolia/src/formatting.ts index 5c6b62e65..4a86fa5d6 100644 --- a/packages/autocomplete-preset-algolia/src/formatting.ts +++ b/packages/autocomplete-preset-algolia/src/formatting.ts @@ -1,6 +1,6 @@ type ParseAttributeParams = { - highlightPreTag: string; - highlightPostTag: string; + highlightPreTag?: string; + highlightPostTag?: string; highlightedValue: string; }; @@ -61,8 +61,8 @@ function getAttributeValueByPath(hit: object, path: string): string { type SharedParseAttributeParams = { hit: any; attribute: string; - highlightPreTag: string; - highlightPostTag: string; + highlightPreTag?: string; + highlightPostTag?: string; }; export function parseHighlightedAttribute({