@olympusoss/canvas 2.20.2 → 4.0.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 (208) hide show
  1. package/package.json +41 -177
  2. package/src/cn.ts +3 -0
  3. package/src/index.ts +12 -603
  4. package/src/theme.ts +41 -0
  5. package/src/tokens.ts +11 -0
  6. package/styles/base.css +17 -0
  7. package/styles/canvas.css +69 -52
  8. package/styles/components/alert.css +66 -0
  9. package/styles/components/app-shell.css +46 -0
  10. package/styles/components/avatar.css +15 -0
  11. package/styles/components/badge.css +83 -0
  12. package/styles/components/breadcrumb.css +35 -0
  13. package/styles/components/button-group.css +23 -0
  14. package/styles/components/button.css +107 -0
  15. package/styles/components/calendar.css +73 -0
  16. package/styles/components/card.css +58 -0
  17. package/styles/components/checkbox.css +55 -0
  18. package/styles/components/code-block.css +18 -0
  19. package/styles/components/combobox.css +75 -0
  20. package/styles/components/command.css +94 -0
  21. package/styles/components/data-table.css +142 -0
  22. package/styles/components/dialog.css +72 -0
  23. package/styles/components/dropdown.css +54 -0
  24. package/styles/components/empty-state.css +17 -0
  25. package/styles/components/field.css +27 -0
  26. package/styles/components/filter-panel.css +58 -0
  27. package/styles/components/form.css +27 -0
  28. package/styles/components/icon.css +8 -0
  29. package/styles/components/input-group.css +45 -0
  30. package/styles/components/input.css +56 -0
  31. package/styles/components/kbd.css +15 -0
  32. package/styles/components/page-header.css +52 -0
  33. package/styles/components/pagination.css +48 -0
  34. package/styles/components/popover.css +14 -0
  35. package/styles/components/radio.css +28 -0
  36. package/styles/components/row-menu.css +69 -0
  37. package/styles/components/section-card.css +49 -0
  38. package/styles/components/select.css +57 -0
  39. package/styles/components/separator.css +32 -0
  40. package/styles/components/sheet.css +70 -0
  41. package/styles/components/sidebar.css +146 -0
  42. package/styles/components/skeleton.css +32 -0
  43. package/styles/components/spinner.css +26 -0
  44. package/styles/components/stat-card.css +71 -0
  45. package/styles/components/stepper.css +63 -0
  46. package/styles/components/switch.css +45 -0
  47. package/styles/components/tabs.css +40 -0
  48. package/styles/components/textarea.css +31 -0
  49. package/styles/components/toast.css +95 -0
  50. package/styles/components/tooltip.css +53 -0
  51. package/styles/components/topbar.css +24 -0
  52. package/styles/components/typography.css +105 -0
  53. package/styles/patterns/backdrops.css +35 -0
  54. package/styles/patterns/density.css +66 -0
  55. package/styles/patterns/focus.css +38 -0
  56. package/styles/patterns/glass.css +85 -0
  57. package/styles/patterns/high-contrast.css +70 -0
  58. package/styles/patterns/reduced-motion.css +12 -0
  59. package/styles/patterns/scrollbar.css +10 -0
  60. package/styles/reset.css +89 -0
  61. package/styles/tokens/colors.css +106 -0
  62. package/styles/tokens/motion.css +33 -0
  63. package/styles/tokens/radius.css +10 -0
  64. package/styles/tokens/shadows.css +35 -0
  65. package/styles/tokens/spacing.css +19 -0
  66. package/styles/tokens/typography.css +6 -0
  67. package/styles/tokens/z-index.css +12 -0
  68. package/tsconfig.json +20 -21
  69. package/README.md +0 -60
  70. package/src/components/atoms/README.md +0 -11
  71. package/src/components/atoms/aspect-ratio.tsx +0 -32
  72. package/src/components/atoms/avatar.tsx +0 -98
  73. package/src/components/atoms/badge.tsx +0 -44
  74. package/src/components/atoms/brand-mark.tsx +0 -74
  75. package/src/components/atoms/button.tsx +0 -105
  76. package/src/components/atoms/checkbox.tsx +0 -63
  77. package/src/components/atoms/flex-box.tsx +0 -105
  78. package/src/components/atoms/icon.tsx +0 -34
  79. package/src/components/atoms/input.tsx +0 -92
  80. package/src/components/atoms/label.tsx +0 -41
  81. package/src/components/atoms/logo.tsx +0 -89
  82. package/src/components/atoms/progress.tsx +0 -55
  83. package/src/components/atoms/radio-group.tsx +0 -122
  84. package/src/components/atoms/scroll-area.tsx +0 -106
  85. package/src/components/atoms/section.tsx +0 -48
  86. package/src/components/atoms/separator.tsx +0 -45
  87. package/src/components/atoms/skeleton.tsx +0 -17
  88. package/src/components/atoms/slider.tsx +0 -93
  89. package/src/components/atoms/spinner.tsx +0 -47
  90. package/src/components/atoms/switch.tsx +0 -60
  91. package/src/components/atoms/textarea.tsx +0 -78
  92. package/src/components/atoms/toggle.tsx +0 -80
  93. package/src/components/charts/activity-heatmap.tsx +0 -186
  94. package/src/components/charts/axes.tsx +0 -21
  95. package/src/components/charts/chart-container.tsx +0 -254
  96. package/src/components/charts/chart-legend.tsx +0 -67
  97. package/src/components/charts/chart-tooltip.tsx +0 -161
  98. package/src/components/charts/chart-types.tsx +0 -49
  99. package/src/components/charts/containers.tsx +0 -11
  100. package/src/components/charts/data.tsx +0 -16
  101. package/src/components/charts/details.tsx +0 -25
  102. package/src/components/charts/dot-pulse.tsx +0 -61
  103. package/src/components/charts/gauge.tsx +0 -106
  104. package/src/components/charts/grids.tsx +0 -8
  105. package/src/components/charts/index.ts +0 -62
  106. package/src/components/charts/labeled-bar-list.tsx +0 -85
  107. package/src/components/charts/metric-breakdown.tsx +0 -316
  108. package/src/components/charts/references.tsx +0 -8
  109. package/src/components/charts/service-health-list.tsx +0 -85
  110. package/src/components/charts/sparkline-area.tsx +0 -80
  111. package/src/components/charts/sparkline.tsx +0 -52
  112. package/src/components/charts/stacked-bar.tsx +0 -104
  113. package/src/components/charts/text.tsx +0 -10
  114. package/src/components/charts/world-heat-map-inner.tsx +0 -317
  115. package/src/components/charts/world-heat-map.tsx +0 -184
  116. package/src/components/molecules/README.md +0 -12
  117. package/src/components/molecules/action-bar.tsx +0 -73
  118. package/src/components/molecules/activity-item.tsx +0 -74
  119. package/src/components/molecules/alert.tsx +0 -86
  120. package/src/components/molecules/animated-background.tsx +0 -92
  121. package/src/components/molecules/auth-shell.tsx +0 -95
  122. package/src/components/molecules/brand-lockup.tsx +0 -48
  123. package/src/components/molecules/breadcrumb.tsx +0 -157
  124. package/src/components/molecules/button-group.tsx +0 -104
  125. package/src/components/molecules/calendar.tsx +0 -217
  126. package/src/components/molecules/card.tsx +0 -102
  127. package/src/components/molecules/client-brand.tsx +0 -95
  128. package/src/components/molecules/code-block.tsx +0 -86
  129. package/src/components/molecules/countdown-button.tsx +0 -92
  130. package/src/components/molecules/empty-state.tsx +0 -56
  131. package/src/components/molecules/error-state.tsx +0 -42
  132. package/src/components/molecules/field-display.tsx +0 -35
  133. package/src/components/molecules/input-otp.tsx +0 -74
  134. package/src/components/molecules/launcher-card.tsx +0 -152
  135. package/src/components/molecules/loading-state.tsx +0 -36
  136. package/src/components/molecules/notification-item.tsx +0 -67
  137. package/src/components/molecules/notification-list.tsx +0 -45
  138. package/src/components/molecules/number-badge.tsx +0 -53
  139. package/src/components/molecules/or-separator.tsx +0 -38
  140. package/src/components/molecules/page-header.tsx +0 -88
  141. package/src/components/molecules/page-tabs.tsx +0 -94
  142. package/src/components/molecules/pagination.tsx +0 -150
  143. package/src/components/molecules/password-input.tsx +0 -83
  144. package/src/components/molecules/password-strength-meter.tsx +0 -104
  145. package/src/components/molecules/phone-input.tsx +0 -200
  146. package/src/components/molecules/search-bar.tsx +0 -64
  147. package/src/components/molecules/secret-field.tsx +0 -158
  148. package/src/components/molecules/section-card.tsx +0 -91
  149. package/src/components/molecules/social-buttons.tsx +0 -165
  150. package/src/components/molecules/stat-card.tsx +0 -100
  151. package/src/components/molecules/status-badge.tsx +0 -42
  152. package/src/components/molecules/stepper.tsx +0 -96
  153. package/src/components/molecules/table.tsx +0 -157
  154. package/src/components/molecules/terminal.tsx +0 -74
  155. package/src/components/molecules/toggle-group.tsx +0 -145
  156. package/src/components/molecules/tooltip.tsx +0 -155
  157. package/src/components/molecules/user-avatar-chip.tsx +0 -71
  158. package/src/components/organisms/README.md +0 -14
  159. package/src/components/organisms/accordion.tsx +0 -154
  160. package/src/components/organisms/alert-dialog.tsx +0 -277
  161. package/src/components/organisms/carousel.tsx +0 -244
  162. package/src/components/organisms/collapsible.tsx +0 -69
  163. package/src/components/organisms/command.tsx +0 -144
  164. package/src/components/organisms/context-menu.tsx +0 -339
  165. package/src/components/organisms/dashboard-grid.tsx +0 -369
  166. package/src/components/organisms/data-table.tsx +0 -330
  167. package/src/components/organisms/dialog.tsx +0 -312
  168. package/src/components/organisms/drawer.tsx +0 -123
  169. package/src/components/organisms/dropdown-menu.tsx +0 -440
  170. package/src/components/organisms/editors/code-editor.tsx +0 -144
  171. package/src/components/organisms/editors/index.ts +0 -4
  172. package/src/components/organisms/editors/markdown-editor.tsx +0 -153
  173. package/src/components/organisms/editors/markdown-renderer.ts +0 -27
  174. package/src/components/organisms/editors/prose-canvas-classes.ts +0 -45
  175. package/src/components/organisms/editors/rich-text-editor.tsx +0 -126
  176. package/src/components/organisms/editors/toolbar/md-toolbar.tsx +0 -129
  177. package/src/components/organisms/editors/toolbar/rte-toolbar.tsx +0 -211
  178. package/src/components/organisms/editors/toolbar/toolbar-shell.tsx +0 -45
  179. package/src/components/organisms/editors/use-codemirror-theme.ts +0 -61
  180. package/src/components/organisms/error-boundary.tsx +0 -61
  181. package/src/components/organisms/form.tsx +0 -174
  182. package/src/components/organisms/hover-card.tsx +0 -115
  183. package/src/components/organisms/menubar.tsx +0 -498
  184. package/src/components/organisms/navbar.tsx +0 -104
  185. package/src/components/organisms/navigation-menu.tsx +0 -235
  186. package/src/components/organisms/popover.tsx +0 -149
  187. package/src/components/organisms/resizable.tsx +0 -58
  188. package/src/components/organisms/schema-form.tsx +0 -232
  189. package/src/components/organisms/select.tsx +0 -309
  190. package/src/components/organisms/sheet.tsx +0 -265
  191. package/src/components/organisms/sidebar.tsx +0 -1040
  192. package/src/components/organisms/sonner.tsx +0 -96
  193. package/src/components/organisms/tabs.tsx +0 -133
  194. package/src/components/organisms/theme-provider.tsx +0 -101
  195. package/src/hooks/use-mobile.tsx +0 -19
  196. package/src/lib/portal-container.tsx +0 -35
  197. package/src/lib/utils.ts +0 -6
  198. package/src/native.ts +0 -23
  199. package/src/tokens/colors.ts +0 -91
  200. package/src/tokens/index.ts +0 -3
  201. package/src/tokens/spacing.ts +0 -55
  202. package/src/tokens/typography.ts +0 -27
  203. package/styles/dashboard-grid.css +0 -47
  204. package/styles/fonts/Roboto-VariableFont_wdth_wght.ttf +0 -0
  205. package/styles/glass.css +0 -175
  206. package/styles/leaflet.css +0 -13
  207. package/styles/tokens.css +0 -317
  208. package/tailwind.config.ts +0 -70
@@ -1,369 +0,0 @@
1
- "use client";
2
-
3
- import { GripVertical, Trash2 } from "lucide-react";
4
- import * as React from "react";
5
- import type { Layout, Layouts } from "react-grid-layout";
6
- import GridLayout from "react-grid-layout";
7
-
8
- import { cn } from "../../lib/utils";
9
-
10
- // We bypass `WidthProvider` and feed `width` manually via our own
11
- // ResizeObserver. WidthProvider defaults its initial width to 1280px and
12
- // updates only on `window.resize`, which never fires for grids inside
13
- // iframes, modals, or any other container that resizes independently of the
14
- // window. The ResizeObserver below fires correctly in all those contexts.
15
- const ResponsiveGridLayout = GridLayout.Responsive;
16
-
17
- /* ---------- Types ---------- */
18
-
19
- export interface DashboardItem {
20
- /** Stable widget id. Matches react-grid-layout's `i` field. */
21
- i: string;
22
- /** Column position (0-indexed). */
23
- x: number;
24
- /** Row position (0-indexed). */
25
- y: number;
26
- /** Width in columns. */
27
- w: number;
28
- /** Height in row units (`rowHeight` pixels each). */
29
- h: number;
30
- /** When true, this item never moves and can't be resized. */
31
- static?: boolean;
32
- /** Minimum width in columns. */
33
- minW?: number;
34
- /** Minimum height in row units. */
35
- minH?: number;
36
- /** Maximum width in columns. */
37
- maxW?: number;
38
- /** Maximum height in row units. */
39
- maxH?: number;
40
- }
41
-
42
- export type DashboardGridBreakpoint = "lg" | "md" | "sm" | "xs" | "xxs";
43
-
44
- export interface DashboardGridProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
45
- /** Controlled item list. The component is purely controlled — pass changes to `onItemsChange`. */
46
- items: DashboardItem[];
47
- /** Fired with the next item snapshot whenever a drag or resize completes. */
48
- onItemsChange?: (next: DashboardItem[]) => void;
49
- /** Render the inner widget for an item. The wrapper is owned by the grid. */
50
- renderItem: (item: DashboardItem) => React.ReactNode;
51
- /** When true, drag handles + resize edges are active. Default `false`. */
52
- editing?: boolean;
53
- /** Pixel height of one row unit. Default `80`. */
54
- rowHeight?: number;
55
- /** Column count per breakpoint. Default `{ lg:12, md:8, sm:4, xs:2, xxs:1 }`. */
56
- cols?: Partial<Record<DashboardGridBreakpoint, number>>;
57
- /** Pixel breakpoint widths. Default react-grid-layout's standard set. */
58
- breakpoints?: Partial<Record<DashboardGridBreakpoint, number>>;
59
- /** Gutter between items in pixels (`[x, y]`). Default `[16, 16]` — kept even on both axes
60
- * so rows and columns share the same spacing. */
61
- margin?: [number, number];
62
- /** Rendered when `items.length === 0`. */
63
- emptyState?: React.ReactNode;
64
- }
65
-
66
- const DEFAULT_COLS: Record<DashboardGridBreakpoint, number> = {
67
- lg: 12,
68
- md: 8,
69
- sm: 4,
70
- xs: 2,
71
- xxs: 1,
72
- };
73
-
74
- const DEFAULT_BREAKPOINTS: Record<DashboardGridBreakpoint, number> = {
75
- lg: 1200,
76
- md: 996,
77
- sm: 768,
78
- xs: 480,
79
- xxs: 0,
80
- };
81
-
82
- const ITEM_KEYS: Array<keyof DashboardItem> = [
83
- "i",
84
- "x",
85
- "y",
86
- "w",
87
- "h",
88
- "static",
89
- "minW",
90
- "minH",
91
- "maxW",
92
- "maxH",
93
- ];
94
-
95
- /** @internal Exported for unit testing only. */
96
- export function toLibLayout(items: DashboardItem[]): Layout[] {
97
- return items.map((item) => {
98
- const out: Layout = { i: item.i, x: item.x, y: item.y, w: item.w, h: item.h };
99
- if (item.static !== undefined) out.static = item.static;
100
- if (item.minW !== undefined) out.minW = item.minW;
101
- if (item.minH !== undefined) out.minH = item.minH;
102
- if (item.maxW !== undefined) out.maxW = item.maxW;
103
- if (item.maxH !== undefined) out.maxH = item.maxH;
104
- return out;
105
- });
106
- }
107
-
108
- /** @internal Exported for unit testing only. Returns true when the merged
109
- * snapshot's positional fields match the prev input — used to skip spurious
110
- * onItemsChange calls react-grid-layout fires on initial mount. */
111
- export function isSameLayout(prev: DashboardItem[], next: DashboardItem[]): boolean {
112
- if (next.length !== prev.length) return false;
113
- return next.every((n, idx) => {
114
- const p = prev[idx];
115
- return n.i === p.i && n.x === p.x && n.y === p.y && n.w === p.w && n.h === p.h;
116
- });
117
- }
118
-
119
- /** @internal Exported for unit testing only. Scales a single item's `x` and `w`
120
- * proportionally from one breakpoint's column count to another. Preserves `y`,
121
- * `h`, and all constraint fields untouched.
122
- *
123
- * Rounding rules:
124
- * - `w`: `Math.round(w * ratio)`, clamped to `[1, toCols]`. Items never become
125
- * zero-width and never exceed the target column count.
126
- * - `x`: `Math.floor(x * ratio)`, clamped to `[0, toCols - 1]`. Items always
127
- * start at a valid column. RGL's vertical compactor handles any wraps that
128
- * result.
129
- */
130
- export function scaleItem(item: DashboardItem, fromCols: number, toCols: number): DashboardItem {
131
- if (fromCols === toCols) return item;
132
- const ratio = toCols / fromCols;
133
- return {
134
- ...item,
135
- x: Math.min(Math.max(0, toCols - 1), Math.max(0, Math.floor(item.x * ratio))),
136
- w: Math.max(1, Math.min(toCols, Math.round(item.w * ratio))),
137
- };
138
- }
139
-
140
- /** @internal Exported for unit testing only. */
141
- export function mergeLibLayout(prev: DashboardItem[], next: Layout[]): DashboardItem[] {
142
- const byKey = new Map(prev.map((p) => [p.i, p]));
143
- return next.map((n) => {
144
- const base = byKey.get(n.i) ?? {};
145
- const merged: DashboardItem = {
146
- ...base,
147
- i: n.i,
148
- x: n.x,
149
- y: n.y,
150
- w: n.w,
151
- h: n.h,
152
- };
153
- // Preserve constraints + static flag from the lib's snapshot.
154
- if (n.static !== undefined) merged.static = n.static;
155
- if (n.minW !== undefined) merged.minW = n.minW;
156
- if (n.minH !== undefined) merged.minH = n.minH;
157
- if (n.maxW !== undefined) merged.maxW = n.maxW;
158
- if (n.maxH !== undefined) merged.maxH = n.maxH;
159
- // Drop fields the lib zeroed out compared to the original.
160
- for (const key of ITEM_KEYS) {
161
- if (key in merged && merged[key] === undefined) {
162
- delete merged[key];
163
- }
164
- }
165
- return merged;
166
- });
167
- }
168
-
169
- /**
170
- * Drag-to-reorder, drag-to-resize widget grid backed by `react-grid-layout`.
171
- * Fully controlled: pass `items` + `onItemsChange`. Toggle `editing` to gate
172
- * drag/resize affordances behind a customize-mode UX. The 12-col responsive
173
- * grid auto-packs collisions and adapts column count to viewport via the
174
- * `breakpoints` map.
175
- *
176
- * `items` is consumed in **lg-coords** (the `cols.lg` column count, default 12).
177
- * Per-breakpoint layouts are derived automatically by scaling each item's `x`
178
- * and `w` proportionally to the target breakpoint's column count via
179
- * `scaleItem`. This is the documented `react-grid-layout` pattern — each
180
- * breakpoint needs its own layout array, not a single layout fanned out
181
- * (fanning produces the cascading staircase bug at smaller breakpoints because
182
- * RGL clamps `w` but preserves `x`).
183
- *
184
- * **Edit at lg.** Drags performed at smaller breakpoints update only that
185
- * breakpoint's layout (per RGL); the canonical lg layout doesn't reflect those
186
- * edits, so they revert when the viewport resizes back to lg. Persist your
187
- * customize-mode UX at the lg breakpoint.
188
- *
189
- * Consumers must import the lib's stylesheet once at app entry:
190
- *
191
- * import "@olympusoss/canvas/styles/dashboard-grid.css";
192
- *
193
- * That sheet pulls in `react-grid-layout/css/styles.css` and
194
- * `react-resizable/css/styles.css`, plus a small canvas-token override that
195
- * recolours the placeholder + resize handles.
196
- */
197
- export const DashboardGrid = React.forwardRef<HTMLDivElement, DashboardGridProps>(
198
- (
199
- {
200
- items,
201
- onItemsChange,
202
- renderItem,
203
- editing = false,
204
- rowHeight = 80,
205
- cols,
206
- breakpoints,
207
- margin = [16, 16],
208
- emptyState,
209
- className,
210
- ...props
211
- },
212
- ref,
213
- ) => {
214
- const resolvedCols = { ...DEFAULT_COLS, ...cols };
215
- const resolvedBreakpoints = { ...DEFAULT_BREAKPOINTS, ...breakpoints };
216
-
217
- // Track the wrapper's width via ResizeObserver and pass it explicitly to
218
- // react-grid-layout's Responsive component. See the `ResponsiveGridLayout`
219
- // comment above for why we don't use WidthProvider.
220
- //
221
- // `useLayoutEffect` measures synchronously after DOM mount but before
222
- // paint, so the first paint already has the correct width in real
223
- // browsers (no flicker). In SSR / jsdom (no real layout), clientWidth
224
- // is 0 — the fallback below kicks in.
225
- const wrapperRef = React.useRef<HTMLDivElement | null>(null);
226
- const [measuredWidth, setMeasuredWidth] = React.useState<number | undefined>(undefined);
227
- // SSR-safe: useLayoutEffect warns when called in node — fall back to useEffect.
228
- // jsdom always defines `window`, so the SSR branch is unreachable in tests.
229
- /* v8 ignore start */
230
- const useIsoLayoutEffect =
231
- typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
232
- /* v8 ignore stop */
233
- useIsoLayoutEffect(() => {
234
- const el = wrapperRef.current;
235
- /* v8 ignore next — `el` is always set after mount; defensive guard */
236
- if (!el) return;
237
- if (el.clientWidth > 0) setMeasuredWidth(el.clientWidth);
238
- }, []);
239
- React.useEffect(() => {
240
- const el = wrapperRef.current;
241
- if (!el || typeof ResizeObserver === "undefined") return;
242
- const obs = new ResizeObserver((entries) => {
243
- const w = entries[0]?.contentRect.width ?? el.clientWidth;
244
- if (w > 0) setMeasuredWidth(w);
245
- });
246
- obs.observe(el);
247
- return () => obs.disconnect();
248
- }, []);
249
-
250
- // Merge the forwarded ref with our internal wrapperRef so callers still
251
- // get their ref set, while we keep a handle for ResizeObserver.
252
- const setMergedRef = React.useCallback(
253
- (node: HTMLDivElement | null) => {
254
- wrapperRef.current = node;
255
- if (typeof ref === "function") ref(node);
256
- else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
257
- },
258
- [ref],
259
- );
260
-
261
- // Generate one layout PER breakpoint by scaling each item's `x` and `w`
262
- // proportionally to that breakpoint's column count. Treats `lg` as the
263
- // canonical source — `items` is consumed as `lg`-coords. Anything else is
264
- // derived. This is the documented react-grid-layout pattern (their own
265
- // demos hand-roll per-breakpoint layouts) — fanning a single layout into
266
- // every breakpoint causes RGL to clamp `w` to `cols`, leaving wide items
267
- // stacked diagonally with their original `x` offsets ("staircase" bug).
268
- const lgCols = resolvedCols.lg;
269
- const libLayouts = React.useMemo<Layouts>(
270
- () => ({
271
- lg: toLibLayout(items),
272
- md: toLibLayout(items.map((it) => scaleItem(it, lgCols, resolvedCols.md))),
273
- sm: toLibLayout(items.map((it) => scaleItem(it, lgCols, resolvedCols.sm))),
274
- xs: toLibLayout(items.map((it) => scaleItem(it, lgCols, resolvedCols.xs))),
275
- xxs: toLibLayout(items.map((it) => scaleItem(it, lgCols, resolvedCols.xxs))),
276
- }),
277
- [items, lgCols, resolvedCols.md, resolvedCols.sm, resolvedCols.xs, resolvedCols.xxs],
278
- );
279
-
280
- const handleLayoutChange = React.useCallback(
281
- (currentLayout: Layout[], allLayouts: Layouts) => {
282
- if (!onItemsChange) return;
283
- // Merge from `allLayouts.lg` so we always feed lg-coord items back into
284
- // state — even when the user is interacting at a smaller breakpoint, the
285
- // canonical `items` shape stays in lg-coords. (Caveat: drags performed
286
- // at sm/xs/xxs only edit that breakpoint's layout; the lg layout doesn't
287
- // reflect those edits — drag at lg for the persisted change.)
288
- const lgLayout = allLayouts?.lg ?? currentLayout;
289
- const next = mergeLibLayout(items, lgLayout);
290
- if (isSameLayout(items, next)) return;
291
- onItemsChange(next);
292
- },
293
- [items, onItemsChange],
294
- );
295
-
296
- if (items.length === 0 && emptyState !== undefined) {
297
- return (
298
- <div
299
- ref={setMergedRef}
300
- className={cn("w-full", className)}
301
- data-dashboard-grid-editing={editing ? "true" : "false"}
302
- {...props}
303
- >
304
- {emptyState}
305
- </div>
306
- );
307
- }
308
-
309
- return (
310
- <div
311
- ref={setMergedRef}
312
- className={cn("w-full", className)}
313
- data-dashboard-grid-editing={editing ? "true" : "false"}
314
- {...props}
315
- >
316
- <ResponsiveGridLayout
317
- // Fallback width (1024) is used only when there's no real layout
318
- // to measure (SSR, jsdom, first paint in some edge cases). In
319
- // real browsers `useLayoutEffect` sets the actual width before
320
- // first paint.
321
- width={measuredWidth ?? 1024}
322
- layouts={libLayouts}
323
- cols={resolvedCols}
324
- breakpoints={resolvedBreakpoints}
325
- rowHeight={rowHeight}
326
- margin={margin}
327
- isDraggable={editing}
328
- isResizable={editing}
329
- draggableHandle=".dashboard-grid-handle"
330
- onLayoutChange={handleLayoutChange}
331
- compactType="vertical"
332
- preventCollision={false}
333
- >
334
- {items.map((item) => (
335
- <div key={item.i} className="group/dashboard-grid-item relative h-full overflow-hidden">
336
- {/* Inner wrapper forces the rendered widget to fill the cell —
337
- consumers shouldn't have to add `h-full` to every card just to
338
- make rows align. Kept separate from the drag handle so the
339
- `*:h-full *:w-full` rule doesn't blow up the absolute handle. */}
340
- <div className="h-full w-full *:h-full *:w-full">{renderItem(item)}</div>
341
- {editing && (
342
- <>
343
- <div
344
- role="button"
345
- tabIndex={0}
346
- className="dashboard-grid-handle absolute right-2 top-2 z-10 flex h-6 w-6 cursor-grab items-center justify-center rounded-md border border-border bg-background/80 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing"
347
- aria-label={`Drag ${item.i}`}
348
- >
349
- <GripVertical className="h-3.5 w-3.5" />
350
- </div>
351
- <button
352
- type="button"
353
- onClick={() => onItemsChange?.(items.filter((it) => it.i !== item.i))}
354
- className="absolute bottom-2 left-1/2 z-10 flex h-7 -translate-x-1/2 items-center gap-1.5 rounded-md border border-border bg-background/80 px-2.5 text-xs font-medium text-destructive shadow-sm backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
355
- aria-label={`Remove ${item.i}`}
356
- >
357
- <Trash2 className="h-3.5 w-3.5" />
358
- Remove
359
- </button>
360
- </>
361
- )}
362
- </div>
363
- ))}
364
- </ResponsiveGridLayout>
365
- </div>
366
- );
367
- },
368
- );
369
- DashboardGrid.displayName = "DashboardGrid";
@@ -1,330 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- type ColumnDef,
5
- type ColumnFiltersState,
6
- flexRender,
7
- getCoreRowModel,
8
- getFilteredRowModel,
9
- getPaginationRowModel,
10
- getSortedRowModel,
11
- type RowSelectionState,
12
- type SortingState,
13
- useReactTable,
14
- } from "@tanstack/react-table";
15
- import { Plus, RefreshCw, Search } from "lucide-react";
16
- import * as React from "react";
17
-
18
- import { cn } from "../../lib/utils";
19
- import { Button } from "../atoms/button";
20
- import { Checkbox } from "../atoms/checkbox";
21
- import { Input } from "../atoms/input";
22
- import { LoadingState } from "../molecules/loading-state";
23
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../molecules/table";
24
-
25
- /**
26
- * Column descriptor. Accepts both the legacy API
27
- * (`field`/`headerName`/`renderCell`/`minWidth`/`maxWidth`/`sortable`)
28
- * and the TanStack-style API (`accessorKey`/`id`/`header`/`cell`).
29
- * Internally columns are normalized to TanStack `ColumnDef`.
30
- */
31
- export interface DataTableColumn<TData = unknown> {
32
- field?: keyof TData | string;
33
- headerName?: string;
34
- width?: number;
35
- minWidth?: number;
36
- maxWidth?: number;
37
- flex?: number;
38
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
- renderCell?: (value: any, row: any) => React.ReactNode;
40
- sortable?: boolean;
41
- accessorKey?: keyof TData | string;
42
- id?: string;
43
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
- header?: React.ReactNode | ((ctx: any) => React.ReactNode);
45
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
- cell?: (ctx: { row: { original: TData }; getValue: () => any }) => React.ReactNode;
47
- }
48
-
49
- export interface DataTableProps<TData> {
50
- columns: DataTableColumn<TData>[];
51
- data: TData[];
52
- className?: string;
53
- emptyMessage?: string;
54
- pageSize?: number;
55
- /** Row key field. Defaults to "id". */
56
- keyField?: string;
57
- loading?: boolean;
58
- searchable?: boolean;
59
- searchKey?: string;
60
- searchValue?: string;
61
- onSearchChange?: (v: string) => void;
62
- searchPlaceholder?: string;
63
- onRowClick?: (row: TData) => void;
64
- onRefresh?: () => void;
65
- onAdd?: () => void;
66
- addButtonText?: string;
67
- selectable?: boolean;
68
- selectedKeys?: Set<string>;
69
- onSelectionChange?: (keys: Set<string>) => void;
70
- pagination?: boolean;
71
- pageSizeOptions?: number[];
72
- }
73
-
74
- function normalizeColumn<TData>(col: DataTableColumn<TData>): ColumnDef<TData, unknown> {
75
- const id = col.id ?? (col.field as string | undefined) ?? (col.accessorKey as string | undefined);
76
- const accessorKey = col.accessorKey ?? col.field;
77
- const header = col.headerName ?? col.header;
78
- const cell =
79
- col.cell ??
80
- (col.renderCell
81
- ? (ctx: { row: { original: TData }; getValue: () => unknown }) =>
82
- col.renderCell!(ctx.getValue(), ctx.row.original)
83
- : undefined);
84
-
85
- const def: Record<string, unknown> = {};
86
- /* c8 ignore next -- false branch: callers always supply id/accessorKey/field */
87
- if (id) def.id = String(id);
88
- /* c8 ignore next -- false branch: callers always supply accessorKey/field */
89
- if (accessorKey) def.accessorKey = String(accessorKey);
90
- /* c8 ignore next -- false branch: callers always supply header/headerName */
91
- if (header !== undefined) def.header = header;
92
- if (cell) def.cell = cell;
93
- if (col.sortable !== undefined) def.enableSorting = col.sortable;
94
- const widthHint = col.minWidth ?? col.width;
95
- if (widthHint !== undefined) {
96
- def.size = widthHint;
97
- def.minSize = widthHint;
98
- }
99
- if (col.maxWidth !== undefined) def.maxSize = col.maxWidth;
100
- return def as unknown as ColumnDef<TData, unknown>;
101
- }
102
-
103
- function DataTable<TData>({
104
- columns,
105
- data,
106
- className,
107
- emptyMessage = "No results.",
108
- pageSize = 10,
109
- keyField = "id",
110
- loading = false,
111
- searchable = false,
112
- searchKey,
113
- searchValue,
114
- onSearchChange,
115
- searchPlaceholder = "Search...",
116
- onRowClick,
117
- onRefresh,
118
- onAdd,
119
- addButtonText = "Add",
120
- selectable = false,
121
- selectedKeys,
122
- onSelectionChange,
123
- pagination = true,
124
- pageSizeOptions: _pageSizeOptions,
125
- }: DataTableProps<TData>) {
126
- const [sorting, setSorting] = React.useState<SortingState>([]);
127
- const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
128
- const [localSearch, setLocalSearch] = React.useState<string>(searchValue ?? "");
129
- const currentSearch = searchValue ?? localSearch;
130
-
131
- const normalizedColumns = React.useMemo(() => columns.map((c) => normalizeColumn(c)), [columns]);
132
-
133
- const tableColumns = React.useMemo<ColumnDef<TData, unknown>[]>(() => {
134
- if (!selectable) return normalizedColumns;
135
- const selectionCol: ColumnDef<TData, unknown> = {
136
- id: "__select__",
137
- enableSorting: false,
138
- header: ({ table }) => (
139
- <Checkbox
140
- checked={
141
- table.getIsAllPageRowsSelected() ||
142
- (table.getIsSomePageRowsSelected() && "indeterminate")
143
- }
144
- onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
145
- aria-label="Select all"
146
- />
147
- ),
148
- cell: ({ row }) => (
149
- <Checkbox
150
- checked={row.getIsSelected()}
151
- onCheckedChange={(v) => row.toggleSelected(!!v)}
152
- aria-label="Select row"
153
- onClick={(e) => e.stopPropagation()}
154
- />
155
- ),
156
- };
157
- return [selectionCol, ...normalizedColumns];
158
- }, [normalizedColumns, selectable]);
159
-
160
- const rowSelection = React.useMemo<RowSelectionState>(() => {
161
- if (!selectable || !selectedKeys) return {};
162
- const out: RowSelectionState = {};
163
- selectedKeys.forEach((k) => {
164
- out[k] = true;
165
- });
166
- return out;
167
- }, [selectable, selectedKeys]);
168
-
169
- React.useEffect(() => {
170
- if (searchKey && currentSearch !== undefined) {
171
- setColumnFilters([{ id: searchKey, value: currentSearch }]);
172
- }
173
- }, [searchKey, currentSearch]);
174
-
175
- const getRowId = React.useCallback(
176
- /* c8 ignore next -- fallback to "" only triggers if a row is missing keyField, which callers guarantee */
177
- (row: TData) => String((row as Record<string, unknown>)[keyField] ?? ""),
178
- [keyField],
179
- );
180
-
181
- const table = useReactTable<TData>({
182
- data,
183
- columns: tableColumns,
184
- getRowId,
185
- getCoreRowModel: getCoreRowModel(),
186
- ...(pagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
187
- getSortedRowModel: getSortedRowModel(),
188
- getFilteredRowModel: getFilteredRowModel(),
189
- onSortingChange: setSorting,
190
- onColumnFiltersChange: setColumnFilters,
191
- onRowSelectionChange: (updater) => {
192
- if (!onSelectionChange) return;
193
- /* c8 ignore next -- TanStack always calls this with a function updater; the direct-object path is never exercised */
194
- const newState = typeof updater === "function" ? updater(rowSelection) : updater;
195
- onSelectionChange(new Set(Object.keys(newState).filter((k) => newState[k])));
196
- },
197
- state: { sorting, columnFilters, rowSelection },
198
- ...(pagination ? { initialState: { pagination: { pageSize } } } : {}),
199
- enableRowSelection: !!selectable,
200
- });
201
-
202
- const hasToolbar = searchable || onRefresh || onAdd;
203
-
204
- return (
205
- <div className={cn("space-y-4", className)}>
206
- {hasToolbar && (
207
- <div className="flex items-center justify-between gap-4">
208
- {searchable ? (
209
- <div className="relative flex-1 max-w-sm">
210
- <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
211
- <Input
212
- placeholder={searchPlaceholder}
213
- value={currentSearch}
214
- onChange={(e) => {
215
- const v = e.target.value;
216
- setLocalSearch(v);
217
- onSearchChange?.(v);
218
- }}
219
- className="pl-8"
220
- />
221
- </div>
222
- ) : (
223
- <div />
224
- )}
225
- <div className="flex items-center gap-2">
226
- {onRefresh && (
227
- <Button variant="outline" size="sm" onClick={onRefresh}>
228
- <RefreshCw className="h-4 w-4" />
229
- </Button>
230
- )}
231
- {onAdd && (
232
- <Button size="sm" onClick={onAdd}>
233
- <Plus className="mr-1 h-4 w-4" /> {addButtonText}
234
- </Button>
235
- )}
236
- </div>
237
- </div>
238
- )}
239
-
240
- <div data-slot="data-table" className="rounded-md border border-border">
241
- <Table>
242
- <TableHeader>
243
- {table.getHeaderGroups().map((headerGroup) => (
244
- <TableRow key={headerGroup.id}>
245
- {headerGroup.headers.map((header) => {
246
- const colDef = header.column.columnDef as ColumnDef<TData, unknown> & {
247
- minSize?: number;
248
- maxSize?: number;
249
- };
250
- return (
251
- <TableHead
252
- key={header.id}
253
- style={{
254
- minWidth: colDef.minSize,
255
- maxWidth: colDef.maxSize,
256
- }}
257
- >
258
- {flexRender(header.column.columnDef.header, header.getContext())}
259
- </TableHead>
260
- );
261
- })}
262
- </TableRow>
263
- ))}
264
- </TableHeader>
265
- <TableBody>
266
- {loading ? (
267
- <TableRow>
268
- <TableCell colSpan={tableColumns.length} className="h-24 p-0">
269
- <LoadingState />
270
- </TableCell>
271
- </TableRow>
272
- ) : table.getRowModel().rows?.length ? (
273
- table.getRowModel().rows.map((row) => (
274
- <TableRow
275
- key={row.id}
276
- data-state={row.getIsSelected() ? "selected" : undefined}
277
- className={onRowClick ? "cursor-pointer" : undefined}
278
- onClick={() => onRowClick?.(row.original)}
279
- >
280
- {row.getVisibleCells().map((cell) => (
281
- <TableCell key={cell.id}>
282
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
283
- </TableCell>
284
- ))}
285
- </TableRow>
286
- ))
287
- ) : (
288
- <TableRow>
289
- <TableCell
290
- colSpan={tableColumns.length}
291
- className="h-24 text-center text-muted-foreground"
292
- >
293
- {emptyMessage}
294
- </TableCell>
295
- </TableRow>
296
- )}
297
- </TableBody>
298
- </Table>
299
- </div>
300
-
301
- {pagination && table.getPageCount() > 1 && (
302
- <div className="flex items-center justify-between">
303
- <p className="text-sm text-muted-foreground">
304
- Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
305
- </p>
306
- <div className="flex gap-2">
307
- <Button
308
- variant="outline"
309
- size="sm"
310
- onClick={() => table.previousPage()}
311
- disabled={!table.getCanPreviousPage()}
312
- >
313
- Previous
314
- </Button>
315
- <Button
316
- variant="outline"
317
- size="sm"
318
- onClick={() => table.nextPage()}
319
- disabled={!table.getCanNextPage()}
320
- >
321
- Next
322
- </Button>
323
- </div>
324
- </div>
325
- )}
326
- </div>
327
- );
328
- }
329
-
330
- export { DataTable };