@lumiose/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. package/package.json +102 -3
@@ -0,0 +1,214 @@
1
+ // Dialog and alert dialog (decision-62, TASK-355): a native modal <dialog> opened and closed by
2
+ // invoker commands, so it works in server HTML with no JavaScript. The dialog element is the only
3
+ // state: the trigger, a close button, Escape, a click on the backdrop and the `open` prop all end
4
+ // in its `toggle` event, the single source of `onOpenChange`. Ported from Timeless's approach
5
+ // (https://timeless.build); the keyboard contract is the platform's (APG Dialog pattern).
6
+ import {
7
+ createContext,
8
+ effect,
9
+ isServer,
10
+ onCleanup,
11
+ ref,
12
+ signal,
13
+ untrack,
14
+ useContext,
15
+ useId,
16
+ type Signal,
17
+ } from "lumiose";
18
+ import dialogScript from "./dialog.script.ts?client";
19
+
20
+ /**
21
+ * `Dialog.Root` renders no element: it holds the dialog's state and the ids its parts share.
22
+ * `bind:open={signal}` stands in for `open` and `onOpenChange`.
23
+ */
24
+ export interface DialogRootProps {
25
+ /**
26
+ * Opens (`true`) or closes (`false`) the dialog when it changes, in the browser. Pair it with
27
+ * `onOpenChange` to keep a signal in sync; leave both out and the dialog keeps its own state.
28
+ * Server HTML always starts closed: a modal dialog only opens from script or a trigger.
29
+ */
30
+ open?: boolean;
31
+ /** Called after the dialog opens or closes, whatever opened or closed it. */
32
+ onOpenChange?: (open: boolean) => void;
33
+ /**
34
+ * `"alertdialog"` for a confirmation that needs an answer: a click outside does not close it
35
+ * (Escape still does). Default `"dialog"`.
36
+ */
37
+ role?: "dialog" | "alertdialog";
38
+ /** The other parts: a `Dialog.Trigger` and the `Dialog.Popup`, anywhere inside. */
39
+ children?: JSX.Element;
40
+ }
41
+
42
+ /** A `<button>` that opens the dialog (`command="show-modal"`). */
43
+ export type DialogTriggerProps = JSX.IntrinsicElements["button"];
44
+ /** The `<dialog>`. `closedby` defaults to `"any"`, or `"closerequest"` for an alert dialog. */
45
+ export type DialogPopupProps = JSX.IntrinsicElements["dialog"];
46
+ /** The `<h2>` that names the dialog. */
47
+ export type DialogTitleProps = JSX.IntrinsicElements["h2"];
48
+ /** The `<p>` that describes the dialog. */
49
+ export type DialogDescriptionProps = JSX.IntrinsicElements["p"];
50
+ /** A `<button>` that closes the dialog (`command="close"`); its `value` becomes `returnValue`. */
51
+ export type DialogCloseProps = JSX.IntrinsicElements["button"];
52
+
53
+ interface DialogContextValue {
54
+ popup: JSX.Ref<HTMLDialogElement>;
55
+ role: () => "dialog" | "alertdialog";
56
+ /** Whether the dialog is open, as its last `toggle` event said. */
57
+ open: Signal<boolean>;
58
+ onToggle: (open: boolean) => void;
59
+ popupId: string;
60
+ titleId: string;
61
+ descriptionId: string;
62
+ /** How many Title and Description parts are rendered: the popup references only those. */
63
+ titles: Signal<number>;
64
+ descriptions: Signal<number>;
65
+ }
66
+
67
+ const DialogContext = createContext<DialogContextValue>();
68
+
69
+ function useDialog(): DialogContextValue {
70
+ const context = useContext(DialogContext);
71
+ if (!context) throw new Error("Dialog parts must be inside <Dialog.Root>.");
72
+ return context;
73
+ }
74
+
75
+ function Root(props: DialogRootProps) {
76
+ // Closes the native dialog's gaps where JavaScript runs; the page loads it once (decision-63).
77
+ dialogScript();
78
+ const popup = ref<HTMLDialogElement>();
79
+ const id = useId();
80
+ const open = signal(false);
81
+ const context: DialogContextValue = {
82
+ popup,
83
+ role: () => props.role ?? "dialog",
84
+ open,
85
+ onToggle: (next) => {
86
+ open.set(next);
87
+ props.onOpenChange?.(next);
88
+ },
89
+ popupId: `${id}-popup`,
90
+ titleId: `${id}-title`,
91
+ descriptionId: `${id}-description`,
92
+ titles: signal(0),
93
+ descriptions: signal(0),
94
+ };
95
+
96
+ // The `open` prop drives the native state; the dialog's `toggle` event reports back, so a
97
+ // signal written by `onOpenChange` sees the same value again and nothing loops.
98
+ effect(() => {
99
+ const dialog = popup();
100
+ const wanted = props.open;
101
+ if (!dialog?.isConnected || wanted === undefined) return;
102
+ if (wanted && !dialog.open) dialog.showModal();
103
+ else if (!wanted && dialog.open) dialog.close();
104
+ });
105
+
106
+ return <DialogContext value={context}>{props.children}</DialogContext>;
107
+ }
108
+
109
+ // Each part spreads the caller's props first, so they override the defaults (`type`, `closedby`,
110
+ // the label references), and sets the wiring last, so `id`, `command` and `commandfor` stay
111
+ // consistent between the parts.
112
+
113
+ function Trigger({ children, ...rest }: DialogTriggerProps) {
114
+ const dialog = useDialog();
115
+ // While the modal dialog is open the trigger is inert, so the server's "false" is never wrong
116
+ // where assistive technology can reach it; in the browser it follows the dialog.
117
+ return (
118
+ <button
119
+ type="button"
120
+ aria-haspopup="dialog"
121
+ aria-expanded={dialog.open()}
122
+ {...rest}
123
+ command="show-modal"
124
+ commandfor={dialog.popupId}
125
+ >
126
+ {children}
127
+ </button>
128
+ );
129
+ }
130
+
131
+ function Popup({ children, ref: forwarded, ...rest }: DialogPopupProps) {
132
+ const dialog = useDialog();
133
+ const alert = () => dialog.role() === "alertdialog";
134
+ // The server writes the attributes before it renders the children, so it renders them first to
135
+ // know which Title and Description exist. The browser claims the dialog before its children
136
+ // (hydration needs that order) and updates the references when the parts register. The server
137
+ // renders once, so reading them once is the intent: `untrack` says so (no LUM2001).
138
+ const content = isServer ? untrack(() => children) : undefined;
139
+ return (
140
+ <dialog
141
+ role={alert() ? "alertdialog" : undefined}
142
+ closedby={alert() ? "closerequest" : "any"}
143
+ aria-labelledby={dialog.titles() > 0 ? dialog.titleId : undefined}
144
+ aria-describedby={dialog.descriptions() > 0 ? dialog.descriptionId : undefined}
145
+ {...rest}
146
+ ref={(element: HTMLDialogElement) => {
147
+ dialog.popup(element);
148
+ forwarded?.(element);
149
+ }}
150
+ id={dialog.popupId}
151
+ on:toggle={(event: ToggleEvent) => dialog.onToggle(event.newState === "open")}
152
+ >
153
+ {isServer ? content : children}
154
+ </dialog>
155
+ );
156
+ }
157
+
158
+ /** Counts a rendered part in `count` while it is mounted. */
159
+ function register(count: Signal<number>) {
160
+ count.update((n) => n + 1);
161
+ onCleanup(() => count.update((n) => n - 1));
162
+ }
163
+
164
+ function Title({ children, ...rest }: DialogTitleProps) {
165
+ const dialog = useDialog();
166
+ // A caller's `id` names it: the Popup renders its parts before its own attributes on the server,
167
+ // and follows them in the browser (TASK-441 M39).
168
+ if (rest.id) dialog.titleId = rest.id;
169
+ register(dialog.titles);
170
+ return (
171
+ <h2 {...rest} id={dialog.titleId}>
172
+ {children}
173
+ </h2>
174
+ );
175
+ }
176
+
177
+ function Description({ children, ...rest }: DialogDescriptionProps) {
178
+ const dialog = useDialog();
179
+ // As Title's.
180
+ if (rest.id) dialog.descriptionId = rest.id;
181
+ register(dialog.descriptions);
182
+ return (
183
+ <p {...rest} id={dialog.descriptionId}>
184
+ {children}
185
+ </p>
186
+ );
187
+ }
188
+
189
+ function Close({ children, ...rest }: DialogCloseProps) {
190
+ const dialog = useDialog();
191
+ return (
192
+ <button type="button" {...rest} command="close" commandfor={dialog.popupId}>
193
+ {children}
194
+ </button>
195
+ );
196
+ }
197
+
198
+ /**
199
+ * A modal dialog, or an alert dialog with `role="alertdialog"` on `Root`. No JavaScript: the
200
+ * native `<dialog>` and invoker commands do the work, in server HTML too. Unstyled: style the
201
+ * parts through `class` and native state (`dialog[open]`, `::backdrop`).
202
+ *
203
+ * @example
204
+ * <Dialog.Root role="alertdialog">
205
+ * <Dialog.Trigger>Delete project…</Dialog.Trigger>
206
+ * <Dialog.Popup>
207
+ * <Dialog.Title>Delete project?</Dialog.Title>
208
+ * <Dialog.Description>This removes the project and its deployments.</Dialog.Description>
209
+ * <Dialog.Close value="cancel">Cancel</Dialog.Close>
210
+ * <Dialog.Close value="delete">Delete</Dialog.Close>
211
+ * </Dialog.Popup>
212
+ * </Dialog.Root>
213
+ */
214
+ export const Dialog = { Root, Trigger, Popup, Title, Description, Close };
@@ -0,0 +1,38 @@
1
+ /*
2
+ * A drawer is a modal <dialog> against the edge of the window its `data-swipe-direction` names,
3
+ * across that edge's length, instead of the browser's centred box; Dialog's rule keeps the page
4
+ * behind it from scrolling. While it is swiped it takes the pointer's moves rather than selecting
5
+ * text.
6
+ */
7
+ @layer lu.core, lu.theme;
8
+ @import "../dialog/dialog.css";
9
+
10
+ @layer lu.core {
11
+ dialog[data-swipe-direction] {
12
+ margin: 0;
13
+ max-inline-size: none;
14
+ max-block-size: none;
15
+ }
16
+ /* The browser's `fit-content` would leave it short of the edge's length. */
17
+ dialog:is([data-swipe-direction="down"], [data-swipe-direction="up"]) {
18
+ inline-size: auto;
19
+ }
20
+ dialog:is([data-swipe-direction="left"], [data-swipe-direction="right"]) {
21
+ block-size: auto;
22
+ }
23
+ dialog[data-swipe-direction="down"] {
24
+ inset: auto 0 0;
25
+ }
26
+ dialog[data-swipe-direction="up"] {
27
+ inset: 0 0 auto;
28
+ }
29
+ dialog[data-swipe-direction="left"] {
30
+ inset: 0 auto 0 0;
31
+ }
32
+ dialog[data-swipe-direction="right"] {
33
+ inset: 0 0 0 auto;
34
+ }
35
+ dialog[data-swipe-direction][data-swiping] {
36
+ user-select: none;
37
+ }
38
+ }
@@ -0,0 +1,207 @@
1
+ // Drawer's client script (decision-62, decision-63). A drawer is a Dialog: a native modal
2
+ // `<dialog>` with `data-swipe-direction`, which drawer.css puts against that edge of the window,
3
+ // so it opens and closes, traps focus and makes the page inert in server HTML with no JavaScript,
4
+ // and Dialog's script closes the native element's gaps. This module, which runs once on a page
5
+ // that renders a Drawer, adds swiping it away in that direction, as Base UI's Drawer does:
6
+ //
7
+ // - A drag in the dismiss direction moves the drawer: `data-swiping` and
8
+ // `--lu-drawer-swipe-movement-x` or `-y` (pixels) while it runs, `--lu-drawer-swipe-progress` (0 to 1)
9
+ // for the backdrop. Against that direction it moves a little, as a hint.
10
+ // - Released past 40% of its size, or flicked (faster than 0.5px/ms over 20px), it closes, with
11
+ // `data-swipe-dismiss` so a stylesheet can let it leave from where it is. Otherwise it springs
12
+ // back.
13
+ // - A drag never starts on a control (a button, link or field) or inside `[data-swipe-ignore]`,
14
+ // and not where the content under it can still scroll that way: the content scrolls instead.
15
+ // It starts only inside the drawer's box, not on its backdrop (TASK-436 M5), and a mouse or pen
16
+ // never starts one in `Drawer.Content` (`[data-drawer-content]`), where it selects text (M6).
17
+ // On a touch screen it takes the gesture (`touchmove`'s `preventDefault`) only once it is a swipe.
18
+ // - The close buttons, Escape and a click on the backdrop close it without a gesture (WCAG 2.5.7).
19
+ //
20
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
21
+ import { afterAnimations } from "../animation/animation.ts";
22
+
23
+ const CONTROLS = "button, a[href], input, select, textarea, [contenteditable], [data-swipe-ignore]";
24
+ /** Pixels a drag moves before it counts, and the share of the drawer's size that dismisses it. */
25
+ const SLOP = 6;
26
+ const DISMISS = 0.4;
27
+ const FLICK = 0.5;
28
+
29
+ type Direction = "up" | "down" | "left" | "right";
30
+
31
+ interface Drag {
32
+ dialog: HTMLDialogElement;
33
+ direction: Direction;
34
+ x: number;
35
+ y: number;
36
+ /** Whether the drag became a swipe (it moved in the dismiss direction). */
37
+ swiping: boolean;
38
+ /** The last move's time and movement, for the flick. */
39
+ at: number;
40
+ movement: number;
41
+ velocity: number;
42
+ }
43
+
44
+ let drag: Drag | undefined;
45
+
46
+ const axis = (direction: Direction) => (direction === "up" || direction === "down" ? "y" : "x");
47
+ const sign = (direction: Direction) => (direction === "down" || direction === "right" ? 1 : -1);
48
+
49
+ /** Whether an element between `target` and the dialog can scroll toward `direction`'s opposite. */
50
+ function scrolls(target: Element, dialog: HTMLDialogElement, direction: Direction): boolean {
51
+ for (
52
+ let node: Element | null = target;
53
+ node && node !== dialog.parentElement;
54
+ node = node.parentElement
55
+ ) {
56
+ const vertical = axis(direction) === "y";
57
+ const size = vertical
58
+ ? node.scrollHeight - node.clientHeight
59
+ : node.scrollWidth - node.clientWidth;
60
+ if (size <= 1) continue;
61
+ const overflow = getComputedStyle(node)[vertical ? "overflowY" : "overflowX"];
62
+ if (!/auto|scroll/.test(overflow)) continue;
63
+ const position = vertical ? node.scrollTop : node.scrollLeft;
64
+ // Swiping down dismisses only once the content is scrolled to its top, and so on.
65
+ if (sign(direction) > 0 ? position > 0 : position < size - 1) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function start(target: EventTarget | null, x: number, y: number, mouse: boolean): boolean {
71
+ if (!(target instanceof Element)) return false;
72
+ const dialog = target.closest<HTMLDialogElement>("dialog[data-swipe-direction]");
73
+ if (!dialog?.open || target.closest(CONTROLS)) return false;
74
+ // A press on the backdrop targets the dialog too: only presses in its box count.
75
+ const box = dialog.getBoundingClientRect();
76
+ if (x < box.left || x > box.right || y < box.top || y > box.bottom) return false;
77
+ if (mouse && target.closest("[data-drawer-content]")) return false;
78
+ const direction = dialog.dataset["swipeDirection"] as Direction;
79
+ if (scrolls(target, dialog, direction)) return false;
80
+ drag = {
81
+ dialog,
82
+ direction,
83
+ x,
84
+ y,
85
+ swiping: false,
86
+ at: performance.now(),
87
+ movement: 0,
88
+ velocity: 0,
89
+ };
90
+ return true;
91
+ }
92
+
93
+ /** Follows the pointer; returns whether the drag is a swipe (so a touch is not a scroll). */
94
+ function move(x: number, y: number): boolean {
95
+ if (!drag) return false;
96
+ const { dialog, direction } = drag;
97
+ const delta = axis(direction) === "y" ? y - drag.y : x - drag.x;
98
+ const toward = delta * sign(direction);
99
+ if (!drag.swiping) {
100
+ if (Math.abs(delta) < SLOP) return false;
101
+ // A first move the other way, or along the other axis, is not a swipe.
102
+ const across = axis(direction) === "y" ? Math.abs(x - drag.x) : Math.abs(y - drag.y);
103
+ if (toward <= 0 || across > Math.abs(delta)) {
104
+ drag = undefined;
105
+ return false;
106
+ }
107
+ drag.swiping = true;
108
+ dialog.setAttribute("data-swiping", "");
109
+ }
110
+ const now = performance.now();
111
+ const movement = toward > 0 ? toward : toward / 8;
112
+ drag.velocity = (movement - drag.movement) / Math.max(1, now - drag.at);
113
+ drag.at = now;
114
+ drag.movement = movement;
115
+ const size = axis(direction) === "y" ? dialog.offsetHeight : dialog.offsetWidth;
116
+ dialog.style.setProperty(
117
+ `--lu-drawer-swipe-movement-${axis(direction)}`,
118
+ `${movement * sign(direction)}px`,
119
+ );
120
+ dialog.style.setProperty(
121
+ "--lu-drawer-swipe-progress",
122
+ String(Math.min(1, Math.max(0, movement / size))),
123
+ );
124
+ return true;
125
+ }
126
+
127
+ function reset(dialog: HTMLDialogElement) {
128
+ for (const name of [
129
+ "--lu-drawer-swipe-movement-x",
130
+ "--lu-drawer-swipe-movement-y",
131
+ "--lu-drawer-swipe-progress",
132
+ ]) {
133
+ dialog.style.removeProperty(name);
134
+ }
135
+ }
136
+
137
+ function end(): void {
138
+ const current = drag;
139
+ drag = undefined;
140
+ if (!current?.swiping) return;
141
+ const { dialog, direction, movement, velocity } = current;
142
+ dialog.removeAttribute("data-swiping");
143
+ const size = axis(direction) === "y" ? dialog.offsetHeight : dialog.offsetWidth;
144
+ if (movement > size * DISMISS || (velocity > FLICK && movement > 20)) {
145
+ dialog.setAttribute("data-swipe-dismiss", direction);
146
+ dialog.close();
147
+ // Once it has left: a reopened drawer starts from its edge.
148
+ void afterAnimations(dialog).then(() => {
149
+ if (dialog.open) return;
150
+ dialog.removeAttribute("data-swipe-dismiss");
151
+ reset(dialog);
152
+ });
153
+ } else reset(dialog);
154
+ }
155
+
156
+ function on<K extends keyof DocumentEventMap>(
157
+ type: K,
158
+ listener: (event: DocumentEventMap[K]) => void,
159
+ options: AddEventListenerOptions = {},
160
+ ) {
161
+ document.addEventListener(type, listener, { capture: true, ...options });
162
+ }
163
+
164
+ // Mouse and pen: pointer events. Touch: touch events, so the swipe can take the gesture from
165
+ // scrolling only once it is one.
166
+ on("pointerdown", (event) => {
167
+ if (event.pointerType !== "touch" && event.button === 0 && event.isPrimary) {
168
+ start(event.target, event.clientX, event.clientY, true);
169
+ }
170
+ });
171
+ on("pointermove", (event) => {
172
+ if (event.pointerType !== "touch" && move(event.clientX, event.clientY)) event.preventDefault();
173
+ });
174
+ on("pointerup", (event) => {
175
+ if (event.pointerType !== "touch") end();
176
+ });
177
+ on("pointercancel", (event) => {
178
+ if (event.pointerType !== "touch" && drag) {
179
+ reset(drag.dialog);
180
+ drag.dialog.removeAttribute("data-swiping");
181
+ drag = undefined;
182
+ }
183
+ });
184
+ on("touchstart", (event) => {
185
+ const touch = event.touches[0];
186
+ if (event.touches.length === 1 && touch) {
187
+ start(event.target, touch.clientX, touch.clientY, false);
188
+ } else if (drag) end();
189
+ });
190
+ on(
191
+ "touchmove",
192
+ (event) => {
193
+ const touch = event.touches[0];
194
+ if (touch && move(touch.clientX, touch.clientY)) event.preventDefault();
195
+ },
196
+ { passive: false },
197
+ );
198
+ on("touchend", () => end());
199
+ on("touchcancel", () => end());
200
+ // A drawer that opens again starts from its edge.
201
+ on("toggle", (event) => {
202
+ const dialog = event.target;
203
+ if (dialog instanceof HTMLDialogElement && dialog.open && dialog.dataset["swipeDirection"]) {
204
+ dialog.removeAttribute("data-swipe-dismiss");
205
+ reset(dialog);
206
+ }
207
+ });
@@ -0,0 +1,113 @@
1
+ // Drawer (decision-62, TASK-382): a Dialog against an edge of the window that a swipe toward that
2
+ // edge closes, after Base UI's Drawer. Its parts are Dialog's (a native modal `<dialog>` opened by
3
+ // an invoker command, so it works in server HTML with no JavaScript); Popup adds
4
+ // `data-swipe-direction`, which drawer.css turns into the edge it sits on, and the client script
5
+ // (./drawer.script.ts) the swipe. Ported from Timeless's approach (https://timeless.build).
6
+ import { createContext, useContext } from "lumiose";
7
+ import {
8
+ Dialog,
9
+ type DialogCloseProps,
10
+ type DialogDescriptionProps,
11
+ type DialogPopupProps,
12
+ type DialogTitleProps,
13
+ type DialogTriggerProps,
14
+ } from "../dialog/dialog.tsx";
15
+ import drawerScript from "./drawer.script.ts?client";
16
+
17
+ /** The direction a swipe closes the drawer in, and the edge of the window it sits against. */
18
+ export type DrawerSwipeDirection = "up" | "down" | "left" | "right";
19
+
20
+ /**
21
+ * `Drawer.Root` renders no element: it holds the state the other parts share, as `Dialog.Root`.
22
+ * `bind:open={signal}` stands in for `open` and `onOpenChange`.
23
+ */
24
+ export interface DrawerRootProps {
25
+ /**
26
+ * Opens (`true`) or closes (`false`) the drawer when it changes, in the browser. Pair it with
27
+ * `onOpenChange` to keep a signal in sync; leave both out and the drawer keeps its own state.
28
+ * Server HTML always starts closed: a modal dialog only opens from script or a trigger.
29
+ */
30
+ open?: boolean;
31
+ /** Called after the drawer opens or closes, whatever did it: a swipe included. */
32
+ onOpenChange?: (open: boolean) => void;
33
+ /**
34
+ * The direction a swipe closes the drawer in, which is also the window's edge it sits against.
35
+ * Default `"down"`: a bottom sheet.
36
+ */
37
+ swipeDirection?: DrawerSwipeDirection;
38
+ /** The other parts: a `Drawer.Trigger` and the `Drawer.Popup`, anywhere inside. */
39
+ children?: JSX.Element;
40
+ }
41
+
42
+ /** The `<button>` that opens the drawer, as `Dialog.Trigger`. */
43
+ export type DrawerTriggerProps = DialogTriggerProps;
44
+ /** The drawer: a `<dialog>` with `data-swipe-direction`, against that edge of the window. */
45
+ export type DrawerPopupProps = DialogPopupProps;
46
+ /** A `<div>` around the drawer's content, which may scroll: a swipe inside it waits for its top. */
47
+ export type DrawerContentProps = JSX.IntrinsicElements["div"];
48
+ /** The `<h2>` that names the drawer. */
49
+ export type DrawerTitleProps = DialogTitleProps;
50
+ /** The `<p>` that describes the drawer. */
51
+ export type DrawerDescriptionProps = DialogDescriptionProps;
52
+ /** A `<button>` that closes the drawer: the alternative to the swipe. */
53
+ export type DrawerCloseProps = DialogCloseProps;
54
+
55
+ const DrawerContext = createContext<{ direction: () => DrawerSwipeDirection }>();
56
+
57
+ function Root(props: DrawerRootProps) {
58
+ // The swipe; the page loads it once (decision-63). Dialog.Root loads Dialog's script.
59
+ drawerScript();
60
+ return (
61
+ <DrawerContext value={{ direction: () => props.swipeDirection ?? "down" }}>
62
+ <Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
63
+ {props.children}
64
+ </Dialog.Root>
65
+ </DrawerContext>
66
+ );
67
+ }
68
+
69
+ function Popup({ children, ...rest }: DrawerPopupProps) {
70
+ const drawer = useContext(DrawerContext);
71
+ if (!drawer) throw new Error("Drawer parts must be inside <Drawer.Root>.");
72
+ return (
73
+ <Dialog.Popup {...rest} data-swipe-direction={drawer.direction()}>
74
+ {children}
75
+ </Dialog.Popup>
76
+ );
77
+ }
78
+
79
+ function Content({ children, ...rest }: DrawerContentProps) {
80
+ // A mouse or pen drag here selects text: it never swipes the drawer (drawer.script.ts).
81
+ return (
82
+ <div {...rest} data-drawer-content="">
83
+ {children}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * A panel that slides in from an edge of the window: a modal dialog, so it opens from its trigger
90
+ * in server HTML with no JavaScript, and a swipe toward its edge closes it, as do its close
91
+ * buttons, Escape and a click on the backdrop. Unstyled: style it through `class`,
92
+ * `[data-swipe-direction]`, `[open]`, `[data-swiping]`, `[data-swipe-dismiss]` and the
93
+ * `--lu-drawer-swipe-movement-x`/`-y` and `--lu-drawer-swipe-progress` variables.
94
+ *
95
+ * @example
96
+ * <Drawer.Root>
97
+ * <Drawer.Trigger>Filters</Drawer.Trigger>
98
+ * <Drawer.Popup>
99
+ * <Drawer.Title>Filters</Drawer.Title>
100
+ * <Drawer.Content>…</Drawer.Content>
101
+ * <Drawer.Close>Done</Drawer.Close>
102
+ * </Drawer.Popup>
103
+ * </Drawer.Root>
104
+ */
105
+ export const Drawer = {
106
+ Root,
107
+ Trigger: Dialog.Trigger,
108
+ Popup,
109
+ Content,
110
+ Title: Dialog.Title,
111
+ Description: Dialog.Description,
112
+ Close: Dialog.Close,
113
+ };