bo-grid 0.8.0 → 0.21.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 +202 -9
- package/dist/bo-grid.FilterMenu-BHI6rILc.js +154 -0
- package/dist/bo-grid.ToolPanel-C3u-4YKc.js +34 -0
- package/dist/bo-grid.element-DPnHUXMa.js +6623 -0
- package/dist/bo-grid.element.js +4 -0
- package/dist/charts/BarChart.svelte +50 -0
- package/dist/charts/BarChart.svelte.d.ts +16 -0
- package/dist/charts/DonutChart.svelte +54 -0
- package/dist/charts/DonutChart.svelte.d.ts +18 -0
- package/dist/charts/Legend.svelte +47 -0
- package/dist/charts/Legend.svelte.d.ts +12 -0
- package/dist/charts/LineChart.svelte +59 -0
- package/dist/charts/LineChart.svelte.d.ts +14 -0
- package/dist/charts/StackedBarChart.svelte +56 -0
- package/dist/charts/StackedBarChart.svelte.d.ts +18 -0
- package/dist/charts/chart-math.d.ts +57 -0
- package/dist/charts/chart-math.js +174 -0
- package/dist/charts/index.d.ts +8 -0
- package/dist/charts/index.js +11 -0
- package/dist/charts/palette.d.ts +4 -0
- package/dist/charts/palette.js +14 -0
- package/dist/format/format.d.ts +6 -0
- package/dist/format/format.js +41 -0
- package/dist/grid/Cell.svelte +247 -8
- package/dist/grid/Cell.svelte.d.ts +6 -0
- package/dist/grid/FilterMenu.svelte +7 -0
- package/dist/grid/Grid.svelte +307 -85
- package/dist/grid/Grid.svelte.d.ts +19 -0
- package/dist/grid/GroupRow.svelte +5 -2
- package/dist/grid/Pager.svelte +4 -0
- package/dist/grid/RowMenu.svelte +65 -2
- package/dist/grid/ToolPanel.svelte +5 -0
- package/dist/grid/column.d.ts +133 -0
- package/dist/grid/column.js +133 -4
- package/dist/grid/colvirt.d.ts +15 -0
- package/dist/grid/colvirt.js +43 -0
- package/dist/grid/export.js +5 -2
- package/dist/grid/filtering.d.ts +5 -2
- package/dist/grid/filtering.js +5 -4
- package/dist/grid/grouping.d.ts +30 -0
- package/dist/grid/grouping.js +33 -0
- package/dist/grid/theme.d.ts +15 -0
- package/dist/grid/theme.js +78 -0
- package/dist/grid/tree.d.ts +19 -7
- package/dist/grid/tree.js +16 -11
- package/dist/index.d.ts +5 -4
- package/dist/index.js +2 -2
- package/package.json +12 -2
package/dist/grid/Grid.svelte
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
import { untrack } from 'svelte';
|
|
8
8
|
import type { Snippet } from 'svelte';
|
|
9
9
|
import type { ColumnDef, GridRow, SortState, SortDir, CellEditEvent } from './column';
|
|
10
|
-
import { colStyle, isNumeric, isSortable, isEditable, compareBySorts, formatCell } from './column';
|
|
10
|
+
import { colStyle, isNumeric, isSortable, isEditable, compareBySorts, formatCell, cellValue } from './column';
|
|
11
11
|
import { arrangePinned } from './pin';
|
|
12
|
+
import { columnWindow, columnOffsets } from './colvirt';
|
|
12
13
|
import { uniformHeights, variableHeights } from './rowheight';
|
|
13
14
|
import { themeVars, lightTheme, type GridTheme } from './theme';
|
|
14
15
|
import { Selection } from './selection.svelte';
|
|
15
16
|
import { aggregate, type AggKind, type AggResult } from './aggregate';
|
|
16
|
-
import { buildFlatRows, activeGroupsAt, type VisualRow, type GroupNode } from './grouping';
|
|
17
|
+
import { buildFlatRows, buildLazyGroupRows, activeGroupsAt, type VisualRow, type GroupNode, type LazyGroup } from './grouping';
|
|
17
18
|
import { buildTreeRows } from './tree';
|
|
18
19
|
import { moveIndex } from './reorder';
|
|
19
20
|
import { parseClipboard, isSingleCell } from './clipboard';
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
onColumnVisibilityChange,
|
|
54
55
|
columnMenu = false,
|
|
55
56
|
columnsPanel = false,
|
|
57
|
+
virtualizeColumns = false,
|
|
56
58
|
rowClass,
|
|
57
59
|
getRowId = (r: GridRow) => r.id,
|
|
58
60
|
onRowClick,
|
|
@@ -73,6 +75,10 @@
|
|
|
73
75
|
detail,
|
|
74
76
|
detailHeight = 160,
|
|
75
77
|
getChildren,
|
|
78
|
+
loadChildren,
|
|
79
|
+
hasChildren,
|
|
80
|
+
lazyGroups,
|
|
81
|
+
loadGroup,
|
|
76
82
|
onRowReorder,
|
|
77
83
|
pageSize = 0,
|
|
78
84
|
page,
|
|
@@ -112,6 +118,10 @@
|
|
|
112
118
|
(the place to restore columns hidden via the menu). Lazy-loaded. Default
|
|
113
119
|
false. */
|
|
114
120
|
columnsPanel?: boolean;
|
|
121
|
+
/** Render only the columns within the horizontal scroll window (+ overscan)
|
|
122
|
+
for very wide grids (100+ columns). Forces fixed-width horizontal scroll;
|
|
123
|
+
pinned columns always render. Default false (fit-to-width). */
|
|
124
|
+
virtualizeColumns?: boolean;
|
|
115
125
|
/** Return extra CSS class(es) for a data row (e.g. to colour by value).
|
|
116
126
|
Style them via `:global(.your-class)` since rows live inside the grid. */
|
|
117
127
|
rowClass?: (row: GridRow) => string | undefined;
|
|
@@ -179,6 +189,20 @@
|
|
|
179
189
|
`rows` are the roots; the grid renders an indented, expandable tree.
|
|
180
190
|
In-memory mode; filter/sort/group/paginate are not applied to the tree. */
|
|
181
191
|
getChildren?: (row: GridRow) => GridRow[] | undefined;
|
|
192
|
+
/** Async tree data: load a row's children on expand (server-backed trees).
|
|
193
|
+
Returns a promise; the grid shows a loading row, then caches the result.
|
|
194
|
+
Pair with `hasChildren` (a cheap predicate) so chevrons show without
|
|
195
|
+
loading. Use instead of `getChildren`. In-memory roots. */
|
|
196
|
+
loadChildren?: (row: GridRow) => Promise<GridRow[]>;
|
|
197
|
+
/** Cheap predicate for whether a row has children (drives the expand chevron
|
|
198
|
+
without loading). Required with `loadChildren`; optional with `getChildren`. */
|
|
199
|
+
hasChildren?: (row: GridRow) => boolean;
|
|
200
|
+
/** Server-side grouping: top-level group summaries (header data, no leaf rows).
|
|
201
|
+
Each group's rows load on expand via `loadGroup`. In-memory mode. */
|
|
202
|
+
lazyGroups?: LazyGroup[];
|
|
203
|
+
/** Load a lazy group's leaf rows on expand (returns a promise; shows a loading
|
|
204
|
+
row, then caches). Required with `lazyGroups`. */
|
|
205
|
+
loadGroup?: (key: string) => Promise<GridRow[]>;
|
|
182
206
|
/** Enable drag-to-reorder rows via a handle in the first column. Called with
|
|
183
207
|
the from/to indices (into the visible rows) on drop — reorder your own
|
|
184
208
|
`rows` in here. Flat, unsorted, in-memory lists only. */
|
|
@@ -375,6 +399,70 @@
|
|
|
375
399
|
expVersion++;
|
|
376
400
|
}
|
|
377
401
|
|
|
402
|
+
// Async tree children: cache loaded children by row id; track in-flight loads.
|
|
403
|
+
const childrenCache = new Map<string | number, GridRow[]>();
|
|
404
|
+
const loadingIds = new Set<string | number>();
|
|
405
|
+
let treeVersion = $state(0);
|
|
406
|
+
|
|
407
|
+
// Expand/collapse a tree node, lazy-loading children on first expand (async).
|
|
408
|
+
function toggleTreeNode(row: GridRow): void {
|
|
409
|
+
const id = getRowId(row);
|
|
410
|
+
if (expandedRows.has(id)) {
|
|
411
|
+
expandedRows.delete(id);
|
|
412
|
+
expVersion++;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
expandedRows.add(id);
|
|
416
|
+
expVersion++;
|
|
417
|
+
if (loadChildren && !childrenCache.has(id) && !loadingIds.has(id)) {
|
|
418
|
+
loadingIds.add(id);
|
|
419
|
+
treeVersion++;
|
|
420
|
+
Promise.resolve(loadChildren(row)).then(
|
|
421
|
+
(kids) => {
|
|
422
|
+
childrenCache.set(id, kids);
|
|
423
|
+
loadingIds.delete(id);
|
|
424
|
+
treeVersion++;
|
|
425
|
+
},
|
|
426
|
+
() => {
|
|
427
|
+
// On failure, collapse so the user can retry by expanding again.
|
|
428
|
+
loadingIds.delete(id);
|
|
429
|
+
expandedRows.delete(id);
|
|
430
|
+
expVersion++;
|
|
431
|
+
treeVersion++;
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Expand/collapse a server-side group, lazy-loading its rows on first expand.
|
|
438
|
+
// Reuses the tree's children cache + loading machinery, keyed by group key.
|
|
439
|
+
function toggleLazyGroup(key: string): void {
|
|
440
|
+
if (expandedRows.has(key)) {
|
|
441
|
+
expandedRows.delete(key);
|
|
442
|
+
expVersion++;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
expandedRows.add(key);
|
|
446
|
+
expVersion++;
|
|
447
|
+
if (loadGroup && !childrenCache.has(key) && !loadingIds.has(key)) {
|
|
448
|
+
loadingIds.add(key);
|
|
449
|
+
treeVersion++;
|
|
450
|
+
Promise.resolve(loadGroup(key)).then(
|
|
451
|
+
(rows) => {
|
|
452
|
+
childrenCache.set(key, rows);
|
|
453
|
+
loadingIds.delete(key);
|
|
454
|
+
treeVersion++;
|
|
455
|
+
},
|
|
456
|
+
() => {
|
|
457
|
+
loadingIds.delete(key);
|
|
458
|
+
expandedRows.delete(key);
|
|
459
|
+
expVersion++;
|
|
460
|
+
treeVersion++;
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
378
466
|
function isRowSelected(id: string | number): boolean {
|
|
379
467
|
selRowsVersion; // track
|
|
380
468
|
return selectedRows.has(id);
|
|
@@ -413,6 +501,33 @@
|
|
|
413
501
|
const layout = $derived(arrangePinned(pinnedSized));
|
|
414
502
|
const cols = $derived(layout.columns);
|
|
415
503
|
const pinned = $derived(layout.anyPinned);
|
|
504
|
+
// Fixed-width horizontal-scroll mode: when columns are pinned OR column
|
|
505
|
+
// virtualization is on. Drives the same layout (explicit widths, overflow-x,
|
|
506
|
+
// scroll-synced header) that pinning already uses.
|
|
507
|
+
const hScroll = $derived(pinned || virtualizeColumns);
|
|
508
|
+
|
|
509
|
+
// Column virtualization: render only the columns whose x-range intersects the
|
|
510
|
+
// horizontal viewport (+ overscan); pinned columns always render. Off-window
|
|
511
|
+
// runs of columns collapse into a single spacer so widths/positions are exact.
|
|
512
|
+
const COL_OVERSCAN = 320; // px
|
|
513
|
+
let scrollLeft = $state(0);
|
|
514
|
+
let viewW = $state(0);
|
|
515
|
+
type ColItem = { kind: 'cell'; ci: number; key: string } | { kind: 'spacer'; w: number; key: string };
|
|
516
|
+
const colItems = $derived.by<ColItem[]>(() => {
|
|
517
|
+
const n = cols.length;
|
|
518
|
+
// Off, or before the viewport width is measured: render every column.
|
|
519
|
+
if (!virtualizeColumns || !viewW) {
|
|
520
|
+
return Array.from({ length: n }, (_, ci) => ({ kind: 'cell', ci, key: 'c' + ci }) as ColItem);
|
|
521
|
+
}
|
|
522
|
+
const widths = layout.info.map((inf) => inf.width);
|
|
523
|
+
const pins = layout.info.map((inf) => inf.pinned);
|
|
524
|
+
let sp = 0;
|
|
525
|
+
return columnWindow(columnOffsets(widths), widths, pins, scrollLeft, viewW, COL_OVERSCAN).map((it) =>
|
|
526
|
+
it.kind === 'cell'
|
|
527
|
+
? ({ kind: 'cell', ci: it.ci, key: 'c' + it.ci } as ColItem)
|
|
528
|
+
: ({ kind: 'spacer', w: it.w, key: 's' + sp++ } as ColItem),
|
|
529
|
+
);
|
|
530
|
+
});
|
|
416
531
|
|
|
417
532
|
// Spanning header groups: consecutive columns sharing a `group` label merge
|
|
418
533
|
// into one parent header cell (width = sum of child widths). null when unused.
|
|
@@ -448,14 +563,14 @@
|
|
|
448
563
|
return `position:sticky;left:${inf.left + leadPx}px;`;
|
|
449
564
|
}
|
|
450
565
|
function headStyle(ci: number): string {
|
|
451
|
-
if (!
|
|
566
|
+
if (!hScroll) return colStyle(cols[ci]);
|
|
452
567
|
const inf = layout.info[ci];
|
|
453
568
|
let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
|
|
454
569
|
if (inf.pinned) s += `${pinStick(ci)}z-index:5;background:var(--bo-header-bg);`;
|
|
455
570
|
return s;
|
|
456
571
|
}
|
|
457
572
|
function cellWidthStyle(ci: number): string {
|
|
458
|
-
if (!
|
|
573
|
+
if (!hScroll) return colStyle(cols[ci]);
|
|
459
574
|
const inf = layout.info[ci];
|
|
460
575
|
let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
|
|
461
576
|
if (inf.pinned) s += `${pinStick(ci)}z-index:1;background:var(--bo-bg);`;
|
|
@@ -465,14 +580,14 @@
|
|
|
465
580
|
// expand column, if any) when the grid scrolls horizontally (pinned mode).
|
|
466
581
|
function selCellStyle(header: boolean): string {
|
|
467
582
|
let s = `flex:0 0 ${SEL_W}px;width:${SEL_W}px;`;
|
|
468
|
-
if (
|
|
583
|
+
if (hScroll)
|
|
469
584
|
s += `position:sticky;left:${expOffset * EXP_W}px;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
|
|
470
585
|
return s;
|
|
471
586
|
}
|
|
472
587
|
// The leading expand-toggle column (master-detail), sticky at the far left.
|
|
473
588
|
function expandCellStyle(header: boolean): string {
|
|
474
589
|
let s = `flex:0 0 ${EXP_W}px;width:${EXP_W}px;`;
|
|
475
|
-
if (
|
|
590
|
+
if (hScroll)
|
|
476
591
|
s += `position:sticky;left:0;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
|
|
477
592
|
return s;
|
|
478
593
|
}
|
|
@@ -724,6 +839,14 @@
|
|
|
724
839
|
}
|
|
725
840
|
|
|
726
841
|
// In-memory pipeline (skipped entirely in source mode).
|
|
842
|
+
// Resolve a column's value by key (computed-aware) — for filtering and the
|
|
843
|
+
// set-filter distinct list. Falls back to the raw row field for plain columns.
|
|
844
|
+
const colByKey = $derived(new Map(columns.map((c) => [c.key, c] as const)));
|
|
845
|
+
const valueOf = (row: GridRow, key: string): unknown => {
|
|
846
|
+
const c = colByKey.get(key);
|
|
847
|
+
return c ? cellValue(c, row) : row[key];
|
|
848
|
+
};
|
|
849
|
+
|
|
727
850
|
const view = $derived.by(() => {
|
|
728
851
|
if (source) return [] as GridRow[];
|
|
729
852
|
const base = rows;
|
|
@@ -741,10 +864,10 @@
|
|
|
741
864
|
const hasColFilters = Object.keys(active).length > 0;
|
|
742
865
|
return untrack(() => {
|
|
743
866
|
let r = base;
|
|
744
|
-
if (f) r = r.filter((row) => allCols.some((c) => String(
|
|
745
|
-
if (q) r = r.filter((row) => allCols.some((c) => String(
|
|
867
|
+
if (f) r = r.filter((row) => allCols.some((c) => String(cellValue(c, row) ?? '').toLowerCase().includes(f)));
|
|
868
|
+
if (q) r = r.filter((row) => allCols.some((c) => String(cellValue(c, row) ?? '').toLowerCase().includes(q)));
|
|
746
869
|
if (hasColFilters) {
|
|
747
|
-
r = r.filter((row) => passesFilters(row, active));
|
|
870
|
+
r = r.filter((row) => passesFilters(row, active, valueOf));
|
|
748
871
|
}
|
|
749
872
|
if (s.length > 0) {
|
|
750
873
|
const colOf = (k: string) => allCols.find((c) => c.key === k);
|
|
@@ -754,6 +877,30 @@
|
|
|
754
877
|
});
|
|
755
878
|
});
|
|
756
879
|
|
|
880
|
+
// Conditional-formatting ranges: the data extent (min/max over the current
|
|
881
|
+
// view) for each column with a data bar or colour scale. Per-column `min`/`max`
|
|
882
|
+
// config overrides are applied later, in the cell. Keyed by column key.
|
|
883
|
+
const cfRanges = $derived.by<Record<string, { min: number; max: number }>>(() => {
|
|
884
|
+
const out: Record<string, { min: number; max: number }> = {};
|
|
885
|
+
for (const col of columns) {
|
|
886
|
+
if (!col.dataBar && !col.colorScale) continue;
|
|
887
|
+
let lo = Infinity;
|
|
888
|
+
let hi = -Infinity;
|
|
889
|
+
for (const row of view) {
|
|
890
|
+
const raw = cellValue(col, row);
|
|
891
|
+
if (raw === null || raw === undefined || raw === '') continue; // blanks aren't 0
|
|
892
|
+
const n = Number(raw);
|
|
893
|
+
if (!Number.isFinite(n)) continue;
|
|
894
|
+
if (n < lo) lo = n;
|
|
895
|
+
if (n > hi) hi = n;
|
|
896
|
+
}
|
|
897
|
+
if (!Number.isFinite(lo)) lo = 0;
|
|
898
|
+
if (!Number.isFinite(hi)) hi = lo + 1;
|
|
899
|
+
out[col.key] = { min: lo, max: hi };
|
|
900
|
+
}
|
|
901
|
+
return out;
|
|
902
|
+
});
|
|
903
|
+
|
|
757
904
|
// Pagination (in-memory only): slice the view into pages; rows still
|
|
758
905
|
// virtualize within a page. Off when pageSize <= 0.
|
|
759
906
|
const paged = $derived(pageSize > 0 && !source);
|
|
@@ -775,7 +922,9 @@
|
|
|
775
922
|
paged ? view.slice(currentPage * pageSize, currentPage * pageSize + pageSize) : view,
|
|
776
923
|
);
|
|
777
924
|
|
|
778
|
-
const treeData = $derived(!!getChildren && !source);
|
|
925
|
+
const treeData = $derived(!!(getChildren || loadChildren) && !source);
|
|
926
|
+
// Server-side (lazy) grouping: group summaries up front, rows on expand.
|
|
927
|
+
const lazyGrouped = $derived(!!lazyGroups && !source && !treeData);
|
|
779
928
|
|
|
780
929
|
// Drag-to-reorder rows (flat, unsorted, in-memory only). The handle lives in
|
|
781
930
|
// the first cell; the dragged/drop indices are tracked in component state.
|
|
@@ -793,11 +942,29 @@
|
|
|
793
942
|
}
|
|
794
943
|
|
|
795
944
|
const flat = $derived.by<VisualRow[]>(() => {
|
|
796
|
-
if (treeData
|
|
945
|
+
if (treeData) {
|
|
797
946
|
const roots = rows;
|
|
798
947
|
expVersion; // track expand/collapse
|
|
948
|
+
treeVersion; // track async child loads
|
|
799
949
|
return untrack(() =>
|
|
800
|
-
buildTreeRows(roots,
|
|
950
|
+
buildTreeRows(roots, {
|
|
951
|
+
childrenOf: (r) => (loadChildren ? childrenCache.get(getRowId(r)) : getChildren?.(r)),
|
|
952
|
+
hasChildren: hasChildren ?? ((r) => !!getChildren?.(r)?.length),
|
|
953
|
+
isExpanded: (r) => expandedRows.has(getRowId(r)),
|
|
954
|
+
isLoading: (r) => loadingIds.has(getRowId(r)),
|
|
955
|
+
}),
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
if (lazyGrouped && lazyGroups) {
|
|
959
|
+
const groups = lazyGroups;
|
|
960
|
+
expVersion; // track expand/collapse
|
|
961
|
+
treeVersion; // track async row loads
|
|
962
|
+
return untrack(() =>
|
|
963
|
+
buildLazyGroupRows(groups, {
|
|
964
|
+
isExpanded: (key) => expandedRows.has(key),
|
|
965
|
+
rowsOf: (key) => childrenCache.get(key),
|
|
966
|
+
isLoading: (key) => loadingIds.has(key),
|
|
967
|
+
}),
|
|
801
968
|
);
|
|
802
969
|
}
|
|
803
970
|
const v = pageRows;
|
|
@@ -846,7 +1013,7 @@
|
|
|
846
1013
|
if (col.type === 'sparkline' || col.type === 'text' || col.type === 'custom' || !col.groupAgg) return '';
|
|
847
1014
|
const vals: number[] = [];
|
|
848
1015
|
for (const row of v) {
|
|
849
|
-
const n = Number(row
|
|
1016
|
+
const n = Number(cellValue(col, row));
|
|
850
1017
|
if (Number.isFinite(n)) vals.push(n);
|
|
851
1018
|
}
|
|
852
1019
|
const a = aggregate(vals);
|
|
@@ -890,7 +1057,7 @@
|
|
|
890
1057
|
|
|
891
1058
|
const total = $derived(hm.total);
|
|
892
1059
|
const rowWidthStyle = $derived(
|
|
893
|
-
|
|
1060
|
+
hScroll ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
|
|
894
1061
|
);
|
|
895
1062
|
const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
|
|
896
1063
|
const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
|
|
@@ -903,6 +1070,7 @@
|
|
|
903
1070
|
type RenderItem =
|
|
904
1071
|
| { vr: number; kind: 'group'; group: GroupNode }
|
|
905
1072
|
| { vr: number; kind: 'data'; row: GridRow; depth?: number; hasChildren?: boolean }
|
|
1073
|
+
| { vr: number; kind: 'treeloading'; depth: number }
|
|
906
1074
|
| { vr: number; kind: 'skeleton' };
|
|
907
1075
|
|
|
908
1076
|
const renderItems = $derived.by<RenderItem[]>(() => {
|
|
@@ -918,6 +1086,7 @@
|
|
|
918
1086
|
const item = flat[vr];
|
|
919
1087
|
if (!item) continue;
|
|
920
1088
|
if (item.kind === 'group') out.push({ vr, kind: 'group', group: item.group });
|
|
1089
|
+
else if (item.kind === 'treeloading') out.push({ vr, kind: 'treeloading', depth: item.depth });
|
|
921
1090
|
else out.push({ vr, kind: 'data', row: item.row, depth: item.depth, hasChildren: item.hasChildren });
|
|
922
1091
|
}
|
|
923
1092
|
}
|
|
@@ -956,7 +1125,7 @@
|
|
|
956
1125
|
if (!row) continue;
|
|
957
1126
|
for (let c = b.c0; c <= cEnd; c++) {
|
|
958
1127
|
if (!isNumeric(cols[c])) continue;
|
|
959
|
-
const v = Number(
|
|
1128
|
+
const v = Number(cellValue(cols[c], row));
|
|
960
1129
|
if (Number.isFinite(v)) vals.push(v);
|
|
961
1130
|
}
|
|
962
1131
|
}
|
|
@@ -966,9 +1135,10 @@
|
|
|
966
1135
|
function onScroll(e: Event) {
|
|
967
1136
|
const el = e.currentTarget as HTMLElement;
|
|
968
1137
|
scrollTop = el.scrollTop;
|
|
969
|
-
if (
|
|
970
|
-
if (
|
|
971
|
-
if (
|
|
1138
|
+
if (hScroll) scrollLeft = el.scrollLeft; // drives column virtualization
|
|
1139
|
+
if (hScroll && headEl) headEl.scrollLeft = el.scrollLeft; // keep header in sync
|
|
1140
|
+
if (hScroll && filterRowEl) filterRowEl.scrollLeft = el.scrollLeft; // and the filter row
|
|
1141
|
+
if (hScroll && groupHeadEl) groupHeadEl.scrollLeft = el.scrollLeft; // and group headers
|
|
972
1142
|
}
|
|
973
1143
|
|
|
974
1144
|
function toggleGroup(path: string) {
|
|
@@ -1078,17 +1248,14 @@
|
|
|
1078
1248
|
function filterKindFor(col: ColumnDef): FilterKind {
|
|
1079
1249
|
return typeof col.filter === 'string' ? col.filter : defaultFilterKind(col);
|
|
1080
1250
|
}
|
|
1081
|
-
async function openFilterMenu(col: ColumnDef,
|
|
1082
|
-
e.stopPropagation();
|
|
1083
|
-
// Read the anchor rect now — after the await, the event's currentTarget is null.
|
|
1084
|
-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1251
|
+
async function openFilterMenu(col: ColumnDef, anchor: { left: number; bottom: number }) {
|
|
1085
1252
|
let kind = filterKindFor(col);
|
|
1086
1253
|
// A set filter needs distinct values; in source mode they can't be
|
|
1087
1254
|
// enumerated, so fall back to the column's typed filter.
|
|
1088
1255
|
if (source && kind === 'set') kind = defaultFilterKind(col);
|
|
1089
|
-
const values = !source && kind === 'set' ? distinctValues(rows, col.key) : [];
|
|
1256
|
+
const values = !source && kind === 'set' ? distinctValues(rows, col.key, valueOf) : [];
|
|
1090
1257
|
if (!FilterMenuComp) FilterMenuComp = (await import('./FilterMenu.svelte')).default;
|
|
1091
|
-
filterUi = { key: col.key, kind, header: col.header, values, x:
|
|
1258
|
+
filterUi = { key: col.key, kind, header: col.header, values, x: anchor.left, y: anchor.bottom + 2 };
|
|
1092
1259
|
}
|
|
1093
1260
|
function applyColumnFilter(key: string, f: ColumnFilter | null): void {
|
|
1094
1261
|
const next = { ...activeColumnFilters };
|
|
@@ -1120,7 +1287,7 @@
|
|
|
1120
1287
|
let maxLen = col.header.length;
|
|
1121
1288
|
if (!source) {
|
|
1122
1289
|
for (const row of view.slice(0, 500)) {
|
|
1123
|
-
const s = formatCell(col,
|
|
1290
|
+
const s = formatCell(col, cellValue(col, row), row);
|
|
1124
1291
|
if (s.length > maxLen) maxLen = s.length;
|
|
1125
1292
|
}
|
|
1126
1293
|
}
|
|
@@ -1132,7 +1299,11 @@
|
|
|
1132
1299
|
|
|
1133
1300
|
// Column header menu (⋮): sort / pin / autosize / hide. Reuses the floating
|
|
1134
1301
|
// RowMenu (a light action list — no lazy chunk, unlike the filter menu).
|
|
1135
|
-
|
|
1302
|
+
// `anchor` positions the (keyboard-reachable) Filter submenu at the column.
|
|
1303
|
+
function columnMenuItems(
|
|
1304
|
+
col: ColumnDef,
|
|
1305
|
+
anchor: { left: number; bottom: number },
|
|
1306
|
+
): Array<{ label: string; onSelect: () => void }> {
|
|
1136
1307
|
const items: Array<{ label: string; onSelect: () => void }> = [];
|
|
1137
1308
|
if (isSortable(col)) {
|
|
1138
1309
|
items.push({ label: 'Sort ascending', onSelect: () => setSorts([{ key: col.key, dir: 'asc' }]) });
|
|
@@ -1141,6 +1312,10 @@
|
|
|
1141
1312
|
items.push({ label: 'Clear sort', onSelect: () => setSorts(sorts.filter((s) => s.key !== col.key)) });
|
|
1142
1313
|
}
|
|
1143
1314
|
}
|
|
1315
|
+
// Keyboard path to filtering (the header funnel is pointer-only by design).
|
|
1316
|
+
if (filterMenu && col.type !== 'sparkline' && col.filter !== false) {
|
|
1317
|
+
items.push({ label: 'Filter…', onSelect: () => void openFilterMenu(col, anchor) });
|
|
1318
|
+
}
|
|
1144
1319
|
const side = pinSideOf(col);
|
|
1145
1320
|
if (side !== 'left') items.push({ label: 'Pin left', onSelect: () => setPinOverride(col.key, 'left') });
|
|
1146
1321
|
if (side !== 'right') items.push({ label: 'Pin right', onSelect: () => setPinOverride(col.key, 'right') });
|
|
@@ -1152,7 +1327,8 @@
|
|
|
1152
1327
|
function openColumnMenu(col: ColumnDef, e: Event) {
|
|
1153
1328
|
e.stopPropagation();
|
|
1154
1329
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1155
|
-
|
|
1330
|
+
const anchor = { left: rect.left, bottom: rect.bottom };
|
|
1331
|
+
menu = { x: rect.left, y: rect.bottom + 2, items: columnMenuItems(col, anchor) };
|
|
1156
1332
|
}
|
|
1157
1333
|
|
|
1158
1334
|
function onCellClicked(r: number, c: number, e: MouseEvent) {
|
|
@@ -1160,7 +1336,7 @@
|
|
|
1160
1336
|
const row = dataAt(r);
|
|
1161
1337
|
if (!row) return;
|
|
1162
1338
|
const column = cols[c];
|
|
1163
|
-
onCellClick({ row, column, value:
|
|
1339
|
+
onCellClick({ row, column, value: cellValue(column, row) }, e);
|
|
1164
1340
|
}
|
|
1165
1341
|
|
|
1166
1342
|
// Move the focus to an absolute (r, c), clamped; extend the selection if asked.
|
|
@@ -1193,7 +1369,7 @@
|
|
|
1193
1369
|
const cells: string[] = [];
|
|
1194
1370
|
for (let c = b.c0; c <= cEnd; c++) {
|
|
1195
1371
|
const col = cols[c];
|
|
1196
|
-
cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col,
|
|
1372
|
+
cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, cellValue(col, row), row));
|
|
1197
1373
|
}
|
|
1198
1374
|
lines.push(cells.join('\t'));
|
|
1199
1375
|
}
|
|
@@ -1337,7 +1513,8 @@
|
|
|
1337
1513
|
if (columnMenu && sel.focus && e.altKey && e.key === 'ArrowDown') {
|
|
1338
1514
|
e.preventDefault();
|
|
1339
1515
|
const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
|
|
1340
|
-
|
|
1516
|
+
const anchor = { left: rect?.left ?? 0, bottom: rect?.bottom ?? 0 };
|
|
1517
|
+
menu = { x: anchor.left, y: anchor.bottom, items: columnMenuItems(cols[sel.focus.c], anchor) };
|
|
1341
1518
|
return;
|
|
1342
1519
|
}
|
|
1343
1520
|
// Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
|
|
@@ -1349,12 +1526,12 @@
|
|
|
1349
1526
|
const open = expandedRows.has(id);
|
|
1350
1527
|
if (e.key === 'ArrowRight' && !open) {
|
|
1351
1528
|
e.preventDefault();
|
|
1352
|
-
|
|
1529
|
+
toggleTreeNode(item.row);
|
|
1353
1530
|
return;
|
|
1354
1531
|
}
|
|
1355
1532
|
if (e.key === 'ArrowLeft' && open) {
|
|
1356
1533
|
e.preventDefault();
|
|
1357
|
-
|
|
1534
|
+
toggleTreeNode(item.row);
|
|
1358
1535
|
return;
|
|
1359
1536
|
}
|
|
1360
1537
|
}
|
|
@@ -1438,7 +1615,7 @@
|
|
|
1438
1615
|
</div>
|
|
1439
1616
|
{/if}
|
|
1440
1617
|
{#if headerGroups}
|
|
1441
|
-
<div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={
|
|
1618
|
+
<div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={hScroll ? 'overflow:hidden;' : ''}>
|
|
1442
1619
|
{#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
|
|
1443
1620
|
{#if rowSelection}<span class="selcell" style={selCellStyle(true)}></span>{/if}
|
|
1444
1621
|
{#each headerGroups as g, gi (gi)}
|
|
@@ -1446,7 +1623,7 @@
|
|
|
1446
1623
|
{/each}
|
|
1447
1624
|
</div>
|
|
1448
1625
|
{/if}
|
|
1449
|
-
<div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={
|
|
1626
|
+
<div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={hScroll ? 'overflow:hidden;' : ''}>
|
|
1450
1627
|
{#if expandable}
|
|
1451
1628
|
<span class="expandcell selhead" role="columnheader" aria-colindex={1} style={expandCellStyle(true)}></span>
|
|
1452
1629
|
{/if}
|
|
@@ -1512,6 +1689,9 @@
|
|
|
1512
1689
|
</span>
|
|
1513
1690
|
{/if}
|
|
1514
1691
|
{#if filterMenu && col.type !== 'sparkline' && col.filter !== false}
|
|
1692
|
+
<!-- Pointer affordance only (tabindex=-1, APG grid pattern): keyboard
|
|
1693
|
+
users reach filtering via the column menu's "Filter…" item. -->
|
|
1694
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
1515
1695
|
<span
|
|
1516
1696
|
class="funnel"
|
|
1517
1697
|
class:on={isFilterActive(activeColumnFilters[col.key])}
|
|
@@ -1519,9 +1699,9 @@
|
|
|
1519
1699
|
tabindex="-1"
|
|
1520
1700
|
aria-label="Filter {col.header}"
|
|
1521
1701
|
title="Filter {col.header}"
|
|
1522
|
-
onclick={(e) =>
|
|
1523
|
-
|
|
1524
|
-
|
|
1702
|
+
onclick={(e) => {
|
|
1703
|
+
e.stopPropagation();
|
|
1704
|
+
openFilterMenu(col, (e.currentTarget as HTMLElement).getBoundingClientRect());
|
|
1525
1705
|
}}
|
|
1526
1706
|
ondragstart={(e) => e.preventDefault()}
|
|
1527
1707
|
draggable="false"
|
|
@@ -1562,7 +1742,7 @@
|
|
|
1562
1742
|
</div>
|
|
1563
1743
|
|
|
1564
1744
|
{#if filterRow && !source}
|
|
1565
|
-
<div class="filter-row" role="row" bind:this={filterRowEl} style={
|
|
1745
|
+
<div class="filter-row" role="row" bind:this={filterRowEl} style={hScroll ? 'overflow:hidden;' : ''}>
|
|
1566
1746
|
{#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
|
|
1567
1747
|
{#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
|
|
1568
1748
|
{#each cols as col, ci (ci)}
|
|
@@ -1584,8 +1764,9 @@
|
|
|
1584
1764
|
|
|
1585
1765
|
<div
|
|
1586
1766
|
class="viewport"
|
|
1587
|
-
style="height:{height}px;{
|
|
1767
|
+
style="height:{height}px;{hScroll ? 'overflow-x:auto;' : ''}"
|
|
1588
1768
|
bind:this={viewportEl}
|
|
1769
|
+
bind:clientWidth={viewW}
|
|
1589
1770
|
onscroll={onScroll}
|
|
1590
1771
|
>
|
|
1591
1772
|
{#if pinnedRows.length > 0}
|
|
@@ -1606,7 +1787,7 @@
|
|
|
1606
1787
|
pinned={pinned && layout.info[ci].pinned}
|
|
1607
1788
|
pinSide={layout.info[ci].side ?? 'left'}
|
|
1608
1789
|
pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
|
|
1609
|
-
width={
|
|
1790
|
+
width={hScroll ? layout.info[ci].width : undefined}
|
|
1610
1791
|
/>
|
|
1611
1792
|
{/each}
|
|
1612
1793
|
</div>
|
|
@@ -1631,13 +1812,13 @@
|
|
|
1631
1812
|
{/each}
|
|
1632
1813
|
</div>
|
|
1633
1814
|
{/if}
|
|
1634
|
-
<div class="spacer" style="height:{total}px;{
|
|
1815
|
+
<div class="spacer" style="height:{total}px;{hScroll ? `width:${layout.totalWidth + leadPx}px;` : ''}">
|
|
1635
1816
|
{#each renderItems as item (item.vr)}
|
|
1636
1817
|
{#if item.kind === 'group'}
|
|
1637
1818
|
<div class="grouprow" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
|
|
1638
1819
|
{#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
|
|
1639
1820
|
{#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
|
|
1640
|
-
<GroupRow group={item.group} columns={cols} onToggle={toggleGroup} rowIndex={item.vr + 2} />
|
|
1821
|
+
<GroupRow group={item.group} columns={cols} onToggle={lazyGrouped ? toggleLazyGroup : toggleGroup} rowIndex={item.vr + 2} />
|
|
1641
1822
|
</div>
|
|
1642
1823
|
{:else if item.kind === 'skeleton'}
|
|
1643
1824
|
<div class="row skeleton" role="row" aria-rowindex={item.vr + 2} aria-hidden="true" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
|
|
@@ -1647,6 +1828,14 @@
|
|
|
1647
1828
|
<span class="c" style={cellWidthStyle(ci)}><span class="skelbar"></span></span>
|
|
1648
1829
|
{/each}
|
|
1649
1830
|
</div>
|
|
1831
|
+
{:else if item.kind === 'treeloading'}
|
|
1832
|
+
<div class="row treeloading" role="row" aria-rowindex={item.vr + 2} aria-live="polite" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
|
|
1833
|
+
{#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
|
|
1834
|
+
{#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
|
|
1835
|
+
<span class="tree-loading-cell" style="padding-left:{(item.depth ?? 0) * 16 + 24}px">
|
|
1836
|
+
<span class="spinner sm" aria-hidden="true"></span>Loading…
|
|
1837
|
+
</span>
|
|
1838
|
+
</div>
|
|
1650
1839
|
{:else}
|
|
1651
1840
|
<!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
|
|
1652
1841
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
@@ -1681,48 +1870,55 @@
|
|
|
1681
1870
|
/>
|
|
1682
1871
|
</span>
|
|
1683
1872
|
{/if}
|
|
1684
|
-
{#each
|
|
1685
|
-
|
|
1686
|
-
{
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1873
|
+
{#each colItems as it (it.key)}
|
|
1874
|
+
{#if it.kind === 'cell'}
|
|
1875
|
+
{@const ci = it.ci}
|
|
1876
|
+
{@const col = cols[ci]}
|
|
1877
|
+
<Cell
|
|
1878
|
+
{col}
|
|
1879
|
+
row={item.row}
|
|
1880
|
+
r={item.vr}
|
|
1881
|
+
c={ci}
|
|
1882
|
+
colIndex={ci + 1 + leadCols}
|
|
1883
|
+
cellId={`${gid}-r${item.vr}-c${ci}`}
|
|
1884
|
+
cellSnippet={cell}
|
|
1885
|
+
selected={sel.contains(item.vr, ci)}
|
|
1886
|
+
focused={sel.isFocus(item.vr, ci)}
|
|
1887
|
+
pinned={pinned && layout.info[ci].pinned}
|
|
1888
|
+
pinSide={layout.info[ci].side ?? 'left'}
|
|
1889
|
+
pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
|
|
1890
|
+
width={hScroll ? layout.info[ci].width : undefined}
|
|
1891
|
+
alt={item.vr % 2 === 1}
|
|
1892
|
+
editing={editing?.r === item.vr && editing?.c === ci}
|
|
1893
|
+
seed={editing?.r === item.vr && editing?.c === ci ? editSeed : null}
|
|
1894
|
+
fillCorner={fillHandle && !!sel.bounds && item.vr === sel.bounds.r1 && ci === sel.bounds.c1}
|
|
1895
|
+
fillpreview={inFillPreview(item.vr, ci)}
|
|
1896
|
+
cfRange={col.dataBar || col.colorScale ? cfRanges[col.key] : null}
|
|
1897
|
+
{onFillStart}
|
|
1898
|
+
tree={treeData && ci === 0
|
|
1899
|
+
? {
|
|
1900
|
+
depth: item.depth ?? 0,
|
|
1901
|
+
hasChildren: item.hasChildren ?? false,
|
|
1902
|
+
expanded: isExpanded(getRowId(item.row)),
|
|
1903
|
+
onToggle: () => toggleTreeNode(item.row),
|
|
1904
|
+
}
|
|
1905
|
+
: undefined}
|
|
1906
|
+
dragHandle={reorderable && ci === 0
|
|
1907
|
+
? { onStart: () => (dragRowVr = item.vr), onEnd: () => (dragRowVr = -1) }
|
|
1908
|
+
: undefined}
|
|
1909
|
+
{onCellDown}
|
|
1910
|
+
{onCellEnter}
|
|
1911
|
+
onCellClick={onCellClick ? onCellClicked : undefined}
|
|
1912
|
+
onCellDblClick={startEdit}
|
|
1913
|
+
onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
|
|
1914
|
+
onEditCancel={() => {
|
|
1915
|
+
editing = null;
|
|
1916
|
+
editSeed = null;
|
|
1917
|
+
}}
|
|
1918
|
+
/>
|
|
1919
|
+
{:else}
|
|
1920
|
+
<span class="colspacer" aria-hidden="true" style="flex:0 0 {it.w}px;width:{it.w}px;"></span>
|
|
1921
|
+
{/if}
|
|
1726
1922
|
{/each}
|
|
1727
1923
|
</div>
|
|
1728
1924
|
{#if expandable && detail && isExpanded(getRowId(item.row))}
|
|
@@ -1734,7 +1930,7 @@
|
|
|
1734
1930
|
{/each}
|
|
1735
1931
|
</div>
|
|
1736
1932
|
{#if footerCells}
|
|
1737
|
-
<div class="footer" role="row" style={
|
|
1933
|
+
<div class="footer" role="row" style={hScroll ? `width:${layout.totalWidth + leadPx}px;` : ''}>
|
|
1738
1934
|
{#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
|
|
1739
1935
|
{#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
|
|
1740
1936
|
{#each cols as col, ci (ci)}
|
|
@@ -1913,6 +2109,15 @@
|
|
|
1913
2109
|
border: 0;
|
|
1914
2110
|
cursor: grab;
|
|
1915
2111
|
}
|
|
2112
|
+
/* Visible keyboard focus (WCAG 2.4.7) for the grid's tabbable controls. */
|
|
2113
|
+
.h:focus-visible,
|
|
2114
|
+
.bo-cols-toggle:focus-visible,
|
|
2115
|
+
.expand-toggle:focus-visible,
|
|
2116
|
+
.bo-quickfilter:focus-visible {
|
|
2117
|
+
outline: 2px solid var(--bo-sel-border);
|
|
2118
|
+
outline-offset: -2px;
|
|
2119
|
+
color: var(--bo-text);
|
|
2120
|
+
}
|
|
1916
2121
|
/* Drag-to-resize grip: a thin hit-target straddling the column's right edge. */
|
|
1917
2122
|
.h .grip {
|
|
1918
2123
|
position: absolute;
|
|
@@ -2110,6 +2315,23 @@
|
|
|
2110
2315
|
animation: none;
|
|
2111
2316
|
}
|
|
2112
2317
|
}
|
|
2318
|
+
.spinner.sm {
|
|
2319
|
+
width: 12px;
|
|
2320
|
+
height: 12px;
|
|
2321
|
+
border-width: 1.5px;
|
|
2322
|
+
}
|
|
2323
|
+
/* Async tree: placeholder row shown while a node's children load. */
|
|
2324
|
+
.row.treeloading {
|
|
2325
|
+
align-items: center;
|
|
2326
|
+
}
|
|
2327
|
+
.tree-loading-cell {
|
|
2328
|
+
display: inline-flex;
|
|
2329
|
+
align-items: center;
|
|
2330
|
+
gap: 8px;
|
|
2331
|
+
font-size: var(--bo-font-size, 13px);
|
|
2332
|
+
color: var(--bo-text-dim);
|
|
2333
|
+
font-style: italic;
|
|
2334
|
+
}
|
|
2113
2335
|
.spacer {
|
|
2114
2336
|
position: relative;
|
|
2115
2337
|
width: 100%;
|