@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.23",
3
+ "version": "0.2.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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 geometry only. What the slots contain live status, version,
21
- * links, product copystays with the product.
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
  >
@@ -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
- /** Slot rendered at the start of the top header bar, after the sidebar trigger. */
113
- headerStart?: ReactNode;
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
- <BirdIcon
231
- aria-hidden
232
- className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
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
- <BirdIcon
257
- aria-hidden
258
- className="size-5 drop-shadow-[0_1px_1px_rgb(0_0_0/0.3)]"
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 className="py-3">
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?: ReactNode;
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
- {headerStart && (
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}>{children}</a>
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
@@ -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
  }
@@ -9,7 +9,11 @@ export interface PostListProps {
9
9
  posts: PostCardProps[];
10
10
  renderLink?: AppShellRenderLink;
11
11
  emptyMessage?: string;
12
- /** Max columns at the widest breakpoint. */
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 = 3,
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
- columns === 3 && 'lg:grid-cols-3',
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';