@olwiba/ui 0.2.22 → 0.2.24
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/dist/index.d.ts +106 -10
- package/dist/index.js +341 -103
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/app/AppFooter.tsx +39 -0
- package/src/app/AppPageHero.tsx +73 -20
- package/src/app/AppShell.tsx +179 -32
- package/src/app/EmptyState.tsx +63 -12
- package/src/blog/PostList.tsx +19 -6
- package/src/components/LoadMore.tsx +80 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olwiba/ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@dnd-kit/modifiers": "^9.0.0",
|
|
42
42
|
"@dnd-kit/sortable": "^10.0.0",
|
|
43
43
|
"@dnd-kit/utilities": "^3.2.2",
|
|
44
|
-
"@olwiba/dx": "0.0.
|
|
44
|
+
"@olwiba/dx": "0.0.23",
|
|
45
45
|
"@tanstack/react-table": "^8.21.3",
|
|
46
46
|
"clsx": "^2.1.1",
|
|
47
47
|
"lucide-react": "^0.562.0",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@content-collections/mdx": "^0.2.2",
|
|
66
|
-
"@olwiba/cn": "0.1.
|
|
67
|
-
"@olwiba/docs": "0.1.
|
|
66
|
+
"@olwiba/cn": "0.1.35",
|
|
67
|
+
"@olwiba/docs": "0.1.40",
|
|
68
68
|
"@tailwindcss/vite": "^4.1.18",
|
|
69
69
|
"@tanstack/react-router": "1.154.8",
|
|
70
70
|
"@tanstack/react-router-devtools": "1.154.8",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../lib/utils';
|
|
5
|
+
|
|
6
|
+
export interface AppFooterProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
/** Leading content — a product line, build info, a rotating message. */
|
|
8
|
+
start?: ReactNode;
|
|
9
|
+
/** Trailing content — a status pill, version, or a short link row. */
|
|
10
|
+
end?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Quiet utility row for signed-in chrome.
|
|
15
|
+
*
|
|
16
|
+
* Pass it to `AppShell`'s `footer` slot: it is app chrome, so it lives beside
|
|
17
|
+
* the page outlet rather than inside a page pattern. Public marketing pages use
|
|
18
|
+
* the full `Footer` instead — this exists so long app pages do not end abruptly.
|
|
19
|
+
*
|
|
20
|
+
* The row owns its solid chrome surface and geometry so page ambience cannot
|
|
21
|
+
* bleed through it. What the slots contain — live status, version, links,
|
|
22
|
+
* product copy — stays with the product.
|
|
23
|
+
*/
|
|
24
|
+
export function AppFooter({ start, end, children, className, ...props }: AppFooterProps) {
|
|
25
|
+
return (
|
|
26
|
+
<footer
|
|
27
|
+
{...props}
|
|
28
|
+
className={cn(
|
|
29
|
+
'border-t bg-background px-4 py-3 text-xs text-muted-foreground sm:px-6',
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
34
|
+
<div className="min-w-0 font-medium text-foreground">{start ?? children}</div>
|
|
35
|
+
{end && <div className="flex shrink-0 items-center gap-2">{end}</div>}
|
|
36
|
+
</div>
|
|
37
|
+
</footer>
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/app/AppPageHero.tsx
CHANGED
|
@@ -12,6 +12,15 @@ export interface AppPageHeroProps {
|
|
|
12
12
|
action?: ReactNode;
|
|
13
13
|
children?: ReactNode;
|
|
14
14
|
compact?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* How the address is drawn.
|
|
17
|
+
* - `"card"` (default) — a bordered surface that introduces the route.
|
|
18
|
+
* - `"plain"` — type only. Use when the page content is itself made of
|
|
19
|
+
* surfaces: a second card at the top competes with them instead of
|
|
20
|
+
* introducing them, and pushes the region that actually does something
|
|
21
|
+
* further down the page.
|
|
22
|
+
*/
|
|
23
|
+
surface?: 'card' | 'plain';
|
|
15
24
|
className?: string;
|
|
16
25
|
}
|
|
17
26
|
|
|
@@ -29,52 +38,96 @@ export function AppPageHero({
|
|
|
29
38
|
action,
|
|
30
39
|
children,
|
|
31
40
|
compact = false,
|
|
41
|
+
surface = 'card',
|
|
32
42
|
className,
|
|
33
43
|
}: AppPageHeroProps) {
|
|
44
|
+
const isPlain = surface === 'plain';
|
|
45
|
+
|
|
34
46
|
return (
|
|
35
47
|
<section
|
|
36
48
|
className={cn(
|
|
37
|
-
'relative
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
'relative',
|
|
50
|
+
!isPlain && [
|
|
51
|
+
'overflow-hidden border bg-card/80 shadow-sm',
|
|
52
|
+
// Compact is the page-pattern header: same surface and glow, less
|
|
53
|
+
// bulk, so it introduces a route without eating the fold above dense
|
|
54
|
+
// content.
|
|
55
|
+
compact ? 'rounded-2xl p-4 sm:p-5' : 'rounded-3xl p-5 sm:p-6',
|
|
56
|
+
// The glow blobs are decoration, and `::after` is the section's last
|
|
57
|
+
// child — so with everything on `z-index: auto` it paints above the
|
|
58
|
+
// header content and swallows clicks on whatever sits under it. At
|
|
59
|
+
// narrow widths the action row stacks below the copy, right into the
|
|
60
|
+
// bottom-left blob, which is how a visible button stops responding.
|
|
61
|
+
// pointer-events-none keeps them purely visual.
|
|
62
|
+
'before:pointer-events-none before:absolute before:-right-16 before:-top-16 before:rounded-full before:bg-primary/15 before:blur-3xl',
|
|
63
|
+
'after:pointer-events-none after:absolute after:left-10 after:rounded-full after:bg-primary/10 after:blur-3xl',
|
|
64
|
+
compact
|
|
65
|
+
? 'before:size-32 after:-bottom-16 after:size-40'
|
|
66
|
+
: 'before:size-44 after:-bottom-20 after:size-52',
|
|
67
|
+
],
|
|
46
68
|
className,
|
|
47
69
|
)}
|
|
48
70
|
>
|
|
49
|
-
<div
|
|
50
|
-
|
|
71
|
+
<div
|
|
72
|
+
className={cn(
|
|
73
|
+
'relative flex flex-col sm:flex-row sm:items-start sm:justify-between',
|
|
74
|
+
compact ? 'gap-3' : 'gap-5',
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<div className={cn('flex min-w-0 items-start', compact ? 'gap-3' : 'gap-4')}>
|
|
51
78
|
{Icon && (
|
|
52
|
-
<div
|
|
53
|
-
|
|
79
|
+
<div
|
|
80
|
+
className={cn(
|
|
81
|
+
'flex shrink-0 items-center justify-center border bg-background/70 text-primary shadow-sm',
|
|
82
|
+
compact ? 'size-10 rounded-xl' : 'size-12 rounded-2xl',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<Icon className={compact ? 'size-4' : 'size-5'} />
|
|
54
86
|
</div>
|
|
55
87
|
)}
|
|
56
|
-
<div className=
|
|
88
|
+
<div className={cn('min-w-0', compact ? 'space-y-1' : 'space-y-2')}>
|
|
57
89
|
{eyebrow && (
|
|
58
|
-
<p
|
|
90
|
+
<p
|
|
91
|
+
className={cn(
|
|
92
|
+
'font-semibold uppercase text-primary/80',
|
|
93
|
+
compact
|
|
94
|
+
? 'text-[11px] tracking-[0.18em]'
|
|
95
|
+
: 'text-xs tracking-[0.24em]',
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
59
98
|
{eyebrow}
|
|
60
99
|
</p>
|
|
61
100
|
)}
|
|
62
101
|
<h1
|
|
63
102
|
className={cn(
|
|
64
|
-
|
|
65
|
-
|
|
103
|
+
// Default leading puts ~4px of half-leading above the cap, so
|
|
104
|
+
// the title reads as sitting low against a top-aligned icon.
|
|
105
|
+
'font-semibold leading-tight tracking-tight',
|
|
106
|
+
compact ? 'text-xl sm:text-2xl' : 'text-3xl sm:text-4xl',
|
|
66
107
|
)}
|
|
67
108
|
>
|
|
68
109
|
{title}
|
|
69
110
|
</h1>
|
|
70
111
|
{description && (
|
|
71
|
-
|
|
112
|
+
// A div, not a p: `description` is a ReactNode, and callers pass
|
|
113
|
+
// multiple paragraphs. Nesting <p> inside <p> is invalid HTML and
|
|
114
|
+
// the parser silently unnests it, which breaks hydration.
|
|
115
|
+
<div
|
|
116
|
+
className={cn(
|
|
117
|
+
'max-w-2xl text-muted-foreground',
|
|
118
|
+
compact ? 'text-sm leading-5' : 'text-sm leading-6',
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{description}
|
|
122
|
+
</div>
|
|
72
123
|
)}
|
|
73
124
|
</div>
|
|
74
125
|
</div>
|
|
75
|
-
{action &&
|
|
126
|
+
{action && (
|
|
127
|
+
<div className={cn('relative shrink-0', !compact && 'sm:pt-1')}>{action}</div>
|
|
128
|
+
)}
|
|
76
129
|
</div>
|
|
77
|
-
{children && <div className=
|
|
130
|
+
{children && <div className={cn('relative', compact ? 'mt-4' : 'mt-5')}>{children}</div>}
|
|
78
131
|
</section>
|
|
79
132
|
);
|
|
80
133
|
}
|
package/src/app/AppShell.tsx
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
CreditCardIcon,
|
|
9
9
|
LogOutIcon,
|
|
10
10
|
MoreVerticalIcon,
|
|
11
|
-
PlusCircleIcon,
|
|
12
11
|
SettingsIcon,
|
|
13
12
|
} from 'lucide-react';
|
|
14
13
|
import {
|
|
@@ -60,6 +59,8 @@ export interface AppShellUser {
|
|
|
60
59
|
email: string;
|
|
61
60
|
name?: string;
|
|
62
61
|
avatar?: string;
|
|
62
|
+
/** Product-owned artwork shown when no avatar image exists. Defaults to the Olwiba bird. */
|
|
63
|
+
avatarFallback?: ReactNode;
|
|
63
64
|
/** Displayed as a sub-label (e.g. plan tier). */
|
|
64
65
|
plan?: string;
|
|
65
66
|
onSignOut?: () => void;
|
|
@@ -84,18 +85,54 @@ export type AppShellRenderLink = (props: {
|
|
|
84
85
|
className?: string;
|
|
85
86
|
}) => ReactNode;
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* One step in the header location trail. A bare string is a plain label; give
|
|
90
|
+
* it an `href` to make it walkable. The final crumb is the current route and is
|
|
91
|
+
* never rendered as a link.
|
|
92
|
+
*/
|
|
93
|
+
export type AppShellBreadcrumb = string | { label: string; href?: string };
|
|
94
|
+
|
|
95
|
+
export interface AppShellHeaderControls {
|
|
96
|
+
/** Expands the desktop sidebar or opens its native mobile presentation. */
|
|
97
|
+
expandSidebar: () => void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type AppShellHeaderStart = ReactNode | ((controls: AppShellHeaderControls) => ReactNode);
|
|
101
|
+
|
|
87
102
|
export interface AppShellProps {
|
|
88
103
|
brand?: AppShellBrand;
|
|
89
104
|
navItems?: AppNavItem[];
|
|
90
105
|
/** Primary CTA rendered above nav (e.g. "New project", "Quick create") */
|
|
91
106
|
action?: AppShellAction;
|
|
92
107
|
user?: AppShellUser;
|
|
93
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Location context shown in the top header bar. This is app chrome, not the
|
|
110
|
+
* page address — the page (or its page pattern) owns the `<h1>`. Repeating
|
|
111
|
+
* the page title here prints the same words twice on every route.
|
|
112
|
+
*/
|
|
94
113
|
pageTitle?: string;
|
|
95
|
-
/**
|
|
96
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Where the current route sits in the app, e.g.
|
|
116
|
+
* `[{ label: "Admin", href: "/admin" }, "Users"]`. Rendered as a muted trail
|
|
117
|
+
* and preferred over `pageTitle` when supplied.
|
|
118
|
+
*/
|
|
119
|
+
breadcrumbs?: AppShellBreadcrumb[];
|
|
120
|
+
/**
|
|
121
|
+
* Slot rendered at the start of the top header bar, after the sidebar trigger.
|
|
122
|
+
* Use the render form when a custom control needs to expand the shell's own
|
|
123
|
+
* sidebar without importing its private provider context.
|
|
124
|
+
*/
|
|
125
|
+
headerStart?: AppShellHeaderStart;
|
|
97
126
|
/** Slot rendered at the end of the top header bar. */
|
|
98
127
|
headerEnd?: ReactNode;
|
|
128
|
+
/** Product chrome rendered above the user menu in the sidebar footer. */
|
|
129
|
+
sidebarFooterStart?: ReactNode;
|
|
130
|
+
/**
|
|
131
|
+
* App chrome rendered after the page outlet — normally an `AppFooter`. This
|
|
132
|
+
* belongs to the shell, not to a page pattern: otherwise every route
|
|
133
|
+
* re-declares it and height-filling pages fight it for the remaining space.
|
|
134
|
+
*/
|
|
135
|
+
footer?: ReactNode;
|
|
99
136
|
/** Override link rendering for SPA navigation. Defaults to a native <a>. */
|
|
100
137
|
renderLink?: AppShellRenderLink;
|
|
101
138
|
children?: ReactNode;
|
|
@@ -112,6 +149,10 @@ export interface AppShellProps {
|
|
|
112
149
|
* - `"contained"` — fills its parent container; use in docs sandboxes, modals, or embedded previews.
|
|
113
150
|
*/
|
|
114
151
|
sidebarPosition?: 'viewport' | 'contained';
|
|
152
|
+
/** Remount key for an animated sidebar identity or navigation-mode change. */
|
|
153
|
+
sidebarContentKey?: string;
|
|
154
|
+
/** Classes applied to the sidebar header, content, and footer. */
|
|
155
|
+
sidebarContentClassName?: string;
|
|
115
156
|
/** Which side the sidebar sits on. @default "left" */
|
|
116
157
|
side?: 'left' | 'right';
|
|
117
158
|
/**
|
|
@@ -204,10 +245,12 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
204
245
|
<Avatar mode={avatarMode} size="sm">
|
|
205
246
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
206
247
|
<AvatarFallback className="text-white" style={identityTint}>
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
248
|
+
{user.avatarFallback ?? (
|
|
249
|
+
<BirdIcon
|
|
250
|
+
aria-hidden
|
|
251
|
+
className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
|
|
252
|
+
/>
|
|
253
|
+
)}
|
|
211
254
|
</AvatarFallback>
|
|
212
255
|
</Avatar>
|
|
213
256
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
@@ -230,10 +273,12 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
230
273
|
<Avatar mode={avatarMode} size="sm">
|
|
231
274
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
232
275
|
<AvatarFallback className="text-white" style={identityTint}>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
276
|
+
{user.avatarFallback ?? (
|
|
277
|
+
<BirdIcon
|
|
278
|
+
aria-hidden
|
|
279
|
+
className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
237
282
|
</AvatarFallback>
|
|
238
283
|
</Avatar>
|
|
239
284
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
@@ -292,6 +337,9 @@ function ShellSidebar({
|
|
|
292
337
|
collapsible,
|
|
293
338
|
side,
|
|
294
339
|
sidebarPosition,
|
|
340
|
+
sidebarContentKey,
|
|
341
|
+
sidebarContentClassName,
|
|
342
|
+
sidebarFooterStart,
|
|
295
343
|
}: {
|
|
296
344
|
brand: AppShellBrand;
|
|
297
345
|
navItems: AppNavItem[];
|
|
@@ -301,6 +349,9 @@ function ShellSidebar({
|
|
|
301
349
|
collapsible: 'icon' | 'offcanvas' | 'none';
|
|
302
350
|
side: 'left' | 'right';
|
|
303
351
|
sidebarPosition: 'viewport' | 'contained';
|
|
352
|
+
sidebarContentKey?: string;
|
|
353
|
+
sidebarContentClassName?: string;
|
|
354
|
+
sidebarFooterStart?: ReactNode;
|
|
304
355
|
}) {
|
|
305
356
|
const fallbackLogo = typeof brand.name === 'string' ? brand.name.slice(0, 1).toUpperCase() : null;
|
|
306
357
|
const { isMobile, setOpenMobile } = useSidebar();
|
|
@@ -310,7 +361,10 @@ function ShellSidebar({
|
|
|
310
361
|
|
|
311
362
|
return (
|
|
312
363
|
<Sidebar side={side} collapsible={collapsible} sidebarPosition={sidebarPosition}>
|
|
313
|
-
<SidebarHeader
|
|
364
|
+
<SidebarHeader
|
|
365
|
+
key={`${sidebarContentKey ?? 'default'}-header`}
|
|
366
|
+
className={cn('py-3', sidebarContentClassName)}
|
|
367
|
+
>
|
|
314
368
|
<SidebarMenu>
|
|
315
369
|
<SidebarMenuItem onClickCapture={closeMobileMenu}>
|
|
316
370
|
{/* Not a link, and not a button.
|
|
@@ -365,7 +419,10 @@ function ShellSidebar({
|
|
|
365
419
|
</SidebarMenu>
|
|
366
420
|
</SidebarHeader>
|
|
367
421
|
|
|
368
|
-
<SidebarContent
|
|
422
|
+
<SidebarContent
|
|
423
|
+
key={`${sidebarContentKey ?? 'default'}-content`}
|
|
424
|
+
className={sidebarContentClassName}
|
|
425
|
+
>
|
|
369
426
|
<SidebarGroup>
|
|
370
427
|
<SidebarGroupContent className="flex flex-col gap-2">
|
|
371
428
|
{action && (
|
|
@@ -422,7 +479,11 @@ function ShellSidebar({
|
|
|
422
479
|
</SidebarGroup>
|
|
423
480
|
</SidebarContent>
|
|
424
481
|
|
|
425
|
-
<SidebarFooter
|
|
482
|
+
<SidebarFooter
|
|
483
|
+
key={`${sidebarContentKey ?? 'default'}-footer`}
|
|
484
|
+
className={sidebarContentClassName}
|
|
485
|
+
>
|
|
486
|
+
{sidebarFooterStart}
|
|
426
487
|
<NavUser user={user} />
|
|
427
488
|
</SidebarFooter>
|
|
428
489
|
</Sidebar>
|
|
@@ -431,29 +492,87 @@ function ShellSidebar({
|
|
|
431
492
|
|
|
432
493
|
function ShellHeader({
|
|
433
494
|
pageTitle,
|
|
495
|
+
breadcrumbs,
|
|
434
496
|
headerStart,
|
|
435
497
|
headerEnd,
|
|
498
|
+
renderLink,
|
|
436
499
|
}: {
|
|
437
|
-
pageTitle
|
|
438
|
-
|
|
500
|
+
pageTitle?: string;
|
|
501
|
+
breadcrumbs?: AppShellBreadcrumb[];
|
|
502
|
+
headerStart?: AppShellHeaderStart;
|
|
439
503
|
headerEnd?: ReactNode;
|
|
504
|
+
renderLink: AppShellRenderLink;
|
|
440
505
|
}) {
|
|
506
|
+
const { isMobile, setOpen, setOpenMobile } = useSidebar();
|
|
507
|
+
const expandSidebar = useCallback(() => {
|
|
508
|
+
if (isMobile) setOpenMobile(true);
|
|
509
|
+
else setOpen(true);
|
|
510
|
+
}, [isMobile, setOpen, setOpenMobile]);
|
|
511
|
+
const headerStartContent =
|
|
512
|
+
typeof headerStart === 'function' ? headerStart({ expandSidebar }) : headerStart;
|
|
513
|
+
|
|
514
|
+
// App chrome states location, the page states its own name. This used to be
|
|
515
|
+
// an <h1>, which meant every page rendering its own heading shipped two of
|
|
516
|
+
// them and printed the same words twice.
|
|
517
|
+
const source: AppShellBreadcrumb[] = breadcrumbs?.length
|
|
518
|
+
? breadcrumbs
|
|
519
|
+
: pageTitle
|
|
520
|
+
? [pageTitle]
|
|
521
|
+
: [];
|
|
522
|
+
const trail = source.map((crumb) =>
|
|
523
|
+
typeof crumb === 'string' ? { label: crumb, href: undefined } : crumb,
|
|
524
|
+
);
|
|
525
|
+
|
|
441
526
|
return (
|
|
442
527
|
<header className="flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
|
443
528
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
|
444
529
|
<SidebarTrigger className="-ml-1" />
|
|
445
|
-
{
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
530
|
+
{headerStartContent && <div className="flex items-center gap-1">{headerStartContent}</div>}
|
|
531
|
+
{trail.length > 0 && (
|
|
532
|
+
<>
|
|
533
|
+
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
|
|
534
|
+
<nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
|
|
535
|
+
{trail.map((crumb, index) => {
|
|
536
|
+
const isCurrent = index === trail.length - 1;
|
|
537
|
+
// The last crumb is where you already are, so it stays inert
|
|
538
|
+
// even when the consumer hands it an href.
|
|
539
|
+
const isLink = !isCurrent && Boolean(crumb.href);
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<span
|
|
543
|
+
className="flex min-w-0 items-center gap-1.5"
|
|
544
|
+
key={`${crumb.label}-${index}`}
|
|
545
|
+
>
|
|
546
|
+
{index > 0 && (
|
|
547
|
+
<span aria-hidden="true" className="text-muted-foreground/60">
|
|
548
|
+
/
|
|
549
|
+
</span>
|
|
550
|
+
)}
|
|
551
|
+
{isLink ? (
|
|
552
|
+
renderLink({
|
|
553
|
+
href: crumb.href as string,
|
|
554
|
+
className:
|
|
555
|
+
'truncate rounded-sm text-muted-foreground transition-colors hover:text-foreground hover:underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
556
|
+
children: crumb.label,
|
|
557
|
+
})
|
|
558
|
+
) : (
|
|
559
|
+
<span
|
|
560
|
+
aria-current={isCurrent ? 'page' : undefined}
|
|
561
|
+
className={cn(
|
|
562
|
+
'truncate',
|
|
563
|
+
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
|
|
564
|
+
)}
|
|
565
|
+
>
|
|
566
|
+
{crumb.label}
|
|
567
|
+
</span>
|
|
568
|
+
)}
|
|
569
|
+
</span>
|
|
570
|
+
);
|
|
571
|
+
})}
|
|
572
|
+
</nav>
|
|
573
|
+
</>
|
|
456
574
|
)}
|
|
575
|
+
{headerEnd && <div className="ml-auto flex items-center gap-1">{headerEnd}</div>}
|
|
457
576
|
</div>
|
|
458
577
|
</header>
|
|
459
578
|
);
|
|
@@ -472,7 +591,9 @@ const defaultUser: AppShellUser = {
|
|
|
472
591
|
};
|
|
473
592
|
|
|
474
593
|
const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
|
|
475
|
-
<a href={href} className={className}>
|
|
594
|
+
<a href={href} className={className}>
|
|
595
|
+
{children}
|
|
596
|
+
</a>
|
|
476
597
|
);
|
|
477
598
|
|
|
478
599
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
@@ -482,12 +603,17 @@ export function AppShell({
|
|
|
482
603
|
navItems = [],
|
|
483
604
|
action,
|
|
484
605
|
user = defaultUser,
|
|
485
|
-
pageTitle
|
|
606
|
+
pageTitle,
|
|
607
|
+
breadcrumbs,
|
|
608
|
+
footer,
|
|
486
609
|
headerStart,
|
|
487
610
|
headerEnd,
|
|
488
611
|
renderLink = defaultRenderLink,
|
|
489
612
|
collapsible = 'icon',
|
|
490
613
|
sidebarPosition = 'viewport',
|
|
614
|
+
sidebarContentKey,
|
|
615
|
+
sidebarContentClassName,
|
|
616
|
+
sidebarFooterStart,
|
|
491
617
|
side = 'left',
|
|
492
618
|
contentClassName = 'p-6',
|
|
493
619
|
children,
|
|
@@ -508,10 +634,31 @@ export function AppShell({
|
|
|
508
634
|
collapsible={collapsible}
|
|
509
635
|
side={side}
|
|
510
636
|
sidebarPosition={sidebarPosition}
|
|
637
|
+
sidebarContentKey={sidebarContentKey}
|
|
638
|
+
sidebarContentClassName={sidebarContentClassName}
|
|
639
|
+
sidebarFooterStart={sidebarFooterStart}
|
|
511
640
|
/>
|
|
512
641
|
<SidebarInset className="overflow-y-auto">
|
|
513
|
-
<ShellHeader
|
|
514
|
-
|
|
642
|
+
<ShellHeader
|
|
643
|
+
pageTitle={pageTitle}
|
|
644
|
+
breadcrumbs={breadcrumbs}
|
|
645
|
+
headerStart={headerStart}
|
|
646
|
+
headerEnd={headerEnd}
|
|
647
|
+
renderLink={renderLink}
|
|
648
|
+
/>
|
|
649
|
+
{/* The shell owns the height chain so routes never have to reconstruct
|
|
650
|
+
it. `grow` fills the shell on a short page, which settles the footer
|
|
651
|
+
at the bottom; `shrink-0` with an auto basis means a long page keeps
|
|
652
|
+
its intrinsic height and scrolls instead of spilling over the
|
|
653
|
+
footer. `flex flex-col` + `min-h-0` is what lets a route opt into
|
|
654
|
+
filling the leftover height with `flex-1` on its own root.
|
|
655
|
+
|
|
656
|
+
This cannot be left to the consumer: a route passing `flex-1` here
|
|
657
|
+
gets basis 0, which ignores content height entirely. */}
|
|
658
|
+
<div className={cn('flex min-h-0 grow shrink-0 flex-col', contentClassName)}>
|
|
659
|
+
{children}
|
|
660
|
+
</div>
|
|
661
|
+
{footer && <div className="shrink-0">{footer}</div>}
|
|
515
662
|
</SidebarInset>
|
|
516
663
|
</SidebarProvider>
|
|
517
664
|
);
|
package/src/app/EmptyState.tsx
CHANGED
|
@@ -7,28 +7,79 @@ import { cn } from '@olwiba/cn';
|
|
|
7
7
|
export interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
8
|
icon?: LucideIcon;
|
|
9
9
|
title: string;
|
|
10
|
-
description?:
|
|
10
|
+
description?: React.ReactNode;
|
|
11
|
+
eyebrow?: React.ReactNode;
|
|
11
12
|
action?: React.ReactNode;
|
|
13
|
+
secondaryAction?: React.ReactNode;
|
|
14
|
+
/** Adds the application-card surface used for route-level empty states. */
|
|
15
|
+
variant?: 'plain' | 'card';
|
|
16
|
+
/** Reduces the card presentation's minimum height and padding. */
|
|
17
|
+
compact?: boolean;
|
|
18
|
+
/** Fills a flex-sized parent rather than using a fixed minimum height. */
|
|
19
|
+
fill?: boolean;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
export function EmptyState({
|
|
22
|
+
export function EmptyState({
|
|
23
|
+
icon: Icon,
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
eyebrow,
|
|
27
|
+
action,
|
|
28
|
+
secondaryAction,
|
|
29
|
+
variant = 'plain',
|
|
30
|
+
compact = false,
|
|
31
|
+
fill = false,
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: EmptyStateProps) {
|
|
35
|
+
const card = variant === 'card';
|
|
36
|
+
|
|
15
37
|
return (
|
|
16
38
|
<div
|
|
17
|
-
className={cn(
|
|
39
|
+
className={cn(
|
|
40
|
+
'relative flex flex-col items-center justify-center overflow-hidden text-center',
|
|
41
|
+
!card && 'gap-3 py-12',
|
|
42
|
+
card && 'min-h-72 rounded-xl border border-dashed bg-card/80 p-8 shadow-sm sm:p-10',
|
|
43
|
+
card && compact && 'min-h-0 p-5 sm:p-6',
|
|
44
|
+
fill && 'min-h-0 w-full flex-1',
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
18
47
|
{...props}
|
|
19
48
|
>
|
|
20
|
-
{
|
|
21
|
-
<div className="
|
|
22
|
-
<Icon className="size-6 text-muted-foreground" />
|
|
23
|
-
</div>
|
|
49
|
+
{card && (
|
|
50
|
+
<div className="pointer-events-none absolute inset-x-10 top-0 h-24 rounded-full bg-primary/10 blur-3xl" />
|
|
24
51
|
)}
|
|
25
|
-
<div className=
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
52
|
+
<div className={cn('relative flex flex-col items-center', card ? 'gap-4' : 'gap-3')}>
|
|
53
|
+
{Icon && (
|
|
54
|
+
<div
|
|
55
|
+
className={cn(
|
|
56
|
+
'flex size-12 items-center justify-center rounded-2xl border',
|
|
57
|
+
card ? 'bg-background text-primary shadow-sm' : 'bg-muted text-muted-foreground',
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
<Icon className={card ? 'size-5' : 'size-6'} />
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
<div className={cn(card ? 'max-w-md space-y-2' : 'max-w-xs space-y-1')}>
|
|
64
|
+
{eyebrow && (
|
|
65
|
+
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
|
66
|
+
{eyebrow}
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
<h3 className={cn('font-semibold', card && 'text-xl tracking-tight')}>{title}</h3>
|
|
70
|
+
{description && (
|
|
71
|
+
<div className={cn('text-sm text-muted-foreground', card && 'leading-6')}>
|
|
72
|
+
{description}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{(action || secondaryAction) && (
|
|
77
|
+
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
|
78
|
+
{action}
|
|
79
|
+
{secondaryAction}
|
|
80
|
+
</div>
|
|
29
81
|
)}
|
|
30
82
|
</div>
|
|
31
|
-
{action}
|
|
32
83
|
</div>
|
|
33
84
|
);
|
|
34
85
|
}
|