@alepha/ui 0.16.2 → 0.17.1

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 (175) hide show
  1. package/dist/admin/{AdminApiKeys-CoTOTfgU.js → AdminApiKeys-CF_qOO3u.js} +20 -20
  2. package/dist/admin/AdminApiKeys-CF_qOO3u.js.map +1 -0
  3. package/dist/admin/{AdminAudits-BmsxFbDa.js → AdminAudits-BQno3hZG.js} +7 -8
  4. package/dist/admin/AdminAudits-BQno3hZG.js.map +1 -0
  5. package/dist/admin/{AdminFiles-BBB8knca.js → AdminFiles-kvuUaASF.js} +3 -5
  6. package/dist/admin/{AdminFiles-BBB8knca.js.map → AdminFiles-kvuUaASF.js.map} +1 -1
  7. package/dist/admin/AdminJobDashboard-CrPxp0W1.js +485 -0
  8. package/dist/admin/AdminJobDashboard-CrPxp0W1.js.map +1 -0
  9. package/dist/admin/AdminJobExecutions-D-b4Zt7W.js +678 -0
  10. package/dist/admin/AdminJobExecutions-D-b4Zt7W.js.map +1 -0
  11. package/dist/admin/AdminJobRegistry-CNX5cpDx.js +301 -0
  12. package/dist/admin/AdminJobRegistry-CNX5cpDx.js.map +1 -0
  13. package/dist/admin/{AdminLayout-CsjvpeD1.js → AdminLayout-e-ZP5nWw.js} +1 -1
  14. package/dist/admin/{AdminLayout-CsjvpeD1.js.map → AdminLayout-e-ZP5nWw.js.map} +1 -1
  15. package/dist/admin/{AdminNotifications-LwR6RKrx.js → AdminNotifications-DeHJFf6W.js} +3 -5
  16. package/dist/admin/{AdminNotifications-LwR6RKrx.js.map → AdminNotifications-DeHJFf6W.js.map} +1 -1
  17. package/dist/admin/{AdminParameters-B_83Vie9.js → AdminParameters-iQE8o7a7.js} +43 -36
  18. package/dist/admin/AdminParameters-iQE8o7a7.js.map +1 -0
  19. package/dist/admin/{AdminSessions-CWnPosdd.js → AdminSessions-oKJCbd7w.js} +5 -7
  20. package/dist/admin/AdminSessions-oKJCbd7w.js.map +1 -0
  21. package/dist/admin/{AdminUserAudits-nHv636E_.js → AdminUserAudits-BNCEle_E.js} +6 -8
  22. package/dist/admin/AdminUserAudits-BNCEle_E.js.map +1 -0
  23. package/dist/admin/{AdminUserCreate-CjYD3Kjc.js → AdminUserCreate-CgqeFwCt.js} +6 -7
  24. package/dist/admin/AdminUserCreate-CgqeFwCt.js.map +1 -0
  25. package/dist/admin/{AdminUserDetails-Ccq-LsZ0.js → AdminUserDetails-DDe1A1GP.js} +30 -29
  26. package/dist/admin/AdminUserDetails-DDe1A1GP.js.map +1 -0
  27. package/dist/admin/{AdminUserLayout-7s41DiF_.js → AdminUserLayout-HAlobhWf.js} +18 -16
  28. package/dist/admin/AdminUserLayout-HAlobhWf.js.map +1 -0
  29. package/dist/admin/{AdminUserSessions-Ds3ODq_d.js → AdminUserSessions-Bq1LnVLf.js} +5 -7
  30. package/dist/admin/AdminUserSessions-Bq1LnVLf.js.map +1 -0
  31. package/dist/admin/{AdminUserSettings-CGh4gROo.js → AdminUserSettings-BRsBZoxV.js} +10 -10
  32. package/dist/admin/AdminUserSettings-BRsBZoxV.js.map +1 -0
  33. package/dist/admin/{AdminUsers-CvPiBzQK.js → AdminUsers-D71kIOSn.js} +6 -8
  34. package/dist/admin/AdminUsers-D71kIOSn.js.map +1 -0
  35. package/dist/admin/index.d.ts +7 -83
  36. package/dist/admin/index.d.ts.map +1 -1
  37. package/dist/admin/index.js +49 -70
  38. package/dist/admin/index.js.map +1 -1
  39. package/dist/auth/{Login-DS_OqA0G.js → Login-BS_FYTy0.js} +13 -8
  40. package/dist/auth/Login-BS_FYTy0.js.map +1 -0
  41. package/dist/auth/{Profile-Di7N7HZL.js → Profile-CjDsW378.js} +16 -10
  42. package/dist/auth/Profile-CjDsW378.js.map +1 -0
  43. package/dist/auth/{Register-BRR2_gux.js → Register-C5eqzAaD.js} +21 -12
  44. package/dist/auth/Register-C5eqzAaD.js.map +1 -0
  45. package/dist/auth/{ResetPassword-oQu72lod.js → ResetPassword-XifinVao.js} +14 -8
  46. package/dist/auth/ResetPassword-XifinVao.js.map +1 -0
  47. package/dist/auth/{VerifyEmail-DC6HPZjd.js → VerifyEmail-DTgbeJOO.js} +6 -4
  48. package/dist/auth/VerifyEmail-DTgbeJOO.js.map +1 -0
  49. package/dist/auth/index.d.ts +4 -0
  50. package/dist/auth/index.d.ts.map +1 -1
  51. package/dist/auth/index.js +15 -14
  52. package/dist/auth/index.js.map +1 -1
  53. package/dist/core/index.d.ts +37 -26
  54. package/dist/core/index.d.ts.map +1 -1
  55. package/dist/core/index.js +444 -193
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/demo/DemoDataTable-lnBKWBf8.js +362 -0
  58. package/dist/demo/DemoDataTable-lnBKWBf8.js.map +1 -0
  59. package/dist/demo/{DemoHome-DpRrPlBC.js → DemoHome-CUMZsYaH.js} +6 -7
  60. package/dist/demo/DemoHome-CUMZsYaH.js.map +1 -0
  61. package/dist/demo/{DemoJsonViewer-zeucGKHV.js → DemoJsonViewer-_uokbGaW.js} +17 -19
  62. package/dist/demo/DemoJsonViewer-_uokbGaW.js.map +1 -0
  63. package/dist/demo/{DemoLayout-PhgbAAiQ.js → DemoLayout-DHVoacE6.js} +2 -4
  64. package/dist/demo/{DemoLayout-PhgbAAiQ.js.map → DemoLayout-DHVoacE6.js.map} +1 -1
  65. package/dist/demo/{DemoLogin-DSzP0Lkv.js → DemoLogin-DjJ9314c.js} +22 -17
  66. package/dist/demo/DemoLogin-DjJ9314c.js.map +1 -0
  67. package/dist/demo/{DemoRegister-DavFBsCz.js → DemoRegister-DzkJ5M83.js} +34 -25
  68. package/dist/demo/DemoRegister-DzkJ5M83.js.map +1 -0
  69. package/dist/demo/{DemoResetPassword-BS2rIAQK.js → DemoResetPassword-DWh4_BpQ.js} +27 -21
  70. package/dist/demo/DemoResetPassword-DWh4_BpQ.js.map +1 -0
  71. package/dist/demo/{DemoSidebar-zNkUmHRl.js → DemoSidebar-C1csnGhX.js} +2 -2
  72. package/dist/demo/{DemoSidebar-zNkUmHRl.js.map → DemoSidebar-C1csnGhX.js.map} +1 -1
  73. package/dist/demo/{DemoTypeForm-B9q7oT0b.js → DemoTypeForm-CWz6fJrJ.js} +2 -2
  74. package/dist/demo/{DemoTypeForm-B9q7oT0b.js.map → DemoTypeForm-CWz6fJrJ.js.map} +1 -1
  75. package/dist/demo/{DemoVerifyEmail-Bi4SdWz0.js → DemoVerifyEmail-DbU_tCj8.js} +13 -11
  76. package/dist/demo/DemoVerifyEmail-DbU_tCj8.js.map +1 -0
  77. package/dist/demo/{IconGoogle-CTeZyrek.js → IconGoogle-Ch1m3Uzl.js} +1 -1
  78. package/dist/demo/{IconGoogle-CTeZyrek.js.map → IconGoogle-Ch1m3Uzl.js.map} +1 -1
  79. package/dist/demo/{Showcase-C9btr_SJ.js → Showcase-BzoXNlCn.js} +10 -10
  80. package/dist/demo/Showcase-BzoXNlCn.js.map +1 -0
  81. package/dist/demo/index.d.ts +1 -68
  82. package/dist/demo/index.d.ts.map +1 -1
  83. package/dist/demo/index.js +11 -15
  84. package/dist/demo/index.js.map +1 -1
  85. package/dist/json/index.js +2 -2
  86. package/dist/json/index.js.map +1 -1
  87. package/package.json +9 -5
  88. package/src/admin/AdminRouter.ts +36 -5
  89. package/src/admin/components/audits/AdminAudits.tsx +5 -5
  90. package/src/admin/components/jobs/AdminJobDashboard.tsx +455 -0
  91. package/src/admin/components/jobs/AdminJobExecutions.tsx +693 -0
  92. package/src/admin/components/jobs/AdminJobRegistry.tsx +325 -0
  93. package/src/admin/components/keys/AdminApiKeys.tsx +28 -31
  94. package/src/admin/components/parameters/AdminParameters.tsx +3 -3
  95. package/src/admin/components/parameters/ParameterDetails.tsx +34 -29
  96. package/src/admin/components/parameters/ParameterEmptyState.tsx +5 -5
  97. package/src/admin/components/parameters/ParameterHistory.tsx +11 -19
  98. package/src/admin/components/parameters/ParameterTree.tsx +16 -18
  99. package/src/admin/components/sessions/AdminSessions.tsx +3 -3
  100. package/src/admin/components/shared/AdminResourceHeader.tsx +20 -16
  101. package/src/admin/components/users/AdminUserAudits.tsx +5 -5
  102. package/src/admin/components/users/AdminUserCreate.tsx +3 -3
  103. package/src/admin/components/users/AdminUserDetails.tsx +51 -53
  104. package/src/admin/components/users/AdminUserLayout.tsx +7 -7
  105. package/src/admin/components/users/AdminUserSessions.tsx +3 -3
  106. package/src/admin/components/users/AdminUserSettings.tsx +9 -9
  107. package/src/admin/components/users/AdminUsers.tsx +5 -5
  108. package/src/admin/components/verifications/AdminVerifications.tsx +3 -3
  109. package/src/admin/index.ts +0 -24
  110. package/src/auth/components/Login.tsx +13 -13
  111. package/src/auth/components/Profile.tsx +17 -26
  112. package/src/auth/components/Register.tsx +21 -31
  113. package/src/auth/components/ResetPassword.tsx +13 -22
  114. package/src/auth/components/VerifyEmail.tsx +5 -5
  115. package/src/auth/components/buttons/UserButton.tsx +14 -4
  116. package/src/core/components/buttons/ActionButton.tsx +9 -2
  117. package/src/core/components/data/ErrorViewer.tsx +15 -15
  118. package/src/core/components/dialogs/AlertDialog.tsx +3 -3
  119. package/src/core/components/dialogs/ConfirmDialog.tsx +3 -3
  120. package/src/core/components/dialogs/PromptDialog.tsx +3 -3
  121. package/src/core/components/form/Control.tsx +9 -0
  122. package/src/core/components/form/ControlArray.tsx +6 -7
  123. package/src/core/components/form/ControlObject.tsx +3 -3
  124. package/src/core/components/form/ControlQueryBuilder.tsx +20 -22
  125. package/src/core/components/form/ControlSelect.tsx +4 -0
  126. package/src/core/components/form/TypeForm.tsx +7 -0
  127. package/src/core/components/layout/Breadcrumb.tsx +6 -6
  128. package/src/core/components/layout/Omnibar.tsx +2 -1
  129. package/src/core/components/layout/Sidebar.tsx +5 -1
  130. package/src/core/components/table/ColumnPicker.tsx +47 -31
  131. package/src/core/components/table/DataTable.tsx +277 -201
  132. package/src/core/components/table/DataTableFilters.tsx +8 -0
  133. package/src/core/components/table/DataTableToolbar.tsx +98 -5
  134. package/src/core/components/table/FilterPicker.tsx +28 -26
  135. package/src/core/components/table/types.ts +52 -37
  136. package/src/core/components/table/useTableSelection.ts +83 -0
  137. package/src/core/styles.css +1 -0
  138. package/src/core/utils/parseInput.ts +1 -0
  139. package/src/demo/components/DemoHome.tsx +5 -5
  140. package/src/demo/components/core/DemoDataTable.tsx +209 -5
  141. package/src/demo/components/json/DemoJsonViewer.tsx +1 -1
  142. package/src/demo/components/shared/MacWindow.tsx +7 -7
  143. package/src/demo/components/shared/Showcase.tsx +3 -3
  144. package/src/demo/index.ts +0 -11
  145. package/src/json/components/JsonViewer.tsx +3 -3
  146. package/dist/admin/AdminApiKeys-CoTOTfgU.js.map +0 -1
  147. package/dist/admin/AdminAudits-BmsxFbDa.js.map +0 -1
  148. package/dist/admin/AdminJobs-C604joTz.js +0 -698
  149. package/dist/admin/AdminJobs-C604joTz.js.map +0 -1
  150. package/dist/admin/AdminParameters-B_83Vie9.js.map +0 -1
  151. package/dist/admin/AdminSessions-CWnPosdd.js.map +0 -1
  152. package/dist/admin/AdminUserAudits-nHv636E_.js.map +0 -1
  153. package/dist/admin/AdminUserCreate-CjYD3Kjc.js.map +0 -1
  154. package/dist/admin/AdminUserDetails-Ccq-LsZ0.js.map +0 -1
  155. package/dist/admin/AdminUserLayout-7s41DiF_.js.map +0 -1
  156. package/dist/admin/AdminUserSessions-Ds3ODq_d.js.map +0 -1
  157. package/dist/admin/AdminUserSettings-CGh4gROo.js.map +0 -1
  158. package/dist/admin/AdminUsers-CvPiBzQK.js.map +0 -1
  159. package/dist/admin/rolldown-runtime-CjeV3_4I.js +0 -18
  160. package/dist/auth/Login-DS_OqA0G.js.map +0 -1
  161. package/dist/auth/Profile-Di7N7HZL.js.map +0 -1
  162. package/dist/auth/Register-BRR2_gux.js.map +0 -1
  163. package/dist/auth/ResetPassword-oQu72lod.js.map +0 -1
  164. package/dist/auth/VerifyEmail-DC6HPZjd.js.map +0 -1
  165. package/dist/demo/DemoDataTable-DCsJq8v5.js +0 -149
  166. package/dist/demo/DemoDataTable-DCsJq8v5.js.map +0 -1
  167. package/dist/demo/DemoHome-DpRrPlBC.js.map +0 -1
  168. package/dist/demo/DemoJsonViewer-zeucGKHV.js.map +0 -1
  169. package/dist/demo/DemoLogin-DSzP0Lkv.js.map +0 -1
  170. package/dist/demo/DemoRegister-DavFBsCz.js.map +0 -1
  171. package/dist/demo/DemoResetPassword-BS2rIAQK.js.map +0 -1
  172. package/dist/demo/DemoVerifyEmail-Bi4SdWz0.js.map +0 -1
  173. package/dist/demo/Showcase-C9btr_SJ.js.map +0 -1
  174. package/dist/demo/rolldown-runtime-CjeV3_4I.js +0 -18
  175. package/src/admin/components/jobs/AdminJobs.tsx +0 -772
@@ -1,16 +1,25 @@
1
- import { Checkbox, Flex, Table, Text, UnstyledButton } from "@mantine/core";
2
- import { useDebouncedCallback } from "@mantine/hooks";
1
+ import {
2
+ Checkbox,
3
+ Drawer,
4
+ Flex,
5
+ Table,
6
+ Text,
7
+ UnstyledButton,
8
+ } from "@mantine/core";
3
9
  import {
4
10
  IconArrowDown,
5
11
  IconArrowsSort,
6
12
  IconArrowUp,
13
+ IconChevronDown,
14
+ IconChevronRight,
7
15
  } from "@tabler/icons-react";
8
16
  import { Alepha, type Static, type TObject, t } from "alepha";
9
17
  import { DateTimeProvider } from "alepha/datetime";
10
18
  import { useInject } from "alepha/react";
11
19
  import { type FormModel, useForm } from "alepha/react/form";
12
- import { useCallback, useEffect, useMemo, useState } from "react";
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
21
  import { ui } from "../../constants/ui.ts";
22
+ import ActionButton from "../buttons/ActionButton.tsx";
14
23
  import DataTableFilters, {
15
24
  type DataTableFiltersProps,
16
25
  } from "./DataTableFilters.tsx";
@@ -23,8 +32,8 @@ import type {
23
32
  FilterVisibility,
24
33
  MaybePage,
25
34
  } from "./types.ts";
26
-
27
- const DEFAULT_VISIBLE_COLUMN_COUNT = 10;
35
+ import { DEFAULT_MAX_VISIBLE_COLUMNS } from "./types.ts";
36
+ import { useTableSelection } from "./useTableSelection.ts";
28
37
 
29
38
  type SortDirection = "asc" | "desc" | null;
30
39
 
@@ -73,15 +82,21 @@ const toggleSort = (
73
82
  return parts.length > 0 ? parts.join(",") : undefined;
74
83
  };
75
84
 
85
+ const toAriaSort = (
86
+ dir: SortDirection,
87
+ ): "ascending" | "descending" | "none" => {
88
+ if (dir === "asc") return "ascending";
89
+ if (dir === "desc") return "descending";
90
+ return "none";
91
+ };
92
+
93
+ const FIT_STYLE = { width: 1, whiteSpace: "nowrap" } as const;
94
+
76
95
  const DataTable = <T extends object, Filters extends TObject>(
77
96
  props: DataTableProps<T, Filters>,
78
97
  ) => {
79
98
  const [items, setItems] = useState<MaybePage<T>>(
80
- typeof props.items === "function"
81
- ? {
82
- content: [],
83
- }
84
- : props.items,
99
+ typeof props.items === "function" ? { content: [] } : props.items,
85
100
  );
86
101
 
87
102
  const defaultSize = props.infinityScroll ? 100 : props.defaultSize || 10;
@@ -89,54 +104,39 @@ const DataTable = <T extends object, Filters extends TObject>(
89
104
  const [size, setSize] = useState(String(defaultSize));
90
105
  const [currentPage, setCurrentPage] = useState(0);
91
106
  const alepha = useInject(Alepha);
107
+ const sentinelRef = useRef<HTMLDivElement>(null);
92
108
 
93
109
  // Column visibility state
94
110
  const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility>(
95
111
  () => {
96
- if (props.defaultColumnVisibility) {
97
- return props.defaultColumnVisibility;
98
- }
99
- const columnKeys = Object.keys(props.columns);
100
- const maxVisible =
101
- props.defaultVisibleColumnCount ?? DEFAULT_VISIBLE_COLUMN_COUNT;
102
- return columnKeys.reduce(
103
- (acc, key, index) => ({
104
- ...acc,
105
- [key]: index < maxVisible,
106
- }),
107
- {} as ColumnVisibility,
108
- );
112
+ const entries = Object.entries(props.columns);
113
+ let visibleCount = 0;
114
+ return entries.reduce((acc, [key, col]) => {
115
+ if (col.defaultHidden) {
116
+ acc[key] = false;
117
+ } else if (visibleCount < DEFAULT_MAX_VISIBLE_COLUMNS) {
118
+ acc[key] = true;
119
+ visibleCount++;
120
+ } else {
121
+ acc[key] = false;
122
+ }
123
+ return acc;
124
+ }, {} as ColumnVisibility);
109
125
  },
110
126
  );
111
127
 
112
- // Filter visibility state
128
+ // Filter visibility state — default: none visible
113
129
  const [filterVisibility, setFilterVisibility] = useState<FilterVisibility>(
114
130
  () => {
115
- if (props.defaultFilterVisibility) {
116
- return props.defaultFilterVisibility;
117
- }
118
- if (!props.filters?.properties) {
119
- return {};
120
- }
131
+ if (!props.filters?.properties) return {};
132
+ const defaults = new Set(props.defaultFilters ?? []);
121
133
  return Object.keys(props.filters.properties).reduce(
122
- (acc, key) => ({ ...acc, [key]: true }),
134
+ (acc, key) => ({ ...acc, [key]: defaults.has(key) }),
123
135
  {} as FilterVisibility,
124
136
  );
125
137
  },
126
138
  );
127
139
 
128
- // Handle column visibility changes
129
- const handleColumnVisibilityChange = (visibility: ColumnVisibility) => {
130
- setColumnVisibility(visibility);
131
- props.onColumnVisibilityChange?.(visibility);
132
- };
133
-
134
- // Handle filter visibility changes
135
- const handleFilterVisibilityChange = (visibility: FilterVisibility) => {
136
- setFilterVisibility(visibility);
137
- props.onFilterVisibilityChange?.(visibility);
138
- };
139
-
140
140
  // Compute visible columns
141
141
  const visibleColumns = useMemo(() => {
142
142
  return Object.entries(props.columns).filter(
@@ -144,7 +144,7 @@ const DataTable = <T extends object, Filters extends TObject>(
144
144
  );
145
145
  }, [props.columns, columnVisibility]);
146
146
 
147
- // Current sort string from form
147
+ // Current sort string
148
148
  const [sortString, setSortString] = useState<string | undefined>(undefined);
149
149
 
150
150
  // Handle column header click for sorting
@@ -156,88 +156,52 @@ const DataTable = <T extends object, Filters extends TObject>(
156
156
  form.input.page.set(0); // Reset to first page when sorting changes
157
157
  };
158
158
 
159
- // Checkbox selection state
160
- const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
161
-
162
- // Default getItemKey uses JSON.stringify if not provided
159
+ // Item key resolver — prefers explicit prop, falls back to .id, then JSON
163
160
  const getItemKey = useCallback(
164
161
  (item: T): string => {
165
- if (props.getItemKey) {
166
- return props.getItemKey(item);
167
- }
162
+ if (props.getItemKey) return props.getItemKey(item);
163
+ if ("id" in item) return String((item as Record<string, unknown>).id);
168
164
  return JSON.stringify(item);
169
165
  },
170
166
  [props.getItemKey],
171
167
  );
172
168
 
173
- // Get selected items from current content
174
- const selectedItems = useMemo(() => {
175
- if (!props.withCheckbox) return [];
176
- return items.content.filter((item) =>
177
- selectedKeys.has(getItemKey(item as T)),
178
- ) as T[];
179
- }, [items.content, selectedKeys, getItemKey, props.withCheckbox]);
180
-
181
- // Check if all current page items are selected
182
- const allSelected = useMemo(() => {
183
- if (items.content.length === 0) return false;
184
- return items.content.every((item) =>
185
- selectedKeys.has(getItemKey(item as T)),
186
- );
187
- }, [items.content, selectedKeys, getItemKey]);
188
-
189
- // Check if some (but not all) items are selected
190
- const someSelected = useMemo(() => {
191
- if (items.content.length === 0) return false;
192
- const selectedCount = items.content.filter((item) =>
193
- selectedKeys.has(getItemKey(item as T)),
194
- ).length;
195
- return selectedCount > 0 && selectedCount < items.content.length;
196
- }, [items.content, selectedKeys, getItemKey]);
197
-
198
- // Toggle selection of a single item
199
- const toggleItemSelection = useCallback(
200
- (item: T) => {
201
- const key = getItemKey(item);
202
- setSelectedKeys((prev) => {
203
- const next = new Set(prev);
204
- if (next.has(key)) {
205
- next.delete(key);
206
- } else {
207
- next.add(key);
208
- }
209
- return next;
210
- });
211
- },
212
- [getItemKey],
169
+ // Checkbox selection (extracted hook)
170
+ const selection = useTableSelection(
171
+ items.content as T[],
172
+ getItemKey,
173
+ props.withCheckbox ?? false,
213
174
  );
214
175
 
215
- // Toggle selection of all items on current page
216
- const toggleAllSelection = useCallback(() => {
217
- if (allSelected) {
218
- // Deselect all current page items
219
- setSelectedKeys((prev) => {
220
- const next = new Set(prev);
221
- for (const item of items.content) {
222
- next.delete(getItemKey(item as T));
223
- }
224
- return next;
225
- });
226
- } else {
227
- // Select all current page items
228
- setSelectedKeys((prev) => {
229
- const next = new Set(prev);
230
- for (const item of items.content) {
231
- next.add(getItemKey(item as T));
232
- }
233
- return next;
234
- });
176
+ // Panel normalize shorthand vs object form
177
+ const panelConfig = useMemo(() => {
178
+ if (!props.panel) return null;
179
+ if (typeof props.panel === "function") {
180
+ return { render: props.panel, can: undefined };
235
181
  }
236
- }, [allSelected, items.content, getItemKey]);
237
-
238
- // Clear all selections
239
- const clearSelection = useCallback(() => {
240
- setSelectedKeys(new Set());
182
+ return props.panel;
183
+ }, [props.panel]);
184
+
185
+ // Drawer normalize shorthand vs object form
186
+ const [drawerItem, setDrawerItem] = useState<T | null>(null);
187
+ const drawerConfig = useMemo(() => {
188
+ if (!props.drawer) return null;
189
+ if (typeof props.drawer === "function") {
190
+ return { render: props.drawer, can: undefined, props: undefined };
191
+ }
192
+ return props.drawer;
193
+ }, [props.drawer]);
194
+
195
+ // Panel expand state
196
+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
197
+
198
+ const toggleExpand = useCallback((key: string) => {
199
+ setExpandedKeys((prev) => {
200
+ const next = new Set(prev);
201
+ if (next.has(key)) next.delete(key);
202
+ else next.add(key);
203
+ return next;
204
+ });
241
205
  }, []);
242
206
 
243
207
  const form = useForm(
@@ -276,7 +240,7 @@ const DataTable = <T extends object, Filters extends TObject>(
276
240
  },
277
241
  onReset: async () => {
278
242
  setPage(1);
279
- setSize("10");
243
+ setSize(String(defaultSize));
280
244
  await form.submit();
281
245
  },
282
246
  onChange: async (key, value) => {
@@ -302,10 +266,6 @@ const DataTable = <T extends object, Filters extends TObject>(
302
266
  [items],
303
267
  );
304
268
 
305
- const submitDebounce = useDebouncedCallback(() => form.submit(), {
306
- delay: 800,
307
- });
308
-
309
269
  const dt = useInject(DateTimeProvider);
310
270
 
311
271
  useEffect(() => {
@@ -326,30 +286,27 @@ const DataTable = <T extends object, Filters extends TObject>(
326
286
  }
327
287
  }, [props.items]);
328
288
 
329
- // Infinity scroll detection
289
+ // Infinity scroll via IntersectionObserver
330
290
  useEffect(() => {
331
291
  if (!props.infinityScroll || typeof props.items !== "function") return;
332
292
 
333
- const handleScroll = () => {
334
- if (form.submitting) return;
335
-
336
- const scrollTop = window.scrollY;
337
- const windowHeight = window.innerHeight;
338
- const docHeight = document.documentElement.scrollHeight;
293
+ const sentinel = sentinelRef.current;
294
+ if (!sentinel) return;
339
295
 
340
- const isNearBottom = scrollTop + windowHeight >= docHeight - 300;
296
+ const observer = new IntersectionObserver(
297
+ (entries) => {
298
+ if (!entries[0].isIntersecting || form.submitting) return;
341
299
 
342
- if (isNearBottom) {
343
300
  const totalPages = items.page?.totalPages ?? 1;
344
-
345
301
  if (currentPage + 1 < totalPages) {
346
302
  form.input.page.set(currentPage + 1);
347
303
  }
348
- }
349
- };
304
+ },
305
+ { rootMargin: "300px" },
306
+ );
350
307
 
351
- window.addEventListener("scroll", handleScroll);
352
- return () => window.removeEventListener("scroll", handleScroll);
308
+ observer.observe(sentinel);
309
+ return () => observer.disconnect();
353
310
  }, [
354
311
  props.infinityScroll,
355
312
  form.submitting,
@@ -358,13 +315,19 @@ const DataTable = <T extends object, Filters extends TObject>(
358
315
  form,
359
316
  ]);
360
317
 
318
+ // Total column count (for colSpan)
319
+ const totalColumns =
320
+ visibleColumns.length +
321
+ (panelConfig ? 1 : 0) +
322
+ (props.withCheckbox ? 1 : 0);
323
+
361
324
  // Checkbox header column
362
325
  const checkboxHeader = props.withCheckbox ? (
363
326
  <Table.Th style={{ width: 40 }}>
364
327
  <Checkbox
365
- checked={allSelected}
366
- indeterminate={someSelected}
367
- onChange={toggleAllSelection}
328
+ checked={selection.allSelected}
329
+ indeterminate={selection.someSelected}
330
+ onChange={selection.toggleAll}
368
331
  aria-label="Select all"
369
332
  />
370
333
  </Table.Th>
@@ -376,74 +339,145 @@ const DataTable = <T extends object, Filters extends TObject>(
376
339
  ? getSortDirection(sortString, sortField)
377
340
  : null;
378
341
 
379
- const headerContent = (
380
- <Flex align="center" gap={4}>
381
- <Text size="xs">{col.label}</Text>
382
- {col.sortable && (
383
- <Flex c="dimmed">
384
- {sortDir === "asc" && <IconArrowUp size={ui.sizes.icon.sm} />}
385
- {sortDir === "desc" && <IconArrowDown size={ui.sizes.icon.sm} />}
386
- {sortDir === null && <IconArrowsSort size={ui.sizes.icon.sm} />}
387
- </Flex>
388
- )}
389
- </Flex>
390
- );
391
-
392
342
  return (
393
343
  <Table.Th
394
344
  key={key}
345
+ onClick={
346
+ col.sortable ? () => handleSortClick(key, col.sortKey) : undefined
347
+ }
348
+ aria-sort={col.sortable ? toAriaSort(sortDir) : undefined}
395
349
  style={{
396
- ...(col.fit
397
- ? {
398
- // TODO: not working well (bad formatting in some cases)
399
- // width: "1%",
400
- // whiteSpace: "nowrap",
401
- }
402
- : {}),
403
- ...(col.sortable ? { cursor: "pointer" } : {}),
350
+ ...(col.fit ? FIT_STYLE : {}),
351
+ ...(col.sortable ? { cursor: "pointer", userSelect: "none" } : {}),
404
352
  }}
405
353
  >
406
- {col.sortable ? (
407
- <UnstyledButton onClick={() => handleSortClick(key, col.sortKey)}>
408
- {headerContent}
409
- </UnstyledButton>
410
- ) : (
411
- headerContent
412
- )}
354
+ <Flex align="center" gap={4}>
355
+ <Text size="xs">{col.label}</Text>
356
+ {col.sortable && (
357
+ <Flex c="dimmed">
358
+ {sortDir === "asc" && <IconArrowUp size={ui.sizes.icon.sm} />}
359
+ {sortDir === "desc" && <IconArrowDown size={ui.sizes.icon.sm} />}
360
+ {sortDir === null && <IconArrowsSort size={ui.sizes.icon.sm} />}
361
+ </Flex>
362
+ )}
363
+ </Flex>
413
364
  </Table.Th>
414
365
  );
415
366
  });
416
367
 
417
- const rows = items.content.map((item, index) => {
368
+ const rows = items.content.flatMap((item, index) => {
418
369
  const trProps = props.tableTrProps ? props.tableTrProps(item as T) : {};
419
370
  const itemKey = getItemKey(item as T);
420
- const isSelected = selectedKeys.has(itemKey);
421
-
422
- return (
423
- <Table.Tr key={itemKey} {...trProps}>
371
+ const isSelected = selection.isSelected(item as T);
372
+ const showPanel =
373
+ panelConfig && (panelConfig.can ? panelConfig.can(item as T) : true);
374
+ const isExpanded = expandedKeys.has(itemKey);
375
+ const canOpenDrawer =
376
+ drawerConfig && (drawerConfig.can ? drawerConfig.can(item as T) : true);
377
+
378
+ const elements = [
379
+ <Table.Tr
380
+ key={itemKey}
381
+ {...trProps}
382
+ style={{
383
+ ...(canOpenDrawer ? { cursor: "pointer" } : {}),
384
+ ...(trProps.style ?? {}),
385
+ }}
386
+ onClick={(e) => {
387
+ if (canOpenDrawer) setDrawerItem(item as T);
388
+ trProps.onClick?.(e);
389
+ }}
390
+ >
391
+ {panelConfig && (
392
+ <Table.Td style={{ width: 36, textAlign: "center" }} py={2} px={0}>
393
+ {showPanel && (
394
+ <UnstyledButton
395
+ onClick={(e) => {
396
+ e.stopPropagation();
397
+ toggleExpand(itemKey);
398
+ }}
399
+ style={{ display: "inline-flex" }}
400
+ >
401
+ <Flex c="dimmed" align="center" justify="center">
402
+ {isExpanded ? (
403
+ <IconChevronDown size={ui.sizes.icon.sm} />
404
+ ) : (
405
+ <IconChevronRight size={ui.sizes.icon.sm} />
406
+ )}
407
+ </Flex>
408
+ </UnstyledButton>
409
+ )}
410
+ </Table.Td>
411
+ )}
424
412
  {props.withCheckbox && (
425
- <Table.Td style={{ width: 40 }}>
413
+ <Table.Td style={{ width: 40 }} onClick={(e) => e.stopPropagation()}>
426
414
  <Checkbox
427
415
  checked={isSelected}
428
- onChange={() => toggleItemSelection(item as T)}
416
+ onChange={() => selection.toggleItem(item as T)}
429
417
  aria-label="Select row"
430
418
  />
431
419
  </Table.Td>
432
420
  )}
433
- {visibleColumns.map(([key, col]) => (
434
- <Table.Td key={key}>
435
- {col.value(
436
- item as T,
437
- {
438
- index,
439
- form: form as unknown as FormModel<Filters>,
440
- alepha,
441
- } as DataTableColumnContext<Filters>,
442
- )}
421
+ {visibleColumns.map(([key, col]) => {
422
+ const ctx = {
423
+ index,
424
+ form: form as unknown as FormModel<Filters>,
425
+ alepha,
426
+ } as DataTableColumnContext<Filters>;
427
+
428
+ if (col.actions) {
429
+ const rowActions = col
430
+ .actions(item as T, ctx)
431
+ .filter((a) => a.visible !== false);
432
+ return (
433
+ <Table.Td
434
+ py={2}
435
+ px={4}
436
+ key={key}
437
+ style={col.fit ? FIT_STYLE : undefined}
438
+ onClick={(e) => e.stopPropagation()}
439
+ >
440
+ <Flex gap={4}>
441
+ {rowActions.map(({ visible: _, ...actionProps }, i) => (
442
+ <ActionButton
443
+ key={i}
444
+ variant="subtle"
445
+ size="xs"
446
+ preventDefault
447
+ h={20}
448
+ {...actionProps}
449
+ />
450
+ ))}
451
+ </Flex>
452
+ </Table.Td>
453
+ );
454
+ }
455
+
456
+ return (
457
+ <Table.Td
458
+ py={2}
459
+ px={4}
460
+ key={key}
461
+ style={col.fit ? FIT_STYLE : undefined}
462
+ >
463
+ {col.value?.(item as T, ctx)}
464
+ </Table.Td>
465
+ );
466
+ })}
467
+ </Table.Tr>,
468
+ ];
469
+
470
+ if (panelConfig && showPanel && isExpanded) {
471
+ elements.push(
472
+ <Table.Tr key={`${itemKey}-panel`}>
473
+ <Table.Td colSpan={totalColumns} p={0}>
474
+ {panelConfig.render(item as T)}
443
475
  </Table.Td>
444
- ))}
445
- </Table.Tr>
446
- );
476
+ </Table.Tr>,
477
+ );
478
+ }
479
+
480
+ return elements;
447
481
  });
448
482
 
449
483
  const filterSchema = useMemo(() => {
@@ -452,26 +486,21 @@ const DataTable = <T extends object, Filters extends TObject>(
452
486
  }, [props.filters, form.options.schema]);
453
487
 
454
488
  return (
455
- <Flex
456
- flex={1}
457
- p={0}
458
- bg="var(--alepha-elevated)"
459
- bdrs="sm"
460
- bd="1px solid var(--alepha-border)"
461
- direction="column"
462
- >
489
+ <Flex flex={1} p={0} bdrs="sm" direction="column">
463
490
  <DataTableToolbar
464
491
  columns={props.columns}
465
492
  filters={props.filters}
466
493
  columnVisibility={columnVisibility}
467
494
  filterVisibility={filterVisibility}
468
- onColumnVisibilityChange={handleColumnVisibilityChange}
469
- onFilterVisibilityChange={handleFilterVisibilityChange}
495
+ onColumnVisibilityChange={setColumnVisibility}
496
+ onFilterVisibilityChange={setFilterVisibility}
470
497
  actions={props.actions}
471
498
  onRefresh={() => form.submit()}
472
- selectedItems={selectedItems}
499
+ items={items.content as T[]}
500
+ withExport={props.withExport}
501
+ selectedItems={selection.selectedItems}
473
502
  checkboxActions={props.checkboxActions}
474
- onClearSelection={clearSelection}
503
+ onClearSelection={selection.clear}
475
504
  />
476
505
 
477
506
  {filterSchema && props.filters && (
@@ -486,17 +515,52 @@ const DataTable = <T extends object, Filters extends TObject>(
486
515
  )}
487
516
 
488
517
  <Flex className="overflow-auto">
489
- <Table withColumnBorders withRowBorders {...props.tableProps}>
490
- <Table.Thead>
518
+ <Table
519
+ aria-label="Data table"
520
+ withColumnBorders
521
+ withRowBorders
522
+ {...props.tableProps}
523
+ >
524
+ <Table.Thead
525
+ style={{
526
+ position: "sticky",
527
+ top: 0,
528
+ zIndex: 1,
529
+ backgroundColor: "var(--mantine-color-body)",
530
+ }}
531
+ >
491
532
  <Table.Tr>
533
+ {panelConfig && <Table.Th style={{ width: 36 }} />}
492
534
  {checkboxHeader}
493
535
  {head}
494
536
  </Table.Tr>
495
537
  </Table.Thead>
496
- <Table.Tbody>{rows}</Table.Tbody>
538
+ <Table.Tbody
539
+ style={{
540
+ opacity: form.submitting ? 0.5 : 1,
541
+ transition: "opacity 150ms ease",
542
+ }}
543
+ >
544
+ {rows}
545
+ {items.content.length === 0 && (
546
+ <Table.Tr>
547
+ <Table.Td
548
+ colSpan={totalColumns || 1}
549
+ py="xl"
550
+ style={{ textAlign: "center" }}
551
+ >
552
+ <Text c="dimmed" size="sm">
553
+ {form.submitting ? "Loading…" : "No results"}
554
+ </Text>
555
+ </Table.Td>
556
+ </Table.Tr>
557
+ )}
558
+ </Table.Tbody>
497
559
  </Table>
498
560
  </Flex>
499
561
 
562
+ {props.infinityScroll && <div ref={sentinelRef} />}
563
+
500
564
  {!props.infinityScroll && (
501
565
  <DataTablePagination
502
566
  page={page}
@@ -510,6 +574,18 @@ const DataTable = <T extends object, Filters extends TObject>(
510
574
  }}
511
575
  />
512
576
  )}
577
+
578
+ {drawerConfig && (
579
+ <Drawer
580
+ opened={drawerItem !== null}
581
+ onClose={() => setDrawerItem(null)}
582
+ position="right"
583
+ size="xl"
584
+ {...drawerConfig.props}
585
+ >
586
+ {drawerItem && drawerConfig.render(drawerItem)}
587
+ </Drawer>
588
+ )}
513
589
  </Flex>
514
590
  );
515
591
  };
@@ -51,11 +51,19 @@ const DataTableFilters = ({
51
51
  style={{ borderBottom: "1px solid var(--alepha-border)" }}
52
52
  >
53
53
  <TypeForm
54
+ size={"xs"}
54
55
  {...typeFormProps}
55
56
  skipSubmitButton
56
57
  fill
57
58
  form={form}
58
59
  schema={visibleSchema}
60
+ columns={{
61
+ base: 1,
62
+ sm: 2,
63
+ md: 3,
64
+ lg: 4,
65
+ xl: 6,
66
+ }}
59
67
  />
60
68
  </Flex>
61
69
  );