@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
package/docs/combobox.md
ADDED
|
@@ -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.
|