virtual-tree-canvas 0.5.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/CHANGELOG.md +65 -0
- package/README.md +181 -27
- package/docs/icon-catalog.html +67 -0
- package/docs/icon-catalog.json +392 -0
- package/docs/icon-catalog.md +171 -0
- package/examples/generate-tree.js +86 -0
- package/examples/reorder-table.html +33 -0
- package/examples/units.html +43 -0
- package/package.json +7 -3
- package/resources/icons/bicycle.svg +1 -0
- package/resources/icons/bird.svg +1 -0
- package/resources/icons/box.svg +1 -0
- package/resources/icons/building.svg +1 -0
- package/resources/icons/calendar.svg +1 -0
- package/resources/icons/car.svg +1 -0
- package/resources/icons/cat.svg +1 -0
- package/resources/icons/child.svg +1 -0
- package/resources/icons/clock.svg +1 -0
- package/resources/icons/cloud.svg +1 -0
- package/resources/icons/data-bus.svg +1 -0
- package/resources/icons/database.svg +1 -0
- package/resources/icons/dog.svg +1 -0
- package/resources/icons/drone.svg +1 -0
- package/resources/icons/fish.svg +1 -0
- package/resources/icons/flower.svg +1 -0
- package/resources/icons/fog.svg +1 -0
- package/resources/icons/globe.svg +1 -0
- package/resources/icons/grip.svg +1 -0
- package/resources/icons/heart.svg +1 -0
- package/resources/icons/helicopter.svg +1 -0
- package/resources/icons/insect.svg +1 -0
- package/resources/icons/leaf.svg +1 -0
- package/resources/icons/link.svg +1 -0
- package/resources/icons/lock.svg +1 -0
- package/resources/icons/moon.svg +1 -0
- package/resources/icons/network.svg +1 -0
- package/resources/icons/people.svg +1 -0
- package/resources/icons/person.svg +1 -0
- package/resources/icons/pin.svg +1 -0
- package/resources/icons/radar.svg +1 -0
- package/resources/icons/rain.svg +1 -0
- package/resources/icons/seedling.svg +1 -0
- package/resources/icons/sensor.svg +1 -0
- package/resources/icons/server.svg +1 -0
- package/resources/icons/ship.svg +1 -0
- package/resources/icons/snow.svg +1 -0
- package/resources/icons/star-filled.svg +1 -0
- package/resources/icons/star.svg +1 -0
- package/resources/icons/storm.svg +1 -0
- package/resources/icons/sun.svg +1 -0
- package/resources/icons/thermometer.svg +1 -0
- package/resources/icons/trash.svg +1 -0
- package/resources/icons/tree.svg +1 -0
- package/resources/icons/wind.svg +1 -0
- package/src/assets/icons.js +75 -25
- package/src/core/icon-registry.js +6 -8
- package/src/core/theme-manager.js +17 -4
- package/src/core/tree-cell-layout.js +9 -0
- package/src/core/tree-column-model.js +27 -3
- package/src/core/tree-model.js +20 -0
- package/src/core/types.js +1 -0
- package/src/core/view-options.js +37 -0
- package/src/core/visible-row-model.js +1 -1
- package/src/index.d.ts +209 -5
- package/src/index.js +3 -0
- package/src/input/index.js +1 -0
- package/src/input/row-actions.js +115 -0
- package/src/input/row-reorder-input.js +168 -0
- package/src/input/tree-filter-bar.js +55 -0
- package/src/input/tree-tooltip.js +55 -0
- package/src/input/tree-view-input-controller.js +10 -3
- package/src/inspector/cell-editor-manager.js +42 -13
- package/src/inspector/pane-layout.js +12 -0
- package/src/renderers/checkbox.js +14 -0
- package/src/renderers/tree-row-renderer.js +103 -62
- package/src/tree-view-controller.js +309 -57
- package/src/tree-view.js +183 -0
- package/src/view/styles.js +14 -0
- package/src/assets/icons/air.svg +0 -1
- package/src/assets/icons/radar.svg +0 -1
- /package/{src/assets → resources}/icons/aircraft.svg +0 -0
- /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
- /package/{src/assets → resources}/icons/control.svg +0 -0
- /package/{src/assets → resources}/icons/damage.svg +0 -0
- /package/{src/assets → resources}/icons/error.svg +0 -0
- /package/{src/assets → resources}/icons/folder.svg +0 -0
- /package/{src/assets → resources}/icons/ground.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
- /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
- /package/{src/assets → resources}/icons/munition.svg +0 -0
- /package/{src/assets → resources}/icons/placeholder.svg +0 -0
- /package/{src/assets → resources}/icons/point.svg +0 -0
- /package/{src/assets → resources}/icons/situation.svg +0 -0
- /package/{src/assets → resources}/icons/space.svg +0 -0
- /package/{src/assets → resources}/icons/subsurface.svg +0 -0
- /package/{src/assets → resources}/icons/surface.svg +0 -0
- /package/{src/assets → resources}/icons/task.svg +0 -0
- /package/{src/assets → resources}/icons/track.svg +0 -0
- /package/{src/assets → resources}/icons/warning.svg +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolveTheme } from './theme-manager.js';
|
|
2
|
+
/** Supported TreeView configuration options and defaults. */
|
|
3
|
+
export const treeViewDefaults = Object.freeze({
|
|
4
|
+
mode: 'tree', presentation: 'pane', nodes: Object.freeze([]), model: null, meta: null,
|
|
5
|
+
columns: null, theme: 'dark', flatRoot: true, enforceMeta: false,
|
|
6
|
+
filter: true, filterPlacement: 'auto', markUpdated: true, editable: true,
|
|
7
|
+
rowActions: Object.freeze([]), rowDrag: null, rowReorder: false, initialExpandDepth: 1, rowHeight: undefined,
|
|
8
|
+
indentWidth: undefined, showHeader: true, headerHeight: 28, iconResolver: null, fontFamily: null,
|
|
9
|
+
});
|
|
10
|
+
export const treeViewOptionNames = Object.freeze(Object.keys(treeViewDefaults));
|
|
11
|
+
|
|
12
|
+
export function validateTreeViewOptions(options) {
|
|
13
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Options must be an object');
|
|
14
|
+
for (const [key, value] of Object.entries(options)) {
|
|
15
|
+
if (key === 'rowActions' && (!Array.isArray(value) || value.some(action => !action || typeof action.id !== 'string' || !action.id || typeof action.label !== 'string') || new Set(value.map(action => action.id)).size !== value.length)) throw new TypeError('rowActions requires unique ids and labels');
|
|
16
|
+
if (key === 'rowActions') for (const action of value) {
|
|
17
|
+
if (action.kind !== undefined && !['button','checkbox'].includes(action.kind)) throw new TypeError('row action kind must be button or checkbox');
|
|
18
|
+
for (const property of ['visible', 'disabled', 'pressed', 'checked']) if (action[property] !== undefined && !['boolean', 'function'].includes(typeof action[property])) throw new TypeError(`${property} must be boolean or a function`);
|
|
19
|
+
if (action.icon !== undefined && !['string', 'function'].includes(typeof action.icon)) throw new TypeError('action icon must be a name or a function');
|
|
20
|
+
}
|
|
21
|
+
if (key === 'rowDrag' && value !== null && typeof value !== 'function') throw new TypeError('rowDrag must be a function or null');
|
|
22
|
+
if (key === 'theme') resolveTheme(value);
|
|
23
|
+
if (key === 'mode' && !['tree', 'inspector'].includes(value)) throw new TypeError('mode must be tree or inspector');
|
|
24
|
+
if (key === 'presentation' && !['pane', 'table'].includes(value)) throw new TypeError('presentation must be pane or table');
|
|
25
|
+
if (key === 'filterPlacement' && !['auto', 'bar', 'header'].includes(value)) throw new TypeError('filterPlacement must be auto, bar or header');
|
|
26
|
+
if (key === 'nodes' && !Array.isArray(value)) throw new TypeError('nodes must be an array');
|
|
27
|
+
if (key === 'columns' && value !== null && !Array.isArray(value)) throw new TypeError('columns must be an array or null');
|
|
28
|
+
if (key === 'model' && value === undefined) throw new TypeError('model must not be undefined');
|
|
29
|
+
if (key === 'meta' && value !== null && (!value || typeof value !== 'object' || Array.isArray(value))) throw new TypeError('meta must be an object or null');
|
|
30
|
+
if (['rowHeight', 'indentWidth'].includes(key) && value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new TypeError(`${key} must be a positive number`);
|
|
31
|
+
if (key === 'headerHeight' && (!Number.isFinite(value) || value < 0)) throw new TypeError('headerHeight must be non-negative');
|
|
32
|
+
if (key === 'initialExpandDepth' && (!Number.isInteger(value) || value < 0)) throw new TypeError('initialExpandDepth must be a non-negative integer');
|
|
33
|
+
if (key === 'iconResolver' && value !== null && typeof value !== 'function') throw new TypeError('iconResolver must be a function or null');
|
|
34
|
+
if (key === 'fontFamily' && value !== null && typeof value !== 'string') throw new TypeError('fontFamily must be a string or null');
|
|
35
|
+
if (['flatRoot', 'enforceMeta', 'filter', 'markUpdated', 'editable', 'rowReorder', 'showHeader'].includes(key) && typeof value !== 'boolean') throw new TypeError(`${key} must be boolean`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -151,7 +151,7 @@ export class VisibleRowModel extends EventTarget {
|
|
|
151
151
|
/** @param {import('./tree-view-viewport.js').TreeViewViewport} viewport */
|
|
152
152
|
getStickyRows(viewport) {
|
|
153
153
|
const rowViewportHeight = viewport.rowViewportHeight ?? viewport.viewportHeight;
|
|
154
|
-
if (rowViewportHeight < this.rowHeight * 2) return [];
|
|
154
|
+
if (viewport.scrollY <= 0 || rowViewportHeight < this.rowHeight * 2) return [];
|
|
155
155
|
const max = Math.max(0, Math.min(6, Math.floor(rowViewportHeight / this.rowHeight) - 1));
|
|
156
156
|
const first = Math.max(0, Math.floor(viewport.scrollY / this.rowHeight));
|
|
157
157
|
let row = this.getRow(first);
|
package/src/index.d.ts
CHANGED
|
@@ -1,10 +1,54 @@
|
|
|
1
|
+
export interface RowAction {
|
|
2
|
+
kind?: 'button' | 'checkbox';
|
|
3
|
+
checked?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon?: string | ((node: TreeNode, state: Record<string, any>) => string);
|
|
7
|
+
/** Icon variants to rasterize before the first action. */
|
|
8
|
+
preloadIcons?: string[];
|
|
9
|
+
visible?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
10
|
+
disabled?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
11
|
+
pressed?: boolean | ((node: TreeNode, state: Record<string, any>) => boolean);
|
|
12
|
+
}
|
|
13
|
+
export type RowDragResolver = ((node: TreeNode, state: Record<string, any>) => any | null) | null;
|
|
14
|
+
export const builtinIconNames: readonly string[];
|
|
15
|
+
export interface RowReorderDetail {
|
|
16
|
+
nodeId: string;
|
|
17
|
+
parentId: string | null;
|
|
18
|
+
fromIndex: number;
|
|
19
|
+
toIndex: number;
|
|
20
|
+
siblingOrder: string[];
|
|
21
|
+
order: string[];
|
|
22
|
+
source: 'api' | 'pointer' | 'button' | 'keyboard';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TreeViewOptions {
|
|
26
|
+
nativeScrollbars?: boolean;
|
|
27
|
+
iconsBaseUrl?: string | URL;
|
|
28
|
+
iconRegistry?: IconRegistry;
|
|
29
|
+
rowReorder?: boolean;
|
|
30
|
+
rowActions?: RowAction[];
|
|
31
|
+
rowDrag?: RowDragResolver;
|
|
32
|
+
autoRender?: boolean;
|
|
33
|
+
tooltip?: boolean;
|
|
34
|
+
canvas?: HTMLCanvasElement;
|
|
35
|
+
host?: HTMLElement;
|
|
36
|
+
[key: string]: any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TreeTooltip {
|
|
40
|
+
constructor(options: { controller: TreeViewController; host?: HTMLElement });
|
|
41
|
+
hide(): void;
|
|
42
|
+
destroy(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
1
45
|
export type TreeViewAlign = 'start' | 'center' | 'end' | 'nearest';
|
|
2
46
|
|
|
3
47
|
export type IconDrawFunction = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) => void;
|
|
4
48
|
export type IconSource = string | CanvasImageSource | IconDrawFunction;
|
|
5
49
|
|
|
6
50
|
export class IconRegistry {
|
|
7
|
-
constructor(options?: { pixelRatio?: number });
|
|
51
|
+
constructor(options?: { pixelRatio?: number; iconsBaseUrl?: string | URL });
|
|
8
52
|
register(name: string, icon: IconSource): any;
|
|
9
53
|
get(name: string): any;
|
|
10
54
|
onChange(listener: () => void): () => void;
|
|
@@ -20,6 +64,7 @@ export type TreeNode = {
|
|
|
20
64
|
icon?: string;
|
|
21
65
|
image?: string;
|
|
22
66
|
tags?: string[];
|
|
67
|
+
reorderable?: boolean;
|
|
23
68
|
data?: any;
|
|
24
69
|
};
|
|
25
70
|
|
|
@@ -60,8 +105,12 @@ export type Column = {
|
|
|
60
105
|
minWidth?: number;
|
|
61
106
|
align?: 'left' | 'center' | 'right';
|
|
62
107
|
kind?: string;
|
|
108
|
+
/** Optional semantic type for value-column coloring; defaults to the raw value type. */
|
|
109
|
+
valueType?: string | ((value: any, node: TreeNode, state: Record<string, any>) => string);
|
|
63
110
|
sortable?: boolean;
|
|
64
111
|
value?: (node: TreeNode, state: Record<string, any>) => string | number | boolean;
|
|
112
|
+
/** Formats displayed text and tooltips without changing sorting values. */
|
|
113
|
+
format?: (value: any, node: TreeNode, state: Record<string, any>) => string;
|
|
65
114
|
render?: (ctx: CanvasRenderingContext2D, cell: any) => void;
|
|
66
115
|
};
|
|
67
116
|
|
|
@@ -89,16 +138,50 @@ export class TreeViewController {
|
|
|
89
138
|
rowModel: any;
|
|
90
139
|
expansion: any;
|
|
91
140
|
selection: any;
|
|
92
|
-
constructor(options?:
|
|
141
|
+
constructor(options?: TreeViewOptions);
|
|
142
|
+
iconRegistry: IconRegistry;
|
|
143
|
+
tooltip?: TreeTooltip | null;
|
|
144
|
+
rowReorder: boolean;
|
|
145
|
+
editable: boolean;
|
|
146
|
+
initialExpandDepth: number;
|
|
147
|
+
filterOptions: {caseSensitive: boolean; wholeWord: boolean};
|
|
148
|
+
model: any;
|
|
149
|
+
inspector: any;
|
|
150
|
+
columnModel: any;
|
|
151
|
+
setRowActions(actions: RowAction[]): void;
|
|
152
|
+
setRowDrag(resolver: RowDragResolver): void;
|
|
153
|
+
sortBy(id: string, direction?: string): boolean;
|
|
154
|
+
clearSort(): void;
|
|
155
|
+
setEditable(enabled: boolean): void;
|
|
156
|
+
setInitialExpandDepth(depth: number): void;
|
|
157
|
+
setIconResolver(resolver: TreeViewConfiguration['iconResolver']): void;
|
|
158
|
+
setInspectorOptions(options: {filter?: boolean; markUpdated?: boolean}): void;
|
|
159
|
+
setHeaderFilter(enabled: boolean): void;
|
|
160
|
+
setInspectorValue(path: string, value: any, options?: InspectorWriteOptions): boolean;
|
|
161
|
+
updateInspectorValue(nodeId: string, value: any, editorType?: string, options?: InspectorWriteOptions): boolean;
|
|
162
|
+
closeEditor(): void;
|
|
163
|
+
clearSearch(): void;
|
|
164
|
+
getSearchState(): TreeSearchState;
|
|
165
|
+
nextSearchResult(): string | null;
|
|
166
|
+
previousSearchResult(): string | null;
|
|
167
|
+
setRowReorder(enabled: boolean): void;
|
|
168
|
+
canReorderRows(nodeId?: string): boolean;
|
|
169
|
+
getRowOrder(parentId?: string | null): string[];
|
|
170
|
+
moveRow(nodeId: string, targetIndex: number, options?: {source?: RowReorderDetail["source"]}): boolean;
|
|
171
|
+
moveRowBy(nodeId: string, offset: number, options?: {source?: RowReorderDetail["source"]}): boolean;
|
|
172
|
+
requestRender(force?: boolean): void;
|
|
173
|
+
cancelRender(): void;
|
|
174
|
+
attachTooltip(options?: {host?: HTMLElement}): TreeTooltip;
|
|
93
175
|
initialize(canvas: HTMLCanvasElement): this;
|
|
94
176
|
attachCellEditor(options?: { host?: HTMLElement | null }): CellEditorManager;
|
|
95
177
|
attachInput(options?: { cellEditor?: CellEditorManager | null }): TreeViewInputController;
|
|
96
178
|
destroy(): void;
|
|
97
|
-
on(type:
|
|
179
|
+
on<K extends keyof TreeViewEvents>(type: K, listener: (event: TreeViewEvent<K>) => void): () => void;
|
|
180
|
+
on(type: string, listener: (event: any) => void): () => void;
|
|
98
181
|
off(type: string, listener: (event: any) => void): void;
|
|
99
|
-
setData(nodes: TreeNode[]): void;
|
|
182
|
+
setData(nodes: TreeNode[], options?: { iconResolver?: TreeViewConfiguration['iconResolver'] }): void;
|
|
100
183
|
setModel(model: any, meta?: Record<string, MetaRule>, options?: Record<string, any>): void;
|
|
101
|
-
setColumns(columns: Column[]): void;
|
|
184
|
+
setColumns(columns: Column[] | null): void;
|
|
102
185
|
setDynamicState(patches: DynamicPatch[]): void;
|
|
103
186
|
setTheme(theme: any): void;
|
|
104
187
|
setLayoutMetrics(options?: { rowHeight?: number; indentWidth?: number; headerHeight?: number }): void;
|
|
@@ -117,6 +200,7 @@ export class TreeViewController {
|
|
|
117
200
|
getSelection(): string[];
|
|
118
201
|
setSelection(ids: string[]): void;
|
|
119
202
|
clearSelection(): void;
|
|
203
|
+
toggle(nodeId: string): boolean;
|
|
120
204
|
expandAll(): void;
|
|
121
205
|
collapseAll(): void;
|
|
122
206
|
}
|
|
@@ -129,5 +213,125 @@ export class TreeViewInputController {
|
|
|
129
213
|
export class CellEditorManager {
|
|
130
214
|
constructor(options: { controller: TreeViewController; host?: HTMLElement | null });
|
|
131
215
|
destroy(): void;
|
|
216
|
+
setEditable(enabled: boolean): void;
|
|
132
217
|
close(): void;
|
|
133
218
|
}
|
|
219
|
+
|
|
220
|
+
export interface InspectorWriteOptions { emit?: boolean; source?: string }
|
|
221
|
+
export interface TreeViewConfiguration {
|
|
222
|
+
mode?: 'tree' | 'inspector';
|
|
223
|
+
presentation?: 'pane' | 'table';
|
|
224
|
+
nodes?: TreeNode[];
|
|
225
|
+
model?: any;
|
|
226
|
+
meta?: Record<string, MetaRule> | null;
|
|
227
|
+
columns?: Column[] | null;
|
|
228
|
+
theme?: string | Record<string, any>;
|
|
229
|
+
flatRoot?: boolean;
|
|
230
|
+
enforceMeta?: boolean;
|
|
231
|
+
filter?: boolean;
|
|
232
|
+
filterPlacement?: 'auto' | 'bar' | 'header';
|
|
233
|
+
markUpdated?: boolean;
|
|
234
|
+
editable?: boolean;
|
|
235
|
+
rowReorder?: boolean;
|
|
236
|
+
rowActions?: RowAction[];
|
|
237
|
+
rowDrag?: RowDragResolver;
|
|
238
|
+
initialExpandDepth?: number;
|
|
239
|
+
rowHeight?: number;
|
|
240
|
+
indentWidth?: number;
|
|
241
|
+
/** Hide column headings without hiding the filter bar. Defaults to true; inspector panes may hide headings automatically. */
|
|
242
|
+
showHeader?: boolean;
|
|
243
|
+
headerHeight?: number;
|
|
244
|
+
iconResolver?: ((node: TreeNode) => string | Partial<TreeNode> | null | undefined) | null;
|
|
245
|
+
fontFamily?: string | null;
|
|
246
|
+
}
|
|
247
|
+
export const treeViewOptionNames: readonly (keyof TreeViewConfiguration)[];
|
|
248
|
+
export function validateTreeViewOptions(options: TreeViewConfiguration): void;
|
|
249
|
+
|
|
250
|
+
export class TreeView {
|
|
251
|
+
constructor(host: HTMLElement, options?: TreeViewConfiguration & Pick<TreeViewOptions, 'iconsBaseUrl' | 'iconRegistry' | 'nativeScrollbars'>);
|
|
252
|
+
readonly controller: TreeViewController;
|
|
253
|
+
readonly element: HTMLDivElement;
|
|
254
|
+
readonly canvas: HTMLCanvasElement;
|
|
255
|
+
readonly destroyed: boolean;
|
|
256
|
+
configure(options: TreeViewConfiguration): this;
|
|
257
|
+
flush(): void;
|
|
258
|
+
destroy(): void;
|
|
259
|
+
setData(nodes: TreeNode[]): void;
|
|
260
|
+
setModel(model: any, meta?: Record<string, MetaRule>): void;
|
|
261
|
+
setColumns(columns: Column[] | null): void;
|
|
262
|
+
setTheme(theme: string | Record<string, any>): void;
|
|
263
|
+
setDynamicState(patches: DynamicPatch[]): void;
|
|
264
|
+
setInspectorValue(path: string, value: any, options?: InspectorWriteOptions): boolean;
|
|
265
|
+
on<K extends keyof TreeViewEvents>(type: K, listener: (event: TreeViewEvent<K>) => void): () => void;
|
|
266
|
+
on(type: string, listener: (event: any) => void): () => void;
|
|
267
|
+
off(type: string, listener: (event: any) => void): void;
|
|
268
|
+
setFilter(query?: string | ((node: TreeNode, state: Record<string, any>) => boolean), options?: {caseSensitive?: boolean; wholeWord?: boolean}): void;
|
|
269
|
+
clearFilter(): void;
|
|
270
|
+
getRowOrder(parentId?: string | null): string[];
|
|
271
|
+
moveRow(id: string, index: number, options?: {source?: RowReorderDetail['source']}): boolean;
|
|
272
|
+
moveRowBy(id: string, offset: number, options?: {source?: RowReorderDetail['source']}): boolean;
|
|
273
|
+
getSelection(): string[];
|
|
274
|
+
setSelection(ids: string[]): void;
|
|
275
|
+
clearSelection(): void;
|
|
276
|
+
search(query: string, options?: Record<string, any>): any;
|
|
277
|
+
clearSearch(): void;
|
|
278
|
+
getSearchState(): TreeSearchState;
|
|
279
|
+
nextSearchResult(): string | null;
|
|
280
|
+
previousSearchResult(): string | null;
|
|
281
|
+
focusNode(id: string, options?: Record<string, any>): boolean;
|
|
282
|
+
scrollToNode(id: string, align?: TreeViewAlign): boolean;
|
|
283
|
+
expandAll(): void;
|
|
284
|
+
collapseAll(): void;
|
|
285
|
+
registerIcon(name: string, source: IconSource): any;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Public event details shared by the DOM view and the controller. */
|
|
289
|
+
export interface InspectorValueChange {
|
|
290
|
+
path: string;
|
|
291
|
+
oldValue: any;
|
|
292
|
+
newValue: any;
|
|
293
|
+
model: any;
|
|
294
|
+
nodeId: string;
|
|
295
|
+
editorType: string;
|
|
296
|
+
source: string;
|
|
297
|
+
}
|
|
298
|
+
export interface TreeSearchState {
|
|
299
|
+
query: string;
|
|
300
|
+
results: string[];
|
|
301
|
+
cursor: number;
|
|
302
|
+
current: string | null;
|
|
303
|
+
count: number;
|
|
304
|
+
}
|
|
305
|
+
export interface RowDragDetail {
|
|
306
|
+
nodeId: string;
|
|
307
|
+
payload: any;
|
|
308
|
+
label: string;
|
|
309
|
+
originalEvent: PointerEvent;
|
|
310
|
+
}
|
|
311
|
+
export interface TreeViewEvents {
|
|
312
|
+
rowaction: {checked?: boolean; actionId: string; nodeId: string; node: TreeNode; originalEvent: MouseEvent};
|
|
313
|
+
rowdragstart: RowDragDetail;
|
|
314
|
+
rowdragmove: RowDragDetail;
|
|
315
|
+
rowdragend: RowDragDetail;
|
|
316
|
+
rowdragcancel: Omit<RowDragDetail, 'originalEvent'>;
|
|
317
|
+
valuechange: InspectorValueChange;
|
|
318
|
+
modelchange: {model: any; meta?: Record<string, MetaRule>; path?: string; structural?: boolean; action?: string; value?: any} & Partial<InspectorValueChange>;
|
|
319
|
+
action: {path: string; label: string; nodeId: string; model: any; source: string};
|
|
320
|
+
rowreorder: RowReorderDetail;
|
|
321
|
+
selectionchange: {selection: string[]; focusedId: string | null};
|
|
322
|
+
focuschange: {nodeId: string | null};
|
|
323
|
+
filterchange: {query: string; options: {caseSensitive: boolean; wholeWord: boolean}; visibleRows: number; worker?: boolean};
|
|
324
|
+
searchchange: Omit<TreeSearchState, 'count'>;
|
|
325
|
+
nodeclick: {nodeId: string; row: any; originalEvent: any};
|
|
326
|
+
nodedblclick: {nodeId: string; row: any; originalEvent: any};
|
|
327
|
+
editablechange: {editable: boolean};
|
|
328
|
+
rowreorderchange: {enabled: boolean};
|
|
329
|
+
}
|
|
330
|
+
export type TreeViewEvent<K extends keyof TreeViewEvents> = {type: K; detail: TreeViewEvents[K]};
|
|
331
|
+
|
|
332
|
+
/** Formats an inspector value using its options and precision. */
|
|
333
|
+
export function formatInspectorValue(value: any, meta?: MetaRule): string;
|
|
334
|
+
|
|
335
|
+
export class ModelInspectorBuilder {
|
|
336
|
+
build(model: any, meta?: Record<string, MetaRule>, options?: {flatRoot?: boolean; enforceMeta?: boolean}): TreeNode[];
|
|
337
|
+
}
|
package/src/index.js
CHANGED
|
@@ -4,3 +4,6 @@ export * from './inspector/index.js';
|
|
|
4
4
|
export * from './renderers/index.js';
|
|
5
5
|
export * from './tree-view-controller.js';
|
|
6
6
|
export * from './benchmark/index.js';
|
|
7
|
+
export { builtinIconNames } from './assets/icons.js';
|
|
8
|
+
export { TreeView } from './tree-view.js';
|
|
9
|
+
export { treeViewOptionNames, validateTreeViewOptions } from './core/view-options.js';
|
package/src/input/index.js
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { drawCheckbox } from '../renderers/checkbox.js';
|
|
2
|
+
/** Accessible buttons for visible rows. Actions and their meaning belong to the host. */
|
|
3
|
+
export class RowActions {
|
|
4
|
+
constructor(controller) {
|
|
5
|
+
this.controller = controller;
|
|
6
|
+
this.buttons = new Map();
|
|
7
|
+
this.preparedIcons = new Set();
|
|
8
|
+
this.element = controller.canvas.ownerDocument.createElement('div');
|
|
9
|
+
Object.assign(this.element.style, { position: 'absolute', overflow: 'hidden', pointerEvents: 'none', zIndex: '2' });
|
|
10
|
+
const style = controller.canvas.ownerDocument.createElement('style');
|
|
11
|
+
style.textContent = `
|
|
12
|
+
.vtc-row-action { appearance:none; display:flex; align-items:center; justify-content:center;
|
|
13
|
+
background:transparent; color:inherit; outline:none; }
|
|
14
|
+
.vtc-row-action:hover:not(:disabled) canvas { filter:brightness(1.45) drop-shadow(0 0 2px var(--vtc-action-focus)); }
|
|
15
|
+
.vtc-row-action:focus-visible { outline:1px solid var(--vtc-action-focus); outline-offset:-2px; }
|
|
16
|
+
`;
|
|
17
|
+
this.element.append(style);
|
|
18
|
+
controller.canvas.parentElement?.append(this.element);
|
|
19
|
+
this.element.addEventListener('pointerdown', event => event.stopPropagation());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
render(scene) {
|
|
23
|
+
const { viewport, theme, rows, visibleRange, stickyRows } = scene;
|
|
24
|
+
const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
|
|
25
|
+
for (const action of this.controller.rowActions) {
|
|
26
|
+
const icons = action.preloadIcons ?? (typeof action.icon === 'string' ? [action.icon] : []);
|
|
27
|
+
for (const icon of icons) for (const color of [theme.colors.text, theme.colors.textMuted]) {
|
|
28
|
+
const key = JSON.stringify([icon, color, ratio]);
|
|
29
|
+
if (this.preparedIcons.has(key)) continue;
|
|
30
|
+
this.preparedIcons.add(key);
|
|
31
|
+
void this.controller.iconRegistry.prepare({icons:[icon],size:16,color,pixelRatio:ratio});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const column = scene.columns.find(c => c.id === '__vtc_actions');
|
|
35
|
+
const used = new Set();
|
|
36
|
+
const width = column?.width ?? 0;
|
|
37
|
+
this.element.style.setProperty('--vtc-action-focus', theme.colors.focus);
|
|
38
|
+
Object.assign(this.element.style, { background:'transparent', left: `${viewport.renderInsetX + Math.max(0, viewport.contentViewportWidth - width)}px`, top: `${viewport.renderInsetY + viewport.headerHeight}px`,
|
|
39
|
+
width: `${width}px`, height: `${viewport.rowViewportHeight}px` });
|
|
40
|
+
if (column) {
|
|
41
|
+
const visible = new Map();
|
|
42
|
+
const stickyBottom = stickyRows.reduce((end, row) => Math.max(end, row.stickyY + row.height), 0);
|
|
43
|
+
for (let i = visibleRange.first; i <= visibleRange.last; i++) {
|
|
44
|
+
const row = rows[i];
|
|
45
|
+
if (row && row.y + row.height - viewport.scrollY > stickyBottom) visible.set(row.nodeId, {row, y:row.y - viewport.scrollY});
|
|
46
|
+
}
|
|
47
|
+
for (const row of stickyRows) visible.set(row.nodeId, {row, y:row.stickyY, sticky:true});
|
|
48
|
+
for (const {row, y, sticky} of visible.values()) {
|
|
49
|
+
if (y + row.height <= 0 || y >= viewport.rowViewportHeight) continue;
|
|
50
|
+
const node = this.controller.model.index.getNode(row.nodeId);
|
|
51
|
+
const state = this.controller.model.dynamicState.get(node.id) ?? {};
|
|
52
|
+
this.controller.rowActions.forEach((action, index) => {
|
|
53
|
+
const resolve = value => typeof value === 'function' ? value(node, state) : value;
|
|
54
|
+
if (resolve(action.visible) === false) return;
|
|
55
|
+
const key = JSON.stringify([node.id, action.id, action.kind]);
|
|
56
|
+
used.add(key);
|
|
57
|
+
let button = this.buttons.get(key);
|
|
58
|
+
if (!button) {
|
|
59
|
+
button = this.element.ownerDocument.createElement('button');
|
|
60
|
+
button.type = 'button';
|
|
61
|
+
if (action.kind === 'checkbox') button.setAttribute('role','checkbox');
|
|
62
|
+
button.className = 'vtc-row-action';
|
|
63
|
+
button.dataset.nodeId = node.id;
|
|
64
|
+
button.dataset.action = action.id;
|
|
65
|
+
Object.assign(button.style, { position:'absolute', pointerEvents:'auto', width:'26px', padding:'3px', border:'0', borderRadius:'3px', cursor:'pointer' });
|
|
66
|
+
const icon = this.element.ownerDocument.createElement('canvas');
|
|
67
|
+
Object.assign(icon.style, {width:'16px',height:'16px',display:'block'});
|
|
68
|
+
button.append(icon);
|
|
69
|
+
button.onclick = event => {
|
|
70
|
+
event.stopPropagation();
|
|
71
|
+
const current = this.controller.model.index.getNode(button.dataset.nodeId);
|
|
72
|
+
const spec = this.controller.rowActions.find(item => item.id === button.dataset.action);
|
|
73
|
+
const currentState = this.controller.model.dynamicState.get(current?.id) ?? {};
|
|
74
|
+
if (!current || !spec || (typeof spec.disabled === 'function' ? spec.disabled(current, currentState) : spec.disabled)) return;
|
|
75
|
+
this.controller.events.emit('rowaction', { actionId: spec.id, nodeId: current.id, node: current, ...(spec.kind === 'checkbox' ? {checked:!(typeof spec.checked === 'function' ? spec.checked(current,currentState) : spec.checked)} : {}), originalEvent: event });
|
|
76
|
+
};
|
|
77
|
+
this.buttons.set(key, button);
|
|
78
|
+
this.element.append(button);
|
|
79
|
+
}
|
|
80
|
+
button.title = action.label;
|
|
81
|
+
button.setAttribute('aria-label', `${action.label}: ${node.label ?? node.id}`);
|
|
82
|
+
if (action.pressed !== undefined) button.setAttribute('aria-pressed', String(Boolean(resolve(action.pressed))));
|
|
83
|
+
else button.removeAttribute('aria-pressed');
|
|
84
|
+
button.disabled = Boolean(resolve(action.disabled));
|
|
85
|
+
Object.assign(button.style, { left:`${index * 28 + 1}px`, top:`${y + 1}px`, height:`${row.height - 2}px`,
|
|
86
|
+
clipPath:sticky ? 'none' : `inset(${Math.max(0, stickyBottom - y - 1)}px 0 0)`, opacity:button.disabled ? '0.3':'1' });
|
|
87
|
+
const canvas = button.firstChild;
|
|
88
|
+
const ratio = Math.max(1, this.element.ownerDocument.defaultView.devicePixelRatio || 1);
|
|
89
|
+
const size = action.kind === 'checkbox' ? 18 : 16;
|
|
90
|
+
canvas.style.width = canvas.style.height = `${size}px`;
|
|
91
|
+
const pixels = Math.round(size * ratio);
|
|
92
|
+
if (canvas.width !== pixels || canvas.height !== pixels) canvas.width = canvas.height = pixels;
|
|
93
|
+
const ctx = canvas.getContext('2d');
|
|
94
|
+
ctx.setTransform(pixels / size, 0, 0, pixels / size, 0, 0);
|
|
95
|
+
ctx.clearRect(0,0,size,size);
|
|
96
|
+
if (action.kind === 'checkbox') {
|
|
97
|
+
const checked = Boolean(resolve(action.checked));
|
|
98
|
+
button.setAttribute('aria-checked', String(checked));
|
|
99
|
+
drawCheckbox(ctx,1,1,checked,theme);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.controller.iconRegistry.draw(ctx, resolve(action.icon) ?? 'star', 0,0,16,resolve(action.pressed) ? theme.colors.text : theme.colors.textMuted);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let refocus = false;
|
|
107
|
+
for (const [key, button] of this.buttons) if (!used.has(key)) {
|
|
108
|
+
refocus ||= this.element.getRootNode().activeElement === button;
|
|
109
|
+
button.remove(); this.buttons.delete(key);
|
|
110
|
+
}
|
|
111
|
+
if (refocus) (this.buttons.values().next().value ?? this.controller.canvas).focus({preventScroll:true});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
destroy() { this.element.remove(); this.buttons.clear(); }
|
|
115
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
let nextStatusId = 0;
|
|
2
|
+
|
|
3
|
+
/** Pointer interaction for the optional row-order column; persistence belongs to the host. */
|
|
4
|
+
export class RowReorderInput {
|
|
5
|
+
constructor(controller) {
|
|
6
|
+
this.controller = controller;
|
|
7
|
+
this.canvas = controller.canvas;
|
|
8
|
+
this.view = this.canvas.ownerDocument?.defaultView ?? globalThis.window;
|
|
9
|
+
this.gesture = null;
|
|
10
|
+
this.frame = null;
|
|
11
|
+
this.previousTouchAction = this.canvas.style.touchAction;
|
|
12
|
+
this.canvas.addEventListener('pointerdown', this.onDown);
|
|
13
|
+
this.canvas.addEventListener('keydown', this.onKey);
|
|
14
|
+
this.canvas.addEventListener('lostpointercapture', this.onCancel);
|
|
15
|
+
this.view.addEventListener('pointermove', this.onMove);
|
|
16
|
+
this.view.addEventListener('pointerup', this.onUp);
|
|
17
|
+
this.view.addEventListener('pointercancel', this.onCancel);
|
|
18
|
+
this.view.addEventListener('blur', this.cancel);
|
|
19
|
+
this.stops = ['filterchange', 'sortchange', 'rowreorderchange'].map(type => controller.on(type, () => {
|
|
20
|
+
this.cancel();
|
|
21
|
+
this.syncMode();
|
|
22
|
+
}));
|
|
23
|
+
this.stops.push(controller.on('datachange', () => {
|
|
24
|
+
if (this.gesture && !controller.model.index.getNode(this.gesture.id)) this.cancel();
|
|
25
|
+
}));
|
|
26
|
+
this.stops.push(controller.on('rowreorder', event => {
|
|
27
|
+
if (!this.status) return;
|
|
28
|
+
const { nodeId, toIndex, siblingOrder } = event.detail;
|
|
29
|
+
const label = controller.model.index.getNode(nodeId)?.label ?? nodeId;
|
|
30
|
+
this.status.textContent = `${label}: position ${toIndex + 1} of ${siblingOrder.length}`;
|
|
31
|
+
}));
|
|
32
|
+
const doc = this.canvas.ownerDocument;
|
|
33
|
+
if (doc?.createElement && this.canvas.parentElement) {
|
|
34
|
+
this.status = doc.createElement('span');
|
|
35
|
+
this.status.id = `vtc-row-order-status-${nextStatusId++}`;
|
|
36
|
+
this.status.setAttribute('aria-live', 'polite');
|
|
37
|
+
Object.assign(this.status.style, { position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', clipPath: 'inset(50%)' });
|
|
38
|
+
this.canvas.parentElement.appendChild(this.status);
|
|
39
|
+
}
|
|
40
|
+
this.syncMode();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
syncMode() {
|
|
44
|
+
this.canvas.style.touchAction = (this.controller.canReorderRows() || this.controller.rowDrag) ? 'none' : this.previousTouchAction;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onDown = event => {
|
|
48
|
+
if (event.button !== 0 || event.isPrimary === false || this.gesture) return;
|
|
49
|
+
this.suppressClick = false;
|
|
50
|
+
const point = this.point(event);
|
|
51
|
+
const hit = this.controller.hitTest(point.x, point.y);
|
|
52
|
+
if (!hit?.row) return;
|
|
53
|
+
const node = this.controller.model.index.getNode(hit.row.nodeId);
|
|
54
|
+
const payload = this.controller.rowDrag?.(node, this.controller.model.dynamicState.get(node.id) ?? {}) ?? null;
|
|
55
|
+
const orderPart = ['rowDrag', 'rowUp', 'rowDown'].includes(hit.part);
|
|
56
|
+
if (orderPart && node.reorderable === false) return;
|
|
57
|
+
if (orderPart ? !this.controller.canReorderRows() && !(payload != null && hit.part === 'rowDrag') : payload == null || !['label', 'row', 'icon', 'cell'].includes(hit.part)) return;
|
|
58
|
+
this.canvas.focus({ preventScroll: true });
|
|
59
|
+
this.controller.cellEditor?.close?.();
|
|
60
|
+
this.controller.focusedId = hit.row.nodeId;
|
|
61
|
+
this.gesture = { id: hit.row.nodeId, part: orderPart ? hit.part : 'rowDrag', payload, pointerId: event.pointerId,
|
|
62
|
+
startX: point.x, startY: point.y, point, dragging: false };
|
|
63
|
+
this.canvas.setPointerCapture?.(event.pointerId);
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
if (orderPart) event.preventDefault();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
onMove = event => {
|
|
69
|
+
const gesture = this.gesture;
|
|
70
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
71
|
+
gesture.point = this.point(event);
|
|
72
|
+
if (gesture.part !== 'rowDrag') return;
|
|
73
|
+
if (!gesture.dragging && Math.hypot(gesture.point.x - gesture.startX, gesture.point.y - gesture.startY) < 5) return;
|
|
74
|
+
if (!gesture.dragging && gesture.payload != null) this.emitDrag('rowdragstart', event);
|
|
75
|
+
gesture.dragging = true;
|
|
76
|
+
if (gesture.payload != null) this.emitDrag('rowdragmove', event);
|
|
77
|
+
this.canvas.style.cursor = 'grabbing';
|
|
78
|
+
this.updateTarget();
|
|
79
|
+
this.scheduleScroll();
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
onUp = event => {
|
|
84
|
+
const gesture = this.gesture;
|
|
85
|
+
if (!gesture || event.pointerId !== gesture.pointerId) return;
|
|
86
|
+
gesture.point = this.point(event);
|
|
87
|
+
const target = gesture.dragging ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null;
|
|
88
|
+
const hit = this.controller.hitTest(gesture.point.x, gesture.point.y);
|
|
89
|
+
if (gesture.dragging && gesture.payload != null) this.emitDrag('rowdragend', event);
|
|
90
|
+
this.suppressClick = gesture.dragging;
|
|
91
|
+
this.cancel(false);
|
|
92
|
+
if (target) this.controller.moveRow(gesture.id, target.index, { source: 'pointer' });
|
|
93
|
+
else if (!gesture.dragging && hit?.row?.nodeId === gesture.id && hit.part === gesture.part) {
|
|
94
|
+
if (gesture.part === 'rowUp') this.controller.moveRowBy(gesture.id, -1, { source: 'button' });
|
|
95
|
+
if (gesture.part === 'rowDown') this.controller.moveRowBy(gesture.id, 1, { source: 'button' });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
onCancel = event => { if (this.gesture?.pointerId === event.pointerId) this.cancel(); };
|
|
100
|
+
onKey = event => {
|
|
101
|
+
if (event.key === 'Escape' && this.gesture) {
|
|
102
|
+
this.cancel();
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
point(event) {
|
|
108
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
109
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
updateTarget() {
|
|
113
|
+
const gesture = this.gesture;
|
|
114
|
+
this.controller.setRowDrop(gesture ? this.controller.getRowDropTarget(gesture.id, gesture.point.x, gesture.point.y) : null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
scheduleScroll() {
|
|
118
|
+
if (this.frame !== null || !this.gesture?.dragging) return;
|
|
119
|
+
const viewport = this.controller.viewport;
|
|
120
|
+
if (this.gesture.point.x < 0 || this.gesture.point.x > viewport.viewportWidth || this.gesture.point.y < 0 || this.gesture.point.y > viewport.viewportHeight) return;
|
|
121
|
+
const y = this.gesture.point.y - viewport.renderInsetY;
|
|
122
|
+
const top = viewport.headerHeight;
|
|
123
|
+
const bottom = top + viewport.rowViewportHeight;
|
|
124
|
+
const delta = y < top + 24 ? -10 : y > bottom - 24 ? 10 : 0;
|
|
125
|
+
if (!delta) return;
|
|
126
|
+
const schedule = this.view.requestAnimationFrame?.bind(this.view) ?? (callback => setTimeout(callback, 16));
|
|
127
|
+
this.frame = schedule(() => {
|
|
128
|
+
this.frame = null;
|
|
129
|
+
if (!this.gesture?.dragging) return;
|
|
130
|
+
const previous = viewport.scrollY;
|
|
131
|
+
this.controller.scrollBy(0, delta);
|
|
132
|
+
this.updateTarget();
|
|
133
|
+
if (viewport.scrollY !== previous) this.scheduleScroll();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
emitDrag(type, originalEvent) {
|
|
138
|
+
const gesture = this.gesture;
|
|
139
|
+
if (gesture) this.controller.events.emit(type, { nodeId: gesture.id, payload: gesture.payload,
|
|
140
|
+
label: this.controller.model.index.getNode(gesture.id)?.label ?? gesture.id, originalEvent });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
cancel = (notify = true) => {
|
|
144
|
+
const gesture = this.gesture;
|
|
145
|
+
if (gesture?.dragging) this.suppressClick = true;
|
|
146
|
+
if (notify && gesture?.dragging && gesture.payload != null) this.emitDrag('rowdragcancel');
|
|
147
|
+
this.gesture = null;
|
|
148
|
+
if (this.frame !== null) (this.view.cancelAnimationFrame?.bind(this.view) ?? clearTimeout)(this.frame);
|
|
149
|
+
this.frame = null;
|
|
150
|
+
if (gesture && this.canvas.hasPointerCapture?.(gesture.pointerId)) this.canvas.releasePointerCapture(gesture.pointerId);
|
|
151
|
+
this.canvas.style.cursor = '';
|
|
152
|
+
this.controller.setRowDrop(null);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
destroy() {
|
|
156
|
+
this.cancel();
|
|
157
|
+
this.canvas.removeEventListener('pointerdown', this.onDown);
|
|
158
|
+
this.canvas.removeEventListener('keydown', this.onKey);
|
|
159
|
+
this.canvas.removeEventListener('lostpointercapture', this.onCancel);
|
|
160
|
+
this.view.removeEventListener('pointermove', this.onMove);
|
|
161
|
+
this.view.removeEventListener('pointerup', this.onUp);
|
|
162
|
+
this.view.removeEventListener('pointercancel', this.onCancel);
|
|
163
|
+
this.view.removeEventListener('blur', this.cancel);
|
|
164
|
+
this.canvas.style.touchAction = this.previousTouchAction;
|
|
165
|
+
for (const stop of this.stops) stop();
|
|
166
|
+
this.status?.remove();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** A view of controller filter state. No duplicated query or toggle state. */
|
|
2
|
+
export class TreeFilterBar {
|
|
3
|
+
constructor(controller, document) {
|
|
4
|
+
this.controller = controller;
|
|
5
|
+
this.element = document.createElement('div');
|
|
6
|
+
this.element.className = 'vtc-filter';
|
|
7
|
+
this.input = document.createElement('input');
|
|
8
|
+
this.input.type = 'search';
|
|
9
|
+
this.input.placeholder = 'Filter';
|
|
10
|
+
this.input.setAttribute('aria-label', 'Filter rows');
|
|
11
|
+
this.caseButton = this.button(document, 'Aa', 'Match case');
|
|
12
|
+
this.wholeButton = this.button(document, 'W', 'Match whole word');
|
|
13
|
+
this.element.append(this.input, this.caseButton, this.wholeButton);
|
|
14
|
+
this.input.addEventListener('input', this.onInput);
|
|
15
|
+
this.caseButton.addEventListener('click', this.onCase);
|
|
16
|
+
this.wholeButton.addEventListener('click', this.onWhole);
|
|
17
|
+
this.stop = controller.on('filterchange', () => this.sync());
|
|
18
|
+
this.sync();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
button(document, label, title) {
|
|
22
|
+
const button = document.createElement('button');
|
|
23
|
+
button.type = 'button';
|
|
24
|
+
button.textContent = label;
|
|
25
|
+
button.title = title;
|
|
26
|
+
button.setAttribute('aria-label', title);
|
|
27
|
+
return button;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onInput = () => this.controller.setFilter(this.input.value, this.controller.filterOptions);
|
|
31
|
+
onCase = () => this.toggle('caseSensitive');
|
|
32
|
+
onWhole = () => this.toggle('wholeWord');
|
|
33
|
+
|
|
34
|
+
toggle(key) {
|
|
35
|
+
const options = this.controller.filterOptions;
|
|
36
|
+
this.controller.setFilter(this.input.value, { ...options, [key]: !options[key] });
|
|
37
|
+
this.input.focus();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
sync() {
|
|
41
|
+
this.input.value = this.controller.filterQuery;
|
|
42
|
+
this.caseButton.setAttribute('aria-pressed', String(this.controller.filterOptions.caseSensitive));
|
|
43
|
+
this.wholeButton.setAttribute('aria-pressed', String(this.controller.filterOptions.wholeWord));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setVisible(visible) { this.element.hidden = !visible; }
|
|
47
|
+
|
|
48
|
+
destroy() {
|
|
49
|
+
this.stop();
|
|
50
|
+
this.input.removeEventListener('input', this.onInput);
|
|
51
|
+
this.caseButton.removeEventListener('click', this.onCase);
|
|
52
|
+
this.wholeButton.removeEventListener('click', this.onWhole);
|
|
53
|
+
this.element.remove();
|
|
54
|
+
}
|
|
55
|
+
}
|