- Notifications
You must be signed in to change notification settings - Fork0
⚡️Zero dependencies 🔌 Framework agnostic 💪 TypeScript 🧠 Headless Combobox
NotificationsYou must be signed in to change notification settings
crvouga/headless-combobox
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
I'm comfortable using this in my projects but use at your own risk!
The public API may be unstable.
Let me know if you find any issues.
- 🧠 Headless. Bring your own styles.
- 🔌 Framework agnostic. Bring your own framework.
- ⚡️ Zero dependencies
- ♿️WAI ARIA Combobox support
- 🧺 Multi Select supported
- 🥚 Select Only supported
- 💪 Written in TypeScript
- 🌳 Simple pure functionalElm-like API
- 💼 Works anywhere JavaScript works.
- React Native
- Vanilla JS & HTML
- Vue
- Node.js
- Redux (Since the API is just pure functions)
- Any JS framework
- 🧠 Headless. You do have to write your own styles.
- 🔌 Framework agnostic. You do have to write error prone adapter code.
- 🌳Elm-like API. People may hate that.
- 📚 Missing good documentation. The only way to learn this library is through the examples.
- You need a custom looking combobox
- You're working in a legacy framework
- You're working in a framework with a small ecosystem
- You're working in a framework that always has breaking changes
- You hate learning how to override styles in combobox libraries
npm install headless-combobox
yarn add headless-combobox
pnpm install headless-combobox
- match-sorter for filtering items
- floating-ui for rendering the drop down.
This library is steals from these libraries:
<scriptlang="ts">import*asComboboxfrom"./src";/* Step 0: Have some data to display*/typeItem= { id:number; label:string };const fruits= [ { id:0, label:"pear" }, { id:1, label:"apple" }, { id:2, label:"banana" }, { id:3, label:"orange" }, { id:4, label:"strawberry" }, { id:5, label:"kiwi" }, { id:6, label:"mango" }, { id:7, label:"pineapple" }, { id:8, label:"watermelon" }, { id:9, label:"grape" }, ];let items: { [itemId:string]:HTMLElement }= {};let input:HTMLInputElement|null=null;/* Step 1: Init the config*/const config=Combobox.initConfig<Item>({toItemId: (item)=>item.id,toItemInputValue: (item)=>item.label, });/* Step 2: Init the state*/let model=Combobox.init(config, { allItems:fruits, inputMode: { type:"search-mode", inputValue:"", }, selectMode: { type:"single-select", }, });/* Step 3: Write some glue code*/const dispatch= (msg:Combobox.Msg<Item>|null)=> {if (!msg) {return; }const output=Combobox.update(config, {msg,model });console.log(model.type,msg.type,output.model);model=output.model;Combobox.handleEffects(output, {focusInput: ()=> {input?.focus(); },focusSelectedItem: ()=> {},scrollItemIntoView: (item)=> {items[item.id]?.scrollIntoView({ block:"nearest" }); }, });// useful for emitting changed events to parent componentsCombobox.handleEvents(output, { onInputValueChanged() {console.log("onInputValueChanged"); }, onSelectedItemsChanged() {console.log("onSelectedItemsChanged"); }, }); };const onKeydown= (event:KeyboardEvent)=> {const msg=Combobox.keyToMsg<Item>(event.key);if (msg.shouldPreventDefault) {event.preventDefault(); }dispatch(msg); };/* Step 4: Wire up to the UI ⚠️ This is the error prone part*/$:state=Combobox.toState(config,model);</script><divclass="container"> <labelclass="label" {...state.aria.inputLabel}for={state.aria.inputLabel.for} > Fruit Single Select </label> <p {...state.aria.helperText}>{Combobox.ariaContentDefaults.helperText}</p> <buttonon:click={()=>dispatch({ type:"pressed-unselect-all-button" })}> Clear </button> <divclass="input-container"> <input {...state.aria.input}class="input"value={state.inputValue}bind:this={input}on:input={(event)=>dispatch({ type:"inputted-value", inputValue:event.currentTarget.value, })}on:focus={()=>dispatch({ type:"focused-input" })}on:blur={()=>dispatch({ type:"blurred-input" })}on:mousedown={()=>dispatch({ type:"pressed-input" })}on:keydown={onKeydown} /> <ul {...state.aria.itemList}class="suggestions"class:hide={!state.isOpened} > {#ifstate.renderItems.length===0} <li>No results</li> {/if} {#eachstate.renderItemsasitem,index} <li {...item.aria}bind:this={items[item.item.id]}on:mousemove={()=>dispatch({ type:"hovered-over-item",index })}on:mousedown|preventDefault={()=>/* Make sure it's a mousedown event instead of click event*/dispatch({ type:"pressed-item", item:item.item })}on:focus={()=>dispatch({ type:"hovered-over-item",index })}class="option"class:highlighted={item.status==="highlighted"}class:selected={item.status==="selected"}class:selected-and-highlighted={item.status==="selected-and-highlighted"} > {item.inputValue} </li> {/each} </ul> </div></div><!-- We get to use our own styles 🎉 --><style>.container {width:100%;max-width:300px; }.input-container {position:relative; }.label {position:relative;display:block;width:100%; }.hide {display:none; }.input {width:100%;padding:0.5rem;font-size:large;box-sizing:border-box;border:1pxsolid#ccc; }.suggestions {position:absolute;top:100%;left:0;right:0;z-index:1;width:100%;max-height:300px;overflow:scroll;border:1pxsolid#ccc;width:100%;max-width:100%;margin:0;padding:0;background:#efefef;font-size:large; }@media (prefers-color-scheme: dark) {.suggestions {background:#121212; } }@media (prefers-color-scheme: dark) {.highlighted {background-color:#eee;color:black; } }.option {display:block;cursor:pointer;list-style:none;width:100%;margin:0;padding:0; }.highlighted {background-color:#333;color:white; }.selected {background-color:blue;color:#fff; }.selected-and-highlighted {background-color:lightblue; }</style>
About
⚡️Zero dependencies 🔌 Framework agnostic 💪 TypeScript 🧠 Headless Combobox
Topics
Resources
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Uh oh!
There was an error while loading.Please reload this page.