@rebasepro/ui 0.6.1 → 0.7.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 (52) hide show
  1. package/dist/icons/index.d.ts +1 -1
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.es.js +2058 -16
  4. package/dist/index.es.js.map +1 -1
  5. package/dist/styles.d.ts +1 -1
  6. package/dist/views/CardView.d.ts +32 -0
  7. package/dist/views/CollectionView/CollectionCardView.d.ts +30 -0
  8. package/dist/views/CollectionView/CollectionKanbanView.d.ts +31 -0
  9. package/dist/views/CollectionView/CollectionListView.d.ts +30 -0
  10. package/dist/views/CollectionView/CollectionTableView.d.ts +37 -0
  11. package/dist/views/CollectionView/CollectionView.d.ts +121 -0
  12. package/dist/views/CollectionView/CollectionViewToolbar.d.ts +31 -0
  13. package/dist/views/CollectionView/CollectionViewTypes.d.ts +106 -0
  14. package/dist/views/CollectionView/DefaultCellRenderer.d.ts +9 -0
  15. package/dist/views/CollectionView/index.d.ts +24 -0
  16. package/dist/views/CollectionView/utils.d.ts +5 -0
  17. package/dist/views/Kanban/Board.d.ts +3 -0
  18. package/dist/views/Kanban/BoardColumn.d.ts +20 -0
  19. package/dist/views/Kanban/BoardColumnTitle.d.ts +8 -0
  20. package/dist/views/Kanban/BoardSortableList.d.ts +14 -0
  21. package/dist/views/Kanban/board_types.d.ts +61 -0
  22. package/dist/views/Kanban/index.d.ts +5 -0
  23. package/dist/views/KanbanView.d.ts +24 -0
  24. package/dist/views/ListView.d.ts +40 -0
  25. package/dist/views/TableView.d.ts +6 -0
  26. package/dist/views/index.d.ts +5 -0
  27. package/package.json +1 -1
  28. package/src/components/Sheet.tsx +8 -4
  29. package/src/icons/index.ts +3 -0
  30. package/src/index.ts +2 -0
  31. package/src/styles.ts +1 -1
  32. package/src/views/CardView.tsx +281 -0
  33. package/src/views/CollectionView/CollectionCardView.tsx +270 -0
  34. package/src/views/CollectionView/CollectionKanbanView.tsx +251 -0
  35. package/src/views/CollectionView/CollectionListView.tsx +245 -0
  36. package/src/views/CollectionView/CollectionTableView.tsx +229 -0
  37. package/src/views/CollectionView/CollectionView.tsx +388 -0
  38. package/src/views/CollectionView/CollectionViewToolbar.tsx +229 -0
  39. package/src/views/CollectionView/CollectionViewTypes.ts +137 -0
  40. package/src/views/CollectionView/DefaultCellRenderer.tsx +404 -0
  41. package/src/views/CollectionView/index.ts +44 -0
  42. package/src/views/CollectionView/utils.ts +14 -0
  43. package/src/views/Kanban/Board.tsx +421 -0
  44. package/src/views/Kanban/BoardColumn.tsx +135 -0
  45. package/src/views/Kanban/BoardColumnTitle.tsx +47 -0
  46. package/src/views/Kanban/BoardSortableList.tsx +170 -0
  47. package/src/views/Kanban/board_types.ts +70 -0
  48. package/src/views/Kanban/index.tsx +5 -0
  49. package/src/views/KanbanView.tsx +32 -0
  50. package/src/views/ListView.tsx +281 -0
  51. package/src/views/TableView.tsx +7 -0
  52. package/src/views/index.ts +5 -0
@@ -0,0 +1,6 @@
1
+ export { VirtualTable as TableView } from "../components/VirtualTable/VirtualTable";
2
+ export type { VirtualTableProps as TableViewProps } from "../components/VirtualTable/VirtualTableProps";
3
+ export type OnColumnResizeParams = {
4
+ width: number;
5
+ key: string;
6
+ };
@@ -0,0 +1,5 @@
1
+ export * from "./ListView";
2
+ export * from "./CardView";
3
+ export * from "./KanbanView";
4
+ export * from "./TableView";
5
+ export * from "./CollectionView";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/ui",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.7.0",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
3
3
  import { cls } from "../util";
4
4
  import { defaultBorderMixin } from "../styles";
5
5
  import * as DialogPrimitive from "@radix-ui/react-dialog";
6
- import { usePortalContainer } from "../hooks/PortalContainerContext";
6
+ import { usePortalContainer, PortalContainerProvider } from "../hooks/PortalContainerContext";
7
7
 
8
8
  interface SheetProps {
9
9
  children: React.ReactNode;
@@ -45,6 +45,7 @@ export const Sheet: React.FC<SheetProps> = ({
45
45
  ...props
46
46
  }) => {
47
47
  const [displayed, setDisplayed] = useState(false);
48
+ const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
48
49
 
49
50
  // Get the portal container from context
50
51
  const contextContainer = usePortalContainer();
@@ -90,7 +91,8 @@ export const Sheet: React.FC<SheetProps> = ({
90
91
  />}
91
92
  <DialogPrimitive.Content
92
93
  {...props}
93
- onFocusCapture={(event) => event.preventDefault()}
94
+ ref={setContentEl}
95
+ onOpenAutoFocus={(event) => event.preventDefault()}
94
96
  onPointerDownOutside={onPointerDownOutside}
95
97
  onInteractOutside={onInteractOutside}
96
98
  className={cls(
@@ -112,10 +114,12 @@ export const Sheet: React.FC<SheetProps> = ({
112
114
  )}
113
115
  style={style}
114
116
  >
115
- <DialogPrimitive.Title autoFocus tabIndex={0} className="sr-only outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus:ring-0">
117
+ <DialogPrimitive.Title className="sr-only outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus:ring-0">
116
118
  {title ?? "Sheet"}
117
119
  </DialogPrimitive.Title>
118
- {children}
120
+ <PortalContainerProvider container={contentEl}>
121
+ {children}
122
+ </PortalContainerProvider>
119
123
  </DialogPrimitive.Content>
120
124
  </DialogPrimitive.Portal>
121
125
  </DialogPrimitive.Root>
@@ -74,7 +74,9 @@ export {
74
74
  LanguagesIcon,
75
75
  LayoutGridIcon,
76
76
  LinkIcon,
77
+ Link2Icon,
77
78
  ListIcon,
79
+ LockIcon,
78
80
  ListOrderedIcon,
79
81
  LoaderIcon,
80
82
  LogOutIcon,
@@ -121,6 +123,7 @@ export {
121
123
  UploadIcon,
122
124
  UserCheckIcon,
123
125
  UserIcon,
126
+ UserPlus,
124
127
  VideoIcon,
125
128
  VoteIcon,
126
129
  Wand2Icon,
package/src/index.ts CHANGED
@@ -3,3 +3,5 @@ export * from "./styles";
3
3
  export * from "./util";
4
4
  export * from "./icons";
5
5
  export * from "./hooks";
6
+ export * from "./views";
7
+
package/src/styles.ts CHANGED
@@ -5,7 +5,7 @@ export const fieldBackgroundMixin = "bg-surface-accent-200/50 dark:bg-black/30";
5
5
  export const fieldBackgroundInvisibleMixin = "bg-surface-accent-200/0 dark:bg-black/0";
6
6
  export const fieldBackgroundDisabledMixin = "bg-surface-accent-200/50 dark:bg-black/20";
7
7
  export const fieldBackgroundHoverMixin = "hover:bg-surface-accent-200/70 hover:dark:bg-black/50";
8
- export const defaultBorderMixin = "border-surface-200 dark:border-surface-700 ";
8
+ export const defaultBorderMixin = "border-surface-200 dark:border-surface-700/60 ";
9
9
  export const paperMixin = "bg-white rounded-lg dark:bg-surface-900 border border-surface-200 dark:border-surface-700";
10
10
  export const cardMixin = "bg-white dark:bg-surface-900 rounded-lg border border-surface-200 dark:border-surface-700";
11
11
  export const cardClickableMixin = "hover:bg-primary/5 dark:hover:bg-primary/5 cursor-pointer transition-colors duration-150";
@@ -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
+ }