@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.23",
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",
@@ -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
  >
@@ -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
- fillOpacity={0.12}
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
- ...(pageSize > 0 ? { getPaginationRowModel: getPaginationRowModel() } : {}),
98
- initialState: pageSize > 0 ? { pagination: { pageSize } } : undefined,
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 || (pageSize > 0 && table.getPageCount() > 1)) && (
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 && pageSize > 0 && table.getPageCount() > 1 && ' · '}
194
- {pageSize > 0 && table.getPageCount() > 1 && `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
241
+ {selectable && showPager && ' · '}
242
+ {showPager && pageLabel}
195
243
  </p>
196
- {pageSize > 0 && table.getPageCount() > 1 && (
244
+ {showPager && (
197
245
  <div className="flex gap-2">
198
- <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
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={() => table.nextPage()} disabled={!table.getCanNextPage()}>
249
+ <Button variant="outline" size="sm" onClick={goNext} disabled={!canNext}>
202
250
  Next <ChevronRight className="size-4" />
203
251
  </Button>
204
252
  </div>