@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
package/docs/dialog.md ADDED
@@ -0,0 +1,256 @@
1
+ # Dialog
2
+
3
+ > A modal dialog, or an alert dialog that needs an answer: a native `<dialog>` opened and closed by invoker commands, so it works in server HTML with no JavaScript, and a 1.5 kB script closes the native element's gaps where JavaScript runs. `bind:open` (or `open` and `onOpenChange`) keeps a signal in sync in islands.
4
+
5
+ ```ts
6
+ import { Dialog } from "@lumiose/ui"; // or "@lumiose/ui/dialog"
7
+ import "@lumiose/ui/dialog.css"; // locks the page's scroll behind a modal dialog
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <Dialog.Root>
15
+ <Dialog.Trigger />
16
+ <Dialog.Popup>
17
+ <Dialog.Title />
18
+ <Dialog.Description />
19
+ <Dialog.Close />
20
+ </Dialog.Popup>
21
+ </Dialog.Root>
22
+ ```
23
+
24
+ | Part | Renders |
25
+ | ------------- | ----------------------------------------------------------------------------------------------------------------------------- |
26
+ | `Root` | No element. Holds the state and the ids the other parts share. |
27
+ | `Trigger` | `<button type="button" command="show-modal" commandfor="…" aria-haspopup="dialog" aria-expanded="…">` |
28
+ | `Popup` | `<dialog closedby="…">`, labelled by its `Title` and described by its `Description`; `role="alertdialog"` for an alert dialog |
29
+ | `Title` | `<h2>`, which names the dialog. |
30
+ | `Description` | `<p>`, which describes it. |
31
+ | `Close` | `<button type="button" command="close" commandfor="…">`: its `value` becomes the dialog's `returnValue`. |
32
+
33
+ The trigger can sit anywhere inside `Root`, and a popup may have several close buttons. `Title` and
34
+ `Description` go inside `Popup`. A dialog may contain another `Dialog.Root`: the inner dialog
35
+ opens on top of the outer one.
36
+
37
+ ## API
38
+
39
+ ### Dialog.Root
40
+
41
+ | Prop | Type | Default | |
42
+ | -------------- | --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `open` | `boolean` | | Opens or closes the dialog when it changes, in the browser. Server HTML always starts closed: a modal dialog opens only from a trigger or script. |
44
+ | `onOpenChange` | `(open: boolean) => void` | | Called after the dialog opens or closes, whatever did it: the trigger, a close button, Escape, a click outside or `open`. |
45
+ | `bind:open` | `Signal<boolean>` | | Stands in for `open` and `onOpenChange`: the signal opens and closes the dialog, and follows it. |
46
+ | `role` | `"dialog" \| "alertdialog"` | `"dialog"` | `"alertdialog"` for a confirmation that needs an answer: a click outside does not close it. |
47
+
48
+ Without `open`, `onOpenChange` or `bind:open` the dialog keeps its own state.
49
+
50
+ ### The other parts
51
+
52
+ Each part takes the props of the element it renders (`class`, `style`, `aria-*`, `data-*`, event
53
+ handlers, `ref`) and passes them through. Your props override the defaults (`type`,
54
+ `aria-haspopup`, `aria-expanded`, `closedby`, `aria-labelledby`, `aria-describedby`). A `Title`'s or
55
+ a `Description`'s `id` names it, and the popup points at it; the `Popup` keeps its own `id`, which
56
+ its triggers point at (see [Ids](README.md#ids)), and `command` and `commandfor` stay the parts' own.
57
+
58
+ - **`Trigger`**: `aria-expanded` follows the dialog in the browser. In server HTML it stays
59
+ `"false"`, which is never wrong where assistive technology can reach it: while the modal dialog
60
+ is open, the trigger is inert.
61
+ - **`Popup`**: `closedby` is `"any"` for a dialog (Escape or a click outside closes it) and
62
+ `"closerequest"` for an alert dialog (Escape only); pass `closedby="none"` to allow neither. Native
63
+ events work as on a `<dialog>`: `on:close` runs after any close, and
64
+ `event.currentTarget.returnValue` holds the value of the `Close` that closed it (it is unchanged
65
+ after Escape). To keep a dialog open (unsaved changes, say), use `closedby="none"` while it must
66
+ stay, not `preventDefault()` on its `cancel` event: browsers let a page cancel only the first of
67
+ repeated close requests, so a second Escape closes it anyway.
68
+ - **`Title`** and **`Description`**: the popup's `aria-labelledby` and `aria-describedby` point at
69
+ the ones it renders, and only those; in an island they follow parts that appear or go away. A
70
+ dialog without a `Title` needs an `aria-label` on `Popup`.
71
+
72
+ ## Example
73
+
74
+ A confirmation in a server-rendered page, with no island, styled by the default theme:
75
+
76
+ ```tsx check
77
+ import { Dialog } from "@lumiose/ui";
78
+
79
+ export function DeleteProject() {
80
+ return (
81
+ <Dialog.Root role="alertdialog">
82
+ <Dialog.Trigger class="lu-button">Delete project…</Dialog.Trigger>
83
+ <Dialog.Popup class="lu-dialog">
84
+ <Dialog.Title>Delete project?</Dialog.Title>
85
+ <Dialog.Description>This removes the project and its deployments.</Dialog.Description>
86
+ <footer>
87
+ <Dialog.Close class="lu-button" value="cancel">
88
+ Cancel
89
+ </Dialog.Close>
90
+ <Dialog.Close class="lu-button" data-variant="destructive" value="delete">
91
+ Delete
92
+ </Dialog.Close>
93
+ </footer>
94
+ </Dialog.Popup>
95
+ </Dialog.Root>
96
+ );
97
+ }
98
+ ```
99
+
100
+ In an island, bound to a signal: the button outside opens it, and the signal follows whatever
101
+ closes it. `bind:open={open}` is short for `open={open()}` plus
102
+ `onOpenChange={(next) => open.set(next)}`.
103
+
104
+ ```tsx check
105
+ import { Dialog } from "@lumiose/ui";
106
+ import { signal } from "lumiose";
107
+
108
+ export function EditProfile() {
109
+ const open = signal(false);
110
+ const saved = signal("");
111
+ return (
112
+ <>
113
+ <button type="button" class="lu-button" onClick={() => open.set(true)}>
114
+ Edit profile
115
+ </button>
116
+ <Dialog.Root bind:open={open}>
117
+ <Dialog.Popup
118
+ class="lu-dialog"
119
+ on:close={(event) => saved.set(event.currentTarget.returnValue)}
120
+ >
121
+ <Dialog.Title>Edit profile</Dialog.Title>
122
+ <Dialog.Description>Your name as others see it.</Dialog.Description>
123
+ <footer>
124
+ <Dialog.Close class="lu-button" data-variant="primary" value="save">
125
+ Save
126
+ </Dialog.Close>
127
+ </footer>
128
+ </Dialog.Popup>
129
+ </Dialog.Root>
130
+ <p>{open() ? "Editing…" : `Last answer: ${saved() || "none"}`}</p>
131
+ </>
132
+ );
133
+ }
134
+ ```
135
+
136
+ ## Styling
137
+
138
+ **With the default theme**, put `lu-dialog` on `Popup` and `lu-button` on the buttons
139
+ (`data-variant="primary"` or `"destructive"` for filled ones). `lu-dialog` also styles the backdrop,
140
+ the title, the description and a `<footer>` for the buttons, and fades and zooms the dialog in and
141
+ out. Its colours come from the theme's `--lu-` properties ([UI components](README.md#the-default-theme)).
142
+
143
+ **With your own CSS**, style the parts through `class` and the dialog's native state: `dialog[open]`,
144
+ `:modal`, `::backdrop` and `[role="alertdialog"]`. Where the client script runs, a dialog with open
145
+ dialogs inside also gets `data-nested-dialog-open` and `--lu-nested-dialogs` (how many are open inside
146
+ it), as in Base UI; `dialog:has(dialog[open])` matches the same dialogs without the script. A starting point, with a fade that keeps the dialog in the top layer while it
147
+ closes:
148
+
149
+ ```css
150
+ .dialog {
151
+ inline-size: min(28rem, 100vw - 2rem);
152
+ padding: 1.25rem;
153
+ border: 1px solid #d4d4d8;
154
+ border-radius: 0.75rem;
155
+ box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.35);
156
+ }
157
+ .dialog::backdrop {
158
+ background: rgb(0 0 0 / 0.45);
159
+ }
160
+
161
+ @media (prefers-reduced-motion: no-preference) {
162
+ .dialog,
163
+ .dialog::backdrop {
164
+ transition:
165
+ opacity 150ms ease-out,
166
+ overlay 150ms allow-discrete,
167
+ display 150ms allow-discrete;
168
+ }
169
+ .dialog:not([open]),
170
+ .dialog:not([open])::backdrop {
171
+ opacity: 0;
172
+ }
173
+ @starting-style {
174
+ .dialog[open],
175
+ .dialog[open]::backdrop {
176
+ opacity: 0;
177
+ }
178
+ }
179
+ }
180
+ ```
181
+
182
+ `@lumiose/ui/dialog.css` holds the scroll lock, in the `lu.core` layer: the page behind any modal
183
+ `<dialog>` does not scroll (`:root:has(dialog:modal) { overflow: hidden }`). A page that overflows
184
+ keeps its scrollbar's space (`scrollbar-gutter: stable`), so with classic scrollbars (Windows,
185
+ Linux) its layout does not shift. A page that fits the window gets no gutter, which would leave an
186
+ empty strip of about 15px at its inline end. The client script measures the page before the dialog
187
+ opens; without it, a scroll timeline in the stylesheet tells the two apart (decision-79), and a
188
+ browser without scroll timelines keeps the gutter on a page that fits too
189
+ ([Browser support](#browser-support)). Leave the stylesheet out, or override the rule, to let the
190
+ page scroll.
191
+
192
+ ## Keyboard and focus
193
+
194
+ The browser's modal dialog provides the
195
+ [WAI-ARIA dialog pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/):
196
+
197
+ | Key | Does |
198
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
199
+ | Enter, Space | On the trigger: opens the dialog. |
200
+ | Tab, Shift+Tab | Move between the focusable elements of the dialog. The page behind is inert: focus never reaches it. |
201
+ | Escape | Closes the dialog on top, alert dialogs included. With nested dialogs, one Escape closes one dialog. An Escape that ends an input method's composition closes nothing. |
202
+
203
+ - **On open**, focus moves to the element with `autofocus`, or else the first tab stop inside,
204
+ or else the dialog, as in Base UI and the same in every browser: not a scroller before it, nor a
205
+ `tabindex="-1"` title (where the script runs). Opened by touch, the dialog focuses itself
206
+ instead, so no virtual keyboard opens over it (an `autofocus` element still gets focus).
207
+ - **On close**, focus returns to the element that had it before: the trigger, or the button that
208
+ set the signal. If that element is gone, focus goes to another trigger of the dialog, without
209
+ scrolling the page. The popovers, menus and dialogs open inside it close with it.
210
+ - **A click outside** closes a dialog, not an alert dialog, and only the dialog on top. A press
211
+ that starts inside and ends outside, such as selecting text, does not count, and neither does a
212
+ right-click or a second finger.
213
+ - Popups opened from a dialog (a menu, a select) belong inside its `Popup`: everything outside a
214
+ modal dialog is inert.
215
+
216
+ ## Without JavaScript, and the client script
217
+
218
+ The trigger, the close buttons, Escape and the click outside are HTML features: a dialog in a
219
+ server-rendered page works before any script loads, and with none. `open`, `onOpenChange`,
220
+ `bind:open` and a live `aria-expanded` need the component to run in the browser, in an island.
221
+
222
+ Where the native element falls short of Base UI's dialog, a small script closes the gap: a page
223
+ that renders a `Dialog` loads it once, as a 1.5 kB (brotli) module without the Lumiose runtime, and
224
+ islands bundle it. It covers every `<dialog>` on the page:
225
+
226
+ - a right-click or a second finger outside does not close the dialog;
227
+ - `data-nested-dialog-open` and `--lu-nested-dialogs` on a dialog with open dialogs inside;
228
+ - the first tab stop takes focus on open, in every browser, and a dialog opened by touch focuses
229
+ itself;
230
+ - focus returns without scrolling, to the opener or, if it is gone, another trigger (WebKit, which
231
+ does not focus a clicked button, would otherwise leave focus on the page);
232
+ - the click outside where the browser lacks `closedby` (Safari), and the invoker commands where it
233
+ lacks them.
234
+
235
+ Until the script loads (or without JavaScript) the dialog behaves as the browser's own.
236
+
237
+ ## Browser support
238
+
239
+ - **Invoker commands** (`command`, `commandfor`) open and close the dialog: Chrome and Edge 135,
240
+ Firefox 144, Safari 26.2. In older browsers the client script stands in for them; without
241
+ JavaScript the trigger and the close buttons do nothing there.
242
+ - **`closedby`**: Chrome 134 and Firefox 141; Safari only in Technology Preview. Elsewhere the
243
+ client script closes the dialog on a click outside; without JavaScript only Escape does.
244
+ - **Exit animations** that keep the dialog in the top layer (`overlay`) are Chromium-only.
245
+ Elsewhere the dialog leaves the top layer as soon as it starts closing.
246
+ - **Scroll timelines** (`animation-timeline: scroll()`) keep the scroll lock's gutter off a page
247
+ that fits the window without JavaScript: Chrome and Edge 115, Safari 26. In Firefox, until the
248
+ client script runs or without JavaScript, such a page keeps the gutter while a dialog is open:
249
+ with classic scrollbars, an empty strip at its inline end.
250
+
251
+ ## Related
252
+
253
+ - [UI components](README.md): the approach, styling, the default theme and the other components.
254
+ - [Islands](https://itsjavi.com/lumiose/docs/concepts/islands/): running a component in the browser, for `open` and
255
+ `onOpenChange`.
256
+ - [JSX](https://itsjavi.com/lumiose/docs/concepts/jsx/): native events with `on:` and `ref`.
package/docs/drawer.md ADDED
@@ -0,0 +1,204 @@
1
+ # Drawer
2
+
3
+ > A panel that slides in from an edge of the window and that a swipe toward that edge closes: a Dialog (a native modal `<dialog>` opened by an invoker command, so it works in server HTML with no JavaScript) against the edge its `swipeDirection` names. A 1 kB script adds the swipe, with the close buttons, Escape and a click on the backdrop as its alternatives. `bind:open` (or `open` and `onOpenChange`) keeps a signal in sync in islands.
4
+
5
+ ```ts
6
+ import { Drawer } from "@lumiose/ui"; // or "@lumiose/ui/drawer"
7
+ import "@lumiose/ui/drawer.css"; // puts the dialog against its edge; locks the page's scroll
8
+ import "@lumiose/ui/themes/default.css"; // optional: the default theme
9
+ ```
10
+
11
+ ## Anatomy
12
+
13
+ ```tsx
14
+ <Drawer.Root>
15
+ <Drawer.Trigger />
16
+ <Drawer.Popup>
17
+ <Drawer.Title />
18
+ <Drawer.Description />
19
+ <Drawer.Content />
20
+ <Drawer.Close />
21
+ </Drawer.Popup>
22
+ </Drawer.Root>
23
+ ```
24
+
25
+ | Part | Renders |
26
+ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
27
+ | `Root` | No element. Holds the state and the ids the other parts share, as `Dialog.Root`. |
28
+ | `Trigger` | `<button type="button" command="show-modal" commandfor="…" aria-haspopup="dialog" aria-expanded="…">`, as Dialog's. |
29
+ | `Popup` | `<dialog closedby="any" data-swipe-direction="…">`, labelled by its `Title` and described by its `Description`. |
30
+ | `Content` | `<div>` around the drawer's content, which may scroll. |
31
+ | `Title` | `<h2>`, which names the drawer. |
32
+ | `Description` | `<p>`, which describes it. |
33
+ | `Close` | `<button type="button" command="close" commandfor="…">`, as Dialog's. |
34
+
35
+ `Trigger`, `Title`, `Description` and `Close` are [Dialog's](dialog.md#anatomy). A drawer is
36
+ modal: the page behind it is inert, and focus stays inside.
37
+
38
+ ## API
39
+
40
+ ### Drawer.Root
41
+
42
+ | Prop | Type | Default | |
43
+ | ---------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
44
+ | `open` | `boolean` | | Opens or closes the drawer when it changes, in the browser. Server HTML always starts closed: a modal dialog opens only from a trigger or script. |
45
+ | `onOpenChange` | `(open: boolean) => void` | | Called after the drawer opens or closes, whatever did it: the trigger, a close button, Escape, a click outside, a swipe or `open`. |
46
+ | `bind:open` | `Signal<boolean>` | | Stands in for `open` and `onOpenChange`. |
47
+ | `swipeDirection` | `"down" \| "up" \| "left" \| "right"` | `"down"` | The direction a swipe closes the drawer in, and the window's edge it sits against: `"down"` is a bottom sheet. |
48
+
49
+ ### The other parts
50
+
51
+ Each part takes the props of the element it renders (`class`, `style`, `aria-*`, `data-*`, event
52
+ handlers, `ref`) and passes them through, as Dialog's do ([Dialog: API](dialog.md#api)): `closedby`
53
+ on `Popup` (`"closerequest"` keeps a click outside from closing it), `on:close` and the close
54
+ buttons' `value`.
55
+
56
+ - **`Content`**: a swipe that starts in content that can still scroll toward the drawer's edge
57
+ scrolls it instead, until it reaches its end. When it scrolls, make it focusable for the
58
+ keyboard: `tabindex={0}` with `role="region"` and an `aria-label`.
59
+ - **`[data-swipe-ignore]`** on any element inside keeps swipes from starting on it, as on buttons,
60
+ links and fields. The toasts' region has it, so a toast's swipe never swipes a drawer it sits in.
61
+ - A swipe starts only in the drawer's box: a drag that starts on the backdrop does nothing. A
62
+ mouse or pen drag in `Content` selects its text and never swipes (a touch swipe still does).
63
+
64
+ ## Example
65
+
66
+ A bottom sheet in a server-rendered page, with no island, styled by the default theme:
67
+
68
+ ```tsx check
69
+ import { Drawer } from "@lumiose/ui";
70
+
71
+ export function FiltersSheet() {
72
+ return (
73
+ <Drawer.Root>
74
+ <Drawer.Trigger class="lu-button">Filters</Drawer.Trigger>
75
+ <Drawer.Popup class="lu-drawer">
76
+ <Drawer.Title>Filters</Drawer.Title>
77
+ <Drawer.Description>Swipe down, or press Done, to close.</Drawer.Description>
78
+ <footer>
79
+ <Drawer.Close class="lu-button" data-variant="primary">
80
+ Done
81
+ </Drawer.Close>
82
+ </footer>
83
+ </Drawer.Popup>
84
+ </Drawer.Root>
85
+ );
86
+ }
87
+ ```
88
+
89
+ In an island, a side drawer bound to a signal: `bind:open={open}` is short for `open={open()}`
90
+ plus `onOpenChange={(next) => open.set(next)}`, and the signal follows a swipe too.
91
+
92
+ ```tsx check
93
+ import { Drawer } from "@lumiose/ui";
94
+ import { signal } from "lumiose";
95
+
96
+ export function NavigationDrawer() {
97
+ const open = signal(false);
98
+ return (
99
+ <>
100
+ <button type="button" class="lu-button" onClick={() => open.set(true)}>
101
+ Menu
102
+ </button>
103
+ <Drawer.Root bind:open={open} swipeDirection="left">
104
+ <Drawer.Popup class="lu-drawer">
105
+ <Drawer.Title>Menu</Drawer.Title>
106
+ <Drawer.Description>Swipe left to close.</Drawer.Description>
107
+ <Drawer.Close class="lu-button">Close</Drawer.Close>
108
+ </Drawer.Popup>
109
+ </Drawer.Root>
110
+ <p>{open() ? "The menu is open" : "The menu is closed"}</p>
111
+ </>
112
+ );
113
+ }
114
+ ```
115
+
116
+ ## Styling
117
+
118
+ **With the default theme**, put `lu-drawer` on `Popup`: a surface against its edge, rounded on the
119
+ inside, with a handle on a bottom sheet, the title, the description and a `<footer>` styled. It
120
+ slides in and out from its edge, follows a swipe, and leaves from where the swipe let go; the
121
+ backdrop fades with it.
122
+
123
+ **With your own CSS**, style the drawer through `class`, `[data-swipe-direction]`, `dialog[open]`
124
+ and `::backdrop`, and the swipe through the script's state, named as in Base UI:
125
+
126
+ - `[data-swiping]` while a swipe runs, with `--lu-drawer-swipe-movement-x` or
127
+ `--lu-drawer-swipe-movement-y` (pixels, toward the edge) and `--lu-drawer-swipe-progress` (0 to 1,
128
+ for the backdrop);
129
+ - `[data-swipe-dismiss]` (the direction) when a swipe closed it, until it has left.
130
+
131
+ ```css
132
+ .drawer {
133
+ translate: var(--lu-drawer-swipe-movement-x, 0) var(--lu-drawer-swipe-movement-y, 0);
134
+ transition:
135
+ translate 250ms ease-out,
136
+ overlay 250ms allow-discrete,
137
+ display 250ms allow-discrete;
138
+ }
139
+ .drawer[data-swiping] {
140
+ transition: none;
141
+ }
142
+ .drawer[data-swipe-direction="down"]:not([open]) {
143
+ translate: 0 100%;
144
+ }
145
+ @starting-style {
146
+ .drawer[data-swipe-direction="down"][open] {
147
+ translate: 0 100%;
148
+ }
149
+ }
150
+ ```
151
+
152
+ `@lumiose/ui/drawer.css` holds Dialog's scroll lock and, in the `lu.core` layer, puts each
153
+ `dialog[data-swipe-direction]` against its edge, across that edge's length, instead of the
154
+ browser's centred box; a swiped drawer selects no text.
155
+
156
+ With classic scrollbars (Windows, Linux), the scroll lock keeps the scrollbar's space on a page
157
+ that overflows, so the page does not shift: a drawer across the window stops at that gutter, as
158
+ the page does. On a page that fits the window there is no gutter, and the drawer spans it all
159
+ ([Dialog's scroll lock](dialog.md#styling); without JavaScript in Firefox, the gutter stays).
160
+
161
+ ## Keyboard and focus
162
+
163
+ | Key | Does |
164
+ | -------------- | ---------------------------------------------------------------------------- |
165
+ | Enter, Space | On the trigger: opens the drawer. |
166
+ | Tab, Shift+Tab | Move between the focusable elements of the drawer; the page behind is inert. |
167
+ | Escape | Closes the drawer on top. |
168
+
169
+ - **Focus** moves into the drawer on open and returns to the trigger on close, a swipe included, as
170
+ with [Dialog](dialog.md#keyboard-and-focus): on open, to the element with `autofocus`, or else the
171
+ first tab stop, or else the drawer. Base UI's Drawer focuses the drawer itself; give the drawer
172
+ `autofocus` (`<Drawer.Popup autofocus>`) for that, where the script runs. Opened by touch, it
173
+ focuses itself already.
174
+ - **A swipe** is a dragging movement: the close buttons, Escape and a click on the backdrop close
175
+ the drawer without one (WCAG 2.5.7). Give a drawer a visible close button.
176
+ - **The swipe** needs to travel 40% of the drawer's size toward its edge, or to be a quick flick;
177
+ shorter, the drawer springs back. It never starts on a button, link or field.
178
+
179
+ ## Without JavaScript, and the client script
180
+
181
+ The trigger, the close buttons, Escape and the click outside are HTML features, as for Dialog: a
182
+ drawer in a server-rendered page works before any script loads, and with none, against its edge.
183
+ `open`, `onOpenChange` and `bind:open` need the component to run in an island.
184
+
185
+ A page that renders a `Drawer` loads Dialog's script and a 1 kB (brotli) one of its own (2.5 kB
186
+ together), without the Lumiose runtime; islands bundle them. The drawer's script handles the
187
+ swipe, with the mouse and pen through pointer events and on touch screens through touch events,
188
+ taking the gesture from scrolling only once it is a swipe.
189
+
190
+ Not in this Drawer, next to Base UI's: snap points, a swipe area that opens the drawer from the
191
+ edge, indented backgrounds, the virtual keyboard provider, detached triggers (`createHandle`), and
192
+ non-modal drawers.
193
+
194
+ ## Browser support
195
+
196
+ - **`<dialog>`, invoker commands, `closedby` and scroll timelines**: as for
197
+ [Dialog](dialog.md#browser-support).
198
+ - **Exit animations** that keep the drawer in the top layer (`overlay`) are Chromium-only.
199
+ Elsewhere it leaves the top layer as soon as it starts closing.
200
+
201
+ ## Related
202
+
203
+ - [Dialog](dialog.md): the same parts, centred.
204
+ - [UI components](README.md): the approach, styling and the other components.