@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.
- package/components/ui/.gitkeep +0 -0
- package/components/ui/accordion.stories.tsx +231 -0
- package/components/ui/accordion.tsx +250 -0
- package/components/ui/app-shell.stories.tsx +270 -0
- package/components/ui/app-shell.tsx +491 -0
- package/components/ui/avatar.stories.tsx +174 -0
- package/components/ui/avatar.tsx +257 -0
- package/components/ui/badge.stories.tsx +127 -0
- package/components/ui/badge.tsx +146 -0
- package/components/ui/breadcrumb.stories.tsx +92 -0
- package/components/ui/breadcrumb.tsx +302 -0
- package/components/ui/button.stories.tsx +186 -0
- package/components/ui/button.tsx +128 -0
- package/components/ui/card.stories.tsx +279 -0
- package/components/ui/card.tsx +250 -0
- package/components/ui/checkbox.stories.tsx +93 -0
- package/components/ui/checkbox.tsx +131 -0
- package/components/ui/combobox.stories.tsx +489 -0
- package/components/ui/combobox.tsx +874 -0
- package/components/ui/context-menu.stories.tsx +202 -0
- package/components/ui/context-menu.tsx +309 -0
- package/components/ui/data-table.stories.tsx +227 -0
- package/components/ui/data-table.tsx +539 -0
- package/components/ui/date-picker.stories.tsx +225 -0
- package/components/ui/date-picker.tsx +597 -0
- package/components/ui/dialog.stories.tsx +193 -0
- package/components/ui/dialog.tsx +262 -0
- package/components/ui/divider.stories.tsx +84 -0
- package/components/ui/divider.tsx +135 -0
- package/components/ui/drawer.stories.tsx +218 -0
- package/components/ui/drawer.tsx +329 -0
- package/components/ui/dropdown-menu.stories.tsx +270 -0
- package/components/ui/dropdown-menu.tsx +353 -0
- package/components/ui/empty-state.stories.tsx +121 -0
- package/components/ui/empty-state.tsx +289 -0
- package/components/ui/field-group.stories.tsx +201 -0
- package/components/ui/field-group.tsx +276 -0
- package/components/ui/form.stories.tsx +219 -0
- package/components/ui/form.tsx +542 -0
- package/components/ui/input.stories.tsx +154 -0
- package/components/ui/input.tsx +208 -0
- package/components/ui/label.stories.tsx +84 -0
- package/components/ui/label.tsx +98 -0
- package/components/ui/page-header.stories.tsx +136 -0
- package/components/ui/page-header.tsx +315 -0
- package/components/ui/pagination.stories.tsx +136 -0
- package/components/ui/pagination.tsx +427 -0
- package/components/ui/popover.stories.tsx +212 -0
- package/components/ui/popover.tsx +167 -0
- package/components/ui/radio-group.stories.tsx +96 -0
- package/components/ui/radio-group.tsx +250 -0
- package/components/ui/select.stories.tsx +203 -0
- package/components/ui/select.tsx +318 -0
- package/components/ui/sidebar.stories.tsx +186 -0
- package/components/ui/sidebar.tsx +623 -0
- package/components/ui/skeleton.stories.tsx +131 -0
- package/components/ui/skeleton.tsx +311 -0
- package/components/ui/switch.stories.tsx +74 -0
- package/components/ui/switch.tsx +186 -0
- package/components/ui/table.stories.tsx +107 -0
- package/components/ui/table.tsx +285 -0
- package/components/ui/tabs.stories.tsx +222 -0
- package/components/ui/tabs.tsx +287 -0
- package/components/ui/textarea.stories.tsx +96 -0
- package/components/ui/textarea.tsx +182 -0
- package/components/ui/toast.stories.tsx +169 -0
- package/components/ui/toast.tsx +250 -0
- package/components/ui/tooltip.stories.tsx +146 -0
- package/components/ui/tooltip.tsx +156 -0
- package/components/ui/top-bar.stories.tsx +182 -0
- package/components/ui/top-bar.tsx +155 -0
- package/dist/components/ui/accordion.d.ts +45 -0
- package/dist/components/ui/accordion.d.ts.map +1 -0
- package/dist/components/ui/accordion.js +99 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/app-shell.d.ts +70 -0
- package/dist/components/ui/app-shell.d.ts.map +1 -0
- package/dist/components/ui/app-shell.js +199 -0
- package/dist/components/ui/app-shell.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +41 -0
- package/dist/components/ui/avatar.d.ts.map +1 -0
- package/dist/components/ui/avatar.js +104 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +27 -0
- package/dist/components/ui/badge.d.ts.map +1 -0
- package/dist/components/ui/badge.js +65 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +35 -0
- package/dist/components/ui/breadcrumb.d.ts.map +1 -0
- package/dist/components/ui/breadcrumb.js +88 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button.d.ts +26 -0
- package/dist/components/ui/button.d.ts.map +1 -0
- package/dist/components/ui/button.js +73 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/card.d.ts +52 -0
- package/dist/components/ui/card.d.ts.map +1 -0
- package/dist/components/ui/card.js +96 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +18 -0
- package/dist/components/ui/checkbox.d.ts.map +1 -0
- package/dist/components/ui/checkbox.js +59 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/combobox.d.ts +194 -0
- package/dist/components/ui/combobox.d.ts.map +1 -0
- package/dist/components/ui/combobox.js +361 -0
- package/dist/components/ui/combobox.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +46 -0
- package/dist/components/ui/context-menu.d.ts.map +1 -0
- package/dist/components/ui/context-menu.js +95 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/data-table.d.ts +53 -0
- package/dist/components/ui/data-table.d.ts.map +1 -0
- package/dist/components/ui/data-table.js +163 -0
- package/dist/components/ui/data-table.js.map +1 -0
- package/dist/components/ui/date-picker.d.ts +103 -0
- package/dist/components/ui/date-picker.d.ts.map +1 -0
- package/dist/components/ui/date-picker.js +306 -0
- package/dist/components/ui/date-picker.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +40 -0
- package/dist/components/ui/dialog.d.ts.map +1 -0
- package/dist/components/ui/dialog.js +110 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/divider.d.ts +30 -0
- package/dist/components/ui/divider.d.ts.map +1 -0
- package/dist/components/ui/divider.js +62 -0
- package/dist/components/ui/divider.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +56 -0
- package/dist/components/ui/drawer.d.ts.map +1 -0
- package/dist/components/ui/drawer.js +147 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +63 -0
- package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
- package/dist/components/ui/dropdown-menu.js +116 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/empty-state.d.ts +43 -0
- package/dist/components/ui/empty-state.d.ts.map +1 -0
- package/dist/components/ui/empty-state.js +128 -0
- package/dist/components/ui/empty-state.js.map +1 -0
- package/dist/components/ui/field-group.d.ts +38 -0
- package/dist/components/ui/field-group.d.ts.map +1 -0
- package/dist/components/ui/field-group.js +107 -0
- package/dist/components/ui/field-group.js.map +1 -0
- package/dist/components/ui/form.d.ts +67 -0
- package/dist/components/ui/form.d.ts.map +1 -0
- package/dist/components/ui/form.js +286 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/input.d.ts +36 -0
- package/dist/components/ui/input.d.ts.map +1 -0
- package/dist/components/ui/input.js +99 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/label.d.ts +37 -0
- package/dist/components/ui/label.d.ts.map +1 -0
- package/dist/components/ui/label.js +34 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/page-header.d.ts +65 -0
- package/dist/components/ui/page-header.d.ts.map +1 -0
- package/dist/components/ui/page-header.js +140 -0
- package/dist/components/ui/page-header.js.map +1 -0
- package/dist/components/ui/pagination.d.ts +67 -0
- package/dist/components/ui/pagination.d.ts.map +1 -0
- package/dist/components/ui/pagination.js +109 -0
- package/dist/components/ui/pagination.js.map +1 -0
- package/dist/components/ui/popover.d.ts +28 -0
- package/dist/components/ui/popover.d.ts.map +1 -0
- package/dist/components/ui/popover.js +85 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +35 -0
- package/dist/components/ui/radio-group.d.ts.map +1 -0
- package/dist/components/ui/radio-group.js +103 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/select.d.ts +42 -0
- package/dist/components/ui/select.d.ts.map +1 -0
- package/dist/components/ui/select.js +86 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/sidebar.d.ts +59 -0
- package/dist/components/ui/sidebar.d.ts.map +1 -0
- package/dist/components/ui/sidebar.js +189 -0
- package/dist/components/ui/sidebar.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +77 -0
- package/dist/components/ui/skeleton.d.ts.map +1 -0
- package/dist/components/ui/skeleton.js +115 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/switch.d.ts +26 -0
- package/dist/components/ui/switch.d.ts.map +1 -0
- package/dist/components/ui/switch.js +84 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/table.d.ts +52 -0
- package/dist/components/ui/table.d.ts.map +1 -0
- package/dist/components/ui/table.js +109 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +42 -0
- package/dist/components/ui/tabs.d.ts.map +1 -0
- package/dist/components/ui/tabs.js +163 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +26 -0
- package/dist/components/ui/textarea.d.ts.map +1 -0
- package/dist/components/ui/textarea.js +96 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/toast.d.ts +77 -0
- package/dist/components/ui/toast.d.ts.map +1 -0
- package/dist/components/ui/toast.js +141 -0
- package/dist/components/ui/toast.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +31 -0
- package/dist/components/ui/tooltip.d.ts.map +1 -0
- package/dist/components/ui/tooltip.js +71 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/components/ui/top-bar.d.ts +30 -0
- package/dist/components/ui/top-bar.d.ts.map +1 -0
- package/dist/components/ui/top-bar.js +64 -0
- package/dist/components/ui/top-bar.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/lib/utils.ts +6 -0
- package/package.json +112 -0
- package/styles/globals.css +685 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, forwardRef, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
5
|
+
import { cva } from "class-variance-authority";
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
|
|
9
|
+
// Per docs/emara-ui-phase-4-components.md §3.
|
|
10
|
+
|
|
11
|
+
type TabsVariant = "line" | "pill" | "card" | "vertical";
|
|
12
|
+
type TabsSize = "sm" | "md" | "lg";
|
|
13
|
+
|
|
14
|
+
interface TabsContextValue {
|
|
15
|
+
variant: TabsVariant;
|
|
16
|
+
size: TabsSize;
|
|
17
|
+
lazy: boolean;
|
|
18
|
+
unmountInactive: boolean;
|
|
19
|
+
/** Values that have ever been activated — used for `lazy` mount tracking. */
|
|
20
|
+
activated: Set<string>;
|
|
21
|
+
markActivated: (value: string) => void;
|
|
22
|
+
currentValue: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
function useTabsContext(): TabsContextValue {
|
|
28
|
+
const ctx = useContext(TabsContext);
|
|
29
|
+
if (!ctx) {
|
|
30
|
+
throw new Error("Tabs subcomponents must be used inside <Tabs>");
|
|
31
|
+
}
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Tabs (root) ------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
type TabsRootProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
|
|
38
|
+
variant?: TabsVariant;
|
|
39
|
+
size?: TabsSize;
|
|
40
|
+
lazy?: boolean;
|
|
41
|
+
unmountInactive?: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const Tabs = forwardRef<React.ElementRef<typeof TabsPrimitive.Root>, TabsRootProps>(function Tabs(
|
|
45
|
+
{
|
|
46
|
+
className,
|
|
47
|
+
variant = "line",
|
|
48
|
+
size = "md",
|
|
49
|
+
lazy = false,
|
|
50
|
+
unmountInactive = false,
|
|
51
|
+
orientation,
|
|
52
|
+
value,
|
|
53
|
+
defaultValue,
|
|
54
|
+
onValueChange,
|
|
55
|
+
...props
|
|
56
|
+
},
|
|
57
|
+
ref,
|
|
58
|
+
) {
|
|
59
|
+
// `variant="vertical"` implies vertical orientation unless overridden.
|
|
60
|
+
const resolvedOrientation = orientation ?? (variant === "vertical" ? "vertical" : "horizontal");
|
|
61
|
+
|
|
62
|
+
const [internalValue, setInternalValue] = useState<string | undefined>(
|
|
63
|
+
typeof defaultValue === "string" ? defaultValue : undefined,
|
|
64
|
+
);
|
|
65
|
+
const isControlled = value !== undefined;
|
|
66
|
+
const currentValue = isControlled ? value : internalValue;
|
|
67
|
+
|
|
68
|
+
const [activated, setActivated] = useState<Set<string>>(() => {
|
|
69
|
+
const s = new Set<string>();
|
|
70
|
+
if (currentValue) s.add(currentValue);
|
|
71
|
+
return s;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Track every value that becomes active so `lazy` mounting works.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!currentValue) return;
|
|
77
|
+
setActivated((prev) => {
|
|
78
|
+
if (prev.has(currentValue)) return prev;
|
|
79
|
+
const next = new Set(prev);
|
|
80
|
+
next.add(currentValue);
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
}, [currentValue]);
|
|
84
|
+
|
|
85
|
+
const handleValueChange = (next: string) => {
|
|
86
|
+
if (!isControlled) setInternalValue(next);
|
|
87
|
+
onValueChange?.(next);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const ctx = useMemo<TabsContextValue>(
|
|
91
|
+
() => ({
|
|
92
|
+
variant,
|
|
93
|
+
size,
|
|
94
|
+
lazy,
|
|
95
|
+
unmountInactive,
|
|
96
|
+
activated,
|
|
97
|
+
markActivated: (v) => {
|
|
98
|
+
setActivated((prev) => {
|
|
99
|
+
if (prev.has(v)) return prev;
|
|
100
|
+
const next = new Set(prev);
|
|
101
|
+
next.add(v);
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
currentValue,
|
|
106
|
+
}),
|
|
107
|
+
[variant, size, lazy, unmountInactive, activated, currentValue],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<TabsContext.Provider value={ctx}>
|
|
112
|
+
<TabsPrimitive.Root
|
|
113
|
+
ref={ref}
|
|
114
|
+
orientation={resolvedOrientation}
|
|
115
|
+
{...(currentValue !== undefined ? { value: currentValue } : {})}
|
|
116
|
+
{...(isControlled || defaultValue === undefined ? {} : { defaultValue })}
|
|
117
|
+
onValueChange={handleValueChange}
|
|
118
|
+
className={cn(variant === "vertical" && "flex w-full gap-4", className)}
|
|
119
|
+
{...props}
|
|
120
|
+
/>
|
|
121
|
+
</TabsContext.Provider>
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
Tabs.displayName = "Tabs";
|
|
125
|
+
|
|
126
|
+
// --- TabsList ---------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const tabsListVariants = cva("inline-flex items-center", {
|
|
129
|
+
variants: {
|
|
130
|
+
variant: {
|
|
131
|
+
line: "relative border-b border-border w-full",
|
|
132
|
+
pill: "gap-1 rounded-md bg-muted p-1",
|
|
133
|
+
card: "gap-1",
|
|
134
|
+
vertical: "flex-col gap-1 items-stretch border-e border-border pe-2 min-w-40",
|
|
135
|
+
},
|
|
136
|
+
scrollable: {
|
|
137
|
+
true: "overflow-x-auto scrollbar-thin",
|
|
138
|
+
false: "",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
compoundVariants: [
|
|
142
|
+
{ variant: "vertical", scrollable: true, class: "overflow-x-visible overflow-y-auto" },
|
|
143
|
+
],
|
|
144
|
+
defaultVariants: {
|
|
145
|
+
variant: "line",
|
|
146
|
+
scrollable: false,
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
type TabsListProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
|
151
|
+
scrollable?: boolean;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const TabsList = forwardRef<React.ElementRef<typeof TabsPrimitive.List>, TabsListProps>(
|
|
155
|
+
function TabsList({ className, scrollable, ...props }, ref) {
|
|
156
|
+
const { variant } = useTabsContext();
|
|
157
|
+
return (
|
|
158
|
+
<TabsPrimitive.List
|
|
159
|
+
ref={ref}
|
|
160
|
+
className={cn(tabsListVariants({ variant, scrollable }), className)}
|
|
161
|
+
{...props}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
TabsList.displayName = "TabsList";
|
|
167
|
+
|
|
168
|
+
// --- TabsTrigger ------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const tabsTriggerVariants = cva(
|
|
171
|
+
[
|
|
172
|
+
"inline-flex items-center gap-2 whitespace-nowrap font-medium select-none cursor-pointer",
|
|
173
|
+
"transition-colors",
|
|
174
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
175
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
176
|
+
"[&_svg]:size-4 [&_svg]:shrink-0",
|
|
177
|
+
].join(" "),
|
|
178
|
+
{
|
|
179
|
+
variants: {
|
|
180
|
+
variant: {
|
|
181
|
+
line: [
|
|
182
|
+
"relative px-3 py-2 text-muted-foreground hover:text-foreground",
|
|
183
|
+
"data-[state=active]:text-foreground",
|
|
184
|
+
// Underline indicator (uses pseudo-element so it sits at the bottom of list border)
|
|
185
|
+
"after:absolute after:inset-x-0 after:-bottom-px after:h-0.5",
|
|
186
|
+
"after:bg-transparent after:transition-colors after:duration-normal",
|
|
187
|
+
"data-[state=active]:after:bg-primary",
|
|
188
|
+
].join(" "),
|
|
189
|
+
pill: [
|
|
190
|
+
"rounded-md px-3 py-1.5 text-muted-foreground hover:text-foreground",
|
|
191
|
+
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
192
|
+
].join(" "),
|
|
193
|
+
card: [
|
|
194
|
+
"rounded-t-md border border-b-0 border-transparent px-4 py-2 text-muted-foreground",
|
|
195
|
+
"hover:text-foreground",
|
|
196
|
+
"data-[state=active]:border-border data-[state=active]:bg-card data-[state=active]:text-foreground",
|
|
197
|
+
"-mb-px data-[state=active]:z-10",
|
|
198
|
+
].join(" "),
|
|
199
|
+
vertical: [
|
|
200
|
+
"rounded-md px-3 py-2 text-start text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
|
201
|
+
"data-[state=active]:bg-accent data-[state=active]:text-accent-foreground",
|
|
202
|
+
].join(" "),
|
|
203
|
+
},
|
|
204
|
+
size: {
|
|
205
|
+
sm: "text-xs h-8",
|
|
206
|
+
md: "text-sm h-9",
|
|
207
|
+
lg: "text-base h-10",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
defaultVariants: {
|
|
211
|
+
variant: "line",
|
|
212
|
+
size: "md",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
type TabsTriggerProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
|
218
|
+
icon?: React.ReactNode;
|
|
219
|
+
badge?: React.ReactNode;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const TabsTrigger = forwardRef<React.ElementRef<typeof TabsPrimitive.Trigger>, TabsTriggerProps>(
|
|
223
|
+
function TabsTrigger({ className, icon, badge, children, value, ...props }, ref) {
|
|
224
|
+
const { variant, size } = useTabsContext();
|
|
225
|
+
return (
|
|
226
|
+
<TabsPrimitive.Trigger
|
|
227
|
+
ref={ref}
|
|
228
|
+
value={value}
|
|
229
|
+
className={cn(tabsTriggerVariants({ variant, size }), className)}
|
|
230
|
+
{...props}
|
|
231
|
+
>
|
|
232
|
+
{icon ? <span aria-hidden="true">{icon}</span> : null}
|
|
233
|
+
<span>{children}</span>
|
|
234
|
+
{badge ? <span className="ms-1 inline-flex items-center">{badge}</span> : null}
|
|
235
|
+
</TabsPrimitive.Trigger>
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
TabsTrigger.displayName = "TabsTrigger";
|
|
240
|
+
|
|
241
|
+
// --- TabsContent ------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
type TabsContentProps = React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>;
|
|
244
|
+
|
|
245
|
+
const TabsContent = forwardRef<React.ElementRef<typeof TabsPrimitive.Content>, TabsContentProps>(
|
|
246
|
+
function TabsContent({ className, value, forceMount, children, ...props }, ref) {
|
|
247
|
+
const { lazy, unmountInactive, activated, currentValue, variant } = useTabsContext();
|
|
248
|
+
const hasMountedRef = useRef(false);
|
|
249
|
+
const valueStr = typeof value === "string" ? value : "";
|
|
250
|
+
const isActive = currentValue === valueStr;
|
|
251
|
+
|
|
252
|
+
if (isActive) hasMountedRef.current = true;
|
|
253
|
+
|
|
254
|
+
// Decide whether to render. `unmountInactive` wins (always render only when
|
|
255
|
+
// active). Otherwise, with `lazy`, render once activated and keep mounted.
|
|
256
|
+
let shouldRender = true;
|
|
257
|
+
if (unmountInactive) shouldRender = isActive;
|
|
258
|
+
else if (lazy) shouldRender = activated.has(valueStr) || hasMountedRef.current;
|
|
259
|
+
|
|
260
|
+
// When kept mounted but inactive, Radix hides via `hidden` attribute
|
|
261
|
+
// automatically; we just need `forceMount` so it stays in the DOM.
|
|
262
|
+
const effectiveForceMount = !unmountInactive ? true : forceMount;
|
|
263
|
+
|
|
264
|
+
if (!shouldRender) return null;
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<TabsPrimitive.Content
|
|
268
|
+
ref={ref}
|
|
269
|
+
value={value}
|
|
270
|
+
{...(effectiveForceMount ? { forceMount: true as const } : {})}
|
|
271
|
+
className={cn(
|
|
272
|
+
"focus-visible:ring-ring focus-visible:ring-offset-background outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
|
273
|
+
variant !== "vertical" && "mt-3",
|
|
274
|
+
variant === "vertical" && "flex-1",
|
|
275
|
+
className,
|
|
276
|
+
)}
|
|
277
|
+
{...props}
|
|
278
|
+
>
|
|
279
|
+
{children}
|
|
280
|
+
</TabsPrimitive.Content>
|
|
281
|
+
);
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
TabsContent.displayName = "TabsContent";
|
|
285
|
+
|
|
286
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants, tabsTriggerVariants };
|
|
287
|
+
export type { TabsRootProps, TabsListProps, TabsTriggerProps, TabsContentProps };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
|
|
3
|
+
import { Textarea } from "./textarea";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Textarea> = {
|
|
6
|
+
title: "Foundations/Textarea",
|
|
7
|
+
component: Textarea,
|
|
8
|
+
parameters: { layout: "centered" },
|
|
9
|
+
argTypes: {
|
|
10
|
+
size: { control: "select", options: ["sm", "md", "lg"] },
|
|
11
|
+
invalid: { control: "boolean" },
|
|
12
|
+
autoResize: { control: "boolean" },
|
|
13
|
+
showCount: { control: "boolean" },
|
|
14
|
+
disabled: { control: "boolean" },
|
|
15
|
+
readOnly: { control: "boolean" },
|
|
16
|
+
rows: { control: "number" },
|
|
17
|
+
maxRows: { control: "number" },
|
|
18
|
+
maxLength: { control: "number" },
|
|
19
|
+
},
|
|
20
|
+
args: { placeholder: "Tell us a bit about yourself…" },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof Textarea>;
|
|
25
|
+
|
|
26
|
+
export const Default: Story = {
|
|
27
|
+
render: (args) => (
|
|
28
|
+
<div className="w-96">
|
|
29
|
+
<Textarea {...args} />
|
|
30
|
+
</div>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Sizes: Story = {
|
|
35
|
+
render: (args) => (
|
|
36
|
+
<div className="w-96 space-y-3">
|
|
37
|
+
<Textarea {...args} size="sm" placeholder="sm" />
|
|
38
|
+
<Textarea {...args} size="md" placeholder="md" />
|
|
39
|
+
<Textarea {...args} size="lg" placeholder="lg" />
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Invalid: Story = {
|
|
45
|
+
args: { invalid: true, defaultValue: "Something's wrong." },
|
|
46
|
+
render: (args) => (
|
|
47
|
+
<div className="w-96">
|
|
48
|
+
<Textarea {...args} />
|
|
49
|
+
</div>
|
|
50
|
+
),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const Disabled: Story = {
|
|
54
|
+
args: { disabled: true, defaultValue: "Cannot edit this." },
|
|
55
|
+
render: (args) => (
|
|
56
|
+
<div className="w-96">
|
|
57
|
+
<Textarea {...args} />
|
|
58
|
+
</div>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const ReadOnly: Story = {
|
|
63
|
+
args: { readOnly: true, defaultValue: "Read-only content." },
|
|
64
|
+
render: (args) => (
|
|
65
|
+
<div className="w-96">
|
|
66
|
+
<Textarea {...args} />
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const AutoResize: Story = {
|
|
72
|
+
args: { autoResize: true, defaultValue: "Type more…\nlines\ngrow\nthe field" },
|
|
73
|
+
render: (args) => (
|
|
74
|
+
<div className="w-96">
|
|
75
|
+
<Textarea {...args} />
|
|
76
|
+
</div>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const AutoResizeWithMaxRows: Story = {
|
|
81
|
+
args: { autoResize: true, maxRows: 4, defaultValue: "Up to 4 rows;\nthen it scrolls." },
|
|
82
|
+
render: (args) => (
|
|
83
|
+
<div className="w-96">
|
|
84
|
+
<Textarea {...args} />
|
|
85
|
+
</div>
|
|
86
|
+
),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const WithCharacterCount: Story = {
|
|
90
|
+
args: { showCount: true, maxLength: 140 },
|
|
91
|
+
render: (args) => (
|
|
92
|
+
<div className="w-96">
|
|
93
|
+
<Textarea {...args} />
|
|
94
|
+
</div>
|
|
95
|
+
),
|
|
96
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useCallback, useEffect, useId, useRef, useState } 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 §3.
|
|
10
|
+
|
|
11
|
+
const textareaVariants = cva(
|
|
12
|
+
[
|
|
13
|
+
"block w-full rounded-md border border-input bg-background text-foreground",
|
|
14
|
+
"ps-3 pe-3 py-2",
|
|
15
|
+
"placeholder:text-muted-foreground",
|
|
16
|
+
"transition-colors",
|
|
17
|
+
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
18
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
19
|
+
"read-only:bg-muted",
|
|
20
|
+
].join(" "),
|
|
21
|
+
{
|
|
22
|
+
variants: {
|
|
23
|
+
size: {
|
|
24
|
+
sm: "text-xs leading-5 min-h-16",
|
|
25
|
+
md: "text-sm leading-5 min-h-20",
|
|
26
|
+
lg: "text-base leading-6 min-h-28",
|
|
27
|
+
},
|
|
28
|
+
invalid: {
|
|
29
|
+
true: "border-destructive focus-visible:ring-destructive",
|
|
30
|
+
false: "",
|
|
31
|
+
},
|
|
32
|
+
resize: {
|
|
33
|
+
vertical: "resize-y",
|
|
34
|
+
none: "resize-none",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
defaultVariants: {
|
|
38
|
+
size: "md",
|
|
39
|
+
invalid: false,
|
|
40
|
+
resize: "vertical",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
type TextareaVariants = VariantProps<typeof textareaVariants>;
|
|
46
|
+
|
|
47
|
+
type TextareaProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> & {
|
|
48
|
+
size?: TextareaVariants["size"];
|
|
49
|
+
invalid?: boolean;
|
|
50
|
+
autoResize?: boolean;
|
|
51
|
+
maxRows?: number;
|
|
52
|
+
showCount?: boolean;
|
|
53
|
+
asChild?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Internal hook — keeps the textarea sized to its content, capped at `maxRows`.
|
|
57
|
+
function useAutoResize(
|
|
58
|
+
ref: React.RefObject<HTMLTextAreaElement | null>,
|
|
59
|
+
enabled: boolean,
|
|
60
|
+
maxRows: number | undefined,
|
|
61
|
+
value: string,
|
|
62
|
+
) {
|
|
63
|
+
const resize = useCallback(() => {
|
|
64
|
+
const node = ref.current;
|
|
65
|
+
if (!node) return;
|
|
66
|
+
node.style.height = "auto";
|
|
67
|
+
const styles = window.getComputedStyle(node);
|
|
68
|
+
// `styles.lineHeight` is normally a `<n>px` string (Tailwind's text-*
|
|
69
|
+
// utilities all set an explicit line-height). For elements that resolve
|
|
70
|
+
// to `normal`, fall back to fontSize × 1.5 — the conventional ratio —
|
|
71
|
+
// and finally to 20px (≈ --text-sm--line-height) if everything fails.
|
|
72
|
+
const lineHeight = parseFloat(styles.lineHeight) || parseFloat(styles.fontSize) * 1.5 || 20;
|
|
73
|
+
const paddingTop = parseFloat(styles.paddingTop) || 0;
|
|
74
|
+
const paddingBottom = parseFloat(styles.paddingBottom) || 0;
|
|
75
|
+
const borderTop = parseFloat(styles.borderTopWidth) || 0;
|
|
76
|
+
const borderBottom = parseFloat(styles.borderBottomWidth) || 0;
|
|
77
|
+
const chrome = paddingTop + paddingBottom + borderTop + borderBottom;
|
|
78
|
+
const contentHeight = node.scrollHeight - chrome;
|
|
79
|
+
const cap = maxRows !== undefined ? maxRows * lineHeight + chrome : Number.POSITIVE_INFINITY;
|
|
80
|
+
const next = Math.min(node.scrollHeight, cap);
|
|
81
|
+
node.style.height = `${next}px`;
|
|
82
|
+
node.style.overflowY = contentHeight + chrome > cap ? "auto" : "hidden";
|
|
83
|
+
}, [ref, maxRows]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!enabled) return;
|
|
87
|
+
resize();
|
|
88
|
+
}, [enabled, resize, value]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
|
92
|
+
{
|
|
93
|
+
className,
|
|
94
|
+
size,
|
|
95
|
+
invalid = false,
|
|
96
|
+
autoResize = false,
|
|
97
|
+
maxRows,
|
|
98
|
+
showCount = false,
|
|
99
|
+
rows = 4,
|
|
100
|
+
maxLength,
|
|
101
|
+
asChild = false,
|
|
102
|
+
value,
|
|
103
|
+
defaultValue,
|
|
104
|
+
onChange,
|
|
105
|
+
disabled,
|
|
106
|
+
readOnly,
|
|
107
|
+
id,
|
|
108
|
+
"aria-describedby": ariaDescribedBy,
|
|
109
|
+
children,
|
|
110
|
+
...props
|
|
111
|
+
},
|
|
112
|
+
forwardedRef,
|
|
113
|
+
) {
|
|
114
|
+
const isControlled = value !== undefined;
|
|
115
|
+
const [uncontrolledValue, setUncontrolledValue] = useState<string>(
|
|
116
|
+
typeof defaultValue === "string" ? defaultValue : "",
|
|
117
|
+
);
|
|
118
|
+
const currentValue = isControlled ? String(value ?? "") : uncontrolledValue;
|
|
119
|
+
|
|
120
|
+
const innerRef = useRef<HTMLTextAreaElement | null>(null);
|
|
121
|
+
const setRefs = (node: HTMLTextAreaElement | null) => {
|
|
122
|
+
innerRef.current = node;
|
|
123
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
124
|
+
else if (forwardedRef) forwardedRef.current = node;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
useAutoResize(innerRef, autoResize && !disabled, maxRows, currentValue);
|
|
128
|
+
|
|
129
|
+
const reactId = useId();
|
|
130
|
+
const textareaId = id ?? reactId;
|
|
131
|
+
const counterId = showCount ? `${textareaId}-count` : undefined;
|
|
132
|
+
const describedBy = [ariaDescribedBy, counterId].filter(Boolean).join(" ") || undefined;
|
|
133
|
+
|
|
134
|
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
135
|
+
if (!isControlled) setUncontrolledValue(e.target.value);
|
|
136
|
+
onChange?.(e);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const Field = asChild ? Slot : "textarea";
|
|
140
|
+
const showCounter = showCount && maxLength !== undefined;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="w-full">
|
|
144
|
+
<Field
|
|
145
|
+
ref={setRefs}
|
|
146
|
+
id={textareaId}
|
|
147
|
+
rows={rows}
|
|
148
|
+
maxLength={maxLength}
|
|
149
|
+
className={cn(
|
|
150
|
+
textareaVariants({ size, invalid, resize: autoResize ? "none" : "vertical" }),
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
readOnly={readOnly}
|
|
155
|
+
aria-invalid={invalid || undefined}
|
|
156
|
+
aria-describedby={describedBy}
|
|
157
|
+
value={isControlled ? currentValue : undefined}
|
|
158
|
+
defaultValue={isControlled ? undefined : defaultValue}
|
|
159
|
+
onChange={handleChange}
|
|
160
|
+
{...props}
|
|
161
|
+
>
|
|
162
|
+
{asChild ? children : null}
|
|
163
|
+
</Field>
|
|
164
|
+
{showCounter ? (
|
|
165
|
+
<div
|
|
166
|
+
id={counterId}
|
|
167
|
+
className={cn(
|
|
168
|
+
"text-muted-foreground mt-1 text-end text-xs tabular-nums",
|
|
169
|
+
currentValue.length >= (maxLength ?? 0) && "text-destructive",
|
|
170
|
+
)}
|
|
171
|
+
aria-live="polite"
|
|
172
|
+
>
|
|
173
|
+
{currentValue.length}/{maxLength}
|
|
174
|
+
</div>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
Textarea.displayName = "Textarea";
|
|
180
|
+
|
|
181
|
+
export { Textarea, textareaVariants };
|
|
182
|
+
export type { TextareaProps };
|