├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── LICENSE ├── README.md ├── babel.config.cjs ├── dist ├── cjs │ ├── common.cjs │ ├── features │ │ ├── atom │ │ │ ├── autoFocus.cjs │ │ │ ├── autoInline.cjs │ │ │ ├── autocompleteLite.cjs │ │ │ ├── dropdownToggle.cjs │ │ │ ├── index.cjs │ │ │ ├── inputFocus.cjs │ │ │ ├── inputToggle.cjs │ │ │ ├── label.cjs │ │ │ ├── multiInput.cjs │ │ │ └── nonblurToggle.cjs │ │ └── molecule │ │ │ ├── autocomplete.cjs │ │ │ ├── dropdown.cjs │ │ │ ├── index.cjs │ │ │ ├── multiSelect.cjs │ │ │ ├── multiSelectDropdown.cjs │ │ │ └── supercomplete.cjs │ ├── hooks │ │ ├── useAutocomplete.cjs │ │ ├── useCombobox.cjs │ │ ├── useFocusCapture.cjs │ │ ├── useId.cjs │ │ ├── useMultiSelect.cjs │ │ ├── useMutableState.cjs │ │ └── useToggle.cjs │ ├── index.cjs │ └── utils │ │ ├── mergeGroupedItems.cjs │ │ ├── mergeModules.cjs │ │ └── mergeObjects.cjs └── esm │ ├── common.mjs │ ├── features │ ├── atom │ │ ├── autoFocus.mjs │ │ ├── autoInline.mjs │ │ ├── autocompleteLite.mjs │ │ ├── dropdownToggle.mjs │ │ ├── index.mjs │ │ ├── inputFocus.mjs │ │ ├── inputToggle.mjs │ │ ├── label.mjs │ │ ├── multiInput.mjs │ │ └── nonblurToggle.mjs │ └── molecule │ │ ├── autocomplete.mjs │ │ ├── dropdown.mjs │ │ ├── index.mjs │ │ ├── multiSelect.mjs │ │ ├── multiSelectDropdown.mjs │ │ └── supercomplete.mjs │ ├── hooks │ ├── useAutocomplete.mjs │ ├── useCombobox.mjs │ ├── useFocusCapture.mjs │ ├── useId.mjs │ ├── useMultiSelect.mjs │ ├── useMutableState.mjs │ └── useToggle.mjs │ ├── index.mjs │ └── utils │ ├── mergeGroupedItems.mjs │ ├── mergeModules.mjs │ └── mergeObjects.mjs ├── eslint.config.mjs ├── examples ├── .eslintrc.json ├── .gitignore ├── README.md ├── data.ts ├── features │ └── autocompleteFocus.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── hello.ts │ ├── dropdown.tsx │ ├── floating-ui.tsx │ ├── index.tsx │ ├── multiDropdown.tsx │ ├── multiSelect.tsx │ ├── multiSelectAction.tsx │ ├── reactWindow.tsx │ ├── search.tsx │ ├── shadowDom.tsx │ ├── tanstackVirtual.tsx │ └── tanstackVirtualGrouped.tsx ├── public │ ├── favicon.ico │ ├── next.svg │ └── vercel.svg ├── styles │ ├── Home.module.css │ └── globals.css └── tsconfig.json ├── features ├── atom.d.ts └── molecule.d.ts ├── jest.config.cjs ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── __tests__ │ ├── autocomplete.test.tsx │ ├── autocompleteFocus.test.tsx │ ├── dropdown.test.tsx │ ├── mergeGroupedItems.test.ts │ ├── multiSelect.test.tsx │ ├── noOptions.test.ts │ ├── supercomplete.test.tsx │ ├── useId.test.ts │ └── utils │ │ ├── Autocomplete.tsx │ │ ├── Dropdown.tsx │ │ ├── MultiSelect.tsx │ │ ├── autocompleteFocus.ts │ │ ├── data.ts │ │ ├── globals.ts │ │ └── scrollIntoView.ts ├── common.ts ├── features │ ├── atom │ │ ├── autoFocus.ts │ │ ├── autoInline.ts │ │ ├── autocompleteLite.ts │ │ ├── dropdownToggle.ts │ │ ├── index.ts │ │ ├── inputFocus.ts │ │ ├── inputToggle.ts │ │ ├── label.ts │ │ ├── multiInput.ts │ │ └── nonblurToggle.ts │ └── molecule │ │ ├── autocomplete.ts │ │ ├── dropdown.ts │ │ ├── index.ts │ │ ├── multiSelect.ts │ │ ├── multiSelectDropdown.ts │ │ └── supercomplete.ts ├── hooks │ ├── useAutocomplete.ts │ ├── useCombobox.ts │ ├── useFocusCapture.ts │ ├── useId.ts │ ├── useMultiSelect.ts │ ├── useMutableState.ts │ └── useToggle.ts ├── index.ts ├── types.ts └── utils │ ├── mergeGroupedItems.ts │ ├── mergeModules.ts │ └── mergeObjects.ts ├── tsconfig.json ├── types ├── common.d.ts ├── features │ ├── atom │ │ ├── autoFocus.d.ts │ │ ├── autoInline.d.ts │ │ ├── autocompleteLite.d.ts │ │ ├── dropdownToggle.d.ts │ │ ├── index.d.ts │ │ ├── inputFocus.d.ts │ │ ├── inputToggle.d.ts │ │ ├── label.d.ts │ │ ├── multiInput.d.ts │ │ └── nonblurToggle.d.ts │ └── molecule │ │ ├── autocomplete.d.ts │ │ ├── dropdown.d.ts │ │ ├── index.d.ts │ │ ├── multiSelect.d.ts │ │ ├── multiSelectDropdown.d.ts │ │ └── supercomplete.d.ts ├── hooks │ ├── useAutocomplete.d.ts │ ├── useCombobox.d.ts │ ├── useFocusCapture.d.ts │ ├── useId.d.ts │ ├── useMultiSelect.d.ts │ ├── useMutableState.d.ts │ └── useToggle.d.ts ├── index.d.ts ├── types.d.ts └── utils │ ├── mergeGroupedItems.d.ts │ ├── mergeModules.d.ts │ └── mergeObjects.d.ts └── website ├── .gitignore ├── README.md ├── babel.config.js ├── docs └── docs │ ├── design.md │ ├── extras │ ├── _category_.json │ ├── action-items.mdx │ ├── async.mdx │ ├── disabled-items.mdx │ ├── floating-ui.mdx │ ├── grouped.mdx │ ├── object-items.mdx │ ├── select-only.mdx │ └── virtualization.mdx │ ├── features │ ├── _category_.json │ ├── autocomplete.mdx │ ├── dropdown.mdx │ ├── multiSelect.mdx │ ├── multiSelectDropdown.mdx │ └── supercomplete.mdx │ ├── install.mdx │ └── intro.mdx ├── docusaurus.config.ts ├── gh-pages.sh ├── package-lock.json ├── package.json ├── sidebars.ts ├── src ├── components │ ├── ActionItems │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── AsyncExample │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── Autocomplete │ │ ├── CodeBlock.tsx │ │ ├── index.tsx │ │ └── styles.module.css │ ├── BundleLink │ │ └── AutocompleteLite.tsx │ ├── Checkbox │ │ ├── index.tsx │ │ └── styles.module.css │ ├── DisabledItems │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── Dropdown │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── FloatingUI │ │ ├── CodeAutocomplete.tsx │ │ ├── CodeDropdown.tsx │ │ └── index.tsx │ ├── Grouped │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── HomepageFeatures │ │ ├── index.tsx │ │ └── styles.module.css │ ├── Intro │ │ ├── index.tsx │ │ └── styles.module.css │ ├── MultiSelect │ │ ├── CodeBlock.tsx │ │ ├── index.tsx │ │ └── styles.module.css │ ├── MultiSelectDropdown │ │ ├── CodeBlock.tsx │ │ ├── index.tsx │ │ └── styles.module.css │ ├── ObjectItems │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── Radio │ │ ├── index.tsx │ │ └── styles.module.css │ ├── SelectOnly │ │ ├── CodeBlock.tsx │ │ └── index.tsx │ ├── Supercomplete │ │ └── CodeBlock.tsx │ └── Virtualization │ │ ├── CodeBlock.tsx │ │ └── index.tsx ├── css │ ├── custom.css │ └── styles.module.css ├── data │ ├── fruits.ts │ ├── states-grouped.ts │ ├── states-obj.ts │ └── states.ts ├── pages │ ├── index.module.css │ └── index.tsx ├── theme │ ├── Footer │ │ ├── index.module.css │ │ └── index.tsx │ ├── TabItem │ │ ├── index.tsx │ │ └── styles.module.css │ └── Tabs │ │ ├── index.tsx │ │ └── styles.module.css └── utils │ └── useAutoScroll.ts ├── static ├── .nojekyll └── img │ ├── check.svg │ ├── chevron-down.svg │ ├── chevron-up.svg │ ├── external-link.svg │ ├── favicon.ico │ ├── radio_checked.svg │ ├── radio_unchecked.svg │ ├── square-check.svg │ ├── square.svg │ └── x.svg └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | 4 | # builds 5 | build/ 6 | .next/ 7 | _next/ 8 | .docusaurus/ 9 | examples/out/ 10 | 11 | # tests 12 | coverage/ 13 | **/__tests__/**/*.d.ts 14 | 15 | # logs 16 | logs 17 | *.log 18 | 19 | # typescript 20 | *.tsbuildinfo 21 | next-env.d.ts 22 | 23 | # misc 24 | .DS_Store 25 | .npm 26 | .env 27 | .eslintcache 28 | .vercel 29 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | static/ 3 | coverage/ 4 | dist/ 5 | types/ 6 | examples/out/ 7 | .next/ 8 | _next/ 9 | .docusaurus/ 10 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: none 2 | singleQuote: true 3 | printWidth: 95 4 | overrides: 5 | - files: '*.md' 6 | options: 7 | printWidth: 80 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Zheng Song 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | targets: 'defaults', 3 | assumptions: { 4 | constantReexports: true, 5 | ignoreFunctionLength: true, 6 | ignoreToPrimitiveHint: true, 7 | iterableIsArray: true, 8 | noDocumentAll: true, 9 | noIncompleteNsImportDetection: true, 10 | noNewArrows: true, 11 | objectRestNoSymbols: true, 12 | pureGetters: true, 13 | setComputedProperties: true, 14 | setSpreadProperties: true, 15 | skipForOfIteratorClosing: true 16 | }, 17 | shouldPrintComment: (val) => /[@#]__PURE__/.test(val), 18 | plugins: ['pure-annotations'], 19 | presets: [ 20 | [ 21 | '@babel/preset-env', 22 | { 23 | bugfixes: true, 24 | include: [ 25 | '@babel/plugin-transform-nullish-coalescing-operator', 26 | '@babel/plugin-transform-optional-catch-binding' 27 | ], 28 | exclude: ['@babel/plugin-transform-typeof-symbol'] 29 | } 30 | ], 31 | ['@babel/preset-react', { runtime: 'automatic' }], 32 | ['@babel/preset-typescript', { isTSX: true, allExtensions: true }] 33 | ] 34 | }; 35 | -------------------------------------------------------------------------------- /dist/cjs/common.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const defaultFocusIndex = -1; 4 | const defaultEqual = (itemA, itemB) => itemA === itemB; 5 | const getId = (prefix, suffix) => prefix && prefix + suffix; 6 | const buttonProps = { 7 | tabIndex: -1, 8 | type: 'button' 9 | }; 10 | const getInputToggleProps = (id, open) => ({ 11 | ...buttonProps, 12 | 'aria-expanded': open, 13 | 'aria-controls': getId(id, 'l') 14 | }); 15 | 16 | exports.buttonProps = buttonProps; 17 | exports.defaultEqual = defaultEqual; 18 | exports.defaultFocusIndex = defaultFocusIndex; 19 | exports.getId = getId; 20 | exports.getInputToggleProps = getInputToggleProps; 21 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/autoFocus.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const autoFocus = ({ 4 | onRequestItem 5 | }) => ({ 6 | setFocusIndex 7 | }) => ({ 8 | getInputProps: () => ({ 9 | onChange: e => { 10 | const value = e.target.value; 11 | if (value) { 12 | onRequestItem({ 13 | value 14 | }, data => setFocusIndex(data.index)); 15 | } 16 | } 17 | }) 18 | }); 19 | 20 | exports.autoFocus = autoFocus; 21 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/autoInline.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const autoInline = ({ 4 | onRequestItem 5 | }) => ({ 6 | getItemValue, 7 | setTmpValue, 8 | setFocusIndex 9 | }) => ({ 10 | getInputProps: () => ({ 11 | 'aria-autocomplete': 'both', 12 | onChange: ({ 13 | target, 14 | nativeEvent 15 | }) => { 16 | if (nativeEvent.inputType !== 'insertText') { 17 | return; 18 | } 19 | const value = target.value; 20 | onRequestItem({ 21 | value 22 | }, data => { 23 | setFocusIndex(data.index); 24 | const itemValue = getItemValue(data.item); 25 | const start = value.length; 26 | const end = itemValue.length; 27 | setTmpValue(value + itemValue.slice(start)); 28 | setTimeout(() => target.setSelectionRange(start, end), 0); 29 | }); 30 | } 31 | }) 32 | }); 33 | 34 | exports.autoInline = autoInline; 35 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/dropdownToggle.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var useToggle = require('../../hooks/useToggle.cjs'); 5 | 6 | const dropdownToggle = ({ 7 | closeOnSelect = true, 8 | toggleRef: externalToggleRef 9 | } = {}) => ({ 10 | inputRef, 11 | open, 12 | setOpen, 13 | focusIndex, 14 | value, 15 | tmpValue 16 | }) => { 17 | const [startToggle, stopToggle] = useToggle.useToggle(open, setOpen); 18 | const internalToggleRef = React.useRef(null); 19 | const toggleRef = externalToggleRef || internalToggleRef; 20 | const inputValue = tmpValue || value || ''; 21 | React.useEffect(() => { 22 | if (open) inputRef.current?.focus({ 23 | preventScroll: true 24 | }); 25 | }, [open, inputRef]); 26 | const focusToggle = () => setTimeout(() => toggleRef.current?.focus(), 0); 27 | return { 28 | toggleRef, 29 | isInputEmpty: !inputValue, 30 | getToggleProps: () => ({ 31 | type: 'button', 32 | 'aria-haspopup': true, 33 | 'aria-expanded': open, 34 | ref: toggleRef, 35 | onMouseDown: startToggle, 36 | onClick: stopToggle, 37 | onKeyDown: e => { 38 | const { 39 | key 40 | } = e; 41 | if (key === 'ArrowDown') { 42 | e.preventDefault(); 43 | setOpen(true); 44 | } 45 | } 46 | }), 47 | getInputProps: () => ({ 48 | value: inputValue, 49 | onKeyDown: e => { 50 | const { 51 | key 52 | } = e; 53 | if (key === 'Escape' || closeOnSelect && focusIndex >= 0 && key === 'Enter') { 54 | focusToggle(); 55 | } 56 | } 57 | }) 58 | }; 59 | }; 60 | 61 | exports.dropdownToggle = dropdownToggle; 62 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var autocompleteLite = require('./autocompleteLite.cjs'); 4 | var autoFocus = require('./autoFocus.cjs'); 5 | var autoInline = require('./autoInline.cjs'); 6 | var dropdownToggle = require('./dropdownToggle.cjs'); 7 | var inputFocus = require('./inputFocus.cjs'); 8 | var inputToggle = require('./inputToggle.cjs'); 9 | var label = require('./label.cjs'); 10 | var multiInput = require('./multiInput.cjs'); 11 | var nonblurToggle = require('./nonblurToggle.cjs'); 12 | 13 | 14 | 15 | exports.autocompleteLite = autocompleteLite.autocompleteLite; 16 | exports.autoFocus = autoFocus.autoFocus; 17 | exports.autoInline = autoInline.autoInline; 18 | exports.dropdownToggle = dropdownToggle.dropdownToggle; 19 | exports.inputFocus = inputFocus.inputFocus; 20 | exports.inputToggle = inputToggle.inputToggle; 21 | exports.label = label.label; 22 | exports.multiInput = multiInput.multiInput; 23 | exports.nonblurToggle = nonblurToggle.nonblurToggle; 24 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/inputFocus.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | 5 | const inputFocus = () => () => { 6 | const [focused, setFocused] = React.useState(false); 7 | return { 8 | focused, 9 | getInputProps: () => ({ 10 | onFocusCapture: () => setFocused(true), 11 | onBlurCapture: () => setFocused(false) 12 | }) 13 | }; 14 | }; 15 | 16 | exports.inputFocus = inputFocus; 17 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/inputToggle.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var common = require('../../common.cjs'); 4 | var useToggle = require('../../hooks/useToggle.cjs'); 5 | var useFocusCapture = require('../../hooks/useFocusCapture.cjs'); 6 | 7 | const inputToggle = () => ({ 8 | id, 9 | inputRef, 10 | open, 11 | setOpen 12 | }) => { 13 | const [startToggle, stopToggle] = useToggle.useToggle(open, setOpen); 14 | const [startCapture, inCapture, stopCapture] = useFocusCapture.useFocusCapture(inputRef); 15 | return { 16 | getToggleProps: () => ({ 17 | ...common.getInputToggleProps(id, open), 18 | onMouseDown: () => { 19 | startToggle(); 20 | startCapture(); 21 | }, 22 | onClick: () => { 23 | stopToggle(); 24 | stopCapture(); 25 | } 26 | }), 27 | getInputProps: () => ({ 28 | onBlur: inCapture 29 | }) 30 | }; 31 | }; 32 | 33 | exports.inputToggle = inputToggle; 34 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/label.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var common = require('../../common.cjs'); 4 | 5 | const label = () => ({ 6 | id 7 | }) => { 8 | const inputId = common.getId(id, 'i'); 9 | const labelId = common.getId(id, 'a'); 10 | return { 11 | getLabelProps: () => ({ 12 | id: labelId, 13 | htmlFor: inputId 14 | }), 15 | getInputProps: () => ({ 16 | id: inputId 17 | }), 18 | getListProps: () => ({ 19 | 'aria-labelledby': labelId 20 | }) 21 | }; 22 | }; 23 | 24 | exports.label = label; 25 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/multiInput.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const multiInput = () => ({ 4 | removeSelect 5 | }) => ({ 6 | getInputProps: () => ({ 7 | onKeyDown: e => !e.target.value && e.key === 'Backspace' && removeSelect?.() 8 | }) 9 | }); 10 | 11 | exports.multiInput = multiInput; 12 | -------------------------------------------------------------------------------- /dist/cjs/features/atom/nonblurToggle.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var common = require('../../common.cjs'); 4 | 5 | const nonblurToggle = () => ({ 6 | id, 7 | open, 8 | setOpen 9 | }) => ({ 10 | getToggleProps: () => ({ 11 | ...common.getInputToggleProps(id, open), 12 | onClick: () => setOpen(!open) 13 | }) 14 | }); 15 | 16 | exports.nonblurToggle = nonblurToggle; 17 | -------------------------------------------------------------------------------- /dist/cjs/features/molecule/autocomplete.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mergeModules = require('../../utils/mergeModules.cjs'); 4 | var autocompleteLite = require('../atom/autocompleteLite.cjs'); 5 | var inputToggle = require('../atom/inputToggle.cjs'); 6 | var label = require('../atom/label.cjs'); 7 | 8 | const autocomplete = props => mergeModules.mergeModules(autocompleteLite.autocompleteLite(props), inputToggle.inputToggle(), label.label()); 9 | 10 | exports.autocomplete = autocomplete; 11 | -------------------------------------------------------------------------------- /dist/cjs/features/molecule/dropdown.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mergeModules = require('../../utils/mergeModules.cjs'); 4 | var autocompleteLite = require('../atom/autocompleteLite.cjs'); 5 | var dropdownToggle = require('../atom/dropdownToggle.cjs'); 6 | 7 | const dropdown = props => mergeModules.mergeModules(autocompleteLite.autocompleteLite({ 8 | ...props, 9 | select: true, 10 | deselectOnClear: false 11 | }), dropdownToggle.dropdownToggle(props)); 12 | 13 | exports.dropdown = dropdown; 14 | -------------------------------------------------------------------------------- /dist/cjs/features/molecule/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var autocomplete = require('./autocomplete.cjs'); 4 | var dropdown = require('./dropdown.cjs'); 5 | var multiSelect = require('./multiSelect.cjs'); 6 | var multiSelectDropdown = require('./multiSelectDropdown.cjs'); 7 | var supercomplete = require('./supercomplete.cjs'); 8 | 9 | 10 | 11 | exports.autocomplete = autocomplete.autocomplete; 12 | exports.dropdown = dropdown.dropdown; 13 | exports.multiSelect = multiSelect.multiSelect; 14 | exports.multiSelectDropdown = multiSelectDropdown.multiSelectDropdown; 15 | exports.supercomplete = supercomplete.supercomplete; 16 | -------------------------------------------------------------------------------- /dist/cjs/features/molecule/multiSelect.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mergeModules = require('../../utils/mergeModules.cjs'); 4 | var autocompleteLite = require('../atom/autocompleteLite.cjs'); 5 | var nonblurToggle = require('../atom/nonblurToggle.cjs'); 6 | var label = require('../atom/label.cjs'); 7 | var inputFocus = require('../atom/inputFocus.cjs'); 8 | var multiInput = require('../atom/multiInput.cjs'); 9 | 10 | const multiSelect = props => mergeModules.mergeModules(autocompleteLite.autocompleteLite({ 11 | ...props, 12 | select: true 13 | }), nonblurToggle.nonblurToggle(), label.label(), inputFocus.inputFocus(), multiInput.multiInput()); 14 | 15 | exports.multiSelect = multiSelect; 16 | -------------------------------------------------------------------------------- /dist/cjs/features/molecule/multiSelectDropdown.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mergeModules = require('../../utils/mergeModules.cjs'); 4 | var multiInput = require('../atom/multiInput.cjs'); 5 | var dropdown = require('./dropdown.cjs'); 6 | 7 | const multiSelectDropdown = props => mergeModules.mergeModules(dropdown.dropdown(props), multiInput.multiInput()); 8 | 9 | exports.multiSelectDropdown = multiSelectDropdown; 10 | -------------------------------------------------------------------------------- /dist/cjs/features/molecule/supercomplete.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mergeModules = require('../../utils/mergeModules.cjs'); 4 | var autocomplete = require('./autocomplete.cjs'); 5 | var autoInline = require('../atom/autoInline.cjs'); 6 | 7 | const supercomplete = props => mergeModules.mergeModules(autocomplete.autocomplete({ 8 | ...props, 9 | rovingText: true 10 | }), autoInline.autoInline(props)); 11 | 12 | exports.supercomplete = supercomplete; 13 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useAutocomplete.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var useId = require('./useId.cjs'); 5 | var common = require('../common.cjs'); 6 | 7 | const useAutocomplete = ({ 8 | onChange, 9 | feature: useFeature, 10 | isItemSelected, 11 | inputRef: externalInputRef, 12 | getItemValue, 13 | ...passthrough 14 | }) => { 15 | const internalInputRef = React.useRef(null); 16 | const [tmpValue, setTmpValue] = React.useState(); 17 | const [open, setOpen] = React.useState(false); 18 | const [focusIndex, setFocusIndex] = React.useState(common.defaultFocusIndex); 19 | const state = { 20 | isItemSelected, 21 | inputRef: externalInputRef || internalInputRef, 22 | focusIndex, 23 | setFocusIndex, 24 | open, 25 | setOpen 26 | }; 27 | const featureYield = useFeature({ 28 | id: useId.useId(), 29 | tmpValue, 30 | setTmpValue, 31 | onChange: newValue => passthrough.value != newValue && onChange?.(newValue), 32 | getItemValue: item => item == null ? '' : getItemValue ? getItemValue(item) : item.toString(), 33 | ...passthrough, 34 | ...state 35 | }); 36 | return { 37 | ...state, 38 | ...featureYield 39 | }; 40 | }; 41 | 42 | exports.useAutocomplete = useAutocomplete; 43 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useCombobox.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var common = require('../common.cjs'); 4 | var useAutocomplete = require('./useAutocomplete.cjs'); 5 | 6 | const useCombobox = ({ 7 | isEqual = common.defaultEqual, 8 | selected, 9 | onSelectChange, 10 | flipOnSelect, 11 | ...passthrough 12 | }) => useAutocomplete.useAutocomplete({ 13 | ...passthrough, 14 | selected, 15 | isEqual, 16 | isItemSelected: item => isEqual(item, selected), 17 | onSelectChange: newItem => { 18 | if (!isEqual(newItem, selected)) { 19 | onSelectChange?.(newItem); 20 | } else if (flipOnSelect) { 21 | onSelectChange?.(); 22 | } 23 | } 24 | }); 25 | 26 | exports.useCombobox = useCombobox; 27 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useFocusCapture.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var useMutableState = require('./useMutableState.cjs'); 4 | 5 | const useFocusCapture = focusRef => { 6 | const mutable = useMutableState.useMutableState({}); 7 | return [() => { 8 | mutable.a = 1; 9 | }, () => { 10 | if (mutable.a) { 11 | mutable.a = 0; 12 | focusRef.current?.focus(); 13 | return true; 14 | } 15 | }, () => { 16 | mutable.a = 0; 17 | focusRef.current?.focus(); 18 | }]; 19 | }; 20 | 21 | exports.useFocusCapture = useFocusCapture; 22 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useId.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | 5 | let current = 0; 6 | const useIdShim = () => { 7 | const [id, setId] = React.useState(); 8 | React.useEffect(() => setId(++current), []); 9 | return id && `szh-ac${id}-`; 10 | }; 11 | const useId = React.useId || useIdShim; 12 | 13 | exports.useId = useId; 14 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useMultiSelect.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var common = require('../common.cjs'); 4 | var useAutocomplete = require('./useAutocomplete.cjs'); 5 | 6 | const useMultiSelect = ({ 7 | isEqual = common.defaultEqual, 8 | selected, 9 | onSelectChange, 10 | flipOnSelect, 11 | ...passthrough 12 | }) => { 13 | const removeItem = itemToRemove => onSelectChange?.(selected.filter(item => !isEqual(itemToRemove, item))); 14 | const removeSelect = item => { 15 | if (item) { 16 | removeItem(item); 17 | } else { 18 | selected.length && onSelectChange?.(selected.slice(0, selected.length - 1)); 19 | } 20 | }; 21 | const isItemSelected = item => selected.findIndex(s => isEqual(item, s)) >= 0; 22 | return { 23 | ...useAutocomplete.useAutocomplete({ 24 | ...passthrough, 25 | selected, 26 | isEqual, 27 | isItemSelected, 28 | onSelectChange: newItem => { 29 | if (!newItem) return; 30 | if (!isItemSelected(newItem)) { 31 | onSelectChange?.([...selected, newItem]); 32 | } else if (flipOnSelect) { 33 | removeItem(newItem); 34 | } 35 | }, 36 | removeSelect 37 | }), 38 | removeSelect 39 | }; 40 | }; 41 | 42 | exports.useMultiSelect = useMultiSelect; 43 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useMutableState.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | 5 | const useMutableState = stateContainer => React.useState(stateContainer)[0]; 6 | 7 | exports.useMutableState = useMutableState; 8 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useToggle.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var useMutableState = require('./useMutableState.cjs'); 4 | 5 | const useToggle = (open, setOpen) => { 6 | const mutable = useMutableState.useMutableState({}); 7 | return [() => mutable.a = open, () => { 8 | if (mutable.a) { 9 | mutable.a = 0; 10 | } else { 11 | setOpen(true); 12 | } 13 | }]; 14 | }; 15 | 16 | exports.useToggle = useToggle; 17 | -------------------------------------------------------------------------------- /dist/cjs/index.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var useCombobox = require('./hooks/useCombobox.cjs'); 4 | var useMultiSelect = require('./hooks/useMultiSelect.cjs'); 5 | var autocompleteLite = require('./features/atom/autocompleteLite.cjs'); 6 | var autocomplete = require('./features/molecule/autocomplete.cjs'); 7 | var dropdown = require('./features/molecule/dropdown.cjs'); 8 | var multiSelect = require('./features/molecule/multiSelect.cjs'); 9 | var multiSelectDropdown = require('./features/molecule/multiSelectDropdown.cjs'); 10 | var supercomplete = require('./features/molecule/supercomplete.cjs'); 11 | var mergeGroupedItems = require('./utils/mergeGroupedItems.cjs'); 12 | var mergeModules = require('./utils/mergeModules.cjs'); 13 | 14 | 15 | 16 | exports.useCombobox = useCombobox.useCombobox; 17 | exports.useMultiSelect = useMultiSelect.useMultiSelect; 18 | exports.autocompleteLite = autocompleteLite.autocompleteLite; 19 | exports.autocomplete = autocomplete.autocomplete; 20 | exports.dropdown = dropdown.dropdown; 21 | exports.multiSelect = multiSelect.multiSelect; 22 | exports.multiSelectDropdown = multiSelectDropdown.multiSelectDropdown; 23 | exports.supercomplete = supercomplete.supercomplete; 24 | exports.mergeGroupedItems = mergeGroupedItems.mergeGroupedItems; 25 | exports.mergeModules = mergeModules.mergeModules; 26 | -------------------------------------------------------------------------------- /dist/cjs/utils/mergeGroupedItems.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const isArray = Array.isArray; 4 | const mergeGroupedItems = ({ 5 | groups, 6 | getItemsInGroup 7 | }) => { 8 | const groupArray = isArray(groups) ? groups : Object.values(groups); 9 | return groupArray.reduce((accu, group) => accu.concat(isArray(group) ? group : getItemsInGroup ? getItemsInGroup(group) : []), []); 10 | }; 11 | 12 | exports.mergeGroupedItems = mergeGroupedItems; 13 | -------------------------------------------------------------------------------- /dist/cjs/utils/mergeModules.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var mergeObjects = require('./mergeObjects.cjs'); 4 | 5 | const mergeModules = (...modules) => cx => modules.reduce((accu, curr) => mergeObjects.mergeObjects(accu, curr(cx)), {}); 6 | 7 | exports.mergeModules = mergeModules; 8 | -------------------------------------------------------------------------------- /dist/cjs/utils/mergeObjects.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const mergeObjects = (obj1, obj2) => { 4 | const merged = { 5 | ...obj1 6 | }; 7 | Object.entries(obj2).forEach(([key, prop2]) => { 8 | if (typeof prop2 === 'function') { 9 | const prop1 = obj1[key]; 10 | merged[key] = prop1 ? (...args) => { 11 | const result1 = prop1(...args); 12 | const result2 = prop2(...args); 13 | if (typeof result1 === 'object') { 14 | return mergeObjects(result1, result2); 15 | } 16 | } : prop2; 17 | } else { 18 | merged[key] = prop2; 19 | } 20 | }); 21 | return merged; 22 | }; 23 | 24 | exports.mergeObjects = mergeObjects; 25 | -------------------------------------------------------------------------------- /dist/esm/common.mjs: -------------------------------------------------------------------------------- 1 | const defaultFocusIndex = -1; 2 | const defaultEqual = (itemA, itemB) => itemA === itemB; 3 | const getId = (prefix, suffix) => prefix && prefix + suffix; 4 | const buttonProps = { 5 | tabIndex: -1, 6 | type: 'button' 7 | }; 8 | const getInputToggleProps = (id, open) => ({ 9 | ...buttonProps, 10 | 'aria-expanded': open, 11 | 'aria-controls': getId(id, 'l') 12 | }); 13 | 14 | export { buttonProps, defaultEqual, defaultFocusIndex, getId, getInputToggleProps }; 15 | -------------------------------------------------------------------------------- /dist/esm/features/atom/autoFocus.mjs: -------------------------------------------------------------------------------- 1 | const autoFocus = ({ 2 | onRequestItem 3 | }) => ({ 4 | setFocusIndex 5 | }) => ({ 6 | getInputProps: () => ({ 7 | onChange: e => { 8 | const value = e.target.value; 9 | if (value) { 10 | onRequestItem({ 11 | value 12 | }, data => setFocusIndex(data.index)); 13 | } 14 | } 15 | }) 16 | }); 17 | 18 | export { autoFocus }; 19 | -------------------------------------------------------------------------------- /dist/esm/features/atom/autoInline.mjs: -------------------------------------------------------------------------------- 1 | const autoInline = ({ 2 | onRequestItem 3 | }) => ({ 4 | getItemValue, 5 | setTmpValue, 6 | setFocusIndex 7 | }) => ({ 8 | getInputProps: () => ({ 9 | 'aria-autocomplete': 'both', 10 | onChange: ({ 11 | target, 12 | nativeEvent 13 | }) => { 14 | if (nativeEvent.inputType !== 'insertText') { 15 | return; 16 | } 17 | const value = target.value; 18 | onRequestItem({ 19 | value 20 | }, data => { 21 | setFocusIndex(data.index); 22 | const itemValue = getItemValue(data.item); 23 | const start = value.length; 24 | const end = itemValue.length; 25 | setTmpValue(value + itemValue.slice(start)); 26 | setTimeout(() => target.setSelectionRange(start, end), 0); 27 | }); 28 | } 29 | }) 30 | }); 31 | 32 | export { autoInline }; 33 | -------------------------------------------------------------------------------- /dist/esm/features/atom/dropdownToggle.mjs: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import { useToggle } from '../../hooks/useToggle.mjs'; 3 | 4 | const dropdownToggle = ({ 5 | closeOnSelect = true, 6 | toggleRef: externalToggleRef 7 | } = {}) => ({ 8 | inputRef, 9 | open, 10 | setOpen, 11 | focusIndex, 12 | value, 13 | tmpValue 14 | }) => { 15 | const [startToggle, stopToggle] = useToggle(open, setOpen); 16 | const internalToggleRef = useRef(null); 17 | const toggleRef = externalToggleRef || internalToggleRef; 18 | const inputValue = tmpValue || value || ''; 19 | useEffect(() => { 20 | if (open) inputRef.current?.focus({ 21 | preventScroll: true 22 | }); 23 | }, [open, inputRef]); 24 | const focusToggle = () => setTimeout(() => toggleRef.current?.focus(), 0); 25 | return { 26 | toggleRef, 27 | isInputEmpty: !inputValue, 28 | getToggleProps: () => ({ 29 | type: 'button', 30 | 'aria-haspopup': true, 31 | 'aria-expanded': open, 32 | ref: toggleRef, 33 | onMouseDown: startToggle, 34 | onClick: stopToggle, 35 | onKeyDown: e => { 36 | const { 37 | key 38 | } = e; 39 | if (key === 'ArrowDown') { 40 | e.preventDefault(); 41 | setOpen(true); 42 | } 43 | } 44 | }), 45 | getInputProps: () => ({ 46 | value: inputValue, 47 | onKeyDown: e => { 48 | const { 49 | key 50 | } = e; 51 | if (key === 'Escape' || closeOnSelect && focusIndex >= 0 && key === 'Enter') { 52 | focusToggle(); 53 | } 54 | } 55 | }) 56 | }; 57 | }; 58 | 59 | export { dropdownToggle }; 60 | -------------------------------------------------------------------------------- /dist/esm/features/atom/index.mjs: -------------------------------------------------------------------------------- 1 | export { autocompleteLite } from './autocompleteLite.mjs'; 2 | export { autoFocus } from './autoFocus.mjs'; 3 | export { autoInline } from './autoInline.mjs'; 4 | export { dropdownToggle } from './dropdownToggle.mjs'; 5 | export { inputFocus } from './inputFocus.mjs'; 6 | export { inputToggle } from './inputToggle.mjs'; 7 | export { label } from './label.mjs'; 8 | export { multiInput } from './multiInput.mjs'; 9 | export { nonblurToggle } from './nonblurToggle.mjs'; 10 | -------------------------------------------------------------------------------- /dist/esm/features/atom/inputFocus.mjs: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const inputFocus = () => () => { 4 | const [focused, setFocused] = useState(false); 5 | return { 6 | focused, 7 | getInputProps: () => ({ 8 | onFocusCapture: () => setFocused(true), 9 | onBlurCapture: () => setFocused(false) 10 | }) 11 | }; 12 | }; 13 | 14 | export { inputFocus }; 15 | -------------------------------------------------------------------------------- /dist/esm/features/atom/inputToggle.mjs: -------------------------------------------------------------------------------- 1 | import { getInputToggleProps } from '../../common.mjs'; 2 | import { useToggle } from '../../hooks/useToggle.mjs'; 3 | import { useFocusCapture } from '../../hooks/useFocusCapture.mjs'; 4 | 5 | const inputToggle = () => ({ 6 | id, 7 | inputRef, 8 | open, 9 | setOpen 10 | }) => { 11 | const [startToggle, stopToggle] = useToggle(open, setOpen); 12 | const [startCapture, inCapture, stopCapture] = useFocusCapture(inputRef); 13 | return { 14 | getToggleProps: () => ({ 15 | ...getInputToggleProps(id, open), 16 | onMouseDown: () => { 17 | startToggle(); 18 | startCapture(); 19 | }, 20 | onClick: () => { 21 | stopToggle(); 22 | stopCapture(); 23 | } 24 | }), 25 | getInputProps: () => ({ 26 | onBlur: inCapture 27 | }) 28 | }; 29 | }; 30 | 31 | export { inputToggle }; 32 | -------------------------------------------------------------------------------- /dist/esm/features/atom/label.mjs: -------------------------------------------------------------------------------- 1 | import { getId } from '../../common.mjs'; 2 | 3 | const label = () => ({ 4 | id 5 | }) => { 6 | const inputId = getId(id, 'i'); 7 | const labelId = getId(id, 'a'); 8 | return { 9 | getLabelProps: () => ({ 10 | id: labelId, 11 | htmlFor: inputId 12 | }), 13 | getInputProps: () => ({ 14 | id: inputId 15 | }), 16 | getListProps: () => ({ 17 | 'aria-labelledby': labelId 18 | }) 19 | }; 20 | }; 21 | 22 | export { label }; 23 | -------------------------------------------------------------------------------- /dist/esm/features/atom/multiInput.mjs: -------------------------------------------------------------------------------- 1 | const multiInput = () => ({ 2 | removeSelect 3 | }) => ({ 4 | getInputProps: () => ({ 5 | onKeyDown: e => !e.target.value && e.key === 'Backspace' && removeSelect?.() 6 | }) 7 | }); 8 | 9 | export { multiInput }; 10 | -------------------------------------------------------------------------------- /dist/esm/features/atom/nonblurToggle.mjs: -------------------------------------------------------------------------------- 1 | import { getInputToggleProps } from '../../common.mjs'; 2 | 3 | const nonblurToggle = () => ({ 4 | id, 5 | open, 6 | setOpen 7 | }) => ({ 8 | getToggleProps: () => ({ 9 | ...getInputToggleProps(id, open), 10 | onClick: () => setOpen(!open) 11 | }) 12 | }); 13 | 14 | export { nonblurToggle }; 15 | -------------------------------------------------------------------------------- /dist/esm/features/molecule/autocomplete.mjs: -------------------------------------------------------------------------------- 1 | import { mergeModules } from '../../utils/mergeModules.mjs'; 2 | import { autocompleteLite } from '../atom/autocompleteLite.mjs'; 3 | import { inputToggle } from '../atom/inputToggle.mjs'; 4 | import { label } from '../atom/label.mjs'; 5 | 6 | const autocomplete = props => mergeModules(autocompleteLite(props), inputToggle(), label()); 7 | 8 | export { autocomplete }; 9 | -------------------------------------------------------------------------------- /dist/esm/features/molecule/dropdown.mjs: -------------------------------------------------------------------------------- 1 | import { mergeModules } from '../../utils/mergeModules.mjs'; 2 | import { autocompleteLite } from '../atom/autocompleteLite.mjs'; 3 | import { dropdownToggle } from '../atom/dropdownToggle.mjs'; 4 | 5 | const dropdown = props => mergeModules(autocompleteLite({ 6 | ...props, 7 | select: true, 8 | deselectOnClear: false 9 | }), dropdownToggle(props)); 10 | 11 | export { dropdown }; 12 | -------------------------------------------------------------------------------- /dist/esm/features/molecule/index.mjs: -------------------------------------------------------------------------------- 1 | export { autocomplete } from './autocomplete.mjs'; 2 | export { dropdown } from './dropdown.mjs'; 3 | export { multiSelect } from './multiSelect.mjs'; 4 | export { multiSelectDropdown } from './multiSelectDropdown.mjs'; 5 | export { supercomplete } from './supercomplete.mjs'; 6 | -------------------------------------------------------------------------------- /dist/esm/features/molecule/multiSelect.mjs: -------------------------------------------------------------------------------- 1 | import { mergeModules } from '../../utils/mergeModules.mjs'; 2 | import { autocompleteLite } from '../atom/autocompleteLite.mjs'; 3 | import { nonblurToggle } from '../atom/nonblurToggle.mjs'; 4 | import { label } from '../atom/label.mjs'; 5 | import { inputFocus } from '../atom/inputFocus.mjs'; 6 | import { multiInput } from '../atom/multiInput.mjs'; 7 | 8 | const multiSelect = props => mergeModules(autocompleteLite({ 9 | ...props, 10 | select: true 11 | }), nonblurToggle(), label(), inputFocus(), multiInput()); 12 | 13 | export { multiSelect }; 14 | -------------------------------------------------------------------------------- /dist/esm/features/molecule/multiSelectDropdown.mjs: -------------------------------------------------------------------------------- 1 | import { mergeModules } from '../../utils/mergeModules.mjs'; 2 | import { multiInput } from '../atom/multiInput.mjs'; 3 | import { dropdown } from './dropdown.mjs'; 4 | 5 | const multiSelectDropdown = props => mergeModules(dropdown(props), multiInput()); 6 | 7 | export { multiSelectDropdown }; 8 | -------------------------------------------------------------------------------- /dist/esm/features/molecule/supercomplete.mjs: -------------------------------------------------------------------------------- 1 | import { mergeModules } from '../../utils/mergeModules.mjs'; 2 | import { autocomplete } from './autocomplete.mjs'; 3 | import { autoInline } from '../atom/autoInline.mjs'; 4 | 5 | const supercomplete = props => mergeModules(autocomplete({ 6 | ...props, 7 | rovingText: true 8 | }), autoInline(props)); 9 | 10 | export { supercomplete }; 11 | -------------------------------------------------------------------------------- /dist/esm/hooks/useAutocomplete.mjs: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | import { useId } from './useId.mjs'; 3 | import { defaultFocusIndex } from '../common.mjs'; 4 | 5 | const useAutocomplete = ({ 6 | onChange, 7 | feature: useFeature, 8 | isItemSelected, 9 | inputRef: externalInputRef, 10 | getItemValue, 11 | ...passthrough 12 | }) => { 13 | const internalInputRef = useRef(null); 14 | const [tmpValue, setTmpValue] = useState(); 15 | const [open, setOpen] = useState(false); 16 | const [focusIndex, setFocusIndex] = useState(defaultFocusIndex); 17 | const state = { 18 | isItemSelected, 19 | inputRef: externalInputRef || internalInputRef, 20 | focusIndex, 21 | setFocusIndex, 22 | open, 23 | setOpen 24 | }; 25 | const featureYield = useFeature({ 26 | id: useId(), 27 | tmpValue, 28 | setTmpValue, 29 | onChange: newValue => passthrough.value != newValue && onChange?.(newValue), 30 | getItemValue: item => item == null ? '' : getItemValue ? getItemValue(item) : item.toString(), 31 | ...passthrough, 32 | ...state 33 | }); 34 | return { 35 | ...state, 36 | ...featureYield 37 | }; 38 | }; 39 | 40 | export { useAutocomplete }; 41 | -------------------------------------------------------------------------------- /dist/esm/hooks/useCombobox.mjs: -------------------------------------------------------------------------------- 1 | import { defaultEqual } from '../common.mjs'; 2 | import { useAutocomplete } from './useAutocomplete.mjs'; 3 | 4 | const useCombobox = ({ 5 | isEqual = defaultEqual, 6 | selected, 7 | onSelectChange, 8 | flipOnSelect, 9 | ...passthrough 10 | }) => useAutocomplete({ 11 | ...passthrough, 12 | selected, 13 | isEqual, 14 | isItemSelected: item => isEqual(item, selected), 15 | onSelectChange: newItem => { 16 | if (!isEqual(newItem, selected)) { 17 | onSelectChange?.(newItem); 18 | } else if (flipOnSelect) { 19 | onSelectChange?.(); 20 | } 21 | } 22 | }); 23 | 24 | export { useCombobox }; 25 | -------------------------------------------------------------------------------- /dist/esm/hooks/useFocusCapture.mjs: -------------------------------------------------------------------------------- 1 | import { useMutableState } from './useMutableState.mjs'; 2 | 3 | const useFocusCapture = focusRef => { 4 | const mutable = useMutableState({}); 5 | return [() => { 6 | mutable.a = 1; 7 | }, () => { 8 | if (mutable.a) { 9 | mutable.a = 0; 10 | focusRef.current?.focus(); 11 | return true; 12 | } 13 | }, () => { 14 | mutable.a = 0; 15 | focusRef.current?.focus(); 16 | }]; 17 | }; 18 | 19 | export { useFocusCapture }; 20 | -------------------------------------------------------------------------------- /dist/esm/hooks/useId.mjs: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | let current = 0; 4 | const useIdShim = () => { 5 | const [id, setId] = useState(); 6 | useEffect(() => setId(++current), []); 7 | return id && `szh-ac${id}-`; 8 | }; 9 | const useId = React.useId || useIdShim; 10 | 11 | export { useId }; 12 | -------------------------------------------------------------------------------- /dist/esm/hooks/useMultiSelect.mjs: -------------------------------------------------------------------------------- 1 | import { defaultEqual } from '../common.mjs'; 2 | import { useAutocomplete } from './useAutocomplete.mjs'; 3 | 4 | const useMultiSelect = ({ 5 | isEqual = defaultEqual, 6 | selected, 7 | onSelectChange, 8 | flipOnSelect, 9 | ...passthrough 10 | }) => { 11 | const removeItem = itemToRemove => onSelectChange?.(selected.filter(item => !isEqual(itemToRemove, item))); 12 | const removeSelect = item => { 13 | if (item) { 14 | removeItem(item); 15 | } else { 16 | selected.length && onSelectChange?.(selected.slice(0, selected.length - 1)); 17 | } 18 | }; 19 | const isItemSelected = item => selected.findIndex(s => isEqual(item, s)) >= 0; 20 | return { 21 | ...useAutocomplete({ 22 | ...passthrough, 23 | selected, 24 | isEqual, 25 | isItemSelected, 26 | onSelectChange: newItem => { 27 | if (!newItem) return; 28 | if (!isItemSelected(newItem)) { 29 | onSelectChange?.([...selected, newItem]); 30 | } else if (flipOnSelect) { 31 | removeItem(newItem); 32 | } 33 | }, 34 | removeSelect 35 | }), 36 | removeSelect 37 | }; 38 | }; 39 | 40 | export { useMultiSelect }; 41 | -------------------------------------------------------------------------------- /dist/esm/hooks/useMutableState.mjs: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useMutableState = stateContainer => useState(stateContainer)[0]; 4 | 5 | export { useMutableState }; 6 | -------------------------------------------------------------------------------- /dist/esm/hooks/useToggle.mjs: -------------------------------------------------------------------------------- 1 | import { useMutableState } from './useMutableState.mjs'; 2 | 3 | const useToggle = (open, setOpen) => { 4 | const mutable = useMutableState({}); 5 | return [() => mutable.a = open, () => { 6 | if (mutable.a) { 7 | mutable.a = 0; 8 | } else { 9 | setOpen(true); 10 | } 11 | }]; 12 | }; 13 | 14 | export { useToggle }; 15 | -------------------------------------------------------------------------------- /dist/esm/index.mjs: -------------------------------------------------------------------------------- 1 | export { useCombobox } from './hooks/useCombobox.mjs'; 2 | export { useMultiSelect } from './hooks/useMultiSelect.mjs'; 3 | export { autocompleteLite } from './features/atom/autocompleteLite.mjs'; 4 | export { autocomplete } from './features/molecule/autocomplete.mjs'; 5 | export { dropdown } from './features/molecule/dropdown.mjs'; 6 | export { multiSelect } from './features/molecule/multiSelect.mjs'; 7 | export { multiSelectDropdown } from './features/molecule/multiSelectDropdown.mjs'; 8 | export { supercomplete } from './features/molecule/supercomplete.mjs'; 9 | export { mergeGroupedItems } from './utils/mergeGroupedItems.mjs'; 10 | export { mergeModules } from './utils/mergeModules.mjs'; 11 | -------------------------------------------------------------------------------- /dist/esm/utils/mergeGroupedItems.mjs: -------------------------------------------------------------------------------- 1 | const isArray = Array.isArray; 2 | const mergeGroupedItems = ({ 3 | groups, 4 | getItemsInGroup 5 | }) => { 6 | const groupArray = isArray(groups) ? groups : Object.values(groups); 7 | return groupArray.reduce((accu, group) => accu.concat(isArray(group) ? group : getItemsInGroup ? getItemsInGroup(group) : []), []); 8 | }; 9 | 10 | export { mergeGroupedItems }; 11 | -------------------------------------------------------------------------------- /dist/esm/utils/mergeModules.mjs: -------------------------------------------------------------------------------- 1 | import { mergeObjects } from './mergeObjects.mjs'; 2 | 3 | const mergeModules = (...modules) => cx => modules.reduce((accu, curr) => mergeObjects(accu, curr(cx)), {}); 4 | 5 | export { mergeModules }; 6 | -------------------------------------------------------------------------------- /dist/esm/utils/mergeObjects.mjs: -------------------------------------------------------------------------------- 1 | const mergeObjects = (obj1, obj2) => { 2 | const merged = { 3 | ...obj1 4 | }; 5 | Object.entries(obj2).forEach(([key, prop2]) => { 6 | if (typeof prop2 === 'function') { 7 | const prop1 = obj1[key]; 8 | merged[key] = prop1 ? (...args) => { 9 | const result1 = prop1(...args); 10 | const result2 = prop2(...args); 11 | if (typeof result1 === 'object') { 12 | return mergeObjects(result1, result2); 13 | } 14 | } : prop2; 15 | } else { 16 | merged[key] = prop2; 17 | } 18 | }); 19 | return merged; 20 | }; 21 | 22 | export { mergeObjects }; 23 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | import prettier from 'eslint-config-prettier'; 7 | import jest from 'eslint-plugin-jest'; 8 | import react from 'eslint-plugin-react'; 9 | import reactHooks from 'eslint-plugin-react-hooks'; 10 | import reactHooksAddons from 'eslint-plugin-react-hooks-addons'; 11 | 12 | export default tseslint.config( 13 | eslint.configs.recommended, 14 | prettier, 15 | jest.configs['flat/recommended'], 16 | jest.configs['flat/style'], 17 | react.configs.flat.recommended, 18 | react.configs.flat['jsx-runtime'], 19 | reactHooksAddons.configs.recommended, 20 | ...tseslint.configs.recommendedTypeChecked, 21 | { 22 | files: ['**/*.js', '**/*.cjs', '**/*.mjs'], 23 | ...tseslint.configs.disableTypeChecked 24 | }, 25 | { 26 | ignores: [ 27 | 'features/', 28 | 'types/', 29 | '**/examples/', 30 | '**/coverage/', 31 | '**/dist/', 32 | '**/build/', 33 | '**/static/', 34 | '**/.docusaurus/' 35 | ] 36 | }, 37 | { 38 | languageOptions: { 39 | ecmaVersion: 'latest', 40 | sourceType: 'module', 41 | parserOptions: { 42 | projectService: { 43 | allowDefaultProject: ['*.js', '*.cjs', '*.mjs'] 44 | }, 45 | tsconfigRootDir: import.meta.dirname, 46 | ecmaFeatures: { 47 | jsx: true 48 | } 49 | }, 50 | globals: { 51 | ...globals.browser, 52 | ...globals.node, 53 | ...globals.jest 54 | } 55 | }, 56 | plugins: { 57 | jest, 58 | react, 59 | // @ts-ignore 60 | 'react-hooks': reactHooks 61 | }, 62 | settings: { 63 | react: { 64 | version: 'detect' 65 | } 66 | }, 67 | rules: { 68 | 'no-console': ['error', { allow: ['warn', 'error'] }], 69 | 'jest/expect-expect': 0, 70 | 'react-hooks/rules-of-hooks': 'error', 71 | 'react-hooks/exhaustive-deps': 'error', 72 | 'react-hooks-addons/no-unused-deps': 'error', 73 | '@typescript-eslint/ban-ts-comment': 0, 74 | '@typescript-eslint/no-unused-expressions': [ 75 | 'error', 76 | { allowShortCircuit: true, allowTernary: true } 77 | ], 78 | '@typescript-eslint/no-misused-promises': [ 79 | 'error', 80 | { 81 | checksVoidReturn: false 82 | } 83 | ] 84 | } 85 | } 86 | ); 87 | -------------------------------------------------------------------------------- /examples/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "plugins": ["react-hooks-addons"], 4 | "extends": ["eslint:recommended", "next/core-web-vitals", "prettier"], 5 | "rules": { 6 | "react-hooks-addons/no-unused-deps": "warn" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | \*\*\***WARNING**\*\*\* 2 | 3 | _The purpose of this project is to assist with local development, rather than to demonstrate how to use the main library. The examples here do not always follow best practices for using the main library, and some may not even function properly._ 4 | 5 | If you're seeking proper examples, please refer to the [website](../website/) directory. 6 | 7 | ## Getting Started 8 | 9 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 10 | 11 | Run the development server: 12 | 13 | ```bash 14 | npm run dev 15 | ``` 16 | 17 | Open [http://localhost:8080](http://localhost:8080) with your browser to see the result. 18 | -------------------------------------------------------------------------------- /examples/features/autocompleteFocus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | mergeModules, 3 | MergedFeature, 4 | FeatureProps, 5 | AutocompleteFeature, 6 | autocomplete 7 | } from '@szhsin/react-autocomplete'; 8 | import { AutoFocusFeature, autoFocus } from '@szhsin/react-autocomplete/features/atom'; 9 | 10 | type AutocompleteFocusFeature = MergedFeature< 11 | T, 12 | [AutocompleteFeature, AutoFocusFeature] 13 | >; 14 | 15 | const autocompleteFocus = (props: FeatureProps): AutocompleteFocusFeature => 16 | mergeModules(autocomplete(props), autoFocus(props)); 17 | 18 | export { autocompleteFocus }; 19 | -------------------------------------------------------------------------------- /examples/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 8080", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@floating-ui/react-dom": "^2.1.2", 13 | "@szhsin/react-autocomplete": "file:..", 14 | "@tanstack/react-virtual": "^3.13.9", 15 | "next": "^15.1.8", 16 | "react": "file:../node_modules/react", 17 | "react-dom": "file:../node_modules/react-dom", 18 | "react-window": "^1.8.11" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^22.15.21", 22 | "@types/react": "^19.1.5", 23 | "@types/react-dom": "^19.1.5", 24 | "@types/react-window": "^1.8.8", 25 | "eslint-config-next": "^15.1.8", 26 | "typescript": "file:../node_modules/typescript" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /examples/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /examples/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 9 | res.status(200).json({ name: 'John Doe' }); 10 | } 11 | -------------------------------------------------------------------------------- /examples/pages/search.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useCombobox, supercomplete } from '@szhsin/react-autocomplete'; 3 | import styles from '@/styles/Home.module.css'; 4 | import { US_STATES } from '../data'; 5 | 6 | type Item = { name: string; abbr: string }; 7 | const getItemValue = (item: Item) => item.name; 8 | 9 | const search = (value: string | undefined) => value && console.log(`Searching for "${value}"`); 10 | 11 | export default function Home() { 12 | const [value, setValue] = useState(); 13 | const items = value 14 | ? US_STATES.filter((item) => item.name.toLowerCase().startsWith(value.toLowerCase())) 15 | : US_STATES; 16 | 17 | const { 18 | getInputProps, 19 | getListProps, 20 | getItemProps, 21 | getToggleProps, 22 | getClearProps, 23 | open, 24 | focusIndex, 25 | isInputEmpty 26 | } = useCombobox({ 27 | onSelectChange: (selected) => search(selected?.name), 28 | getItemValue, 29 | value, 30 | onChange: setValue, 31 | feature: supercomplete({ 32 | onRequestItem: ({ value: newValue }, res) => 33 | res({ 34 | index: 0, 35 | item: US_STATES.filter((item) => 36 | item.name.toLowerCase().startsWith(newValue.toLowerCase()) 37 | )[0] 38 | }) 39 | }), 40 | 41 | items 42 | }); 43 | 44 | return ( 45 |
46 |
value: {value}
47 |
focusIndex: {focusIndex}
48 | 49 |
{ 51 | e.preventDefault(); 52 | search(value); 53 | }} 54 | > 55 | 56 | 57 | {!isInputEmpty && ( 58 | 66 | )} 67 | 70 |
71 |
    80 | {items.map((item, index) => ( 81 |
  • 89 | {item.name} 90 |
  • 91 | ))} 92 |
93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /examples/pages/shadowDom.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 3 | import { US_STATES_PLAIN as STATES } from '../data'; 4 | import { createRoot } from 'react-dom/client'; 5 | 6 | const ShadowDom = () => { 7 | const [value, setValue] = useState(); 8 | const [selected, setSelected] = useState(); 9 | 10 | const items = value 11 | ? STATES.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) 12 | : STATES; 13 | 14 | const { 15 | getFocusCaptureProps, 16 | getLabelProps, 17 | getInputProps, 18 | getClearProps, 19 | getToggleProps, 20 | getListProps, 21 | getItemProps, 22 | open, 23 | focusIndex, 24 | isInputEmpty 25 | } = useCombobox({ 26 | items, 27 | value, 28 | onChange: setValue, 29 | selected, 30 | onSelectChange: setSelected, 31 | feature: autocomplete({ select: true }) 32 | }); 33 | 34 | return ( 35 |
36 |

Shadow DOM

37 | 40 |
41 | 42 | {!isInputEmpty && } 43 | 44 |
45 | 46 |
    60 | {items.length ? ( 61 | items.map((item, index) => ( 62 |
  • 70 | {item} 71 |
  • 72 | )) 73 | ) : ( 74 |
  • No results
  • 75 | )} 76 |
77 |
78 | ); 79 | }; 80 | 81 | if (typeof document !== 'undefined' && !document.getElementById('shadow-container')) { 82 | const domElement = document.getElementById('__next'); 83 | const container = document.createElement('div'); 84 | container.id = 'shadow-container'; 85 | domElement?.appendChild(container); 86 | const shadow = container.attachShadow({ mode: 'open' }); 87 | 88 | const nestedContainer = document.createElement('div'); 89 | nestedContainer.id = 'nested-shadow-container'; 90 | const nestedShadow = nestedContainer.attachShadow({ mode: 'closed' }); 91 | shadow.appendChild(nestedContainer); 92 | 93 | createRoot(nestedShadow).render(); 94 | } 95 | 96 | export default function Example() { 97 | return null; 98 | } 99 | -------------------------------------------------------------------------------- /examples/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-autocomplete/a68a628011e4ac1c753ce626ce172c8de8088f05/examples/public/favicon.ico -------------------------------------------------------------------------------- /examples/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-size: 20px; 3 | } 4 | 5 | .input { 6 | font-size: 24px; 7 | } 8 | 9 | .input::selection { 10 | background-color: rgb(222, 146, 6); 11 | } 12 | 13 | .list { 14 | max-height: 300px; 15 | overflow: auto; 16 | } 17 | 18 | .option:hover { 19 | background-color: #777 !important; 20 | color: rgb(163, 210, 42); 21 | } 22 | 23 | .disabled { 24 | color: #777; 25 | user-select: none; 26 | } 27 | 28 | .multiInputWrapper { 29 | display: flex; 30 | flex-wrap: wrap; 31 | align-items: center; 32 | gap: 10px; 33 | max-width: 300px; 34 | border: 2px solid; 35 | border-radius: 4px; 36 | padding: 4px; 37 | } 38 | 39 | .focused { 40 | border-color: lightskyblue; 41 | } 42 | 43 | .multiInputContainer { 44 | flex-grow: 1; 45 | display: flex; 46 | align-items: center; 47 | } 48 | 49 | .multiInput { 50 | flex-grow: 1; 51 | appearance: none; 52 | min-width: 60px; 53 | width: 0; 54 | border: none; 55 | background: none; 56 | outline: none; 57 | } 58 | 59 | .clearButton { 60 | border: none; 61 | background: none; 62 | outline: none; 63 | font-size: 20px; 64 | cursor: pointer; 65 | } 66 | 67 | .selectedItem { 68 | display: flex; 69 | align-items: center; 70 | gap: 4px; 71 | padding: 0 4px; 72 | border: 2px solid #999; 73 | border-radius: 8px; 74 | background-color: #777; 75 | } 76 | 77 | .selectedItem > span { 78 | cursor: pointer; 79 | } 80 | -------------------------------------------------------------------------------- /examples/styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: 5 | ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', 6 | 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 7 | 'Courier New', monospace; 8 | 9 | --foreground-rgb: 0, 0, 0; 10 | --background-start-rgb: 214, 219, 220; 11 | --background-end-rgb: 255, 255, 255; 12 | 13 | --primary-glow: conic-gradient( 14 | from 180deg at 50% 50%, 15 | #16abff33 0deg, 16 | #0885ff33 55deg, 17 | #54d6ff33 120deg, 18 | #0071ff33 160deg, 19 | transparent 360deg 20 | ); 21 | --secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); 22 | 23 | --tile-start-rgb: 239, 245, 249; 24 | --tile-end-rgb: 228, 232, 233; 25 | --tile-border: conic-gradient( 26 | #00000080, 27 | #00000040, 28 | #00000030, 29 | #00000020, 30 | #00000010, 31 | #00000010, 32 | #00000080 33 | ); 34 | 35 | --callout-rgb: 238, 240, 241; 36 | --callout-border-rgb: 172, 175, 176; 37 | --card-rgb: 180, 185, 188; 38 | --card-border-rgb: 131, 134, 135; 39 | } 40 | 41 | @media (prefers-color-scheme: dark) { 42 | :root { 43 | --foreground-rgb: 255, 255, 255; 44 | --background-start-rgb: 0, 0, 0; 45 | --background-end-rgb: 0, 0, 0; 46 | 47 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 48 | --secondary-glow: linear-gradient( 49 | to bottom right, 50 | rgba(1, 65, 255, 0), 51 | rgba(1, 65, 255, 0), 52 | rgba(1, 65, 255, 0.3) 53 | ); 54 | 55 | --tile-start-rgb: 2, 13, 46; 56 | --tile-end-rgb: 2, 5, 19; 57 | --tile-border: conic-gradient( 58 | #ffffff80, 59 | #ffffff40, 60 | #ffffff30, 61 | #ffffff20, 62 | #ffffff10, 63 | #ffffff10, 64 | #ffffff80 65 | ); 66 | 67 | --callout-rgb: 20, 20, 20; 68 | --callout-border-rgb: 108, 108, 108; 69 | --card-rgb: 100, 100, 100; 70 | --card-border-rgb: 200, 200, 200; 71 | } 72 | } 73 | 74 | * { 75 | box-sizing: border-box; 76 | padding: 0; 77 | margin: 0; 78 | } 79 | 80 | html, 81 | body { 82 | max-width: 100vw; 83 | overflow-x: hidden; 84 | } 85 | 86 | body { 87 | color: rgb(var(--foreground-rgb)); 88 | background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) 89 | rgb(var(--background-start-rgb)); 90 | font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; 91 | } 92 | 93 | a { 94 | color: inherit; 95 | text-decoration: none; 96 | } 97 | 98 | @media (prefers-color-scheme: dark) { 99 | html { 100 | color-scheme: dark; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /features/atom.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../types/features/atom'; 2 | -------------------------------------------------------------------------------- /features/molecule.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../types/features/molecule'; 2 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | testEnvironment: 'jsdom', 4 | testMatch: ['**/*.test.[jt]s?(x)'], 5 | setupFilesAfterEnv: ['@testing-library/jest-dom'], 6 | clearMocks: true, 7 | collectCoverage: true 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@szhsin/react-autocomplete", 3 | "version": "1.1.0", 4 | "description": "A modular, lightweight, and headless React autocomplete solution.", 5 | "author": "Zheng Song", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/szhsin/react-autocomplete.git" 10 | }, 11 | "homepage": "https://szhsin.github.io/react-autocomplete/", 12 | "type": "module", 13 | "main": "./dist/cjs/index.cjs", 14 | "module": "./dist/esm/index.mjs", 15 | "types": "./types/index.d.ts", 16 | "sideEffects": false, 17 | "publishConfig": { 18 | "access": "public" 19 | }, 20 | "files": [ 21 | "dist/", 22 | "features/", 23 | "types/" 24 | ], 25 | "keywords": [ 26 | "react", 27 | "autocomplete", 28 | "select", 29 | "combobox", 30 | "dropdown", 31 | "headless" 32 | ], 33 | "scripts": { 34 | "start": "run-p watch \"types -- --watch\"", 35 | "bundle": "rollup -c", 36 | "watch": "rollup -c -w", 37 | "clean": "rm -Rf dist types", 38 | "types": "tsc", 39 | "prepare": "rm -Rf types/__tests__", 40 | "lint": "eslint .", 41 | "lint:fix": "eslint --fix .", 42 | "pret": "prettier -c .", 43 | "pret:fix": "prettier -w .", 44 | "build": "run-s pret clean types lint bundle", 45 | "test": "jest", 46 | "test:watch": "jest --watch", 47 | "eg": "npm run dev --prefix examples" 48 | }, 49 | "exports": { 50 | ".": { 51 | "types": "./types/index.d.ts", 52 | "require": "./dist/cjs/index.cjs", 53 | "default": "./dist/esm/index.mjs" 54 | }, 55 | "./features/atom": { 56 | "types": "./types/features/atom/index.d.ts", 57 | "require": "./dist/cjs/features/atom/index.cjs", 58 | "default": "./dist/esm/features/atom/index.mjs" 59 | }, 60 | "./features/molecule": { 61 | "types": "./types/features/molecule/index.d.ts", 62 | "require": "./dist/cjs/features/molecule/index.cjs", 63 | "default": "./dist/esm/features/molecule/index.mjs" 64 | } 65 | }, 66 | "peerDependencies": { 67 | "react": "^16.8 || ^17 || ^18 || ^19" 68 | }, 69 | "devDependencies": { 70 | "@babel/core": "^7.27.1", 71 | "@babel/preset-env": "^7.27.2", 72 | "@babel/preset-react": "^7.27.1", 73 | "@babel/preset-typescript": "^7.27.1", 74 | "@rollup/plugin-babel": "^6.0.4", 75 | "@rollup/plugin-node-resolve": "^16.0.1", 76 | "@testing-library/jest-dom": "^6.6.3", 77 | "@testing-library/react": "^16.3.0", 78 | "@testing-library/user-event": "^14.6.1", 79 | "@types/jest": "^29.5.14", 80 | "@types/react": "^19.1.5", 81 | "babel-plugin-pure-annotations": "^0.1.2", 82 | "deplift": "^1.0.0", 83 | "eslint": "^9.27.0", 84 | "eslint-config-prettier": "^10.1.5", 85 | "eslint-plugin-jest": "^28.11.0", 86 | "eslint-plugin-react": "^7.37.5", 87 | "eslint-plugin-react-hooks": "^5.2.0", 88 | "eslint-plugin-react-hooks-addons": "^0.5.0", 89 | "globals": "^16.1.0", 90 | "jest": "^29.7.0", 91 | "jest-environment-jsdom": "^29.7.0", 92 | "npm-run-all": "^4.1.5", 93 | "prettier": "^3.5.3", 94 | "react": "^19.1.0", 95 | "react-dom": "^19.1.0", 96 | "rollup": "^4.41.1", 97 | "typescript": "^5.8.3", 98 | "typescript-eslint": "^8.32.1" 99 | }, 100 | "overrides": { 101 | "whatwg-url@11.0.0": { 102 | "tr46": "^4" 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import { babel } from '@rollup/plugin-babel'; 3 | 4 | const config = { 5 | external: ['react', 'react-dom', 'react/jsx-runtime'], 6 | plugins: [ 7 | nodeResolve({ extensions: ['.ts', '.tsx', '.js', '.jsx'] }), 8 | babel({ 9 | babelHelpers: 'bundled', 10 | extensions: ['.ts', '.tsx', '.js', '.jsx'] 11 | }) 12 | ], 13 | treeshake: { 14 | moduleSideEffects: false, 15 | propertyReadSideEffects: false 16 | }, 17 | input: ['src/index.ts', 'src/features/atom/index.ts', 'src/features/molecule/index.ts'], 18 | output: [ 19 | { 20 | dir: 'dist/cjs', 21 | format: 'cjs', 22 | interop: 'default', 23 | entryFileNames: '[name].cjs', 24 | preserveModules: true 25 | }, 26 | { 27 | dir: 'dist/esm', 28 | format: 'es', 29 | entryFileNames: '[name].mjs', 30 | preserveModules: true 31 | } 32 | ] 33 | }; 34 | 35 | export default config; 36 | -------------------------------------------------------------------------------- /src/__tests__/autocompleteFocus.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { Autocomplete } from './utils/Autocomplete'; 4 | import './utils/scrollIntoView'; 5 | 6 | describe('autocompleteFocus', () => { 7 | test('continuous interactions', async () => { 8 | const user = userEvent.setup(); 9 | render(); 10 | const combobox = screen.getByRole('combobox'); 11 | 12 | await user.type(combobox, 'c'); 13 | expect(screen.getByTestId('value')).toHaveTextContent(/^c$/); 14 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 15 | expect(combobox).toHaveValue('c'); 16 | expect(screen.getByRole('option', { name: 'California' })).toHaveStyle({ 17 | backgroundColor: 'red' 18 | }); 19 | 20 | await user.keyboard('{Backspace}'); 21 | expect(screen.getByRole('option', { name: 'California' })).toHaveStyle({ 22 | backgroundColor: 'transparent' 23 | }); 24 | 25 | await user.keyboard('co'); 26 | expect(screen.getByTestId('value')).toHaveTextContent(/^co$/); 27 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 28 | expect(combobox).toHaveValue('co'); 29 | expect(screen.getByRole('option', { name: 'Colorado' })).toHaveStyle({ 30 | backgroundColor: 'red' 31 | }); 32 | 33 | await user.keyboard('z'); 34 | expect(screen.getByTestId('value')).toHaveTextContent(/^coz$/); 35 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 36 | expect(combobox).toHaveValue('coz'); 37 | expect(screen.queryAllByRole('option')).toHaveLength(0); 38 | 39 | await user.keyboard('{Backspace}'); 40 | expect(screen.getByTestId('value')).toHaveTextContent(/^co$/); 41 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 42 | expect(combobox).toHaveValue('co'); 43 | expect(screen.getByRole('option', { name: 'Colorado' })).toHaveStyle({ 44 | backgroundColor: 'red' 45 | }); 46 | 47 | await user.keyboard('{Enter}'); 48 | expect(screen.getByTestId('value')).toHaveTextContent(/^Colorado$/); 49 | expect(screen.getByTestId('selected')).toHaveTextContent(/^Colorado$/); 50 | expect(combobox).toHaveValue('Colorado'); 51 | expect(combobox).toHaveFocus(); 52 | expect(screen.queryByRole('listbox')).toBeNull(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/__tests__/mergeGroupedItems.test.ts: -------------------------------------------------------------------------------- 1 | import { mergeGroupedItems } from '..'; 2 | 3 | describe('mergeGroupedItems', () => { 4 | test('keyed groups', () => { 5 | const groups = { 6 | A: ['Alabama', 'Alaska', 'Arizona', 'Arkansas'], 7 | C: ['California', 'Colorado', 'Connecticut'], 8 | D: ['Delaware'] 9 | }; 10 | 11 | const mergedItems = mergeGroupedItems({ groups }); 12 | expect(mergedItems).toEqual([ 13 | 'Alabama', 14 | 'Alaska', 15 | 'Arizona', 16 | 'Arkansas', 17 | 'California', 18 | 'Colorado', 19 | 'Connecticut', 20 | 'Delaware' 21 | ]); 22 | }); 23 | 24 | test('object groups', () => { 25 | const groups = [ 26 | { 27 | groupKey: 'A', 28 | states: ['Alabama', 'Alaska', 'Arizona', 'Arkansas'] 29 | }, 30 | { 31 | groupKey: 'C', 32 | states: ['California', 'Colorado', 'Connecticut'] 33 | }, 34 | { 35 | groupKey: 'D', 36 | states: ['Delaware'] 37 | } 38 | ]; 39 | 40 | const mergedItems = mergeGroupedItems({ 41 | groups, 42 | getItemsInGroup: (group) => group.states 43 | }); 44 | expect(mergedItems).toEqual([ 45 | 'Alabama', 46 | 'Alaska', 47 | 'Arizona', 48 | 'Arkansas', 49 | 'California', 50 | 'Colorado', 51 | 'Connecticut', 52 | 'Delaware' 53 | ]); 54 | 55 | expect(mergeGroupedItems({ groups })).toEqual([]); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/__tests__/noOptions.test.ts: -------------------------------------------------------------------------------- 1 | import { autocompleteLite } from '../features/atom/autocompleteLite'; 2 | import { dropdownToggle } from '../features/atom/dropdownToggle'; 3 | import { multiSelectDropdown } from '../features/molecule/multiSelectDropdown'; 4 | 5 | test('no options', () => { 6 | autocompleteLite(); 7 | dropdownToggle(); 8 | multiSelectDropdown(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/supercomplete.test.tsx: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import './utils/scrollIntoView'; 4 | import { Autocomplete } from './utils/Autocomplete'; 5 | 6 | describe('supercomplete', () => { 7 | test('continuous interactions', async () => { 8 | const user = userEvent.setup(); 9 | render(); 10 | const combobox = screen.getByRole('combobox'); 11 | expect(combobox).toHaveAttribute('aria-autocomplete', 'both'); 12 | 13 | await user.type(combobox, 'c'); 14 | expect(screen.getByTestId('value')).toHaveTextContent(/^c$/); 15 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 16 | expect(combobox).toHaveValue('california'); 17 | expect(combobox.selectionStart).toBe(1); 18 | expect(combobox.selectionEnd).toBe(10); 19 | expect(screen.getByRole('option', { name: 'California' })).toHaveStyle({ 20 | backgroundColor: 'red' 21 | }); 22 | 23 | await user.keyboard('o'); 24 | expect(screen.getByTestId('value')).toHaveTextContent(/^co$/); 25 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 26 | expect(combobox).toHaveValue('colorado'); 27 | expect(combobox.selectionStart).toBe(2); 28 | expect(combobox.selectionEnd).toBe(8); 29 | expect(screen.getByRole('option', { name: 'Colorado' })).toHaveStyle({ 30 | backgroundColor: 'red' 31 | }); 32 | 33 | await user.keyboard('z'); 34 | expect(screen.getByTestId('value')).toHaveTextContent(/^coz$/); 35 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 36 | expect(combobox).toHaveValue('coz'); 37 | expect(combobox.selectionStart).toBe(3); 38 | expect(combobox.selectionEnd).toBe(3); 39 | expect(screen.queryAllByRole('option')).toHaveLength(0); 40 | 41 | await user.keyboard('{Backspace}'); 42 | expect(screen.getByTestId('value')).toHaveTextContent(/^co$/); 43 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 44 | expect(combobox).toHaveValue('co'); 45 | expect(combobox.selectionStart).toBe(2); 46 | expect(combobox.selectionEnd).toBe(2); 47 | expect(screen.getByRole('option', { name: 'Colorado' })).toHaveStyle({ 48 | backgroundColor: 'transparent' 49 | }); 50 | 51 | await user.keyboard('{ArrowUp}'); 52 | expect(screen.getByTestId('value')).toHaveTextContent(/^co$/); 53 | expect(screen.getByTestId('selected')).toBeEmptyDOMElement(); 54 | expect(combobox).toHaveValue('Connecticut'); 55 | expect(screen.getByRole('option', { name: 'Connecticut' })).toHaveStyle({ 56 | backgroundColor: 'red' 57 | }); 58 | 59 | await user.keyboard('{Enter}'); 60 | expect(screen.getByTestId('value')).toHaveTextContent(/^Connecticut$/); 61 | expect(screen.getByTestId('selected')).toHaveTextContent(/^Connecticut$/); 62 | expect(combobox).toHaveValue('Connecticut'); 63 | expect(combobox).toHaveFocus(); 64 | expect(screen.queryByRole('listbox')).toBeNull(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/__tests__/useId.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { useId } from '../hooks/useId'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 5 | jest.mock('react', () => ({ 6 | ...jest.requireActual('react'), 7 | useId: undefined 8 | })); 9 | 10 | test('useId', () => { 11 | const id1 = renderHook(() => useId()); 12 | const id2 = renderHook(() => useId()); 13 | expect(id1.result.current).toBe('szh-ac1-'); 14 | expect(id2.result.current).toBe('szh-ac2-'); 15 | id1.rerender(); 16 | id2.rerender(); 17 | expect(id1.result.current).toBe('szh-ac1-'); 18 | expect(id2.result.current).toBe('szh-ac2-'); 19 | }); 20 | -------------------------------------------------------------------------------- /src/__tests__/utils/Autocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { type ComboboxProps, useCombobox, autocomplete, supercomplete } from '../..'; 3 | import type { AutocompleteFeatureProps } from '../../types'; 4 | import { autocompleteFocus } from './autocompleteFocus'; 5 | import { US_STATES } from './data'; 6 | 7 | type Item = { name: string; abbr: string }; 8 | const getItemValue = (item: Item) => item.name; 9 | 10 | const filterItems = (value?: string) => 11 | value 12 | ? US_STATES.filter((item) => item.name.toLowerCase().startsWith(value.toLowerCase())) 13 | : US_STATES; 14 | 15 | export const Autocomplete = ({ 16 | isSupercomplete, 17 | isAutoFocus, 18 | ...props 19 | }: Partial> & 20 | AutocompleteFeatureProps & { isSupercomplete?: boolean; isAutoFocus?: boolean }) => { 21 | const [value, setValue] = useState(); 22 | const [selected, setSelected] = useState(); 23 | const items = filterItems(value); 24 | 25 | const { 26 | getLabelProps, 27 | getInputProps, 28 | getToggleProps, 29 | getClearProps, 30 | getListProps, 31 | getItemProps, 32 | open, 33 | focusIndex, 34 | isInputEmpty, 35 | isItemSelected 36 | } = useCombobox({ 37 | ...props, 38 | getItemValue, 39 | items, 40 | value, 41 | onChange: setValue, 42 | selected, 43 | onSelectChange: setSelected, 44 | feature: 45 | isSupercomplete || isAutoFocus 46 | ? (isSupercomplete ? supercomplete : autocompleteFocus)({ 47 | ...props, 48 | onRequestItem: ({ value: newValue }, res) => { 49 | const items = filterItems(newValue); 50 | if (items.length) { 51 | res({ index: 0, item: items[0] }); 52 | } 53 | } 54 | }) 55 | : autocomplete(props) 56 | }); 57 | 58 | const displayList = !!(open && items.length); 59 | 60 | return ( 61 |
62 |
63 | value: {value} 64 |
65 |
66 | selected: {selected?.name} 67 |
68 | 69 |
70 | 71 |
72 | 73 | {!isInputEmpty && } 74 | 75 | 76 |
    85 |
  • header
  • 86 | {items.map((item, index) => ( 87 |
  • 96 | {item.name} 97 |
  • 98 | ))} 99 |
100 |
101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /src/__tests__/utils/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, dropdown } from '../..'; 3 | import type { ComboboxProps } from '../../types'; 4 | import { US_STATES_STRING } from './data'; 5 | 6 | const filterItems = (value?: string) => 7 | value 8 | ? US_STATES_STRING.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) 9 | : US_STATES_STRING; 10 | 11 | export const Dropdown = ( 12 | props: Partial> & Parameters>[0] 13 | ) => { 14 | const [value, setValue] = useState(); 15 | const [selected, setSelected] = useState(); 16 | const items = filterItems(value); 17 | 18 | const { 19 | getInputProps, 20 | getToggleProps, 21 | getClearProps, 22 | getListProps, 23 | getItemProps, 24 | open, 25 | focusIndex, 26 | isInputEmpty 27 | } = useCombobox({ 28 | ...props, 29 | flipOnSelect: true, 30 | items, 31 | value, 32 | onChange: setValue, 33 | selected, 34 | onSelectChange: setSelected, 35 | feature: dropdown(props) 36 | }); 37 | 38 | const displayList = !!(open && items.length); 39 | 40 | return ( 41 |
42 |
43 | value: {value} 44 |
45 |
46 | selected: {selected} 47 |
48 | 49 |
50 | 51 |
52 | 53 | {displayList && ( 54 |
60 |
61 | 62 | {!isInputEmpty && } 63 |
64 |
    70 |
  • header
  • 71 | {items.map((item, index) => ( 72 |
  • 80 | {item} 81 |
  • 82 | ))} 83 |
84 |
85 | )} 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/__tests__/utils/MultiSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMultiSelect, multiSelect } from '../..'; 3 | import type { MultiSelectProps } from '../../types'; 4 | import { US_STATES } from './data'; 5 | 6 | type Item = { name: string; abbr: string }; 7 | const getItemValue = (item: Item) => item.name; 8 | 9 | const filterItems = (value?: string) => 10 | value 11 | ? US_STATES.filter((item) => item.name.toLowerCase().startsWith(value.toLowerCase())) 12 | : US_STATES; 13 | 14 | export const MultiSelect = ( 15 | props: Partial> & Parameters>[0] 16 | ) => { 17 | const [value, setValue] = useState(); 18 | const [selected, setSelected] = useState([]); 19 | const items = filterItems(value); 20 | 21 | const { 22 | getLabelProps, 23 | getInputProps, 24 | getListProps, 25 | getItemProps, 26 | getToggleProps, 27 | getClearProps, 28 | getFocusCaptureProps, 29 | removeSelect, 30 | isItemSelected, 31 | open, 32 | focusIndex, 33 | isInputEmpty, 34 | focused 35 | } = useMultiSelect({ 36 | ...props, 37 | getItemValue, 38 | items, 39 | value, 40 | onChange: setValue, 41 | selected, 42 | onSelectChange: setSelected, 43 | feature: multiSelect(props) 44 | }); 45 | 46 | const displayList = !!(open && items.length); 47 | 48 | return ( 49 |
50 |
51 | value: {value} 52 |
53 | 54 |
55 | 56 |
57 |
62 | {selected.map((item) => ( 63 | 66 | ))} 67 | 68 | {!isInputEmpty && } 69 | 70 |
71 | 72 |
    81 |
  • header
  • 82 | {items.map((item, index) => ( 83 |
  • 91 | {item.name} 92 |
  • 93 | ))} 94 |
95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/__tests__/utils/autocompleteFocus.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MergedFeature, 3 | FeatureProps, 4 | AutocompleteFeature, 5 | mergeModules, 6 | autocomplete 7 | } from '../..'; 8 | import { AutoFocusFeature, autoFocus } from '../../features/atom'; 9 | 10 | type AutocompleteFocusFeature = MergedFeature< 11 | T, 12 | [AutocompleteFeature, AutoFocusFeature] 13 | >; 14 | 15 | const autocompleteFocus = (props: FeatureProps): AutocompleteFocusFeature => 16 | mergeModules(autocomplete(props), autoFocus(props)); 17 | 18 | export { autocompleteFocus }; 19 | -------------------------------------------------------------------------------- /src/__tests__/utils/globals.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /src/__tests__/utils/scrollIntoView.ts: -------------------------------------------------------------------------------- 1 | const scrollIntoView = jest.fn(); 2 | Element.prototype.scrollIntoView = scrollIntoView; 3 | 4 | export { scrollIntoView }; 5 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | export const defaultFocusIndex = -1; 2 | 3 | export const defaultEqual = (itemA: T | undefined, itemB: T | undefined) => itemA === itemB; 4 | 5 | export const getId = (prefix: string | undefined, suffix: 'l' | 'i' | 'a' | number) => 6 | prefix && prefix + suffix; 7 | 8 | export const buttonProps: React.ButtonHTMLAttributes = { 9 | tabIndex: -1, 10 | type: 'button' 11 | }; 12 | 13 | export const getInputToggleProps = ( 14 | id: string | undefined, 15 | open: boolean 16 | ): React.ButtonHTMLAttributes => ({ 17 | ...buttonProps, 18 | 'aria-expanded': open, 19 | 'aria-controls': getId(id, 'l') 20 | }); 21 | -------------------------------------------------------------------------------- /src/features/atom/autoFocus.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps, FeatureProps } from '../../types'; 2 | 3 | type AutoFocusFeature = Feature, 'getInputProps'>>; 4 | 5 | const autoFocus = 6 | ({ onRequestItem }: Pick, 'onRequestItem'>): AutoFocusFeature => 7 | ({ setFocusIndex }) => ({ 8 | getInputProps: () => ({ 9 | onChange: (e) => { 10 | const value = e.target.value; 11 | if (value) { 12 | onRequestItem({ value }, (data) => setFocusIndex(data.index)); 13 | } 14 | } 15 | }) 16 | }); 17 | 18 | export { type AutoFocusFeature, autoFocus }; 19 | -------------------------------------------------------------------------------- /src/features/atom/autoInline.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps, FeatureProps } from '../../types'; 2 | 3 | type AutoInlineFeature = Feature, 'getInputProps'>>; 4 | 5 | const autoInline = 6 | ({ onRequestItem }: Pick, 'onRequestItem'>): AutoInlineFeature => 7 | ({ getItemValue, setTmpValue, setFocusIndex }) => ({ 8 | getInputProps: () => ({ 9 | 'aria-autocomplete': 'both', 10 | 11 | onChange: ({ target, nativeEvent }) => { 12 | if ((nativeEvent as unknown as { inputType: string }).inputType !== 'insertText') { 13 | return; 14 | } 15 | 16 | const value = target.value; 17 | onRequestItem({ value }, (data) => { 18 | setFocusIndex(data.index); 19 | const itemValue = getItemValue(data.item); 20 | const start = value.length; 21 | const end = itemValue.length; 22 | setTmpValue(value + itemValue.slice(start)); 23 | setTimeout(() => target.setSelectionRange(start, end), 0); 24 | }); 25 | } 26 | }) 27 | }); 28 | 29 | export { type AutoInlineFeature, autoInline }; 30 | -------------------------------------------------------------------------------- /src/features/atom/dropdownToggle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import type { Feature, FeatureProps, GetProps, FeatureState } from '../../types'; 3 | import { useToggle } from '../../hooks/useToggle'; 4 | 5 | type DropdownToggleFeature = Feature< 6 | T, 7 | Pick, 'getToggleProps' | 'getInputProps'> & 8 | FeatureState & { toggleRef: React.RefObject } 9 | >; 10 | 11 | const dropdownToggle = 12 | ({ 13 | closeOnSelect = true, 14 | toggleRef: externalToggleRef 15 | }: Pick, 'closeOnSelect' | 'toggleRef'> = {}): DropdownToggleFeature => 16 | ({ inputRef, open, setOpen, focusIndex, value, tmpValue }) => { 17 | const [startToggle, stopToggle] = useToggle(open, setOpen); 18 | const internalToggleRef = useRef(null); 19 | const toggleRef = externalToggleRef || internalToggleRef; 20 | const inputValue = tmpValue || value || ''; 21 | 22 | useEffect(() => { 23 | if (open) inputRef.current?.focus({ preventScroll: true }); 24 | }, [open, inputRef]); 25 | 26 | // We don't want to flow through onBlur handler in `autocompleteLite`, 27 | // this is a workaround to short cut it by waiting for `open` becomes false 28 | const focusToggle = () => setTimeout(() => toggleRef.current?.focus(), 0); 29 | 30 | return { 31 | toggleRef, 32 | isInputEmpty: !inputValue, 33 | 34 | getToggleProps: () => ({ 35 | type: 'button', 36 | 'aria-haspopup': true, 37 | 'aria-expanded': open, 38 | ref: toggleRef, 39 | 40 | onMouseDown: startToggle, 41 | 42 | onClick: stopToggle, 43 | 44 | onKeyDown: (e) => { 45 | const { key } = e; 46 | if (key === 'ArrowDown') { 47 | e.preventDefault(); 48 | setOpen(true); 49 | } 50 | } 51 | }), 52 | 53 | getInputProps: () => ({ 54 | value: inputValue, 55 | onKeyDown: (e) => { 56 | const { key } = e; 57 | if (key === 'Escape' || (closeOnSelect && focusIndex >= 0 && key === 'Enter')) { 58 | focusToggle(); 59 | } 60 | } 61 | }) 62 | }; 63 | }; 64 | 65 | export { type DropdownToggleFeature, dropdownToggle }; 66 | -------------------------------------------------------------------------------- /src/features/atom/index.ts: -------------------------------------------------------------------------------- 1 | export * from './autocompleteLite'; 2 | export * from './autoFocus'; 3 | export * from './autoInline'; 4 | export * from './dropdownToggle'; 5 | export * from './inputFocus'; 6 | export * from './inputToggle'; 7 | export * from './label'; 8 | export * from './multiInput'; 9 | export * from './nonblurToggle'; 10 | -------------------------------------------------------------------------------- /src/features/atom/inputFocus.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import type { Feature, GetProps } from '../../types'; 3 | 4 | type InputFocusFeature = Feature< 5 | T, 6 | Pick, 'getInputProps'> & { focused: boolean } 7 | >; 8 | 9 | const inputFocus = 10 | (): InputFocusFeature => 11 | () => { 12 | const [focused, setFocused] = useState(false); 13 | 14 | return { 15 | focused, 16 | 17 | getInputProps: () => ({ 18 | onFocusCapture: () => setFocused(true), 19 | onBlurCapture: () => setFocused(false) 20 | }) 21 | }; 22 | }; 23 | 24 | export { type InputFocusFeature, inputFocus }; 25 | -------------------------------------------------------------------------------- /src/features/atom/inputToggle.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | import { getInputToggleProps } from '../../common'; 3 | import { useToggle } from '../../hooks/useToggle'; 4 | import { useFocusCapture } from '../../hooks/useFocusCapture'; 5 | 6 | type InputToggleFeature = Feature, 'getToggleProps' | 'getInputProps'>>; 7 | 8 | const inputToggle = 9 | (): InputToggleFeature => 10 | ({ id, inputRef, open, setOpen }) => { 11 | const [startToggle, stopToggle] = useToggle(open, setOpen); 12 | const [startCapture, inCapture, stopCapture] = useFocusCapture(inputRef); 13 | 14 | return { 15 | getToggleProps: () => ({ 16 | ...getInputToggleProps(id, open), 17 | 18 | onMouseDown: () => { 19 | startToggle(); 20 | startCapture(); 21 | }, 22 | 23 | onClick: () => { 24 | stopToggle(); 25 | stopCapture(); 26 | } 27 | }), 28 | 29 | getInputProps: () => ({ 30 | onBlur: inCapture 31 | }) 32 | }; 33 | }; 34 | 35 | export { type InputToggleFeature, inputToggle }; 36 | -------------------------------------------------------------------------------- /src/features/atom/label.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | import { getId } from '../../common'; 3 | 4 | type LabelFeature = Feature< 5 | T, 6 | Pick, 'getLabelProps' | 'getInputProps' | 'getListProps'> 7 | >; 8 | 9 | const label = 10 | (): LabelFeature => 11 | ({ id }) => { 12 | const inputId = getId(id, 'i'); 13 | const labelId = getId(id, 'a'); 14 | 15 | return { 16 | getLabelProps: () => ({ 17 | id: labelId, 18 | htmlFor: inputId 19 | }), 20 | 21 | getInputProps: () => ({ 22 | id: inputId 23 | }), 24 | 25 | getListProps: () => ({ 26 | 'aria-labelledby': labelId 27 | }) 28 | }; 29 | }; 30 | 31 | export { type LabelFeature, label }; 32 | -------------------------------------------------------------------------------- /src/features/atom/multiInput.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | 3 | type MultiInputFeature = Feature, 'getInputProps'>>; 4 | 5 | const multiInput = 6 | (): MultiInputFeature => 7 | ({ removeSelect }) => ({ 8 | getInputProps: () => ({ 9 | onKeyDown: (e) => 10 | !(e.target as HTMLInputElement).value && e.key === 'Backspace' && removeSelect?.() 11 | }) 12 | }); 13 | 14 | export { type MultiInputFeature, multiInput }; 15 | -------------------------------------------------------------------------------- /src/features/atom/nonblurToggle.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | import { getInputToggleProps } from '../../common'; 3 | 4 | type NonblurToggleFeature = Feature, 'getToggleProps'>>; 5 | 6 | const nonblurToggle = 7 | (): NonblurToggleFeature => 8 | ({ id, open, setOpen }) => ({ 9 | getToggleProps: () => ({ 10 | ...getInputToggleProps(id, open), 11 | onClick: () => setOpen(!open) 12 | }) 13 | }); 14 | 15 | export { type NonblurToggleFeature, nonblurToggle }; 16 | -------------------------------------------------------------------------------- /src/features/molecule/autocomplete.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, AutocompleteFeatureProps } from '../../types'; 2 | import { mergeModules } from '../../utils/mergeModules'; 3 | import { type AutocompleteLiteFeature, autocompleteLite } from '../atom/autocompleteLite'; 4 | import { type InputToggleFeature, inputToggle } from '../atom/inputToggle'; 5 | import { type LabelFeature, label } from '../atom/label'; 6 | 7 | type AutocompleteFeature = MergedFeature< 8 | T, 9 | [AutocompleteLiteFeature, InputToggleFeature, LabelFeature] 10 | >; 11 | 12 | const autocomplete = (props?: AutocompleteFeatureProps): AutocompleteFeature => 13 | mergeModules(autocompleteLite(props), inputToggle(), label()); 14 | 15 | export { type AutocompleteFeature, autocomplete }; 16 | -------------------------------------------------------------------------------- /src/features/molecule/dropdown.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { mergeModules } from '../../utils/mergeModules'; 3 | import { type AutocompleteLiteFeature, autocompleteLite } from '../atom/autocompleteLite'; 4 | import { type DropdownToggleFeature, dropdownToggle } from '../atom/dropdownToggle'; 5 | 6 | type DropdownFeature = MergedFeature< 7 | T, 8 | [AutocompleteLiteFeature, DropdownToggleFeature] 9 | >; 10 | 11 | const dropdown = ( 12 | props?: Pick, 'rovingText' | 'closeOnSelect' | 'toggleRef'> 13 | ): DropdownFeature => 14 | mergeModules( 15 | autocompleteLite({ 16 | ...props, 17 | select: true, 18 | deselectOnClear: false 19 | }), 20 | dropdownToggle(props) 21 | ); 22 | 23 | export { type DropdownFeature, dropdown }; 24 | -------------------------------------------------------------------------------- /src/features/molecule/index.ts: -------------------------------------------------------------------------------- 1 | export * from './autocomplete'; 2 | export * from './dropdown'; 3 | export * from './multiSelect'; 4 | export * from './multiSelectDropdown'; 5 | export * from './supercomplete'; 6 | -------------------------------------------------------------------------------- /src/features/molecule/multiSelect.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { mergeModules } from '../../utils/mergeModules'; 3 | import { type AutocompleteLiteFeature, autocompleteLite } from '../atom/autocompleteLite'; 4 | import { type NonblurToggleFeature, nonblurToggle } from '../atom/nonblurToggle'; 5 | import { type LabelFeature, label } from '../atom/label'; 6 | import { type InputFocusFeature, inputFocus } from '../atom/inputFocus'; 7 | import { type MultiInputFeature, multiInput } from '../atom/multiInput'; 8 | 9 | type MultiSelectFeature = MergedFeature< 10 | T, 11 | [ 12 | AutocompleteLiteFeature, 13 | NonblurToggleFeature, 14 | LabelFeature, 15 | InputFocusFeature, 16 | MultiInputFeature 17 | ] 18 | >; 19 | 20 | const multiSelect = ( 21 | props?: Pick, 'rovingText' | 'closeOnSelect'> 22 | ): MultiSelectFeature => 23 | mergeModules( 24 | autocompleteLite({ ...props, select: true }), 25 | nonblurToggle(), 26 | label(), 27 | inputFocus(), 28 | multiInput() 29 | ); 30 | 31 | export { type MultiSelectFeature, multiSelect }; 32 | -------------------------------------------------------------------------------- /src/features/molecule/multiSelectDropdown.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { mergeModules } from '../../utils/mergeModules'; 3 | import { type MultiInputFeature, multiInput } from '../atom/multiInput'; 4 | import { type DropdownFeature, dropdown } from './dropdown'; 5 | 6 | type MultiSelectDropdownFeature = MergedFeature< 7 | T, 8 | [DropdownFeature, MultiInputFeature] 9 | >; 10 | 11 | const multiSelectDropdown = ( 12 | props?: Pick, 'rovingText' | 'closeOnSelect' | 'toggleRef'> 13 | ): MultiSelectDropdownFeature => mergeModules(dropdown(props), multiInput()); 14 | 15 | export { type MultiSelectDropdownFeature, multiSelectDropdown }; 16 | -------------------------------------------------------------------------------- /src/features/molecule/supercomplete.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { mergeModules } from '../../utils/mergeModules'; 3 | import { type AutocompleteFeature, autocomplete } from './autocomplete'; 4 | import { type AutoInlineFeature, autoInline } from '../atom/autoInline'; 5 | 6 | type SupercompleteFeature = MergedFeature< 7 | T, 8 | [AutocompleteFeature, AutoInlineFeature] 9 | >; 10 | 11 | const supercomplete = ( 12 | props: Pick< 13 | FeatureProps, 14 | 'onRequestItem' | 'select' | 'deselectOnClear' | 'deselectOnChange' | 'closeOnSelect' 15 | > 16 | ): SupercompleteFeature => 17 | mergeModules(autocomplete({ ...props, rovingText: true }), autoInline(props)); 18 | 19 | export { type SupercompleteFeature, supercomplete }; 20 | -------------------------------------------------------------------------------- /src/hooks/useAutocomplete.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react'; 2 | import { useId } from './useId'; 3 | import { defaultFocusIndex } from '../common'; 4 | import type { AutocompleteProps, AutocompleteReturn } from '../types'; 5 | 6 | const useAutocomplete = ({ 7 | onChange, 8 | feature: useFeature, 9 | isItemSelected, 10 | inputRef: externalInputRef, 11 | getItemValue, 12 | ...passthrough 13 | }: AutocompleteProps) => { 14 | const internalInputRef = useRef(null); 15 | const [tmpValue, setTmpValue] = useState(); 16 | const [open, setOpen] = useState(false); 17 | const [focusIndex, setFocusIndex] = useState(defaultFocusIndex); 18 | 19 | const state: AutocompleteReturn = { 20 | isItemSelected, 21 | inputRef: externalInputRef || internalInputRef, 22 | focusIndex, 23 | setFocusIndex, 24 | open, 25 | setOpen 26 | }; 27 | 28 | const featureYield = useFeature({ 29 | id: useId(), 30 | tmpValue, 31 | setTmpValue, 32 | onChange: (newValue) => passthrough.value != newValue && onChange?.(newValue), 33 | getItemValue: (item) => 34 | item == null ? '' : getItemValue ? getItemValue(item) : item.toString(), 35 | ...passthrough, 36 | ...state 37 | }); 38 | 39 | return { 40 | ...state, 41 | ...featureYield 42 | }; 43 | }; 44 | 45 | export { useAutocomplete }; 46 | -------------------------------------------------------------------------------- /src/hooks/useCombobox.ts: -------------------------------------------------------------------------------- 1 | import type { ComboboxProps } from '../types'; 2 | import { defaultEqual } from '../common'; 3 | import { useAutocomplete } from './useAutocomplete'; 4 | 5 | const useCombobox = ({ 6 | isEqual = defaultEqual, 7 | selected, 8 | onSelectChange, 9 | flipOnSelect, 10 | ...passthrough 11 | }: ComboboxProps) => 12 | useAutocomplete({ 13 | ...passthrough, 14 | selected, 15 | isEqual, 16 | isItemSelected: (item) => isEqual(item, selected), 17 | onSelectChange: (newItem) => { 18 | if (!isEqual(newItem, selected)) { 19 | onSelectChange?.(newItem); 20 | } else if (flipOnSelect) { 21 | onSelectChange?.(); 22 | } 23 | } 24 | }); 25 | 26 | export { useCombobox }; 27 | -------------------------------------------------------------------------------- /src/hooks/useFocusCapture.ts: -------------------------------------------------------------------------------- 1 | import { useMutableState } from './useMutableState'; 2 | 3 | interface MutableState { 4 | /** 5 | * ### INTERNAL API ### 6 | * Whether to capture focus 7 | */ 8 | a?: boolean | 0 | 1; 9 | } 10 | 11 | const useFocusCapture = (focusRef: React.RefObject) => { 12 | const mutable = useMutableState({}); 13 | 14 | return [ 15 | () => { 16 | mutable.a = 1; 17 | }, 18 | () => { 19 | if (mutable.a) { 20 | mutable.a = 0; 21 | focusRef.current?.focus(); 22 | return true; 23 | } 24 | }, 25 | () => { 26 | mutable.a = 0; 27 | focusRef.current?.focus(); 28 | } 29 | ] as const; 30 | }; 31 | 32 | export { useFocusCapture }; 33 | -------------------------------------------------------------------------------- /src/hooks/useId.ts: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | let current = 0; 4 | 5 | const useIdShim = () => { 6 | const [id, setId] = useState(); 7 | useEffect(() => setId(++current), []); 8 | return id && `szh-ac${id}-`; 9 | }; 10 | 11 | const useId = (React.useId || useIdShim) as () => string | undefined; 12 | 13 | export { useId }; 14 | -------------------------------------------------------------------------------- /src/hooks/useMultiSelect.ts: -------------------------------------------------------------------------------- 1 | import type { MultiSelectProps, AdapterProps } from '../types'; 2 | import { defaultEqual } from '../common'; 3 | import { useAutocomplete } from './useAutocomplete'; 4 | 5 | const useMultiSelect = ({ 6 | isEqual = defaultEqual, 7 | selected, 8 | onSelectChange, 9 | flipOnSelect, 10 | ...passthrough 11 | }: MultiSelectProps) => { 12 | const removeItem = (itemToRemove: T) => 13 | onSelectChange?.(selected.filter((item) => !isEqual(itemToRemove, item))); 14 | 15 | const removeSelect: AdapterProps['removeSelect'] = (item) => { 16 | if (item) { 17 | removeItem(item); 18 | } else { 19 | selected.length && onSelectChange?.(selected.slice(0, selected.length - 1)); 20 | } 21 | }; 22 | 23 | const isItemSelected = (item: T) => selected.findIndex((s) => isEqual(item, s)) >= 0; 24 | 25 | return { 26 | ...useAutocomplete({ 27 | ...passthrough, 28 | selected, 29 | isEqual, 30 | isItemSelected, 31 | onSelectChange: (newItem) => { 32 | if (!newItem) return; 33 | 34 | if (!isItemSelected(newItem)) { 35 | onSelectChange?.([...selected, newItem]); 36 | } else if (flipOnSelect) { 37 | removeItem(newItem); 38 | } 39 | }, 40 | removeSelect 41 | }), 42 | removeSelect 43 | }; 44 | }; 45 | 46 | export { useMultiSelect }; 47 | -------------------------------------------------------------------------------- /src/hooks/useMutableState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useMutableState = (stateContainer: S) => useState(stateContainer)[0]; 4 | 5 | export { useMutableState }; 6 | -------------------------------------------------------------------------------- /src/hooks/useToggle.ts: -------------------------------------------------------------------------------- 1 | import { useMutableState } from './useMutableState'; 2 | 3 | interface MutableState { 4 | /** 5 | * ### INTERNAL API ### 6 | * Whether to capture focus 7 | */ 8 | a?: boolean | 0 | 1; 9 | } 10 | 11 | const useToggle = (open: boolean, setOpen: (value: boolean) => void) => { 12 | const mutable = useMutableState({}); 13 | 14 | return [ 15 | () => (mutable.a = open), 16 | () => { 17 | if (mutable.a) { 18 | mutable.a = 0; 19 | } else { 20 | setOpen(true); 21 | } 22 | } 23 | ] as const; 24 | }; 25 | 26 | export { useToggle }; 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useCombobox } from './hooks/useCombobox'; 2 | export { useMultiSelect } from './hooks/useMultiSelect'; 3 | export { 4 | type AutocompleteLiteFeature, 5 | autocompleteLite 6 | } from './features/atom/autocompleteLite'; 7 | export { type AutocompleteFeature, autocomplete } from './features/molecule/autocomplete'; 8 | export { type DropdownFeature, dropdown } from './features/molecule/dropdown'; 9 | export { type MultiSelectFeature, multiSelect } from './features/molecule/multiSelect'; 10 | export { 11 | type MultiSelectDropdownFeature, 12 | multiSelectDropdown 13 | } from './features/molecule/multiSelectDropdown'; 14 | export { type SupercompleteFeature, supercomplete } from './features/molecule/supercomplete'; 15 | export { mergeGroupedItems } from './utils/mergeGroupedItems'; 16 | export { mergeModules } from './utils/mergeModules'; 17 | export type { 18 | ComboboxProps, 19 | MultiSelectProps, 20 | Feature, 21 | MergedFeature, 22 | FeatureProps 23 | } from './types'; 24 | -------------------------------------------------------------------------------- /src/utils/mergeGroupedItems.ts: -------------------------------------------------------------------------------- 1 | export interface GroupedItemsProps { 2 | groups: G[] | { [s: string]: T[] } | ArrayLike; 3 | getItemsInGroup?: (group: G) => T[]; 4 | } 5 | 6 | const isArray = Array.isArray; 7 | 8 | const mergeGroupedItems = ({ 9 | groups, 10 | getItemsInGroup 11 | }: GroupedItemsProps): T[] => { 12 | const groupArray = isArray(groups) ? groups : Object.values(groups); 13 | return groupArray.reduce( 14 | (accu, group) => 15 | accu.concat(isArray(group) ? group : getItemsInGroup ? getItemsInGroup(group) : []), 16 | [] 17 | ); 18 | }; 19 | 20 | export { mergeGroupedItems }; 21 | -------------------------------------------------------------------------------- /src/utils/mergeModules.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | /* eslint-disable */ 3 | 4 | import { mergeObjects } from './mergeObjects'; 5 | 6 | const mergeModules = 7 | (...modules) => 8 | (cx) => 9 | modules.reduce((accu, curr) => mergeObjects(accu, curr(cx)), {}); 10 | 11 | export { mergeModules }; 12 | -------------------------------------------------------------------------------- /src/utils/mergeObjects.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | /* eslint-disable */ 3 | 4 | const mergeObjects = (obj1: T1, obj2: T2): T1 & T2 => { 5 | const merged = { ...obj1 }; 6 | 7 | Object.entries(obj2).forEach(([key, prop2]) => { 8 | if (typeof prop2 === 'function') { 9 | const prop1 = obj1[key]; 10 | merged[key] = prop1 11 | ? (...args) => { 12 | const result1 = prop1(...args); 13 | const result2 = prop2(...args); 14 | if (typeof result1 === 'object') { 15 | return mergeObjects(result1, result2); 16 | } 17 | } 18 | : prop2; 19 | } else { 20 | merged[key] = prop2; 21 | } 22 | }); 23 | 24 | return merged; 25 | }; 26 | 27 | export { mergeObjects }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDeclarationOnly": true, 4 | "declaration": true, 5 | "declarationDir": "./types", 6 | "jsx": "react-jsx", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true 12 | }, 13 | "include": ["./src/"] 14 | } 15 | -------------------------------------------------------------------------------- /types/common.d.ts: -------------------------------------------------------------------------------- 1 | export declare const defaultFocusIndex = -1; 2 | export declare const defaultEqual: (itemA: T | undefined, itemB: T | undefined) => boolean; 3 | export declare const getId: (prefix: string | undefined, suffix: "l" | "i" | "a" | number) => string | undefined; 4 | export declare const buttonProps: React.ButtonHTMLAttributes; 5 | export declare const getInputToggleProps: (id: string | undefined, open: boolean) => React.ButtonHTMLAttributes; 6 | -------------------------------------------------------------------------------- /types/features/atom/autoFocus.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps, FeatureProps } from '../../types'; 2 | type AutoFocusFeature = Feature, 'getInputProps'>>; 3 | declare const autoFocus: ({ onRequestItem }: Pick, "onRequestItem">) => AutoFocusFeature; 4 | export { type AutoFocusFeature, autoFocus }; 5 | -------------------------------------------------------------------------------- /types/features/atom/autoInline.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps, FeatureProps } from '../../types'; 2 | type AutoInlineFeature = Feature, 'getInputProps'>>; 3 | declare const autoInline: ({ onRequestItem }: Pick, "onRequestItem">) => AutoInlineFeature; 4 | export { type AutoInlineFeature, autoInline }; 5 | -------------------------------------------------------------------------------- /types/features/atom/autocompleteLite.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps, AutocompleteFeatureProps, FeatureState } from '../../types'; 2 | type AutocompleteLiteFeature = Feature, 'getInputProps' | 'getListProps' | 'getItemProps' | 'getClearProps' | 'getFocusCaptureProps'> & FeatureState>; 3 | declare const autocompleteLite: ({ select, rovingText, deselectOnClear, deselectOnChange, closeOnSelect }?: AutocompleteFeatureProps) => AutocompleteLiteFeature; 4 | export { type AutocompleteLiteFeature, autocompleteLite }; 5 | -------------------------------------------------------------------------------- /types/features/atom/dropdownToggle.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, FeatureProps, GetProps, FeatureState } from '../../types'; 2 | type DropdownToggleFeature = Feature, 'getToggleProps' | 'getInputProps'> & FeatureState & { 3 | toggleRef: React.RefObject; 4 | }>; 5 | declare const dropdownToggle: ({ closeOnSelect, toggleRef: externalToggleRef }?: Pick, "closeOnSelect" | "toggleRef">) => DropdownToggleFeature; 6 | export { type DropdownToggleFeature, dropdownToggle }; 7 | -------------------------------------------------------------------------------- /types/features/atom/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './autocompleteLite'; 2 | export * from './autoFocus'; 3 | export * from './autoInline'; 4 | export * from './dropdownToggle'; 5 | export * from './inputFocus'; 6 | export * from './inputToggle'; 7 | export * from './label'; 8 | export * from './multiInput'; 9 | export * from './nonblurToggle'; 10 | -------------------------------------------------------------------------------- /types/features/atom/inputFocus.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | type InputFocusFeature = Feature, 'getInputProps'> & { 3 | focused: boolean; 4 | }>; 5 | declare const inputFocus: () => InputFocusFeature; 6 | export { type InputFocusFeature, inputFocus }; 7 | -------------------------------------------------------------------------------- /types/features/atom/inputToggle.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | type InputToggleFeature = Feature, 'getToggleProps' | 'getInputProps'>>; 3 | declare const inputToggle: () => InputToggleFeature; 4 | export { type InputToggleFeature, inputToggle }; 5 | -------------------------------------------------------------------------------- /types/features/atom/label.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | type LabelFeature = Feature, 'getLabelProps' | 'getInputProps' | 'getListProps'>>; 3 | declare const label: () => LabelFeature; 4 | export { type LabelFeature, label }; 5 | -------------------------------------------------------------------------------- /types/features/atom/multiInput.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | type MultiInputFeature = Feature, 'getInputProps'>>; 3 | declare const multiInput: () => MultiInputFeature; 4 | export { type MultiInputFeature, multiInput }; 5 | -------------------------------------------------------------------------------- /types/features/atom/nonblurToggle.d.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, GetProps } from '../../types'; 2 | type NonblurToggleFeature = Feature, 'getToggleProps'>>; 3 | declare const nonblurToggle: () => NonblurToggleFeature; 4 | export { type NonblurToggleFeature, nonblurToggle }; 5 | -------------------------------------------------------------------------------- /types/features/molecule/autocomplete.d.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, AutocompleteFeatureProps } from '../../types'; 2 | import { type AutocompleteLiteFeature } from '../atom/autocompleteLite'; 3 | import { type InputToggleFeature } from '../atom/inputToggle'; 4 | import { type LabelFeature } from '../atom/label'; 5 | type AutocompleteFeature = MergedFeature, 7 | InputToggleFeature, 8 | LabelFeature 9 | ]>; 10 | declare const autocomplete: (props?: AutocompleteFeatureProps) => AutocompleteFeature; 11 | export { type AutocompleteFeature, autocomplete }; 12 | -------------------------------------------------------------------------------- /types/features/molecule/dropdown.d.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { type AutocompleteLiteFeature } from '../atom/autocompleteLite'; 3 | import { type DropdownToggleFeature } from '../atom/dropdownToggle'; 4 | type DropdownFeature = MergedFeature, 6 | DropdownToggleFeature 7 | ]>; 8 | declare const dropdown: (props?: Pick, "rovingText" | "closeOnSelect" | "toggleRef">) => DropdownFeature; 9 | export { type DropdownFeature, dropdown }; 10 | -------------------------------------------------------------------------------- /types/features/molecule/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './autocomplete'; 2 | export * from './dropdown'; 3 | export * from './multiSelect'; 4 | export * from './multiSelectDropdown'; 5 | export * from './supercomplete'; 6 | -------------------------------------------------------------------------------- /types/features/molecule/multiSelect.d.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { type AutocompleteLiteFeature } from '../atom/autocompleteLite'; 3 | import { type NonblurToggleFeature } from '../atom/nonblurToggle'; 4 | import { type LabelFeature } from '../atom/label'; 5 | import { type InputFocusFeature } from '../atom/inputFocus'; 6 | import { type MultiInputFeature } from '../atom/multiInput'; 7 | type MultiSelectFeature = MergedFeature, 9 | NonblurToggleFeature, 10 | LabelFeature, 11 | InputFocusFeature, 12 | MultiInputFeature 13 | ]>; 14 | declare const multiSelect: (props?: Pick, "rovingText" | "closeOnSelect">) => MultiSelectFeature; 15 | export { type MultiSelectFeature, multiSelect }; 16 | -------------------------------------------------------------------------------- /types/features/molecule/multiSelectDropdown.d.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { type MultiInputFeature } from '../atom/multiInput'; 3 | import { type DropdownFeature } from './dropdown'; 4 | type MultiSelectDropdownFeature = MergedFeature, 6 | MultiInputFeature 7 | ]>; 8 | declare const multiSelectDropdown: (props?: Pick, "rovingText" | "closeOnSelect" | "toggleRef">) => MultiSelectDropdownFeature; 9 | export { type MultiSelectDropdownFeature, multiSelectDropdown }; 10 | -------------------------------------------------------------------------------- /types/features/molecule/supercomplete.d.ts: -------------------------------------------------------------------------------- 1 | import type { MergedFeature, FeatureProps } from '../../types'; 2 | import { type AutocompleteFeature } from './autocomplete'; 3 | import { type AutoInlineFeature } from '../atom/autoInline'; 4 | type SupercompleteFeature = MergedFeature, 6 | AutoInlineFeature 7 | ]>; 8 | declare const supercomplete: (props: Pick, "onRequestItem" | "select" | "deselectOnClear" | "deselectOnChange" | "closeOnSelect">) => SupercompleteFeature; 9 | export { type SupercompleteFeature, supercomplete }; 10 | -------------------------------------------------------------------------------- /types/hooks/useAutocomplete.d.ts: -------------------------------------------------------------------------------- 1 | import type { AutocompleteProps } from '../types'; 2 | declare const useAutocomplete: ({ onChange, feature: useFeature, isItemSelected, inputRef: externalInputRef, getItemValue, ...passthrough }: AutocompleteProps) => { 3 | inputRef: React.RefObject; 4 | focusIndex: number; 5 | setFocusIndex: (index: number) => void; 6 | open: boolean; 7 | setOpen: (value: boolean) => void; 8 | isItemSelected: (item: T) => boolean; 9 | } & FeatureYield; 10 | export { useAutocomplete }; 11 | -------------------------------------------------------------------------------- /types/hooks/useCombobox.d.ts: -------------------------------------------------------------------------------- 1 | import type { ComboboxProps } from '../types'; 2 | declare const useCombobox: ({ isEqual, selected, onSelectChange, flipOnSelect, ...passthrough }: ComboboxProps) => { 3 | inputRef: React.RefObject; 4 | focusIndex: number; 5 | setFocusIndex: (index: number) => void; 6 | open: boolean; 7 | setOpen: (value: boolean) => void; 8 | isItemSelected: (item: T) => boolean; 9 | } & FeatureYield; 10 | export { useCombobox }; 11 | -------------------------------------------------------------------------------- /types/hooks/useFocusCapture.d.ts: -------------------------------------------------------------------------------- 1 | declare const useFocusCapture: (focusRef: React.RefObject) => readonly [() => void, () => true | undefined, () => void]; 2 | export { useFocusCapture }; 3 | -------------------------------------------------------------------------------- /types/hooks/useId.d.ts: -------------------------------------------------------------------------------- 1 | declare const useId: () => string | undefined; 2 | export { useId }; 3 | -------------------------------------------------------------------------------- /types/hooks/useMultiSelect.d.ts: -------------------------------------------------------------------------------- 1 | import type { MultiSelectProps } from '../types'; 2 | declare const useMultiSelect: ({ isEqual, selected, onSelectChange, flipOnSelect, ...passthrough }: MultiSelectProps) => { 3 | inputRef: React.RefObject; 4 | focusIndex: number; 5 | setFocusIndex: (index: number) => void; 6 | open: boolean; 7 | setOpen: (value: boolean) => void; 8 | isItemSelected: (item: T) => boolean; 9 | } & FeatureYield & { 10 | removeSelect: (item?: T | undefined) => void; 11 | }; 12 | export { useMultiSelect }; 13 | -------------------------------------------------------------------------------- /types/hooks/useMutableState.d.ts: -------------------------------------------------------------------------------- 1 | declare const useMutableState: (stateContainer: S) => S; 2 | export { useMutableState }; 3 | -------------------------------------------------------------------------------- /types/hooks/useToggle.d.ts: -------------------------------------------------------------------------------- 1 | declare const useToggle: (open: boolean, setOpen: (value: boolean) => void) => readonly [() => boolean, () => void]; 2 | export { useToggle }; 3 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export { useCombobox } from './hooks/useCombobox'; 2 | export { useMultiSelect } from './hooks/useMultiSelect'; 3 | export { type AutocompleteLiteFeature, autocompleteLite } from './features/atom/autocompleteLite'; 4 | export { type AutocompleteFeature, autocomplete } from './features/molecule/autocomplete'; 5 | export { type DropdownFeature, dropdown } from './features/molecule/dropdown'; 6 | export { type MultiSelectFeature, multiSelect } from './features/molecule/multiSelect'; 7 | export { type MultiSelectDropdownFeature, multiSelectDropdown } from './features/molecule/multiSelectDropdown'; 8 | export { type SupercompleteFeature, supercomplete } from './features/molecule/supercomplete'; 9 | export { mergeGroupedItems } from './utils/mergeGroupedItems'; 10 | export { mergeModules } from './utils/mergeModules'; 11 | export type { ComboboxProps, MultiSelectProps, Feature, MergedFeature, FeatureProps } from './types'; 12 | -------------------------------------------------------------------------------- /types/utils/mergeGroupedItems.d.ts: -------------------------------------------------------------------------------- 1 | export interface GroupedItemsProps { 2 | groups: G[] | { 3 | [s: string]: T[]; 4 | } | ArrayLike; 5 | getItemsInGroup?: (group: G) => T[]; 6 | } 7 | declare const mergeGroupedItems: ({ groups, getItemsInGroup }: GroupedItemsProps) => T[]; 8 | export { mergeGroupedItems }; 9 | -------------------------------------------------------------------------------- /types/utils/mergeModules.d.ts: -------------------------------------------------------------------------------- 1 | declare const mergeModules: (...modules: any[]) => (cx: any) => any; 2 | export { mergeModules }; 3 | -------------------------------------------------------------------------------- /types/utils/mergeObjects.d.ts: -------------------------------------------------------------------------------- 1 | declare const mergeObjects: (obj1: T1, obj2: T2) => T1 & T2; 2 | export { mergeObjects }; 3 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ npm install 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ npm start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ npm run build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')] 3 | }; 4 | -------------------------------------------------------------------------------- /website/docs/docs/design.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Design Concept 6 | 7 | The API consists of a main React hook and a feature that work together under a defined contract. 8 | 9 | ### Main hook 10 | 11 | **useCombobox/useMultiSelect** - acts as the primary entry point, utilizing a classic headless React hook style API. It manages state and data, and must connect with a _feature_ to deliver the required functionalities. 12 | 13 | ### Feature (A replaceable module) 14 | 15 | A feature implements the desired functionalities (behavior), such as `autocomplete` or `multiSelect`. There are two types of features: 16 | 17 | - **[Atom](https://github.com/szhsin/react-autocomplete/tree/master/src/features/atom)**: A minimal, indivisible unit that can function independently or be combined into larger features. 18 | - **[Molecule](https://github.com/szhsin/react-autocomplete/tree/master/src/features/molecule)**: Composed of two or more atoms or other molecules, providing more advanced features. 19 | 20 | One advantage of this architecture is you can easily combine any number of atoms or molecules to create the feature you need, as long as the resulting feature conforms to the same contract. 21 | -------------------------------------------------------------------------------- /website/docs/docs/extras/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 5, 3 | "label": "Extras", 4 | "collapsed": false, 5 | "collapsible": false 6 | } 7 | -------------------------------------------------------------------------------- /website/docs/docs/extras/action-items.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/ActionItems/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/fruits'; 11 | import { ActionItems } from '@site/src/components/ActionItems'; 12 | 13 | # Action items 14 | 15 | You can specify items in the list as actions and define what happens after they are triggered. This is useful for extending functionality, such as building a **'creatable'** autocomplete feature. 16 | 17 | 18 | 19 | 20 | 21 | {sourceCode} 22 | 23 | 24 | {data} 25 | 26 | 27 | -------------------------------------------------------------------------------- /website/docs/docs/extras/async.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/AsyncExample/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states'; 11 | import { AsyncExample } from '@site/src/components/AsyncExample'; 12 | 13 | # Async example 14 | 15 | When the list of items is fetched asynchronously, instead of computing them directly in the render body, the `items` should be stored in a local state, a global state store, or managed by a data-fetching library like React Query. 16 | 17 | 18 | 19 | 20 | 21 | {sourceCode} 22 | 23 | 24 | {data} 25 | 26 | 27 | -------------------------------------------------------------------------------- /website/docs/docs/extras/disabled-items.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/DisabledItems/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/fruits'; 11 | import { DisabledItems } from '@site/src/components/DisabledItems'; 12 | 13 | # Disabled items 14 | 15 | Items can be disabled, preventing them from being interacted with or selected. 16 | 17 | 18 | 19 | 20 | 21 | {sourceCode} 22 | 23 | 24 | {data} 25 | 26 | 27 | -------------------------------------------------------------------------------- /website/docs/docs/extras/floating-ui.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import codeAutocomplete from '!!raw-loader!@site/src/components/FloatingUI/CodeAutocomplete'; 10 | import codeDropdown from '!!raw-loader!@site/src/components/FloatingUI/CodeDropdown'; 11 | import data from '!!raw-loader!@site/src/data/states'; 12 | import { FloatingUI } from '@site/src/components/FloatingUI'; 13 | 14 | # Floating UI 15 | 16 | The API lets you easily integrate with a positioning library like [Floating UI](https://floating-ui.com/docs/useFloating), so you can fine-tune the position of the autocomplete list. 17 | 18 | Below are two examples of integration: one for **autocomplete** and one for a **dropdown**. 19 | 20 | :::info 21 | 22 | These examples are set up to make the list take up as much available height as possible within the viewport. Try opening the list and scrolling the page up and down to observe how the list always perfectly fits within the page. 23 | 24 | ::: 25 | 26 | 27 | 28 | 29 | 30 | {codeAutocomplete} 31 | 32 | 33 | {codeDropdown} 34 | 35 | 36 | {data} 37 | 38 | 39 | -------------------------------------------------------------------------------- /website/docs/docs/extras/grouped.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/Grouped/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states-grouped'; 11 | import { Grouped } from '@site/src/components/Grouped'; 12 | 13 | # Grouped items 14 | 15 | 16 | 17 | 18 | 19 | {sourceCode} 20 | 21 | 22 | {data} 23 | 24 | 25 | -------------------------------------------------------------------------------- /website/docs/docs/extras/object-items.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/ObjectItems/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states-obj'; 11 | import { ObjectItems } from '@site/src/components/ObjectItems'; 12 | 13 | # Object items 14 | 15 | List items can be not only strings but also objects. 16 | 17 | 18 | 19 | 20 | 21 | {sourceCode} 22 | 23 | 24 | {data} 25 | 26 | 27 | -------------------------------------------------------------------------------- /website/docs/docs/extras/select-only.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | sidebar_label: Select-Only 4 | --- 5 | 6 | import Tabs from '@theme/Tabs'; 7 | import TabItem from '@theme/TabItem'; 8 | import CodeBlock from '@theme/CodeBlock'; 9 | 10 | import sourceCode from '!!raw-loader!@site/src/components/SelectOnly/CodeBlock'; 11 | import data from '!!raw-loader!@site/src/data/fruits'; 12 | import { SelectOnly } from '@site/src/components/SelectOnly'; 13 | 14 | # Select-Only Combobox 15 | 16 | This is not a separate feature, but it can be easily achieved by by making the input read-only. 17 | 18 | 19 | 20 | 21 | 22 | {sourceCode} 23 | 24 | 25 | {data} 26 | 27 | 28 | -------------------------------------------------------------------------------- /website/docs/docs/extras/virtualization.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | import CodeBlock from '@theme/CodeBlock'; 6 | 7 | import sourceCode from '!!raw-loader!@site/src/components/Virtualization/CodeBlock'; 8 | import { Virtualization } from '@site/src/components/Virtualization'; 9 | 10 | # Virtualization 11 | 12 | Here’s an example of integrating [@tanstack/react-virtual](https://tanstack.com/virtual/latest/docs/introduction) to virtualize an autocomplete list with 10,000 items. 13 | 14 | 15 | 16 | {sourceCode} 17 | -------------------------------------------------------------------------------- /website/docs/docs/features/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "position": 4, 3 | "label": "Features", 4 | "collapsed": false, 5 | "collapsible": false 6 | } 7 | -------------------------------------------------------------------------------- /website/docs/docs/features/autocomplete.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/Autocomplete/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states'; 11 | import { Autocomplete } from '@site/src/components/Autocomplete'; 12 | 13 | # Autocomplete 14 | 15 | The `autocomplete` feature supports two modes, each offering a few available options. 16 | 17 | 18 | 19 | 20 | 21 | {sourceCode} 22 | 23 | 24 | {data} 25 | 26 | 31 | CodeSandbox 32 | 33 | 34 | 35 | :::info 36 | 37 | When comparing `autocomplete` to `autocompleteLite`, the former adds support for the label and input toggle. If you don't need these features, you can simply use `autocompleteLite`, which is more lightweight. 38 | 39 | ::: 40 | -------------------------------------------------------------------------------- /website/docs/docs/features/dropdown.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/Dropdown/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states'; 11 | import { Dropdown } from '@site/src/components/Dropdown'; 12 | 13 | # Dropdown 14 | 15 | 16 | 17 | 18 | 19 | {sourceCode} 20 | 21 | 22 | {data} 23 | 24 | 29 | CodeSandbox 30 | 31 | 32 | -------------------------------------------------------------------------------- /website/docs/docs/features/multiSelect.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/MultiSelect/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states'; 11 | import { MultiSelect } from '@site/src/components/MultiSelect'; 12 | 13 | # Multiple Selection 14 | 15 | 16 | 17 | 18 | 19 | {sourceCode} 20 | 21 | 22 | {data} 23 | 24 | 29 | CodeSandbox 30 | 31 | 32 | -------------------------------------------------------------------------------- /website/docs/docs/features/multiSelectDropdown.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | sidebar_label: Multi-select Dropdown 4 | --- 5 | 6 | import Tabs from '@theme/Tabs'; 7 | import TabItem from '@theme/TabItem'; 8 | import CodeBlock from '@theme/CodeBlock'; 9 | 10 | import sourceCode from '!!raw-loader!@site/src/components/MultiSelectDropdown/CodeBlock'; 11 | import data from '!!raw-loader!@site/src/data/states'; 12 | import { MultiSelectDropdown } from '@site/src/components/MultiSelectDropdown'; 13 | 14 | # Multiple Selection in Dropdown 15 | 16 | 17 | 18 | 19 | 20 | {sourceCode} 21 | 22 | 23 | {data} 24 | 25 | 30 | CodeSandbox 31 | 32 | 33 | -------------------------------------------------------------------------------- /website/docs/docs/features/supercomplete.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | import CodeBlock from '@theme/CodeBlock'; 8 | 9 | import sourceCode from '!!raw-loader!@site/src/components/Supercomplete/CodeBlock'; 10 | import data from '!!raw-loader!@site/src/data/states'; 11 | import { Autocomplete } from '@site/src/components/Autocomplete'; 12 | 13 | # Supercomplete 14 | 15 | The `supercomplete` feature supports everything provided by `autocomplete`, along with [inline text completion](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-both/). 16 | 17 | 18 | 19 | 20 | 21 | {sourceCode} 22 | 23 | 24 | {data} 25 | 26 | 31 | CodeSandbox 32 | 33 | 34 | -------------------------------------------------------------------------------- /website/docs/docs/install.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | import Tabs from '@theme/Tabs'; 6 | import TabItem from '@theme/TabItem'; 7 | 8 | # Install 9 | 10 | 11 | 12 | 13 | ```bash 14 | npm install @szhsin/react-autocomplete 15 | ``` 16 | 17 | 18 | 19 | 20 | ```bash 21 | yarn add @szhsin/react-autocomplete 22 | ``` 23 | 24 | 25 | 26 | 27 | ```bash 28 | pnpm add @szhsin/react-autocomplete 29 | ``` 30 | 31 | 32 | 33 | 34 | ```bash 35 | bun add @szhsin/react-autocomplete 36 | ``` 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /website/docs/docs/intro.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | import { Intro } from '@site/src/components/Intro'; 6 | import BundleLink from '@site/src/components/BundleLink/AutocompleteLite'; 7 | 8 | # Getting Started 9 | 10 | ### What's the problem? 11 | 12 | - You require an autocomplete/select/search feature for your website, and you want it to be accessible. 13 | - You begin by building one from scratch, but quickly realize that the implementation is not trivial. 14 | - While searching for open-source alternatives, you find that many have significant bundle sizes, typically ranging from **10kB to 50kB**[^1], and some do not support tree shaking. 15 | - Furthermore, these options often do not offer the level of customization you need. 16 | 17 | ### What makes this solution unique? 18 | 19 | - **Modular**: We carefully design the API with a modular approach, providing a no-frills solution that allows you to bundle only the code you need for your website. _No more and no less!_ 20 | 21 | - **Lightweight**: At just [^2], you get a fully functional and accessible autocomplete solution in React. It's almost negligible in size and likely lighter than creating one from scratch, so you can adopt it without hesitation. 22 | 23 | - **Customizable**: Thanks to the modular design, you can easily customize existing features or even create your own feature (a plugin-style module) to enhance the solution. 24 | 25 | ### Sounds promising! How does it look? 26 | 27 | Here’s a live example of the accessible React autocomplete: 28 | 29 | 30 | 31 | [^1]: Referring to traditional solutions such as [react-select](https://bundlephobia.com/package/react-select) and [downshift](https://bundlephobia.com/package/downshift). 32 | 33 | [^2]: Using the `autocompleteLite` feature. 34 | -------------------------------------------------------------------------------- /website/docusaurus.config.ts: -------------------------------------------------------------------------------- 1 | import { themes as prismThemes } from 'prism-react-renderer'; 2 | import type { Config } from '@docusaurus/types'; 3 | import type * as Preset from '@docusaurus/preset-classic'; 4 | 5 | const config: Config = { 6 | title: 'React Autocomplete', 7 | tagline: 'A modular, lightweight, and headless solution', 8 | favicon: 'img/favicon.ico', 9 | url: 'https://szhsin.github.io', 10 | baseUrl: '/react-autocomplete/', 11 | onBrokenLinks: 'throw', 12 | onBrokenMarkdownLinks: 'warn', 13 | 14 | // GitHub pages deployment config. 15 | // If you aren't using GitHub pages, you don't need these. 16 | organizationName: 'szhsin', // Usually your GitHub org/user name. 17 | projectName: 'react-autocomplete', // Usually your repo name. 18 | 19 | // Even if you don't use internationalization, you can use this field to set 20 | // useful metadata like html lang. For example, if your site is Chinese, you 21 | // may want to replace "en" with "zh-Hans". 22 | i18n: { 23 | defaultLocale: 'en', 24 | locales: ['en'] 25 | }, 26 | 27 | presets: [ 28 | [ 29 | 'classic', 30 | { 31 | docs: { 32 | routeBasePath: '/', 33 | sidebarPath: './sidebars.ts', 34 | // Please change this to your repo. 35 | // Remove this to remove the "edit this page" links. 36 | editUrl: 'https://github.com/szhsin/react-autocomplete/tree/master/website/' 37 | }, 38 | blog: false, 39 | theme: { 40 | customCss: './src/css/custom.css' 41 | } 42 | } satisfies Preset.Options 43 | ] 44 | ], 45 | 46 | themeConfig: { 47 | navbar: { 48 | title: 'React Autocomplete', 49 | items: [ 50 | { 51 | type: 'docSidebar', 52 | sidebarId: 'docsSidebar', 53 | position: 'left', 54 | label: 'Docs' 55 | }, 56 | { 57 | href: 'https://github.com/szhsin/react-autocomplete', 58 | label: 'GitHub', 59 | position: 'right' 60 | } 61 | ] 62 | }, 63 | prism: { 64 | theme: prismThemes.github, 65 | darkTheme: prismThemes.vsDark, 66 | additionalLanguages: ['bash'] 67 | }, 68 | colorMode: { 69 | defaultMode: 'dark' 70 | } 71 | } satisfies Preset.ThemeConfig 72 | }; 73 | 74 | export default config; 75 | -------------------------------------------------------------------------------- /website/gh-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | check_str=$(pwd | grep "/website") 6 | if [ -z "$check_str" ]; then 7 | echo "Not in /website" 8 | exit 1 9 | fi 10 | 11 | npm run clear 12 | npm run build 13 | 14 | tmpdir="$HOME/gh-pages" 15 | rm -Rf "$tmpdir" 16 | mkdir "$tmpdir" 17 | mv build "$tmpdir" 18 | cd .. 19 | 20 | git checkout gh-pages 21 | check_str=$(git branch | grep "*" | grep "gh-pages") 22 | if [ -z "$check_str" ]; then 23 | echo "Not on branch gh-pages" 24 | exit 1 25 | fi 26 | 27 | rm -Rf api/ assets/ category/ docs/ img/ 28 | cp -Rf "$tmpdir/build/" . 29 | git add . 30 | git commit -m "Updates" 31 | rm -Rf "$tmpdir" 32 | echo "Ready to push gh-pages" 33 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.7.0", 19 | "@docusaurus/preset-classic": "3.7.0", 20 | "@floating-ui/react-dom": "^2.1.2", 21 | "@mdx-js/react": "^3.1.0", 22 | "@szhsin/react-autocomplete": "file:..", 23 | "@tanstack/react-virtual": "^3.13.9", 24 | "clsx": "^2.1.1", 25 | "prism-react-renderer": "^2.4.1", 26 | "react": "file:../node_modules/react", 27 | "react-dom": "file:../node_modules/react-dom" 28 | }, 29 | "devDependencies": { 30 | "@docusaurus/module-type-aliases": "3.7.0", 31 | "@docusaurus/tsconfig": "3.7.0", 32 | "@docusaurus/types": "3.7.0", 33 | "raw-loader": "^4.0.2", 34 | "typescript": "file:../node_modules/typescript" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.5%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 3 chrome version", 44 | "last 3 firefox version", 45 | "last 5 safari version" 46 | ] 47 | }, 48 | "engines": { 49 | "node": ">=18.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /website/sidebars.ts: -------------------------------------------------------------------------------- 1 | import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; 2 | 3 | /** 4 | * Creating a sidebar enables you to: 5 | - create an ordered group of docs 6 | - render a sidebar for each doc of that group 7 | - provide next/previous navigation 8 | 9 | The sidebars can be generated from the filesystem, or explicitly defined here. 10 | 11 | Create as many sidebars as you want. 12 | */ 13 | const sidebars: SidebarsConfig = { 14 | // By default, Docusaurus generates a sidebar from the docs folder structure 15 | docsSidebar: [{ type: 'autogenerated', dirName: 'docs' }] 16 | 17 | // But you can create a sidebar manually 18 | /* 19 | tutorialSidebar: [ 20 | 'intro', 21 | 'hello', 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['tutorial-basics/create-a-document'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | export default sidebars; 32 | -------------------------------------------------------------------------------- /website/src/components/ActionItems/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 3 | import FRUITS from '../../data/fruits'; 4 | 5 | // highlight-next-line 6 | type Item = { value: string; creatable?: boolean }; 7 | 8 | const ActionItems = () => { 9 | const [value, setValue] = useState(); 10 | const [selected, setSelected] = useState(); 11 | 12 | const [fruits, setFruit] = useState(FRUITS); 13 | const items: Item[] = fruits 14 | .filter((fruit) => fruit.toLowerCase().includes((value || '').toLowerCase())) 15 | .map((fruit) => ({ value: fruit })); 16 | // highlight-start 17 | // If the value does not exist in the list of items, add a creatable action to the end. 18 | if (value && !items.find((item) => !item.creatable && item.value === value)) { 19 | items.push({ value, creatable: true }); 20 | } 21 | // highlight-end 22 | 23 | const { 24 | getFocusCaptureProps, 25 | getLabelProps, 26 | getInputProps, 27 | getClearProps, 28 | getToggleProps, 29 | getListProps, 30 | getItemProps, 31 | isItemSelected, 32 | open, 33 | focusIndex, 34 | isInputEmpty 35 | } = useCombobox({ 36 | items, 37 | getItemValue: (item) => item.value, 38 | isEqual: (a, b) => a?.value === b?.value, 39 | // highlight-start 40 | // Specify how to determine if an item is an action, 41 | isItemAction: (item) => !!item.creatable, 42 | // and what happens after the action is triggered. 43 | onAction: (item) => { 44 | if (item.creatable) { 45 | setSelected({ value: item.value }); 46 | setFruit([item.value, ...fruits]); 47 | } 48 | }, 49 | // highlight-end 50 | value, 51 | onChange: setValue, 52 | selected, 53 | onSelectChange: setSelected, 54 | feature: autocomplete({ select: true }) 55 | }); 56 | 57 | return ( 58 |
59 | 62 | 63 |
64 | 65 | {!isInputEmpty && } 66 | 67 |
68 | 69 |
    83 | {items.length ? ( 84 | items.map((item, index) => ( 85 |
  • 93 | {/* highlight-next-line */} 94 | {item.creatable ? `Create "${item.value}"` : item.value} 95 |
  • 96 | )) 97 | ) : ( 98 |
  • No options
  • 99 | )} 100 |
101 |
102 | ); 103 | }; 104 | 105 | export default ActionItems; 106 | -------------------------------------------------------------------------------- /website/src/components/AsyncExample/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocompleteLite } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states'; 4 | 5 | // Simulate obtaining data from a remote server 6 | let id = 0; 7 | const fetchData = (value?: string) => { 8 | clearTimeout(id); 9 | if (!value || !value.trim()) return; 10 | 11 | return new Promise((res) => { 12 | id = window.setTimeout( 13 | () => 14 | res(STATES.filter((item) => item.toLowerCase().includes(value.toLowerCase().trim()))), 15 | 500 16 | ); 17 | }); 18 | }; 19 | 20 | const AsyncExample = () => { 21 | const [value, setValue] = useState(); 22 | const [selected, setSelected] = useState(); 23 | const [items, setItems] = useState(); 24 | 25 | const { 26 | getInputProps, 27 | getClearProps, 28 | getListProps, 29 | getItemProps, 30 | open, 31 | focusIndex, 32 | isInputEmpty 33 | } = useCombobox({ 34 | items: items || [], 35 | value, 36 | // highlight-start 37 | // Whenever the user types in the input, fetch data remotely and update the items. 38 | onChange: async (newValue) => { 39 | setValue(newValue); 40 | setItems(await fetchData(newValue)); 41 | }, 42 | // highlight-end 43 | selected, 44 | onSelectChange: setSelected, 45 | feature: autocompleteLite({ 46 | select: true // or false 47 | }) 48 | }); 49 | 50 | return ( 51 |
52 |
53 | 54 | {!isInputEmpty && } 55 |
56 | 57 | {open && items && ( 58 |
    71 | {items.length ? ( 72 | items.map((item, index) => ( 73 |
  • 81 | {item} 82 |
  • 83 | )) 84 | ) : ( 85 |
  • No options
  • 86 | )} 87 |
88 | )} 89 |
90 | ); 91 | }; 92 | 93 | export default AsyncExample; 94 | -------------------------------------------------------------------------------- /website/src/components/Autocomplete/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states'; 4 | 5 | const Autocomplete = () => { 6 | const [value, setValue] = useState(); 7 | const [selected, setSelected] = useState(); 8 | 9 | // It's up to you how to filter items based on the input value 10 | const items = value 11 | ? STATES.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) 12 | : STATES; 13 | 14 | const { 15 | getLabelProps, 16 | getInputProps, 17 | getClearProps, 18 | getToggleProps, 19 | getListProps, 20 | getItemProps, 21 | open, 22 | focusIndex, 23 | isInputEmpty 24 | } = useCombobox({ 25 | items, 26 | value, 27 | onChange: setValue, 28 | selected, 29 | onSelectChange: setSelected, 30 | feature: autocomplete({ 31 | // The `select` option controls autocomplete in free or select mode 32 | // highlight-next-line 33 | select: true // or false 34 | // Other options: rovingText, deselectOnClear, deselectOnChange, closeOnSelect 35 | }) 36 | }); 37 | 38 | return ( 39 |
40 | 41 |
42 | 43 | {!isInputEmpty && } 44 | 45 |
46 | 47 |
    61 | {items.length ? ( 62 | items.map((item, index) => ( 63 |
  • 71 | {item} 72 |
  • 73 | )) 74 | ) : ( 75 |
  • No results
  • 76 | )} 77 |
78 |
79 | ); 80 | }; 81 | 82 | export default Autocomplete; 83 | -------------------------------------------------------------------------------- /website/src/components/Autocomplete/styles.module.css: -------------------------------------------------------------------------------- 1 | .desc { 2 | color: var(--ifm-color-emphasis-800); 3 | } 4 | 5 | .modes { 6 | display: grid; 7 | grid-template-columns: max-content 1fr; 8 | align-items: start; 9 | gap: 16px; 10 | } 11 | 12 | @media (max-width: 400px) { 13 | .modes { 14 | grid-template-columns: 1fr; 15 | gap: 8px; 16 | } 17 | } 18 | 19 | .inputWrap { 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .info { 25 | color: var(--ifm-color-emphasis-700); 26 | line-height: 1; 27 | font-size: 14px; 28 | margin-left: 24px; 29 | } 30 | -------------------------------------------------------------------------------- /website/src/components/BundleLink/AutocompleteLite.tsx: -------------------------------------------------------------------------------- 1 | const Link = () => ( 2 | 7 | 1.4 kB 8 | 9 | ); 10 | 11 | export default Link; 12 | -------------------------------------------------------------------------------- /website/src/components/Checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import Unchecked from '@site/static/img/square.svg'; 3 | import Checked from '@site/static/img/square-check.svg'; 4 | import styles from './styles.module.css'; 5 | 6 | const Checkbox = ({ 7 | label, 8 | checked, 9 | onChange 10 | }: { 11 | label: string; 12 | checked: boolean; 13 | onChange: (value: boolean) => void; 14 | }) => { 15 | return ( 16 | 26 | ); 27 | }; 28 | 29 | export { Checkbox }; 30 | -------------------------------------------------------------------------------- /website/src/components/Checkbox/styles.module.css: -------------------------------------------------------------------------------- 1 | .label { 2 | display: flex; 3 | align-items: center; 4 | cursor: pointer; 5 | font-size: 14px; 6 | } 7 | 8 | .input { 9 | appearance: none; 10 | display: none; 11 | } 12 | 13 | .icon { 14 | margin-right: 4px; 15 | width: 20px; 16 | height: 20px; 17 | } 18 | 19 | .checked { 20 | color: var(--ifm-color-primary); 21 | } 22 | -------------------------------------------------------------------------------- /website/src/components/DisabledItems/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 3 | import FRUITS from '../../data/fruits'; 4 | 5 | // highlight-next-line 6 | const isItemDisabled = (item: string) => item.includes('berry'); 7 | 8 | const DisabledItems = () => { 9 | const [value, setValue] = useState(); 10 | const [selected, setSelected] = useState(); 11 | const items = value 12 | ? FRUITS.filter((item) => item.toLowerCase().includes(value.toLowerCase())) 13 | : FRUITS; 14 | 15 | const { 16 | getFocusCaptureProps, 17 | getLabelProps, 18 | getInputProps, 19 | getClearProps, 20 | getToggleProps, 21 | getListProps, 22 | getItemProps, 23 | open, 24 | focusIndex, 25 | isInputEmpty 26 | } = useCombobox({ 27 | items, 28 | // highlight-next-line 29 | isItemDisabled, 30 | value, 31 | onChange: setValue, 32 | selected, 33 | onSelectChange: setSelected, 34 | feature: autocomplete({ select: true }) 35 | }); 36 | 37 | return ( 38 |
39 | 42 | 43 |
44 | 45 | {!isInputEmpty && } 46 | 47 |
48 | 49 |
    62 | {items.length ? ( 63 | items.map((item, index) => ( 64 |
  • 74 | {item} 75 |
  • 76 | )) 77 | ) : ( 78 |
  • No options
  • 79 | )} 80 |
81 |
82 | ); 83 | }; 84 | 85 | export default DisabledItems; 86 | -------------------------------------------------------------------------------- /website/src/components/DisabledItems/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 4 | import ClearIcon from '@site/static/img/x.svg'; 5 | import ChevronDown from '@site/static/img/chevron-down.svg'; 6 | import ChevronUp from '@site/static/img/chevron-up.svg'; 7 | import FRUITS from '@site/src/data/fruits'; 8 | import styles from '@site/src/css/styles.module.css'; 9 | import { useAutoScroll } from '../../utils/useAutoScroll'; 10 | 11 | const isItemDisabled = (item: string) => item.includes('berry'); 12 | 13 | const DisabledItems = () => { 14 | const [value, setValue] = useState(); 15 | const [selected, setSelected] = useState(); 16 | const items = value 17 | ? FRUITS.filter((item) => item.toLowerCase().includes(value.toLowerCase())) 18 | : FRUITS; 19 | 20 | const { 21 | getFocusCaptureProps, 22 | getLabelProps, 23 | getInputProps, 24 | getClearProps, 25 | getToggleProps, 26 | getListProps, 27 | getItemProps, 28 | open, 29 | focusIndex, 30 | isInputEmpty 31 | } = useCombobox({ 32 | items, 33 | isItemDisabled, 34 | value, 35 | onChange: setValue, 36 | selected, 37 | onSelectChange: setSelected, 38 | feature: autocomplete({ select: true }) 39 | }); 40 | 41 | const listRef = useAutoScroll(open, items); 42 | 43 | return ( 44 |
45 | 48 | 49 |
50 | 51 | {!isInputEmpty && ( 52 | 55 | )} 56 | 59 |
60 | 61 |
    67 | {items.length ? ( 68 | items.map((item, index) => ( 69 |
  • 79 | {item} 80 |
  • 81 | )) 82 | ) : ( 83 |
  • No options
  • 84 | )} 85 |
86 |
87 | ); 88 | }; 89 | 90 | export { DisabledItems }; 91 | -------------------------------------------------------------------------------- /website/src/components/Dropdown/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, dropdown } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states'; 4 | 5 | const Dropdown = () => { 6 | const [value, setValue] = useState(); 7 | const [selected, setSelected] = useState(); 8 | // It's up to you how to filter items based on the input value 9 | const items = value 10 | ? STATES.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) 11 | : STATES; 12 | 13 | const { 14 | getInputProps, 15 | getClearProps, 16 | getToggleProps, 17 | getListProps, 18 | getItemProps, 19 | open, 20 | focusIndex, 21 | isInputEmpty 22 | } = useCombobox({ 23 | // flipOnSelect: true or false, 24 | items, 25 | value, 26 | onChange: setValue, 27 | selected, 28 | onSelectChange: setSelected, 29 | // highlight-next-line 30 | feature: dropdown({ 31 | // Options: rovingText, closeOnSelect 32 | rovingText: true 33 | }) 34 | }); 35 | 36 | return ( 37 |
38 | 39 | 40 | {open && ( 41 |
49 |
50 | 51 | {!isInputEmpty && } 52 |
53 |
    62 | {items.length ? ( 63 | items.map((item, index) => ( 64 |
  • 72 | {item} 73 |
  • 74 | )) 75 | ) : ( 76 |
  • No options
  • 77 | )} 78 |
79 |
80 | )} 81 |
82 | ); 83 | }; 84 | 85 | export default Dropdown; 86 | -------------------------------------------------------------------------------- /website/src/components/Grouped/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useCombobox, autocomplete, mergeGroupedItems } from '@szhsin/react-autocomplete'; 3 | import GROUPED from '../../data/states-grouped'; 4 | 5 | const Grouped = () => { 6 | const [value, setValue] = useState(); 7 | const [selected, setSelected] = useState(); 8 | 9 | // It's up to you how to filter items based on the input value 10 | const groups = value 11 | ? GROUPED.map(({ initial, states }) => ({ 12 | initial, 13 | states: states.filter((item) => item.toLowerCase().includes(value.toLowerCase())) 14 | })).filter((group) => group.states.length > 0) 15 | : GROUPED; 16 | 17 | const { 18 | getFocusCaptureProps, 19 | getLabelProps, 20 | getInputProps, 21 | getClearProps, 22 | getToggleProps, 23 | getListProps, 24 | getItemProps, 25 | open, 26 | focusIndex, 27 | isInputEmpty 28 | } = useCombobox({ 29 | // The main hook always expects a one-dimensional array, 30 | // and we provide a `mergeGroupedItems` utility to merge the groups. 31 | // highlight-next-line 32 | items: mergeGroupedItems({ groups, getItemsInGroup: (group) => group.states }), 33 | value, 34 | onChange: setValue, 35 | selected, 36 | onSelectChange: setSelected, 37 | feature: autocomplete({ select: true }) 38 | }); 39 | 40 | // highlight-next-line 41 | let itemIndex = -1; 42 | 43 | return ( 44 |
45 | 48 | 49 |
50 | 51 | {!isInputEmpty && } 52 | 53 |
54 | 55 |
    69 | {groups.length ? ( 70 | groups.map(({ initial, states }) => ( 71 | 72 |
  • {initial}
  • 73 | {states.map((item) => { 74 | // `itemIndex` should be the index within the flattened array of items. 75 | // highlight-next-line 76 | itemIndex++; 77 | return ( 78 |
  • 86 | {item} 87 |
  • 88 | ); 89 | })} 90 |
    91 | )) 92 | ) : ( 93 |
  • No options
  • 94 | )} 95 |
96 |
97 | ); 98 | }; 99 | 100 | export default Grouped; 101 | -------------------------------------------------------------------------------- /website/src/components/Grouped/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { useCombobox, autocomplete, mergeGroupedItems } from '@szhsin/react-autocomplete'; 4 | import ClearIcon from '@site/static/img/x.svg'; 5 | import ChevronDown from '@site/static/img/chevron-down.svg'; 6 | import ChevronUp from '@site/static/img/chevron-up.svg'; 7 | import GROUPED from '@site/src/data/states-grouped'; 8 | import styles from '@site/src/css/styles.module.css'; 9 | 10 | const Grouped = () => { 11 | const [value, setValue] = useState(); 12 | const [selected, setSelected] = useState(); 13 | const groups = value 14 | ? GROUPED.map(({ initial, states }) => ({ 15 | initial, 16 | states: states.filter((item) => item.toLowerCase().includes(value.toLowerCase())) 17 | })).filter((group) => group.states.length > 0) 18 | : GROUPED; 19 | 20 | const { 21 | getFocusCaptureProps, 22 | getLabelProps, 23 | getInputProps, 24 | getClearProps, 25 | getToggleProps, 26 | getListProps, 27 | getItemProps, 28 | open, 29 | focusIndex, 30 | isInputEmpty 31 | } = useCombobox({ 32 | items: mergeGroupedItems({ groups, getItemsInGroup: (group) => group.states }), 33 | value, 34 | onChange: setValue, 35 | selected, 36 | onSelectChange: setSelected, 37 | feature: autocomplete({ 38 | select: true 39 | }) 40 | }); 41 | 42 | let itemIndex = -1; 43 | 44 | return ( 45 |
46 | 49 | 50 |
51 | 52 | {!isInputEmpty && ( 53 | 56 | )} 57 | 60 |
61 | 62 |
    67 | {groups.length ? ( 68 | groups.map(({ initial, states }) => ( 69 | 70 |
  • {initial}
  • 71 | {states.map((item) => { 72 | itemIndex++; 73 | return ( 74 |
  • 83 | {item} 84 |
  • 85 | ); 86 | })} 87 |
    88 | )) 89 | ) : ( 90 |
  • No options
  • 91 | )} 92 |
93 |
94 | ); 95 | }; 96 | 97 | export { Grouped }; 98 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Heading from '@theme/Heading'; 3 | import BundleLink from '@site/src/components/BundleLink/AutocompleteLite'; 4 | import styles from './styles.module.css'; 5 | 6 | type FeatureItem = { 7 | title: string; 8 | description: JSX.Element; 9 | }; 10 | 11 | const FeatureList: FeatureItem[] = [ 12 | { 13 | title: 'Modular', 14 | description: ( 15 | <> 16 | Modular architecture and composable features that minimize the amount of code bundled 17 | into your production website. 18 | 19 | ) 20 | }, 21 | { 22 | title: 'Lightweight', 23 | description: ( 24 | <> 25 | Just for a fully functional and accessible autocomplete solution in 26 | React. 27 | 28 | ) 29 | }, 30 | { 31 | title: 'Headless', 32 | description: ( 33 | <> 34 | Providing behavior and data/state management without imposing any specific markup or 35 | styling. 36 | 37 | ) 38 | } 39 | ]; 40 | 41 | function Feature({ title, description }: FeatureItem) { 42 | return ( 43 |
44 |
45 | {title} 46 |

{description}

47 |
48 |
49 | ); 50 | } 51 | 52 | export default function HomepageFeatures(): JSX.Element { 53 | return ( 54 |
55 |
56 |
57 | {FeatureList.map((props, idx) => ( 58 | 59 | ))} 60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /website/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .features h3 { 9 | font-size: 1.5rem; 10 | } 11 | -------------------------------------------------------------------------------- /website/src/components/Intro/styles.module.css: -------------------------------------------------------------------------------- 1 | .inputWrap { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .clear { 7 | transform: translateX(-120%); 8 | } 9 | 10 | .modes { 11 | display: flex; 12 | gap: 20px; 13 | margin-bottom: 16px; 14 | } 15 | 16 | .sandbox { 17 | font-weight: bold; 18 | display: inline-flex; 19 | align-items: center; 20 | gap: 2px; 21 | padding: 4px 0; 22 | margin-top: 48px; 23 | } 24 | -------------------------------------------------------------------------------- /website/src/components/MultiSelect/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMultiSelect, multiSelect } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states'; 4 | 5 | const MultiSelect = () => { 6 | const [value, setValue] = useState(); 7 | // You can set a few items to be selected initially 8 | const [selected, setSelected] = useState(['Alaska', 'Florida']); 9 | // It's up to you how to filter items based on the input value 10 | const items = value 11 | ? STATES.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) 12 | : STATES; 13 | 14 | const { 15 | getLabelProps, 16 | getFocusCaptureProps, 17 | getInputProps, 18 | getClearProps, 19 | getToggleProps, 20 | getListProps, 21 | getItemProps, 22 | isItemSelected, 23 | removeSelect, 24 | focused, 25 | open, 26 | focusIndex, 27 | isInputEmpty 28 | // highlight-next-line 29 | } = useMultiSelect({ 30 | // flipOnSelect: true or false, 31 | items, 32 | value, 33 | onChange: setValue, 34 | selected, 35 | onSelectChange: setSelected, 36 | // highlight-next-line 37 | feature: multiSelect({ 38 | // Options: rovingText, closeOnSelect 39 | rovingText: true 40 | }) 41 | }); 42 | 43 | return ( 44 |
45 | 48 |
61 | {selected.map((item) => ( 62 | 65 | ))} 66 |
67 | 68 | {!isInputEmpty && } 69 |
70 | 71 |
72 | 73 |
    87 | {items.length ? ( 88 | items.map((item, index) => ( 89 |
  • 96 | {item} 97 | {isItemSelected(item) && '✔️'} 98 |
  • 99 | )) 100 | ) : ( 101 |
  • No options
  • 102 | )} 103 |
104 |
105 | ); 106 | }; 107 | 108 | export default MultiSelect; 109 | -------------------------------------------------------------------------------- /website/src/components/MultiSelect/styles.module.css: -------------------------------------------------------------------------------- 1 | .multiInputRoot { 2 | display: flex; 3 | align-items: center; 4 | padding: 6px 4px 6px 10px; 5 | width: 320px; 6 | border-radius: 4px; 7 | border: 1px solid var(--ifm-color-emphasis-500); 8 | background-color: var(--ifm-background-surface-color); 9 | } 10 | 11 | .multiInputRoot:hover { 12 | border-color: var(--ifm-color-primary-light); 13 | } 14 | 15 | .multiInputRootFocused { 16 | box-shadow: 0 0 0 1px var(--ifm-color-primary-light); 17 | border: 1px solid var(--ifm-color-primary-light); 18 | } 19 | 20 | .multiInputWrap { 21 | display: flex; 22 | align-items: center; 23 | flex-wrap: wrap; 24 | flex-grow: 1; 25 | gap: 6px; 26 | } 27 | -------------------------------------------------------------------------------- /website/src/components/MultiSelectDropdown/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMultiSelect, multiSelectDropdown } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states'; 4 | 5 | const MultiSelectDropdown = () => { 6 | const [value, setValue] = useState(); 7 | // You can set a few items to be selected initially 8 | const [selected, setSelected] = useState(['Alaska', 'Florida']); 9 | // It's up to you how to filter items based on the input value 10 | const items = value 11 | ? STATES.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) 12 | : STATES; 13 | 14 | const { 15 | getInputProps, 16 | getClearProps, 17 | getToggleProps, 18 | getListProps, 19 | getItemProps, 20 | isItemSelected, 21 | removeSelect, 22 | open, 23 | focusIndex, 24 | isInputEmpty 25 | } = useMultiSelect({ 26 | // highlight-next-line 27 | flipOnSelect: true, // or false 28 | items, 29 | value, 30 | onChange: setValue, 31 | selected, 32 | onSelectChange: setSelected, 33 | // highlight-next-line 34 | feature: multiSelectDropdown({ 35 | // Options: rovingText, closeOnSelect 36 | rovingText: true, 37 | closeOnSelect: false 38 | }) 39 | }); 40 | 41 | return ( 42 |
43 | 44 | {open && ( 45 |
54 |
63 | {selected.map((item) => ( 64 | 67 | ))} 68 |
69 | 70 | {!isInputEmpty && } 71 |
72 |
73 | 74 |
    83 | {items.length ? ( 84 | items.map((item, index) => ( 85 |
  • 92 | {item} 93 | {isItemSelected(item) && '✔️'} 94 |
  • 95 | )) 96 | ) : ( 97 |
  • No options
  • 98 | )} 99 |
100 |
101 | )} 102 |
103 | ); 104 | }; 105 | 106 | export default MultiSelectDropdown; 107 | -------------------------------------------------------------------------------- /website/src/components/MultiSelectDropdown/styles.module.css: -------------------------------------------------------------------------------- 1 | .multiInputWrap { 2 | display: flex; 3 | align-items: center; 4 | flex-wrap: wrap; 5 | gap: 6px; 6 | padding: 8px 6px 8px 10px; 7 | } 8 | -------------------------------------------------------------------------------- /website/src/components/ObjectItems/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states-obj'; 4 | 5 | const ObjectItems = () => { 6 | const [value, setValue] = useState(); 7 | // highlight-next-line 8 | const [selected, setSelected] = useState<{ name: string; abbr: string }>(); 9 | const items = value 10 | ? STATES.filter((item) => item.name.toLowerCase().startsWith(value.toLowerCase())) 11 | : STATES; 12 | 13 | const { 14 | getFocusCaptureProps, 15 | getLabelProps, 16 | getInputProps, 17 | getClearProps, 18 | getToggleProps, 19 | getListProps, 20 | getItemProps, 21 | // highlight-next-line 22 | isItemSelected, 23 | open, 24 | focusIndex, 25 | isInputEmpty 26 | } = useCombobox({ 27 | items, 28 | // When items are objects, you must specify how to retrieve the text value from the item. 29 | // highlight-next-line 30 | getItemValue: (item) => item.name, 31 | 32 | // If item references change on each render, you should define how items are equal. 33 | // By default, it compares object references if `isEqual` is not provided. 34 | // highlight-next-line 35 | isEqual: (item1, item2) => item1?.abbr === item2?.abbr, 36 | 37 | value, 38 | onChange: setValue, 39 | selected, 40 | onSelectChange: setSelected, 41 | feature: autocomplete({ select: true }) 42 | }); 43 | 44 | return ( 45 |
46 | 49 | 50 |
51 | 52 | {!isInputEmpty && } 53 | 54 |
55 | 56 |
    70 | {items.length ? ( 71 | items.map((item, index) => ( 72 |
  • 82 | {item.name} 83 |
  • 84 | )) 85 | ) : ( 86 |
  • No options
  • 87 | )} 88 |
89 |
90 | ); 91 | }; 92 | 93 | export default ObjectItems; 94 | -------------------------------------------------------------------------------- /website/src/components/ObjectItems/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 4 | import ClearIcon from '@site/static/img/x.svg'; 5 | import ChevronDown from '@site/static/img/chevron-down.svg'; 6 | import ChevronUp from '@site/static/img/chevron-up.svg'; 7 | import STATES from '@site/src/data/states-obj'; 8 | import styles from '@site/src/css/styles.module.css'; 9 | import { useAutoScroll } from '../../utils/useAutoScroll'; 10 | 11 | const ObjectItems = () => { 12 | const [value, setValue] = useState(); 13 | const [selected, setSelected] = useState<{ name: string; abbr: string }>(); 14 | const items = value 15 | ? STATES.filter((item) => item.name.toLowerCase().startsWith(value.toLowerCase())) 16 | : STATES; 17 | 18 | const { 19 | getFocusCaptureProps, 20 | getLabelProps, 21 | getInputProps, 22 | getClearProps, 23 | getToggleProps, 24 | getListProps, 25 | getItemProps, 26 | isItemSelected, 27 | open, 28 | focusIndex, 29 | isInputEmpty 30 | } = useCombobox({ 31 | items, 32 | getItemValue: (item) => item.name, 33 | isEqual: (item1, item2) => item1?.abbr === item2?.abbr, 34 | value, 35 | onChange: setValue, 36 | selected, 37 | onSelectChange: setSelected, 38 | feature: autocomplete({ select: true }) 39 | }); 40 | 41 | const listRef = useAutoScroll(open, items); 42 | 43 | return ( 44 |
45 | 48 | 49 |
50 | 51 | {!isInputEmpty && ( 52 | 55 | )} 56 | 59 |
60 | 61 |
    67 | {items.length ? ( 68 | items.map((item, index) => ( 69 |
  • 78 | {item.name} 79 |
  • 80 | )) 81 | ) : ( 82 |
  • No options
  • 83 | )} 84 |
85 |
86 | ); 87 | }; 88 | 89 | export { ObjectItems }; 90 | -------------------------------------------------------------------------------- /website/src/components/Radio/index.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from 'clsx'; 2 | import RadioChecked from '@site/static/img/radio_checked.svg'; 3 | import RadioUnchecked from '@site/static/img/radio_unchecked.svg'; 4 | import styles from './styles.module.css'; 5 | 6 | const RadioButton = ({ 7 | label, 8 | name, 9 | value, 10 | groupValue, 11 | onChange 12 | }: { 13 | label: string; 14 | name: string; 15 | value: T; 16 | groupValue: T; 17 | onChange: (value: T) => void; 18 | }) => { 19 | const checked = groupValue === value; 20 | return ( 21 | 37 | ); 38 | }; 39 | 40 | export { RadioButton }; 41 | -------------------------------------------------------------------------------- /website/src/components/Radio/styles.module.css: -------------------------------------------------------------------------------- 1 | .label { 2 | display: flex; 3 | align-items: center; 4 | cursor: pointer; 5 | } 6 | 7 | .input { 8 | appearance: none; 9 | display: none; 10 | } 11 | 12 | .radio { 13 | margin-right: 4px; 14 | } 15 | 16 | .checked { 17 | color: var(--ifm-color-primary); 18 | } 19 | -------------------------------------------------------------------------------- /website/src/components/SelectOnly/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 3 | import FRUITS from '../../data/fruits'; 4 | 5 | const SelectOnly = () => { 6 | const [value, setValue] = useState(); 7 | const [selected, setSelected] = useState(); 8 | 9 | const { 10 | getFocusCaptureProps, 11 | getLabelProps, 12 | getInputProps, 13 | getClearProps, 14 | getToggleProps, 15 | getListProps, 16 | getItemProps, 17 | open, 18 | focusIndex, 19 | isInputEmpty 20 | } = useCombobox({ 21 | // items are fixed 22 | // highlight-next-line 23 | items: FRUITS, 24 | value, 25 | onChange: setValue, 26 | selected, 27 | onSelectChange: setSelected, 28 | feature: autocomplete({ select: true }) 29 | }); 30 | 31 | return ( 32 |
33 | 36 | 37 |
38 | 45 | {!isInputEmpty && } 46 | 47 |
48 | 49 |
    63 | {FRUITS.map((item, index) => ( 64 |
  • 72 | {item} 73 |
  • 74 | ))} 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default SelectOnly; 81 | -------------------------------------------------------------------------------- /website/src/components/SelectOnly/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { clsx } from 'clsx'; 3 | import { useCombobox, autocomplete } from '@szhsin/react-autocomplete'; 4 | import ClearIcon from '@site/static/img/x.svg'; 5 | import ChevronDown from '@site/static/img/chevron-down.svg'; 6 | import ChevronUp from '@site/static/img/chevron-up.svg'; 7 | import FRUITS from '@site/src/data/fruits'; 8 | import styles from '@site/src/css/styles.module.css'; 9 | import { useAutoScroll } from '../../utils/useAutoScroll'; 10 | 11 | const SelectOnly = () => { 12 | const [value, setValue] = useState(); 13 | const [selected, setSelected] = useState(); 14 | const items = FRUITS; 15 | 16 | const { 17 | getFocusCaptureProps, 18 | getLabelProps, 19 | getInputProps, 20 | getClearProps, 21 | getToggleProps, 22 | getListProps, 23 | getItemProps, 24 | open, 25 | focusIndex, 26 | isInputEmpty 27 | } = useCombobox({ 28 | items, 29 | value, 30 | onChange: setValue, 31 | selected, 32 | onSelectChange: setSelected, 33 | feature: autocomplete({ select: true }) 34 | }); 35 | 36 | const listRef = useAutoScroll(open, items); 37 | 38 | return ( 39 |
40 | 43 | 44 |
45 | 51 | {!isInputEmpty && ( 52 | 55 | )} 56 | 59 |
60 | 61 |
    67 | {items.map((item, index) => ( 68 |
  • 77 | {item} 78 |
  • 79 | ))} 80 |
81 |
82 | ); 83 | }; 84 | 85 | export { SelectOnly }; 86 | -------------------------------------------------------------------------------- /website/src/components/Supercomplete/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useCombobox, supercomplete } from '@szhsin/react-autocomplete'; 3 | import STATES from '../../data/states'; 4 | 5 | const filterItems = (value?: string) => 6 | value ? STATES.filter((item) => item.toLowerCase().startsWith(value.toLowerCase())) : STATES; 7 | 8 | const Supercomplete = () => { 9 | const [value, setValue] = useState(); 10 | const [selected, setSelected] = useState(); 11 | const items = filterItems(value); 12 | 13 | const { 14 | getLabelProps, 15 | getInputProps, 16 | getClearProps, 17 | getToggleProps, 18 | getListProps, 19 | getItemProps, 20 | open, 21 | focusIndex, 22 | isInputEmpty 23 | } = useCombobox({ 24 | items, 25 | value, 26 | onChange: setValue, 27 | selected, 28 | onSelectChange: setSelected, 29 | feature: supercomplete({ 30 | // highlight-start 31 | // The `onRequestItem` event will be triggered with the updated input value. 32 | // You should compute the item for inline text completion using the same filtering logic, 33 | // and invoke the `res` function to complete. 34 | // If no item matches for inline completion, do not call the `res` function. 35 | onRequestItem: ({ value: newValue }, res) => { 36 | const items = filterItems(newValue); 37 | if (items.length > 0) res({ index: 0, item: items[0] }); 38 | }, 39 | // highlight-end 40 | 41 | // The `select` option controls autocomplete in free or select mode 42 | select: false // or true 43 | // Other options: deselectOnClear, deselectOnChange, closeOnSelect 44 | }) 45 | }); 46 | 47 | return ( 48 |
49 | 50 |
51 | 52 | {!isInputEmpty && } 53 | 54 |
55 | 56 |
    70 | {items.length ? ( 71 | items.map((item, index) => ( 72 |
  • 80 | {item} 81 |
  • 82 | )) 83 | ) : ( 84 |
  • No results
  • 85 | )} 86 |
87 |
88 | ); 89 | }; 90 | 91 | export default Supercomplete; 92 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #007bff; 10 | --ifm-color-primary-light: #509afa; 11 | --ifm-code-font-size: 95%; 12 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 13 | } 14 | 15 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 16 | [data-theme='dark'] { 17 | --ifm-color-primary: #69a6f8; 18 | --ifm-color-primary-light: #69a6f8; 19 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 20 | } 21 | 22 | .markdown h1:first-child { 23 | --ifm-h1-font-size: 2.5rem; 24 | } 25 | 26 | @media (max-width: 576px) { 27 | .markdown h1:first-child { 28 | --ifm-h1-font-size: 2rem; 29 | } 30 | } 31 | 32 | input::-webkit-contacts-auto-fill-button { 33 | visibility: hidden; 34 | display: none !important; 35 | pointer-events: none; 36 | position: absolute; 37 | right: 0; 38 | } 39 | 40 | .theme-code-block-highlighted-line { 41 | box-shadow: inset 3px 0 0 0 var(--ifm-color-primary); 42 | } 43 | 44 | [data-theme='dark'] .theme-code-block-highlighted-line { 45 | background-color: #242c37; 46 | } 47 | 48 | [data-theme='light'] .token.comment { 49 | color: #028000 !important; 50 | font-style: normal !important; 51 | } 52 | 53 | .tabs__item { 54 | padding: 6px 16px; 55 | } 56 | 57 | [data-footnotes='true'] { 58 | border-top: 1px solid var(--ifm-color-gray-300); 59 | color: var(--ifm-color-gray-700); 60 | padding-top: 12px; 61 | margin-top: 32px; 62 | font-size: 12px; 63 | } 64 | 65 | [data-theme='dark'] [data-footnotes='true'] { 66 | border-top: 1px solid var(--ifm-color-gray-800); 67 | color: var(--ifm-color-gray-500); 68 | } 69 | 70 | [data-footnotes='true'] h2 { 71 | display: none; 72 | } 73 | 74 | [data-footnotes='true'] ol { 75 | padding: 0 0 0 16px; 76 | } 77 | 78 | [data-footnotes='true'] p { 79 | margin: 0 !important; 80 | } 81 | -------------------------------------------------------------------------------- /website/src/data/fruits.ts: -------------------------------------------------------------------------------- 1 | export default ['Apple', 'Banana', 'Blueberry', 'Cherry', 'Grape', 'Pineapple', 'Strawberry']; 2 | -------------------------------------------------------------------------------- /website/src/data/states-grouped.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | initial: 'A', 4 | states: ['Alabama', 'Alaska', 'Arizona', 'Arkansas'] 5 | }, 6 | { 7 | initial: 'C', 8 | states: ['California', 'Colorado', 'Connecticut'] 9 | }, 10 | { 11 | initial: 'D', 12 | states: ['Delaware'] 13 | }, 14 | { 15 | initial: 'F', 16 | states: ['Florida'] 17 | }, 18 | { 19 | initial: 'G', 20 | states: ['Georgia'] 21 | }, 22 | { 23 | initial: 'H', 24 | states: ['Hawaii'] 25 | }, 26 | { 27 | initial: 'I', 28 | states: ['Idaho', 'Illinois', 'Indiana', 'Iowa'] 29 | }, 30 | { 31 | initial: 'K', 32 | states: ['Kansas', 'Kentucky'] 33 | }, 34 | { 35 | initial: 'L', 36 | states: ['Louisiana'] 37 | }, 38 | { 39 | initial: 'M', 40 | states: [ 41 | 'Maine', 42 | 'Maryland', 43 | 'Massachusetts', 44 | 'Michigan', 45 | 'Minnesota', 46 | 'Mississippi', 47 | 'Missouri', 48 | 'Montana' 49 | ] 50 | }, 51 | { 52 | initial: 'N', 53 | states: [ 54 | 'Nebraska', 55 | 'Nevada', 56 | 'New Hampshire', 57 | 'New Jersey', 58 | 'New Mexico', 59 | 'New York', 60 | 'North Carolina', 61 | 'North Dakota' 62 | ] 63 | }, 64 | { 65 | initial: 'O', 66 | states: ['Ohio', 'Oklahoma', 'Oregon'] 67 | }, 68 | { 69 | initial: 'P', 70 | states: ['Pennsylvania'] 71 | }, 72 | { 73 | initial: 'R', 74 | states: ['Rhode Island'] 75 | }, 76 | { 77 | initial: 'S', 78 | states: ['South Carolina', 'South Dakota'] 79 | }, 80 | { 81 | initial: 'T', 82 | states: ['Tennessee', 'Texas'] 83 | }, 84 | { 85 | initial: 'U', 86 | states: ['Utah'] 87 | }, 88 | { 89 | initial: 'V', 90 | states: ['Vermont', 'Virginia'] 91 | }, 92 | { 93 | initial: 'W', 94 | states: ['Washington', 'West Virginia', 'Wisconsin', 'Wyoming'] 95 | } 96 | ]; 97 | -------------------------------------------------------------------------------- /website/src/data/states.ts: -------------------------------------------------------------------------------- 1 | // https://en.wikipedia.org/wiki/List_of_states_and_territories_of_the_United_States#States 2 | 3 | export default [ 4 | 'Alabama', 5 | 'Alaska', 6 | 'Arizona', 7 | 'Arkansas', 8 | 'California', 9 | 'Colorado', 10 | 'Connecticut', 11 | 'Delaware', 12 | 'Florida', 13 | 'Georgia', 14 | 'Hawaii', 15 | 'Idaho', 16 | 'Illinois', 17 | 'Indiana', 18 | 'Iowa', 19 | 'Kansas', 20 | 'Kentucky', 21 | 'Louisiana', 22 | 'Maine', 23 | 'Maryland', 24 | 'Massachusetts', 25 | 'Michigan', 26 | 'Minnesota', 27 | 'Mississippi', 28 | 'Missouri', 29 | 'Montana', 30 | 'Nebraska', 31 | 'Nevada', 32 | 'New Hampshire', 33 | 'New Jersey', 34 | 'New Mexico', 35 | 'New York', 36 | 'North Carolina', 37 | 'North Dakota', 38 | 'Ohio', 39 | 'Oklahoma', 40 | 'Oregon', 41 | 'Pennsylvania', 42 | 'Rhode Island', 43 | 'South Carolina', 44 | 'South Dakota', 45 | 'Tennessee', 46 | 'Texas', 47 | 'Utah', 48 | 'Vermont', 49 | 'Virginia', 50 | 'Washington', 51 | 'West Virginia', 52 | 'Wisconsin', 53 | 'Wyoming' 54 | ]; 55 | -------------------------------------------------------------------------------- /website/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .heroBanner { 2 | padding: 4rem 0; 3 | text-align: center; 4 | position: relative; 5 | color: #000; 6 | background-color: #69a6f8; 7 | } 8 | 9 | .container { 10 | padding: 0; 11 | } 12 | 13 | @media screen and (max-width: 996px) { 14 | .heroBanner { 15 | padding: 2rem; 16 | } 17 | } 18 | 19 | @media screen and (max-width: 480px) { 20 | .heroBanner { 21 | padding: 2rem 1rem; 22 | } 23 | .heroTitle { 24 | font-size: 2rem; 25 | } 26 | } 27 | 28 | .buttons { 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | } 33 | 34 | .install { 35 | margin-bottom: 1.5rem; 36 | } 37 | -------------------------------------------------------------------------------- /website/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import Link from '@docusaurus/Link'; 3 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 4 | import Layout from '@theme/Layout'; 5 | import CodeBlock from '@theme/CodeBlock'; 6 | import Heading from '@theme/Heading'; 7 | import HomepageFeatures from '@site/src/components/HomepageFeatures'; 8 | 9 | import styles from './index.module.css'; 10 | 11 | export default function Home(): JSX.Element { 12 | const { siteConfig } = useDocusaurusContext(); 13 | return ( 14 | 15 |
16 |
17 | 18 | {siteConfig.title} 19 | 20 |

{siteConfig.tagline}

21 |
22 | 23 | npm install @szhsin/react-autocomplete 24 | 25 | 26 | Getting Started 27 | 28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /website/src/theme/Footer/index.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | text-align: center; 3 | padding: 2rem 1rem; 4 | font-size: 14px; 5 | line-height: 1.5; 6 | color: var(--ifm-color-gray-500); 7 | } 8 | 9 | .footer a { 10 | width: auto; 11 | font-size: 16px; 12 | } 13 | 14 | .license { 15 | margin-top: 16px; 16 | } 17 | -------------------------------------------------------------------------------- /website/src/theme/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import LinkItem from '@theme/Footer/LinkItem'; 3 | import styles from './index.module.css'; 4 | 5 | export default function Footer() { 6 | return ( 7 |
8 | 14 |
Released under the MIT License.
15 |
Copyright © {new Date().getFullYear()} Zheng Song.
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /website/src/theme/TabItem/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import type { Props } from '@theme/TabItem'; 3 | 4 | import styles from './styles.module.css'; 5 | 6 | export default function TabItem({ children, hidden, className }: Props): JSX.Element { 7 | return ( 8 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /website/src/theme/TabItem/styles.module.css: -------------------------------------------------------------------------------- 1 | .tabItem > *:last-child { 2 | margin-bottom: 0; 3 | } 4 | -------------------------------------------------------------------------------- /website/src/theme/Tabs/styles.module.css: -------------------------------------------------------------------------------- 1 | .tabList { 2 | margin-bottom: var(--ifm-leading); 3 | } 4 | 5 | .tabItem { 6 | margin-top: 0 !important; 7 | } 8 | 9 | .tabItemLink { 10 | padding: 0 !important; 11 | } 12 | 13 | .tabLink { 14 | padding: 6px 16px; 15 | display: flex; 16 | align-items: center; 17 | gap: 4px; 18 | } 19 | -------------------------------------------------------------------------------- /website/src/utils/useAutoScroll.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | const useAutoScroll = ( 4 | open: boolean, 5 | items: unknown[] 6 | ) => { 7 | const ref = useRef(null); 8 | useEffect(() => { 9 | if (open) { 10 | const elt = ref.current; 11 | if (!elt) return; 12 | if (elt.getBoundingClientRect().bottom > window.innerHeight) { 13 | elt.scrollIntoView({ block: 'end', behavior: 'smooth' }); 14 | } 15 | elt.scrollTop = 0; 16 | } 17 | }, [open, items.length]); 18 | 19 | return ref; 20 | }; 21 | 22 | export { useAutoScroll }; 23 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-autocomplete/a68a628011e4ac1c753ce626ce172c8de8088f05/website/static/.nojekyll -------------------------------------------------------------------------------- /website/static/img/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/chevron-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/chevron-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/external-link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-autocomplete/a68a628011e4ac1c753ce626ce172c8de8088f05/website/static/img/favicon.ico -------------------------------------------------------------------------------- /website/static/img/radio_checked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/radio_unchecked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/square-check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/static/img/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@docusaurus/tsconfig", 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "strict": true 7 | } 8 | } 9 | --------------------------------------------------------------------------------