@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/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.
|