@lumiose/ui 0.0.0-stage → 0.1.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 +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// Combobox (decision-62, TASK-358): an input that filters a list of options, after the APG combobox
|
|
2
|
+
// pattern with list autocomplete and manual selection, and Base UI's anatomy. Root renders
|
|
3
|
+
// `<lu-combobox>`, a light-DOM, form-associated element whose script (combobox.script.ts) owns the
|
|
4
|
+
// behaviour: the server HTML carries the roles, the wiring and the selection, and the element's DOM
|
|
5
|
+
// is the state from then on. The `value` and `open` props drive it when they change, and its
|
|
6
|
+
// `change` and `openchange` events report back through `onValueChange` and `onOpenChange`. The
|
|
7
|
+
// input shows the selected option's label; the form gets its value. Ported from Timeless's
|
|
8
|
+
// approach (https://timeless.build).
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
effect,
|
|
12
|
+
isServer,
|
|
13
|
+
ref,
|
|
14
|
+
signal,
|
|
15
|
+
untrack,
|
|
16
|
+
useContext,
|
|
17
|
+
useId,
|
|
18
|
+
type Signal,
|
|
19
|
+
} from "lumiose";
|
|
20
|
+
import type {
|
|
21
|
+
ComboboxElement,
|
|
22
|
+
ComboboxFilter,
|
|
23
|
+
ComboboxOpenChangeDetail,
|
|
24
|
+
} from "./combobox.script.ts";
|
|
25
|
+
import comboboxScript from "./combobox.script.ts?client";
|
|
26
|
+
import { controlId, describedBy, labelledBy, useField, useFormValues } from "../field/context.ts";
|
|
27
|
+
import { onFormReset } from "../form/reset.ts";
|
|
28
|
+
import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
|
|
29
|
+
import { ComboboxContext, register, useCombobox, type ComboboxContextValue } from "./context.ts";
|
|
30
|
+
|
|
31
|
+
declare module "lumiose/jsx-runtime" {
|
|
32
|
+
namespace JSX {
|
|
33
|
+
interface IntrinsicElements {
|
|
34
|
+
/**
|
|
35
|
+
* Combobox's element, rendered by `Combobox.Root` and `Autocomplete.Root`: a form control
|
|
36
|
+
* with no box. Properties `value` (an array with `multiple`), `open`, `name`, `required`,
|
|
37
|
+
* `disabled`, `multiple`; attributes `filter`, and for an Autocomplete `free` (the input's
|
|
38
|
+
* text is the value), `inline` (the arrows complete the text) and `open-on-input-click`;
|
|
39
|
+
* events `change` and `openchange` (`detail.open`).
|
|
40
|
+
*/
|
|
41
|
+
"lu-combobox": JSX.HTMLProps<ComboboxElement> & {
|
|
42
|
+
name?: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
multiple?: boolean;
|
|
46
|
+
filter?: ComboboxFilter;
|
|
47
|
+
free?: boolean;
|
|
48
|
+
inline?: boolean;
|
|
49
|
+
"open-on-input-click"?: boolean;
|
|
50
|
+
"auto-highlight"?: boolean;
|
|
51
|
+
"keep-highlight"?: boolean;
|
|
52
|
+
"submit-on-item-click"?: boolean;
|
|
53
|
+
limit?: number;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The value of a combobox: a string, or the selected strings with `multiple`. */
|
|
60
|
+
export type ComboboxValue<Multiple extends boolean | undefined> = Multiple extends true
|
|
61
|
+
? string[]
|
|
62
|
+
: string;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The element props Root passes to `<lu-combobox>` (`class`, `style`, `data-*`, events), without
|
|
66
|
+
* the brand of intrinsic elements' props, so that `bind:value` and `bind:open` apply to Root.
|
|
67
|
+
*/
|
|
68
|
+
type ElementProps = {
|
|
69
|
+
[
|
|
70
|
+
K in keyof JSX.HTMLProps<ComboboxElement> as K extends string ? K : never
|
|
71
|
+
]: JSX.HTMLProps<ComboboxElement>[K];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `Combobox.Root` renders `<lu-combobox>`, the form control around the other parts.
|
|
76
|
+
* `bind:value={signal}` stands in for `value` and `onValueChange`, `bind:open` for `open` and
|
|
77
|
+
* `onOpenChange`.
|
|
78
|
+
*/
|
|
79
|
+
export interface ComboboxRootProps<Multiple extends boolean | undefined = false> extends Omit<
|
|
80
|
+
ElementProps,
|
|
81
|
+
"children" | "onChange"
|
|
82
|
+
> {
|
|
83
|
+
/**
|
|
84
|
+
* Selects the option with this value (with `multiple`, these values) when it changes. Pair it
|
|
85
|
+
* with `onValueChange` to keep a signal in sync; leave both out and the combobox keeps its own
|
|
86
|
+
* state. `""` (or `[]`) selects nothing.
|
|
87
|
+
*/
|
|
88
|
+
value?: ComboboxValue<Multiple>;
|
|
89
|
+
/** The selection at first, when `value` is left out. */
|
|
90
|
+
defaultValue?: ComboboxValue<Multiple>;
|
|
91
|
+
/** Called after the user chooses, removes or clears a value, with the new value. */
|
|
92
|
+
onValueChange?: (value: ComboboxValue<Multiple>) => void;
|
|
93
|
+
/**
|
|
94
|
+
* Opens (`true`) or closes (`false`) the popup when it changes, in the browser. Pair it with
|
|
95
|
+
* `onOpenChange`; server HTML always starts closed.
|
|
96
|
+
*/
|
|
97
|
+
open?: boolean;
|
|
98
|
+
/** Called after the popup opens or closes, whatever opened or closed it. */
|
|
99
|
+
onOpenChange?: (open: boolean) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Several values can be selected: choosing an option toggles it, the popup stays open, and the
|
|
102
|
+
* values show as `Combobox.Chips`. `value` is then an array.
|
|
103
|
+
*/
|
|
104
|
+
multiple?: Multiple;
|
|
105
|
+
/** The name the value is submitted under; with `multiple`, each value is its own entry. */
|
|
106
|
+
name?: string;
|
|
107
|
+
/** The form needs a value: the browser reports it empty, at the input, on submit. */
|
|
108
|
+
required?: boolean;
|
|
109
|
+
/** The combobox cannot be used: the input and the buttons are disabled. */
|
|
110
|
+
disabled?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* How typing filters the options by their label: `"contains"` (default) or `"startsWith"`,
|
|
113
|
+
* ignoring case and accents, or `"none"` to filter them yourself (async options).
|
|
114
|
+
*/
|
|
115
|
+
filter?: ComboboxFilter;
|
|
116
|
+
/** The other parts: `Label`, `Input`, the buttons, `Chips`, and the `List` or `Popup`. */
|
|
117
|
+
children?: JSX.Element;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The `<label>` of the input. */
|
|
121
|
+
export type ComboboxLabelProps = JSX.IntrinsicElements["label"];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The `<input role="combobox">` the user types in. It shows the selected option's label, filters
|
|
125
|
+
* the options and keeps focus while the arrows move through them.
|
|
126
|
+
*/
|
|
127
|
+
export type ComboboxInputProps = Omit<JSX.IntrinsicElements["input"], "type" | "value">;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A `<button>` that opens and closes the popup (`command="--toggle"`), out of the tab order: name
|
|
131
|
+
* it with `aria-label` or its content.
|
|
132
|
+
*/
|
|
133
|
+
export type ComboboxTriggerProps = JSX.IntrinsicElements["button"];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A `<button>` that clears the value and the text (`command="--clear"`), out of the tab order and
|
|
137
|
+
* hidden while there is nothing to clear: name it with `aria-label` or its content.
|
|
138
|
+
*/
|
|
139
|
+
export type ComboboxClearProps = JSX.IntrinsicElements["button"];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A `<div popover>` around the `List`, for an empty state, a status or anything else beside the
|
|
143
|
+
* options. Without it, the `List` is the popup. Placed next to the input.
|
|
144
|
+
*/
|
|
145
|
+
export type ComboboxPopupProps = JSX.IntrinsicElements["div"] & PlacementProps;
|
|
146
|
+
|
|
147
|
+
/** The `<div role="listbox">` of options: the popup itself, unless it is inside a `Popup`. */
|
|
148
|
+
export type ComboboxListProps = JSX.IntrinsicElements["div"] & PlacementProps;
|
|
149
|
+
|
|
150
|
+
/** A `<div role="option">`. */
|
|
151
|
+
export type ComboboxItemProps = Omit<JSX.IntrinsicElements["div"], "value"> & {
|
|
152
|
+
/** The value the combobox takes, and submits, when the option is chosen. */
|
|
153
|
+
value: string;
|
|
154
|
+
/** The text the input shows and typing matches, when the option's text content is not it. */
|
|
155
|
+
label?: string;
|
|
156
|
+
/** The option cannot be chosen, and the arrow keys skip it (`aria-disabled`). */
|
|
157
|
+
disabled?: boolean;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** A `<div role="group">` of options, named by its `GroupLabel`; hidden while none matches. */
|
|
161
|
+
export type ComboboxGroupProps = JSX.IntrinsicElements["div"];
|
|
162
|
+
|
|
163
|
+
/** A `<div role="presentation">` that names its group. */
|
|
164
|
+
export type ComboboxGroupLabelProps = JSX.IntrinsicElements["div"];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A `<div role="status">` shown instead of the options when none matches the text; the popup stays
|
|
168
|
+
* open to show it. Put it in a `Popup`, next to the `List`.
|
|
169
|
+
*/
|
|
170
|
+
export type ComboboxEmptyProps = JSX.IntrinsicElements["div"];
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A `<div role="status">` whose text changes are announced, for a loading or result-count
|
|
174
|
+
* message. Put it in a `Popup`, next to the `List`.
|
|
175
|
+
*/
|
|
176
|
+
export type ComboboxStatusProps = JSX.IntrinsicElements["div"];
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The `<div role="list">` of selected values, with `multiple`: the script renders a chip for each
|
|
180
|
+
* one, a `<span role="listitem">` with the label and a remove button.
|
|
181
|
+
*/
|
|
182
|
+
export type ComboboxChipsProps = Omit<JSX.IntrinsicElements["div"], "children"> & {
|
|
183
|
+
/** The remove buttons' name, before the value's label. Default `"Remove"`. */
|
|
184
|
+
removeLabel?: string;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const GroupContext = createContext<{ labelId: string; labels: Signal<number> }>();
|
|
188
|
+
|
|
189
|
+
const list = (value: string | readonly string[] | undefined): string[] =>
|
|
190
|
+
value === undefined || value === "" ? [] : typeof value === "string" ? [value] : [...value];
|
|
191
|
+
|
|
192
|
+
/** An id fragment for a value: ids may not hold spaces. */
|
|
193
|
+
const fragment = (value: string) => encodeURIComponent(value).replaceAll("%", "_");
|
|
194
|
+
|
|
195
|
+
function Root<Multiple extends boolean | undefined = false>({
|
|
196
|
+
value,
|
|
197
|
+
defaultValue,
|
|
198
|
+
onValueChange,
|
|
199
|
+
open,
|
|
200
|
+
onOpenChange,
|
|
201
|
+
multiple,
|
|
202
|
+
name,
|
|
203
|
+
required,
|
|
204
|
+
disabled,
|
|
205
|
+
filter,
|
|
206
|
+
children,
|
|
207
|
+
...rest
|
|
208
|
+
}: ComboboxRootProps<Multiple>) {
|
|
209
|
+
// The behaviour, on the server HTML and in islands; the page loads it once (decision-63).
|
|
210
|
+
comboboxScript();
|
|
211
|
+
const id = useId();
|
|
212
|
+
// Inside a Field: the field's id, name, state and ARIA references (TASK-404).
|
|
213
|
+
const field = useField();
|
|
214
|
+
const host = ref<ComboboxElement>();
|
|
215
|
+
// A failed submission's values (a Form's `values`) replace the default (TASK-430).
|
|
216
|
+
const sent = useFormValues(
|
|
217
|
+
untrack(() => name ?? field?.name()),
|
|
218
|
+
untrack(() => disabled),
|
|
219
|
+
);
|
|
220
|
+
const selected = signal<string[]>(
|
|
221
|
+
untrack(() => (value === undefined && sent?.length ? sent : list(value ?? defaultValue))),
|
|
222
|
+
);
|
|
223
|
+
const context: ComboboxContextValue = {
|
|
224
|
+
id,
|
|
225
|
+
inputId: field ? controlId(field) : `${id}-input`,
|
|
226
|
+
selected,
|
|
227
|
+
multiple: () => multiple === true,
|
|
228
|
+
disabled: () => disabled === true || field?.disabled() === true,
|
|
229
|
+
required: () => required === true,
|
|
230
|
+
labels: signal(0),
|
|
231
|
+
popup: false,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// The props drive the element when they change; its events report back, so a signal written by
|
|
235
|
+
// a callback sees the same value again and nothing loops.
|
|
236
|
+
effect(() => {
|
|
237
|
+
const element = host();
|
|
238
|
+
const wanted = value;
|
|
239
|
+
if (!element?.isConnected || wanted === undefined) return;
|
|
240
|
+
element.value = wanted;
|
|
241
|
+
selected.set(list(wanted));
|
|
242
|
+
});
|
|
243
|
+
effect(() => {
|
|
244
|
+
const element = host();
|
|
245
|
+
const wanted = open;
|
|
246
|
+
if (element?.isConnected && wanted !== undefined) element.open = wanted;
|
|
247
|
+
});
|
|
248
|
+
// A form reset puts the selection back without a `change`: it is reported (TASK-435).
|
|
249
|
+
onFormReset(
|
|
250
|
+
host,
|
|
251
|
+
() => host()!.value,
|
|
252
|
+
(next) => {
|
|
253
|
+
selected.set(list(next));
|
|
254
|
+
onValueChange?.(next as ComboboxValue<Multiple>);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<ComboboxContext value={context}>
|
|
260
|
+
<lu-combobox
|
|
261
|
+
{...rest}
|
|
262
|
+
ref={host}
|
|
263
|
+
id={id}
|
|
264
|
+
name={name ?? field?.name()}
|
|
265
|
+
required={required}
|
|
266
|
+
disabled={context.disabled()}
|
|
267
|
+
multiple={multiple}
|
|
268
|
+
filter={filter}
|
|
269
|
+
on:change={() => {
|
|
270
|
+
const next = host()!.value;
|
|
271
|
+
selected.set(list(next));
|
|
272
|
+
onValueChange?.(next as ComboboxValue<Multiple>);
|
|
273
|
+
}}
|
|
274
|
+
on:openchange={(event: CustomEvent<ComboboxOpenChangeDetail>) =>
|
|
275
|
+
onOpenChange?.(event.detail.open)
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
{children}
|
|
279
|
+
</lu-combobox>
|
|
280
|
+
</ComboboxContext>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Each part spreads the caller's props first, so they override the defaults, and sets the wiring
|
|
285
|
+
// last, so the roles, ids, references and commands stay consistent between the parts.
|
|
286
|
+
|
|
287
|
+
function Label({ children, ...rest }: ComboboxLabelProps) {
|
|
288
|
+
const combobox = useCombobox();
|
|
289
|
+
// A caller's `id` names it; the listbox, after it, points at that (TASK-441 M39).
|
|
290
|
+
if (rest.id) combobox.labelId = rest.id;
|
|
291
|
+
register(combobox.labels);
|
|
292
|
+
return (
|
|
293
|
+
<label {...rest} id={combobox.labelId ?? `${combobox.id}-label`} for={combobox.inputId}>
|
|
294
|
+
{children}
|
|
295
|
+
</label>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function Input({ style, ...rest }: ComboboxInputProps) {
|
|
300
|
+
const combobox = useCombobox();
|
|
301
|
+
const field = useField();
|
|
302
|
+
// In an Autocomplete the input is the form control (name, required, its text); in a Combobox the
|
|
303
|
+
// element is.
|
|
304
|
+
const own = untrack(() => {
|
|
305
|
+
const text = combobox.text;
|
|
306
|
+
return text ? { name: text.name(), required: text.required(), value: text.initial } : {};
|
|
307
|
+
});
|
|
308
|
+
return (
|
|
309
|
+
<input
|
|
310
|
+
autocomplete="off"
|
|
311
|
+
spellcheck={false}
|
|
312
|
+
aria-describedby={field ? describedBy(field) : undefined}
|
|
313
|
+
aria-invalid={field?.invalid() ? "true" : undefined}
|
|
314
|
+
{...rest}
|
|
315
|
+
{...own}
|
|
316
|
+
type="text"
|
|
317
|
+
id={combobox.inputId}
|
|
318
|
+
role="combobox"
|
|
319
|
+
aria-autocomplete={combobox.text?.autocomplete ?? "list"}
|
|
320
|
+
// The element holds `required`, but has no role: the input tells assistive technology.
|
|
321
|
+
aria-required={combobox.required?.() ? "true" : undefined}
|
|
322
|
+
aria-expanded="false"
|
|
323
|
+
aria-controls={`${combobox.id}-list`}
|
|
324
|
+
disabled={combobox.disabled() || rest.disabled}
|
|
325
|
+
style={withStyle(style, { "anchor-name": anchorName(combobox.id) })}
|
|
326
|
+
/>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function Trigger({ children, ...rest }: ComboboxTriggerProps) {
|
|
331
|
+
const combobox = useCombobox();
|
|
332
|
+
return (
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
tabindex={-1}
|
|
336
|
+
{...rest}
|
|
337
|
+
disabled={combobox.disabled() || rest.disabled}
|
|
338
|
+
// The popup it opens and closes, as the input says (TASK-454, L32).
|
|
339
|
+
aria-haspopup="listbox"
|
|
340
|
+
aria-controls={`${combobox.id}-list`}
|
|
341
|
+
aria-expanded="false"
|
|
342
|
+
command="--toggle"
|
|
343
|
+
commandfor={combobox.id}
|
|
344
|
+
>
|
|
345
|
+
{children}
|
|
346
|
+
</button>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function Clear({ children, ...rest }: ComboboxClearProps) {
|
|
351
|
+
const combobox = useCombobox();
|
|
352
|
+
// The script shows and hides it from then on.
|
|
353
|
+
const empty = untrack(() =>
|
|
354
|
+
combobox.text ? !combobox.text.initial : combobox.selected().length === 0,
|
|
355
|
+
);
|
|
356
|
+
return (
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
tabindex={-1}
|
|
360
|
+
hidden={empty}
|
|
361
|
+
{...rest}
|
|
362
|
+
disabled={combobox.disabled() || rest.disabled}
|
|
363
|
+
command="--clear"
|
|
364
|
+
commandfor={combobox.id}
|
|
365
|
+
>
|
|
366
|
+
{children}
|
|
367
|
+
</button>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function Popup({
|
|
372
|
+
children,
|
|
373
|
+
side,
|
|
374
|
+
align,
|
|
375
|
+
sideOffset,
|
|
376
|
+
alignOffset,
|
|
377
|
+
style,
|
|
378
|
+
...rest
|
|
379
|
+
}: ComboboxPopupProps) {
|
|
380
|
+
const combobox = useCombobox();
|
|
381
|
+
return (
|
|
382
|
+
<ComboboxContext value={{ ...combobox, popup: true }}>
|
|
383
|
+
<div
|
|
384
|
+
{...rest}
|
|
385
|
+
id={`${combobox.id}-popup`}
|
|
386
|
+
popover="manual"
|
|
387
|
+
{...placement(
|
|
388
|
+
{ side, align, sideOffset, alignOffset, style },
|
|
389
|
+
combobox.id,
|
|
390
|
+
"bottom",
|
|
391
|
+
"start",
|
|
392
|
+
)}
|
|
393
|
+
>
|
|
394
|
+
{children}
|
|
395
|
+
</div>
|
|
396
|
+
</ComboboxContext>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function List({
|
|
401
|
+
children,
|
|
402
|
+
side,
|
|
403
|
+
align,
|
|
404
|
+
sideOffset,
|
|
405
|
+
alignOffset,
|
|
406
|
+
style,
|
|
407
|
+
...rest
|
|
408
|
+
}: ComboboxListProps) {
|
|
409
|
+
const combobox = useCombobox();
|
|
410
|
+
const field = useField();
|
|
411
|
+
// The listbox is the popover unless a Popup holds it. A function, so the placement and `style`
|
|
412
|
+
// follow the props (TASK-477).
|
|
413
|
+
const popover = () =>
|
|
414
|
+
combobox.popup
|
|
415
|
+
? { style }
|
|
416
|
+
: {
|
|
417
|
+
popover: "manual" as const,
|
|
418
|
+
...placement(
|
|
419
|
+
{ side, align, sideOffset, alignOffset, style },
|
|
420
|
+
combobox.id,
|
|
421
|
+
"bottom",
|
|
422
|
+
"start",
|
|
423
|
+
),
|
|
424
|
+
};
|
|
425
|
+
return (
|
|
426
|
+
<div
|
|
427
|
+
// Named by the Label, or in a field by the field's (TASK-454, L32).
|
|
428
|
+
aria-labelledby={
|
|
429
|
+
combobox.labels() > 0 ? (combobox.labelId ?? `${combobox.id}-label`) : labelledBy(field)
|
|
430
|
+
}
|
|
431
|
+
{...rest}
|
|
432
|
+
{...popover()}
|
|
433
|
+
id={`${combobox.id}-list`}
|
|
434
|
+
role="listbox"
|
|
435
|
+
aria-multiselectable={combobox.multiple() || undefined}
|
|
436
|
+
>
|
|
437
|
+
{children}
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function Item({ children, value, label, disabled, ...rest }: ComboboxItemProps) {
|
|
443
|
+
const combobox = useCombobox();
|
|
444
|
+
return (
|
|
445
|
+
<div
|
|
446
|
+
{...rest}
|
|
447
|
+
// Nothing points at an option before the script highlights it: a caller's `id` names it.
|
|
448
|
+
id={rest.id ?? `${combobox.id}-option-${fragment(value)}`}
|
|
449
|
+
role="option"
|
|
450
|
+
data-value={value}
|
|
451
|
+
data-label={label}
|
|
452
|
+
// An Autocomplete selects nothing: its options carry no `aria-selected` (TASK-454, L32).
|
|
453
|
+
aria-selected={combobox.text ? undefined : combobox.selected().includes(value)}
|
|
454
|
+
aria-disabled={disabled || undefined}
|
|
455
|
+
>
|
|
456
|
+
{children}
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function Group(props: ComboboxGroupProps) {
|
|
462
|
+
useCombobox();
|
|
463
|
+
const id = useId();
|
|
464
|
+
const group = { labelId: `${id}-label`, labels: signal(0) };
|
|
465
|
+
return (
|
|
466
|
+
<GroupContext value={group}>
|
|
467
|
+
<GroupElement {...props} group={group} />
|
|
468
|
+
</GroupContext>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The group's `<div>`, inside its context, labelled by its `GroupLabel` if any. */
|
|
473
|
+
function GroupElement({
|
|
474
|
+
group,
|
|
475
|
+
children,
|
|
476
|
+
...rest
|
|
477
|
+
}: ComboboxGroupProps & { group: { labelId: string; labels: Signal<number> } }) {
|
|
478
|
+
// The server writes the attributes before it renders the children, so it renders them first to
|
|
479
|
+
// know whether a GroupLabel exists. The browser claims the group before its children (hydration
|
|
480
|
+
// needs that order) and updates the reference when the label registers.
|
|
481
|
+
const content = isServer ? untrack(() => children) : undefined;
|
|
482
|
+
return (
|
|
483
|
+
<div aria-labelledby={group.labels() > 0 ? group.labelId : undefined} {...rest} role="group">
|
|
484
|
+
{isServer ? content : children}
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function GroupLabel({ children, ...rest }: ComboboxGroupLabelProps) {
|
|
490
|
+
const group = useContext(GroupContext);
|
|
491
|
+
if (!group) throw new Error("Combobox.GroupLabel must be inside <Combobox.Group>.");
|
|
492
|
+
register(group.labels);
|
|
493
|
+
return (
|
|
494
|
+
<div role="presentation" {...rest} id={group.labelId}>
|
|
495
|
+
{children}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function Empty({ children, ...rest }: ComboboxEmptyProps) {
|
|
501
|
+
const combobox = useCombobox();
|
|
502
|
+
// Shown by the script when no option matches.
|
|
503
|
+
return (
|
|
504
|
+
<div {...rest} id={`${combobox.id}-empty`} role="status" hidden>
|
|
505
|
+
{children}
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function Status({ children, ...rest }: ComboboxStatusProps) {
|
|
511
|
+
useCombobox();
|
|
512
|
+
return (
|
|
513
|
+
<div {...rest} role="status">
|
|
514
|
+
{children}
|
|
515
|
+
</div>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function Chips({ removeLabel, ...rest }: ComboboxChipsProps) {
|
|
520
|
+
const combobox = useCombobox();
|
|
521
|
+
// The script renders the chips: it knows the labels of the selected values.
|
|
522
|
+
return <div {...rest} id={`${combobox.id}-chips`} role="list" data-remove-label={removeLabel} />;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A text input that filters a list of options, for picking one value (or several, with
|
|
527
|
+
* `multiple`) from many: the APG combobox pattern with list autocomplete, after Base UI's
|
|
528
|
+
* Combobox. The input shows the option's label, the form gets its value. `<lu-combobox>`'s script
|
|
529
|
+
* runs it, on server-rendered pages too. Unstyled: style the parts through `class` and
|
|
530
|
+
* `[aria-expanded]`, `:popover-open`, `[aria-selected]`, `[aria-disabled]`, `[data-highlighted]`.
|
|
531
|
+
*
|
|
532
|
+
* @example
|
|
533
|
+
* <Combobox.Root name="city" bind:value={city}>
|
|
534
|
+
* <Combobox.Label>City</Combobox.Label>
|
|
535
|
+
* <Combobox.Input placeholder="Search cities" />
|
|
536
|
+
* <Combobox.List>
|
|
537
|
+
* <Combobox.Item value="ber">Berlin</Combobox.Item>
|
|
538
|
+
* <Combobox.Item value="lis">Lisbon</Combobox.Item>
|
|
539
|
+
* </Combobox.List>
|
|
540
|
+
* </Combobox.Root>
|
|
541
|
+
*/
|
|
542
|
+
export const Combobox = {
|
|
543
|
+
Root,
|
|
544
|
+
Label,
|
|
545
|
+
Input,
|
|
546
|
+
Trigger,
|
|
547
|
+
Clear,
|
|
548
|
+
Chips,
|
|
549
|
+
Popup,
|
|
550
|
+
List,
|
|
551
|
+
Item,
|
|
552
|
+
Group,
|
|
553
|
+
GroupLabel,
|
|
554
|
+
Empty,
|
|
555
|
+
Status,
|
|
556
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The context the Combobox parts share, with Autocomplete's Root (TASK-377): the same parts work
|
|
2
|
+
// under either Root, which says how the input behaves.
|
|
3
|
+
import { createContext, onCleanup, useContext, type Signal } from "lumiose";
|
|
4
|
+
|
|
5
|
+
export interface ComboboxContextValue {
|
|
6
|
+
/** The element's id; the parts' ids and the anchor name derive from it. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** The input's id, which a Label points at: the Field's control id inside a Field. */
|
|
9
|
+
inputId: string;
|
|
10
|
+
/** The selected values, as the element last reported them (none in an Autocomplete). */
|
|
11
|
+
selected: () => string[];
|
|
12
|
+
multiple: () => boolean;
|
|
13
|
+
disabled: () => boolean;
|
|
14
|
+
/** The combobox needs a value: its input says so with `aria-required` (TASK-431). */
|
|
15
|
+
required?: () => boolean;
|
|
16
|
+
/** How many Labels are rendered: the listbox is named by the label only when there is one. */
|
|
17
|
+
labels: Signal<number>;
|
|
18
|
+
/** A Label's own `id`, which the listbox points at (TASK-441 M39). */
|
|
19
|
+
labelId?: string;
|
|
20
|
+
/** Whether the parts are inside a `Popup`: the `List` is then not the popover. */
|
|
21
|
+
popup: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* In an Autocomplete, the input itself is the form control: its name, `required`, its first
|
|
24
|
+
* text, and the kind of completion it announces (`aria-autocomplete`).
|
|
25
|
+
*/
|
|
26
|
+
text?: {
|
|
27
|
+
name: () => string | undefined;
|
|
28
|
+
required: () => boolean | undefined;
|
|
29
|
+
initial: string | undefined;
|
|
30
|
+
autocomplete: "list" | "both" | "inline" | "none";
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const ComboboxContext = createContext<ComboboxContextValue>();
|
|
35
|
+
|
|
36
|
+
export function useCombobox(): ComboboxContextValue {
|
|
37
|
+
const context = useContext(ComboboxContext);
|
|
38
|
+
if (!context) throw new Error("Combobox parts must be inside <Combobox.Root>.");
|
|
39
|
+
return context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Counts a rendered part in `count` while it is mounted. */
|
|
43
|
+
export function register(count: Signal<number>) {
|
|
44
|
+
count.update((n) => n + 1);
|
|
45
|
+
onCleanup(() => count.update((n) => n - 1));
|
|
46
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Context Menu is Menu, opened at the pointer: `@lumiose/ui/menu.css` lays out its popup and
|
|
3
|
+
* items. `<lu-context-menu>` adds no box, and the trigger area keeps iOS's callout (the long-press
|
|
4
|
+
* preview) from showing over the menu.
|
|
5
|
+
*/
|
|
6
|
+
@layer lu.core, lu.theme;
|
|
7
|
+
@import "../menu/menu.css";
|
|
8
|
+
|
|
9
|
+
@layer lu.core {
|
|
10
|
+
lu-context-menu {
|
|
11
|
+
display: contents;
|
|
12
|
+
}
|
|
13
|
+
lu-context-menu > :not(lu-menu) {
|
|
14
|
+
-webkit-touch-callout: none;
|
|
15
|
+
}
|
|
16
|
+
}
|