@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
|
@@ -9,7 +9,14 @@ export const AccordionItem = forwardRef<
|
|
|
9
9
|
ElementRef<typeof AccordionPrimitive.Item>,
|
|
10
10
|
ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
11
11
|
>(function AccordionItem({ className, ...props }, ref) {
|
|
12
|
-
return
|
|
12
|
+
return (
|
|
13
|
+
<AccordionPrimitive.Item
|
|
14
|
+
ref={ref}
|
|
15
|
+
data-slot="accordion-item"
|
|
16
|
+
className={cn("border-b", className)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
13
20
|
});
|
|
14
21
|
|
|
15
22
|
export const AccordionTrigger = forwardRef<
|
|
@@ -17,11 +24,12 @@ export const AccordionTrigger = forwardRef<
|
|
|
17
24
|
ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
18
25
|
>(function AccordionTrigger({ className, children, ...props }, ref) {
|
|
19
26
|
return (
|
|
20
|
-
<AccordionPrimitive.Header className="flex">
|
|
27
|
+
<AccordionPrimitive.Header className="flex" data-slot="accordion-header">
|
|
21
28
|
<AccordionPrimitive.Trigger
|
|
22
29
|
ref={ref}
|
|
30
|
+
data-slot="accordion-trigger"
|
|
23
31
|
className={cn(
|
|
24
|
-
"flex flex-1 items-center justify-between py-4 text-
|
|
32
|
+
"flex flex-1 items-center justify-between py-4 text-body font-medium transition-all hover:underline focus-ring [&[data-state=open]>svg]:rotate-180",
|
|
25
33
|
className,
|
|
26
34
|
)}
|
|
27
35
|
{...props}
|
|
@@ -40,7 +48,8 @@ export const AccordionContent = forwardRef<
|
|
|
40
48
|
return (
|
|
41
49
|
<AccordionPrimitive.Content
|
|
42
50
|
ref={ref}
|
|
43
|
-
|
|
51
|
+
data-slot="accordion-content"
|
|
52
|
+
className="overflow-hidden text-body data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
44
53
|
{...props}
|
|
45
54
|
>
|
|
46
55
|
<div className={cn("pb-4 pt-0 text-muted-foreground", className)}>{children}</div>
|
|
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
|
|
5
5
|
export const alertVariants = cva(
|
|
6
|
-
"relative w-full rounded-lg border px-4 py-3 text-
|
|
6
|
+
"relative w-full rounded-lg border px-4 py-3 text-body [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg]:text-current [&>svg~*]:ps-7",
|
|
7
7
|
{
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
@@ -34,7 +34,13 @@ export const Alert = forwardRef<
|
|
|
34
34
|
ref,
|
|
35
35
|
) {
|
|
36
36
|
return (
|
|
37
|
-
<div
|
|
37
|
+
<div
|
|
38
|
+
ref={ref}
|
|
39
|
+
role={role}
|
|
40
|
+
data-slot="alert"
|
|
41
|
+
className={cn(alertVariants({ variant }), className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
38
44
|
);
|
|
39
45
|
});
|
|
40
46
|
|
|
@@ -56,6 +62,7 @@ export const AlertTitle = forwardRef<HTMLHeadingElement, AlertTitleProps>(functi
|
|
|
56
62
|
return (
|
|
57
63
|
<Tag
|
|
58
64
|
ref={ref}
|
|
65
|
+
data-slot="alert-title"
|
|
59
66
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
60
67
|
{...props}
|
|
61
68
|
/>
|
|
@@ -82,8 +89,9 @@ export const AlertDescription = forwardRef<HTMLParagraphElement, AlertDescriptio
|
|
|
82
89
|
return (
|
|
83
90
|
<div
|
|
84
91
|
ref={ref}
|
|
92
|
+
data-slot="alert-description"
|
|
85
93
|
className={cn(
|
|
86
|
-
"text-
|
|
94
|
+
"text-body [&_p]:leading-relaxed text-muted-foreground",
|
|
87
95
|
measure && "max-w-prose",
|
|
88
96
|
className,
|
|
89
97
|
)}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
AlertDialog,
|
|
5
|
+
AlertDialogContent,
|
|
6
|
+
AlertDialogDescription,
|
|
7
|
+
AlertDialogHeader,
|
|
8
|
+
AlertDialogTitle,
|
|
9
|
+
} from "./alert-dialog";
|
|
10
|
+
|
|
11
|
+
describe("AlertDialogContent overflow", () => {
|
|
12
|
+
it("caps height and scrolls a long body instead of overflowing the viewport", () => {
|
|
13
|
+
render(
|
|
14
|
+
<AlertDialog open>
|
|
15
|
+
<AlertDialogContent>
|
|
16
|
+
<AlertDialogHeader>
|
|
17
|
+
<AlertDialogTitle>Title</AlertDialogTitle>
|
|
18
|
+
<AlertDialogDescription>Description</AlertDialogDescription>
|
|
19
|
+
</AlertDialogHeader>
|
|
20
|
+
Body
|
|
21
|
+
</AlertDialogContent>
|
|
22
|
+
</AlertDialog>,
|
|
23
|
+
);
|
|
24
|
+
const content = screen.getByRole("alertdialog");
|
|
25
|
+
// Without a height ceiling + `overflow-y-auto`, a body longer than the
|
|
26
|
+
// viewport overflows past both edges — and the `-translate-y-1/2`
|
|
27
|
+
// centring pushes its top half off-screen, unreachable even by scrolling
|
|
28
|
+
// the page.
|
|
29
|
+
expect(content.className).toContain("max-h-[85dvh]");
|
|
30
|
+
expect(content.className).toContain("overflow-y-auto");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -19,8 +19,9 @@ const AlertDialogOverlay = forwardRef<
|
|
|
19
19
|
return (
|
|
20
20
|
<AlertDialogPrimitive.Overlay
|
|
21
21
|
ref={ref}
|
|
22
|
+
data-slot="alert-dialog-overlay"
|
|
22
23
|
className={cn(
|
|
23
|
-
"fixed inset-0 z-50 bg-
|
|
24
|
+
"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",
|
|
24
25
|
className,
|
|
25
26
|
)}
|
|
26
27
|
{...props}
|
|
@@ -37,8 +38,13 @@ export const AlertDialogContent = forwardRef<
|
|
|
37
38
|
<AlertDialogOverlay />
|
|
38
39
|
<AlertDialogPrimitive.Content
|
|
39
40
|
ref={ref}
|
|
41
|
+
data-slot="alert-dialog-content"
|
|
40
42
|
className={cn(
|
|
41
|
-
|
|
43
|
+
// `max-h` + `overflow-y-auto`: without a height ceiling, a long body
|
|
44
|
+
// (a big diff, a long list of items to confirm) overflows past both
|
|
45
|
+
// viewport edges with its actions unreachable — the `-translate-y-1/2`
|
|
46
|
+
// centring only makes this worse by pushing the top half off-screen.
|
|
47
|
+
"fixed left-1/2 top-1/2 z-50 grid max-h-[85dvh] w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl bg-card p-6 text-card-foreground shadow-ring-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
|
42
48
|
className,
|
|
43
49
|
)}
|
|
44
50
|
{...props}
|
|
@@ -48,11 +54,18 @@ export const AlertDialogContent = forwardRef<
|
|
|
48
54
|
});
|
|
49
55
|
|
|
50
56
|
export function AlertDialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
51
|
-
return
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-slot="alert-dialog-header"
|
|
60
|
+
className={cn("flex flex-col gap-1.5 text-start", className)}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
52
64
|
}
|
|
53
65
|
export function AlertDialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
54
66
|
return (
|
|
55
67
|
<div
|
|
68
|
+
data-slot="alert-dialog-footer"
|
|
56
69
|
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
|
57
70
|
{...props}
|
|
58
71
|
/>
|
|
@@ -63,9 +76,11 @@ export const AlertDialogTitle = forwardRef<
|
|
|
63
76
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
|
64
77
|
>(function AlertDialogTitle({ className, ...props }, ref) {
|
|
65
78
|
return (
|
|
79
|
+
// `text-title`, matching `DialogTitle` (#286) — was `text-lg font-semibold`.
|
|
66
80
|
<AlertDialogPrimitive.Title
|
|
67
81
|
ref={ref}
|
|
68
|
-
|
|
82
|
+
data-slot="alert-dialog-title"
|
|
83
|
+
className={cn("text-title leading-none", className)}
|
|
69
84
|
{...props}
|
|
70
85
|
/>
|
|
71
86
|
);
|
|
@@ -77,7 +92,8 @@ export const AlertDialogDescription = forwardRef<
|
|
|
77
92
|
return (
|
|
78
93
|
<AlertDialogPrimitive.Description
|
|
79
94
|
ref={ref}
|
|
80
|
-
|
|
95
|
+
data-slot="alert-dialog-description"
|
|
96
|
+
className={cn("text-body text-muted-foreground", className)}
|
|
81
97
|
{...props}
|
|
82
98
|
/>
|
|
83
99
|
);
|
|
@@ -87,7 +103,12 @@ export const AlertDialogAction = forwardRef<
|
|
|
87
103
|
ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
|
88
104
|
>(function AlertDialogAction({ className, ...props }, ref) {
|
|
89
105
|
return (
|
|
90
|
-
<AlertDialogPrimitive.Action
|
|
106
|
+
<AlertDialogPrimitive.Action
|
|
107
|
+
ref={ref}
|
|
108
|
+
data-slot="alert-dialog-action"
|
|
109
|
+
className={cn(buttonVariants(), className)}
|
|
110
|
+
{...props}
|
|
111
|
+
/>
|
|
91
112
|
);
|
|
92
113
|
});
|
|
93
114
|
export const AlertDialogCancel = forwardRef<
|
|
@@ -97,6 +118,7 @@ export const AlertDialogCancel = forwardRef<
|
|
|
97
118
|
return (
|
|
98
119
|
<AlertDialogPrimitive.Cancel
|
|
99
120
|
ref={ref}
|
|
121
|
+
data-slot="alert-dialog-cancel"
|
|
100
122
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
|
101
123
|
{...props}
|
|
102
124
|
/>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { AppShell } from "./app-shell";
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarMenu,
|
|
10
|
+
SidebarMenuButton,
|
|
11
|
+
SidebarMenuItem,
|
|
12
|
+
SidebarProvider,
|
|
13
|
+
SidebarTrigger,
|
|
14
|
+
} from "../sidebar";
|
|
15
|
+
|
|
16
|
+
describe("AppShell", () => {
|
|
17
|
+
it("forwards a ref to the root element", () => {
|
|
18
|
+
const ref = createRef<HTMLDivElement>();
|
|
19
|
+
render(
|
|
20
|
+
<AppShell ref={ref} mainId="main">
|
|
21
|
+
Content
|
|
22
|
+
</AppShell>,
|
|
23
|
+
);
|
|
24
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
|
25
|
+
// The forwarded node IS the shell root — the flex/overflow box that
|
|
26
|
+
// establishes the fixed-height viewport, not some inner wrapper.
|
|
27
|
+
expect(ref.current?.className).toContain("h-dvh");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("AppShell — mobile navigation (#review 2.1: sidebar disappears below md)", () => {
|
|
32
|
+
const originalInnerWidth = window.innerWidth;
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
Object.defineProperty(window, "innerWidth", {
|
|
36
|
+
writable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
value: originalInnerWidth,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `sidebar` slot is `hidden md:flex` with no fallback of its own (see
|
|
44
|
+
* `app-shell.tsx`'s module doc comment) — by design, because `<Sidebar>`
|
|
45
|
+
* already renders its own off-canvas `Sheet` below the mobile breakpoint,
|
|
46
|
+
* through a Radix portal that mounts into `document.body`, entirely
|
|
47
|
+
* unaffected by AppShell's `hidden md:flex` wrapper (a portal is not a
|
|
48
|
+
* descendant of that wrapper's DOM subtree). Composing `<AppShell>` with
|
|
49
|
+
* the real `<Sidebar>` primitive (not a bare custom `<nav>`) and placing a
|
|
50
|
+
* `<SidebarTrigger>` in `topNav` — both wrapped in ONE shared
|
|
51
|
+
* `<SidebarProvider>`, which is a plain React context and so is never
|
|
52
|
+
* blocked by AppShell's internal wrapper `<div>` — is therefore ALREADY a
|
|
53
|
+
* working mobile nav path with no AppShell code change: this test locks
|
|
54
|
+
* that composition rather than reworking AppShell into owning a second,
|
|
55
|
+
* competing `Sheet` (which would have to unmount `<Sidebar>` whenever ITS
|
|
56
|
+
* own drawer closes — see the doc comment for why that is a regression,
|
|
57
|
+
* not a fix). A consumer who instead passes a bare nav node still needs to
|
|
58
|
+
* bring its own responsive fallback, exactly as documented.
|
|
59
|
+
*/
|
|
60
|
+
it("reaches sidebar navigation through Sidebar's own Sheet fallback at a mobile width", async () => {
|
|
61
|
+
Object.defineProperty(window, "innerWidth", {
|
|
62
|
+
writable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
value: 500,
|
|
65
|
+
});
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
render(
|
|
68
|
+
<SidebarProvider>
|
|
69
|
+
<AppShell
|
|
70
|
+
mainId="main"
|
|
71
|
+
topNav={<SidebarTrigger />}
|
|
72
|
+
sidebar={
|
|
73
|
+
<Sidebar>
|
|
74
|
+
<SidebarContent>
|
|
75
|
+
<SidebarMenu>
|
|
76
|
+
<SidebarMenuItem>
|
|
77
|
+
<SidebarMenuButton>Dashboard</SidebarMenuButton>
|
|
78
|
+
</SidebarMenuItem>
|
|
79
|
+
</SidebarMenu>
|
|
80
|
+
</SidebarContent>
|
|
81
|
+
</Sidebar>
|
|
82
|
+
}
|
|
83
|
+
>
|
|
84
|
+
Main content
|
|
85
|
+
</AppShell>
|
|
86
|
+
</SidebarProvider>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Below `md`, the desktop sidebar branch renders nothing (it is inside
|
|
90
|
+
// AppShell's `hidden md:flex` slot AND `<Sidebar>` itself renders `null`
|
|
91
|
+
// desktop markup on mobile) — the nav item is reachable only once the
|
|
92
|
+
// trigger opens the portal-rendered Sheet.
|
|
93
|
+
expect(screen.queryByRole("button", { name: "Dashboard" })).not.toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
await user.click(screen.getByRole("button", { name: "Toggle Sidebar" }));
|
|
96
|
+
|
|
97
|
+
expect(await screen.findByRole("button", { name: "Dashboard" })).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
1
|
+
import { forwardRef, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../../lib/cn";
|
|
3
3
|
import { SkipLink } from "../skip-link";
|
|
4
4
|
|
|
@@ -22,17 +22,36 @@ export interface AppShellProps {
|
|
|
22
22
|
/**
|
|
23
23
|
* App-first layout: fixed-height viewport with an optional sidebar, a top bar,
|
|
24
24
|
* and a scrollable main region. Pure layout — bring your own nav components.
|
|
25
|
+
*
|
|
26
|
+
* The `sidebar` slot is hidden below `md` with no fallback of its own: this
|
|
27
|
+
* shell does not know whether `sidebar` already manages its own responsive
|
|
28
|
+
* behavior. `<Sidebar>` (`../sidebar`) does — it renders as an offcanvas
|
|
29
|
+
* `Sheet` below the mobile breakpoint entirely on its own, through a Radix
|
|
30
|
+
* portal that is unaffected by this wrapper's `hidden md:flex` (a portal
|
|
31
|
+
* mounts into `document.body`, not into this DOM subtree) — pair it with a
|
|
32
|
+
* `<SidebarTrigger>` inside `topNav` for full mobile navigation. Wrapping an
|
|
33
|
+
* arbitrary `sidebar` node in a SECOND, shell-owned `Sheet` here would look
|
|
34
|
+
* like a fix but silently break that exact pattern: a Radix `Sheet`/`Dialog`
|
|
35
|
+
* only mounts its content while open, so `<Sidebar>` would be unmounted
|
|
36
|
+
* (not just hidden) whenever the shell's own drawer is closed — and with it,
|
|
37
|
+
* the very trigger a caller uses to open ITS mobile view. A bare nav node
|
|
38
|
+
* (no built-in responsive behavior of its own) has no such fallback; give it
|
|
39
|
+
* one explicitly, e.g. by rendering a `<Sheet>` alongside `topNav` in the
|
|
40
|
+
* consuming app.
|
|
41
|
+
*
|
|
42
|
+
* The `SidebarProvider` + `Sidebar` + `SidebarTrigger` composition above is
|
|
43
|
+
* regression-locked in `app-shell.test.tsx` at a mobile viewport width: no
|
|
44
|
+
* AppShell code change is needed to reach navigation below `md`, because
|
|
45
|
+
* `SidebarProvider` is a plain React context and is never blocked by this
|
|
46
|
+
* component's internal wrapper `<div>`.
|
|
25
47
|
*/
|
|
26
|
-
export function AppShell(
|
|
27
|
-
sidebar,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
className,
|
|
31
|
-
mainClassName,
|
|
32
|
-
mainId = "main-content",
|
|
33
|
-
}: AppShellProps) {
|
|
48
|
+
export const AppShell = forwardRef<HTMLDivElement, AppShellProps>(function AppShell(
|
|
49
|
+
{ sidebar, topNav, children, className, mainClassName, mainId = "main-content" },
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
34
52
|
return (
|
|
35
53
|
<div
|
|
54
|
+
ref={ref}
|
|
36
55
|
className={cn("flex h-dvh w-full overflow-hidden bg-background text-foreground", className)}
|
|
37
56
|
>
|
|
38
57
|
{/* The first focusable element of the shell, so a keyboard user can pass
|
|
@@ -61,4 +80,4 @@ export function AppShell({
|
|
|
61
80
|
</div>
|
|
62
81
|
</div>
|
|
63
82
|
);
|
|
64
|
-
}
|
|
83
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// GENERATED by scripts/gen-attributions.mjs — DO NOT EDIT BY HAND.
|
|
2
|
-
// Run `pnpm gen
|
|
3
|
-
// scripts/attributions.sources.json. `pnpm
|
|
2
|
+
// Run `pnpm gen` after changing a dependency, a vendored font, or
|
|
3
|
+
// scripts/attributions.sources.json. `pnpm gen:check` fails on a stale copy.
|
|
4
4
|
//
|
|
5
5
|
// 116 entries (data: 2, source: 18, font: 2, dependency: 94).
|
|
6
6
|
|
|
@@ -9,6 +9,7 @@ export const Avatar = forwardRef<
|
|
|
9
9
|
return (
|
|
10
10
|
<AvatarPrimitive.Root
|
|
11
11
|
ref={ref}
|
|
12
|
+
data-slot="avatar"
|
|
12
13
|
className={cn("relative flex size-9 shrink-0 overflow-hidden rounded-full", className)}
|
|
13
14
|
{...props}
|
|
14
15
|
/>
|
|
@@ -21,6 +22,7 @@ export const AvatarImage = forwardRef<
|
|
|
21
22
|
return (
|
|
22
23
|
<AvatarPrimitive.Image
|
|
23
24
|
ref={ref}
|
|
25
|
+
data-slot="avatar-image"
|
|
24
26
|
className={cn(
|
|
25
27
|
"aspect-square size-full animate-in fade-in duration-base ease-entrance",
|
|
26
28
|
className,
|
|
@@ -36,8 +38,9 @@ export const AvatarFallback = forwardRef<
|
|
|
36
38
|
return (
|
|
37
39
|
<AvatarPrimitive.Fallback
|
|
38
40
|
ref={ref}
|
|
41
|
+
data-slot="avatar-fallback"
|
|
39
42
|
className={cn(
|
|
40
|
-
"flex size-full items-center justify-center rounded-full bg-muted text-
|
|
43
|
+
"flex size-full items-center justify-center rounded-full bg-muted text-meta font-medium text-muted-foreground",
|
|
41
44
|
className,
|
|
42
45
|
)}
|
|
43
46
|
{...props}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import { render, screen } from "@testing-library/react";
|
|
3
4
|
import { Badge } from "./badge";
|
|
@@ -7,4 +8,11 @@ describe("Badge", () => {
|
|
|
7
8
|
render(<Badge>New</Badge>);
|
|
8
9
|
expect(screen.getByText("New")).toBeInTheDocument();
|
|
9
10
|
});
|
|
11
|
+
|
|
12
|
+
it("forwards its ref to the rendered <span>", () => {
|
|
13
|
+
const ref = createRef<HTMLSpanElement>();
|
|
14
|
+
render(<Badge ref={ref}>New</Badge>);
|
|
15
|
+
expect(ref.current).toBeInstanceOf(HTMLSpanElement);
|
|
16
|
+
expect(ref.current).toBe(screen.getByText("New"));
|
|
17
|
+
});
|
|
10
18
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type HTMLAttributes } from "react";
|
|
1
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
2
2
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
|
|
@@ -23,6 +23,16 @@ export const badgeVariants = cva(
|
|
|
23
23
|
export interface BadgeProps
|
|
24
24
|
extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
|
|
25
25
|
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
|
|
27
|
+
{ className, variant, ...props },
|
|
28
|
+
ref,
|
|
29
|
+
) {
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
ref={ref}
|
|
33
|
+
data-slot="badge"
|
|
34
|
+
className={cn(badgeVariants({ variant }), className)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
|
|
4
4
|
import { BentoGrid, BentoGridItem } from "./bento-grid";
|
|
5
5
|
|
|
@@ -116,4 +116,25 @@ describe("BentoGridItem", () => {
|
|
|
116
116
|
);
|
|
117
117
|
expect(screen.getByTestId("item").className).toMatch(/from-primary/);
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
it("cancels a pending spotlight rAF on unmount", () => {
|
|
121
|
+
const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(7);
|
|
122
|
+
const cafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
|
|
123
|
+
const { unmount } = render(
|
|
124
|
+
<BentoGridItem spotlight data-testid="item">
|
|
125
|
+
content
|
|
126
|
+
</BentoGridItem>,
|
|
127
|
+
);
|
|
128
|
+
const item = screen.getByTestId("item");
|
|
129
|
+
fireEvent.mouseMove(item, { clientX: 10, clientY: 10 });
|
|
130
|
+
expect(rafSpy).toHaveBeenCalled();
|
|
131
|
+
|
|
132
|
+
unmount();
|
|
133
|
+
// A mouse move right before the tile unmounts (removed from the grid
|
|
134
|
+
// while hovered) must not leave a scheduled frame pending after teardown.
|
|
135
|
+
expect(cafSpy).toHaveBeenCalledWith(7);
|
|
136
|
+
|
|
137
|
+
rafSpy.mockRestore();
|
|
138
|
+
cafSpy.mockRestore();
|
|
139
|
+
});
|
|
119
140
|
});
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
createContext,
|
|
35
35
|
forwardRef,
|
|
36
36
|
use,
|
|
37
|
+
useEffect,
|
|
37
38
|
useRef,
|
|
38
39
|
useCallback,
|
|
39
40
|
type CSSProperties,
|
|
@@ -244,6 +245,15 @@ export const BentoGridItem = forwardRef<HTMLDivElement, BentoGridItemProps>(func
|
|
|
244
245
|
// rAF ref so we only schedule one frame at a time.
|
|
245
246
|
const rafRef = useRef<number>(0);
|
|
246
247
|
|
|
248
|
+
// Cancel a pending frame on unmount — otherwise a mouse move right before
|
|
249
|
+
// the tile unmounts (removed from the grid while hovered) still fires the
|
|
250
|
+
// scheduled callback after teardown.
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
return () => {
|
|
253
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
254
|
+
};
|
|
255
|
+
}, []);
|
|
256
|
+
|
|
247
257
|
const handleMouseMove = useCallback(
|
|
248
258
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
249
259
|
if (!spotlightOn || !tileRef.current || !overlayRef.current) return;
|
|
@@ -18,7 +18,7 @@ describe("BoundedNumber", () => {
|
|
|
18
18
|
it("hides the empty label once a value is set", () => {
|
|
19
19
|
render(<BoundedNumber aria-label="Limit" defaultValue={250} />);
|
|
20
20
|
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
|
|
21
|
-
expect(screen.getByRole("spinbutton", { name: "Limit" })).toHaveValue(250);
|
|
21
|
+
expect(screen.getByRole("spinbutton", { name: "Limit" })).toHaveValue("250");
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
it("hides the empty label while focused so the field reads as truly empty", async () => {
|