@olwiba/ui 0.2.22 → 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/dist/index.d.ts +57 -5
- package/dist/index.js +153 -38
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/app/AppFooter.tsx +38 -0
- package/src/app/AppPageHero.tsx +73 -20
- package/src/app/AppShell.tsx +108 -7
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olwiba/ui",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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,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
|
+
}
|
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
|
@@ -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
|
-
/**
|
|
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;
|
|
@@ -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
|
|
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
|
-
|
|
451
|
-
|
|
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
|
|
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
|
|
514
|
-
|
|
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
|
);
|
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,
|