Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/crvouga/headless-combobox
β‘οΈZero dependencies π Framework agnostic πͺ TypeScript π§ Headless Combobox
https://github.com/crvouga/headless-combobox
aria autocomplete combobox framework-agnostic functional-programming headless-ui typescript zero-dependency
Last synced: about 1 month ago
JSON representation
β‘οΈZero dependencies π Framework agnostic πͺ TypeScript π§ Headless Combobox
- Host: GitHub
- URL: https://github.com/crvouga/headless-combobox
- Owner: crvouga
- Created: 2023-04-20T23:04:30.000Z (almost 2 years ago)
- Default Branch: main
- Last Pushed: 2024-06-25T19:10:11.000Z (7 months ago)
- Last Synced: 2024-12-08T19:35:51.651Z (about 1 month ago)
- Topics: aria, autocomplete, combobox, framework-agnostic, functional-programming, headless-ui, typescript, zero-dependency
- Language: TypeScript
- Homepage: https://headless-combobox.vercel.app
- Size: 956 KB
- Stars: 43
- Watchers: 2
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: readme.md
Awesome Lists containing this project
README
# headless-combobox
![demo](https://github.com/crvouga/headless-combobox/raw/main/demo.gif)
## β οΈ WORK IN PROGRESS
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.
## Pros
- π§ Headless. Bring your own styles.
- π Framework agnostic. Bring your own framework.
- β‘οΈ Zero dependencies
- βΏοΈ [WAI ARIA Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) support
- π§Ί Multi Select supported
- π₯ Select Only supported
- πͺ Written in TypeScript
- π³ Simple pure functional [Elm](https://elm-lang.org/)-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## Cons
- π§ Headless. You do have to write your own styles.
- π Framework agnostic. You do have to write error prone adapter code.
- π³ [Elm](https://elm-lang.org/)-like API. People may hate that.
- π Missing good documentation. The only way to learn this library is through the examples.## Good use cases are
- 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## Demos
- [Svelte Demo](https://headless-combobox-demo-svelte.vercel.app/)
## Links
- [bundlephobia](https://bundlephobia.com/package/headless-combobox)
- [API Reference](https://headless-combobox.vercel.app/)
- [Github](https://github.com/crvouga/headless-combobox)
- [NPM](https://www.npmjs.com/package/headless-combobox)## Installation
### NPM
```shell
npm install headless-combobox
```### Yarn
```shell
yarn add headless-combobox
```### PNPM
```shell
pnpm install headless-combobox
```## Complementary Libraries
- [match-sorter](https://github.com/kentcdodds/match-sorter) for filtering items
- [floating-ui](https://floating-ui.com/) for rendering the drop down.## Credit
This library is steals from these libraries:
- [MUI's Autocomplete](https://mui.com/material-ui/react-autocomplete/#multiple-values)
- [Headless UI's Combobox](https://headlessui.com/react/combobox)## Usage
### Svelte Single Select Example
```svelte
import * as Combobox from "./src";
/*
Step 0: Have some data to display
*/
type Item = { 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 components
Combobox.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);
Fruit Single Select
{Combobox.ariaContentDefaults.helperText}
dispatch({ type: "pressed-unselect-all-button" })}>
Clear
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}
/>
{#if state.renderItems.length === 0}
- No results
{/if}
{#each state.renderItems as item, index}
- 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}
{/each}
.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: 1px solid #ccc;
}
.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1;
width: 100%;
max-height: 300px;
overflow: scroll;
border: 1px solid #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;
}```