@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,491 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useMemo,
|
|
11
|
+
useState,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { RiMenuLine } from "@remixicon/react";
|
|
15
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
16
|
+
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
import { Button } from "./button";
|
|
19
|
+
import { Drawer, DrawerContent, DrawerOverlay, DrawerPortal } from "./drawer";
|
|
20
|
+
import { TooltipProvider } from "./tooltip";
|
|
21
|
+
|
|
22
|
+
// Per docs/emara-ui-phase-5-components.md §1.
|
|
23
|
+
|
|
24
|
+
type AppShellVariant = "default" | "header-only" | "sidebar-only" | "minimal";
|
|
25
|
+
type SidebarPosition = "start" | "end";
|
|
26
|
+
type SidebarWidthToken = "sm" | "md" | "lg";
|
|
27
|
+
type HeaderHeightToken = "sm" | "md" | "lg";
|
|
28
|
+
type Breakpoint = "sm" | "md" | "lg";
|
|
29
|
+
|
|
30
|
+
// Resolved px sizes (kept in TS so we can drive CSS variables for use in
|
|
31
|
+
// transitions and Drawer fallback width).
|
|
32
|
+
const SIDEBAR_WIDTH_PX = {
|
|
33
|
+
sm: 200,
|
|
34
|
+
md: 240,
|
|
35
|
+
lg: 280,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const SIDEBAR_COLLAPSED_WIDTH_PX = 64;
|
|
39
|
+
|
|
40
|
+
const HEADER_HEIGHT_PX = {
|
|
41
|
+
sm: 48,
|
|
42
|
+
md: 56,
|
|
43
|
+
lg: 64,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
// Tailwind class fragments for each breakpoint (typed to keep `min-w-` arbitrary
|
|
47
|
+
// values out so the JIT only emits what we use).
|
|
48
|
+
const BREAKPOINT_MIN_WIDTH_CLASS: Record<Breakpoint, string> = {
|
|
49
|
+
sm: "min-[640px]:flex",
|
|
50
|
+
md: "min-[768px]:flex",
|
|
51
|
+
lg: "min-[1024px]:flex",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const BREAKPOINT_HIDE_BELOW_CLASS: Record<Breakpoint, string> = {
|
|
55
|
+
sm: "max-[639px]:hidden",
|
|
56
|
+
md: "max-[767px]:hidden",
|
|
57
|
+
lg: "max-[1023px]:hidden",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const BREAKPOINT_SHOW_TRIGGER_BELOW_CLASS: Record<Breakpoint, string> = {
|
|
61
|
+
// Hamburger button appears below the breakpoint.
|
|
62
|
+
sm: "min-[640px]:hidden",
|
|
63
|
+
md: "min-[768px]:hidden",
|
|
64
|
+
lg: "min-[1024px]:hidden",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ===========================================================================
|
|
68
|
+
// Context
|
|
69
|
+
// ===========================================================================
|
|
70
|
+
|
|
71
|
+
interface AppShellContextValue {
|
|
72
|
+
variant: AppShellVariant;
|
|
73
|
+
sidebarPosition: SidebarPosition;
|
|
74
|
+
sidebarWidthPx: number;
|
|
75
|
+
collapsedWidthPx: number;
|
|
76
|
+
headerHeightPx: number;
|
|
77
|
+
sidebarBreakpoint: Breakpoint;
|
|
78
|
+
stickyHeader: boolean;
|
|
79
|
+
bordered: boolean;
|
|
80
|
+
/** True when inline sidebar is in icon-only mode (desktop). */
|
|
81
|
+
sidebarCollapsed: boolean;
|
|
82
|
+
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
83
|
+
/** True when mobile drawer is open. */
|
|
84
|
+
sidebarOpen: boolean;
|
|
85
|
+
setSidebarOpen: (open: boolean) => void;
|
|
86
|
+
mainId: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const AppShellContext = createContext<AppShellContextValue | null>(null);
|
|
90
|
+
|
|
91
|
+
/** Read AppShell state from any descendant. */
|
|
92
|
+
function useAppShell(): AppShellContextValue {
|
|
93
|
+
const ctx = useContext(AppShellContext);
|
|
94
|
+
if (!ctx) throw new Error("useAppShell must be used inside <AppShell>");
|
|
95
|
+
return ctx;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
// Skip link
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
|
|
102
|
+
function SkipLink({
|
|
103
|
+
targetId,
|
|
104
|
+
label = "Skip to main content",
|
|
105
|
+
}: {
|
|
106
|
+
targetId: string;
|
|
107
|
+
label?: string;
|
|
108
|
+
}) {
|
|
109
|
+
return (
|
|
110
|
+
<a
|
|
111
|
+
href={`#${targetId}`}
|
|
112
|
+
className={cn(
|
|
113
|
+
"sr-only focus:not-sr-only",
|
|
114
|
+
"focus:z-overlay focus:fixed focus:inset-s-2 focus:top-2",
|
|
115
|
+
"focus:bg-primary focus:rounded-md focus:px-3 focus:py-2",
|
|
116
|
+
"focus:text-primary-foreground focus:shadow-md",
|
|
117
|
+
"focus:ring-ring focus:ring-offset-background focus:ring-2 focus:ring-offset-2 focus:outline-none",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{label}
|
|
121
|
+
</a>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ===========================================================================
|
|
126
|
+
// Root
|
|
127
|
+
// ===========================================================================
|
|
128
|
+
|
|
129
|
+
const appShellRootVariants = cva("min-h-dvh w-full bg-background text-foreground", {
|
|
130
|
+
variants: {
|
|
131
|
+
bordered: { true: "", false: "" },
|
|
132
|
+
},
|
|
133
|
+
defaultVariants: { bordered: false },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
type AppShellRootVariants = VariantProps<typeof appShellRootVariants>;
|
|
137
|
+
|
|
138
|
+
type AppShellProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> &
|
|
139
|
+
AppShellRootVariants & {
|
|
140
|
+
variant?: AppShellVariant;
|
|
141
|
+
sidebarPosition?: SidebarPosition;
|
|
142
|
+
sidebarWidth?: SidebarWidthToken | number;
|
|
143
|
+
headerHeight?: HeaderHeightToken | number;
|
|
144
|
+
sidebarCollapsed?: boolean;
|
|
145
|
+
defaultSidebarCollapsed?: boolean;
|
|
146
|
+
onSidebarCollapsedChange?: (collapsed: boolean) => void;
|
|
147
|
+
sidebarBreakpoint?: Breakpoint;
|
|
148
|
+
stickyHeader?: boolean;
|
|
149
|
+
/** aria-label for the auto-injected hamburger button. Default "Open navigation". */
|
|
150
|
+
sidebarOpenLabel?: string;
|
|
151
|
+
/** Localized text for the skip link. Default "Skip to main content". */
|
|
152
|
+
skipLinkLabel?: string;
|
|
153
|
+
children?: ReactNode;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const AppShell = forwardRef<HTMLDivElement, AppShellProps>(function AppShell(
|
|
157
|
+
{
|
|
158
|
+
className,
|
|
159
|
+
variant = "default",
|
|
160
|
+
sidebarPosition = "start",
|
|
161
|
+
sidebarWidth = "md",
|
|
162
|
+
headerHeight = "md",
|
|
163
|
+
sidebarCollapsed: controlledCollapsed,
|
|
164
|
+
defaultSidebarCollapsed = false,
|
|
165
|
+
onSidebarCollapsedChange,
|
|
166
|
+
sidebarBreakpoint = "md",
|
|
167
|
+
stickyHeader = true,
|
|
168
|
+
bordered = false,
|
|
169
|
+
sidebarOpenLabel = "Open navigation",
|
|
170
|
+
skipLinkLabel = "Skip to main content",
|
|
171
|
+
children,
|
|
172
|
+
...props
|
|
173
|
+
},
|
|
174
|
+
ref,
|
|
175
|
+
) {
|
|
176
|
+
const mainId = useId();
|
|
177
|
+
|
|
178
|
+
// Collapsed state (controlled / uncontrolled)
|
|
179
|
+
const [uncontrolledCollapsed, setUncontrolledCollapsed] = useState(defaultSidebarCollapsed);
|
|
180
|
+
const collapsedIsControlled = controlledCollapsed !== undefined;
|
|
181
|
+
const sidebarCollapsed = collapsedIsControlled ? controlledCollapsed : uncontrolledCollapsed;
|
|
182
|
+
const setSidebarCollapsed = useCallback(
|
|
183
|
+
(next: boolean) => {
|
|
184
|
+
if (!collapsedIsControlled) setUncontrolledCollapsed(next);
|
|
185
|
+
onSidebarCollapsedChange?.(next);
|
|
186
|
+
},
|
|
187
|
+
[collapsedIsControlled, onSidebarCollapsedChange],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Mobile drawer open state
|
|
191
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
192
|
+
|
|
193
|
+
// Resolve widths/heights to px values
|
|
194
|
+
const sidebarWidthPx =
|
|
195
|
+
typeof sidebarWidth === "number" ? sidebarWidth : SIDEBAR_WIDTH_PX[sidebarWidth];
|
|
196
|
+
const headerHeightPx =
|
|
197
|
+
typeof headerHeight === "number" ? headerHeight : HEADER_HEIGHT_PX[headerHeight];
|
|
198
|
+
|
|
199
|
+
const ctx = useMemo<AppShellContextValue>(
|
|
200
|
+
() => ({
|
|
201
|
+
variant,
|
|
202
|
+
sidebarPosition,
|
|
203
|
+
sidebarWidthPx,
|
|
204
|
+
collapsedWidthPx: SIDEBAR_COLLAPSED_WIDTH_PX,
|
|
205
|
+
headerHeightPx,
|
|
206
|
+
sidebarBreakpoint,
|
|
207
|
+
stickyHeader,
|
|
208
|
+
bordered: Boolean(bordered),
|
|
209
|
+
sidebarCollapsed,
|
|
210
|
+
setSidebarCollapsed,
|
|
211
|
+
sidebarOpen,
|
|
212
|
+
setSidebarOpen,
|
|
213
|
+
mainId,
|
|
214
|
+
}),
|
|
215
|
+
[
|
|
216
|
+
variant,
|
|
217
|
+
sidebarPosition,
|
|
218
|
+
sidebarWidthPx,
|
|
219
|
+
headerHeightPx,
|
|
220
|
+
sidebarBreakpoint,
|
|
221
|
+
stickyHeader,
|
|
222
|
+
bordered,
|
|
223
|
+
sidebarCollapsed,
|
|
224
|
+
setSidebarCollapsed,
|
|
225
|
+
sidebarOpen,
|
|
226
|
+
mainId,
|
|
227
|
+
],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Auto-close the mobile drawer when the viewport widens above the breakpoint.
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
233
|
+
const min = sidebarBreakpoint === "sm" ? 640 : sidebarBreakpoint === "md" ? 768 : 1024;
|
|
234
|
+
const mql = window.matchMedia(`(min-width: ${min}px)`);
|
|
235
|
+
const onChange = () => {
|
|
236
|
+
if (mql.matches) setSidebarOpen(false);
|
|
237
|
+
};
|
|
238
|
+
mql.addEventListener("change", onChange);
|
|
239
|
+
return () => mql.removeEventListener("change", onChange);
|
|
240
|
+
}, [sidebarBreakpoint]);
|
|
241
|
+
|
|
242
|
+
const hasSidebar = variant === "default" || variant === "sidebar-only";
|
|
243
|
+
const hasHeader = variant === "default" || variant === "header-only";
|
|
244
|
+
|
|
245
|
+
// Style: CSS variables for inline sidebar/header sizing so children can
|
|
246
|
+
// refer to them (e.g. drawer fallback width, content offset).
|
|
247
|
+
const style: React.CSSProperties = {
|
|
248
|
+
"--app-shell-sidebar-width": `${sidebarWidthPx}px`,
|
|
249
|
+
"--app-shell-sidebar-collapsed-width": `${SIDEBAR_COLLAPSED_WIDTH_PX}px`,
|
|
250
|
+
"--app-shell-header-height": `${headerHeightPx}px`,
|
|
251
|
+
...(props.style as React.CSSProperties | undefined),
|
|
252
|
+
} as React.CSSProperties;
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<AppShellContext.Provider value={ctx}>
|
|
256
|
+
<TooltipProvider delayDuration={200}>
|
|
257
|
+
<div
|
|
258
|
+
ref={ref}
|
|
259
|
+
data-slot="app-shell"
|
|
260
|
+
data-variant={variant}
|
|
261
|
+
data-sidebar-position={hasSidebar ? sidebarPosition : undefined}
|
|
262
|
+
className={cn(appShellRootVariants({ bordered }), className)}
|
|
263
|
+
style={style}
|
|
264
|
+
{...props}
|
|
265
|
+
>
|
|
266
|
+
<SkipLink targetId={mainId} label={skipLinkLabel} />
|
|
267
|
+
|
|
268
|
+
{children}
|
|
269
|
+
|
|
270
|
+
{/*
|
|
271
|
+
Auto-injected hamburger button for narrow viewports. AppShellSidebar
|
|
272
|
+
renders the mobile Drawer that this button opens.
|
|
273
|
+
*/}
|
|
274
|
+
{hasSidebar && hasHeader ? (
|
|
275
|
+
<Button
|
|
276
|
+
variant="ghost"
|
|
277
|
+
size="icon-sm"
|
|
278
|
+
aria-label={sidebarOpenLabel}
|
|
279
|
+
onClick={() => setSidebarOpen(true)}
|
|
280
|
+
className={cn(
|
|
281
|
+
"z-fixed fixed top-2",
|
|
282
|
+
sidebarPosition === "start" ? "inset-s-2" : "inset-e-2",
|
|
283
|
+
BREAKPOINT_SHOW_TRIGGER_BELOW_CLASS[sidebarBreakpoint],
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
<RiMenuLine />
|
|
287
|
+
</Button>
|
|
288
|
+
) : null}
|
|
289
|
+
</div>
|
|
290
|
+
</TooltipProvider>
|
|
291
|
+
</AppShellContext.Provider>
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
AppShell.displayName = "AppShell";
|
|
295
|
+
|
|
296
|
+
// ===========================================================================
|
|
297
|
+
// Slots
|
|
298
|
+
// ===========================================================================
|
|
299
|
+
|
|
300
|
+
const AppShellHeader = forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
|
|
301
|
+
function AppShellHeader({ className, ...props }, ref) {
|
|
302
|
+
const { stickyHeader, bordered, headerHeightPx, variant } = useAppShell();
|
|
303
|
+
if (variant === "sidebar-only" || variant === "minimal") return null;
|
|
304
|
+
return (
|
|
305
|
+
<header
|
|
306
|
+
ref={ref}
|
|
307
|
+
role="banner"
|
|
308
|
+
data-slot="app-shell-header"
|
|
309
|
+
className={cn(
|
|
310
|
+
"bg-background z-sticky flex w-full items-center",
|
|
311
|
+
stickyHeader && "sticky top-0",
|
|
312
|
+
bordered && "border-border border-b",
|
|
313
|
+
className,
|
|
314
|
+
)}
|
|
315
|
+
style={{ height: headerHeightPx }}
|
|
316
|
+
{...props}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
AppShellHeader.displayName = "AppShellHeader";
|
|
322
|
+
|
|
323
|
+
const AppShellSidebar = forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
|
|
324
|
+
function AppShellSidebar({ className, children, ...props }, ref) {
|
|
325
|
+
const {
|
|
326
|
+
variant,
|
|
327
|
+
sidebarPosition,
|
|
328
|
+
sidebarCollapsed,
|
|
329
|
+
sidebarBreakpoint,
|
|
330
|
+
bordered,
|
|
331
|
+
sidebarWidthPx,
|
|
332
|
+
collapsedWidthPx,
|
|
333
|
+
sidebarOpen,
|
|
334
|
+
setSidebarOpen,
|
|
335
|
+
headerHeightPx,
|
|
336
|
+
stickyHeader,
|
|
337
|
+
} = useAppShell();
|
|
338
|
+
if (variant === "header-only" || variant === "minimal") return null;
|
|
339
|
+
|
|
340
|
+
const widthPx = sidebarCollapsed ? collapsedWidthPx : sidebarWidthPx;
|
|
341
|
+
const showInlineAtClass = BREAKPOINT_MIN_WIDTH_CLASS[sidebarBreakpoint];
|
|
342
|
+
const hideBelowClass = BREAKPOINT_HIDE_BELOW_CLASS[sidebarBreakpoint];
|
|
343
|
+
|
|
344
|
+
return (
|
|
345
|
+
<>
|
|
346
|
+
{/* Inline sidebar (desktop) */}
|
|
347
|
+
<aside
|
|
348
|
+
ref={ref}
|
|
349
|
+
data-slot="app-shell-sidebar"
|
|
350
|
+
data-position={sidebarPosition}
|
|
351
|
+
className={cn(
|
|
352
|
+
"hidden flex-col",
|
|
353
|
+
// Show inline only above the breakpoint.
|
|
354
|
+
showInlineAtClass,
|
|
355
|
+
hideBelowClass,
|
|
356
|
+
// Pinned to the side; respects sticky header by offsetting top.
|
|
357
|
+
stickyHeader ? "sticky" : "",
|
|
358
|
+
bordered &&
|
|
359
|
+
(sidebarPosition === "start" ? "border-border border-e" : "border-border border-s"),
|
|
360
|
+
className,
|
|
361
|
+
)}
|
|
362
|
+
style={{
|
|
363
|
+
width: widthPx,
|
|
364
|
+
transition: "width var(--duration-normal)",
|
|
365
|
+
...(stickyHeader
|
|
366
|
+
? { top: headerHeightPx, height: `calc(100dvh - ${headerHeightPx}px)` }
|
|
367
|
+
: { height: "100dvh" }),
|
|
368
|
+
// Use logical order to put sidebar on the start/end side.
|
|
369
|
+
order: sidebarPosition === "start" ? 0 : 2,
|
|
370
|
+
}}
|
|
371
|
+
{...props}
|
|
372
|
+
>
|
|
373
|
+
{children}
|
|
374
|
+
</aside>
|
|
375
|
+
|
|
376
|
+
{/* Mobile portal: render the same children inside the drawer that AppShell already opens. */}
|
|
377
|
+
<MobileSidebarPortal
|
|
378
|
+
open={sidebarOpen}
|
|
379
|
+
onOpenChange={setSidebarOpen}
|
|
380
|
+
position={sidebarPosition}
|
|
381
|
+
>
|
|
382
|
+
{children}
|
|
383
|
+
</MobileSidebarPortal>
|
|
384
|
+
</>
|
|
385
|
+
);
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
AppShellSidebar.displayName = "AppShellSidebar";
|
|
389
|
+
|
|
390
|
+
function MobileSidebarPortal({
|
|
391
|
+
open,
|
|
392
|
+
onOpenChange,
|
|
393
|
+
position,
|
|
394
|
+
children,
|
|
395
|
+
}: {
|
|
396
|
+
open: boolean;
|
|
397
|
+
onOpenChange: (open: boolean) => void;
|
|
398
|
+
position: SidebarPosition;
|
|
399
|
+
children: ReactNode;
|
|
400
|
+
}) {
|
|
401
|
+
return (
|
|
402
|
+
<Drawer open={open} onOpenChange={onOpenChange}>
|
|
403
|
+
<DrawerPortal>
|
|
404
|
+
<DrawerOverlay />
|
|
405
|
+
<DrawerContent position={position} size="sm" aria-label="Navigation" className="p-0">
|
|
406
|
+
{children}
|
|
407
|
+
</DrawerContent>
|
|
408
|
+
</DrawerPortal>
|
|
409
|
+
</Drawer>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const AppShellMain = forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
|
|
414
|
+
function AppShellMain({ className, ...props }, ref) {
|
|
415
|
+
const { mainId, sidebarPosition, variant } = useAppShell();
|
|
416
|
+
const hasSidebar = variant === "default" || variant === "sidebar-only";
|
|
417
|
+
return (
|
|
418
|
+
<main
|
|
419
|
+
ref={ref}
|
|
420
|
+
id={mainId}
|
|
421
|
+
role="main"
|
|
422
|
+
data-slot="app-shell-main"
|
|
423
|
+
className={cn("min-w-0 flex-1 overflow-x-hidden focus:outline-none", className)}
|
|
424
|
+
style={hasSidebar ? { order: sidebarPosition === "start" ? 1 : 1 } : undefined}
|
|
425
|
+
// Focus the main on programmatic navigation (skip link target).
|
|
426
|
+
tabIndex={-1}
|
|
427
|
+
{...props}
|
|
428
|
+
/>
|
|
429
|
+
);
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
AppShellMain.displayName = "AppShellMain";
|
|
433
|
+
|
|
434
|
+
const AppShellFooter = forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(
|
|
435
|
+
function AppShellFooter({ className, ...props }, ref) {
|
|
436
|
+
const { variant, bordered } = useAppShell();
|
|
437
|
+
if (variant === "minimal") {
|
|
438
|
+
// Minimal still allows a footer.
|
|
439
|
+
}
|
|
440
|
+
return (
|
|
441
|
+
<footer
|
|
442
|
+
ref={ref}
|
|
443
|
+
role="contentinfo"
|
|
444
|
+
data-slot="app-shell-footer"
|
|
445
|
+
className={cn("bg-background w-full", bordered && "border-border border-t", className)}
|
|
446
|
+
style={{ order: 3 }}
|
|
447
|
+
{...props}
|
|
448
|
+
/>
|
|
449
|
+
);
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
AppShellFooter.displayName = "AppShellFooter";
|
|
453
|
+
|
|
454
|
+
// ===========================================================================
|
|
455
|
+
// Layout container (auto-injected: when AppShell variants need it, consumers
|
|
456
|
+
// can wrap their Header+Sidebar+Main+Footer in <AppShellLayout> for proper
|
|
457
|
+
// flex/grid composition).
|
|
458
|
+
// ===========================================================================
|
|
459
|
+
|
|
460
|
+
const AppShellLayout = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
461
|
+
function AppShellLayout({ className, ...props }, ref) {
|
|
462
|
+
const { variant } = useAppShell();
|
|
463
|
+
// For default/sidebar-only: lay out header on top + (sidebar | main) row.
|
|
464
|
+
// We use a simple flex column with header sticky and a row inside via order tricks.
|
|
465
|
+
// Easiest: grid template based on variant.
|
|
466
|
+
return (
|
|
467
|
+
<div
|
|
468
|
+
ref={ref}
|
|
469
|
+
data-slot="app-shell-layout"
|
|
470
|
+
className={cn(
|
|
471
|
+
"flex w-full",
|
|
472
|
+
variant === "header-only" || variant === "minimal" ? "min-h-dvh flex-col" : "min-h-dvh",
|
|
473
|
+
className,
|
|
474
|
+
)}
|
|
475
|
+
{...props}
|
|
476
|
+
/>
|
|
477
|
+
);
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
AppShellLayout.displayName = "AppShellLayout";
|
|
481
|
+
|
|
482
|
+
export {
|
|
483
|
+
AppShell,
|
|
484
|
+
AppShellHeader,
|
|
485
|
+
AppShellSidebar,
|
|
486
|
+
AppShellMain,
|
|
487
|
+
AppShellFooter,
|
|
488
|
+
AppShellLayout,
|
|
489
|
+
useAppShell,
|
|
490
|
+
};
|
|
491
|
+
export type { AppShellProps, AppShellVariant, SidebarPosition, AppShellContextValue };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarGroup, AvatarImage } from "./avatar";
|
|
4
|
+
|
|
5
|
+
const SRC_VALID = "https://i.pravatar.cc/96?img=12";
|
|
6
|
+
const SRC_BROKEN = "/__missing-avatar.png";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof Avatar> = {
|
|
9
|
+
title: "Foundations/Avatar",
|
|
10
|
+
component: Avatar,
|
|
11
|
+
parameters: { layout: "centered" },
|
|
12
|
+
argTypes: {
|
|
13
|
+
size: { control: "select", options: ["xs", "sm", "md", "lg", "xl", "2xl"] },
|
|
14
|
+
shape: { control: "select", options: ["circle", "rounded"] },
|
|
15
|
+
status: { control: "select", options: ["none", "online", "offline", "busy", "away"] },
|
|
16
|
+
statusPosition: { control: "select", options: ["top-end", "bottom-end"] },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default meta;
|
|
21
|
+
type Story = StoryObj<typeof Avatar>;
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
render: (args) => (
|
|
25
|
+
<Avatar {...args}>
|
|
26
|
+
<AvatarImage src={SRC_VALID} alt="Alice" />
|
|
27
|
+
<AvatarFallback>AB</AvatarFallback>
|
|
28
|
+
</Avatar>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Sizes: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<div className="flex items-end gap-3">
|
|
35
|
+
{(["xs", "sm", "md", "lg", "xl", "2xl"] as const).map((size) => (
|
|
36
|
+
<Avatar key={size} size={size}>
|
|
37
|
+
<AvatarImage src={SRC_VALID} alt="" />
|
|
38
|
+
<AvatarFallback>A</AvatarFallback>
|
|
39
|
+
</Avatar>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Shapes: Story = {
|
|
46
|
+
render: () => (
|
|
47
|
+
<div className="flex items-center gap-4">
|
|
48
|
+
<Avatar shape="circle" size="lg">
|
|
49
|
+
<AvatarImage src={SRC_VALID} alt="" />
|
|
50
|
+
<AvatarFallback>C</AvatarFallback>
|
|
51
|
+
</Avatar>
|
|
52
|
+
<Avatar shape="rounded" size="lg">
|
|
53
|
+
<AvatarImage src={SRC_VALID} alt="" />
|
|
54
|
+
<AvatarFallback>R</AvatarFallback>
|
|
55
|
+
</Avatar>
|
|
56
|
+
</div>
|
|
57
|
+
),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const FallbackOnly: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<div className="flex items-center gap-3">
|
|
63
|
+
<Avatar size="md">
|
|
64
|
+
<AvatarFallback>AB</AvatarFallback>
|
|
65
|
+
</Avatar>
|
|
66
|
+
<Avatar size="lg">
|
|
67
|
+
<AvatarFallback>JD</AvatarFallback>
|
|
68
|
+
</Avatar>
|
|
69
|
+
</div>
|
|
70
|
+
),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const FallbackOnError: Story = {
|
|
74
|
+
render: () => (
|
|
75
|
+
<Avatar size="lg">
|
|
76
|
+
<AvatarImage src={SRC_BROKEN} alt="" />
|
|
77
|
+
<AvatarFallback>ER</AvatarFallback>
|
|
78
|
+
</Avatar>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Status: Story = {
|
|
83
|
+
render: () => (
|
|
84
|
+
<div className="flex items-center gap-4">
|
|
85
|
+
{(["online", "offline", "busy", "away"] as const).map((status) => (
|
|
86
|
+
<Avatar key={status} status={status} size="lg">
|
|
87
|
+
<AvatarImage src={SRC_VALID} alt="" />
|
|
88
|
+
<AvatarFallback>{status.charAt(0).toUpperCase()}</AvatarFallback>
|
|
89
|
+
</Avatar>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const StatusTopEnd: Story = {
|
|
96
|
+
render: () => (
|
|
97
|
+
<Avatar status="online" statusPosition="top-end" size="lg">
|
|
98
|
+
<AvatarImage src={SRC_VALID} alt="" />
|
|
99
|
+
<AvatarFallback>T</AvatarFallback>
|
|
100
|
+
</Avatar>
|
|
101
|
+
),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const Group: Story = {
|
|
105
|
+
render: () => (
|
|
106
|
+
<AvatarGroup size="md">
|
|
107
|
+
<Avatar>
|
|
108
|
+
<AvatarImage src="https://i.pravatar.cc/96?img=1" alt="A" />
|
|
109
|
+
<AvatarFallback>A</AvatarFallback>
|
|
110
|
+
</Avatar>
|
|
111
|
+
<Avatar>
|
|
112
|
+
<AvatarImage src="https://i.pravatar.cc/96?img=2" alt="B" />
|
|
113
|
+
<AvatarFallback>B</AvatarFallback>
|
|
114
|
+
</Avatar>
|
|
115
|
+
<Avatar>
|
|
116
|
+
<AvatarImage src="https://i.pravatar.cc/96?img=3" alt="C" />
|
|
117
|
+
<AvatarFallback>C</AvatarFallback>
|
|
118
|
+
</Avatar>
|
|
119
|
+
</AvatarGroup>
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const GroupWithOverflow: Story = {
|
|
124
|
+
render: () => (
|
|
125
|
+
<AvatarGroup size="md" max={3}>
|
|
126
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
127
|
+
<Avatar key={i}>
|
|
128
|
+
<AvatarImage src={`https://i.pravatar.cc/96?img=${i + 1}`} alt="" />
|
|
129
|
+
<AvatarFallback>{String(i + 1)}</AvatarFallback>
|
|
130
|
+
</Avatar>
|
|
131
|
+
))}
|
|
132
|
+
</AvatarGroup>
|
|
133
|
+
),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const GroupSpacing: Story = {
|
|
137
|
+
render: () => (
|
|
138
|
+
<div className="space-y-3">
|
|
139
|
+
<div>
|
|
140
|
+
<p className="text-muted-foreground mb-2 text-xs">tight</p>
|
|
141
|
+
<AvatarGroup size="md" spacing="tight">
|
|
142
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
143
|
+
<Avatar key={i}>
|
|
144
|
+
<AvatarImage src={`https://i.pravatar.cc/96?img=${i + 10}`} alt="" />
|
|
145
|
+
<AvatarFallback>{i}</AvatarFallback>
|
|
146
|
+
</Avatar>
|
|
147
|
+
))}
|
|
148
|
+
</AvatarGroup>
|
|
149
|
+
</div>
|
|
150
|
+
<div>
|
|
151
|
+
<p className="text-muted-foreground mb-2 text-xs">normal</p>
|
|
152
|
+
<AvatarGroup size="md" spacing="normal">
|
|
153
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
154
|
+
<Avatar key={i}>
|
|
155
|
+
<AvatarImage src={`https://i.pravatar.cc/96?img=${i + 10}`} alt="" />
|
|
156
|
+
<AvatarFallback>{i}</AvatarFallback>
|
|
157
|
+
</Avatar>
|
|
158
|
+
))}
|
|
159
|
+
</AvatarGroup>
|
|
160
|
+
</div>
|
|
161
|
+
<div>
|
|
162
|
+
<p className="text-muted-foreground mb-2 text-xs">loose</p>
|
|
163
|
+
<AvatarGroup size="md" spacing="loose">
|
|
164
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
165
|
+
<Avatar key={i}>
|
|
166
|
+
<AvatarImage src={`https://i.pravatar.cc/96?img=${i + 10}`} alt="" />
|
|
167
|
+
<AvatarFallback>{i}</AvatarFallback>
|
|
168
|
+
</Avatar>
|
|
169
|
+
))}
|
|
170
|
+
</AvatarGroup>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
),
|
|
174
|
+
};
|