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
|
@@ -9,20 +9,56 @@
|
|
|
9
9
|
* / a boolean (confirm).
|
|
10
10
|
*
|
|
11
11
|
* Field schema:
|
|
12
|
-
* { name, label, type='text', default, step?, options?, required?,
|
|
13
|
-
* placeholder?, hint?, validator?, rows?, create?, pick?, displayFor
|
|
12
|
+
* { name, label, type='text', default, step?, min?, max?, options?, required?,
|
|
13
|
+
* placeholder?, hint?, validator?, rows?, create?, pick?, displayFor?,
|
|
14
|
+
* onText?, offText?, disabled?, compact? }
|
|
14
15
|
*
|
|
15
16
|
* - type: 'text' | 'number' | 'password' | 'select' | 'textarea' |
|
|
16
|
-
* 'checkbox' | 'picker'
|
|
17
|
+
* 'checkbox' | 'toggle' | 'picker' | 'checklist' | 'radio' |
|
|
18
|
+
* 'color' | 'colorList'
|
|
17
19
|
* - hint: small dim help line rendered under the field
|
|
18
20
|
* - validator: fn(value, allValues) => string | null (inline error)
|
|
21
|
+
* - min / max: a `number` field's bounds — the input's own `min` / `max`
|
|
22
|
+
* attributes, and on submit a value outside them is refused with a field
|
|
23
|
+
* error under that field ("Must be between 2 and 200."), not the browser's
|
|
24
|
+
* bubble. An empty, non-required field is not out of range.
|
|
19
25
|
* - rows: number of textarea rows (only used for type='textarea')
|
|
26
|
+
* - toggle: a `ToggleField` switch, submitted as a boolean like a checkbox;
|
|
27
|
+
* `onText` / `offText` label its state, `disabled` and `compact` pass
|
|
28
|
+
* through to it
|
|
29
|
+
*
|
|
30
|
+
* Options — `select`, `checklist` and `radio` take `options`: bare values, or
|
|
31
|
+
* `{ value, label?, disabled?, reason?, description? }`. A value comes back AS
|
|
32
|
+
* ITSELF — an option whose value is the number 2 submits 2, not '2' — and a
|
|
33
|
+
* default matches an option of the same text (`'2'` selects 2). A `disabled`
|
|
34
|
+
* option is shown with its `reason` and is never the submitted value: it is not
|
|
35
|
+
* selected by a default or by `api.set`, the reader cannot pick it, and a
|
|
36
|
+
* submit that finds one selected anyway is refused with a field error.
|
|
37
|
+
* `{ group: 'Heading', options: [...] }` in a select's list is an `<optgroup>`;
|
|
38
|
+
* in a checklist or radio it is a `.twm-choice__group` heading over its
|
|
39
|
+
* options. A combobox (a `select` with `create`) offers a group's enabled
|
|
40
|
+
* options, flattened, and an entry naming a disabled one — a default or an
|
|
41
|
+
* `api.set` — leaves the box empty.
|
|
42
|
+
*
|
|
43
|
+
* - `checklist`: toggle chips; submits the ARRAY of chosen values in the
|
|
44
|
+
* list's order (`[]` for none). `required` means at least one.
|
|
45
|
+
* - `radio`: one radio row per option, each with its `description` under it;
|
|
46
|
+
* submits the chosen value, or null. `required` means one is chosen.
|
|
47
|
+
*
|
|
48
|
+
* These three are drawn by `choice_controls.js` — the same select, chips and
|
|
49
|
+
* radio list as the flow kit's settings widgets, so there is one of each.
|
|
20
50
|
*
|
|
21
51
|
* Section dividers — entries of the form `{ section: 'Label' }` are
|
|
22
52
|
* rendered as a small uppercase heading that groups the fields below
|
|
23
53
|
* them. Useful for longer forms. A section that also carries a `name`
|
|
24
54
|
* can be hidden and shown by `onFieldChange`'s `setVisible`.
|
|
25
55
|
*
|
|
56
|
+
* Rows — an entry of the form `{ row: [field, field, ...] }` puts its fields
|
|
57
|
+
* side by side (a min and a max, say). Each is an ordinary field: its own
|
|
58
|
+
* label, hint, error, `setVisible`, value and submitted key — the row is
|
|
59
|
+
* layout only and has no name. The row stacks its fields when the form is
|
|
60
|
+
* narrower than 360px (a container query on `.twm-modal__form`).
|
|
61
|
+
*
|
|
26
62
|
* Picker — a value the user LOOKS UP rather than types (an id chosen in a
|
|
27
63
|
* search dialog, say). The row shows a read-only box and a search button;
|
|
28
64
|
* the button (or a click on the box) awaits `pick(currentValue)`, which
|
|
@@ -56,9 +92,25 @@
|
|
|
56
92
|
* create, so there is no "will create" hint either. A bare `true` used to
|
|
57
93
|
* reach `f.create.onCreate(v)` and fail the submit with "onCreate is not a
|
|
58
94
|
* function", blaming the user for typing their own name.
|
|
95
|
+
*
|
|
96
|
+
* Colours — `type: 'color'` is a `ColorField` (swatch + native picker, a hex
|
|
97
|
+
* box kept in step, `presets?` as swatch buttons) and submits lowercase
|
|
98
|
+
* `#rrggbb`, or `''` when left empty. `type: 'colorList'` is a
|
|
99
|
+
* `ColorListField` (add, remove, reorder; `presets?` on every row) and submits
|
|
100
|
+
* an ordered array of `#rrggbb`. An `rgb()` default is shown as its hex. A box
|
|
101
|
+
* that holds no hex colour is a field error and the form does not resolve;
|
|
102
|
+
* `required` on either is checked by the form, not the browser:
|
|
103
|
+
*
|
|
104
|
+
* { name: 'accent', label: 'Accent', type: 'color', default: 'rgb(31,119,180)',
|
|
105
|
+
* presets: ['#1f77b4', '#ff7f0e'] }
|
|
106
|
+
* { name: 'palette', label: 'Palette', type: 'colorList', default: ['#1f77b4'] }
|
|
59
107
|
*/
|
|
60
108
|
|
|
61
109
|
import { ManagedWindow } from '../../ui/components/managed_window.js';
|
|
110
|
+
import { ToggleField } from '../../ui/components/toggle_field.js';
|
|
111
|
+
import { ColorField } from './color_field.js';
|
|
112
|
+
import { ColorListField } from './color_list_field.js';
|
|
113
|
+
import { choiceList, findChoice, flatChoices, selectBox, toggleChips } from './choice_controls.js';
|
|
62
114
|
|
|
63
115
|
/**
|
|
64
116
|
* C25. WHERE A DIALOG OPENS, when the application spans more than one window.
|
|
@@ -90,18 +142,32 @@ let _modalSeq = 1;
|
|
|
90
142
|
* @param {Function?} o.onFieldChange `(name, value, api)` — called after any
|
|
91
143
|
* field's `change`, and ONCE on open with `name === null` so the form starts
|
|
92
144
|
* consistent with its defaults. `api` is
|
|
93
|
-
* `{ setVisible(name, on), get(name), set(name, value)
|
|
94
|
-
* and named sections by name. `get` reads the
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
145
|
+
* `{ setVisible(name, on), get(name), set(name, value), setOptions(name,
|
|
146
|
+
* options) }`, addressing fields and named sections by name. `get` reads the
|
|
147
|
+
* control as it stands (a checkbox or toggle as a boolean, a select or radio as its
|
|
148
|
+
* option's own value — a select with no option selected as '', a radio
|
|
149
|
+
* with none as null — a combobox as the value of the option its text names,
|
|
150
|
+
* exactly as it submits, or as its text when it names none — a checklist as
|
|
151
|
+
* the array of chosen values, a colorList as the array of its rows' text,
|
|
152
|
+
* everything else — a colour too — as its string). An option
|
|
153
|
+
* whose value IS the given one wins over one that only has its text, here,
|
|
154
|
+
* in `set` and in defaults. `set` writes it (a value a select or radio does not
|
|
155
|
+
* offer, or offers only disabled, clears it; so does a disabled option's
|
|
156
|
+
* value in a combobox) and does NOT call
|
|
157
|
+
* `onFieldChange` back, so a hook that fills one field from another cannot
|
|
158
|
+
* loop. `setOptions` replaces the options of a `select` (a combobox's too),
|
|
159
|
+
* `checklist` or `radio` while the form is open, keeping the current value
|
|
160
|
+
* when the new list still offers it enabled — otherwise a select falls to its
|
|
161
|
+
* first enabled option, a radio to none, and a checklist drops what is gone.
|
|
162
|
+
* It does not call `onFieldChange` back either.
|
|
98
163
|
*
|
|
99
164
|
* It exists for the form whose Type select spans two record kinds and must
|
|
100
165
|
* show the fields of the kind actually chosen. Rebuilding the form on every
|
|
101
166
|
* change would lose what the user had already typed, so every field is
|
|
102
167
|
* built once and the irrelevant rows are hidden. A hidden row keeps its
|
|
103
168
|
* value and is still submitted — the caller drops what its chosen kind does
|
|
104
|
-
* not use
|
|
169
|
+
* not use; a disabled option it holds submits as no value ('' for a select
|
|
170
|
+
* or combobox, null for a radio) — but it is not ASKED about: its `required` and its `validator`
|
|
105
171
|
* stand down until it is shown again, because a constraint on a field the
|
|
106
172
|
* user cannot see is a submit button that silently does nothing.
|
|
107
173
|
*
|
|
@@ -117,7 +183,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
117
183
|
body.innerHTML = `
|
|
118
184
|
<form class="twm-modal__form">
|
|
119
185
|
<div class="twm-modal__fields">
|
|
120
|
-
${fields.map((f) => _renderEntry(f, defaults
|
|
186
|
+
${fields.map((f) => _renderEntry(f, defaults)).join('')}
|
|
121
187
|
</div>
|
|
122
188
|
<div class="twm-modal__error" data-role="form-error" hidden></div>
|
|
123
189
|
<div class="twm-modal__actions">
|
|
@@ -140,9 +206,11 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
140
206
|
canDrag: true,
|
|
141
207
|
defaultWidth: _estimateWidth(fields),
|
|
142
208
|
defaultHeight: _estimateHeight(fields),
|
|
143
|
-
onClose: () => {
|
|
209
|
+
onClose: () => {
|
|
210
|
+
disposeControls();
|
|
211
|
+
if (!_resolved) { _resolved = true; resolve(null); }
|
|
212
|
+
},
|
|
144
213
|
});
|
|
145
|
-
win.show();
|
|
146
214
|
|
|
147
215
|
let _resolved = false;
|
|
148
216
|
const close = (result) => {
|
|
@@ -150,6 +218,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
150
218
|
_resolved = true;
|
|
151
219
|
resolve(result);
|
|
152
220
|
try { win.close({ force: true }); } catch {}
|
|
221
|
+
disposeControls();
|
|
153
222
|
};
|
|
154
223
|
|
|
155
224
|
const errEl = body.querySelector('[data-role="form-error"]');
|
|
@@ -160,9 +229,16 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
160
229
|
};
|
|
161
230
|
body.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(null));
|
|
162
231
|
|
|
163
|
-
const formFields = fields
|
|
232
|
+
const formFields = _formFields(fields);
|
|
233
|
+
// The options each field offers NOW — `setOptions` replaces them, the
|
|
234
|
+
// caller's field object is never written to.
|
|
235
|
+
const optionsOf = new Map(formFields.map((f) => [f.name, f.options]));
|
|
236
|
+
const comboHints = new Map();
|
|
164
237
|
for (const f of formFields) {
|
|
165
|
-
if (f.type === 'select' && f.create)
|
|
238
|
+
if (f.type === 'select' && f.create) {
|
|
239
|
+
const update = _wireComboboxHint(body, f, () => optionsOf.get(f.name));
|
|
240
|
+
if (update) comboHints.set(f.name, update);
|
|
241
|
+
}
|
|
166
242
|
if (f.type === 'picker') _wirePicker(body, f);
|
|
167
243
|
}
|
|
168
244
|
const rowHidden = (name) => !!body.querySelector(`[data-field-row="${name}"]`)?.hidden;
|
|
@@ -179,11 +255,51 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
179
255
|
if (row) row.classList.add('twm-is-invalid');
|
|
180
256
|
if (errEl) { errEl.textContent = msg; errEl.hidden = false; }
|
|
181
257
|
};
|
|
258
|
+
// Fields that are components, not markup — toggles, selects,
|
|
259
|
+
// checklists, radios and colours — are mounted into the slot their
|
|
260
|
+
// row left (`_mountControl`) and read back through it: a toggle as a
|
|
261
|
+
// boolean, a choice as its option's own value, a colour as its hex. `onChoice` is what a checklist or
|
|
262
|
+
// radio calls when the reader changes it (a toggle or select is a
|
|
263
|
+
// named control and is heard by the loops below).
|
|
264
|
+
let onChoice = (name) => clearFieldError(name);
|
|
265
|
+
const controls = new Map();
|
|
266
|
+
for (const f of formFields) {
|
|
267
|
+
const c = _mountControl(body, f, defaults[f.name] ?? f.default, (name) => onChoice(name));
|
|
268
|
+
if (c) controls.set(f.name, c);
|
|
269
|
+
}
|
|
270
|
+
function disposeControls() {
|
|
271
|
+
for (const c of controls.values()) c.dispose?.();
|
|
272
|
+
controls.clear();
|
|
273
|
+
}
|
|
274
|
+
win.show();
|
|
275
|
+
/** The option a combobox entry names — the one whose value IS it, else the
|
|
276
|
+
* one whose value has its text — or undefined. `get` and the submit both
|
|
277
|
+
* read a combobox through this, so they agree (`'2'` is option `2`). */
|
|
278
|
+
const comboOption = (f, text) => findChoice(flatChoices(optionsOf.get(f.name)), text, true);
|
|
279
|
+
/** A disabled option's reason ('' with none) when one is the value, else null. */
|
|
280
|
+
const unavailable = (f, value) => {
|
|
281
|
+
const c = controls.get(f.name);
|
|
282
|
+
if (c) return c.unavailable();
|
|
283
|
+
if (f.type !== 'select' || !f.create || value == null || value === '') return null;
|
|
284
|
+
const o = comboOption(f, value);
|
|
285
|
+
return o?.disabled ? (o.reason || '') : null;
|
|
286
|
+
};
|
|
287
|
+
|
|
182
288
|
// Clear per-field errors on input so the user sees the row recover.
|
|
183
289
|
for (const f of formFields) {
|
|
184
290
|
const el = body.querySelector(`[name="${f.name}"]`);
|
|
185
291
|
el?.addEventListener('input', () => clearFieldError(f.name));
|
|
186
292
|
el?.addEventListener('change', () => clearFieldError(f.name));
|
|
293
|
+
// The browser refuses a number outside its min / max before the
|
|
294
|
+
// submit handler runs; its bubble is replaced by the field error
|
|
295
|
+
// the submit would have shown.
|
|
296
|
+
if (f.type === 'number' && _bounded(f)) {
|
|
297
|
+
el?.addEventListener('invalid', (e) => {
|
|
298
|
+
if (!el.validity.rangeUnderflow && !el.validity.rangeOverflow) return;
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
setFieldError(f.name, _rangeMessage(f));
|
|
301
|
+
});
|
|
302
|
+
}
|
|
187
303
|
}
|
|
188
304
|
|
|
189
305
|
// Adaptive fields — see `onFieldChange` above.
|
|
@@ -200,29 +316,60 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
200
316
|
row.style.display = on ? '' : 'none';
|
|
201
317
|
clearFieldError(name);
|
|
202
318
|
// A required control nobody can see blocks the submit with
|
|
203
|
-
// no message the user can find
|
|
204
|
-
//
|
|
319
|
+
// no message the user can find, and so does a number out
|
|
320
|
+
// of its min or max. Remembered on the control, so showing
|
|
321
|
+
// the row gives back exactly what it had.
|
|
205
322
|
for (const el of row.querySelectorAll('input, select, textarea')) {
|
|
206
323
|
if (!on && el.required) { el.required = false; el.dataset.twmRequired = '1'; }
|
|
207
324
|
else if (on && el.dataset.twmRequired) { el.required = true; delete el.dataset.twmRequired; }
|
|
325
|
+
for (const [attr, key] of [['min', 'twmMin'], ['max', 'twmMax']]) {
|
|
326
|
+
if (!on && el.hasAttribute(attr)) { el.dataset[key] = el.getAttribute(attr); el.removeAttribute(attr); }
|
|
327
|
+
else if (on && el.dataset[key] != null) { el.setAttribute(attr, el.dataset[key]); delete el.dataset[key]; }
|
|
328
|
+
}
|
|
208
329
|
}
|
|
209
330
|
},
|
|
210
331
|
get(name) {
|
|
332
|
+
if (controls.has(name)) return controls.get(name).read();
|
|
211
333
|
const el = body.querySelector(`[name="${name}"]`);
|
|
212
334
|
if (!el) return undefined;
|
|
213
|
-
|
|
335
|
+
if (el.type === 'checkbox') return !!el.checked;
|
|
336
|
+
const f = fieldOf(name);
|
|
337
|
+
if (f?.type === 'select' && f.create) {
|
|
338
|
+
const match = comboOption(f, el.value);
|
|
339
|
+
return match ? match.value : el.value;
|
|
340
|
+
}
|
|
341
|
+
return el.value;
|
|
214
342
|
},
|
|
215
343
|
set(name, value) {
|
|
344
|
+
if (controls.has(name)) { controls.get(name).write(value); return; }
|
|
216
345
|
const el = body.querySelector(`[name="${name}"]`);
|
|
217
346
|
if (!el) return;
|
|
218
347
|
if (el.type === 'checkbox') { el.checked = !!value; return; }
|
|
219
|
-
el.value = value == null ? '' : String(value);
|
|
220
348
|
const f = fieldOf(name);
|
|
349
|
+
// A combobox does not take a disabled option's text either.
|
|
350
|
+
const shut = f?.type === 'select' && f.create && comboOption(f, value)?.disabled;
|
|
351
|
+
el.value = value == null || shut ? '' : String(value);
|
|
352
|
+
comboHints.get(name)?.();
|
|
221
353
|
if (f?.type === 'picker') {
|
|
222
354
|
const display = body.querySelector(`[data-picker-display="${name}"]`);
|
|
223
355
|
if (display) display.value = _pickerLabel(f, el.value);
|
|
224
356
|
}
|
|
225
357
|
},
|
|
358
|
+
setOptions(name, options) {
|
|
359
|
+
const f = fieldOf(name);
|
|
360
|
+
if (!f) return;
|
|
361
|
+
const c = controls.get(name);
|
|
362
|
+
if (c?.setOptions) {
|
|
363
|
+
optionsOf.set(name, options);
|
|
364
|
+
c.setOptions(options);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (f.type !== 'select' || !f.create) return;
|
|
368
|
+
optionsOf.set(name, options);
|
|
369
|
+
const list = body.querySelector(`[data-datalist-for="${name}"]`);
|
|
370
|
+
if (list) list.innerHTML = _datalistOptions(options);
|
|
371
|
+
comboHints.get(name)?.();
|
|
372
|
+
},
|
|
226
373
|
};
|
|
227
374
|
const fire = (name) => {
|
|
228
375
|
try { onFieldChange(name, name == null ? undefined : api.get(name), api); }
|
|
@@ -232,6 +379,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
232
379
|
const el = body.querySelector(`[name="${f.name}"]`);
|
|
233
380
|
el?.addEventListener('change', () => fire(f.name));
|
|
234
381
|
}
|
|
382
|
+
onChoice = (name) => { clearFieldError(name); fire(name); };
|
|
235
383
|
// Once on open, so a preselected value is reflected immediately
|
|
236
384
|
// rather than only after the user touches something.
|
|
237
385
|
fire(null);
|
|
@@ -243,10 +391,16 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
243
391
|
showError('');
|
|
244
392
|
const data = {};
|
|
245
393
|
for (const f of formFields) {
|
|
246
|
-
const
|
|
247
|
-
|
|
394
|
+
const control = controls.get(f.name);
|
|
395
|
+
const el = control ? null : body.querySelector(`[name="${f.name}"]`);
|
|
396
|
+
if (!control && !el) continue;
|
|
248
397
|
let v;
|
|
249
|
-
if (
|
|
398
|
+
if (control) {
|
|
399
|
+
// A colour reads as its text but submits as its hex.
|
|
400
|
+
v = control.submit ? control.submit() : control.read();
|
|
401
|
+
// An option with no value submits '', as an empty select does.
|
|
402
|
+
if (v === undefined) v = f.type === 'select' ? '' : null;
|
|
403
|
+
} else if (f.type === 'checkbox') {
|
|
250
404
|
v = !!el.checked;
|
|
251
405
|
} else if (f.type === 'number') {
|
|
252
406
|
v = (el.value === '' ? null : Number(el.value));
|
|
@@ -255,8 +409,11 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
255
409
|
}
|
|
256
410
|
if (f.type === 'select' && f.create) {
|
|
257
411
|
const spec = _createSpec(f);
|
|
258
|
-
|
|
259
|
-
|
|
412
|
+
// A typed value that is an option's text IS that option,
|
|
413
|
+
// with the option's own value (a number stays a number).
|
|
414
|
+
const match = comboOption(f, v);
|
|
415
|
+
if (match) v = match.value;
|
|
416
|
+
else if (v) {
|
|
260
417
|
if (submitBtn) submitBtn.disabled = true;
|
|
261
418
|
let created;
|
|
262
419
|
let failMsg = null;
|
|
@@ -280,14 +437,25 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
280
437
|
let firstBadName = null;
|
|
281
438
|
for (const f of formFields) {
|
|
282
439
|
clearFieldError(f.name);
|
|
283
|
-
if (rowHidden(f.name))
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
440
|
+
if (rowHidden(f.name)) {
|
|
441
|
+
// Not asked about — but a disabled option is still never
|
|
442
|
+
// the value: a hidden row holding one submits no value.
|
|
443
|
+
if (unavailable(f, data[f.name]) != null) data[f.name] = f.type === 'radio' ? null : '';
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const v = data[f.name];
|
|
447
|
+
// A picker's value lives in a hidden input, and a checklist or
|
|
448
|
+
// radio is no single control, so the browser's own `required`
|
|
449
|
+
// check never sees them — they are checked here.
|
|
450
|
+
let msg = f.required && _missing(f, v) ? 'Required.' : null;
|
|
451
|
+
if (!msg && _outOfRange(f, v)) msg = _rangeMessage(f);
|
|
452
|
+
if (!msg) {
|
|
453
|
+
const reason = unavailable(f, v);
|
|
454
|
+
if (reason != null) msg = reason ? `Not available: ${reason}` : 'Not available.';
|
|
455
|
+
}
|
|
456
|
+
if (!msg) msg = controls.get(f.name)?.error?.() ?? null;
|
|
457
|
+
if (!msg && typeof f.validator === 'function') {
|
|
458
|
+
try { msg = f.validator(v, data); } catch (err) { msg = err?.message || String(err); }
|
|
291
459
|
}
|
|
292
460
|
if (msg) {
|
|
293
461
|
setFieldError(f.name, msg);
|
|
@@ -295,6 +463,7 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
295
463
|
}
|
|
296
464
|
}
|
|
297
465
|
if (firstBadName) {
|
|
466
|
+
controls.get(firstBadName)?.focus();
|
|
298
467
|
body.querySelector(`[data-picker="${firstBadName}"]`)?.focus();
|
|
299
468
|
body.querySelector(`[name="${firstBadName}"]:not([type="hidden"])`)?.focus();
|
|
300
469
|
return;
|
|
@@ -315,6 +484,15 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
315
484
|
}
|
|
316
485
|
|
|
317
486
|
|
|
487
|
+
/**
|
|
488
|
+
* `openConfirm` — a yes/no modal. Resolves `true` on confirm, `false` on
|
|
489
|
+
* cancel, Esc, X or backdrop.
|
|
490
|
+
*
|
|
491
|
+
* `message`, `confirmLabel` and `cancelLabel` are TEXT, never markup: callers
|
|
492
|
+
* put user-named things in them ("Delete flow "<name>"?"), so they are set
|
|
493
|
+
* with `textContent` and a `<` in a name shows as a `<`. A `\n` in `message`
|
|
494
|
+
* breaks the line (`white-space: pre-line` on `.twm-modal__body--text`).
|
|
495
|
+
*/
|
|
318
496
|
export function openConfirm({
|
|
319
497
|
title, message, confirmLabel = 'OK',
|
|
320
498
|
cancelLabel = 'Cancel',
|
|
@@ -323,14 +501,25 @@ export function openConfirm({
|
|
|
323
501
|
return new Promise((resolve) => {
|
|
324
502
|
const body = document.createElement('div');
|
|
325
503
|
body.className = 'twm-modal__body-host';
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
504
|
+
|
|
505
|
+
const messageEl = document.createElement('div');
|
|
506
|
+
messageEl.className = 'twm-modal__body twm-modal__body--text';
|
|
507
|
+
messageEl.textContent = message == null ? '' : String(message);
|
|
508
|
+
|
|
509
|
+
const actionsEl = document.createElement('div');
|
|
510
|
+
actionsEl.className = 'twm-modal__actions';
|
|
511
|
+
const button = (action, className, label) => {
|
|
512
|
+
const btn = document.createElement('button');
|
|
513
|
+
btn.type = 'button';
|
|
514
|
+
btn.className = className;
|
|
515
|
+
btn.dataset.action = action;
|
|
516
|
+
btn.textContent = label == null ? '' : String(label);
|
|
517
|
+
actionsEl.appendChild(btn);
|
|
518
|
+
};
|
|
519
|
+
button('cancel', 'twm-btn', cancelLabel);
|
|
520
|
+
button('confirm', danger ? 'twm-btn twm-btn--danger' : 'twm-btn twm-btn--primary', confirmLabel);
|
|
521
|
+
|
|
522
|
+
body.append(messageEl, actionsEl);
|
|
334
523
|
|
|
335
524
|
let _resolved = false;
|
|
336
525
|
const win = new ManagedWindow({
|
|
@@ -693,17 +882,27 @@ export function openModal({
|
|
|
693
882
|
function _estimateHeight(fields) {
|
|
694
883
|
// Header (~36) + actions row (~46) + section dividers (~32 each)
|
|
695
884
|
// + per-field rows (~46 text, ~140 textarea, ~32 checkbox)
|
|
696
|
-
// + body padding. Clamped to 200..80vh.
|
|
885
|
+
// + body padding. Clamped to 200..80vh. A `{ row }` is as tall as its
|
|
886
|
+
// tallest field.
|
|
887
|
+
const height = (f) => {
|
|
888
|
+
if (f.section) return 32;
|
|
889
|
+
const hint = f.hint ? 16 : 0;
|
|
890
|
+
if (f.type === 'textarea') return 32 + (Number(f.rows) || 4) * 18 + hint;
|
|
891
|
+
if (f.type === 'checkbox' || f.type === 'toggle') return 32 + hint;
|
|
892
|
+
if (f.type === 'colorList') {
|
|
893
|
+
const n = Array.isArray(f.default) ? f.default.length : 0;
|
|
894
|
+
return 46 + Math.max(n, 1) * 40 + 40 + hint;
|
|
895
|
+
}
|
|
896
|
+
if (f.type === 'color') return 46 + (Array.isArray(f.presets) && f.presets.length ? 28 : 0) + hint;
|
|
897
|
+
if (f.type === 'radio') {
|
|
898
|
+
const opts = flatChoices(f.options);
|
|
899
|
+
return 12 + opts.reduce((h, o) => h + 22 + (o.description || o.reason ? 16 : 0), 0) + hint;
|
|
900
|
+
}
|
|
901
|
+
return 46 + hint;
|
|
902
|
+
};
|
|
697
903
|
let body = 0;
|
|
698
904
|
for (const f of fields) {
|
|
699
|
-
|
|
700
|
-
else if (f.type === 'textarea') {
|
|
701
|
-
const rows = Number(f.rows) || 4;
|
|
702
|
-
body += 32 + rows * 18;
|
|
703
|
-
if (f.hint) body += 16;
|
|
704
|
-
}
|
|
705
|
-
else if (f.type === 'checkbox') { body += 32; if (f.hint) body += 16; }
|
|
706
|
-
else { body += 46; if (f.hint) body += 16; }
|
|
905
|
+
body += Array.isArray(f?.row) ? Math.max(0, ..._formFields(f.row).map(height)) : height(f);
|
|
707
906
|
}
|
|
708
907
|
const base = 36 + 46 + 28 + body;
|
|
709
908
|
const max = Math.floor(window.innerHeight * 0.8);
|
|
@@ -711,15 +910,158 @@ function _estimateHeight(fields) {
|
|
|
711
910
|
}
|
|
712
911
|
|
|
713
912
|
function _estimateWidth(fields) {
|
|
714
|
-
// Forms with a textarea
|
|
715
|
-
// multi-line content + grouped fields
|
|
716
|
-
// enough for label + single-line input pairs.
|
|
717
|
-
const wide = fields.some((f) => f && (f.type === 'textarea' || f.section));
|
|
913
|
+
// Forms with a textarea, a colour list, a section divider or a row of
|
|
914
|
+
// fields get the wider layout so multi-line content + grouped fields
|
|
915
|
+
// breathe; otherwise 480 px is enough for label + single-line input pairs.
|
|
916
|
+
const wide = fields.some((f) => f && (f.type === 'textarea' || f.type === 'colorList' || f.section || Array.isArray(f.row)));
|
|
718
917
|
return wide ? 640 : 480;
|
|
719
918
|
}
|
|
720
919
|
|
|
721
|
-
|
|
722
|
-
|
|
920
|
+
/** The fields of a form's entries, rows opened up, headings left out. */
|
|
921
|
+
function _formFields(entries) {
|
|
922
|
+
return entries.flatMap((e) => (Array.isArray(e?.row) ? e.row : [e])).filter((f) => f && f.section == null);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Does a field carry a `min` or a `max`? */
|
|
926
|
+
function _bounded(f) {
|
|
927
|
+
return f.min != null || f.max != null;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Is a `number` field's submitted value outside its `min` / `max`? */
|
|
931
|
+
function _outOfRange(f, v) {
|
|
932
|
+
if (f.type !== 'number' || !_bounded(f) || v == null || Number.isNaN(v)) return false;
|
|
933
|
+
return (f.min != null && v < Number(f.min)) || (f.max != null && v > Number(f.max));
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** The field error for a number outside its bounds. */
|
|
937
|
+
function _rangeMessage(f) {
|
|
938
|
+
if (f.min != null && f.max != null) return `Must be between ${f.min} and ${f.max}.`;
|
|
939
|
+
return f.min != null ? `Must be at least ${f.min}.` : `Must be at most ${f.max}.`;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** A combobox's offered options: enabled, groups flattened. */
|
|
943
|
+
function _offered(options) {
|
|
944
|
+
return flatChoices(options).filter((o) => !o.disabled);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function _datalistOptions(options) {
|
|
948
|
+
return _offered(options).map((o) => `<option value="${_esc(o.value)}"></option>`).join('');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Mount a field that is a component rather than markup — a `toggle`, a
|
|
953
|
+
* `select` (not a combobox), `checklist` or `radio` drawn by
|
|
954
|
+
* `choice_controls.js`, or a `color` / `colorList` — into the
|
|
955
|
+
* `data-control-slot` its row left, and hand back what the form needs of it:
|
|
956
|
+
* `{ read(), write(v), focus(), unavailable(), submit?(), error?(),
|
|
957
|
+
* setOptions?(options), dispose?() }`. `unavailable()` is the reason (or '')
|
|
958
|
+
* when the selected option is disabled, else null. `submit()`, where given,
|
|
959
|
+
* is the value submitted when it differs from what `read()` shows (a colour's
|
|
960
|
+
* hex, null while its box holds none), and `error()` is the field's own
|
|
961
|
+
* message for a value it cannot submit, else null. `onChange(name)` hears the
|
|
962
|
+
* reader's choice on a checklist or radio; a toggle or select is a named
|
|
963
|
+
* control, heard as one. Null for any other field.
|
|
964
|
+
*/
|
|
965
|
+
function _mountControl(host, f, value, onChange) {
|
|
966
|
+
const slot = host.querySelector(`[data-control-slot="${f.name}"]`);
|
|
967
|
+
if (!slot) return null;
|
|
968
|
+
if (f.type === 'toggle') {
|
|
969
|
+
// Mounted INTO the slot: the slot is the row's `.twm-modal__toggle`
|
|
970
|
+
// cell, and the switch's own root is the <label>.
|
|
971
|
+
const toggle = new ToggleField({
|
|
972
|
+
name: f.name,
|
|
973
|
+
checked: value === true || value === 'true',
|
|
974
|
+
ariaLabel: f.label || f.name,
|
|
975
|
+
onText: f.onText ?? null,
|
|
976
|
+
offText: f.offText ?? null,
|
|
977
|
+
disabled: !!f.disabled,
|
|
978
|
+
compact: !!f.compact,
|
|
979
|
+
});
|
|
980
|
+
slot.appendChild(toggle.render());
|
|
981
|
+
return {
|
|
982
|
+
read: () => toggle.get(),
|
|
983
|
+
write: (v) => toggle.set(v),
|
|
984
|
+
focus: () => toggle.input?.focus(),
|
|
985
|
+
unavailable: () => null,
|
|
986
|
+
dispose: () => toggle.dispose(),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (f.type === 'color' || f.type === 'colorList') {
|
|
990
|
+
// Read as their text as it stands (a list as its rows' text), submitted
|
|
991
|
+
// as hex: `getValue()` is null while a box holds no hex colour.
|
|
992
|
+
const c = f.type === 'color'
|
|
993
|
+
? new ColorField({ name: f.name, value: value ?? '', presets: f.presets, label: f.label })
|
|
994
|
+
: new ColorListField({ name: f.name, value: value ?? [], presets: f.presets });
|
|
995
|
+
slot.replaceWith(c.render());
|
|
996
|
+
const list = f.type === 'colorList';
|
|
997
|
+
return {
|
|
998
|
+
read: () => (list ? c.getTexts() : c.getText()),
|
|
999
|
+
submit: () => c.getValue(),
|
|
1000
|
+
write: (v) => c.setValue(v),
|
|
1001
|
+
focus: () => c.focus(),
|
|
1002
|
+
unavailable: () => null,
|
|
1003
|
+
error: () => {
|
|
1004
|
+
if (c.getValue() !== null) return null;
|
|
1005
|
+
if (!list) return 'Not a hex colour: use #rgb or #rrggbb.';
|
|
1006
|
+
const bad = c.invalidIndexes().map((i) => i + 1).join(', ');
|
|
1007
|
+
return `Not a hex colour (row ${bad}): use #rgb or #rrggbb.`;
|
|
1008
|
+
},
|
|
1009
|
+
dispose: () => c.dispose(),
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const common = { keepMissing: false, loose: true };
|
|
1013
|
+
const disabledReason = (entry) => (entry?.disabled ? (entry.reason || '') : null);
|
|
1014
|
+
if (f.type === 'select') {
|
|
1015
|
+
const c = selectBox(f.options, value, { ...common, valueAttr: 'value', className: '' });
|
|
1016
|
+
c.box.name = f.name;
|
|
1017
|
+
if (f.required) c.box.required = true;
|
|
1018
|
+
slot.replaceWith(c.box);
|
|
1019
|
+
return {
|
|
1020
|
+
// No option selected reads '', as an empty select always has (and
|
|
1021
|
+
// as it submits); a selected option reads as its own value.
|
|
1022
|
+
read: () => (c.entry() ? c.entry().value : ''),
|
|
1023
|
+
// A value the list does not offer enabled clears the select, as
|
|
1024
|
+
// writing an unknown `.value` always has (it then submits '').
|
|
1025
|
+
write: (v) => { if (!c.select(v)) c.box.selectedIndex = -1; },
|
|
1026
|
+
focus: () => c.box.focus(),
|
|
1027
|
+
unavailable: () => disabledReason(c.entry()),
|
|
1028
|
+
setOptions: c.setOptions,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const list = (v) => (Array.isArray(v) ? v : (v == null || v === '' ? [] : [v]));
|
|
1032
|
+
if (f.type === 'checklist') {
|
|
1033
|
+
const c = toggleChips({ ...common, options: f.options, value: list(value), label: f.label || f.name,
|
|
1034
|
+
onChange: () => onChange(f.name) });
|
|
1035
|
+
slot.replaceWith(c.el);
|
|
1036
|
+
return {
|
|
1037
|
+
read: c.valueOf,
|
|
1038
|
+
write: (v) => c.setValue(list(v)),
|
|
1039
|
+
focus: () => c.el.querySelector('button:not(:disabled)')?.focus(),
|
|
1040
|
+
unavailable: () => null, // a disabled chip cannot be pressed, by hand or by set
|
|
1041
|
+
setOptions: c.setOptions,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
const c = choiceList({ ...common, options: f.options, value, label: f.label || f.name,
|
|
1045
|
+
name: `twm-form-${_modalSeq}-${f.name}`, onChange: () => onChange(f.name) });
|
|
1046
|
+
slot.replaceWith(c.el);
|
|
1047
|
+
return {
|
|
1048
|
+
read: () => c.valueOf() ?? null,
|
|
1049
|
+
write: (v) => { c.select(v); },
|
|
1050
|
+
focus: c.focus,
|
|
1051
|
+
unavailable: () => disabledReason(c.entry()),
|
|
1052
|
+
setOptions: c.setOptions,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Is a `required` field without a value the browser cannot check for itself? */
|
|
1057
|
+
function _missing(f, v) {
|
|
1058
|
+
if (f.type === 'picker') return !v;
|
|
1059
|
+
if (f.type === 'checklist') return !Array.isArray(v) || v.length === 0;
|
|
1060
|
+
if (f.type === 'radio') return v == null;
|
|
1061
|
+
// A colour that is not a hex (null) is its own error, not a missing one.
|
|
1062
|
+
if (f.type === 'color') return v === '';
|
|
1063
|
+
if (f.type === 'colorList') return Array.isArray(v) && v.length === 0;
|
|
1064
|
+
return false;
|
|
723
1065
|
}
|
|
724
1066
|
|
|
725
1067
|
/** Normalise a `create` spec. `true` means the typed value is accepted as-is. */
|
|
@@ -772,7 +1114,7 @@ function _wirePicker(host, f) {
|
|
|
772
1114
|
|
|
773
1115
|
/** Show / hide the "↳ will create …" hint as the user types into a
|
|
774
1116
|
* create-capable combobox. */
|
|
775
|
-
function _wireComboboxHint(host, f) {
|
|
1117
|
+
function _wireComboboxHint(host, f, optionsOf) {
|
|
776
1118
|
const input = host.querySelector(`input[name="${f.name}"]`);
|
|
777
1119
|
const hintEl = host.querySelector(`.twm-combobox__hint[data-for="${f.name}"]`);
|
|
778
1120
|
if (!input || !hintEl) return;
|
|
@@ -780,10 +1122,10 @@ function _wireComboboxHint(host, f) {
|
|
|
780
1122
|
// entity being made, so promising to "create" one is a lie that reads as
|
|
781
1123
|
// a warning.
|
|
782
1124
|
if (f.create === true) return;
|
|
783
|
-
const known = new Set(_optionValues(f.options));
|
|
784
1125
|
const label = _createSpec(f).hint || 'new entry';
|
|
785
1126
|
const update = () => {
|
|
786
1127
|
const v = input.value.trim();
|
|
1128
|
+
const known = new Set(flatChoices(optionsOf()).map((o) => String(o.value)));
|
|
787
1129
|
if (v && !known.has(v)) {
|
|
788
1130
|
hintEl.textContent = `↳ will create ${label} “${v}”`;
|
|
789
1131
|
hintEl.hidden = false;
|
|
@@ -793,33 +1135,43 @@ function _wireComboboxHint(host, f) {
|
|
|
793
1135
|
};
|
|
794
1136
|
input.addEventListener('input', update);
|
|
795
1137
|
update();
|
|
1138
|
+
return update;
|
|
796
1139
|
}
|
|
797
1140
|
|
|
798
1141
|
|
|
799
|
-
function _renderEntry(f,
|
|
1142
|
+
function _renderEntry(f, defaults) {
|
|
800
1143
|
if (f && f.section != null) {
|
|
801
1144
|
// A named section is addressable by `setVisible`, so a heading can be
|
|
802
1145
|
// hidden along with the group it introduces.
|
|
803
1146
|
const attr = f.name ? ` data-field-row="${f.name}"` : '';
|
|
804
1147
|
return `<div class="twm-modal__section"${attr}>${_esc(f.section)}</div>`;
|
|
805
1148
|
}
|
|
806
|
-
|
|
1149
|
+
// A row is layout only: each field in it is rendered exactly as it would
|
|
1150
|
+
// be on its own line, with its own `data-field-row`.
|
|
1151
|
+
if (Array.isArray(f?.row)) {
|
|
1152
|
+
const cells = _formFields(f.row).map((g) => _renderField(g, defaults[g.name] ?? g.default));
|
|
1153
|
+
return `<div class="twm-modal__field-group">${cells.join('')}</div>`;
|
|
1154
|
+
}
|
|
1155
|
+
return _renderField(f, defaults[f.name] ?? f.default);
|
|
807
1156
|
}
|
|
808
1157
|
|
|
809
1158
|
function _renderField(f, value) {
|
|
810
1159
|
const required = f.required ? 'required' : '';
|
|
811
|
-
const rowMod = (f.type === 'textarea'
|
|
1160
|
+
const rowMod = (f.type === 'textarea' || f.type === 'checklist' || f.type === 'radio')
|
|
1161
|
+
? ' twm-modal__row--multiline' : '';
|
|
812
1162
|
const hintHtml = f.hint
|
|
813
1163
|
? `<div class="twm-modal__hint--field">${_esc(f.hint)}</div>` : '';
|
|
814
1164
|
const errHtml = `<div class="twm-modal__field-error" data-field-error="${f.name}" hidden></div>`;
|
|
815
|
-
const wrap = (inner) => `
|
|
816
|
-
|
|
1165
|
+
const wrap = (inner, tag = 'label') => `
|
|
1166
|
+
<${tag} class="twm-modal__row${rowMod}" data-field-row="${f.name}">
|
|
817
1167
|
<span>${_esc(f.label || '')}</span>
|
|
818
1168
|
${inner}
|
|
819
1169
|
${hintHtml}
|
|
820
1170
|
${errHtml}
|
|
821
|
-
|
|
1171
|
+
</${tag}>
|
|
822
1172
|
`;
|
|
1173
|
+
// A component's place, filled once the markup is in (`_mountControl`).
|
|
1174
|
+
const slot = `<span data-control-slot="${f.name}"></span>`;
|
|
823
1175
|
|
|
824
1176
|
if (f.type === 'checkbox') {
|
|
825
1177
|
const checked = (value === true || value === 'true') ? 'checked' : '';
|
|
@@ -836,6 +1188,16 @@ function _renderField(f, value) {
|
|
|
836
1188
|
`;
|
|
837
1189
|
}
|
|
838
1190
|
|
|
1191
|
+
// A group of controls is not one labelable control: a `<label>` round it
|
|
1192
|
+
// would hand every click on the row to its first chip or radio.
|
|
1193
|
+
if (f.type === 'checklist' || f.type === 'radio') return wrap(slot, 'div');
|
|
1194
|
+
|
|
1195
|
+
// A div, not a label: the switch's own root is the <label>, and a label
|
|
1196
|
+
// may not hold another.
|
|
1197
|
+
if (f.type === 'toggle') {
|
|
1198
|
+
return wrap(`<span class="twm-modal__toggle" data-control-slot="${f.name}"></span>`, 'div');
|
|
1199
|
+
}
|
|
1200
|
+
|
|
839
1201
|
if (f.type === 'textarea') {
|
|
840
1202
|
const v = value == null ? '' : String(value);
|
|
841
1203
|
const rows = f.rows != null ? `rows="${Number(f.rows) || 4}"` : 'rows="4"';
|
|
@@ -843,6 +1205,20 @@ function _renderField(f, value) {
|
|
|
843
1205
|
return wrap(`<textarea name="${f.name}" ${rows} ${placeholder} ${required}>${_esc(v)}</textarea>`);
|
|
844
1206
|
}
|
|
845
1207
|
|
|
1208
|
+
if (f.type === 'color' || f.type === 'colorList') {
|
|
1209
|
+
// The component is mounted into the slot by `_mountControl`. A list
|
|
1210
|
+
// is a group of controls, so its row is not a <label>: a label would
|
|
1211
|
+
// hand every click on it to the first row's swatch.
|
|
1212
|
+
if (f.type === 'color') return wrap(slot);
|
|
1213
|
+
return `
|
|
1214
|
+
<div class="twm-modal__row twm-modal__row--multiline" data-field-row="${f.name}" role="group" aria-label="${_esc(f.label || '')}">
|
|
1215
|
+
<span>${_esc(f.label || '')}</span>
|
|
1216
|
+
${slot}
|
|
1217
|
+
${hintHtml}
|
|
1218
|
+
${errHtml}
|
|
1219
|
+
</div>
|
|
1220
|
+
`;
|
|
1221
|
+
}
|
|
846
1222
|
const v = value == null ? '' : String(value);
|
|
847
1223
|
if (f.type === 'picker') {
|
|
848
1224
|
// The display text and the stored value are different things (an id
|
|
@@ -863,33 +1239,31 @@ function _renderField(f, value) {
|
|
|
863
1239
|
`);
|
|
864
1240
|
}
|
|
865
1241
|
if (f.type === 'select' && f.create) {
|
|
1242
|
+
// A default naming a disabled option is not taken, as in a select.
|
|
1243
|
+
const shown = findChoice(flatChoices(f.options), value, true)?.disabled ? '' : v;
|
|
866
1244
|
const dlId = `ea-dl-${f.name}-${_modalSeq}`;
|
|
867
|
-
const opts =
|
|
868
|
-
.map((ov) => `<option value="${_esc(ov)}"></option>`).join('');
|
|
1245
|
+
const opts = _datalistOptions(f.options);
|
|
869
1246
|
const placeholder = f.placeholder
|
|
870
1247
|
? `placeholder="${_esc(f.placeholder)}"` : 'placeholder="type to pick or create…"';
|
|
871
1248
|
return wrap(`
|
|
872
1249
|
<div class="twm-combobox">
|
|
873
1250
|
<input name="${f.name}" type="text" list="${dlId}"
|
|
874
|
-
value="${_esc(
|
|
1251
|
+
value="${_esc(shown)}" ${placeholder} ${required}
|
|
875
1252
|
autocomplete="off">
|
|
876
|
-
<datalist id="${dlId}">${opts}</datalist>
|
|
1253
|
+
<datalist id="${dlId}" data-datalist-for="${f.name}">${opts}</datalist>
|
|
877
1254
|
<span class="twm-combobox__hint" data-for="${f.name}" hidden></span>
|
|
878
1255
|
</div>
|
|
879
1256
|
`);
|
|
880
1257
|
}
|
|
881
|
-
if (f.type === 'select')
|
|
882
|
-
const opts = (f.options || []).map((o) => {
|
|
883
|
-
const ov = (typeof o === 'object') ? o.value : o;
|
|
884
|
-
const ol = (typeof o === 'object') ? o.label : o;
|
|
885
|
-
return `<option value="${_esc(ov)}" ${ov === v ? 'selected' : ''}>${_esc(ol)}</option>`;
|
|
886
|
-
}).join('');
|
|
887
|
-
return wrap(`<select name="${f.name}" ${required}>${opts}</select>`);
|
|
888
|
-
}
|
|
1258
|
+
if (f.type === 'select') return wrap(slot);
|
|
889
1259
|
const step = f.step != null ? `step="${f.step}"` : '';
|
|
890
1260
|
const placeholder = f.placeholder ? `placeholder="${_esc(f.placeholder)}"` : '';
|
|
891
1261
|
const type = (f.type === 'number' || f.type === 'password') ? f.type : 'text';
|
|
892
|
-
|
|
1262
|
+
// Attributes only a bounded number has, so no other input's markup moves.
|
|
1263
|
+
let bounds = '';
|
|
1264
|
+
if (f.type === 'number' && f.min != null) bounds += ` min="${_esc(f.min)}"`;
|
|
1265
|
+
if (f.type === 'number' && f.max != null) bounds += ` max="${_esc(f.max)}"`;
|
|
1266
|
+
return wrap(`<input name="${f.name}" type="${type}" value="${_esc(v)}" ${step}${bounds} ${placeholder} ${required}>`);
|
|
893
1267
|
}
|
|
894
1268
|
|
|
895
1269
|
function _esc(s) {
|