-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Add Bento Autocomplete Component (#37837)
- Loading branch information
Becca Bailey
committed
May 19, 2022
1 parent
fb3f61c
commit 9e22a1d
Showing
23 changed files
with
2,932 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
110 changes: 110 additions & 0 deletions
110
src/bento/components/bento-autocomplete/1.0/base-element.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
11
src/bento/components/bento-autocomplete/1.0/bento-autocomplete.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './component/component'; |
72 changes: 72 additions & 0 deletions
72
src/bento/components/bento-autocomplete/1.0/component.jss.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
76 changes: 76 additions & 0 deletions
76
src/bento/components/bento-autocomplete/1.0/component/autocomplete-binding-inline.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
22 changes: 22 additions & 0 deletions
22
src/bento/components/bento-autocomplete/1.0/component/autocomplete-binding-single.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
45 changes: 45 additions & 0 deletions
45
src/bento/components/bento-autocomplete/1.0/component/autocomplete-item.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} |
Oops, something went wrong.