flexdesk 0.16.0 → 0.17.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 +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tag_input.js — A LIST OF SHORT STRINGS, drawn as removable chips followed by
|
|
3
|
+
* a text box. (0.17.0)
|
|
4
|
+
*
|
|
5
|
+
* ┌──────────────────────────────────────────────────┐
|
|
6
|
+
* │ [baseline ×] [stress ×] [q3 ×] Add a tag… │
|
|
7
|
+
* └──────────────────────────────────────────────────┘
|
|
8
|
+
*
|
|
9
|
+
* The text box commits what was typed — trimmed, split at commas — on Enter,
|
|
10
|
+
* on a comma and when it loses focus. Empty pieces are dropped, and so is a
|
|
11
|
+
* piece already in the list unless `allowDuplicates`. Backspace in an empty
|
|
12
|
+
* box removes the last chip. A paste that carries a comma is split into
|
|
13
|
+
* chips at once; a paste without one is ordinary text in the box.
|
|
14
|
+
*
|
|
15
|
+
* Every chip has a remove button with an `aria-label` (`removeLabel(tag)`,
|
|
16
|
+
* "Remove <tag>" by default). `readOnly` draws the chips alone: no remove
|
|
17
|
+
* buttons and no text box.
|
|
18
|
+
*
|
|
19
|
+
* `onChange(list)` is called with a fresh copy of the list after every change
|
|
20
|
+
* the reader makes — never for `setValue`, and never for a commit that added
|
|
21
|
+
* nothing.
|
|
22
|
+
*
|
|
23
|
+
* const tags = createTagInput(host, {
|
|
24
|
+
* value: ['baseline'],
|
|
25
|
+
* placeholder: 'Add a tag…',
|
|
26
|
+
* onChange: (list) => save(list),
|
|
27
|
+
* });
|
|
28
|
+
* tags.getValue(); // ['baseline']
|
|
29
|
+
* tags.setValue(['a', 'b']);
|
|
30
|
+
* tags.dispose();
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The list as strings, the empty ones left out. */
|
|
34
|
+
const normalise = (list) => (Array.isArray(list) ? list : [])
|
|
35
|
+
.map((v) => (v == null ? '' : String(v).trim()))
|
|
36
|
+
.filter((v) => v !== '');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} TagInputOptions
|
|
40
|
+
* @property {string[]} [value] - The tags it opens with.
|
|
41
|
+
* @property {string} [placeholder] - The empty text box's placeholder.
|
|
42
|
+
* @property {boolean} [readOnly] - Chips only: no remove buttons, no text box.
|
|
43
|
+
* @property {boolean} [allowDuplicates] - Keep a tag that is already in the list.
|
|
44
|
+
* @property {(list: string[]) => void} [onChange] - After each change the reader makes.
|
|
45
|
+
* @property {string} [label] - The text box's accessible name.
|
|
46
|
+
* @property {(tag: string) => string} [removeLabel] - A remove button's `aria-label`.
|
|
47
|
+
* @property {string} [className] - Extra classes on the root.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {HTMLElement} host
|
|
52
|
+
* @param {TagInputOptions} [options]
|
|
53
|
+
*/
|
|
54
|
+
export function createTagInput(host, options = {}) {
|
|
55
|
+
if (!host || typeof host.appendChild !== 'function') {
|
|
56
|
+
throw new Error('[TagInput] host must be an HTMLElement');
|
|
57
|
+
}
|
|
58
|
+
const readOnly = !!options.readOnly;
|
|
59
|
+
const allowDuplicates = !!options.allowDuplicates;
|
|
60
|
+
const removeLabel = typeof options.removeLabel === 'function'
|
|
61
|
+
? options.removeLabel : (tag) => `Remove ${tag}`;
|
|
62
|
+
|
|
63
|
+
let tags = normalise(options.value);
|
|
64
|
+
let disposed = false;
|
|
65
|
+
|
|
66
|
+
const root = document.createElement('div');
|
|
67
|
+
root.className = ['twm-tag-input', readOnly ? 'twm-tag-input--readonly' : '', options.className || '']
|
|
68
|
+
.join(' ').replace(/\s+/g, ' ').trim();
|
|
69
|
+
root.setAttribute('role', 'group');
|
|
70
|
+
if (options.label) root.setAttribute('aria-label', options.label);
|
|
71
|
+
|
|
72
|
+
const chips = document.createElement('ul');
|
|
73
|
+
chips.className = 'twm-tag-input__chips';
|
|
74
|
+
root.appendChild(chips);
|
|
75
|
+
|
|
76
|
+
let input = null;
|
|
77
|
+
if (!readOnly) {
|
|
78
|
+
input = document.createElement('input');
|
|
79
|
+
input.type = 'text';
|
|
80
|
+
input.className = 'twm-tag-input__input';
|
|
81
|
+
input.spellcheck = false;
|
|
82
|
+
input.autocomplete = 'off';
|
|
83
|
+
if (options.placeholder) input.placeholder = options.placeholder;
|
|
84
|
+
if (options.label) input.setAttribute('aria-label', options.label);
|
|
85
|
+
root.appendChild(input);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const changed = () => {
|
|
89
|
+
if (typeof options.onChange === 'function') options.onChange([...tags]);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const chipFor = (tag, idx) => {
|
|
93
|
+
const chip = document.createElement('li');
|
|
94
|
+
chip.className = 'twm-tag-input__chip';
|
|
95
|
+
const text = document.createElement('span');
|
|
96
|
+
text.className = 'twm-tag-input__text';
|
|
97
|
+
text.textContent = tag;
|
|
98
|
+
text.title = tag;
|
|
99
|
+
chip.appendChild(text);
|
|
100
|
+
if (!readOnly) {
|
|
101
|
+
const remove = document.createElement('button');
|
|
102
|
+
remove.type = 'button';
|
|
103
|
+
remove.className = 'twm-tag-input__remove';
|
|
104
|
+
remove.setAttribute('aria-label', removeLabel(tag));
|
|
105
|
+
remove.textContent = '×';
|
|
106
|
+
// The text box keeps the focus: a blur would commit its text
|
|
107
|
+
// and redraw the chips under the pointer before the click.
|
|
108
|
+
remove.addEventListener('mousedown', (ev) => ev.preventDefault());
|
|
109
|
+
remove.addEventListener('click', (ev) => {
|
|
110
|
+
ev.preventDefault();
|
|
111
|
+
ev.stopPropagation();
|
|
112
|
+
removeAt(idx);
|
|
113
|
+
input?.focus();
|
|
114
|
+
});
|
|
115
|
+
chip.appendChild(remove);
|
|
116
|
+
}
|
|
117
|
+
return chip;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Redraws every chip. */
|
|
121
|
+
const paint = () => {
|
|
122
|
+
chips.replaceChildren(...tags.map(chipFor));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function removeAt(idx) {
|
|
126
|
+
if (disposed || readOnly || idx < 0 || idx >= tags.length) return;
|
|
127
|
+
tags = tags.filter((_, i) => i !== idx);
|
|
128
|
+
paint();
|
|
129
|
+
changed();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Adds the comma-separated pieces of `text`; true when any was added. */
|
|
133
|
+
const commitText = (text) => {
|
|
134
|
+
if (disposed || readOnly) return false;
|
|
135
|
+
const next = [...tags];
|
|
136
|
+
for (const piece of String(text).split(',')) {
|
|
137
|
+
const tag = piece.trim();
|
|
138
|
+
if (tag === '') continue;
|
|
139
|
+
if (!allowDuplicates && next.includes(tag)) continue;
|
|
140
|
+
next.push(tag);
|
|
141
|
+
}
|
|
142
|
+
if (next.length === tags.length) return false;
|
|
143
|
+
// Only the new chips are drawn: the ones already there stay in place,
|
|
144
|
+
// so a remove button that the focus is moving to (Tab out of the box
|
|
145
|
+
// commits its text) is still in the document when the focus lands.
|
|
146
|
+
const added = next.slice(tags.length).map((tag, i) => chipFor(tag, tags.length + i));
|
|
147
|
+
tags = next;
|
|
148
|
+
chips.append(...added);
|
|
149
|
+
changed();
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const commitInput = () => {
|
|
154
|
+
if (!input) return;
|
|
155
|
+
const text = input.value;
|
|
156
|
+
input.value = '';
|
|
157
|
+
commitText(text);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (input) {
|
|
161
|
+
input.addEventListener('keydown', (ev) => {
|
|
162
|
+
if (ev.isComposing) return;
|
|
163
|
+
if (ev.key === 'Enter' || ev.key === ',') {
|
|
164
|
+
ev.preventDefault();
|
|
165
|
+
commitInput();
|
|
166
|
+
} else if (ev.key === 'Backspace' && input.value === '' && tags.length) {
|
|
167
|
+
ev.preventDefault();
|
|
168
|
+
removeAt(tags.length - 1);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// A comma that arrives without its keydown (a soft keyboard, an IME,
|
|
172
|
+
// a drop) commits all the same.
|
|
173
|
+
input.addEventListener('input', () => {
|
|
174
|
+
if (input.value.includes(',')) commitInput();
|
|
175
|
+
});
|
|
176
|
+
input.addEventListener('blur', commitInput);
|
|
177
|
+
input.addEventListener('paste', (ev) => {
|
|
178
|
+
const pasted = ev.clipboardData?.getData('text/plain') ?? '';
|
|
179
|
+
if (!pasted.includes(',')) return;
|
|
180
|
+
ev.preventDefault();
|
|
181
|
+
const start = input.selectionStart ?? input.value.length;
|
|
182
|
+
const end = input.selectionEnd ?? start;
|
|
183
|
+
const text = input.value.slice(0, start) + pasted + input.value.slice(end);
|
|
184
|
+
input.value = '';
|
|
185
|
+
commitText(text);
|
|
186
|
+
});
|
|
187
|
+
root.addEventListener('mousedown', (ev) => {
|
|
188
|
+
if (ev.target === root || ev.target === chips) {
|
|
189
|
+
ev.preventDefault();
|
|
190
|
+
input.focus();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
paint();
|
|
196
|
+
host.appendChild(root);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
element: root,
|
|
200
|
+
input,
|
|
201
|
+
getValue: () => [...tags],
|
|
202
|
+
/** Replaces the list. Does not call `onChange`. */
|
|
203
|
+
setValue(list) {
|
|
204
|
+
if (disposed) return;
|
|
205
|
+
tags = normalise(list);
|
|
206
|
+
paint();
|
|
207
|
+
},
|
|
208
|
+
focus() { input?.focus(); },
|
|
209
|
+
dispose() {
|
|
210
|
+
if (disposed) return;
|
|
211
|
+
disposed = true;
|
|
212
|
+
root.remove();
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* toast.js — thin wrapper around the
|
|
2
|
+
* toast.js — thin wrapper around the mounted NotificationCenter for
|
|
3
3
|
* callers that don't already hold an eventBus reference.
|
|
4
4
|
*
|
|
5
5
|
* The NotificationCenter (../notification_center.js) subscribes to
|
|
6
|
-
* `toast:show` on
|
|
7
|
-
*
|
|
6
|
+
* `toast:show` on its event bus; these helpers emit the same payload shape
|
|
7
|
+
* on the bus they were given. They read no global: `NotificationCenter.mount()`
|
|
8
|
+
* hands its bus to `setToastBus()`, and `dispose()` takes it back. An embedder
|
|
9
|
+
* that wants toasts on some other bus calls `setToastBus(bus)` itself, and
|
|
10
|
+
* `setToastBus(null)` to stop.
|
|
8
11
|
*
|
|
9
12
|
* Usage:
|
|
10
13
|
* import { toastError, toastSuccess, toastWarn, toastInfo } from './toast.js';
|
|
11
|
-
* toastError('Save failed', '
|
|
14
|
+
* toastError('Save failed', 'the server refused the change');
|
|
12
15
|
*
|
|
13
16
|
* The 1-arg form treats the string as the message (no title); the
|
|
14
|
-
* 2-arg form is title + message.
|
|
17
|
+
* 2-arg form is title + message. With no bus (no NotificationCenter mounted
|
|
18
|
+
* yet, isolated test pages) the message goes to the console and nothing throws.
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
let toastBus = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Route the toast helpers to `bus` (anything with `emit(name, payload)`), or
|
|
25
|
+
* to the console with `null`. Returns the bus that was set before.
|
|
26
|
+
*/
|
|
27
|
+
export function setToastBus(bus) {
|
|
28
|
+
const prev = toastBus;
|
|
29
|
+
toastBus = bus && typeof bus.emit === 'function' ? bus : null;
|
|
30
|
+
return prev;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The bus the toast helpers currently emit on, or null. */
|
|
34
|
+
export function getToastBus() {
|
|
35
|
+
return toastBus;
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
function _emit(severity, persistent, titleOrMsg, msg) {
|
|
22
|
-
const bus =
|
|
39
|
+
const bus = toastBus;
|
|
23
40
|
const title = (msg !== undefined) ? titleOrMsg : null;
|
|
24
41
|
const message = (msg !== undefined) ? msg : titleOrMsg;
|
|
25
42
|
if (!bus) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToggleField — an on/off switch: a track and a knob drawn over a REAL
|
|
3
|
+
* checkbox, so the form, the label and assistive technology all see a native
|
|
4
|
+
* control.
|
|
5
|
+
*
|
|
6
|
+
* const t = new ToggleField({ label: 'Autosave', onText: 'On', offText: 'Off',
|
|
7
|
+
* checked: true, onChange: (on) => save(on) });
|
|
8
|
+
* host.appendChild(t.render());
|
|
9
|
+
* t.get(); // true
|
|
10
|
+
* t.set(false); // no onChange: the caller already knows
|
|
11
|
+
* t.setDisabled(true);
|
|
12
|
+
* t.dispose();
|
|
13
|
+
*
|
|
14
|
+
* The input carries `role="switch"` and an `aria-checked` that follows it.
|
|
15
|
+
* Space toggles it (as it does any checkbox) and so does Enter, which a
|
|
16
|
+
* checkbox ignores but a switch is expected to honour. Both go through the
|
|
17
|
+
* input's own `click()`, so a disabled switch ignores them exactly as it
|
|
18
|
+
* ignores the mouse, and a toggle fires the input's `input` and `change` like
|
|
19
|
+
* any other control. The root is a `<label>`, so a click on the track, the
|
|
20
|
+
* on/off text or the label toggles it too.
|
|
21
|
+
*
|
|
22
|
+
* Markup — every class is styled by `flexdesk.css` from tokens alone:
|
|
23
|
+
*
|
|
24
|
+
* label.twm-toggle[.twm-toggle--compact][.twm-toggle--disabled]
|
|
25
|
+
* span.twm-toggle__control
|
|
26
|
+
* input.twm-toggle__input[type=checkbox][role=switch]
|
|
27
|
+
* span.twm-toggle__track > span.twm-toggle__knob
|
|
28
|
+
* span.twm-toggle__state (onText / offText, when either is given)
|
|
29
|
+
* span.twm-toggle__label (label, when given)
|
|
30
|
+
*/
|
|
31
|
+
export class ToggleField {
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} o
|
|
34
|
+
* @param {boolean} [o.checked=false]
|
|
35
|
+
* @param {string?} [o.label] text beside the switch, and its accessible name
|
|
36
|
+
* @param {string?} [o.ariaLabel] accessible name when there is no visible label
|
|
37
|
+
* @param {string?} [o.onText] state text shown while on
|
|
38
|
+
* @param {string?} [o.offText] state text shown while off
|
|
39
|
+
* @param {boolean} [o.disabled=false]
|
|
40
|
+
* @param {boolean} [o.compact=false] a smaller track, for dense rows
|
|
41
|
+
* @param {string?} [o.name] the input's name, for a surrounding form
|
|
42
|
+
* @param {string?} [o.id] the input's id
|
|
43
|
+
* @param {Function?}[o.onChange] `(checked: boolean)` after a user toggle
|
|
44
|
+
*/
|
|
45
|
+
constructor({
|
|
46
|
+
checked = false, label = null, ariaLabel = null, onText = null, offText = null,
|
|
47
|
+
disabled = false, compact = false, name = null, id = null, onChange = null,
|
|
48
|
+
} = {}) {
|
|
49
|
+
this._initial = !!checked;
|
|
50
|
+
this._label = label == null ? null : String(label);
|
|
51
|
+
this._ariaLabel = ariaLabel == null ? null : String(ariaLabel);
|
|
52
|
+
this._onText = onText == null ? null : String(onText);
|
|
53
|
+
this._offText = offText == null ? null : String(offText);
|
|
54
|
+
this._disabled = !!disabled;
|
|
55
|
+
this._compact = !!compact;
|
|
56
|
+
this._name = name == null ? null : String(name);
|
|
57
|
+
this._id = id == null ? null : String(id);
|
|
58
|
+
this._onChange = typeof onChange === 'function' ? onChange : null;
|
|
59
|
+
|
|
60
|
+
this.root = null;
|
|
61
|
+
this.input = null;
|
|
62
|
+
this._stateEl = null;
|
|
63
|
+
this._disposers = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build the switch once and hand back its root; later calls return it. */
|
|
67
|
+
render() {
|
|
68
|
+
if (this.root) return this.root;
|
|
69
|
+
|
|
70
|
+
const root = document.createElement('label');
|
|
71
|
+
root.className = 'twm-toggle';
|
|
72
|
+
if (this._compact) root.classList.add('twm-toggle--compact');
|
|
73
|
+
|
|
74
|
+
const control = document.createElement('span');
|
|
75
|
+
control.className = 'twm-toggle__control';
|
|
76
|
+
|
|
77
|
+
const input = document.createElement('input');
|
|
78
|
+
input.type = 'checkbox';
|
|
79
|
+
input.className = 'twm-toggle__input';
|
|
80
|
+
input.setAttribute('role', 'switch');
|
|
81
|
+
if (this._name) input.name = this._name;
|
|
82
|
+
if (this._id) input.id = this._id;
|
|
83
|
+
if (this._ariaLabel && !this._label) input.setAttribute('aria-label', this._ariaLabel);
|
|
84
|
+
input.checked = this._initial;
|
|
85
|
+
|
|
86
|
+
const track = document.createElement('span');
|
|
87
|
+
track.className = 'twm-toggle__track';
|
|
88
|
+
track.setAttribute('aria-hidden', 'true');
|
|
89
|
+
const knob = document.createElement('span');
|
|
90
|
+
knob.className = 'twm-toggle__knob';
|
|
91
|
+
track.appendChild(knob);
|
|
92
|
+
|
|
93
|
+
control.append(input, track);
|
|
94
|
+
root.appendChild(control);
|
|
95
|
+
|
|
96
|
+
if (this._onText != null || this._offText != null) {
|
|
97
|
+
this._stateEl = document.createElement('span');
|
|
98
|
+
this._stateEl.className = 'twm-toggle__state';
|
|
99
|
+
// The switch already says on/off to a screen reader.
|
|
100
|
+
this._stateEl.setAttribute('aria-hidden', 'true');
|
|
101
|
+
root.appendChild(this._stateEl);
|
|
102
|
+
}
|
|
103
|
+
if (this._label != null) {
|
|
104
|
+
const labelEl = document.createElement('span');
|
|
105
|
+
labelEl.className = 'twm-toggle__label';
|
|
106
|
+
labelEl.textContent = this._label;
|
|
107
|
+
root.appendChild(labelEl);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.root = root;
|
|
111
|
+
this.input = input;
|
|
112
|
+
this.setDisabled(this._disabled);
|
|
113
|
+
this._sync();
|
|
114
|
+
|
|
115
|
+
const onKeyDown = (e) => {
|
|
116
|
+
if (e.key !== ' ' && e.key !== 'Enter') return;
|
|
117
|
+
// Ours, not the browser's: Space would otherwise activate on keyup
|
|
118
|
+
// as well, and Enter would submit the surrounding form.
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
if (e.repeat) return;
|
|
121
|
+
input.click();
|
|
122
|
+
};
|
|
123
|
+
const onKeyUp = (e) => { if (e.key === ' ') e.preventDefault(); };
|
|
124
|
+
const onChange = () => {
|
|
125
|
+
this._sync();
|
|
126
|
+
this._onChange?.(input.checked);
|
|
127
|
+
};
|
|
128
|
+
input.addEventListener('keydown', onKeyDown);
|
|
129
|
+
input.addEventListener('keyup', onKeyUp);
|
|
130
|
+
input.addEventListener('change', onChange);
|
|
131
|
+
this._disposers.push(
|
|
132
|
+
() => input.removeEventListener('keydown', onKeyDown),
|
|
133
|
+
() => input.removeEventListener('keyup', onKeyUp),
|
|
134
|
+
() => input.removeEventListener('change', onChange),
|
|
135
|
+
);
|
|
136
|
+
return root;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether the switch is on. */
|
|
140
|
+
get() {
|
|
141
|
+
return this.input ? this.input.checked : this._initial;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Turn it on or off. Does NOT call `onChange`. */
|
|
145
|
+
set(checked) {
|
|
146
|
+
if (!this.input) { this._initial = !!checked; return; }
|
|
147
|
+
this.input.checked = !!checked;
|
|
148
|
+
this._sync();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A disabled switch ignores clicks and keys alike. */
|
|
152
|
+
setDisabled(disabled) {
|
|
153
|
+
this._disabled = !!disabled;
|
|
154
|
+
if (!this.input) return;
|
|
155
|
+
this.input.disabled = this._disabled;
|
|
156
|
+
this.root.classList.toggle('twm-toggle--disabled', this._disabled);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
dispose() {
|
|
160
|
+
for (const d of this._disposers) {
|
|
161
|
+
try { d(); } catch { /* the element is already gone */ }
|
|
162
|
+
}
|
|
163
|
+
this._disposers = [];
|
|
164
|
+
this.root?.remove();
|
|
165
|
+
this.root = null;
|
|
166
|
+
this.input = null;
|
|
167
|
+
this._stateEl = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_sync() {
|
|
171
|
+
const on = this.input.checked;
|
|
172
|
+
this.input.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
173
|
+
if (this._stateEl) this._stateEl.textContent = (on ? this._onText : this._offText) ?? '';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -11,6 +11,28 @@
|
|
|
11
11
|
* - TreeItem: Leaf item (e.g., function, series)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Labels, subtitles and badge text are TEXT, set with `textContent` after the
|
|
16
|
+
* skeleton is built (BUG-0013): a dataset called 'A <b>B</b>' is shown as
|
|
17
|
+
* those characters, and a name carrying `<img onerror>` runs nothing.
|
|
18
|
+
*/
|
|
19
|
+
function setText(root, selector, value) {
|
|
20
|
+
const el = root.querySelector(selector);
|
|
21
|
+
if (el) el.textContent = value == null ? '' : String(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An id as the value of a double-quoted attribute selector (BUG-0013).
|
|
26
|
+
* NOT `CSS.escape`: jsdom does not provide it (see modal.js). Inside a quoted
|
|
27
|
+
* attribute value `"` and `\` are escaped, and a line break, which would end
|
|
28
|
+
* the string, becomes its hex escape.
|
|
29
|
+
*/
|
|
30
|
+
function attrValue(value) {
|
|
31
|
+
return String(value)
|
|
32
|
+
.replace(/["\\]/g, '\\$&')
|
|
33
|
+
.replace(/[\n\r\f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `);
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
/**
|
|
15
37
|
* Create a tree category (top-level collapsible section)
|
|
16
38
|
* @param {Object} options - Category options
|
|
@@ -49,11 +71,12 @@ export function createTreeCategory(options) {
|
|
|
49
71
|
<span class="twm-tree-category__icon ${iconClass}">
|
|
50
72
|
<span class="material-symbols-outlined">${icon}</span>
|
|
51
73
|
</span>
|
|
52
|
-
<span class="twm-tree-category__label"
|
|
74
|
+
<span class="twm-tree-category__label"></span>
|
|
53
75
|
<span class="twm-tree-category__count">(${count})</span>
|
|
54
76
|
</div>
|
|
55
77
|
<div class="twm-collapsible-content${expanded ? ' visible' : ''}" data-collapsible-content="true"></div>
|
|
56
78
|
`;
|
|
79
|
+
setText(node, '.twm-tree-category__label', label);
|
|
57
80
|
|
|
58
81
|
return node;
|
|
59
82
|
}
|
|
@@ -97,12 +120,13 @@ export function createTreeNode(options) {
|
|
|
97
120
|
<span class="material-symbols-outlined">${expanded ? 'expand_more' : 'chevron_right'}</span>
|
|
98
121
|
</span>
|
|
99
122
|
${iconHtml}
|
|
100
|
-
<span class="twm-tree-node__label"
|
|
123
|
+
<span class="twm-tree-node__label"></span>
|
|
101
124
|
<span class="twm-tree-node__count">(${count})</span>
|
|
102
125
|
${actionsHtml}
|
|
103
126
|
</div>
|
|
104
127
|
<div class="twm-tree-node__children" style="display: ${expanded ? 'block' : 'none'};"></div>
|
|
105
128
|
`;
|
|
129
|
+
setText(node, '.twm-tree-node__label', label);
|
|
106
130
|
|
|
107
131
|
// Add action buttons if specified
|
|
108
132
|
if (actions) {
|
|
@@ -173,25 +197,28 @@ export function createTreeItem(options) {
|
|
|
173
197
|
: '';
|
|
174
198
|
|
|
175
199
|
const subtitleHtml = subtitle
|
|
176
|
-
?
|
|
200
|
+
? '<span class="twm-tree-item__subtitle"></span>'
|
|
177
201
|
: '';
|
|
178
202
|
|
|
179
203
|
let badgeHtml = '';
|
|
180
204
|
if (badge) {
|
|
181
205
|
const variant = badge.variant || 'muted';
|
|
182
206
|
if (badge.text) {
|
|
183
|
-
badgeHtml = `<span class="twm-tree-item__badge tree-item__badge--${variant}"
|
|
207
|
+
badgeHtml = `<span class="twm-tree-item__badge twm-tree-item__badge--${variant}"></span>`;
|
|
184
208
|
} else {
|
|
185
|
-
badgeHtml = `<span class="twm-tree-item__status-dot tree-item__status-dot--${variant}"></span>`;
|
|
209
|
+
badgeHtml = `<span class="twm-tree-item__status-dot twm-tree-item__status-dot--${variant}"></span>`;
|
|
186
210
|
}
|
|
187
211
|
}
|
|
188
212
|
|
|
189
213
|
item.innerHTML = `
|
|
190
214
|
${iconHtml}
|
|
191
|
-
<span class="twm-tree-item__label"
|
|
215
|
+
<span class="twm-tree-item__label"></span>
|
|
192
216
|
${subtitleHtml}
|
|
193
217
|
${badgeHtml}
|
|
194
218
|
`;
|
|
219
|
+
setText(item, '.twm-tree-item__label', label);
|
|
220
|
+
if (subtitle) setText(item, '.twm-tree-item__subtitle', subtitle);
|
|
221
|
+
if (badge?.text) setText(item, '.twm-tree-item__badge', badge.text);
|
|
195
222
|
|
|
196
223
|
return item;
|
|
197
224
|
}
|
|
@@ -257,6 +284,12 @@ export function toggleNode(nodeEl, force) {
|
|
|
257
284
|
|
|
258
285
|
/**
|
|
259
286
|
* Filter tree items by text match
|
|
287
|
+
*
|
|
288
|
+
* An item is shown when its label contains the filter. A node or a category is
|
|
289
|
+
* shown when any tree item inside it is shown, whether the item sits directly
|
|
290
|
+
* in it or deeper inside nodes. An empty filter shows everything, so clearing
|
|
291
|
+
* the filter restores the whole tree, empty nodes and categories included.
|
|
292
|
+
*
|
|
260
293
|
* @param {HTMLElement} container - Tree container
|
|
261
294
|
* @param {string} filter - Filter text (case-insensitive)
|
|
262
295
|
* @param {Object} [options] - Filter options
|
|
@@ -266,40 +299,28 @@ export function filterTree(container, filter, options = {}) {
|
|
|
266
299
|
const { expandMatches = true } = options;
|
|
267
300
|
const normalizedFilter = (filter || '').toLowerCase().trim();
|
|
268
301
|
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
const categories = container.querySelectorAll('.twm-tree-category');
|
|
272
|
-
|
|
273
|
-
// Reset visibility
|
|
274
|
-
items.forEach((item) => {
|
|
302
|
+
const visibleItems = new Set();
|
|
303
|
+
container.querySelectorAll('.twm-tree-item').forEach((item) => {
|
|
275
304
|
const label = item.querySelector('.twm-tree-item__label')?.textContent || '';
|
|
276
305
|
const matches = !normalizedFilter || label.toLowerCase().includes(normalizedFilter);
|
|
277
306
|
item.style.display = matches ? '' : 'none';
|
|
278
307
|
item.classList.toggle('twm-filter-match', matches && !!normalizedFilter);
|
|
308
|
+
if (matches) visibleItems.add(item);
|
|
279
309
|
});
|
|
280
310
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const visibleChildren = node.querySelectorAll('.twm-tree-item:not([style*="display: none"])');
|
|
284
|
-
const hasVisibleChildren = visibleChildren.length > 0;
|
|
285
|
-
node.style.display = hasVisibleChildren ? '' : 'none';
|
|
311
|
+
const hasVisibleItem = (el) => !normalizedFilter
|
|
312
|
+
|| [...el.querySelectorAll('.twm-tree-item')].some((item) => visibleItems.has(item));
|
|
286
313
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
314
|
+
container.querySelectorAll('.twm-tree-node').forEach((node) => {
|
|
315
|
+
const visible = hasVisibleItem(node);
|
|
316
|
+
node.style.display = visible ? '' : 'none';
|
|
317
|
+
if (expandMatches && visible && normalizedFilter) toggleNode(node, true);
|
|
291
318
|
});
|
|
292
319
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
category.style.display = hasVisibleNodes ? '' : 'none';
|
|
298
|
-
|
|
299
|
-
// Auto-expand categories with matches
|
|
300
|
-
if (expandMatches && hasVisibleNodes && normalizedFilter) {
|
|
301
|
-
toggleCategory(category, true);
|
|
302
|
-
}
|
|
320
|
+
container.querySelectorAll('.twm-tree-category').forEach((category) => {
|
|
321
|
+
const visible = hasVisibleItem(category);
|
|
322
|
+
category.style.display = visible ? '' : 'none';
|
|
323
|
+
if (expandMatches && visible && normalizedFilter) toggleCategory(category, true);
|
|
303
324
|
});
|
|
304
325
|
}
|
|
305
326
|
|
|
@@ -320,7 +341,7 @@ export function selectItem(container, itemId, options = {}) {
|
|
|
320
341
|
});
|
|
321
342
|
|
|
322
343
|
// Find and select item
|
|
323
|
-
const item = container.querySelector(`.twm-tree-item[data-item-id="${itemId}"]`);
|
|
344
|
+
const item = container.querySelector(`.twm-tree-item[data-item-id="${attrValue(itemId)}"]`);
|
|
324
345
|
if (!item) return null;
|
|
325
346
|
|
|
326
347
|
item.classList.add('selected');
|
|
@@ -418,7 +439,7 @@ export class TreeView {
|
|
|
418
439
|
}
|
|
419
440
|
|
|
420
441
|
addNode(categoryId, options) {
|
|
421
|
-
const category = this.container.querySelector(`.twm-tree-category[data-category-id="${categoryId}"]`);
|
|
442
|
+
const category = this.container.querySelector(`.twm-tree-category[data-category-id="${attrValue(categoryId)}"]`);
|
|
422
443
|
const content = category?.querySelector('.twm-collapsible-content');
|
|
423
444
|
if (!content) return null;
|
|
424
445
|
|
|
@@ -428,7 +449,7 @@ export class TreeView {
|
|
|
428
449
|
}
|
|
429
450
|
|
|
430
451
|
addItem(nodeId, options) {
|
|
431
|
-
const node = this.container.querySelector(`.twm-tree-node[data-node-id="${nodeId}"]`);
|
|
452
|
+
const node = this.container.querySelector(`.twm-tree-node[data-node-id="${attrValue(nodeId)}"]`);
|
|
432
453
|
const children = node?.querySelector('.twm-tree-node__children');
|
|
433
454
|
if (!children) return null;
|
|
434
455
|
|
|
File without changes
|