@cntyclub/ui-react 0.1.1 → 0.3.0

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.
@@ -21,4 +21,4 @@ type InputProps = Omit<Input$1.Props & React.RefAttributes<HTMLInputElement>, "s
21
21
  };
22
22
  declare function Input({ className, size, unstyled, nativeInput, ...props }: InputProps): React.JSX.Element;
23
23
 
24
- export { Button as B, Input as I, type InputProps as a, type ButtonProps as b, buttonVariants as c };
24
+ export { Button as B, Input as I, type ButtonProps as a, type InputProps as b, buttonVariants as c };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cntyclub/ui-react",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "React component library for the Country Club UI Kit — Base UI primitives styled with the Country Club design system (Tailwind CSS v4)",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -48,8 +48,10 @@
48
48
  "qr-code-styling": "^1.9.2",
49
49
  "react-day-picker": "^9.14.0",
50
50
  "react-dropzone": "^14.4.0",
51
+ "react-markdown": "^9.1.0",
51
52
  "react-resizable-panels": "^3.0.6",
52
53
  "recharts": "^3.7.0",
54
+ "remark-gfm": "^4.0.1",
53
55
  "spin-delay": "^2.0.1",
54
56
  "tailwind-merge": "^3.4.0",
55
57
  "tw-animate-css": "^1.4.0",
@@ -0,0 +1,494 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import { ArrowRightIcon, ArrowUpIcon, SparklesIcon } from "lucide-react";
5
+ import * as React from "react";
6
+
7
+ import { cn } from "../../lib/utils/css";
8
+ import { Button } from "./button";
9
+
10
+ /**
11
+ * Chat primitives for AI-assistant experiences: a column layout, an
12
+ * auto-scrolling message viewport, role-styled message bubbles, header chrome,
13
+ * an empty-state hero with suggestion prompts, tool activity chips/cards,
14
+ * a typing indicator, and a submit-on-Enter composer. Purely presentational —
15
+ * bring your own state.
16
+ */
17
+
18
+ function Chat({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <div
21
+ className={cn("flex min-h-0 flex-1 flex-col", className)}
22
+ data-slot="chat"
23
+ {...props}
24
+ />
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Header chrome for a chat panel: avatar + title on the left, icon actions on
30
+ * the right. Compose `ChatHeaderAvatar`, `ChatHeaderTitle` and
31
+ * `ChatHeaderActions` inside.
32
+ */
33
+ function ChatHeader({ className, ...props }: React.ComponentProps<"div">) {
34
+ return (
35
+ <div
36
+ className={cn(
37
+ "flex shrink-0 items-center gap-1 border-border/72 border-b bg-background/95 px-3 py-2.5 backdrop-blur supports-backdrop-filter:bg-background/80",
38
+ className,
39
+ )}
40
+ data-slot="chat-header"
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ function ChatHeaderAvatar({
47
+ className,
48
+ children,
49
+ ...props
50
+ }: React.ComponentProps<"span">) {
51
+ return (
52
+ <span
53
+ className={cn(
54
+ "relative flex size-8 shrink-0 items-center justify-center rounded-full bg-linear-to-br from-primary to-primary/78 text-primary-foreground shadow-primary/24 shadow-xs after:absolute after:right-0 after:bottom-0 after:size-2 after:rounded-full after:bg-success after:ring-2 after:ring-background [&_svg:not([class*='size-'])]:size-4",
55
+ className,
56
+ )}
57
+ data-slot="chat-header-avatar"
58
+ {...props}
59
+ >
60
+ {children ?? <SparklesIcon />}
61
+ </span>
62
+ );
63
+ }
64
+
65
+ function ChatHeaderTitle({ className, ...props }: React.ComponentProps<"div">) {
66
+ return (
67
+ <div
68
+ className={cn("min-w-0 flex-1 leading-tight", className)}
69
+ data-slot="chat-header-title"
70
+ {...props}
71
+ />
72
+ );
73
+ }
74
+
75
+ function ChatHeaderActions({ className, ...props }: React.ComponentProps<"div">) {
76
+ return (
77
+ <div
78
+ className={cn("flex shrink-0 items-center gap-0.5", className)}
79
+ data-slot="chat-header-actions"
80
+ {...props}
81
+ />
82
+ );
83
+ }
84
+
85
+ function ChatMessages({
86
+ autoScroll = true,
87
+ className,
88
+ children,
89
+ ...props
90
+ }: React.ComponentProps<"div"> & {
91
+ /** Keep the viewport pinned to the bottom while new messages stream in. */
92
+ autoScroll?: boolean;
93
+ }) {
94
+ const viewportRef = React.useRef<HTMLDivElement>(null);
95
+ const pinnedRef = React.useRef(true);
96
+
97
+ const handleScroll = React.useCallback(() => {
98
+ const viewport = viewportRef.current;
99
+ if (!viewport) return;
100
+ const distanceFromBottom =
101
+ viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
102
+ pinnedRef.current = distanceFromBottom < 48;
103
+ }, []);
104
+
105
+ // biome-ignore lint/correctness/useExhaustiveDependencies: children is the scroll trigger
106
+ React.useEffect(() => {
107
+ const viewport = viewportRef.current;
108
+ if (!viewport || !autoScroll || !pinnedRef.current) return;
109
+ viewport.scrollTop = viewport.scrollHeight;
110
+ }, [children, autoScroll]);
111
+
112
+ return (
113
+ <div
114
+ className={cn(
115
+ "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto scroll-smooth p-4",
116
+ className,
117
+ )}
118
+ data-slot="chat-messages"
119
+ onScroll={handleScroll}
120
+ ref={viewportRef}
121
+ {...props}
122
+ >
123
+ {children}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Centered hero shown when the conversation is empty. Compose
130
+ * `ChatEmptyStateIcon`, `ChatEmptyStateTitle`, `ChatEmptyStateDescription`
131
+ * and `ChatSuggestions` inside.
132
+ */
133
+ function ChatEmptyState({ className, ...props }: React.ComponentProps<"div">) {
134
+ return (
135
+ <div
136
+ className={cn(
137
+ "flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center",
138
+ className,
139
+ )}
140
+ data-slot="chat-empty-state"
141
+ {...props}
142
+ />
143
+ );
144
+ }
145
+
146
+ function ChatEmptyStateIcon({
147
+ className,
148
+ children,
149
+ ...props
150
+ }: React.ComponentProps<"span">) {
151
+ return (
152
+ <span
153
+ className={cn(
154
+ "fade-in-0 zoom-in-90 relative flex size-13 shrink-0 animate-in items-center justify-center rounded-2xl bg-linear-to-br from-primary to-primary/78 text-primary-foreground shadow-lg shadow-primary/24 duration-500 before:absolute before:-inset-2.5 before:-z-10 before:rounded-3xl before:bg-primary/8 before:blur-lg [&_svg:not([class*='size-'])]:size-6",
155
+ className,
156
+ )}
157
+ data-slot="chat-empty-state-icon"
158
+ {...props}
159
+ >
160
+ {children ?? <SparklesIcon />}
161
+ </span>
162
+ );
163
+ }
164
+
165
+ function ChatEmptyStateTitle({ className, ...props }: React.ComponentProps<"p">) {
166
+ return (
167
+ <p
168
+ className={cn("mt-1 font-semibold text-base text-foreground", className)}
169
+ data-slot="chat-empty-state-title"
170
+ {...props}
171
+ />
172
+ );
173
+ }
174
+
175
+ function ChatEmptyStateDescription({
176
+ className,
177
+ ...props
178
+ }: React.ComponentProps<"p">) {
179
+ return (
180
+ <p
181
+ className={cn(
182
+ "max-w-72 text-muted-foreground text-sm leading-relaxed",
183
+ className,
184
+ )}
185
+ data-slot="chat-empty-state-description"
186
+ {...props}
187
+ />
188
+ );
189
+ }
190
+
191
+ /** Vertical stack of `ChatSuggestion` prompts. */
192
+ function ChatSuggestions({ className, ...props }: React.ComponentProps<"div">) {
193
+ return (
194
+ <div
195
+ className={cn("mt-2 flex w-full max-w-sm flex-col gap-2", className)}
196
+ data-slot="chat-suggestions"
197
+ {...props}
198
+ />
199
+ );
200
+ }
201
+
202
+ export interface ChatSuggestionProps extends React.ComponentProps<"button"> {
203
+ /** Leading icon; defaults to a sparkle. */
204
+ icon?: React.ReactNode;
205
+ /** Position in the list — staggers the entrance animation. */
206
+ index?: number;
207
+ }
208
+
209
+ /**
210
+ * One tappable suggested prompt: icon chip + label, with an arrow that slides
211
+ * in on hover and a staggered fade-in entrance (via `index`).
212
+ */
213
+ function ChatSuggestion({
214
+ className,
215
+ children,
216
+ icon,
217
+ index = 0,
218
+ style,
219
+ ...props
220
+ }: ChatSuggestionProps) {
221
+ return (
222
+ <button
223
+ className={cn(
224
+ "group fade-in-0 slide-in-from-bottom-2 flex w-full animate-in cursor-pointer items-center gap-2.5 fill-mode-both rounded-xl border border-border/72 bg-card px-3 py-2.5 text-left font-medium text-card-foreground text-sm shadow-xs outline-none transition-[border-color,background-color,box-shadow,transform] duration-200 hover:border-primary/24 hover:bg-accent/56 hover:shadow-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background active:scale-[0.985] disabled:pointer-events-none disabled:opacity-64",
225
+ className,
226
+ )}
227
+ data-slot="chat-suggestion"
228
+ style={{ animationDelay: `${index * 80}ms`, ...style }}
229
+ type="button"
230
+ {...props}
231
+ >
232
+ <span className="flex size-6.5 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary transition-colors duration-200 group-hover:bg-primary group-hover:text-primary-foreground [&_svg:not([class*='size-'])]:size-3.5">
233
+ {icon ?? <SparklesIcon />}
234
+ </span>
235
+ <span className="min-w-0 flex-1">{children}</span>
236
+ <ArrowRightIcon className="-translate-x-1 size-3.5 shrink-0 text-muted-foreground opacity-0 transition-[opacity,translate] duration-200 group-hover:translate-x-0 group-hover:opacity-100" />
237
+ </button>
238
+ );
239
+ }
240
+
241
+ const chatMessageVariants = cva(
242
+ "fade-in-0 slide-in-from-bottom-1 flex w-full animate-in flex-col gap-1 duration-200",
243
+ {
244
+ defaultVariants: {
245
+ from: "assistant",
246
+ },
247
+ variants: {
248
+ from: {
249
+ assistant: "items-start",
250
+ user: "items-end",
251
+ },
252
+ },
253
+ },
254
+ );
255
+
256
+ export interface ChatMessageProps
257
+ extends React.ComponentProps<"div">,
258
+ VariantProps<typeof chatMessageVariants> {}
259
+
260
+ function ChatMessage({ className, from, ...props }: ChatMessageProps) {
261
+ return (
262
+ <div
263
+ className={cn(chatMessageVariants({ className, from }))}
264
+ data-from={from ?? "assistant"}
265
+ data-slot="chat-message"
266
+ {...props}
267
+ />
268
+ );
269
+ }
270
+
271
+ const chatMessageBubbleVariants = cva(
272
+ "max-w-[85%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed break-words",
273
+ {
274
+ defaultVariants: {
275
+ from: "assistant",
276
+ },
277
+ variants: {
278
+ from: {
279
+ assistant: "rounded-bl-md bg-muted text-foreground",
280
+ user: "rounded-br-md bg-primary text-primary-foreground shadow-primary/16 shadow-xs",
281
+ },
282
+ },
283
+ },
284
+ );
285
+
286
+ export interface ChatMessageBubbleProps
287
+ extends React.ComponentProps<"div">,
288
+ VariantProps<typeof chatMessageBubbleVariants> {}
289
+
290
+ function ChatMessageBubble({ className, from, ...props }: ChatMessageBubbleProps) {
291
+ return (
292
+ <div
293
+ className={cn(chatMessageBubbleVariants({ className, from }))}
294
+ data-from={from ?? "assistant"}
295
+ data-slot="chat-message-bubble"
296
+ {...props}
297
+ />
298
+ );
299
+ }
300
+
301
+ /**
302
+ * Small inline pill for tool activity ("Using X…", "X finished"). Pass the
303
+ * status icon (wrench, spinner, check) as `icon`.
304
+ */
305
+ function ChatToolChip({
306
+ className,
307
+ children,
308
+ icon,
309
+ ...props
310
+ }: React.ComponentProps<"div"> & { icon?: React.ReactNode }) {
311
+ return (
312
+ <div
313
+ className={cn(
314
+ "fade-in-0 flex w-fit max-w-full animate-in items-center gap-1.5 rounded-full border border-border/72 bg-muted/48 py-1 pr-3 pl-1.5 text-muted-foreground text-xs duration-200",
315
+ className,
316
+ )}
317
+ data-slot="chat-tool-chip"
318
+ {...props}
319
+ >
320
+ {icon ? (
321
+ <span className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-background text-foreground/72 shadow-xs [&_svg:not([class*='size-'])]:size-2.5">
322
+ {icon}
323
+ </span>
324
+ ) : null}
325
+ <span className="min-w-0 truncate">{children}</span>
326
+ </div>
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Framed card for tool activity inside a conversation: running/finished tool
332
+ * calls, results, and write-action approval prompts. Compose with `Button`s
333
+ * in the `actions` area for approve/decline flows.
334
+ */
335
+ function ChatToolCard({ className, ...props }: React.ComponentProps<"div">) {
336
+ return (
337
+ <div
338
+ className={cn(
339
+ "fade-in-0 slide-in-from-bottom-1 w-full max-w-[95%] animate-in rounded-xl border border-border bg-card p-3 text-card-foreground text-sm shadow-xs duration-200",
340
+ className,
341
+ )}
342
+ data-slot="chat-tool-card"
343
+ {...props}
344
+ />
345
+ );
346
+ }
347
+
348
+ function ChatToolCardHeader({ className, ...props }: React.ComponentProps<"div">) {
349
+ return (
350
+ <div
351
+ className={cn("flex items-center gap-2 font-medium", className)}
352
+ data-slot="chat-tool-card-header"
353
+ {...props}
354
+ />
355
+ );
356
+ }
357
+
358
+ function ChatToolCardActions({ className, ...props }: React.ComponentProps<"div">) {
359
+ return (
360
+ <div
361
+ className={cn("mt-3 flex items-center justify-end gap-2", className)}
362
+ data-slot="chat-tool-card-actions"
363
+ {...props}
364
+ />
365
+ );
366
+ }
367
+
368
+ function ChatTypingIndicator({ className, ...props }: React.ComponentProps<"div">) {
369
+ return (
370
+ <div
371
+ aria-label="Assistant is typing"
372
+ className={cn(
373
+ "fade-in-0 flex w-fit animate-in items-center gap-1 rounded-2xl rounded-bl-md bg-muted px-3.5 py-2.5 duration-200",
374
+ className,
375
+ )}
376
+ data-slot="chat-typing-indicator"
377
+ role="status"
378
+ {...props}
379
+ >
380
+ <span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:0ms]" />
381
+ <span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:150ms]" />
382
+ <span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:300ms]" />
383
+ </div>
384
+ );
385
+ }
386
+
387
+ export interface ChatInputProps
388
+ extends Omit<React.ComponentProps<"textarea">, "onSubmit" | "value" | "onChange"> {
389
+ value: string;
390
+ onValueChange: (value: string) => void;
391
+ /** Called with the trimmed message when the user submits (Enter or button). */
392
+ onSubmit: (value: string) => void;
393
+ /** Disables both the textarea and the send button (e.g. while sending). */
394
+ disabled?: boolean;
395
+ }
396
+
397
+ /**
398
+ * Composer: a rounded card wrapping an auto-growing textarea and a round send
399
+ * button. Enter submits, Shift+Enter inserts a newline.
400
+ */
401
+ function ChatInput({
402
+ className,
403
+ disabled,
404
+ onSubmit,
405
+ onValueChange,
406
+ placeholder = "Ask anything…",
407
+ value,
408
+ ...props
409
+ }: ChatInputProps) {
410
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
411
+
412
+ const submit = React.useCallback(() => {
413
+ const trimmed = value.trim();
414
+ if (!trimmed || disabled) return;
415
+ onSubmit(trimmed);
416
+ }, [disabled, onSubmit, value]);
417
+
418
+ // Auto-grow up to ~6 lines.
419
+ React.useEffect(() => {
420
+ const textarea = textareaRef.current;
421
+ if (!textarea) return;
422
+ textarea.style.height = "auto";
423
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 144)}px`;
424
+ }, [value]);
425
+
426
+ return (
427
+ <form
428
+ className={cn("shrink-0 bg-background p-3 pt-1.5", className)}
429
+ data-slot="chat-input"
430
+ onSubmit={(event) => {
431
+ event.preventDefault();
432
+ submit();
433
+ }}
434
+ >
435
+ <div
436
+ className={cn(
437
+ "flex items-end gap-1.5 rounded-2xl border border-input bg-popover p-1.5 pl-3.5 shadow-xs transition-[border-color,box-shadow] duration-150 focus-within:border-ring/64 focus-within:ring-2 focus-within:ring-ring/24 dark:bg-input/32",
438
+ disabled && "opacity-64",
439
+ )}
440
+ >
441
+ <textarea
442
+ className="max-h-36 min-h-8 flex-1 resize-none self-center bg-transparent py-1.5 text-sm outline-none placeholder:text-muted-foreground"
443
+ disabled={disabled}
444
+ onChange={(event) => onValueChange(event.target.value)}
445
+ onKeyDown={(event) => {
446
+ if (event.key === "Enter" && !event.shiftKey) {
447
+ event.preventDefault();
448
+ submit();
449
+ }
450
+ }}
451
+ placeholder={placeholder}
452
+ ref={textareaRef}
453
+ rows={1}
454
+ value={value}
455
+ {...props}
456
+ />
457
+ <Button
458
+ aria-label="Send message"
459
+ className="rounded-full before:rounded-full"
460
+ disabled={disabled || !value.trim()}
461
+ size="icon-sm"
462
+ type="submit"
463
+ >
464
+ <ArrowUpIcon />
465
+ </Button>
466
+ </div>
467
+ </form>
468
+ );
469
+ }
470
+
471
+ export {
472
+ Chat,
473
+ ChatEmptyState,
474
+ ChatEmptyStateDescription,
475
+ ChatEmptyStateIcon,
476
+ ChatEmptyStateTitle,
477
+ ChatHeader,
478
+ ChatHeaderActions,
479
+ ChatHeaderAvatar,
480
+ ChatHeaderTitle,
481
+ ChatInput,
482
+ ChatMessage,
483
+ ChatMessageBubble,
484
+ chatMessageBubbleVariants,
485
+ chatMessageVariants,
486
+ ChatMessages,
487
+ ChatSuggestion,
488
+ ChatSuggestions,
489
+ ChatToolCard,
490
+ ChatToolCardActions,
491
+ ChatToolCardHeader,
492
+ ChatToolChip,
493
+ ChatTypingIndicator,
494
+ };
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { SearchIcon } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { cn } from "../../lib/utils/css";
7
+ import { Input } from "./input";
8
+ import { PaginationControls } from "./pagination-controls";
9
+ import {
10
+ Table,
11
+ TableBody,
12
+ TableCell,
13
+ TableHead,
14
+ TableHeader,
15
+ TableRow,
16
+ } from "./table";
17
+
18
+ export interface DataTablePagedColumn {
19
+ key: string;
20
+ label?: string;
21
+ }
22
+
23
+ export interface DataTablePagedProps {
24
+ /** Columns to render. Pass `label` to override the header text. */
25
+ columns: DataTablePagedColumn[];
26
+ /** Row objects; values are rendered with `String(...)` unless a ReactNode. */
27
+ rows: Record<string, unknown>[];
28
+ pageSize?: number;
29
+ /** Show the client-side search box (filters across all columns). */
30
+ searchable?: boolean;
31
+ searchPlaceholder?: string;
32
+ emptyLabel?: string;
33
+ /**
34
+ * Total row count when `rows` is only one page of a larger server-side set —
35
+ * shown as "n of total" in the footer.
36
+ */
37
+ totalCount?: number;
38
+ className?: string;
39
+ }
40
+
41
+ function formatCell(value: unknown): React.ReactNode {
42
+ if (value === null || value === undefined || value === "") return "—";
43
+ if (typeof value === "boolean") return value ? "Yes" : "No";
44
+ if (React.isValidElement(value)) return value;
45
+ if (typeof value === "object") return JSON.stringify(value);
46
+ return String(value);
47
+ }
48
+
49
+ /**
50
+ * Client-side paginated (and optionally searchable) data table. Designed for
51
+ * result sets a chat agent or API returns: pass plain row objects and column
52
+ * keys, get a themed table with pagination that never renders huge lists.
53
+ */
54
+ function DataTablePaged({
55
+ className,
56
+ columns,
57
+ emptyLabel = "No results",
58
+ pageSize = 10,
59
+ rows,
60
+ searchable = true,
61
+ searchPlaceholder = "Filter results…",
62
+ totalCount,
63
+ }: DataTablePagedProps) {
64
+ const [page, setPage] = React.useState(1);
65
+ const [query, setQuery] = React.useState("");
66
+
67
+ const filteredRows = React.useMemo(() => {
68
+ const trimmed = query.trim().toLowerCase();
69
+ if (!trimmed) return rows;
70
+ return rows.filter((row) =>
71
+ columns.some((column) =>
72
+ String(row[column.key] ?? "")
73
+ .toLowerCase()
74
+ .includes(trimmed),
75
+ ),
76
+ );
77
+ }, [columns, query, rows]);
78
+
79
+ const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
80
+ const safePage = Math.min(page, pageCount);
81
+ const pageRows = filteredRows.slice((safePage - 1) * pageSize, safePage * pageSize);
82
+
83
+ return (
84
+ <div className={cn("flex flex-col gap-2", className)} data-slot="data-table-paged">
85
+ {searchable && rows.length > pageSize && (
86
+ <div className="relative">
87
+ <SearchIcon className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
88
+ <Input
89
+ className="h-8 pl-8 text-sm"
90
+ onChange={(event) => {
91
+ setQuery(event.target.value);
92
+ setPage(1);
93
+ }}
94
+ placeholder={searchPlaceholder}
95
+ value={query}
96
+ />
97
+ </div>
98
+ )}
99
+
100
+ <div className="overflow-x-auto rounded-lg border border-border">
101
+ <Table>
102
+ <TableHeader>
103
+ <TableRow>
104
+ {columns.map((column) => (
105
+ <TableHead className="whitespace-nowrap" key={column.key}>
106
+ {column.label ?? column.key.replaceAll("_", " ")}
107
+ </TableHead>
108
+ ))}
109
+ </TableRow>
110
+ </TableHeader>
111
+ <TableBody>
112
+ {pageRows.length === 0 ? (
113
+ <TableRow>
114
+ <TableCell
115
+ className="py-6 text-center text-muted-foreground"
116
+ colSpan={columns.length}
117
+ >
118
+ {emptyLabel}
119
+ </TableCell>
120
+ </TableRow>
121
+ ) : (
122
+ pageRows.map((row, rowIndex) => (
123
+ <TableRow key={`${safePage}-${rowIndex}`}>
124
+ {columns.map((column) => (
125
+ <TableCell className="max-w-56 truncate" key={column.key}>
126
+ {formatCell(row[column.key])}
127
+ </TableCell>
128
+ ))}
129
+ </TableRow>
130
+ ))
131
+ )}
132
+ </TableBody>
133
+ </Table>
134
+ </div>
135
+
136
+ <div className="flex flex-wrap items-center justify-between gap-2">
137
+ <span className="text-muted-foreground text-xs">
138
+ {filteredRows.length}
139
+ {typeof totalCount === "number" && totalCount > rows.length
140
+ ? ` of ${totalCount}`
141
+ : ""}{" "}
142
+ rows
143
+ </span>
144
+ <PaginationControls
145
+ page={safePage}
146
+ pageCount={pageCount}
147
+ renderPageLink={(nextPage) => (
148
+ // biome-ignore lint/a11y/useValidAnchor: client-side pagination
149
+ <a
150
+ href="#"
151
+ onClick={(event) => {
152
+ event.preventDefault();
153
+ setPage(nextPage);
154
+ }}
155
+ />
156
+ )}
157
+ />
158
+ </div>
159
+ </div>
160
+ );
161
+ }
162
+
163
+ export { DataTablePaged };