@icomponents/react 0.1.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.
Files changed (117) hide show
  1. package/README.md +40 -0
  2. package/package.json +165 -0
  3. package/src/assets/custom/icon-dir.tsx +105 -0
  4. package/src/assets/custom/icon-layout-compact.tsx +84 -0
  5. package/src/assets/custom/icon-layout-default.tsx +48 -0
  6. package/src/assets/custom/icon-layout-full.tsx +68 -0
  7. package/src/assets/custom/icon-sidebar-floating.tsx +62 -0
  8. package/src/assets/custom/icon-sidebar-inset.tsx +53 -0
  9. package/src/assets/custom/icon-sidebar-sidebar.tsx +48 -0
  10. package/src/assets/custom/icon-theme-dark.tsx +36 -0
  11. package/src/assets/custom/icon-theme-light.tsx +35 -0
  12. package/src/assets/custom/icon-theme-system.tsx +113 -0
  13. package/src/components/admin-page-header.tsx +29 -0
  14. package/src/components/color-theme-switch.tsx +93 -0
  15. package/src/components/coming-soon.tsx +16 -0
  16. package/src/components/command-menu.tsx +98 -0
  17. package/src/components/config-drawer.tsx +401 -0
  18. package/src/components/confirm-dialog.dom.test.tsx +60 -0
  19. package/src/components/confirm-dialog.tsx +75 -0
  20. package/src/components/data-table/bulk-actions.tsx +193 -0
  21. package/src/components/data-table/column-header.tsx +66 -0
  22. package/src/components/data-table/data-table.dom.test.tsx +54 -0
  23. package/src/components/data-table/data-table.tsx +189 -0
  24. package/src/components/data-table/faceted-filter.tsx +156 -0
  25. package/src/components/data-table/index.ts +5 -0
  26. package/src/components/data-table/pagination.tsx +127 -0
  27. package/src/components/data-table/tanstack-table.d.ts +12 -0
  28. package/src/components/data-table/toolbar.tsx +88 -0
  29. package/src/components/data-table/view-options.tsx +49 -0
  30. package/src/components/date-picker.tsx +39 -0
  31. package/src/components/detail-card.tsx +63 -0
  32. package/src/components/language-switch.tsx +38 -0
  33. package/src/components/layout/app-sidebar.tsx +59 -0
  34. package/src/components/layout/app-title.tsx +62 -0
  35. package/src/components/layout/header.tsx +61 -0
  36. package/src/components/layout/main.tsx +34 -0
  37. package/src/components/layout/nav-group.tsx +185 -0
  38. package/src/components/layout/nav-user.tsx +115 -0
  39. package/src/components/layout/screen-header.tsx +119 -0
  40. package/src/components/layout/team-switcher.tsx +84 -0
  41. package/src/components/layout/top-nav.tsx +68 -0
  42. package/src/components/layout/types.ts +44 -0
  43. package/src/components/learn-more.tsx +35 -0
  44. package/src/components/long-text.tsx +80 -0
  45. package/src/components/navigation-progress.tsx +18 -0
  46. package/src/components/password-input.tsx +36 -0
  47. package/src/components/profile-dropdown.tsx +106 -0
  48. package/src/components/search.tsx +38 -0
  49. package/src/components/select-dropdown.tsx +62 -0
  50. package/src/components/sign-out-dialog.tsx +31 -0
  51. package/src/components/skip-to-main.tsx +10 -0
  52. package/src/components/stat-tile.tsx +56 -0
  53. package/src/components/status-badge.test.ts +23 -0
  54. package/src/components/status-badge.tsx +49 -0
  55. package/src/components/theme-switch.tsx +61 -0
  56. package/src/components/ui/alert-dialog.tsx +134 -0
  57. package/src/components/ui/alert.tsx +59 -0
  58. package/src/components/ui/avatar.tsx +38 -0
  59. package/src/components/ui/badge.tsx +39 -0
  60. package/src/components/ui/base-ui-state-attrs.dom.test.tsx +80 -0
  61. package/src/components/ui/button.tsx +51 -0
  62. package/src/components/ui/calendar.tsx +174 -0
  63. package/src/components/ui/card.tsx +74 -0
  64. package/src/components/ui/checkbox.tsx +26 -0
  65. package/src/components/ui/collapsible.tsx +18 -0
  66. package/src/components/ui/command.tsx +160 -0
  67. package/src/components/ui/dialog.tsx +129 -0
  68. package/src/components/ui/dropdown-menu.tsx +254 -0
  69. package/src/components/ui/form.tsx +155 -0
  70. package/src/components/ui/input-otp.tsx +67 -0
  71. package/src/components/ui/input.tsx +20 -0
  72. package/src/components/ui/label.tsx +21 -0
  73. package/src/components/ui/popover.tsx +54 -0
  74. package/src/components/ui/radio-group.tsx +37 -0
  75. package/src/components/ui/scroll-area.tsx +57 -0
  76. package/src/components/ui/select.tsx +213 -0
  77. package/src/components/ui/separator.tsx +23 -0
  78. package/src/components/ui/sheet.tsx +128 -0
  79. package/src/components/ui/sidebar.tsx +702 -0
  80. package/src/components/ui/skeleton.tsx +13 -0
  81. package/src/components/ui/sonner.tsx +26 -0
  82. package/src/components/ui/switch.tsx +25 -0
  83. package/src/components/ui/table.tsx +89 -0
  84. package/src/components/ui/tabs.tsx +51 -0
  85. package/src/components/ui/textarea.tsx +17 -0
  86. package/src/components/ui/tooltip.tsx +65 -0
  87. package/src/config/fonts.ts +2 -0
  88. package/src/context/direction-provider.tsx +61 -0
  89. package/src/context/font-provider.tsx +70 -0
  90. package/src/context/language-provider.tsx +73 -0
  91. package/src/context/layout-provider.tsx +81 -0
  92. package/src/context/search-provider.tsx +48 -0
  93. package/src/context/theme-provider.tsx +108 -0
  94. package/src/hooks/sort-url.test.ts +16 -0
  95. package/src/hooks/use-dialog-state.tsx +15 -0
  96. package/src/hooks/use-mobile.tsx +16 -0
  97. package/src/hooks/use-table-url-state.ts +243 -0
  98. package/src/i18n/common.ts +164 -0
  99. package/src/i18n/create-i18n.test.ts +50 -0
  100. package/src/i18n/create-i18n.ts +67 -0
  101. package/src/i18n/types.ts +22 -0
  102. package/src/i18n/use-translation.ts +6 -0
  103. package/src/lib/clipboard.test.ts +46 -0
  104. package/src/lib/clipboard.ts +29 -0
  105. package/src/lib/cookies.ts +39 -0
  106. package/src/lib/format-cell.test.ts +57 -0
  107. package/src/lib/format-cell.ts +63 -0
  108. package/src/lib/handle-server-error.test.ts +85 -0
  109. package/src/lib/handle-server-error.ts +39 -0
  110. package/src/lib/random-id.ts +13 -0
  111. package/src/lib/show-submitted-data.tsx +14 -0
  112. package/src/lib/utils.test.ts +35 -0
  113. package/src/lib/utils.ts +78 -0
  114. package/src/secure-context.contract.test.ts +63 -0
  115. package/src/testids.contract.test.ts +113 -0
  116. package/src/testids.ts +8 -0
  117. package/src/theme-contract.test.ts +72 -0
@@ -0,0 +1,193 @@
1
+ import { Badge } from "@icomponents/react/components/ui/badge";
2
+ import { Button } from "@icomponents/react/components/ui/button";
3
+ import { Separator } from "@icomponents/react/components/ui/separator";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@icomponents/react/components/ui/tooltip";
5
+ import { cn } from "@icomponents/react/lib/utils";
6
+ import { type Table } from "@tanstack/react-table";
7
+ import { X } from "lucide-react";
8
+ import { useState, useEffect, useRef } from "react";
9
+
10
+ type DataTableBulkActionsProps<TData> = {
11
+ table: Table<TData>;
12
+ entityName: string;
13
+ children: React.ReactNode;
14
+ };
15
+
16
+ /**
17
+ * A modular toolbar for displaying bulk actions when table rows are selected.
18
+ *
19
+ * @template TData The type of data in the table.
20
+ * @param {object} props The component props.
21
+ * @param {Table<TData>} props.table The react-table instance.
22
+ * @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
23
+ * @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
24
+ * @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
25
+ */
26
+ export function DataTableBulkActions<TData>({
27
+ table,
28
+ entityName,
29
+ children,
30
+ }: DataTableBulkActionsProps<TData>): React.ReactNode | null {
31
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
32
+ const selectedCount = selectedRows.length;
33
+ const toolbarRef = useRef<HTMLDivElement>(null);
34
+ const [announcement, setAnnouncement] = useState("");
35
+
36
+ // Announce selection changes to screen readers
37
+ useEffect(() => {
38
+ if (selectedCount > 0) {
39
+ const message = `${selectedCount} ${entityName}${selectedCount > 1 ? "s" : ""} selected. Bulk actions toolbar is available.`;
40
+
41
+ // Use queueMicrotask to defer state update and avoid cascading renders
42
+ queueMicrotask(() => {
43
+ setAnnouncement(message);
44
+ });
45
+
46
+ // Clear announcement after a delay
47
+ const timer = setTimeout(() => setAnnouncement(""), 3000);
48
+ return () => clearTimeout(timer);
49
+ }
50
+ }, [selectedCount, entityName]);
51
+
52
+ const handleClearSelection = () => {
53
+ table.resetRowSelection();
54
+ };
55
+
56
+ const handleKeyDown = (event: React.KeyboardEvent) => {
57
+ const buttons = toolbarRef.current?.querySelectorAll("button");
58
+ if (!buttons) return;
59
+
60
+ const currentIndex = Array.from(buttons).findIndex(
61
+ (button) => button === document.activeElement
62
+ );
63
+
64
+ switch (event.key) {
65
+ case "ArrowRight": {
66
+ event.preventDefault();
67
+ const nextIndex = (currentIndex + 1) % buttons.length;
68
+ buttons[nextIndex]?.focus();
69
+ break;
70
+ }
71
+ case "ArrowLeft": {
72
+ event.preventDefault();
73
+ const prevIndex = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1;
74
+ buttons[prevIndex]?.focus();
75
+ break;
76
+ }
77
+ case "Home":
78
+ event.preventDefault();
79
+ buttons[0]?.focus();
80
+ break;
81
+ case "End":
82
+ event.preventDefault();
83
+ buttons[buttons.length - 1]?.focus();
84
+ break;
85
+ case "Escape": {
86
+ // Check if the Escape key came from a dropdown trigger or content
87
+ // We can't check dropdown state because Radix UI closes it before our handler runs
88
+ const target = event.target as HTMLElement;
89
+ const activeElement = document.activeElement as HTMLElement;
90
+
91
+ // Check if the event target or currently focused element is a dropdown trigger
92
+ const isFromDropdownTrigger =
93
+ target?.getAttribute("data-slot") === "dropdown-menu-trigger" ||
94
+ activeElement?.getAttribute("data-slot") === "dropdown-menu-trigger" ||
95
+ target?.closest('[data-slot="dropdown-menu-trigger"]') ||
96
+ activeElement?.closest('[data-slot="dropdown-menu-trigger"]');
97
+
98
+ // Check if the focused element is inside dropdown content (which is portaled)
99
+ const isFromDropdownContent =
100
+ activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
101
+ target?.closest('[data-slot="dropdown-menu-content"]');
102
+
103
+ if (isFromDropdownTrigger || isFromDropdownContent) {
104
+ // Escape was meant for the dropdown - don't clear selection
105
+ return;
106
+ }
107
+
108
+ // Escape was meant for the toolbar - clear selection
109
+ event.preventDefault();
110
+ handleClearSelection();
111
+ break;
112
+ }
113
+ }
114
+ };
115
+
116
+ if (selectedCount === 0) {
117
+ return null;
118
+ }
119
+
120
+ return (
121
+ <>
122
+ {/* Live region for screen reader announcements */}
123
+ <div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
124
+ {announcement}
125
+ </div>
126
+
127
+ <div
128
+ ref={toolbarRef}
129
+ role="toolbar"
130
+ aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? "s" : ""}`}
131
+ aria-describedby="bulk-actions-description"
132
+ tabIndex={-1}
133
+ onKeyDown={handleKeyDown}
134
+ className={cn(
135
+ "fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl",
136
+ "transition-all delay-100 duration-300 ease-out hover:scale-105",
137
+ "focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
138
+ )}
139
+ >
140
+ <div
141
+ className={cn(
142
+ "p-2 shadow-xl",
143
+ "rounded-xl border",
144
+ "bg-background/95 backdrop-blur-lg supports-backdrop-filter:bg-background/60",
145
+ "flex items-center gap-x-2"
146
+ )}
147
+ >
148
+ <Tooltip>
149
+ <TooltipTrigger
150
+ render={
151
+ <Button
152
+ variant="outline"
153
+ size="icon"
154
+ onClick={handleClearSelection}
155
+ className="size-6 rounded-full"
156
+ aria-label="Clear selection"
157
+ title="Clear selection (Escape)"
158
+ >
159
+ <X />
160
+ <span className="sr-only">Clear selection</span>
161
+ </Button>
162
+ }
163
+ />
164
+ <TooltipContent>
165
+ <p>Clear selection (Escape)</p>
166
+ </TooltipContent>
167
+ </Tooltip>
168
+
169
+ <Separator className="h-5" orientation="vertical" aria-hidden="true" />
170
+
171
+ <div className="flex items-center gap-x-1 text-sm" id="bulk-actions-description">
172
+ <Badge
173
+ variant="default"
174
+ className="min-w-8 rounded-lg"
175
+ aria-label={`${selectedCount} selected`}
176
+ >
177
+ {selectedCount}
178
+ </Badge>{" "}
179
+ <span className="hidden sm:inline">
180
+ {entityName}
181
+ {selectedCount > 1 ? "s" : ""}
182
+ </span>{" "}
183
+ selected
184
+ </div>
185
+
186
+ <Separator className="h-5" orientation="vertical" aria-hidden="true" />
187
+
188
+ {children}
189
+ </div>
190
+ </div>
191
+ </>
192
+ );
193
+ }
@@ -0,0 +1,66 @@
1
+ import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from "@radix-ui/react-icons";
2
+ import { Button } from "@icomponents/react/components/ui/button";
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuSeparator,
8
+ DropdownMenuTrigger,
9
+ } from "@icomponents/react/components/ui/dropdown-menu";
10
+ import { cn } from "@icomponents/react/lib/utils";
11
+ import { type Column } from "@tanstack/react-table";
12
+
13
+ type DataTableColumnHeaderProps<TData, TValue> = React.HTMLAttributes<HTMLDivElement> & {
14
+ column: Column<TData, TValue>;
15
+ title: string;
16
+ };
17
+
18
+ export function DataTableColumnHeader<TData, TValue>({
19
+ column,
20
+ title,
21
+ className,
22
+ }: DataTableColumnHeaderProps<TData, TValue>) {
23
+ if (!column.getCanSort()) {
24
+ return <div className={cn(className)}>{title}</div>;
25
+ }
26
+
27
+ return (
28
+ <div className={cn("flex items-center space-x-2", className)}>
29
+ <DropdownMenu>
30
+ <DropdownMenuTrigger
31
+ render={
32
+ <Button variant="ghost" size="sm" className="h-8 data-popup-open:bg-accent">
33
+ <span>{title}</span>
34
+ {column.getIsSorted() === "desc" ? (
35
+ <ArrowDownIcon className="ms-2 h-4 w-4" />
36
+ ) : column.getIsSorted() === "asc" ? (
37
+ <ArrowUpIcon className="ms-2 h-4 w-4" />
38
+ ) : (
39
+ <CaretSortIcon className="ms-2 h-4 w-4" />
40
+ )}
41
+ </Button>
42
+ }
43
+ />
44
+ <DropdownMenuContent align="start">
45
+ <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
46
+ <ArrowUpIcon className="size-3.5 text-muted-foreground/70" />
47
+ Asc
48
+ </DropdownMenuItem>
49
+ <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
50
+ <ArrowDownIcon className="size-3.5 text-muted-foreground/70" />
51
+ Desc
52
+ </DropdownMenuItem>
53
+ {column.getCanHide() && (
54
+ <>
55
+ <DropdownMenuSeparator />
56
+ <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
57
+ <EyeNoneIcon className="size-3.5 text-muted-foreground/70" />
58
+ Hide
59
+ </DropdownMenuItem>
60
+ </>
61
+ )}
62
+ </DropdownMenuContent>
63
+ </DropdownMenu>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,54 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+
3
+ import { TESTID } from "@icomponents/react/testids";
4
+ import type { ColumnDef } from "@tanstack/react-table";
5
+ import { cleanup, render, screen } from "@testing-library/react";
6
+
7
+ import { DataTable } from "./data-table";
8
+
9
+ /**
10
+ * Таблица — главный экран консоли, и при перекраске она ломается первой: меняют вёрстку шапки,
11
+ * оборачивают ячейку в новый компонент, теряют пустое состояние.
12
+ *
13
+ * Проверяем СМЫСЛ, а не оформление: строки видны, значения дошли до ячеек, а пустой список честно
14
+ * говорит, что он пуст. Последнее важнее, чем кажется: таблица, которая при нуле строк молча рисует
15
+ * пустой каркас, читается как «данные ещё грузятся» — и человек ждёт то, чего не будет.
16
+ */
17
+ afterEach(cleanup);
18
+
19
+ type Row = { id: string; email: string };
20
+
21
+ const columns: ColumnDef<Row>[] = [
22
+ { accessorKey: "email", header: "Email", cell: ({ row }) => row.original.email },
23
+ ];
24
+
25
+ const props = {
26
+ columns,
27
+ search: {},
28
+ navigate: (() => {}) as never,
29
+ columnFilters: [] as never,
30
+ searchPlaceholder: "Поиск…",
31
+ searchKey: "email",
32
+ filters: [] as never,
33
+ emptyMessage: "Пользователи не найдены.",
34
+ };
35
+
36
+ describe("DataTable — данные, а не каркас", () => {
37
+ it("строку можно адресовать по id сущности, и значение дошло до ячейки", () => {
38
+ render(<DataTable<Row> {...props} data={[{ id: "u1", email: "a@b.co" }]} />);
39
+ // Адресуем КОНКРЕТНУЮ строку, а не «третью сверху»: порядок сортировки — не контракт.
40
+ expect(screen.getByTestId(TESTID.tableRow("u1"))).toBeDefined();
41
+ expect(screen.getByText("a@b.co")).toBeDefined();
42
+ });
43
+
44
+ it("пустой список ГОВОРИТ, что он пуст — а не притворяется загрузкой", () => {
45
+ render(<DataTable<Row> {...props} data={[]} />);
46
+ expect(screen.getByTestId(TESTID.tableEmpty)).toBeDefined();
47
+ expect(screen.getByText("Пользователи не найдены.")).toBeDefined();
48
+ });
49
+
50
+ it("шапка колонки подписана (после перекраски заголовки любят терять текст)", () => {
51
+ render(<DataTable<Row> {...props} data={[{ id: "u1", email: "a@b.co" }]} />);
52
+ expect(screen.getAllByText("Email").length).toBeGreaterThan(0);
53
+ });
54
+ });
@@ -0,0 +1,189 @@
1
+ import {
2
+ Table,
3
+ TableBody,
4
+ TableCell,
5
+ TableHead,
6
+ TableHeader,
7
+ TableRow,
8
+ } from "@icomponents/react/components/ui/table";
9
+ import { type NavigateFn, useTableUrlState } from "@icomponents/react/hooks/use-table-url-state";
10
+ import { cn } from "@icomponents/react/lib/utils";
11
+ import { TESTID, testid } from "@icomponents/react/testids";
12
+ import {
13
+ type ColumnDef,
14
+ type SortingState,
15
+ type VisibilityState,
16
+ flexRender,
17
+ getCoreRowModel,
18
+ getFacetedRowModel,
19
+ getFacetedUniqueValues,
20
+ getFilteredRowModel,
21
+ getPaginationRowModel,
22
+ getSortedRowModel,
23
+ useReactTable,
24
+ } from "@tanstack/react-table";
25
+ import { useEffect, useState } from "react";
26
+
27
+ import { DataTablePagination } from "./pagination";
28
+ import { DataTableToolbar } from "./toolbar";
29
+
30
+ // Generic extraction of the identical CompaniesTable/UsersTable bodies (§3): same URL-synced
31
+ // filter/sort/pagination state, same toolbar + table + pagination composition and className/spacing.
32
+ // A slice supplies only its columns, row type, filter config, and labels.
33
+ export type DataTableProps<TData> = {
34
+ data: TData[];
35
+ columns: ColumnDef<TData>[];
36
+ search: Record<string, unknown>;
37
+ navigate: NavigateFn;
38
+ columnFilters: Parameters<typeof useTableUrlState>[0]["columnFilters"];
39
+ searchPlaceholder: string;
40
+ searchKey: string;
41
+ filters: React.ComponentProps<typeof DataTableToolbar>["filters"];
42
+ emptyMessage: string;
43
+ defaultPageSize?: number;
44
+ rowCount?: number;
45
+ toolbarExtra?: React.ReactNode;
46
+ };
47
+
48
+ /** id сущности, если он есть: тест адресует КОНКРЕТНУЮ строку, а не «третью сверху». */
49
+ const rowKey = (row: unknown, fallback: string): string => {
50
+ const id = (row as { id?: unknown })?.id;
51
+ return typeof id === "string" || typeof id === "number" ? String(id) : fallback;
52
+ };
53
+
54
+ export function DataTable<TData>({
55
+ data,
56
+ columns,
57
+ search,
58
+ navigate,
59
+ columnFilters: columnFilterConfig,
60
+ searchPlaceholder,
61
+ searchKey,
62
+ filters,
63
+ emptyMessage,
64
+ defaultPageSize = 10,
65
+ rowCount,
66
+ toolbarExtra,
67
+ }: DataTableProps<TData>) {
68
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
69
+
70
+ const serverMode = rowCount != null;
71
+ const [localSorting, setLocalSorting] = useState<SortingState>([]);
72
+
73
+ const {
74
+ columnFilters,
75
+ onColumnFiltersChange,
76
+ pagination,
77
+ onPaginationChange,
78
+ ensurePageInRange,
79
+ sorting: urlSorting,
80
+ onSortingChange: urlOnSortingChange,
81
+ } = useTableUrlState({
82
+ search,
83
+ navigate,
84
+ pagination: { defaultPage: 1, defaultPageSize },
85
+ globalFilter: { enabled: false },
86
+ columnFilters: columnFilterConfig,
87
+ sorting: { enabled: serverMode },
88
+ });
89
+
90
+ const sorting = serverMode ? (urlSorting ?? []) : localSorting;
91
+
92
+ // eslint-disable-next-line react-hooks/incompatible-library
93
+ const table = useReactTable({
94
+ data,
95
+ columns,
96
+ rowCount,
97
+ state: { sorting, pagination, columnFilters, columnVisibility },
98
+ manualPagination: serverMode,
99
+ manualSorting: serverMode,
100
+ manualFiltering: serverMode,
101
+ onPaginationChange,
102
+ onColumnFiltersChange,
103
+ onSortingChange: serverMode ? urlOnSortingChange : setLocalSorting,
104
+ onColumnVisibilityChange: setColumnVisibility,
105
+ getCoreRowModel: getCoreRowModel(),
106
+ getPaginationRowModel: serverMode ? undefined : getPaginationRowModel(),
107
+ getFilteredRowModel: serverMode ? undefined : getFilteredRowModel(),
108
+ getSortedRowModel: serverMode ? undefined : getSortedRowModel(),
109
+ getFacetedRowModel: getFacetedRowModel(),
110
+ getFacetedUniqueValues: getFacetedUniqueValues(),
111
+ });
112
+
113
+ useEffect(() => {
114
+ ensurePageInRange(table.getPageCount());
115
+ }, [table, ensurePageInRange]);
116
+
117
+ return (
118
+ <div className="flex flex-1 flex-col gap-4">
119
+ <DataTableToolbar
120
+ table={table}
121
+ searchPlaceholder={searchPlaceholder}
122
+ searchKey={searchKey}
123
+ filters={filters}
124
+ extra={toolbarExtra}
125
+ />
126
+ <div className="overflow-hidden rounded-md border">
127
+ <Table {...testid(TESTID.table)}>
128
+ <TableHeader>
129
+ {table.getHeaderGroups().map((headerGroup) => (
130
+ <TableRow key={headerGroup.id} className="group/row">
131
+ {headerGroup.headers.map((header) => (
132
+ <TableHead
133
+ key={header.id}
134
+ colSpan={header.colSpan}
135
+ className={cn(
136
+ "bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
137
+ header.column.columnDef.meta?.className,
138
+ header.column.columnDef.meta?.thClassName
139
+ )}
140
+ >
141
+ {header.isPlaceholder
142
+ ? null
143
+ : flexRender(header.column.columnDef.header, header.getContext())}
144
+ </TableHead>
145
+ ))}
146
+ </TableRow>
147
+ ))}
148
+ </TableHeader>
149
+ <TableBody>
150
+ {table.getRowModel().rows?.length ? (
151
+ table.getRowModel().rows.map((row) => (
152
+ <TableRow
153
+ key={row.id}
154
+ {...testid(TESTID.tableRow(rowKey(row.original, row.id)))}
155
+ data-state={row.getIsSelected() && "selected"}
156
+ className="group/row"
157
+ >
158
+ {row.getVisibleCells().map((cell) => (
159
+ <TableCell
160
+ key={cell.id}
161
+ className={cn(
162
+ "bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
163
+ cell.column.columnDef.meta?.className,
164
+ cell.column.columnDef.meta?.tdClassName
165
+ )}
166
+ >
167
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
168
+ </TableCell>
169
+ ))}
170
+ </TableRow>
171
+ ))
172
+ ) : (
173
+ <TableRow>
174
+ <TableCell
175
+ {...testid(TESTID.tableEmpty)}
176
+ colSpan={columns.length}
177
+ className="h-24 text-center"
178
+ >
179
+ {emptyMessage}
180
+ </TableCell>
181
+ </TableRow>
182
+ )}
183
+ </TableBody>
184
+ </Table>
185
+ </div>
186
+ <DataTablePagination table={table} className="mt-auto" />
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,156 @@
1
+ import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
2
+ import { Badge } from "@icomponents/react/components/ui/badge";
3
+ import { Button } from "@icomponents/react/components/ui/button";
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ CommandSeparator,
12
+ } from "@icomponents/react/components/ui/command";
13
+ import { Popover, PopoverContent, PopoverTrigger } from "@icomponents/react/components/ui/popover";
14
+ import { Separator } from "@icomponents/react/components/ui/separator";
15
+ import { useTranslation } from "@icomponents/react/i18n/use-translation";
16
+ import { cn } from "@icomponents/react/lib/utils";
17
+ import { type Column } from "@tanstack/react-table";
18
+ import * as React from "react";
19
+
20
+ type DataTableFacetedFilterProps<TData, TValue> = {
21
+ column?: Column<TData, TValue>;
22
+ title?: string;
23
+ options: {
24
+ label: string;
25
+ value: string;
26
+ icon?: React.ComponentType<{ className?: string }>;
27
+ }[];
28
+ testId?: string;
29
+ single?: boolean;
30
+ };
31
+
32
+ export function DataTableFacetedFilter<TData, TValue>({
33
+ column,
34
+ title,
35
+ options,
36
+ testId,
37
+ single = false,
38
+ }: DataTableFacetedFilterProps<TData, TValue>) {
39
+ const { t } = useTranslation("common");
40
+ // Faceted counts are derived client-side from the current row model. With server-side data
41
+ // (manualFiltering + no filtered row model) tanstack's getFacetedUniqueValues() throws
42
+ // ("reading 'length' of undefined"), so guard it — the count badges are cosmetic and simply
43
+ // omitted when unavailable.
44
+ let facets: Map<unknown, number> | undefined;
45
+ try {
46
+ facets = column?.getFacetedUniqueValues();
47
+ } catch {
48
+ facets = undefined;
49
+ }
50
+ // The URL-synced filter value is normally an array; tolerate a scalar/csv just in case.
51
+ const rawFilter = column?.getFilterValue();
52
+ const selectedValues = new Set(
53
+ Array.isArray(rawFilter) ? (rawFilter as string[]) : rawFilter ? [String(rawFilter)] : []
54
+ );
55
+
56
+ return (
57
+ <Popover>
58
+ <PopoverTrigger
59
+ render={
60
+ <Button variant="outline" size="sm" className="h-8 border-dashed" data-testid={testId}>
61
+ <PlusCircledIcon className="size-4" />
62
+ {title}
63
+ {selectedValues?.size > 0 && (
64
+ <>
65
+ <Separator orientation="vertical" className="mx-2 h-4" />
66
+ <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
67
+ {selectedValues.size}
68
+ </Badge>
69
+ <div className="hidden space-x-1 lg:flex">
70
+ {selectedValues.size > 2 ? (
71
+ <Badge variant="secondary" className="rounded-sm px-1 font-normal">
72
+ {t("facetedFilter.selected", { n: selectedValues.size })}
73
+ </Badge>
74
+ ) : (
75
+ options
76
+ .filter((option) => selectedValues.has(option.value))
77
+ .map((option) => (
78
+ <Badge
79
+ variant="secondary"
80
+ key={option.value}
81
+ className="rounded-sm px-1 font-normal"
82
+ >
83
+ {option.label}
84
+ </Badge>
85
+ ))
86
+ )}
87
+ </div>
88
+ </>
89
+ )}
90
+ </Button>
91
+ }
92
+ />
93
+ <PopoverContent className="w-50 p-0" align="start">
94
+ <Command>
95
+ <CommandInput placeholder={title} />
96
+ <CommandList>
97
+ <CommandEmpty>{t("noResults")}</CommandEmpty>
98
+ <CommandGroup>
99
+ {options.map((option) => {
100
+ const isSelected = selectedValues.has(option.value);
101
+ return (
102
+ <CommandItem
103
+ key={option.value}
104
+ onSelect={() => {
105
+ if (isSelected) {
106
+ selectedValues.delete(option.value);
107
+ } else if (single) {
108
+ selectedValues.clear();
109
+ selectedValues.add(option.value);
110
+ } else {
111
+ selectedValues.add(option.value);
112
+ }
113
+ const filterValues = Array.from(selectedValues);
114
+ column?.setFilterValue(filterValues.length ? filterValues : undefined);
115
+ }}
116
+ >
117
+ <div
118
+ className={cn(
119
+ "flex size-4 items-center justify-center rounded-sm border border-primary",
120
+ isSelected
121
+ ? "bg-primary text-primary-foreground"
122
+ : "opacity-50 [&_svg]:invisible"
123
+ )}
124
+ >
125
+ <CheckIcon className={cn("h-4 w-4 text-background")} />
126
+ </div>
127
+ {option.icon && <option.icon className="size-4 text-muted-foreground" />}
128
+ <span>{option.label}</span>
129
+ {facets?.get(option.value) && (
130
+ <span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
131
+ {facets.get(option.value)}
132
+ </span>
133
+ )}
134
+ </CommandItem>
135
+ );
136
+ })}
137
+ </CommandGroup>
138
+ {selectedValues.size > 0 && (
139
+ <>
140
+ <CommandSeparator />
141
+ <CommandGroup>
142
+ <CommandItem
143
+ onSelect={() => column?.setFilterValue(undefined)}
144
+ className="justify-center text-center"
145
+ >
146
+ {t("facetedFilter.clear")}
147
+ </CommandItem>
148
+ </CommandGroup>
149
+ </>
150
+ )}
151
+ </CommandList>
152
+ </Command>
153
+ </PopoverContent>
154
+ </Popover>
155
+ );
156
+ }
@@ -0,0 +1,5 @@
1
+ export { DataTable, type DataTableProps } from "./data-table";
2
+ export { DataTablePagination } from "./pagination";
3
+ export { DataTableColumnHeader } from "./column-header";
4
+ export { DataTableToolbar } from "./toolbar";
5
+ export { DataTableBulkActions } from "./bulk-actions";