7.0.7 โ€ข Published 18 days ago

headless-combobox v7.0.7

Weekly downloads
-
License
ISC
Repository
github
Last release
18 days ago

headless-combobox

demo

โš ๏ธ 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 support
  • ๐Ÿงบ Multi Select supported
  • ๐Ÿฅš Select Only supported
  • ๐Ÿ’ช Written in TypeScript
  • ๐ŸŒณ Simple pure functional Elm-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-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

Links

Installation

NPM

npm install headless-combobox

Yarn

yarn add headless-combobox

PNPM

pnpm install headless-combobox

Complementary Libraries

Credit

This library is steals from these libraries:

Usage

Svelte Single Select Example

<script lang="ts">
  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);
</script>

<div class="container">
  <label
    class="label"
    {...state.aria.inputLabel}
    for={state.aria.inputLabel.for}
  >
    Fruit Single Select
  </label>
  <p {...state.aria.helperText}>{Combobox.ariaContentDefaults.helperText}</p>

  <button on:click={() => dispatch({ type: "pressed-unselect-all-button" })}>
    Clear
  </button>

  <div class="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}
    >
      {#if state.renderItems.length === 0}
        <li>No results</li>
      {/if}
      {#each state.renderItems as item, 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: 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;
  }
</style>
7.0.7

18 days ago

7.0.6

18 days ago

7.0.5

2 months ago

7.0.4

2 months ago

7.0.0

3 months ago

7.0.3

3 months ago

7.0.1

3 months ago

6.1.2

3 months ago

6.1.4

3 months ago

6.1.3

3 months ago

6.1.0

3 months ago

6.1.1

3 months ago

6.0.10

3 months ago

6.0.7

3 months ago

6.0.9

3 months ago

6.0.8

3 months ago

6.0.6

4 months ago

6.0.3

4 months ago

6.0.2

4 months ago

6.0.5

4 months ago

6.0.4

4 months ago

6.0.13

7 months ago

6.0.12

7 months ago

6.0.11

7 months ago

6.0.1

8 months ago

6.0.0

8 months ago

5.4.14

8 months ago

5.4.15

8 months ago

5.1.5

11 months ago

5.1.4

11 months ago

5.1.3

11 months ago

5.3.0

11 months ago

5.1.2

11 months ago

5.1.1

11 months ago

4.9.8

12 months ago

5.1.0

11 months ago

4.9.7

12 months ago

4.9.9

12 months ago

4.9.4

12 months ago

4.9.3

12 months ago

4.9.6

12 months ago

4.9.5

12 months ago

4.9.0

12 months ago

4.7.1

12 months ago

4.9.2

12 months ago

4.9.1

12 months ago

5.2.11

11 months ago

5.2.10

11 months ago

4.4.0

12 months ago

4.6.0

12 months ago

4.0.0

12 months ago

4.2.0

12 months ago

5.4.9

11 months ago

5.4.8

11 months ago

5.4.7

11 months ago

5.2.9

11 months ago

5.4.6

11 months ago

5.2.8

11 months ago

5.4.5

11 months ago

5.2.7

11 months ago

5.0.9

11 months ago

5.4.4

11 months ago

5.2.6

11 months ago

5.4.3

11 months ago

5.2.5

11 months ago

5.0.7

12 months ago

5.4.2

11 months ago

5.2.4

11 months ago

5.0.6

12 months ago

5.4.1

11 months ago

5.2.3

11 months ago

5.0.5

12 months ago

5.4.0

11 months ago

5.2.2

11 months ago

5.0.4

12 months ago

5.2.1

11 months ago

5.0.3

12 months ago

5.2.0

11 months ago

5.0.2

12 months ago

5.0.1

12 months ago

5.0.10

11 months ago

5.0.0

12 months ago

4.8.0

12 months ago

5.4.12

10 months ago

5.4.13

10 months ago

5.4.10

11 months ago

5.4.11

11 months ago

3.7.0

1 year ago

4.5.0

12 months ago

4.7.0

12 months ago

4.1.0

12 months ago

4.3.0

12 months ago

5.1.11

11 months ago

5.1.10

11 months ago

5.1.9

11 months ago

5.1.8

11 months ago

5.1.7

11 months ago

5.1.6

11 months ago

3.6.0

1 year ago

3.5.4

1 year ago

3.5.3

1 year ago

3.5.2

1 year ago

3.5.1

1 year ago

3.5.0

1 year ago

3.4.4

1 year ago

3.4.3

1 year ago

3.4.1

1 year ago

3.4.0

1 year ago

3.3.0

1 year ago

3.2.3

1 year ago

3.2.2

1 year ago

3.2.1

1 year ago

3.2.0

1 year ago

3.1.9

1 year ago

3.1.7

1 year ago

3.0.7

1 year ago

3.0.6

1 year ago

3.0.5

1 year ago

3.0.4

1 year ago

3.0.3

1 year ago

3.0.2

1 year ago