@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
|
@@ -34,12 +34,12 @@ export function SectionHeader({
|
|
|
34
34
|
<div className={cn("flex flex-wrap items-end justify-between gap-4", className)}>
|
|
35
35
|
<div className="space-y-1">
|
|
36
36
|
{eyebrow ? (
|
|
37
|
-
<div className="text-
|
|
37
|
+
<div className="text-meta font-medium uppercase tracking-wide text-muted-foreground">
|
|
38
38
|
{eyebrow}
|
|
39
39
|
</div>
|
|
40
40
|
) : null}
|
|
41
41
|
<TitleTag className="text-title text-foreground">{title}</TitleTag>
|
|
42
|
-
{description ? <p className="text-
|
|
42
|
+
{description ? <p className="text-body text-muted-foreground">{description}</p> : null}
|
|
43
43
|
</div>
|
|
44
44
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
|
45
45
|
</div>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
forwardRef,
|
|
3
5
|
useEffect,
|
|
@@ -121,8 +123,8 @@ export const SelectTrigger = forwardRef<
|
|
|
121
123
|
data-size={size}
|
|
122
124
|
title={title}
|
|
123
125
|
className={cn(
|
|
124
|
-
"flex w-full items-center justify-between gap-2 rounded-
|
|
125
|
-
size === "sm" ? "h-
|
|
126
|
+
"flex w-full items-center justify-between gap-2 rounded-control border border-input bg-input-background px-3 py-2 text-body shadow-input",
|
|
127
|
+
size === "sm" ? "h-control-sm" : "h-control",
|
|
126
128
|
"placeholder:text-muted-foreground focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
127
129
|
// No `disabled:cursor-*` override — disabled controls keep the arrow
|
|
128
130
|
// automatically (interaction-guidelines.md), matching Combobox's
|
|
@@ -154,7 +156,7 @@ export const SelectContent = forwardRef<
|
|
|
154
156
|
ref={ref}
|
|
155
157
|
position={position}
|
|
156
158
|
className={cn(
|
|
157
|
-
"relative z-50 max-h-96 min-w-[
|
|
159
|
+
"relative z-50 max-h-96 min-w-[12rem] overflow-hidden rounded-md bg-popover text-popover-foreground shadow-ring-md",
|
|
158
160
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
|
159
161
|
"data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
|
|
160
162
|
position === "popper" &&
|
|
@@ -190,7 +192,7 @@ export const SelectLabel = forwardRef<
|
|
|
190
192
|
return (
|
|
191
193
|
<SelectPrimitive.Label
|
|
192
194
|
ref={ref}
|
|
193
|
-
className={cn("px-2 py-1.5 text-
|
|
195
|
+
className={cn("px-2 py-1.5 text-meta font-semibold text-muted-foreground", className)}
|
|
194
196
|
{...props}
|
|
195
197
|
/>
|
|
196
198
|
);
|
|
@@ -204,7 +206,7 @@ export const SelectItem = forwardRef<
|
|
|
204
206
|
<SelectPrimitive.Item
|
|
205
207
|
ref={ref}
|
|
206
208
|
className={cn(
|
|
207
|
-
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-2 pe-8 text-
|
|
209
|
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-2 pe-8 text-body outline-none transition-colors duration-fast",
|
|
208
210
|
"focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
209
211
|
className,
|
|
210
212
|
)}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet";
|
|
4
|
+
|
|
5
|
+
describe("SheetContent overflow", () => {
|
|
6
|
+
it("scrolls tall content instead of overflowing the fixed panel", () => {
|
|
7
|
+
render(
|
|
8
|
+
<Sheet open>
|
|
9
|
+
<SheetContent>
|
|
10
|
+
<SheetHeader>
|
|
11
|
+
<SheetTitle>Title</SheetTitle>
|
|
12
|
+
<SheetDescription>Description</SheetDescription>
|
|
13
|
+
</SheetHeader>
|
|
14
|
+
Body
|
|
15
|
+
</SheetContent>
|
|
16
|
+
</Sheet>,
|
|
17
|
+
);
|
|
18
|
+
const content = screen.getByRole("dialog");
|
|
19
|
+
// Without `overflow-y-auto`, content taller than the panel overflows the
|
|
20
|
+
// fixed-position box and its bottom half is unreachable.
|
|
21
|
+
expect(content.className).toContain("overflow-y-auto");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("caps top/bottom sheets at a viewport-relative height so the scroll rule engages", () => {
|
|
25
|
+
render(
|
|
26
|
+
<Sheet open>
|
|
27
|
+
<SheetContent side="bottom">
|
|
28
|
+
<SheetHeader>
|
|
29
|
+
<SheetTitle>Title</SheetTitle>
|
|
30
|
+
<SheetDescription>Description</SheetDescription>
|
|
31
|
+
</SheetHeader>
|
|
32
|
+
Body
|
|
33
|
+
</SheetContent>
|
|
34
|
+
</Sheet>,
|
|
35
|
+
);
|
|
36
|
+
const content = screen.getByRole("dialog");
|
|
37
|
+
expect(content.className).toContain("max-h-[90dvh]");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
forwardRef,
|
|
3
5
|
type ComponentPropsWithoutRef,
|
|
@@ -8,6 +10,7 @@ import * as SheetPrimitive from "@radix-ui/react-dialog";
|
|
|
8
10
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
9
11
|
import { X } from "lucide-react";
|
|
10
12
|
import { cn } from "../../lib/cn";
|
|
13
|
+
import { useLocale } from "../locale-provider";
|
|
11
14
|
|
|
12
15
|
export const Sheet = SheetPrimitive.Root;
|
|
13
16
|
export const SheetTrigger = SheetPrimitive.Trigger;
|
|
@@ -21,8 +24,9 @@ const SheetOverlay = forwardRef<
|
|
|
21
24
|
return (
|
|
22
25
|
<SheetPrimitive.Overlay
|
|
23
26
|
ref={ref}
|
|
27
|
+
data-slot="sheet-overlay"
|
|
24
28
|
className={cn(
|
|
25
|
-
"fixed inset-0 z-50 bg-
|
|
29
|
+
"fixed inset-0 z-50 bg-overlay backdrop-blur-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
26
30
|
className,
|
|
27
31
|
)}
|
|
28
32
|
{...props}
|
|
@@ -31,13 +35,23 @@ const SheetOverlay = forwardRef<
|
|
|
31
35
|
});
|
|
32
36
|
|
|
33
37
|
const sheetVariants = cva(
|
|
34
|
-
|
|
38
|
+
// `overflow-y-auto` so content taller than the sheet scrolls INSIDE it
|
|
39
|
+
// instead of overflowing the fixed-position box (unreachable below the
|
|
40
|
+
// viewport edge).
|
|
41
|
+
"fixed z-50 gap-4 overflow-y-auto overscroll-contain bg-card p-6 text-card-foreground shadow-ring-lg transition duration-slow ease-standard data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:[--tw-ease:var(--ease-entrance)] data-[state=closed]:[--tw-ease:var(--ease-exit)]",
|
|
35
42
|
{
|
|
36
43
|
variants: {
|
|
37
44
|
side: {
|
|
38
|
-
top
|
|
45
|
+
// `top`/`bottom` have no inherent height ceiling, so long content grows
|
|
46
|
+
// past the viewport; cap it at 90dvh (matching Dialog's dvh unit — ADR
|
|
47
|
+
// on mobile browser chrome) so the scroll rule above actually engages.
|
|
48
|
+
top: "inset-x-0 top-0 max-h-[90dvh] border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
39
49
|
bottom:
|
|
40
|
-
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
50
|
+
"inset-x-0 bottom-0 max-h-[90dvh] border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
51
|
+
// `side` is an explicit physical edge of the screen (like Radix's own
|
|
52
|
+
// Popper `side`), not a logical start/end — a caller who picks `"left"`
|
|
53
|
+
// wants the panel on the physical left even in an RTL layout, so these
|
|
54
|
+
// stay physical `left-0`/`right-0` and `border-r`/`border-l` on purpose.
|
|
41
55
|
left: "inset-y-0 left-0 h-full w-3/4 max-w-sm border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
|
42
56
|
right:
|
|
43
57
|
"inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
|
@@ -56,18 +70,24 @@ export const SheetContent = forwardRef<
|
|
|
56
70
|
ElementRef<typeof SheetPrimitive.Content>,
|
|
57
71
|
SheetContentProps
|
|
58
72
|
>(function SheetContent({ side = "right", className, children, ...props }, ref) {
|
|
73
|
+
const { t } = useLocale();
|
|
59
74
|
return (
|
|
60
75
|
<SheetPortal>
|
|
61
76
|
<SheetOverlay />
|
|
62
77
|
<SheetPrimitive.Content
|
|
63
78
|
ref={ref}
|
|
79
|
+
data-slot="sheet-content"
|
|
64
80
|
className={cn(sheetVariants({ side }), className)}
|
|
65
81
|
{...props}
|
|
66
82
|
>
|
|
67
83
|
{children}
|
|
84
|
+
{/* Matches Dialog's close button exactly (radius, padding, ink,
|
|
85
|
+
opacity choreography) — a 24px target (`p-1` + the 16px glyph)
|
|
86
|
+
instead of the previous bare 16px hit area (#286). */}
|
|
68
87
|
<SheetPrimitive.Close
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
data-slot="sheet-close"
|
|
89
|
+
className="absolute end-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
|
|
90
|
+
aria-label={t("close")}
|
|
71
91
|
>
|
|
72
92
|
<X className="size-4" />
|
|
73
93
|
</SheetPrimitive.Close>
|
|
@@ -77,11 +97,14 @@ export const SheetContent = forwardRef<
|
|
|
77
97
|
});
|
|
78
98
|
|
|
79
99
|
export function SheetHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
80
|
-
return
|
|
100
|
+
return (
|
|
101
|
+
<div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />
|
|
102
|
+
);
|
|
81
103
|
}
|
|
82
104
|
export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
83
105
|
return (
|
|
84
106
|
<div
|
|
107
|
+
data-slot="sheet-footer"
|
|
85
108
|
className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
|
86
109
|
{...props}
|
|
87
110
|
/>
|
|
@@ -92,7 +115,14 @@ export const SheetTitle = forwardRef<
|
|
|
92
115
|
ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
|
93
116
|
>(function SheetTitle({ className, ...props }, ref) {
|
|
94
117
|
return (
|
|
95
|
-
|
|
118
|
+
// `text-title`, matching `DialogTitle` (#286) — was `text-lg font-semibold`,
|
|
119
|
+
// one rung below Dialog's for the same anatomical role (a modal title).
|
|
120
|
+
<SheetPrimitive.Title
|
|
121
|
+
ref={ref}
|
|
122
|
+
data-slot="sheet-title"
|
|
123
|
+
className={cn("text-title leading-none", className)}
|
|
124
|
+
{...props}
|
|
125
|
+
/>
|
|
96
126
|
);
|
|
97
127
|
});
|
|
98
128
|
export const SheetDescription = forwardRef<
|
|
@@ -102,7 +132,8 @@ export const SheetDescription = forwardRef<
|
|
|
102
132
|
return (
|
|
103
133
|
<SheetPrimitive.Description
|
|
104
134
|
ref={ref}
|
|
105
|
-
|
|
135
|
+
data-slot="sheet-description"
|
|
136
|
+
className={cn("text-body text-muted-foreground", className)}
|
|
106
137
|
{...props}
|
|
107
138
|
/>
|
|
108
139
|
);
|
|
@@ -270,4 +270,30 @@ describe("SideDock", () => {
|
|
|
270
270
|
expect(document.activeElement).not.toBe(document.body);
|
|
271
271
|
expect(document.activeElement).toBe(trigger);
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
it("coalesces a burst of resize events into one rAF-scheduled update", () => {
|
|
275
|
+
const rafCallbacks: FrameRequestCallback[] = [];
|
|
276
|
+
const rafSpy = vi
|
|
277
|
+
.spyOn(window, "requestAnimationFrame")
|
|
278
|
+
.mockImplementation((cb: FrameRequestCallback) => {
|
|
279
|
+
rafCallbacks.push(cb);
|
|
280
|
+
return rafCallbacks.length;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
render(
|
|
284
|
+
<SideDock title="Assistant" open overlayBreakpoint={COLUMN_BREAKPOINT}>
|
|
285
|
+
Body
|
|
286
|
+
</SideDock>,
|
|
287
|
+
);
|
|
288
|
+
rafCallbacks.length = 0; // clear anything scheduled at mount
|
|
289
|
+
|
|
290
|
+
// A window drag fires `resize` many times per frame — every one of these
|
|
291
|
+
// must coalesce into a SINGLE scheduled frame, not one per event.
|
|
292
|
+
fireEvent(window, new Event("resize"));
|
|
293
|
+
fireEvent(window, new Event("resize"));
|
|
294
|
+
fireEvent(window, new Event("resize"));
|
|
295
|
+
expect(rafCallbacks).toHaveLength(1);
|
|
296
|
+
|
|
297
|
+
rafSpy.mockRestore();
|
|
298
|
+
});
|
|
273
299
|
});
|
|
@@ -216,9 +216,22 @@ export const SideDock = forwardRef<HTMLElement, SideDockProps>(function SideDock
|
|
|
216
216
|
typeof window !== "undefined" ? window.innerWidth : FALLBACK_VIEWPORT_WIDTH,
|
|
217
217
|
);
|
|
218
218
|
useEffect(() => {
|
|
219
|
-
|
|
219
|
+
// `resize` can fire dozens of times per second while a window is being
|
|
220
|
+
// dragged — coalesce every event inside one frame into a single state
|
|
221
|
+
// update via rAF, instead of re-rendering on each raw event.
|
|
222
|
+
let rafId: number | null = null;
|
|
223
|
+
const onResize = () => {
|
|
224
|
+
if (rafId !== null) return;
|
|
225
|
+
rafId = requestAnimationFrame(() => {
|
|
226
|
+
rafId = null;
|
|
227
|
+
setViewportWidth(window.innerWidth);
|
|
228
|
+
});
|
|
229
|
+
};
|
|
220
230
|
window.addEventListener("resize", onResize);
|
|
221
|
-
return () =>
|
|
231
|
+
return () => {
|
|
232
|
+
window.removeEventListener("resize", onResize);
|
|
233
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
234
|
+
};
|
|
222
235
|
}, []);
|
|
223
236
|
|
|
224
237
|
const isWidthControlled = width !== undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
Sidebar,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
SidebarMenuButton,
|
|
10
11
|
SidebarMenuItem,
|
|
11
12
|
SidebarProvider,
|
|
13
|
+
SidebarTrigger,
|
|
12
14
|
} from "./sidebar";
|
|
13
15
|
|
|
14
16
|
function renderFrame() {
|
|
@@ -75,4 +77,36 @@ describe("SidebarProvider", () => {
|
|
|
75
77
|
expect(active.className).toContain("data-[active=true]:font-semibold");
|
|
76
78
|
expect(resting).toHaveAttribute("data-active", "false");
|
|
77
79
|
});
|
|
80
|
+
|
|
81
|
+
it("does not re-bind the global keydown listener on every toggle", async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
84
|
+
const removeSpy = vi.spyOn(window, "removeEventListener");
|
|
85
|
+
render(
|
|
86
|
+
<SidebarProvider>
|
|
87
|
+
<Sidebar>chrome</Sidebar>
|
|
88
|
+
<SidebarInset>
|
|
89
|
+
<SidebarTrigger />
|
|
90
|
+
</SidebarInset>
|
|
91
|
+
</SidebarProvider>,
|
|
92
|
+
);
|
|
93
|
+
const keydownAddCallsAtMount = addSpy.mock.calls.filter((c) => c[0] === "keydown").length;
|
|
94
|
+
expect(keydownAddCallsAtMount).toBe(1);
|
|
95
|
+
|
|
96
|
+
// Each click flips `open`, which recreates `toggleSidebar` (it closes
|
|
97
|
+
// over `setOpen`, which closes over `open`) — a `useEffect` depending on
|
|
98
|
+
// that callback directly would tear down and re-add the `keydown`
|
|
99
|
+
// listener on every single toggle.
|
|
100
|
+
await user.click(screen.getByRole("button"));
|
|
101
|
+
await user.click(screen.getByRole("button"));
|
|
102
|
+
await user.click(screen.getByRole("button"));
|
|
103
|
+
|
|
104
|
+
const keydownAddCallsAfterToggles = addSpy.mock.calls.filter((c) => c[0] === "keydown").length;
|
|
105
|
+
const keydownRemoveCalls = removeSpy.mock.calls.filter((c) => c[0] === "keydown").length;
|
|
106
|
+
expect(keydownAddCallsAfterToggles).toBe(1);
|
|
107
|
+
expect(keydownRemoveCalls).toBe(0);
|
|
108
|
+
|
|
109
|
+
addSpy.mockRestore();
|
|
110
|
+
removeSpy.mockRestore();
|
|
111
|
+
});
|
|
78
112
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useContext,
|
|
8
8
|
useEffect,
|
|
9
9
|
useMemo,
|
|
10
|
+
useRef,
|
|
10
11
|
useState,
|
|
11
12
|
type ComponentProps,
|
|
12
13
|
type CSSProperties,
|
|
@@ -118,6 +119,17 @@ export const SidebarProvider = forwardRef<
|
|
|
118
119
|
return isMobile ? setOpenMobile((o) => !o) : setOpen((o) => !o);
|
|
119
120
|
}, [isMobile, setOpen]);
|
|
120
121
|
|
|
122
|
+
// `toggleSidebar` is a NEW function on every `open` change (it closes over
|
|
123
|
+
// `setOpen`, which itself closes over `open`) — a `useEffect` depending on
|
|
124
|
+
// it directly would tear down and re-add the global `keydown` listener on
|
|
125
|
+
// every single toggle. A ref holding the latest callback keeps the listener
|
|
126
|
+
// registration stable across toggles; only `isNested` (which never changes
|
|
127
|
+
// after mount) re-runs the effect.
|
|
128
|
+
const toggleSidebarRef = useRef(toggleSidebar);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
toggleSidebarRef.current = toggleSidebar;
|
|
131
|
+
}, [toggleSidebar]);
|
|
132
|
+
|
|
121
133
|
useEffect(() => {
|
|
122
134
|
// Same reasoning as the cookie write above: the global keyboard shortcut
|
|
123
135
|
// belongs to the ONE app frame, not to every nested provider a compound
|
|
@@ -126,12 +138,12 @@ export const SidebarProvider = forwardRef<
|
|
|
126
138
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
127
139
|
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
|
128
140
|
event.preventDefault();
|
|
129
|
-
|
|
141
|
+
toggleSidebarRef.current();
|
|
130
142
|
}
|
|
131
143
|
};
|
|
132
144
|
window.addEventListener("keydown", handleKeyDown);
|
|
133
145
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
134
|
-
}, [isNested
|
|
146
|
+
}, [isNested]);
|
|
135
147
|
|
|
136
148
|
const state = open ? "expanded" : "collapsed";
|
|
137
149
|
|
|
@@ -273,7 +285,7 @@ export const Sidebar = forwardRef<
|
|
|
273
285
|
>
|
|
274
286
|
<SheetHeader className="sr-only">
|
|
275
287
|
<SheetTitle>{t("ui.sidebar.title")}</SheetTitle>
|
|
276
|
-
<SheetDescription>
|
|
288
|
+
<SheetDescription>{t("ui.sidebar.mobileDescription")}</SheetDescription>
|
|
277
289
|
</SheetHeader>
|
|
278
290
|
<div className="flex h-full w-full flex-col">{children}</div>
|
|
279
291
|
</SheetContent>
|
|
@@ -335,6 +347,7 @@ export const Sidebar = forwardRef<
|
|
|
335
347
|
|
|
336
348
|
export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeof Button>>(
|
|
337
349
|
function SidebarTrigger({ className, onClick, ...props }, ref) {
|
|
350
|
+
const { t } = useLocale();
|
|
338
351
|
const { isMobile, open, openMobile, toggleSidebar } = useSidebar();
|
|
339
352
|
// `toggleSidebar` flips `openMobile` below the mobile breakpoint and `open`
|
|
340
353
|
// above it, so the state this button EXPOSES has to be read the same way —
|
|
@@ -368,7 +381,7 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
|
|
|
368
381
|
{...props}
|
|
369
382
|
>
|
|
370
383
|
<PanelLeftIcon />
|
|
371
|
-
<span className="sr-only">
|
|
384
|
+
<span className="sr-only">{t("ui.sidebar.toggle")}</span>
|
|
372
385
|
</Button>
|
|
373
386
|
);
|
|
374
387
|
},
|
|
@@ -376,16 +389,17 @@ export const SidebarTrigger = forwardRef<HTMLButtonElement, ComponentProps<typeo
|
|
|
376
389
|
|
|
377
390
|
export const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<"button">>(
|
|
378
391
|
function SidebarRail({ className, ...props }, ref) {
|
|
392
|
+
const { t } = useLocale();
|
|
379
393
|
const { toggleSidebar } = useSidebar();
|
|
380
394
|
return (
|
|
381
395
|
<button
|
|
382
396
|
ref={ref}
|
|
383
397
|
data-sidebar="rail"
|
|
384
398
|
data-slot="sidebar-rail"
|
|
385
|
-
aria-label="
|
|
399
|
+
aria-label={t("ui.sidebar.toggle")}
|
|
386
400
|
tabIndex={-1}
|
|
387
401
|
onClick={toggleSidebar}
|
|
388
|
-
title="
|
|
402
|
+
title={t("ui.sidebar.toggle")}
|
|
389
403
|
className={cn(
|
|
390
404
|
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
|
|
391
405
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
|
@@ -7,6 +7,7 @@ export type SkeletonProps = HTMLAttributes<HTMLDivElement>;
|
|
|
7
7
|
export function Skeleton({ className, ...props }: SkeletonProps) {
|
|
8
8
|
return (
|
|
9
9
|
<div
|
|
10
|
+
data-slot="skeleton"
|
|
10
11
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
11
12
|
aria-hidden="true"
|
|
12
13
|
{...props}
|
|
@@ -109,7 +109,10 @@ export const DragThenReset: Story = {
|
|
|
109
109
|
thumb.focus();
|
|
110
110
|
await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}");
|
|
111
111
|
const numberInput = canvas.getByRole("spinbutton", { name: "Temperature" });
|
|
112
|
-
|
|
112
|
+
// Text spinbutton (locale-formatted): string display + numeric aria-valuenow.
|
|
113
|
+
await expect(numberInput).toHaveValue("0.3");
|
|
114
|
+
await expect(numberInput).toHaveAttribute("aria-valuenow", "0.3");
|
|
115
|
+
await expect(thumb).toHaveAttribute("aria-valuenow", "0.3");
|
|
113
116
|
|
|
114
117
|
const resetBtn = canvas.getByRole("button", { name: "Reset" });
|
|
115
118
|
await expect(resetBtn).not.toBeDisabled();
|
|
@@ -10,7 +10,7 @@ describe("SliderNumber", () => {
|
|
|
10
10
|
"aria-valuenow",
|
|
11
11
|
"0.5",
|
|
12
12
|
);
|
|
13
|
-
expect(screen.getByRole("spinbutton", { name: "Temperature" })).toHaveValue(0.5);
|
|
13
|
+
expect(screen.getByRole("spinbutton", { name: "Temperature" })).toHaveValue("0.5");
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it("rounds slider-driven and typed values identically (lockstep)", async () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { Spinner } from "./spinner";
|
|
5
|
+
|
|
6
|
+
describe("Spinner", () => {
|
|
7
|
+
it("renders with its default accessible label", () => {
|
|
8
|
+
render(<Spinner />);
|
|
9
|
+
expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("forwards its ref to the rendered svg", () => {
|
|
13
|
+
const ref = createRef<SVGSVGElement>();
|
|
14
|
+
render(<Spinner ref={ref} />);
|
|
15
|
+
expect(ref.current).toBeInstanceOf(SVGSVGElement);
|
|
16
|
+
expect(ref.current).toBe(screen.getByRole("status", { name: "Loading" }));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -1,19 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from "react";
|
|
1
4
|
import { Loader2 } from "lucide-react";
|
|
2
5
|
import { cn } from "../../lib/cn";
|
|
6
|
+
import { useLocale } from "../locale-provider";
|
|
3
7
|
|
|
4
8
|
export interface SpinnerProps extends React.SVGProps<SVGSVGElement> {
|
|
5
|
-
/** Accessible label; defaults to "Loading". */
|
|
9
|
+
/** Accessible label; defaults to the localized "Loading…" microcopy. */
|
|
6
10
|
label?: string;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
/** Indeterminate spinner with an accessible label. */
|
|
10
|
-
export
|
|
14
|
+
export const Spinner = forwardRef<SVGSVGElement, SpinnerProps>(function Spinner(
|
|
15
|
+
{ className, label, ...props },
|
|
16
|
+
ref,
|
|
17
|
+
) {
|
|
18
|
+
const { t } = useLocale();
|
|
11
19
|
return (
|
|
12
20
|
<Loader2
|
|
21
|
+
ref={ref}
|
|
22
|
+
data-slot="spinner"
|
|
13
23
|
role="status"
|
|
14
|
-
aria-label={label}
|
|
24
|
+
aria-label={label ?? t("ui.spinner.label")}
|
|
15
25
|
className={cn("size-4 animate-spin text-muted-foreground", className)}
|
|
16
26
|
{...props}
|
|
17
27
|
/>
|
|
18
28
|
);
|
|
19
|
-
}
|
|
29
|
+
});
|
|
@@ -122,7 +122,7 @@ describe("StatePanel", () => {
|
|
|
122
122
|
expect(wrapper?.className ?? "").not.toContain("size-10");
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
-
it("illustration path renders the title at the subtitle role, distinct from the icon path's text-
|
|
125
|
+
it("illustration path renders the title at the subtitle role, distinct from the icon path's text-body (#47)", () => {
|
|
126
126
|
// A 112px+ illustration needs a heavier title to group with — bumping the
|
|
127
127
|
// title to `text-subtitle` (styling-and-tokens.md's typography-scale
|
|
128
128
|
// roles) only on the illustration path, so the plain `icon` path (a
|
|
@@ -132,13 +132,13 @@ describe("StatePanel", () => {
|
|
|
132
132
|
);
|
|
133
133
|
const titleWithIllustration = withIllustration.querySelector("h3");
|
|
134
134
|
expect(titleWithIllustration?.className ?? "").toContain("text-subtitle");
|
|
135
|
-
expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-
|
|
135
|
+
expect(titleWithIllustration?.className ?? "").not.toMatch(/\btext-body\b/);
|
|
136
136
|
|
|
137
137
|
const { container: withIcon } = render(
|
|
138
138
|
<StatePanel kind="empty" title="No items" icon={<span data-testid="i" />} />,
|
|
139
139
|
);
|
|
140
140
|
const titleWithIcon = withIcon.querySelector("h3");
|
|
141
|
-
expect(titleWithIcon?.className ?? "").toMatch(/\btext-
|
|
141
|
+
expect(titleWithIcon?.className ?? "").toMatch(/\btext-body\b/);
|
|
142
142
|
expect(titleWithIcon?.className ?? "").not.toContain("text-subtitle");
|
|
143
143
|
});
|
|
144
144
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import { type CSSProperties, type ReactNode } from "react";
|
|
2
4
|
import { CircleAlert } from "lucide-react";
|
|
3
5
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
6
|
import { cn } from "../../lib/cn";
|
|
5
7
|
import { ILLUSTRATION_ACCENT_VAR } from "../../illustrations/illustration-base";
|
|
8
|
+
import { useLocale } from "../locale-provider";
|
|
6
9
|
|
|
7
10
|
// ─── Variants ────────────────────────────────────────────────────────────────
|
|
8
11
|
|
|
@@ -123,13 +126,15 @@ export function StatePanel({
|
|
|
123
126
|
icon,
|
|
124
127
|
illustration,
|
|
125
128
|
titleAs: Tag = "h3",
|
|
126
|
-
loadingLabel
|
|
129
|
+
loadingLabel,
|
|
127
130
|
size = "md",
|
|
128
131
|
actions,
|
|
129
132
|
className,
|
|
130
133
|
}: StatePanelProps) {
|
|
134
|
+
const { t } = useLocale();
|
|
131
135
|
const isLoading = kind === "loading";
|
|
132
136
|
const isError = kind === "error";
|
|
137
|
+
const resolvedLoadingLabel = loadingLabel ?? t("loading");
|
|
133
138
|
|
|
134
139
|
// Role: loading → status + live region; error → alert; empty → (none needed).
|
|
135
140
|
const roleProps = isLoading
|
|
@@ -139,9 +144,9 @@ export function StatePanel({
|
|
|
139
144
|
: {};
|
|
140
145
|
|
|
141
146
|
// Default title / description fallbacks for error state (mirroring old ErrorState).
|
|
142
|
-
const resolvedTitle = title ?? (isError ? "
|
|
147
|
+
const resolvedTitle = title ?? (isError ? t("ui.statePanel.errorTitle") : undefined);
|
|
143
148
|
const resolvedDescription =
|
|
144
|
-
description ?? (isError ? "
|
|
149
|
+
description ?? (isError ? t("ui.statePanel.errorDescription") : undefined);
|
|
145
150
|
|
|
146
151
|
// Resolve displayed icon.
|
|
147
152
|
let resolvedIcon: ReactNode;
|
|
@@ -167,7 +172,7 @@ export function StatePanel({
|
|
|
167
172
|
correct there — do not "tidy" it onto -text too. */}
|
|
168
173
|
{isError && (
|
|
169
174
|
<span className="text-meta font-semibold uppercase tracking-widest text-destructive-text">
|
|
170
|
-
|
|
175
|
+
{t("ui.statePanel.errorEyebrow")}
|
|
171
176
|
</span>
|
|
172
177
|
)}
|
|
173
178
|
|
|
@@ -223,21 +228,23 @@ export function StatePanel({
|
|
|
223
228
|
<Tag
|
|
224
229
|
className={cn(
|
|
225
230
|
"font-semibold text-foreground",
|
|
226
|
-
illustration ? "text-subtitle" : "text-
|
|
231
|
+
illustration ? "text-subtitle" : "text-body",
|
|
227
232
|
)}
|
|
228
233
|
>
|
|
229
234
|
{resolvedTitle}
|
|
230
235
|
</Tag>
|
|
231
236
|
)}
|
|
232
237
|
{resolvedDescription && (
|
|
233
|
-
<p className="mx-auto max-w-sm text-
|
|
238
|
+
<p className="mx-auto max-w-sm text-body text-muted-foreground">
|
|
239
|
+
{resolvedDescription}
|
|
240
|
+
</p>
|
|
234
241
|
)}
|
|
235
242
|
</div>
|
|
236
243
|
)}
|
|
237
244
|
|
|
238
245
|
{/* Loading label (visually shown as supporting text; also the live-region content). */}
|
|
239
|
-
{isLoading &&
|
|
240
|
-
<span className="text-
|
|
246
|
+
{isLoading && resolvedLoadingLabel && (
|
|
247
|
+
<span className="text-body text-muted-foreground">{resolvedLoadingLabel}</span>
|
|
241
248
|
)}
|
|
242
249
|
|
|
243
250
|
{actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
|
|
@@ -9,6 +9,7 @@ export const Switch = forwardRef<
|
|
|
9
9
|
return (
|
|
10
10
|
<SwitchPrimitive.Root
|
|
11
11
|
ref={ref}
|
|
12
|
+
data-slot="switch"
|
|
12
13
|
className={cn(
|
|
13
14
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-fast ease-standard",
|
|
14
15
|
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
@@ -19,6 +20,7 @@ export const Switch = forwardRef<
|
|
|
19
20
|
{...props}
|
|
20
21
|
>
|
|
21
22
|
<SwitchPrimitive.Thumb
|
|
23
|
+
data-slot="switch-thumb"
|
|
22
24
|
className={cn(
|
|
23
25
|
"pointer-events-none block size-4 rounded-full bg-background shadow-xs ring-0 transition-transform duration-fast ease-standard",
|
|
24
26
|
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
|
@@ -104,7 +104,9 @@ export const Scrolling: Story = {
|
|
|
104
104
|
});
|
|
105
105
|
await expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
106
106
|
await expect(scrollRegion).toHaveAccessibleName("Table contents, scrollable");
|
|
107
|
-
|
|
107
|
+
// A named, non-landmark stop: `group`, never `region` (a landmark per
|
|
108
|
+
// overflowing table collides under axe `landmark-unique`).
|
|
109
|
+
await expect(scrollRegion).toHaveAttribute("role", "group");
|
|
108
110
|
|
|
109
111
|
// The region is the only focusable element the story renders, so a
|
|
110
112
|
// single Tab from nothing focused reaches it — the real WCAG 2.1.1
|