Skip to content
This repository has been archived by the owner on Jun 11, 2021. It is now read-only.

Commit

Permalink
feat(autocomplete): introduce JavaScript API
Browse files Browse the repository at this point in the history
  • Loading branch information
francoischalifour committed Aug 21, 2020
1 parent 50b4879 commit fd9d2b7
Show file tree
Hide file tree
Showing 11 changed files with 422 additions and 15 deletions.
4 changes: 4 additions & 0 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
24 changes: 13 additions & 11 deletions packages/autocomplete-core/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,17 +110,19 @@ export type GetSources<TItem> = (
params: GetSourcesParams<TItem>
) => Promise<Array<AutocompleteSource<TItem>>>;

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<TItem> {
/**
Expand Down
37 changes: 37 additions & 0 deletions packages/autocomplete-js/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
22 changes: 22 additions & 0 deletions packages/autocomplete-js/rollup.config.js
Original file line number Diff line number Diff line change
@@ -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,
};
200 changes: 200 additions & 0 deletions packages/autocomplete-js/src/autocomplete.ts
Original file line number Diff line number Diff line change
@@ -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<TParams> = (params: TParams) => string | void;

type AutocompleteSource<TItem> = AutocompleteCoreSource<TItem> & {
templates: {
item: Template<{
root: HTMLElement;
item: TItem;
state: AutocompleteState<TItem>;
}>;
header?: Template<{ root: HTMLElement; state: AutocompleteState<TItem> }>;
footer?: Template<{ root: HTMLElement; state: AutocompleteState<TItem> }>;
};
};

type GetSources<TItem> = (
params: GetSourcesParams<TItem>
) => Promise<Array<AutocompleteSource<TItem>>>;

export interface AutocompleteOptions<TItem>
extends AutocompleteCoreOptions<TItem> {
container: string | HTMLElement;
render(params: { root: HTMLElement; sections: HTMLElement[] }): void;
getSources: GetSources<TItem>;
}

export function autocomplete<TItem>({
container,
render: renderDropdown = defaultRender,
...props
}: AutocompleteOptions<TItem>) {
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<TItem>) {
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<TItem>;

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;
}
12 changes: 12 additions & 0 deletions packages/autocomplete-js/src/getHTMLElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { AutocompleteOptions } from '@francoischalifour/autocomplete-core';

export function getHTMLElement(
value: string | HTMLElement,
environment: AutocompleteOptions<any>['environment']
): HTMLElement {
if (typeof value === 'string') {
return environment.document.querySelector<HTMLElement>(value)!;
}

return value;
}
53 changes: 53 additions & 0 deletions packages/autocomplete-js/src/highlight.ts
Original file line number Diff line number Diff line change
@@ -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 = '<mark>',
highlightPostTag = '</mark>',
}: 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 = '<mark>',
highlightPostTag = '</mark>',
}: HighlightItemParams) {
return parseReverseHighlightedAttribute({
hit: item,
attribute,
highlightPreTag,
highlightPostTag,
}).reduce((acc, current) => {
return (
acc +
(current.isHighlighted
? current.value
: `${highlightPreTag}${current.value}${highlightPostTag}`)
);
}, '');
}
6 changes: 6 additions & 0 deletions packages/autocomplete-js/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './autocomplete';
export * from './highlight';
export {
getAlgoliaResults,
getAlgoliaHits,
} from '@francoischalifour/autocomplete-preset-algolia';
Loading

0 comments on commit fd9d2b7

Please sign in to comment.