Skip to content

Commit

Permalink
✨ Add Bento Autocomplete Component (#37837)
Browse files Browse the repository at this point in the history
  • Loading branch information
Becca Bailey committed May 19, 2022
1 parent fb3f61c commit 9e22a1d
Show file tree
Hide file tree
Showing 23 changed files with 2,932 additions and 0 deletions.
8 changes: 8 additions & 0 deletions build-system/compile/bundles.config.bento.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
"npm": true
}
},
{
"name": "bento-autocomplete",
"version": "1.0",
"options": {
"hasCss": true,
"npm": true
}
},
{
"name": "bento-base-carousel",
"version": "1.0",
Expand Down
1 change: 1 addition & 0 deletions css/Z_INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
| `amp-date-picker[mode="overlay"] .i-amphtml-date-picker-container` | 10 | [extensions/amp-date-picker/0.1/amp-date-picker.css](/extensions/amp-date-picker/0.1/amp-date-picker.css) |
| `overlay['& .rdp']` | 10 | [extensions/amp-date-picker/1.0/component.jss.js](/extensions/amp-date-picker/1.0/component.jss.js) |
| `.i-amphtml-story-spinner` | 10 | [extensions/amp-story/1.0/amp-story.css](/extensions/amp-story/1.0/amp-story.css) |
| `autocompleteResults` | 10 | [src/bento/components/bento-autocomplete/1.0/component.jss.js](/src/bento/components/bento-autocomplete/1.0/component.jss.js) |
| `.i-amphtml-byside-content-loading-container .i-amphtml-byside-content-loading-animation:before` | 9 | [extensions/amp-byside-content/0.1/amp-byside-content.css](/extensions/amp-byside-content/0.1/amp-byside-content.css) |
| `100%` | 9 | [extensions/amp-byside-content/0.1/amp-byside-content.css](/extensions/amp-byside-content/0.1/amp-byside-content.css) |
| `.i-amphtml-story-ad-attribution` | 4 | [extensions/amp-story-auto-ads/0.1/amp-story-auto-ads-attribution.css](/extensions/amp-story-auto-ads/0.1/amp-story-auto-ads-attribution.css) |
Expand Down
110 changes: 110 additions & 0 deletions src/bento/components/bento-autocomplete/1.0/base-element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import mustache from '#bento/components/bento-mustache/1.0/bento-mustache';
import {getTemplate} from '#bento/util/template';

import {isArray} from '#core/types';
import {getValueForExpr} from '#core/types/object';
import {tryParseJson} from '#core/types/object/json';

import * as Preact from '#preact';
import {PreactBaseElement} from '#preact/base-element';

import {BentoAutocomplete} from './component';
import {CSS as COMPONENT_CSS} from './component.jss';

export class BaseElement extends PreactBaseElement {
/**
* Reads the 'items' data from the child <script> element.
* For use with static local data.
* @param {!Element} script
* @return {!Array<!JsonObject|string>}
* @private
*/
getInlineData_(script) {
const json = tryParseJson(script.textContent, (error) => {
throw error;
});
const itemsExpr = this.element.getAttribute('items') || 'items';
const items = getValueForExpr(/**@type {!JsonObject}*/ (json), itemsExpr);
if (!items) {
this.win.console /* OK */
.warn(
`Expected key "${itemsExpr}" in data but found nothing. Rendering empty results.`
);
return [];
}
if (!isArray(items)) {
throw new Error('Items must be array type');
}
return items;
}

/**
* Gets JSON props from optional script tag.
* Example:
* <script type="application/json">{"items": ["one", "two", "three"]}</script>
* @override
* */
getDefaultProps() {
const defaultProps = super.getDefaultProps();
const jsonScript = this.element.querySelector(
'script[type="application/json"]'
);
if (jsonScript) {
const items = this.getInlineData_(jsonScript);
return {
...defaultProps,
items,
};
} else if (!this.element.hasAttribute('src')) {
this.win.console /*OK*/
.warn(
`Expected a <script type="application/json"> child or a URL specified in "src"`
);
}
return defaultProps;
}

/** @override */
checkPropsPostMutations() {
const template = getTemplate(this.element);
if (!template) {
return;
}

this.mutateProps({
'itemTemplate': (data) => {
const html = mustache.render(template./*OK*/ innerHTML, data);
return <div dangerouslySetInnerHTML={{__html: html}} />;
},
});
}
}

/** @override */
BaseElement['Component'] = BentoAutocomplete;

/** @override */
BaseElement['props'] = {
'children': {passthrough: true},
'filter': {attr: 'filter'},
'filterValue': {attr: 'filter-value'},
'highlightUserEntry': {attr: 'highlight-user-entry', type: 'boolean'},
'id': {attr: 'id'},
'inline': {attr: 'inline'},
'maxItems': {attr: 'max-items', type: 'number'},
'minChars': {attr: 'min-chars', type: 'number'},
'prefetch': {attr: 'prefetch', type: 'boolean'},
'src': {attr: 'src'},
'submitOnEnter': {attr: 'submit-on-enter', type: 'boolean'},
'suggestFirst': {attr: 'suggest-first', type: 'boolean'},
'query': {attr: 'query', type: 'string'},
};

/** @override */
BaseElement['layoutSizeDefined'] = true;

/** @override */
BaseElement['usesShadowDom'] = true;

/** @override */
BaseElement['shadowCss'] = COMPONENT_CSS;
11 changes: 11 additions & 0 deletions src/bento/components/bento-autocomplete/1.0/bento-autocomplete.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
bento-autocomplete,
bento-autocomplete > input,
bento-autocomplete > textarea {
font-family: sans-serif; /* default-ui-font-stack */
}

bento-autocomplete > input,
bento-autocomplete > textarea {
border-radius: 4px; /* default-ui-border-radius */
box-sizing: border-box;
}
1 change: 1 addition & 0 deletions src/bento/components/bento-autocomplete/1.0/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './component/component';
72 changes: 72 additions & 0 deletions src/bento/components/bento-autocomplete/1.0/component.jss.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {createUseStyles} from 'react-jss';

const autocomplete = {
fontFamily: 'sans-serif',
};

const input = {
borderRadius: '4px',
boxSizing: 'border-box',
};

const autocompleteResults = {
position: 'absolute',
// top: '100%',
width: 'calc(100% - 1rem)',
minWidth: 'calc(2em + 2rem)',
maxHeight: '40vh',
marginTop: '.5rem',
marginLeft: '-.5rem',
borderRadius: '4px',

overflowY: 'auto',
overflowX: 'hidden',

backgroundColor: 'white',
boxShadow: '0px 2px 4px 0px rgba(0,0,0,0.5)',
zIndex: '10',
};

const autocompleteItem = {
position: 'relative',
padding: '.5rem 1rem',
cursor: 'pointer',

'&:first-child': {
borderRadius: '4px 4px 0 0',
},

'&:nth-last-child(2)': {
borderRadius: '0 0 4px 4px',
},

'&:hover:not([data-disabled])': {
backgroundColor: 'rgba(0,0,0,0.15)',
},

'&[data-disabled]': {
color: 'rgba(0,0,0,0.33)',
},

'& > .autocomplete-partial': {
fontWeight: 'bold',
},
};

const autocompleteItemActive = {
'&:not([data-disabled])': {
backgroundColor: 'rgba(0,0,0,0.15)',
},
};

const JSS = {
autocomplete,
input,
autocompleteResults,
autocompleteItem,
autocompleteItemActive,
};

// useStyles gets replaced for AMP builds via `babel-plugin-transform-jss`.
// eslint-disable-next-line local/no-export-side-effect
export const useStyles = createUseStyles(JSS);
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {ownProperty} from '#core/types/object';

import {AutocompleteBinding, InputElement} from '../types';

export class AutocompleteBindingInline implements AutocompleteBinding {
trigger_: string = '';
regex_: RegExp = new RegExp('');
match_: RegExpExecArray | null = null;

constructor(trigger: string) {
this.trigger_ = trigger;

const delimiter = this.trigger_.replace(/([()[{*+.$^\\|?])/g, '\\$1');
const pattern = `((${delimiter}|^${delimiter})(\\w+)?)`;
this.regex_ = new RegExp(pattern, 'gm');
}

get shouldShowOnFocus() {
return false;
}

/**
* Finds the closest string in the user input prior to the cursor
* to display suggestions.
*/
private getClosestPriorMatch_(inputEl: InputElement) {
const regex = this.regex_;

const {selectionStart: cursor, value} = inputEl;
let match, lastMatch;

while ((match = regex.exec(value)) !== null) {
if (match[0].length + ownProperty(match, 'index') > cursor!) {
break;
}
lastMatch = match;
}

if (
!lastMatch ||
lastMatch[0].length + ownProperty(lastMatch, 'index') < cursor!
) {
return null;
}
return lastMatch;
}

/**
* Returns true if a match on the publisher-provided trigger is found in the input element value.
* Otherwise, should not display any suggestions.
*/
shouldAutocomplete(inputEl: InputElement): boolean {
const match = this.getClosestPriorMatch_(inputEl);
this.match_ = match;
return !!match;
}

/**
* Display suggestions based on the partial string following the trigger
* in the input element value.
*/
getUserInputForUpdate(): string {
if (!this.match_ || !this.match_[0]) {
return '';
}
return this.match_[0].slice(this.trigger_.length);
}

/**
* If results are not showing or there is no actively navigated-to suggestion item,
* the user should be able to 'Enter' to add a new line.
*/
shouldPreventDefaultOnEnter(activeElement: boolean) {
return activeElement;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {AutocompleteBinding, InputElement} from '../types';

export class AutocompleteBindingSingle implements AutocompleteBinding {
get shouldShowOnFocus() {
return true;
}

shouldAutocomplete(): boolean {
return true;
}

getUserInputForUpdate(inputEl: InputElement): string {
return inputEl.value || '';
}

shouldPreventDefaultOnEnter(
unusedActiveElement: boolean,
submitOnEnter: boolean
): boolean {
return !submitOnEnter;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import objStr from 'obj-str';

import {cloneElement, isValidElement} from '#preact';

import {useStyles} from '../component.jss';
import {AutocompleteItemProps, ItemNode, ItemTemplateProps} from '../types';

export function AutocompleteItem({
id,
item,
itemTemplate,
onError = () => {},
selected = false,
onMouseDown = () => {},
}: AutocompleteItemProps) {
const classes = useStyles();

const component: ItemNode = itemTemplate(item);

if (!isValidElement<ItemTemplateProps>(component)) {
return component;
}
const isDisabled = component.props['data-disabled'];
if (!component.props['data-value'] && !isDisabled) {
onError(
`expected a "data-value" or "data-disabled" attribute on the rendered template item.`
);
}
return cloneElement(component, {
'aria-disabled': isDisabled,
'aria-selected': selected,
class: objStr({
'autocomplete-item': true,
[classes.autocompleteItem]: true,
[classes.autocompleteItemActive]: selected,
}),
dir: 'auto',
id,
key: id,
onMouseDown,
part: 'option',
role: 'option',
...component.props,
});
}

0 comments on commit 9e22a1d

Please sign in to comment.