@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.22",
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.20",
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.26",
67
- "@olwiba/docs": "0.1.38",
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
+ }
@@ -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 overflow-hidden rounded-3xl border bg-card/80 p-5 shadow-sm sm:p-6',
38
- // The glow blobs are decoration, and `::after` is the section's last
39
- // child so with everything on `z-index: auto` it paints above the
40
- // header content and swallows clicks on whatever sits under it. At
41
- // narrow widths the action row stacks below the copy, right into the
42
- // bottom-left blob, which is how a visible button stops responding.
43
- // pointer-events-none keeps them purely visual.
44
- 'before:pointer-events-none before:absolute before:-right-16 before:-top-16 before:size-44 before:rounded-full before:bg-primary/15 before:blur-3xl',
45
- 'after:pointer-events-none after:absolute after:-bottom-20 after:left-10 after:size-52 after:rounded-full after:bg-primary/10 after:blur-3xl',
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 className="relative flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
50
- <div className="flex min-w-0 gap-4">
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 className="flex size-12 shrink-0 items-center justify-center rounded-2xl border bg-background/70 text-primary shadow-sm">
53
- <Icon className="size-5" />
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="min-w-0 space-y-2">
88
+ <div className={cn('min-w-0', compact ? 'space-y-1' : 'space-y-2')}>
57
89
  {eyebrow && (
58
- <p className="text-xs font-semibold uppercase tracking-[0.24em] text-primary/80">
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
- 'font-semibold tracking-tight',
65
- compact ? 'text-2xl' : 'text-3xl sm:text-4xl',
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
- <p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
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 && <div className="relative shrink-0 sm:pt-1">{action}</div>}
126
+ {action && (
127
+ <div className={cn('relative shrink-0', !compact && 'sm:pt-1')}>{action}</div>
128
+ )}
76
129
  </div>
77
- {children && <div className="relative mt-5">{children}</div>}
130
+ {children && <div className={cn('relative', compact ? 'mt-4' : 'mt-5')}>{children}</div>}
78
131
  </section>
79
132
  );
80
133
  }
@@ -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
- /** Text shown in the top header bar */
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
- /** Slot rendered at the start of the top header bar, after the sidebar trigger. */
96
- headerStart?: ReactNode;
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
- <BirdIcon
208
- aria-hidden
209
- className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
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
- <BirdIcon
234
- aria-hidden
235
- className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
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 className="py-3">
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: string;
438
- headerStart?: ReactNode;
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
- {headerStart && (
446
- <div className="flex items-center gap-1">
447
- {headerStart}
448
- </div>
449
- )}
450
- <Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
451
- <h1 className="text-base font-medium">{pageTitle}</h1>
452
- {headerEnd && (
453
- <div className="ml-auto flex items-center gap-1">
454
- {headerEnd}
455
- </div>
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}>{children}</a>
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 = 'Dashboard',
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 pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
514
- <div className={contentClassName}>{children}</div>
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
  );
@@ -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?: string;
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({ icon: Icon, title, description, action, className, ...props }: EmptyStateProps) {
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('flex flex-col items-center justify-center gap-3 py-12 text-center', className)}
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
- {Icon && (
21
- <div className="flex h-12 w-12 items-center justify-center rounded-2xl border bg-muted">
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="space-y-1">
26
- <h3 className="font-semibold">{title}</h3>
27
- {description && (
28
- <p className="max-w-xs text-sm text-muted-foreground">{description}</p>
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
  }