flexdesk 0.16.1 → 0.17.1
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 +112 -0
- package/LICENSE +0 -0
- package/README.md +2435 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/canvas.js +18 -0
- package/charts.js +21 -0
- package/core.js +14 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3165 -159
- 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 +707 -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-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
- package/dist/chunk-LWV23YVE.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
- package/dist/chunk-RMTRWWEO.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 +3165 -159
- 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 +3290 -2124
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -347
- package/dist/wm.js.map +4 -4
- package/editor.js +20 -0
- package/flow.js +21 -0
- package/host.js +14 -0
- package/package.json +12 -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 +293 -0
- package/src/ui/components/cell_editors.js +480 -0
- package/src/ui/components/cell_grid.js +761 -0
- package/src/ui/components/cell_paste.js +243 -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 +696 -71
- 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 +361 -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/tsv.js +74 -0
- 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/tiles.js +27 -0
- package/widgets.js +69 -0
- package/wm.js +28 -0
- 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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHOICE CONTROLS — the one select, the one set of toggle chips and the one
|
|
3
|
+
* radio list, shared by `openForm` and the flow kit's settings widgets.
|
|
4
|
+
*
|
|
5
|
+
* Each draws a fixed list of options and reads back the option's OWN value —
|
|
6
|
+
* a number stays a number, an object stays the object — never the text of an
|
|
7
|
+
* `<option>` or a `value` attribute. That is why every control here keeps the
|
|
8
|
+
* values beside the DOM it draws and resolves the reader's choice by position.
|
|
9
|
+
*
|
|
10
|
+
* An option is a bare value, or `{ value, label?, description?, disabled?,
|
|
11
|
+
* reason? }`. Each also takes `{ group, options }` entries: `selectBox` draws
|
|
12
|
+
* one as an `<optgroup>`, the chips and the radio list as a `.twm-choice__group`
|
|
13
|
+
* heading over its options. A group with no options draws no heading there.
|
|
14
|
+
*
|
|
15
|
+
* A DISABLED OPTION IS SHOWN, WITH ITS REASON, AND IS NEVER CHOSEN BY VALUE:
|
|
16
|
+
* a stored value that names one is not selected (the select falls to its first
|
|
17
|
+
* enabled option, the radios to none, the chips leave it unpressed), unless
|
|
18
|
+
* `keepMissing` keeps it as a value the list does not offer. The reader cannot
|
|
19
|
+
* pick one either. What a caller does about a disabled option selected some
|
|
20
|
+
* other way (a script setting `.value`) is the caller's: `selectBox.entry()`
|
|
21
|
+
* and `choiceList.entry()` say which option is selected, disabled or not.
|
|
22
|
+
*
|
|
23
|
+
* `keepMissing` (the kit's rule, on by default): a stored value the list does
|
|
24
|
+
* not offer is KEPT, marked with `notFound(text)`, so a settings panel cannot
|
|
25
|
+
* silently turn it into the first entry on the next save. A form that should
|
|
26
|
+
* offer only what is listed passes `keepMissing: false`.
|
|
27
|
+
*
|
|
28
|
+
* `loose`: a stored value matches an option whose value has the same TEXT
|
|
29
|
+
* (`2` matches `'2'`), for a caller whose stored values may arrive as strings.
|
|
30
|
+
* Off by default: the kit's values keep their type, and `'2'` is not `2`.
|
|
31
|
+
* Text is only the fallback: an option whose value IS the stored value (`===`)
|
|
32
|
+
* wins over an earlier one that only reads the same (`findChoice`).
|
|
33
|
+
*
|
|
34
|
+
* Nothing here names a consumer's vocabulary; every word on screen is the
|
|
35
|
+
* caller's (`label`, `reason`, `description`, `notFound`).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** A value as one line of text shows it: a string as itself, anything else as JSON. */
|
|
39
|
+
export const shownValue = (v) => (v === undefined ? '' : typeof v === 'string' ? v : JSON.stringify(v));
|
|
40
|
+
|
|
41
|
+
function el(tag, className, text) {
|
|
42
|
+
const n = document.createElement(tag);
|
|
43
|
+
if (className) n.className = className;
|
|
44
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isGroup = (o) => o !== null && typeof o === 'object' && Array.isArray(o.options) && 'group' in o;
|
|
49
|
+
|
|
50
|
+
/** One option, normalised: `{value, label, description, disabled, reason}`. */
|
|
51
|
+
function choiceOption(o) {
|
|
52
|
+
if (o === null || typeof o !== 'object') {
|
|
53
|
+
return { value: o, label: shownValue(o), description: null, disabled: false, reason: null };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
value: o.value,
|
|
57
|
+
label: o.label != null ? String(o.label) : shownValue(o.value),
|
|
58
|
+
description: o.description != null ? String(o.description) : null,
|
|
59
|
+
disabled: Boolean(o.disabled),
|
|
60
|
+
reason: o.reason != null && o.reason !== '' ? String(o.reason) : null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Every option of a list, groups flattened in order. */
|
|
65
|
+
export function flatChoices(options) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const o of options || []) {
|
|
68
|
+
if (isGroup(o)) for (const inner of o.options) out.push(choiceOption(inner));
|
|
69
|
+
else out.push(choiceOption(o));
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where the chips and the radio list draw a group heading: the position in
|
|
76
|
+
* the flattened list of each non-empty group's first option, to its heading.
|
|
77
|
+
*/
|
|
78
|
+
function groupHeads(options) {
|
|
79
|
+
const heads = new Map();
|
|
80
|
+
let at = 0;
|
|
81
|
+
for (const o of options || []) {
|
|
82
|
+
if (!isGroup(o)) { at += 1; continue; }
|
|
83
|
+
if (o.options.length) heads.set(at, String(o.group ?? ''));
|
|
84
|
+
at += o.options.length;
|
|
85
|
+
}
|
|
86
|
+
return heads;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const groupHeading = (text) => el('div', 'twm-choice__group', text);
|
|
90
|
+
|
|
91
|
+
/** Does stored `value` name option value `v`? */
|
|
92
|
+
function sameChoice(v, value, loose = false) {
|
|
93
|
+
if (v === value) return true;
|
|
94
|
+
if (!loose || v === undefined || value === undefined || v === null || value === null) return false;
|
|
95
|
+
return String(v) === String(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The position in `list` (normalised options) of the option stored value `v`
|
|
100
|
+
* names, or -1: the first whose value IS `v`, else — with `loose` — the first
|
|
101
|
+
* whose value has the same text. `from` skips the leading entries.
|
|
102
|
+
*/
|
|
103
|
+
function choiceIndex(list, v, loose = false, from = 0) {
|
|
104
|
+
for (let i = from; i < list.length; i += 1) if (list[i].value === v) return i;
|
|
105
|
+
if (!loose) return -1;
|
|
106
|
+
for (let i = from; i < list.length; i += 1) if (sameChoice(list[i].value, v, true)) return i;
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The option of `list` stored value `v` names (`choiceIndex`), or undefined. */
|
|
111
|
+
export function findChoice(list, v, loose = false) {
|
|
112
|
+
const i = choiceIndex(list, v, loose);
|
|
113
|
+
return i >= 0 ? list[i] : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** An option's text: its label, and the reason it is unavailable when it is. */
|
|
117
|
+
const optionText = (o) => (o.disabled && o.reason ? `${o.label} — ${o.reason}` : o.label);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A `<select>` over `options`.
|
|
121
|
+
*
|
|
122
|
+
* `empty` (text) draws a first option that stands for "no value" (`undefined`).
|
|
123
|
+
* `valueAttr`: what each `<option value>` carries — `'index'` (its position;
|
|
124
|
+
* the kit's) or `'value'` (the option's value as text, so a script that sets
|
|
125
|
+
* `.value = 'b'` picks option `'b'`). The value READ BACK is the option's own
|
|
126
|
+
* either way.
|
|
127
|
+
*
|
|
128
|
+
* Returns `{ box, valueOf(), entry(), select(value), setOptions(options, value?) }`:
|
|
129
|
+
* `select` re-selects by value (true when the value is offered and enabled);
|
|
130
|
+
* `setOptions` redraws the list and places `value` (default: the current
|
|
131
|
+
* value) the way the first draw places one, so with `keepMissing` a value the
|
|
132
|
+
* list lacks shows as its own not-found entry.
|
|
133
|
+
*/
|
|
134
|
+
export function selectBox(options, value, {
|
|
135
|
+
empty = null, label = '', notFound = (v) => `${v} (not found)`, keepMissing = true,
|
|
136
|
+
valueAttr = 'index', loose = false, className = 'twm-flow-input twm-flow-input--select',
|
|
137
|
+
} = {}) {
|
|
138
|
+
const box = el('select', className);
|
|
139
|
+
if (label) box.setAttribute('aria-label', label);
|
|
140
|
+
let entries = [];
|
|
141
|
+
|
|
142
|
+
const add = (parent, o) => {
|
|
143
|
+
const attr = valueAttr === 'index' ? String(entries.length) : (o.value === undefined ? '' : String(o.value));
|
|
144
|
+
// `createElement`, not `new Option`: the constructor is a window
|
|
145
|
+
// global, and a page that installed only `document` has none.
|
|
146
|
+
const opt = el('option', '', optionText(o));
|
|
147
|
+
opt.value = attr;
|
|
148
|
+
if (o.disabled) {
|
|
149
|
+
opt.disabled = true;
|
|
150
|
+
if (o.reason) opt.title = o.reason;
|
|
151
|
+
}
|
|
152
|
+
parent.appendChild(opt);
|
|
153
|
+
entries.push(o);
|
|
154
|
+
};
|
|
155
|
+
const firstEnabled = () => {
|
|
156
|
+
const i = entries.findIndex((e) => !e.disabled);
|
|
157
|
+
return i >= 0 ? i : 0;
|
|
158
|
+
};
|
|
159
|
+
const select = (v) => {
|
|
160
|
+
const start = empty !== null ? 1 : 0;
|
|
161
|
+
const at = choiceIndex(entries, v, loose, start);
|
|
162
|
+
if (at < 0 || entries[at].disabled || entries[at].stray) return false;
|
|
163
|
+
box.selectedIndex = at;
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
const place = (v) => {
|
|
167
|
+
if (select(v)) return;
|
|
168
|
+
if (keepMissing && v !== undefined && v !== null && v !== '') {
|
|
169
|
+
// A stored value the list no longer offers is KEPT as its own option:
|
|
170
|
+
// it must not silently become the first entry on the next save.
|
|
171
|
+
add(box, { value: v, label: notFound(shownValue(v)), description: null, disabled: false, reason: null,
|
|
172
|
+
stray: true });
|
|
173
|
+
box.selectedIndex = entries.length - 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
box.selectedIndex = empty !== null ? 0 : firstEnabled();
|
|
177
|
+
};
|
|
178
|
+
const fill = (opts, v) => {
|
|
179
|
+
box.replaceChildren();
|
|
180
|
+
entries = [];
|
|
181
|
+
if (empty !== null) add(box, { value: undefined, label: String(empty), description: null, disabled: false,
|
|
182
|
+
reason: null });
|
|
183
|
+
for (const o of opts || []) {
|
|
184
|
+
if (isGroup(o)) {
|
|
185
|
+
const g = el('optgroup');
|
|
186
|
+
g.label = String(o.group ?? '');
|
|
187
|
+
for (const inner of o.options) add(g, choiceOption(inner));
|
|
188
|
+
box.appendChild(g);
|
|
189
|
+
} else {
|
|
190
|
+
add(box, choiceOption(o));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
place(v);
|
|
194
|
+
};
|
|
195
|
+
const entry = () => (box.selectedIndex < 0 ? null : entries[box.selectedIndex] ?? null);
|
|
196
|
+
const valueOf = () => entry()?.value;
|
|
197
|
+
|
|
198
|
+
fill(options, value);
|
|
199
|
+
return {
|
|
200
|
+
box,
|
|
201
|
+
valueOf,
|
|
202
|
+
entry,
|
|
203
|
+
select,
|
|
204
|
+
setOptions(opts, v = valueOf()) { fill(opts, v); },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Toggle chips over a fixed list: a multi-select. The value is the list of
|
|
210
|
+
* pressed values in the list's order, and `onChange(list | undefined)` hears
|
|
211
|
+
* each toggle (`undefined` when none is pressed). With `keepMissing`, a stored
|
|
212
|
+
* entry the list lacks is kept, marked, until it is toggled off. A disabled
|
|
213
|
+
* option is a chip that cannot be pressed, its reason under its label.
|
|
214
|
+
*
|
|
215
|
+
* Returns `{ el, valueOf(), setValue(list), setOptions(options) }`;
|
|
216
|
+
* `setOptions` keeps the pressed values the new list still offers.
|
|
217
|
+
*/
|
|
218
|
+
export function toggleChips({ options, value, label, readOnly, onChange, notFound = (v) => `${v} (not found)`,
|
|
219
|
+
keepMissing = true, loose = false }) {
|
|
220
|
+
const root = el('div', 'twm-flow-togglechips');
|
|
221
|
+
root.setAttribute('role', 'group');
|
|
222
|
+
if (label) root.setAttribute('aria-label', label);
|
|
223
|
+
let shown = []; // the options as given, groups and all
|
|
224
|
+
let list = [];
|
|
225
|
+
let chosen = new Set();
|
|
226
|
+
let strays = [];
|
|
227
|
+
|
|
228
|
+
const emit = () => {
|
|
229
|
+
const out = valueOf();
|
|
230
|
+
onChange?.(out.length ? out : undefined);
|
|
231
|
+
};
|
|
232
|
+
const chipFor = (text, pressed, toggle, { stray = false, reason = null, disabled = false } = {}) => {
|
|
233
|
+
const b = el('button', `twm-flow-togglechip${stray ? ' twm-flow-togglechip--missing' : ''}`, text);
|
|
234
|
+
b.type = 'button';
|
|
235
|
+
b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
|
|
236
|
+
b.disabled = Boolean(readOnly) || disabled;
|
|
237
|
+
if (reason) {
|
|
238
|
+
b.title = reason;
|
|
239
|
+
b.appendChild(el('span', 'twm-choice__reason', reason));
|
|
240
|
+
}
|
|
241
|
+
b.addEventListener('click', () => {
|
|
242
|
+
const on = toggle();
|
|
243
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
244
|
+
emit();
|
|
245
|
+
});
|
|
246
|
+
return b;
|
|
247
|
+
};
|
|
248
|
+
const draw = (opts, stored) => {
|
|
249
|
+
shown = opts || [];
|
|
250
|
+
list = flatChoices(shown);
|
|
251
|
+
const heads = groupHeads(shown);
|
|
252
|
+
const wanted = Array.isArray(stored) ? stored : [];
|
|
253
|
+
const offered = (v) => {
|
|
254
|
+
const o = findChoice(list, v, loose);
|
|
255
|
+
return o && !o.disabled ? o : undefined;
|
|
256
|
+
};
|
|
257
|
+
chosen = new Set(wanted.map(offered).filter(Boolean).map((o) => o.value));
|
|
258
|
+
strays = keepMissing ? wanted.filter((v) => !offered(v)) : [];
|
|
259
|
+
root.replaceChildren();
|
|
260
|
+
list.forEach((o, i) => {
|
|
261
|
+
if (heads.has(i)) root.appendChild(groupHeading(heads.get(i)));
|
|
262
|
+
root.appendChild(chipFor(o.label, chosen.has(o.value), () => {
|
|
263
|
+
if (o.disabled) return false; // never chosen, even with the button forced
|
|
264
|
+
if (chosen.has(o.value)) chosen.delete(o.value); else chosen.add(o.value);
|
|
265
|
+
return chosen.has(o.value);
|
|
266
|
+
}, { disabled: o.disabled, reason: o.disabled ? o.reason : null }));
|
|
267
|
+
});
|
|
268
|
+
for (const v of [...strays]) {
|
|
269
|
+
root.appendChild(chipFor(notFound(shownValue(v)), true, () => {
|
|
270
|
+
const i = strays.indexOf(v);
|
|
271
|
+
if (i >= 0) { strays.splice(i, 1); return false; }
|
|
272
|
+
strays.push(v);
|
|
273
|
+
return true;
|
|
274
|
+
}, { stray: true }));
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const valueOf = () => [...list.filter((o) => chosen.has(o.value)).map((o) => o.value), ...strays];
|
|
278
|
+
draw(options, value);
|
|
279
|
+
return {
|
|
280
|
+
el: root,
|
|
281
|
+
valueOf,
|
|
282
|
+
setValue: (stored) => draw(shown, stored),
|
|
283
|
+
setOptions: (opts) => draw(opts, valueOf()),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A one-of-N as radio rows, each with its label — and a sentence under it when
|
|
289
|
+
* the option has a `description`, and its `reason` when it is disabled. `name`
|
|
290
|
+
* groups the radios (the caller's, so it is unique where it needs to be).
|
|
291
|
+
*
|
|
292
|
+
* Returns `{ el, focus(), valueOf(), entry(), select(value), setOptions(options) }`.
|
|
293
|
+
* `onChange(value)` hears the reader's choice. With `keepMissing`, a stored
|
|
294
|
+
* value the list lacks is named in a note under the list (and kept by the
|
|
295
|
+
* caller, since no radio holds it).
|
|
296
|
+
*/
|
|
297
|
+
export function choiceList({ options, value, name, label, readOnly, onChange, notFound = (v) => `${v} (not found)`,
|
|
298
|
+
keepMissing = true, loose = false }) {
|
|
299
|
+
const root = el('div', 'twm-flow-choices');
|
|
300
|
+
root.setAttribute('role', 'radiogroup');
|
|
301
|
+
if (label) root.setAttribute('aria-label', label);
|
|
302
|
+
let list = [];
|
|
303
|
+
let radios = [];
|
|
304
|
+
|
|
305
|
+
const draw = (opts, current) => {
|
|
306
|
+
list = flatChoices(opts);
|
|
307
|
+
const heads = groupHeads(opts);
|
|
308
|
+
radios = [];
|
|
309
|
+
root.replaceChildren();
|
|
310
|
+
const named = choiceIndex(list, current, loose);
|
|
311
|
+
const at = named >= 0 && !list[named].disabled ? named : -1;
|
|
312
|
+
list.forEach((o, i) => {
|
|
313
|
+
if (heads.has(i)) root.appendChild(groupHeading(heads.get(i)));
|
|
314
|
+
const row = el('label', `twm-flow-choice${o.disabled ? ' twm-choice--disabled' : ''}`);
|
|
315
|
+
const radio = el('input', 'twm-flow-choice__radio');
|
|
316
|
+
radio.type = 'radio';
|
|
317
|
+
radio.name = name;
|
|
318
|
+
radio.checked = i === at;
|
|
319
|
+
radio.disabled = Boolean(readOnly) || o.disabled;
|
|
320
|
+
radio.addEventListener('change', () => { if (radio.checked) onChange?.(o.value); });
|
|
321
|
+
radios.push(radio);
|
|
322
|
+
const words = el('span', 'twm-flow-choice__words');
|
|
323
|
+
words.appendChild(el('span', 'twm-flow-choice__label', o.label));
|
|
324
|
+
if (o.description !== null) words.appendChild(el('span', 'twm-flow-choice__sub', o.description));
|
|
325
|
+
if (o.disabled && o.reason) words.appendChild(el('span', 'twm-choice__reason', o.reason));
|
|
326
|
+
row.append(radio, words);
|
|
327
|
+
root.appendChild(row);
|
|
328
|
+
});
|
|
329
|
+
if (keepMissing && current !== undefined && named < 0) {
|
|
330
|
+
root.appendChild(el('p', 'twm-flow-field__note', notFound(shownValue(current))));
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const checkedAt = () => radios.findIndex((r) => r.checked);
|
|
334
|
+
const entry = () => list[checkedAt()] ?? null;
|
|
335
|
+
const api = {
|
|
336
|
+
el: root,
|
|
337
|
+
focus: () => (radios.find((r) => r.checked && !r.disabled) || radios.find((r) => !r.disabled))
|
|
338
|
+
?.focus({ preventScroll: true }),
|
|
339
|
+
entry,
|
|
340
|
+
valueOf: () => entry()?.value,
|
|
341
|
+
select(v) {
|
|
342
|
+
const named = choiceIndex(list, v, loose);
|
|
343
|
+
const at = named >= 0 && !list[named].disabled ? named : -1;
|
|
344
|
+
radios.forEach((r, i) => { r.checked = i === at; });
|
|
345
|
+
return at >= 0;
|
|
346
|
+
},
|
|
347
|
+
setOptions(opts) { draw(opts, api.valueOf()); },
|
|
348
|
+
};
|
|
349
|
+
draw(options, value);
|
|
350
|
+
return api;
|
|
351
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ColorField — one colour: a swatch that opens the native picker, a hex text
|
|
3
|
+
* box kept in step with it, and an optional row of preset swatches.
|
|
4
|
+
*
|
|
5
|
+
* const field = new ColorField({
|
|
6
|
+
* value: 'rgb(31, 119, 180)', // shown as #1f77b4
|
|
7
|
+
* presets: ['#1f77b4', '#ff7f0e', '#2ca02c'],
|
|
8
|
+
* onChange: (hex, { commit }) => { ... },
|
|
9
|
+
* });
|
|
10
|
+
* host.appendChild(field.render());
|
|
11
|
+
* field.getValue(); // '#1f77b4' | '' | null
|
|
12
|
+
*
|
|
13
|
+
* THE HEX BOX IS THE CONTROL. Picking in the swatch writes the box and fires
|
|
14
|
+
* the box's own `input` (and, when the picker commits, `change`); a preset
|
|
15
|
+
* does the same. So there is one path from any gesture to the value, and a
|
|
16
|
+
* host listening on the box — `openForm` does, by its `name` — hears every
|
|
17
|
+
* change whatever made it. Typing a valid hex moves the swatch; typing an
|
|
18
|
+
* invalid one leaves the swatch on the last good colour and marks the field
|
|
19
|
+
* `.twm-color-field--invalid`.
|
|
20
|
+
*
|
|
21
|
+
* VALUES. `getValue()` is the colour as lowercase `#rrggbb`, `''` when the box
|
|
22
|
+
* is empty, and `null` when what is typed is not a hex colour (`#rgb` and
|
|
23
|
+
* `#rrggbb` are). An `rgb()` / `rgba()` value handed IN — a default, a preset,
|
|
24
|
+
* `setValue` — is converted to its hex equivalent first; alpha is dropped,
|
|
25
|
+
* because the native picker has no alpha channel.
|
|
26
|
+
*
|
|
27
|
+
* `onChange(value, { commit })` runs on every edit with `commit: false` and on
|
|
28
|
+
* the box's `change` (blur, Enter, a preset, the picker closing) with
|
|
29
|
+
* `commit: true`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
33
|
+
const RGB_RE = /^rgba?\(\s*([^)]*)\)$/i;
|
|
34
|
+
|
|
35
|
+
/** True for `#rgb` and `#rrggbb` (any case), and nothing else. */
|
|
36
|
+
export function isHexColor(value) {
|
|
37
|
+
return typeof value === 'string' && HEX_RE.test(value.trim());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A colour as lowercase `#rrggbb`, or null when `value` is not one.
|
|
42
|
+
* Accepts `#rgb`, `#rrggbb` and `rgb()` / `rgba()` in comma or space syntax,
|
|
43
|
+
* with channels as 0–255 or percentages; an alpha channel is dropped.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeHex(value) {
|
|
46
|
+
if (typeof value !== 'string') return null;
|
|
47
|
+
const s = value.trim();
|
|
48
|
+
if (HEX_RE.test(s)) {
|
|
49
|
+
const h = s.slice(1).toLowerCase();
|
|
50
|
+
return h.length === 3 ? `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}` : `#${h}`;
|
|
51
|
+
}
|
|
52
|
+
const m = RGB_RE.exec(s);
|
|
53
|
+
if (!m) return null;
|
|
54
|
+
const parts = m[1].split(/\s*[,/]\s*|\s+/).filter(Boolean);
|
|
55
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
56
|
+
const channels = [];
|
|
57
|
+
for (const p of parts.slice(0, 3)) {
|
|
58
|
+
const pct = p.endsWith('%');
|
|
59
|
+
const n = Number(pct ? p.slice(0, -1) : p);
|
|
60
|
+
if (!Number.isFinite(n)) return null;
|
|
61
|
+
const v = pct ? (n / 100) * 255 : n;
|
|
62
|
+
if (v < 0 || v > 255) return null;
|
|
63
|
+
channels.push(Math.round(v));
|
|
64
|
+
}
|
|
65
|
+
return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What the box shows for a value handed in: its hex if it is a colour, else as given. */
|
|
69
|
+
function displayText(value) {
|
|
70
|
+
if (value == null) return '';
|
|
71
|
+
return normalizeHex(String(value)) ?? String(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ColorField {
|
|
75
|
+
/**
|
|
76
|
+
* @param {Object} [o]
|
|
77
|
+
* @param {string} [o.value] initial colour (hex or rgb()); '' for none
|
|
78
|
+
* @param {string[]} [o.presets] preset colours drawn as buttons; ones that
|
|
79
|
+
* are not colours are left out
|
|
80
|
+
* @param {string} [o.name] `name` of the hex box, for a host form
|
|
81
|
+
* @param {string} [o.label] accessible name of the swatch and the box
|
|
82
|
+
* @param {Function} [o.onChange] `(value, { commit })`, value as `getValue()`
|
|
83
|
+
*/
|
|
84
|
+
constructor({ value = '', presets = [], name = null, label = null, onChange = null } = {}) {
|
|
85
|
+
this.initial = displayText(value);
|
|
86
|
+
this.presets = (Array.isArray(presets) ? presets : []).map(normalizeHex).filter(Boolean);
|
|
87
|
+
this.name = name;
|
|
88
|
+
this.label = label;
|
|
89
|
+
this.onChange = typeof onChange === 'function' ? onChange : null;
|
|
90
|
+
|
|
91
|
+
this.root = null;
|
|
92
|
+
this.swatch = null;
|
|
93
|
+
this.hex = null;
|
|
94
|
+
this._disposers = [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
render() {
|
|
98
|
+
if (this.root) return this.root;
|
|
99
|
+
const root = document.createElement('div');
|
|
100
|
+
root.className = 'twm-color-field';
|
|
101
|
+
|
|
102
|
+
const swatch = document.createElement('input');
|
|
103
|
+
swatch.type = 'color';
|
|
104
|
+
swatch.className = 'twm-color-field__swatch';
|
|
105
|
+
swatch.value = normalizeHex(this.initial) ?? '#000000';
|
|
106
|
+
if (this.label) swatch.title = this.label;
|
|
107
|
+
|
|
108
|
+
const hex = document.createElement('input');
|
|
109
|
+
hex.type = 'text';
|
|
110
|
+
hex.className = 'twm-color-field__hex';
|
|
111
|
+
hex.value = this.initial;
|
|
112
|
+
hex.placeholder = '#rrggbb';
|
|
113
|
+
hex.spellcheck = false;
|
|
114
|
+
hex.autocomplete = 'off';
|
|
115
|
+
hex.maxLength = 7;
|
|
116
|
+
if (this.name) hex.name = this.name;
|
|
117
|
+
if (this.label) hex.setAttribute('aria-label', this.label);
|
|
118
|
+
|
|
119
|
+
root.append(swatch, hex);
|
|
120
|
+
this.root = root;
|
|
121
|
+
this.swatch = swatch;
|
|
122
|
+
this.hex = hex;
|
|
123
|
+
|
|
124
|
+
if (this.presets.length) {
|
|
125
|
+
const strip = document.createElement('div');
|
|
126
|
+
strip.className = 'twm-color-field__presets';
|
|
127
|
+
for (const color of this.presets) {
|
|
128
|
+
const btn = document.createElement('button');
|
|
129
|
+
btn.type = 'button';
|
|
130
|
+
btn.className = 'twm-color-field__preset';
|
|
131
|
+
btn.style.backgroundColor = color;
|
|
132
|
+
btn.title = color;
|
|
133
|
+
btn.setAttribute('aria-label', color);
|
|
134
|
+
this.#listen(btn, 'click', () => this.#writeBox(color, true));
|
|
135
|
+
strip.appendChild(btn);
|
|
136
|
+
}
|
|
137
|
+
root.appendChild(strip);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Every gesture lands on the box; the box's listeners do the rest.
|
|
141
|
+
this.#listen(swatch, 'input', () => this.#writeBox(swatch.value, false));
|
|
142
|
+
this.#listen(swatch, 'change', () => this.#writeBox(swatch.value, true));
|
|
143
|
+
this.#listen(hex, 'input', () => this.#sync(false));
|
|
144
|
+
this.#listen(hex, 'change', () => {
|
|
145
|
+
const v = this.getValue();
|
|
146
|
+
if (v) hex.value = v; // canonical form once committed
|
|
147
|
+
this.#sync(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this.#sync(null);
|
|
151
|
+
return root;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `#rrggbb`, `''` when empty, or `null` when the box holds no hex colour. */
|
|
155
|
+
getValue() {
|
|
156
|
+
const text = this.getText().trim();
|
|
157
|
+
if (text === '') return '';
|
|
158
|
+
return isHexColor(text) ? normalizeHex(text) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The box as it stands. */
|
|
162
|
+
getText() {
|
|
163
|
+
return this.hex ? this.hex.value : this.initial;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
isValid() {
|
|
167
|
+
return this.getValue() !== null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Write a colour (hex or rgb()) without firing `onChange`. */
|
|
171
|
+
setValue(value) {
|
|
172
|
+
const text = displayText(value);
|
|
173
|
+
if (!this.hex) { this.initial = text; return; }
|
|
174
|
+
this.hex.value = text;
|
|
175
|
+
this.#sync(null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
focus() {
|
|
179
|
+
this.hex?.focus();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
dispose() {
|
|
183
|
+
for (const d of this._disposers) {
|
|
184
|
+
try { d(); } catch { /* listener already gone */ }
|
|
185
|
+
}
|
|
186
|
+
this._disposers = [];
|
|
187
|
+
this.root = null;
|
|
188
|
+
this.swatch = null;
|
|
189
|
+
this.hex = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#listen(el, type, fn) {
|
|
193
|
+
el.addEventListener(type, fn);
|
|
194
|
+
this._disposers.push(() => el.removeEventListener(type, fn));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Put `text` in the box and fire the box's own events, as typing would. */
|
|
198
|
+
#writeBox(text, commit) {
|
|
199
|
+
this.hex.value = text;
|
|
200
|
+
this.hex.dispatchEvent(new Event('input', { bubbles: true }));
|
|
201
|
+
if (commit) this.hex.dispatchEvent(new Event('change', { bubbles: true }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Swatch and invalid mark follow the box; `commit === null` is silent. */
|
|
205
|
+
#sync(commit) {
|
|
206
|
+
const v = this.getValue();
|
|
207
|
+
if (v) this.swatch.value = v;
|
|
208
|
+
this.root.classList.toggle('twm-color-field--invalid', v === null);
|
|
209
|
+
if (commit !== null) this.onChange?.(v, { commit });
|
|
210
|
+
}
|
|
211
|
+
}
|