@rebasepro/ui 0.6.1 → 0.8.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.
- package/dist/components/VirtualTable/fields/VirtualTableDateField.d.ts +13 -0
- package/dist/components/VirtualTable/fields/VirtualTableInput.d.ts +10 -0
- package/dist/components/VirtualTable/fields/VirtualTableNumberInput.d.ts +9 -0
- package/dist/components/VirtualTable/fields/VirtualTableSelect.d.ts +17 -0
- package/dist/components/VirtualTable/fields/VirtualTableSwitch.d.ts +8 -0
- package/dist/components/VirtualTable/index.d.ts +7 -0
- package/dist/components/VirtualTable/selection/SelectionContext.d.ts +11 -0
- package/dist/components/VirtualTable/selection/SelectionStore.d.ts +14 -0
- package/dist/icons/index.d.ts +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.es.js +2322 -23
- package/dist/index.es.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/views/CardView.d.ts +32 -0
- package/dist/views/CollectionView/CollectionCardView.d.ts +30 -0
- package/dist/views/CollectionView/CollectionKanbanView.d.ts +31 -0
- package/dist/views/CollectionView/CollectionListView.d.ts +30 -0
- package/dist/views/CollectionView/CollectionTableView.d.ts +37 -0
- package/dist/views/CollectionView/CollectionView.d.ts +121 -0
- package/dist/views/CollectionView/CollectionViewToolbar.d.ts +31 -0
- package/dist/views/CollectionView/CollectionViewTypes.d.ts +106 -0
- package/dist/views/CollectionView/DefaultCellRenderer.d.ts +9 -0
- package/dist/views/CollectionView/index.d.ts +24 -0
- package/dist/views/CollectionView/utils.d.ts +5 -0
- package/dist/views/Kanban/Board.d.ts +3 -0
- package/dist/views/Kanban/BoardColumn.d.ts +20 -0
- package/dist/views/Kanban/BoardColumnTitle.d.ts +8 -0
- package/dist/views/Kanban/BoardSortableList.d.ts +14 -0
- package/dist/views/Kanban/board_types.d.ts +61 -0
- package/dist/views/Kanban/index.d.ts +5 -0
- package/dist/views/KanbanView.d.ts +24 -0
- package/dist/views/ListView.d.ts +40 -0
- package/dist/views/TableView.d.ts +6 -0
- package/dist/views/index.d.ts +5 -0
- package/package.json +1 -1
- package/src/components/Sheet.tsx +8 -4
- package/src/components/VirtualTable/fields/VirtualTableDateField.tsx +32 -0
- package/src/components/VirtualTable/fields/VirtualTableInput.tsx +82 -0
- package/src/components/VirtualTable/fields/VirtualTableNumberInput.tsx +71 -0
- package/src/components/VirtualTable/fields/VirtualTableSelect.tsx +107 -0
- package/src/components/VirtualTable/fields/VirtualTableSwitch.tsx +28 -0
- package/src/components/VirtualTable/index.tsx +7 -0
- package/src/components/VirtualTable/selection/SelectionContext.tsx +36 -0
- package/src/components/VirtualTable/selection/SelectionStore.ts +54 -0
- package/src/icons/index.ts +3 -0
- package/src/index.ts +43 -3
- package/src/styles.ts +1 -1
- package/src/views/CardView.tsx +281 -0
- package/src/views/CollectionView/CollectionCardView.tsx +270 -0
- package/src/views/CollectionView/CollectionKanbanView.tsx +251 -0
- package/src/views/CollectionView/CollectionListView.tsx +245 -0
- package/src/views/CollectionView/CollectionTableView.tsx +229 -0
- package/src/views/CollectionView/CollectionView.tsx +388 -0
- package/src/views/CollectionView/CollectionViewToolbar.tsx +229 -0
- package/src/views/CollectionView/CollectionViewTypes.ts +137 -0
- package/src/views/CollectionView/DefaultCellRenderer.tsx +404 -0
- package/src/views/CollectionView/index.ts +44 -0
- package/src/views/CollectionView/utils.ts +14 -0
- package/src/views/Kanban/Board.tsx +421 -0
- package/src/views/Kanban/BoardColumn.tsx +135 -0
- package/src/views/Kanban/BoardColumnTitle.tsx +47 -0
- package/src/views/Kanban/BoardSortableList.tsx +170 -0
- package/src/views/Kanban/board_types.ts +70 -0
- package/src/views/Kanban/index.tsx +5 -0
- package/src/views/KanbanView.tsx +32 -0
- package/src/views/ListView.tsx +281 -0
- package/src/views/TableView.tsx +7 -0
- package/src/views/index.ts +5 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
CircularProgress,
|
|
4
|
+
Typography
|
|
5
|
+
} from "../components";
|
|
6
|
+
import { cls } from "../util";
|
|
7
|
+
|
|
8
|
+
import { CollectionSize } from "./ListView";
|
|
9
|
+
|
|
10
|
+
export type CardViewProps<T> = {
|
|
11
|
+
data: T[];
|
|
12
|
+
dataLoading?: boolean;
|
|
13
|
+
noMoreToLoad?: boolean;
|
|
14
|
+
dataLoadingError?: Error;
|
|
15
|
+
itemCount?: number;
|
|
16
|
+
setItemCount?: (itemCount: number) => void;
|
|
17
|
+
pageSize?: number;
|
|
18
|
+
paginationEnabled?: boolean;
|
|
19
|
+
|
|
20
|
+
onItemClick?: (item: T) => void;
|
|
21
|
+
selectedIds?: Set<string | number>;
|
|
22
|
+
highlightedIds?: Set<string | number>;
|
|
23
|
+
selectionEnabled?: boolean;
|
|
24
|
+
onSelectionChange?: (item: T, selected: boolean) => void;
|
|
25
|
+
|
|
26
|
+
onScroll?: (props: {
|
|
27
|
+
scrollDirection: "forward" | "backward";
|
|
28
|
+
scrollOffset: number;
|
|
29
|
+
scrollUpdateWasRequested: boolean;
|
|
30
|
+
}) => void;
|
|
31
|
+
initialScroll?: number;
|
|
32
|
+
|
|
33
|
+
size?: CollectionSize;
|
|
34
|
+
renderCard: (
|
|
35
|
+
item: T,
|
|
36
|
+
extra: {
|
|
37
|
+
selected: boolean;
|
|
38
|
+
highlighted: boolean;
|
|
39
|
+
onSelectionChange: (selected: boolean) => void;
|
|
40
|
+
onClick: (e: React.MouseEvent) => void;
|
|
41
|
+
}
|
|
42
|
+
) => React.ReactNode;
|
|
43
|
+
emptyComponent?: React.ReactNode;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function getGridColumnsClass(size: CollectionSize): string {
|
|
47
|
+
switch (size) {
|
|
48
|
+
case "xs":
|
|
49
|
+
return "grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10";
|
|
50
|
+
case "s":
|
|
51
|
+
return "grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8";
|
|
52
|
+
case "m":
|
|
53
|
+
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
|
54
|
+
case "l":
|
|
55
|
+
return "grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4";
|
|
56
|
+
case "xl":
|
|
57
|
+
return "grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3";
|
|
58
|
+
default:
|
|
59
|
+
return "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
|
|
64
|
+
if (!element) return null;
|
|
65
|
+
let parent = element.parentElement;
|
|
66
|
+
while (parent) {
|
|
67
|
+
const overflowY = window.getComputedStyle(parent).overflowY;
|
|
68
|
+
if (overflowY === "auto" || overflowY === "scroll") {
|
|
69
|
+
return parent;
|
|
70
|
+
}
|
|
71
|
+
parent = parent.parentElement;
|
|
72
|
+
}
|
|
73
|
+
return document.documentElement;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function CardView<T>({
|
|
77
|
+
data,
|
|
78
|
+
dataLoading = false,
|
|
79
|
+
noMoreToLoad = false,
|
|
80
|
+
dataLoadingError,
|
|
81
|
+
itemCount,
|
|
82
|
+
setItemCount,
|
|
83
|
+
pageSize = 50,
|
|
84
|
+
paginationEnabled = true,
|
|
85
|
+
|
|
86
|
+
onItemClick,
|
|
87
|
+
selectedIds,
|
|
88
|
+
highlightedIds,
|
|
89
|
+
selectionEnabled = true,
|
|
90
|
+
onSelectionChange,
|
|
91
|
+
|
|
92
|
+
onScroll,
|
|
93
|
+
initialScroll,
|
|
94
|
+
size = "m",
|
|
95
|
+
renderCard,
|
|
96
|
+
emptyComponent
|
|
97
|
+
}: CardViewProps<T>) {
|
|
98
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
99
|
+
const hasRestoredScroll = useRef(false);
|
|
100
|
+
const isLoadingMore = useRef(false);
|
|
101
|
+
|
|
102
|
+
// Sync mutable ref with pagination settings to avoid resetting listeners
|
|
103
|
+
const paginationStateRef = useRef({ paginationEnabled, noMoreToLoad, itemCount, pageSize });
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
paginationStateRef.current = { paginationEnabled, noMoreToLoad, itemCount, pageSize };
|
|
106
|
+
}, [paginationEnabled, noMoreToLoad, itemCount, pageSize]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!dataLoading) isLoadingMore.current = false;
|
|
110
|
+
}, [dataLoading]);
|
|
111
|
+
|
|
112
|
+
// Infinite scroll and resize observer
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const el = containerRef.current;
|
|
115
|
+
if (!el) return;
|
|
116
|
+
const scrollEl = getScrollParent(el);
|
|
117
|
+
if (!scrollEl) return;
|
|
118
|
+
|
|
119
|
+
let rafId: number | null = null;
|
|
120
|
+
|
|
121
|
+
const update = () => {
|
|
122
|
+
rafId = null;
|
|
123
|
+
const { paginationEnabled: pe, noMoreToLoad: nm, itemCount: ic, pageSize: ps } = paginationStateRef.current;
|
|
124
|
+
if (
|
|
125
|
+
pe &&
|
|
126
|
+
!nm &&
|
|
127
|
+
!isLoadingMore.current &&
|
|
128
|
+
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 400
|
|
129
|
+
) {
|
|
130
|
+
isLoadingMore.current = true;
|
|
131
|
+
setItemCount?.((ic ?? ps) + ps);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onScrollEvent = () => {
|
|
136
|
+
if (rafId === null) rafId = requestAnimationFrame(update);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
scrollEl.addEventListener("scroll", onScrollEvent, { passive: true });
|
|
140
|
+
const ro = new ResizeObserver(() => update());
|
|
141
|
+
ro.observe(scrollEl);
|
|
142
|
+
update();
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
scrollEl.removeEventListener("scroll", onScrollEvent);
|
|
146
|
+
ro.disconnect();
|
|
147
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
148
|
+
};
|
|
149
|
+
}, [setItemCount]);
|
|
150
|
+
|
|
151
|
+
// Scroll restoration
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!containerRef.current || !initialScroll || hasRestoredScroll.current || data.length === 0) return;
|
|
154
|
+
|
|
155
|
+
const scrollEl = getScrollParent(containerRef.current);
|
|
156
|
+
if (!scrollEl) return;
|
|
157
|
+
|
|
158
|
+
let attempts = 0;
|
|
159
|
+
const maxAttempts = 5;
|
|
160
|
+
|
|
161
|
+
const tryRestore = () => {
|
|
162
|
+
if (scrollEl.scrollHeight >= initialScroll || attempts >= maxAttempts) {
|
|
163
|
+
scrollEl.scrollTop = initialScroll;
|
|
164
|
+
hasRestoredScroll.current = true;
|
|
165
|
+
} else {
|
|
166
|
+
attempts++;
|
|
167
|
+
requestAnimationFrame(tryRestore);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
requestAnimationFrame(tryRestore);
|
|
172
|
+
}, [initialScroll, data.length]);
|
|
173
|
+
|
|
174
|
+
// Scroll tracking: call onScroll callback
|
|
175
|
+
const lastScrollOffset = useRef(0);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const el = containerRef.current;
|
|
178
|
+
if (!el || !onScroll) return;
|
|
179
|
+
const scrollEl = getScrollParent(el);
|
|
180
|
+
if (!scrollEl) return;
|
|
181
|
+
|
|
182
|
+
const handleScroll = () => {
|
|
183
|
+
const currentOffset = scrollEl.scrollTop;
|
|
184
|
+
const direction = currentOffset > lastScrollOffset.current ? "forward" : "backward";
|
|
185
|
+
lastScrollOffset.current = currentOffset;
|
|
186
|
+
onScroll({
|
|
187
|
+
scrollDirection: direction,
|
|
188
|
+
scrollOffset: currentOffset,
|
|
189
|
+
scrollUpdateWasRequested: false
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
|
194
|
+
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
|
195
|
+
}, [onScroll]);
|
|
196
|
+
|
|
197
|
+
const getItemId = useCallback((item: T): string | number => {
|
|
198
|
+
if (item && typeof item === "object" && "id" in item) {
|
|
199
|
+
const id = (item as Record<"id", unknown>).id;
|
|
200
|
+
return typeof id === "number" ? id : String(id);
|
|
201
|
+
}
|
|
202
|
+
return String(item);
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
const gridColumnsClass = getGridColumnsClass(size);
|
|
206
|
+
|
|
207
|
+
const isInitialLoading = dataLoading && data.length === 0 && !dataLoadingError;
|
|
208
|
+
const isEmpty = !dataLoading && data.length === 0 && !dataLoadingError;
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div
|
|
212
|
+
ref={containerRef}
|
|
213
|
+
className="w-full p-4"
|
|
214
|
+
>
|
|
215
|
+
{dataLoadingError && data.length === 0 ? (
|
|
216
|
+
<div className="h-full flex items-center justify-center p-8">
|
|
217
|
+
<Typography className="text-red-500">
|
|
218
|
+
Error loading data: {dataLoadingError.message}
|
|
219
|
+
</Typography>
|
|
220
|
+
</div>
|
|
221
|
+
) : isInitialLoading ? (
|
|
222
|
+
<div className="flex items-center justify-center py-12 px-8">
|
|
223
|
+
<CircularProgress size="small"/>
|
|
224
|
+
</div>
|
|
225
|
+
) : isEmpty ? (
|
|
226
|
+
<div className="w-full flex items-center justify-center py-12 px-8">
|
|
227
|
+
{emptyComponent ?? (
|
|
228
|
+
<Typography variant="label" color="secondary">
|
|
229
|
+
No entries found
|
|
230
|
+
</Typography>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
) : (
|
|
234
|
+
<div className="max-w-7xl mx-auto">
|
|
235
|
+
<div className={cls("grid gap-4", gridColumnsClass)}>
|
|
236
|
+
{data.map((item, index) => {
|
|
237
|
+
const id = getItemId(item);
|
|
238
|
+
const selected = selectedIds?.has(id) ?? false;
|
|
239
|
+
const highlighted = highlightedIds?.has(id) ?? false;
|
|
240
|
+
|
|
241
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
242
|
+
if ((e.metaKey || e.ctrlKey) && selectionEnabled) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
onSelectionChange?.(item, !selected);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
onItemClick?.(item);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const handleSelectionChange = (val: boolean) => {
|
|
251
|
+
onSelectionChange?.(item, val);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<React.Fragment key={id || index}>
|
|
256
|
+
{renderCard(item, {
|
|
257
|
+
selected,
|
|
258
|
+
highlighted,
|
|
259
|
+
onSelectionChange: handleSelectionChange,
|
|
260
|
+
onClick: handleClick
|
|
261
|
+
})}
|
|
262
|
+
</React.Fragment>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div className="flex items-center justify-center py-8">
|
|
268
|
+
{dataLoading && (
|
|
269
|
+
<CircularProgress size="small"/>
|
|
270
|
+
)}
|
|
271
|
+
{!dataLoading && noMoreToLoad && data.length > 0 && (
|
|
272
|
+
<Typography variant="caption" color="secondary">
|
|
273
|
+
All {data.length} entries loaded
|
|
274
|
+
</Typography>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useCallback, useMemo } from "react";
|
|
3
|
+
import { CardView } from "../CardView";
|
|
4
|
+
import { Card } from "../../components/Card";
|
|
5
|
+
import { Typography } from "../../components/Typography";
|
|
6
|
+
import { Checkbox } from "../../components/Checkbox";
|
|
7
|
+
import { DefaultCellRenderer } from "./DefaultCellRenderer";
|
|
8
|
+
import { cls } from "../../util";
|
|
9
|
+
import { cardSelectedMixin, fieldBackgroundMixin } from "../../styles";
|
|
10
|
+
import type {
|
|
11
|
+
CollectionPropertyConfig,
|
|
12
|
+
CollectionDataController,
|
|
13
|
+
CellRendererOverride,
|
|
14
|
+
CollectionViewSize,
|
|
15
|
+
CollectionSelectionController,
|
|
16
|
+
} from "./CollectionViewTypes";
|
|
17
|
+
|
|
18
|
+
import { getValueInPath } from "./utils";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Props for the {@link CollectionCardView} component.
|
|
22
|
+
* @group Collection View
|
|
23
|
+
*/
|
|
24
|
+
export type CollectionCardViewProps<T = Record<string, unknown>> = {
|
|
25
|
+
dataController: CollectionDataController<T>;
|
|
26
|
+
properties: Record<string, CollectionPropertyConfig>;
|
|
27
|
+
propertiesOrder?: string[];
|
|
28
|
+
idProperty?: string;
|
|
29
|
+
titleProperty?: string;
|
|
30
|
+
onRowClick?: (row: T) => void;
|
|
31
|
+
cellRenderer?: CellRendererOverride<T>;
|
|
32
|
+
size?: CollectionViewSize;
|
|
33
|
+
selectionEnabled?: boolean;
|
|
34
|
+
selectionController?: CollectionSelectionController<T>;
|
|
35
|
+
highlightedItems?: T[];
|
|
36
|
+
emptyComponent?: React.ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Headless card grid view that wraps {@link CardView}.
|
|
42
|
+
*
|
|
43
|
+
* Renders each item as a card with an optional image, title,
|
|
44
|
+
* preview properties, and selection support.
|
|
45
|
+
*
|
|
46
|
+
* @group Collection View
|
|
47
|
+
*/
|
|
48
|
+
export function CollectionCardView<T extends Record<string, unknown> = Record<string, unknown>>({
|
|
49
|
+
dataController,
|
|
50
|
+
properties,
|
|
51
|
+
propertiesOrder,
|
|
52
|
+
idProperty = "id",
|
|
53
|
+
titleProperty,
|
|
54
|
+
onRowClick,
|
|
55
|
+
cellRenderer,
|
|
56
|
+
size = "m",
|
|
57
|
+
selectionEnabled,
|
|
58
|
+
selectionController,
|
|
59
|
+
highlightedItems,
|
|
60
|
+
emptyComponent,
|
|
61
|
+
className,
|
|
62
|
+
}: CollectionCardViewProps<T>) {
|
|
63
|
+
const order = useMemo(
|
|
64
|
+
() => propertiesOrder ?? Object.keys(properties),
|
|
65
|
+
[propertiesOrder, properties]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// ── Resolve title property ───────────────────────────────────────
|
|
69
|
+
const titlePropertyKey = useMemo(() => {
|
|
70
|
+
if (titleProperty) return titleProperty;
|
|
71
|
+
for (const key of order) {
|
|
72
|
+
const prop = properties[key];
|
|
73
|
+
if (prop && prop.type === "string" && !prop.hideFromCollection && !prop.storage) {
|
|
74
|
+
return key;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return idProperty;
|
|
78
|
+
}, [titleProperty, order, properties, idProperty]);
|
|
79
|
+
|
|
80
|
+
// ── Resolve image property ───────────────────────────────────────
|
|
81
|
+
const imageProperty = useMemo(() => {
|
|
82
|
+
for (const key of order) {
|
|
83
|
+
const prop = properties[key];
|
|
84
|
+
if (prop && prop.type === "string" && prop.storage) {
|
|
85
|
+
return key;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}, [order, properties]);
|
|
90
|
+
|
|
91
|
+
// ── Preview properties (up to 3) ─────────────────────────────────
|
|
92
|
+
const previewProperties = useMemo(() => {
|
|
93
|
+
const result: { key: string; property: CollectionPropertyConfig }[] = [];
|
|
94
|
+
for (const key of order) {
|
|
95
|
+
if (key === titlePropertyKey || key === imageProperty) continue;
|
|
96
|
+
const prop = properties[key];
|
|
97
|
+
if (prop && !prop.hideFromCollection) {
|
|
98
|
+
result.push({ key, property: prop });
|
|
99
|
+
if (result.length >= 3) break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}, [order, properties, titlePropertyKey, imageProperty]);
|
|
104
|
+
|
|
105
|
+
// ── Selection IDs ────────────────────────────────────────────────
|
|
106
|
+
const selectedIds = useMemo(() => {
|
|
107
|
+
if (!selectionController?.selectedItems) return undefined;
|
|
108
|
+
const ids = new Set<string | number>();
|
|
109
|
+
for (const item of selectionController.selectedItems) {
|
|
110
|
+
const id = getValueInPath(item as Record<string, unknown>, idProperty);
|
|
111
|
+
if (id !== undefined && id !== null) {
|
|
112
|
+
ids.add(id as string | number);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return ids;
|
|
116
|
+
}, [selectionController?.selectedItems, idProperty]);
|
|
117
|
+
|
|
118
|
+
// ── Highlighted IDs ──────────────────────────────────────────────
|
|
119
|
+
const highlightedIds = useMemo(() => {
|
|
120
|
+
if (!highlightedItems) return undefined;
|
|
121
|
+
const ids = new Set<string | number>();
|
|
122
|
+
for (const item of highlightedItems) {
|
|
123
|
+
const id = getValueInPath(item as Record<string, unknown>, idProperty);
|
|
124
|
+
if (id !== undefined && id !== null) {
|
|
125
|
+
ids.add(id as string | number);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
}, [highlightedItems, idProperty]);
|
|
130
|
+
|
|
131
|
+
// ── Selection handler ────────────────────────────────────────────
|
|
132
|
+
const handleSelectionChange = useCallback(
|
|
133
|
+
(item: T, selected: boolean) => {
|
|
134
|
+
if (!selectionController) return;
|
|
135
|
+
selectionController.setSelectedItems((prev) => {
|
|
136
|
+
if (selected) {
|
|
137
|
+
return [...prev, item];
|
|
138
|
+
}
|
|
139
|
+
const itemId = getValueInPath(item as Record<string, unknown>, idProperty);
|
|
140
|
+
return prev.filter((existing) => {
|
|
141
|
+
const existingId = getValueInPath(existing as Record<string, unknown>, idProperty);
|
|
142
|
+
return existingId !== itemId;
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
[selectionController, idProperty]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// ── Render card ──────────────────────────────────────────────────
|
|
150
|
+
const renderCard = useCallback(
|
|
151
|
+
(
|
|
152
|
+
item: T,
|
|
153
|
+
extra: {
|
|
154
|
+
selected: boolean;
|
|
155
|
+
highlighted: boolean;
|
|
156
|
+
onSelectionChange: (selected: boolean) => void;
|
|
157
|
+
onClick: (e: React.MouseEvent) => void;
|
|
158
|
+
}
|
|
159
|
+
): React.ReactNode => {
|
|
160
|
+
const titleValue = getValueInPath(item as Record<string, unknown>, titlePropertyKey);
|
|
161
|
+
const imageValue = imageProperty
|
|
162
|
+
? getValueInPath(item as Record<string, unknown>, imageProperty)
|
|
163
|
+
: undefined;
|
|
164
|
+
const hasImage = typeof imageValue === "string" && imageValue.length > 0;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Card
|
|
168
|
+
onClick={(e) => { if (e) extra.onClick(e); }}
|
|
169
|
+
className={cls(
|
|
170
|
+
"overflow-hidden relative",
|
|
171
|
+
extra.selected && cardSelectedMixin,
|
|
172
|
+
extra.highlighted && !extra.selected && "ring-1 ring-primary/50"
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
{/* Image */}
|
|
176
|
+
{imageProperty && (
|
|
177
|
+
hasImage ? (
|
|
178
|
+
<img
|
|
179
|
+
src={imageValue as string}
|
|
180
|
+
alt=""
|
|
181
|
+
className="w-full h-32 object-cover rounded-t-lg"
|
|
182
|
+
/>
|
|
183
|
+
) : (
|
|
184
|
+
<div className={cls("h-24 rounded-t-lg", fieldBackgroundMixin)} />
|
|
185
|
+
)
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{/* Selection checkbox */}
|
|
189
|
+
{selectionEnabled && (
|
|
190
|
+
<div
|
|
191
|
+
className="absolute top-2 right-2"
|
|
192
|
+
onClick={(e) => e.stopPropagation()}
|
|
193
|
+
>
|
|
194
|
+
<Checkbox
|
|
195
|
+
checked={extra.selected}
|
|
196
|
+
onCheckedChange={extra.onSelectionChange}
|
|
197
|
+
size="small"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Title */}
|
|
203
|
+
<Typography variant="subtitle2" noWrap className="px-3 pt-2 pb-1">
|
|
204
|
+
{String(titleValue ?? "")}
|
|
205
|
+
</Typography>
|
|
206
|
+
|
|
207
|
+
{/* Preview properties */}
|
|
208
|
+
{previewProperties.map(({ key, property }) => {
|
|
209
|
+
const value = getValueInPath(item as Record<string, unknown>, key);
|
|
210
|
+
|
|
211
|
+
if (cellRenderer) {
|
|
212
|
+
const custom = cellRenderer({
|
|
213
|
+
row: item,
|
|
214
|
+
propertyKey: key,
|
|
215
|
+
property,
|
|
216
|
+
value,
|
|
217
|
+
size: "small",
|
|
218
|
+
});
|
|
219
|
+
if (custom !== undefined) {
|
|
220
|
+
return (
|
|
221
|
+
<div key={key} className="px-3 py-0.5">
|
|
222
|
+
{custom}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div key={key} className="px-3 py-0.5">
|
|
230
|
+
<DefaultCellRenderer
|
|
231
|
+
row={item}
|
|
232
|
+
propertyKey={key}
|
|
233
|
+
property={property}
|
|
234
|
+
value={value}
|
|
235
|
+
size="small"
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
})}
|
|
240
|
+
|
|
241
|
+
<div className="pb-2" />
|
|
242
|
+
</Card>
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
[titlePropertyKey, imageProperty, previewProperties, cellRenderer, selectionEnabled]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className={cls("w-full h-full overflow-auto", className)}>
|
|
250
|
+
<CardView<T>
|
|
251
|
+
data={dataController.data}
|
|
252
|
+
dataLoading={dataController.loading}
|
|
253
|
+
noMoreToLoad={dataController.noMoreToLoad}
|
|
254
|
+
dataLoadingError={dataController.error}
|
|
255
|
+
itemCount={dataController.itemCount}
|
|
256
|
+
setItemCount={dataController.setItemCount}
|
|
257
|
+
pageSize={dataController.pageSize}
|
|
258
|
+
paginationEnabled={dataController.paginationEnabled}
|
|
259
|
+
onItemClick={onRowClick}
|
|
260
|
+
selectedIds={selectedIds}
|
|
261
|
+
highlightedIds={highlightedIds}
|
|
262
|
+
selectionEnabled={!!selectionEnabled}
|
|
263
|
+
onSelectionChange={handleSelectionChange}
|
|
264
|
+
size={size}
|
|
265
|
+
renderCard={renderCard}
|
|
266
|
+
emptyComponent={emptyComponent}
|
|
267
|
+
/>
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|