@olwiba/ui 0.2.23 → 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 +53 -9
- package/dist/index.js +191 -68
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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/LoadMore.tsx +80 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
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
|
>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Button, Spinner, cn } from '@olwiba/cn';
|
|
5
|
+
|
|
6
|
+
export interface LoadMoreProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
hasNextPage: boolean;
|
|
8
|
+
isFetchingNextPage: boolean;
|
|
9
|
+
fetchNextPage: () => void | Promise<unknown>;
|
|
10
|
+
/** Watches the sentinel instead of showing a manual button. */
|
|
11
|
+
auto?: boolean;
|
|
12
|
+
/** Plural noun used by the manual button, for example `properties`. */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** Starts loading before the sentinel reaches the viewport. */
|
|
15
|
+
rootMargin?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Cursor-pagination footer with either guarded infinite scroll or a manual fallback. */
|
|
19
|
+
export function LoadMore({
|
|
20
|
+
hasNextPage,
|
|
21
|
+
isFetchingNextPage,
|
|
22
|
+
fetchNextPage,
|
|
23
|
+
auto = true,
|
|
24
|
+
label = 'more',
|
|
25
|
+
rootMargin = '400px',
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
}: LoadMoreProps) {
|
|
29
|
+
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
|
30
|
+
const stateRef = React.useRef({ hasNextPage, isFetchingNextPage, fetchNextPage });
|
|
31
|
+
stateRef.current = { hasNextPage, isFetchingNextPage, fetchNextPage };
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
if (!auto) return;
|
|
35
|
+
const node = sentinelRef.current;
|
|
36
|
+
if (!node) return;
|
|
37
|
+
|
|
38
|
+
const observer = new IntersectionObserver(
|
|
39
|
+
([entry]) => {
|
|
40
|
+
if (!entry?.isIntersecting) return;
|
|
41
|
+
const state = stateRef.current;
|
|
42
|
+
if (state.hasNextPage && !state.isFetchingNextPage) void state.fetchNextPage();
|
|
43
|
+
},
|
|
44
|
+
{ rootMargin },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
observer.observe(node);
|
|
48
|
+
return () => observer.disconnect();
|
|
49
|
+
}, [auto, rootMargin]);
|
|
50
|
+
|
|
51
|
+
if (!hasNextPage) return null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div ref={sentinelRef} className={cn('flex justify-center py-4', className)} {...props}>
|
|
55
|
+
{auto ? (
|
|
56
|
+
<span className="flex h-9 items-center text-sm text-muted-foreground" aria-live="polite">
|
|
57
|
+
{isFetchingNextPage && (
|
|
58
|
+
<>
|
|
59
|
+
<Spinner className="mr-2 size-4" /> Loading more…
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
</span>
|
|
63
|
+
) : (
|
|
64
|
+
<Button
|
|
65
|
+
variant="outline"
|
|
66
|
+
onClick={() => void fetchNextPage()}
|
|
67
|
+
disabled={isFetchingNextPage}
|
|
68
|
+
>
|
|
69
|
+
{isFetchingNextPage ? (
|
|
70
|
+
<>
|
|
71
|
+
<Spinner className="mr-2 size-4" /> Loading…
|
|
72
|
+
</>
|
|
73
|
+
) : (
|
|
74
|
+
`Load more ${label}`
|
|
75
|
+
)}
|
|
76
|
+
</Button>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,8 @@ export {
|
|
|
27
27
|
type AppShellAction,
|
|
28
28
|
type AppShellRenderLink,
|
|
29
29
|
type AppShellBreadcrumb,
|
|
30
|
+
type AppShellHeaderControls,
|
|
31
|
+
type AppShellHeaderStart,
|
|
30
32
|
type AppNavItem,
|
|
31
33
|
} from './app/AppShell';
|
|
32
34
|
|
|
@@ -145,6 +147,7 @@ export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type Command
|
|
|
145
147
|
|
|
146
148
|
// ─── Components — data ───────────────────────────────────────────────────────
|
|
147
149
|
export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
|
|
150
|
+
export { LoadMore, type LoadMoreProps } from './components/LoadMore';
|
|
148
151
|
export { DataView, type DataViewProps } from './app/DataView';
|
|
149
152
|
export { Chart, type ChartProps, type ChartSeries } from './components/Chart';
|
|
150
153
|
export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
|