@emara/ui 1.1.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 (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,131 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+
3
+ import {
4
+ Skeleton,
5
+ SkeletonButton,
6
+ SkeletonCard,
7
+ SkeletonCircle,
8
+ SkeletonInput,
9
+ SkeletonTable,
10
+ SkeletonText,
11
+ } from "./skeleton";
12
+
13
+ const meta: Meta<typeof Skeleton> = {
14
+ title: "Foundations/Skeleton",
15
+ component: Skeleton,
16
+ parameters: { layout: "centered" },
17
+ argTypes: {
18
+ animation: { control: "select", options: ["pulse", "shimmer", "none"] },
19
+ },
20
+ };
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof Skeleton>;
24
+
25
+ export const Default: Story = {
26
+ args: { className: "h-4 w-48" },
27
+ };
28
+
29
+ export const Animations: Story = {
30
+ render: () => (
31
+ <div className="w-96 space-y-4">
32
+ <div className="space-y-1">
33
+ <p className="text-muted-foreground text-xs">pulse</p>
34
+ <Skeleton animation="pulse" className="h-4 w-full" />
35
+ </div>
36
+ <div className="space-y-1">
37
+ <p className="text-muted-foreground text-xs">shimmer</p>
38
+ <Skeleton animation="shimmer" className="h-4 w-full" />
39
+ </div>
40
+ <div className="space-y-1">
41
+ <p className="text-muted-foreground text-xs">none</p>
42
+ <Skeleton animation="none" className="h-4 w-full" />
43
+ </div>
44
+ </div>
45
+ ),
46
+ };
47
+
48
+ export const Text: Story = {
49
+ render: () => (
50
+ <div className="w-96">
51
+ <SkeletonText lines={4} />
52
+ </div>
53
+ ),
54
+ };
55
+
56
+ export const Circles: Story = {
57
+ render: () => (
58
+ <div className="flex items-end gap-3">
59
+ <SkeletonCircle size="xs" />
60
+ <SkeletonCircle size="sm" />
61
+ <SkeletonCircle size="md" />
62
+ <SkeletonCircle size="lg" />
63
+ <SkeletonCircle size="xl" />
64
+ <SkeletonCircle size="2xl" />
65
+ </div>
66
+ ),
67
+ };
68
+
69
+ /**
70
+ * SkeletonCircle with the shimmer animation. Validates that the parent
71
+ * Skeleton's shimmer band (via `before:` pseudo-element) correctly clips
72
+ * to the circle shape and is visible against the base fill.
73
+ */
74
+ export const CirclesWithShimmer: Story = {
75
+ render: () => (
76
+ <div className="flex items-end gap-3">
77
+ <SkeletonCircle size="sm" animation="shimmer" />
78
+ <SkeletonCircle size="md" animation="shimmer" />
79
+ <SkeletonCircle size="lg" animation="shimmer" />
80
+ <SkeletonCircle size="xl" animation="shimmer" />
81
+ </div>
82
+ ),
83
+ };
84
+
85
+ export const Buttons: Story = {
86
+ render: () => (
87
+ <div className="flex items-end gap-3">
88
+ <SkeletonButton size="xs" />
89
+ <SkeletonButton size="sm" />
90
+ <SkeletonButton size="default" />
91
+ <SkeletonButton size="lg" />
92
+ <SkeletonButton size="xl" />
93
+ </div>
94
+ ),
95
+ };
96
+
97
+ export const Inputs: Story = {
98
+ render: () => (
99
+ <div className="w-72 space-y-2">
100
+ <SkeletonInput size="xs" />
101
+ <SkeletonInput size="sm" />
102
+ <SkeletonInput size="md" />
103
+ <SkeletonInput size="lg" />
104
+ <SkeletonInput size="xl" />
105
+ </div>
106
+ ),
107
+ };
108
+
109
+ export const Card: Story = {
110
+ render: () => (
111
+ <div className="w-96">
112
+ <SkeletonCard />
113
+ </div>
114
+ ),
115
+ };
116
+
117
+ export const Table: Story = {
118
+ render: () => (
119
+ <div className="w-[40rem]">
120
+ <SkeletonTable rows={5} cols={4} />
121
+ </div>
122
+ ),
123
+ };
124
+
125
+ export const ShimmerLayout: Story = {
126
+ render: () => (
127
+ <div className="w-96">
128
+ <SkeletonCard animation="shimmer" />
129
+ </div>
130
+ ),
131
+ };
@@ -0,0 +1,311 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ // Per docs/emara-ui-phase-1-components.md §9.
10
+
11
+ const skeletonVariants = cva("rounded-md bg-foreground/10", {
12
+ variants: {
13
+ animation: {
14
+ pulse: "animate-pulse",
15
+ shimmer: [
16
+ "relative overflow-hidden isolate",
17
+ // Sliding band — a contrasting tone sweeps across the base fill.
18
+ // Base is `bg-foreground/10`; the band is `foreground/35` so it's
19
+ // visibly darker in light mode and visibly lighter in dark mode.
20
+ // The keyframe `shimmer-slide` (in globals.css) animates translateX
21
+ // and the cascade flips direction for RTL.
22
+ "before:absolute before:inset-0 before:content-[''] before:z-10 before:pointer-events-none",
23
+ "before:bg-gradient-to-r before:from-transparent before:via-foreground/35 before:to-transparent",
24
+ "before:animate-[shimmer-slide_1.2s_linear_infinite]",
25
+ ].join(" "),
26
+ none: "",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ animation: "pulse",
31
+ },
32
+ });
33
+
34
+ type SkeletonVariants = VariantProps<typeof skeletonVariants>;
35
+
36
+ type SkeletonProps = React.HTMLAttributes<HTMLDivElement> &
37
+ SkeletonVariants & {
38
+ asChild?: boolean;
39
+ };
40
+
41
+ const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(function Skeleton(
42
+ { className, animation, asChild = false, ...props },
43
+ ref,
44
+ ) {
45
+ const Comp = asChild ? Slot : "div";
46
+ return (
47
+ <Comp
48
+ ref={ref}
49
+ aria-hidden="true"
50
+ className={cn(skeletonVariants({ animation }), className)}
51
+ {...props}
52
+ />
53
+ );
54
+ });
55
+ Skeleton.displayName = "Skeleton";
56
+
57
+ // ============================================================================
58
+ // Preset shapes (Emara additions). All are aria-hidden via the base Skeleton.
59
+ // ============================================================================
60
+
61
+ // --- SkeletonText -----------------------------------------------------------
62
+
63
+ type SkeletonTextProps = Omit<SkeletonProps, "asChild"> & {
64
+ lines?: number;
65
+ /** Width of the last line as a fraction of full width. Default 0.6. */
66
+ lastLineWidth?: number;
67
+ };
68
+
69
+ // Pre-computed "realistic" widths for paragraphs. Repeats after this many lines.
70
+ const TEXT_LINE_WIDTHS = ["w-full", "w-11/12", "w-full", "w-10/12", "w-full"];
71
+
72
+ const SkeletonText = forwardRef<HTMLDivElement, SkeletonTextProps>(function SkeletonText(
73
+ { className, animation, lines = 3, lastLineWidth = 0.6, ...props },
74
+ ref,
75
+ ) {
76
+ const lastWidthStyle: React.CSSProperties = { width: `${Math.round(lastLineWidth * 100)}%` };
77
+ return (
78
+ <div ref={ref} aria-hidden="true" className={cn("space-y-2", className)} {...props}>
79
+ {Array.from({ length: Math.max(1, lines) }, (_, i) => {
80
+ const isLast = i === lines - 1 && lines > 1;
81
+ const widthClass = isLast
82
+ ? ""
83
+ : (TEXT_LINE_WIDTHS[i % TEXT_LINE_WIDTHS.length] ?? "w-full");
84
+ return (
85
+ <Skeleton
86
+ key={i}
87
+ animation={animation}
88
+ className={cn("h-4", widthClass)}
89
+ style={isLast ? lastWidthStyle : undefined}
90
+ />
91
+ );
92
+ })}
93
+ </div>
94
+ );
95
+ });
96
+ SkeletonText.displayName = "SkeletonText";
97
+
98
+ // --- SkeletonCircle ---------------------------------------------------------
99
+
100
+ type SkeletonCircleSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
101
+
102
+ // Note: animation is intentionally NOT part of this variant set — the parent
103
+ // <Skeleton> already supplies the right classes for pulse / shimmer / none
104
+ // (including the relative+overflow-hidden+::before band that shimmer needs).
105
+ // Duplicating animation here used to strip the shimmer styling, so the audit
106
+ // flagged "shimmer doesn't work on SkeletonCircle".
107
+ const skeletonCircleVariants = cva("rounded-full", {
108
+ variants: {
109
+ size: {
110
+ xs: "size-6",
111
+ sm: "size-8",
112
+ md: "size-10",
113
+ lg: "size-12",
114
+ xl: "size-16",
115
+ "2xl": "size-24",
116
+ },
117
+ },
118
+ defaultVariants: { size: "md" },
119
+ });
120
+
121
+ type SkeletonCircleProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
122
+ size?: SkeletonCircleSize;
123
+ animation?: SkeletonVariants["animation"];
124
+ };
125
+
126
+ const SkeletonCircle = forwardRef<HTMLDivElement, SkeletonCircleProps>(function SkeletonCircle(
127
+ { className, size, animation = "pulse", ...props },
128
+ ref,
129
+ ) {
130
+ return (
131
+ <Skeleton
132
+ ref={ref}
133
+ animation={animation}
134
+ className={cn(skeletonCircleVariants({ size }), className)}
135
+ {...props}
136
+ />
137
+ );
138
+ });
139
+ SkeletonCircle.displayName = "SkeletonCircle";
140
+
141
+ // --- SkeletonButton ---------------------------------------------------------
142
+
143
+ type SkeletonButtonSize = "xs" | "sm" | "default" | "lg" | "xl";
144
+
145
+ const skeletonButtonVariants = cva("rounded-md bg-foreground/10", {
146
+ variants: {
147
+ size: {
148
+ xs: "h-7",
149
+ sm: "h-8",
150
+ default: "h-9",
151
+ lg: "h-10",
152
+ xl: "h-12",
153
+ },
154
+ width: {
155
+ auto: "w-24",
156
+ full: "w-full",
157
+ },
158
+ },
159
+ defaultVariants: { size: "default", width: "auto" },
160
+ });
161
+
162
+ type SkeletonButtonProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
163
+ size?: SkeletonButtonSize;
164
+ width?: "auto" | "full";
165
+ animation?: SkeletonVariants["animation"];
166
+ };
167
+
168
+ const SkeletonButton = forwardRef<HTMLDivElement, SkeletonButtonProps>(function SkeletonButton(
169
+ { className, size, width, animation = "pulse", ...props },
170
+ ref,
171
+ ) {
172
+ return (
173
+ <Skeleton
174
+ ref={ref}
175
+ animation={animation}
176
+ className={cn(skeletonButtonVariants({ size, width }), className)}
177
+ {...props}
178
+ />
179
+ );
180
+ });
181
+ SkeletonButton.displayName = "SkeletonButton";
182
+
183
+ // --- SkeletonInput ----------------------------------------------------------
184
+
185
+ type SkeletonInputSize = "xs" | "sm" | "md" | "lg" | "xl";
186
+
187
+ const skeletonInputVariants = cva("rounded-md bg-foreground/10 w-full", {
188
+ variants: {
189
+ size: {
190
+ xs: "h-7",
191
+ sm: "h-8",
192
+ md: "h-9",
193
+ lg: "h-10",
194
+ xl: "h-12",
195
+ },
196
+ },
197
+ defaultVariants: { size: "md" },
198
+ });
199
+
200
+ type SkeletonInputProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
201
+ size?: SkeletonInputSize;
202
+ animation?: SkeletonVariants["animation"];
203
+ };
204
+
205
+ const SkeletonInput = forwardRef<HTMLDivElement, SkeletonInputProps>(function SkeletonInput(
206
+ { className, size, animation = "pulse", ...props },
207
+ ref,
208
+ ) {
209
+ return (
210
+ <Skeleton
211
+ ref={ref}
212
+ animation={animation}
213
+ className={cn(skeletonInputVariants({ size }), className)}
214
+ {...props}
215
+ />
216
+ );
217
+ });
218
+ SkeletonInput.displayName = "SkeletonInput";
219
+
220
+ // --- SkeletonCard -----------------------------------------------------------
221
+
222
+ type SkeletonCardProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
223
+ /** Show the header section (title + description). Default true. */
224
+ header?: boolean;
225
+ /** Number of body lines. Default 3. */
226
+ lines?: number;
227
+ animation?: SkeletonVariants["animation"];
228
+ };
229
+
230
+ const SkeletonCard = forwardRef<HTMLDivElement, SkeletonCardProps>(function SkeletonCard(
231
+ { className, header = true, lines = 3, animation = "pulse", ...props },
232
+ ref,
233
+ ) {
234
+ return (
235
+ <div
236
+ ref={ref}
237
+ aria-hidden="true"
238
+ className={cn("border-border space-y-4 rounded-md border p-4", className)}
239
+ {...props}
240
+ >
241
+ {header ? (
242
+ <div className="space-y-2">
243
+ <Skeleton animation={animation} className="h-5 w-2/5" />
244
+ <Skeleton animation={animation} className="h-4 w-3/5" />
245
+ </div>
246
+ ) : null}
247
+ <SkeletonText animation={animation} lines={lines} />
248
+ </div>
249
+ );
250
+ });
251
+ SkeletonCard.displayName = "SkeletonCard";
252
+
253
+ // --- SkeletonTable ----------------------------------------------------------
254
+
255
+ type SkeletonTableProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
256
+ rows?: number;
257
+ cols?: number;
258
+ animation?: SkeletonVariants["animation"];
259
+ };
260
+
261
+ const SkeletonTable = forwardRef<HTMLDivElement, SkeletonTableProps>(function SkeletonTable(
262
+ { className, rows = 5, cols = 4, animation = "pulse", ...props },
263
+ ref,
264
+ ) {
265
+ return (
266
+ <div ref={ref} aria-hidden="true" className={cn("space-y-2", className)} {...props}>
267
+ {/* Header row */}
268
+ <div
269
+ className="grid gap-3"
270
+ style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
271
+ >
272
+ {Array.from({ length: cols }, (_, c) => (
273
+ <Skeleton key={`h-${c}`} animation={animation} className="h-4" />
274
+ ))}
275
+ </div>
276
+ {/* Body rows */}
277
+ {Array.from({ length: rows }, (_, r) => (
278
+ <div
279
+ key={r}
280
+ className="grid gap-3"
281
+ style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
282
+ >
283
+ {Array.from({ length: cols }, (_, c) => (
284
+ <Skeleton key={`${r}-${c}`} animation={animation} className="h-4" />
285
+ ))}
286
+ </div>
287
+ ))}
288
+ </div>
289
+ );
290
+ });
291
+ SkeletonTable.displayName = "SkeletonTable";
292
+
293
+ export {
294
+ Skeleton,
295
+ SkeletonText,
296
+ SkeletonCircle,
297
+ SkeletonButton,
298
+ SkeletonInput,
299
+ SkeletonCard,
300
+ SkeletonTable,
301
+ skeletonVariants,
302
+ };
303
+ export type {
304
+ SkeletonProps,
305
+ SkeletonTextProps,
306
+ SkeletonCircleProps,
307
+ SkeletonButtonProps,
308
+ SkeletonInputProps,
309
+ SkeletonCardProps,
310
+ SkeletonTableProps,
311
+ };
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { RiMoonLine, RiSunLine } from "@remixicon/react";
3
+
4
+ import { Switch } from "./switch";
5
+
6
+ const meta: Meta<typeof Switch> = {
7
+ title: "Forms/Switch",
8
+ component: Switch,
9
+ parameters: { layout: "centered" },
10
+ argTypes: {
11
+ size: { control: "select", options: ["sm", "md", "lg"] },
12
+ loading: { control: "boolean" },
13
+ disabled: { control: "boolean" },
14
+ },
15
+ };
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof Switch>;
19
+
20
+ export const Default: Story = {};
21
+
22
+ export const Sizes: Story = {
23
+ render: () => (
24
+ <div className="flex items-center gap-4">
25
+ <Switch size="sm" defaultChecked />
26
+ <Switch size="md" defaultChecked />
27
+ <Switch size="lg" defaultChecked />
28
+ </div>
29
+ ),
30
+ };
31
+
32
+ export const States: Story = {
33
+ render: () => (
34
+ <div className="grid grid-cols-2 gap-3 text-sm">
35
+ <span>off</span>
36
+ <Switch />
37
+ <span>on</span>
38
+ <Switch defaultChecked />
39
+ <span>disabled off</span>
40
+ <Switch disabled />
41
+ <span>disabled on</span>
42
+ <Switch disabled defaultChecked />
43
+ <span>loading</span>
44
+ <Switch loading defaultChecked />
45
+ </div>
46
+ ),
47
+ };
48
+
49
+ export const PairedWithLabel: Story = {
50
+ render: () => (
51
+ <div className="flex items-center gap-3">
52
+ <Switch id="airplane" />
53
+ <label htmlFor="airplane" className="cursor-pointer text-sm font-medium">
54
+ Airplane mode
55
+ </label>
56
+ </div>
57
+ ),
58
+ };
59
+
60
+ export const WithIcons: Story = {
61
+ render: () => (
62
+ <Switch
63
+ defaultChecked
64
+ size="lg"
65
+ onIcon={<RiMoonLine />}
66
+ offIcon={<RiSunLine />}
67
+ aria-label="Theme"
68
+ />
69
+ ),
70
+ };
71
+
72
+ export const WithSideLabels: Story = {
73
+ render: () => <Switch defaultChecked size="lg" onLabel="ON" offLabel="OFF" aria-label="Power" />,
74
+ };
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import * as SwitchPrimitive from "@radix-ui/react-switch";
5
+ import { RiLoader2Line } from "@remixicon/react";
6
+ import { cva, type VariantProps } from "class-variance-authority";
7
+
8
+ import { cn } from "@/lib/utils";
9
+
10
+ // Per docs/emara-ui-phase-2-components.md §3.
11
+
12
+ const switchRootVariants = cva(
13
+ [
14
+ "peer inline-flex shrink-0 cursor-pointer items-center rounded-full",
15
+ "border border-transparent transition-colors",
16
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
17
+ "disabled:cursor-not-allowed disabled:opacity-50",
18
+ "data-[state=checked]:bg-primary",
19
+ "data-[state=unchecked]:bg-input",
20
+ "aria-busy:cursor-progress",
21
+ ].join(" "),
22
+ {
23
+ variants: {
24
+ size: {
25
+ // [track width × height], thumb size handled via the thumb's variants.
26
+ // Track widths leave enough room for short side-labels in the empty
27
+ // half (~22px at md, ~26px at lg).
28
+ sm: "h-5 w-10 px-0.5",
29
+ md: "h-6 w-12 px-0.5",
30
+ lg: "h-7 w-16 px-0.5",
31
+ },
32
+ },
33
+ defaultVariants: { size: "md" },
34
+ },
35
+ );
36
+
37
+ const switchThumbVariants = cva(
38
+ [
39
+ "pointer-events-none block rounded-full bg-background shadow-sm",
40
+ "transition-transform duration-fast",
41
+ // RTL: in `dir="rtl"` the "checked" position is on the visual start
42
+ // (LTR right == RTL left). Use a CSS variable so the transform flips.
43
+ "translate-x-0",
44
+ ].join(" "),
45
+ {
46
+ variants: {
47
+ size: {
48
+ // Translation = (track width - thumb size - 2 × px-0.5).
49
+ // sm: 40 - 16 - 4 = 20 → translate-x-5
50
+ // md: 48 - 20 - 4 = 24 → translate-x-6
51
+ // lg: 64 - 24 - 4 = 36 → translate-x-9
52
+ sm: "size-4 data-[state=checked]:translate-x-5 rtl:data-[state=checked]:-translate-x-5",
53
+ md: "size-5 data-[state=checked]:translate-x-6 rtl:data-[state=checked]:-translate-x-6",
54
+ lg: "size-6 data-[state=checked]:translate-x-9 rtl:data-[state=checked]:-translate-x-9",
55
+ },
56
+ },
57
+ defaultVariants: { size: "md" },
58
+ },
59
+ );
60
+
61
+ const thumbIconSize: Record<"sm" | "md" | "lg", string> = {
62
+ sm: "size-2.5",
63
+ md: "size-3",
64
+ lg: "size-3.5",
65
+ };
66
+
67
+ // Side-label sizes are intentionally **sub-canonical** (below the type-scale's
68
+ // `--text-2xs` step at 11px) — they're decorative micro-labels that sit inside
69
+ // the empty half of the track, not body text. Per design-tokens §4.2's note on
70
+ // sub-canonical micro-labels: arbitrary px values are allowed here precisely
71
+ // because these aren't part of the readable type scale. No `tracking-wide`
72
+ // — it would expand the text and clip it against the track edge.
73
+ const sideLabelSize: Record<"sm" | "md" | "lg", string> = {
74
+ sm: "text-[8px]",
75
+ md: "text-[9px]",
76
+ lg: "text-[10px]",
77
+ };
78
+
79
+ type SwitchVariants = VariantProps<typeof switchRootVariants>;
80
+
81
+ type SwitchProps = Omit<React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>, "size"> &
82
+ SwitchVariants & {
83
+ loading?: boolean;
84
+ onIcon?: React.ReactNode;
85
+ offIcon?: React.ReactNode;
86
+ onLabel?: string;
87
+ offLabel?: string;
88
+ };
89
+
90
+ const Switch = forwardRef<React.ElementRef<typeof SwitchPrimitive.Root>, SwitchProps>(
91
+ function Switch(
92
+ {
93
+ className,
94
+ size = "md",
95
+ loading = false,
96
+ onIcon,
97
+ offIcon,
98
+ onLabel,
99
+ offLabel,
100
+ disabled,
101
+ checked,
102
+ defaultChecked,
103
+ ...props
104
+ },
105
+ ref,
106
+ ) {
107
+ const resolvedSize = size ?? "md";
108
+ const isDisabled = disabled || loading;
109
+ // exactOptionalPropertyTypes: explicitly omit `checked`/`defaultChecked`
110
+ // when undefined rather than passing through.
111
+ const controlProps: Pick<
112
+ React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
113
+ "checked" | "defaultChecked"
114
+ > = {};
115
+ if (checked !== undefined) controlProps.checked = checked;
116
+ if (defaultChecked !== undefined) controlProps.defaultChecked = defaultChecked;
117
+
118
+ return (
119
+ <SwitchPrimitive.Root
120
+ ref={ref}
121
+ disabled={isDisabled}
122
+ aria-busy={loading || undefined}
123
+ {...controlProps}
124
+ className={cn(switchRootVariants({ size }), "relative", className)}
125
+ {...props}
126
+ >
127
+ {/* Optional inline side-labels. The Root carries `data-state`; child
128
+ spans react via the `in-data-[state=...]` Tailwind v4 modifier so the
129
+ label that's NOT covered by the thumb is the one visible:
130
+ - on → thumb at end → ON label appears at start
131
+ - off → thumb at start → OFF label appears at end
132
+ Pointer-events-none so the labels never block the toggle. */}
133
+ {onLabel ? (
134
+ <span
135
+ aria-hidden="true"
136
+ className={cn(
137
+ // Sit at the start side (empty when on), vertically centered.
138
+ "text-primary-foreground pointer-events-none absolute inset-y-0 start-1 inline-flex items-center leading-none font-semibold uppercase select-none",
139
+ sideLabelSize[resolvedSize],
140
+ "opacity-0 in-data-[state=checked]:opacity-100",
141
+ )}
142
+ >
143
+ {onLabel}
144
+ </span>
145
+ ) : null}
146
+ {offLabel ? (
147
+ <span
148
+ aria-hidden="true"
149
+ className={cn(
150
+ // Sit at the end side (empty when off), vertically centered.
151
+ "text-muted-foreground pointer-events-none absolute inset-y-0 end-1 inline-flex items-center leading-none font-semibold uppercase select-none",
152
+ sideLabelSize[resolvedSize],
153
+ "opacity-100 in-data-[state=checked]:opacity-0",
154
+ )}
155
+ >
156
+ {offLabel}
157
+ </span>
158
+ ) : null}
159
+
160
+ <SwitchPrimitive.Thumb className={cn(switchThumbVariants({ size }))}>
161
+ {/* Thumb-internal slot: spinner during loading, otherwise on/off icon
162
+ driven by the parent's data-state (peer would be cleaner but the
163
+ Thumb's own [data-state] does the same job at the Radix level). */}
164
+ <span className="text-foreground/70 flex h-full w-full items-center justify-center [&_svg]:size-3 [&_svg]:shrink-0">
165
+ {loading ? (
166
+ <RiLoader2Line className={cn(thumbIconSize[resolvedSize], "animate-spin")} />
167
+ ) : (
168
+ <>
169
+ {onIcon ? (
170
+ <span className="hidden in-data-[state=checked]:inline-flex">{onIcon}</span>
171
+ ) : null}
172
+ {offIcon ? (
173
+ <span className="inline-flex in-data-[state=checked]:hidden">{offIcon}</span>
174
+ ) : null}
175
+ </>
176
+ )}
177
+ </span>
178
+ </SwitchPrimitive.Thumb>
179
+ </SwitchPrimitive.Root>
180
+ );
181
+ },
182
+ );
183
+ Switch.displayName = "Switch";
184
+
185
+ export { Switch, switchRootVariants, switchThumbVariants };
186
+ export type { SwitchProps };