@olwiba/ui 0.2.21 → 0.2.23

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.21",
3
+ "version": "0.2.23",
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,38 @@
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 geometry only. What the slots contain — live status, version,
21
+ * links, product copy — stays with the product.
22
+ */
23
+ export function AppFooter({ start, end, children, className, ...props }: AppFooterProps) {
24
+ return (
25
+ <footer
26
+ {...props}
27
+ className={cn(
28
+ 'border-t px-4 py-3 text-xs text-muted-foreground sm:px-6',
29
+ className,
30
+ )}
31
+ >
32
+ <div className="flex min-w-0 items-center justify-between gap-3">
33
+ <div className="min-w-0 font-medium text-foreground">{start ?? children}</div>
34
+ {end && <div className="flex shrink-0 items-center gap-2">{end}</div>}
35
+ </div>
36
+ </footer>
37
+ );
38
+ }
@@ -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
  }
@@ -84,18 +84,41 @@ export type AppShellRenderLink = (props: {
84
84
  className?: string;
85
85
  }) => ReactNode;
86
86
 
87
+ /**
88
+ * One step in the header location trail. A bare string is a plain label; give
89
+ * it an `href` to make it walkable. The final crumb is the current route and is
90
+ * never rendered as a link.
91
+ */
92
+ export type AppShellBreadcrumb = string | { label: string; href?: string };
93
+
87
94
  export interface AppShellProps {
88
95
  brand?: AppShellBrand;
89
96
  navItems?: AppNavItem[];
90
97
  /** Primary CTA rendered above nav (e.g. "New project", "Quick create") */
91
98
  action?: AppShellAction;
92
99
  user?: AppShellUser;
93
- /** Text shown in the top header bar */
100
+ /**
101
+ * Location context shown in the top header bar. This is app chrome, not the
102
+ * page address — the page (or its page pattern) owns the `<h1>`. Repeating
103
+ * the page title here prints the same words twice on every route.
104
+ */
94
105
  pageTitle?: string;
106
+ /**
107
+ * Where the current route sits in the app, e.g.
108
+ * `[{ label: "Admin", href: "/admin" }, "Users"]`. Rendered as a muted trail
109
+ * and preferred over `pageTitle` when supplied.
110
+ */
111
+ breadcrumbs?: AppShellBreadcrumb[];
95
112
  /** Slot rendered at the start of the top header bar, after the sidebar trigger. */
96
113
  headerStart?: ReactNode;
97
114
  /** Slot rendered at the end of the top header bar. */
98
115
  headerEnd?: ReactNode;
116
+ /**
117
+ * App chrome rendered after the page outlet — normally an `AppFooter`. This
118
+ * belongs to the shell, not to a page pattern: otherwise every route
119
+ * re-declares it and height-filling pages fight it for the remaining space.
120
+ */
121
+ footer?: ReactNode;
99
122
  /** Override link rendering for SPA navigation. Defaults to a native <a>. */
100
123
  renderLink?: AppShellRenderLink;
101
124
  children?: ReactNode;
@@ -206,7 +229,7 @@ function NavUser({ user }: { user: AppShellUser }) {
206
229
  <AvatarFallback className="text-white" style={identityTint}>
207
230
  <BirdIcon
208
231
  aria-hidden
209
- className="size-4 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
232
+ className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
210
233
  />
211
234
  </AvatarFallback>
212
235
  </Avatar>
@@ -232,7 +255,7 @@ function NavUser({ user }: { user: AppShellUser }) {
232
255
  <AvatarFallback className="text-white" style={identityTint}>
233
256
  <BirdIcon
234
257
  aria-hidden
235
- className="size-4 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
258
+ className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
236
259
  />
237
260
  </AvatarFallback>
238
261
  </Avatar>
@@ -431,13 +454,29 @@ function ShellSidebar({
431
454
 
432
455
  function ShellHeader({
433
456
  pageTitle,
457
+ breadcrumbs,
434
458
  headerStart,
435
459
  headerEnd,
460
+ renderLink,
436
461
  }: {
437
- pageTitle: string;
462
+ pageTitle?: string;
463
+ breadcrumbs?: AppShellBreadcrumb[];
438
464
  headerStart?: ReactNode;
439
465
  headerEnd?: ReactNode;
466
+ renderLink: AppShellRenderLink;
440
467
  }) {
468
+ // App chrome states location, the page states its own name. This used to be
469
+ // an <h1>, which meant every page rendering its own heading shipped two of
470
+ // them and printed the same words twice.
471
+ const source: AppShellBreadcrumb[] = breadcrumbs?.length
472
+ ? breadcrumbs
473
+ : pageTitle
474
+ ? [pageTitle]
475
+ : [];
476
+ const trail = source.map((crumb) =>
477
+ typeof crumb === 'string' ? { label: crumb, href: undefined } : crumb,
478
+ );
479
+
441
480
  return (
442
481
  <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
482
  <div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -447,8 +486,50 @@ function ShellHeader({
447
486
  {headerStart}
448
487
  </div>
449
488
  )}
450
- <Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
451
- <h1 className="text-base font-medium">{pageTitle}</h1>
489
+ {trail.length > 0 && (
490
+ <>
491
+ <Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
492
+ <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 text-sm">
493
+ {trail.map((crumb, index) => {
494
+ const isCurrent = index === trail.length - 1;
495
+ // The last crumb is where you already are, so it stays inert
496
+ // even when the consumer hands it an href.
497
+ const isLink = !isCurrent && Boolean(crumb.href);
498
+
499
+ return (
500
+ <span
501
+ className="flex min-w-0 items-center gap-1.5"
502
+ key={`${crumb.label}-${index}`}
503
+ >
504
+ {index > 0 && (
505
+ <span aria-hidden="true" className="text-muted-foreground/60">
506
+ /
507
+ </span>
508
+ )}
509
+ {isLink ? (
510
+ renderLink({
511
+ href: crumb.href as string,
512
+ className:
513
+ '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',
514
+ children: crumb.label,
515
+ })
516
+ ) : (
517
+ <span
518
+ aria-current={isCurrent ? 'page' : undefined}
519
+ className={cn(
520
+ 'truncate',
521
+ isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
522
+ )}
523
+ >
524
+ {crumb.label}
525
+ </span>
526
+ )}
527
+ </span>
528
+ );
529
+ })}
530
+ </nav>
531
+ </>
532
+ )}
452
533
  {headerEnd && (
453
534
  <div className="ml-auto flex items-center gap-1">
454
535
  {headerEnd}
@@ -482,7 +563,9 @@ export function AppShell({
482
563
  navItems = [],
483
564
  action,
484
565
  user = defaultUser,
485
- pageTitle = 'Dashboard',
566
+ pageTitle,
567
+ breadcrumbs,
568
+ footer,
486
569
  headerStart,
487
570
  headerEnd,
488
571
  renderLink = defaultRenderLink,
@@ -510,8 +593,26 @@ export function AppShell({
510
593
  sidebarPosition={sidebarPosition}
511
594
  />
512
595
  <SidebarInset className="overflow-y-auto">
513
- <ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
514
- <div className={contentClassName}>{children}</div>
596
+ <ShellHeader
597
+ pageTitle={pageTitle}
598
+ breadcrumbs={breadcrumbs}
599
+ headerStart={headerStart}
600
+ headerEnd={headerEnd}
601
+ renderLink={renderLink}
602
+ />
603
+ {/* The shell owns the height chain so routes never have to reconstruct
604
+ it. `grow` fills the shell on a short page, which settles the footer
605
+ at the bottom; `shrink-0` with an auto basis means a long page keeps
606
+ its intrinsic height and scrolls instead of spilling over the
607
+ footer. `flex flex-col` + `min-h-0` is what lets a route opt into
608
+ filling the leftover height with `flex-1` on its own root.
609
+
610
+ This cannot be left to the consumer: a route passing `flex-1` here
611
+ gets basis 0, which ignores content height entirely. */}
612
+ <div className={cn('flex min-h-0 grow shrink-0 flex-col', contentClassName)}>
613
+ {children}
614
+ </div>
615
+ {footer && <div className="shrink-0">{footer}</div>}
515
616
  </SidebarInset>
516
617
  </SidebarProvider>
517
618
  );
@@ -105,20 +105,20 @@ export function UpdateBanner({
105
105
  >
106
106
  <Card
107
107
  className={cn(
108
- 'max-w-3xl border-amber-300/60 bg-amber-50 dark:border-amber-700/60 dark:bg-amber-950',
108
+ 'max-w-3xl border-border bg-card text-card-foreground shadow-lg',
109
109
  className,
110
110
  )}
111
111
  >
112
112
  <div className="flex items-center justify-between gap-6 p-4">
113
113
  <div className="flex items-center gap-3">
114
- <Sparkles className="size-5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
115
- <p className="text-sm text-amber-800 dark:text-amber-200">{message}</p>
114
+ <Sparkles className="size-5 shrink-0 text-primary" aria-hidden="true" />
115
+ <p className="text-sm text-card-foreground">{message}</p>
116
116
  </div>
117
117
  <Button
118
118
  onClick={handleRefresh}
119
119
  disabled={refreshing}
120
120
  size="sm"
121
- className="shrink-0 bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700"
121
+ className="shrink-0"
122
122
  >
123
123
  {refreshing ? <RefreshCw className="size-4 animate-spin" /> : buttonLabel}
124
124
  </Button>
package/src/index.ts CHANGED
@@ -26,11 +26,14 @@ export {
26
26
  type AppShellUser,
27
27
  type AppShellAction,
28
28
  type AppShellRenderLink,
29
+ type AppShellBreadcrumb,
29
30
  type AppNavItem,
30
31
  } from './app/AppShell';
31
32
 
32
33
  export { AppPageHero, type AppPageHeroProps } from './app/AppPageHero';
33
34
 
35
+ export { AppFooter, type AppFooterProps } from './app/AppFooter';
36
+
34
37
  export {
35
38
  AuthSection,
36
39
  type AuthSectionProps,