@lumiose/ui 0.0.0-stage → 0.1.1

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.
Files changed (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. 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
+ }