@olwiba/ui 0.2.23 → 0.2.25
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 +104 -12
- package/dist/index.js +231 -84
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/app/AppFooter.tsx +4 -3
- package/src/app/AppShell.tsx +72 -26
- package/src/app/EmptyState.tsx +63 -12
- package/src/blog/PostList.tsx +19 -6
- package/src/components/Chart.tsx +38 -7
- package/src/components/DataTable.tsx +56 -8
- package/src/components/LoadMore.tsx +80 -0
- package/src/components/Notify.tsx +32 -1
- package/src/index.ts +3 -0
- package/src/marketing/Footer.tsx +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olwiba/ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -41,7 +41,6 @@
|
|
|
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.23",
|
|
45
44
|
"@tanstack/react-table": "^8.21.3",
|
|
46
45
|
"clsx": "^2.1.1",
|
|
47
46
|
"lucide-react": "^0.562.0",
|
|
@@ -65,6 +64,7 @@
|
|
|
65
64
|
"@content-collections/mdx": "^0.2.2",
|
|
66
65
|
"@olwiba/cn": "0.1.35",
|
|
67
66
|
"@olwiba/docs": "0.1.40",
|
|
67
|
+
"@olwiba/dx": "0.0.23",
|
|
68
68
|
"@tailwindcss/vite": "^4.1.18",
|
|
69
69
|
"@tanstack/react-router": "1.154.8",
|
|
70
70
|
"@tanstack/react-router-devtools": "1.154.8",
|
package/src/app/AppFooter.tsx
CHANGED
|
@@ -17,15 +17,16 @@ export interface AppFooterProps extends HTMLAttributes<HTMLElement> {
|
|
|
17
17
|
* the page outlet rather than inside a page pattern. Public marketing pages use
|
|
18
18
|
* the full `Footer` instead — this exists so long app pages do not end abruptly.
|
|
19
19
|
*
|
|
20
|
-
* The row owns
|
|
21
|
-
*
|
|
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.
|
|
22
23
|
*/
|
|
23
24
|
export function AppFooter({ start, end, children, className, ...props }: AppFooterProps) {
|
|
24
25
|
return (
|
|
25
26
|
<footer
|
|
26
27
|
{...props}
|
|
27
28
|
className={cn(
|
|
28
|
-
'border-t px-4 py-3 text-xs text-muted-foreground sm:px-6',
|
|
29
|
+
'border-t bg-background px-4 py-3 text-xs text-muted-foreground sm:px-6',
|
|
29
30
|
className,
|
|
30
31
|
)}
|
|
31
32
|
>
|
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;
|
|
@@ -91,6 +92,13 @@ export type AppShellRenderLink = (props: {
|
|
|
91
92
|
*/
|
|
92
93
|
export type AppShellBreadcrumb = string | { label: string; href?: string };
|
|
93
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
|
+
|
|
94
102
|
export interface AppShellProps {
|
|
95
103
|
brand?: AppShellBrand;
|
|
96
104
|
navItems?: AppNavItem[];
|
|
@@ -109,10 +117,16 @@ export interface AppShellProps {
|
|
|
109
117
|
* and preferred over `pageTitle` when supplied.
|
|
110
118
|
*/
|
|
111
119
|
breadcrumbs?: AppShellBreadcrumb[];
|
|
112
|
-
/**
|
|
113
|
-
|
|
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;
|
|
114
126
|
/** Slot rendered at the end of the top header bar. */
|
|
115
127
|
headerEnd?: ReactNode;
|
|
128
|
+
/** Product chrome rendered above the user menu in the sidebar footer. */
|
|
129
|
+
sidebarFooterStart?: ReactNode;
|
|
116
130
|
/**
|
|
117
131
|
* App chrome rendered after the page outlet — normally an `AppFooter`. This
|
|
118
132
|
* belongs to the shell, not to a page pattern: otherwise every route
|
|
@@ -135,6 +149,10 @@ export interface AppShellProps {
|
|
|
135
149
|
* - `"contained"` — fills its parent container; use in docs sandboxes, modals, or embedded previews.
|
|
136
150
|
*/
|
|
137
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;
|
|
138
156
|
/** Which side the sidebar sits on. @default "left" */
|
|
139
157
|
side?: 'left' | 'right';
|
|
140
158
|
/**
|
|
@@ -227,10 +245,12 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
227
245
|
<Avatar mode={avatarMode} size="sm">
|
|
228
246
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
229
247
|
<AvatarFallback className="text-white" style={identityTint}>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
)}
|
|
234
254
|
</AvatarFallback>
|
|
235
255
|
</Avatar>
|
|
236
256
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
@@ -253,10 +273,12 @@ function NavUser({ user }: { user: AppShellUser }) {
|
|
|
253
273
|
<Avatar mode={avatarMode} size="sm">
|
|
254
274
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
255
275
|
<AvatarFallback className="text-white" style={identityTint}>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
)}
|
|
260
282
|
</AvatarFallback>
|
|
261
283
|
</Avatar>
|
|
262
284
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
@@ -315,6 +337,9 @@ function ShellSidebar({
|
|
|
315
337
|
collapsible,
|
|
316
338
|
side,
|
|
317
339
|
sidebarPosition,
|
|
340
|
+
sidebarContentKey,
|
|
341
|
+
sidebarContentClassName,
|
|
342
|
+
sidebarFooterStart,
|
|
318
343
|
}: {
|
|
319
344
|
brand: AppShellBrand;
|
|
320
345
|
navItems: AppNavItem[];
|
|
@@ -324,6 +349,9 @@ function ShellSidebar({
|
|
|
324
349
|
collapsible: 'icon' | 'offcanvas' | 'none';
|
|
325
350
|
side: 'left' | 'right';
|
|
326
351
|
sidebarPosition: 'viewport' | 'contained';
|
|
352
|
+
sidebarContentKey?: string;
|
|
353
|
+
sidebarContentClassName?: string;
|
|
354
|
+
sidebarFooterStart?: ReactNode;
|
|
327
355
|
}) {
|
|
328
356
|
const fallbackLogo = typeof brand.name === 'string' ? brand.name.slice(0, 1).toUpperCase() : null;
|
|
329
357
|
const { isMobile, setOpenMobile } = useSidebar();
|
|
@@ -333,7 +361,10 @@ function ShellSidebar({
|
|
|
333
361
|
|
|
334
362
|
return (
|
|
335
363
|
<Sidebar side={side} collapsible={collapsible} sidebarPosition={sidebarPosition}>
|
|
336
|
-
<SidebarHeader
|
|
364
|
+
<SidebarHeader
|
|
365
|
+
key={`${sidebarContentKey ?? 'default'}-header`}
|
|
366
|
+
className={cn('py-3', sidebarContentClassName)}
|
|
367
|
+
>
|
|
337
368
|
<SidebarMenu>
|
|
338
369
|
<SidebarMenuItem onClickCapture={closeMobileMenu}>
|
|
339
370
|
{/* Not a link, and not a button.
|
|
@@ -388,7 +419,10 @@ function ShellSidebar({
|
|
|
388
419
|
</SidebarMenu>
|
|
389
420
|
</SidebarHeader>
|
|
390
421
|
|
|
391
|
-
<SidebarContent
|
|
422
|
+
<SidebarContent
|
|
423
|
+
key={`${sidebarContentKey ?? 'default'}-content`}
|
|
424
|
+
className={sidebarContentClassName}
|
|
425
|
+
>
|
|
392
426
|
<SidebarGroup>
|
|
393
427
|
<SidebarGroupContent className="flex flex-col gap-2">
|
|
394
428
|
{action && (
|
|
@@ -445,7 +479,11 @@ function ShellSidebar({
|
|
|
445
479
|
</SidebarGroup>
|
|
446
480
|
</SidebarContent>
|
|
447
481
|
|
|
448
|
-
<SidebarFooter
|
|
482
|
+
<SidebarFooter
|
|
483
|
+
key={`${sidebarContentKey ?? 'default'}-footer`}
|
|
484
|
+
className={sidebarContentClassName}
|
|
485
|
+
>
|
|
486
|
+
{sidebarFooterStart}
|
|
449
487
|
<NavUser user={user} />
|
|
450
488
|
</SidebarFooter>
|
|
451
489
|
</Sidebar>
|
|
@@ -461,10 +499,18 @@ function ShellHeader({
|
|
|
461
499
|
}: {
|
|
462
500
|
pageTitle?: string;
|
|
463
501
|
breadcrumbs?: AppShellBreadcrumb[];
|
|
464
|
-
headerStart?:
|
|
502
|
+
headerStart?: AppShellHeaderStart;
|
|
465
503
|
headerEnd?: ReactNode;
|
|
466
504
|
renderLink: AppShellRenderLink;
|
|
467
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
|
+
|
|
468
514
|
// App chrome states location, the page states its own name. This used to be
|
|
469
515
|
// an <h1>, which meant every page rendering its own heading shipped two of
|
|
470
516
|
// them and printed the same words twice.
|
|
@@ -481,11 +527,7 @@ function ShellHeader({
|
|
|
481
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">
|
|
482
528
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
|
483
529
|
<SidebarTrigger className="-ml-1" />
|
|
484
|
-
{
|
|
485
|
-
<div className="flex items-center gap-1">
|
|
486
|
-
{headerStart}
|
|
487
|
-
</div>
|
|
488
|
-
)}
|
|
530
|
+
{headerStartContent && <div className="flex items-center gap-1">{headerStartContent}</div>}
|
|
489
531
|
{trail.length > 0 && (
|
|
490
532
|
<>
|
|
491
533
|
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
|
|
@@ -530,11 +572,7 @@ function ShellHeader({
|
|
|
530
572
|
</nav>
|
|
531
573
|
</>
|
|
532
574
|
)}
|
|
533
|
-
{headerEnd &&
|
|
534
|
-
<div className="ml-auto flex items-center gap-1">
|
|
535
|
-
{headerEnd}
|
|
536
|
-
</div>
|
|
537
|
-
)}
|
|
575
|
+
{headerEnd && <div className="ml-auto flex items-center gap-1">{headerEnd}</div>}
|
|
538
576
|
</div>
|
|
539
577
|
</header>
|
|
540
578
|
);
|
|
@@ -553,7 +591,9 @@ const defaultUser: AppShellUser = {
|
|
|
553
591
|
};
|
|
554
592
|
|
|
555
593
|
const defaultRenderLink: AppShellRenderLink = ({ href, children, className }) => (
|
|
556
|
-
<a href={href} className={className}>
|
|
594
|
+
<a href={href} className={className}>
|
|
595
|
+
{children}
|
|
596
|
+
</a>
|
|
557
597
|
);
|
|
558
598
|
|
|
559
599
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
@@ -571,6 +611,9 @@ export function AppShell({
|
|
|
571
611
|
renderLink = defaultRenderLink,
|
|
572
612
|
collapsible = 'icon',
|
|
573
613
|
sidebarPosition = 'viewport',
|
|
614
|
+
sidebarContentKey,
|
|
615
|
+
sidebarContentClassName,
|
|
616
|
+
sidebarFooterStart,
|
|
574
617
|
side = 'left',
|
|
575
618
|
contentClassName = 'p-6',
|
|
576
619
|
children,
|
|
@@ -591,6 +634,9 @@ export function AppShell({
|
|
|
591
634
|
collapsible={collapsible}
|
|
592
635
|
side={side}
|
|
593
636
|
sidebarPosition={sidebarPosition}
|
|
637
|
+
sidebarContentKey={sidebarContentKey}
|
|
638
|
+
sidebarContentClassName={sidebarContentClassName}
|
|
639
|
+
sidebarFooterStart={sidebarFooterStart}
|
|
594
640
|
/>
|
|
595
641
|
<SidebarInset className="overflow-y-auto">
|
|
596
642
|
<ShellHeader
|
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
|
}
|
package/src/blog/PostList.tsx
CHANGED
|
@@ -9,7 +9,11 @@ export interface PostListProps {
|
|
|
9
9
|
posts: PostCardProps[];
|
|
10
10
|
renderLink?: AppShellRenderLink;
|
|
11
11
|
emptyMessage?: string;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Max columns at the widest breakpoint. When omitted, one- and two-post
|
|
14
|
+
* collections are balanced automatically instead of leaving an empty third
|
|
15
|
+
* column.
|
|
16
|
+
*/
|
|
13
17
|
columns?: 2 | 3;
|
|
14
18
|
className?: string;
|
|
15
19
|
}
|
|
@@ -18,20 +22,29 @@ export function PostList({
|
|
|
18
22
|
posts,
|
|
19
23
|
renderLink,
|
|
20
24
|
emptyMessage = 'No posts published yet.',
|
|
21
|
-
columns
|
|
25
|
+
columns,
|
|
22
26
|
className,
|
|
23
27
|
}: PostListProps) {
|
|
24
28
|
if (posts.length === 0) {
|
|
25
|
-
return
|
|
26
|
-
<p className="text-sm text-muted-foreground italic">{emptyMessage}</p>
|
|
27
|
-
);
|
|
29
|
+
return <p className="text-sm text-muted-foreground italic">{emptyMessage}</p>;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
const resolvedColumns = columns ?? (posts.length < 3 ? 2 : 3);
|
|
33
|
+
const balancedShortListClassName =
|
|
34
|
+
columns === undefined
|
|
35
|
+
? posts.length === 1
|
|
36
|
+
? 'mx-auto w-full max-w-md sm:grid-cols-1'
|
|
37
|
+
: posts.length === 2
|
|
38
|
+
? 'mx-auto w-full max-w-3xl'
|
|
39
|
+
: undefined
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
30
42
|
return (
|
|
31
43
|
<div
|
|
32
44
|
className={cn(
|
|
33
45
|
'grid grid-cols-1 gap-x-8 gap-y-14 sm:grid-cols-2',
|
|
34
|
-
|
|
46
|
+
resolvedColumns === 3 && 'lg:grid-cols-3',
|
|
47
|
+
balancedShortListClassName,
|
|
35
48
|
className,
|
|
36
49
|
)}
|
|
37
50
|
>
|
package/src/components/Chart.tsx
CHANGED
|
@@ -42,6 +42,27 @@ export interface ChartProps {
|
|
|
42
42
|
grid?: boolean;
|
|
43
43
|
/** Legend. @default true for multiple series or donut, false for one series */
|
|
44
44
|
legend?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Stack series instead of overlaying them. Area and bar only — a stacked
|
|
47
|
+
* line is a shape people misread as absolute values.
|
|
48
|
+
*
|
|
49
|
+
* Off by default: stacking answers "what does the total split into", and
|
|
50
|
+
* silently switching a two-series comparison to it would change what an
|
|
51
|
+
* existing chart claims.
|
|
52
|
+
*/
|
|
53
|
+
stacked?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Formats x-axis ticks. Separate from `valueFormatter`, which formats the
|
|
56
|
+
* measured value — an axis of ISO dates wants shortening without changing
|
|
57
|
+
* what the tooltip reports.
|
|
58
|
+
*/
|
|
59
|
+
xTickFormatter?: (value: string) => string;
|
|
60
|
+
/**
|
|
61
|
+
* Fixes the y-axis range. Without it recharts scales to the data, which for
|
|
62
|
+
* a percentage series makes a 98-100%% band fill the plot and read as
|
|
63
|
+
* volatility.
|
|
64
|
+
*/
|
|
65
|
+
yDomain?: [number, number];
|
|
45
66
|
/** Formats values in tooltips and the y-axis, e.g. `(v) => \`$${v}\``. */
|
|
46
67
|
valueFormatter?: (value: number) => string;
|
|
47
68
|
className?: string;
|
|
@@ -121,6 +142,9 @@ export function Chart({
|
|
|
121
142
|
height = 300,
|
|
122
143
|
grid = true,
|
|
123
144
|
legend,
|
|
145
|
+
stacked = false,
|
|
146
|
+
xTickFormatter,
|
|
147
|
+
yDomain,
|
|
124
148
|
valueFormatter = (v) => String(v),
|
|
125
149
|
className,
|
|
126
150
|
}: ChartProps) {
|
|
@@ -170,11 +194,12 @@ export function Chart({
|
|
|
170
194
|
chart = (
|
|
171
195
|
<BarChart data={data} barCategoryGap="25%">
|
|
172
196
|
{gridEl}
|
|
173
|
-
<XAxis dataKey={xKey} {...axisProps} />
|
|
174
|
-
<YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
|
|
197
|
+
<XAxis dataKey={xKey} {...axisProps} tickFormatter={xTickFormatter} />
|
|
198
|
+
<YAxis {...axisProps} width={48} domain={yDomain} tickFormatter={valueFormatter} />
|
|
175
199
|
{tooltip}
|
|
176
200
|
{series.map((s, i) => (
|
|
177
201
|
<Bar
|
|
202
|
+
stackId={stacked ? 'stack' : undefined}
|
|
178
203
|
key={s.key}
|
|
179
204
|
dataKey={s.key}
|
|
180
205
|
name={s.label ?? s.key}
|
|
@@ -189,8 +214,8 @@ export function Chart({
|
|
|
189
214
|
chart = (
|
|
190
215
|
<AreaChart data={data}>
|
|
191
216
|
{gridEl}
|
|
192
|
-
<XAxis dataKey={xKey} {...axisProps} />
|
|
193
|
-
<YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
|
|
217
|
+
<XAxis dataKey={xKey} {...axisProps} tickFormatter={xTickFormatter} />
|
|
218
|
+
<YAxis {...axisProps} width={48} domain={yDomain} tickFormatter={valueFormatter} />
|
|
194
219
|
{tooltip}
|
|
195
220
|
{series.map((s, i) => (
|
|
196
221
|
<Area
|
|
@@ -198,10 +223,16 @@ export function Chart({
|
|
|
198
223
|
type="monotone"
|
|
199
224
|
dataKey={s.key}
|
|
200
225
|
name={s.label ?? s.key}
|
|
226
|
+
// One shared id is what makes recharts stack rather than
|
|
227
|
+
// overlay; undefined leaves the existing behaviour untouched.
|
|
228
|
+
stackId={stacked ? 'stack' : undefined}
|
|
201
229
|
stroke={seriesColor(s, i)}
|
|
202
230
|
strokeWidth={2}
|
|
203
231
|
fill={seriesColor(s, i)}
|
|
204
|
-
|
|
232
|
+
// Overlaid areas must stay translucent so the ones behind show
|
|
233
|
+
// through. Stacked bands never overlap, and at 0.12 they are too
|
|
234
|
+
// faint to tell apart.
|
|
235
|
+
fillOpacity={stacked ? 0.35 : 0.12}
|
|
205
236
|
dot={false}
|
|
206
237
|
activeDot={{ r: 4 }}
|
|
207
238
|
/>
|
|
@@ -212,8 +243,8 @@ export function Chart({
|
|
|
212
243
|
chart = (
|
|
213
244
|
<LineChart data={data}>
|
|
214
245
|
{gridEl}
|
|
215
|
-
<XAxis dataKey={xKey} {...axisProps} />
|
|
216
|
-
<YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
|
|
246
|
+
<XAxis dataKey={xKey} {...axisProps} tickFormatter={xTickFormatter} />
|
|
247
|
+
<YAxis {...axisProps} width={48} domain={yDomain} tickFormatter={valueFormatter} />
|
|
217
248
|
{tooltip}
|
|
218
249
|
{series.map((s, i) => (
|
|
219
250
|
<Line
|
|
@@ -26,6 +26,23 @@ export interface DataTableProps<TData> {
|
|
|
26
26
|
onSelectionChange?: (rows: TData[]) => void;
|
|
27
27
|
/** Rows per page. Set to `0` to disable pagination entirely. @default 10 */
|
|
28
28
|
pageSize?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Hands pagination to the caller, for data the client does not hold all of.
|
|
31
|
+
*
|
|
32
|
+
* With this set, `data` is treated as the current page rather than the whole
|
|
33
|
+
* set: the table stops slicing, and the footer reports and drives the
|
|
34
|
+
* caller's page instead of its own. Without it nothing changes — a table
|
|
35
|
+
* given every row keeps paginating locally, which is right for the common
|
|
36
|
+
* case and avoids making every consumer implement paging to get a table.
|
|
37
|
+
*/
|
|
38
|
+
pagination?: {
|
|
39
|
+
pageIndex: number;
|
|
40
|
+
/** Total pages. Omit when unknown — the footer then relies on `hasNext`. */
|
|
41
|
+
pageCount?: number;
|
|
42
|
+
hasNext?: boolean;
|
|
43
|
+
hasPrevious?: boolean;
|
|
44
|
+
onPageChange: (pageIndex: number) => void;
|
|
45
|
+
};
|
|
29
46
|
/** Slot rendered top-right of the toolbar — e.g. an "Add" button. */
|
|
30
47
|
toolbar?: React.ReactNode;
|
|
31
48
|
onRowClick?: (row: TData) => void;
|
|
@@ -66,6 +83,7 @@ export function DataTable<TData>({
|
|
|
66
83
|
selectable = false,
|
|
67
84
|
onSelectionChange,
|
|
68
85
|
pageSize = 10,
|
|
86
|
+
pagination,
|
|
69
87
|
toolbar,
|
|
70
88
|
onRowClick,
|
|
71
89
|
emptyMessage = 'No results.',
|
|
@@ -94,8 +112,13 @@ export function DataTable<TData>({
|
|
|
94
112
|
},
|
|
95
113
|
getCoreRowModel: getCoreRowModel(),
|
|
96
114
|
getSortedRowModel: getSortedRowModel(),
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
// Server-paginated tables must not slice: `data` is already the page, and
|
|
116
|
+
// running the client model over it would paginate a single page again.
|
|
117
|
+
...(pageSize > 0 && !pagination
|
|
118
|
+
? { getPaginationRowModel: getPaginationRowModel() }
|
|
119
|
+
: {}),
|
|
120
|
+
...(pagination ? { manualPagination: true } : {}),
|
|
121
|
+
initialState: pageSize > 0 && !pagination ? { pagination: { pageSize } } : undefined,
|
|
99
122
|
enableRowSelection: selectable,
|
|
100
123
|
});
|
|
101
124
|
|
|
@@ -107,6 +130,31 @@ export function DataTable<TData>({
|
|
|
107
130
|
|
|
108
131
|
const rows = table.getRowModel().rows;
|
|
109
132
|
|
|
133
|
+
// One set of derivations so the footer does not branch on `pagination`
|
|
134
|
+
// three separate times and drift between them.
|
|
135
|
+
const canPrevious = pagination
|
|
136
|
+
? (pagination.hasPrevious ?? pagination.pageIndex > 0)
|
|
137
|
+
: table.getCanPreviousPage();
|
|
138
|
+
const canNext = pagination
|
|
139
|
+
? (pagination.hasNext ??
|
|
140
|
+
(pagination.pageCount !== undefined && pagination.pageIndex < pagination.pageCount - 1))
|
|
141
|
+
: table.getCanNextPage();
|
|
142
|
+
const goPrevious = () =>
|
|
143
|
+
pagination ? pagination.onPageChange(pagination.pageIndex - 1) : table.previousPage();
|
|
144
|
+
const goNext = () =>
|
|
145
|
+
pagination ? pagination.onPageChange(pagination.pageIndex + 1) : table.nextPage();
|
|
146
|
+
// A server-paginated table shows its pager whenever paging is possible.
|
|
147
|
+
// Total pages are often unknown, so "more than one page" is not a question
|
|
148
|
+
// it can answer up front the way the local model can.
|
|
149
|
+
const showPager = pagination
|
|
150
|
+
? canPrevious || canNext
|
|
151
|
+
: pageSize > 0 && table.getPageCount() > 1;
|
|
152
|
+
const pageLabel = pagination
|
|
153
|
+
? pagination.pageCount !== undefined
|
|
154
|
+
? `Page ${pagination.pageIndex + 1} of ${pagination.pageCount}`
|
|
155
|
+
: `Page ${pagination.pageIndex + 1}`
|
|
156
|
+
: `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`;
|
|
157
|
+
|
|
110
158
|
return (
|
|
111
159
|
<div className={cn('space-y-4', className)}>
|
|
112
160
|
{(searchKey || toolbar) && (
|
|
@@ -186,19 +234,19 @@ export function DataTable<TData>({
|
|
|
186
234
|
</Table>
|
|
187
235
|
</div>
|
|
188
236
|
|
|
189
|
-
{(selectable ||
|
|
237
|
+
{(selectable || showPager) && (
|
|
190
238
|
<div className="flex items-center justify-between">
|
|
191
239
|
<p className="text-sm text-muted-foreground">
|
|
192
240
|
{selectable && `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`}
|
|
193
|
-
{selectable &&
|
|
194
|
-
{
|
|
241
|
+
{selectable && showPager && ' · '}
|
|
242
|
+
{showPager && pageLabel}
|
|
195
243
|
</p>
|
|
196
|
-
{
|
|
244
|
+
{showPager && (
|
|
197
245
|
<div className="flex gap-2">
|
|
198
|
-
<Button variant="outline" size="sm" onClick={
|
|
246
|
+
<Button variant="outline" size="sm" onClick={goPrevious} disabled={!canPrevious}>
|
|
199
247
|
<ChevronLeft className="size-4" /> Previous
|
|
200
248
|
</Button>
|
|
201
|
-
<Button variant="outline" size="sm" onClick={
|
|
249
|
+
<Button variant="outline" size="sm" onClick={goNext} disabled={!canNext}>
|
|
202
250
|
Next <ChevronRight className="size-4" />
|
|
203
251
|
</Button>
|
|
204
252
|
</div>
|