@ruckc/truss-ui 0.0.0-stage → 1.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/LICENSE +24 -0
- package/README.md +91 -2
- package/dist/color.d.ts +21 -0
- package/dist/color.js +32 -0
- package/dist/components/Accordion.svelte +70 -0
- package/dist/components/Accordion.svelte.d.ts +15 -0
- package/dist/components/Alert.svelte +46 -0
- package/dist/components/Alert.svelte.d.ts +15 -0
- package/dist/components/Avatar.svelte +32 -0
- package/dist/components/Avatar.svelte.d.ts +9 -0
- package/dist/components/AvatarGroup.svelte +20 -0
- package/dist/components/AvatarGroup.svelte.d.ts +11 -0
- package/dist/components/Badge.svelte +27 -0
- package/dist/components/Badge.svelte.d.ts +9 -0
- package/dist/components/Breadcrumbs.svelte +38 -0
- package/dist/components/Breadcrumbs.svelte.d.ts +12 -0
- package/dist/components/Button.svelte +119 -0
- package/dist/components/Button.svelte.d.ts +16 -0
- package/dist/components/ButtonGroup.svelte +23 -0
- package/dist/components/ButtonGroup.svelte.d.ts +9 -0
- package/dist/components/Calendar.svelte +166 -0
- package/dist/components/Calendar.svelte.d.ts +19 -0
- package/dist/components/Callout.svelte +50 -0
- package/dist/components/Callout.svelte.d.ts +14 -0
- package/dist/components/Card.svelte +50 -0
- package/dist/components/Card.svelte.d.ts +15 -0
- package/dist/components/Checkbox.svelte +37 -0
- package/dist/components/Checkbox.svelte.d.ts +16 -0
- package/dist/components/ColorPicker.svelte +164 -0
- package/dist/components/ColorPicker.svelte.d.ts +14 -0
- package/dist/components/Combobox.svelte +131 -0
- package/dist/components/Combobox.svelte.d.ts +19 -0
- package/dist/components/ContextMenu.svelte +51 -0
- package/dist/components/ContextMenu.svelte.d.ts +10 -0
- package/dist/components/DataTable.svelte +657 -0
- package/dist/components/DataTable.svelte.d.ts +76 -0
- package/dist/components/DatePicker.svelte +70 -0
- package/dist/components/DatePicker.svelte.d.ts +15 -0
- package/dist/components/DateRangePicker.svelte +92 -0
- package/dist/components/DateRangePicker.svelte.d.ts +15 -0
- package/dist/components/DateTimePicker.svelte +62 -0
- package/dist/components/DateTimePicker.svelte.d.ts +19 -0
- package/dist/components/DateTimeRangePicker.svelte +167 -0
- package/dist/components/DateTimeRangePicker.svelte.d.ts +22 -0
- package/dist/components/Dialog.svelte +70 -0
- package/dist/components/Dialog.svelte.d.ts +14 -0
- package/dist/components/Divider.svelte +16 -0
- package/dist/components/Divider.svelte.d.ts +8 -0
- package/dist/components/Drawer.svelte +64 -0
- package/dist/components/Drawer.svelte.d.ts +13 -0
- package/dist/components/DurationInput.svelte +112 -0
- package/dist/components/DurationInput.svelte.d.ts +19 -0
- package/dist/components/EditableText.svelte +73 -0
- package/dist/components/EditableText.svelte.d.ts +14 -0
- package/dist/components/EmptyState.svelte +30 -0
- package/dist/components/EmptyState.svelte.d.ts +12 -0
- package/dist/components/FileUpload.svelte +143 -0
- package/dist/components/FileUpload.svelte.d.ts +18 -0
- package/dist/components/FormGroup.svelte +27 -0
- package/dist/components/FormGroup.svelte.d.ts +13 -0
- package/dist/components/Input.svelte +62 -0
- package/dist/components/Input.svelte.d.ts +15 -0
- package/dist/components/InputAddon.svelte +21 -0
- package/dist/components/InputAddon.svelte.d.ts +9 -0
- package/dist/components/InputGroup.svelte +70 -0
- package/dist/components/InputGroup.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +95 -0
- package/dist/components/Menu.svelte.d.ts +15 -0
- package/dist/components/MenuButton.svelte +45 -0
- package/dist/components/MenuButton.svelte.d.ts +16 -0
- package/dist/components/MultiSelect.svelte +129 -0
- package/dist/components/MultiSelect.svelte.d.ts +18 -0
- package/dist/components/Navbar.svelte +17 -0
- package/dist/components/Navbar.svelte.d.ts +10 -0
- package/dist/components/NumberInput.svelte +66 -0
- package/dist/components/NumberInput.svelte.d.ts +16 -0
- package/dist/components/OptionList.svelte +74 -0
- package/dist/components/OptionList.svelte.d.ts +15 -0
- package/dist/components/Popover.svelte +87 -0
- package/dist/components/Popover.svelte.d.ts +14 -0
- package/dist/components/ProgressBar.svelte +35 -0
- package/dist/components/ProgressBar.svelte.d.ts +10 -0
- package/dist/components/RadioGroup.svelte +39 -0
- package/dist/components/RadioGroup.svelte.d.ts +14 -0
- package/dist/components/Section.svelte +56 -0
- package/dist/components/Section.svelte.d.ts +15 -0
- package/dist/components/SegmentedControl.svelte +56 -0
- package/dist/components/SegmentedControl.svelte.d.ts +17 -0
- package/dist/components/Select.svelte +204 -0
- package/dist/components/Select.svelte.d.ts +41 -0
- package/dist/components/Shell.svelte +143 -0
- package/dist/components/Shell.svelte.d.ts +36 -0
- package/dist/components/Sidebar.svelte +84 -0
- package/dist/components/Sidebar.svelte.d.ts +24 -0
- package/dist/components/Skeleton.svelte +25 -0
- package/dist/components/Skeleton.svelte.d.ts +9 -0
- package/dist/components/Slider.svelte +121 -0
- package/dist/components/Slider.svelte.d.ts +16 -0
- package/dist/components/Spinner.svelte +30 -0
- package/dist/components/Spinner.svelte.d.ts +9 -0
- package/dist/components/SplitButton.svelte +113 -0
- package/dist/components/SplitButton.svelte.d.ts +14 -0
- package/dist/components/SplitPane.svelte +106 -0
- package/dist/components/SplitPane.svelte.d.ts +16 -0
- package/dist/components/Stepper.svelte +73 -0
- package/dist/components/Stepper.svelte.d.ts +26 -0
- package/dist/components/Switch.svelte +21 -0
- package/dist/components/Switch.svelte.d.ts +10 -0
- package/dist/components/Tabs.svelte +74 -0
- package/dist/components/Tabs.svelte.d.ts +18 -0
- package/dist/components/Tag.svelte +49 -0
- package/dist/components/Tag.svelte.d.ts +16 -0
- package/dist/components/TagInput.svelte +72 -0
- package/dist/components/TagInput.svelte.d.ts +17 -0
- package/dist/components/Textarea.svelte +37 -0
- package/dist/components/Textarea.svelte.d.ts +11 -0
- package/dist/components/TimeList.svelte +76 -0
- package/dist/components/TimeList.svelte.d.ts +10 -0
- package/dist/components/TimePicker.svelte +79 -0
- package/dist/components/TimePicker.svelte.d.ts +15 -0
- package/dist/components/TimeRangePicker.svelte +44 -0
- package/dist/components/TimeRangePicker.svelte.d.ts +17 -0
- package/dist/components/Timeline.svelte +38 -0
- package/dist/components/Timeline.svelte.d.ts +17 -0
- package/dist/components/Toaster.svelte +35 -0
- package/dist/components/Toaster.svelte.d.ts +18 -0
- package/dist/components/Tooltip.svelte +66 -0
- package/dist/components/Tooltip.svelte.d.ts +11 -0
- package/dist/components/Tree.svelte +147 -0
- package/dist/components/Tree.svelte.d.ts +16 -0
- package/dist/date.d.ts +19 -0
- package/dist/date.js +43 -0
- package/dist/duration.d.ts +11 -0
- package/dist/duration.js +44 -0
- package/dist/fonts/LICENSE-Inter.txt +93 -0
- package/dist/fonts/LICENSE-JetBrainsMono.txt +93 -0
- package/dist/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/dist/icons/Icon.svelte +30 -0
- package/dist/icons/Icon.svelte.d.ts +9 -0
- package/dist/icons/paths.d.ts +39 -0
- package/dist/icons/paths.js +43 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +65 -0
- package/dist/styles/base.css +71 -0
- package/dist/styles/fonts.css +21 -0
- package/dist/styles/index.css +3 -0
- package/dist/styles/tokens.css +143 -0
- package/dist/time.d.ts +7 -0
- package/dist/time.js +30 -0
- package/dist/toast.svelte.d.ts +22 -0
- package/dist/toast.svelte.js +19 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.js +1 -0
- package/package.json +76 -5
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends Record<string, any>">
|
|
2
|
+
import { tick, untrack, type Snippet } from 'svelte';
|
|
3
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
4
|
+
import Icon from '../icons/Icon.svelte';
|
|
5
|
+
import Button from './Button.svelte';
|
|
6
|
+
import Checkbox from './Checkbox.svelte';
|
|
7
|
+
import Popover from './Popover.svelte';
|
|
8
|
+
import Spinner from './Spinner.svelte';
|
|
9
|
+
|
|
10
|
+
import type { Column, ColumnFilters, SortState } from '../types.js';
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
columns,
|
|
14
|
+
rows,
|
|
15
|
+
rowKey,
|
|
16
|
+
getChildren,
|
|
17
|
+
cell,
|
|
18
|
+
selected = $bindable(),
|
|
19
|
+
selectable = false,
|
|
20
|
+
checked = $bindable([]),
|
|
21
|
+
columnMenu = false,
|
|
22
|
+
toolbar,
|
|
23
|
+
caption,
|
|
24
|
+
maxHeight = '420px',
|
|
25
|
+
// pagination vs. continuous
|
|
26
|
+
pagination = true,
|
|
27
|
+
pageSize = 10,
|
|
28
|
+
// virtual scrolling + infinite loading
|
|
29
|
+
virtual = false,
|
|
30
|
+
rowHeight = 32,
|
|
31
|
+
overscan = 6,
|
|
32
|
+
hasMore = false,
|
|
33
|
+
loading = false,
|
|
34
|
+
loadThreshold = 8,
|
|
35
|
+
onloadmore,
|
|
36
|
+
// column filtering / sorting
|
|
37
|
+
filterable = false,
|
|
38
|
+
filters = $bindable({}),
|
|
39
|
+
sort = $bindable(null),
|
|
40
|
+
remote = false,
|
|
41
|
+
filterDelay,
|
|
42
|
+
empty = 'No matching rows'
|
|
43
|
+
}: {
|
|
44
|
+
columns: Column<T>[];
|
|
45
|
+
rows: T[];
|
|
46
|
+
rowKey: (row: T) => string;
|
|
47
|
+
/** Provide to render a tree-grid; first column gets indentation + expander. */
|
|
48
|
+
getChildren?: (row: T) => T[] | undefined;
|
|
49
|
+
/** Custom cell renderer, e.g. status badges. Fall back to the raw value. */
|
|
50
|
+
cell?: Snippet<[row: T, column: Column<T>]>;
|
|
51
|
+
/** Active (highlighted) row id. */
|
|
52
|
+
selected?: string;
|
|
53
|
+
/** Show checkbox column; `checked` holds the checked row ids. */
|
|
54
|
+
selectable?: boolean;
|
|
55
|
+
checked?: string[];
|
|
56
|
+
/** Show a column visibility menu. */
|
|
57
|
+
columnMenu?: boolean;
|
|
58
|
+
toolbar?: Snippet;
|
|
59
|
+
caption: string;
|
|
60
|
+
maxHeight?: string;
|
|
61
|
+
|
|
62
|
+
/** Page the rows with a floating pager. Turn off for one continuous (optionally virtual) list. */
|
|
63
|
+
pagination?: boolean;
|
|
64
|
+
pageSize?: number;
|
|
65
|
+
|
|
66
|
+
/** Render only the rows in view (fixed `rowHeight`). Needs a bounded `maxHeight`. Use with `pagination={false}`. */
|
|
67
|
+
virtual?: boolean;
|
|
68
|
+
/** px; every row is exactly this tall when `virtual`. */
|
|
69
|
+
rowHeight?: number;
|
|
70
|
+
/** Extra rows rendered above/below the viewport. */
|
|
71
|
+
overscan?: number;
|
|
72
|
+
/** Infinite scroll: more rows exist beyond `rows`. */
|
|
73
|
+
hasMore?: boolean;
|
|
74
|
+
/** Infinite scroll: a fetch is in flight (shows a spinner row). */
|
|
75
|
+
loading?: boolean;
|
|
76
|
+
/** Request more when within this many rows of the bottom. */
|
|
77
|
+
loadThreshold?: number;
|
|
78
|
+
/** Called near the end of the list. Append to `rows` (or flip `hasMore` off); it is not called again until `rows.length` changes. */
|
|
79
|
+
onloadmore?: () => void | Promise<void>;
|
|
80
|
+
|
|
81
|
+
/** Show a filter row; columns opt in with `filter`. */
|
|
82
|
+
/**
|
|
83
|
+
* Column filtering UI (columns opt in with `filter`): `'row'` (or `true`) adds a filter row under the header;
|
|
84
|
+
* `'menu'` has no row — filters live in each column heading's menu (funnel button, right-click, or Shift+F10);
|
|
85
|
+
* `'both'` offers both. `false` turns filtering off.
|
|
86
|
+
*/
|
|
87
|
+
filterable?: boolean | 'row' | 'menu' | 'both';
|
|
88
|
+
/** Active filters by column key: text → string, select → string[], number → { min, max }. */
|
|
89
|
+
filters?: ColumnFilters;
|
|
90
|
+
sort?: SortState;
|
|
91
|
+
/** Your data source filters and sorts: the table only reports `filters`/`sort` (bind them) and shows `rows` as-is. */
|
|
92
|
+
remote?: boolean;
|
|
93
|
+
/** ms to debounce typed filters (default 120 client, 300 remote). */
|
|
94
|
+
filterDelay?: number;
|
|
95
|
+
empty?: string;
|
|
96
|
+
} = $props();
|
|
97
|
+
|
|
98
|
+
// ---------- columns / widths
|
|
99
|
+
let widths = $state<Record<string, number>>({});
|
|
100
|
+
const w = (c: Column<T>) => widths[c.key] ?? c.width ?? 160;
|
|
101
|
+
let hidden = $state<string[]>([]);
|
|
102
|
+
let menuOpen = $state(false);
|
|
103
|
+
const cols = $derived(columns.filter((c) => !hidden.includes(c.key)));
|
|
104
|
+
const total = $derived(cols.reduce((a, c) => a + w(c), selectable ? 40 : 0));
|
|
105
|
+
const toggleCol = (key: string) => (hidden = hidden.includes(key) ? hidden.filter((k) => k !== key) : [...hidden, key]);
|
|
106
|
+
const ncols = $derived(cols.length + (selectable ? 1 : 0));
|
|
107
|
+
const rowFilters = $derived(filterable === true || filterable === 'row' || filterable === 'both');
|
|
108
|
+
const menuFilters = $derived(filterable === 'menu' || filterable === 'both');
|
|
109
|
+
|
|
110
|
+
// ---------- filtering
|
|
111
|
+
const isActive = (v: unknown) =>
|
|
112
|
+
v != null && v !== '' && !(Array.isArray(v) && !v.length) && !(typeof v === 'object' && !Array.isArray(v) && (v as { min?: number; max?: number }).min == null && (v as { max?: number }).max == null);
|
|
113
|
+
const activeFilters = $derived(Object.entries(filters).filter(([, v]) => isActive(v)));
|
|
114
|
+
const activeCount = $derived(activeFilters.length);
|
|
115
|
+
const clientFilter = $derived(!remote && activeCount > 0);
|
|
116
|
+
|
|
117
|
+
function matches(row: T): boolean {
|
|
118
|
+
for (const [key, v] of activeFilters) {
|
|
119
|
+
const kind = columns.find((c) => c.key === key)?.filter;
|
|
120
|
+
const raw = row[key];
|
|
121
|
+
if (kind === 'select') { if (!(v as string[]).includes(String(raw ?? ''))) return false; }
|
|
122
|
+
else if (kind === 'number') {
|
|
123
|
+
const n = Number(raw), { min, max } = v as { min?: number; max?: number };
|
|
124
|
+
if (Number.isNaN(n) || (min != null && n < min) || (max != null && n > max)) return false;
|
|
125
|
+
} else if (!String(raw ?? '').toLowerCase().includes(String(v).toLowerCase())) return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
/** With filters on, keep matching rows plus the ancestors of matching descendants. */
|
|
130
|
+
const kept = $derived.by(() => {
|
|
131
|
+
if (!clientFilter) return null;
|
|
132
|
+
const kids = new Map<string, T[]>();
|
|
133
|
+
const prune = (list: T[]): T[] => {
|
|
134
|
+
const out: T[] = [];
|
|
135
|
+
for (const row of list) {
|
|
136
|
+
const k = prune(getChildren?.(row) ?? []);
|
|
137
|
+
if (k.length) kids.set(rowKey(row), k);
|
|
138
|
+
if (matches(row) || k.length) out.push(row);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
};
|
|
142
|
+
return { roots: prune(rows), kids };
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const distinct = $derived.by(() => {
|
|
146
|
+
const out: Record<string, string[]> = {};
|
|
147
|
+
const sel = columns.filter((c) => c.filter === 'select' && !c.filterOptions);
|
|
148
|
+
if (!sel.length) return out;
|
|
149
|
+
const sets = Object.fromEntries(sel.map((c) => [c.key, new Set<string>()]));
|
|
150
|
+
const walk = (list: T[]) => list.forEach((r) => { for (const c of sel) sets[c.key].add(String(r[c.key] ?? '')); const k = getChildren?.(r); if (k) walk(k); });
|
|
151
|
+
walk(rows);
|
|
152
|
+
for (const c of sel) out[c.key] = [...sets[c.key]].sort();
|
|
153
|
+
return out;
|
|
154
|
+
});
|
|
155
|
+
const optionsFor = (c: Column<T>) => c.filterOptions ?? distinct[c.key] ?? [];
|
|
156
|
+
|
|
157
|
+
const setFilter = (key: string, v: ColumnFilters[string]) => (filters = { ...filters, [key]: v });
|
|
158
|
+
const clearFilters = () => { filters = {}; drafts = {}; };
|
|
159
|
+
|
|
160
|
+
// typed filters keep a local draft and commit after a short debounce
|
|
161
|
+
let drafts = $state<Record<string, string>>({});
|
|
162
|
+
const timers: Record<string, ReturnType<typeof setTimeout>> = {};
|
|
163
|
+
const delay = $derived(filterDelay ?? (remote ? 300 : 120));
|
|
164
|
+
function typed(key: string, value: string, commit: () => void) {
|
|
165
|
+
drafts[key] = value;
|
|
166
|
+
clearTimeout(timers[key]);
|
|
167
|
+
timers[key] = setTimeout(commit, delay);
|
|
168
|
+
}
|
|
169
|
+
$effect(() => () => Object.values(timers).forEach(clearTimeout));
|
|
170
|
+
$effect(() => {
|
|
171
|
+
// keep drafts in step when filters change from outside (e.g. cleared)
|
|
172
|
+
const f = filters;
|
|
173
|
+
untrack(() => {
|
|
174
|
+
const next: Record<string, string> = {};
|
|
175
|
+
for (const c of columns) {
|
|
176
|
+
const v = f[c.key];
|
|
177
|
+
if (c.filter === 'text' || (c.filter === undefined && typeof v === 'string')) next[c.key] = typeof v === 'string' ? v : '';
|
|
178
|
+
if (c.filter === 'number') {
|
|
179
|
+
const r = (v ?? {}) as { min?: number; max?: number };
|
|
180
|
+
next[`${c.key}:min`] = r.min != null ? String(r.min) : '';
|
|
181
|
+
next[`${c.key}:max`] = r.max != null ? String(r.max) : '';
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
drafts = next;
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
function commitNumber(key: string) {
|
|
188
|
+
const mn = (drafts[`${key}:min`] ?? '').trim(), mx = (drafts[`${key}:max`] ?? '').trim();
|
|
189
|
+
const min = mn === '' || Number.isNaN(Number(mn)) ? undefined : Number(mn);
|
|
190
|
+
const max = mx === '' || Number.isNaN(Number(mx)) ? undefined : Number(mx);
|
|
191
|
+
setFilter(key, min == null && max == null ? undefined : { min, max });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// select-filter popover lives at the root so it can escape the scroll container
|
|
195
|
+
let filterMenu = $state<{ key: string; el: HTMLElement } | null>(null);
|
|
196
|
+
let menuCol = $derived(filterMenu ? columns.find((c) => c.key === filterMenu!.key) : undefined);
|
|
197
|
+
|
|
198
|
+
// column-heading menu: sort, filter, hide — opened from a funnel button, right-click, or Shift+F10 / the Menu key
|
|
199
|
+
let headMenu = $state<{ key: string; el: HTMLElement; opener?: HTMLElement } | null>(null);
|
|
200
|
+
const headCol = $derived(headMenu ? columns.find((c) => c.key === headMenu!.key) : undefined);
|
|
201
|
+
let headMenuEl = $state<HTMLDivElement>();
|
|
202
|
+
const hasHeadMenu = (c: Column<T>) => menuFilters && (!!c.filter || !!c.sortable);
|
|
203
|
+
function openHeadMenu(c: Column<T>, th: HTMLElement, opener?: HTMLElement) {
|
|
204
|
+
filterMenu = null;
|
|
205
|
+
headMenu = { key: c.key, el: th, opener: opener ?? th.querySelector<HTMLElement>('button.hf, button.head') ?? undefined };
|
|
206
|
+
}
|
|
207
|
+
function onHeadContext(e: MouseEvent, c: Column<T>) {
|
|
208
|
+
if (!hasHeadMenu(c)) return; // keep the browser's own menu where we have nothing to offer
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
openHeadMenu(c, e.currentTarget as HTMLElement);
|
|
211
|
+
}
|
|
212
|
+
function onHeadKey(e: KeyboardEvent, c: Column<T>) {
|
|
213
|
+
if (!hasHeadMenu(c)) return;
|
|
214
|
+
if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { e.preventDefault(); openHeadMenu(c, e.currentTarget as HTMLElement, e.target as HTMLElement); }
|
|
215
|
+
}
|
|
216
|
+
const setSort = (c: Column<T>, dir: 1 | -1 | null) => (sort = dir ? { key: c.key, dir } : null);
|
|
217
|
+
$effect(() => { if (headMenu && headMenuEl) tick().then(() => headMenuEl?.querySelector<HTMLElement>('input, button:not(:disabled)')?.focus()); });
|
|
218
|
+
function togglePick(key: string, opt: string) {
|
|
219
|
+
const cur = (filters[key] as string[] | undefined) ?? [];
|
|
220
|
+
const next = cur.includes(opt) ? cur.filter((x) => x !== opt) : [...cur, opt];
|
|
221
|
+
setFilter(key, next.length ? next : undefined);
|
|
222
|
+
}
|
|
223
|
+
const selLabel = (key: string) => {
|
|
224
|
+
const v = (filters[key] as string[] | undefined) ?? [];
|
|
225
|
+
return v.length === 0 ? 'All' : v.length === 1 ? v[0] : `${v.length} selected`;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ---------- sorting + flattening
|
|
229
|
+
let page = $state(0);
|
|
230
|
+
const expanded = new SvelteSet<string>();
|
|
231
|
+
|
|
232
|
+
function sorted(list: T[]): T[] {
|
|
233
|
+
if (!sort || remote) return list;
|
|
234
|
+
const { key, dir } = sort;
|
|
235
|
+
return [...list].sort((a, b) => (a[key] > b[key] ? dir : a[key] < b[key] ? -dir : 0));
|
|
236
|
+
}
|
|
237
|
+
type Flat = { row: T; depth: number; expandable: boolean };
|
|
238
|
+
const flat = $derived.by(() => {
|
|
239
|
+
const out: Flat[] = [];
|
|
240
|
+
const kidsOf = (row: T) => (kept ? kept.kids.get(rowKey(row)) : getChildren?.(row));
|
|
241
|
+
const walk = (list: T[], depth: number) => {
|
|
242
|
+
for (const row of sorted(list)) {
|
|
243
|
+
const kids = kidsOf(row);
|
|
244
|
+
const expandable = !!kids?.length;
|
|
245
|
+
out.push({ row, depth, expandable });
|
|
246
|
+
if (expandable && (expanded.has(rowKey(row)) || !!kept)) walk(kids!, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
walk(kept ? kept.roots : rows, 0);
|
|
250
|
+
return out;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const pages = $derived(Math.max(1, Math.ceil(flat.length / pageSize)));
|
|
254
|
+
const list = $derived(pagination ? flat.slice(page * pageSize, (page + 1) * pageSize) : flat);
|
|
255
|
+
$effect(() => { if (page >= pages) page = pages - 1; });
|
|
256
|
+
|
|
257
|
+
// ---------- virtual window
|
|
258
|
+
let wrap = $state<HTMLDivElement>();
|
|
259
|
+
let viewH = $state(0);
|
|
260
|
+
let headH = $state(28);
|
|
261
|
+
let scrollTop = $state(0);
|
|
262
|
+
const start = $derived(virtual ? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan) : 0);
|
|
263
|
+
const end = $derived(virtual ? Math.min(list.length, Math.ceil((scrollTop + viewH) / rowHeight) + overscan) : list.length);
|
|
264
|
+
const shown = $derived(virtual ? list.slice(start, end) : list);
|
|
265
|
+
|
|
266
|
+
// ---------- infinite loading
|
|
267
|
+
let calledAt = -1;
|
|
268
|
+
function maybeLoad(el: HTMLElement) {
|
|
269
|
+
if (!onloadmore || !hasMore || loading || pagination) return;
|
|
270
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight > loadThreshold * rowHeight) return;
|
|
271
|
+
if (calledAt === rows.length) return;
|
|
272
|
+
calledAt = rows.length;
|
|
273
|
+
onloadmore();
|
|
274
|
+
}
|
|
275
|
+
function onScroll(e: Event) {
|
|
276
|
+
const el = e.currentTarget as HTMLElement;
|
|
277
|
+
scrollTop = el.scrollTop;
|
|
278
|
+
maybeLoad(el);
|
|
279
|
+
}
|
|
280
|
+
// Fill the viewport when the first batch is shorter than the container
|
|
281
|
+
$effect(() => {
|
|
282
|
+
rows.length; hasMore; viewH; flat.length;
|
|
283
|
+
untrack(() => { if (wrap) tick().then(() => wrap && maybeLoad(wrap)); });
|
|
284
|
+
});
|
|
285
|
+
// filter/sort changes restart from the top
|
|
286
|
+
$effect(() => {
|
|
287
|
+
filters; sort;
|
|
288
|
+
untrack(() => { page = 0; calledAt = -1; if (wrap) { wrap.scrollTop = 0; scrollTop = 0; } });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------- selection
|
|
292
|
+
const scopeIds = $derived(list.map((f) => rowKey(f.row)));
|
|
293
|
+
const allOn = $derived(scopeIds.length > 0 && scopeIds.every((id) => checked.includes(id)));
|
|
294
|
+
const someOn = $derived(scopeIds.some((id) => checked.includes(id)));
|
|
295
|
+
function toggleAll(on: boolean) {
|
|
296
|
+
checked = on ? [...new Set([...checked, ...scopeIds])] : checked.filter((id) => !scopeIds.includes(id));
|
|
297
|
+
}
|
|
298
|
+
const toggleRow = (id: string) => (checked = checked.includes(id) ? checked.filter((x) => x !== id) : [...checked, id]);
|
|
299
|
+
|
|
300
|
+
function toggleSort(c: Column<T>) {
|
|
301
|
+
if (!c.sortable) return;
|
|
302
|
+
sort = sort?.key !== c.key ? { key: c.key, dir: 1 } : sort.dir === 1 ? { key: c.key, dir: -1 } : null;
|
|
303
|
+
}
|
|
304
|
+
function toggleExpand(id: string) {
|
|
305
|
+
if (expanded.has(id)) expanded.delete(id);
|
|
306
|
+
else expanded.add(id);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------- resize
|
|
310
|
+
function startResize(e: PointerEvent, c: Column<T>) {
|
|
311
|
+
const el = e.currentTarget as HTMLElement;
|
|
312
|
+
el.setPointerCapture(e.pointerId);
|
|
313
|
+
const x0 = e.clientX, w0 = w(c), min = c.minWidth ?? 60;
|
|
314
|
+
const move = (ev: PointerEvent) => (widths[c.key] = Math.max(min, w0 + ev.clientX - x0));
|
|
315
|
+
const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); };
|
|
316
|
+
el.addEventListener('pointermove', move);
|
|
317
|
+
el.addEventListener('pointerup', up);
|
|
318
|
+
}
|
|
319
|
+
function resizeKey(e: KeyboardEvent, c: Column<T>) {
|
|
320
|
+
const d = e.key === 'ArrowRight' ? 12 : e.key === 'ArrowLeft' ? -12 : 0;
|
|
321
|
+
if (d) { e.preventDefault(); widths[c.key] = Math.max(c.minWidth ?? 60, w(c) + d); }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---------- keyboard (index based so it works across the virtual window)
|
|
325
|
+
async function focusIndex(i: number) {
|
|
326
|
+
if (i < 0 || i >= list.length || !wrap) return;
|
|
327
|
+
if (virtual && (i < start + 1 || i >= end - 1)) {
|
|
328
|
+
scrollTop = wrap.scrollTop = Math.max(0, i * rowHeight - viewH / 2 + headH);
|
|
329
|
+
await tick();
|
|
330
|
+
}
|
|
331
|
+
wrap.querySelector<HTMLElement>(`tr[data-index="${i}"]`)?.focus();
|
|
332
|
+
}
|
|
333
|
+
function rowKeydown(e: KeyboardEvent, f: Flat, index: number) {
|
|
334
|
+
if (e.target !== e.currentTarget) return; // ignore keys from inner controls (checkbox, twisty)
|
|
335
|
+
const id = rowKey(f.row);
|
|
336
|
+
const page_ = Math.max(1, Math.floor((viewH - headH) / rowHeight) - 1);
|
|
337
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selected = id; }
|
|
338
|
+
else if (f.expandable && e.key === 'ArrowRight' && !expanded.has(id)) toggleExpand(id);
|
|
339
|
+
else if (f.expandable && e.key === 'ArrowLeft' && expanded.has(id)) toggleExpand(id);
|
|
340
|
+
else if (e.key === 'ArrowDown') { e.preventDefault(); focusIndex(index + 1); }
|
|
341
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); focusIndex(index - 1); }
|
|
342
|
+
else if (e.key === 'PageDown') { e.preventDefault(); focusIndex(Math.min(list.length - 1, index + page_)); }
|
|
343
|
+
else if (e.key === 'PageUp') { e.preventDefault(); focusIndex(Math.max(0, index - page_)); }
|
|
344
|
+
else if (e.key === 'Home') { e.preventDefault(); focusIndex(0); }
|
|
345
|
+
else if (e.key === 'End') { e.preventDefault(); focusIndex(list.length - 1); }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const showBar = $derived(selectable || columnMenu || !!toolbar || activeCount > 0 || !pagination);
|
|
349
|
+
const fmt = (n: number) => n.toLocaleString();
|
|
350
|
+
</script>
|
|
351
|
+
|
|
352
|
+
{#snippet textCtl(c: Column<T>)}
|
|
353
|
+
<input
|
|
354
|
+
class="fi" data-on={isActive(filters[c.key])} placeholder="Filter…" aria-label="Filter {c.header}"
|
|
355
|
+
value={drafts[c.key] ?? ''}
|
|
356
|
+
oninput={(e) => { const v = e.currentTarget.value; typed(c.key, v, () => setFilter(c.key, v.trim() ? v : undefined)); }}
|
|
357
|
+
/>
|
|
358
|
+
{/snippet}
|
|
359
|
+
{#snippet numCtl(c: Column<T>)}
|
|
360
|
+
<span class="range" data-on={isActive(filters[c.key])}>
|
|
361
|
+
<input class="fi" inputmode="decimal" placeholder="Min" aria-label="{c.header} minimum" value={drafts[`${c.key}:min`] ?? ''}
|
|
362
|
+
oninput={(e) => typed(`${c.key}:min`, e.currentTarget.value, () => commitNumber(c.key))} />
|
|
363
|
+
<input class="fi" inputmode="decimal" placeholder="Max" aria-label="{c.header} maximum" value={drafts[`${c.key}:max`] ?? ''}
|
|
364
|
+
oninput={(e) => typed(`${c.key}:max`, e.currentTarget.value, () => commitNumber(c.key))} />
|
|
365
|
+
</span>
|
|
366
|
+
{/snippet}
|
|
367
|
+
|
|
368
|
+
<div class="tr-table" style:--rh="{rowHeight}px">
|
|
369
|
+
{#if showBar}
|
|
370
|
+
<div class="bar">
|
|
371
|
+
{#if selectable}<span class="sel" aria-live="polite">{checked.length ? `${fmt(checked.length)} selected` : ''}</span>{/if}
|
|
372
|
+
<div class="grow">{#if toolbar}{@render toolbar()}{/if}</div>
|
|
373
|
+
{#if activeCount > 0}
|
|
374
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="cross" onclick={clearFilters}>Clear {activeCount === 1 ? 'filter' : `${activeCount} filters`}</Button>
|
|
375
|
+
{/if}
|
|
376
|
+
{#if !pagination}
|
|
377
|
+
<span class="rows" aria-live="polite">{fmt(flat.length)}{hasMore ? '+' : ''} rows</span>
|
|
378
|
+
{/if}
|
|
379
|
+
{#if columnMenu}
|
|
380
|
+
<div class="tr-anchor">
|
|
381
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="cog" aria-haspopup="true" aria-expanded={menuOpen} onclick={() => (menuOpen = !menuOpen)}>Columns</Button>
|
|
382
|
+
<Popover bind:open={menuOpen} align="end">
|
|
383
|
+
<ul class="colmenu" aria-label="Visible columns">
|
|
384
|
+
{#each columns as c}
|
|
385
|
+
<li><Checkbox checked={!hidden.includes(c.key)} disabled={!hidden.includes(c.key) && cols.length === 1} onchange={() => toggleCol(c.key)}>{c.header}</Checkbox></li>
|
|
386
|
+
{/each}
|
|
387
|
+
</ul>
|
|
388
|
+
</Popover>
|
|
389
|
+
</div>
|
|
390
|
+
{/if}
|
|
391
|
+
</div>
|
|
392
|
+
{/if}
|
|
393
|
+
|
|
394
|
+
<div class="tr-table-wrap" style:max-height={maxHeight} bind:this={wrap} bind:clientHeight={viewH} onscroll={onScroll} data-virtual={virtual}>
|
|
395
|
+
<table style:width="{total}px" role={getChildren ? 'treegrid' : 'grid'} aria-rowcount={virtual ? list.length + 1 : undefined} aria-busy={loading || undefined}>
|
|
396
|
+
<caption class="tr-visually-hidden">{caption}</caption>
|
|
397
|
+
<colgroup>
|
|
398
|
+
{#if selectable}<col style:width="40px" />{/if}
|
|
399
|
+
{#each cols as c (c.key)}<col style:width="{w(c)}px" />{/each}
|
|
400
|
+
</colgroup>
|
|
401
|
+
<thead bind:clientHeight={headH}>
|
|
402
|
+
<tr>
|
|
403
|
+
{#if selectable}
|
|
404
|
+
<th scope="col" class="chk"><Checkbox checked={allOn} indeterminate={!allOn && someOn} aria-label={pagination ? 'Select all rows on this page' : 'Select all rows'} onchange={(e) => toggleAll((e.currentTarget as HTMLInputElement).checked)} /></th>
|
|
405
|
+
{/if}
|
|
406
|
+
{#each cols as c, i (c.key)}
|
|
407
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
408
|
+
<th
|
|
409
|
+
scope="col"
|
|
410
|
+
data-align={c.align}
|
|
411
|
+
data-sortable={c.sortable}
|
|
412
|
+
data-menu={hasHeadMenu(c)}
|
|
413
|
+
oncontextmenu={(e) => onHeadContext(e, c)}
|
|
414
|
+
onkeydown={(e) => onHeadKey(e, c)}
|
|
415
|
+
aria-sort={sort?.key === c.key ? (sort.dir === 1 ? 'ascending' : 'descending') : c.sortable ? 'none' : undefined}
|
|
416
|
+
>
|
|
417
|
+
<div class="hc">
|
|
418
|
+
{#if c.sortable}
|
|
419
|
+
<button type="button" class="head" onclick={() => toggleSort(c)}>
|
|
420
|
+
{c.header}
|
|
421
|
+
{#if sort?.key === c.key}<Icon name={sort.dir === 1 ? 'sort-asc' : 'sort-desc'} />{/if}
|
|
422
|
+
</button>
|
|
423
|
+
{:else}<span class="head">{c.header}</span>{/if}
|
|
424
|
+
{#if menuFilters && c.filter}
|
|
425
|
+
<button
|
|
426
|
+
type="button" class="hf" data-on={isActive(filters[c.key])} aria-haspopup="dialog" aria-expanded={headMenu?.key === c.key}
|
|
427
|
+
aria-label="{c.header} column menu" title="Filter and sort"
|
|
428
|
+
onclick={(e) => (headMenu?.key === c.key ? (headMenu = null) : openHeadMenu(c, e.currentTarget.closest('th')!, e.currentTarget))}
|
|
429
|
+
><Icon name="filter" /></button>
|
|
430
|
+
{/if}
|
|
431
|
+
</div>
|
|
432
|
+
{#if c.resizable !== false && i < cols.length - 1}
|
|
433
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
434
|
+
<div
|
|
435
|
+
class="grip"
|
|
436
|
+
role="separator"
|
|
437
|
+
aria-orientation="vertical"
|
|
438
|
+
aria-label="Resize {c.header} column"
|
|
439
|
+
tabindex="0"
|
|
440
|
+
onpointerdown={(e) => startResize(e, c)}
|
|
441
|
+
onkeydown={(e) => resizeKey(e, c)}
|
|
442
|
+
></div>
|
|
443
|
+
{/if}
|
|
444
|
+
</th>
|
|
445
|
+
{/each}
|
|
446
|
+
</tr>
|
|
447
|
+
{#if rowFilters}
|
|
448
|
+
<tr class="filters">
|
|
449
|
+
{#if selectable}<th class="chk"></th>{/if}
|
|
450
|
+
{#each cols as c (c.key)}
|
|
451
|
+
<th>
|
|
452
|
+
{#if c.filter === 'text'}
|
|
453
|
+
{@render textCtl(c)}
|
|
454
|
+
{:else if c.filter === 'number'}
|
|
455
|
+
{@render numCtl(c)}
|
|
456
|
+
{:else if c.filter === 'select'}
|
|
457
|
+
<button
|
|
458
|
+
type="button" class="fi fsel" data-on={isActive(filters[c.key])} aria-haspopup="listbox" aria-expanded={filterMenu?.key === c.key}
|
|
459
|
+
aria-label="Filter {c.header}: {selLabel(c.key)}"
|
|
460
|
+
onclick={(e) => (filterMenu = filterMenu?.key === c.key ? null : { key: c.key, el: e.currentTarget })}
|
|
461
|
+
><span>{selLabel(c.key)}</span><Icon name="chevron-down" /></button>
|
|
462
|
+
{/if}
|
|
463
|
+
</th>
|
|
464
|
+
{/each}
|
|
465
|
+
</tr>
|
|
466
|
+
{/if}
|
|
467
|
+
</thead>
|
|
468
|
+
<tbody>
|
|
469
|
+
{#if virtual && start > 0}
|
|
470
|
+
<tr class="spacer" aria-hidden="true" style:height="{start * rowHeight}px"><td colspan={ncols} style:height="{start * rowHeight}px"></td></tr>
|
|
471
|
+
{/if}
|
|
472
|
+
{#each shown as f, k (rowKey(f.row))}
|
|
473
|
+
{@const id = rowKey(f.row)}
|
|
474
|
+
{@const index = start + k}
|
|
475
|
+
<tr
|
|
476
|
+
tabindex="0"
|
|
477
|
+
data-index={index}
|
|
478
|
+
data-active={selected === id}
|
|
479
|
+
aria-selected={selected === id}
|
|
480
|
+
aria-rowindex={virtual ? index + 2 : undefined}
|
|
481
|
+
aria-level={getChildren ? f.depth + 1 : undefined}
|
|
482
|
+
aria-expanded={f.expandable ? expanded.has(id) || !!kept : undefined}
|
|
483
|
+
onclick={() => (selected = id)}
|
|
484
|
+
onkeydown={(e) => rowKeydown(e, f, index)}
|
|
485
|
+
>
|
|
486
|
+
{#if selectable}
|
|
487
|
+
<td class="chk" onclick={(e) => e.stopPropagation()}><Checkbox checked={checked.includes(id)} aria-label="Select row" onchange={() => toggleRow(id)} /></td>
|
|
488
|
+
{/if}
|
|
489
|
+
{#each cols as c, ci (c.key)}
|
|
490
|
+
<td data-align={c.align}>
|
|
491
|
+
<div class="cell" style:padding-inline-start={ci === 0 && getChildren ? `${f.depth * 20}px` : undefined}>
|
|
492
|
+
{#if ci === 0 && getChildren}
|
|
493
|
+
{#if f.expandable}
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
class="twisty"
|
|
497
|
+
aria-label={expanded.has(id) || kept ? 'Collapse' : 'Expand'}
|
|
498
|
+
onclick={(e) => { e.stopPropagation(); toggleExpand(id); }}
|
|
499
|
+
>
|
|
500
|
+
<Icon name={expanded.has(id) || kept ? 'chevron-down' : 'chevron-right'} />
|
|
501
|
+
</button>
|
|
502
|
+
{:else}<span class="twisty"></span>{/if}
|
|
503
|
+
{/if}
|
|
504
|
+
{#if cell}{@render cell(f.row, c)}{:else}{f.row[c.key]}{/if}
|
|
505
|
+
</div>
|
|
506
|
+
</td>
|
|
507
|
+
{/each}
|
|
508
|
+
</tr>
|
|
509
|
+
{/each}
|
|
510
|
+
{#if virtual && end < list.length}
|
|
511
|
+
<tr class="spacer" aria-hidden="true" style:height="{(list.length - end) * rowHeight}px"><td colspan={ncols} style:height="{(list.length - end) * rowHeight}px"></td></tr>
|
|
512
|
+
{/if}
|
|
513
|
+
{#if loading}
|
|
514
|
+
<tr class="more" aria-live="polite"><td colspan={ncols}><span class="ld"><Spinner size={16} label="Loading more rows" /> Loading more…</span></td></tr>
|
|
515
|
+
{:else if list.length === 0}
|
|
516
|
+
<tr class="more"><td colspan={ncols}><span class="ld">{empty}</span></td></tr>
|
|
517
|
+
{/if}
|
|
518
|
+
</tbody>
|
|
519
|
+
</table>
|
|
520
|
+
|
|
521
|
+
<!-- Floating pagination (Material elevation) pinned to the scroll viewport -->
|
|
522
|
+
{#if pagination}
|
|
523
|
+
<nav class="pager" aria-label="Pagination">
|
|
524
|
+
<span class="count">{flat.length ? page * pageSize + 1 : 0}–{Math.min((page + 1) * pageSize, flat.length)} of {flat.length}</span>
|
|
525
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="chevron-left" aria-label="Previous page" disabled={page === 0} onclick={() => page--} />
|
|
526
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="chevron-right" aria-label="Next page" disabled={page >= pages - 1} onclick={() => page++} />
|
|
527
|
+
</nav>
|
|
528
|
+
{/if}
|
|
529
|
+
</div>
|
|
530
|
+
|
|
531
|
+
{#if headMenu && headCol}
|
|
532
|
+
<Popover open={true} fixed anchor={headMenu.el} onclose={(r) => { const o = headMenu?.opener; headMenu = null; if (r) o?.focus(); }}>
|
|
533
|
+
<div class="hmenu" role="dialog" aria-label="{headCol.header} column menu" bind:this={headMenuEl}>
|
|
534
|
+
{#if headCol.sortable}
|
|
535
|
+
<div class="hsec" role="group" aria-label="Sort">
|
|
536
|
+
<button type="button" class="hi" data-on={sort?.key === headCol.key && sort.dir === 1} onclick={() => setSort(headCol!, 1)}><Icon name="sort-asc" />Sort ascending</button>
|
|
537
|
+
<button type="button" class="hi" data-on={sort?.key === headCol.key && sort.dir === -1} onclick={() => setSort(headCol!, -1)}><Icon name="sort-desc" />Sort descending</button>
|
|
538
|
+
<button type="button" class="hi" disabled={sort?.key !== headCol.key} onclick={() => setSort(headCol!, null)}><Icon name="cross" />Clear sort</button>
|
|
539
|
+
</div>
|
|
540
|
+
{/if}
|
|
541
|
+
{#if headCol.filter}
|
|
542
|
+
<div class="hsec">
|
|
543
|
+
<div class="hlabel">Filter</div>
|
|
544
|
+
{#if headCol.filter === 'text'}{@render textCtl(headCol)}
|
|
545
|
+
{:else if headCol.filter === 'number'}{@render numCtl(headCol)}
|
|
546
|
+
{:else}
|
|
547
|
+
<ul class="fmenu inline" aria-label="{headCol.header} values">
|
|
548
|
+
{#each optionsFor(headCol) as opt (opt)}
|
|
549
|
+
<li><Checkbox checked={((filters[headCol.key] as string[] | undefined) ?? []).includes(opt)} onchange={() => togglePick(headCol!.key, opt)}>{opt || '(empty)'}</Checkbox></li>
|
|
550
|
+
{/each}
|
|
551
|
+
</ul>
|
|
552
|
+
{/if}
|
|
553
|
+
<Button size="sm" variant="minimal" intent="neutral" disabled={!isActive(filters[headCol.key])} onclick={() => setFilter(headCol!.key, undefined)}>Clear filter</Button>
|
|
554
|
+
</div>
|
|
555
|
+
{/if}
|
|
556
|
+
{#if columnMenu && cols.length > 1}
|
|
557
|
+
<div class="hsec"><button type="button" class="hi" onclick={() => { toggleCol(headCol!.key); headMenu = null; }}><Icon name="minus" />Hide column</button></div>
|
|
558
|
+
{/if}
|
|
559
|
+
</div>
|
|
560
|
+
</Popover>
|
|
561
|
+
{/if}
|
|
562
|
+
|
|
563
|
+
{#if filterMenu && menuCol}
|
|
564
|
+
<Popover open={true} fixed anchor={filterMenu.el} onclose={() => (filterMenu = null)}>
|
|
565
|
+
<ul class="fmenu" aria-label="{menuCol.header} values">
|
|
566
|
+
{#each optionsFor(menuCol) as opt (opt)}
|
|
567
|
+
<li><Checkbox checked={((filters[menuCol.key] as string[] | undefined) ?? []).includes(opt)} onchange={() => togglePick(menuCol!.key, opt)}>{opt || '(empty)'}</Checkbox></li>
|
|
568
|
+
{/each}
|
|
569
|
+
</ul>
|
|
570
|
+
<div class="fmfoot"><Button size="sm" variant="minimal" intent="neutral" disabled={!isActive(filters[menuCol.key])} onclick={() => setFilter(menuCol!.key, undefined)}>Clear</Button></div>
|
|
571
|
+
</Popover>
|
|
572
|
+
{/if}
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
<style>
|
|
576
|
+
.bar { display: flex; align-items: center; gap: 8px; min-height: 32px; margin-bottom: 6px; }
|
|
577
|
+
.grow { flex: 1; }
|
|
578
|
+
.sel { font-size: var(--tr-text-sm); font-weight: 600; color: var(--tr-primary); min-width: 80px; }
|
|
579
|
+
.rows { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); font-variant-numeric: tabular-nums; }
|
|
580
|
+
.colmenu { list-style: none; margin: 0; padding: 8px 12px; display: grid; gap: 4px; white-space: nowrap; }
|
|
581
|
+
.chk { width: 40px; padding: 0 0 0 10px !important; }
|
|
582
|
+
.tr-table-wrap { position: relative; overflow: auto; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface); }
|
|
583
|
+
table { table-layout: fixed; border-collapse: separate; border-spacing: 0; font-size: var(--tr-text-md); }
|
|
584
|
+
/* Frozen header */
|
|
585
|
+
thead th {
|
|
586
|
+
position: sticky; top: 0; z-index: 2; height: 28px; padding: 0 8px; text-align: start;
|
|
587
|
+
background: var(--tr-surface-container); border-bottom: 1px solid var(--tr-outline-variant);
|
|
588
|
+
font-size: var(--tr-text-sm); font-weight: 700; letter-spacing: 0.02em; color: var(--tr-on-surface-variant);
|
|
589
|
+
white-space: nowrap; overflow: hidden;
|
|
590
|
+
}
|
|
591
|
+
th[data-align='end'], td[data-align='end'] { text-align: end; }
|
|
592
|
+
.head { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; }
|
|
593
|
+
button.head { cursor: pointer; }
|
|
594
|
+
button.head:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: 2px; }
|
|
595
|
+
th[data-sortable='true']:hover { background: var(--tr-surface-container-high); }
|
|
596
|
+
.grip { position: absolute; top: 0; right: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
|
|
597
|
+
.grip::after { content: ''; position: absolute; top: 6px; bottom: 6px; right: 3px; width: 1px; background: var(--tr-outline-variant); }
|
|
598
|
+
.grip:hover::after, .grip:focus-visible::after { width: 2px; right: 3px; background: var(--tr-primary); }
|
|
599
|
+
.grip:focus-visible { outline: none; }
|
|
600
|
+
|
|
601
|
+
/* Heading cell: label + (optional) funnel that opens the column menu */
|
|
602
|
+
.hc { display: flex; align-items: center; gap: 2px; height: 28px; min-width: 0; }
|
|
603
|
+
.hf { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); cursor: pointer; opacity: 0; transition: opacity var(--tr-dur-fast) var(--tr-ease), background-color var(--tr-dur-fast) var(--tr-ease); }
|
|
604
|
+
th:hover .hf, .hf:focus-visible, .hf[data-on='true'], .hf[aria-expanded='true'] { opacity: 1; }
|
|
605
|
+
.hf:hover { background: var(--tr-surface-container-highest); }
|
|
606
|
+
.hf:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
|
|
607
|
+
.hf[data-on='true'] { color: var(--tr-primary); background: color-mix(in srgb, var(--tr-primary-container) 70%, transparent); }
|
|
608
|
+
.hmenu { display: grid; min-width: 248px; padding: 2px 0; }
|
|
609
|
+
.hsec { display: grid; gap: 6px; padding: 6px 8px; }
|
|
610
|
+
.hsec + .hsec { border-top: 1px solid var(--tr-outline-variant); }
|
|
611
|
+
.hlabel { font-size: var(--tr-text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--tr-on-surface-variant); }
|
|
612
|
+
.hi { display: flex; align-items: center; gap: 8px; width: 100%; height: var(--tr-control-h); padding: 0 8px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface); font: inherit; text-align: left; cursor: pointer; }
|
|
613
|
+
.hi:hover:not(:disabled), .hi:focus-visible { outline: none; background: var(--tr-surface-container-highest); }
|
|
614
|
+
.hi:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
615
|
+
.hi[data-on='true'] { color: var(--tr-primary); font-weight: 700; }
|
|
616
|
+
.fmenu.inline { padding: 0; max-height: 160px; }
|
|
617
|
+
|
|
618
|
+
/* Filter row sits under the header, also frozen */
|
|
619
|
+
thead tr.filters th { top: 28px; z-index: 2; height: 34px; padding: 4px 6px; font-weight: 400; background: var(--tr-surface-container-low); overflow: visible; }
|
|
620
|
+
.fi { width: 100%; min-width: 0; height: 24px; padding: 0 6px; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-sm); background: var(--tr-surface); color: var(--tr-on-surface); font: var(--tr-text-sm) var(--tr-font-sans); box-sizing: border-box; }
|
|
621
|
+
.fi::placeholder { color: var(--tr-placeholder); }
|
|
622
|
+
.fi:hover { border-color: var(--tr-outline); }
|
|
623
|
+
.fi:focus-visible { outline: none; border-color: var(--tr-primary); box-shadow: inset 0 -2px 0 var(--tr-primary), var(--tr-focus-ring); }
|
|
624
|
+
.fi[data-on='true'], .range[data-on='true'] .fi { border-color: var(--tr-primary); background: color-mix(in srgb, var(--tr-primary-container) 45%, var(--tr-surface)); }
|
|
625
|
+
.range { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
|
626
|
+
.fsel { display: flex; align-items: center; justify-content: space-between; gap: 4px; text-align: left; cursor: pointer; }
|
|
627
|
+
.fsel span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
628
|
+
.fmenu { list-style: none; margin: 0; padding: 8px 12px; display: grid; gap: 4px; max-height: 240px; overflow: auto; white-space: nowrap; }
|
|
629
|
+
.fmfoot { display: flex; justify-content: flex-end; padding: 4px 8px; border-top: 1px solid var(--tr-outline-variant); }
|
|
630
|
+
|
|
631
|
+
/* Every row is exactly --rh tall so the virtual window math is exact */
|
|
632
|
+
tbody tr { height: var(--rh); }
|
|
633
|
+
tbody td { height: var(--rh); box-sizing: border-box; padding: 0 8px; border-bottom: 1px solid var(--tr-outline-variant); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
634
|
+
tbody tr.spacer, tbody tr.spacer td { padding: 0; border: 0; pointer-events: none; }
|
|
635
|
+
tbody tr.more td { text-align: center; color: var(--tr-on-surface-variant); border-bottom: 0; }
|
|
636
|
+
tbody tr.more:hover { background: none; }
|
|
637
|
+
.ld { display: inline-flex; align-items: center; gap: 8px; font-size: var(--tr-text-sm); }
|
|
638
|
+
.cell { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
|
639
|
+
td[data-align='end'] .cell { justify-content: flex-end; }
|
|
640
|
+
.twisty { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); cursor: pointer; }
|
|
641
|
+
button.twisty:hover { background: var(--tr-surface-container-highest); }
|
|
642
|
+
|
|
643
|
+
/* Material tonal hover; active row = left accent bar + soft tonal highlight */
|
|
644
|
+
tbody tr { cursor: default; transition: background-color var(--tr-dur-fast) var(--tr-ease); }
|
|
645
|
+
tbody tr:hover { background: var(--tr-surface-container-low); }
|
|
646
|
+
tbody tr:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
|
|
647
|
+
tbody tr[data-active='true'] { background: color-mix(in srgb, var(--tr-primary-container) 55%, transparent); }
|
|
648
|
+
tbody tr[data-active='true'] td:first-child { box-shadow: inset 3px 0 0 var(--tr-primary); }
|
|
649
|
+
|
|
650
|
+
.pager {
|
|
651
|
+
position: sticky; bottom: 12px; left: 100%; z-index: 3; width: max-content; margin: 12px 12px 12px auto;
|
|
652
|
+
display: flex; align-items: center; gap: 2px; padding: 2px 4px 2px 12px;
|
|
653
|
+
background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant);
|
|
654
|
+
border-radius: var(--tr-radius-pill); box-shadow: var(--tr-elevation-3);
|
|
655
|
+
}
|
|
656
|
+
.count { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); margin-right: 4px; font-variant-numeric: tabular-nums; }
|
|
657
|
+
</style>
|