@hilum/ui 3.2.3 → 3.2.4

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.
@@ -0,0 +1,412 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ArrowDown, ArrowUp, ChevronsUpDown, Filter, Search } from "lucide-react";
5
+ import { Button } from "./button";
6
+ import { EmptyState } from "./empty-state";
7
+ import { InputGroup } from "./input-group";
8
+ import {
9
+ Pagination,
10
+ PaginationContent,
11
+ PaginationEllipsis,
12
+ PaginationItem,
13
+ PaginationLink,
14
+ PaginationNext,
15
+ PaginationPrevious,
16
+ } from "./pagination";
17
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
18
+ import { StackedList, StackedListItem } from "./stacked-list";
19
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table";
20
+ import { cn } from "../lib/utils";
21
+
22
+ interface SearchableTableFilterOption {
23
+ value: string;
24
+ label: string;
25
+ }
26
+
27
+ interface SearchableTableColumn<T = unknown> {
28
+ key: string;
29
+ label: string;
30
+ render?: (item: T) => React.ReactNode;
31
+ sortable?: boolean;
32
+ sortAccessor?: (item: T) => string | number | Date | null | undefined;
33
+ className?: string;
34
+ }
35
+
36
+ interface SearchableTableProps<T extends { id: string | number }> {
37
+ data: T[];
38
+ columns: SearchableTableColumn<T>[];
39
+ searchPlaceholder?: string;
40
+ searchTerm: string;
41
+ onSearchChange: (value: string) => void;
42
+ filters?: Record<
43
+ string,
44
+ {
45
+ value: string;
46
+ onChange: (value: string) => void;
47
+ options: SearchableTableFilterOption[];
48
+ placeholder: string;
49
+ }
50
+ >;
51
+ emptyState?: {
52
+ icon?: React.ReactNode;
53
+ title: string;
54
+ description: string;
55
+ action?: {
56
+ label: string;
57
+ href?: string;
58
+ onClick?: () => void;
59
+ };
60
+ };
61
+ actions?: (item: T) => React.ReactNode;
62
+ mobileCard?: (item: T) => React.ReactNode;
63
+ tableClassName?: string;
64
+ pagination?: {
65
+ pageSize?: number;
66
+ currentPage: number;
67
+ onPageChange: (page: number) => void;
68
+ };
69
+ }
70
+
71
+ function appendSearchableValue(value: unknown, parts: string[], depth = 0) {
72
+ if (value == null || depth > 2) return;
73
+
74
+ if (value instanceof Date) {
75
+ parts.push(value.toISOString());
76
+ return;
77
+ }
78
+
79
+ if (["string", "number", "boolean"].includes(typeof value)) {
80
+ parts.push(String(value));
81
+ return;
82
+ }
83
+
84
+ if (Array.isArray(value)) {
85
+ value.forEach((entry) => appendSearchableValue(entry, parts, depth + 1));
86
+ return;
87
+ }
88
+
89
+ if (typeof value === "object") {
90
+ Object.values(value as Record<string, unknown>).forEach((entry) =>
91
+ appendSearchableValue(entry, parts, depth + 1),
92
+ );
93
+ }
94
+ }
95
+
96
+ function getSearchableText(item: unknown) {
97
+ const parts: string[] = [];
98
+ appendSearchableValue(item, parts);
99
+ return parts.join(" ").toLowerCase();
100
+ }
101
+
102
+ function normalizeEmptyStateIcon(icon: React.ReactNode) {
103
+ return icon ? <span className="flex [&_svg]:size-5">{icon}</span> : undefined;
104
+ }
105
+
106
+ function SearchableTable<T extends { id: string | number }>({
107
+ data,
108
+ columns,
109
+ searchPlaceholder = "Search...",
110
+ searchTerm,
111
+ onSearchChange,
112
+ filters = {},
113
+ emptyState,
114
+ actions,
115
+ mobileCard,
116
+ tableClassName,
117
+ pagination,
118
+ }: SearchableTableProps<T>) {
119
+ const [sort, setSort] = React.useState<{ key: string; direction: "asc" | "desc" } | null>(null);
120
+ const rows = React.useMemo(() => (Array.isArray(data) ? data : []), [data]);
121
+ const normalizedSearchTerm = searchTerm.trim().toLowerCase();
122
+
123
+ const filteredRows = React.useMemo(() => {
124
+ if (!normalizedSearchTerm) return rows;
125
+ return rows.filter((item) => getSearchableText(item).includes(normalizedSearchTerm));
126
+ }, [normalizedSearchTerm, rows]);
127
+
128
+ const sortedData = React.useMemo(() => {
129
+ if (!sort) return filteredRows;
130
+
131
+ const column = columns.find((candidate) => candidate.key === sort.key);
132
+ if (!column || (!column.sortable && !column.sortAccessor)) return filteredRows;
133
+
134
+ const directionMultiplier = sort.direction === "asc" ? 1 : -1;
135
+ const getSortValue = (item: T) =>
136
+ column.sortAccessor?.(item) ?? (item as Record<string, unknown>)[column.key];
137
+
138
+ return [...filteredRows].sort((left, right) => {
139
+ const leftValue = getSortValue(left);
140
+ const rightValue = getSortValue(right);
141
+
142
+ if (leftValue == null && rightValue == null) return 0;
143
+ if (leftValue == null) return 1;
144
+ if (rightValue == null) return -1;
145
+
146
+ const leftComparable = leftValue instanceof Date ? leftValue.getTime() : leftValue;
147
+ const rightComparable = rightValue instanceof Date ? rightValue.getTime() : rightValue;
148
+
149
+ if (typeof leftComparable === "number" && typeof rightComparable === "number") {
150
+ return (leftComparable - rightComparable) * directionMultiplier;
151
+ }
152
+
153
+ return (
154
+ String(leftComparable).localeCompare(String(rightComparable), undefined, {
155
+ numeric: true,
156
+ sensitivity: "base",
157
+ }) * directionMultiplier
158
+ );
159
+ });
160
+ }, [columns, filteredRows, sort]);
161
+
162
+ const pageSize = pagination?.pageSize ?? 10;
163
+ const currentPage = pagination?.currentPage ?? 1;
164
+ const totalPages = Math.max(1, Math.ceil(sortedData.length / pageSize));
165
+ const startIndex = (currentPage - 1) * pageSize;
166
+ const displayData = pagination ? sortedData.slice(startIndex, startIndex + pageSize) : sortedData;
167
+
168
+ const toggleSort = (column: SearchableTableColumn<T>) => {
169
+ if (!column.sortable && !column.sortAccessor) return;
170
+
171
+ setSort((current) =>
172
+ current?.key === column.key
173
+ ? { key: column.key, direction: current.direction === "asc" ? "desc" : "asc" }
174
+ : { key: column.key, direction: "asc" },
175
+ );
176
+ };
177
+
178
+ const renderEmptyState = (className?: string) => (
179
+ <EmptyState
180
+ title={emptyState?.title ?? "No results found"}
181
+ description={emptyState?.description ?? "Try changing the search or filters."}
182
+ {...(className ? { className } : {})}
183
+ {...(emptyState?.icon ? { icon: normalizeEmptyStateIcon(emptyState.icon) } : {})}
184
+ {...(emptyState?.action ? { action: emptyState.action } : {})}
185
+ />
186
+ );
187
+
188
+ const renderPagination = () => {
189
+ if (!pagination || totalPages <= 1) return null;
190
+
191
+ const pages: Array<number | "ellipsis-start" | "ellipsis-end"> = [];
192
+ const showEllipsis = totalPages > 7;
193
+
194
+ if (showEllipsis) {
195
+ pages.push(1);
196
+ if (currentPage > 4) pages.push("ellipsis-start");
197
+
198
+ for (let page = Math.max(2, currentPage - 1); page <= Math.min(totalPages - 1, currentPage + 1); page++) {
199
+ pages.push(page);
200
+ }
201
+
202
+ if (currentPage < totalPages - 3) pages.push("ellipsis-end");
203
+ pages.push(totalPages);
204
+ } else {
205
+ for (let page = 1; page <= totalPages; page++) pages.push(page);
206
+ }
207
+
208
+ return (
209
+ <div className="mt-6 flex justify-center">
210
+ <Pagination>
211
+ <PaginationContent>
212
+ <PaginationItem>
213
+ <PaginationPrevious
214
+ onClick={() => pagination.onPageChange(Math.max(1, currentPage - 1))}
215
+ className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
216
+ />
217
+ </PaginationItem>
218
+ {pages.map((page, index) => (
219
+ <PaginationItem key={`${page}-${index}`}>
220
+ {page === "ellipsis-start" || page === "ellipsis-end" ? (
221
+ <PaginationEllipsis />
222
+ ) : (
223
+ <PaginationLink
224
+ onClick={() => pagination.onPageChange(page)}
225
+ isActive={currentPage === page}
226
+ className="cursor-pointer"
227
+ >
228
+ {page}
229
+ </PaginationLink>
230
+ )}
231
+ </PaginationItem>
232
+ ))}
233
+ <PaginationItem>
234
+ <PaginationNext
235
+ onClick={() => pagination.onPageChange(Math.min(totalPages, currentPage + 1))}
236
+ className={
237
+ currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"
238
+ }
239
+ />
240
+ </PaginationItem>
241
+ </PaginationContent>
242
+ </Pagination>
243
+ </div>
244
+ );
245
+ };
246
+
247
+ return (
248
+ <div className="space-y-4">
249
+ <div className="md:rounded-lg md:border md:border-border md:bg-muted/30 md:p-3">
250
+ <div className="flex flex-col gap-2.5 md:flex-row md:flex-wrap md:items-center md:justify-start md:gap-3">
251
+ <InputGroup
252
+ placeholder={searchPlaceholder}
253
+ value={searchTerm}
254
+ onChange={(event) => onSearchChange(event.target.value)}
255
+ leadingIcon={<Search className="size-4" />}
256
+ wrapperClassName="w-full min-w-0 md:min-w-[280px] md:max-w-[520px] md:flex-1 md:basis-[340px]"
257
+ className="h-9 bg-background"
258
+ />
259
+ <div className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:flex-wrap md:w-auto md:justify-end">
260
+ {Object.entries(filters).map(([key, filter]) => (
261
+ <Select key={key} value={filter.value} onValueChange={filter.onChange}>
262
+ <SelectTrigger className="h-9 w-full bg-background sm:w-[180px]">
263
+ <Filter className="mr-2 size-4" />
264
+ <SelectValue placeholder={filter.placeholder} />
265
+ </SelectTrigger>
266
+ <SelectContent>
267
+ {filter.options.map((option) => (
268
+ <SelectItem key={option.value} value={option.value}>
269
+ {option.label}
270
+ </SelectItem>
271
+ ))}
272
+ </SelectContent>
273
+ </Select>
274
+ ))}
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <StackedList bordered={false} className="rounded-none bg-transparent md:hidden">
280
+ {displayData.length === 0
281
+ ? renderEmptyState("px-0 py-9")
282
+ : displayData.map((item) => {
283
+ if (mobileCard) {
284
+ return (
285
+ <StackedListItem key={item.id} className="px-0 py-0">
286
+ {mobileCard(item)}
287
+ </StackedListItem>
288
+ );
289
+ }
290
+
291
+ const primaryColumn = columns.find((column) => column.label.trim()) ?? columns[0];
292
+ const secondaryColumns = columns
293
+ .filter((column) => column.key !== primaryColumn?.key && column.label)
294
+ .slice(0, 5);
295
+
296
+ return (
297
+ <StackedListItem key={item.id} className="px-0 py-2.5">
298
+ <div className="flex items-start justify-between gap-3">
299
+ <div className="min-w-0 flex-1">
300
+ {primaryColumn && (
301
+ <div className="min-w-0 text-sm font-semibold text-foreground [&_*]:min-w-0">
302
+ {primaryColumn.render
303
+ ? primaryColumn.render(item)
304
+ : (item as Record<string, React.ReactNode>)[primaryColumn.key]}
305
+ </div>
306
+ )}
307
+ </div>
308
+ {actions && <div className="shrink-0">{actions(item)}</div>}
309
+ </div>
310
+ {secondaryColumns.length > 0 && (
311
+ <dl className="mt-2 divide-y divide-border/70 border-y border-border/70 text-xs">
312
+ {secondaryColumns.map((column) => (
313
+ <div
314
+ key={column.key}
315
+ className="grid min-w-0 grid-cols-[6.25rem_minmax(0,1fr)] gap-2 py-1.5"
316
+ >
317
+ <dt className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
318
+ {column.label}
319
+ </dt>
320
+ <dd className="min-w-0 break-words text-foreground">
321
+ {column.render
322
+ ? column.render(item)
323
+ : (item as Record<string, React.ReactNode>)[column.key]}
324
+ </dd>
325
+ </div>
326
+ ))}
327
+ </dl>
328
+ )}
329
+ </StackedListItem>
330
+ );
331
+ })}
332
+ </StackedList>
333
+
334
+ {displayData.length === 0 ? (
335
+ <div className="hidden min-h-[18rem] items-center justify-center border-t border-border/60 md:flex">
336
+ {renderEmptyState("px-6 py-12")}
337
+ </div>
338
+ ) : (
339
+ <Table containerClassName="hidden overflow-x-auto md:block" className={cn("min-w-full", tableClassName)}>
340
+ <TableHeader>
341
+ <TableRow className="relative border-b-0 bg-muted/80 transition-colors hover:bg-muted/80">
342
+ {columns.map((column) => {
343
+ const isSortable = Boolean(column.sortable || column.sortAccessor);
344
+ const isSorted = sort?.key === column.key;
345
+ const SortIcon = !isSorted ? ChevronsUpDown : sort.direction === "asc" ? ArrowUp : ArrowDown;
346
+
347
+ return (
348
+ <TableHead
349
+ key={column.key}
350
+ className={cn(
351
+ "cursor-default select-none whitespace-nowrap px-4 py-3 text-xs font-medium text-muted-foreground transition-colors",
352
+ column.className,
353
+ )}
354
+ aria-sort={
355
+ isSortable
356
+ ? isSorted
357
+ ? sort.direction === "asc"
358
+ ? "ascending"
359
+ : "descending"
360
+ : "none"
361
+ : undefined
362
+ }
363
+ >
364
+ {isSortable ? (
365
+ <Button
366
+ type="button"
367
+ variant="ghost"
368
+ size="sm"
369
+ className="-ml-2 h-auto px-2 py-1 text-xs font-medium"
370
+ onClick={() => toggleSort(column)}
371
+ aria-label={`Sort by ${column.label}`}
372
+ >
373
+ <span>{column.label}</span>
374
+ <SortIcon className="size-3.5" />
375
+ </Button>
376
+ ) : (
377
+ column.label
378
+ )}
379
+ </TableHead>
380
+ );
381
+ })}
382
+ {actions && (
383
+ <TableHead className="w-32 cursor-default select-none whitespace-nowrap px-4 py-3 text-xs font-medium text-muted-foreground">
384
+ Actions
385
+ </TableHead>
386
+ )}
387
+ </TableRow>
388
+ </TableHeader>
389
+ <TableBody>
390
+ {displayData.map((item, index) => (
391
+ <TableRow key={item.id} className={cn(index === 0 && "border-t-0")}>
392
+ {columns.map((column) => (
393
+ <TableCell key={column.key} className={cn("py-3", column.className)}>
394
+ {column.render
395
+ ? column.render(item)
396
+ : (item as Record<string, React.ReactNode>)[column.key]}
397
+ </TableCell>
398
+ ))}
399
+ {actions && <TableCell className="w-32 whitespace-nowrap py-3">{actions(item)}</TableCell>}
400
+ </TableRow>
401
+ ))}
402
+ </TableBody>
403
+ </Table>
404
+ )}
405
+
406
+ {renderPagination()}
407
+ </div>
408
+ );
409
+ }
410
+
411
+ export { SearchableTable };
412
+ export type { SearchableTableColumn, SearchableTableFilterOption, SearchableTableProps };
@@ -4,6 +4,12 @@ import * as React from "react";
4
4
  import { Select } from "radix-ui";
5
5
  import { Check, ChevronDown, ChevronUp } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
+ import {
8
+ mobilePopperSheetMotionClassName,
9
+ mobilePopperSheetPositionClassName,
10
+ mobilePopperSheetStyle,
11
+ mobilePopperSheetSurfaceClassName,
12
+ } from "../lib/mobile-popper-sheet";
7
13
 
8
14
  const SelectRoot = Select.Root;
9
15
  const SelectGroup = Select.Group;
@@ -38,38 +44,48 @@ const SelectContent = React.forwardRef<
38
44
  React.ComponentRef<typeof Select.Content>,
39
45
  React.ComponentPropsWithoutRef<typeof Select.Content>
40
46
  >(({ className, children, position = "popper", ...props }, ref) => (
41
- <Select.Portal>
42
- <Select.Content
43
- ref={ref}
44
- className={cn(
45
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden",
46
- "bg-card rounded-xl shadow-natural p-1",
47
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
48
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
49
- "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
50
- "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
51
- position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
52
- className,
53
- )}
54
- position={position}
55
- {...props}
56
- >
57
- <Select.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
58
- <ChevronUp size={14} className="text-muted-foreground" />
59
- </Select.ScrollUpButton>
60
- <Select.Viewport
47
+ <>
48
+ <style>{mobilePopperSheetStyle}</style>
49
+ <Select.Portal>
50
+ <Select.Content
51
+ ref={ref}
52
+ data-hilum-mobile-sheet="true"
61
53
  className={cn(
54
+ "relative z-50 max-h-96 min-w-[8rem] overflow-hidden",
55
+ "bg-card rounded-xl shadow-natural p-1",
56
+ mobilePopperSheetPositionClassName,
57
+ mobilePopperSheetSurfaceClassName,
58
+ "max-sm:px-2 max-sm:pb-2 max-sm:pt-5",
59
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
60
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
61
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
62
+ mobilePopperSheetMotionClassName,
63
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
62
64
  position === "popper" &&
63
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
65
+ "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
66
+ className,
64
67
  )}
68
+ position={position}
69
+ {...props}
65
70
  >
66
- {children}
67
- </Select.Viewport>
68
- <Select.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
69
- <ChevronDown size={14} className="text-muted-foreground" />
70
- </Select.ScrollDownButton>
71
- </Select.Content>
72
- </Select.Portal>
71
+ <Select.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
72
+ <ChevronUp size={14} className="text-muted-foreground" />
73
+ </Select.ScrollUpButton>
74
+ <Select.Viewport
75
+ className={cn(
76
+ position === "popper" &&
77
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
78
+ "max-sm:!h-auto max-sm:max-h-[calc(min(70dvh,28rem)-3rem)] max-sm:!w-full max-sm:!min-w-0",
79
+ )}
80
+ >
81
+ {children}
82
+ </Select.Viewport>
83
+ <Select.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
84
+ <ChevronDown size={14} className="text-muted-foreground" />
85
+ </Select.ScrollDownButton>
86
+ </Select.Content>
87
+ </Select.Portal>
88
+ </>
73
89
  ));
74
90
  SelectContent.displayName = "SelectContent";
75
91