@tapestry-ui/combobox 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/dist/combobox.d.ts +66 -0
- package/dist/combobox.js +251 -0
- package/package.json +54 -4
- package/styles.css +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brandon Minton
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,113 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tapestry-ui/combobox
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A combobox for Preact: **type to narrow, or open the whole list like a select.**
|
|
4
|
+
WCAG 2.1 AA, headless behavior, skinned entirely through CSS.
|
|
5
|
+
|
|
6
|
+
Part of [Tapestry UI](https://github.com/ryurage/brandonminton) — components
|
|
7
|
+
forged inside a woven-tapestry game and a private war-room dashboard, then
|
|
8
|
+
published so they have to survive strangers.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm i @tapestry-ui/combobox
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import { Combobox } from '@tapestry-ui/combobox';
|
|
16
|
+
import '@tapestry-ui/combobox/styles.css'; // structural only — no colors
|
|
17
|
+
|
|
18
|
+
<Combobox
|
|
19
|
+
label="Property"
|
|
20
|
+
options={properties.map((name) => ({ value: name }))}
|
|
21
|
+
value={chosen}
|
|
22
|
+
onChange={setChosen}
|
|
23
|
+
placeholder="all properties"
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why not a `<select>`
|
|
28
|
+
|
|
29
|
+
A native select cannot be styled past its edges and cannot be typed into. With
|
|
30
|
+
eighteen options, "scroll until you see Fairview" is the wrong interaction —
|
|
31
|
+
you already know the name. This is both halves at once: a text field that
|
|
32
|
+
filters as you type, and a chevron that shows you everything.
|
|
33
|
+
|
|
34
|
+
## The two contracts
|
|
35
|
+
|
|
36
|
+
**Typing is a query, never a value.** A component that replaces a `<select>`
|
|
37
|
+
must never end up holding something nobody chose. Abandon the field half-typed
|
|
38
|
+
and the committed label comes back. Only Enter, Tab, a click, or — with
|
|
39
|
+
`allowCustom` — blurring on free text commits anything.
|
|
40
|
+
|
|
41
|
+
**The container contract, and its one exception.** Tapestry UI components never
|
|
42
|
+
own scrolling. The popup is the exception, deliberately: it is an overlay over
|
|
43
|
+
the page rather than part of your layout, so capping its height constrains
|
|
44
|
+
nothing you laid out. The cap is still yours —
|
|
45
|
+
`--tui-combobox-list-max-h: none` removes it.
|
|
46
|
+
|
|
47
|
+
## Props
|
|
48
|
+
|
|
49
|
+
| Prop | Type | Notes |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `options` | `ComboboxOption[]` | `{ value, label?, description?, disabled? }`; `label` defaults to `value` |
|
|
52
|
+
| `label` | `string` | **required** — the accessible name, read before the value |
|
|
53
|
+
| `showLabel` | `boolean` | render the label visibly too (default: screen readers only) |
|
|
54
|
+
| `value` / `onChange` | `string` / `(value) => void` | controlled; the component reports intent and never drifts |
|
|
55
|
+
| `defaultValue` | `string` | uncontrolled starting value |
|
|
56
|
+
| `placeholder` | `string` | |
|
|
57
|
+
| `allowCustom` | `boolean` | commit typed text that matches no option |
|
|
58
|
+
| `openOnFocus` | `boolean` | show the whole list the moment the field is focused |
|
|
59
|
+
| `filter` | `(query, option) => boolean` | the query arrives lowercased and de-accented |
|
|
60
|
+
| `emptyLabel` | `string` | shown when nothing matches (default `"no match"`) |
|
|
61
|
+
| `toggleLabel` | `string` | accessible name for the chevron |
|
|
62
|
+
| `maxVisible` | `number` | rendered-row cap (default 100); the rest are counted, not drawn |
|
|
63
|
+
| `disabled` / `required` | `boolean` | |
|
|
64
|
+
| `name` | `string` | adds a hidden input so the field posts like a `<select>` |
|
|
65
|
+
| `class` / `inputClass` / `listClass` / `optionClass` / `id` | `string` | skin hooks |
|
|
66
|
+
|
|
67
|
+
`foldCase(text)` is exported — the same lowercase-and-strip-accents the default
|
|
68
|
+
filter uses, in case you write your own.
|
|
69
|
+
|
|
70
|
+
## Keyboard
|
|
71
|
+
|
|
72
|
+
| Key | Does |
|
|
73
|
+
|---|---|
|
|
74
|
+
| <kbd>↓</kbd> / <kbd>↑</kbd> | open the list; move the highlight, wrapping at the ends |
|
|
75
|
+
| <kbd>Home</kbd> / <kbd>End</kbd> | first / last option |
|
|
76
|
+
| <kbd>Enter</kbd> | commit the highlighted option (and **not** submit the form) |
|
|
77
|
+
| <kbd>Tab</kbd> | commit the highlighted option and move on |
|
|
78
|
+
| <kbd>Esc</kbd> | close the list; again restores the committed label |
|
|
79
|
+
| typing | filter, highlighting the matched run |
|
|
80
|
+
|
|
81
|
+
Disabled options are never a destination. DOM focus stays in the input the
|
|
82
|
+
whole time — the highlight is carried by `aria-activedescendant`, so a screen
|
|
83
|
+
reader reads the option without losing the text field, and the option count is
|
|
84
|
+
announced from a polite live region as the list narrows.
|
|
85
|
+
|
|
86
|
+
## Styling
|
|
87
|
+
|
|
88
|
+
Structural CSS ships in `styles.css`; it picks no colors. Skin it with custom
|
|
89
|
+
properties, or ignore the file and style the `[data-part]` hooks yourself.
|
|
90
|
+
**Never fork the package to restyle it.**
|
|
91
|
+
|
|
92
|
+
| Property | Default |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `--tui-combobox-pad` | `0.4em 2em 0.4em 0.6em` |
|
|
95
|
+
| `--tui-combobox-focus` | `2px solid currentColor` |
|
|
96
|
+
| `--tui-combobox-toggle-w` | `2em` |
|
|
97
|
+
| `--tui-combobox-chevron-w` | `0.625em` |
|
|
98
|
+
| `--tui-combobox-list-max-h` | `15em` (`none` to uncap) |
|
|
99
|
+
| `--tui-combobox-option-pad` | `0.4em 0.6em` |
|
|
100
|
+
| `--tui-combobox-z` | `20` |
|
|
101
|
+
|
|
102
|
+
Parts: `root` (`data-state="open|closed"`, `data-placement="below|above"`),
|
|
103
|
+
`label`, `field`, `input`, `toggle`, `list`, `option`
|
|
104
|
+
(`data-state="active|idle"`), `option-label`, `option-desc`, `match`, `empty`,
|
|
105
|
+
`more`, `status`, `value`.
|
|
106
|
+
|
|
107
|
+
The popup opens upward on its own when there is no room below it — a phone
|
|
108
|
+
keyboard eats the bottom half of the screen, and a popup under the fold is a
|
|
109
|
+
popup that isn't there.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT © Brandon Minton
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type JSX } from 'preact';
|
|
2
|
+
/**
|
|
3
|
+
* Everything this component says that it ASSEMBLES rather than simply renders.
|
|
4
|
+
* Functions, not templates with holes in them, because a string cannot
|
|
5
|
+
* pluralise: "1 option" / "2 options" is an English rule, and Polish has three
|
|
6
|
+
* forms. The target language decides, not a format string.
|
|
7
|
+
*/
|
|
8
|
+
export interface ComboboxStrings {
|
|
9
|
+
/** The chevron's accessible name, given the field's own label. */
|
|
10
|
+
showAll: (label: string) => string;
|
|
11
|
+
/** The note under a capped list. */
|
|
12
|
+
more: (count: number) => string;
|
|
13
|
+
/** The live count, announced as the list narrows. */
|
|
14
|
+
optionCount: (count: number) => string;
|
|
15
|
+
}
|
|
16
|
+
export declare const EN_COMBOBOX: ComboboxStrings;
|
|
17
|
+
export interface ComboboxOption {
|
|
18
|
+
/** the committed value — what onChange reports and a hidden input submits */
|
|
19
|
+
value: string;
|
|
20
|
+
/** what the human reads; defaults to `value` */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** a quiet second line in the option row (a role, a count, an address) */
|
|
23
|
+
description?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface ComboboxProps {
|
|
27
|
+
options: ComboboxOption[];
|
|
28
|
+
/** accessible name for the field — assistive tech reads this before the value */
|
|
29
|
+
label: string;
|
|
30
|
+
/** render the label as visible text above the field (default: it is only for AT) */
|
|
31
|
+
showLabel?: boolean;
|
|
32
|
+
/** controlled value — the component follows it and only reports intent */
|
|
33
|
+
value?: string;
|
|
34
|
+
/** uncontrolled initial value (default: nothing chosen) */
|
|
35
|
+
defaultValue?: string;
|
|
36
|
+
onChange?: (value: string) => void;
|
|
37
|
+
placeholder?: string;
|
|
38
|
+
/** commit typed text that matches no option (default: typing is only a filter) */
|
|
39
|
+
allowCustom?: boolean;
|
|
40
|
+
/** open the whole list as soon as the field is focused */
|
|
41
|
+
openOnFocus?: boolean;
|
|
42
|
+
/** override the match test; the query arrives already lowercased and de-accented */
|
|
43
|
+
filter?: (query: string, option: ComboboxOption) => boolean;
|
|
44
|
+
/** shown in the popup when nothing matches */
|
|
45
|
+
emptyLabel?: string;
|
|
46
|
+
/** accessible name for the chevron */
|
|
47
|
+
toggleLabel?: string;
|
|
48
|
+
/** Replace any sentence this component builds. Defaults are English. */
|
|
49
|
+
strings?: Partial<ComboboxStrings>;
|
|
50
|
+
/** cap on rendered options (default 100); the rest are counted, not drawn */
|
|
51
|
+
maxVisible?: number;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
required?: boolean;
|
|
54
|
+
/** render a hidden input under this name so the field posts like a <select> */
|
|
55
|
+
name?: string;
|
|
56
|
+
/** skin hooks — tapestry-ui components style through CSS, never props */
|
|
57
|
+
class?: string;
|
|
58
|
+
inputClass?: string;
|
|
59
|
+
listClass?: string;
|
|
60
|
+
optionClass?: string;
|
|
61
|
+
id?: string;
|
|
62
|
+
}
|
|
63
|
+
/** Lowercased and stripped of accents, so "Fairview" finds "fairview" and
|
|
64
|
+
* "Muñoz" finds "munoz". Both sides of every comparison go through this. */
|
|
65
|
+
export declare function foldCase(text: string): string;
|
|
66
|
+
export declare function Combobox(props: ComboboxProps): JSX.Element;
|
package/dist/combobox.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
|
|
2
|
+
// @tapestry-ui/combobox — the picker (Tapestry UI citizen #5).
|
|
3
|
+
//
|
|
4
|
+
// WHY IT EXISTS: a native <select> cannot be styled past a certain point, and
|
|
5
|
+
// it cannot be typed into. With eighteen properties in the list, "scroll until
|
|
6
|
+
// you see Fairview" is the wrong interaction — you know the name, you should be
|
|
7
|
+
// able to say it. This component is both halves at once: type to narrow, or hit
|
|
8
|
+
// the chevron and read the whole list like a select.
|
|
9
|
+
//
|
|
10
|
+
// A11y shape: the WAI-ARIA combobox pattern, list-autocomplete variant. The
|
|
11
|
+
// input carries role=combobox + aria-expanded + aria-controls, the popup is a
|
|
12
|
+
// listbox of options, and the keyboard's position is announced through
|
|
13
|
+
// aria-activedescendant — DOM focus never leaves the input, so a screen reader
|
|
14
|
+
// reads the option without losing the text field.
|
|
15
|
+
//
|
|
16
|
+
// THE CONTAINER CONTRACT, and its one exception: tapestry-ui components never
|
|
17
|
+
// own scrolling. The popup is the exception, and deliberately: it is an overlay
|
|
18
|
+
// over the page, not part of the consumer's flow, so a cap on its height
|
|
19
|
+
// constrains nothing the consumer laid out. The cap is a custom property
|
|
20
|
+
// (--tui-combobox-list-max-h) so it is still yours to set or remove.
|
|
21
|
+
//
|
|
22
|
+
// THE SELECT CONTRACT: a thing that replaces a <select> must never hold a value
|
|
23
|
+
// nobody chose. Typing is a QUERY, not a value — abandon the field half-typed
|
|
24
|
+
// and the committed label comes back. Only Enter, Tab, a click, or (with
|
|
25
|
+
// `allowCustom`) blur-on-free-text commits anything.
|
|
26
|
+
import { Fragment } from 'preact';
|
|
27
|
+
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
28
|
+
/** Options rendered at once. Past this, typing is the way through — a popup
|
|
29
|
+
* with four hundred rows is a scroll race, not a choice. */
|
|
30
|
+
const DEFAULT_MAX_VISIBLE = 100;
|
|
31
|
+
/** Space (px) the popup needs below the input before it gives up and opens upward. */
|
|
32
|
+
const FLIP_THRESHOLD_PX = 220;
|
|
33
|
+
export const EN_COMBOBOX = {
|
|
34
|
+
showAll: (label) => `Show all ${label} options`,
|
|
35
|
+
more: (count) => `${count} more — keep typing`,
|
|
36
|
+
optionCount: (count) => `${count} ${count === 1 ? 'option' : 'options'}`,
|
|
37
|
+
};
|
|
38
|
+
/** Lowercased and stripped of accents, so "Fairview" finds "fairview" and
|
|
39
|
+
* "Muñoz" finds "munoz". Both sides of every comparison go through this. */
|
|
40
|
+
export function foldCase(text) {
|
|
41
|
+
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
const labelOf = (option) => option.label ?? option.value;
|
|
44
|
+
/** The default match: the query appears anywhere in the label or description. */
|
|
45
|
+
const containsQuery = (query, option) => foldCase(labelOf(option)).includes(query) ||
|
|
46
|
+
(option.description !== undefined && foldCase(option.description).includes(query));
|
|
47
|
+
/** The label with every occurrence of the query wrapped, so the eye lands on
|
|
48
|
+
* the reason the row survived the filter. */
|
|
49
|
+
function Highlight({ text, query }) {
|
|
50
|
+
if (query === '')
|
|
51
|
+
return _jsx(_Fragment, { children: text });
|
|
52
|
+
const folded = foldCase(text);
|
|
53
|
+
const pieces = [];
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
let hit = folded.indexOf(query);
|
|
56
|
+
while (hit !== -1) {
|
|
57
|
+
if (hit > cursor)
|
|
58
|
+
pieces.push(_jsx(Fragment, { children: text.slice(cursor, hit) }, `t${cursor}`));
|
|
59
|
+
pieces.push(_jsx("mark", { "data-part": "match", children: text.slice(hit, hit + query.length) }, `m${hit}`));
|
|
60
|
+
cursor = hit + query.length;
|
|
61
|
+
hit = folded.indexOf(query, cursor);
|
|
62
|
+
}
|
|
63
|
+
pieces.push(_jsx(Fragment, { children: text.slice(cursor) }, "tail"));
|
|
64
|
+
return _jsx(_Fragment, { children: pieces });
|
|
65
|
+
}
|
|
66
|
+
export function Combobox(props) {
|
|
67
|
+
const autoId = useId();
|
|
68
|
+
const base = props.id ?? `tui-combobox-${autoId}`;
|
|
69
|
+
const listId = `${base}-list`;
|
|
70
|
+
const optionId = (index) => `${base}-opt-${index}`;
|
|
71
|
+
const words = { ...EN_COMBOBOX, ...props.strings };
|
|
72
|
+
const rootRef = useRef(null);
|
|
73
|
+
const inputRef = useRef(null);
|
|
74
|
+
const controlled = props.value !== undefined;
|
|
75
|
+
const [ownValue, setOwnValue] = useState(props.defaultValue ?? '');
|
|
76
|
+
const committed = controlled ? props.value : ownValue;
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
/** null means "not typing" — the field shows the committed label */
|
|
79
|
+
const [query, setQuery] = useState(null);
|
|
80
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
81
|
+
const [placement, setPlacement] = useState('below');
|
|
82
|
+
const chosen = props.options.find((option) => option.value === committed);
|
|
83
|
+
const committedText = chosen ? labelOf(chosen) : props.allowCustom ? committed : '';
|
|
84
|
+
const text = query ?? committedText;
|
|
85
|
+
// An empty query is the SAME as not typing: show everything. That is what
|
|
86
|
+
// makes the chevron and a cleared field behave identically.
|
|
87
|
+
const folded = query === null ? '' : foldCase(query.trim());
|
|
88
|
+
const match = props.filter ?? containsQuery;
|
|
89
|
+
const matching = useMemo(() => (folded === '' ? props.options : props.options.filter((option) => match(folded, option))), [folded, props.options, props.filter]);
|
|
90
|
+
const maxVisible = props.maxVisible ?? DEFAULT_MAX_VISIBLE;
|
|
91
|
+
const shown = matching.slice(0, maxVisible);
|
|
92
|
+
const hiddenCount = matching.length - shown.length;
|
|
93
|
+
/** Keep the keyboard's position inside the list as the list shrinks under it. */
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (activeIndex >= shown.length)
|
|
96
|
+
setActiveIndex(shown.length === 0 ? -1 : shown.length - 1);
|
|
97
|
+
}, [shown.length, activeIndex]);
|
|
98
|
+
/** Follow the active option with the popup's own scroll. jsdom has no
|
|
99
|
+
* scrollIntoView and an exception here would take the whole render with it. */
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!open || activeIndex < 0)
|
|
102
|
+
return;
|
|
103
|
+
document.getElementById(optionId(activeIndex))?.scrollIntoView?.({ block: 'nearest' });
|
|
104
|
+
}, [open, activeIndex]);
|
|
105
|
+
/** Open upward when there is no room below — a phone keyboard eats the bottom
|
|
106
|
+
* half of the screen, and a popup under the fold is a popup that isn't there. */
|
|
107
|
+
useLayoutEffect(() => {
|
|
108
|
+
if (!open)
|
|
109
|
+
return;
|
|
110
|
+
const box = inputRef.current?.getBoundingClientRect?.();
|
|
111
|
+
if (!box)
|
|
112
|
+
return;
|
|
113
|
+
const roomBelow = window.innerHeight - box.bottom;
|
|
114
|
+
setPlacement(roomBelow < FLIP_THRESHOLD_PX && box.top > roomBelow ? 'above' : 'below');
|
|
115
|
+
}, [open, shown.length]);
|
|
116
|
+
const commit = (value) => {
|
|
117
|
+
if (!controlled)
|
|
118
|
+
setOwnValue(value);
|
|
119
|
+
setQuery(null);
|
|
120
|
+
setOpen(false);
|
|
121
|
+
setActiveIndex(-1);
|
|
122
|
+
if (value !== committed)
|
|
123
|
+
props.onChange?.(value);
|
|
124
|
+
};
|
|
125
|
+
/** Back to the committed value with nothing chosen: typing was only ever a query. */
|
|
126
|
+
const abandon = () => {
|
|
127
|
+
setQuery(null);
|
|
128
|
+
setOpen(false);
|
|
129
|
+
setActiveIndex(-1);
|
|
130
|
+
};
|
|
131
|
+
const openFullList = () => {
|
|
132
|
+
setQuery(null);
|
|
133
|
+
setOpen(true);
|
|
134
|
+
const atCommitted = props.options.findIndex((option) => option.value === committed);
|
|
135
|
+
setActiveIndex(atCommitted);
|
|
136
|
+
};
|
|
137
|
+
/** The next selectable row in `step` direction, wrapping; disabled is never a
|
|
138
|
+
* destination. Returns -1 when every row is disabled. */
|
|
139
|
+
const nextEnabled = (from, step) => {
|
|
140
|
+
const count = shown.length;
|
|
141
|
+
if (count === 0)
|
|
142
|
+
return -1;
|
|
143
|
+
for (let hop = 1; hop <= count; hop++) {
|
|
144
|
+
const index = (((from + step * hop) % count) + count) % count;
|
|
145
|
+
if (!shown[index].disabled)
|
|
146
|
+
return index;
|
|
147
|
+
}
|
|
148
|
+
return -1;
|
|
149
|
+
};
|
|
150
|
+
const handleKey = (event) => {
|
|
151
|
+
if (props.disabled)
|
|
152
|
+
return;
|
|
153
|
+
switch (event.key) {
|
|
154
|
+
case 'ArrowDown':
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
if (!open) {
|
|
157
|
+
setOpen(true);
|
|
158
|
+
setActiveIndex(nextEnabled(-1, 1));
|
|
159
|
+
}
|
|
160
|
+
else
|
|
161
|
+
setActiveIndex(nextEnabled(activeIndex, 1));
|
|
162
|
+
return;
|
|
163
|
+
case 'ArrowUp':
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
if (!open) {
|
|
166
|
+
setOpen(true);
|
|
167
|
+
setActiveIndex(nextEnabled(0, -1));
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
setActiveIndex(nextEnabled(activeIndex, -1));
|
|
171
|
+
return;
|
|
172
|
+
case 'Home':
|
|
173
|
+
if (!open)
|
|
174
|
+
return;
|
|
175
|
+
event.preventDefault();
|
|
176
|
+
setActiveIndex(nextEnabled(-1, 1));
|
|
177
|
+
return;
|
|
178
|
+
case 'End':
|
|
179
|
+
if (!open)
|
|
180
|
+
return;
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
setActiveIndex(nextEnabled(0, -1));
|
|
183
|
+
return;
|
|
184
|
+
case 'Enter': {
|
|
185
|
+
const active = open && activeIndex >= 0 ? shown[activeIndex] : undefined;
|
|
186
|
+
if (active && !active.disabled) {
|
|
187
|
+
// the pick is the whole point of the keystroke — it must not also submit
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
commit(active.value);
|
|
190
|
+
}
|
|
191
|
+
else if (props.allowCustom && query !== null) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
commit(query.trim());
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case 'Escape':
|
|
198
|
+
// first Escape closes the popup, a second puts the committed label back
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
if (open) {
|
|
201
|
+
setOpen(false);
|
|
202
|
+
setActiveIndex(-1);
|
|
203
|
+
}
|
|
204
|
+
else
|
|
205
|
+
abandon();
|
|
206
|
+
return;
|
|
207
|
+
case 'Tab': {
|
|
208
|
+
// leaving the field is a decision: take the highlighted row with you
|
|
209
|
+
const active = open && activeIndex >= 0 ? shown[activeIndex] : undefined;
|
|
210
|
+
if (active && !active.disabled)
|
|
211
|
+
commit(active.value);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
/** Focus leaving the whole widget — not merely hopping input -> chevron. */
|
|
219
|
+
const handleFocusOut = (event) => {
|
|
220
|
+
const movingTo = event.relatedTarget;
|
|
221
|
+
if (movingTo && rootRef.current?.contains(movingTo))
|
|
222
|
+
return;
|
|
223
|
+
const typed = query?.trim() ?? '';
|
|
224
|
+
if (props.allowCustom && query !== null && typed !== committed)
|
|
225
|
+
commit(typed);
|
|
226
|
+
else
|
|
227
|
+
abandon();
|
|
228
|
+
};
|
|
229
|
+
const activeOption = activeIndex >= 0 ? shown[activeIndex] : undefined;
|
|
230
|
+
return (_jsxs("div", { ref: rootRef, id: base, class: cx('tui-combobox', props.class), "data-part": "root", "data-state": open ? 'open' : 'closed', "data-placement": placement, onFocusOut: handleFocusOut, children: [_jsx("label", { class: cx('tui-combobox__label', !props.showLabel && 'tui-combobox__sr'), "data-part": "label", for: `${base}-input`, children: props.label }), _jsxs("div", { class: "tui-combobox__field", "data-part": "field", children: [_jsx("input", { ref: inputRef, id: `${base}-input`, class: cx('tui-combobox__input', props.inputClass), "data-part": "input", type: "text", role: "combobox", autocomplete: "off", spellcheck: false, "aria-expanded": open, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": open && activeIndex >= 0 ? optionId(activeIndex) : undefined, "aria-required": props.required, placeholder: props.placeholder, disabled: props.disabled, value: text, onInput: (event) => {
|
|
231
|
+
setQuery(event.currentTarget.value);
|
|
232
|
+
setOpen(true);
|
|
233
|
+
setActiveIndex(0);
|
|
234
|
+
}, onFocus: () => { if (props.openOnFocus && !open)
|
|
235
|
+
openFullList(); }, onKeyDown: handleKey }), _jsx("button", { type: "button", class: "tui-combobox__toggle", "data-part": "toggle",
|
|
236
|
+
// the chevron is not a tab stop: the input is the widget's one stop,
|
|
237
|
+
// and ArrowDown already opens the list from there
|
|
238
|
+
tabIndex: -1, "aria-label": props.toggleLabel ?? words.showAll(props.label), "aria-expanded": open, "aria-controls": listId, disabled: props.disabled, onMouseDown: (event) => event.preventDefault(), onClick: () => {
|
|
239
|
+
if (open)
|
|
240
|
+
abandon();
|
|
241
|
+
else {
|
|
242
|
+
openFullList();
|
|
243
|
+
inputRef.current?.focus();
|
|
244
|
+
}
|
|
245
|
+
}, children: _jsx("svg", { viewBox: "0 0 10 6", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M1 1l4 4 4-4", fill: "none", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round" }) }) })] }), open && (_jsxs("ul", { id: listId, class: cx('tui-combobox__list', props.listClass), "data-part": "list", role: "listbox", "aria-label": props.label, children: [shown.length === 0 && (_jsx("li", { class: "tui-combobox__empty", "data-part": "empty", role: "presentation", children: props.emptyLabel ?? 'no match' })), shown.map((option, index) => (_jsxs("li", { id: optionId(index), class: cx('tui-combobox__option', props.optionClass), "data-part": "option", "data-state": index === activeIndex ? 'active' : 'idle', role: "option", "aria-selected": option.value === committed, "aria-disabled": option.disabled, onMouseDown: (event) => event.preventDefault(), onMouseMove: () => { if (!option.disabled)
|
|
246
|
+
setActiveIndex(index); }, onClick: () => { if (!option.disabled)
|
|
247
|
+
commit(option.value); }, children: [_jsx("span", { class: "tui-combobox__option-label", "data-part": "option-label", children: _jsx(Highlight, { text: labelOf(option), query: folded }) }), option.description !== undefined && (_jsx("span", { class: "tui-combobox__option-desc", "data-part": "option-desc", children: _jsx(Highlight, { text: option.description, query: folded }) }))] }, option.value))), hiddenCount > 0 && (_jsx("li", { class: "tui-combobox__more", "data-part": "more", role: "presentation", children: words.more(hiddenCount) }))] })), _jsxs("span", { class: "tui-combobox__sr", "data-part": "status", role: "status", "aria-live": "polite", children: [open ? words.optionCount(matching.length) : '', activeOption ? `, ${labelOf(activeOption)}` : ''] }), props.name !== undefined && (_jsx("input", { type: "hidden", name: props.name, value: committed, "data-part": "value" }))] }));
|
|
248
|
+
}
|
|
249
|
+
function cx(...parts) {
|
|
250
|
+
return parts.filter(Boolean).join(' ');
|
|
251
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tapestry-ui/combobox",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A WCAG 2.1 AA combobox for Preact \u2014 type to filter or open the whole list, APG keyboard, match highlighting, headless behavior, skin via CSS. The polished replacement for a native select.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Brandon Minton",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"*.css"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/combobox.d.ts",
|
|
14
|
+
"default": "./dist/combobox.js"
|
|
15
|
+
},
|
|
16
|
+
"./styles.css": "./styles.css"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"styles.css",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.build.json",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"preact": ">=10.24.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"jsdom": "^25.0.1",
|
|
36
|
+
"preact": "^10.29.4",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"vitest": "^4.1.10"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/ryurage/brandonminton.git",
|
|
43
|
+
"directory": "packages/tapestry-ui/combobox"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"preact",
|
|
47
|
+
"combobox",
|
|
48
|
+
"autocomplete",
|
|
49
|
+
"typeahead",
|
|
50
|
+
"select",
|
|
51
|
+
"a11y",
|
|
52
|
+
"wcag",
|
|
53
|
+
"headless",
|
|
54
|
+
"tapestry-ui"
|
|
55
|
+
]
|
|
56
|
+
}
|
package/styles.css
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/* @tapestry-ui/combobox — STRUCTURAL styles only. The skin is yours:
|
|
2
|
+
override the custom properties, or ignore this file entirely and style the
|
|
3
|
+
[data-part] hooks from scratch. Nothing here picks a color. */
|
|
4
|
+
.tui-combobox { position: relative; }
|
|
5
|
+
|
|
6
|
+
.tui-combobox__field { position: relative; display: flex; align-items: stretch; }
|
|
7
|
+
.tui-combobox__input {
|
|
8
|
+
font: inherit;
|
|
9
|
+
color: inherit;
|
|
10
|
+
background: none;
|
|
11
|
+
flex: 1;
|
|
12
|
+
min-width: 0; /* a flex child with a text input in it will not shrink without this */
|
|
13
|
+
padding: var(--tui-combobox-pad, 0.4em 2em 0.4em 0.6em);
|
|
14
|
+
}
|
|
15
|
+
.tui-combobox__input:focus-visible {
|
|
16
|
+
outline: var(--tui-combobox-focus, 2px solid currentColor);
|
|
17
|
+
outline-offset: 2px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* the chevron sits INSIDE the field's right edge, over the input's padding */
|
|
21
|
+
.tui-combobox__toggle {
|
|
22
|
+
position: absolute;
|
|
23
|
+
inset: 0 0 0 auto;
|
|
24
|
+
width: var(--tui-combobox-toggle-w, 2em);
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
background: none;
|
|
29
|
+
border: none;
|
|
30
|
+
color: inherit;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
padding: 0;
|
|
33
|
+
}
|
|
34
|
+
.tui-combobox__toggle svg { width: var(--tui-combobox-chevron-w, 0.625em); display: block; }
|
|
35
|
+
.tui-combobox__toggle:disabled { cursor: default; opacity: 0.5; }
|
|
36
|
+
.tui-combobox[data-state="open"] .tui-combobox__toggle svg { transform: rotate(180deg); }
|
|
37
|
+
|
|
38
|
+
/* THE ONE PLACE THIS LIBRARY SCROLLS. The popup is an overlay, not part of the
|
|
39
|
+
consumer's flow, so a height cap here constrains nothing they laid out — and
|
|
40
|
+
it is still a custom property, so `none` gives you the uncapped list. */
|
|
41
|
+
.tui-combobox__list {
|
|
42
|
+
position: absolute;
|
|
43
|
+
z-index: var(--tui-combobox-z, 20);
|
|
44
|
+
left: 0;
|
|
45
|
+
right: 0;
|
|
46
|
+
margin: 0;
|
|
47
|
+
padding: 0;
|
|
48
|
+
list-style: none;
|
|
49
|
+
max-height: var(--tui-combobox-list-max-h, 15em);
|
|
50
|
+
overflow-y: auto;
|
|
51
|
+
overscroll-behavior: contain; /* scrolling the list must not scroll the page behind it */
|
|
52
|
+
}
|
|
53
|
+
.tui-combobox[data-placement="below"] .tui-combobox__list { top: 100%; }
|
|
54
|
+
.tui-combobox[data-placement="above"] .tui-combobox__list { bottom: 100%; }
|
|
55
|
+
|
|
56
|
+
.tui-combobox__option {
|
|
57
|
+
display: block;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
padding: var(--tui-combobox-option-pad, 0.4em 0.6em);
|
|
60
|
+
}
|
|
61
|
+
.tui-combobox__option[aria-disabled="true"] { cursor: default; opacity: 0.5; }
|
|
62
|
+
.tui-combobox__option-label { display: block; }
|
|
63
|
+
.tui-combobox__option-desc { display: block; }
|
|
64
|
+
.tui-combobox__option mark { background: none; color: inherit; font-weight: 700; }
|
|
65
|
+
|
|
66
|
+
.tui-combobox__empty,
|
|
67
|
+
.tui-combobox__more { padding: var(--tui-combobox-option-pad, 0.4em 0.6em); }
|
|
68
|
+
|
|
69
|
+
/* visually hidden, still read aloud — the label and the live count live here */
|
|
70
|
+
.tui-combobox__sr {
|
|
71
|
+
position: absolute;
|
|
72
|
+
width: 1px;
|
|
73
|
+
height: 1px;
|
|
74
|
+
margin: -1px;
|
|
75
|
+
padding: 0;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
clip-path: inset(50%);
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
border: 0;
|
|
80
|
+
}
|