bo-grid 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -5
- package/dist/grid/Cell.svelte +50 -2
- package/dist/grid/Cell.svelte.d.ts +5 -0
- package/dist/grid/FilterMenu.svelte +263 -0
- package/dist/grid/FilterMenu.svelte.d.ts +15 -0
- package/dist/grid/Grid.svelte +581 -31
- package/dist/grid/Grid.svelte.d.ts +33 -1
- package/dist/grid/ToolPanel.svelte +117 -0
- package/dist/grid/ToolPanel.svelte.d.ts +15 -0
- package/dist/grid/column.d.ts +5 -0
- package/dist/grid/column.js +1 -1
- package/dist/grid/filtering.d.ts +41 -0
- package/dist/grid/filtering.js +107 -0
- package/dist/grid/source.d.ts +3 -0
- package/dist/grid/source.js +5 -0
- package/dist/grid/source.svelte.d.ts +2 -1
- package/dist/grid/source.svelte.js +6 -5
- package/dist/index.d.ts +1 -0
- package/package.json +1 -1
package/dist/grid/Grid.svelte
CHANGED
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
import { moveIndex } from './reorder';
|
|
19
19
|
import { parseClipboard, isSingleCell } from './clipboard';
|
|
20
20
|
import { applyWidths, clampWidth, isResizable, type WidthMap } from './sizing';
|
|
21
|
+
import {
|
|
22
|
+
passesFilters,
|
|
23
|
+
isFilterActive,
|
|
24
|
+
defaultFilterKind,
|
|
25
|
+
distinctValues,
|
|
26
|
+
type ColumnFilter,
|
|
27
|
+
type FilterKind,
|
|
28
|
+
} from './filtering';
|
|
21
29
|
import type { RowSource } from './source';
|
|
22
30
|
import { RowSourceController } from './source.svelte';
|
|
23
31
|
import Cell from './Cell.svelte';
|
|
@@ -42,15 +50,23 @@
|
|
|
42
50
|
rowSelection = false,
|
|
43
51
|
onRowSelectionChange,
|
|
44
52
|
hiddenColumns = [],
|
|
53
|
+
onColumnVisibilityChange,
|
|
54
|
+
columnMenu = false,
|
|
55
|
+
columnsPanel = false,
|
|
45
56
|
rowClass,
|
|
46
57
|
getRowId = (r: GridRow) => r.id,
|
|
47
58
|
onRowClick,
|
|
48
59
|
sort,
|
|
49
60
|
onSortChange,
|
|
61
|
+
columnFilters,
|
|
62
|
+
onFilterChange,
|
|
50
63
|
footer = false,
|
|
51
64
|
onCellClick,
|
|
52
65
|
pinnedRows = [],
|
|
53
66
|
filterRow = false,
|
|
67
|
+
filterMenu = false,
|
|
68
|
+
quickFilter = false,
|
|
69
|
+
fillHandle = false,
|
|
54
70
|
emptyMessage = 'No matching rows',
|
|
55
71
|
loading = false,
|
|
56
72
|
rowMenu,
|
|
@@ -83,8 +99,19 @@
|
|
|
83
99
|
/** Called with the selected row ids whenever the row-selection set changes. */
|
|
84
100
|
onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
|
|
85
101
|
/** Column keys to hide (controlled). Build your own column-picker UI and
|
|
86
|
-
drive this prop — the grid stays presentation-only.
|
|
102
|
+
drive this prop — the grid stays presentation-only. Composed (union) with
|
|
103
|
+
columns the user hides at runtime via the column menu. */
|
|
87
104
|
hiddenColumns?: string[];
|
|
105
|
+
/** Called with all currently-hidden column keys whenever the runtime set
|
|
106
|
+
changes (column menu hide/show). */
|
|
107
|
+
onColumnVisibilityChange?: (hidden: string[]) => void;
|
|
108
|
+
/** Enable a per-column header menu (a ⋮ trigger) with sort, hide and (with
|
|
109
|
+
`filterMenu`) filter actions. Default false. */
|
|
110
|
+
columnMenu?: boolean;
|
|
111
|
+
/** Show a "Columns" button that opens a panel to toggle column visibility
|
|
112
|
+
(the place to restore columns hidden via the menu). Lazy-loaded. Default
|
|
113
|
+
false. */
|
|
114
|
+
columnsPanel?: boolean;
|
|
88
115
|
/** Return extra CSS class(es) for a data row (e.g. to colour by value).
|
|
89
116
|
Style them via `:global(.your-class)` since rows live inside the grid. */
|
|
90
117
|
rowClass?: (row: GridRow) => string | undefined;
|
|
@@ -100,6 +127,12 @@
|
|
|
100
127
|
sort?: SortState[];
|
|
101
128
|
/** Called with the new sort order whenever a header is clicked. */
|
|
102
129
|
onSortChange?: (sort: SortState[]) => void;
|
|
130
|
+
/** Controlled column filters (keyed by column key). When set, the grid
|
|
131
|
+
reflects these and reports changes via `onFilterChange` instead of holding
|
|
132
|
+
its own. Omit for uncontrolled filtering. */
|
|
133
|
+
columnFilters?: Record<string, ColumnFilter>;
|
|
134
|
+
/** Called with the full column-filter map whenever a header filter changes. */
|
|
135
|
+
onFilterChange?: (filters: Record<string, ColumnFilter>) => void;
|
|
103
136
|
/** Show a pinned totals row: each column with a `groupAgg` shows that
|
|
104
137
|
aggregate over all (filtered) rows. In-memory mode only. Default false. */
|
|
105
138
|
footer?: boolean;
|
|
@@ -115,6 +148,20 @@
|
|
|
115
148
|
/** Show a per-column filter input row under the header. Rows must match every
|
|
116
149
|
non-empty column filter (AND). In-memory mode only. Default false. */
|
|
117
150
|
filterRow?: boolean;
|
|
151
|
+
/** Enable a per-column header filter menu (lazy-loaded on first open). Each
|
|
152
|
+
filterable column shows a funnel; the menu's control matches the column
|
|
153
|
+
type (text/number/date). Override or disable per column with `col.filter`.
|
|
154
|
+
Works in source mode too (filters are delegated to the `RowSource`); set
|
|
155
|
+
filters need in-memory data. Default false. */
|
|
156
|
+
filterMenu?: boolean;
|
|
157
|
+
/** Show a built-in quick-filter search box above the grid that matches across
|
|
158
|
+
all column values (ANDed with the `filter` prop). In-memory mode only.
|
|
159
|
+
Default false. */
|
|
160
|
+
quickFilter?: boolean;
|
|
161
|
+
/** Show an Excel-style fill handle at the selection's bottom-right corner;
|
|
162
|
+
drag it to copy the selected value(s) across the extended range (editable
|
|
163
|
+
columns only). In-memory mode only. Default false. */
|
|
164
|
+
fillHandle?: boolean;
|
|
118
165
|
/** Message shown when there are no rows. Default 'No matching rows'. */
|
|
119
166
|
emptyMessage?: string;
|
|
120
167
|
/** Show a loading overlay over the grid (for consumer-driven async work in
|
|
@@ -184,6 +231,10 @@
|
|
|
184
231
|
|
|
185
232
|
const sel = new Selection();
|
|
186
233
|
let dragging = $state(false);
|
|
234
|
+
// Fill handle: drag the selection's corner to copy its value(s) across.
|
|
235
|
+
let filling = $state(false);
|
|
236
|
+
let fillTo = $state<{ r: number; c: number } | null>(null);
|
|
237
|
+
let fillSource: { r0: number; c0: number; r1: number; c1: number } | null = null;
|
|
187
238
|
let editing = $state<{ r: number; c: number } | null>(null);
|
|
188
239
|
// When editing was opened by typing a character (type-to-edit), the editor
|
|
189
240
|
// seeds its input with it; null means edit the existing value (dblclick/Enter).
|
|
@@ -194,24 +245,74 @@
|
|
|
194
245
|
editSeed = seed;
|
|
195
246
|
editing = { r, c };
|
|
196
247
|
}
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
248
|
+
// ---- Edit history (undo / redo) -------------------------------------------
|
|
249
|
+
// The grid is controlled (the consumer owns the data via onCellEdit), so undo
|
|
250
|
+
// re-emits onCellEdit with the previous value. History is keyed by row object
|
|
251
|
+
// reference + column, so it survives sort/filter/reorder. Multi-cell ops
|
|
252
|
+
// (paste, fill) record as one grouped step.
|
|
253
|
+
type EditCell = { row: GridRow; col: ColumnDef; old: string | number; value: string | number };
|
|
254
|
+
const UNDO_LIMIT = 100;
|
|
255
|
+
let undoStack: EditCell[][] = [];
|
|
256
|
+
let redoStack: EditCell[][] = [];
|
|
257
|
+
let currentBatch: EditCell[] | null = null;
|
|
258
|
+
function pushUndo(group: EditCell[]): void {
|
|
259
|
+
undoStack.push(group);
|
|
260
|
+
if (undoStack.length > UNDO_LIMIT) undoStack.shift();
|
|
261
|
+
redoStack = [];
|
|
262
|
+
}
|
|
263
|
+
/** Group every writeCell inside `fn` into a single undo step. */
|
|
264
|
+
function batch(fn: () => void): void {
|
|
265
|
+
const prev = currentBatch;
|
|
266
|
+
currentBatch = [];
|
|
267
|
+
fn();
|
|
268
|
+
const group = currentBatch;
|
|
269
|
+
currentBatch = prev;
|
|
270
|
+
if (group.length) pushUndo(group);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Coerce + validate a raw string for cell (r,c) and emit onCellEdit, recording
|
|
274
|
+
// it for undo. Returns true if written, false if rejected (not editable,
|
|
275
|
+
// missing row, or invalid number). Shared by inline edit, paste and fill.
|
|
200
276
|
function writeCell(r: number, c: number, raw: string): boolean {
|
|
201
277
|
const col = cols[c];
|
|
202
278
|
if (!col || !isEditable(col)) return false;
|
|
203
279
|
const row = dataAt(r);
|
|
204
280
|
if (!row) return false;
|
|
205
281
|
let value: string | number = raw;
|
|
206
|
-
if (
|
|
282
|
+
if (col.type === 'date') {
|
|
283
|
+
// The date editor emits a yyyy-mm-dd string; store the column's ms value.
|
|
284
|
+
const ms = Date.parse(`${raw}T00:00:00Z`);
|
|
285
|
+
if (!Number.isFinite(ms)) return false;
|
|
286
|
+
value = ms;
|
|
287
|
+
} else if (isNumeric(col)) {
|
|
207
288
|
const n = Number(raw);
|
|
208
289
|
if (!Number.isFinite(n)) return false; // reject invalid number, keep old value
|
|
209
290
|
value = n;
|
|
210
291
|
}
|
|
211
292
|
if (col.validate && !col.validate(value, row)) return false; // consumer rejected it
|
|
293
|
+
const old = (row[col.key] ?? '') as string | number;
|
|
212
294
|
onCellEdit?.({ row, column: col, value });
|
|
295
|
+
if (onCellEdit) {
|
|
296
|
+
const entry: EditCell = { row, col, old, value };
|
|
297
|
+
if (currentBatch) currentBatch.push(entry);
|
|
298
|
+
else pushUndo([entry]);
|
|
299
|
+
}
|
|
213
300
|
return true;
|
|
214
301
|
}
|
|
302
|
+
function undo(): void {
|
|
303
|
+
const group = undoStack.pop();
|
|
304
|
+
if (!group) return;
|
|
305
|
+
for (const e of group) onCellEdit?.({ row: e.row, column: e.col, value: e.old });
|
|
306
|
+
redoStack.push(group);
|
|
307
|
+
editing = null;
|
|
308
|
+
}
|
|
309
|
+
function redo(): void {
|
|
310
|
+
const group = redoStack.pop();
|
|
311
|
+
if (!group) return;
|
|
312
|
+
for (const e of group) onCellEdit?.({ row: e.row, column: e.col, value: e.value });
|
|
313
|
+
undoStack.push(group);
|
|
314
|
+
editing = null;
|
|
315
|
+
}
|
|
215
316
|
|
|
216
317
|
function commitEdit(r: number, c: number, raw: string) {
|
|
217
318
|
editing = null;
|
|
@@ -233,6 +334,19 @@
|
|
|
233
334
|
// Per-column filter text (filterRow), keyed by column key.
|
|
234
335
|
let colFilters = $state<Record<string, string>>({});
|
|
235
336
|
|
|
337
|
+
// Structured per-column filters from the header filter menu (v0.3), keyed by
|
|
338
|
+
// column key. Menu filters take precedence over the filterRow text inputs.
|
|
339
|
+
// Controlled by the `columnFilters` prop when provided, else internal state.
|
|
340
|
+
let internalColumnFilters = $state<Record<string, ColumnFilter>>({});
|
|
341
|
+
const activeColumnFilters = $derived(columnFilters ?? internalColumnFilters);
|
|
342
|
+
function setColumnFilters(next: Record<string, ColumnFilter>): void {
|
|
343
|
+
if (columnFilters === undefined) internalColumnFilters = next; // uncontrolled: own it
|
|
344
|
+
onFilterChange?.(next); // always notify
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Built-in quick-filter text (global, matches across all columns).
|
|
348
|
+
let quickText = $state('');
|
|
349
|
+
|
|
236
350
|
// Whole-row selection (opt-in), keyed by row id so it survives sort/filter.
|
|
237
351
|
// Plain Set + a version counter for reactivity (same pattern as `collapsed`).
|
|
238
352
|
const SEL_W = 40; // checkbox column width (px)
|
|
@@ -273,17 +387,30 @@
|
|
|
273
387
|
}
|
|
274
388
|
|
|
275
389
|
const ordered = $derived(order.length === columns.length ? order.map((i) => columns[i]) : columns);
|
|
276
|
-
//
|
|
277
|
-
//
|
|
390
|
+
// Columns hidden at runtime via the column menu, unioned with the controlled
|
|
391
|
+
// `hiddenColumns` prop.
|
|
392
|
+
let runtimeHidden = $state<string[]>([]);
|
|
393
|
+
const effectiveHidden = $derived(
|
|
394
|
+
runtimeHidden.length ? [...new Set([...hiddenColumns, ...runtimeHidden])] : hiddenColumns,
|
|
395
|
+
);
|
|
396
|
+
// Drop hidden columns. Applied after ordering so `order` stays indexed over the
|
|
397
|
+
// full column set.
|
|
278
398
|
const visible = $derived(
|
|
279
|
-
|
|
399
|
+
effectiveHidden.length ? ordered.filter((c) => !effectiveHidden.includes(c.key)) : ordered,
|
|
280
400
|
);
|
|
281
401
|
// Apply any resize overrides (turns the dragged column fixed-width), then
|
|
282
402
|
// pin-arrange. Both are no-ops by default, so the grid stays fit-to-width.
|
|
283
403
|
const sized = $derived(applyWidths(visible, widths));
|
|
284
|
-
//
|
|
404
|
+
// Runtime pin overrides (column menu) layered on top of static `col.pinned`.
|
|
405
|
+
let pinOverrides = $state<Record<string, 'left' | 'right' | false>>({});
|
|
406
|
+
const pinnedSized = $derived(
|
|
407
|
+
Object.keys(pinOverrides).length
|
|
408
|
+
? sized.map((c) => (c.key in pinOverrides ? { ...c, pinned: pinOverrides[c.key] } : c))
|
|
409
|
+
: sized,
|
|
410
|
+
);
|
|
411
|
+
// Pin-arrangement: pinned columns move to the edges and get sticky offsets.
|
|
285
412
|
// When nothing is pinned this is a no-op and the grid stays fit-to-width.
|
|
286
|
-
const layout = $derived(arrangePinned(
|
|
413
|
+
const layout = $derived(arrangePinned(pinnedSized));
|
|
287
414
|
const cols = $derived(layout.columns);
|
|
288
415
|
const pinned = $derived(layout.anyPinned);
|
|
289
416
|
|
|
@@ -422,6 +549,105 @@
|
|
|
422
549
|
});
|
|
423
550
|
});
|
|
424
551
|
|
|
552
|
+
// ---- Runtime column visibility (column menu) ------------------------------
|
|
553
|
+
function hiddenStorageKey(): string | null {
|
|
554
|
+
return persistKey ? `bo-grid:hidden:${persistKey}` : null;
|
|
555
|
+
}
|
|
556
|
+
$effect(() => {
|
|
557
|
+
persistKey;
|
|
558
|
+
untrack(() => {
|
|
559
|
+
const key = hiddenStorageKey();
|
|
560
|
+
if (!key || typeof localStorage === 'undefined') return;
|
|
561
|
+
try {
|
|
562
|
+
const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
|
|
563
|
+
if (Array.isArray(saved)) runtimeHidden = saved.filter((k) => typeof k === 'string');
|
|
564
|
+
} catch {
|
|
565
|
+
/* corrupt value — ignore */
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
function setRuntimeHidden(next: string[]): void {
|
|
570
|
+
runtimeHidden = next;
|
|
571
|
+
const key = hiddenStorageKey();
|
|
572
|
+
if (key && typeof localStorage !== 'undefined') {
|
|
573
|
+
try {
|
|
574
|
+
localStorage.setItem(key, JSON.stringify(next));
|
|
575
|
+
} catch {
|
|
576
|
+
/* storage unavailable — still applies this session */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
onColumnVisibilityChange?.(effectiveHidden);
|
|
580
|
+
}
|
|
581
|
+
function hideColumn(key: string): void {
|
|
582
|
+
if (!runtimeHidden.includes(key)) setRuntimeHidden([...runtimeHidden, key]);
|
|
583
|
+
}
|
|
584
|
+
function showColumn(key: string): void {
|
|
585
|
+
if (runtimeHidden.includes(key)) setRuntimeHidden(runtimeHidden.filter((k) => k !== key));
|
|
586
|
+
}
|
|
587
|
+
function toggleColumnVisible(key: string): void {
|
|
588
|
+
if (effectiveHidden.includes(key)) showColumn(key);
|
|
589
|
+
else hideColumn(key);
|
|
590
|
+
}
|
|
591
|
+
function showAllColumns(): void {
|
|
592
|
+
setRuntimeHidden([]);
|
|
593
|
+
}
|
|
594
|
+
// Columns tool panel (lazy-loaded checklist), anchored at the toolbar button.
|
|
595
|
+
let ToolPanelComp = $state<typeof import('./ToolPanel.svelte').default | null>(null);
|
|
596
|
+
let panelXY = $state<{ x: number; y: number } | null>(null);
|
|
597
|
+
async function openToolPanel(e: Event) {
|
|
598
|
+
e.stopPropagation();
|
|
599
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
600
|
+
if (!ToolPanelComp) ToolPanelComp = (await import('./ToolPanel.svelte')).default;
|
|
601
|
+
panelXY = { x: rect.left, y: rect.bottom + 2 };
|
|
602
|
+
}
|
|
603
|
+
$effect(() => {
|
|
604
|
+
if (!panelXY) return;
|
|
605
|
+
const close = () => (panelXY = null);
|
|
606
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (panelXY = null);
|
|
607
|
+
window.addEventListener('pointerdown', close);
|
|
608
|
+
window.addEventListener('keydown', onKey);
|
|
609
|
+
window.addEventListener('blur', close);
|
|
610
|
+
return () => {
|
|
611
|
+
window.removeEventListener('pointerdown', close);
|
|
612
|
+
window.removeEventListener('keydown', onKey);
|
|
613
|
+
window.removeEventListener('blur', close);
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// ---- Runtime column pinning (column menu) ---------------------------------
|
|
618
|
+
function pinStorageKey(): string | null {
|
|
619
|
+
return persistKey ? `bo-grid:pin:${persistKey}` : null;
|
|
620
|
+
}
|
|
621
|
+
$effect(() => {
|
|
622
|
+
persistKey;
|
|
623
|
+
untrack(() => {
|
|
624
|
+
const key = pinStorageKey();
|
|
625
|
+
if (!key || typeof localStorage === 'undefined') return;
|
|
626
|
+
try {
|
|
627
|
+
const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
|
|
628
|
+
if (saved && typeof saved === 'object') pinOverrides = saved as typeof pinOverrides;
|
|
629
|
+
} catch {
|
|
630
|
+
/* corrupt value — ignore */
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
function setPinOverride(key: string, side: 'left' | 'right' | false): void {
|
|
635
|
+
pinOverrides = { ...pinOverrides, [key]: side };
|
|
636
|
+
const sk = pinStorageKey();
|
|
637
|
+
if (sk && typeof localStorage !== 'undefined') {
|
|
638
|
+
try {
|
|
639
|
+
localStorage.setItem(sk, JSON.stringify(pinOverrides));
|
|
640
|
+
} catch {
|
|
641
|
+
/* storage unavailable — still applies this session */
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Effective pin side for a column key: runtime override, else static config.
|
|
646
|
+
function pinSideOf(col: ColumnDef): 'left' | 'right' | false {
|
|
647
|
+
if (col.key in pinOverrides) return pinOverrides[col.key];
|
|
648
|
+
return col.pinned === true ? 'left' : col.pinned || false;
|
|
649
|
+
}
|
|
650
|
+
|
|
425
651
|
let resize: { key: string; startX: number; startW: number; min?: number; max?: number } | null = null;
|
|
426
652
|
let justResized = false;
|
|
427
653
|
|
|
@@ -503,16 +729,22 @@
|
|
|
503
729
|
const base = rows;
|
|
504
730
|
const allCols = columns;
|
|
505
731
|
const f = filter.trim().toLowerCase();
|
|
732
|
+
const q = quickText.trim().toLowerCase();
|
|
506
733
|
const s = sorts;
|
|
507
|
-
// Active per-column filters
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
734
|
+
// Active per-column filters: menu-driven structured filters (columnFilters)
|
|
735
|
+
// take precedence; filterRow text inputs (colFilters) fill in the rest as
|
|
736
|
+
// case-insensitive "contains".
|
|
737
|
+
const active: Record<string, ColumnFilter> = { ...activeColumnFilters };
|
|
738
|
+
for (const [k, v] of Object.entries(colFilters)) {
|
|
739
|
+
if (!active[k] && v.trim()) active[k] = { kind: 'text', op: 'contains', q: v };
|
|
740
|
+
}
|
|
741
|
+
const hasColFilters = Object.keys(active).length > 0;
|
|
511
742
|
return untrack(() => {
|
|
512
743
|
let r = base;
|
|
513
744
|
if (f) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(f)));
|
|
514
|
-
if (
|
|
515
|
-
|
|
745
|
+
if (q) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(q)));
|
|
746
|
+
if (hasColFilters) {
|
|
747
|
+
r = r.filter((row) => passesFilters(row, active));
|
|
516
748
|
}
|
|
517
749
|
if (s.length > 0) {
|
|
518
750
|
const colOf = (k: string) => allCols.find((c) => c.key === k);
|
|
@@ -703,7 +935,8 @@
|
|
|
703
935
|
const range = { start, end: start + visibleCount };
|
|
704
936
|
const s = sorts;
|
|
705
937
|
const f = filter;
|
|
706
|
-
|
|
938
|
+
const cf = activeColumnFilters;
|
|
939
|
+
void ctrl.fetch(range, s, f, cf);
|
|
707
940
|
});
|
|
708
941
|
|
|
709
942
|
function dataAt(r: number): GridRow | null {
|
|
@@ -755,7 +988,58 @@
|
|
|
755
988
|
}
|
|
756
989
|
|
|
757
990
|
function onCellEnter(r: number, c: number) {
|
|
758
|
-
if (
|
|
991
|
+
if (filling) fillTo = { r, c };
|
|
992
|
+
else if (dragging) sel.extendTo(r, c);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Fill handle: start dragging from the selection's bottom-right corner.
|
|
996
|
+
function onFillStart(): void {
|
|
997
|
+
const b = sel.bounds;
|
|
998
|
+
if (!b) return;
|
|
999
|
+
fillSource = { ...b };
|
|
1000
|
+
fillTo = { r: b.r1, c: b.c1 };
|
|
1001
|
+
filling = true;
|
|
1002
|
+
}
|
|
1003
|
+
// The rectangle the fill drag currently covers (extends down/right only).
|
|
1004
|
+
const fillRange = $derived.by(() => {
|
|
1005
|
+
if (!filling || !fillSource || !fillTo) return null;
|
|
1006
|
+
return {
|
|
1007
|
+
r0: fillSource.r0,
|
|
1008
|
+
c0: fillSource.c0,
|
|
1009
|
+
r1: Math.max(fillSource.r1, fillTo.r),
|
|
1010
|
+
c1: Math.max(fillSource.c1, fillTo.c),
|
|
1011
|
+
};
|
|
1012
|
+
});
|
|
1013
|
+
function inFillPreview(r: number, c: number): boolean {
|
|
1014
|
+
const fr = fillRange;
|
|
1015
|
+
return !!fr && r >= fr.r0 && r <= fr.r1 && c >= fr.c0 && c <= fr.c1 && !sel.contains(r, c);
|
|
1016
|
+
}
|
|
1017
|
+
// Commit the fill: tile the source block's values across the extended range.
|
|
1018
|
+
function commitFill(): void {
|
|
1019
|
+
if (!filling) return;
|
|
1020
|
+
filling = false;
|
|
1021
|
+
const b = fillSource;
|
|
1022
|
+
const ft = fillTo;
|
|
1023
|
+
fillTo = null;
|
|
1024
|
+
fillSource = null;
|
|
1025
|
+
if (!b || !ft) return;
|
|
1026
|
+
const r1 = Math.max(b.r1, ft.r);
|
|
1027
|
+
const c1 = Math.max(b.c1, ft.c);
|
|
1028
|
+
if (r1 === b.r1 && c1 === b.c1) return; // no extension
|
|
1029
|
+
const srcRows = b.r1 - b.r0 + 1;
|
|
1030
|
+
const srcCols = b.c1 - b.c0 + 1;
|
|
1031
|
+
batch(() => {
|
|
1032
|
+
for (let r = b.r0; r <= r1; r++) {
|
|
1033
|
+
for (let c = b.c0; c <= c1; c++) {
|
|
1034
|
+
if (r <= b.r1 && c <= b.c1) continue; // skip the source block
|
|
1035
|
+
const srcRow = dataAt(b.r0 + ((r - b.r0) % srcRows));
|
|
1036
|
+
if (!srcRow) continue;
|
|
1037
|
+
writeCell(r, c, String(srcRow[cols[b.c0 + ((c - b.c0) % srcCols)].key] ?? ''));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
sel.anchor = { r: b.r0, c: b.c0 };
|
|
1042
|
+
sel.focus = { r: r1, c: c1 };
|
|
759
1043
|
}
|
|
760
1044
|
|
|
761
1045
|
// Right-click row menu (floating).
|
|
@@ -781,6 +1065,96 @@
|
|
|
781
1065
|
};
|
|
782
1066
|
});
|
|
783
1067
|
|
|
1068
|
+
// Header filter menu (v0.3), lazy-loaded on first open to keep the core lean.
|
|
1069
|
+
let FilterMenuComp = $state<typeof import('./FilterMenu.svelte').default | null>(null);
|
|
1070
|
+
let filterUi = $state<{
|
|
1071
|
+
key: string;
|
|
1072
|
+
kind: FilterKind;
|
|
1073
|
+
header: string;
|
|
1074
|
+
values: string[];
|
|
1075
|
+
x: number;
|
|
1076
|
+
y: number;
|
|
1077
|
+
} | null>(null);
|
|
1078
|
+
function filterKindFor(col: ColumnDef): FilterKind {
|
|
1079
|
+
return typeof col.filter === 'string' ? col.filter : defaultFilterKind(col);
|
|
1080
|
+
}
|
|
1081
|
+
async function openFilterMenu(col: ColumnDef, e: Event) {
|
|
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();
|
|
1085
|
+
let kind = filterKindFor(col);
|
|
1086
|
+
// A set filter needs distinct values; in source mode they can't be
|
|
1087
|
+
// enumerated, so fall back to the column's typed filter.
|
|
1088
|
+
if (source && kind === 'set') kind = defaultFilterKind(col);
|
|
1089
|
+
const values = !source && kind === 'set' ? distinctValues(rows, col.key) : [];
|
|
1090
|
+
if (!FilterMenuComp) FilterMenuComp = (await import('./FilterMenu.svelte')).default;
|
|
1091
|
+
filterUi = { key: col.key, kind, header: col.header, values, x: rect.left, y: rect.bottom + 2 };
|
|
1092
|
+
}
|
|
1093
|
+
function applyColumnFilter(key: string, f: ColumnFilter | null): void {
|
|
1094
|
+
const next = { ...activeColumnFilters };
|
|
1095
|
+
if (f) next[key] = f;
|
|
1096
|
+
else delete next[key];
|
|
1097
|
+
setColumnFilters(next);
|
|
1098
|
+
filterUi = null;
|
|
1099
|
+
}
|
|
1100
|
+
$effect(() => {
|
|
1101
|
+
if (!filterUi) return;
|
|
1102
|
+
const close = () => (filterUi = null);
|
|
1103
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (filterUi = null);
|
|
1104
|
+
window.addEventListener('pointerdown', close);
|
|
1105
|
+
window.addEventListener('keydown', onKey);
|
|
1106
|
+
window.addEventListener('blur', close);
|
|
1107
|
+
return () => {
|
|
1108
|
+
window.removeEventListener('pointerdown', close);
|
|
1109
|
+
window.removeEventListener('keydown', onKey);
|
|
1110
|
+
window.removeEventListener('blur', close);
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
// Autosize a column to its content (a character-count heuristic — no DOM
|
|
1115
|
+
// measurement, so it's cheap and deterministic). Sets the same width override
|
|
1116
|
+
// as a manual resize.
|
|
1117
|
+
function autosizeColumn(col: ColumnDef): void {
|
|
1118
|
+
const CHAR_PX = 7.5;
|
|
1119
|
+
const PAD = 32; // cell padding + room for the header icons
|
|
1120
|
+
let maxLen = col.header.length;
|
|
1121
|
+
if (!source) {
|
|
1122
|
+
for (const row of view.slice(0, 500)) {
|
|
1123
|
+
const s = formatCell(col, row[col.key], row);
|
|
1124
|
+
if (s.length > maxLen) maxLen = s.length;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const w = clampWidth(Math.round(maxLen * CHAR_PX) + PAD, col.minWidth, col.maxWidth);
|
|
1128
|
+
widths = { ...widths, [col.key]: w };
|
|
1129
|
+
persistWidths();
|
|
1130
|
+
onColumnResize?.(col.key, w);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Column header menu (⋮): sort / pin / autosize / hide. Reuses the floating
|
|
1134
|
+
// RowMenu (a light action list — no lazy chunk, unlike the filter menu).
|
|
1135
|
+
function columnMenuItems(col: ColumnDef): Array<{ label: string; onSelect: () => void }> {
|
|
1136
|
+
const items: Array<{ label: string; onSelect: () => void }> = [];
|
|
1137
|
+
if (isSortable(col)) {
|
|
1138
|
+
items.push({ label: 'Sort ascending', onSelect: () => setSorts([{ key: col.key, dir: 'asc' }]) });
|
|
1139
|
+
items.push({ label: 'Sort descending', onSelect: () => setSorts([{ key: col.key, dir: 'desc' }]) });
|
|
1140
|
+
if (sortInfo(col.key)) {
|
|
1141
|
+
items.push({ label: 'Clear sort', onSelect: () => setSorts(sorts.filter((s) => s.key !== col.key)) });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const side = pinSideOf(col);
|
|
1145
|
+
if (side !== 'left') items.push({ label: 'Pin left', onSelect: () => setPinOverride(col.key, 'left') });
|
|
1146
|
+
if (side !== 'right') items.push({ label: 'Pin right', onSelect: () => setPinOverride(col.key, 'right') });
|
|
1147
|
+
if (side) items.push({ label: 'Unpin', onSelect: () => setPinOverride(col.key, false) });
|
|
1148
|
+
if (isResizable(col, resizable)) items.push({ label: 'Autosize', onSelect: () => autosizeColumn(col) });
|
|
1149
|
+
items.push({ label: 'Hide column', onSelect: () => hideColumn(col.key) });
|
|
1150
|
+
return items;
|
|
1151
|
+
}
|
|
1152
|
+
function openColumnMenu(col: ColumnDef, e: Event) {
|
|
1153
|
+
e.stopPropagation();
|
|
1154
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1155
|
+
menu = { x: rect.left, y: rect.bottom + 2, items: columnMenuItems(col) };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
784
1158
|
function onCellClicked(r: number, c: number, e: MouseEvent) {
|
|
785
1159
|
if (!onCellClick) return;
|
|
786
1160
|
const row = dataAt(r);
|
|
@@ -850,18 +1224,20 @@
|
|
|
850
1224
|
const c0 = anchor.c0;
|
|
851
1225
|
const rSpan = single ? anchor.r1 - anchor.r0 + 1 : grid.length;
|
|
852
1226
|
let wrote = 0;
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
1227
|
+
batch(() => {
|
|
1228
|
+
for (let dr = 0; dr < rSpan; dr++) {
|
|
1229
|
+
const r = r0 + dr;
|
|
1230
|
+
if (r > rowCount - 1) break;
|
|
1231
|
+
const srcRow = single ? grid[0] : grid[dr];
|
|
1232
|
+
const cSpan = single ? anchor.c1 - anchor.c0 + 1 : srcRow.length;
|
|
1233
|
+
for (let dc = 0; dc < cSpan; dc++) {
|
|
1234
|
+
const c = c0 + dc;
|
|
1235
|
+
if (c > cols.length - 1) break;
|
|
1236
|
+
const raw = single ? grid[0][0] : (srcRow[dc] ?? '');
|
|
1237
|
+
if (writeCell(r, c, raw)) wrote++;
|
|
1238
|
+
}
|
|
863
1239
|
}
|
|
864
|
-
}
|
|
1240
|
+
});
|
|
865
1241
|
// Surface the pasted region as the new selection so it's visible.
|
|
866
1242
|
if (wrote > 0) {
|
|
867
1243
|
const rEnd = Math.min(r0 + rSpan - 1, rowCount - 1);
|
|
@@ -907,12 +1283,26 @@
|
|
|
907
1283
|
if (!mod && !e.altKey && e.key.length === 1 && e.key !== ' ' && sel.focus && !editing) {
|
|
908
1284
|
const f = sel.focus;
|
|
909
1285
|
const col = cols[f.c];
|
|
910
|
-
if (isEditable(col) && !(col.options && col.options.length) && dataAt(f.r)) {
|
|
1286
|
+
if (isEditable(col) && col.type !== 'date' && !(col.options && col.options.length) && dataAt(f.r)) {
|
|
911
1287
|
e.preventDefault();
|
|
912
1288
|
startEdit(f.r, f.c, e.key);
|
|
913
1289
|
return;
|
|
914
1290
|
}
|
|
915
1291
|
}
|
|
1292
|
+
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
|
1293
|
+
if (undoStack.length) {
|
|
1294
|
+
e.preventDefault();
|
|
1295
|
+
undo();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (mod && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) {
|
|
1300
|
+
if (redoStack.length) {
|
|
1301
|
+
e.preventDefault();
|
|
1302
|
+
redo();
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
916
1306
|
if (mod && e.key.toLowerCase() === 'a') {
|
|
917
1307
|
e.preventDefault();
|
|
918
1308
|
sel.selectAll(rowCount, cols.length);
|
|
@@ -943,6 +1333,13 @@
|
|
|
943
1333
|
return;
|
|
944
1334
|
}
|
|
945
1335
|
}
|
|
1336
|
+
// Open the column menu from the keyboard (Alt+ArrowDown) at the focused column.
|
|
1337
|
+
if (columnMenu && sel.focus && e.altKey && e.key === 'ArrowDown') {
|
|
1338
|
+
e.preventDefault();
|
|
1339
|
+
const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
|
|
1340
|
+
menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items: columnMenuItems(cols[sel.focus.c]) };
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
946
1343
|
// Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
|
|
947
1344
|
// expanded one (treegrid pattern); otherwise arrows move normally.
|
|
948
1345
|
if (treeData && sel.focus && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
|
|
@@ -988,7 +1385,10 @@
|
|
|
988
1385
|
}
|
|
989
1386
|
|
|
990
1387
|
$effect(() => {
|
|
991
|
-
const up = () =>
|
|
1388
|
+
const up = () => {
|
|
1389
|
+
dragging = false;
|
|
1390
|
+
if (filling) commitFill();
|
|
1391
|
+
};
|
|
992
1392
|
window.addEventListener('pointerup', up);
|
|
993
1393
|
return () => window.removeEventListener('pointerup', up);
|
|
994
1394
|
});
|
|
@@ -1021,6 +1421,22 @@
|
|
|
1021
1421
|
bind:this={gridEl}
|
|
1022
1422
|
onkeydown={onKeydown}
|
|
1023
1423
|
>
|
|
1424
|
+
{#if quickFilter || columnsPanel}
|
|
1425
|
+
<div class="bo-toolbar">
|
|
1426
|
+
{#if quickFilter}
|
|
1427
|
+
<input
|
|
1428
|
+
class="bo-quickfilter"
|
|
1429
|
+
type="search"
|
|
1430
|
+
placeholder="Search…"
|
|
1431
|
+
aria-label="Quick filter"
|
|
1432
|
+
bind:value={quickText}
|
|
1433
|
+
/>
|
|
1434
|
+
{/if}
|
|
1435
|
+
{#if columnsPanel}
|
|
1436
|
+
<button class="bo-cols-toggle" type="button" onclick={openToolPanel}>⊟ Columns</button>
|
|
1437
|
+
{/if}
|
|
1438
|
+
</div>
|
|
1439
|
+
{/if}
|
|
1024
1440
|
{#if headerGroups}
|
|
1025
1441
|
<div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
|
|
1026
1442
|
{#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
|
|
@@ -1095,6 +1511,40 @@
|
|
|
1095
1511
|
{si?.dir === 'asc' ? '▲' : '▼'}{#if sorts.length > 1}<span class="ord">{si?.pos}</span>{/if}
|
|
1096
1512
|
</span>
|
|
1097
1513
|
{/if}
|
|
1514
|
+
{#if filterMenu && col.type !== 'sparkline' && col.filter !== false}
|
|
1515
|
+
<span
|
|
1516
|
+
class="funnel"
|
|
1517
|
+
class:on={isFilterActive(activeColumnFilters[col.key])}
|
|
1518
|
+
role="button"
|
|
1519
|
+
tabindex="-1"
|
|
1520
|
+
aria-label="Filter {col.header}"
|
|
1521
|
+
title="Filter {col.header}"
|
|
1522
|
+
onclick={(e) => openFilterMenu(col, e)}
|
|
1523
|
+
onkeydown={(e) => {
|
|
1524
|
+
if (e.key === 'Enter' || e.key === ' ') openFilterMenu(col, e);
|
|
1525
|
+
}}
|
|
1526
|
+
ondragstart={(e) => e.preventDefault()}
|
|
1527
|
+
draggable="false"
|
|
1528
|
+
>
|
|
1529
|
+
<svg viewBox="0 0 10 10" width="9" height="9" aria-hidden="true">
|
|
1530
|
+
<path d="M0 1h10L6 6v3L4 8V6z" fill="currentColor" />
|
|
1531
|
+
</svg>
|
|
1532
|
+
</span>
|
|
1533
|
+
{/if}
|
|
1534
|
+
{#if columnMenu}
|
|
1535
|
+
<span
|
|
1536
|
+
class="hmenu"
|
|
1537
|
+
role="button"
|
|
1538
|
+
tabindex="-1"
|
|
1539
|
+
aria-label="{col.header} menu"
|
|
1540
|
+
title="{col.header} menu"
|
|
1541
|
+
onclick={(e) => openColumnMenu(col, e)}
|
|
1542
|
+
onkeydown={(e) => {
|
|
1543
|
+
if (e.key === 'Enter' || e.key === ' ') openColumnMenu(col, e);
|
|
1544
|
+
}}
|
|
1545
|
+
ondragstart={(e) => e.preventDefault()}
|
|
1546
|
+
draggable="false">⋮</span>
|
|
1547
|
+
{/if}
|
|
1098
1548
|
{#if isResizable(col, resizable)}
|
|
1099
1549
|
<span
|
|
1100
1550
|
class="grip"
|
|
@@ -1249,6 +1699,9 @@
|
|
|
1249
1699
|
alt={item.vr % 2 === 1}
|
|
1250
1700
|
editing={editing?.r === item.vr && editing?.c === ci}
|
|
1251
1701
|
seed={editing?.r === item.vr && editing?.c === ci ? editSeed : null}
|
|
1702
|
+
fillCorner={fillHandle && !!sel.bounds && item.vr === sel.bounds.r1 && ci === sel.bounds.c1}
|
|
1703
|
+
fillpreview={inFillPreview(item.vr, ci)}
|
|
1704
|
+
{onFillStart}
|
|
1252
1705
|
tree={treeData && ci === 0
|
|
1253
1706
|
? {
|
|
1254
1707
|
depth: item.depth ?? 0,
|
|
@@ -1302,6 +1755,34 @@
|
|
|
1302
1755
|
{#if menu}
|
|
1303
1756
|
<RowMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => (menu = null)} />
|
|
1304
1757
|
{/if}
|
|
1758
|
+
|
|
1759
|
+
{#if filterUi && FilterMenuComp}
|
|
1760
|
+
{@const Menu = FilterMenuComp}
|
|
1761
|
+
{@const key = filterUi.key}
|
|
1762
|
+
<Menu
|
|
1763
|
+
kind={filterUi.kind}
|
|
1764
|
+
header={filterUi.header}
|
|
1765
|
+
filter={activeColumnFilters[key] ?? null}
|
|
1766
|
+
values={filterUi.values}
|
|
1767
|
+
x={filterUi.x}
|
|
1768
|
+
y={filterUi.y}
|
|
1769
|
+
onApply={(f) => applyColumnFilter(key, f)}
|
|
1770
|
+
onClose={() => (filterUi = null)}
|
|
1771
|
+
/>
|
|
1772
|
+
{/if}
|
|
1773
|
+
|
|
1774
|
+
{#if panelXY && ToolPanelComp}
|
|
1775
|
+
{@const Panel = ToolPanelComp}
|
|
1776
|
+
<Panel
|
|
1777
|
+
columns={ordered.map((c) => ({ key: c.key, header: c.header }))}
|
|
1778
|
+
hidden={effectiveHidden}
|
|
1779
|
+
x={panelXY.x}
|
|
1780
|
+
y={panelXY.y}
|
|
1781
|
+
onToggle={toggleColumnVisible}
|
|
1782
|
+
onShowAll={showAllColumns}
|
|
1783
|
+
onClose={() => (panelXY = null)}
|
|
1784
|
+
/>
|
|
1785
|
+
{/if}
|
|
1305
1786
|
</div>
|
|
1306
1787
|
|
|
1307
1788
|
<style>
|
|
@@ -1336,6 +1817,41 @@
|
|
|
1336
1817
|
.grid:focus-visible {
|
|
1337
1818
|
border-color: var(--bo-sel-border);
|
|
1338
1819
|
}
|
|
1820
|
+
/* Built-in quick-filter toolbar (opt-in via `quickFilter`). */
|
|
1821
|
+
.bo-toolbar {
|
|
1822
|
+
display: flex;
|
|
1823
|
+
padding: 6px;
|
|
1824
|
+
border-bottom: 0.5px solid var(--bo-border);
|
|
1825
|
+
background: var(--bo-header-bg);
|
|
1826
|
+
}
|
|
1827
|
+
.bo-quickfilter {
|
|
1828
|
+
width: 100%;
|
|
1829
|
+
max-width: 260px;
|
|
1830
|
+
padding: 5px 9px;
|
|
1831
|
+
font: inherit;
|
|
1832
|
+
font-size: 12px;
|
|
1833
|
+
color: var(--bo-text);
|
|
1834
|
+
background: var(--bo-bg);
|
|
1835
|
+
border: 0.5px solid var(--bo-border);
|
|
1836
|
+
border-radius: 6px;
|
|
1837
|
+
}
|
|
1838
|
+
.bo-quickfilter::placeholder {
|
|
1839
|
+
color: var(--bo-text-dim);
|
|
1840
|
+
}
|
|
1841
|
+
.bo-cols-toggle {
|
|
1842
|
+
margin-left: auto;
|
|
1843
|
+
padding: 5px 11px;
|
|
1844
|
+
font: inherit;
|
|
1845
|
+
font-size: 12px;
|
|
1846
|
+
color: var(--bo-text);
|
|
1847
|
+
background: var(--bo-bg);
|
|
1848
|
+
border: 0.5px solid var(--bo-border);
|
|
1849
|
+
border-radius: 6px;
|
|
1850
|
+
cursor: pointer;
|
|
1851
|
+
}
|
|
1852
|
+
.bo-cols-toggle:hover {
|
|
1853
|
+
border-color: var(--bo-text-dim);
|
|
1854
|
+
}
|
|
1339
1855
|
|
|
1340
1856
|
/* Spanning header groups (row above the column headers). */
|
|
1341
1857
|
.head-groups {
|
|
@@ -1450,6 +1966,40 @@
|
|
|
1450
1966
|
color: var(--bo-text-dim);
|
|
1451
1967
|
font-variant-numeric: tabular-nums;
|
|
1452
1968
|
}
|
|
1969
|
+
/* Header filter funnel: a click target that opens the (lazy) filter menu. */
|
|
1970
|
+
.h .funnel {
|
|
1971
|
+
display: inline-flex;
|
|
1972
|
+
align-items: center;
|
|
1973
|
+
margin-left: 4px;
|
|
1974
|
+
color: var(--bo-text-dim);
|
|
1975
|
+
opacity: 0.55;
|
|
1976
|
+
cursor: pointer;
|
|
1977
|
+
transition: opacity 120ms, color 120ms;
|
|
1978
|
+
}
|
|
1979
|
+
.h .funnel:hover {
|
|
1980
|
+
opacity: 1;
|
|
1981
|
+
color: var(--bo-text);
|
|
1982
|
+
}
|
|
1983
|
+
.h .funnel.on {
|
|
1984
|
+
opacity: 1;
|
|
1985
|
+
color: var(--bo-up);
|
|
1986
|
+
}
|
|
1987
|
+
/* Header column-menu (⋮) trigger. */
|
|
1988
|
+
.h .hmenu {
|
|
1989
|
+
display: inline-flex;
|
|
1990
|
+
align-items: center;
|
|
1991
|
+
margin-left: 4px;
|
|
1992
|
+
font-size: 14px;
|
|
1993
|
+
line-height: 1;
|
|
1994
|
+
color: var(--bo-text-dim);
|
|
1995
|
+
opacity: 0.55;
|
|
1996
|
+
cursor: pointer;
|
|
1997
|
+
transition: opacity 120ms, color 120ms;
|
|
1998
|
+
}
|
|
1999
|
+
.h .hmenu:hover {
|
|
2000
|
+
opacity: 1;
|
|
2001
|
+
color: var(--bo-text);
|
|
2002
|
+
}
|
|
1453
2003
|
|
|
1454
2004
|
/* Per-column filter input row, under the header. */
|
|
1455
2005
|
.filter-row {
|