@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,166 @@
1
+ // ToggleGroup's client script (decision-62, decision-63): it defines `<lu-toggle-group>`, the
2
+ // light-DOM element ToggleGroup renders around its toggles, native `<button aria-pressed>`s that
3
+ // the platform cannot press. After Base UI and doc-9 section 13:
4
+ //
5
+ // - A click (the pointer, Enter, Space) on a toggle fires a cancelable, bubbling `pressedchange`
6
+ // (`detail.pressed`) on it and, unless it is cancelled, flips `aria-pressed`. One toggle is
7
+ // pressed at a time (pressing it again releases it), or any number with `multiple`.
8
+ // - `value` (the pressed toggles' values) and a `valuechange` event after the user changes it.
9
+ // - One tab stop, moved by the arrow keys along `data-orientation` (swapped right to left), Home
10
+ // and End, looping unless `data-loop-focus="false"`. Disabled toggles are native `disabled`
11
+ // buttons, which focus skips. When the tab stop is removed or disabled, or the group is enabled,
12
+ // the first pressed or enabled toggle takes it (TASK-437 M11).
13
+ //
14
+ // A Toggle outside a group needs no script: the component presses it, in an island. Inside a
15
+ // Toolbar (`lu-toolbar`), the group still presses its toggles but leaves the tab stop and the
16
+ // arrow keys to the toolbar, whose items its toggles are.
17
+ //
18
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
19
+
20
+ export interface ToggleChangeDetail {
21
+ pressed: boolean;
22
+ }
23
+
24
+ const ITEM = "button[aria-pressed]";
25
+
26
+ const pressed = (item: Element) => item.getAttribute("aria-pressed") === "true";
27
+ /** Whether focus can move to `item`: not natively disabled, nor hidden. */
28
+ const usable = (item: HTMLButtonElement) => !item.disabled && (item.checkVisibility?.() ?? true);
29
+
30
+ /** Asks with a cancelable `pressedchange` whether `item` may become `next`, and sets it if so. */
31
+ function press(item: HTMLElement, next: boolean): boolean {
32
+ const ok = item.dispatchEvent(
33
+ new CustomEvent<ToggleChangeDetail>("pressedchange", {
34
+ bubbles: true,
35
+ cancelable: true,
36
+ detail: { pressed: next },
37
+ }),
38
+ );
39
+ if (ok) item.setAttribute("aria-pressed", String(next));
40
+ return ok;
41
+ }
42
+
43
+ export class ToggleGroupElement extends HTMLElement {
44
+ /** Moves the tab stop on when toggles come, go, or are disabled or enabled. */
45
+ #observer = new MutationObserver(() => this.#rove());
46
+
47
+ /** The group's own toggles: nested groups keep theirs. */
48
+ get items(): HTMLButtonElement[] {
49
+ return [...this.querySelectorAll<HTMLButtonElement>(ITEM)].filter(
50
+ (item) => item.closest("lu-toggle-group") === this,
51
+ );
52
+ }
53
+
54
+ /** The pressed toggles' values. Setting it presses those toggles, without events. */
55
+ get value(): string[] {
56
+ return this.items.filter(pressed).map((item) => item.value);
57
+ }
58
+
59
+ set value(next: readonly string[] | null | undefined) {
60
+ const values = next ?? [];
61
+ for (const item of this.items)
62
+ item.setAttribute("aria-pressed", String(values.includes(item.value)));
63
+ this.#rove();
64
+ }
65
+
66
+ /** Whether several toggles can be pressed; reflects the `multiple` attribute. */
67
+ get multiple(): boolean {
68
+ return this.hasAttribute("multiple");
69
+ }
70
+
71
+ set multiple(on: boolean) {
72
+ this.toggleAttribute("multiple", on);
73
+ }
74
+
75
+ connectedCallback(): void {
76
+ this.addEventListener("click", this.#onClick);
77
+ this.addEventListener("keydown", this.#onKeyDown);
78
+ this.addEventListener("focusin", this.#onFocusIn);
79
+ this.#observer.observe(this, {
80
+ subtree: true,
81
+ childList: true,
82
+ attributeFilter: ["disabled", "hidden"],
83
+ });
84
+ if (document.readyState === "loading") {
85
+ document.addEventListener("DOMContentLoaded", () => this.#rove(), { once: true });
86
+ } else queueMicrotask(() => this.#rove());
87
+ }
88
+
89
+ disconnectedCallback(): void {
90
+ this.removeEventListener("click", this.#onClick);
91
+ this.removeEventListener("keydown", this.#onKeyDown);
92
+ this.removeEventListener("focusin", this.#onFocusIn);
93
+ this.#observer.disconnect();
94
+ }
95
+
96
+ /** Whether a Toolbar around the group moves focus instead. */
97
+ get #inToolbar(): boolean {
98
+ return this.closest("lu-toolbar") !== null;
99
+ }
100
+
101
+ /** One tab stop: `to`, else the current one if still usable, else the first pressed or enabled. */
102
+ #rove(to?: HTMLElement): void {
103
+ if (this.#inToolbar) return;
104
+ const items = this.items;
105
+ const enabled = items.filter(usable);
106
+ const current = enabled.find((item) => item.tabIndex === 0);
107
+ const stop = to ?? current ?? enabled.find(pressed) ?? enabled[0];
108
+ for (const item of items) item.tabIndex = item === stop ? 0 : -1;
109
+ }
110
+
111
+ #itemOf(event: Event): HTMLButtonElement | undefined {
112
+ const item = (event.target as Element).closest?.<HTMLButtonElement>(ITEM);
113
+ return item && item.closest("lu-toggle-group") === this ? item : undefined;
114
+ }
115
+
116
+ #onClick = (event: MouseEvent) => {
117
+ const item = this.#itemOf(event);
118
+ if (!item || item.disabled) return;
119
+ const next = !pressed(item);
120
+ const before = this.value;
121
+ if (!press(item, next)) return;
122
+ // One at a time: pressing an item releases the others.
123
+ if (next && !this.multiple) {
124
+ for (const other of this.items) if (other !== item && pressed(other)) press(other, false);
125
+ }
126
+ this.#rove(item);
127
+ const after = this.value;
128
+ if (after.length !== before.length || after.some((value, i) => value !== before[i])) {
129
+ this.dispatchEvent(new Event("valuechange"));
130
+ }
131
+ };
132
+
133
+ #onFocusIn = (event: FocusEvent) => {
134
+ const item = this.#itemOf(event);
135
+ if (item) this.#rove(item);
136
+ };
137
+
138
+ #onKeyDown = (event: KeyboardEvent) => {
139
+ if (this.#inToolbar) return;
140
+ const item = this.#itemOf(event);
141
+ if (!item || event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
142
+ const vertical = this.dataset.orientation === "vertical";
143
+ const rtl = !vertical && getComputedStyle(this).direction === "rtl";
144
+ const steps: Record<string, number> = vertical
145
+ ? { ArrowDown: 1, ArrowUp: -1 }
146
+ : { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 };
147
+ const enabled = this.items.filter(usable);
148
+ const at = enabled.indexOf(item);
149
+ const loop = this.dataset.loopFocus !== "false";
150
+ let next: number | undefined;
151
+ if (event.key === "Home") next = 0;
152
+ else if (event.key === "End") next = enabled.length - 1;
153
+ else if (steps[event.key] !== undefined) {
154
+ next = at + steps[event.key]!;
155
+ if (next < 0) next = loop ? enabled.length - 1 : 0;
156
+ if (next >= enabled.length) next = loop ? 0 : enabled.length - 1;
157
+ }
158
+ if (next === undefined) return;
159
+ event.preventDefault();
160
+ enabled[next]?.focus();
161
+ };
162
+ }
163
+
164
+ if (!customElements.get("lu-toggle-group")) {
165
+ customElements.define("lu-toggle-group", ToggleGroupElement);
166
+ }
@@ -0,0 +1,117 @@
1
+ // ToggleGroup (decision-62, TASK-369): `<lu-toggle-group role="group">` around Toggles, after Base
2
+ // UI's ToggleGroup. Its script (toggle-group.script.ts) presses the toggles, one at a time or with
3
+ // `multiple` any number, and gives the group one tab stop moved by the arrow keys, on server pages
4
+ // too. The `value` prop drives the element when it changes, and its `valuechange` event reports
5
+ // back through `onValueChange`.
6
+ import { effect, ref, signal, untrack } from "lumiose";
7
+ import {
8
+ ToggleGroupContext,
9
+ type ToggleGroupContextValue,
10
+ type ToggleRecord,
11
+ } from "../toggle/context.ts";
12
+ import type { ToggleGroupElement } from "./toggle-group.script.ts";
13
+ import toggleGroupScript from "./toggle-group.script.ts?client";
14
+
15
+ declare module "lumiose/jsx-runtime" {
16
+ namespace JSX {
17
+ interface IntrinsicElements {
18
+ /**
19
+ * ToggleGroup's element: property `value` (the pressed toggles' values) and `multiple`,
20
+ * event `valuechange`.
21
+ */
22
+ "lu-toggle-group": JSX.HTMLProps<ToggleGroupElement> & { multiple?: boolean };
23
+ }
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The element props ToggleGroup passes to `<lu-toggle-group>`, without the brand of intrinsic
29
+ * elements' props, so that `bind:value` applies to it.
30
+ */
31
+ type ElementProps = {
32
+ [
33
+ K in keyof JSX.HTMLProps<ToggleGroupElement> as K extends string ? K : never
34
+ ]: JSX.HTMLProps<ToggleGroupElement>[K];
35
+ };
36
+
37
+ /**
38
+ * `ToggleGroup` renders `<lu-toggle-group role="group">`. Name it with `aria-label` or
39
+ * `aria-labelledby`. `bind:value={signal}` stands in for `value` and `onValueChange`.
40
+ */
41
+ export interface ToggleGroupProps extends Omit<ElementProps, "children"> {
42
+ /** Presses the toggles with these values when it changes. Pair it with `onValueChange`. */
43
+ value?: string[];
44
+ /** The toggles pressed at first, when `value` is left out. */
45
+ defaultValue?: string[];
46
+ /** Called with the pressed toggles' values after the user presses or releases one. */
47
+ onValueChange?: (value: string[]) => void;
48
+ /** Any number of toggles can be pressed; otherwise one at a time. */
49
+ multiple?: boolean;
50
+ /** The arrow keys that move between the toggles: left and right, or up and down. */
51
+ orientation?: "horizontal" | "vertical";
52
+ /** Arrow keys go from the last toggle to the first and back. Default `true`. */
53
+ loopFocus?: boolean;
54
+ /** Disables every toggle. */
55
+ disabled?: boolean;
56
+ /** The `Toggle`s. */
57
+ children?: JSX.Element;
58
+ }
59
+
60
+ /**
61
+ * A set of toggles with one value, the pressed ones: a text alignment (one at a time) or text
62
+ * styles (`multiple`). One tab stop, arrow keys between the toggles.
63
+ *
64
+ * @example
65
+ * <ToggleGroup aria-label="Alignment" defaultValue={["left"]}>
66
+ * <Toggle value="left">Left</Toggle>
67
+ * <Toggle value="center">Center</Toggle>
68
+ * </ToggleGroup>
69
+ */
70
+ export function ToggleGroup({
71
+ value,
72
+ defaultValue,
73
+ onValueChange,
74
+ multiple,
75
+ orientation,
76
+ loopFocus,
77
+ disabled,
78
+ children,
79
+ ...rest
80
+ }: ToggleGroupProps) {
81
+ // Presses the toggles and moves focus, on the server HTML and in islands (decision-63).
82
+ toggleGroupScript();
83
+ const element = ref<ToggleGroupElement>();
84
+ const values = signal<readonly string[]>(untrack(() => value ?? defaultValue ?? []));
85
+ // The `value` prop drives the toggles when it changes.
86
+ effect(() => {
87
+ const next = value;
88
+ if (next === undefined) return;
89
+ values.set(next);
90
+ const host = element();
91
+ if (host?.isConnected) host.value = next;
92
+ });
93
+ const context: ToggleGroupContextValue = {
94
+ values,
95
+ items: signal<ToggleRecord[]>([]),
96
+ disabled: () => disabled === true,
97
+ };
98
+ return (
99
+ <ToggleGroupContext value={context}>
100
+ <lu-toggle-group
101
+ {...rest}
102
+ ref={element}
103
+ role="group"
104
+ multiple={multiple}
105
+ data-orientation={orientation ?? "horizontal"}
106
+ data-loop-focus={loopFocus === false ? "false" : undefined}
107
+ on:valuechange={() => {
108
+ const next = element()!.value;
109
+ values.set(next);
110
+ onValueChange?.(next);
111
+ }}
112
+ >
113
+ {children}
114
+ </lu-toggle-group>
115
+ </ToggleGroupContext>
116
+ );
117
+ }
@@ -0,0 +1,18 @@
1
+ // The context a Toolbar gives its items (TASK-383), Toggles in a ToggleGroup inside it included:
2
+ // the orientation and the disabled state, which reaches its Menu and Popover triggers and Toggles
3
+ // too (TASK-437 M13).
4
+ import { createContext } from "lumiose";
5
+
6
+ export interface ToolbarContextValue {
7
+ orientation: () => "horizontal" | "vertical";
8
+ disabled: () => boolean;
9
+ }
10
+
11
+ export const ToolbarContext = createContext<ToolbarContextValue>();
12
+
13
+ /**
14
+ * What a popup gives its content instead (Menu.Popup, Popover.Popup): the content of a popup
15
+ * opened from a toolbar is not the toolbar's, whose script skips popups too, so its toggles and
16
+ * triggers keep their own tab stops.
17
+ */
18
+ export const OUTSIDE_TOOLBAR = undefined as unknown as ToolbarContextValue;
@@ -0,0 +1,12 @@
1
+ /*
2
+ * The CSS Toolbar needs, and nothing else (decision-62): `<lu-toolbar>` is a block, as Base UI's
3
+ * `<div>` root, rather than a custom element's default inline box. It sits in the `lu.core`
4
+ * layer, so any app CSS wins.
5
+ */
6
+ @layer lu.core, lu.theme;
7
+
8
+ @layer lu.core {
9
+ lu-toolbar {
10
+ display: block;
11
+ }
12
+ }
@@ -0,0 +1,123 @@
1
+ // Toolbar's client script (decision-62, decision-63): it defines `<lu-toolbar>`, the light-DOM
2
+ // element Toolbar.Root renders with `role="toolbar"`, after the APG toolbar pattern and Base UI:
3
+ //
4
+ // - One tab stop for the whole toolbar: its items (buttons, links, inputs, selects, the toggles of
5
+ // a ToggleGroup inside it) have `tabindex="-1"` but one, the last focused (at first, the first).
6
+ // The server HTML leaves every item a tab stop, so all are reachable before the script runs
7
+ // (TASK-437 M12). Disabled items stay focusable (`aria-disabled`, as Base UI's
8
+ // `focusableWhenDisabled`); natively disabled controls are skipped. Separators are not items.
9
+ // When the tab stop is removed, disabled or hidden, the first item takes it (TASK-437 M11).
10
+ // - The arrow keys along `data-orientation` (Left and Right swapped right to left) move focus to
11
+ // the next or previous item, looping unless `data-loop-focus="false"`; Home and End to the first
12
+ // and last. A control keeps the keys it needs: a text field its caret's Left and Right (focus
13
+ // moves on at its start or end; with Shift they always select) and Home and End, a number field
14
+ // or a slider its arrows, a select its Up and Down. A text field the keys move into has its text
15
+ // selected, as in Base UI (TASK-452 L26). A key an item already handled (a Menu trigger's) is left alone.
16
+ // - A ToggleGroup inside a toolbar leaves its keys and tab stop to the toolbar (toggle-group's
17
+ // script checks for an `lu-toolbar` around it).
18
+ //
19
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
20
+
21
+ const ITEM = "button, a[href], input:not([type=hidden]), select, textarea, [data-toolbar-item]";
22
+
23
+ /** Inputs whose Left and Right move a caret. */
24
+ const TEXT = /^(text|search|email|url|tel|password)$/;
25
+
26
+ export class ToolbarElement extends HTMLElement {
27
+ /** The tab stop, kept while it stays an item. */
28
+ #stop: HTMLElement | undefined;
29
+ /** Moves the tab stop on when items come, go, or are disabled or hidden. */
30
+ #observer = new MutationObserver(() => this.#rove());
31
+
32
+ /** The toolbar's items, in order: not those of a nested toolbar or of a popup inside it. */
33
+ get items(): HTMLElement[] {
34
+ return [...this.querySelectorAll<HTMLElement>(ITEM)].filter((item) => {
35
+ if (item.closest("lu-toolbar") !== this) return false;
36
+ const popup = item.closest("[popover], dialog");
37
+ if (popup && this.contains(popup)) return false;
38
+ return !(item as HTMLButtonElement).disabled && (item.checkVisibility?.() ?? true);
39
+ });
40
+ }
41
+
42
+ connectedCallback(): void {
43
+ this.addEventListener("keydown", this.#onKeyDown);
44
+ this.addEventListener("focusin", this.#onFocusIn);
45
+ this.#observer.observe(this, {
46
+ subtree: true,
47
+ childList: true,
48
+ attributeFilter: ["disabled", "hidden"],
49
+ });
50
+ if (document.readyState === "loading") {
51
+ document.addEventListener("DOMContentLoaded", () => this.#rove(), { once: true });
52
+ } else queueMicrotask(() => this.#rove());
53
+ }
54
+
55
+ disconnectedCallback(): void {
56
+ this.removeEventListener("keydown", this.#onKeyDown);
57
+ this.removeEventListener("focusin", this.#onFocusIn);
58
+ this.#observer.disconnect();
59
+ }
60
+
61
+ /** One tab stop: `to`, else the current one if it is still an item, else the first item. */
62
+ #rove(to?: HTMLElement): void {
63
+ const items = this.items;
64
+ const kept = this.#stop && items.includes(this.#stop) ? this.#stop : undefined;
65
+ const stop = to ?? kept ?? items[0];
66
+ this.#stop = stop;
67
+ for (const item of items) item.tabIndex = item === stop ? 0 : -1;
68
+ }
69
+
70
+ #onFocusIn = (event: FocusEvent) => {
71
+ const item = this.items.find((each) => each === event.target);
72
+ if (item) this.#rove(item);
73
+ };
74
+
75
+ /** Whether `item` keeps the key for itself. */
76
+ #owns(item: HTMLElement, { key, shiftKey }: KeyboardEvent): boolean {
77
+ if (item instanceof HTMLTextAreaElement) return true;
78
+ if (item instanceof HTMLSelectElement) return key === "ArrowUp" || key === "ArrowDown";
79
+ if (!(item instanceof HTMLInputElement)) return false;
80
+ if (item.type === "number" || item.type === "range") return key.startsWith("Arrow");
81
+ if (!TEXT.test(item.type)) return false;
82
+ if (key === "Home" || key === "End") return true;
83
+ // Shift selects: it never leaves the field.
84
+ if (shiftKey && (key === "ArrowLeft" || key === "ArrowRight")) return true;
85
+ // A text field keeps Left and Right until its caret is at the edge it moves toward.
86
+ const { selectionStart: start, selectionEnd: end, value } = item;
87
+ if (start === null || start !== end) return key === "ArrowLeft" || key === "ArrowRight";
88
+ const rtl = getComputedStyle(item).direction === "rtl";
89
+ if (key === (rtl ? "ArrowRight" : "ArrowLeft")) return start > 0;
90
+ if (key === (rtl ? "ArrowLeft" : "ArrowRight")) return end < value.length;
91
+ return false;
92
+ }
93
+
94
+ #onKeyDown = (event: KeyboardEvent) => {
95
+ const { defaultPrevented, isComposing, altKey, ctrlKey, metaKey } = event;
96
+ if (defaultPrevented || isComposing || altKey || ctrlKey || metaKey) return;
97
+ const items = this.items;
98
+ const item = items.find((each) => each === event.target);
99
+ if (!item || this.#owns(item, event)) return;
100
+ const vertical = this.dataset.orientation === "vertical";
101
+ const rtl = !vertical && getComputedStyle(this).direction === "rtl";
102
+ const steps: Record<string, number> = vertical
103
+ ? { ArrowDown: 1, ArrowUp: -1 }
104
+ : { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 };
105
+ const loop = this.dataset.loopFocus !== "false";
106
+ const at = items.indexOf(item);
107
+ let next: number | undefined;
108
+ if (event.key === "Home") next = 0;
109
+ else if (event.key === "End") next = items.length - 1;
110
+ else if (steps[event.key] !== undefined) {
111
+ next = at + steps[event.key]!;
112
+ if (next < 0) next = loop ? items.length - 1 : 0;
113
+ if (next >= items.length) next = loop ? 0 : items.length - 1;
114
+ }
115
+ if (next === undefined) return;
116
+ event.preventDefault();
117
+ const to = items[next];
118
+ to?.focus();
119
+ if (to instanceof HTMLInputElement && TEXT.test(to.type)) to.select();
120
+ };
121
+ }
122
+
123
+ if (!customElements.get("lu-toolbar")) customElements.define("lu-toolbar", ToolbarElement);
@@ -0,0 +1,180 @@
1
+ // Toolbar (decision-62, TASK-383): a row (or column) of controls with one tab stop, after Base UI's
2
+ // Toolbar and the APG toolbar pattern. Root renders `<lu-toolbar role="toolbar">`, whose script
3
+ // (toolbar.script.ts) moves focus between the items with the arrow keys, Home and End, following
4
+ // the orientation and the direction. The items are native: Button is a `<button>` that stays
5
+ // focusable when disabled (`aria-disabled`), Link an `<a>`, Input an `<input>`; a ToggleGroup's
6
+ // toggles inside a toolbar are its items too. In the server HTML every item is a tab stop, so all
7
+ // are reachable before the script (or without it); the script makes them one (TASK-437 M12).
8
+ // `disabled` reaches every item but links: Menu and Popover triggers and Toggles render
9
+ // `aria-disabled`, and button.script.ts cancels their clicks (TASK-437 M13).
10
+ import { useContext } from "lumiose";
11
+ import { Button, type ButtonProps } from "../button/button.tsx";
12
+ import buttonScript from "../button/button.script.ts?client";
13
+ import { Separator, type SeparatorProps } from "../separator/separator.tsx";
14
+ import { ToolbarContext, type ToolbarContextValue } from "./context.ts";
15
+ import type { ToolbarElement } from "./toolbar.script.ts";
16
+ import toolbarScript from "./toolbar.script.ts?client";
17
+
18
+ declare module "lumiose/jsx-runtime" {
19
+ namespace JSX {
20
+ interface IntrinsicElements {
21
+ /**
22
+ * Toolbar's element: `role="toolbar"`, with `data-orientation` and `data-loop-focus`
23
+ * (`"false"` stops focus at the ends).
24
+ */
25
+ "lu-toolbar": JSX.HTMLProps<ToolbarElement>;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** `Toolbar.Root` renders `<lu-toolbar role="toolbar">` around the items. */
31
+ export type ToolbarRootProps = JSX.HTMLProps<ToolbarElement> & {
32
+ /** A row (`"horizontal"`, default) moved through with Left and Right, or a column with Up and Down. */
33
+ orientation?: "horizontal" | "vertical";
34
+ /** The arrow keys go from the last item to the first, and back. Default `true`. */
35
+ loopFocus?: boolean;
36
+ /** Disables every item; they stay focusable (`aria-disabled`). */
37
+ disabled?: boolean;
38
+ };
39
+
40
+ /**
41
+ * A `<button>` item: a [Button](button.md) that, disabled, stays focusable (`aria-disabled`) unless
42
+ * `focusableWhenDisabled` is `false`.
43
+ */
44
+ export type ToolbarButtonProps = ButtonProps;
45
+
46
+ /** An `<a>` item. */
47
+ export type ToolbarLinkProps = JSX.IntrinsicElements["a"];
48
+
49
+ /** An `<input>` item: Left and Right move its caret, and focus moves on at its start or end. */
50
+ export type ToolbarInputProps = JSX.IntrinsicElements["input"] & {
51
+ /** Disabled, it stays focusable (`aria-disabled` and read-only). Default `true`. */
52
+ focusableWhenDisabled?: boolean;
53
+ };
54
+
55
+ /** A `<div role="group">` of items, which `disabled` disables together. */
56
+ export type ToolbarGroupProps = JSX.IntrinsicElements["div"] & {
57
+ /** Disables the group's items; they stay focusable. */
58
+ disabled?: boolean;
59
+ };
60
+
61
+ /** A [Separator](separator.md) between items: across the toolbar (vertical in a row) by default. */
62
+ export type ToolbarSeparatorProps = SeparatorProps;
63
+
64
+ function useToolbar(part: string): ToolbarContextValue {
65
+ const context = useContext(ToolbarContext);
66
+ if (!context) throw new Error(`Toolbar.${part} must be inside <Toolbar.Root>.`);
67
+ return context;
68
+ }
69
+
70
+ function Root({ orientation, loopFocus, disabled, children, ...rest }: ToolbarRootProps) {
71
+ // The arrow keys and the single tab stop, on server pages and in islands (decision-63).
72
+ toolbarScript();
73
+ // Cancels the clicks of its `aria-disabled` items, Menu and Popover triggers and Toggles included.
74
+ buttonScript();
75
+ const context: ToolbarContextValue = {
76
+ orientation: () => orientation ?? "horizontal",
77
+ disabled: () => disabled === true,
78
+ };
79
+ return (
80
+ <ToolbarContext value={context}>
81
+ <lu-toolbar
82
+ {...rest}
83
+ role="toolbar"
84
+ aria-orientation={context.orientation()}
85
+ data-orientation={context.orientation()}
86
+ data-loop-focus={loopFocus === false ? "false" : undefined}
87
+ data-disabled={context.disabled() ? "" : undefined}
88
+ >
89
+ {children}
90
+ </lu-toolbar>
91
+ </ToolbarContext>
92
+ );
93
+ }
94
+
95
+ function ToolbarButton({ disabled, focusableWhenDisabled, children, ...rest }: ToolbarButtonProps) {
96
+ const toolbar = useToolbar("Button");
97
+ return (
98
+ <Button
99
+ {...rest}
100
+ disabled={disabled === true || toolbar.disabled()}
101
+ focusableWhenDisabled={focusableWhenDisabled ?? true}
102
+ data-orientation={toolbar.orientation()}
103
+ >
104
+ {children}
105
+ </Button>
106
+ );
107
+ }
108
+
109
+ function Link({ children, ...rest }: ToolbarLinkProps) {
110
+ const toolbar = useToolbar("Link");
111
+ return (
112
+ <a {...rest} data-orientation={toolbar.orientation()}>
113
+ {children}
114
+ </a>
115
+ );
116
+ }
117
+
118
+ function Input({ disabled, focusableWhenDisabled, ...rest }: ToolbarInputProps) {
119
+ const toolbar = useToolbar("Input");
120
+ const off = () => disabled === true || toolbar.disabled();
121
+ const soft = () => off() && focusableWhenDisabled !== false;
122
+ return (
123
+ <input
124
+ {...rest}
125
+ disabled={off() && !soft()}
126
+ readonly={soft() || rest.readonly}
127
+ aria-disabled={soft() ? "true" : rest["aria-disabled"]}
128
+ data-orientation={toolbar.orientation()}
129
+ />
130
+ );
131
+ }
132
+
133
+ function Group({ disabled, children, ...rest }: ToolbarGroupProps) {
134
+ const toolbar = useToolbar("Group");
135
+ const context: ToolbarContextValue = {
136
+ ...toolbar,
137
+ disabled: () => disabled === true || toolbar.disabled(),
138
+ };
139
+ return (
140
+ <ToolbarContext value={context}>
141
+ <div
142
+ {...rest}
143
+ role="group"
144
+ data-orientation={toolbar.orientation()}
145
+ data-disabled={context.disabled() ? "" : undefined}
146
+ >
147
+ {children}
148
+ </div>
149
+ </ToolbarContext>
150
+ );
151
+ }
152
+
153
+ function ToolbarSeparator({ orientation, ...rest }: ToolbarSeparatorProps) {
154
+ const toolbar = useToolbar("Separator");
155
+ // Across the toolbar: a vertical line in a row, a horizontal one in a column.
156
+ const across = () => (toolbar.orientation() === "horizontal" ? "vertical" : "horizontal");
157
+ return <Separator {...rest} orientation={orientation ?? across()} />;
158
+ }
159
+
160
+ /**
161
+ * A set of controls with one tab stop: the arrow keys move between them, Home and End go to the
162
+ * ends, following the orientation and the direction. Buttons, links, inputs and ToggleGroups
163
+ * inside it are its items. Unstyled: style the parts through `class`, `[data-orientation]`,
164
+ * `[aria-disabled="true"]` and `:focus-visible`.
165
+ *
166
+ * @example
167
+ * <Toolbar.Root aria-label="Formatting">
168
+ * <Toolbar.Button>Bold</Toolbar.Button>
169
+ * <Toolbar.Separator />
170
+ * <Toolbar.Link href="/help">Help</Toolbar.Link>
171
+ * </Toolbar.Root>
172
+ */
173
+ export const Toolbar = {
174
+ Root,
175
+ Button: ToolbarButton,
176
+ Link,
177
+ Input,
178
+ Group,
179
+ Separator: ToolbarSeparator,
180
+ };
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Tooltip needs no CSS to open, close or stack: the native popover does it, and its client script
3
+ * opens it on hover and focus. What it needs is its placement next to the trigger, shared with
4
+ * Popover (placement.css).
5
+ */
6
+ @layer lu.core, lu.theme;
7
+
8
+ @import "../placement/placement.css";