@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,172 @@
1
+ // Context Menu's client script (decision-62, decision-63): defines `<lu-context-menu>`, the
2
+ // light-DOM element that ContextMenu.Root renders around its trigger area and its menu. The menu
3
+ // is Menu's own `<lu-menu>` (../menu/menu.script.ts, which this module loads): its keys, items,
4
+ // typeahead, submenus and dismissal (../layer/layer.ts). This element only opens it, where Base UI
5
+ // and Zag open a context menu (doc-9 §5):
6
+ //
7
+ // - A right-click (`contextmenu`) in the trigger area opens the menu at the pointer, instead of
8
+ // the browser's menu; another one moves it there. The popup is anchored to a fixed, zero-size
9
+ // element that the element puts at the pointer (`--lu-context-x`, `--lu-context-y`), so CSS
10
+ // anchor positioning places it with Menu's flips; elsewhere ../placement/place.ts does.
11
+ // - A long press on a touch screen (500ms without moving 10px, one finger) opens it at the
12
+ // finger: iOS fires no `contextmenu`, and the `contextmenu` Android fires after it is ignored.
13
+ // - The keyboard (WCAG 2.5.1): Shift+F10 or the ContextMenu key, with focus in the trigger area,
14
+ // opens it at the focused element and focuses its first item.
15
+ // - Focus: a keyboard opening focuses the first item, a pointer one the popup (Menu's rules). With
16
+ // no trigger to return to, closing returns focus to where it was before the menu opened, unless
17
+ // it already moved elsewhere (an outside press on a control).
18
+ // - A right-click inside the open menu shows neither menu.
19
+ // - `data-disabled` on the element leaves the browser's menu alone.
20
+ // - Nested: in the area of a context menu inside another's, the inner one opens (unless it is
21
+ // disabled, where the outer one does).
22
+ //
23
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
24
+ import "../menu/menu.script.ts";
25
+ import type { MenuElement } from "../menu/menu.script.ts";
26
+ import { anchoring, place } from "../placement/place.ts";
27
+
28
+ /** How long (ms) a touch rests before it opens the menu, and how far (px) it may move. */
29
+ const LONG_PRESS = 500;
30
+ const SLOP = 10;
31
+
32
+ interface Press {
33
+ pointer: number;
34
+ x: number;
35
+ y: number;
36
+ timer: ReturnType<typeof setTimeout>;
37
+ }
38
+
39
+ export class ContextMenuElement extends HTMLElement {
40
+ #press: Press | undefined;
41
+ /** The element focused before the menu opened, to return focus to. */
42
+ #previous: HTMLElement | null = null;
43
+ /** When a long press opened the menu: the `contextmenu` that follows it on Android is ignored. */
44
+ #pressedAt = -Infinity;
45
+
46
+ /** The menu: its `<lu-menu>`. */
47
+ get menu(): MenuElement | null {
48
+ return this.querySelector<MenuElement>(":scope > lu-menu, :scope > * > lu-menu");
49
+ }
50
+
51
+ connectedCallback(): void {
52
+ for (const type of ["contextmenu", "keydown", "pointerdown", "pointermove", "pointerup"]) {
53
+ this.addEventListener(type, this);
54
+ }
55
+ this.addEventListener("pointercancel", this);
56
+ // The menu's `openchange` does not bubble: a capturing listener still sees it.
57
+ this.addEventListener("openchange", this, true);
58
+ }
59
+
60
+ disconnectedCallback(): void {
61
+ for (const type of ["contextmenu", "keydown", "pointerdown", "pointermove", "pointerup"]) {
62
+ this.removeEventListener(type, this);
63
+ }
64
+ this.removeEventListener("pointercancel", this);
65
+ this.removeEventListener("openchange", this, true);
66
+ this.#cancel();
67
+ }
68
+
69
+ handleEvent(event: Event): void {
70
+ const menu = this.menu;
71
+ if (event.type === "openchange") {
72
+ // The menu's own, not a submenu's: focus left with the popup, or stayed in it.
73
+ if (event.target !== menu || (event as CustomEvent<{ open: boolean }>).detail.open) return;
74
+ const active = document.activeElement;
75
+ if (!active || active === document.body || menu?.contains(active)) {
76
+ this.#previous?.focus({ preventScroll: true });
77
+ }
78
+ this.#previous = null;
79
+ return;
80
+ }
81
+ if (!menu || this.hasAttribute("data-disabled")) return;
82
+ const target = event.target as Node;
83
+ // A context menu nested in this one's area opens instead (TASK-437 M15).
84
+ const owner = (target instanceof Element ? target : target.parentElement)?.closest(
85
+ "lu-context-menu:not([data-disabled])",
86
+ );
87
+ if (owner !== this) return;
88
+ const inMenu = menu.contains(target);
89
+ switch (event.type) {
90
+ case "contextmenu": {
91
+ event.preventDefault();
92
+ if (inMenu || event.timeStamp - this.#pressedAt < 1000) return;
93
+ const pointer = event as MouseEvent;
94
+ // A keyboard's `contextmenu` (where Shift+F10 was not caught below) has no pointer.
95
+ const keyboard = pointer.detail === 0 && pointer.clientX === 0 && pointer.clientY === 0;
96
+ if (keyboard) this.#openAtFocus(target);
97
+ else this.#open(pointer.clientX, pointer.clientY, false);
98
+ break;
99
+ }
100
+ case "keydown": {
101
+ const key = event as KeyboardEvent;
102
+ const opens = key.key === "ContextMenu" || (key.key === "F10" && key.shiftKey);
103
+ if (!opens || inMenu || key.altKey || key.ctrlKey || key.metaKey) return;
104
+ event.preventDefault();
105
+ this.#openAtFocus(target);
106
+ break;
107
+ }
108
+ case "pointerdown": {
109
+ const pointer = event as PointerEvent;
110
+ if (this.#press) {
111
+ // A second finger: not a long press.
112
+ this.#cancel();
113
+ return;
114
+ }
115
+ if (inMenu || pointer.pointerType === "mouse" || !pointer.isPrimary) return;
116
+ const { clientX: x, clientY: y, pointerId } = pointer;
117
+ this.#press = {
118
+ pointer: pointerId,
119
+ x,
120
+ y,
121
+ timer: setTimeout(() => {
122
+ this.#press = undefined;
123
+ this.#pressedAt = performance.now();
124
+ this.#open(x, y, false);
125
+ }, LONG_PRESS),
126
+ };
127
+ break;
128
+ }
129
+ case "pointermove": {
130
+ const pointer = event as PointerEvent;
131
+ const press = this.#press;
132
+ if (press?.pointer !== pointer.pointerId) return;
133
+ if (Math.hypot(pointer.clientX - press.x, pointer.clientY - press.y) > SLOP) this.#cancel();
134
+ break;
135
+ }
136
+ default:
137
+ if (this.#press?.pointer === (event as PointerEvent).pointerId) this.#cancel();
138
+ }
139
+ }
140
+
141
+ #cancel(): void {
142
+ if (this.#press) clearTimeout(this.#press.timer);
143
+ this.#press = undefined;
144
+ }
145
+
146
+ /** Opens the menu below the start of the focused element, as from the keyboard. */
147
+ #openAtFocus(target: Node): void {
148
+ const element = target instanceof Element ? target : target.parentElement;
149
+ const box = (element ?? this).getBoundingClientRect();
150
+ this.#open(box.left, box.bottom, true);
151
+ }
152
+
153
+ /** Opens the menu at `x`, `y` (viewport pixels), or moves it there when it is open. */
154
+ #open(x: number, y: number, keyboard: boolean): void {
155
+ const menu = this.menu!;
156
+ if (!menu.open) {
157
+ const active = document.activeElement;
158
+ this.#previous = active instanceof HTMLElement && active !== document.body ? active : null;
159
+ }
160
+ this.style.setProperty("--lu-context-x", `${x}px`);
161
+ this.style.setProperty("--lu-context-y", `${y}px`);
162
+ menu.show(keyboard);
163
+ const popup = menu.popup;
164
+ if (!anchoring && popup) {
165
+ place(popup, { getBoundingClientRect: () => new DOMRect(x, y, 0, 0) } as Element);
166
+ }
167
+ }
168
+ }
169
+
170
+ if (!customElements.get("lu-context-menu")) {
171
+ customElements.define("lu-context-menu", ContextMenuElement);
172
+ }
@@ -0,0 +1,178 @@
1
+ // Context Menu (decision-62, TASK-378): Menu, opened at the pointer by a right-click in its trigger
2
+ // area, a long press on a touch screen, or Shift+F10 and the ContextMenu key. Root renders
3
+ // `<lu-context-menu>` (./context-menu.script.ts) around the area and the menu; Popup renders
4
+ // Menu's `<lu-menu>` and `Menu.Popup`, anchored to a zero-size element that the script moves to
5
+ // the pointer; the items are Menu's parts, with Menu's keys, typeahead, submenus and dismissal.
6
+ // A right-click needs the script: without JavaScript the browser shows its own menu.
7
+ import {
8
+ createContext,
9
+ effect,
10
+ ref,
11
+ signal,
12
+ untrack,
13
+ useContext,
14
+ useId,
15
+ type Signal,
16
+ } from "lumiose";
17
+ import { Menu, MenuContext, type MenuContextValue, type MenuPopupProps } from "../menu/menu.tsx";
18
+ import type { MenuElement, MenuOpenChangeDetail } from "../menu/menu.script.ts";
19
+ import { anchorName } from "../placement/placement.ts";
20
+ import contextMenuScript from "./context-menu.script.ts?client";
21
+ import type { ContextMenuElement } from "./context-menu.script.ts";
22
+
23
+ declare module "lumiose/jsx-runtime" {
24
+ namespace JSX {
25
+ interface IntrinsicElements {
26
+ /** ContextMenu.Root's element: it opens its menu on a right-click, long press or key. */
27
+ "lu-context-menu": JSX.HTMLProps<ContextMenuElement>;
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * `ContextMenu.Root` renders `<lu-context-menu>`, an element with no box around the trigger area
34
+ * and the menu. `bind:open={signal}` stands in for `open` and `onOpenChange`.
35
+ */
36
+ export interface ContextMenuRootProps {
37
+ /**
38
+ * Opens (`true`) or closes (`false`) the menu when it changes, in the browser, at the last place
39
+ * it opened (the top start corner of the window at first). Pair it with `onOpenChange` to keep a
40
+ * signal in sync; leave both out and the menu keeps its own state.
41
+ */
42
+ open?: boolean;
43
+ /** Called after the menu opens or closes, whatever opened or closed it. */
44
+ onOpenChange?: (open: boolean) => void;
45
+ /** The trigger area keeps the browser's own menu. */
46
+ disabled?: boolean;
47
+ /** The other parts: the `ContextMenu.Trigger` and the `ContextMenu.Popup`. */
48
+ children?: JSX.Element;
49
+ }
50
+
51
+ /**
52
+ * The `<div>` area whose right-click, long press, Shift+F10 or ContextMenu key opens the menu. For
53
+ * the keys, focus must be in it: give it `tabindex={0}`, or focusable content.
54
+ */
55
+ export type ContextMenuTriggerProps = JSX.IntrinsicElements["div"];
56
+
57
+ /**
58
+ * The `<div role="menu" popover>` of items: Menu's popup, placed at the pointer. Its placement
59
+ * props work from that point (default `side="bottom"`, `align="start"`, `sideOffset={0}`). Inside
60
+ * a `SubmenuRoot` it is the submenu's popup, placed next to its trigger.
61
+ */
62
+ export type ContextMenuPopupProps = MenuPopupProps;
63
+
64
+ interface ContextMenuContextValue {
65
+ menu: MenuContextValue;
66
+ host: (element: MenuElement) => void;
67
+ onOpenChange: (open: boolean) => void;
68
+ }
69
+
70
+ const ContextMenuContext = createContext<ContextMenuContextValue>();
71
+
72
+ function useContextMenu(): ContextMenuContextValue {
73
+ const context = useContext(ContextMenuContext);
74
+ if (!context) throw new Error("ContextMenu parts must be inside <ContextMenu.Root>.");
75
+ return context;
76
+ }
77
+
78
+ function Root(props: ContextMenuRootProps) {
79
+ // Right-click, long press and the keys; Menu's script comes with it (decision-63).
80
+ contextMenuScript();
81
+ const id = useId();
82
+ const host = ref<MenuElement>();
83
+ const menu: MenuContextValue = {
84
+ popupId: `${id}-popup`,
85
+ triggerId: `${id}-trigger`,
86
+ id,
87
+ submenu: false,
88
+ triggers: signal(0) as Signal<number>,
89
+ };
90
+
91
+ // The `open` prop drives the menu; its `openchange` event reports back (as Menu.Root).
92
+ effect(() => {
93
+ const element = host();
94
+ const wanted = props.open;
95
+ if (!element?.isConnected || wanted === undefined) return;
96
+ if (wanted) element.show(false);
97
+ else element.open = false;
98
+ });
99
+
100
+ return (
101
+ <ContextMenuContext
102
+ value={{
103
+ menu,
104
+ host,
105
+ onOpenChange: (open) => untrack(() => props.onOpenChange?.(open)),
106
+ }}
107
+ >
108
+ <MenuContext value={menu}>
109
+ <lu-context-menu data-disabled={props.disabled ? "" : undefined}>
110
+ {props.children}
111
+ </lu-context-menu>
112
+ </MenuContext>
113
+ </ContextMenuContext>
114
+ );
115
+ }
116
+
117
+ function Trigger({ children, ...rest }: ContextMenuTriggerProps) {
118
+ useContextMenu();
119
+ return <div {...rest}>{children}</div>;
120
+ }
121
+
122
+ function Popup(props: ContextMenuPopupProps) {
123
+ const context = useContextMenu();
124
+ // In a submenu, a submenu's popup: placed next to its trigger, as in Menu.
125
+ if (useContext(MenuContext)?.submenu) return <Menu.Popup {...props} />;
126
+ return (
127
+ <lu-menu
128
+ ref={context.host}
129
+ on:openchange={(event: CustomEvent<MenuOpenChangeDetail>) =>
130
+ context.onOpenChange(event.detail.open)
131
+ }
132
+ >
133
+ {/* The point the popup is anchored to: the script moves it to the pointer. */}
134
+ <span
135
+ aria-hidden="true"
136
+ style={{
137
+ position: "fixed",
138
+ "inset-inline-start": "var(--lu-context-x, 0)",
139
+ "inset-block-start": "var(--lu-context-y, 0)",
140
+ "anchor-name": anchorName(context.menu.id),
141
+ }}
142
+ />
143
+ {/* At the pointer itself: no gap, unless the props ask for one. */}
144
+ <Menu.Popup {...props} sideOffset={props.sideOffset ?? 0} />
145
+ </lu-menu>
146
+ );
147
+ }
148
+
149
+ /**
150
+ * A menu opened at the pointer by a right-click in its trigger area, a long press on a touch
151
+ * screen, or Shift+F10 and the ContextMenu key with focus in it. Its items are Menu's: commands,
152
+ * links, checkbox and radio items, groups, separators and submenus, with Menu's keys and
153
+ * dismissal. Unstyled: style the popup as a Menu's (the default theme's `lu-menu`).
154
+ *
155
+ * @example
156
+ * <ContextMenu.Root>
157
+ * <ContextMenu.Trigger tabindex={0}>Right-click here</ContextMenu.Trigger>
158
+ * <ContextMenu.Popup>
159
+ * <ContextMenu.Item>Copy</ContextMenu.Item>
160
+ * <ContextMenu.Item>Paste</ContextMenu.Item>
161
+ * </ContextMenu.Popup>
162
+ * </ContextMenu.Root>
163
+ */
164
+ export const ContextMenu = {
165
+ Root,
166
+ Trigger,
167
+ Popup,
168
+ Item: Menu.Item,
169
+ LinkItem: Menu.LinkItem,
170
+ CheckboxItem: Menu.CheckboxItem,
171
+ RadioGroup: Menu.RadioGroup,
172
+ RadioItem: Menu.RadioItem,
173
+ Group: Menu.Group,
174
+ GroupLabel: Menu.GroupLabel,
175
+ Separator: Menu.Separator,
176
+ SubmenuRoot: Menu.SubmenuRoot,
177
+ SubmenuTrigger: Menu.SubmenuTrigger,
178
+ };
@@ -0,0 +1,35 @@
1
+ /*
2
+ * Dialog needs no CSS to open, close, trap focus or make the page inert: the native <dialog>
3
+ * does it. This keeps the page behind a modal dialog from scrolling, as Base UI's and Zag's modal
4
+ * dialogs do, without shifting the layout where the page had a classic scrollbar. It matches every
5
+ * modal <dialog> on the page, since parts carry no class or attribute of their own (decision-62).
6
+ */
7
+ @layer lu.core, lu.theme;
8
+
9
+ @layer lu.core {
10
+ :root:has(dialog:modal) {
11
+ overflow: hidden;
12
+ scrollbar-gutter: stable;
13
+ }
14
+
15
+ /*
16
+ * The gutter only where the page overflows, so had a scrollbar (TASK-507, decision-79): a scroll
17
+ * timeline is active only then, so this animation sets it only then, with no script. Elsewhere
18
+ * a short page would lose 15px at its inline end to an empty gutter with classic scrollbars.
19
+ * Without scroll timelines (Firefox) the gutter stays, and Dialog's script measures the page.
20
+ */
21
+ @supports (animation-timeline: scroll()) {
22
+ :root:has(dialog:modal) {
23
+ scrollbar-gutter: auto;
24
+ animation: lu-dialog-gutter linear both;
25
+ animation-timeline: scroll(root block);
26
+ }
27
+ }
28
+
29
+ @keyframes lu-dialog-gutter {
30
+ from,
31
+ to {
32
+ scrollbar-gutter: stable;
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,269 @@
1
+ // Dialog's client script (decision-62's "native gaps are closed by small scripts", shipped through
2
+ // the client-script import of decision-63). Dialog works without it; on a page that renders one,
3
+ // this module runs once and closes what a native modal <dialog> misses next to Base UI and Zag
4
+ // (doc-9), for every <dialog> on the page:
5
+ //
6
+ // - Light dismiss: a secondary button or a second finger outside does not close a dialog, and
7
+ // `closedby="any"` works where the browser lacks it (Safari).
8
+ // - Invoker commands (`show-modal`, `close`, `request-close`) where the browser lacks them.
9
+ // - Nesting: a dialog with open dialogs inside gets `data-nested-dialog-open` and `--lu-nested-dialogs`.
10
+ // - Initial focus: the `autofocus` element (the dialog itself when it has `autofocus`), or else the
11
+ // first tab stop inside, or else the dialog,
12
+ // as in Base UI, the same in every browser: Chromium would focus a scroller before the first
13
+ // control, Chromium and WebKit a `tabindex="-1"` title (TASK-453 L29). A dialog opened by touch
14
+ // focuses itself rather than its first field, so no virtual keyboard opens (an `autofocus`
15
+ // element still gets focus).
16
+ // - Focus return: when closing leaves focus nowhere (WebKit does not focus a clicked trigger, or the
17
+ // trigger is gone), focus goes to the opener, else to a trigger of the dialog, without scrolling.
18
+ // - Scroll lock (dialog.css locks <html> and keeps its scrollbar's gutter): a <body> that scrolls is
19
+ // locked too, and only a page that overflows keeps a gutter, measured before the dialog opens,
20
+ // so a page without a scrollbar gets no empty one.
21
+ // - A dialog that closes closes the popovers and dialogs open inside it, innermost first, so none
22
+ // outlives it (TASK-436 M1; Chromium would keep a popover showing without its dialog).
23
+ // - One Escape closes one layer (TASK-427): an Escape that nothing above took closes the topmost
24
+ // modal dialog through `requestClose()` (not one with `closedby="none"`), and is cancelled, so
25
+ // the browser's own close request does not follow. A dialog opened without user activation
26
+ // (by a timer) shares a close-watcher group with the dialog or popover before it, and that
27
+ // request would close the whole group.
28
+ //
29
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
30
+
31
+ /** The last press, until a key is pressed: what a `cancel` or a `click` came from. */
32
+ let press: PointerEvent | undefined;
33
+ /** A dialog opening after a touch: its first focus moves to the dialog itself. */
34
+ let touched: HTMLDialogElement | undefined;
35
+ /** A dialog opening: its first focus moves to its first tab stop. */
36
+ let opening: HTMLDialogElement | undefined;
37
+ const openers = new WeakMap<HTMLDialogElement, HTMLElement>();
38
+ /** Undoes the scroll lock's inline styles once no modal dialog is open. */
39
+ let unlock: (() => void) | undefined;
40
+ /** The open modal dialogs, in the order they opened: the last is on top. */
41
+ const modals: HTMLDialogElement[] = [];
42
+
43
+ function on<K extends keyof DocumentEventMap>(
44
+ type: K,
45
+ listener: (event: DocumentEventMap[K]) => void,
46
+ ) {
47
+ document.addEventListener(type, listener, true);
48
+ }
49
+
50
+ const isDialog = (node: unknown): node is HTMLDialogElement => node instanceof HTMLDialogElement;
51
+ const recent = (event: Event) => press !== undefined && event.timeStamp - press.timeStamp < 1000;
52
+ const primary = (event: PointerEvent) => event.button === 0 && event.isPrimary;
53
+
54
+ /** Runs before a dialog opens, while the page can still be measured unlocked. */
55
+ function lockScroll() {
56
+ if (unlock) return;
57
+ const { documentElement: html, body } = document;
58
+ const { scrollbarGutter } = html.style;
59
+ const { overflowY } = body.style;
60
+ // The gutter only where the page overflows, so has a scrollbar (TASK-507): set either way, so
61
+ // this does not depend on dialog.css's scroll timeline (Firefox has none, and a page's own
62
+ // `animation` on the root would cancel it).
63
+ if (getComputedStyle(html).scrollbarGutter === "auto") {
64
+ html.style.scrollbarGutter = html.scrollHeight > html.clientHeight ? "stable" : "auto";
65
+ }
66
+ if (
67
+ /auto|scroll/.test(getComputedStyle(body).overflowY) &&
68
+ body.scrollHeight > body.clientHeight
69
+ ) {
70
+ body.style.overflowY = "hidden";
71
+ }
72
+ unlock = () => {
73
+ html.style.scrollbarGutter = scrollbarGutter;
74
+ body.style.overflowY = overflowY;
75
+ unlock = undefined;
76
+ };
77
+ }
78
+
79
+ /** Whether a press is outside the dialog's box, on its backdrop. */
80
+ function outside(dialog: HTMLDialogElement, { clientX: x, clientY: y }: MouseEvent): boolean {
81
+ const box = dialog.getBoundingClientRect();
82
+ return x < box.left || x > box.right || y < box.top || y > box.bottom;
83
+ }
84
+
85
+ on("pointerdown", (event) => (press = event));
86
+ on("keydown", () => (press = undefined));
87
+
88
+ // Native light dismiss fires a cancelable `cancel`: keep the dialog open for other presses.
89
+ on("cancel", (event) => {
90
+ if (recent(event) && !primary(press!)) event.preventDefault();
91
+ });
92
+
93
+ if (!("closedBy" in HTMLDialogElement.prototype)) {
94
+ on("click", (event) => {
95
+ const dialog = event.target;
96
+ if (
97
+ isDialog(dialog) &&
98
+ dialog.matches("[open][closedby=any]") &&
99
+ press?.target === dialog &&
100
+ primary(press) &&
101
+ outside(dialog, press) &&
102
+ outside(dialog, event)
103
+ ) {
104
+ if (dialog.requestClose) dialog.requestClose();
105
+ else dialog.close();
106
+ }
107
+ });
108
+ }
109
+
110
+ if (!("command" in HTMLButtonElement.prototype)) {
111
+ on("click", (event) => {
112
+ const button = (event.target as Element).closest?.<HTMLButtonElement>("button[commandfor]");
113
+ const dialog = button && document.getElementById(button.getAttribute("commandfor")!);
114
+ if (!isDialog(dialog)) return;
115
+ const command = button!.getAttribute("command");
116
+ if (command === "show-modal" && !dialog.open) {
117
+ openers.set(dialog, button!);
118
+ dialog.showModal();
119
+ } else if (command === "close" && dialog.open) {
120
+ dialog.close(button!.value);
121
+ } else if (command === "request-close" && dialog.open) {
122
+ if (dialog.requestClose) dialog.requestClose(button!.value);
123
+ else dialog.close(button!.value);
124
+ }
125
+ });
126
+ }
127
+
128
+ on("command", (event) => {
129
+ // A CommandEvent: `source` is the invoker.
130
+ const { command, source } = event as Event & { command: string; source: Element | null };
131
+ if (isDialog(event.target) && command === "show-modal" && source instanceof HTMLElement) {
132
+ openers.set(event.target, source);
133
+ }
134
+ });
135
+
136
+ on("beforetoggle", (event) => {
137
+ const dialog = event.target;
138
+ if (!isDialog(dialog) || event.newState !== "open") return;
139
+ const active = document.activeElement;
140
+ if (!openers.has(dialog) && active instanceof HTMLElement && active !== document.body) {
141
+ openers.set(dialog, active);
142
+ }
143
+ if (recent(event) && press!.pointerType === "touch") touched = dialog;
144
+ else opening = dialog;
145
+ lockScroll();
146
+ });
147
+
148
+ const TABBABLE =
149
+ "a[href], button, input:not([type=hidden]), select, textarea, summary, iframe, [tabindex], [contenteditable]";
150
+
151
+ /** The dialog's first tab stop: not a scroller, a `tabindex="-1"` element or a hidden one. */
152
+ function firstTabStop(dialog: HTMLDialogElement): HTMLElement | undefined {
153
+ for (const element of dialog.querySelectorAll<HTMLElement>(TABBABLE)) {
154
+ if (element.tabIndex < 0 || element.matches(":disabled")) continue;
155
+ if (!(element.checkVisibility?.() ?? true)) continue;
156
+ // A radio group's tab stop is its checked radio.
157
+ if (element instanceof HTMLInputElement && element.type === "radio" && !element.checked) {
158
+ // Through the prototype: a control named `elements` shadows the form's (TASK-473, L9).
159
+ const elements = Object.getOwnPropertyDescriptor(HTMLFormElement.prototype, "elements")!;
160
+ const group = element.form && elements.get!.call(element.form).namedItem(element.name);
161
+ if (group instanceof RadioNodeList && group.value !== "") continue;
162
+ }
163
+ return element;
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ /** Focuses the dialog itself, which takes focus without being a tab stop. */
169
+ function focusItself(dialog: HTMLDialogElement) {
170
+ if (!dialog.hasAttribute("tabindex")) dialog.tabIndex = -1;
171
+ dialog.focus({ preventScroll: true });
172
+ }
173
+
174
+ // Opening focuses an element synchronously, between `beforetoggle` and `toggle`.
175
+ on("focusin", (event) => {
176
+ const target = event.target as Element;
177
+ if (touched) {
178
+ const dialog = touched;
179
+ if (!dialog.contains(target) || target === dialog) return;
180
+ touched = undefined;
181
+ if (!target.hasAttribute("autofocus")) focusItself(dialog);
182
+ return;
183
+ }
184
+ const dialog = opening;
185
+ if (!dialog?.contains(target)) return;
186
+ opening = undefined;
187
+ // `autofocus` on the dialog itself: the dialog takes focus (Chromium would focus its first
188
+ // control).
189
+ if (dialog.hasAttribute("autofocus")) {
190
+ if (target !== dialog) focusItself(dialog);
191
+ return;
192
+ }
193
+ if (target.hasAttribute("autofocus")) return;
194
+ const first = firstTabStop(dialog);
195
+ if (first) {
196
+ if (first !== target) first.focus();
197
+ } else if (target !== dialog) focusItself(dialog);
198
+ });
199
+
200
+ /**
201
+ * Whether a native popup is open above `dialog`: a select's picker, or a popover that no layer
202
+ * script runs (the toast viewport, always open, aside). The browser's close request is its.
203
+ */
204
+ function popupAbove(dialog: HTMLDialogElement): boolean {
205
+ const popovers = [...document.querySelectorAll(":popover-open")].filter(
206
+ (popover) => popover.localName !== "lu-toast-viewport" && !popover.contains(dialog),
207
+ );
208
+ if (popovers.length > 0) return true;
209
+ try {
210
+ return dialog.querySelector("select:open") !== null;
211
+ } catch {
212
+ // No `:open` (Firefox): no customizable select picker either.
213
+ return false;
214
+ }
215
+ }
216
+
217
+ // On `window`, after the layers' listeners on `document`: an open layer takes the Escape first.
218
+ window.addEventListener("keydown", (event) => {
219
+ // `keyCode` 229: Safari's key that ends an input method's composition (TASK-438 M21).
220
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
221
+ if (event.keyCode === 229) return;
222
+ const dialog = modals.filter((each) => each.isConnected && each.matches(":modal")).at(-1);
223
+ if (!dialog || popupAbove(dialog)) return;
224
+ event.preventDefault();
225
+ if (dialog.getAttribute("closedby") === "none") return;
226
+ if (dialog.requestClose) dialog.requestClose();
227
+ else if (dialog.dispatchEvent(new Event("cancel", { cancelable: true }))) dialog.close();
228
+ });
229
+
230
+ on("toggle", (event) => {
231
+ const dialog = event.target;
232
+ if (!isDialog(dialog)) return;
233
+ touched = opening = undefined;
234
+ const at = modals.indexOf(dialog);
235
+ if (at !== -1) modals.splice(at, 1);
236
+ if (event.newState === "open" && dialog.matches(":modal")) modals.push(dialog);
237
+ if (!document.querySelector("dialog:modal")) unlock?.();
238
+ for (const each of document.querySelectorAll("dialog")) {
239
+ const nested = each.querySelectorAll("dialog[open]").length;
240
+ each.toggleAttribute("data-nested-dialog-open", nested > 0);
241
+ if (nested) each.style.setProperty("--lu-nested-dialogs", String(nested));
242
+ else each.style.removeProperty("--lu-nested-dialogs");
243
+ }
244
+ if (event.newState !== "closed") return;
245
+ for (const each of [
246
+ ...dialog.querySelectorAll<HTMLElement>(":popover-open, dialog[open]"),
247
+ ].toReversed()) {
248
+ if (each.closest("lu-toast-viewport")) continue;
249
+ if (isDialog(each)) each.close();
250
+ else if (each.matches(":popover-open")) each.hidePopover();
251
+ }
252
+ // The browser has already restored focus, or failed to: fix only the failure.
253
+ const opener = openers.get(dialog);
254
+ openers.delete(dialog);
255
+ const active = document.activeElement;
256
+ if (dialog.open || (active && active !== document.body && !dialog.contains(active))) return;
257
+ // A trigger found by comparing ids, not by a selector built from one: a quote in the id must
258
+ // not end the selector's string (TASK-473, L8).
259
+ const trigger = opener?.isConnected
260
+ ? opener
261
+ : [...document.querySelectorAll<HTMLElement>('[command="show-modal"][commandfor]')].find(
262
+ (each) => dialog.id !== "" && each.getAttribute("commandfor") === dialog.id,
263
+ );
264
+ trigger?.focus({ preventScroll: true });
265
+ });
266
+
267
+ // A module, so it runs once per page whoever imports it.
268
+ // oxlint-disable-next-line unicorn/require-module-specifiers
269
+ export {};