@lumiose/ui 0.0.0-stage → 0.1.1

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,3 @@
1
+ // The custom invoker command of `Toast.Close` (`command="--close-toast"`): shared by the component
2
+ // and its client script, which imports nothing else from the component.
3
+ export const CLOSE_COMMAND = "--close-toast";
@@ -0,0 +1,68 @@
1
+ /*
2
+ * Toast's viewport is a manual popover once its client script runs: in the top layer, as a box
3
+ * fixed to the end of the window's bottom edge, without the popover's default border, padding,
4
+ * colours or centring; before the script (and without it) the same box, out of the top layer.
5
+ * `<lu-toast-viewport>`, a custom element, is inline until styled. Toasts past the limit are
6
+ * `hidden`, whatever display a stylesheet gives toasts (an important declaration in this first
7
+ * layer wins over every later one). A toast that swipes leaves the pointer's moves to its script
8
+ * rather than scrolling the page along its axes, and selects no text.
9
+ */
10
+ @layer lu.core, lu.theme;
11
+
12
+ @layer lu.core {
13
+ lu-toast-viewport {
14
+ display: block;
15
+ position: fixed;
16
+ inset: auto;
17
+ inset-block-end: 0;
18
+ inset-inline-end: 0;
19
+ inline-size: auto;
20
+ block-size: auto;
21
+ margin: 0;
22
+ padding: 0;
23
+ border: 0;
24
+ background: transparent;
25
+ color: inherit;
26
+ overflow: visible;
27
+ }
28
+ lu-toast-viewport > [hidden] {
29
+ display: none !important;
30
+ }
31
+ /* A control that takes focus scrolls clear of the toasts (the script sets the height). */
32
+ :root {
33
+ scroll-padding-block-end: var(--lu-toast-stack-block-size, 0px);
34
+ }
35
+ lu-toast-viewport > [data-swiping] {
36
+ user-select: none;
37
+ }
38
+ /* The swipe's axis is the toast's: the other one scrolls the page. */
39
+ lu-toast-viewport > :is([data-swipe-directions~="up"], [data-swipe-directions~="down"]) {
40
+ touch-action: pan-x;
41
+ }
42
+ lu-toast-viewport > :is([data-swipe-directions~="left"], [data-swipe-directions~="right"]) {
43
+ touch-action: pan-y;
44
+ }
45
+ lu-toast-viewport
46
+ > :is([data-swipe-directions~="up"], [data-swipe-directions~="down"]):is(
47
+ [data-swipe-directions~="left"],
48
+ [data-swipe-directions~="right"]
49
+ ) {
50
+ touch-action: none;
51
+ }
52
+ /* And pinch-zoom stays, where `touch-action` knows it (not Safari) (TASK-442 L3). */
53
+ @supports (touch-action: pinch-zoom) {
54
+ lu-toast-viewport > :is([data-swipe-directions~="up"], [data-swipe-directions~="down"]) {
55
+ touch-action: pan-x pinch-zoom;
56
+ }
57
+ lu-toast-viewport > :is([data-swipe-directions~="left"], [data-swipe-directions~="right"]) {
58
+ touch-action: pan-y pinch-zoom;
59
+ }
60
+ lu-toast-viewport
61
+ > :is([data-swipe-directions~="up"], [data-swipe-directions~="down"]):is(
62
+ [data-swipe-directions~="left"],
63
+ [data-swipe-directions~="right"]
64
+ ) {
65
+ touch-action: pinch-zoom;
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,516 @@
1
+ // Toast's client script (decision-62, decision-63): defines `<lu-toast-viewport>`, the light-DOM
2
+ // element that Toast.Viewport renders. Its toasts are its children, server-rendered (a form
3
+ // action's result) or rendered by an island from a toast manager; this script gives both the same
4
+ // behaviour, after Base UI and Zag (doc-9 §16):
5
+ //
6
+ // - Top layer: the viewport becomes a manual popover (`popover="manual"`) and shows itself, so it
7
+ // sits above the page and dialogs, and no other popover's light dismiss closes it. A modal
8
+ // dialog makes everything outside it inert, toasts included: while one is open, the viewport
9
+ // moves into the topmost modal dialog and back when it closes, or when that dialog leaves the
10
+ // page (TASK-436 M8). It has `data-swipe-ignore`, so a toast's swipe never swipes a Drawer it
11
+ // was moved into (M7).
12
+ // - Focus: the toast that holds focus is never hidden by the limit (M9). While toasts show, the
13
+ // stack's height is `--lu-toast-stack-block-size` on the root, which toast.css turns into the
14
+ // page's `scroll-padding-block-end`, so a control that takes focus is scrolled clear of the
15
+ // stack (M10).
16
+ // - Timers: a toast closes after its `data-timeout` milliseconds (`0` keeps it). Timers pause
17
+ // while the pointer is over the viewport, while keyboard focus (`:focus-visible`) is in it, while
18
+ // the window is in the background or the page is hidden, and resume with the time that was
19
+ // left. A press that focuses a toast pauses it only while the pointer stays (TASK-442 L1).
20
+ // - Limit: past `data-limit` toasts (on the viewport), the older ones are hidden and paused until
21
+ // a newer one closes. Each shown toast gets `--lu-toast-index` (0 for the first child).
22
+ // - Closing: a close button (`command="--close-toast"`), Escape in a toast, a swipe or its timer
23
+ // fires a cancelable `close` event on the toast. An island's toast handles it (its manager
24
+ // removes it after its exit animation); otherwise the script sets `data-ending-style`, waits
25
+ // for the toast's animations and removes it. When the focused toast goes, focus moves to the
26
+ // next one, or back to where it was before it entered the viewport (F6, Tab or a click).
27
+ // - Keyboard: F6 focuses the viewport; Escape there, or Shift+Tab from it, returns focus.
28
+ // - Swipe: a pointer drag in one of the toast's `data-swipe-directions` (up, down, left, right)
29
+ // past 40px closes it. It locks to one axis, never starts on a control, and springs back when
30
+ // it falls short or reverses: pulled back 10px or more from its farthest point, it stays
31
+ // (TASK-442 L2). While it runs the toast has `data-swiping`, `data-swipe-direction`
32
+ // and `--lu-toast-swipe-movement-x` or `-y`. The close button and Escape do the same without a
33
+ // gesture (WCAG 2.5.1, 2.5.7).
34
+ // - Announcements: each toast is a live region (`role="status"`, or `role="alert"`). Safari's
35
+ // VoiceOver misses the text of a region inserted with it, so 250ms after a toast appears the
36
+ // script adds an invisible word joiner to its first text, a change that VoiceOver reads (as Base
37
+ // UI does).
38
+ // - New toasts get `data-starting-style` for their first frame, for an enter animation.
39
+ //
40
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
41
+
42
+ import { afterAnimations, startFrom } from "../animation/animation.ts";
43
+ import { CLOSE_COMMAND } from "./command.ts";
44
+
45
+ const SWIPE_THRESHOLD = 40;
46
+ /** A swipe pulled back this far (px) from its farthest point is cancelled, as in Base UI. */
47
+ const SWIPE_REVERSAL = 10;
48
+ /** U+2060, invisible: the change that makes VoiceOver read a new toast. */
49
+ const WORD_JOINER = String.fromCodePoint(0x2060);
50
+ const CONTROLS = "button, a[href], input, select, textarea, [contenteditable]";
51
+
52
+ interface Timer {
53
+ remaining: number;
54
+ started: number;
55
+ handle?: ReturnType<typeof setTimeout>;
56
+ }
57
+
58
+ interface Swipe {
59
+ toast: HTMLElement;
60
+ pointer: number;
61
+ x: number;
62
+ y: number;
63
+ axis?: "x" | "y";
64
+ directions: string[];
65
+ /** The longest distance from the press along the axis so far (px). */
66
+ farthest: number;
67
+ }
68
+
69
+ const viewports = new Set<LuToastViewport>();
70
+ /** Open modal dialogs, in the order they opened: the last is on top. */
71
+ const modals: HTMLDialogElement[] = [];
72
+ let windowFocused = true;
73
+
74
+ export class LuToastViewport extends HTMLElement {
75
+ #timers = new Map<HTMLElement, Timer>();
76
+ #closing = new WeakSet<HTMLElement>();
77
+ #hovered = false;
78
+ #focused = false;
79
+ #previous: HTMLElement | null = null;
80
+ #focusedToast: HTMLElement | null = null;
81
+ #swipe: Swipe | undefined;
82
+ #observer: MutationObserver | undefined;
83
+ /** Where the viewport belongs while a modal dialog holds it. */
84
+ #home: Comment | undefined;
85
+ #moving = false;
86
+ #resize: ResizeObserver | undefined;
87
+
88
+ /** Its toasts: its element children. */
89
+ get toasts(): HTMLElement[] {
90
+ return [...this.children].filter((child): child is HTMLElement => child instanceof HTMLElement);
91
+ }
92
+
93
+ connectedCallback() {
94
+ if (this.#moving) return;
95
+ viewports.add(this);
96
+ this.setAttribute("data-swipe-ignore", "");
97
+ this.#resize = new ResizeObserver(() => this.#publish());
98
+ this.#resize.observe(this);
99
+ this.addEventListener("pointerenter", this.#onPointerEnter);
100
+ this.addEventListener("pointerleave", this.#onPointerLeave);
101
+ this.addEventListener("focusin", this.#onFocusIn);
102
+ this.addEventListener("focusout", this.#onFocusOut);
103
+ this.addEventListener("keydown", this.#onKeyDown);
104
+ this.addEventListener("command", this.#onCommand, true);
105
+ this.addEventListener("click", this.#onClick);
106
+ this.addEventListener("pointerdown", this.#onPointerDown);
107
+ this.#observer = new MutationObserver((records) => this.#onMutations(records));
108
+ this.#observer.observe(this, {
109
+ childList: true,
110
+ subtree: true,
111
+ attributes: true,
112
+ attributeFilter: ["data-timeout"],
113
+ });
114
+ this.#show();
115
+ this.#relocate();
116
+ for (const toast of this.toasts) this.#added(toast, true);
117
+ this.#layout();
118
+ }
119
+
120
+ disconnectedCallback() {
121
+ if (this.#moving) return;
122
+ // Removed with the modal dialog that held it: back home, still the page's toast region.
123
+ if (this.#home?.isConnected) {
124
+ this.#moving = true;
125
+ this.#home.replaceWith(this);
126
+ this.#home = undefined;
127
+ this.#moving = false;
128
+ this.#show();
129
+ return;
130
+ }
131
+ viewports.delete(this);
132
+ this.#resize?.disconnect();
133
+ if (document.documentElement.style.getPropertyValue("--lu-toast-stack-block-size")) {
134
+ document.documentElement.style.removeProperty("--lu-toast-stack-block-size");
135
+ }
136
+ this.#follow(false);
137
+ this.#observer?.disconnect();
138
+ for (const timer of this.#timers.values()) clearTimeout(timer.handle);
139
+ this.#timers.clear();
140
+ }
141
+
142
+ /** Closes `toast`, as its close button does. */
143
+ close(toast: HTMLElement): void {
144
+ if (this.#closing.has(toast) || toast.parentElement !== this) return;
145
+ this.#closing.add(toast);
146
+ this.#stop(toast);
147
+ if (toast.dispatchEvent(new Event("close", { cancelable: true }))) {
148
+ toast.setAttribute("data-ending-style", "");
149
+ void afterAnimations(toast).then(() => toast.remove());
150
+ }
151
+ this.#layout();
152
+ }
153
+
154
+ /** Focuses the viewport (F6), to come back to the focused element after. */
155
+ focusFromHotkey(): void {
156
+ const active = document.activeElement;
157
+ if (!this.contains(active)) this.#previous = active instanceof HTMLElement ? active : null;
158
+ this.focus();
159
+ }
160
+
161
+ /** Whether it shows a toast, for F6. */
162
+ get active(): boolean {
163
+ return this.toasts.some((toast) => !toast.hidden);
164
+ }
165
+
166
+ /** Moves into the topmost modal dialog, or back home when none is open. */
167
+ relocate(): void {
168
+ this.#relocate();
169
+ }
170
+
171
+ #show() {
172
+ if (typeof this.showPopover !== "function") return;
173
+ if (this.popover !== "manual") this.popover = "manual";
174
+ if (!this.matches(":popover-open")) this.showPopover();
175
+ }
176
+
177
+ /** The stack's height while a toast shows, for the page's scroll padding. */
178
+ #publish() {
179
+ const root = document.documentElement.style;
180
+ const height = this.active ? this.getBoundingClientRect().height : 0;
181
+ if (height > 0) root.setProperty("--lu-toast-stack-block-size", `${Math.ceil(height)}px`);
182
+ else root.removeProperty("--lu-toast-stack-block-size");
183
+ }
184
+
185
+ #relocate() {
186
+ // A modal dialog removed from the page fires no close: it holds nothing any more.
187
+ for (const dialog of modals.filter((each) => !each.isConnected)) {
188
+ modals.splice(modals.indexOf(dialog), 1);
189
+ }
190
+ const top = modals.at(-1);
191
+ const target = top && !top.contains(this) ? top : undefined;
192
+ if (!target && (!this.#home || (top && top.contains(this)))) return;
193
+ this.#moving = true;
194
+ if (target) {
195
+ this.#home ??= this.ownerDocument.createComment("lu-toast-viewport");
196
+ if (!this.#home.isConnected) this.before(this.#home);
197
+ target.append(this);
198
+ } else if (this.#home) {
199
+ this.#home.replaceWith(this);
200
+ this.#home = undefined;
201
+ }
202
+ this.#moving = false;
203
+ // Leaving the document hid the popover.
204
+ this.#show();
205
+ }
206
+
207
+ #onMutations(records: MutationRecord[]) {
208
+ for (const record of records) {
209
+ if (record.type === "attributes") {
210
+ const toast = record.target as HTMLElement;
211
+ if (toast.parentElement === this && !this.#closing.has(toast)) this.#start(toast);
212
+ continue;
213
+ }
214
+ if (record.target !== this) continue;
215
+ for (const node of record.addedNodes) {
216
+ if (node instanceof HTMLElement) this.#added(node, false);
217
+ }
218
+ for (const node of record.removedNodes) {
219
+ if (node instanceof HTMLElement) this.#removed(node);
220
+ }
221
+ }
222
+ this.#layout();
223
+ }
224
+
225
+ #added(toast: HTMLElement, initial: boolean) {
226
+ // A toast rendered with the page does not animate in: nothing changed.
227
+ if (!initial) startFrom(toast);
228
+ setTimeout(() => {
229
+ if (!toast.isConnected) return;
230
+ // Inside its first text (its title), not as a loose node a grid would lay out on its own.
231
+ const text = [...toast.querySelectorAll("*")].find(
232
+ (element) =>
233
+ element.children.length === 0 &&
234
+ element.textContent!.trim() &&
235
+ !element.closest(CONTROLS),
236
+ );
237
+ (text ?? toast).append(WORD_JOINER);
238
+ }, 250);
239
+ this.#start(toast);
240
+ }
241
+
242
+ #removed(toast: HTMLElement) {
243
+ this.#stop(toast);
244
+ if (this.#focusedToast !== toast) return;
245
+ this.#focusedToast = null;
246
+ const next = this.toasts.find((each) => !each.hidden && !this.#closing.has(each));
247
+ const target = next?.querySelector<HTMLElement>(CONTROLS) ?? this.#previous;
248
+ if (target?.isConnected) target.focus({ preventScroll: true });
249
+ else if (this.active) this.focus({ preventScroll: true });
250
+ // Removing the focused element fires no `focusout` in every browser.
251
+ const active = document.activeElement;
252
+ this.#focused = this.contains(active) && active!.matches(":focus-visible");
253
+ }
254
+
255
+ /** Starts the toast's timer again, from its `data-timeout`. */
256
+ #start(toast: HTMLElement) {
257
+ this.#stop(toast);
258
+ const timeout = Number(toast.dataset["timeout"] ?? "5000");
259
+ if (!(timeout > 0)) return;
260
+ this.#timers.set(toast, { remaining: timeout, started: 0 });
261
+ this.#run(toast);
262
+ }
263
+
264
+ #stop(toast: HTMLElement) {
265
+ clearTimeout(this.#timers.get(toast)?.handle);
266
+ this.#timers.delete(toast);
267
+ }
268
+
269
+ #paused(): boolean {
270
+ return this.#hovered || this.#focused || !windowFocused || document.hidden;
271
+ }
272
+
273
+ #run(toast: HTMLElement) {
274
+ const timer = this.#timers.get(toast);
275
+ if (!timer || timer.handle !== undefined || toast.hidden || this.#paused()) return;
276
+ timer.started = performance.now();
277
+ timer.handle = setTimeout(() => this.close(toast), timer.remaining);
278
+ }
279
+
280
+ #pause(toast: HTMLElement) {
281
+ const timer = this.#timers.get(toast);
282
+ if (!timer || timer.handle === undefined) return;
283
+ clearTimeout(timer.handle);
284
+ timer.handle = undefined;
285
+ timer.remaining = Math.max(0, timer.remaining - (performance.now() - timer.started));
286
+ }
287
+
288
+ /** Applies the pause state to every timer. */
289
+ syncTimers(): void {
290
+ for (const toast of this.#timers.keys()) {
291
+ if (this.#paused() || toast.hidden) this.#pause(toast);
292
+ else this.#run(toast);
293
+ }
294
+ this.toggleAttribute("data-expanded", this.#hovered || this.#focused);
295
+ }
296
+
297
+ /** Hides the toasts past the limit, numbers the shown ones and updates the timers. */
298
+ #layout() {
299
+ const limit = Number(this.dataset["limit"] ?? "3");
300
+ // The focused toast stays shown, past the limit too: hiding it would drop focus to the page.
301
+ const focused = this.#toastOf(document.activeElement);
302
+ let index = 0;
303
+ for (const toast of this.toasts) {
304
+ if (this.#closing.has(toast)) continue;
305
+ const limited = limit > 0 && index >= limit && toast !== focused;
306
+ toast.hidden = limited;
307
+ toast.toggleAttribute("data-limited", limited);
308
+ if (limited) toast.style.removeProperty("--lu-toast-index");
309
+ else toast.style.setProperty("--lu-toast-index", String(index));
310
+ index += 1;
311
+ }
312
+ this.syncTimers();
313
+ }
314
+
315
+ #toastOf(node: EventTarget | null): HTMLElement | undefined {
316
+ if (!(node instanceof Node)) return undefined;
317
+ return this.toasts.find((toast) => toast.contains(node));
318
+ }
319
+
320
+ #onPointerEnter = () => {
321
+ this.#hovered = true;
322
+ this.syncTimers();
323
+ };
324
+
325
+ #onPointerLeave = () => {
326
+ this.#hovered = false;
327
+ this.syncTimers();
328
+ };
329
+
330
+ #onFocusIn = (event: FocusEvent) => {
331
+ // Where focus came from, to return to when the toasts it went to are gone.
332
+ const from = event.relatedTarget;
333
+ if (from instanceof HTMLElement && !this.contains(from)) this.#previous = from;
334
+ this.#focused = (event.target as Element).matches(":focus-visible");
335
+ this.#focusedToast = this.#toastOf(event.target) ?? null;
336
+ this.syncTimers();
337
+ };
338
+
339
+ #onFocusOut = (event: FocusEvent) => {
340
+ if (this.contains(event.relatedTarget as Node | null)) return;
341
+ this.#focused = false;
342
+ // A toast that leaves takes focus with it: `#removed` moves focus on, so keep track of it.
343
+ if (event.relatedTarget !== null) this.#focusedToast = null;
344
+ this.syncTimers();
345
+ };
346
+
347
+ #onKeyDown = (event: KeyboardEvent) => {
348
+ if (event.key === "Escape") {
349
+ const toast = this.#toastOf(event.target);
350
+ if (toast) this.close(toast);
351
+ else if (event.target === this) this.#previous?.focus({ preventScroll: true });
352
+ else return;
353
+ event.preventDefault();
354
+ } else if (event.key === "Tab" && event.shiftKey && event.target === this && this.#previous) {
355
+ event.preventDefault();
356
+ this.#previous.focus({ preventScroll: true });
357
+ }
358
+ };
359
+
360
+ #onCommand = (event: Event) => {
361
+ if ((event as Event & { command?: string }).command !== CLOSE_COMMAND) return;
362
+ const toast = this.#toastOf(event.target);
363
+ if (toast) this.close(toast);
364
+ };
365
+
366
+ /** Where the browser lacks invoker commands, the close button's click does it. */
367
+ #onClick = (event: MouseEvent) => {
368
+ if ("command" in HTMLButtonElement.prototype) return;
369
+ const button = (event.target as Element | null)?.closest?.("button[command]");
370
+ if (button?.getAttribute("command") !== CLOSE_COMMAND) return;
371
+ const toast = this.#toastOf(button);
372
+ if (toast) this.close(toast);
373
+ };
374
+
375
+ #onPointerDown = (event: PointerEvent) => {
376
+ if (event.button !== 0 || !event.isPrimary) return;
377
+ const toast = this.#toastOf(event.target);
378
+ if (!toast || this.#closing.has(toast)) return;
379
+ if ((event.target as Element).closest(CONTROLS)) return;
380
+ const directions = (toast.dataset["swipeDirections"] ?? "").split(/\s+/).filter(Boolean);
381
+ if (directions.length === 0) return;
382
+ this.#swipe = {
383
+ toast,
384
+ pointer: event.pointerId,
385
+ x: event.clientX,
386
+ y: event.clientY,
387
+ directions,
388
+ farthest: 0,
389
+ };
390
+ this.#follow(true);
391
+ };
392
+
393
+ /**
394
+ * While a press may become a swipe, the document's pointer events follow it, wherever the
395
+ * pointer goes (pointer capture would, but WebKit then stops dispatching Playwright's mouse).
396
+ */
397
+ #follow(on: boolean) {
398
+ const listeners = [
399
+ ["pointermove", this.#onPointerMove],
400
+ ["pointerup", this.#onPointerUp],
401
+ ["pointercancel", this.#onPointerCancel],
402
+ ] as const;
403
+ for (const [type, listener] of listeners) {
404
+ if (on) document.addEventListener(type, listener, true);
405
+ else document.removeEventListener(type, listener, true);
406
+ }
407
+ }
408
+
409
+ #onPointerMove = (event: PointerEvent) => {
410
+ const swipe = this.#swipe;
411
+ if (!swipe || event.pointerId !== swipe.pointer) return;
412
+ const dx = event.clientX - swipe.x;
413
+ const dy = event.clientY - swipe.y;
414
+ if (!swipe.axis) {
415
+ if (Math.hypot(dx, dy) < 6) return;
416
+ const axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
417
+ const along = axis === "x" ? ["left", "right"] : ["up", "down"];
418
+ if (!swipe.directions.some((direction) => along.includes(direction))) {
419
+ this.#swipe = undefined;
420
+ this.#follow(false);
421
+ return;
422
+ }
423
+ swipe.axis = axis;
424
+ swipe.toast.setAttribute("data-swiping", "");
425
+ // Text the press began to select would be dragged by the next press (Firefox cancels the
426
+ // pointer then): a swipe selects nothing.
427
+ getSelection()?.removeAllRanges();
428
+ }
429
+ const delta = swipe.axis === "x" ? dx : dy;
430
+ const direction =
431
+ swipe.axis === "x" ? (delta > 0 ? "right" : "left") : delta > 0 ? "down" : "up";
432
+ // Against an allowed direction it moves a little, as a hint that it does not go that way.
433
+ const movement = swipe.directions.includes(direction) ? delta : delta / 8;
434
+ swipe.farthest = Math.max(swipe.farthest, Math.abs(delta));
435
+ swipe.toast.style.setProperty(`--lu-toast-swipe-movement-${swipe.axis}`, `${movement}px`);
436
+ swipe.toast.setAttribute("data-swipe-direction", direction);
437
+ };
438
+
439
+ #onPointerUp = (event: PointerEvent) => {
440
+ const swipe = this.#swipe;
441
+ if (!swipe || event.pointerId !== swipe.pointer) return;
442
+ this.#swipe = undefined;
443
+ this.#follow(false);
444
+ if (!swipe.axis) return;
445
+ const delta = swipe.axis === "x" ? event.clientX - swipe.x : event.clientY - swipe.y;
446
+ const direction =
447
+ swipe.axis === "x" ? (delta > 0 ? "right" : "left") : delta > 0 ? "down" : "up";
448
+ swipe.toast.removeAttribute("data-swiping");
449
+ const reversed = swipe.farthest - Math.abs(delta) >= SWIPE_REVERSAL;
450
+ if (swipe.directions.includes(direction) && Math.abs(delta) >= SWIPE_THRESHOLD && !reversed) {
451
+ this.close(swipe.toast);
452
+ } else this.#reset(swipe.toast);
453
+ };
454
+
455
+ #onPointerCancel = (event: PointerEvent) => {
456
+ const swipe = this.#swipe;
457
+ if (!swipe || event.pointerId !== swipe.pointer) return;
458
+ this.#swipe = undefined;
459
+ this.#follow(false);
460
+ swipe.toast.removeAttribute("data-swiping");
461
+ this.#reset(swipe.toast);
462
+ };
463
+
464
+ #reset(toast: HTMLElement) {
465
+ toast.removeAttribute("data-swipe-direction");
466
+ toast.style.removeProperty("--lu-toast-swipe-movement-x");
467
+ toast.style.removeProperty("--lu-toast-swipe-movement-y");
468
+ }
469
+ }
470
+
471
+ function relocateAll() {
472
+ for (const viewport of viewports) viewport.relocate();
473
+ }
474
+
475
+ if (!customElements.get("lu-toast-viewport")) {
476
+ customElements.define("lu-toast-viewport", LuToastViewport);
477
+ // Modal dialogs: the viewport follows the topmost one, where it is not inert.
478
+ modals.push(...document.querySelectorAll<HTMLDialogElement>("dialog:modal"));
479
+ new MutationObserver((records) => {
480
+ let changed = false;
481
+ for (const record of records) {
482
+ const dialog = record.target;
483
+ if (!(dialog instanceof HTMLDialogElement)) continue;
484
+ const at = modals.indexOf(dialog);
485
+ if (at !== -1) modals.splice(at, 1);
486
+ if (dialog.open && dialog.matches(":modal")) modals.push(dialog);
487
+ changed = true;
488
+ }
489
+ if (changed) relocateAll();
490
+ }).observe(document.documentElement, {
491
+ subtree: true,
492
+ attributes: true,
493
+ attributeFilter: ["open"],
494
+ });
495
+ document.addEventListener("keydown", (event) => {
496
+ if (event.key !== "F6" || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
497
+ return;
498
+ }
499
+ const viewport = [...viewports].find((each) => each.isConnected && each.active);
500
+ if (!viewport) return;
501
+ event.preventDefault();
502
+ viewport.focusFromHotkey();
503
+ });
504
+ const pause = () => {
505
+ for (const viewport of viewports) viewport.syncTimers();
506
+ };
507
+ window.addEventListener("blur", () => {
508
+ windowFocused = false;
509
+ pause();
510
+ });
511
+ window.addEventListener("focus", () => {
512
+ windowFocused = true;
513
+ pause();
514
+ });
515
+ document.addEventListener("visibilitychange", pause);
516
+ }