@svgrid/grid 3.0.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/SvGrid.controller.svelte.d.ts +12 -2
- package/dist/SvGrid.controller.svelte.js +191 -8
- package/dist/SvGrid.css +73 -0
- package/dist/SvGrid.svelte +82 -3
- package/dist/SvGrid.types.d.ts +105 -3
- package/dist/build-api.js +11 -6
- package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-CLI93jke.js} +26 -26
- package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CwooL3QA.js} +26 -26
- package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
- package/dist/cdn/{src-BKhZ6eXd.js → src-B6HXdfHY.js} +4099 -4140
- package/dist/cdn/{src-V1uu8iE9.js → src-C6Iowdvs.js} +4052 -4093
- package/dist/cdn/svgrid.js +27 -26
- package/dist/cdn/svgrid.svelte-external.js +27 -26
- package/dist/cdn/validate-CchXzyrX.js +76 -0
- package/dist/core.js +36 -13
- package/dist/editing.js +10 -0
- package/dist/grid-icons.d.ts +2 -2
- package/dist/grid-icons.js +2 -2
- package/dist/grid-messages.d.ts +2 -0
- package/dist/grid-messages.js +2 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -3
- package/dist/row-model.d.ts +133 -0
- package/dist/row-model.js +16 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +30 -2
- package/dist/server-block-cache.d.ts +214 -0
- package/dist/server-block-cache.js +531 -0
- package/dist/server-data-source.d.ts +240 -6
- package/dist/server-data-source.js +220 -20
- package/dist/server.d.ts +13 -0
- package/dist/server.js +13 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +8 -3
- package/package.json +6 -1
- package/src/SvGrid.controller.svelte.ts +190 -8
- package/src/SvGrid.css +73 -0
- package/src/SvGrid.svelte +82 -3
- package/src/SvGrid.types.ts +105 -3
- package/src/build-api.ts +11 -4
- package/src/core.rowmodel-cache.test.ts +51 -0
- package/src/core.ts +33 -12
- package/src/editing.ts +8 -0
- package/src/grid-icons.ts +2 -2
- package/src/grid-messages.ts +5 -0
- package/src/icon-seam.test.ts +2 -4
- package/src/index.ts +19 -10
- package/src/row-model.ts +146 -0
- package/src/selection.test.ts +3 -0
- package/src/selection.ts +32 -2
- package/src/server-block-cache.test.ts +645 -0
- package/src/server-block-cache.ts +677 -0
- package/src/server-data-source.infinite.test.ts +343 -0
- package/src/server-data-source.ts +450 -27
- package/src/server.ts +47 -0
- package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
- package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
- package/src/validate.test.ts +10 -0
- package/src/validate.ts +10 -3
- package/dist/SvGroupCell.svelte +0 -141
- package/dist/SvGroupCell.svelte.d.ts +0 -49
- package/dist/SvRowGroupPanel.svelte +0 -186
- package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
- package/dist/cdn/validate-_CDJzgIo.js +0 -75
- package/dist/server-group-model.d.ts +0 -98
- package/dist/server-group-model.js +0 -263
- package/src/SvGroupCell.svelte +0 -141
- package/src/SvRowGroupPanel.svelte +0 -186
- package/src/server-group-model.test.ts +0 -294
- package/src/server-group-model.ts +0 -370
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
export function serverGroupRows(state) {
|
|
2
|
-
return (state?.displayRows ?? []).map((r) => ({ ...('data' in r ? r.data : {}), __group: r }));
|
|
3
|
-
}
|
|
4
|
-
/**
|
|
5
|
-
* Build the `serverGroup` prop for `<SvGrid>` from a controller, so the grid's
|
|
6
|
-
* built-in tree keyboard (ArrowRight / ArrowLeft expand / collapse) and treegrid
|
|
7
|
-
* a11y work with no app-level key handling. Reads the `__group` marker that
|
|
8
|
-
* `serverGroupRows` attaches. Usage: `<SvGrid ... serverGroup={serverGroupNav(ctl)} />`.
|
|
9
|
-
*/
|
|
10
|
-
export function serverGroupNav(ctl) {
|
|
11
|
-
return {
|
|
12
|
-
isGroup: (row) => row.__group?.kind === 'group',
|
|
13
|
-
level: (row) => row.__group?.level ?? 0,
|
|
14
|
-
expanded: (row) => row.__group?.kind === 'group' ? row.__group.expanded : false,
|
|
15
|
-
onToggle: (row) => {
|
|
16
|
-
if (row.__group?.kind === 'group')
|
|
17
|
-
ctl.toggleGroup(row.__group);
|
|
18
|
-
else if (row.__group?.kind === 'more')
|
|
19
|
-
ctl.loadMoreChildren(row.__group.path);
|
|
20
|
-
},
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
const keyOf = (path) => JSON.stringify(path);
|
|
24
|
-
export function createServerGroupModel(source, options) {
|
|
25
|
-
let groupBy = [...(options.groupBy ?? [])];
|
|
26
|
-
const aggregations = options.aggregations ?? [];
|
|
27
|
-
const pageSize = options.pageSize ?? 200;
|
|
28
|
-
const treeData = !!options.treeData;
|
|
29
|
-
if (treeData && (!options.getRowId || !options.hasChildren)) {
|
|
30
|
-
throw new Error('createServerGroupModel: treeData requires getRowId and hasChildren');
|
|
31
|
-
}
|
|
32
|
-
const getRowId = options.getRowId;
|
|
33
|
-
const hasChildren = options.hasChildren;
|
|
34
|
-
const groupFooters = !!options.groupFooters;
|
|
35
|
-
const skeletonRows = Math.max(0, options.skeletonRows ?? 3);
|
|
36
|
-
let sortModel = [];
|
|
37
|
-
let filterModel = {};
|
|
38
|
-
const nodes = new Map();
|
|
39
|
-
const expanded = new Set(); // path keys
|
|
40
|
-
// Generation guard: bumped on any tree reset so every fetch in flight for the
|
|
41
|
-
// previous shape is discarded when it lands (the classic stale-response bug).
|
|
42
|
-
let generation = 0;
|
|
43
|
-
let disposed = false;
|
|
44
|
-
const rootKey = keyOf([]);
|
|
45
|
-
function ensureNode(key) {
|
|
46
|
-
let node = nodes.get(key);
|
|
47
|
-
if (!node) {
|
|
48
|
-
node = { loaded: false, loading: false, error: null, count: 0, children: [] };
|
|
49
|
-
nodes.set(key, node);
|
|
50
|
-
}
|
|
51
|
-
return node;
|
|
52
|
-
}
|
|
53
|
-
// Walk the loaded tree from the root, splicing expanded groups' children in.
|
|
54
|
-
function flatten() {
|
|
55
|
-
const out = [];
|
|
56
|
-
const walk = (path) => {
|
|
57
|
-
const node = nodes.get(keyOf(path));
|
|
58
|
-
if (!node?.loaded)
|
|
59
|
-
return;
|
|
60
|
-
for (const child of node.children) {
|
|
61
|
-
if (child.kind === 'leaf') {
|
|
62
|
-
out.push({ kind: 'leaf', id: child.id, level: child.level, data: child.data });
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const cid = keyOf(child.path);
|
|
66
|
-
const cnode = nodes.get(cid);
|
|
67
|
-
const isExp = expanded.has(cid);
|
|
68
|
-
out.push({
|
|
69
|
-
kind: 'group',
|
|
70
|
-
id: cid,
|
|
71
|
-
path: child.path,
|
|
72
|
-
field: child.field,
|
|
73
|
-
key: child.key,
|
|
74
|
-
level: child.level,
|
|
75
|
-
expanded: isExp,
|
|
76
|
-
loading: !!cnode?.loading,
|
|
77
|
-
aggregates: child.aggregates,
|
|
78
|
-
data: child.data,
|
|
79
|
-
});
|
|
80
|
-
if (isExp) {
|
|
81
|
-
if (cnode?.loaded) {
|
|
82
|
-
walk(child.path);
|
|
83
|
-
// Subtotal row after the group's children.
|
|
84
|
-
if (groupFooters) {
|
|
85
|
-
out.push({
|
|
86
|
-
kind: 'footer',
|
|
87
|
-
id: cid + 'footer',
|
|
88
|
-
level: child.level + 1,
|
|
89
|
-
path: child.path,
|
|
90
|
-
key: child.key,
|
|
91
|
-
aggregates: child.aggregates,
|
|
92
|
-
data: child.data,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
else if (cnode?.loading) {
|
|
97
|
-
// First block in flight: placeholder skeleton rows.
|
|
98
|
-
for (let i = 0; i < skeletonRows; i += 1) {
|
|
99
|
-
out.push({ kind: 'skeleton', id: cid + 'sk' + i, level: child.level + 1 });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
// Intra-group paging: emit a "load more" affordance when only part of this
|
|
105
|
-
// node's children have been fetched.
|
|
106
|
-
if (node.children.length < node.count) {
|
|
107
|
-
out.push({
|
|
108
|
-
kind: 'more',
|
|
109
|
-
id: keyOf([...path, 'more']),
|
|
110
|
-
level: path.length,
|
|
111
|
-
path,
|
|
112
|
-
remaining: node.count - node.children.length,
|
|
113
|
-
loading: node.loading,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
walk([]);
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
function emit() {
|
|
121
|
-
if (disposed)
|
|
122
|
-
return;
|
|
123
|
-
const root = nodes.get(rootKey);
|
|
124
|
-
options.onChange({
|
|
125
|
-
displayRows: flatten(),
|
|
126
|
-
groupBy: [...groupBy],
|
|
127
|
-
loading: !!root?.loading && !root?.loaded,
|
|
128
|
-
error: root?.error ?? null,
|
|
129
|
-
sortModel,
|
|
130
|
-
filterModel,
|
|
131
|
-
expandedGroups: [...expanded],
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
// append=false loads the first block of a node's children (on expand);
|
|
135
|
-
// append=true fetches the NEXT block and concatenates (intra-group paging).
|
|
136
|
-
async function fetchNode(path, append = false) {
|
|
137
|
-
if (disposed)
|
|
138
|
-
return;
|
|
139
|
-
const gen = generation;
|
|
140
|
-
const key = keyOf(path);
|
|
141
|
-
const node = ensureNode(key);
|
|
142
|
-
if (node.loading)
|
|
143
|
-
return; // a fetch for this node is already in flight
|
|
144
|
-
node.loading = true;
|
|
145
|
-
node.error = null;
|
|
146
|
-
emit();
|
|
147
|
-
const level = path.length;
|
|
148
|
-
const groupField = groupBy[level];
|
|
149
|
-
const startRow = append ? node.children.length : 0;
|
|
150
|
-
try {
|
|
151
|
-
const res = await source.getRows({
|
|
152
|
-
startRow,
|
|
153
|
-
endRow: startRow + pageSize,
|
|
154
|
-
pageIndex: Math.floor(startRow / pageSize),
|
|
155
|
-
pageSize,
|
|
156
|
-
sortModel,
|
|
157
|
-
filterModel,
|
|
158
|
-
groupBy,
|
|
159
|
-
groupKeys: path,
|
|
160
|
-
aggregations,
|
|
161
|
-
});
|
|
162
|
-
if (disposed || gen !== generation)
|
|
163
|
-
return; // stale: the tree was reset
|
|
164
|
-
node.count = res.rowCount;
|
|
165
|
-
const block = res.rows.map((data, i) => {
|
|
166
|
-
// A row is an expandable "group" when: (tree mode) the source says it has
|
|
167
|
-
// children, or (grouping mode) we are above the innermost groupBy level.
|
|
168
|
-
const isGroup = treeData ? hasChildren(data) : level < groupBy.length;
|
|
169
|
-
if (isGroup) {
|
|
170
|
-
const gk = treeData
|
|
171
|
-
? getRowId(data)
|
|
172
|
-
: ((v) => (v == null ? '' : String(v)))(data[groupField]);
|
|
173
|
-
const aggregates = {};
|
|
174
|
-
for (const a of aggregations)
|
|
175
|
-
aggregates[a.col] = data[a.col];
|
|
176
|
-
return { kind: 'group', path: [...path, gk], field: treeData ? '' : groupField, key: gk, level, aggregates, data };
|
|
177
|
-
}
|
|
178
|
-
const id = treeData && getRowId ? `${key}:${getRowId(data)}` : `${key}:${startRow + i}`;
|
|
179
|
-
return { kind: 'leaf', id, level, data };
|
|
180
|
-
});
|
|
181
|
-
node.children = append ? [...node.children, ...block] : block;
|
|
182
|
-
node.loaded = true;
|
|
183
|
-
node.loading = false;
|
|
184
|
-
emit();
|
|
185
|
-
}
|
|
186
|
-
catch (err) {
|
|
187
|
-
if (disposed || gen !== generation)
|
|
188
|
-
return;
|
|
189
|
-
node.error = err;
|
|
190
|
-
if (!append)
|
|
191
|
-
node.loaded = false;
|
|
192
|
-
node.loading = false;
|
|
193
|
-
emit();
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
// Clear the cache and reload the root plus any still-expanded paths (in
|
|
197
|
-
// parallel; flatten only descends into loaded nodes, so children appear as
|
|
198
|
-
// each level lands).
|
|
199
|
-
function resetTree() {
|
|
200
|
-
generation += 1;
|
|
201
|
-
nodes.clear();
|
|
202
|
-
void fetchNode([]);
|
|
203
|
-
for (const k of expanded)
|
|
204
|
-
void fetchNode(JSON.parse(k));
|
|
205
|
-
}
|
|
206
|
-
function expandGroup(path) {
|
|
207
|
-
const key = keyOf(path);
|
|
208
|
-
if (expanded.has(key))
|
|
209
|
-
return;
|
|
210
|
-
expanded.add(key);
|
|
211
|
-
const node = nodes.get(key);
|
|
212
|
-
if (!node?.loaded && !node?.loading)
|
|
213
|
-
void fetchNode(path);
|
|
214
|
-
else
|
|
215
|
-
emit();
|
|
216
|
-
}
|
|
217
|
-
function collapseGroup(path) {
|
|
218
|
-
if (expanded.delete(keyOf(path)))
|
|
219
|
-
emit();
|
|
220
|
-
}
|
|
221
|
-
return {
|
|
222
|
-
refresh: resetTree,
|
|
223
|
-
setSort(next) {
|
|
224
|
-
sortModel = next;
|
|
225
|
-
resetTree();
|
|
226
|
-
},
|
|
227
|
-
setFilter(next) {
|
|
228
|
-
filterModel = next;
|
|
229
|
-
resetTree();
|
|
230
|
-
},
|
|
231
|
-
setGroupBy(next) {
|
|
232
|
-
groupBy = [...next];
|
|
233
|
-
expanded.clear();
|
|
234
|
-
resetTree();
|
|
235
|
-
},
|
|
236
|
-
toggleGroup(row) {
|
|
237
|
-
if (expanded.has(row.id))
|
|
238
|
-
collapseGroup(row.path);
|
|
239
|
-
else
|
|
240
|
-
expandGroup(row.path);
|
|
241
|
-
},
|
|
242
|
-
expandGroup,
|
|
243
|
-
collapseGroup,
|
|
244
|
-
loadMoreChildren: (path) => void fetchNode(path, true),
|
|
245
|
-
isExpanded: (path) => expanded.has(keyOf(path)),
|
|
246
|
-
getState() {
|
|
247
|
-
const root = nodes.get(rootKey);
|
|
248
|
-
return {
|
|
249
|
-
displayRows: flatten(),
|
|
250
|
-
groupBy: [...groupBy],
|
|
251
|
-
loading: !!root?.loading && !root?.loaded,
|
|
252
|
-
error: root?.error ?? null,
|
|
253
|
-
sortModel,
|
|
254
|
-
filterModel,
|
|
255
|
-
expandedGroups: [...expanded],
|
|
256
|
-
};
|
|
257
|
-
},
|
|
258
|
-
dispose() {
|
|
259
|
-
disposed = true;
|
|
260
|
-
generation += 1;
|
|
261
|
-
},
|
|
262
|
-
};
|
|
263
|
-
}
|
package/src/SvGroupCell.svelte
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
Built-in group cell for the server-side group/tree model. Drop it into the
|
|
3
|
-
grouping column and it draws the expander + indentation for group rows (and an
|
|
4
|
-
indented leaf value) - no hand-written cell recipe. Pair with `serverGroupRows`
|
|
5
|
-
to feed `<SvGrid data>`, and wire `onToggle` to the controller's `toggleGroup`.
|
|
6
|
-
|
|
7
|
-
cell: (ctx) => renderComponent(SvGroupCell, {
|
|
8
|
-
row: ctx.row.original, onToggle: ctl.toggleGroup, leafField: 'name',
|
|
9
|
-
})
|
|
10
|
-
-->
|
|
11
|
-
<script lang="ts" generics="TData">
|
|
12
|
-
import type { ServerDisplayRow } from './server-data-source'
|
|
13
|
-
import type { GridIcons } from './grid-icons'
|
|
14
|
-
|
|
15
|
-
type GroupGridRow = { __group?: ServerDisplayRow<TData> } & Record<string, unknown>
|
|
16
|
-
type Props = {
|
|
17
|
-
/** The grid row from `serverGroupRows` (carries the `__group` marker). */
|
|
18
|
-
row: GroupGridRow
|
|
19
|
-
/**
|
|
20
|
-
* Expand / collapse a group, or load more children. Receives the grid row;
|
|
21
|
-
* wire to `serverGroupNav(ctl).onToggle` (which drives clicks and keyboard).
|
|
22
|
-
*/
|
|
23
|
-
onToggle: (row: GroupGridRow) => void
|
|
24
|
-
/** Field to show for leaf rows in the group column (blank if omitted). */
|
|
25
|
-
leafField?: string
|
|
26
|
-
/** Pixels of indentation per level. Default 18. */
|
|
27
|
-
indent?: number
|
|
28
|
-
/**
|
|
29
|
-
* Icon overrides, the same map `<SvGrid icons>` takes. Only
|
|
30
|
-
* `chevron-right` is drawn here. You mount this component yourself, so it
|
|
31
|
-
* cannot inherit the grid's icons - pass the same object to both to keep
|
|
32
|
-
* the expander consistent with the rest of the chrome.
|
|
33
|
-
*/
|
|
34
|
-
icons?: GridIcons
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
let { row, onToggle, leafField, indent = 18, icons }: Props = $props()
|
|
38
|
-
const chevron = $derived(icons?.['chevron-right'])
|
|
39
|
-
const meta = $derived(row.__group)
|
|
40
|
-
</script>
|
|
41
|
-
|
|
42
|
-
{#if meta && meta.kind === 'group'}
|
|
43
|
-
<button
|
|
44
|
-
type="button"
|
|
45
|
-
class="sv-group-cell"
|
|
46
|
-
style={`padding-inline-start: ${meta.level * indent}px`}
|
|
47
|
-
aria-expanded={meta.expanded}
|
|
48
|
-
aria-label={meta.expanded ? 'Collapse group' : 'Expand group'}
|
|
49
|
-
onclick={() => onToggle(row)}
|
|
50
|
-
>
|
|
51
|
-
<!-- Same chevron the grid's own expanders and SvTree draw, so every
|
|
52
|
-
expander in the library looks identical. -->
|
|
53
|
-
<span class="sv-group-chev" class:open={meta.expanded} aria-hidden="true">
|
|
54
|
-
{#if chevron}
|
|
55
|
-
{@render chevron()}
|
|
56
|
-
{:else}
|
|
57
|
-
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
|
|
58
|
-
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
|
|
59
|
-
{/if}
|
|
60
|
-
</span>
|
|
61
|
-
<span class="sv-group-key">{meta.key}</span>
|
|
62
|
-
{#if meta.loading}<span class="sv-group-load" aria-hidden="true">…</span>{/if}
|
|
63
|
-
</button>
|
|
64
|
-
{:else if meta && meta.kind === 'more'}
|
|
65
|
-
<button
|
|
66
|
-
type="button"
|
|
67
|
-
class="sv-group-more"
|
|
68
|
-
style={`padding-inline-start: ${meta.level * indent}px`}
|
|
69
|
-
disabled={meta.loading}
|
|
70
|
-
onclick={() => onToggle(row)}
|
|
71
|
-
>
|
|
72
|
-
{meta.loading ? 'Loading...' : `Load ${meta.remaining} more`}
|
|
73
|
-
</button>
|
|
74
|
-
{:else if meta && meta.kind === 'footer'}
|
|
75
|
-
<span class="sv-group-footer" style={`padding-inline-start: ${meta.level * indent}px`}>Total</span>
|
|
76
|
-
{:else if meta && meta.kind === 'skeleton'}
|
|
77
|
-
<span class="sv-group-skeleton" style={`margin-inline-start: ${meta.level * indent}px`} aria-hidden="true"></span>
|
|
78
|
-
{:else if meta}
|
|
79
|
-
<span class="sv-group-leaf" style={`padding-inline-start: ${(meta.level + 1) * indent}px`}>
|
|
80
|
-
{leafField ? (row[leafField] ?? '') : ''}
|
|
81
|
-
</span>
|
|
82
|
-
{/if}
|
|
83
|
-
|
|
84
|
-
<style>
|
|
85
|
-
.sv-group-cell {
|
|
86
|
-
display: inline-flex;
|
|
87
|
-
align-items: center;
|
|
88
|
-
gap: 6px;
|
|
89
|
-
background: none;
|
|
90
|
-
border: 0;
|
|
91
|
-
padding: 0;
|
|
92
|
-
font: inherit;
|
|
93
|
-
color: inherit;
|
|
94
|
-
cursor: pointer;
|
|
95
|
-
}
|
|
96
|
-
.sv-group-chev {
|
|
97
|
-
display: inline-flex;
|
|
98
|
-
align-items: center;
|
|
99
|
-
justify-content: center;
|
|
100
|
-
width: 12px;
|
|
101
|
-
height: 12px;
|
|
102
|
-
transition: transform 0.15s;
|
|
103
|
-
color: var(--sg-muted, #64748b);
|
|
104
|
-
}
|
|
105
|
-
/* An `icons` override renders inside this wrapper, and this style block is
|
|
106
|
-
scoped, so its markup needs :global() to be reached. Sizing it here means
|
|
107
|
-
a custom chevron lands in the same box as the built-in and rotates with
|
|
108
|
-
the wrapper rather than needing to know the rule exists. */
|
|
109
|
-
.sv-group-chev :global(svg),
|
|
110
|
-
.sv-group-chev :global(img) {
|
|
111
|
-
width: 100%;
|
|
112
|
-
height: 100%;
|
|
113
|
-
display: block;
|
|
114
|
-
}
|
|
115
|
-
.sv-group-chev.open { transform: rotate(90deg); }
|
|
116
|
-
.sv-group-key { font-weight: 600; }
|
|
117
|
-
.sv-group-load { color: var(--sg-muted, #64748b); font-size: 12px; }
|
|
118
|
-
.sv-group-leaf { color: var(--sg-muted, #475569); }
|
|
119
|
-
.sv-group-more {
|
|
120
|
-
background: none;
|
|
121
|
-
border: 0;
|
|
122
|
-
padding: 0;
|
|
123
|
-
font: inherit;
|
|
124
|
-
font-size: 13px;
|
|
125
|
-
color: var(--sg-accent, #2563eb);
|
|
126
|
-
cursor: pointer;
|
|
127
|
-
}
|
|
128
|
-
.sv-group-more:disabled { color: var(--sg-muted, #64748b); cursor: default; }
|
|
129
|
-
.sv-group-footer { font-weight: 600; color: var(--sg-muted, #64748b); font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
130
|
-
.sv-group-skeleton {
|
|
131
|
-
display: inline-block;
|
|
132
|
-
width: 60%;
|
|
133
|
-
height: 10px;
|
|
134
|
-
border-radius: 5px;
|
|
135
|
-
background: linear-gradient(90deg, var(--sg-border, #e2e8f0) 25%, var(--sg-header-bg, #f1f5f9) 50%, var(--sg-border, #e2e8f0) 75%);
|
|
136
|
-
background-size: 200% 100%;
|
|
137
|
-
animation: sv-group-shimmer 1.2s infinite;
|
|
138
|
-
}
|
|
139
|
-
@keyframes sv-group-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
|
140
|
-
@media (prefers-reduced-motion: reduce) { .sv-group-skeleton { animation: none; } }
|
|
141
|
-
</style>
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
A row-group panel (like the "drag columns to group" bar in enterprise grids).
|
|
3
|
-
Shows the current group-by columns as chips you can remove or drag to reorder,
|
|
4
|
-
plus a "+ Group by" menu to add one. It is also a drop target: a column drag
|
|
5
|
-
that carries `text/sv-column` (its column id) adds that column. Wire `onChange`
|
|
6
|
-
to a server group model's `setGroupBy`, or to client grouping.
|
|
7
|
-
|
|
8
|
-
<SvRowGroupPanel
|
|
9
|
-
columns={[{ id: 'region', label: 'Region' }, { id: 'country', label: 'Country' }]}
|
|
10
|
-
groupBy={view.groupBy}
|
|
11
|
-
onChange={(g) => ctl.setGroupBy(g)}
|
|
12
|
-
/>
|
|
13
|
-
-->
|
|
14
|
-
<script lang="ts">
|
|
15
|
-
import { GRID_ICON_GLYPHS, type GridIconName, type GridIcons } from './grid-icons'
|
|
16
|
-
|
|
17
|
-
type Col = { id: string; label: string }
|
|
18
|
-
type Props = {
|
|
19
|
-
/** Columns that can be grouped on. */
|
|
20
|
-
columns: Col[]
|
|
21
|
-
/** Current group-by column ids, outer to inner. */
|
|
22
|
-
groupBy: string[]
|
|
23
|
-
/** Called with the new group-by order. */
|
|
24
|
-
onChange: (groupBy: string[]) => void
|
|
25
|
-
/** Text shown when nothing is grouped. */
|
|
26
|
-
placeholder?: string
|
|
27
|
-
/**
|
|
28
|
-
* Icon overrides, the same map `<SvGrid icons>` takes. This panel draws
|
|
29
|
-
* `drag-handle`, `remove` and `breadcrumb-separator`. You mount it
|
|
30
|
-
* yourself, so pass the same object you gave the grid to keep them
|
|
31
|
-
* consistent.
|
|
32
|
-
*/
|
|
33
|
-
icons?: GridIcons
|
|
34
|
-
}
|
|
35
|
-
let { columns, groupBy, onChange, placeholder = 'Drag a column here to group by it', icons }: Props = $props()
|
|
36
|
-
|
|
37
|
-
const labelOf = (id: string) => columns.find((c) => c.id === id)?.label ?? id
|
|
38
|
-
const available = $derived(columns.filter((c) => !groupBy.includes(c.id)))
|
|
39
|
-
|
|
40
|
-
let dragIndex = $state<number | null>(null)
|
|
41
|
-
|
|
42
|
-
function remove(id: string) {
|
|
43
|
-
onChange(groupBy.filter((g) => g !== id))
|
|
44
|
-
}
|
|
45
|
-
function add(id: string) {
|
|
46
|
-
if (id && !groupBy.includes(id)) onChange([...groupBy, id])
|
|
47
|
-
}
|
|
48
|
-
function reorder(to: number) {
|
|
49
|
-
if (dragIndex === null || dragIndex === to) return
|
|
50
|
-
const next = [...groupBy]
|
|
51
|
-
const [moved] = next.splice(dragIndex, 1)
|
|
52
|
-
next.splice(to, 0, moved!)
|
|
53
|
-
dragIndex = null
|
|
54
|
-
onChange(next)
|
|
55
|
-
}
|
|
56
|
-
function onPanelDrop(e: DragEvent) {
|
|
57
|
-
const id = e.dataTransfer?.getData('text/sv-column')
|
|
58
|
-
if (id) add(id)
|
|
59
|
-
}
|
|
60
|
-
/** Keyboard equivalent of dragging a chip: moves the chip at `i` one slot earlier/later,
|
|
61
|
-
* reusing `reorder()` (via `dragIndex`) so there is a single source of truth for the move. */
|
|
62
|
-
function moveByKeyboard(i: number, delta: number) {
|
|
63
|
-
const to = i + delta
|
|
64
|
-
if (to < 0 || to >= groupBy.length) return
|
|
65
|
-
dragIndex = i
|
|
66
|
-
reorder(to)
|
|
67
|
-
}
|
|
68
|
-
function onChipKeydown(e: KeyboardEvent, i: number) {
|
|
69
|
-
if (!e.altKey) return
|
|
70
|
-
if (e.key === 'ArrowLeft') {
|
|
71
|
-
e.preventDefault()
|
|
72
|
-
moveByKeyboard(i, -1)
|
|
73
|
-
} else if (e.key === 'ArrowRight') {
|
|
74
|
-
e.preventDefault()
|
|
75
|
-
moveByKeyboard(i, 1)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
</script>
|
|
79
|
-
|
|
80
|
-
<!-- Same override-or-default resolution SvGrid uses, kept local because this
|
|
81
|
-
component is mounted by the consumer and never sees the grid's snippet. -->
|
|
82
|
-
{#snippet ic(name: GridIconName)}
|
|
83
|
-
{@const override = icons?.[name]}
|
|
84
|
-
{#if override}{@render override()}{:else}{GRID_ICON_GLYPHS[name]}{/if}
|
|
85
|
-
{/snippet}
|
|
86
|
-
|
|
87
|
-
<div
|
|
88
|
-
class="sv-rowgroup-panel"
|
|
89
|
-
role="group"
|
|
90
|
-
aria-label="Row groups"
|
|
91
|
-
ondragover={(e) => e.preventDefault()}
|
|
92
|
-
ondrop={onPanelDrop}
|
|
93
|
-
>
|
|
94
|
-
<span class="sv-rgp-label" aria-hidden="true">Group by:</span>
|
|
95
|
-
{#if groupBy.length === 0}
|
|
96
|
-
<span class="sv-rgp-empty">{placeholder}</span>
|
|
97
|
-
{:else}
|
|
98
|
-
{#each groupBy as id, i (id)}
|
|
99
|
-
{#if i > 0}<span class="sv-rgp-sep" aria-hidden="true">{@render ic('breadcrumb-separator')}</span>{/if}
|
|
100
|
-
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
101
|
-
<span
|
|
102
|
-
class="sv-rgp-chip"
|
|
103
|
-
class:sv-rgp-chip-drag={dragIndex === i}
|
|
104
|
-
role="group"
|
|
105
|
-
tabindex="0"
|
|
106
|
-
aria-label={`Grouped by ${labelOf(id)}, position ${i + 1} of ${groupBy.length}. Press Alt+Arrow keys to reorder.`}
|
|
107
|
-
draggable="true"
|
|
108
|
-
ondragstart={() => (dragIndex = i)}
|
|
109
|
-
ondragend={() => (dragIndex = null)}
|
|
110
|
-
ondragover={(e) => e.preventDefault()}
|
|
111
|
-
ondrop={() => reorder(i)}
|
|
112
|
-
onkeydown={(e) => onChipKeydown(e, i)}
|
|
113
|
-
>
|
|
114
|
-
<span class="sv-rgp-grip" aria-hidden="true">{@render ic('drag-handle')}</span>
|
|
115
|
-
{labelOf(id)}
|
|
116
|
-
<button type="button" class="sv-rgp-x" onclick={() => remove(id)} aria-label={`Stop grouping by ${labelOf(id)}`}>{@render ic('remove')}</button>
|
|
117
|
-
</span>
|
|
118
|
-
{/each}
|
|
119
|
-
{/if}
|
|
120
|
-
{#if available.length}
|
|
121
|
-
<select
|
|
122
|
-
class="sv-rgp-add"
|
|
123
|
-
aria-label="Add a group column"
|
|
124
|
-
onchange={(e) => { add(e.currentTarget.value); e.currentTarget.value = '' }}
|
|
125
|
-
>
|
|
126
|
-
<option value="">+ Group by</option>
|
|
127
|
-
{#each available as c (c.id)}<option value={c.id}>{c.label}</option>{/each}
|
|
128
|
-
</select>
|
|
129
|
-
{/if}
|
|
130
|
-
</div>
|
|
131
|
-
|
|
132
|
-
<style>
|
|
133
|
-
.sv-rowgroup-panel {
|
|
134
|
-
display: flex;
|
|
135
|
-
align-items: center;
|
|
136
|
-
flex-wrap: wrap;
|
|
137
|
-
gap: 6px;
|
|
138
|
-
padding: 8px 10px;
|
|
139
|
-
border: 1px dashed var(--sg-border, #cbd5e1);
|
|
140
|
-
border-radius: 10px;
|
|
141
|
-
background: var(--sg-header-bg, #f8fafc);
|
|
142
|
-
font-size: 13px;
|
|
143
|
-
}
|
|
144
|
-
.sv-rgp-label { color: var(--sg-muted, #64748b); font-weight: 600; }
|
|
145
|
-
.sv-rgp-empty { color: var(--sg-muted, #94a3b8); font-style: italic; }
|
|
146
|
-
.sv-rgp-sep { color: var(--sg-muted, #94a3b8); }
|
|
147
|
-
.sv-rgp-chip {
|
|
148
|
-
display: inline-flex;
|
|
149
|
-
align-items: center;
|
|
150
|
-
gap: 6px;
|
|
151
|
-
padding: 3px 6px 3px 8px;
|
|
152
|
-
border-radius: 999px;
|
|
153
|
-
background: var(--sg-bg, #fff);
|
|
154
|
-
border: 1px solid var(--sg-border, #e2e8f0);
|
|
155
|
-
cursor: grab;
|
|
156
|
-
user-select: none;
|
|
157
|
-
font-weight: 600;
|
|
158
|
-
}
|
|
159
|
-
.sv-rgp-chip-drag { opacity: 0.5; }
|
|
160
|
-
.sv-rgp-grip { color: var(--sg-muted, #94a3b8); cursor: grab; }
|
|
161
|
-
.sv-rgp-x {
|
|
162
|
-
display: inline-flex;
|
|
163
|
-
align-items: center;
|
|
164
|
-
justify-content: center;
|
|
165
|
-
width: 16px;
|
|
166
|
-
height: 16px;
|
|
167
|
-
border: 0;
|
|
168
|
-
border-radius: 50%;
|
|
169
|
-
background: none;
|
|
170
|
-
color: var(--sg-muted, #64748b);
|
|
171
|
-
cursor: pointer;
|
|
172
|
-
font-size: 14px;
|
|
173
|
-
line-height: 1;
|
|
174
|
-
}
|
|
175
|
-
.sv-rgp-x:hover { background: var(--sg-header-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
176
|
-
.sv-rgp-add {
|
|
177
|
-
font: inherit;
|
|
178
|
-
font-size: 13px;
|
|
179
|
-
padding: 4px 8px;
|
|
180
|
-
border-radius: 8px;
|
|
181
|
-
border: 1px solid var(--sg-border, #e2e8f0);
|
|
182
|
-
background: var(--sg-bg, #fff);
|
|
183
|
-
color: var(--sg-accent, #2563eb);
|
|
184
|
-
cursor: pointer;
|
|
185
|
-
}
|
|
186
|
-
</style>
|