flexdesk 0.16.1 → 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 +2217 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3146 -164
- 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-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
- 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 +3146 -164
- 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 +657 -234
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3252 -2124
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -347
- 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 +7 -7
- package/src/tiles/scope_picker.js +0 -0
- package/src/tiles/tile_base.js +1 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +2 -0
- 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 +0 -0
- 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-B5Q53LAJ.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/dist/chunk-W6FBJLPW.js +0 -367
- package/dist/chunk-W6FBJLPW.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tile_config_form.js — a tile's config schema drawn as a form (0.17.0).
|
|
3
|
+
*
|
|
4
|
+
* `TileBase.getConfigSchema()` answers `{fields: [...]}` and `config_schema.js`
|
|
5
|
+
* knows how to validate, default and list options for those fields. This draws
|
|
6
|
+
* them, so an embedder no longer hand-builds a control per field:
|
|
7
|
+
*
|
|
8
|
+
* import { mountTileConfigForm } from '@flexdesk/tiles';
|
|
9
|
+
* const form = mountTileConfigForm(host, {
|
|
10
|
+
* schema: tile.getConfigSchema(),
|
|
11
|
+
* config: tile.config,
|
|
12
|
+
* data, // for `options: 'variables'` and the like
|
|
13
|
+
* onChange: (key, value, config) => tile.update(undefined, config),
|
|
14
|
+
* renderers: { variable: (ctx) => myPicker(ctx) },
|
|
15
|
+
* });
|
|
16
|
+
* form.getValues(); form.setValues(next); form.isValid();
|
|
17
|
+
* form.focusFirstInvalid(); form.flush(); form.dispose();
|
|
18
|
+
*
|
|
19
|
+
* ══ A FIELD ═════════════════════════════════════════════════════════════
|
|
20
|
+
*
|
|
21
|
+
* { key, type, label?, description?, default?, required?, readonly?,
|
|
22
|
+
* min?, max?, step?, options?, visibleWhen? }
|
|
23
|
+
*
|
|
24
|
+
* `type` is a `FieldTypes` entry (text, number, checkbox, select, multiselect,
|
|
25
|
+
* color, range) or a type a consumer renders through `renderers`. `options` is
|
|
26
|
+
* what `getFieldOptions(field, data)` reads. `visibleWhen` is
|
|
27
|
+
* `{field, in: [...]}` or `{field, equals}`: the row is hidden while the other
|
|
28
|
+
* field's value does not match, and shown again when it does — by toggling the
|
|
29
|
+
* row, never by rebuilding the form, so the control being typed into keeps its
|
|
30
|
+
* focus and its caret.
|
|
31
|
+
*
|
|
32
|
+
* A field with no `key` throws: a form that wrote every edit to
|
|
33
|
+
* `config['undefined']` was BUG-0021, and a loud mount is the cheaper failure.
|
|
34
|
+
* So does a `type` with neither a built-in control nor a renderer. `label` is
|
|
35
|
+
* optional: without one the field is called by its `key`, on its label and in
|
|
36
|
+
* every message (`count must be at least 1`).
|
|
37
|
+
*
|
|
38
|
+
* ══ AN EDIT ═════════════════════════════════════════════════════════════
|
|
39
|
+
*
|
|
40
|
+
* Every edit is checked by `validateField`. A valid one is applied and calls
|
|
41
|
+
* `onChange(key, value, config)` with a copy of the whole config. An invalid one
|
|
42
|
+
* shows its error under the field and is NOT applied: the config keeps the last
|
|
43
|
+
* valid value until the field is corrected (or `setValues` replaces it).
|
|
44
|
+
* `setValues` does not call `onChange`. `isValid()` answers false while a
|
|
45
|
+
* shown row has an error; `focusFirstInvalid()` focuses the first such row's
|
|
46
|
+
* control (a renderer's element, or the first focusable element inside it) and
|
|
47
|
+
* answers whether there was one.
|
|
48
|
+
*
|
|
49
|
+
* `commitOn: 'input'` (the default) checks text, number, range and colour
|
|
50
|
+
* controls on every keystroke. `commitOn: 'change'` checks them when the edit is
|
|
51
|
+
* finished (the control's `change`: blur, Enter, a released slider), for a
|
|
52
|
+
* consumer whose `onChange` is expensive — a save, a reload. A range still shows
|
|
53
|
+
* its value as it moves. `flush()` commits an edit typed but not yet finished,
|
|
54
|
+
* for a consumer about to take the form away; it is a no-op under `'input'`.
|
|
55
|
+
*
|
|
56
|
+
* ══ A CONSUMER'S CONTROL ════════════════════════════════════════════════
|
|
57
|
+
*
|
|
58
|
+
* `renderers[type](ctx)` — consulted before the built-ins, so it may also
|
|
59
|
+
* replace one — gets `{field, key, value, data, readonly, commit}` and returns an
|
|
60
|
+
* element, or `{element, setValue?(value), dispose?()}`. `commit(value)` runs
|
|
61
|
+
* the same check as a built-in edit and answers `{valid, error?}`. On a
|
|
62
|
+
* `readonly` field it applies nothing and answers `{valid: false, error}`, as a
|
|
63
|
+
* built-in control on a readonly field is disabled and never commits.
|
|
64
|
+
*
|
|
65
|
+
* ══ THE GRID'S COGWHEEL ═════════════════════════════════════════════════
|
|
66
|
+
*
|
|
67
|
+
* `TileGrid` emits `grid:config-modal-show` and nothing listens by default.
|
|
68
|
+
* `openTileConfig(eventBus, opts)` is the opt-in listener: it shows the form in
|
|
69
|
+
* a `SlideOutPanel` over `opts.host` (every finished, valid edit is saved — the
|
|
70
|
+
* form runs `commitOn: 'change'`, so a typed title is one save, not one per
|
|
71
|
+
* keystroke; closing the panel saves an edit still being typed) or in a modal
|
|
72
|
+
* (saved on Save, which is refused while a field shows an error: the modal
|
|
73
|
+
* stays open and the first invalid field gets the focus). A request that arrives while a modal is open is
|
|
74
|
+
* ignored: the open modal is the one being answered. It answers a function that
|
|
75
|
+
* unsubscribes; it also closes the slide-out, but leaves an open modal alone —
|
|
76
|
+
* that modal stays working (its Save still saves) until the user dismisses it.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import {
|
|
80
|
+
FieldTypes, validateField, mergeWithDefaults, getFieldOptions,
|
|
81
|
+
} from './config_schema.js';
|
|
82
|
+
import { SlideOutPanel } from '../ui/components/slide_out_panel.js';
|
|
83
|
+
import { openModal } from '../ui/components/modal.js';
|
|
84
|
+
|
|
85
|
+
let _seq = 0;
|
|
86
|
+
// What focusFirstInvalid may focus inside a renderer's element.
|
|
87
|
+
const FOCUSABLE = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), '
|
|
88
|
+
+ 'button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
|
89
|
+
|
|
90
|
+
function el(tag, className, text) {
|
|
91
|
+
const n = document.createElement(tag);
|
|
92
|
+
if (className) n.className = className;
|
|
93
|
+
if (text != null) n.textContent = String(text);
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isVisible(field, values) {
|
|
98
|
+
const rule = field.visibleWhen;
|
|
99
|
+
if (!rule) return true;
|
|
100
|
+
const v = values[rule.field];
|
|
101
|
+
if (Array.isArray(rule.in)) return rule.in.includes(v);
|
|
102
|
+
if ('equals' in rule) return v === rule.equals;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Built-in controls: each answers `{element, read(), write(value)}`. */
|
|
107
|
+
const BUILT_IN = {
|
|
108
|
+
[FieldTypes.TEXT]: (field, id) => {
|
|
109
|
+
const input = el('input', 'fd-tile-config__input');
|
|
110
|
+
input.type = 'text';
|
|
111
|
+
input.id = id;
|
|
112
|
+
return {
|
|
113
|
+
element: input, events: ['input'],
|
|
114
|
+
read: () => input.value,
|
|
115
|
+
write: (v) => { input.value = v == null ? '' : String(v); },
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
[FieldTypes.NUMBER]: (field, id) => numeric(field, id, 'number'),
|
|
119
|
+
[FieldTypes.RANGE]: (field, id) => numeric(field, id, 'range'),
|
|
120
|
+
[FieldTypes.CHECKBOX]: (field, id) => {
|
|
121
|
+
const input = el('input', 'fd-tile-config__checkbox');
|
|
122
|
+
input.type = 'checkbox';
|
|
123
|
+
input.id = id;
|
|
124
|
+
return {
|
|
125
|
+
element: input, events: ['change'],
|
|
126
|
+
read: () => input.checked,
|
|
127
|
+
write: (v) => { input.checked = !!v; },
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
[FieldTypes.COLOR]: (field, id) => {
|
|
131
|
+
const input = el('input', 'fd-tile-config__color');
|
|
132
|
+
input.type = 'color';
|
|
133
|
+
input.id = id;
|
|
134
|
+
return {
|
|
135
|
+
element: input, events: ['input'],
|
|
136
|
+
read: () => input.value,
|
|
137
|
+
write: (v) => { if (v) input.value = String(v); },
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
[FieldTypes.SELECT]: (field, id, data) => choice(field, id, data, false),
|
|
141
|
+
[FieldTypes.MULTISELECT]: (field, id, data) => choice(field, id, data, true),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function numeric(field, id, kind) {
|
|
145
|
+
const input = el('input', `fd-tile-config__${kind === 'range' ? 'range' : 'input'}`);
|
|
146
|
+
input.type = kind;
|
|
147
|
+
input.id = id;
|
|
148
|
+
if (field.min != null) input.min = String(field.min);
|
|
149
|
+
if (field.max != null) input.max = String(field.max);
|
|
150
|
+
if (field.step != null) input.step = String(field.step);
|
|
151
|
+
const out = kind === 'range' ? el('output', 'fd-tile-config__range-value') : null;
|
|
152
|
+
const element = out ? el('span', 'fd-tile-config__range-wrap') : input;
|
|
153
|
+
if (out) element.append(input, out);
|
|
154
|
+
return {
|
|
155
|
+
element, events: ['input'],
|
|
156
|
+
// The raw text goes to `validateField`; the form turns it into a number.
|
|
157
|
+
read: () => input.value,
|
|
158
|
+
write: (v) => {
|
|
159
|
+
input.value = v == null ? '' : String(v);
|
|
160
|
+
if (out) out.textContent = input.value;
|
|
161
|
+
},
|
|
162
|
+
shown: (v) => { if (out) out.textContent = v == null ? '' : String(v); },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function choice(field, id, data, multiple) {
|
|
167
|
+
const select = el('select', 'fd-tile-config__select');
|
|
168
|
+
select.id = id;
|
|
169
|
+
select.multiple = multiple;
|
|
170
|
+
// Option values keep their type (a number stays a number) — they are read
|
|
171
|
+
// back from this array by index, never from the element's string value.
|
|
172
|
+
const options = getFieldOptions(field, data);
|
|
173
|
+
options.forEach((o) => select.append(el('option', null, o.label ?? o.value)));
|
|
174
|
+
return {
|
|
175
|
+
element: select, events: ['change'],
|
|
176
|
+
read: () => (multiple
|
|
177
|
+
? options.filter((_, i) => select.options[i].selected).map((o) => o.value)
|
|
178
|
+
: (options[select.selectedIndex]?.value ?? null)),
|
|
179
|
+
write: (v) => {
|
|
180
|
+
if (multiple) {
|
|
181
|
+
const want = Array.isArray(v) ? v : [];
|
|
182
|
+
options.forEach((o, i) => { select.options[i].selected = want.includes(o.value); });
|
|
183
|
+
} else {
|
|
184
|
+
select.selectedIndex = options.findIndex((o) => o.value === v);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Number and range read as text; an empty one is null. */
|
|
191
|
+
function coerce(field, raw) {
|
|
192
|
+
if (field.type === FieldTypes.NUMBER || field.type === FieldTypes.RANGE) {
|
|
193
|
+
return raw === '' || raw == null ? null : Number(raw);
|
|
194
|
+
}
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Draw `schema.fields` into `host`.
|
|
200
|
+
* @param {'input'|'change'} [opts.commitOn='input'] When a text, number, range
|
|
201
|
+
* or colour control is checked and committed: on every keystroke, or once the
|
|
202
|
+
* edit is finished.
|
|
203
|
+
* @returns {{getValues(): Object, setValues(config: Object): void,
|
|
204
|
+
* isValid(): boolean, focusFirstInvalid(): boolean, flush(): void,
|
|
205
|
+
* dispose(): void}}
|
|
206
|
+
*/
|
|
207
|
+
export function mountTileConfigForm(host, {
|
|
208
|
+
schema, config = {}, data = null, onChange = null, renderers = {}, commitOn = 'input',
|
|
209
|
+
} = {}) {
|
|
210
|
+
if (!host) throw new TypeError('mountTileConfigForm: a host element is required');
|
|
211
|
+
if (commitOn !== 'input' && commitOn !== 'change') {
|
|
212
|
+
throw new TypeError(`mountTileConfigForm: unknown commitOn "${commitOn}"`);
|
|
213
|
+
}
|
|
214
|
+
const fields = Array.isArray(schema?.fields) ? schema.fields : [];
|
|
215
|
+
fields.forEach((f, i) => {
|
|
216
|
+
if (typeof f?.key !== 'string' || !f.key) {
|
|
217
|
+
const called = typeof f?.label === 'string' && f.label ? ` "${f.label}"` : '';
|
|
218
|
+
throw new TypeError(`mountTileConfigForm: field #${i + 1}${called} has no key`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
let values = mergeWithDefaults(config || {}, schema);
|
|
223
|
+
const unlisten = []; // listeners removed by dispose
|
|
224
|
+
const root = el('div', 'fd-tile-config');
|
|
225
|
+
const rows = []; // {field, named, row, error, control, invalid, pending}
|
|
226
|
+
|
|
227
|
+
const showError = (r, message) => {
|
|
228
|
+
r.invalid = !!message;
|
|
229
|
+
r.error.textContent = message || '';
|
|
230
|
+
r.error.hidden = !message;
|
|
231
|
+
r.row.classList.toggle('fd-tile-config__row--invalid', !!message);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const applyVisibility = () => {
|
|
235
|
+
for (const r of rows) r.row.hidden = !isVisible(r.field, values);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const commit = (r, raw) => {
|
|
239
|
+
if (r.field.readonly) return { valid: false, error: `${r.named.label} is read-only` };
|
|
240
|
+
const result = validateField(raw, r.named);
|
|
241
|
+
if (!result.valid) {
|
|
242
|
+
showError(r, result.error);
|
|
243
|
+
return { valid: false, error: result.error };
|
|
244
|
+
}
|
|
245
|
+
showError(r, null);
|
|
246
|
+
const value = coerce(r.field, raw);
|
|
247
|
+
values = { ...values, [r.field.key]: value };
|
|
248
|
+
applyVisibility();
|
|
249
|
+
onChange?.(r.field.key, value, { ...values });
|
|
250
|
+
return { valid: true };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
for (const field of fields) {
|
|
254
|
+
const id = `fd-tile-config-${++_seq}`;
|
|
255
|
+
const row = el('div', `fd-tile-config__row fd-tile-config__row--${field.type}`);
|
|
256
|
+
row.setAttribute('data-field', field.key);
|
|
257
|
+
// What validateField and the messages read: the label, or the key.
|
|
258
|
+
const named = { ...field, label: field.label ?? field.key };
|
|
259
|
+
const label = el('label', 'fd-tile-config__label', named.label);
|
|
260
|
+
const error = el('div', 'fd-tile-config__error');
|
|
261
|
+
error.hidden = true;
|
|
262
|
+
const r = { field, named, row, error, control: null, invalid: false, pending: null };
|
|
263
|
+
|
|
264
|
+
const custom = renderers?.[field.type];
|
|
265
|
+
if (typeof custom === 'function') {
|
|
266
|
+
const made = custom({
|
|
267
|
+
field, key: field.key, value: values[field.key], data,
|
|
268
|
+
readonly: !!field.readonly, commit: (v) => commit(r, v),
|
|
269
|
+
});
|
|
270
|
+
const element = made instanceof HTMLElement ? made : made?.element;
|
|
271
|
+
if (!(element instanceof HTMLElement)) {
|
|
272
|
+
throw new TypeError(`mountTileConfigForm: the renderer for "${field.type}" returned no element`);
|
|
273
|
+
}
|
|
274
|
+
element.id ||= id;
|
|
275
|
+
// The id the control carries: a renderer may have set its own.
|
|
276
|
+
label.htmlFor = element.id;
|
|
277
|
+
r.control = {
|
|
278
|
+
element,
|
|
279
|
+
write: made instanceof HTMLElement ? () => {} : (v) => made.setValue?.(v),
|
|
280
|
+
dispose: made instanceof HTMLElement ? null : () => made.dispose?.(),
|
|
281
|
+
};
|
|
282
|
+
} else {
|
|
283
|
+
const build = BUILT_IN[field.type];
|
|
284
|
+
if (!build) {
|
|
285
|
+
throw new TypeError(`mountTileConfigForm: no control for type "${field.type}" (field "${field.key}"); pass a renderer`);
|
|
286
|
+
}
|
|
287
|
+
const c = build(field, id, data);
|
|
288
|
+
label.htmlFor = id;
|
|
289
|
+
c.write(values[field.key]);
|
|
290
|
+
const input = c.element.matches('input, select') ? c.element : c.element.querySelector('input, select');
|
|
291
|
+
if (field.readonly) {
|
|
292
|
+
input.disabled = true;
|
|
293
|
+
} else {
|
|
294
|
+
const listen = (type, fn) => {
|
|
295
|
+
input.addEventListener(type, fn);
|
|
296
|
+
unlisten.push(() => input.removeEventListener(type, fn));
|
|
297
|
+
};
|
|
298
|
+
const onEdit = () => {
|
|
299
|
+
r.pending = null;
|
|
300
|
+
const raw = c.read();
|
|
301
|
+
c.shown?.(raw);
|
|
302
|
+
commit(r, raw);
|
|
303
|
+
};
|
|
304
|
+
if (commitOn === 'change') {
|
|
305
|
+
// Checked once the edit is finished; until then it is
|
|
306
|
+
// pending, so flush() can still commit it.
|
|
307
|
+
listen('change', onEdit);
|
|
308
|
+
if (c.events.includes('input')) {
|
|
309
|
+
listen('input', () => { r.pending = onEdit; c.shown?.(c.read()); });
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
for (const type of c.events) listen(type, onEdit);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
r.control = c;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (field.type === FieldTypes.CHECKBOX) row.append(r.control.element, label);
|
|
319
|
+
else row.append(label, r.control.element);
|
|
320
|
+
if (field.description) row.append(el('div', 'fd-tile-config__help', field.description));
|
|
321
|
+
row.append(error);
|
|
322
|
+
rows.push(r);
|
|
323
|
+
root.append(row);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
applyVisibility();
|
|
327
|
+
host.append(root);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
getValues: () => ({ ...values }),
|
|
331
|
+
setValues(next) {
|
|
332
|
+
values = mergeWithDefaults(next || {}, schema);
|
|
333
|
+
for (const r of rows) {
|
|
334
|
+
r.pending = null;
|
|
335
|
+
r.control.write(values[r.field.key]);
|
|
336
|
+
showError(r, null);
|
|
337
|
+
}
|
|
338
|
+
applyVisibility();
|
|
339
|
+
},
|
|
340
|
+
isValid: () => rows.every((r) => r.row.hidden || !r.invalid),
|
|
341
|
+
focusFirstInvalid() {
|
|
342
|
+
const r = rows.find((x) => !x.row.hidden && x.invalid);
|
|
343
|
+
if (!r) return false;
|
|
344
|
+
const { element } = r.control;
|
|
345
|
+
const target = element.matches(FOCUSABLE) ? element : element.querySelector(FOCUSABLE);
|
|
346
|
+
(target || element).focus();
|
|
347
|
+
return true;
|
|
348
|
+
},
|
|
349
|
+
flush() {
|
|
350
|
+
for (const r of rows) r.pending?.();
|
|
351
|
+
},
|
|
352
|
+
dispose() {
|
|
353
|
+
unlisten.splice(0).forEach((off) => off());
|
|
354
|
+
for (const r of rows) {
|
|
355
|
+
try { r.control.dispose?.(); } catch (err) { console.warn('[tile-config] a renderer dispose threw', err); }
|
|
356
|
+
}
|
|
357
|
+
rows.length = 0;
|
|
358
|
+
root.remove();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Show `grid:config-modal-show` requests as a config form.
|
|
365
|
+
*
|
|
366
|
+
* @param {{on(name: string, handler: Function): {dispose(): void}}} eventBus
|
|
367
|
+
* The `EventBus` the `TileGrid` emits on.
|
|
368
|
+
* @param {object} [opts]
|
|
369
|
+
* @param {'modal'|'slide-out'} [opts.mode='modal'] A modal saves on Save, and
|
|
370
|
+
* refuses Save while a field shows an error; a slide-out saves every
|
|
371
|
+
* finished, valid edit (`commitOn: 'change'`).
|
|
372
|
+
* @param {HTMLElement} [opts.host] The slide-out's host (required for it).
|
|
373
|
+
* @param {number} [opts.width] The slide-out's or the modal's width.
|
|
374
|
+
* @param {Object} [opts.renderers] Passed to `mountTileConfigForm`.
|
|
375
|
+
* @param {string|Function} [opts.title='Tile settings'] Or `(detail) => string`.
|
|
376
|
+
* @returns {() => void} Unsubscribes and closes the slide-out. An open modal is
|
|
377
|
+
* left to the user: it keeps its form, and its Save still calls `onSave`.
|
|
378
|
+
*/
|
|
379
|
+
export function openTileConfig(eventBus, { mode = 'modal', host = null, width, renderers = {}, title = 'Tile settings' } = {}) {
|
|
380
|
+
if (typeof eventBus?.on !== 'function') throw new TypeError('openTileConfig: an event bus with on() is required');
|
|
381
|
+
if (mode !== 'modal' && mode !== 'slide-out') throw new TypeError(`openTileConfig: unknown mode "${mode}"`);
|
|
382
|
+
if (mode === 'slide-out' && !(host instanceof HTMLElement)) {
|
|
383
|
+
throw new TypeError('openTileConfig: a slide-out needs a host element');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let form = null; // the slide-out's form
|
|
387
|
+
let panel = null;
|
|
388
|
+
let modalOpen = false;
|
|
389
|
+
// An edit still being typed when the panel goes is saved, not lost.
|
|
390
|
+
const drop = () => { form?.flush(); form?.dispose(); form = null; };
|
|
391
|
+
|
|
392
|
+
const show = (detail = {}) => {
|
|
393
|
+
if (modalOpen) return;
|
|
394
|
+
const heading = typeof title === 'function' ? title(detail) : title;
|
|
395
|
+
const base = { schema: detail.schema, config: detail.config, data: detail.data ?? null, renderers };
|
|
396
|
+
|
|
397
|
+
if (mode === 'slide-out') {
|
|
398
|
+
drop();
|
|
399
|
+
if (!panel) {
|
|
400
|
+
panel = new SlideOutPanel({ ...(width ? { width } : {}), onClose: drop });
|
|
401
|
+
panel.mount(host);
|
|
402
|
+
}
|
|
403
|
+
const body = panel.open(heading, 'tune');
|
|
404
|
+
form = mountTileConfigForm(body, {
|
|
405
|
+
...base, commitOn: 'change', onChange: (_k, _v, cfg) => detail.onSave?.(cfg),
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The modal's form lives until the user dismisses the modal, whatever
|
|
411
|
+
// else happens meanwhile: requests are ignored while it is open, and
|
|
412
|
+
// the returned function leaves it alone.
|
|
413
|
+
const content = el('div', 'fd-tile-config-host');
|
|
414
|
+
const mine = mountTileConfigForm(content, base);
|
|
415
|
+
modalOpen = true;
|
|
416
|
+
openModal({
|
|
417
|
+
title: heading, icon: 'tune', content, ...(width ? { width } : {}),
|
|
418
|
+
actions: [
|
|
419
|
+
{ label: 'Cancel', value: null },
|
|
420
|
+
// Refused while a field shows an error: its last valid value is
|
|
421
|
+
// not what the user is looking at, so saving it would be a lie.
|
|
422
|
+
{ label: 'Save', value: 'save', primary: true, keepOpen: true, onClick: (close) => {
|
|
423
|
+
if (mine.isValid()) { close('save'); return; }
|
|
424
|
+
mine.focusFirstInvalid();
|
|
425
|
+
} },
|
|
426
|
+
],
|
|
427
|
+
}).then((v) => {
|
|
428
|
+
modalOpen = false;
|
|
429
|
+
if (v === 'save') detail.onSave?.(mine.getValues());
|
|
430
|
+
mine.dispose();
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const sub = eventBus.on('grid:config-modal-show', show);
|
|
435
|
+
|
|
436
|
+
return () => {
|
|
437
|
+
sub.dispose();
|
|
438
|
+
drop();
|
|
439
|
+
panel?.dispose();
|
|
440
|
+
panel = null;
|
|
441
|
+
};
|
|
442
|
+
}
|
package/src/tiles/tile_grid.js
CHANGED
|
@@ -848,6 +848,8 @@ export class TileGrid {
|
|
|
848
848
|
tileType,
|
|
849
849
|
config,
|
|
850
850
|
schema,
|
|
851
|
+
// What `getFieldOptions` reads for a field with dynamic options.
|
|
852
|
+
data: this.data,
|
|
851
853
|
variables: this.data ? this._getVariableList() : [],
|
|
852
854
|
onSave: (newConfig) => {
|
|
853
855
|
const tile = this.tiles.get(tileId);
|
|
File without changes
|
|
@@ -41,12 +41,15 @@
|
|
|
41
41
|
* the panel itself is open is the embedder's (a tile it toggles), so the bus
|
|
42
42
|
* event is the embedder's to hear too.
|
|
43
43
|
*
|
|
44
|
-
* The strip is
|
|
45
|
-
*
|
|
44
|
+
* The strip is `createTabs` (`../ui/components/tabs.js`, STORY-0048) under the
|
|
45
|
+
* `twm-bp` block: this file adds only what a bottom panel has and a strip does
|
|
46
|
+
* not — the pane's own store, the remembered tab, `persist`, and its event.
|
|
46
47
|
*/
|
|
47
48
|
|
|
49
|
+
import { createTabs, TAB_LEVELS } from '../ui/components/tabs.js';
|
|
50
|
+
|
|
48
51
|
/** Badge levels, worst last. Any other value draws the neutral badge. */
|
|
49
|
-
export const BADGE_LEVELS =
|
|
52
|
+
export const BADGE_LEVELS = TAB_LEVELS;
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* @param {HTMLElement} container
|
|
@@ -61,108 +64,44 @@ export const BADGE_LEVELS = Object.freeze(['info', 'warning', 'alert']);
|
|
|
61
64
|
export function createBottomPanelTabs(container, {
|
|
62
65
|
panes = [], remember = null, store = null, eventBus = null, onSelect = null, initial = null,
|
|
63
66
|
} = {}) {
|
|
64
|
-
const doc = container.ownerDocument;
|
|
65
|
-
const root = doc.createElement('div');
|
|
66
|
-
root.className = 'twm-bp';
|
|
67
|
-
const strip = doc.createElement('div');
|
|
68
|
-
strip.className = 'twm-bp__tabs';
|
|
69
|
-
strip.setAttribute('role', 'tablist');
|
|
70
|
-
const body = doc.createElement('div');
|
|
71
|
-
body.className = 'twm-bp__body';
|
|
72
|
-
root.append(strip, body);
|
|
73
|
-
container.appendChild(root);
|
|
74
|
-
|
|
75
67
|
const byId = new Map(panes.map((pane) => [pane.id, pane]));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let
|
|
68
|
+
// A tab put in front by THIS FILE (the remembered one) is not a choice: it
|
|
69
|
+
// is neither written back nor allowed to beat a choice made meanwhile.
|
|
70
|
+
let restoring = false;
|
|
79
71
|
let chosen = false;
|
|
80
72
|
let destroyed = false;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
tab.className = 'twm-bp__tab';
|
|
86
|
-
tab.dataset.pane = pane.id;
|
|
87
|
-
tab.setAttribute('role', 'tab');
|
|
88
|
-
tab.setAttribute('aria-selected', 'false');
|
|
89
|
-
const label = doc.createElement('span');
|
|
90
|
-
label.className = 'twm-bp__label';
|
|
91
|
-
label.textContent = pane.label;
|
|
92
|
-
const badge = doc.createElement('span');
|
|
93
|
-
badge.className = 'twm-bp__badge';
|
|
94
|
-
badge.hidden = true;
|
|
95
|
-
tab.append(label, badge);
|
|
96
|
-
tab.addEventListener('click', () => api.select(pane.id));
|
|
97
|
-
strip.appendChild(tab);
|
|
98
|
-
tabs.set(pane.id, tab);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function mount(id) {
|
|
102
|
-
if (mounted.has(id)) return mounted.get(id);
|
|
103
|
-
const pane = byId.get(id);
|
|
104
|
-
const el = doc.createElement('div');
|
|
105
|
-
el.className = 'twm-bp-pane';
|
|
106
|
-
el.dataset.pane = id;
|
|
107
|
-
el.setAttribute('role', 'tabpanel');
|
|
108
|
-
el.hidden = id !== active;
|
|
109
|
-
body.appendChild(el);
|
|
110
|
-
const entry = { el, instance: null };
|
|
111
|
-
mounted.set(id, entry);
|
|
112
|
-
// ONE PANE THAT THROWS IS ONE EMPTY TAB, not a dead panel: the strip
|
|
113
|
-
// and every other pane go on working.
|
|
114
|
-
try {
|
|
115
|
-
entry.instance = pane.mount?.(el, store ? store(pane) : null, api) ?? null;
|
|
116
|
-
} catch (err) {
|
|
117
|
-
console.error(`[bottom-panel] pane "${id}" failed to mount`, err);
|
|
118
|
-
}
|
|
119
|
-
return entry;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function show(id) {
|
|
123
|
-
if (destroyed || !byId.has(id)) return false;
|
|
124
|
-
active = id;
|
|
125
|
-
for (const [key, tab] of tabs) {
|
|
126
|
-
const on = key === id;
|
|
127
|
-
tab.classList.toggle('twm-bp__tab--on', on);
|
|
128
|
-
tab.setAttribute('aria-selected', String(on));
|
|
129
|
-
}
|
|
130
|
-
for (const [key, entry] of mounted) entry.el.hidden = key !== id;
|
|
131
|
-
mount(id).instance?.onShow?.();
|
|
132
|
-
onSelect?.(id, byId.get(id));
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
73
|
+
// Eager panes and the first one shown mount inside `createTabs`, before it
|
|
74
|
+
// has answered; `mountPane` hands the strip (and `api.root`) over then, so
|
|
75
|
+
// a pane sees the whole api inside its `mount`.
|
|
76
|
+
let strip = null;
|
|
135
77
|
|
|
136
78
|
const api = {
|
|
137
|
-
root,
|
|
79
|
+
root: null,
|
|
138
80
|
/** Bring a tab to the front, and remember it. */
|
|
139
81
|
select(id) {
|
|
140
|
-
if (!
|
|
82
|
+
if (destroyed || !byId.has(id)) return;
|
|
141
83
|
chosen = true;
|
|
142
|
-
|
|
84
|
+
if (strip.active() !== id) { strip.select(id); return; }
|
|
85
|
+
// Already in front: asked for again (a click on it, a toast's
|
|
86
|
+
// "+N more"), so the pane is told again and the choice is
|
|
87
|
+
// remembered, as any select.
|
|
88
|
+
strip.instance(id)?.onShow?.();
|
|
89
|
+
remembered(id);
|
|
143
90
|
},
|
|
144
91
|
/** The tab in front, or null. */
|
|
145
|
-
active: () => active,
|
|
92
|
+
active: () => strip.active(),
|
|
146
93
|
/** `count` on a tab, coloured by `level`; nothing at zero. */
|
|
147
|
-
setBadge(id, count, level = null)
|
|
148
|
-
const badge = tabs.get(id)?.querySelector('.twm-bp__badge');
|
|
149
|
-
if (!badge) return;
|
|
150
|
-
const n = Number(count) || 0;
|
|
151
|
-
badge.hidden = n <= 0;
|
|
152
|
-
badge.textContent = n > 99 ? '99+' : String(Math.max(n, 0));
|
|
153
|
-
badge.className = 'twm-bp__badge'
|
|
154
|
-
+ (BADGE_LEVELS.includes(level) ? ` twm-bp__badge--${level}` : '');
|
|
155
|
-
},
|
|
94
|
+
setBadge: (id, count, level = null) => strip.setBadge(id, count, level),
|
|
156
95
|
/** A mounted pane's live instance, or null — how one tab hands work to
|
|
157
96
|
* another. `select` it first, which mounts it. */
|
|
158
|
-
instance: (id) =>
|
|
97
|
+
instance: (id) => strip.instance(id),
|
|
159
98
|
/** The panel is on screen again (its tile re-mounted): tell the pane in front. */
|
|
160
99
|
reshow() {
|
|
161
|
-
|
|
100
|
+
strip.instance(strip.active())?.onShow?.();
|
|
162
101
|
},
|
|
163
102
|
/** Before the panel is torn down: every mounted pane saves. */
|
|
164
103
|
async persist() {
|
|
165
|
-
for (const
|
|
104
|
+
for (const instance of strip.instances()) await instance.persist?.();
|
|
166
105
|
},
|
|
167
106
|
/** Gone for good: panes save, then are destroyed; the bus is let go. */
|
|
168
107
|
async destroy() {
|
|
@@ -170,29 +109,58 @@ export function createBottomPanelTabs(container, {
|
|
|
170
109
|
await api.persist();
|
|
171
110
|
destroyed = true;
|
|
172
111
|
try { subscription?.dispose?.(); } catch { /* bus gone */ }
|
|
173
|
-
|
|
174
|
-
try { entry.instance?.destroy?.(); } catch (err) {
|
|
175
|
-
console.warn('[bottom-panel] pane destroy failed', err);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
mounted.clear();
|
|
179
|
-
root.remove();
|
|
112
|
+
strip.destroy();
|
|
180
113
|
},
|
|
181
114
|
};
|
|
182
115
|
|
|
183
|
-
|
|
116
|
+
function remembered(id) {
|
|
117
|
+
onSelect?.(id, byId.get(id));
|
|
118
|
+
try { remember?.write?.(id); } catch { /* storage refused: not ours to fix */ }
|
|
119
|
+
}
|
|
184
120
|
|
|
121
|
+
strip = createTabs(container, {
|
|
122
|
+
block: 'twm-bp',
|
|
123
|
+
dataKey: 'pane',
|
|
124
|
+
items: panes.map(({ id, label, eager }) => ({ id, label, eager: !!eager })),
|
|
125
|
+
active: byId.has(initial) ? initial : panes[0]?.id,
|
|
126
|
+
mountPane: (item, el, tabs) => {
|
|
127
|
+
strip ??= tabs;
|
|
128
|
+
api.root ??= tabs.root;
|
|
129
|
+
const pane = byId.get(item.id);
|
|
130
|
+
return pane.mount?.(el, store ? store(pane) : null, api) ?? null;
|
|
131
|
+
},
|
|
132
|
+
onSelect: (id) => {
|
|
133
|
+
if (restoring) { onSelect?.(id, byId.get(id)); return; }
|
|
134
|
+
chosen = true;
|
|
135
|
+
remembered(id);
|
|
136
|
+
},
|
|
137
|
+
// A click on the tab already in front is a select here, as it always
|
|
138
|
+
// was: the pane hears onShow again and the choice is remembered.
|
|
139
|
+
onReselect: (id) => api.select(id),
|
|
140
|
+
});
|
|
141
|
+
api.root ??= strip.root;
|
|
142
|
+
if (strip.active() != null) onSelect?.(strip.active(), byId.get(strip.active()));
|
|
143
|
+
|
|
144
|
+
// The bus is the bottom panel's, not the strip's: a `bottompanel:show` for
|
|
145
|
+
// the tab already in front still tells its pane, which a strip does not.
|
|
185
146
|
const subscription = eventBus?.on?.('bottompanel:show',
|
|
186
147
|
(payload) => api.select(payload?.tab ?? panes[0]?.id)) ?? null;
|
|
187
148
|
|
|
188
|
-
show(byId.has(initial) ? initial : panes[0]?.id);
|
|
189
|
-
|
|
190
149
|
if (remember?.read) {
|
|
191
150
|
Promise.resolve()
|
|
192
151
|
.then(() => remember.read())
|
|
193
152
|
.then((saved) => {
|
|
194
|
-
if (chosen || destroyed) return;
|
|
195
|
-
if (
|
|
153
|
+
if (chosen || destroyed || !byId.has(saved)) return;
|
|
154
|
+
if (strip.active() === saved) {
|
|
155
|
+
// Already in front: shown again, as it always was — the
|
|
156
|
+
// pane hears onShow and the caller onSelect, nothing is
|
|
157
|
+
// written back.
|
|
158
|
+
strip.instance(saved)?.onShow?.();
|
|
159
|
+
onSelect?.(saved, byId.get(saved));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
restoring = true;
|
|
163
|
+
try { strip.select(saved); } finally { restoring = false; }
|
|
196
164
|
})
|
|
197
165
|
.catch(() => { /* nothing remembered */ });
|
|
198
166
|
}
|