@bearmenu/ui 0.5.2 → 0.6.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 (223) hide show
  1. package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
  2. package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
  3. package/dist/chunk-D5QHEHXJ.js +26 -0
  4. package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
  5. package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
  6. package/dist/chunk-RIDXFY6M.js +38 -0
  7. package/dist/chunk-UUJVXN2O.js +82 -0
  8. package/dist/chunk-WKMLZDUT.js +122 -0
  9. package/dist/chunk-XK2ZDQVE.js +122 -0
  10. package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
  11. package/dist/chunk-YJ6OBKZJ.js +32 -0
  12. package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
  13. package/dist/chunk-ZFNOCP6W.js +106 -0
  14. package/dist/components/accordion.js +11 -5
  15. package/dist/components/alert-dialog.js +5 -3
  16. package/dist/components/alert.d.ts +5 -2
  17. package/dist/components/alert.js +25 -9
  18. package/dist/components/avatar.d.ts +17 -2
  19. package/dist/components/avatar.js +91 -12
  20. package/dist/components/badge.d.ts +1 -1
  21. package/dist/components/badge.js +1 -1
  22. package/dist/components/breadcrumb.js +5 -2
  23. package/dist/components/button-group.d.ts +14 -0
  24. package/dist/components/button-group.js +39 -0
  25. package/dist/components/button.d.ts +4 -2
  26. package/dist/components/button.js +1 -1
  27. package/dist/components/calendar.js +3 -3
  28. package/dist/components/card.d.ts +13 -5
  29. package/dist/components/card.js +2 -2
  30. package/dist/components/carousel.d.ts +20 -3
  31. package/dist/components/carousel.js +72 -12
  32. package/dist/components/checkbox.js +1 -1
  33. package/dist/components/circular-progress.d.ts +16 -0
  34. package/dist/components/circular-progress.js +105 -0
  35. package/dist/components/combobox.d.ts +27 -0
  36. package/dist/components/combobox.js +102 -0
  37. package/dist/components/command.d.ts +29 -16
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/currency-input.d.ts +1 -0
  40. package/dist/components/currency-input.js +73 -52
  41. package/dist/components/date-input.d.ts +10 -0
  42. package/dist/components/date-input.js +40 -0
  43. package/dist/components/description-list.d.ts +15 -0
  44. package/dist/components/description-list.js +67 -0
  45. package/dist/components/dialog.d.ts +13 -2
  46. package/dist/components/dialog.js +1 -1
  47. package/dist/components/drawer.d.ts +5 -2
  48. package/dist/components/drawer.js +47 -9
  49. package/dist/components/dropdown-menu.d.ts +7 -2
  50. package/dist/components/dropdown-menu.js +22 -6
  51. package/dist/components/empty-state.js +2 -2
  52. package/dist/components/field.d.ts +22 -0
  53. package/dist/components/field.js +130 -0
  54. package/dist/components/form.d.ts +2 -1
  55. package/dist/components/form.js +11 -1
  56. package/dist/components/haptic-button.js +3 -3
  57. package/dist/components/input-group.d.ts +18 -0
  58. package/dist/components/input-group.js +92 -0
  59. package/dist/components/input.d.ts +8 -2
  60. package/dist/components/input.js +1 -1
  61. package/dist/components/item.d.ts +23 -0
  62. package/dist/components/item.js +120 -0
  63. package/dist/components/kbd.d.ts +13 -0
  64. package/dist/components/kbd.js +49 -0
  65. package/dist/components/lazy-markdown.d.ts +3 -1
  66. package/dist/components/lazy-markdown.js +7 -7
  67. package/dist/components/link-button.js +1 -1
  68. package/dist/components/multi-select-combobox.js +92 -83
  69. package/dist/components/multi-select-menu.d.ts +0 -4
  70. package/dist/components/multi-select-menu.js +28 -17
  71. package/dist/components/native-select.d.ts +12 -0
  72. package/dist/components/native-select.js +49 -0
  73. package/dist/components/pagination.d.ts +5 -2
  74. package/dist/components/pagination.js +66 -7
  75. package/dist/components/place-about-tab.js +4 -4
  76. package/dist/components/place-card.js +1 -1
  77. package/dist/components/place-details-mobile.js +4 -4
  78. package/dist/components/popover.d.ts +7 -1
  79. package/dist/components/popover.js +1 -1
  80. package/dist/components/progress.d.ts +5 -1
  81. package/dist/components/progress.js +17 -3
  82. package/dist/components/radio-group.js +12 -3
  83. package/dist/components/rating.d.ts +2 -0
  84. package/dist/components/rating.js +88 -70
  85. package/dist/components/search-bar.d.ts +1 -2
  86. package/dist/components/search-bar.js +37 -60
  87. package/dist/components/searchable-select.js +76 -72
  88. package/dist/components/select.d.ts +5 -1
  89. package/dist/components/select.js +30 -14
  90. package/dist/components/selection-bar.d.ts +11 -0
  91. package/dist/components/selection-bar.js +65 -0
  92. package/dist/components/separator.js +3 -2
  93. package/dist/components/sheet.d.ts +1 -1
  94. package/dist/components/sheet.js +1 -1
  95. package/dist/components/skeleton-card.js +1 -1
  96. package/dist/components/skeleton.js +1 -1
  97. package/dist/components/slider.js +21 -4
  98. package/dist/components/sliding-panels.js +9 -6
  99. package/dist/components/spinner.d.ts +17 -0
  100. package/dist/components/spinner.js +50 -0
  101. package/dist/components/stepper.d.ts +18 -0
  102. package/dist/components/stepper.js +199 -0
  103. package/dist/components/switch.js +4 -3
  104. package/dist/components/table.d.ts +23 -5
  105. package/dist/components/table.js +121 -35
  106. package/dist/components/tabs.d.ts +3 -1
  107. package/dist/components/tabs.js +37 -9
  108. package/dist/components/tag-group.d.ts +9 -0
  109. package/dist/components/tag-group.js +26 -0
  110. package/dist/components/tag.d.ts +17 -0
  111. package/dist/components/tag.js +61 -0
  112. package/dist/components/text.d.ts +3 -3
  113. package/dist/components/text.js +1 -1
  114. package/dist/components/textarea.js +2 -1
  115. package/dist/components/toast.js +1 -1
  116. package/dist/components/toaster.js +1 -1
  117. package/dist/components/toggle-group.js +1 -1
  118. package/dist/components/toggle.js +1 -1
  119. package/dist/components/tooltip.d.ts +3 -2
  120. package/dist/components/tooltip.js +16 -2
  121. package/package.json +44 -26
  122. package/src/components/accordion.tsx +11 -6
  123. package/src/components/alert-dialog.tsx +4 -2
  124. package/src/components/alert.tsx +20 -8
  125. package/src/components/avatar.tsx +106 -13
  126. package/src/components/badge.tsx +12 -11
  127. package/src/components/breadcrumb.tsx +5 -2
  128. package/src/components/button-group.stories.tsx +52 -0
  129. package/src/components/button-group.tsx +40 -0
  130. package/src/components/button.test.tsx +174 -0
  131. package/src/components/button.tsx +42 -19
  132. package/src/components/calendar.tsx +2 -2
  133. package/src/components/card.tsx +55 -13
  134. package/src/components/carousel.tsx +85 -13
  135. package/src/components/checkbox.test.tsx +58 -0
  136. package/src/components/checkbox.tsx +8 -3
  137. package/src/components/circular-progress.stories.tsx +39 -0
  138. package/src/components/circular-progress.tsx +102 -0
  139. package/src/components/collapsible.stories.tsx +7 -3
  140. package/src/components/combobox.stories.tsx +82 -0
  141. package/src/components/combobox.test.tsx +118 -0
  142. package/src/components/combobox.tsx +136 -0
  143. package/src/components/command.test.tsx +91 -0
  144. package/src/components/command.tsx +38 -15
  145. package/src/components/currency-input.test.tsx +63 -0
  146. package/src/components/currency-input.tsx +84 -66
  147. package/src/components/date-input.stories.tsx +31 -0
  148. package/src/components/date-input.tsx +43 -0
  149. package/src/components/description-list.stories.tsx +62 -0
  150. package/src/components/description-list.tsx +68 -0
  151. package/src/components/dialog.test.tsx +84 -0
  152. package/src/components/dialog.tsx +64 -24
  153. package/src/components/drawer.tsx +72 -23
  154. package/src/components/dropdown-menu.test.tsx +180 -0
  155. package/src/components/dropdown-menu.tsx +31 -6
  156. package/src/components/field.stories.tsx +36 -0
  157. package/src/components/field.test.tsx +157 -0
  158. package/src/components/field.tsx +146 -0
  159. package/src/components/form.stories.tsx +3 -2
  160. package/src/components/form.test.tsx +95 -0
  161. package/src/components/form.tsx +9 -0
  162. package/src/components/haptic-button.tsx +1 -1
  163. package/src/components/input-group.stories.tsx +61 -0
  164. package/src/components/input-group.tsx +99 -0
  165. package/src/components/input.test.tsx +103 -0
  166. package/src/components/input.tsx +24 -7
  167. package/src/components/item.stories.tsx +52 -0
  168. package/src/components/item.tsx +115 -0
  169. package/src/components/kbd.stories.tsx +33 -0
  170. package/src/components/kbd.tsx +46 -0
  171. package/src/components/lazy-markdown.tsx +10 -7
  172. package/src/components/multi-select-combobox.test.tsx +101 -0
  173. package/src/components/multi-select-combobox.tsx +85 -82
  174. package/src/components/multi-select-menu.tsx +49 -41
  175. package/src/components/native-select.stories.tsx +52 -0
  176. package/src/components/native-select.tsx +45 -0
  177. package/src/components/pagination.tsx +64 -5
  178. package/src/components/popover.tsx +66 -3
  179. package/src/components/progress.tsx +27 -4
  180. package/src/components/radio-group.test.tsx +64 -0
  181. package/src/components/radio-group.tsx +11 -3
  182. package/src/components/rating.test.tsx +112 -0
  183. package/src/components/rating.tsx +97 -82
  184. package/src/components/search-bar.stories.tsx +1 -32
  185. package/src/components/search-bar.tsx +22 -59
  186. package/src/components/searchable-select.tsx +69 -73
  187. package/src/components/select.tsx +34 -19
  188. package/src/components/selection-bar.stories.tsx +37 -0
  189. package/src/components/selection-bar.tsx +65 -0
  190. package/src/components/separator.tsx +5 -2
  191. package/src/components/sheet.tsx +1 -1
  192. package/src/components/skeleton.tsx +2 -2
  193. package/src/components/slider.test.tsx +45 -0
  194. package/src/components/slider.tsx +31 -12
  195. package/src/components/sliding-panels.tsx +8 -5
  196. package/src/components/spinner.stories.tsx +35 -0
  197. package/src/components/spinner.tsx +50 -0
  198. package/src/components/stepper.stories.tsx +71 -0
  199. package/src/components/stepper.tsx +220 -0
  200. package/src/components/switch.test.tsx +52 -0
  201. package/src/components/switch.tsx +4 -3
  202. package/src/components/table.tsx +140 -35
  203. package/src/components/tabs.test.tsx +107 -0
  204. package/src/components/tabs.tsx +70 -23
  205. package/src/components/tag-group.stories.tsx +39 -0
  206. package/src/components/tag-group.tsx +28 -0
  207. package/src/components/tag.stories.tsx +50 -0
  208. package/src/components/tag.tsx +63 -0
  209. package/src/components/text.test.tsx +1 -1
  210. package/src/components/text.tsx +19 -3
  211. package/src/components/textarea.test.tsx +48 -0
  212. package/src/components/textarea.tsx +2 -1
  213. package/src/components/toast.tsx +4 -3
  214. package/src/components/toggle.tsx +2 -2
  215. package/src/components/tooltip.tsx +12 -2
  216. package/src/globals.css +126 -90
  217. package/dist/chunk-2BMJRXQX.js +0 -80
  218. package/dist/chunk-4AVS6CV4.js +0 -89
  219. package/dist/chunk-4UO73SKC.js +0 -26
  220. package/dist/chunk-CW24PLDW.js +0 -24
  221. package/dist/chunk-IQASCH2V.js +0 -25
  222. package/dist/chunk-NKHH23YK.js +0 -31
  223. package/dist/chunk-QJ54Z7TC.js +0 -83
@@ -0,0 +1,71 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Stepper } from "./stepper";
4
+ import { Button } from "./button";
5
+
6
+ const meta = {
7
+ title: "Components/Stepper",
8
+ component: Stepper,
9
+ tags: ["autodocs"],
10
+ } satisfies Meta<typeof Stepper>;
11
+
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ const steps = [
16
+ { id: "account", title: "Account" },
17
+ { id: "details", title: "Details" },
18
+ { id: "review", title: "Review" },
19
+ ];
20
+
21
+ export const Default: Story = {
22
+ render: () => (
23
+ <div className="w-[36rem]">
24
+ <Stepper steps={steps} activeStep={1} />
25
+ </div>
26
+ ),
27
+ };
28
+
29
+ export const Complete: Story = {
30
+ render: () => (
31
+ <div className="w-[36rem]">
32
+ <Stepper steps={steps} activeStep={steps.length} />
33
+ </div>
34
+ ),
35
+ };
36
+
37
+ export const Interactive: Story = {
38
+ render: () => {
39
+ const [active, setActive] = useState(0);
40
+ const [furthest, setFurthest] = useState(0);
41
+ return (
42
+ <div className="w-[36rem] space-y-6">
43
+ <Stepper
44
+ steps={steps}
45
+ activeStep={active}
46
+ furthestStep={furthest}
47
+ onStepClick={(i) => setActive(i)}
48
+ />
49
+ <div className="flex gap-2">
50
+ <Button
51
+ variant="outline"
52
+ disabled={active === 0}
53
+ onClick={() => setActive((a) => Math.max(0, a - 1))}
54
+ >
55
+ Back
56
+ </Button>
57
+ <Button
58
+ disabled={active === steps.length - 1}
59
+ onClick={() => {
60
+ const next = Math.min(steps.length - 1, active + 1);
61
+ setActive(next);
62
+ setFurthest((f) => Math.max(f, next));
63
+ }}
64
+ >
65
+ Next
66
+ </Button>
67
+ </div>
68
+ </div>
69
+ );
70
+ },
71
+ };
@@ -0,0 +1,220 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cn } from "../lib/utils";
5
+
6
+ export interface StepperStep {
7
+ id: string;
8
+ title: string;
9
+ description?: string;
10
+ }
11
+
12
+ type StepStatus = "complete" | "current" | "upcoming";
13
+
14
+ type StepperProps = React.HTMLAttributes<HTMLElement> & {
15
+ steps: StepperStep[];
16
+ activeStep: number;
17
+ /** Highest step the user has reached (enables clicking back). Defaults to activeStep. */
18
+ furthestStep?: number;
19
+ /** When provided, steps render as buttons. */
20
+ onStepClick?: (index: number) => void;
21
+ ariaLabel?: string;
22
+ };
23
+
24
+ const Stepper = React.forwardRef<HTMLElement, StepperProps>(
25
+ (
26
+ {
27
+ className,
28
+ steps,
29
+ activeStep,
30
+ furthestStep,
31
+ onStepClick,
32
+ ariaLabel = "Progress",
33
+ ...props
34
+ },
35
+ ref,
36
+ ) => {
37
+ const reachable = furthestStep ?? activeStep;
38
+ const isInteractive = typeof onStepClick === "function";
39
+
40
+ return (
41
+ <nav
42
+ ref={ref as React.Ref<HTMLElement>}
43
+ aria-label={ariaLabel}
44
+ className={cn("w-full", className)}
45
+ {...props}
46
+ >
47
+ {/* Desktop: circle + label, animated connectors */}
48
+ <ol className="hidden md:flex w-full items-center" data-slot="stepper">
49
+ {steps.map((step, i) => {
50
+ const status: StepStatus =
51
+ i < activeStep ? "complete" : i === activeStep ? "current" : "upcoming";
52
+ const isLast = i === steps.length - 1;
53
+ const canClick = isInteractive && i <= reachable;
54
+
55
+ const Item = canClick ? "button" : "div";
56
+ const itemProps = canClick
57
+ ? {
58
+ type: "button" as const,
59
+ onClick: () => onStepClick?.(i),
60
+ disabled: !canClick,
61
+ }
62
+ : {};
63
+
64
+ return (
65
+ <li key={step.id} className="contents" data-status={status}>
66
+ <Item
67
+ {...itemProps}
68
+ aria-current={status === "current" ? "step" : undefined}
69
+ className={cn(
70
+ "group inline-flex items-center gap-2 rounded-full text-sm transition-opacity",
71
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
72
+ canClick && status !== "current" && "hover:opacity-80",
73
+ )}
74
+ >
75
+ <StepCircle index={i} status={status} />
76
+ <span
77
+ className={cn(
78
+ "whitespace-nowrap transition-colors",
79
+ status === "current" && "font-semibold text-foreground",
80
+ status === "complete" && "font-medium text-foreground/80",
81
+ status === "upcoming" && "text-foreground/40",
82
+ )}
83
+ >
84
+ {step.title}
85
+ </span>
86
+ </Item>
87
+
88
+ {!isLast && <StepConnector filled={i < activeStep} />}
89
+ </li>
90
+ );
91
+ })}
92
+ </ol>
93
+
94
+ {/* Mobile: numeric dots + active label below */}
95
+ <div className="md:hidden">
96
+ <ol className="flex items-center gap-1.5" role="list">
97
+ {steps.map((step, i) => {
98
+ const status: StepStatus =
99
+ i < activeStep ? "complete" : i === activeStep ? "current" : "upcoming";
100
+ const canClick = isInteractive && i <= reachable;
101
+
102
+ return (
103
+ <li key={step.id} className="flex-1">
104
+ {canClick ? (
105
+ <button
106
+ type="button"
107
+ onClick={() => onStepClick?.(i)}
108
+ aria-label={`${i + 1}. ${step.title}`}
109
+ aria-current={status === "current" ? "step" : undefined}
110
+ className={cn(
111
+ "block w-full h-1.5 rounded-full transition-colors",
112
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
113
+ status === "current" && "bg-foreground",
114
+ status === "complete" && "bg-foreground/50",
115
+ status === "upcoming" && "bg-foreground/15",
116
+ )}
117
+ />
118
+ ) : (
119
+ <div
120
+ aria-label={`${i + 1}. ${step.title}`}
121
+ aria-current={status === "current" ? "step" : undefined}
122
+ className={cn(
123
+ "block w-full h-1.5 rounded-full transition-colors",
124
+ status === "current" && "bg-foreground",
125
+ status === "complete" && "bg-foreground/50",
126
+ status === "upcoming" && "bg-foreground/15",
127
+ )}
128
+ />
129
+ )}
130
+ </li>
131
+ );
132
+ })}
133
+ </ol>
134
+ <p className="mt-3 text-xs uppercase tracking-[0.12em] text-foreground/40">
135
+ {`${activeStep + 1} / ${steps.length}`}
136
+ </p>
137
+ <p className="mt-1 text-base font-semibold text-foreground">
138
+ {steps[activeStep]?.title}
139
+ </p>
140
+ </div>
141
+ </nav>
142
+ );
143
+ },
144
+ );
145
+ Stepper.displayName = "Stepper";
146
+
147
+ function StepCircle({ index, status }: { index: number; status: StepStatus }) {
148
+ const isFilled = status !== "upcoming";
149
+ const isComplete = status === "complete";
150
+
151
+ return (
152
+ <span
153
+ aria-hidden="true"
154
+ className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
155
+ >
156
+ {/* Outline (visible when upcoming, fades when filled) */}
157
+ <span
158
+ className={cn(
159
+ "absolute inset-0 rounded-full border transition-opacity duration-300",
160
+ isFilled ? "opacity-0 border-transparent" : "opacity-100 border-foreground/25",
161
+ )}
162
+ />
163
+ {/* Filled background — scales in from center, synced with check stroke */}
164
+ <span
165
+ className={cn(
166
+ "absolute inset-0 rounded-full bg-foreground origin-center transition-transform duration-300 ease-out",
167
+ isFilled ? "scale-100" : "scale-0",
168
+ )}
169
+ />
170
+ {/* Number (visible when not complete) */}
171
+ <span
172
+ className={cn(
173
+ "relative text-xs font-semibold transition-opacity duration-200",
174
+ isComplete ? "opacity-0" : "opacity-100",
175
+ status === "current" ? "text-background" : "text-foreground/40",
176
+ )}
177
+ >
178
+ {index + 1}
179
+ </span>
180
+ {/* Animated check — short stroke first, then long stroke */}
181
+ <svg
182
+ className="pointer-events-none absolute inset-0 m-auto h-3.5 w-3.5 text-background"
183
+ viewBox="0 0 24 24"
184
+ fill="none"
185
+ stroke="currentColor"
186
+ strokeWidth="3"
187
+ strokeLinecap="round"
188
+ strokeLinejoin="round"
189
+ >
190
+ <path
191
+ d="M5 12 L10 17 L20 7"
192
+ pathLength={1}
193
+ strokeDasharray="1"
194
+ strokeDashoffset={isComplete ? 0 : 1}
195
+ style={{
196
+ transition: isComplete
197
+ ? "stroke-dashoffset 450ms cubic-bezier(0.65, 0, 0.35, 1) 180ms"
198
+ : "stroke-dashoffset 150ms ease-in",
199
+ }}
200
+ />
201
+ </svg>
202
+ </span>
203
+ );
204
+ }
205
+
206
+ function StepConnector({ filled }: { filled: boolean }) {
207
+ return (
208
+ <div
209
+ aria-hidden="true"
210
+ className="relative h-1 flex-1 mx-3 overflow-hidden rounded-full bg-foreground/10"
211
+ >
212
+ <div
213
+ className="absolute inset-y-0 left-0 rounded-full bg-foreground/50 transition-[width] duration-500 ease-out"
214
+ style={{ width: filled ? "100%" : "0%" }}
215
+ />
216
+ </div>
217
+ );
218
+ }
219
+
220
+ export { Stepper };
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { Switch } from "./switch";
5
+
6
+ describe("Switch", () => {
7
+ it("renders unchecked by default", () => {
8
+ render(<Switch aria-label="notifications" />);
9
+ expect(screen.getByRole("switch")).toHaveAttribute("data-state", "unchecked");
10
+ });
11
+
12
+ it("renders checked when prop is set", () => {
13
+ render(<Switch aria-label="x" checked onCheckedChange={() => {}} />);
14
+ expect(screen.getByRole("switch")).toHaveAttribute("data-state", "checked");
15
+ });
16
+
17
+ it("toggles on click", async () => {
18
+ const user = userEvent.setup();
19
+ const onCheckedChange = vi.fn();
20
+ render(<Switch aria-label="x" onCheckedChange={onCheckedChange} />);
21
+ await user.click(screen.getByRole("switch"));
22
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
23
+ });
24
+
25
+ it("ignores clicks when disabled", async () => {
26
+ const user = userEvent.setup();
27
+ const onCheckedChange = vi.fn();
28
+ render(<Switch aria-label="x" disabled onCheckedChange={onCheckedChange} />);
29
+ await user.click(screen.getByRole("switch"));
30
+ expect(onCheckedChange).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("toggles via Space key when focused", async () => {
34
+ const user = userEvent.setup();
35
+ const onCheckedChange = vi.fn();
36
+ render(<Switch aria-label="x" onCheckedChange={onCheckedChange} />);
37
+ const sw = screen.getByRole("switch");
38
+ sw.focus();
39
+ await user.keyboard(" ");
40
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
41
+ });
42
+
43
+ it("supports aria-invalid", () => {
44
+ render(<Switch aria-label="x" aria-invalid />);
45
+ expect(screen.getByRole("switch")).toHaveAttribute("aria-invalid", "true");
46
+ });
47
+
48
+ it("merges className", () => {
49
+ render(<Switch aria-label="x" className="custom" />);
50
+ expect(screen.getByRole("switch").className).toContain("custom");
51
+ });
52
+ });
@@ -9,16 +9,17 @@ const Switch = React.forwardRef<
9
9
  React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
10
10
  >(({ className, ...props }, ref) => (
11
11
  <SwitchPrimitive.Root
12
+ ref={ref}
13
+ data-slot="switch"
12
14
  className={cn(
13
- "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input glass data-[state=checked]:shadow-lg data-[state=checked]:shadow-primary/20 hover:shadow-md",
15
+ "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input aria-invalid:ring-2 aria-invalid:ring-destructive/40",
14
16
  className
15
17
  )}
16
18
  {...props}
17
- ref={ref}
18
19
  >
19
20
  <SwitchPrimitive.Thumb
20
21
  className={cn(
21
- "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-all duration-300 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground backdrop-blur-md border border-border/50"
22
+ "pointer-events-none block size-5 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground"
22
23
  )}
23
24
  />
24
25
  </SwitchPrimitive.Root>
@@ -1,20 +1,38 @@
1
+ "use client";
2
+
1
3
  import * as React from "react";
4
+ import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
2
5
  import { cn } from "../lib/utils";
3
6
 
4
- const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
5
- ({ className, ...props }, ref) => (
6
- <div className="relative w-full overflow-auto">
7
- <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
8
- </div>
9
- )
10
- );
7
+ const Table = React.forwardRef<
8
+ HTMLTableElement,
9
+ React.HTMLAttributes<HTMLTableElement> & { containerClassName?: string }
10
+ >(({ className, containerClassName, ...props }, ref) => (
11
+ <div className={cn("relative w-full overflow-auto rounded-xl border border-border", containerClassName)}>
12
+ <table
13
+ ref={ref}
14
+ data-slot="table"
15
+ className={cn("w-full caption-bottom text-sm", className)}
16
+ {...props}
17
+ />
18
+ </div>
19
+ ));
11
20
  Table.displayName = "Table";
12
21
 
13
22
  const TableHeader = React.forwardRef<
14
23
  HTMLTableSectionElement,
15
- React.HTMLAttributes<HTMLTableSectionElement>
16
- >(({ className, ...props }, ref) => (
17
- <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
24
+ React.HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }
25
+ >(({ className, sticky, ...props }, ref) => (
26
+ <thead
27
+ ref={ref}
28
+ data-slot="table-header"
29
+ className={cn(
30
+ "bg-muted/50 [&_tr]:border-b [&_tr]:border-border",
31
+ sticky && "sticky top-0 z-10",
32
+ className
33
+ )}
34
+ {...props}
35
+ />
18
36
  ));
19
37
  TableHeader.displayName = "TableHeader";
20
38
 
@@ -22,7 +40,12 @@ const TableBody = React.forwardRef<
22
40
  HTMLTableSectionElement,
23
41
  React.HTMLAttributes<HTMLTableSectionElement>
24
42
  >(({ className, ...props }, ref) => (
25
- <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
43
+ <tbody
44
+ ref={ref}
45
+ data-slot="table-body"
46
+ className={cn("[&_tr:last-child]:border-0", className)}
47
+ {...props}
48
+ />
26
49
  ));
27
50
  TableBody.displayName = "TableBody";
28
51
 
@@ -32,39 +55,88 @@ const TableFooter = React.forwardRef<
32
55
  >(({ className, ...props }, ref) => (
33
56
  <tfoot
34
57
  ref={ref}
35
- className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
58
+ data-slot="table-footer"
59
+ className={cn("border-t border-border bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
36
60
  {...props}
37
61
  />
38
62
  ));
39
63
  TableFooter.displayName = "TableFooter";
40
64
 
41
- const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
42
- ({ className, ...props }, ref) => (
43
- <tr
44
- ref={ref}
45
- className={cn(
46
- "border-b border-border/50 transition-all duration-300 hover:bg-muted/30 hover:backdrop-blur-sm data-[state=selected]:bg-muted/50",
47
- className
48
- )}
49
- {...props}
50
- />
51
- )
52
- );
53
- TableRow.displayName = "TableRow";
54
-
55
- const TableHead = React.forwardRef<
56
- HTMLTableCellElement,
57
- React.ThHTMLAttributes<HTMLTableCellElement>
58
- >(({ className, ...props }, ref) => (
59
- <th
65
+ const TableRow = React.forwardRef<
66
+ HTMLTableRowElement,
67
+ React.HTMLAttributes<HTMLTableRowElement> & { interactive?: boolean }
68
+ >(({ className, interactive, ...props }, ref) => (
69
+ <tr
60
70
  ref={ref}
71
+ data-slot="table-row"
61
72
  className={cn(
62
- "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
73
+ "border-b border-border transition-colors data-[state=selected]:bg-muted",
74
+ interactive && "cursor-pointer hover:bg-muted/50",
63
75
  className
64
76
  )}
65
77
  {...props}
66
78
  />
67
79
  ));
80
+ TableRow.displayName = "TableRow";
81
+
82
+ type SortDirection = "asc" | "desc" | "none";
83
+
84
+ type TableHeadProps = React.ThHTMLAttributes<HTMLTableCellElement> & {
85
+ sortable?: boolean;
86
+ sortDirection?: SortDirection;
87
+ onSort?: () => void;
88
+ };
89
+
90
+ const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
91
+ ({ className, sortable, sortDirection = "none", onSort, children, ...props }, ref) => {
92
+ if (!sortable) {
93
+ return (
94
+ <th
95
+ ref={ref}
96
+ data-slot="table-head"
97
+ className={cn(
98
+ "h-11 px-4 text-left align-middle text-xs font-semibold uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0",
99
+ className
100
+ )}
101
+ {...props}
102
+ >
103
+ {children}
104
+ </th>
105
+ );
106
+ }
107
+
108
+ const ariaSort =
109
+ sortDirection === "asc" ? "ascending" : sortDirection === "desc" ? "descending" : "none";
110
+
111
+ return (
112
+ <th
113
+ ref={ref}
114
+ data-slot="table-head"
115
+ aria-sort={ariaSort}
116
+ className={cn(
117
+ "h-11 px-4 text-left align-middle text-xs font-semibold uppercase tracking-wide text-muted-foreground [&:has([role=checkbox])]:pr-0",
118
+ className
119
+ )}
120
+ {...props}
121
+ >
122
+ <button
123
+ type="button"
124
+ onClick={onSort}
125
+ className="inline-flex items-center gap-1 rounded-sm text-inherit transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
126
+ >
127
+ {children}
128
+ {sortDirection === "asc" ? (
129
+ <ChevronUp className="size-3.5" aria-hidden />
130
+ ) : sortDirection === "desc" ? (
131
+ <ChevronDown className="size-3.5" aria-hidden />
132
+ ) : (
133
+ <ChevronsUpDown className="size-3.5 opacity-50" aria-hidden />
134
+ )}
135
+ </button>
136
+ </th>
137
+ );
138
+ }
139
+ );
68
140
  TableHead.displayName = "TableHead";
69
141
 
70
142
  const TableCell = React.forwardRef<
@@ -73,7 +145,8 @@ const TableCell = React.forwardRef<
73
145
  >(({ className, ...props }, ref) => (
74
146
  <td
75
147
  ref={ref}
76
- className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
148
+ data-slot="table-cell"
149
+ className={cn("p-4 align-middle text-sm [&:has([role=checkbox])]:pr-0", className)}
77
150
  {...props}
78
151
  />
79
152
  ));
@@ -83,8 +156,40 @@ const TableCaption = React.forwardRef<
83
156
  HTMLTableCaptionElement,
84
157
  React.HTMLAttributes<HTMLTableCaptionElement>
85
158
  >(({ className, ...props }, ref) => (
86
- <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
159
+ <caption
160
+ ref={ref}
161
+ data-slot="table-caption"
162
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
163
+ {...props}
164
+ />
87
165
  ));
88
166
  TableCaption.displayName = "TableCaption";
89
167
 
90
- export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
168
+ const TableEmpty = ({
169
+ colSpan,
170
+ children = "No results.",
171
+ className,
172
+ ...props
173
+ }: React.ComponentProps<"tr"> & { colSpan: number }) => (
174
+ <tr data-slot="table-empty" {...props}>
175
+ <td
176
+ colSpan={colSpan}
177
+ className={cn("h-24 text-center text-sm text-muted-foreground", className)}
178
+ >
179
+ {children}
180
+ </td>
181
+ </tr>
182
+ );
183
+ TableEmpty.displayName = "TableEmpty";
184
+
185
+ export {
186
+ Table,
187
+ TableHeader,
188
+ TableBody,
189
+ TableFooter,
190
+ TableHead,
191
+ TableRow,
192
+ TableCell,
193
+ TableCaption,
194
+ TableEmpty,
195
+ };