@r2digisolutions/components 0.1.5 → 0.1.7
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/README.md +6 -1
- package/dist/components/atoms/Checkbox/Checkbox.svelte +10 -3
- package/dist/components/atoms/Checkbox/Checkbox.svelte.d.ts +3 -0
- package/dist/components/atoms/Input/Input.svelte +17 -2
- package/dist/components/atoms/Input/Input.svelte.d.ts +6 -1
- package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte +21 -0
- package/dist/components/atoms/MarqueeRect/MarqueeRect.svelte.d.ts +8 -0
- package/dist/components/atoms/Textarea/Textarea.svelte +4 -1
- package/dist/components/atoms/Textarea/Textarea.svelte.d.ts +1 -0
- package/dist/components/atoms/Toggle/Toggle.svelte +1 -1
- package/dist/components/molecules/BulkActionBar/BulkActionBar.svelte +19 -5
- package/dist/components/molecules/CodeEditor/CodeEditor.svelte +13 -10
- package/dist/components/molecules/ColorPicker/ColorPicker.svelte +166 -96
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.d.ts +9 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.stories.js +8 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte +412 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanel.svelte.d.ts +14 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte +27 -0
- package/dist/components/molecules/ConditionalFormatPanel/ConditionalFormatPanelStory.svelte.d.ts +3 -0
- package/dist/components/molecules/ContextMenu/ContextMenu.svelte +73 -21
- package/dist/components/molecules/ContextMenu/ContextMenu.svelte.d.ts +8 -1
- package/dist/components/molecules/DateTimePicker/DateTimePicker.svelte.d.ts +1 -1
- package/dist/components/molecules/FormDateTimePicker/FormDateTimePicker.svelte.d.ts +1 -1
- package/dist/components/molecules/Gauge/Gauge.stories.d.ts +1 -1
- package/dist/components/molecules/InfiniteScroll/InfiniteScroll.stories.d.ts +1 -1
- package/dist/components/organisms/AssetPickerPage/AssetPickerPage.svelte.d.ts +1 -1
- package/dist/components/organisms/BacklogPage/BacklogPage.svelte.d.ts +1 -1
- package/dist/components/organisms/CalendarApp/CalendarApp.svelte.d.ts +1 -1
- package/dist/components/organisms/DataGrid/DataGrid.stories.d.ts +65 -0
- package/dist/components/organisms/DataGrid/DataGrid.stories.js +95 -0
- package/dist/components/organisms/DataGrid/DataGrid.svelte +2264 -0
- package/dist/components/organisms/DataGrid/DataGrid.svelte.d.ts +99 -0
- package/dist/components/organisms/DataGrid/DataGridStory.svelte +659 -0
- package/dist/components/organisms/DataGrid/DataGridStory.svelte.d.ts +11 -0
- package/dist/components/organisms/DataGrid/breakpoints.d.ts +10 -0
- package/dist/components/organisms/DataGrid/breakpoints.js +20 -0
- package/dist/components/organisms/DataGrid/cellEditors.d.ts +8 -0
- package/dist/components/organisms/DataGrid/cellEditors.js +59 -0
- package/dist/components/organisms/DataGrid/conditionalFormat.d.ts +11 -0
- package/dist/components/organisms/DataGrid/conditionalFormat.js +98 -0
- package/dist/components/organisms/DataGrid/filterRows.d.ts +5 -0
- package/dist/components/organisms/DataGrid/filterRows.js +60 -0
- package/dist/components/organisms/DataGrid/marks.d.ts +9 -0
- package/dist/components/organisms/DataGrid/marks.js +91 -0
- package/dist/components/organisms/DataGrid/types.d.ts +126 -0
- package/dist/components/organisms/DataGrid/types.js +21 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.d.ts +33 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.stories.js +56 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte +830 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudio.svelte.d.ts +68 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte +600 -0
- package/dist/components/organisms/ResourceStudio/ResourceStudioStory.svelte.d.ts +7 -0
- package/dist/components/organisms/ResourceStudio/fieldsToColumns.d.ts +4 -0
- package/dist/components/organisms/ResourceStudio/fieldsToColumns.js +20 -0
- package/dist/components/organisms/ResourceStudio/fieldsToSchema.d.ts +6 -0
- package/dist/components/organisms/ResourceStudio/fieldsToSchema.js +53 -0
- package/dist/components/organisms/ResourceStudio/types.d.ts +69 -0
- package/dist/components/organisms/ResourceStudio/types.js +15 -0
- package/dist/components/organisms/Scheduler/Scheduler.svelte.d.ts +1 -1
- package/dist/components/organisms/SchemaForm/SchemaForm.svelte +10 -3
- package/dist/components/organisms/SchemaForm/SchemaForm.svelte.d.ts +1 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.d.ts +25 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.stories.js +18 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte +192 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplate.svelte.d.ts +62 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte +129 -0
- package/dist/components/templates/ResourceStudioTemplate/ResourceStudioTemplateStory.svelte.d.ts +7 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/utils/marqueeSelect.d.ts +48 -0
- package/dist/utils/marqueeSelect.js +189 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2264 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type {
|
|
3
|
+
DataGridColumn,
|
|
4
|
+
DataGridEditType,
|
|
5
|
+
DataGridEditConfig,
|
|
6
|
+
DataGridEditOption,
|
|
7
|
+
DataGridViewMode,
|
|
8
|
+
DataGridFilter,
|
|
9
|
+
Breakpoint,
|
|
10
|
+
CellRef,
|
|
11
|
+
GridSelection,
|
|
12
|
+
CellNote,
|
|
13
|
+
GridMark,
|
|
14
|
+
MarkColor,
|
|
15
|
+
MarkPreset,
|
|
16
|
+
ConditionalFormatRule,
|
|
17
|
+
ConditionalFormatStyle,
|
|
18
|
+
FormatCondition,
|
|
19
|
+
FormatTarget,
|
|
20
|
+
SortDir
|
|
21
|
+
} from './types.js';
|
|
22
|
+
export {
|
|
23
|
+
EMPTY_SELECTION,
|
|
24
|
+
noteKey,
|
|
25
|
+
markKey,
|
|
26
|
+
selectionCount,
|
|
27
|
+
selectionLabel
|
|
28
|
+
} from './types.js';
|
|
29
|
+
export { evaluateFormatRules, evaluateCondition, styleToCss } from './conditionalFormat.js';
|
|
30
|
+
export {
|
|
31
|
+
inferCellEditor,
|
|
32
|
+
resolveCellEditor,
|
|
33
|
+
coerceEditValue,
|
|
34
|
+
draftFromValue
|
|
35
|
+
} from './cellEditors.js';
|
|
36
|
+
export { MARK_COLORS, MARK_TINT, MARK_SWATCH, markTint, markSwatch, isMarkPreset } from './marks.js';
|
|
37
|
+
export {
|
|
38
|
+
BREAKPOINT_MIN,
|
|
39
|
+
matchesMinBreakpoint,
|
|
40
|
+
isColumnVisibleAtWidth
|
|
41
|
+
} from './breakpoints.js';
|
|
42
|
+
export { filterRows, cellText, uniqueColumnValues } from './filterRows.js';
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<script lang="ts" generics="T extends Record<string, unknown> = Record<string, unknown>">
|
|
46
|
+
import type { Snippet } from 'svelte';
|
|
47
|
+
import Skeleton from '../../atoms/Skeleton/Skeleton.svelte';
|
|
48
|
+
import Button from '../../atoms/Button/Button.svelte';
|
|
49
|
+
import Checkbox from '../../atoms/Checkbox/Checkbox.svelte';
|
|
50
|
+
import Input from '../../atoms/Input/Input.svelte';
|
|
51
|
+
import Textarea from '../../atoms/Textarea/Textarea.svelte';
|
|
52
|
+
import Toggle from '../../atoms/Toggle/Toggle.svelte';
|
|
53
|
+
import MarqueeRect from '../../atoms/MarqueeRect/MarqueeRect.svelte';
|
|
54
|
+
import Tooltip from '../../atoms/Tooltip/Tooltip.svelte';
|
|
55
|
+
import BulkActionBar from '../../molecules/BulkActionBar/BulkActionBar.svelte';
|
|
56
|
+
import type { BulkAction } from '../../molecules/BulkActionBar/BulkActionBar.svelte';
|
|
57
|
+
import type {
|
|
58
|
+
ContextMenuItem,
|
|
59
|
+
ContextMenuAnchor
|
|
60
|
+
} from '../../molecules/ContextMenu/ContextMenu.svelte';
|
|
61
|
+
import DropdownMenu from '../../molecules/DropdownMenu/DropdownMenu.svelte';
|
|
62
|
+
import type { DropdownItem } from '../../molecules/DropdownMenu/DropdownMenu.svelte';
|
|
63
|
+
import Select from '../../molecules/Select/Select.svelte';
|
|
64
|
+
import ColorPicker from '../../molecules/ColorPicker/ColorPicker.svelte';
|
|
65
|
+
import FilterBar from '../../molecules/FilterBar/FilterBar.svelte';
|
|
66
|
+
import type { FilterChip } from '../../molecules/FilterBar/FilterBar.svelte';
|
|
67
|
+
import Card from '../../molecules/Card/Card.svelte';
|
|
68
|
+
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
|
69
|
+
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
70
|
+
import Pencil from '@lucide/svelte/icons/pencil';
|
|
71
|
+
import LayoutList from '@lucide/svelte/icons/layout-list';
|
|
72
|
+
import Table2 from '@lucide/svelte/icons/table-2';
|
|
73
|
+
import LayoutGrid from '@lucide/svelte/icons/layout-grid';
|
|
74
|
+
import Rows3 from '@lucide/svelte/icons/rows-3';
|
|
75
|
+
import {
|
|
76
|
+
attachMarqueeSelect,
|
|
77
|
+
collectMarqueeItems,
|
|
78
|
+
defaultMarqueeIgnore,
|
|
79
|
+
resolveMarqueeSelection,
|
|
80
|
+
type MarqueeRect as MarqueeRectBox
|
|
81
|
+
} from '../../../utils/marqueeSelect.js';
|
|
82
|
+
import {
|
|
83
|
+
type DataGridColumn,
|
|
84
|
+
type DataGridEditConfig,
|
|
85
|
+
type DataGridViewMode,
|
|
86
|
+
type DataGridFilter,
|
|
87
|
+
type GridSelection,
|
|
88
|
+
type CellNote,
|
|
89
|
+
type GridMark,
|
|
90
|
+
type MarkColor,
|
|
91
|
+
type CellRef,
|
|
92
|
+
type ConditionalFormatRule,
|
|
93
|
+
type SortDir,
|
|
94
|
+
EMPTY_SELECTION,
|
|
95
|
+
noteKey,
|
|
96
|
+
markKey,
|
|
97
|
+
selectionCount,
|
|
98
|
+
selectionLabel
|
|
99
|
+
} from './types.js';
|
|
100
|
+
import { evaluateFormatRules, styleToCss } from './conditionalFormat.js';
|
|
101
|
+
import { coerceEditValue, draftFromValue, resolveCellEditor } from './cellEditors.js';
|
|
102
|
+
import { MARK_COLORS, markTint, markSwatch } from './marks.js';
|
|
103
|
+
import { isColumnVisibleAtWidth } from './breakpoints.js';
|
|
104
|
+
import { filterRows, cellText, uniqueColumnValues } from './filterRows.js';
|
|
105
|
+
|
|
106
|
+
function portalToBody(node: HTMLElement) {
|
|
107
|
+
document.body.appendChild(node);
|
|
108
|
+
return {
|
|
109
|
+
destroy() {
|
|
110
|
+
node.remove();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface DataGridProps {
|
|
116
|
+
columns?: DataGridColumn<T>[];
|
|
117
|
+
rows?: T[];
|
|
118
|
+
rowKey?: keyof T | ((row: T, index: number) => string);
|
|
119
|
+
selection?: GridSelection;
|
|
120
|
+
notes?: CellNote[];
|
|
121
|
+
marks?: GridMark[];
|
|
122
|
+
formatRules?: ConditionalFormatRule[];
|
|
123
|
+
/** Client-side search string (FilterBar). */
|
|
124
|
+
filterQuery?: string;
|
|
125
|
+
/** Active column filters (FilterBar chips). */
|
|
126
|
+
columnFilters?: DataGridFilter[];
|
|
127
|
+
/** Expanded row ids when collapsible. */
|
|
128
|
+
expandedRows?: string[];
|
|
129
|
+
viewMode?: DataGridViewMode;
|
|
130
|
+
sortable?: boolean;
|
|
131
|
+
selectable?: boolean;
|
|
132
|
+
selectColumns?: boolean;
|
|
133
|
+
selectCells?: boolean;
|
|
134
|
+
/** When false, row/cell click only fires onrowclick — selection via checkbox/marquee/context menu. */
|
|
135
|
+
selectOnClick?: boolean;
|
|
136
|
+
marqueeSelect?: boolean;
|
|
137
|
+
contextMenu?: boolean;
|
|
138
|
+
editable?: boolean;
|
|
139
|
+
/** Show expand control + detail panel (auto-hidden cols + custom snippet). */
|
|
140
|
+
collapsible?: boolean;
|
|
141
|
+
/** Show FilterBar above the grid. */
|
|
142
|
+
filterable?: boolean;
|
|
143
|
+
/** Show table/list/card/grid toggle. */
|
|
144
|
+
showViewModes?: boolean;
|
|
145
|
+
/** Column id used as title in list/card/grid modes. */
|
|
146
|
+
titleColumnId?: string;
|
|
147
|
+
striped?: boolean;
|
|
148
|
+
hoverable?: boolean;
|
|
149
|
+
compact?: boolean;
|
|
150
|
+
stickyHeader?: boolean;
|
|
151
|
+
loading?: boolean;
|
|
152
|
+
empty?: string;
|
|
153
|
+
showDock?: boolean;
|
|
154
|
+
actions?: BulkAction[];
|
|
155
|
+
contextActions?: BulkAction[];
|
|
156
|
+
rowActions?: BulkAction[];
|
|
157
|
+
showRowActions?: boolean;
|
|
158
|
+
class?: string;
|
|
159
|
+
cell?: Snippet<[T, DataGridColumn<T>]>;
|
|
160
|
+
/** Custom expanded-row content. Receives row + auto-hidden columns. */
|
|
161
|
+
expandedRow?: Snippet<[T, DataGridColumn<T>[]]>;
|
|
162
|
+
/** Custom card/list body; falls back to title + fields. */
|
|
163
|
+
itemCard?: Snippet<[T]>;
|
|
164
|
+
dockExtra?: Snippet;
|
|
165
|
+
leading?: Snippet;
|
|
166
|
+
onselectionchange?: (selection: GridSelection) => void;
|
|
167
|
+
onnoteschange?: (notes: CellNote[]) => void;
|
|
168
|
+
onmarkschange?: (marks: GridMark[]) => void;
|
|
169
|
+
onformatruleschange?: (rules: ConditionalFormatRule[]) => void;
|
|
170
|
+
onfilterchange?: (query: string, filters: DataGridFilter[]) => void;
|
|
171
|
+
onviewmodechange?: (mode: DataGridViewMode) => void;
|
|
172
|
+
onexpandedchange?: (ids: string[]) => void;
|
|
173
|
+
oncolumnresize?: (columnId: string, width: number) => void;
|
|
174
|
+
oncelledit?: (rowId: string, columnId: string, value: unknown, row: T) => void;
|
|
175
|
+
onrowpatch?: (rowId: string, patch: Record<string, unknown>, row: T) => void;
|
|
176
|
+
onaction?: (id: string, selection: GridSelection) => void;
|
|
177
|
+
onsort?: (columnId: string, direction: Exclude<SortDir, null>) => void;
|
|
178
|
+
onrowclick?: (row: T) => void;
|
|
179
|
+
onrowdblclick?: (row: T) => void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let {
|
|
183
|
+
columns = [] as DataGridColumn<T>[],
|
|
184
|
+
rows = [] as T[],
|
|
185
|
+
rowKey,
|
|
186
|
+
selection = $bindable<GridSelection>(EMPTY_SELECTION),
|
|
187
|
+
notes = $bindable<CellNote[]>([]),
|
|
188
|
+
marks = $bindable<GridMark[]>([]),
|
|
189
|
+
formatRules = $bindable<ConditionalFormatRule[]>([]),
|
|
190
|
+
filterQuery = $bindable(''),
|
|
191
|
+
columnFilters = $bindable<DataGridFilter[]>([]),
|
|
192
|
+
expandedRows = $bindable<string[]>([]),
|
|
193
|
+
viewMode = $bindable<DataGridViewMode>('table'),
|
|
194
|
+
sortable = true,
|
|
195
|
+
selectable = true,
|
|
196
|
+
selectColumns = true,
|
|
197
|
+
selectCells = true,
|
|
198
|
+
selectOnClick = true,
|
|
199
|
+
marqueeSelect = true,
|
|
200
|
+
contextMenu = true,
|
|
201
|
+
editable = false,
|
|
202
|
+
collapsible = false,
|
|
203
|
+
filterable = false,
|
|
204
|
+
showViewModes = false,
|
|
205
|
+
titleColumnId,
|
|
206
|
+
striped = false,
|
|
207
|
+
hoverable = true,
|
|
208
|
+
compact = false,
|
|
209
|
+
stickyHeader = true,
|
|
210
|
+
loading = false,
|
|
211
|
+
empty = 'No results',
|
|
212
|
+
showDock = true,
|
|
213
|
+
actions,
|
|
214
|
+
contextActions,
|
|
215
|
+
rowActions,
|
|
216
|
+
showRowActions,
|
|
217
|
+
class: className = '',
|
|
218
|
+
cell,
|
|
219
|
+
expandedRow,
|
|
220
|
+
itemCard,
|
|
221
|
+
dockExtra,
|
|
222
|
+
leading,
|
|
223
|
+
onselectionchange,
|
|
224
|
+
onnoteschange,
|
|
225
|
+
onmarkschange,
|
|
226
|
+
onformatruleschange,
|
|
227
|
+
onfilterchange,
|
|
228
|
+
onviewmodechange,
|
|
229
|
+
onexpandedchange,
|
|
230
|
+
oncolumnresize,
|
|
231
|
+
oncelledit,
|
|
232
|
+
onrowpatch,
|
|
233
|
+
onaction,
|
|
234
|
+
onsort,
|
|
235
|
+
onrowclick,
|
|
236
|
+
onrowdblclick
|
|
237
|
+
}: DataGridProps = $props();
|
|
238
|
+
|
|
239
|
+
let sortId = $state<string | null>(null);
|
|
240
|
+
let sortDir = $state<SortDir>(null);
|
|
241
|
+
let columnWidths = $state<Record<string, number>>({});
|
|
242
|
+
let resizing = $state<{ id: string; startX: number; startW: number } | null>(null);
|
|
243
|
+
let editing = $state<CellRef | null>(null);
|
|
244
|
+
let editValue = $state('');
|
|
245
|
+
let editBool = $state(false);
|
|
246
|
+
let activeEditor = $state<DataGridEditConfig | null>(null);
|
|
247
|
+
let noteTargets = $state<CellRef[] | null>(null);
|
|
248
|
+
let noteDraft = $state('');
|
|
249
|
+
let markPickerOpen = $state(false);
|
|
250
|
+
let markTargets = $state<GridMark[] | null>(null);
|
|
251
|
+
let markCustomColor = $state('#f59e0b');
|
|
252
|
+
let anchorCell = $state<CellRef | null>(null);
|
|
253
|
+
let lastClickedRow = $state<string | null>(null);
|
|
254
|
+
let gridScrollEl = $state<HTMLElement | null>(null);
|
|
255
|
+
let marqueeRect = $state<MarqueeRectBox | null>(null);
|
|
256
|
+
let contextTarget = $state<CellRef | null>(null);
|
|
257
|
+
let contextOpen = $state(false);
|
|
258
|
+
let contextAnchor = $state<ContextMenuAnchor | null>(null);
|
|
259
|
+
let viewportWidth = $state(
|
|
260
|
+
typeof window !== 'undefined' ? window.innerWidth : 1280
|
|
261
|
+
);
|
|
262
|
+
let filterColumnDraft = $state('');
|
|
263
|
+
let filterValueDraft = $state('');
|
|
264
|
+
|
|
265
|
+
$effect(() => {
|
|
266
|
+
const onResize = () => {
|
|
267
|
+
viewportWidth = window.innerWidth;
|
|
268
|
+
};
|
|
269
|
+
window.addEventListener('resize', onResize);
|
|
270
|
+
onResize();
|
|
271
|
+
return () => window.removeEventListener('resize', onResize);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const autoHiddenColumns = $derived(
|
|
275
|
+
columns.filter(
|
|
276
|
+
(c) =>
|
|
277
|
+
!c.hidden &&
|
|
278
|
+
c.hideBelow &&
|
|
279
|
+
!isColumnVisibleAtWidth(c.hideBelow, viewportWidth)
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
/** Expand controls only when there is content to reveal. */
|
|
284
|
+
const collapsibleEnabled = $derived(
|
|
285
|
+
autoHiddenColumns.length > 0 || (collapsible && Boolean(expandedRow))
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
$effect(() => {
|
|
289
|
+
if (collapsibleEnabled || expandedRows.length === 0) return;
|
|
290
|
+
expandedRows = [];
|
|
291
|
+
onexpandedchange?.([]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const visibleColumns = $derived(
|
|
295
|
+
columns.filter((c) => {
|
|
296
|
+
if (c.hidden) return false;
|
|
297
|
+
if (c.hideBelow && !isColumnVisibleAtWidth(c.hideBelow, viewportWidth)) return false;
|
|
298
|
+
return true;
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
const pinnedColumns = $derived(visibleColumns.filter((c) => c.pinned));
|
|
302
|
+
const scrollColumns = $derived(visibleColumns.filter((c) => !c.pinned));
|
|
303
|
+
const orderedColumns = $derived([...pinnedColumns, ...scrollColumns]);
|
|
304
|
+
|
|
305
|
+
const filterableColumns = $derived(columns.filter((c) => c.filterable !== false && !c.hidden));
|
|
306
|
+
const titleCol = $derived(
|
|
307
|
+
columns.find((c) => c.id === titleColumnId) ??
|
|
308
|
+
orderedColumns[0] ??
|
|
309
|
+
columns.find((c) => !c.hidden) ??
|
|
310
|
+
null
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const filterBarChips = $derived.by((): FilterChip[] =>
|
|
314
|
+
columnFilters.map((f) => ({
|
|
315
|
+
id: f.id,
|
|
316
|
+
label: f.label ?? `${columns.find((c) => c.id === f.columnId)?.header ?? f.columnId}: ${f.value}`
|
|
317
|
+
}))
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const notesMap = $derived.by(() => {
|
|
321
|
+
const map = new Map<string, CellNote>();
|
|
322
|
+
for (const n of notes) map.set(noteKey(n.rowId, n.columnId), n);
|
|
323
|
+
return map;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const marksMap = $derived.by(() => {
|
|
327
|
+
const map = new Map<string, GridMark>();
|
|
328
|
+
for (const m of marks) map.set(markKey(m.rowId, m.columnId), m);
|
|
329
|
+
return map;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const defaultActions = $derived.by((): BulkAction[] => {
|
|
333
|
+
if (actions) return actions;
|
|
334
|
+
const list: BulkAction[] = [
|
|
335
|
+
{ id: 'note', label: 'Note', variant: 'secondary' },
|
|
336
|
+
{ id: 'mark', label: 'Mark', variant: 'secondary' },
|
|
337
|
+
{ id: 'copy', label: 'Copy', variant: 'secondary' }
|
|
338
|
+
];
|
|
339
|
+
if (formatRules.length > 0) {
|
|
340
|
+
list.push({ id: 'format', label: 'Format', variant: 'secondary' });
|
|
341
|
+
}
|
|
342
|
+
return list;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const resolvedContextActions = $derived(contextActions ?? actions ?? []);
|
|
346
|
+
const resolvedRowActions = $derived(rowActions ?? contextActions ?? actions ?? []);
|
|
347
|
+
const rowActionsEnabled = $derived(showRowActions ?? selectable);
|
|
348
|
+
const contextMenuEnabled = $derived(contextMenu && (selectable || editable) && !loading);
|
|
349
|
+
const dockCount = $derived(selectionCount(selection));
|
|
350
|
+
const dockLabels = $derived(selectionLabel(selection));
|
|
351
|
+
const colCount = $derived(
|
|
352
|
+
orderedColumns.length +
|
|
353
|
+
(selectable ? 1 : 0) +
|
|
354
|
+
(rowActionsEnabled ? 1 : 0) +
|
|
355
|
+
(collapsibleEnabled && viewMode === 'table' ? 1 : 0)
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const isTableMode = $derived(viewMode === 'table');
|
|
359
|
+
const marqueeSelectActive = $derived(marqueeSelect && isTableMode);
|
|
360
|
+
|
|
361
|
+
function actionToContextItem(action: BulkAction): ContextMenuItem {
|
|
362
|
+
return {
|
|
363
|
+
id: action.id,
|
|
364
|
+
label: action.label,
|
|
365
|
+
disabled: action.disabled || action.loading,
|
|
366
|
+
destructive: action.variant === 'destructive'
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function actionToDropdownItem(action: BulkAction): DropdownItem {
|
|
371
|
+
return {
|
|
372
|
+
id: action.id,
|
|
373
|
+
label: action.label,
|
|
374
|
+
disabled: action.disabled || action.loading,
|
|
375
|
+
destructive: action.variant === 'destructive',
|
|
376
|
+
icon: action.icon
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const rowActionMenuItems = $derived(
|
|
381
|
+
resolvedRowActions.length > 0
|
|
382
|
+
? resolvedRowActions.map(actionToDropdownItem)
|
|
383
|
+
: [
|
|
384
|
+
{ id: 'copy', label: 'Copy' },
|
|
385
|
+
{ id: 'note', label: 'Add note' },
|
|
386
|
+
{ id: 'mark', label: 'Mark…' },
|
|
387
|
+
...(editable ? [{ id: 'edit', label: 'Edit cell' }] : [])
|
|
388
|
+
]
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
function getKey(row: T, index: number): string {
|
|
392
|
+
if (typeof rowKey === 'function') return rowKey(row, index);
|
|
393
|
+
if (rowKey && row[rowKey] != null) return String(row[rowKey]);
|
|
394
|
+
if ('id' in row && row.id != null) return String(row.id);
|
|
395
|
+
return String(index);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function getValue(row: T, column: DataGridColumn<T>): unknown {
|
|
399
|
+
const key = (column.accessor ?? column.id) as string;
|
|
400
|
+
return key.split('.').reduce<unknown>((acc, part) => {
|
|
401
|
+
if (acc && typeof acc === 'object' && part in (acc as object)) {
|
|
402
|
+
return (acc as Record<string, unknown>)[part];
|
|
403
|
+
}
|
|
404
|
+
return undefined;
|
|
405
|
+
}, row);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function formatCell(value: unknown): string {
|
|
409
|
+
if (value == null) return '';
|
|
410
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
411
|
+
return String(value);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function setSelection(next: GridSelection) {
|
|
415
|
+
selection = next;
|
|
416
|
+
onselectionchange?.(next);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function clearSelection() {
|
|
420
|
+
setSelection(EMPTY_SELECTION);
|
|
421
|
+
anchorCell = null;
|
|
422
|
+
lastClickedRow = null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const filteredRows = $derived(
|
|
426
|
+
filterable || filterQuery || columnFilters.length
|
|
427
|
+
? filterRows(
|
|
428
|
+
rows as Record<string, unknown>[],
|
|
429
|
+
columns as DataGridColumn[],
|
|
430
|
+
filterQuery,
|
|
431
|
+
columnFilters
|
|
432
|
+
)
|
|
433
|
+
: rows
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const sortedRows = $derived.by(() => {
|
|
437
|
+
const base = filteredRows as T[];
|
|
438
|
+
if (!sortId || !sortDir) return base;
|
|
439
|
+
const col = columns.find((c) => c.id === sortId);
|
|
440
|
+
if (!col) return base;
|
|
441
|
+
const dir = sortDir === 'asc' ? 1 : -1;
|
|
442
|
+
return [...base].sort((a, b) => {
|
|
443
|
+
const av = getValue(a, col);
|
|
444
|
+
const bv = getValue(b, col);
|
|
445
|
+
if (av == null && bv == null) return 0;
|
|
446
|
+
if (av == null) return 1;
|
|
447
|
+
if (bv == null) return -1;
|
|
448
|
+
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
|
449
|
+
return String(av).localeCompare(String(bv), undefined, { numeric: true }) * dir;
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const rowIds = $derived(sortedRows.map((r, i) => getKey(r, i)));
|
|
454
|
+
|
|
455
|
+
const contextMenuItems = $derived.by((): ContextMenuItem[] => {
|
|
456
|
+
const target = contextTarget;
|
|
457
|
+
const targetCol = target ? columns.find((c) => c.id === target.columnId) : undefined;
|
|
458
|
+
const canEditTarget = Boolean(target && targetCol && canEditColumn(targetCol));
|
|
459
|
+
const items: ContextMenuItem[] = [];
|
|
460
|
+
if (canEditTarget) {
|
|
461
|
+
items.push({ id: 'edit', label: 'Edit cell', shortcut: 'F2' });
|
|
462
|
+
}
|
|
463
|
+
items.push({ id: 'copy', label: 'Copy', shortcut: '⌘C' });
|
|
464
|
+
if (target) {
|
|
465
|
+
const hasNote = notesMap.has(noteKey(target.rowId, target.columnId));
|
|
466
|
+
items.push({ id: 'note', label: hasNote ? 'Edit note' : 'Add note' });
|
|
467
|
+
} else {
|
|
468
|
+
items.push({ id: 'note', label: 'Add note', disabled: true });
|
|
469
|
+
}
|
|
470
|
+
const markable = Boolean(target) || selection.type !== 'none';
|
|
471
|
+
items.push({ id: 'mark', label: 'Mark…', disabled: !markable });
|
|
472
|
+
items.push({
|
|
473
|
+
id: 'clear-mark',
|
|
474
|
+
label: 'Clear mark',
|
|
475
|
+
disabled: !markable || !targetsHaveMarks(markTargetsFromSelection(selectionOrContext()))
|
|
476
|
+
});
|
|
477
|
+
items.push({ id: 'sep-select', label: '', separator: true });
|
|
478
|
+
items.push({ id: 'select-row', label: 'Select row', disabled: !target || !selectable });
|
|
479
|
+
items.push({
|
|
480
|
+
id: 'select-all',
|
|
481
|
+
label: 'Select all',
|
|
482
|
+
disabled: !selectable || rowIds.length === 0
|
|
483
|
+
});
|
|
484
|
+
items.push({
|
|
485
|
+
id: 'clear',
|
|
486
|
+
label: 'Clear selection',
|
|
487
|
+
disabled: !selectable || dockCount === 0
|
|
488
|
+
});
|
|
489
|
+
if (resolvedContextActions.length > 0) {
|
|
490
|
+
const used = new Set(items.map((i) => i.id));
|
|
491
|
+
const extras = resolvedContextActions.filter((a) => !used.has(a.id));
|
|
492
|
+
if (extras.length > 0) {
|
|
493
|
+
items.push({ id: 'sep-actions', label: '', separator: true });
|
|
494
|
+
for (const action of extras) items.push(actionToContextItem(action));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return items;
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
function rowSelectionIds(sel: GridSelection): string[] {
|
|
501
|
+
return sel.type === 'rows' ? sel.ids : [];
|
|
502
|
+
}
|
|
503
|
+
function columnSelectionIds(sel: GridSelection): string[] {
|
|
504
|
+
return sel.type === 'columns' ? sel.ids : [];
|
|
505
|
+
}
|
|
506
|
+
function cellSelection(sel: GridSelection): CellRef[] {
|
|
507
|
+
return sel.type === 'cells' ? sel.cells : [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const allRowsSelected = $derived.by(() => {
|
|
511
|
+
const ids = rowSelectionIds(selection);
|
|
512
|
+
return rowIds.length > 0 && ids.length === rowIds.length && rowIds.every((id) => ids.includes(id));
|
|
513
|
+
});
|
|
514
|
+
const someRowsSelected = $derived(rowSelectionIds(selection).length > 0 && !allRowsSelected);
|
|
515
|
+
|
|
516
|
+
const rowFormats = $derived.by(() => {
|
|
517
|
+
const map = new Map<string, ReturnType<typeof evaluateFormatRules>>();
|
|
518
|
+
if (!formatRules.length) return map;
|
|
519
|
+
for (let i = 0; i < sortedRows.length; i++) {
|
|
520
|
+
const row = sortedRows[i];
|
|
521
|
+
map.set(getKey(row, i), evaluateFormatRules(row as Record<string, unknown>, formatRules));
|
|
522
|
+
}
|
|
523
|
+
return map;
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
function canSort(column: DataGridColumn<T>) {
|
|
527
|
+
if (column.sortable === false) return false;
|
|
528
|
+
return sortable || column.sortable === true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function toggleSort(column: DataGridColumn<T>) {
|
|
532
|
+
if (!canSort(column)) return;
|
|
533
|
+
if (sortId !== column.id) {
|
|
534
|
+
sortId = column.id;
|
|
535
|
+
sortDir = 'asc';
|
|
536
|
+
} else if (sortDir === 'asc') {
|
|
537
|
+
sortDir = 'desc';
|
|
538
|
+
} else {
|
|
539
|
+
sortId = null;
|
|
540
|
+
sortDir = null;
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (sortDir) onsort?.(column.id, sortDir);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function toggleAllRows() {
|
|
547
|
+
if (allRowsSelected) {
|
|
548
|
+
clearSelection();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
setSelection({ type: 'rows', ids: [...rowIds] });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function isRowExpanded(rowId: string) {
|
|
555
|
+
return expandedRows.includes(rowId);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function toggleExpand(rowId: string) {
|
|
559
|
+
const next = isRowExpanded(rowId)
|
|
560
|
+
? expandedRows.filter((id) => id !== rowId)
|
|
561
|
+
: [...expandedRows, rowId];
|
|
562
|
+
expandedRows = next;
|
|
563
|
+
onexpandedchange?.(next);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function setViewMode(mode: DataGridViewMode) {
|
|
567
|
+
viewMode = mode;
|
|
568
|
+
onviewmodechange?.(mode);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function syncFilterQuery(q: string) {
|
|
572
|
+
filterQuery = q;
|
|
573
|
+
onfilterchange?.(q, columnFilters);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function removeFilterChip(id: string) {
|
|
577
|
+
columnFilters = columnFilters.filter((f) => f.id !== id);
|
|
578
|
+
onfilterchange?.(filterQuery, columnFilters);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function clearAllFilters() {
|
|
582
|
+
columnFilters = [];
|
|
583
|
+
filterQuery = '';
|
|
584
|
+
onfilterchange?.('', []);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function addColumnFilter(columnId: string, value: string) {
|
|
588
|
+
const col = columns.find((c) => c.id === columnId);
|
|
589
|
+
if (!col || !value) return;
|
|
590
|
+
const id = `${columnId}::${value}`;
|
|
591
|
+
if (columnFilters.some((f) => f.id === id)) return;
|
|
592
|
+
columnFilters = [
|
|
593
|
+
...columnFilters,
|
|
594
|
+
{
|
|
595
|
+
id,
|
|
596
|
+
columnId,
|
|
597
|
+
value,
|
|
598
|
+
op: 'eq',
|
|
599
|
+
label: `${col.header}: ${value}`
|
|
600
|
+
}
|
|
601
|
+
];
|
|
602
|
+
onfilterchange?.(filterQuery, columnFilters);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function secondaryFields(row: T): Array<{ column: DataGridColumn<T>; text: string }> {
|
|
606
|
+
const skip = new Set([titleCol?.id, ...orderedColumns.slice(0, 2).map((c) => c.id)]);
|
|
607
|
+
const pool = [
|
|
608
|
+
...autoHiddenColumns,
|
|
609
|
+
...columns.filter((c) => !c.hidden && !orderedColumns.some((o) => o.id === c.id))
|
|
610
|
+
];
|
|
611
|
+
const seen = new Set<string>();
|
|
612
|
+
const out: Array<{ column: DataGridColumn<T>; text: string }> = [];
|
|
613
|
+
for (const column of [...orderedColumns.slice(1), ...pool]) {
|
|
614
|
+
if (skip.has(column.id) || seen.has(column.id)) continue;
|
|
615
|
+
seen.add(column.id);
|
|
616
|
+
out.push({
|
|
617
|
+
column,
|
|
618
|
+
text: cellText(row as Record<string, unknown>, column as DataGridColumn)
|
|
619
|
+
});
|
|
620
|
+
if (out.length >= 6) break;
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function toggleRow(rowId: string, event: MouseEvent) {
|
|
626
|
+
event.stopPropagation();
|
|
627
|
+
clearNativeTextSelection();
|
|
628
|
+
if (event.shiftKey && lastClickedRow) {
|
|
629
|
+
const a = rowIds.indexOf(lastClickedRow);
|
|
630
|
+
const b = rowIds.indexOf(rowId);
|
|
631
|
+
if (a >= 0 && b >= 0) {
|
|
632
|
+
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
633
|
+
const range = rowIds.slice(lo, hi + 1);
|
|
634
|
+
const existing = selection.type === 'rows' ? selection.ids : [];
|
|
635
|
+
setSelection({ type: 'rows', ids: [...new Set([...existing, ...range])] });
|
|
636
|
+
lastClickedRow = rowId;
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
lastClickedRow = rowId;
|
|
641
|
+
if (selection.type === 'rows') {
|
|
642
|
+
const has = selection.ids.includes(rowId);
|
|
643
|
+
const ids = has ? selection.ids.filter((id) => id !== rowId) : [...selection.ids, rowId];
|
|
644
|
+
setSelection(ids.length ? { type: 'rows', ids } : EMPTY_SELECTION);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
setSelection({ type: 'rows', ids: [rowId] });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function toggleColumn(columnId: string, event: MouseEvent) {
|
|
651
|
+
if (!selectColumns) return;
|
|
652
|
+
event.stopPropagation();
|
|
653
|
+
if (selection.type === 'columns') {
|
|
654
|
+
const has = selection.ids.includes(columnId);
|
|
655
|
+
const ids = has
|
|
656
|
+
? selection.ids.filter((id) => id !== columnId)
|
|
657
|
+
: [...selection.ids, columnId];
|
|
658
|
+
setSelection(ids.length ? { type: 'columns', ids } : EMPTY_SELECTION);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
setSelection({ type: 'columns', ids: [columnId] });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function cellsEqual(a: CellRef, b: CellRef) {
|
|
665
|
+
return a.rowId === b.rowId && a.columnId === b.columnId;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function expandRange(a: CellRef, b: CellRef): CellRef[] {
|
|
669
|
+
const rowA = rowIds.indexOf(a.rowId);
|
|
670
|
+
const rowB = rowIds.indexOf(b.rowId);
|
|
671
|
+
const colIds = orderedColumns.map((c) => c.id);
|
|
672
|
+
const colA = colIds.indexOf(a.columnId);
|
|
673
|
+
const colB = colIds.indexOf(b.columnId);
|
|
674
|
+
if (rowA < 0 || rowB < 0 || colA < 0 || colB < 0) return [b];
|
|
675
|
+
const [r0, r1] = rowA < rowB ? [rowA, rowB] : [rowB, rowA];
|
|
676
|
+
const [c0, c1] = colA < colB ? [colA, colB] : [colB, colA];
|
|
677
|
+
const cells: CellRef[] = [];
|
|
678
|
+
for (let r = r0; r <= r1; r++) {
|
|
679
|
+
for (let c = c0; c <= c1; c++) {
|
|
680
|
+
cells.push({ rowId: rowIds[r], columnId: colIds[c] });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return cells;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function clearNativeTextSelection() {
|
|
687
|
+
const sel = typeof window !== 'undefined' ? window.getSelection?.() : null;
|
|
688
|
+
if (sel && sel.rangeCount > 0) sel.removeAllRanges();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function selectCell(rowId: string, columnId: string, event: MouseEvent) {
|
|
692
|
+
if (!selectCells) return;
|
|
693
|
+
clearNativeTextSelection();
|
|
694
|
+
const ref = { rowId, columnId };
|
|
695
|
+
if (event.shiftKey && anchorCell) {
|
|
696
|
+
setSelection({ type: 'cells', cells: expandRange(anchorCell, ref) });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (event.metaKey || event.ctrlKey) {
|
|
700
|
+
if (selection.type === 'cells') {
|
|
701
|
+
const has = selection.cells.some((c) => cellsEqual(c, ref));
|
|
702
|
+
const cells = has
|
|
703
|
+
? selection.cells.filter((c) => !cellsEqual(c, ref))
|
|
704
|
+
: [...selection.cells, ref];
|
|
705
|
+
setSelection(cells.length ? { type: 'cells', cells } : EMPTY_SELECTION);
|
|
706
|
+
} else {
|
|
707
|
+
setSelection({ type: 'cells', cells: [ref] });
|
|
708
|
+
}
|
|
709
|
+
anchorCell = ref;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
anchorCell = ref;
|
|
713
|
+
setSelection({ type: 'cells', cells: [ref] });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function isRowSelected(rowId: string) {
|
|
717
|
+
return rowSelectionIds(selection).includes(rowId);
|
|
718
|
+
}
|
|
719
|
+
function isColumnSelected(columnId: string) {
|
|
720
|
+
return columnSelectionIds(selection).includes(columnId);
|
|
721
|
+
}
|
|
722
|
+
function isCellSelected(rowId: string, columnId: string) {
|
|
723
|
+
if (rowSelectionIds(selection).includes(rowId)) return true;
|
|
724
|
+
if (columnSelectionIds(selection).includes(columnId)) return true;
|
|
725
|
+
return cellSelection(selection).some((c) => c.rowId === rowId && c.columnId === columnId);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function canEditColumn(column: DataGridColumn<T>): boolean {
|
|
729
|
+
return editable && column.editable !== false;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function startEdit(row: T, column: DataGridColumn<T>, index: number) {
|
|
733
|
+
if (!canEditColumn(column)) return;
|
|
734
|
+
const rowId = getKey(row, index);
|
|
735
|
+
const value = getValue(row, column);
|
|
736
|
+
const editor = resolveCellEditor(column, value);
|
|
737
|
+
activeEditor = editor;
|
|
738
|
+
editing = { rowId, columnId: column.id };
|
|
739
|
+
if (editor.type === 'boolean') {
|
|
740
|
+
editBool = Boolean(value);
|
|
741
|
+
editValue = '';
|
|
742
|
+
} else {
|
|
743
|
+
editBool = false;
|
|
744
|
+
editValue = String(draftFromValue(value, editor));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function commitEdit(row: T) {
|
|
749
|
+
if (!editing || !activeEditor) return;
|
|
750
|
+
const { rowId, columnId } = editing;
|
|
751
|
+
const col = columns.find((c) => c.id === columnId);
|
|
752
|
+
const accessor = (col?.accessor ?? columnId) as string;
|
|
753
|
+
const draft = activeEditor.type === 'boolean' ? editBool : editValue;
|
|
754
|
+
const nextValue = coerceEditValue(activeEditor, draft);
|
|
755
|
+
editing = null;
|
|
756
|
+
activeEditor = null;
|
|
757
|
+
oncelledit?.(rowId, columnId, nextValue, row);
|
|
758
|
+
onrowpatch?.(rowId, { [accessor]: nextValue }, row);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function cancelEdit() {
|
|
762
|
+
editing = null;
|
|
763
|
+
activeEditor = null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function onEditKeydown(e: KeyboardEvent, row: T) {
|
|
767
|
+
if (e.key === 'Enter' && activeEditor?.type !== 'textarea') {
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
commitEdit(row);
|
|
770
|
+
} else if (e.key === 'Escape') {
|
|
771
|
+
e.preventDefault();
|
|
772
|
+
cancelEdit();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function cellsForNotes(sel: GridSelection = selectionOrContext()): CellRef[] {
|
|
777
|
+
if (sel.type === 'cells') {
|
|
778
|
+
const seen = new Set<string>();
|
|
779
|
+
const out: CellRef[] = [];
|
|
780
|
+
for (const c of sel.cells) {
|
|
781
|
+
const k = noteKey(c.rowId, c.columnId);
|
|
782
|
+
if (seen.has(k)) continue;
|
|
783
|
+
seen.add(k);
|
|
784
|
+
out.push(c);
|
|
785
|
+
}
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
if (sel.type === 'rows') {
|
|
789
|
+
const out: CellRef[] = [];
|
|
790
|
+
for (const rowId of sel.ids) {
|
|
791
|
+
for (const col of orderedColumns) {
|
|
792
|
+
out.push({ rowId, columnId: col.id });
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
if (sel.type === 'columns') {
|
|
798
|
+
const out: CellRef[] = [];
|
|
799
|
+
for (let i = 0; i < sortedRows.length; i++) {
|
|
800
|
+
const rowId = getKey(sortedRows[i], i);
|
|
801
|
+
for (const columnId of sel.ids) {
|
|
802
|
+
out.push({ rowId, columnId });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return out;
|
|
806
|
+
}
|
|
807
|
+
if (contextTarget) return [contextTarget];
|
|
808
|
+
return [];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function openNoteEditor(targets: CellRef[]) {
|
|
812
|
+
if (!targets.length) return;
|
|
813
|
+
noteTargets = targets;
|
|
814
|
+
markPickerOpen = false;
|
|
815
|
+
markTargets = null;
|
|
816
|
+
const texts = targets
|
|
817
|
+
.map((t) => notesMap.get(noteKey(t.rowId, t.columnId))?.text?.trim())
|
|
818
|
+
.filter((t): t is string => Boolean(t));
|
|
819
|
+
const unique = [...new Set(texts)];
|
|
820
|
+
noteDraft = unique.length === 1 ? unique[0] : '';
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function openNoteForSelection() {
|
|
824
|
+
openNoteEditor(cellsForNotes(selectionOrContext()));
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function saveNotes() {
|
|
828
|
+
if (!noteTargets?.length) return;
|
|
829
|
+
const text = noteDraft.trim();
|
|
830
|
+
const keys = new Set(noteTargets.map((t) => noteKey(t.rowId, t.columnId)));
|
|
831
|
+
let next = notes.filter((n) => !keys.has(noteKey(n.rowId, n.columnId)));
|
|
832
|
+
if (text) {
|
|
833
|
+
const updatedAt = new Date().toISOString();
|
|
834
|
+
for (const t of noteTargets) {
|
|
835
|
+
next.push({
|
|
836
|
+
rowId: t.rowId,
|
|
837
|
+
columnId: t.columnId,
|
|
838
|
+
text,
|
|
839
|
+
updatedAt
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
notes = next;
|
|
844
|
+
onnoteschange?.(next);
|
|
845
|
+
noteTargets = null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function closeNoteEditor() {
|
|
849
|
+
noteTargets = null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Resolve mark targets from selection: rows → row marks; cells → cell marks. */
|
|
853
|
+
function markTargetsFromSelection(sel: GridSelection): Array<{ rowId: string; columnId?: string }> {
|
|
854
|
+
if (sel.type === 'rows') {
|
|
855
|
+
return sel.ids.map((rowId) => ({ rowId }));
|
|
856
|
+
}
|
|
857
|
+
if (sel.type === 'cells') {
|
|
858
|
+
const seen = new Set<string>();
|
|
859
|
+
const out: Array<{ rowId: string; columnId?: string }> = [];
|
|
860
|
+
for (const c of sel.cells) {
|
|
861
|
+
const k = markKey(c.rowId, c.columnId);
|
|
862
|
+
if (seen.has(k)) continue;
|
|
863
|
+
seen.add(k);
|
|
864
|
+
out.push({ rowId: c.rowId, columnId: c.columnId });
|
|
865
|
+
}
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
if (sel.type === 'columns') {
|
|
869
|
+
// Plan: column marks out of scope — fall back to cells in those columns.
|
|
870
|
+
const out: Array<{ rowId: string; columnId?: string }> = [];
|
|
871
|
+
for (let i = 0; i < sortedRows.length; i++) {
|
|
872
|
+
const rowId = getKey(sortedRows[i], i);
|
|
873
|
+
for (const columnId of sel.ids) {
|
|
874
|
+
out.push({ rowId, columnId });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return out;
|
|
878
|
+
}
|
|
879
|
+
if (contextTarget) {
|
|
880
|
+
if (selectCells) return [{ rowId: contextTarget.rowId, columnId: contextTarget.columnId }];
|
|
881
|
+
return [{ rowId: contextTarget.rowId }];
|
|
882
|
+
}
|
|
883
|
+
return [];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function targetsHaveMarks(targets: Array<{ rowId: string; columnId?: string }>): boolean {
|
|
887
|
+
return targets.some((t) => marksMap.has(markKey(t.rowId, t.columnId)));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function openMarkPicker(targets?: Array<{ rowId: string; columnId?: string }>) {
|
|
891
|
+
const next = targets ?? markTargetsFromSelection(selectionOrContext());
|
|
892
|
+
if (!next.length) return;
|
|
893
|
+
const firstColor =
|
|
894
|
+
marksMap.get(markKey(next[0].rowId, next[0].columnId))?.color ?? 'amber';
|
|
895
|
+
markTargets = next.map((t) => ({
|
|
896
|
+
rowId: t.rowId,
|
|
897
|
+
columnId: t.columnId,
|
|
898
|
+
color: (marksMap.get(markKey(t.rowId, t.columnId))?.color ?? firstColor) as MarkColor
|
|
899
|
+
}));
|
|
900
|
+
markCustomColor = markSwatch(firstColor);
|
|
901
|
+
markPickerOpen = true;
|
|
902
|
+
noteTargets = null;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function closeMarkPicker() {
|
|
906
|
+
markPickerOpen = false;
|
|
907
|
+
markTargets = null;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function applyMarkColor(color: MarkColor) {
|
|
911
|
+
if (!markTargets?.length) return;
|
|
912
|
+
const keys = new Set(markTargets.map((t) => markKey(t.rowId, t.columnId)));
|
|
913
|
+
const next = marks.filter((m) => !keys.has(markKey(m.rowId, m.columnId)));
|
|
914
|
+
for (const t of markTargets) {
|
|
915
|
+
next.push({
|
|
916
|
+
rowId: t.rowId,
|
|
917
|
+
...(t.columnId ? { columnId: t.columnId } : {}),
|
|
918
|
+
color
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
marks = next;
|
|
922
|
+
onmarkschange?.(next);
|
|
923
|
+
closeMarkPicker();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function applyCustomMarkColor() {
|
|
927
|
+
const value = markCustomColor.trim();
|
|
928
|
+
if (!value) return;
|
|
929
|
+
applyMarkColor(value);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function clearMarksForTargets(targets?: Array<{ rowId: string; columnId?: string }>) {
|
|
933
|
+
const list = targets ?? markTargetsFromSelection(selectionOrContext());
|
|
934
|
+
if (!list.length) return;
|
|
935
|
+
const keys = new Set(list.map((t) => markKey(t.rowId, t.columnId)));
|
|
936
|
+
const next = marks.filter((m) => !keys.has(markKey(m.rowId, m.columnId)));
|
|
937
|
+
marks = next;
|
|
938
|
+
onmarkschange?.(next);
|
|
939
|
+
closeMarkPicker();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function rowMarkColor(rowId: string): MarkColor | null {
|
|
943
|
+
return marksMap.get(markKey(rowId))?.color ?? null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function cellMarkColor(rowId: string, columnId: string): MarkColor | null {
|
|
947
|
+
return marksMap.get(markKey(rowId, columnId))?.color ?? null;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function handleDockAction(id: string) {
|
|
951
|
+
if (id === 'note') {
|
|
952
|
+
openNoteForSelection();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (id === 'mark') {
|
|
956
|
+
openMarkPicker();
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (id === 'copy') {
|
|
960
|
+
copySelection();
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
onaction?.(id, selection);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Right-click only pins the menu target — it does not change selection. */
|
|
967
|
+
function prepareContextTarget(rowId: string, columnId: string) {
|
|
968
|
+
contextTarget = { rowId, columnId };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function openContextMenuAt(e: MouseEvent, rowId: string, columnId: string) {
|
|
972
|
+
if (!contextMenuEnabled) return;
|
|
973
|
+
e.preventDefault();
|
|
974
|
+
e.stopPropagation();
|
|
975
|
+
prepareContextTarget(rowId, columnId);
|
|
976
|
+
contextAnchor = { x: e.clientX, y: e.clientY };
|
|
977
|
+
contextOpen = true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function closeContextMenu() {
|
|
981
|
+
contextOpen = false;
|
|
982
|
+
contextAnchor = null;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function onContextMenuPick(id: string) {
|
|
986
|
+
runGridAction(id, contextTarget);
|
|
987
|
+
closeContextMenu();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function selectRowFromCell(rowId: string, event: MouseEvent) {
|
|
991
|
+
if (event.shiftKey || event.metaKey || event.ctrlKey) {
|
|
992
|
+
toggleRow(rowId, event);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
lastClickedRow = rowId;
|
|
996
|
+
setSelection({ type: 'rows', ids: [rowId] });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function rowIdFromEvent(e: Event): string | null {
|
|
1000
|
+
const t = e.target;
|
|
1001
|
+
if (!(t instanceof Element)) return null;
|
|
1002
|
+
return t.closest('[data-row-id]')?.getAttribute('data-row-id') ?? null;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function columnIdFromEvent(e: Event): string | null {
|
|
1006
|
+
const t = e.target;
|
|
1007
|
+
if (!(t instanceof Element)) return null;
|
|
1008
|
+
return t.closest('[data-column-id]')?.getAttribute('data-column-id') ?? null;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function rowById(rowId: string): T | undefined {
|
|
1012
|
+
const idx = rowIds.indexOf(rowId);
|
|
1013
|
+
return idx >= 0 ? sortedRows[idx] : undefined;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function emitRowClick(rowId: string) {
|
|
1017
|
+
const row = rowById(rowId);
|
|
1018
|
+
if (row) onrowclick?.(row);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function emitRowDblClick(rowId: string) {
|
|
1022
|
+
const row = rowById(rowId);
|
|
1023
|
+
if (row) onrowdblclick?.(row);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Single delegated handlers — avoids broken per-row listeners inside {#each}. */
|
|
1027
|
+
function onGridClick(e: MouseEvent) {
|
|
1028
|
+
// Ignore the synthetic click that precedes dblclick (detail === 2).
|
|
1029
|
+
if (e.detail > 1) return;
|
|
1030
|
+
const t = e.target;
|
|
1031
|
+
if (!(t instanceof Element)) return;
|
|
1032
|
+
if (t.closest('thead, button, input, textarea, select, a, label, [data-marquee-ignore]')) return;
|
|
1033
|
+
const cell = t.closest('[role="gridcell"][data-row-id]');
|
|
1034
|
+
if (!(cell instanceof HTMLElement)) return;
|
|
1035
|
+
const id = cell.getAttribute('data-row-id');
|
|
1036
|
+
const colId = cell.getAttribute('data-column-id') ?? '';
|
|
1037
|
+
if (!id) return;
|
|
1038
|
+
if (selectCells) {
|
|
1039
|
+
selectCell(id, colId, e);
|
|
1040
|
+
} else if (selectable && selectOnClick) {
|
|
1041
|
+
selectRowFromCell(id, e);
|
|
1042
|
+
}
|
|
1043
|
+
emitRowClick(id);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function onGridDblClick(e: MouseEvent) {
|
|
1047
|
+
const t = e.target;
|
|
1048
|
+
if (!(t instanceof Element)) return;
|
|
1049
|
+
if (t.closest('thead, button, input, textarea, select, a, label, [data-marquee-ignore]')) return;
|
|
1050
|
+
const cell = t.closest('[role="gridcell"][data-row-id]');
|
|
1051
|
+
if (!(cell instanceof HTMLElement)) return;
|
|
1052
|
+
const id = cell.getAttribute('data-row-id');
|
|
1053
|
+
const colId = cell.getAttribute('data-column-id') ?? '';
|
|
1054
|
+
if (!id) return;
|
|
1055
|
+
// Prefer consumer dblclick; otherwise fall through to edit.
|
|
1056
|
+
if (onrowdblclick) {
|
|
1057
|
+
emitRowClick(id);
|
|
1058
|
+
emitRowDblClick(id);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (editable) {
|
|
1062
|
+
const col = columns.find((c) => c.id === colId);
|
|
1063
|
+
const current = rowById(id);
|
|
1064
|
+
const idx = rowIds.indexOf(id);
|
|
1065
|
+
if (col && current && canEditColumn(col)) startEdit(current, col, idx);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function selectionOrContext(): GridSelection {
|
|
1070
|
+
if (selection.type !== 'none') return selection;
|
|
1071
|
+
if (!contextTarget) return EMPTY_SELECTION;
|
|
1072
|
+
if (selectCells) {
|
|
1073
|
+
return { type: 'cells', cells: [contextTarget] };
|
|
1074
|
+
}
|
|
1075
|
+
return { type: 'rows', ids: [contextTarget.rowId] };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function copySelection(sel: GridSelection = selection) {
|
|
1079
|
+
const active = sel.type === 'none' ? selectionOrContext() : sel;
|
|
1080
|
+
const lines: string[] = [];
|
|
1081
|
+
if (active.type === 'rows') {
|
|
1082
|
+
for (const id of active.ids) {
|
|
1083
|
+
const idx = rowIds.indexOf(id);
|
|
1084
|
+
if (idx < 0) continue;
|
|
1085
|
+
const row = rows[idx];
|
|
1086
|
+
lines.push(orderedColumns.map((c) => formatCell(getValue(row, c))).join('\t'));
|
|
1087
|
+
}
|
|
1088
|
+
} else if (active.type === 'columns') {
|
|
1089
|
+
for (const row of rows) {
|
|
1090
|
+
lines.push(
|
|
1091
|
+
active.ids
|
|
1092
|
+
.map((id) => {
|
|
1093
|
+
const col = columns.find((c) => c.id === id);
|
|
1094
|
+
return col ? formatCell(getValue(row, col)) : '';
|
|
1095
|
+
})
|
|
1096
|
+
.join('\t')
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
} else if (active.type === 'cells') {
|
|
1100
|
+
const byRow = new Map<string, Map<string, string>>();
|
|
1101
|
+
for (const cellRef of active.cells) {
|
|
1102
|
+
const idx = rowIds.indexOf(cellRef.rowId);
|
|
1103
|
+
if (idx < 0) continue;
|
|
1104
|
+
const col = columns.find((c) => c.id === cellRef.columnId);
|
|
1105
|
+
if (!col) continue;
|
|
1106
|
+
if (!byRow.has(cellRef.rowId)) byRow.set(cellRef.rowId, new Map());
|
|
1107
|
+
byRow
|
|
1108
|
+
.get(cellRef.rowId)!
|
|
1109
|
+
.set(cellRef.columnId, formatCell(getValue(rows[idx], col)));
|
|
1110
|
+
}
|
|
1111
|
+
for (const cols of byRow.values()) {
|
|
1112
|
+
lines.push([...cols.values()].join('\t'));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const text = lines.join('\n');
|
|
1116
|
+
if (text) navigator.clipboard?.writeText(text).catch(() => {});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function runGridAction(id: string, focus?: CellRef | null) {
|
|
1120
|
+
const target = focus ?? contextTarget;
|
|
1121
|
+
if (id === 'copy') {
|
|
1122
|
+
copySelection(selectionOrContext());
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (id === 'note') {
|
|
1126
|
+
const sel = selectionOrContext();
|
|
1127
|
+
const targets = cellsForNotes(sel);
|
|
1128
|
+
if (targets.length) {
|
|
1129
|
+
openNoteEditor(targets);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (target) {
|
|
1133
|
+
openNoteEditor([target]);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
openNoteForSelection();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (id === 'mark') {
|
|
1140
|
+
openMarkPicker();
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (id === 'clear-mark') {
|
|
1144
|
+
clearMarksForTargets();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (id === 'edit' && target && editable) {
|
|
1148
|
+
const col = columns.find((c) => c.id === target.columnId);
|
|
1149
|
+
if (!col || !canEditColumn(col)) return;
|
|
1150
|
+
for (let i = 0; i < sortedRows.length; i++) {
|
|
1151
|
+
const row = sortedRows[i];
|
|
1152
|
+
if (getKey(row, i) === target.rowId) {
|
|
1153
|
+
startEdit(row, col, i);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (id === 'select-row' && target) {
|
|
1160
|
+
setSelection({ type: 'rows', ids: [target.rowId] });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (id === 'select-all') {
|
|
1164
|
+
setSelection({ type: 'rows', ids: [...rowIds] });
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (id === 'clear') {
|
|
1168
|
+
clearSelection();
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
onaction?.(id, selectionOrContext());
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function onContextSelect(id: string) {
|
|
1175
|
+
const target = contextTarget;
|
|
1176
|
+
runGridAction(id, target);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function onRowMenuAction(id: string, rowId: string) {
|
|
1180
|
+
const columnId = orderedColumns[0]?.id ?? 'id';
|
|
1181
|
+
prepareContextTarget(rowId, columnId);
|
|
1182
|
+
runGridAction(id, { rowId, columnId });
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function onKeydown(e: KeyboardEvent) {
|
|
1186
|
+
if (e.key === 'F2' && editable && !editing) {
|
|
1187
|
+
const target =
|
|
1188
|
+
contextTarget ??
|
|
1189
|
+
(selection.type === 'cells' && selection.cells[0]
|
|
1190
|
+
? selection.cells[0]
|
|
1191
|
+
: selection.type === 'rows' && selection.ids[0] && orderedColumns[0]
|
|
1192
|
+
? { rowId: selection.ids[0], columnId: orderedColumns[0].id }
|
|
1193
|
+
: null);
|
|
1194
|
+
if (target) {
|
|
1195
|
+
e.preventDefault();
|
|
1196
|
+
runGridAction('edit', target);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (e.key === 'Escape') {
|
|
1201
|
+
if (editing) {
|
|
1202
|
+
cancelEdit();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (noteTargets) {
|
|
1206
|
+
closeNoteEditor();
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (markPickerOpen) {
|
|
1210
|
+
closeMarkPicker();
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (contextOpen) {
|
|
1214
|
+
closeContextMenu();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
if (dockCount > 0) clearSelection();
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function startResize(columnId: string, e: PointerEvent) {
|
|
1222
|
+
e.preventDefault();
|
|
1223
|
+
e.stopPropagation();
|
|
1224
|
+
const el = (e.currentTarget as HTMLElement).parentElement;
|
|
1225
|
+
const startW = columnWidths[columnId] ?? el?.getBoundingClientRect().width ?? 120;
|
|
1226
|
+
resizing = { id: columnId, startX: e.clientX, startW };
|
|
1227
|
+
const onMove = (ev: PointerEvent) => {
|
|
1228
|
+
if (!resizing) return;
|
|
1229
|
+
const delta = ev.clientX - resizing.startX;
|
|
1230
|
+
columnWidths = {
|
|
1231
|
+
...columnWidths,
|
|
1232
|
+
[resizing.id]: Math.max(72, resizing.startW + delta)
|
|
1233
|
+
};
|
|
1234
|
+
};
|
|
1235
|
+
const onUp = () => {
|
|
1236
|
+
if (resizing) oncolumnresize?.(resizing.id, columnWidths[resizing.id] ?? resizing.startW);
|
|
1237
|
+
resizing = null;
|
|
1238
|
+
window.removeEventListener('pointermove', onMove);
|
|
1239
|
+
window.removeEventListener('pointerup', onUp);
|
|
1240
|
+
};
|
|
1241
|
+
window.addEventListener('pointermove', onMove);
|
|
1242
|
+
window.addEventListener('pointerup', onUp);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function colWidth(column: DataGridColumn<T>): string | undefined {
|
|
1246
|
+
if (columnWidths[column.id]) return `${columnWidths[column.id]}px`;
|
|
1247
|
+
return column.width;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function pinnedOffset(columnId: string): string | undefined {
|
|
1251
|
+
const idx = pinnedColumns.findIndex((c) => c.id === columnId);
|
|
1252
|
+
if (idx < 0) return undefined;
|
|
1253
|
+
let left = selectable ? 40 : 0;
|
|
1254
|
+
for (let i = 0; i < idx; i++) {
|
|
1255
|
+
const c = pinnedColumns[i];
|
|
1256
|
+
left += columnWidths[c.id] ?? (c.width ? parseInt(c.width, 10) || 120 : 120);
|
|
1257
|
+
}
|
|
1258
|
+
return `${left}px`;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function marqueeIdForRow(rowId: string) {
|
|
1262
|
+
return `row:${rowId}`;
|
|
1263
|
+
}
|
|
1264
|
+
function marqueeIdForCell(rowId: string, columnId: string) {
|
|
1265
|
+
return `cell:${noteKey(rowId, columnId)}`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function snapshotMarqueeIds(): string[] {
|
|
1269
|
+
if (selection.type === 'rows') return selection.ids.map(marqueeIdForRow);
|
|
1270
|
+
if (selection.type === 'cells') {
|
|
1271
|
+
return selection.cells.map((c) => marqueeIdForCell(c.rowId, c.columnId));
|
|
1272
|
+
}
|
|
1273
|
+
return [];
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function applyMarqueeIds(ids: string[]) {
|
|
1277
|
+
if (selectCells) {
|
|
1278
|
+
const cells: CellRef[] = [];
|
|
1279
|
+
for (const id of ids) {
|
|
1280
|
+
if (!id.startsWith('cell:')) continue;
|
|
1281
|
+
const rest = id.slice(5);
|
|
1282
|
+
const sep = rest.indexOf('::');
|
|
1283
|
+
if (sep < 0) continue;
|
|
1284
|
+
cells.push({ rowId: rest.slice(0, sep), columnId: rest.slice(sep + 2) });
|
|
1285
|
+
}
|
|
1286
|
+
setSelection(cells.length ? { type: 'cells', cells } : EMPTY_SELECTION);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const idsOnly = ids.filter((id) => id.startsWith('row:')).map((id) => id.slice(4));
|
|
1290
|
+
setSelection(idsOnly.length ? { type: 'rows', ids: idsOnly } : EMPTY_SELECTION);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const marqueeEnabled = $derived(selectable && marqueeSelectActive && !loading);
|
|
1294
|
+
|
|
1295
|
+
$effect(() => {
|
|
1296
|
+
const el = gridScrollEl;
|
|
1297
|
+
if (!el || !marqueeEnabled) return;
|
|
1298
|
+
|
|
1299
|
+
let baseline: string[] = [];
|
|
1300
|
+
let dragStarted = false;
|
|
1301
|
+
|
|
1302
|
+
const dispose = attachMarqueeSelect(el, {
|
|
1303
|
+
getItems: () =>
|
|
1304
|
+
collectMarqueeItems(
|
|
1305
|
+
el,
|
|
1306
|
+
selectCells ? '[data-marquee-id^="cell:"]' : '[data-marquee-id^="row:"]'
|
|
1307
|
+
),
|
|
1308
|
+
shouldIgnore: (target) => {
|
|
1309
|
+
if (editing || noteTargets || markPickerOpen) return true;
|
|
1310
|
+
if (defaultMarqueeIgnore(target)) return true;
|
|
1311
|
+
if (target instanceof Element && target.closest('thead')) return true;
|
|
1312
|
+
return false;
|
|
1313
|
+
},
|
|
1314
|
+
onRect: (rect) => {
|
|
1315
|
+
marqueeRect = rect;
|
|
1316
|
+
if (!rect) dragStarted = false;
|
|
1317
|
+
},
|
|
1318
|
+
onDrag: ({ ids, modifier }) => {
|
|
1319
|
+
if (!dragStarted) {
|
|
1320
|
+
dragStarted = true;
|
|
1321
|
+
baseline = snapshotMarqueeIds();
|
|
1322
|
+
}
|
|
1323
|
+
clearNativeTextSelection();
|
|
1324
|
+
applyMarqueeIds(resolveMarqueeSelection(ids, baseline, modifier));
|
|
1325
|
+
},
|
|
1326
|
+
onSelect: ({ ids, modifier }) => {
|
|
1327
|
+
clearNativeTextSelection();
|
|
1328
|
+
applyMarqueeIds(resolveMarqueeSelection(ids, baseline, modifier));
|
|
1329
|
+
dragStarted = false;
|
|
1330
|
+
baseline = [];
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
return dispose;
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
const alignClass = {
|
|
1338
|
+
left: 'text-left',
|
|
1339
|
+
center: 'text-center',
|
|
1340
|
+
right: 'text-right'
|
|
1341
|
+
} as const;
|
|
1342
|
+
|
|
1343
|
+
</script>
|
|
1344
|
+
|
|
1345
|
+
<svelte:window
|
|
1346
|
+
onkeydown={onKeydown}
|
|
1347
|
+
onpointerdown={(e) => {
|
|
1348
|
+
if (!contextOpen || e.button === 2) return;
|
|
1349
|
+
const t = e.target as Node | null;
|
|
1350
|
+
if (t && typeof (t as Element).closest === 'function' && (t as Element).closest('[aria-label="Context menu"]')) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
closeContextMenu();
|
|
1354
|
+
}}
|
|
1355
|
+
/>
|
|
1356
|
+
|
|
1357
|
+
{#snippet gridBody()}
|
|
1358
|
+
<div class={['relative flex h-full min-h-0 w-full flex-col gap-3', className]}>
|
|
1359
|
+
{#if filterable || showViewModes}
|
|
1360
|
+
<div class="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
|
1361
|
+
{#if filterable}
|
|
1362
|
+
<div class="min-w-0 flex-1">
|
|
1363
|
+
<FilterBar
|
|
1364
|
+
bind:query={filterQuery}
|
|
1365
|
+
filters={filterBarChips}
|
|
1366
|
+
placeholder="Search rows…"
|
|
1367
|
+
onquerychange={syncFilterQuery}
|
|
1368
|
+
onremovefilter={removeFilterChip}
|
|
1369
|
+
onclear={clearAllFilters}
|
|
1370
|
+
>
|
|
1371
|
+
{#snippet actions()}
|
|
1372
|
+
{#if filterableColumns.length}
|
|
1373
|
+
{@const columnOptions = filterableColumns.map((c) => ({
|
|
1374
|
+
value: c.id,
|
|
1375
|
+
label: c.header
|
|
1376
|
+
}))}
|
|
1377
|
+
{@const valueOptions = filterColumnDraft
|
|
1378
|
+
? uniqueColumnValues(
|
|
1379
|
+
rows as Record<string, unknown>[],
|
|
1380
|
+
columns.find((c) => c.id === filterColumnDraft)! as DataGridColumn
|
|
1381
|
+
).map((v) => ({ value: v, label: v }))
|
|
1382
|
+
: []}
|
|
1383
|
+
<div class="flex items-center gap-1.5">
|
|
1384
|
+
<Select
|
|
1385
|
+
size="sm"
|
|
1386
|
+
class="w-[9.5rem] shrink-0"
|
|
1387
|
+
placeholder="Filter column…"
|
|
1388
|
+
options={columnOptions}
|
|
1389
|
+
bind:value={filterColumnDraft}
|
|
1390
|
+
onchange={() => {
|
|
1391
|
+
filterValueDraft = '';
|
|
1392
|
+
}}
|
|
1393
|
+
/>
|
|
1394
|
+
{#if filterColumnDraft}
|
|
1395
|
+
<Select
|
|
1396
|
+
size="sm"
|
|
1397
|
+
class="w-[9.5rem] shrink-0"
|
|
1398
|
+
placeholder="Value…"
|
|
1399
|
+
options={valueOptions}
|
|
1400
|
+
bind:value={filterValueDraft}
|
|
1401
|
+
onchange={(v) => {
|
|
1402
|
+
if (!v) return;
|
|
1403
|
+
addColumnFilter(filterColumnDraft, v);
|
|
1404
|
+
filterValueDraft = '';
|
|
1405
|
+
}}
|
|
1406
|
+
/>
|
|
1407
|
+
{/if}
|
|
1408
|
+
</div>
|
|
1409
|
+
{/if}
|
|
1410
|
+
{/snippet}
|
|
1411
|
+
</FilterBar>
|
|
1412
|
+
</div>
|
|
1413
|
+
{/if}
|
|
1414
|
+
{#if showViewModes}
|
|
1415
|
+
<div
|
|
1416
|
+
class="inline-flex h-12 shrink-0 items-center gap-0.5 rounded-2xl border border-border bg-surface-elevated p-2 shadow-sm"
|
|
1417
|
+
role="group"
|
|
1418
|
+
aria-label="View mode"
|
|
1419
|
+
>
|
|
1420
|
+
{#each [
|
|
1421
|
+
{ id: 'table' as const, label: 'Table', Icon: Table2 },
|
|
1422
|
+
{ id: 'list' as const, label: 'List', Icon: LayoutList },
|
|
1423
|
+
{ id: 'card' as const, label: 'Cards', Icon: Rows3 },
|
|
1424
|
+
{ id: 'grid' as const, label: 'Grid', Icon: LayoutGrid }
|
|
1425
|
+
] as mode (mode.id)}
|
|
1426
|
+
<button
|
|
1427
|
+
type="button"
|
|
1428
|
+
class={[
|
|
1429
|
+
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-secondary transition',
|
|
1430
|
+
viewMode === mode.id && 'bg-brand-500/15 text-brand-700 dark:text-brand-300'
|
|
1431
|
+
]}
|
|
1432
|
+
aria-label={mode.label}
|
|
1433
|
+
aria-pressed={viewMode === mode.id}
|
|
1434
|
+
onclick={() => setViewMode(mode.id)}
|
|
1435
|
+
>
|
|
1436
|
+
<mode.Icon class="h-4 w-4" strokeWidth={2} />
|
|
1437
|
+
</button>
|
|
1438
|
+
{/each}
|
|
1439
|
+
</div>
|
|
1440
|
+
{/if}
|
|
1441
|
+
</div>
|
|
1442
|
+
{/if}
|
|
1443
|
+
|
|
1444
|
+
{#if isTableMode}
|
|
1445
|
+
<div
|
|
1446
|
+
bind:this={gridScrollEl}
|
|
1447
|
+
class={[
|
|
1448
|
+
'relative min-h-0 w-full flex-1 overflow-auto rounded-xl border border-border bg-surface-elevated',
|
|
1449
|
+
selectable && 'select-none'
|
|
1450
|
+
]}
|
|
1451
|
+
role="grid"
|
|
1452
|
+
aria-rowcount={sortedRows.length}
|
|
1453
|
+
aria-colcount={colCount}
|
|
1454
|
+
onclick={onGridClick}
|
|
1455
|
+
ondblclick={onGridDblClick}
|
|
1456
|
+
>
|
|
1457
|
+
<table class="w-full border-collapse text-sm">
|
|
1458
|
+
<thead
|
|
1459
|
+
class={[
|
|
1460
|
+
'border-b border-border bg-surface-overlay/80 text-xs font-semibold tracking-wide text-secondary uppercase',
|
|
1461
|
+
stickyHeader && 'sticky top-0 z-20'
|
|
1462
|
+
]}
|
|
1463
|
+
oncontextmenu={(e) => e.stopPropagation()}
|
|
1464
|
+
>
|
|
1465
|
+
<tr role="row">
|
|
1466
|
+
{#if selectable}
|
|
1467
|
+
<th
|
|
1468
|
+
scope="col"
|
|
1469
|
+
class={['w-10 px-2', compact ? 'py-2' : 'py-2.5', stickyHeader && 'sticky left-0 z-30 bg-surface-overlay']}
|
|
1470
|
+
style={stickyHeader ? 'left:0' : undefined}
|
|
1471
|
+
data-marquee-ignore
|
|
1472
|
+
>
|
|
1473
|
+
<Checkbox
|
|
1474
|
+
size="sm"
|
|
1475
|
+
class="gap-0"
|
|
1476
|
+
checked={allRowsSelected}
|
|
1477
|
+
indeterminate={someRowsSelected}
|
|
1478
|
+
aria-label="Select all rows"
|
|
1479
|
+
onclick={(e) => e.stopPropagation()}
|
|
1480
|
+
onchange={(checked) => {
|
|
1481
|
+
if (checked) setSelection({ type: 'rows', ids: [...rowIds] });
|
|
1482
|
+
else clearSelection();
|
|
1483
|
+
}}
|
|
1484
|
+
/>
|
|
1485
|
+
</th>
|
|
1486
|
+
{/if}
|
|
1487
|
+
{#if collapsibleEnabled}
|
|
1488
|
+
<th
|
|
1489
|
+
scope="col"
|
|
1490
|
+
class={['w-8 px-1', compact ? 'py-2' : 'py-2.5']}
|
|
1491
|
+
data-marquee-ignore
|
|
1492
|
+
aria-label="Expand"
|
|
1493
|
+
></th>
|
|
1494
|
+
{/if}
|
|
1495
|
+
{#each orderedColumns as column (column.id)}
|
|
1496
|
+
{@const selected = isColumnSelected(column.id)}
|
|
1497
|
+
<th
|
|
1498
|
+
scope="col"
|
|
1499
|
+
class={[
|
|
1500
|
+
'relative px-3 font-semibold',
|
|
1501
|
+
compact ? 'py-2' : 'py-2.5',
|
|
1502
|
+
alignClass[column.align ?? 'left'],
|
|
1503
|
+
canSort(column) && 'cursor-pointer select-none hover:text-primary',
|
|
1504
|
+
selected && 'bg-brand-500/10 text-brand-700 dark:text-brand-300',
|
|
1505
|
+
column.pinned && stickyHeader && 'sticky z-30 bg-surface-overlay',
|
|
1506
|
+
column.class
|
|
1507
|
+
]}
|
|
1508
|
+
style={[
|
|
1509
|
+
colWidth(column) ? `width:${colWidth(column)};min-width:${colWidth(column)}` : column.minWidth ? `min-width:${column.minWidth}` : '',
|
|
1510
|
+
column.pinned ? `left:${pinnedOffset(column.id)}` : ''
|
|
1511
|
+
]
|
|
1512
|
+
.filter(Boolean)
|
|
1513
|
+
.join(';')}
|
|
1514
|
+
aria-sort={
|
|
1515
|
+
sortId === column.id
|
|
1516
|
+
? sortDir === 'asc'
|
|
1517
|
+
? 'ascending'
|
|
1518
|
+
: 'descending'
|
|
1519
|
+
: canSort(column)
|
|
1520
|
+
? 'none'
|
|
1521
|
+
: undefined
|
|
1522
|
+
}
|
|
1523
|
+
onclick={(e) => {
|
|
1524
|
+
if (e.altKey && selectColumns) {
|
|
1525
|
+
toggleColumn(column.id, e);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (canSort(column)) toggleSort(column);
|
|
1529
|
+
}}
|
|
1530
|
+
oncontextmenu={(e) => {
|
|
1531
|
+
if (!selectColumns) return;
|
|
1532
|
+
e.preventDefault();
|
|
1533
|
+
e.stopPropagation();
|
|
1534
|
+
toggleColumn(column.id, e as unknown as MouseEvent);
|
|
1535
|
+
}}
|
|
1536
|
+
>
|
|
1537
|
+
<span class="inline-flex items-center gap-1">
|
|
1538
|
+
{column.header}
|
|
1539
|
+
{#if canSort(column)}
|
|
1540
|
+
<span class="text-muted" aria-hidden="true">
|
|
1541
|
+
{#if sortId === column.id && sortDir === 'asc'}↑
|
|
1542
|
+
{:else if sortId === column.id && sortDir === 'desc'}↓
|
|
1543
|
+
{:else}↕{/if}
|
|
1544
|
+
</span>
|
|
1545
|
+
{/if}
|
|
1546
|
+
</span>
|
|
1547
|
+
<span
|
|
1548
|
+
class="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-brand-500/40"
|
|
1549
|
+
role="separator"
|
|
1550
|
+
aria-orientation="vertical"
|
|
1551
|
+
aria-label={`Resize ${column.header}`}
|
|
1552
|
+
data-marquee-ignore
|
|
1553
|
+
onpointerdown={(e) => startResize(column.id, e)}
|
|
1554
|
+
></span>
|
|
1555
|
+
</th>
|
|
1556
|
+
{/each}
|
|
1557
|
+
{#if rowActionsEnabled}
|
|
1558
|
+
<th
|
|
1559
|
+
scope="col"
|
|
1560
|
+
class={['w-12 px-2', compact ? 'py-2' : 'py-2.5']}
|
|
1561
|
+
data-marquee-ignore
|
|
1562
|
+
>
|
|
1563
|
+
<span class="sr-only">Actions</span>
|
|
1564
|
+
</th>
|
|
1565
|
+
{/if}
|
|
1566
|
+
</tr>
|
|
1567
|
+
</thead>
|
|
1568
|
+
<tbody>
|
|
1569
|
+
{#if loading}
|
|
1570
|
+
{#each Array.from({ length: 5 }) as _, i (i)}
|
|
1571
|
+
<tr>
|
|
1572
|
+
{#if selectable}<td class="px-2 py-2"><Skeleton class="h-4 w-4" /></td>{/if}
|
|
1573
|
+
{#if collapsibleEnabled}<td class="px-1 py-2"><Skeleton class="h-4 w-4" /></td>{/if}
|
|
1574
|
+
{#each orderedColumns as column (column.id)}
|
|
1575
|
+
<td class="px-3 py-2"><Skeleton class="h-4 w-full" /></td>
|
|
1576
|
+
{/each}
|
|
1577
|
+
{#if rowActionsEnabled}<td class="px-2 py-2"><Skeleton class="h-4 w-6" /></td>{/if}
|
|
1578
|
+
</tr>
|
|
1579
|
+
{/each}
|
|
1580
|
+
{:else if sortedRows.length === 0}
|
|
1581
|
+
<tr>
|
|
1582
|
+
<td
|
|
1583
|
+
class="px-3 py-10 text-center text-sm text-muted"
|
|
1584
|
+
colspan={Math.max(colCount, 1)}
|
|
1585
|
+
>
|
|
1586
|
+
{empty}
|
|
1587
|
+
</td>
|
|
1588
|
+
</tr>
|
|
1589
|
+
{:else}
|
|
1590
|
+
{#each sortedRows as row, index (getKey(row, index))}
|
|
1591
|
+
{@const rid = getKey(row, index)}
|
|
1592
|
+
{@const rowSelected = isRowSelected(rid)}
|
|
1593
|
+
{@const rowExpanded = isRowExpanded(rid)}
|
|
1594
|
+
{@const formats = rowFormats.get(rid) ?? { rowStyle: {}, cellStyles: {} }}
|
|
1595
|
+
{@const rowMark = rowMarkColor(rid)}
|
|
1596
|
+
<tr
|
|
1597
|
+
role="row"
|
|
1598
|
+
class={[
|
|
1599
|
+
'border-b border-border',
|
|
1600
|
+
!rowExpanded && 'last:border-b-0',
|
|
1601
|
+
striped && index % 2 === 1 && !formats.rowStyle.background && !rowMark && 'bg-surface-overlay/40',
|
|
1602
|
+
hoverable && 'hover:bg-surface-overlay/70',
|
|
1603
|
+
rowSelected && 'bg-brand-500/12 ring-1 ring-inset ring-brand-500/30',
|
|
1604
|
+
onrowclick && 'cursor-pointer'
|
|
1605
|
+
]}
|
|
1606
|
+
style={[
|
|
1607
|
+
rowMark && !rowSelected && !formats.rowStyle.background
|
|
1608
|
+
? `background:${markTint(rowMark)}`
|
|
1609
|
+
: '',
|
|
1610
|
+
styleToCss(formats.rowStyle)
|
|
1611
|
+
]
|
|
1612
|
+
.filter(Boolean)
|
|
1613
|
+
.join(';')}
|
|
1614
|
+
data-row-id={rid}
|
|
1615
|
+
data-marquee-id={!selectCells && marqueeEnabled ? marqueeIdForRow(rid) : undefined}
|
|
1616
|
+
oncontextmenu={(e) => {
|
|
1617
|
+
const el = e.target as HTMLElement | null;
|
|
1618
|
+
if (el?.closest?.('[role="gridcell"]')) return;
|
|
1619
|
+
const id = rowIdFromEvent(e) ?? rid;
|
|
1620
|
+
const colId = orderedColumns[0]?.id;
|
|
1621
|
+
if (colId) openContextMenuAt(e, id, colId);
|
|
1622
|
+
}}
|
|
1623
|
+
>
|
|
1624
|
+
{#if selectable}
|
|
1625
|
+
<td
|
|
1626
|
+
class={['px-2', compact ? 'py-1.5' : 'py-2.5', stickyHeader && 'sticky left-0 z-10 bg-inherit']}
|
|
1627
|
+
data-marquee-ignore
|
|
1628
|
+
onclick={(e) => e.stopPropagation()}
|
|
1629
|
+
>
|
|
1630
|
+
<Checkbox
|
|
1631
|
+
size="sm"
|
|
1632
|
+
class="gap-0"
|
|
1633
|
+
checked={rowSelected}
|
|
1634
|
+
aria-label={`Select row ${rid}`}
|
|
1635
|
+
onclick={(e) => {
|
|
1636
|
+
e.stopPropagation();
|
|
1637
|
+
toggleRow(rid, e);
|
|
1638
|
+
}}
|
|
1639
|
+
/>
|
|
1640
|
+
</td>
|
|
1641
|
+
{/if}
|
|
1642
|
+
{#if collapsibleEnabled}
|
|
1643
|
+
<td
|
|
1644
|
+
class={['px-1', compact ? 'py-1.5' : 'py-2.5']}
|
|
1645
|
+
data-marquee-ignore
|
|
1646
|
+
onclick={(e) => e.stopPropagation()}
|
|
1647
|
+
>
|
|
1648
|
+
<button
|
|
1649
|
+
type="button"
|
|
1650
|
+
class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-secondary hover:bg-surface-overlay hover:text-primary"
|
|
1651
|
+
aria-expanded={rowExpanded}
|
|
1652
|
+
aria-label={rowExpanded ? 'Collapse row' : 'Expand row'}
|
|
1653
|
+
onclick={() => toggleExpand(rid)}
|
|
1654
|
+
>
|
|
1655
|
+
<ChevronRight
|
|
1656
|
+
class={['h-4 w-4 transition-transform', rowExpanded && 'rotate-90']}
|
|
1657
|
+
strokeWidth={2}
|
|
1658
|
+
/>
|
|
1659
|
+
</button>
|
|
1660
|
+
</td>
|
|
1661
|
+
{/if}
|
|
1662
|
+
{#each orderedColumns as column (column.id)}
|
|
1663
|
+
{@const selected = isCellSelected(rid, column.id)}
|
|
1664
|
+
{@const cellMark = cellMarkColor(rid, column.id)}
|
|
1665
|
+
{@const cellStyle = {
|
|
1666
|
+
...formats.rowStyle,
|
|
1667
|
+
...(formats.cellStyles[column.id] ?? {}),
|
|
1668
|
+
...(cellMark &&
|
|
1669
|
+
!selected &&
|
|
1670
|
+
!formats.cellStyles[column.id]?.background
|
|
1671
|
+
? { background: markTint(cellMark) }
|
|
1672
|
+
: {})
|
|
1673
|
+
}}
|
|
1674
|
+
{@const note = notesMap.get(noteKey(rid, column.id))}
|
|
1675
|
+
{@const isEditing =
|
|
1676
|
+
editing?.rowId === rid && editing?.columnId === column.id}
|
|
1677
|
+
<td
|
|
1678
|
+
role="gridcell"
|
|
1679
|
+
class={[
|
|
1680
|
+
'group relative px-3 text-primary',
|
|
1681
|
+
compact ? 'py-1.5' : 'py-2.5',
|
|
1682
|
+
alignClass[column.align ?? 'left'],
|
|
1683
|
+
selected && 'ring-1 ring-inset ring-brand-500/70 bg-brand-500/15',
|
|
1684
|
+
column.pinned && 'sticky z-10 bg-inherit',
|
|
1685
|
+
column.class
|
|
1686
|
+
]}
|
|
1687
|
+
style={[
|
|
1688
|
+
styleToCss(cellStyle),
|
|
1689
|
+
colWidth(column) ? `width:${colWidth(column)};min-width:${colWidth(column)}` : '',
|
|
1690
|
+
column.pinned ? `left:${pinnedOffset(column.id)}` : ''
|
|
1691
|
+
]
|
|
1692
|
+
.filter(Boolean)
|
|
1693
|
+
.join(';')}
|
|
1694
|
+
data-row-id={rid}
|
|
1695
|
+
data-column-id={column.id}
|
|
1696
|
+
data-marquee-id={selectCells && marqueeEnabled
|
|
1697
|
+
? marqueeIdForCell(rid, column.id)
|
|
1698
|
+
: undefined}
|
|
1699
|
+
onmousedown={(e) => {
|
|
1700
|
+
// Avoid native text highlight while selecting cells.
|
|
1701
|
+
if (selectCells && e.button === 0 && !isEditing) {
|
|
1702
|
+
e.preventDefault();
|
|
1703
|
+
}
|
|
1704
|
+
}}
|
|
1705
|
+
oncontextmenu={(e) => {
|
|
1706
|
+
const id = rowIdFromEvent(e) ?? rid;
|
|
1707
|
+
const colId = columnIdFromEvent(e) ?? column.id;
|
|
1708
|
+
openContextMenuAt(e, id, colId);
|
|
1709
|
+
}}
|
|
1710
|
+
>
|
|
1711
|
+
{#if isEditing && activeEditor}
|
|
1712
|
+
<div
|
|
1713
|
+
data-marquee-ignore
|
|
1714
|
+
class="select-text min-w-[7rem]"
|
|
1715
|
+
onclick={(e) => e.stopPropagation()}
|
|
1716
|
+
onmousedown={(e) => e.stopPropagation()}
|
|
1717
|
+
>
|
|
1718
|
+
{#if activeEditor.type === 'boolean'}
|
|
1719
|
+
<Toggle
|
|
1720
|
+
size="sm"
|
|
1721
|
+
checked={editBool}
|
|
1722
|
+
onchange={(v) => {
|
|
1723
|
+
editBool = v;
|
|
1724
|
+
commitEdit(row);
|
|
1725
|
+
}}
|
|
1726
|
+
/>
|
|
1727
|
+
{:else if activeEditor.type === 'select'}
|
|
1728
|
+
<Select
|
|
1729
|
+
size="sm"
|
|
1730
|
+
class="w-full"
|
|
1731
|
+
placeholder={activeEditor.placeholder ?? 'Select…'}
|
|
1732
|
+
options={activeEditor.options ?? []}
|
|
1733
|
+
bind:value={editValue}
|
|
1734
|
+
onchange={() => commitEdit(row)}
|
|
1735
|
+
/>
|
|
1736
|
+
{:else if activeEditor.type === 'textarea'}
|
|
1737
|
+
<Textarea
|
|
1738
|
+
rows={2}
|
|
1739
|
+
class="w-full"
|
|
1740
|
+
placeholder={activeEditor.placeholder}
|
|
1741
|
+
bind:value={editValue}
|
|
1742
|
+
onkeydown={(e) => {
|
|
1743
|
+
if (e.key === 'Escape') {
|
|
1744
|
+
e.preventDefault();
|
|
1745
|
+
cancelEdit();
|
|
1746
|
+
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
1747
|
+
e.preventDefault();
|
|
1748
|
+
commitEdit(row);
|
|
1749
|
+
}
|
|
1750
|
+
}}
|
|
1751
|
+
onblur={() => commitEdit(row)}
|
|
1752
|
+
/>
|
|
1753
|
+
{:else if activeEditor.type === 'number'}
|
|
1754
|
+
<Input
|
|
1755
|
+
type="number"
|
|
1756
|
+
size="sm"
|
|
1757
|
+
class="gap-0! [&>div]:h-7! [&>div]:rounded-md! [&>div]:px-1.5!"
|
|
1758
|
+
placeholder={activeEditor.placeholder}
|
|
1759
|
+
min={activeEditor.min}
|
|
1760
|
+
max={activeEditor.max}
|
|
1761
|
+
step={activeEditor.step}
|
|
1762
|
+
bind:value={editValue}
|
|
1763
|
+
autofocus
|
|
1764
|
+
onkeydown={(e) => onEditKeydown(e, row)}
|
|
1765
|
+
onblur={() => commitEdit(row)}
|
|
1766
|
+
/>
|
|
1767
|
+
{:else if activeEditor.type === 'date'}
|
|
1768
|
+
<Input
|
|
1769
|
+
type="date"
|
|
1770
|
+
size="sm"
|
|
1771
|
+
class="gap-0! [&>div]:h-7! [&>div]:rounded-md! [&>div]:px-1.5!"
|
|
1772
|
+
bind:value={editValue}
|
|
1773
|
+
autofocus
|
|
1774
|
+
onkeydown={(e) => onEditKeydown(e, row)}
|
|
1775
|
+
onblur={() => commitEdit(row)}
|
|
1776
|
+
/>
|
|
1777
|
+
{:else}
|
|
1778
|
+
<Input
|
|
1779
|
+
type="text"
|
|
1780
|
+
size="sm"
|
|
1781
|
+
class="gap-0! [&>div]:h-7! [&>div]:rounded-md! [&>div]:px-1.5!"
|
|
1782
|
+
placeholder={activeEditor.placeholder}
|
|
1783
|
+
bind:value={editValue}
|
|
1784
|
+
autofocus
|
|
1785
|
+
onkeydown={(e) => onEditKeydown(e, row)}
|
|
1786
|
+
onblur={() => commitEdit(row)}
|
|
1787
|
+
/>
|
|
1788
|
+
{/if}
|
|
1789
|
+
</div>
|
|
1790
|
+
{:else if cell}
|
|
1791
|
+
{@render cell(row, column)}
|
|
1792
|
+
{:else}
|
|
1793
|
+
{formatCell(getValue(row, column))}
|
|
1794
|
+
{/if}
|
|
1795
|
+
|
|
1796
|
+
{#if canEditColumn(column) && !isEditing}
|
|
1797
|
+
<button
|
|
1798
|
+
type="button"
|
|
1799
|
+
class="absolute right-1 bottom-1 z-10 inline-flex h-6 w-6 items-center justify-center rounded-md bg-surface-elevated/95 text-secondary opacity-0 shadow-sm ring-1 ring-border/60 transition hover:bg-surface-overlay hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 group-hover:opacity-100"
|
|
1800
|
+
aria-label={`Edit ${column.header}`}
|
|
1801
|
+
data-marquee-ignore
|
|
1802
|
+
onclick={(e) => {
|
|
1803
|
+
e.stopPropagation();
|
|
1804
|
+
startEdit(row, column, index);
|
|
1805
|
+
}}
|
|
1806
|
+
>
|
|
1807
|
+
<Pencil class="h-3.5 w-3.5" strokeWidth={2} />
|
|
1808
|
+
</button>
|
|
1809
|
+
{/if}
|
|
1810
|
+
|
|
1811
|
+
{#if note}
|
|
1812
|
+
<Tooltip
|
|
1813
|
+
content={note.text}
|
|
1814
|
+
side="top"
|
|
1815
|
+
delay={180}
|
|
1816
|
+
class="absolute top-0 right-0 z-10"
|
|
1817
|
+
>
|
|
1818
|
+
<button
|
|
1819
|
+
type="button"
|
|
1820
|
+
class="relative block h-3.5 w-3.5"
|
|
1821
|
+
aria-label="View note"
|
|
1822
|
+
data-marquee-ignore
|
|
1823
|
+
onclick={(e) => {
|
|
1824
|
+
e.stopPropagation();
|
|
1825
|
+
openNoteEditor([{ rowId: rid, columnId: column.id }]);
|
|
1826
|
+
}}
|
|
1827
|
+
>
|
|
1828
|
+
<span
|
|
1829
|
+
class="absolute top-0 right-0 h-0 w-0 border-t-8 border-l-8 border-t-amber-500 border-l-transparent"
|
|
1830
|
+
aria-hidden="true"
|
|
1831
|
+
></span>
|
|
1832
|
+
</button>
|
|
1833
|
+
</Tooltip>
|
|
1834
|
+
{/if}
|
|
1835
|
+
</td>
|
|
1836
|
+
{/each}
|
|
1837
|
+
{#if rowActionsEnabled}
|
|
1838
|
+
<td
|
|
1839
|
+
class={['px-1', compact ? 'py-1' : 'py-1.5']}
|
|
1840
|
+
data-marquee-ignore
|
|
1841
|
+
onclick={(e) => e.stopPropagation()}
|
|
1842
|
+
oncontextmenu={(e) => e.stopPropagation()}
|
|
1843
|
+
>
|
|
1844
|
+
<DropdownMenu
|
|
1845
|
+
size="sm"
|
|
1846
|
+
align="end"
|
|
1847
|
+
label="Row actions"
|
|
1848
|
+
items={rowActionMenuItems}
|
|
1849
|
+
onselect={(id) => onRowMenuAction(id, rid)}
|
|
1850
|
+
>
|
|
1851
|
+
{#snippet trigger()}
|
|
1852
|
+
<button
|
|
1853
|
+
type="button"
|
|
1854
|
+
class="inline-flex h-7 w-7 items-center justify-center rounded-lg text-secondary hover:bg-surface-overlay hover:text-primary"
|
|
1855
|
+
aria-label={`Actions for row ${rid}`}
|
|
1856
|
+
>
|
|
1857
|
+
<MoreHorizontal class="h-4 w-4" strokeWidth={2} />
|
|
1858
|
+
</button>
|
|
1859
|
+
{/snippet}
|
|
1860
|
+
</DropdownMenu>
|
|
1861
|
+
</td>
|
|
1862
|
+
{/if}
|
|
1863
|
+
</tr>
|
|
1864
|
+
{#if collapsibleEnabled && rowExpanded}
|
|
1865
|
+
<tr class="border-b border-border bg-surface-overlay/40">
|
|
1866
|
+
<td colspan={Math.max(colCount, 1)} class="px-4 py-3">
|
|
1867
|
+
{#if expandedRow}
|
|
1868
|
+
{@render expandedRow(row, autoHiddenColumns)}
|
|
1869
|
+
{:else}
|
|
1870
|
+
<dl class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
1871
|
+
{#each autoHiddenColumns.length ? autoHiddenColumns : columns.filter((c) => !orderedColumns.some((o) => o.id === c.id) && !c.hidden) as col (col.id)}
|
|
1872
|
+
<div class="min-w-0">
|
|
1873
|
+
<dt class="text-[11px] font-semibold tracking-wide text-muted uppercase">
|
|
1874
|
+
{col.header}
|
|
1875
|
+
</dt>
|
|
1876
|
+
<dd class="mt-0.5 text-sm text-primary">
|
|
1877
|
+
{#if cell}
|
|
1878
|
+
{@render cell(row, col)}
|
|
1879
|
+
{:else}
|
|
1880
|
+
{formatCell(getValue(row, col))}
|
|
1881
|
+
{/if}
|
|
1882
|
+
</dd>
|
|
1883
|
+
</div>
|
|
1884
|
+
{:else}
|
|
1885
|
+
<p class="text-xs text-muted">No extra fields for this row.</p>
|
|
1886
|
+
{/each}
|
|
1887
|
+
</dl>
|
|
1888
|
+
{/if}
|
|
1889
|
+
</td>
|
|
1890
|
+
</tr>
|
|
1891
|
+
{/if}
|
|
1892
|
+
{/each}
|
|
1893
|
+
{/if}
|
|
1894
|
+
</tbody>
|
|
1895
|
+
</table>
|
|
1896
|
+
<MarqueeRect
|
|
1897
|
+
rect={marqueeRect && gridScrollEl
|
|
1898
|
+
? {
|
|
1899
|
+
x: marqueeRect.x - gridScrollEl.scrollLeft,
|
|
1900
|
+
y: marqueeRect.y - gridScrollEl.scrollTop,
|
|
1901
|
+
width: marqueeRect.width,
|
|
1902
|
+
height: marqueeRect.height
|
|
1903
|
+
}
|
|
1904
|
+
: null}
|
|
1905
|
+
/>
|
|
1906
|
+
</div>
|
|
1907
|
+
{:else}
|
|
1908
|
+
<!-- list / card / grid modes -->
|
|
1909
|
+
<div
|
|
1910
|
+
class={[
|
|
1911
|
+
'min-h-0 w-full flex-1 overflow-auto rounded-xl border border-border bg-surface-elevated p-3',
|
|
1912
|
+
viewMode === 'list' && 'flex flex-col gap-2',
|
|
1913
|
+
viewMode === 'card' && 'flex flex-col gap-3',
|
|
1914
|
+
viewMode === 'grid' &&
|
|
1915
|
+
'grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
|
|
1916
|
+
]}
|
|
1917
|
+
>
|
|
1918
|
+
{#if loading}
|
|
1919
|
+
{#each Array.from({ length: 4 }) as _, i (i)}
|
|
1920
|
+
<Skeleton class="h-24 w-full rounded-xl" />
|
|
1921
|
+
{/each}
|
|
1922
|
+
{:else if sortedRows.length === 0}
|
|
1923
|
+
<p class="px-2 py-8 text-center text-sm text-muted">{empty}</p>
|
|
1924
|
+
{:else}
|
|
1925
|
+
{#each sortedRows as row, index (getKey(row, index))}
|
|
1926
|
+
{@const rid = getKey(row, index)}
|
|
1927
|
+
{@const rowSelected = isRowSelected(rid)}
|
|
1928
|
+
{@const rowExpanded = isRowExpanded(rid)}
|
|
1929
|
+
{@const title =
|
|
1930
|
+
titleCol
|
|
1931
|
+
? cellText(row as Record<string, unknown>, titleCol as DataGridColumn)
|
|
1932
|
+
: rid}
|
|
1933
|
+
{#if itemCard}
|
|
1934
|
+
<div
|
|
1935
|
+
class={rowSelected ? 'rounded-xl ring-2 ring-brand-500/50' : undefined}
|
|
1936
|
+
onclick={() => {
|
|
1937
|
+
if (selectable && selectOnClick) setSelection({ type: 'rows', ids: [rid] });
|
|
1938
|
+
emitRowClick(rid);
|
|
1939
|
+
}}
|
|
1940
|
+
>
|
|
1941
|
+
{@render itemCard(row)}
|
|
1942
|
+
</div>
|
|
1943
|
+
{:else if viewMode === 'list'}
|
|
1944
|
+
<div
|
|
1945
|
+
class={[
|
|
1946
|
+
'flex items-start gap-3 rounded-xl border border-border px-3 py-2.5 transition',
|
|
1947
|
+
rowSelected && 'border-brand-500/40 bg-brand-500/10',
|
|
1948
|
+
hoverable && 'hover:bg-surface-overlay/70'
|
|
1949
|
+
]}
|
|
1950
|
+
>
|
|
1951
|
+
{#if selectable}
|
|
1952
|
+
<Checkbox
|
|
1953
|
+
size="sm"
|
|
1954
|
+
class="mt-0.5 gap-0"
|
|
1955
|
+
checked={rowSelected}
|
|
1956
|
+
aria-label={`Select ${title}`}
|
|
1957
|
+
onchange={(checked) => {
|
|
1958
|
+
if (checked) {
|
|
1959
|
+
const ids =
|
|
1960
|
+
selection.type === 'rows'
|
|
1961
|
+
? [...new Set([...selection.ids, rid])]
|
|
1962
|
+
: [rid];
|
|
1963
|
+
setSelection({ type: 'rows', ids });
|
|
1964
|
+
} else if (selection.type === 'rows') {
|
|
1965
|
+
const ids = selection.ids.filter((id) => id !== rid);
|
|
1966
|
+
setSelection(ids.length ? { type: 'rows', ids } : EMPTY_SELECTION);
|
|
1967
|
+
}
|
|
1968
|
+
}}
|
|
1969
|
+
/>
|
|
1970
|
+
{/if}
|
|
1971
|
+
{#if collapsibleEnabled}
|
|
1972
|
+
<button
|
|
1973
|
+
type="button"
|
|
1974
|
+
class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-secondary hover:bg-surface-overlay"
|
|
1975
|
+
aria-expanded={rowExpanded}
|
|
1976
|
+
onclick={() => toggleExpand(rid)}
|
|
1977
|
+
>
|
|
1978
|
+
<ChevronRight
|
|
1979
|
+
class={['h-4 w-4 transition-transform', rowExpanded && 'rotate-90']}
|
|
1980
|
+
strokeWidth={2}
|
|
1981
|
+
/>
|
|
1982
|
+
</button>
|
|
1983
|
+
{/if}
|
|
1984
|
+
<button
|
|
1985
|
+
type="button"
|
|
1986
|
+
class="min-w-0 flex-1 text-left"
|
|
1987
|
+
onclick={() => {
|
|
1988
|
+
emitRowClick(rid);
|
|
1989
|
+
if (selectable && selectOnClick && !rowSelected) {
|
|
1990
|
+
setSelection({ type: 'rows', ids: [rid] });
|
|
1991
|
+
}
|
|
1992
|
+
}}
|
|
1993
|
+
>
|
|
1994
|
+
<p class="truncate text-sm font-semibold text-primary">{title}</p>
|
|
1995
|
+
<p class="mt-0.5 truncate text-xs text-muted">
|
|
1996
|
+
{secondaryFields(row)
|
|
1997
|
+
.slice(0, 3)
|
|
1998
|
+
.map((f) => f.text)
|
|
1999
|
+
.filter(Boolean)
|
|
2000
|
+
.join(' · ')}
|
|
2001
|
+
</p>
|
|
2002
|
+
{#if rowExpanded}
|
|
2003
|
+
<div class="mt-2 grid gap-2 border-t border-border pt-2 sm:grid-cols-2">
|
|
2004
|
+
{#if expandedRow}
|
|
2005
|
+
{@render expandedRow(row, autoHiddenColumns)}
|
|
2006
|
+
{:else}
|
|
2007
|
+
{#each secondaryFields(row) as field (field.column.id)}
|
|
2008
|
+
<div>
|
|
2009
|
+
<p class="text-[11px] font-semibold text-muted uppercase">
|
|
2010
|
+
{field.column.header}
|
|
2011
|
+
</p>
|
|
2012
|
+
<p class="text-sm text-primary">{field.text}</p>
|
|
2013
|
+
</div>
|
|
2014
|
+
{/each}
|
|
2015
|
+
{/if}
|
|
2016
|
+
</div>
|
|
2017
|
+
{/if}
|
|
2018
|
+
</button>
|
|
2019
|
+
</div>
|
|
2020
|
+
{:else}
|
|
2021
|
+
<Card
|
|
2022
|
+
variant="bordered"
|
|
2023
|
+
padding="sm"
|
|
2024
|
+
hoverable
|
|
2025
|
+
class={rowSelected ? 'ring-2 ring-brand-500/50' : ''}
|
|
2026
|
+
onclick={() => {
|
|
2027
|
+
if (selectable && selectOnClick) setSelection({ type: 'rows', ids: [rid] });
|
|
2028
|
+
emitRowClick(rid);
|
|
2029
|
+
}}
|
|
2030
|
+
>
|
|
2031
|
+
{#snippet header()}
|
|
2032
|
+
<div class="flex items-start justify-between gap-2">
|
|
2033
|
+
<p class="truncate text-sm font-semibold text-primary">{title}</p>
|
|
2034
|
+
{#if collapsibleEnabled}
|
|
2035
|
+
<button
|
|
2036
|
+
type="button"
|
|
2037
|
+
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-secondary hover:bg-surface-overlay"
|
|
2038
|
+
aria-expanded={rowExpanded}
|
|
2039
|
+
onclick={(e) => {
|
|
2040
|
+
e.stopPropagation();
|
|
2041
|
+
toggleExpand(rid);
|
|
2042
|
+
}}
|
|
2043
|
+
>
|
|
2044
|
+
<ChevronRight
|
|
2045
|
+
class={['h-4 w-4 transition-transform', rowExpanded && 'rotate-90']}
|
|
2046
|
+
strokeWidth={2}
|
|
2047
|
+
/>
|
|
2048
|
+
</button>
|
|
2049
|
+
{/if}
|
|
2050
|
+
</div>
|
|
2051
|
+
{/snippet}
|
|
2052
|
+
<div class="space-y-1.5">
|
|
2053
|
+
{#each secondaryFields(row).slice(0, viewMode === 'grid' ? 3 : 5) as field (field.column.id)}
|
|
2054
|
+
<div class="flex items-baseline justify-between gap-2 text-xs">
|
|
2055
|
+
<span class="text-muted">{field.column.header}</span>
|
|
2056
|
+
<span class="truncate font-medium text-primary">{field.text}</span>
|
|
2057
|
+
</div>
|
|
2058
|
+
{/each}
|
|
2059
|
+
{#if rowExpanded}
|
|
2060
|
+
<div class="border-t border-border pt-2">
|
|
2061
|
+
{#if expandedRow}
|
|
2062
|
+
{@render expandedRow(row, autoHiddenColumns)}
|
|
2063
|
+
{:else}
|
|
2064
|
+
{#each autoHiddenColumns as col (col.id)}
|
|
2065
|
+
<div class="flex justify-between gap-2 py-0.5 text-xs">
|
|
2066
|
+
<span class="text-muted">{col.header}</span>
|
|
2067
|
+
<span class="text-primary">{formatCell(getValue(row, col))}</span>
|
|
2068
|
+
</div>
|
|
2069
|
+
{/each}
|
|
2070
|
+
{/if}
|
|
2071
|
+
</div>
|
|
2072
|
+
{/if}
|
|
2073
|
+
</div>
|
|
2074
|
+
</Card>
|
|
2075
|
+
{/if}
|
|
2076
|
+
{/each}
|
|
2077
|
+
{/if}
|
|
2078
|
+
</div>
|
|
2079
|
+
{/if}
|
|
2080
|
+
|
|
2081
|
+
{#if noteTargets || markPickerOpen || (showDock && dockCount > 0)}
|
|
2082
|
+
<!-- Fixed to the viewport (portaled) so parent relative/overflow don't shift the dock. -->
|
|
2083
|
+
<div
|
|
2084
|
+
use:portalToBody
|
|
2085
|
+
class="pointer-events-none fixed inset-x-0 bottom-3 z-[60] flex justify-center px-3"
|
|
2086
|
+
>
|
|
2087
|
+
<div class="relative w-full max-w-2xl">
|
|
2088
|
+
{#if noteTargets}
|
|
2089
|
+
{@const count = noteTargets.length}
|
|
2090
|
+
{@const existingCount = noteTargets.filter((t) =>
|
|
2091
|
+
notesMap.has(noteKey(t.rowId, t.columnId))
|
|
2092
|
+
).length}
|
|
2093
|
+
<div
|
|
2094
|
+
class="pointer-events-auto absolute bottom-full left-1/2 z-50 mb-2 w-72 -translate-x-1/2 rounded-xl border border-border bg-surface-elevated p-3 shadow-xl"
|
|
2095
|
+
role="dialog"
|
|
2096
|
+
aria-label={existingCount ? 'Edit note' : 'Add note'}
|
|
2097
|
+
>
|
|
2098
|
+
<p class="text-xs font-semibold text-primary">
|
|
2099
|
+
{existingCount ? 'Edit note' : 'Add note'}
|
|
2100
|
+
</p>
|
|
2101
|
+
<p class="mt-0.5 text-[11px] text-muted">
|
|
2102
|
+
{#if count === 1}
|
|
2103
|
+
{noteTargets[0].rowId} / {noteTargets[0].columnId}
|
|
2104
|
+
{:else}
|
|
2105
|
+
{count} cells
|
|
2106
|
+
{/if}
|
|
2107
|
+
</p>
|
|
2108
|
+
<textarea
|
|
2109
|
+
class="mt-2 h-24 w-full rounded-lg border border-border bg-surface px-2 py-1.5 text-xs"
|
|
2110
|
+
bind:value={noteDraft}
|
|
2111
|
+
placeholder="Write a note…"
|
|
2112
|
+
></textarea>
|
|
2113
|
+
<div class="mt-2 flex justify-end gap-1.5">
|
|
2114
|
+
<Button size="xs" variant="ghost" onclick={closeNoteEditor}>Cancel</Button>
|
|
2115
|
+
{#if existingCount}
|
|
2116
|
+
<Button
|
|
2117
|
+
size="xs"
|
|
2118
|
+
variant="ghost"
|
|
2119
|
+
onclick={() => {
|
|
2120
|
+
noteDraft = '';
|
|
2121
|
+
saveNotes();
|
|
2122
|
+
}}>Delete</Button
|
|
2123
|
+
>
|
|
2124
|
+
{/if}
|
|
2125
|
+
<Button size="xs" variant="primary" onclick={saveNotes}>Save</Button>
|
|
2126
|
+
</div>
|
|
2127
|
+
</div>
|
|
2128
|
+
{:else if markPickerOpen && markTargets}
|
|
2129
|
+
{@const count = markTargets.length}
|
|
2130
|
+
{@const existingCount = markTargets.filter((t) =>
|
|
2131
|
+
marksMap.has(markKey(t.rowId, t.columnId))
|
|
2132
|
+
).length}
|
|
2133
|
+
<div
|
|
2134
|
+
class="pointer-events-auto absolute bottom-full left-1/2 z-50 mb-2 w-72 -translate-x-1/2 rounded-xl border border-border bg-surface-elevated p-3 shadow-xl"
|
|
2135
|
+
role="dialog"
|
|
2136
|
+
aria-label="Mark color"
|
|
2137
|
+
>
|
|
2138
|
+
<p class="text-xs font-semibold text-primary">Mark</p>
|
|
2139
|
+
<p class="mt-0.5 text-[11px] text-muted">
|
|
2140
|
+
{#if count === 1}
|
|
2141
|
+
{#if markTargets[0].columnId}
|
|
2142
|
+
{markTargets[0].rowId} / {markTargets[0].columnId}
|
|
2143
|
+
{:else}
|
|
2144
|
+
Row {markTargets[0].rowId}
|
|
2145
|
+
{/if}
|
|
2146
|
+
{:else}
|
|
2147
|
+
{count} targets
|
|
2148
|
+
{/if}
|
|
2149
|
+
</p>
|
|
2150
|
+
<div class="mt-3 flex flex-wrap items-center justify-center gap-2">
|
|
2151
|
+
{#each MARK_COLORS as color (color)}
|
|
2152
|
+
<button
|
|
2153
|
+
type="button"
|
|
2154
|
+
class="h-7 w-7 rounded-full border-2 border-white shadow ring-1 ring-black/10 transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
|
2155
|
+
style="background:{markSwatch(color)}"
|
|
2156
|
+
aria-label={`Mark ${color}`}
|
|
2157
|
+
onclick={() => applyMarkColor(color)}
|
|
2158
|
+
></button>
|
|
2159
|
+
{/each}
|
|
2160
|
+
</div>
|
|
2161
|
+
<div class="mt-3 flex items-end gap-2">
|
|
2162
|
+
<div class="min-w-0 flex-1">
|
|
2163
|
+
<ColorPicker
|
|
2164
|
+
label="Custom"
|
|
2165
|
+
bind:value={markCustomColor}
|
|
2166
|
+
format="hex"
|
|
2167
|
+
showSwatches={false}
|
|
2168
|
+
/>
|
|
2169
|
+
</div>
|
|
2170
|
+
<Button size="xs" variant="secondary" onclick={applyCustomMarkColor}
|
|
2171
|
+
>Apply</Button
|
|
2172
|
+
>
|
|
2173
|
+
</div>
|
|
2174
|
+
<div class="mt-3 flex justify-end gap-1.5">
|
|
2175
|
+
<Button size="xs" variant="ghost" onclick={closeMarkPicker}>Cancel</Button>
|
|
2176
|
+
{#if existingCount}
|
|
2177
|
+
<Button size="xs" variant="ghost" onclick={() => clearMarksForTargets(markTargets ?? undefined)}
|
|
2178
|
+
>Clear</Button
|
|
2179
|
+
>
|
|
2180
|
+
{/if}
|
|
2181
|
+
</div>
|
|
2182
|
+
</div>
|
|
2183
|
+
{/if}
|
|
2184
|
+
|
|
2185
|
+
{#if showDock && dockCount > 0}
|
|
2186
|
+
<div class="pointer-events-auto">
|
|
2187
|
+
<BulkActionBar
|
|
2188
|
+
count={dockCount}
|
|
2189
|
+
total={selection.type === 'rows' ? rowIds.length : undefined}
|
|
2190
|
+
itemLabel={dockLabels.itemLabel}
|
|
2191
|
+
itemLabelPlural={dockLabels.itemLabelPlural}
|
|
2192
|
+
placement="dock"
|
|
2193
|
+
sticky={false}
|
|
2194
|
+
actions={defaultActions}
|
|
2195
|
+
{leading}
|
|
2196
|
+
extra={dockExtra}
|
|
2197
|
+
onaction={handleDockAction}
|
|
2198
|
+
onclear={clearSelection}
|
|
2199
|
+
onselectall={selection.type === 'rows'
|
|
2200
|
+
? () => setSelection({ type: 'rows', ids: [...rowIds] })
|
|
2201
|
+
: undefined}
|
|
2202
|
+
/>
|
|
2203
|
+
</div>
|
|
2204
|
+
{:else}
|
|
2205
|
+
<!-- Anchor so the note/mark popover sits above the dock baseline. -->
|
|
2206
|
+
<div class="h-0 w-full" aria-hidden="true"></div>
|
|
2207
|
+
{/if}
|
|
2208
|
+
</div>
|
|
2209
|
+
</div>
|
|
2210
|
+
{/if}
|
|
2211
|
+
</div>
|
|
2212
|
+
{/snippet}
|
|
2213
|
+
|
|
2214
|
+
{#if contextMenuEnabled}
|
|
2215
|
+
{@render gridBody()}
|
|
2216
|
+
{#if contextOpen && contextAnchor}
|
|
2217
|
+
{@const ax = contextAnchor.x}
|
|
2218
|
+
{@const ay = contextAnchor.y}
|
|
2219
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
2220
|
+
<div
|
|
2221
|
+
use:portalToBody
|
|
2222
|
+
role="menu"
|
|
2223
|
+
tabindex={-1}
|
|
2224
|
+
aria-label="Context menu"
|
|
2225
|
+
style={`position:fixed;top:${ay}px;left:${ax}px;z-index:9999;`}
|
|
2226
|
+
class="min-w-44 rounded-xl border border-border bg-surface-elevated p-1.5 shadow-xl outline-none"
|
|
2227
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
2228
|
+
>
|
|
2229
|
+
{#each contextMenuItems as item (item.id)}
|
|
2230
|
+
{#if item.separator}
|
|
2231
|
+
<div class="my-1 h-px bg-border" role="separator"></div>
|
|
2232
|
+
{:else}
|
|
2233
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
2234
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
2235
|
+
<div
|
|
2236
|
+
role="menuitem"
|
|
2237
|
+
tabindex={-1}
|
|
2238
|
+
aria-disabled={item.disabled || undefined}
|
|
2239
|
+
onclick={() => {
|
|
2240
|
+
if (item.disabled) return;
|
|
2241
|
+
onContextMenuPick(item.id);
|
|
2242
|
+
}}
|
|
2243
|
+
class={[
|
|
2244
|
+
'flex cursor-pointer items-center justify-between gap-4 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors hover:bg-surface-overlay',
|
|
2245
|
+
item.disabled && 'cursor-not-allowed opacity-40',
|
|
2246
|
+
item.destructive ? 'text-red-600 dark:text-red-400' : 'text-primary'
|
|
2247
|
+
]}
|
|
2248
|
+
>
|
|
2249
|
+
<span class="truncate">{item.label}</span>
|
|
2250
|
+
{#if item.shortcut}
|
|
2251
|
+
<kbd
|
|
2252
|
+
class="shrink-0 rounded border border-border bg-surface-overlay px-1.5 py-0.5 font-mono text-[10px] text-secondary"
|
|
2253
|
+
>
|
|
2254
|
+
{item.shortcut}
|
|
2255
|
+
</kbd>
|
|
2256
|
+
{/if}
|
|
2257
|
+
</div>
|
|
2258
|
+
{/if}
|
|
2259
|
+
{/each}
|
|
2260
|
+
</div>
|
|
2261
|
+
{/if}
|
|
2262
|
+
{:else}
|
|
2263
|
+
{@render gridBody()}
|
|
2264
|
+
{/if}
|