@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.
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,369 @@
1
+ # Combobox
2
+
3
+ > A text input that filters a list of options, for picking one value or several from many: the WAI-ARIA combobox pattern with list autocomplete, after Base UI's Combobox. The input shows the option's label and the form gets its value. `<lu-combobox>`, a 5.4 kB form-associated light-DOM element, runs it on server-rendered pages and in islands; `bind:value` and `bind:open` keep signals in sync. Groups, an empty state, a status, a clear button, multiple values with chips, validation and async options.
4
+
5
+ ```ts
6
+ import { Combobox } from "@lumiose/ui"; // or "@lumiose/ui/combobox"
7
+ import "@lumiose/ui/combobox.css"; // places the popup and keeps filtered options hidden
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <Combobox.Root>
15
+ <Combobox.Label />
16
+ <Combobox.Chips />
17
+ <Combobox.Input />
18
+ <Combobox.Clear />
19
+ <Combobox.Trigger />
20
+ <Combobox.Popup>
21
+ <Combobox.List>
22
+ <Combobox.Item />
23
+ <Combobox.Group>
24
+ <Combobox.GroupLabel />
25
+ <Combobox.Item />
26
+ </Combobox.Group>
27
+ </Combobox.List>
28
+ <Combobox.Empty />
29
+ <Combobox.Status />
30
+ </Combobox.Popup>
31
+ </Combobox.Root>
32
+ ```
33
+
34
+ | Part | Renders |
35
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
36
+ | `Root` | `<lu-combobox>`, a form control with no box (`display: contents`) that holds the value and runs the parts. |
37
+ | `Label` | `<label for="…">` for the input; it also names the listbox. |
38
+ | `Input` | `<input type="text" role="combobox" aria-autocomplete="list" aria-expanded="…" aria-controls="…" autocomplete="off">`, the anchor of the popup. |
39
+ | `Trigger` | `<button type="button" tabindex="-1" command="--toggle" commandfor="…">`, which opens and closes the popup. |
40
+ | `Clear` | `<button type="button" tabindex="-1" command="--clear" commandfor="…">`, hidden while there is nothing to clear. |
41
+ | `Chips` | `<div role="list">` of the selected values, with `multiple`; the script renders a `<span role="listitem">` for each, with a remove `<button>`. |
42
+ | `Popup` | `<div popover="manual" data-side="…" data-align="…">` around the `List`, for an empty state or a status beside it. Optional. |
43
+ | `List` | `<div role="listbox">` of options; the popover itself (`popover="manual"`, `data-side`, `data-align`) when there is no `Popup`. |
44
+ | `Item` | `<div role="option" data-value="…" aria-selected="…">`. |
45
+ | `Group` | `<div role="group">` of options, named by its `GroupLabel`, hidden while none of its options matches. |
46
+ | `GroupLabel` | `<div role="presentation">`, which names its `Group`. |
47
+ | `Empty` | `<div role="status" hidden>`, shown instead of the options when none matches the text. Inside a `Popup`. |
48
+ | `Status` | `<div role="status">` for a message whose changes are announced, such as "Searching…". Inside a `Popup`, or anywhere in the `Root`. |
49
+
50
+ Every part goes inside `Root`. Options and groups go inside the `List`; the `Empty` and `Status`
51
+ parts go next to it in a `Popup`, never inside the listbox.
52
+
53
+ ## API
54
+
55
+ ### Combobox.Root
56
+
57
+ | Prop | Type | Default | |
58
+ | --------------- | -------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------- |
59
+ | `value` | `string`, `string[]` with `multiple` | | Selects the option with this value (these values) when it changes. `""` or `[]` selects nothing. |
60
+ | `defaultValue` | `string`, `string[]` with `multiple` | | The selection at first, when `value` is left out. |
61
+ | `onValueChange` | `(value) => void` | | Called after the user chooses, removes or clears a value, with the new value. Typing never calls it. |
62
+ | `bind:value` | `Signal<string>`, `Signal<string[]>` | | Stands in for `value` and `onValueChange`. |
63
+ | `open` | `boolean` | | Opens or closes the popup when it changes, in the browser. Server HTML always starts closed. |
64
+ | `onOpenChange` | `(open: boolean) => void` | | Called after the popup opens or closes, whatever did it. |
65
+ | `bind:open` | `Signal<boolean>` | | Stands in for `open` and `onOpenChange`. |
66
+ | `multiple` | `boolean` | `false` | Several values: choosing an option toggles it and the popup stays open. `value` is then an array. |
67
+ | `name` | `string` | | The name the value is submitted under; with `multiple`, each value is its own entry. |
68
+ | `required` | `boolean` | `false` | The form needs a value: the browser reports an empty combobox at its input on submit, and the input has `aria-required`. |
69
+ | `disabled` | `boolean` | `false` | The input and the buttons are disabled. A disabled `<fieldset>` around it does the same. |
70
+ | `filter` | `"contains" \| "startsWith" \| "none"` | `"contains"` | How typing filters the options by their label, ignoring case and accents. `"none"` leaves filtering to you (async options). |
71
+
72
+ Without `value`, `onValueChange` or `bind:value` the combobox keeps its own state. `Root` passes
73
+ `class`, `style` and other element props to `<lu-combobox>`.
74
+
75
+ ### Combobox.Item
76
+
77
+ | Prop | Type | Default | |
78
+ | ---------- | --------- | ------- | -------------------------------------------------------------------------------------- |
79
+ | `value` | `string` | | Required: the value the combobox takes, and submits, when the option is chosen. |
80
+ | `label` | `string` | | The text the input shows and typing matches, when the option's text content is not it. |
81
+ | `disabled` | `boolean` | `false` | The option cannot be chosen, and the arrow keys skip it (`aria-disabled`). |
82
+
83
+ ### Combobox.List and Combobox.Popup
84
+
85
+ | Prop | Type | Default | |
86
+ | ------------- | -------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------ |
87
+ | `side` | `"top" \| "bottom" \| "left" \| "right" \| "inline-start" \| "inline-end"` | `"bottom"` | The input's side the popup goes on. It flips when it does not fit. |
88
+ | `align` | `"start" \| "center" \| "end"` | `"start"` | How the popup aligns with the input along that side. |
89
+ | `sideOffset` | `number` | | The gap between the input and the popup, in CSS pixels. |
90
+ | `alignOffset` | `number` | | A shift along the side, away from the aligned edge, in CSS pixels. |
91
+
92
+ The placement props go on the part that is the popover: the `Popup` when there is one, else the
93
+ `List`.
94
+
95
+ ### Combobox.Chips
96
+
97
+ | Prop | Type | Default | |
98
+ | ------------- | -------- | ---------- | -------------------------------------------------------------------- |
99
+ | `removeLabel` | `string` | `"Remove"` | The remove buttons' name, before the value's label: "Remove Berlin". |
100
+
101
+ ### The other parts
102
+
103
+ Each part takes the props of the element it renders (`class`, `style`, `aria-*`, `data-*`, event
104
+ handlers, `ref`) and passes them through. Your props override the defaults (`tabindex`,
105
+ `autocomplete`, `hidden`). A `Label`'s `id` names it, and the list points at it, and an `Item`'s
106
+ `id` names the option (the input points at it only once the script highlights it). The `Root`,
107
+ `Input`, `List`, `Popup`, `Empty`, `Chips` and group labels keep their own ids, which other parts
108
+ or the element's script find them by (see [Ids](README.md#ids)); the roles, ARIA references,
109
+ commands and the anchor stay the parts' own, since they connect the parts.
110
+
111
+ - **`Input`**: `placeholder`, `onInput` (for async options) and `aria-describedby` pass through.
112
+ Its `type` and `value` are the combobox's. Inside a [Field](field.md), it takes the field's id
113
+ (so `Field.Label` labels it), its description and error references and `aria-invalid`, and Root
114
+ takes the field's `name` and disabled state; the field shows the combobox's validation.
115
+ - **`Trigger`** and **`Clear`**: out of the tab order, as in the APG examples; the Trigger has
116
+ `aria-haspopup`, `aria-controls` and `aria-expanded`, and the input does
117
+ everything from the keyboard. Name them with `aria-label` (or content).
118
+ - **`Group`**: `aria-labelledby` points at its `GroupLabel` while one renders.
119
+ - **`List`**: named by the `Label`, or inside a [Field](field.md) by the field's label.
120
+
121
+ **Differences from Base UI's Combobox.** No `Portal`, `Positioner`, `Backdrop`, `Arrow`, `Value`,
122
+ `Icon`, `ItemIndicator`, `Row`, `Collection` or `Separator`: the top layer and anchor positioning
123
+ replace the first ones, and the theme draws the selected mark from `[aria-selected="true"]`. The
124
+ options are markup, not an `items` array, and filtering compares their labels; `filter="none"`
125
+ hands it to you, as Base UI's `filter={null}`. The chips are rendered by the script from the
126
+ selected options' labels rather than by your components, so a server-rendered page needs no island
127
+ for them. `Label` is a part of its own (Base UI uses `Field.Label`). Arrow keys skip disabled
128
+ options, as in Zag.
129
+
130
+ ## Example
131
+
132
+ A city picker in a server-rendered page, with no island, in a form:
133
+
134
+ ```tsx check
135
+ import { Combobox } from "@lumiose/ui";
136
+
137
+ const CITIES = [
138
+ { value: "ber", label: "Berlin" },
139
+ { value: "lis", label: "Lisbon" },
140
+ { value: "mad", label: "Madrid" },
141
+ ];
142
+
143
+ export function CityField() {
144
+ return (
145
+ <form method="post">
146
+ <Combobox.Root name="city" defaultValue="lis" required class="lu-combobox">
147
+ <Combobox.Label>City</Combobox.Label>
148
+ <Combobox.Input placeholder="Search cities" />
149
+ <Combobox.Clear aria-label="Clear city" />
150
+ <Combobox.Trigger aria-label="Show cities" />
151
+ <Combobox.Popup>
152
+ <Combobox.List>
153
+ {CITIES.map((city) => (
154
+ <Combobox.Item value={city.value}>{city.label}</Combobox.Item>
155
+ ))}
156
+ </Combobox.List>
157
+ <Combobox.Empty>No city matches.</Combobox.Empty>
158
+ </Combobox.Popup>
159
+ </Combobox.Root>
160
+ <button class="lu-button">Save</button>
161
+ </form>
162
+ );
163
+ }
164
+ ```
165
+
166
+ In an island: several values bound to a signal, and options loaded as the user types.
167
+ `bind:value={tags}` is short for `value={tags()}` plus `onValueChange={(next) => tags.set(next)}`.
168
+
169
+ ```tsx check
170
+ import { Combobox } from "@lumiose/ui";
171
+ import { signal } from "lumiose";
172
+
173
+ const TAGS = ["accessibility", "bug", "docs", "performance", "security", "ui"];
174
+
175
+ /** Stands in for a request to your API: the tags that contain `text`, a moment later. */
176
+ function searchTags(text: string): Promise<string[]> {
177
+ const query = text.trim().toLowerCase();
178
+ const found = query ? TAGS.filter((tag) => tag.includes(query)) : [];
179
+ return new Promise((resolve) => setTimeout(() => resolve(found), 200));
180
+ }
181
+
182
+ export function TagPicker() {
183
+ const tags = signal<string[]>([]);
184
+ const results = signal<string[]>([]);
185
+ const searching = signal(false);
186
+ const search = async (text: string) => {
187
+ searching.set(true);
188
+ results.set(await searchTags(text));
189
+ searching.set(false);
190
+ };
191
+ return (
192
+ <Combobox.Root multiple name="tags" filter="none" bind:value={tags} class="lu-combobox">
193
+ <Combobox.Label>Tags</Combobox.Label>
194
+ <Combobox.Chips />
195
+ <Combobox.Input
196
+ onInput={(event) => search((event.currentTarget as HTMLInputElement).value)}
197
+ />
198
+ <Combobox.Popup>
199
+ <Combobox.List>
200
+ {results().map((tag) => (
201
+ <Combobox.Item value={tag}>{tag}</Combobox.Item>
202
+ ))}
203
+ </Combobox.List>
204
+ <Combobox.Status>{searching() ? "Searching…" : ""}</Combobox.Status>
205
+ </Combobox.Popup>
206
+ </Combobox.Root>
207
+ );
208
+ }
209
+ ```
210
+
211
+ With `filter="none"`, the script gives new options the selection and opens the popup when results
212
+ arrive while the user types; a chosen value keeps its label after its option is gone.
213
+
214
+ ## Styling
215
+
216
+ **With the default theme**, put `lu-combobox` on `Root`. It turns the root into the field's grid
217
+ (the label, the chips, then the input with the trigger and clear buttons over its inline end, with
218
+ a glyph when they are empty), and styles the popup, the options (highlighted, selected with a ✓,
219
+ disabled), the group labels, the chips and the empty and status messages. Its colours come from the
220
+ theme's `--lu-` properties ([UI components](README.md#the-default-theme)), and it sets a 4px gap
221
+ with `--lu-side-offset`.
222
+
223
+ **With your own CSS**, style the parts through `class` and their state:
224
+
225
+ - the input: `[aria-expanded="true"]` while the popup is open, `[aria-invalid="true"]` after a
226
+ submit found it empty, `:disabled`;
227
+ - the popup: `:popover-open`, `data-side` and `data-align`;
228
+ - options: `[data-highlighted]` on the active option (by keyboard or pointer; focus stays in the
229
+ input), `[aria-selected="true"]`, `[aria-disabled="true"]`;
230
+ - `[role="group"]`, a group label's `[role="presentation"]`, `[role="status"]`;
231
+ - chips: `[role="listitem"]` in the `Chips` list, and its `button`;
232
+ - the buttons: `[command="--toggle"]`, `[command="--clear"]` (`[hidden]` while there is nothing to
233
+ clear);
234
+ - the element: `lu-combobox:disabled`, `:invalid` and `:user-invalid` (it is a form control).
235
+
236
+ A starting point:
237
+
238
+ ```css
239
+ .combobox [role="listbox"] {
240
+ max-block-size: 18rem;
241
+ overflow-y: auto;
242
+ padding: 0.25rem;
243
+ border: 1px solid #d4d4d8;
244
+ border-radius: 0.5rem;
245
+ --lu-side-offset: 4px;
246
+ }
247
+ .combobox [role="option"] {
248
+ padding: 0.375rem 0.5rem;
249
+ border-radius: 0.25rem;
250
+ }
251
+ .combobox [role="option"][data-highlighted] {
252
+ background: #f4f4f5;
253
+ outline: 2px solid #2563eb;
254
+ outline-offset: -2px;
255
+ }
256
+ .combobox [role="option"][aria-selected="true"]::before {
257
+ content: "✓" / "";
258
+ }
259
+ ```
260
+
261
+ `@lumiose/ui/combobox.css` holds what the combobox needs to work, in the `lu.core` layer:
262
+ `lu-combobox` has no box, the options, groups and empty state the script hides stay hidden whatever
263
+ `display` your CSS gives them, and the shared placement rules anchor the popup to the input (with
264
+ CSS anchor positioning, a popup sits on its `data-side` and `data-align`, keeps the gaps of
265
+ `--lu-side-offset` and `--lu-align-offset`, flips when it does not fit, and shrinks to the room
266
+ beside the input when it fits nowhere: the list scrolls, its highlight in view).
267
+
268
+ ## Keyboard and focus
269
+
270
+ The [WAI-ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) with a listbox
271
+ popup, list autocomplete and manual selection, with Base UI's behaviour where it leaves a choice.
272
+ Focus stays in the input; the active option is `aria-activedescendant`.
273
+
274
+ | Key | Does |
275
+ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
276
+ | A character | Edits the text, filters the options and opens the popup when something matches (or there is an `Empty`). Never commits. |
277
+ | Arrow Down, Arrow Up | Closed: opens the popup on the selected option, else the first (Arrow Up: the last). Open: the next or previous option, past the last back to the input, then around. Disabled options are skipped. |
278
+ | Alt+Arrow Down | Opens the popup without highlighting an option. |
279
+ | Alt+Arrow Up | Closes the popup. |
280
+ | Enter | Chooses the highlighted option. With nothing highlighted, it does what Enter does in a form field. |
281
+ | Escape | Open: closes the popup without choosing and puts the selection's label back. Closed: clears the text and the selection (a `change`), as Base UI's; closed and empty, it goes on to the page, so a dialog closes on the next Escape. |
282
+ | Tab | Closes the popup without choosing and moves on. |
283
+ | Backspace | With `multiple`, in an empty input: removes the last value. |
284
+ | Arrow Left | With `multiple`, at the start of the input: moves to the last chip (Arrow Right right to left). |
285
+ | Arrow Left, Right | On a chip: the previous or next chip; past the last, back to the input. |
286
+ | Backspace, Delete | On a chip: removes its value; focus goes to the chip before it, else the one in its place, else the input. |
287
+ | Home, End | Move the text cursor, as in any text field. |
288
+
289
+ - **Choosing** an option puts its label in the input and closes the popup, or, with `multiple`,
290
+ toggles it, clears the text and keeps the popup open. It fires `change` once per change.
291
+ - **Emptying the input** clears a single selection. Closing the popup without choosing (Escape,
292
+ Tab, a click outside) puts the label back, and so does focus leaving the input after typing that
293
+ matched nothing.
294
+ - **The pointer**: a click on the input opens the popup, hovering highlights an option, a click
295
+ chooses it; presses in the popup and on the buttons keep focus in the input. A click outside
296
+ closes the popup; a right-click or a second finger outside leaves it open, as in Base UI.
297
+ - **IME**: while composing, Enter and the arrows belong to the input method, and the options are
298
+ filtered by the committed text only (Safari's committing Enter, which comes after the
299
+ composition as key 229, chooses nothing).
300
+ - **Autofill**: text the browser fills in (an unfocused field, or a picked suggestion) never opens
301
+ the popup; a text that is an option's label or value exactly chooses it.
302
+ - **A Status** with a message counts as something to show: inside a `Popup`, it opens with
303
+ "Searching…" while async options load.
304
+
305
+ ## Without JavaScript, and the element's script
306
+
307
+ The combobox needs its script: there is no native element that filters a styled list (`<datalist>`
308
+ cannot be styled or grouped, and customizable select cannot filter). Without JavaScript the server
309
+ HTML shows a labelled text field and nothing opens, and the form gets no value. For a choice that
310
+ must work without JavaScript, use [Select](select.md).
311
+
312
+ In the server HTML the input is empty even with a selected value: the input renders before the
313
+ options, so the server does not know the selected option's label when it writes the input. The
314
+ script puts the label in as it upgrades the element (decision-62).
315
+
316
+ A page that renders a `Combobox` loads the script once, as a 5.4 kB (brotli) module without the
317
+ Lumiose runtime, and islands bundle it. It defines `lu-combobox`, which upgrades every combobox on
318
+ the page in place: it reads the selection the server rendered (`aria-selected="true"`), puts its
319
+ label in the input (text typed before it ran is kept, as a search), renders the chips, and from
320
+ then on owns the value, the filtering, the popup,
321
+ the keyboard and the form value. Its popup is a manual popover, dismissed by the model Menu and
322
+ Popover share: an outside press with the main button, a close request (Escape, the Android back
323
+ gesture), Tab, or focus moving elsewhere.
324
+
325
+ The element is also an API of its own, for scripts and other frameworks:
326
+
327
+ - `value`: the selected option's `data-value` (`""` for none), or an array with `multiple`;
328
+ setting it selects without a `change` event.
329
+ - `open`: a property; setting it opens or closes the popup.
330
+ - `name`, `required`, `disabled`, `multiple`: reflect their attributes; `filter` is an attribute.
331
+ - `form`, `validity`, `validationMessage`, `willValidate`, `checkValidity()`, `reportValidity()`:
332
+ as on native controls. It submits under `name`, resets with its form (to the server's selection)
333
+ and follows a disabled `<fieldset>`. After a back navigation the browser restores the user's
334
+ choice, and a reset still goes to the server's selection.
335
+ - `change`: a bubbling event after the user chooses, removes or clears a value. The inner input's
336
+ own `change` stays inside.
337
+ - `openchange`: an event (not bubbling, like `toggle`) with `detail.open`, after the popup opens or
338
+ closes.
339
+ - The buttons are hidden until the element is defined: before, they would do nothing.
340
+ - The buttons are invoker commands: a `<button commandfor="<the element's id>" command="--toggle">`
341
+ (or `"--clear"`) works anywhere on the page; where the browser lacks invoker commands, the
342
+ element handles the clicks of those inside it.
343
+
344
+ ## Browser support
345
+
346
+ - **Form-associated custom elements** (`ElementInternals`): Chrome and Edge 77, Firefox 98,
347
+ Safari 16.4.
348
+ - **Popovers**: Chrome and Edge 114, Firefox 125, Safari 17. **`showPopover({ source })`**: Chrome
349
+ 137, Firefox 144, Safari 26; older browsers open the popup the same way.
350
+ - **CSS anchor positioning** places the popup: Chrome and Edge 125, Firefox 147, Safari 26 (partly;
351
+ 27 fully). Elsewhere the script places it with the same sides, alignment, offsets and flips.
352
+ - **Invoker commands**: Chrome 135, Firefox 144, Safari 26.2; elsewhere the element handles the
353
+ buttons' clicks.
354
+ - **`CloseWatcher`**, for the Android back gesture on a popup opened by a click, a tap or a key:
355
+ Chrome 126, Firefox 149. Escape is handled on its own in every browser, one layer at a time.
356
+
357
+ ## Related
358
+
359
+ - [Autocomplete](autocomplete.md): the same input and list for free text, when any text is fine
360
+ and the options only suggest it.
361
+ - [Select](select.md): a native select, when the user picks from a short list without typing; it
362
+ works without JavaScript.
363
+ - [Field](field.md): a label, a description and errors for a combobox.
364
+ - [Menu](menu.md): commands rather than values, and the dismissal model the combobox shares.
365
+ - [UI components](README.md): the approach, styling, the default theme and the other components.
366
+ - [Islands](https://itsjavi.com/lumiose/docs/concepts/islands/): running a component in the
367
+ browser, for `bind:value`, `bind:open` and async options.
368
+ - [JSX](https://itsjavi.com/lumiose/docs/concepts/jsx/): `bind:`, native events with `on:` and
369
+ `ref`.
@@ -0,0 +1,197 @@
1
+ # Context Menu
2
+
3
+ > A menu opened at the pointer by a right-click in its trigger area, a long press on a touch screen, or Shift+F10 and the ContextMenu key: Menu's popup and items, with Menu's keys, typeahead, submenus and dismissal. A 0.6 kB script on top of Menu's (4.6 kB together) opens it; without JavaScript the browser shows its own menu. `bind:open` (or `open` and `onOpenChange`) keeps a signal in sync in islands.
4
+
5
+ ```ts
6
+ import { ContextMenu } from "@lumiose/ui"; // or "@lumiose/ui/context-menu"
7
+ import "@lumiose/ui/context-menu.css"; // Menu's layout, and no iOS callout over the area
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <ContextMenu.Root>
15
+ <ContextMenu.Trigger />
16
+ <ContextMenu.Popup>
17
+ <ContextMenu.Item />
18
+ <ContextMenu.LinkItem />
19
+ <ContextMenu.CheckboxItem />
20
+ <ContextMenu.Separator />
21
+ <ContextMenu.Group>
22
+ <ContextMenu.GroupLabel />
23
+ <ContextMenu.Item />
24
+ </ContextMenu.Group>
25
+ <ContextMenu.RadioGroup>
26
+ <ContextMenu.GroupLabel />
27
+ <ContextMenu.RadioItem />
28
+ </ContextMenu.RadioGroup>
29
+ <ContextMenu.SubmenuRoot>
30
+ <ContextMenu.SubmenuTrigger />
31
+ <ContextMenu.Popup>…</ContextMenu.Popup>
32
+ </ContextMenu.SubmenuRoot>
33
+ </ContextMenu.Popup>
34
+ </ContextMenu.Root>
35
+ ```
36
+
37
+ | Part | Renders |
38
+ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `Root` | `<lu-context-menu>`, an element with no box (`display: contents`) around the area and the menu; it opens the menu. |
40
+ | `Trigger` | `<div>`: the area whose right-click, long press or keys open the menu. |
41
+ | `Popup` | Menu's `<lu-menu>` with its `<div role="menu" popover="auto" data-side="…" data-align="…">`, anchored to a zero-size `<span aria-hidden="true">` at the pointer. |
42
+ | `Item` … `SubmenuTrigger` | Menu's parts, as [Menu](menu.md#anatomy) renders them. |
43
+
44
+ The popup has no trigger button to be named by: give it an `aria-label`. Inside a `SubmenuRoot`,
45
+ `Popup` is the submenu's, placed next to its trigger as in Menu.
46
+
47
+ ## API
48
+
49
+ ### ContextMenu.Root
50
+
51
+ | Prop | Type | Default | |
52
+ | -------------- | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `open` | `boolean` | | Opens or closes the menu when it changes, in the browser, where it last opened (the window's top start corner at first). Server HTML always starts closed. |
54
+ | `onOpenChange` | `(open: boolean) => void` | | Called after the menu opens or closes, whatever did it: a right-click, a long press, a key, an item, Escape, a click outside or `open`. |
55
+ | `bind:open` | `Signal<boolean>` | | Stands in for `open` and `onOpenChange`. |
56
+ | `disabled` | `boolean` | `false` | The area keeps the browser's own menu. |
57
+
58
+ ### ContextMenu.Popup
59
+
60
+ Menu's [popup props](menu.md#menupopup), from the pointer: `side` (default `"bottom"`), `align`
61
+ (default `"start"`), `sideOffset` (default `0`: the popup's corner at the pointer) and
62
+ `alignOffset`. It flips when it does not fit.
63
+
64
+ ### The other parts
65
+
66
+ Each part takes the props of the element it renders (`class`, `style`, `aria-*`, `data-*`, event
67
+ handlers, `ref`) and passes them through.
68
+
69
+ - **`Trigger`**: for the keys, focus must be in the area: give it `tabindex={0}` (and a name, such
70
+ as `aria-label`), or focusable content.
71
+ - **The items**: Menu's `Item`, `LinkItem`, `CheckboxItem`, `RadioGroup`, `RadioItem`, `Group`,
72
+ `GroupLabel`, `Separator`, `SubmenuRoot` and `SubmenuTrigger`, with
73
+ [Menu's props](menu.md#api): `disabled`, `label`, `closeOnClick`, `checked` and
74
+ `onCheckedChange`, `value` and `onValueChange`, and `bind:`.
75
+
76
+ ## Example
77
+
78
+ A file's actions in a server-rendered page, with no island, styled by the default theme:
79
+
80
+ ```tsx check
81
+ import { ContextMenu } from "@lumiose/ui";
82
+
83
+ export function FileActions() {
84
+ return (
85
+ <ContextMenu.Root>
86
+ <ContextMenu.Trigger class="file" tabindex={0} aria-label="report.pdf">
87
+ report.pdf
88
+ </ContextMenu.Trigger>
89
+ <ContextMenu.Popup class="lu-menu" aria-label="File actions">
90
+ <ContextMenu.Item>Rename</ContextMenu.Item>
91
+ <ContextMenu.Item>Duplicate</ContextMenu.Item>
92
+ <ContextMenu.Separator />
93
+ <ContextMenu.SubmenuRoot>
94
+ <ContextMenu.SubmenuTrigger>Move to</ContextMenu.SubmenuTrigger>
95
+ <ContextMenu.Popup class="lu-menu">
96
+ <ContextMenu.Item>Archive</ContextMenu.Item>
97
+ <ContextMenu.Item>Trash</ContextMenu.Item>
98
+ </ContextMenu.Popup>
99
+ </ContextMenu.SubmenuRoot>
100
+ </ContextMenu.Popup>
101
+ </ContextMenu.Root>
102
+ );
103
+ }
104
+ ```
105
+
106
+ In an island, with a checkbox item and a radio group bound to signals:
107
+
108
+ ```tsx check
109
+ import { ContextMenu } from "@lumiose/ui";
110
+ import { signal } from "lumiose";
111
+
112
+ export function GalleryOptions() {
113
+ const grid = signal(true);
114
+ const sort = signal("name");
115
+ return (
116
+ <>
117
+ <ContextMenu.Root>
118
+ <ContextMenu.Trigger class="gallery" tabindex={0} aria-label="Gallery">
119
+ Right-click the gallery
120
+ </ContextMenu.Trigger>
121
+ <ContextMenu.Popup class="lu-menu" aria-label="View options">
122
+ <ContextMenu.CheckboxItem bind:checked={grid}>Show the grid</ContextMenu.CheckboxItem>
123
+ <ContextMenu.RadioGroup bind:value={sort}>
124
+ <ContextMenu.GroupLabel>Sort by</ContextMenu.GroupLabel>
125
+ <ContextMenu.RadioItem value="name">Name</ContextMenu.RadioItem>
126
+ <ContextMenu.RadioItem value="date">Date</ContextMenu.RadioItem>
127
+ </ContextMenu.RadioGroup>
128
+ </ContextMenu.Popup>
129
+ </ContextMenu.Root>
130
+ <p>
131
+ {grid() ? "Grid" : "No grid"}, sorted by {sort()}
132
+ </p>
133
+ </>
134
+ );
135
+ }
136
+ ```
137
+
138
+ ## Styling
139
+
140
+ **With the default theme**, put `lu-menu` on each `Popup`, as on a Menu's: a surface with its
141
+ items, checkbox and radio marks, separators and group labels. The theme leaves the area to you.
142
+
143
+ **With your own CSS**, style the popup and items as a Menu's ([Menu: Styling](menu.md#styling)):
144
+ `:popover-open`, `[data-highlighted]`, `[aria-checked]`, `[aria-disabled]`, `[data-side]`. The
145
+ element sets `--lu-context-x` and `--lu-context-y` (the pointer, in pixels) on the root.
146
+
147
+ `@lumiose/ui/context-menu.css` holds Menu's rules and two of its own, in the `lu.core` layer:
148
+ `<lu-context-menu>` adds no box, and the area has `-webkit-touch-callout: none`, so iOS shows no
149
+ preview over the menu on a long press.
150
+
151
+ ## Keyboard and focus
152
+
153
+ | Key | Does |
154
+ | ----------------------------- | ------------------------------------------------------------------------------------ |
155
+ | Shift+F10, ContextMenu | With focus in the area: opens the menu below the focused element, on its first item. |
156
+ | Arrow keys, Home, End, typing | Menu's: move between items, open and close submenus, typeahead. |
157
+ | Enter, Space | Run the item (checkbox and radio items toggle and stay open). |
158
+ | Escape | Closes the menu (one submenu at a time). |
159
+ | Tab, Shift+Tab | Close the menu. |
160
+
161
+ - **A right-click or a long press** opens the menu with focus on the popup: the arrows then reach
162
+ the items. A right-click inside the open menu does nothing; another one in the area moves the
163
+ menu there.
164
+ - **Nested**: in the area of a Context Menu inside another's, the inner one opens, by a right-click,
165
+ a long press or the keys; a disabled inner one leaves it to the outer.
166
+ - **On close**, focus returns to where it was before the menu opened (the area, for the keys),
167
+ unless a click outside landed on a control, which keeps it.
168
+ - **A long press** (500ms, one finger, moving less than 10px) is the touch alternative to the
169
+ right-click, and the keys the keyboard one (WCAG 2.5.1). Offer the menu's commands elsewhere too
170
+ when they matter: a context menu is hard to discover.
171
+
172
+ ## Without JavaScript, and the client script
173
+
174
+ Opening a menu at the pointer needs the script: without JavaScript a right-click shows the
175
+ browser's own menu, and the popup stays hidden. `open`, `onOpenChange` and `bind:open` need the
176
+ component to run in an island.
177
+
178
+ A page that renders a `ContextMenu` loads Menu's script and a 0.6 kB (brotli) one of its own,
179
+ 4.6 kB together, without the Lumiose runtime; islands bundle them. The element opens the menu on a
180
+ right-click, a long press or the keys, at the pointer, through a zero-size anchor that it moves
181
+ there (CSS anchor positioning places the popup, with Menu's flips; without it, the shared
182
+ placement script does). Menu's script does the rest: keys, items, submenus, and dismissal shared
183
+ with [Popover](popover.md) and [Combobox](combobox.md).
184
+
185
+ ## Browser support
186
+
187
+ - **CSS anchor positioning**: Chrome and Edge 125, Firefox 147, Safari 26. Elsewhere the script
188
+ places the popup at the pointer.
189
+ - **Long press**: iOS fires no `contextmenu` on one, so the script times it; Android's
190
+ `contextmenu` after a long press is ignored.
191
+ - **The ContextMenu key and Shift+F10**: Windows and Linux keyboards; on macOS, only Shift+F10
192
+ where the keyboard has F10.
193
+
194
+ ## Related
195
+
196
+ - [Menu](menu.md): the same popup and items, opened by a button.
197
+ - [UI components](README.md): the approach, styling and the other components.