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.
Files changed (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -20,6 +20,7 @@ export class SlideOutPanel extends ComponentBase {
20
20
  /** @type {number} */ #width;
21
21
  /** @type {Function|null} */#onClose;
22
22
  /** @type {boolean} */ #open = false;
23
+ /** @type {ResizeObserver|null} */ #hostObserver = null;
23
24
 
24
25
  /**
25
26
  * @param {object} opts
@@ -131,6 +132,7 @@ export class SlideOutPanel extends ComponentBase {
131
132
  this.#trackGeometry();
132
133
  window.addEventListener('scroll', this.#trackGeometry, true);
133
134
  window.addEventListener('resize', this.#trackGeometry);
135
+ this.#observeHost();
134
136
  }
135
137
 
136
138
  return this.#body;
@@ -146,6 +148,8 @@ export class SlideOutPanel extends ComponentBase {
146
148
  document.removeEventListener('keydown', this.#handleEsc);
147
149
  window.removeEventListener('scroll', this.#trackGeometry, true);
148
150
  window.removeEventListener('resize', this.#trackGeometry);
151
+ this.#hostObserver?.disconnect();
152
+ this.#hostObserver = null;
149
153
  // Leave the inline position:fixed geometry in place. The panel
150
154
  // slides off-screen via the transform animation (translateX
151
155
  // 100%); the next open() re-runs #trackGeometry which will
@@ -168,18 +172,51 @@ export class SlideOutPanel extends ComponentBase {
168
172
  }
169
173
  };
170
174
 
175
+ /** The host moves or resizes without any window event when a tile
176
+ * splitter is dragged or a dock panel toggles, and the fixed geometry
177
+ * would then stay at the old coordinates. Observe the host AND each
178
+ * ancestor: a host can move without changing size of its own (a dock
179
+ * panel toggling beside a fixed-width tile shrinks the content area,
180
+ * an ancestor, and the tile only shifts). The callback runs
181
+ * synchronously, after layout and before paint, so the panel follows
182
+ * a drag without a frame of lag; it only writes the panel's own
183
+ * `position: fixed` geometry, which is out of flow and resizes
184
+ * nothing observed, so it cannot loop. */
185
+ #observeHost() {
186
+ this.#hostObserver = new ResizeObserver(this.#trackGeometry);
187
+ for (let el = this.#container; el; el = el.parentElement) {
188
+ this.#hostObserver.observe(el);
189
+ }
190
+ }
191
+
171
192
  /** Pin the panel to the right edge of its host's *visible* frame
172
- * using `position: fixed`. Re-run on every scroll/resize so the
173
- * panel always overlays the host correctly even when the host
174
- * itself sits inside a scrolled ancestor. */
193
+ * using `position: fixed`. Re-run on every window scroll/resize and
194
+ * every host resize (#observeHost) so the panel always overlays the
195
+ * host correctly even when the host itself sits inside a scrolled
196
+ * ancestor.
197
+ *
198
+ * UNDER A CSS `zoom` (the shell's content zoom scales a tile body),
199
+ * `getBoundingClientRect()` answers in screen pixels, which already
200
+ * carry the zoom, while the panel's own `top`/`left`/`height` are
201
+ * CSS pixels that the same zoom multiplies again. Copied across
202
+ * unchanged, every length was scaled twice: at 150% the panel landed
203
+ * right of and below its host, at 50% it was half the host's height.
204
+ * So the rect is divided by the panel's effective zoom
205
+ * (`currentCSSZoom`: 1 when nothing above it zooms, `undefined` in an
206
+ * engine that predates it), and the width subtracted is the panel's
207
+ * laid-out one, which `min-width` may hold above the requested
208
+ * width. Rounding happens in screen pixels, so at 100% the output is
209
+ * exactly what it always was. */
175
210
  #trackGeometry = () => {
176
211
  if (!this.#open || !this.#panel || !this.#container) return;
177
212
  const r = this.#container.getBoundingClientRect();
213
+ const zoom = this.#panel.currentCSSZoom || 1;
214
+ const width = this.#panel.offsetWidth || this.#width;
178
215
  const s = this.#panel.style;
179
216
  s.position = 'fixed';
180
- s.top = `${Math.round(r.top)}px`;
181
- s.left = `${Math.round(r.right - this.#width)}px`;
217
+ s.top = `${Math.round(r.top) / zoom}px`;
218
+ s.left = `${Math.round(r.right - width * zoom) / zoom}px`;
182
219
  s.right = 'auto';
183
- s.height = `${Math.round(r.height)}px`;
220
+ s.height = `${Math.round(r.height) / zoom}px`;
184
221
  };
185
222
  }
@@ -1,4 +1,35 @@
1
+ /**
2
+ * A range slider with optional companions: a linked number box, a readout, a
3
+ * default with a modified state and a reset button, and an enable toggle.
4
+ *
5
+ * Every companion is opt-in through `field`; with none of them the DOM is the
6
+ * range and the helper line, exactly as before.
7
+ *
8
+ * field.min / max / step / value the range (defaults 0 / 100 / 1 / min)
9
+ * field.numberInput true: an editable number box, two-way synced
10
+ * field.readout true, or (value: number) => string
11
+ * field.defaultValue enables the modified state and a reset button
12
+ * field.toggleable true: an enable checkbox before the slider
13
+ * field.enabled the toggle's initial state (default true)
14
+ * field.disabledValue what onChange reports while toggled off (default null)
15
+ * field.emitOnInput also report every drag frame ({ immediate: true })
16
+ * field.disabled / helperText as before
17
+ *
18
+ * onChange(value, { immediate }) receives the slider's value as a string — the
19
+ * range input's own value, as it always has — or `disabledValue` while the
20
+ * toggle is off. A commit that leaves the reported value where it was reports
21
+ * nothing, so setValue/setRange/setEnabled cannot echo back into the caller
22
+ * that drove them.
23
+ */
1
24
  export class SliderField {
25
+ #min = 0;
26
+ #max = 100;
27
+ #step = 1;
28
+ #value = 0;
29
+ #enabled = true;
30
+ #hasDefault = false;
31
+ #defaultValue = 0;
32
+
2
33
  constructor({ fieldId, field = {}, onChange = null, logger = null } = {}) {
3
34
  if (!fieldId) {
4
35
  throw new Error('SliderField requires a fieldId');
@@ -10,9 +41,23 @@ export class SliderField {
10
41
 
11
42
  this.root = null;
12
43
  this.sliderInput = null;
44
+ this.numberInput = null;
45
+ this.readoutEl = null;
46
+ this.resetButton = null;
47
+ this.toggleInput = null;
13
48
  this.helperEl = null;
14
49
  this.emitOnInput = Boolean(field.emitOnInput ?? field.emitsOnInput);
15
50
  this._disposers = [];
51
+
52
+ this.#min = this.#coerceNumber(field.min, 0);
53
+ this.#max = Math.max(this.#min, this.#coerceNumber(field.max, 100));
54
+ this.#step = this.#coercePositiveNumber(field.step, 1);
55
+ this.#value = this.#fit(this.#coerceNumber(field.value, this.#min));
56
+ this.#enabled = field.toggleable ? field.enabled !== false : true;
57
+ this.#hasDefault = this.#isFiniteInput(field.defaultValue);
58
+ if (this.#hasDefault) {
59
+ this.#defaultValue = this.#fit(Number(field.defaultValue));
60
+ }
16
61
  }
17
62
 
18
63
  render() {
@@ -25,13 +70,7 @@ export class SliderField {
25
70
  this.sliderInput = document.createElement('input');
26
71
  this.sliderInput.type = 'range';
27
72
  this.sliderInput.className = 'twm-slider-field__input';
28
- this.sliderInput.min = this.#coerceNumber(this.field.min, 0);
29
- this.sliderInput.max = this.#coerceNumber(this.field.max, 100);
30
- this.sliderInput.step = this.#coercePositiveNumber(this.field.step, 1);
31
- this.sliderInput.value = this.#coerceNumber(this.field.value, Number(this.sliderInput.min));
32
- if (this.field.disabled) {
33
- this.sliderInput.disabled = true;
34
- }
73
+ this.#writeRangeAttributes();
35
74
 
36
75
  // Prevent parent interactions from interfering with slider dragging
37
76
  // Use stopImmediatePropagation to ensure no other handlers on this element run
@@ -41,10 +80,8 @@ export class SliderField {
41
80
  evt.stopImmediatePropagation();
42
81
  };
43
82
  // Use capture phase to intercept before any parent handlers
44
- this.sliderInput.addEventListener('mousedown', stopDrag, { capture: true });
45
- this.sliderInput.addEventListener('touchstart', stopDrag, { passive: false, capture: true });
46
- this._disposers.push(() => this.sliderInput.removeEventListener('mousedown', stopDrag, { capture: true }));
47
- this._disposers.push(() => this.sliderInput.removeEventListener('touchstart', stopDrag, { passive: false, capture: true }));
83
+ this.#listen(this.sliderInput, 'mousedown', stopDrag, { capture: true });
84
+ this.#listen(this.sliderInput, 'touchstart', stopDrag, { passive: false, capture: true });
48
85
 
49
86
  this.helperEl = document.createElement('div');
50
87
  this.helperEl.className = 'twm-slider-field__helper';
@@ -52,7 +89,30 @@ export class SliderField {
52
89
  this.helperEl.textContent = this.field.helperText;
53
90
  }
54
91
 
55
- this.root.appendChild(this.sliderInput);
92
+ const companions = this.field.toggleable || this.field.numberInput
93
+ || this.field.readout || this.#hasDefault;
94
+ if (companions) {
95
+ const row = document.createElement('div');
96
+ row.className = 'twm-slider-field__row';
97
+ if (this.field.toggleable) {
98
+ row.appendChild(this.#buildToggle());
99
+ }
100
+ row.appendChild(this.sliderInput);
101
+ if (this.field.readout) {
102
+ this.readoutEl = document.createElement('span');
103
+ this.readoutEl.className = 'twm-slider-field__readout';
104
+ row.appendChild(this.readoutEl);
105
+ }
106
+ if (this.field.numberInput) {
107
+ row.appendChild(this.#buildNumberInput());
108
+ }
109
+ if (this.#hasDefault) {
110
+ row.appendChild(this.#buildResetButton());
111
+ }
112
+ this.root.appendChild(row);
113
+ } else {
114
+ this.root.appendChild(this.sliderInput);
115
+ }
56
116
  this.root.appendChild(this.helperEl);
57
117
 
58
118
  // Throttle onInput to animation frames to avoid re-render stutter while dragging
@@ -72,6 +132,9 @@ export class SliderField {
72
132
  };
73
133
 
74
134
  const handleInput = (event) => {
135
+ // The companions follow the thumb on every frame; onChange waits for release.
136
+ this.#value = this.#fit(Number(event.target.value));
137
+ this.#syncCompanions();
75
138
  pendingValue = event.target.value;
76
139
  if (!rafToken) {
77
140
  rafToken = requestAnimationFrame(() => {
@@ -82,17 +145,71 @@ export class SliderField {
82
145
  };
83
146
  const handleChange = (event) => {
84
147
  flushPending();
85
- const value = event.target.value;
86
- this.onChange?.(value, { immediate: false });
148
+ this.#value = this.#fit(Number(event.target.value));
149
+ this.#syncCompanions();
150
+ this.onChange?.(event.target.value, { immediate: false });
87
151
  };
88
- this.sliderInput.addEventListener('input', handleInput);
89
- this.sliderInput.addEventListener('change', handleChange);
90
- this._disposers.push(() => this.sliderInput.removeEventListener('input', handleInput));
91
- this._disposers.push(() => this.sliderInput.removeEventListener('change', handleChange));
152
+ this.#listen(this.sliderInput, 'input', handleInput);
153
+ this.#listen(this.sliderInput, 'change', handleChange);
92
154
 
155
+ this.#syncCompanions();
93
156
  return this.root;
94
157
  }
95
158
 
159
+ /** What onChange would report now: the value as a string, or disabledValue while toggled off. */
160
+ getValue() {
161
+ return this.#enabled ? String(this.#value) : this.#disabledValue();
162
+ }
163
+
164
+ isEnabled() {
165
+ return this.#enabled;
166
+ }
167
+
168
+ /** True when a defaultValue was given and the value differs from it. */
169
+ isModified() {
170
+ return this.#hasDefault && this.#value !== this.#defaultValue;
171
+ }
172
+
173
+ /**
174
+ * Clamp to the range and snap to the step. Reports once, unless `silent` or
175
+ * the reported value did not move. A non-finite value is refused (false).
176
+ */
177
+ setValue(value, { silent = false } = {}) {
178
+ if (!this.#isFiniteInput(value)) {
179
+ return false;
180
+ }
181
+ const numeric = Number(value);
182
+ return this.#commit(() => { this.#value = this.#fit(numeric); }, silent);
183
+ }
184
+
185
+ setEnabled(enabled, { silent = false } = {}) {
186
+ return this.#commit(() => { this.#enabled = Boolean(enabled); }, silent);
187
+ }
188
+
189
+ /** Change any of min/max/step; the value and the default are refitted into the new range. */
190
+ setRange({ min, max, step } = {}, { silent = false } = {}) {
191
+ return this.#commit(() => {
192
+ this.#min = this.#isFiniteInput(min) ? Number(min) : this.#min;
193
+ this.#max = Math.max(this.#min, this.#isFiniteInput(max) ? Number(max) : this.#max);
194
+ this.#step = this.#coercePositiveNumber(step, this.#step);
195
+ this.#value = this.#fit(this.#value);
196
+ if (this.#hasDefault) {
197
+ this.#defaultValue = this.#fit(this.#defaultValue);
198
+ }
199
+ if (this.sliderInput) {
200
+ this.#writeRangeAttributes();
201
+ }
202
+ }, silent);
203
+ }
204
+
205
+ /** Restore defaultValue and report it, a commit like a drag release. */
206
+ reset({ silent = false } = {}) {
207
+ if (!this.#hasDefault) {
208
+ return false;
209
+ }
210
+ return this.#commit(() => { this.#value = this.#defaultValue; }, silent);
211
+ }
212
+
96
213
  dispose() {
97
214
  this._disposers.forEach((dispose) => {
98
215
  try {
@@ -104,9 +221,149 @@ export class SliderField {
104
221
  this._disposers = [];
105
222
  this.root = null;
106
223
  this.sliderInput = null;
224
+ this.numberInput = null;
225
+ this.readoutEl = null;
226
+ this.resetButton = null;
227
+ this.toggleInput = null;
107
228
  this.helperEl = null;
108
229
  }
109
230
 
231
+ #buildToggle() {
232
+ this.toggleInput = document.createElement('input');
233
+ this.toggleInput.type = 'checkbox';
234
+ this.toggleInput.className = 'twm-slider-field__toggle';
235
+ this.toggleInput.setAttribute('aria-label', 'Enabled');
236
+ if (this.field.disabled) {
237
+ this.toggleInput.disabled = true;
238
+ }
239
+ this.#listen(this.toggleInput, 'change', () => this.setEnabled(this.toggleInput.checked));
240
+ return this.toggleInput;
241
+ }
242
+
243
+ #buildNumberInput() {
244
+ this.numberInput = document.createElement('input');
245
+ this.numberInput.type = 'text';
246
+ this.numberInput.inputMode = 'decimal';
247
+ this.numberInput.className = 'twm-slider-field__number';
248
+ this.numberInput.setAttribute('aria-label', 'Value');
249
+ // A typed value that is not a finite number is refused and the box shows the value again.
250
+ const commitTyped = () => {
251
+ if (!this.setValue(this.numberInput.value.trim())) {
252
+ this.#syncCompanions();
253
+ }
254
+ };
255
+ this.#listen(this.numberInput, 'keydown', (event) => {
256
+ if (event.key === 'Enter') {
257
+ event.preventDefault();
258
+ commitTyped();
259
+ } else if (event.key === 'Escape') {
260
+ this.#syncCompanions();
261
+ }
262
+ });
263
+ this.#listen(this.numberInput, 'change', commitTyped);
264
+ return this.numberInput;
265
+ }
266
+
267
+ #buildResetButton() {
268
+ this.resetButton = document.createElement('button');
269
+ this.resetButton.type = 'button';
270
+ this.resetButton.className = 'twm-slider-field__reset';
271
+ this.resetButton.title = 'Reset to default';
272
+ this.resetButton.setAttribute('aria-label', 'Reset to default');
273
+ const icon = document.createElement('span');
274
+ icon.className = 'material-symbols-outlined';
275
+ icon.textContent = 'restart_alt';
276
+ this.resetButton.appendChild(icon);
277
+ this.#listen(this.resetButton, 'click', () => this.reset());
278
+ return this.resetButton;
279
+ }
280
+
281
+ /** Apply a change, refresh the DOM, and report once if the reported value moved. */
282
+ #commit(apply, silent) {
283
+ const before = this.getValue();
284
+ apply();
285
+ this.#syncCompanions();
286
+ const after = this.getValue();
287
+ if (after === before) {
288
+ return false;
289
+ }
290
+ if (!silent) {
291
+ this.onChange?.(after, { immediate: false });
292
+ }
293
+ return true;
294
+ }
295
+
296
+ /** Write the model into every element: slider, box, readout, toggle, states. */
297
+ #syncCompanions() {
298
+ if (!this.root) return;
299
+ const text = String(this.#value);
300
+ if (this.sliderInput.value !== text) {
301
+ this.sliderInput.value = text;
302
+ }
303
+ const off = !this.#enabled;
304
+ const hardDisabled = Boolean(this.field.disabled);
305
+ this.sliderInput.disabled = hardDisabled || off;
306
+ if (this.numberInput) {
307
+ this.numberInput.value = text;
308
+ this.numberInput.disabled = hardDisabled || off;
309
+ }
310
+ if (this.readoutEl) {
311
+ const format = typeof this.field.readout === 'function' ? this.field.readout : null;
312
+ this.readoutEl.textContent = format ? String(format(this.#value)) : text;
313
+ }
314
+ if (this.toggleInput) {
315
+ this.toggleInput.checked = this.#enabled;
316
+ }
317
+ const modified = this.isModified();
318
+ if (this.resetButton) {
319
+ this.resetButton.disabled = hardDisabled || off || !modified;
320
+ }
321
+ this.root.classList.toggle('twm-slider-field--modified', modified);
322
+ this.root.classList.toggle('twm-slider-field--off', off);
323
+ }
324
+
325
+ #writeRangeAttributes() {
326
+ this.sliderInput.min = String(this.#min);
327
+ this.sliderInput.max = String(this.#max);
328
+ this.sliderInput.step = String(this.#step);
329
+ this.sliderInput.value = String(this.#value);
330
+ }
331
+
332
+ /** Clamp to [min, max] and snap to the step grid anchored at min, as a range input does. */
333
+ #fit(value) {
334
+ const clamped = Math.min(this.#max, Math.max(this.#min, value));
335
+ let snapped = this.#min + Math.round((clamped - this.#min) / this.#step) * this.#step;
336
+ if (snapped > this.#max) snapped -= this.#step;
337
+ const decimals = Math.max(this.#decimals(this.#step), this.#decimals(this.#min));
338
+ return Number(snapped.toFixed(Math.min(decimals, 12)));
339
+ }
340
+
341
+ #decimals(value) {
342
+ const text = String(value);
343
+ if (text.includes('e-')) return Number(text.split('e-')[1]);
344
+ const dot = text.indexOf('.');
345
+ return dot === -1 ? 0 : text.length - dot - 1;
346
+ }
347
+
348
+ #isFiniteInput(value) {
349
+ if (value === null || value === undefined || typeof value === 'boolean') {
350
+ return false;
351
+ }
352
+ if (typeof value === 'string' && value.trim() === '') {
353
+ return false;
354
+ }
355
+ return Number.isFinite(Number(value));
356
+ }
357
+
358
+ #disabledValue() {
359
+ return this.field.disabledValue === undefined ? null : this.field.disabledValue;
360
+ }
361
+
362
+ #listen(target, type, handler, options) {
363
+ target.addEventListener(type, handler, options);
364
+ this._disposers.push(() => target.removeEventListener(type, handler, options));
365
+ }
366
+
110
367
  #coerceNumber(value, fallback) {
111
368
  const numeric = Number(value);
112
369
  return Number.isFinite(numeric) ? numeric : fallback;
@@ -19,6 +19,12 @@
19
19
 
20
20
  import { DragReorder } from './drag_reorder.js';
21
21
 
22
+ /** The class every row carries, and therefore what DragReorder must match. */
23
+ const ROW_CLASS = 'twm-sortable-list__row';
24
+ const ROW_SELECTOR = `.${ROW_CLASS}`;
25
+ const HANDLE_CLASS = 'twm-sortable-list__handle';
26
+ const HANDLE_SELECTOR = `.${HANDLE_CLASS}`;
27
+
22
28
  export class SortableList {
23
29
  /**
24
30
  * Create a sortable list.
@@ -60,6 +66,7 @@ export class SortableList {
60
66
  this.root = null;
61
67
  this.listEl = null;
62
68
  this._dragReorder = null;
69
+ this._pressedHandle = false;
63
70
  }
64
71
 
65
72
  /**
@@ -198,15 +205,30 @@ export class SortableList {
198
205
  // placeholder). Created once, bound to the persistent list
199
206
  // element; re-attached after each refresh to wire the rebuilt
200
207
  // rows. Drag is restricted to the grip handle.
208
+ //
209
+ // The item selector is ROW_SELECTOR, the class #createRow puts on
210
+ // every row: a selector that matches no row makes nothing draggable.
211
+ //
212
+ // The handle restriction cannot read `dragstart`'s target: a browser
213
+ // fires `dragstart` at the draggable element itself (the row), never
214
+ // at the child the pointer went down on. So the press is recorded on
215
+ // `mousedown` (which does target the handle) and the guard reads that.
201
216
  if (this.allowReorder) {
202
217
  if (!this._dragReorder) {
218
+ this.listEl.addEventListener('mousedown', (e) => {
219
+ this._pressedHandle = !!e.target.closest?.(HANDLE_SELECTOR);
220
+ });
203
221
  this._dragReorder = new DragReorder({
204
222
  container: this.listEl,
205
- itemSelector: '.sortable-list__row',
223
+ itemSelector: ROW_SELECTOR,
206
224
  keyAttr: 'index',
207
225
  axis: 'y',
208
226
  indicatorClass: 'twm-sortable-list__drop',
209
- draggableGuard: (e) => !!e.target.closest('.twm-sortable-list__handle'),
227
+ draggableGuard: () => {
228
+ const fromHandle = this._pressedHandle;
229
+ this._pressedHandle = false;
230
+ return fromHandle;
231
+ },
210
232
  onReorder: (order) => this.#applyReorder(order),
211
233
  });
212
234
  }
@@ -229,7 +251,7 @@ export class SortableList {
229
251
 
230
252
  #createRow(item, index) {
231
253
  const row = document.createElement('div');
232
- row.className = 'twm-sortable-list__row';
254
+ row.className = ROW_CLASS;
233
255
  row.dataset.index = index;
234
256
 
235
257
  // Drag handle (if reordering enabled). The drag mechanics are
@@ -237,7 +259,7 @@ export class SortableList {
237
259
  // handle is just the grab affordance the drag is restricted to.
238
260
  if (this.allowReorder) {
239
261
  const handle = document.createElement('div');
240
- handle.className = 'twm-sortable-list__handle';
262
+ handle.className = HANDLE_CLASS;
241
263
  handle.innerHTML = '<span class="material-symbols-outlined">drag_indicator</span>';
242
264
  row.appendChild(handle);
243
265
  }