@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,225 @@
1
+ // Tabs' client script (decision-62, decision-63): defines `<lu-tabs>`, the light-DOM element that
2
+ // Tabs.Root renders, which upgrades the server HTML in place. The DOM is its only state: the tab
3
+ // with `aria-selected="true"` is the selected one, its `value` attribute the value, and each tab
4
+ // controls its panel (`aria-controls`). It follows the APG tabs pattern and Base UI (doc-9 §8):
5
+ //
6
+ // - Roving tabindex: the selected tab is the tab stop; arrow keys move focus along the
7
+ // orientation (`aria-orientation` on the tab list), swapped in right-to-left, Home and End go
8
+ // to the ends, and focus loops unless the list has `data-loop-focus="false"`.
9
+ // - Activation: on click, Enter or Space (manual, Base UI's default), or on focus when the list
10
+ // has `data-activate-on-focus`. Only the main button activates. Disabled tabs
11
+ // (`aria-disabled="true"`) take focus but are never selected; a selected tab that is disabled
12
+ // gives its selection to the first enabled tab, and with every tab disabled none is selected.
13
+ // Once the element's content is parsed, the selected tab (else the first) is the only tab stop
14
+ // and the only selected tab, whatever the server HTML had (TASK-437 M17).
15
+ // - `value` reads and sets the selection; a change by the user fires `valuechange` (not
16
+ // bubbling), so `bind:value` works on the element too.
17
+ // - With a `[data-indicator]` in the list (Tabs.Indicator, TASK-395): the selected tab's box, from
18
+ // the list's padding edge, as `--lu-tabs-indicator-left`, `-top`, `-width` and `-height` on the
19
+ // element, kept current as the selection changes (by this script or by an island re-rendering
20
+ // `aria-selected`), as the list or a tab resizes, and as a `dir` attribute changes in the page. CSS anchor positioning (tabs.css) places the
21
+ // indicator before this script runs; the variables take over then, so a theme can transition
22
+ // them, and they place it where anchor positioning is missing.
23
+ //
24
+ // It must not import `lumiose`: server-rendered pages load it without the runtime.
25
+
26
+ const enabled = (tab: HTMLElement) => tab.getAttribute("aria-disabled") !== "true";
27
+
28
+ export class LuTabs extends HTMLElement {
29
+ /** The elements with an indicator, and one observer of `dir` for them all: it moves the tabs. */
30
+ static #measured = new Set<LuTabs>();
31
+ static #direction: MutationObserver | undefined;
32
+
33
+ #resize: ResizeObserver | undefined;
34
+ #mutation: MutationObserver | undefined;
35
+
36
+ /** Its own tab list: nested tabs belong to their own element. */
37
+ get list(): HTMLElement | null {
38
+ for (const list of this.querySelectorAll<HTMLElement>("[role=tablist]")) {
39
+ if (list.closest("lu-tabs") === this) return list;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ get tabs(): HTMLElement[] {
45
+ const list = this.list;
46
+ return list
47
+ ? [...list.querySelectorAll<HTMLElement>("[role=tab]")].filter(
48
+ (tab) => tab.closest("lu-tabs") === this,
49
+ )
50
+ : [];
51
+ }
52
+
53
+ /** The selected tab's value, or `null` when none is selected. */
54
+ get value(): string | null {
55
+ return (
56
+ this.tabs
57
+ .find((tab) => tab.getAttribute("aria-selected") === "true")
58
+ ?.getAttribute("value") ?? null
59
+ );
60
+ }
61
+
62
+ set value(next: string | null) {
63
+ this.#select(this.tabs.find((tab) => tab.getAttribute("value") === next));
64
+ }
65
+
66
+ connectedCallback() {
67
+ this.addEventListener("keydown", this.#onKeyDown);
68
+ this.addEventListener("click", this.#onClick);
69
+ this.addEventListener("focusin", this.#onFocusIn);
70
+ // Server HTML may select a disabled tab or leave the tab stop elsewhere: fix it once the
71
+ // element's content is parsed.
72
+ if (document.readyState === "loading") {
73
+ document.addEventListener("DOMContentLoaded", () => this.#settle(), { once: true });
74
+ } else queueMicrotask(() => this.#settle());
75
+ }
76
+
77
+ disconnectedCallback() {
78
+ this.removeEventListener("keydown", this.#onKeyDown);
79
+ this.removeEventListener("click", this.#onClick);
80
+ this.removeEventListener("focusin", this.#onFocusIn);
81
+ this.#resize?.disconnect();
82
+ this.#mutation?.disconnect();
83
+ this.#resize = this.#mutation = undefined;
84
+ LuTabs.#measured.delete(this);
85
+ if (LuTabs.#measured.size === 0) {
86
+ LuTabs.#direction?.disconnect();
87
+ LuTabs.#direction = undefined;
88
+ }
89
+ }
90
+
91
+ #settle() {
92
+ const tabs = this.tabs;
93
+ const selected = tabs.find(
94
+ (tab) => tab.getAttribute("aria-selected") === "true" && enabled(tab),
95
+ );
96
+ this.#select(selected ?? tabs.find(enabled), true);
97
+ const list = this.list;
98
+ if (list && this.#indicator() && !this.#resize) {
99
+ // The tabs' sizes change with fonts, text and the viewport, and an island may select a tab
100
+ // or add one by rendering: the indicator follows.
101
+ const resize = new ResizeObserver(() => this.#measure());
102
+ const observe = () => {
103
+ resize.observe(list);
104
+ for (const tab of this.tabs) resize.observe(tab);
105
+ };
106
+ observe();
107
+ this.#resize = resize;
108
+ this.#mutation = new MutationObserver(() => {
109
+ observe();
110
+ this.#measure();
111
+ });
112
+ this.#mutation.observe(list, {
113
+ subtree: true,
114
+ childList: true,
115
+ attributeFilter: ["aria-selected"],
116
+ });
117
+ LuTabs.#measured.add(this);
118
+ if (!LuTabs.#direction) {
119
+ LuTabs.#direction = new MutationObserver(() => {
120
+ for (const each of LuTabs.#measured) each.#measure();
121
+ });
122
+ LuTabs.#direction.observe(document.documentElement, {
123
+ subtree: true,
124
+ attributeFilter: ["dir"],
125
+ });
126
+ }
127
+ }
128
+ this.#measure();
129
+ }
130
+
131
+ #indicator(): HTMLElement | null {
132
+ const indicator = this.list?.querySelector<HTMLElement>(":scope > [data-indicator]");
133
+ return indicator ?? null;
134
+ }
135
+
136
+ /** Writes the selected tab's box, from the list's padding edge, for the indicator. */
137
+ #measure() {
138
+ const list = this.list;
139
+ const indicator = this.#indicator();
140
+ if (!list || !indicator) return;
141
+ const tab = this.tabs.find((each) => each.getAttribute("aria-selected") === "true");
142
+ indicator.hidden = !tab;
143
+ if (!tab) return;
144
+ const outer = list.getBoundingClientRect();
145
+ const inner = tab.getBoundingClientRect();
146
+ const left = inner.left - outer.left - list.clientLeft + list.scrollLeft;
147
+ const top = inner.top - outer.top - list.clientTop + list.scrollTop;
148
+ this.style.setProperty("--lu-tabs-indicator-left", `${left}px`);
149
+ this.style.setProperty("--lu-tabs-indicator-top", `${top}px`);
150
+ this.style.setProperty("--lu-tabs-indicator-width", `${inner.width}px`);
151
+ this.style.setProperty("--lu-tabs-indicator-height", `${inner.height}px`);
152
+ }
153
+
154
+ /** Selects `tab` (nothing when it is missing or disabled), and reports a change if asked. */
155
+ #select(tab: HTMLElement | undefined, report = false) {
156
+ if (tab && !enabled(tab)) return;
157
+ const before = this.value;
158
+ const tabs = this.tabs;
159
+ for (const each of tabs) {
160
+ const on = each === tab;
161
+ each.setAttribute("aria-selected", String(on));
162
+ each.tabIndex = on || (!tab && each === tabs[0]) ? 0 : -1;
163
+ const panel = this.#panelOf(each);
164
+ if (panel) panel.hidden = !on;
165
+ }
166
+ this.#measure();
167
+ if (report && this.value !== before) this.dispatchEvent(new Event("valuechange"));
168
+ }
169
+
170
+ /**
171
+ * The panel `tab` controls: one of this element's own tab panels, never another element of the
172
+ * page that has the id (TASK-473, L13).
173
+ */
174
+ #panelOf(tab: HTMLElement): HTMLElement | undefined {
175
+ const id = tab.getAttribute("aria-controls");
176
+ if (!id) return undefined;
177
+ for (const panel of this.querySelectorAll<HTMLElement>("[role=tabpanel]")) {
178
+ if (panel.id === id && panel.closest("lu-tabs") === this) return panel;
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ /** The tab an event happened on, if it is one of this element's own tabs. */
184
+ #tabOf(event: Event): HTMLElement | undefined {
185
+ const tab = (event.target as Element).closest?.<HTMLElement>("[role=tab]");
186
+ return tab && tab.closest("lu-tabs") === this && this.tabs.includes(tab) ? tab : undefined;
187
+ }
188
+
189
+ #onClick = (event: MouseEvent) => {
190
+ const tab = this.#tabOf(event);
191
+ if (!tab || event.button !== 0) return;
192
+ this.#select(tab, true);
193
+ };
194
+
195
+ #onFocusIn = (event: FocusEvent) => {
196
+ const tab = this.#tabOf(event);
197
+ if (tab && this.list?.hasAttribute("data-activate-on-focus")) this.#select(tab, true);
198
+ };
199
+
200
+ #onKeyDown = (event: KeyboardEvent) => {
201
+ const tab = this.#tabOf(event);
202
+ if (!tab || event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
203
+ const list = this.list!;
204
+ const vertical = list.getAttribute("aria-orientation") === "vertical";
205
+ const rtl = !vertical && getComputedStyle(list).direction === "rtl";
206
+ const keys: Record<string, number> = vertical
207
+ ? { ArrowDown: 1, ArrowUp: -1 }
208
+ : { ArrowRight: rtl ? -1 : 1, ArrowLeft: rtl ? 1 : -1 };
209
+ const tabs = this.tabs;
210
+ const at = tabs.indexOf(tab);
211
+ const step = keys[event.key];
212
+ let next: number;
213
+ if (event.key === "Home") next = 0;
214
+ else if (event.key === "End") next = tabs.length - 1;
215
+ else if (step !== undefined) {
216
+ next = at + step;
217
+ const loop = list.getAttribute("data-loop-focus") !== "false";
218
+ if (next < 0 || next >= tabs.length) next = loop ? (next + tabs.length) % tabs.length : at;
219
+ } else return;
220
+ event.preventDefault();
221
+ tabs[next]!.focus();
222
+ };
223
+ }
224
+
225
+ if (!customElements.get("lu-tabs")) customElements.define("lu-tabs", LuTabs);
@@ -0,0 +1,272 @@
1
+ // Tabs (decision-62, TASK-359): Root renders `<lu-tabs>`, a light-DOM element whose script
2
+ // (tabs.script.ts) adds the keyboard and the selection to the server HTML, which already carries
3
+ // the full APG state: roles, `aria-selected`, the roving `tabindex`, `aria-controls` and
4
+ // `aria-labelledby`, and `hidden` on the other panels. The element's DOM is the state: the
5
+ // component renders it, `value` drives it when it changes, and the element's `valuechange` event
6
+ // reports back through `onValueChange`.
7
+ import {
8
+ createContext,
9
+ effect,
10
+ isServer,
11
+ onCleanup,
12
+ ref,
13
+ signal,
14
+ untrack,
15
+ useContext,
16
+ useId,
17
+ type Signal,
18
+ } from "lumiose";
19
+ import { withStyle } from "../placement/placement.ts";
20
+ import defineTabs from "./tabs.script.ts?client";
21
+ import type { LuTabs } from "./tabs.script.ts";
22
+
23
+ declare module "lumiose/jsx-runtime" {
24
+ namespace JSX {
25
+ interface IntrinsicElements {
26
+ /** Tabs.Root's element: `value` is the selected tab's, `valuechange` reports a change. */
27
+ "lu-tabs": JSX.HTMLProps<LuTabs> & {
28
+ /** The selected tab's value, `null` when none is; setting it selects that tab. */
29
+ value?: string | null;
30
+ /** Sets `value` from the signal; the element's `valuechange` writes it back. */
31
+ "bind:value"?: Signal<string | null>;
32
+ };
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The element props Root passes to `<lu-tabs>` (`class`, `style`, `data-*`, `aria-*`, events),
39
+ * without the brand of intrinsic elements' props, so that `bind:value` applies to Root.
40
+ */
41
+ type ElementProps = {
42
+ [K in keyof JSX.HTMLProps<LuTabs> as K extends string ? K : never]: JSX.HTMLProps<LuTabs>[K];
43
+ };
44
+
45
+ /**
46
+ * `Tabs.Root` renders `<lu-tabs>`, with the element props it is given. `bind:value={signal}`
47
+ * stands in for `value` and `onValueChange`.
48
+ */
49
+ export interface TabsRootProps extends Omit<ElementProps, "children"> {
50
+ /**
51
+ * Selects the tab with this value when it changes. Pair it with `onValueChange` to keep a signal
52
+ * in sync; leave both out and the tabs keep their own state.
53
+ */
54
+ value?: string;
55
+ /** The tab selected at first, when there is no `value`. Default: the first enabled tab. */
56
+ defaultValue?: string;
57
+ /** Called after the user selects another tab, with its value. */
58
+ onValueChange?: (value: string) => void;
59
+ /** The tab list's direction, for the arrow keys. Default `"horizontal"`. */
60
+ orientation?: "horizontal" | "vertical";
61
+ /** The other parts: the `Tabs.List` and the `Tabs.Panel`s. */
62
+ children?: JSX.Element;
63
+ }
64
+
65
+ /** The `<div role="tablist">` around the tabs. Name it with `aria-label` or `aria-labelledby`. */
66
+ export type TabsListProps = JSX.IntrinsicElements["div"] & {
67
+ /** Selecting a tab when it receives focus (automatic activation). Default `false`: manual. */
68
+ activateOnFocus?: boolean;
69
+ /** The arrow keys go from the last tab to the first and back. Default `true`. */
70
+ loopFocus?: boolean;
71
+ };
72
+
73
+ /** A `<button role="tab">`, paired with the `Tabs.Panel` of the same `value`. */
74
+ export type TabsTabProps = Omit<JSX.IntrinsicElements["button"], "value"> & {
75
+ /** Identifies the tab and its panel. */
76
+ value: string;
77
+ /** Takes focus but cannot be selected. */
78
+ disabled?: boolean;
79
+ };
80
+
81
+ /** A `<div role="tabpanel">`, shown while the tab of the same `value` is selected. */
82
+ export type TabsPanelProps = JSX.IntrinsicElements["div"] & {
83
+ /** The value of the tab that shows the panel. */
84
+ value: string;
85
+ };
86
+
87
+ /**
88
+ * A `<span aria-hidden="true" data-indicator>` inside `Tabs.List` that covers the selected tab's
89
+ * box, placed from the server HTML on: style it as an underline or a highlight that slides from
90
+ * tab to tab.
91
+ */
92
+ export type TabsIndicatorProps = Omit<JSX.IntrinsicElements["span"], "children">;
93
+
94
+ interface TabRecord {
95
+ value: string;
96
+ disabled: () => boolean;
97
+ }
98
+
99
+ interface TabsContextValue {
100
+ /** The selected value: a disabled or missing tab gives its place to the first enabled one. */
101
+ current: () => string | undefined;
102
+ /** The value asked for (`value`, `defaultValue` or the user's choice), even a disabled tab's. */
103
+ wanted: () => string | undefined;
104
+ tabs: Signal<TabRecord[]>;
105
+ orientation: () => "horizontal" | "vertical";
106
+ tabId: (value: string) => string;
107
+ /** Records a Tab's own `id`: the Panel after it points at it. */
108
+ name: (value: string, id: string) => void;
109
+ panelId: (value: string) => string;
110
+ }
111
+
112
+ const TabsContext = createContext<TabsContextValue>();
113
+
114
+ function useTabs(): TabsContextValue {
115
+ const context = useContext(TabsContext);
116
+ if (!context) throw new Error("Tabs parts must be inside <Tabs.Root>.");
117
+ return context;
118
+ }
119
+
120
+ /** An id fragment for a value: ids may not hold spaces. */
121
+ const fragment = (value: string) => encodeURIComponent(value).replaceAll("%", "_");
122
+
123
+ function Root({
124
+ value,
125
+ defaultValue,
126
+ onValueChange,
127
+ orientation,
128
+ children,
129
+ ...rest
130
+ }: TabsRootProps) {
131
+ // Keyboard and selection on the server HTML; the page loads it once (decision-63).
132
+ defineTabs();
133
+ const id = useId();
134
+ const element = ref<LuTabs>();
135
+ const selected = signal<string | undefined>(untrack(() => value ?? defaultValue));
136
+ const tabs = signal<TabRecord[]>([]);
137
+ const named = new Map<string, string>();
138
+ const context: TabsContextValue = {
139
+ current: () => {
140
+ const wanted = selected();
141
+ const list = tabs();
142
+ const tab = list.find((each) => each.value === wanted);
143
+ // A value whose tab has not rendered yet (on the server, a later tab) stays selected.
144
+ if (wanted !== undefined && (!tab || !tab.disabled())) return wanted;
145
+ return list.find((each) => !each.disabled())?.value;
146
+ },
147
+ wanted: selected,
148
+ tabs,
149
+ orientation: () => orientation ?? "horizontal",
150
+ // A Tab's own `id`, else one made from its value (TASK-441 M39).
151
+ tabId: (of) => named.get(of) ?? `${id}-tab-${fragment(of)}`,
152
+ name: (of, tabId) => named.set(of, tabId),
153
+ panelId: (of) => `${id}-panel-${fragment(of)}`,
154
+ };
155
+
156
+ // The `value` prop drives the selection when it changes.
157
+ effect(() => {
158
+ const next = value;
159
+ if (next !== undefined) selected.set(next);
160
+ });
161
+
162
+ return (
163
+ <TabsContext value={context}>
164
+ <lu-tabs
165
+ {...rest}
166
+ ref={element}
167
+ // The anchor the selected tab carries for a Tabs.Indicator (tabs.css).
168
+ style={withStyle(rest.style, { "--lu-tabs-anchor": `--lu-tabs-${id}` })}
169
+ on:valuechange={() => {
170
+ const next = element()?.value;
171
+ if (next == null) return;
172
+ selected.set(next);
173
+ onValueChange?.(next);
174
+ }}
175
+ >
176
+ {children}
177
+ </lu-tabs>
178
+ </TabsContext>
179
+ );
180
+ }
181
+
182
+ function List({ children, activateOnFocus, loopFocus, ...rest }: TabsListProps) {
183
+ const tabs = useTabs();
184
+ return (
185
+ <div
186
+ role="tablist"
187
+ aria-orientation={tabs.orientation() === "vertical" ? "vertical" : undefined}
188
+ data-activate-on-focus={activateOnFocus ? "" : undefined}
189
+ data-loop-focus={loopFocus === false ? "false" : undefined}
190
+ {...rest}
191
+ >
192
+ {children}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function Tab({ children, value, disabled, ...rest }: TabsTabProps) {
198
+ const tabs = useTabs();
199
+ // A caller's `id` names it; its Panel, after it, points at that (TASK-441 M39).
200
+ if (rest.id) tabs.name(value, rest.id);
201
+ const record: TabRecord = { value, disabled: () => disabled === true };
202
+ tabs.tabs.update((list) => [...list, record]);
203
+ onCleanup(() => tabs.tabs.update((list) => list.filter((each) => each !== record)));
204
+ const selected = () => tabs.current() === value;
205
+ // The tab stop: the selected tab, or the first one when none is selected. The server renders
206
+ // once, before the later tabs exist, so there it is the tab asked for (a disabled one stays
207
+ // focusable), else the first enabled one so far: one tab stop either way (TASK-437 M17). The
208
+ // script gives it to the selected tab.
209
+ const stop = () => {
210
+ if (isServer) {
211
+ const wanted = tabs.wanted();
212
+ return wanted === undefined ? selected() : wanted === value;
213
+ }
214
+ return selected() || (tabs.current() === undefined && tabs.tabs()[0] === record);
215
+ };
216
+ return (
217
+ <button
218
+ type="button"
219
+ {...rest}
220
+ role="tab"
221
+ id={tabs.tabId(value)}
222
+ value={value}
223
+ aria-controls={tabs.panelId(value)}
224
+ aria-selected={selected() ? "true" : "false"}
225
+ aria-disabled={disabled ? "true" : undefined}
226
+ tabindex={stop() ? 0 : -1}
227
+ >
228
+ {children}
229
+ </button>
230
+ );
231
+ }
232
+
233
+ function Indicator(props: TabsIndicatorProps) {
234
+ const tabs = useTabs();
235
+ return (
236
+ <span {...props} aria-hidden="true" data-indicator="" hidden={tabs.current() === undefined} />
237
+ );
238
+ }
239
+
240
+ function Panel({ children, value, ...rest }: TabsPanelProps) {
241
+ const tabs = useTabs();
242
+ return (
243
+ <div
244
+ tabindex={0}
245
+ {...rest}
246
+ role="tabpanel"
247
+ id={tabs.panelId(value)}
248
+ aria-labelledby={tabs.tabId(value)}
249
+ hidden={tabs.current() !== value}
250
+ >
251
+ {children}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ /**
257
+ * Tabs: a tab list and the panel of the selected tab, after the APG tabs pattern. The server HTML
258
+ * holds the whole state; `<lu-tabs>`'s small script adds the arrow keys and the selection.
259
+ * Unstyled: style the parts through `class`, `[aria-selected]`, `[aria-disabled]` and
260
+ * `[aria-orientation]`. An optional `Tabs.Indicator` in the list follows the selected tab.
261
+ *
262
+ * @example
263
+ * <Tabs.Root defaultValue="account">
264
+ * <Tabs.List aria-label="Settings">
265
+ * <Tabs.Tab value="account">Account</Tabs.Tab>
266
+ * <Tabs.Tab value="password">Password</Tabs.Tab>
267
+ * </Tabs.List>
268
+ * <Tabs.Panel value="account">…</Tabs.Panel>
269
+ * <Tabs.Panel value="password">…</Tabs.Panel>
270
+ * </Tabs.Root>
271
+ */
272
+ export const Tabs = { Root, List, Tab, Indicator, Panel };