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.
Files changed (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. 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
+ }