@elabs-ai/components-ui 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- package/dist/chunk-JKPPTL63.js.map +0 -1
|
@@ -136,7 +136,7 @@ describe("Table — #366 the scroll wrapper's tab stop exists only while it meas
|
|
|
136
136
|
expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
it('
|
|
139
|
+
it('adds role="group" once it overflows — a named, non-landmark stop', () => {
|
|
140
140
|
const { container } = render(
|
|
141
141
|
<Table>
|
|
142
142
|
<TableBody>
|
|
@@ -147,12 +147,19 @@ describe("Table — #366 the scroll wrapper's tab stop exists only while it meas
|
|
|
147
147
|
</Table>,
|
|
148
148
|
);
|
|
149
149
|
const scrollRegion = scrollRegionOf(container);
|
|
150
|
+
// `aria-label` on a plain `<div>` (role `generic`) is not guaranteed to
|
|
151
|
+
// produce an accessible name at all, so the overflowing stop needs a
|
|
152
|
+
// naming-capable role — `group`, never the `region` landmark, which would
|
|
153
|
+
// collide (axe `landmark-unique`) as soon as two tables overflow on a page.
|
|
154
|
+
expect(scrollRegion).not.toHaveAttribute("role");
|
|
150
155
|
setScrollMetrics(scrollRegion, {
|
|
151
156
|
scrollWidth: 300,
|
|
152
157
|
clientWidth: 300,
|
|
153
158
|
scrollHeight: 800,
|
|
154
159
|
clientHeight: 200,
|
|
155
160
|
});
|
|
156
|
-
expect(scrollRegion).
|
|
161
|
+
expect(scrollRegion).toHaveAttribute("role", "group");
|
|
162
|
+
expect(screen.queryByRole("region")).toBeNull();
|
|
163
|
+
expect(screen.getByRole("group", { name: "Table contents, scrollable" })).toBe(scrollRegion);
|
|
157
164
|
});
|
|
158
165
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
forwardRef,
|
|
3
5
|
useCallback,
|
|
@@ -23,13 +25,20 @@ import { useLocale } from "../locale-provider";
|
|
|
23
25
|
* affordance itself.
|
|
24
26
|
*
|
|
25
27
|
* A table that FITS must stay a byte-identical no-op: no `tabIndex`, no
|
|
26
|
-
* `aria-label`, no ring — an unconditional stop would announce
|
|
27
|
-
* on content that never scrolls. Only a table that measurably
|
|
28
|
-
* gets the tab stop, its accessible name (WCAG 4.1.2) and the
|
|
29
|
-
* ring (`focus-ring-inset` — the wrapper's own edge is the
|
|
30
|
-
* outside ring would be cut, `.claude/rules/theming.md`).
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* `aria-label`, no `role`, no ring — an unconditional stop would announce
|
|
29
|
+
* "scrollable" on content that never scrolls. Only a table that measurably
|
|
30
|
+
* overflows gets the tab stop, its accessible name (WCAG 4.1.2) and the
|
|
31
|
+
* compound focus ring (`focus-ring-inset` — the wrapper's own edge is the
|
|
32
|
+
* scroll clip, so an outside ring would be cut, `.claude/rules/theming.md`).
|
|
33
|
+
*
|
|
34
|
+
* A naming-capable role is REQUIRED alongside that `aria-label`: `aria-label`
|
|
35
|
+
* on a plain `<div>` (role `generic`) is prohibited by ARIA 1.2 and not
|
|
36
|
+
* guaranteed to compute into a name, so `tabIndex={0}` alone would make an
|
|
37
|
+
* UNNAMED stop. That role is `group`, NOT `region`: `region` is a landmark,
|
|
38
|
+
* and every overflowing table on a page would add one more landmark with the
|
|
39
|
+
* same name (axe `landmark-unique`) over a real `<table>` that already has
|
|
40
|
+
* its own semantics. `group` names the stop without joining landmark
|
|
41
|
+
* navigation — the no-redundant-landmark call `DataTable` made first (#330).
|
|
33
42
|
*/
|
|
34
43
|
export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(function Table(
|
|
35
44
|
{ className, ...props },
|
|
@@ -67,6 +76,7 @@ export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElemen
|
|
|
67
76
|
ref={scrollRef}
|
|
68
77
|
data-slot="table-scroll-region"
|
|
69
78
|
tabIndex={scrollOverflows ? 0 : undefined}
|
|
79
|
+
role={scrollOverflows ? "group" : undefined}
|
|
70
80
|
aria-label={scrollOverflows ? t("ui.table.scrollRegion") : undefined}
|
|
71
81
|
onScroll={updateScrollAffordance}
|
|
72
82
|
className="relative w-full overflow-auto focus-ring-inset"
|
|
@@ -138,7 +148,7 @@ export const TableHead = forwardRef<HTMLTableCellElement, ThHTMLAttributes<HTMLT
|
|
|
138
148
|
<th
|
|
139
149
|
ref={ref}
|
|
140
150
|
className={cn(
|
|
141
|
-
"h-10 px-3 text-start align-middle font-
|
|
151
|
+
"h-10 px-3 text-start align-middle font-table-header text-muted-foreground",
|
|
142
152
|
className,
|
|
143
153
|
)}
|
|
144
154
|
{...props}
|
|
@@ -265,3 +265,51 @@ export const ProgrammaticActivation: Story = {
|
|
|
265
265
|
await expect(window.scrollY).toBe(0);
|
|
266
266
|
},
|
|
267
267
|
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `variant="underline"` — line tabs: a transparent row over a 1px rule, the
|
|
271
|
+
* active tab marked by a 2px `--primary` underline and foreground ink. Label
|
|
272
|
+
* weight follows the theme's `--control-weight`. Keyboard behaviour is Radix's,
|
|
273
|
+
* identical to the segmented default.
|
|
274
|
+
*/
|
|
275
|
+
export const Underline: Story = {
|
|
276
|
+
render: () => (
|
|
277
|
+
<Tabs defaultValue="overview" className="w-[28rem]">
|
|
278
|
+
<TabsList variant="underline">
|
|
279
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
280
|
+
<TabsTrigger value="activity">Activity</TabsTrigger>
|
|
281
|
+
<TabsTrigger value="settings">Settings</TabsTrigger>
|
|
282
|
+
</TabsList>
|
|
283
|
+
<TabsContent value="overview" className="text-body text-muted-foreground">
|
|
284
|
+
Overview panel.
|
|
285
|
+
</TabsContent>
|
|
286
|
+
<TabsContent value="activity" className="text-body text-muted-foreground">
|
|
287
|
+
Activity panel.
|
|
288
|
+
</TabsContent>
|
|
289
|
+
<TabsContent value="settings" className="text-body text-muted-foreground">
|
|
290
|
+
Settings panel.
|
|
291
|
+
</TabsContent>
|
|
292
|
+
</Tabs>
|
|
293
|
+
),
|
|
294
|
+
play: async ({ canvas, userEvent }) => {
|
|
295
|
+
const overview = canvas.getByRole("tab", { name: "Overview" });
|
|
296
|
+
const activity = canvas.getByRole("tab", { name: "Activity" });
|
|
297
|
+
|
|
298
|
+
// The underline is a 2px border on every trigger (transparent at rest), so
|
|
299
|
+
// activating a tab never changes its box.
|
|
300
|
+
await expect(getComputedStyle(activity).borderBottomWidth).toBe("2px");
|
|
301
|
+
await expect(getComputedStyle(overview).borderBottomColor).not.toBe(
|
|
302
|
+
getComputedStyle(activity).borderBottomColor,
|
|
303
|
+
);
|
|
304
|
+
const restingHeight = activity.getBoundingClientRect().height;
|
|
305
|
+
|
|
306
|
+
await userEvent.click(overview);
|
|
307
|
+
await userEvent.keyboard("{ArrowRight}");
|
|
308
|
+
await expect(activity).toHaveFocus();
|
|
309
|
+
await expect(activity).toHaveAttribute("aria-selected", "true");
|
|
310
|
+
await expect(activity.getBoundingClientRect().height).toBe(restingHeight);
|
|
311
|
+
// The panel mounts on switch and fades in (gated entrance) — wait for it to settle.
|
|
312
|
+
const panel = await canvas.findByText("Activity panel.");
|
|
313
|
+
await waitFor(() => expect(panel).toBeVisible());
|
|
314
|
+
},
|
|
315
|
+
};
|
|
@@ -76,6 +76,73 @@ describe("Tabs", () => {
|
|
|
76
76
|
// scroll-into-view/320px-container behavior is layout-dependent and is
|
|
77
77
|
// covered by the `OverflowScrollable` Storybook interaction test instead
|
|
78
78
|
// (jsdom doesn't lay out real scroll geometry).
|
|
79
|
+
it("creates one shared observer pair per list, not one per trigger (#387)", () => {
|
|
80
|
+
const originalMutationObserver = globalThis.MutationObserver;
|
|
81
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
82
|
+
let mutationObserverCount = 0;
|
|
83
|
+
let resizeObserverCount = 0;
|
|
84
|
+
|
|
85
|
+
class CountingMutationObserver extends originalMutationObserver {
|
|
86
|
+
constructor(...args: ConstructorParameters<typeof MutationObserver>) {
|
|
87
|
+
super(...args);
|
|
88
|
+
mutationObserverCount += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
class CountingResizeObserver extends originalResizeObserver {
|
|
92
|
+
constructor(...args: ConstructorParameters<typeof ResizeObserver>) {
|
|
93
|
+
super(...args);
|
|
94
|
+
resizeObserverCount += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
globalThis.MutationObserver = CountingMutationObserver as typeof MutationObserver;
|
|
98
|
+
globalThis.ResizeObserver = CountingResizeObserver as typeof ResizeObserver;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
render(
|
|
102
|
+
<Tabs defaultValue="a">
|
|
103
|
+
<TabsList>
|
|
104
|
+
<TabsTrigger value="a">A</TabsTrigger>
|
|
105
|
+
<TabsTrigger value="b">B</TabsTrigger>
|
|
106
|
+
<TabsTrigger value="c">C</TabsTrigger>
|
|
107
|
+
<TabsTrigger value="d">D</TabsTrigger>
|
|
108
|
+
</TabsList>
|
|
109
|
+
<TabsContent value="a">Panel A</TabsContent>
|
|
110
|
+
</Tabs>,
|
|
111
|
+
);
|
|
112
|
+
// 4 triggers in the strip. Before the fix, each trigger owned its own
|
|
113
|
+
// MutationObserver AND ResizeObserver (4 of each here — the review's
|
|
114
|
+
// "20 callbacks per resize" was measured on a 20-tab strip).
|
|
115
|
+
const fourTriggerMutationCount = mutationObserverCount;
|
|
116
|
+
const fourTriggerResizeCount = resizeObserverCount;
|
|
117
|
+
expect(fourTriggerResizeCount).toBeGreaterThan(0);
|
|
118
|
+
|
|
119
|
+
mutationObserverCount = 0;
|
|
120
|
+
resizeObserverCount = 0;
|
|
121
|
+
render(
|
|
122
|
+
<Tabs defaultValue="a">
|
|
123
|
+
<TabsList>
|
|
124
|
+
<TabsTrigger value="a">A</TabsTrigger>
|
|
125
|
+
<TabsTrigger value="b">B</TabsTrigger>
|
|
126
|
+
<TabsTrigger value="c">C</TabsTrigger>
|
|
127
|
+
<TabsTrigger value="d">D</TabsTrigger>
|
|
128
|
+
<TabsTrigger value="e">E</TabsTrigger>
|
|
129
|
+
<TabsTrigger value="f">F</TabsTrigger>
|
|
130
|
+
<TabsTrigger value="g">G</TabsTrigger>
|
|
131
|
+
<TabsTrigger value="h">H</TabsTrigger>
|
|
132
|
+
</TabsList>
|
|
133
|
+
<TabsContent value="a">Panel A</TabsContent>
|
|
134
|
+
</Tabs>,
|
|
135
|
+
);
|
|
136
|
+
// The list owns a FIXED number of observers regardless of trigger
|
|
137
|
+
// count — doubling the trigger count must not double it.
|
|
138
|
+
expect(mutationObserverCount).toBe(fourTriggerMutationCount);
|
|
139
|
+
expect(resizeObserverCount).toBe(fourTriggerResizeCount);
|
|
140
|
+
} finally {
|
|
141
|
+
globalThis.MutationObserver = originalMutationObserver;
|
|
142
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
79
146
|
it("renders TabsList as a bounded scroll container with safe centering", () => {
|
|
80
147
|
render(
|
|
81
148
|
<Tabs defaultValue="a">
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
4
|
+
createContext,
|
|
2
5
|
forwardRef,
|
|
6
|
+
use,
|
|
3
7
|
useEffect,
|
|
4
8
|
useMemo,
|
|
5
9
|
useRef,
|
|
@@ -7,6 +11,7 @@ import {
|
|
|
7
11
|
type ElementRef,
|
|
8
12
|
} from "react";
|
|
9
13
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
14
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
10
15
|
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
11
16
|
import { cn } from "../../lib/cn";
|
|
12
17
|
import { mergeRefs } from "../../lib/merge-refs";
|
|
@@ -68,91 +73,184 @@ function scrollTriggerIntoStrip(trigger: HTMLElement, behavior: ScrollBehavior)
|
|
|
68
73
|
* start-alignment on overflow while staying visually identical when the
|
|
69
74
|
* strip fits (see #344).
|
|
70
75
|
*/
|
|
71
|
-
export const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
export const tabsListVariants = cva(
|
|
77
|
+
"max-w-full items-center overflow-x-auto text-muted-foreground",
|
|
78
|
+
{
|
|
79
|
+
variants: {
|
|
80
|
+
variant: {
|
|
81
|
+
// Recessed pill track with a raised active segment.
|
|
82
|
+
segmented: "inline-flex h-9 justify-center-safe rounded-lg bg-muted p-1",
|
|
83
|
+
// Line tabs: a transparent start-aligned row over a 1px rule; the active
|
|
84
|
+
// tab's 2px underline sits on top of it (see `tabsTriggerVariants`).
|
|
85
|
+
underline: "flex w-full justify-start border-b border-rule",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
defaultVariants: { variant: "segmented" },
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
export const tabsTriggerVariants = cva(
|
|
93
|
+
"inline-flex items-center justify-center whitespace-nowrap text-body font-control transition-colors duration-fast disabled:pointer-events-none disabled:opacity-50",
|
|
94
|
+
{
|
|
95
|
+
variants: {
|
|
96
|
+
variant: {
|
|
97
|
+
segmented: cn(
|
|
98
|
+
"rounded-control px-3 py-1",
|
|
99
|
+
// `ring-offset-1`, not `-2`: the compound indicator's reach is
|
|
100
|
+
// `ring-offset` + 2px ring + 1px contour, and `TabsList` is an
|
|
101
|
+
// `overflow-x-auto` strip with `p-1` (4px). At offset 2 the reach is 5px
|
|
102
|
+
// and the scroll box clips the contour on three sides (#67 fix round 2,
|
|
103
|
+
// measured on `patterns-templates-object-detail-hub--default` tab stop
|
|
104
|
+
// 9); at offset 1 it is exactly 4px and the loop closes.
|
|
105
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
106
|
+
"data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
107
|
+
),
|
|
108
|
+
underline: cn(
|
|
109
|
+
// The underline is a bottom BORDER inside the fixed height, transparent
|
|
110
|
+
// at rest, so activation never shifts layout. No negative margin to
|
|
111
|
+
// overlap the list's rule: the list scrolls (`overflow-x-auto`), which
|
|
112
|
+
// would clip anything hanging past its padding box. The strip has no
|
|
113
|
+
// padding for an outer ring either, so the indicator is drawn inset.
|
|
114
|
+
"h-12 rounded-none border-b-2 border-transparent px-4 hover:text-foreground",
|
|
115
|
+
"focus-ring-inset",
|
|
116
|
+
"data-[state=active]:border-primary data-[state=active]:text-foreground",
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
defaultVariants: { variant: "segmented" },
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
export type TabsVariant = NonNullable<VariantProps<typeof tabsListVariants>["variant"]>;
|
|
125
|
+
|
|
126
|
+
// The list owns the variant; its triggers read it, so a strip never mixes two.
|
|
127
|
+
const TabsVariantContext = createContext<TabsVariant>("segmented");
|
|
128
|
+
|
|
129
|
+
export interface TabsListProps
|
|
130
|
+
extends
|
|
131
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.List>,
|
|
132
|
+
VariantProps<typeof tabsListVariants> {}
|
|
133
|
+
|
|
134
|
+
export const TabsList = forwardRef<ElementRef<typeof TabsPrimitive.List>, TabsListProps>(
|
|
135
|
+
function TabsList({ className, variant, ...props }, ref) {
|
|
136
|
+
const resolved = variant ?? "segmented";
|
|
137
|
+
const innerRef = useRef<HTMLDivElement | null>(null);
|
|
138
|
+
const mergedRef = useMemo(() => mergeRefs(ref, innerRef), [ref]);
|
|
139
|
+
// Honours the in-app tri-state motion preference on `ThemeProvider` as well
|
|
140
|
+
// as the OS setting, and degrades to OS-only outside a provider.
|
|
141
|
+
const reducedMotion = useReducedMotion();
|
|
142
|
+
|
|
143
|
+
// Keep the tab that BECOMES active inside the strip's visible bounds.
|
|
144
|
+
//
|
|
145
|
+
// ONE set of observers per LIST, not one per trigger (#387 perf finding:
|
|
146
|
+
// N triggers used to mean N independent `MutationObserver`s (each
|
|
147
|
+
// watching only its own node) plus N independent `ResizeObserver`s (each
|
|
148
|
+
// ALSO watching this same shared strip) — a resize of a 20-tab strip fired
|
|
149
|
+
// 20 redundant callbacks. Watching the list's subtree in one callback
|
|
150
|
+
// gets the same behaviour for the cost of one.
|
|
151
|
+
//
|
|
152
|
+
// Watching attributes rather than reacting to a render is still
|
|
153
|
+
// load-bearing: Radix flips `data-state` on the trigger it renders, and
|
|
154
|
+
// only ITS OWN context consumer re-renders — this component does not, so
|
|
155
|
+
// a render-driven effect would never observe an activation at all
|
|
156
|
+
// (measured: 8 effect runs for 8 triggers at mount, zero on any later
|
|
157
|
+
// activation). A MutationObserver fires on a genuine inactive → active
|
|
158
|
+
// TRANSITION and never at mount, which is what stops a freshly-mounted
|
|
159
|
+
// Tabs from scrolling anything (#344 shipped a version that scrolled the
|
|
160
|
+
// page 900px on every mount).
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const node = innerRef.current;
|
|
163
|
+
if (!node) return;
|
|
164
|
+
|
|
165
|
+
const scrollActiveIntoView = () => {
|
|
166
|
+
const active = node.querySelector<HTMLElement>(
|
|
167
|
+
'[data-slot="tabs-trigger"][data-state="active"]',
|
|
168
|
+
);
|
|
169
|
+
if (active) scrollTriggerIntoStrip(active, reducedMotion ? "auto" : "smooth");
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const activationObserver = new MutationObserver((mutations) => {
|
|
173
|
+
for (const mutation of mutations) {
|
|
174
|
+
const el = mutation.target as HTMLElement;
|
|
175
|
+
if (el.getAttribute("data-state") === "active") {
|
|
176
|
+
scrollTriggerIntoStrip(el, reducedMotion ? "auto" : "smooth");
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
activationObserver.observe(node, {
|
|
182
|
+
subtree: true,
|
|
183
|
+
attributes: true,
|
|
184
|
+
attributeFilter: ["data-state"],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// A relayout AFTER the scroll invalidates the target it was computed
|
|
188
|
+
// from: a webfont swapping in on a cold load (measured — a
|
|
189
|
+
// mono-everything theme's wider mono face left the last tab 45px
|
|
190
|
+
// outside the strip), or the strip itself being resized. One
|
|
191
|
+
// `ResizeObserver` instance covers both — it watches the strip AND
|
|
192
|
+
// every trigger inside it, not a second instance per trigger.
|
|
193
|
+
let resizeObserver: ResizeObserver | undefined;
|
|
194
|
+
if (typeof ResizeObserver === "function") {
|
|
195
|
+
resizeObserver = new ResizeObserver(scrollActiveIntoView);
|
|
196
|
+
resizeObserver.observe(node);
|
|
197
|
+
for (const trigger of node.querySelectorAll<HTMLElement>('[data-slot="tabs-trigger"]')) {
|
|
198
|
+
resizeObserver.observe(trigger);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A dynamic tab list can add/remove triggers later — keep the single
|
|
203
|
+
// `ResizeObserver`'s target set in sync instead of spinning up a new
|
|
204
|
+
// observer per trigger mount.
|
|
205
|
+
const childListObserver = new MutationObserver((mutations) => {
|
|
206
|
+
if (!resizeObserver) return;
|
|
207
|
+
for (const mutation of mutations) {
|
|
208
|
+
mutation.addedNodes.forEach((added) => {
|
|
209
|
+
if (added instanceof HTMLElement && added.matches('[data-slot="tabs-trigger"]')) {
|
|
210
|
+
resizeObserver!.observe(added);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
mutation.removedNodes.forEach((removed) => {
|
|
214
|
+
if (removed instanceof HTMLElement && removed.matches('[data-slot="tabs-trigger"]')) {
|
|
215
|
+
resizeObserver!.unobserve(removed);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
childListObserver.observe(node, { childList: true, subtree: true });
|
|
221
|
+
|
|
222
|
+
return () => {
|
|
223
|
+
activationObserver.disconnect();
|
|
224
|
+
resizeObserver?.disconnect();
|
|
225
|
+
childListObserver.disconnect();
|
|
226
|
+
};
|
|
227
|
+
}, [reducedMotion]);
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<TabsVariantContext.Provider value={resolved}>
|
|
231
|
+
<TabsPrimitive.List
|
|
232
|
+
ref={mergedRef}
|
|
233
|
+
data-slot="tabs-list"
|
|
234
|
+
data-variant={resolved}
|
|
235
|
+
className={cn(tabsListVariants({ variant: resolved }), className)}
|
|
236
|
+
{...props}
|
|
237
|
+
/>
|
|
238
|
+
</TabsVariantContext.Provider>
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
);
|
|
86
242
|
|
|
87
243
|
export const TabsTrigger = forwardRef<
|
|
88
244
|
ElementRef<typeof TabsPrimitive.Trigger>,
|
|
89
245
|
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
90
246
|
>(function TabsTrigger({ className, ...props }, ref) {
|
|
91
|
-
const
|
|
92
|
-
const mergedRef = useMemo(() => mergeRefs(ref, innerRef), [ref]);
|
|
93
|
-
// Honours the in-app tri-state motion preference on `ThemeProvider` as well
|
|
94
|
-
// as the OS setting, and degrades to OS-only outside a provider.
|
|
95
|
-
const reducedMotion = useReducedMotion();
|
|
96
|
-
|
|
97
|
-
// Keep the tab that BECOMES active inside the strip's visible bounds.
|
|
98
|
-
//
|
|
99
|
-
// Watching the attribute rather than reacting to a render is load-bearing on
|
|
100
|
-
// both counts:
|
|
101
|
-
// - Radix flips `data-state` on the trigger it renders INSIDE this wrapper,
|
|
102
|
-
// and only its own context consumer re-renders — this component does not.
|
|
103
|
-
// A render-driven effect therefore never observes an activation at all
|
|
104
|
-
// (measured: 8 effect runs for 8 triggers at mount, zero on any later
|
|
105
|
-
// activation), so the scroll only ever ran when the CONSUMER's own state
|
|
106
|
-
// changed.
|
|
107
|
-
// - A MutationObserver fires on a genuine inactive → active TRANSITION and
|
|
108
|
-
// never at mount, which is what stops a freshly-mounted Tabs from
|
|
109
|
-
// scrolling anything (#344 shipped a version that scrolled the page 900px
|
|
110
|
-
// on every mount).
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
const node = innerRef.current;
|
|
113
|
-
if (!node) return;
|
|
114
|
-
|
|
115
|
-
const scrollIfActive = () => {
|
|
116
|
-
if (node.getAttribute("data-state") !== "active") return;
|
|
117
|
-
scrollTriggerIntoStrip(node, reducedMotion ? "auto" : "smooth");
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const observer = new MutationObserver(scrollIfActive);
|
|
121
|
-
observer.observe(node, { attributes: true, attributeFilter: ["data-state"] });
|
|
122
|
-
|
|
123
|
-
// A relayout AFTER the scroll invalidates the target it was computed from:
|
|
124
|
-
// a webfont swapping in on a cold load (measured — a mono-everything theme's
|
|
125
|
-
// wider mono face left the last tab 45px outside the strip), or the strip
|
|
126
|
-
// being resized. Re-measure whenever the tab or the strip changes size;
|
|
127
|
-
// the delta check makes every unaffected pass a no-op.
|
|
128
|
-
const resizeObserver =
|
|
129
|
-
typeof ResizeObserver === "function" ? new ResizeObserver(scrollIfActive) : undefined;
|
|
130
|
-
resizeObserver?.observe(node);
|
|
131
|
-
const strip = node.closest<HTMLElement>('[role="tablist"]');
|
|
132
|
-
if (strip) resizeObserver?.observe(strip);
|
|
133
|
-
|
|
134
|
-
return () => {
|
|
135
|
-
observer.disconnect();
|
|
136
|
-
resizeObserver?.disconnect();
|
|
137
|
-
};
|
|
138
|
-
}, [reducedMotion]);
|
|
247
|
+
const variant = use(TabsVariantContext);
|
|
139
248
|
|
|
140
249
|
return (
|
|
141
250
|
<TabsPrimitive.Trigger
|
|
142
|
-
ref={
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// `ring-offset-1`, not `-2`: the compound indicator's reach is
|
|
146
|
-
// `ring-offset` + 2px ring + 1px contour, and `TabsList` is an
|
|
147
|
-
// `overflow-x-auto` strip with `p-1` (4px). At offset 2 the reach is 5px
|
|
148
|
-
// and the scroll box clips the contour on three sides (#67 fix round 2,
|
|
149
|
-
// measured on `patterns-templates-object-detail-hub--default` tab stop
|
|
150
|
-
// 9); at offset 1 it is exactly 4px and the loop closes.
|
|
151
|
-
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
152
|
-
"disabled:pointer-events-none disabled:opacity-50",
|
|
153
|
-
"data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
154
|
-
className,
|
|
155
|
-
)}
|
|
251
|
+
ref={ref}
|
|
252
|
+
data-slot="tabs-trigger"
|
|
253
|
+
className={cn(tabsTriggerVariants({ variant }), className)}
|
|
156
254
|
{...props}
|
|
157
255
|
/>
|
|
158
256
|
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
// a2ui.exposed: yes
|
|
2
4
|
import {
|
|
3
5
|
forwardRef,
|
|
@@ -229,7 +231,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
|
|
|
229
231
|
aria-invalid={error ? "true" : undefined}
|
|
230
232
|
data-slot="input-group-control"
|
|
231
233
|
className={cn(
|
|
232
|
-
"min-w-[6rem] flex-1 bg-transparent text-
|
|
234
|
+
"min-w-[6rem] flex-1 bg-transparent text-body outline-none",
|
|
233
235
|
"placeholder:text-muted-foreground",
|
|
234
236
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
235
237
|
)}
|
|
@@ -241,7 +243,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
|
|
|
241
243
|
</InputGroup>
|
|
242
244
|
{error && (
|
|
243
245
|
// #124: running text (the validation message) — ink rung.
|
|
244
|
-
<p role="alert" className="text-
|
|
246
|
+
<p role="alert" className="text-meta text-destructive-text">
|
|
245
247
|
{error}
|
|
246
248
|
</p>
|
|
247
249
|
)}
|
|
@@ -82,9 +82,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
|
|
|
82
82
|
>
|
|
83
83
|
<Logo className="size-4" />
|
|
84
84
|
</div>
|
|
85
|
-
<div className="grid flex-1 text-start text-
|
|
85
|
+
<div className="grid flex-1 text-start text-body leading-tight group-data-[collapsible=icon]:hidden">
|
|
86
86
|
<span className="truncate font-semibold">{activeTeam.name}</span>
|
|
87
|
-
<span className="truncate text-
|
|
87
|
+
<span className="truncate text-meta">{activeTeam.plan}</span>
|
|
88
88
|
</div>
|
|
89
89
|
{/* Decorative, so it simply goes. */}
|
|
90
90
|
<ChevronsUpDown className="ms-auto group-data-[collapsible=icon]:hidden" />
|
|
@@ -96,7 +96,7 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
|
|
|
96
96
|
side={isMobile ? "bottom" : "right"}
|
|
97
97
|
sideOffset={4}
|
|
98
98
|
>
|
|
99
|
-
<DropdownMenuLabel className="text-
|
|
99
|
+
<DropdownMenuLabel className="text-meta text-muted-foreground">
|
|
100
100
|
{t("ui.teamSwitcher.label")}
|
|
101
101
|
</DropdownMenuLabel>
|
|
102
102
|
{teams.map((team, index) => (
|
|
@@ -11,7 +11,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
|
|
11
11
|
<textarea
|
|
12
12
|
ref={ref}
|
|
13
13
|
className={cn(
|
|
14
|
-
"flex min-h-20 w-full rounded-
|
|
14
|
+
"flex min-h-20 w-full rounded-control border border-input bg-input-background px-3 py-2 text-body shadow-input transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
|
|
15
15
|
"placeholder:text-muted-foreground",
|
|
16
16
|
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
17
17
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
|
|
@@ -144,3 +144,79 @@ export const RestrictedProvider: Story = {
|
|
|
144
144
|
await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
|
|
145
145
|
},
|
|
146
146
|
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Theme families (ADR 0036). Once two or more families declare `family`, the
|
|
150
|
+
* switcher offers a **Theme** group and — for a family with both schemes — a
|
|
151
|
+
* separate **Mode** group (Light / Dark / System). `ocean-*` are the
|
|
152
|
+
* downloadable family in repo-root `themes/ocean/`, whose CSS this Storybook
|
|
153
|
+
* imports.
|
|
154
|
+
*/
|
|
155
|
+
const oceanThemes = [
|
|
156
|
+
defineTheme({
|
|
157
|
+
value: "ocean-light",
|
|
158
|
+
label: "Ocean Light",
|
|
159
|
+
dark: false,
|
|
160
|
+
family: "ocean",
|
|
161
|
+
familyLabel: "Ocean",
|
|
162
|
+
}),
|
|
163
|
+
defineTheme({ value: "ocean-dark", label: "Ocean Dark", dark: true, family: "ocean" }),
|
|
164
|
+
];
|
|
165
|
+
const FAMILY_REGISTRY = [...BUILT_IN_THEME_DEFINITIONS, ...oceanThemes];
|
|
166
|
+
|
|
167
|
+
export const WithThemeFamilies: Story = {
|
|
168
|
+
decorators: [
|
|
169
|
+
(Story) => (
|
|
170
|
+
<ThemeProvider themes={FAMILY_REGISTRY} defaultTheme="light" storageKey={null}>
|
|
171
|
+
<div className="flex min-h-32 items-center justify-center">
|
|
172
|
+
<Story />
|
|
173
|
+
</div>
|
|
174
|
+
</ThemeProvider>
|
|
175
|
+
),
|
|
176
|
+
],
|
|
177
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
178
|
+
const root = canvasElement.ownerDocument.documentElement;
|
|
179
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
180
|
+
await userEvent.click(canvas.getByRole("button", { name: "Theme" }));
|
|
181
|
+
await userEvent.click(await body.findByRole("menuitemradio", { name: "Ocean" }));
|
|
182
|
+
await waitFor(() => expect(root).toHaveAttribute("data-theme", "ocean-light"));
|
|
183
|
+
// The closing menu keeps the canvas aria-hidden until it unmounts; reopen only after.
|
|
184
|
+
await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
|
|
185
|
+
await userEvent.click(await canvas.findByRole("button", { name: "Theme" }));
|
|
186
|
+
await userEvent.click(await body.findByRole("menuitemradio", { name: "Dark" }));
|
|
187
|
+
await waitFor(() => expect(root).toHaveAttribute("data-theme", "ocean-dark"));
|
|
188
|
+
await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A family that ships only one scheme shows no Mode group. `Dusk` borrows the
|
|
194
|
+
* reference `dark` block as its only variant.
|
|
195
|
+
*/
|
|
196
|
+
const duskTheme = defineTheme({
|
|
197
|
+
value: "dark",
|
|
198
|
+
label: "Dusk",
|
|
199
|
+
dark: true,
|
|
200
|
+
family: "dusk",
|
|
201
|
+
familyLabel: "Dusk (dark only)",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export const SingleModeFamily: Story = {
|
|
205
|
+
decorators: [
|
|
206
|
+
(Story) => (
|
|
207
|
+
<ThemeProvider themes={[...oceanThemes, duskTheme]} defaultTheme="dark" storageKey={null}>
|
|
208
|
+
<div className="flex min-h-32 items-center justify-center">
|
|
209
|
+
<Story />
|
|
210
|
+
</div>
|
|
211
|
+
</ThemeProvider>
|
|
212
|
+
),
|
|
213
|
+
],
|
|
214
|
+
play: async ({ canvas, canvasElement, userEvent }) => {
|
|
215
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
216
|
+
await userEvent.click(canvas.getByRole("button", { name: "Theme" }));
|
|
217
|
+
await expect(await body.findByRole("menuitemradio", { name: /Dusk/ })).toBeChecked();
|
|
218
|
+
await expect(body.queryByRole("menuitemradio", { name: "Light" })).not.toBeInTheDocument();
|
|
219
|
+
await userEvent.keyboard("{Escape}");
|
|
220
|
+
await waitFor(() => expect(body.queryByRole("menu")).not.toBeInTheDocument());
|
|
221
|
+
},
|
|
222
|
+
};
|