@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 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
- # Temporary Holding Version
1
+ # @tapestry-ui/combobox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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;
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }