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.
Files changed (100) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +181 -27
  3. package/docs/icon-catalog.html +67 -0
  4. package/docs/icon-catalog.json +392 -0
  5. package/docs/icon-catalog.md +171 -0
  6. package/examples/generate-tree.js +86 -0
  7. package/examples/reorder-table.html +33 -0
  8. package/examples/units.html +43 -0
  9. package/package.json +7 -3
  10. package/resources/icons/bicycle.svg +1 -0
  11. package/resources/icons/bird.svg +1 -0
  12. package/resources/icons/box.svg +1 -0
  13. package/resources/icons/building.svg +1 -0
  14. package/resources/icons/calendar.svg +1 -0
  15. package/resources/icons/car.svg +1 -0
  16. package/resources/icons/cat.svg +1 -0
  17. package/resources/icons/child.svg +1 -0
  18. package/resources/icons/clock.svg +1 -0
  19. package/resources/icons/cloud.svg +1 -0
  20. package/resources/icons/data-bus.svg +1 -0
  21. package/resources/icons/database.svg +1 -0
  22. package/resources/icons/dog.svg +1 -0
  23. package/resources/icons/drone.svg +1 -0
  24. package/resources/icons/fish.svg +1 -0
  25. package/resources/icons/flower.svg +1 -0
  26. package/resources/icons/fog.svg +1 -0
  27. package/resources/icons/globe.svg +1 -0
  28. package/resources/icons/grip.svg +1 -0
  29. package/resources/icons/heart.svg +1 -0
  30. package/resources/icons/helicopter.svg +1 -0
  31. package/resources/icons/insect.svg +1 -0
  32. package/resources/icons/leaf.svg +1 -0
  33. package/resources/icons/link.svg +1 -0
  34. package/resources/icons/lock.svg +1 -0
  35. package/resources/icons/moon.svg +1 -0
  36. package/resources/icons/network.svg +1 -0
  37. package/resources/icons/people.svg +1 -0
  38. package/resources/icons/person.svg +1 -0
  39. package/resources/icons/pin.svg +1 -0
  40. package/resources/icons/radar.svg +1 -0
  41. package/resources/icons/rain.svg +1 -0
  42. package/resources/icons/seedling.svg +1 -0
  43. package/resources/icons/sensor.svg +1 -0
  44. package/resources/icons/server.svg +1 -0
  45. package/resources/icons/ship.svg +1 -0
  46. package/resources/icons/snow.svg +1 -0
  47. package/resources/icons/star-filled.svg +1 -0
  48. package/resources/icons/star.svg +1 -0
  49. package/resources/icons/storm.svg +1 -0
  50. package/resources/icons/sun.svg +1 -0
  51. package/resources/icons/thermometer.svg +1 -0
  52. package/resources/icons/trash.svg +1 -0
  53. package/resources/icons/tree.svg +1 -0
  54. package/resources/icons/wind.svg +1 -0
  55. package/src/assets/icons.js +75 -25
  56. package/src/core/icon-registry.js +6 -8
  57. package/src/core/theme-manager.js +17 -4
  58. package/src/core/tree-cell-layout.js +9 -0
  59. package/src/core/tree-column-model.js +27 -3
  60. package/src/core/tree-model.js +20 -0
  61. package/src/core/types.js +1 -0
  62. package/src/core/view-options.js +37 -0
  63. package/src/core/visible-row-model.js +1 -1
  64. package/src/index.d.ts +209 -5
  65. package/src/index.js +3 -0
  66. package/src/input/index.js +1 -0
  67. package/src/input/row-actions.js +115 -0
  68. package/src/input/row-reorder-input.js +168 -0
  69. package/src/input/tree-filter-bar.js +55 -0
  70. package/src/input/tree-tooltip.js +55 -0
  71. package/src/input/tree-view-input-controller.js +10 -3
  72. package/src/inspector/cell-editor-manager.js +42 -13
  73. package/src/inspector/pane-layout.js +12 -0
  74. package/src/renderers/checkbox.js +14 -0
  75. package/src/renderers/tree-row-renderer.js +103 -62
  76. package/src/tree-view-controller.js +309 -57
  77. package/src/tree-view.js +183 -0
  78. package/src/view/styles.js +14 -0
  79. package/src/assets/icons/air.svg +0 -1
  80. package/src/assets/icons/radar.svg +0 -1
  81. /package/{src/assets → resources}/icons/aircraft.svg +0 -0
  82. /package/{src/assets/icons/bus.svg → resources/icons/bus-vehicle.svg} +0 -0
  83. /package/{src/assets → resources}/icons/control.svg +0 -0
  84. /package/{src/assets → resources}/icons/damage.svg +0 -0
  85. /package/{src/assets → resources}/icons/error.svg +0 -0
  86. /package/{src/assets → resources}/icons/folder.svg +0 -0
  87. /package/{src/assets → resources}/icons/ground.svg +0 -0
  88. /package/{src/assets → resources}/icons/inspector-array.svg +0 -0
  89. /package/{src/assets → resources}/icons/inspector-object.svg +0 -0
  90. /package/{src/assets → resources}/icons/inspector-value.svg +0 -0
  91. /package/{src/assets → resources}/icons/munition.svg +0 -0
  92. /package/{src/assets → resources}/icons/placeholder.svg +0 -0
  93. /package/{src/assets → resources}/icons/point.svg +0 -0
  94. /package/{src/assets → resources}/icons/situation.svg +0 -0
  95. /package/{src/assets → resources}/icons/space.svg +0 -0
  96. /package/{src/assets → resources}/icons/subsurface.svg +0 -0
  97. /package/{src/assets → resources}/icons/surface.svg +0 -0
  98. /package/{src/assets → resources}/icons/task.svg +0 -0
  99. /package/{src/assets → resources}/icons/track.svg +0 -0
  100. /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?: Record<string, any> & { nativeScrollbars?: boolean });
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: string, listener: (event: any) => void): any;
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';
@@ -1,2 +1,3 @@
1
1
  export * from './pointer-controller.js';
2
2
  export * from './tree-view-input-controller.js';
3
+ export * from './tree-tooltip.js';
@@ -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
+ }