@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.
Files changed (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. 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 <AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />;
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-sm font-medium transition-all hover:underline focus-ring [&[data-state=open]>svg]:rotate-180",
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
- className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
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-sm [&>svg]:absolute [&>svg]:start-4 [&>svg]:top-4 [&>svg]:size-4 [&>svg]:text-current [&>svg~*]:ps-7",
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 ref={ref} role={role} className={cn(alertVariants({ variant }), className)} {...props} />
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-sm [&_p]:leading-relaxed text-muted-foreground",
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-foreground/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
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
- "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 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",
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 <div className={cn("flex flex-col gap-1.5 text-start", className)} {...props} />;
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
- className={cn("text-lg font-semibold", className)}
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
- className={cn("text-sm text-muted-foreground", className)}
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 ref={ref} className={cn(buttonVariants(), className)} {...props} />
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
- topNav,
29
- children,
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,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { forwardRef, useId, useMemo, type HTMLAttributes } from "react";
2
4
  import { cn } from "../../lib/cn";
3
5
  import { Badge } from "../badge";
@@ -1,6 +1,6 @@
1
1
  // GENERATED by scripts/gen-attributions.mjs — DO NOT EDIT BY HAND.
2
- // Run `pnpm gen:attributions` after changing a dependency, a vendored font, or
3
- // scripts/attributions.sources.json. `pnpm attributions:check` fails on a stale copy.
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-sm font-medium text-muted-foreground",
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 function Badge({ className, variant, ...props }: BadgeProps) {
27
- return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
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 () => {