flexdesk 0.17.17 → 0.18.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 (300) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +0 -0
  3. package/README.md +1 -0
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +0 -0
  6. package/charts.js +0 -0
  7. package/core.js +0 -0
  8. package/css/flexdesk.css +7499 -5851
  9. package/css/reset.css +1 -1
  10. package/css/tokens.css +23 -1
  11. package/dist/canvas.js +0 -0
  12. package/dist/canvas.js.map +0 -0
  13. package/dist/charts.js +10 -6
  14. package/dist/charts.js.map +1 -1
  15. package/dist/chunk-3PHCPZHT.js +0 -0
  16. package/dist/chunk-3PHCPZHT.js.map +0 -0
  17. package/dist/chunk-5W4EEKSK.js +778 -0
  18. package/dist/chunk-5W4EEKSK.js.map +7 -0
  19. package/dist/chunk-DNJ3N4T4.js +82 -0
  20. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +0 -0
  22. package/dist/chunk-FOOS3T5L.js.map +0 -0
  23. package/dist/chunk-GOQLR5WR.js +0 -0
  24. package/dist/chunk-GOQLR5WR.js.map +0 -0
  25. package/dist/{chunk-JKZLNKG6.js → chunk-HKPB6OFA.js} +90 -25
  26. package/dist/{chunk-JKZLNKG6.js.map → chunk-HKPB6OFA.js.map} +2 -2
  27. package/dist/chunk-HNU6MM2B.js +0 -0
  28. package/dist/chunk-HNU6MM2B.js.map +0 -0
  29. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  30. package/dist/chunk-JYWURG5T.js +0 -0
  31. package/dist/chunk-JYWURG5T.js.map +0 -0
  32. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  33. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  34. package/dist/chunk-N7HC3ADV.js +0 -0
  35. package/dist/chunk-N7HC3ADV.js.map +0 -0
  36. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  37. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  38. package/dist/chunk-PAI2ZUWV.js +260 -0
  39. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  40. package/dist/chunk-Q7BALEPX.js +527 -0
  41. package/dist/chunk-Q7BALEPX.js.map +7 -0
  42. package/dist/chunk-QXAOTGCC.js +40 -0
  43. package/dist/chunk-QXAOTGCC.js.map +7 -0
  44. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  45. package/dist/chunk-RKVYIXDC.js.map +7 -0
  46. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  47. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  48. package/dist/chunk-UKP22RUG.js +219 -0
  49. package/dist/chunk-UKP22RUG.js.map +7 -0
  50. package/dist/chunk-VLOAI3HS.js +43 -0
  51. package/dist/chunk-VLOAI3HS.js.map +7 -0
  52. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  53. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  54. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  55. package/dist/chunk-ZL3VKINP.js.map +7 -0
  56. package/dist/editor.js +3 -2
  57. package/dist/editor.js.map +1 -1
  58. package/dist/flexdesk.css +7499 -5851
  59. package/dist/flow.js +29 -97
  60. package/dist/flow.js.map +3 -3
  61. package/dist/git.js +1356 -0
  62. package/dist/git.js.map +7 -0
  63. package/dist/host.js +0 -0
  64. package/dist/host.js.map +0 -0
  65. package/dist/reset.css +1 -1
  66. package/dist/tile_registry-6WZPTUZV.js +0 -0
  67. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  68. package/dist/tiles.js +20 -15
  69. package/dist/tiles.js.map +2 -2
  70. package/dist/tokens.css +23 -1
  71. package/dist/widgets.js +3969 -1189
  72. package/dist/widgets.js.map +4 -4
  73. package/dist/wm.js +121 -33
  74. package/dist/wm.js.map +2 -2
  75. package/docs/README.md +9 -4
  76. package/docs/best-practices.md +2 -2
  77. package/docs/components/chat-shell.md +198 -0
  78. package/docs/components/command-palette.md +0 -0
  79. package/docs/components/conversation.md +10 -0
  80. package/docs/components/data-table-grid.md +0 -0
  81. package/docs/components/data-table.md +0 -0
  82. package/docs/components/editor.md +0 -0
  83. package/docs/components/filter-builder.md +246 -0
  84. package/docs/components/flow-canvas-editor.md +0 -0
  85. package/docs/components/flow-editors.md +0 -0
  86. package/docs/components/flow-lane-editor.md +0 -0
  87. package/docs/components/flow-outline-editor.md +0 -0
  88. package/docs/components/forms-and-dialogs.md +22 -0
  89. package/docs/components/git.md +355 -0
  90. package/docs/components/input-fields.md +167 -2
  91. package/docs/components/keymap.md +8 -0
  92. package/docs/components/markdown-and-outline.md +0 -0
  93. package/docs/components/menus.md +120 -3
  94. package/docs/components/panels-and-desktops.md +16 -5
  95. package/docs/components/record-list-and-fact-table.md +0 -0
  96. package/docs/components/settings-view.md +0 -0
  97. package/docs/components/shell.md +104 -1
  98. package/docs/components/wizard.md +0 -0
  99. package/docs/components/workspace-picker.md +149 -0
  100. package/docs/concepts/building-and-packaging.md +2 -2
  101. package/docs/concepts/domain-neutrality.md +0 -0
  102. package/docs/concepts/host-port.md +0 -0
  103. package/docs/concepts/packages-and-entry-points.md +14 -13
  104. package/docs/concepts/peer-dependencies.md +0 -0
  105. package/docs/concepts/state-guard.md +3 -3
  106. package/docs/quickstart.md +1 -1
  107. package/editor.js +0 -0
  108. package/flow.js +0 -0
  109. package/git.js +13 -0
  110. package/host.js +0 -0
  111. package/package.json +6 -1
  112. package/src/canvas/canvas_adapter.js +0 -0
  113. package/src/canvas/edge_router.js +0 -0
  114. package/src/canvas/graph/connector_router.js +0 -0
  115. package/src/canvas/graph/history_service.js +0 -0
  116. package/src/canvas/graph/node_platform.js +0 -0
  117. package/src/canvas/index.js +0 -0
  118. package/src/canvas/layered_layout.js +0 -0
  119. package/src/charts/data_series_plot_window.js +0 -0
  120. package/src/charts/downsample.js +0 -0
  121. package/src/charts/plot_config.js +0 -0
  122. package/src/charts/plotly_wrapper.js +0 -0
  123. package/src/core/event_bus.js +0 -0
  124. package/src/core/logging.js +0 -0
  125. package/src/core/settings.js +0 -0
  126. package/src/core/state_guard.js +0 -0
  127. package/src/core/state_machine.js +0 -0
  128. package/src/editor/editor_pane.js +0 -0
  129. package/src/editor/monaco_editor_factory.js +0 -0
  130. package/src/editor/monaco_loader.js +0 -0
  131. package/src/editor/notebook_search.js +0 -0
  132. package/src/editor/notebook_undo_manager.js +0 -0
  133. package/src/editor/split_pane_container.js +0 -0
  134. package/src/flow/canvas_editor/connections.js +0 -0
  135. package/src/flow/canvas_editor/editor.js +0 -0
  136. package/src/flow/canvas_editor/index.js +0 -0
  137. package/src/flow/canvas_editor/layout.js +0 -0
  138. package/src/flow/canvas_editor/strings.js +0 -0
  139. package/src/flow/kit/always_before.js +0 -0
  140. package/src/flow/kit/catalogue.js +0 -0
  141. package/src/flow/kit/chip_input.js +0 -0
  142. package/src/flow/kit/dom.js +28 -119
  143. package/src/flow/kit/findings.js +0 -0
  144. package/src/flow/kit/history.js +0 -0
  145. package/src/flow/kit/index.js +0 -0
  146. package/src/flow/kit/keys.js +0 -0
  147. package/src/flow/kit/logic_graph.js +0 -0
  148. package/src/flow/kit/references.js +0 -0
  149. package/src/flow/kit/settings_panel.js +0 -0
  150. package/src/flow/kit/settings_schema.js +0 -0
  151. package/src/flow/kit/step_picker.js +1 -1
  152. package/src/flow/kit/strings.js +0 -0
  153. package/src/flow/kit/value_picker.js +1 -1
  154. package/src/flow/kit/widgets.js +0 -0
  155. package/src/flow/lanes/editor.js +0 -0
  156. package/src/flow/lanes/index.js +0 -0
  157. package/src/flow/lanes/layout.js +0 -0
  158. package/src/flow/lanes/pipeline.js +0 -0
  159. package/src/flow/lanes/preview.js +0 -0
  160. package/src/flow/lanes/strings.js +0 -0
  161. package/src/flow/outline/editor.js +0 -0
  162. package/src/flow/outline/index.js +0 -0
  163. package/src/flow/outline/mapping.js +0 -0
  164. package/src/flow/outline/menu.js +1 -1
  165. package/src/flow/outline/operations.js +0 -0
  166. package/src/flow/outline/recognise.js +0 -0
  167. package/src/flow/outline/strings.js +0 -0
  168. package/src/git/diff_viewer.js +212 -0
  169. package/src/git/diff_window.js +51 -0
  170. package/src/git/git_graph.js +401 -0
  171. package/src/git/git_ops.js +61 -0
  172. package/src/git/git_panel.js +755 -0
  173. package/src/git/index.js +9 -0
  174. package/src/git/line_diff.js +202 -0
  175. package/src/help/help_modal.js +0 -0
  176. package/src/help/help_registry.js +0 -0
  177. package/src/host/external_links.js +0 -0
  178. package/src/host/file_picker.js +0 -0
  179. package/src/host/host.js +0 -0
  180. package/src/host/photino_host.js +0 -0
  181. package/src/host/photino_host.md +0 -0
  182. package/src/host/pywebview_host.js +0 -0
  183. package/src/tiles/chart_tiles.js +0 -0
  184. package/src/tiles/config_schema.js +0 -0
  185. package/src/tiles/figure_tile.js +0 -0
  186. package/src/tiles/layout_persistence.js +0 -0
  187. package/src/tiles/list_tiles.js +0 -0
  188. package/src/tiles/scope_picker.js +0 -0
  189. package/src/tiles/tile_base.js +5 -4
  190. package/src/tiles/tile_config_form.js +0 -0
  191. package/src/tiles/tile_registry.js +0 -0
  192. package/src/tiling/bottom_panel_tabs.js +0 -0
  193. package/src/tiling/command_palette.js +0 -0
  194. package/src/tiling/content_registry.js +0 -0
  195. package/src/tiling/desktops.js +10 -8
  196. package/src/tiling/entity_sources.js +0 -0
  197. package/src/tiling/keymap.js +6 -5
  198. package/src/tiling/kind_taxonomy.js +15 -2
  199. package/src/tiling/landing_table.js +0 -0
  200. package/src/tiling/loading_overlay.js +0 -0
  201. package/src/tiling/nav_panel.js +0 -0
  202. package/src/tiling/page_factory.js +0 -0
  203. package/src/tiling/panel_availability.js +0 -0
  204. package/src/tiling/panel_keys.js +0 -0
  205. package/src/tiling/shell.js +124 -17
  206. package/src/tiling/tab_strip.js +0 -0
  207. package/src/tiling/tile_renderer.js +2 -3
  208. package/src/tiling/tile_tab_menu.js +0 -0
  209. package/src/tiling/zoom.js +0 -0
  210. package/src/ui/base/component_base.js +0 -0
  211. package/src/ui/base/controller_base.js +0 -0
  212. package/src/ui/base/page_base.js +0 -0
  213. package/src/ui/components/about_dialog.js +0 -0
  214. package/src/ui/components/action_dropdown.js +4 -78
  215. package/src/ui/components/alert_log.js +0 -0
  216. package/src/ui/components/alerts_pane.js +0 -0
  217. package/src/ui/components/anchored_layer.js +255 -0
  218. package/src/ui/components/attribute_list_editor.js +0 -0
  219. package/src/ui/components/autocomplete_field.js +0 -0
  220. package/src/ui/components/cell_editor_host.js +0 -0
  221. package/src/ui/components/cell_editors.js +0 -0
  222. package/src/ui/components/cell_grid.js +0 -0
  223. package/src/ui/components/cell_paste.js +0 -0
  224. package/src/ui/components/cell_selection.js +0 -0
  225. package/src/ui/components/chat_shell.js +920 -0
  226. package/src/ui/components/chat_state_machine.js +223 -0
  227. package/src/ui/components/choice_controls.js +45 -2
  228. package/src/ui/components/color_field.js +3 -2
  229. package/src/ui/components/color_list_field.js +3 -2
  230. package/src/ui/components/column_fit.js +0 -0
  231. package/src/ui/components/column_groups.js +0 -0
  232. package/src/ui/components/column_order.js +0 -0
  233. package/src/ui/components/combobox.js +422 -0
  234. package/src/ui/components/conversation_view.js +17 -2
  235. package/src/ui/components/crosstab.js +0 -0
  236. package/src/ui/components/data_table.js +2 -2
  237. package/src/ui/components/detail_header.js +0 -0
  238. package/src/ui/components/document_outline.js +0 -0
  239. package/src/ui/components/drag_reorder.js +0 -0
  240. package/src/ui/components/fact_table.js +0 -0
  241. package/src/ui/components/fill_handle.js +0 -0
  242. package/src/ui/components/filter_builder.js +766 -0
  243. package/src/ui/components/filter_model.js +614 -0
  244. package/src/ui/components/gallery_picker.js +0 -0
  245. package/src/ui/components/header_actions.js +0 -0
  246. package/src/ui/components/inline_renamer.js +0 -0
  247. package/src/ui/components/job_progress.js +0 -0
  248. package/src/ui/components/managed_window.js +24 -47
  249. package/src/ui/components/menu_bar.js +101 -24
  250. package/src/ui/components/modal.js +79 -109
  251. package/src/ui/components/option_list.js +160 -0
  252. package/src/ui/components/popover.js +94 -0
  253. package/src/ui/components/progress.js +0 -0
  254. package/src/ui/components/recent_items.js +169 -0
  255. package/src/ui/components/record_list.js +0 -0
  256. package/src/ui/components/row_activation.js +0 -0
  257. package/src/ui/components/section_stack.js +0 -0
  258. package/src/ui/components/select.js +322 -0
  259. package/src/ui/components/series.js +0 -0
  260. package/src/ui/components/settings_view.js +0 -0
  261. package/src/ui/components/slide_out_panel.js +0 -0
  262. package/src/ui/components/slider_field.js +0 -0
  263. package/src/ui/components/sortable_list.js +0 -0
  264. package/src/ui/components/staged_edits.js +0 -0
  265. package/src/ui/components/staged_history.js +0 -0
  266. package/src/ui/components/table_filter_chips.js +0 -0
  267. package/src/ui/components/table_state_store.js +0 -0
  268. package/src/ui/components/table_structure.js +0 -0
  269. package/src/ui/components/table_values.js +0 -0
  270. package/src/ui/components/tabs.js +0 -0
  271. package/src/ui/components/tag_input.js +77 -8
  272. package/src/ui/components/toast.js +0 -0
  273. package/src/ui/components/toggle_field.js +0 -0
  274. package/src/ui/components/tsv.js +0 -0
  275. package/src/ui/components/window_taskbar.js +0 -0
  276. package/src/ui/components/workspace_picker.js +1155 -0
  277. package/src/ui/controllers/window_chrome_controller.js +4 -1
  278. package/src/ui/utils/auto_scrollbars.js +0 -0
  279. package/src/ui/utils/fire_event.js +17 -0
  280. package/src/ui/utils/focusable.js +25 -0
  281. package/src/ui/utils/markdown.js +0 -0
  282. package/src/ui/utils/modal_host.js +27 -0
  283. package/src/ui/utils/native_context_menu.js +0 -0
  284. package/src/ui/utils/overlay_scrollbar.js +0 -0
  285. package/src/ui/utils/raf_resize_observer.js +0 -0
  286. package/src/ui/utils/scroll_lock.js +0 -0
  287. package/src/ui/utils/strip_scroll.js +0 -0
  288. package/src/ui/utils/tooltip_service.js +0 -0
  289. package/tiles.js +0 -0
  290. package/widgets.js +12 -2
  291. package/wm.js +0 -0
  292. package/css/base.css +0 -10527
  293. package/css/overrides.css +0 -2596
  294. package/dist/chunk-7JI7HHSY.js +0 -107
  295. package/dist/chunk-7JI7HHSY.js.map +0 -7
  296. package/dist/chunk-MIW7D6WC.js +0 -526
  297. package/dist/chunk-MIW7D6WC.js.map +0 -7
  298. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  299. package/dist/chunk-XJNADRGR.js.map +0 -7
  300. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,1155 @@
1
+ /**
2
+ * workspace_picker.js — the field in a top bar that names what the window is
3
+ * working in, and the switcher that changes it.
4
+ *
5
+ * field switcher, opened in the field's place
6
+ * +-----------------------+ +------------------------------------+
7
+ * | (i) North Ctrl+P | | (o) nor| |
8
+ * +-----------------------+ | heading 2 of 9|
9
+ * | North (check)(x)|
10
+ * | Northwest |
11
+ * | Load 20 more · 2 of 9 shown |
12
+ * | + New "nor"… |
13
+ * | card: the current one's links |
14
+ * | status footer verbs |
15
+ * +------------------------------------+
16
+ *
17
+ * Three exports, each usable alone:
18
+ *
19
+ * mountPickerField the field: a name, an icon, a key hint; centred in
20
+ * its host at the width the host can spare, or inline
21
+ * createPickerSwitcher the switcher, on any anchor or on none
22
+ * createWorkspacePicker both, wired together, with an optional chord
23
+ *
24
+ * It knows nothing of what is being picked. Items are the caller's, `describe`
25
+ * turns one into what a row draws, and every string is the caller's or a
26
+ * neutral default the caller can replace through `labels`.
27
+ *
28
+ * ══ ITEMS ARE ASKED FOR ON EVERY OPEN ══════════════════════════════════════
29
+ *
30
+ * `load({ query, offset, limit, signal })` answers an array, or
31
+ * `{ items, total?, status? }`, now or in a promise. With `search: 'local'` it
32
+ * is asked once per open and typing filters what it answered, names ahead of
33
+ * other matches. With `search: 'remote'` typing is debounced and asked again,
34
+ * and a `total` above what is drawn offers Load more. Either way only the
35
+ * newest question's answer paints: every read carries a sequence number and an
36
+ * AbortSignal, and an answer to an older question is dropped however it
37
+ * arrives. The last unsearched answer, per `cacheKey()`, is drawn at once on
38
+ * the next open while it is asked for again.
39
+ *
40
+ * ══ THE KEYBOARD ═══════════════════════════════════════════════════════════
41
+ *
42
+ * The caret stays in the input, so typing always types. The arrows walk a
43
+ * highlight over every stop, wrapping: the rows a person can choose (a disabled
44
+ * one is stepped over), Load more, the action rows, the card's buttons, the
45
+ * footer's. Until they move, the highlight is the first row or action row of
46
+ * what is shown, and Enter takes it, or, while the answer to what was typed is
47
+ * on its way, takes the first one when it lands. Shift+Delete removes the
48
+ * highlighted row. Esc closes and gives focus back to where it was, as does a
49
+ * pick or a verb, before it runs, so a dialog it opens opens over nothing. A
50
+ * press outside closes and leaves focus where it was pressed. Tab leaves the
51
+ * input for the panel's buttons, and out of the panel closes it.
52
+ *
53
+ * ══ NOTHING IS REBUILT UNDER A POINTER ═════════════════════════════════════
54
+ *
55
+ * A click fires only when the press and the release land on one node. So every
56
+ * part is reconciled by key: an answer, a card or a `setCurrent` updates nodes
57
+ * in place, and a node is created or removed only for a key that came or went.
58
+ * Which node holds what lives in maps here, never in DOM attributes.
59
+ *
60
+ * Nor is a list moved under one. The list scrolls to the highlight only when the
61
+ * keyboard moved the highlight, `open({ select })` asked for a row, or the row
62
+ * the arrows chose went away; typing and a new query's answer go back to the top.
63
+ * A redraw, Load more by the mouse and a removal leave the scroll where it is.
64
+ *
65
+ * ══ AN ASYNC REMOVE MAY ASK ════════════════════════════════════════════════
66
+ *
67
+ * While the promise `onRemove` returned is unsettled the panel and its backdrop
68
+ * step below managed windows and do not close on a press outside, so a
69
+ * confirmation dialog can be answered and the switcher is still there afterwards.
70
+ */
71
+
72
+ import { ActionDropdown } from './action_dropdown.js';
73
+ import { createRafResizeObserver } from '../utils/raf_resize_observer.js';
74
+
75
+ const DEFAULT_LABELS = Object.freeze({
76
+ loading: 'Loading…',
77
+ empty: () => 'Nothing to show.',
78
+ noMatch: (query) => `Nothing matches “${query}”.`,
79
+ failed: (err) => `The list could not be read. ${err?.message || ''}`.trim(),
80
+ failedMore: (err) => `The next page could not be read. ${err?.message || ''}`.trim(),
81
+ failedRemove: (err) => `Could not remove it. ${err?.message || ''}`.trim(),
82
+ loadMore: ({ next, shown, total }) => `Load ${next} more · ${shown} of ${total} shown`,
83
+ remove: (name) => `Remove ${name}`,
84
+ });
85
+
86
+ const PANEL_EDGE = 8;
87
+ /** How far the panel's input row stands above the field it covers. */
88
+ const OVER_LIFT = 3;
89
+
90
+ let nextUid = 0;
91
+
92
+ function part(tag, className, text = null) {
93
+ const node = document.createElement(tag);
94
+ if (className) node.className = className;
95
+ if (text != null) node.textContent = text;
96
+ return node;
97
+ }
98
+
99
+ function glyph(name, className = '') {
100
+ const node = part('span', `material-symbols-outlined ${className}`.trim(), name);
101
+ node.setAttribute('aria-hidden', 'true');
102
+ return node;
103
+ }
104
+
105
+ /** Write text only when it changed: a write that changes nothing is still a
106
+ * mutation, and a screen reader hears it. */
107
+ function setText(node, text) {
108
+ const value = String(text ?? '');
109
+ if (node.textContent !== value) node.textContent = value;
110
+ }
111
+
112
+ function show(node, shown) {
113
+ node.hidden = !shown;
114
+ }
115
+
116
+ function button(className) {
117
+ const node = part('button', className);
118
+ node.type = 'button';
119
+ return node;
120
+ }
121
+
122
+ /**
123
+ * Keep `container`'s children one per key: `build()` makes `{ node, ... }` for
124
+ * a new key, `update(entry, item)` brings any entry up to date, a key that went
125
+ * loses its node, and a node already in its place is not moved (moving one
126
+ * under a pressed pointer is half of rebuilding it).
127
+ * @returns {(items: object[], keyOf: (item: object) => unknown) => Map<string, object>}
128
+ */
129
+ function keyedChildren(container, build, update) {
130
+ let drawn = new Map();
131
+ return (items, keyOf) => {
132
+ const next = new Map();
133
+ const nodes = items.map((item) => {
134
+ const key = String(keyOf(item));
135
+ const entry = drawn.get(key) ?? build();
136
+ update(entry, item);
137
+ next.set(key, entry);
138
+ return entry.node;
139
+ });
140
+ for (const [key, entry] of drawn) {
141
+ if (!next.has(key)) entry.node.remove();
142
+ }
143
+ drawn = next;
144
+ nodes.forEach((node, index) => {
145
+ if (container.children[index] !== node) {
146
+ container.insertBefore(node, container.children[index] || null);
147
+ }
148
+ });
149
+ return drawn;
150
+ };
151
+ }
152
+
153
+ function parseChord(chord) {
154
+ const parts = String(chord).split('+').map((p) => p.trim().toLowerCase());
155
+ const key = parts.pop();
156
+ const known = new Set(['ctrl', 'cmd', 'meta', 'shift', 'alt']);
157
+ if (!key || parts.some((p) => !known.has(p))) {
158
+ throw new TypeError(`createWorkspacePicker: cannot read the chord "${chord}"`);
159
+ }
160
+ return {
161
+ key,
162
+ ctrl: parts.includes('ctrl') || parts.includes('cmd') || parts.includes('meta'),
163
+ shift: parts.includes('shift'),
164
+ alt: parts.includes('alt'),
165
+ };
166
+ }
167
+
168
+ function chordMatches(parsed, event) {
169
+ return (event.ctrlKey || event.metaKey) === parsed.ctrl
170
+ && event.shiftKey === parsed.shift
171
+ && event.altKey === parsed.alt
172
+ && String(event.key).toLowerCase() === parsed.key;
173
+ }
174
+
175
+ /**
176
+ * Mount the field into `hostEl`.
177
+ *
178
+ * `layout: 'centered'` places it in the middle of `hostEl` (which must be
179
+ * positioned) at the width the host can spare between the elements `avoid`
180
+ * selects: at most `maxWidth`, without the key hint below `tightWidth`, and
181
+ * hidden below `minWidth`. `layout: 'inline'` leaves its width to the host and
182
+ * only drops the key hint.
183
+ *
184
+ * @param {HTMLElement} hostEl
185
+ * @param {object} [options]
186
+ * @param {string} [options.name] - The name it shows
187
+ * @param {string} [options.icon] - A Material Symbols name
188
+ * @param {string} [options.keyHint] - Drawn as a key; none by default
189
+ * @param {boolean} [options.muted] - Draw the name as naming nothing
190
+ * @param {boolean} [options.caret] - A trailing caret, for an inline field
191
+ * @param {string} [options.tooltip]
192
+ * @param {string} [options.ariaLabel]
193
+ * @param {string} [options.className] - Extra classes
194
+ * @param {'centered'|'inline'} [options.layout]
195
+ * @param {string} [options.avoid] - Selector, within `hostEl`, of what it must not cover
196
+ * @param {number} [options.maxWidth]
197
+ * @param {number} [options.minWidth]
198
+ * @param {number} [options.tightWidth]
199
+ * @param {number} [options.gap] - Room kept free in all, shared between the two sides
200
+ * @param {() => void} [options.onActivate] - A click
201
+ * @returns {{ el: HTMLButtonElement, update: (patch: object) => void, setOpen: (open: boolean) => void, fit: () => void, destroy: () => void }}
202
+ */
203
+ export function mountPickerField(hostEl, {
204
+ name = '',
205
+ icon = '',
206
+ keyHint = '',
207
+ muted = false,
208
+ caret = false,
209
+ tooltip = '',
210
+ ariaLabel = '',
211
+ className = '',
212
+ layout = 'inline',
213
+ avoid = '',
214
+ maxWidth = 440,
215
+ minWidth = 120,
216
+ tightWidth = 230,
217
+ gap = 24,
218
+ onActivate = null,
219
+ } = {}) {
220
+ const centered = layout === 'centered';
221
+ const el = button(['twm-wspicker-field', centered ? 'twm-wspicker-field--centered' : '', className]
222
+ .filter(Boolean).join(' '));
223
+ el.setAttribute('aria-haspopup', 'dialog');
224
+ el.setAttribute('aria-expanded', 'false');
225
+ const iconEl = glyph('', 'twm-wspicker-field__icon');
226
+ const nameEl = part('span', 'twm-wspicker-field__name');
227
+ const keyEl = part('kbd', 'twm-wspicker-field__key');
228
+ el.append(iconEl, nameEl, keyEl);
229
+ if (caret) el.append(glyph('expand_more', 'twm-wspicker-field__caret'));
230
+
231
+ const update = (patch) => {
232
+ if ('name' in patch) setText(nameEl, patch.name);
233
+ if ('icon' in patch) {
234
+ setText(iconEl, patch.icon);
235
+ show(iconEl, !!patch.icon);
236
+ }
237
+ if ('keyHint' in patch) {
238
+ setText(keyEl, patch.keyHint);
239
+ show(keyEl, !!patch.keyHint);
240
+ }
241
+ if ('muted' in patch) el.classList.toggle('twm-wspicker-field--muted', !!patch.muted);
242
+ if ('tooltip' in patch) {
243
+ el.classList.toggle('twm-has-tooltip', !!patch.tooltip);
244
+ if (patch.tooltip) {
245
+ el.setAttribute('data-tooltip', patch.tooltip);
246
+ el.setAttribute('data-tooltip-placement', 'bottom');
247
+ } else {
248
+ el.removeAttribute('data-tooltip');
249
+ el.removeAttribute('data-tooltip-placement');
250
+ }
251
+ }
252
+ if ('ariaLabel' in patch) {
253
+ if (patch.ariaLabel) el.setAttribute('aria-label', patch.ariaLabel);
254
+ else el.removeAttribute('aria-label');
255
+ }
256
+ };
257
+ update({ name, icon, keyHint, muted, tooltip, ariaLabel });
258
+
259
+ const fit = () => {
260
+ if (!centered) {
261
+ el.classList.toggle('twm-wspicker-field--tight', el.getBoundingClientRect().width < tightWidth);
262
+ return;
263
+ }
264
+ const box = hostEl.getBoundingClientRect();
265
+ const middle = box.left + box.width / 2;
266
+ let leftEdge = box.left;
267
+ let rightEdge = box.right;
268
+ for (const other of avoid ? hostEl.querySelectorAll(avoid) : []) {
269
+ if (el.contains(other)) continue;
270
+ const r = other.getBoundingClientRect();
271
+ if (r.width <= 0) continue;
272
+ if (r.left + r.width / 2 < middle) leftEdge = Math.max(leftEdge, r.right);
273
+ else rightEdge = Math.min(rightEdge, r.left);
274
+ }
275
+ const width = Math.min(maxWidth, 2 * Math.min(middle - leftEdge, rightEdge - middle) - gap);
276
+ el.classList.toggle('twm-wspicker-field--hidden', width < minWidth);
277
+ el.classList.toggle('twm-wspicker-field--tight', width < tightWidth);
278
+ el.style.width = `${Math.max(minWidth, width)}px`;
279
+ };
280
+
281
+ let destroyed = false;
282
+ const onClick = () => onActivate?.();
283
+ el.addEventListener('click', onClick);
284
+ hostEl.appendChild(el);
285
+ const observer = createRafResizeObserver(() => {
286
+ if (!destroyed) fit();
287
+ });
288
+ observer.observe(centered ? hostEl : el);
289
+ fit();
290
+ document.fonts?.ready?.then(() => {
291
+ if (!destroyed) fit();
292
+ });
293
+
294
+ return {
295
+ el,
296
+ update,
297
+ fit,
298
+ setOpen(open) {
299
+ el.classList.toggle('twm-wspicker-field--open', open);
300
+ el.setAttribute('aria-expanded', String(open));
301
+ },
302
+ destroy() {
303
+ destroyed = true;
304
+ observer.disconnect();
305
+ el.removeEventListener('click', onClick);
306
+ el.remove();
307
+ },
308
+ };
309
+ }
310
+
311
+ /**
312
+ * The switcher. See the file's header for how it asks and how it behaves.
313
+ *
314
+ * @param {object} options
315
+ * @param {HTMLElement} [options.anchor] - What it opens from. With none, or one
316
+ * that takes no room, it opens centred over a dimmed page.
317
+ * @param {'over'|'below'} [options.placement] - `over` lays the input over the
318
+ * anchor; `below` hangs from it, flipped above when there is no room.
319
+ * @param {number} [options.minWidth] - The anchored panel's least width
320
+ * @param {number} [options.visibleRows] - Scroll past this many two-line rows
321
+ * @param {string} [options.className] - Extra classes on the panel
322
+ * @param {string} [options.label] - The panel's accessible name
323
+ * @param {string} [options.listLabel] - The list's accessible name
324
+ * @param {string} [options.placeholder] - The input's placeholder
325
+ * @param {string} [options.glyph] - The icon before the input
326
+ * @param {(request: {query: string, offset: number, limit: number, signal: AbortSignal}) => (object[]|{items: object[], total?: number, status?: string|{text: string, error?: boolean}}|Promise)} options.load
327
+ * @param {'local'|'remote'} [options.search]
328
+ * @param {number} [options.pageSize] - `limit`, in remote search
329
+ * @param {number} [options.debounceMs] - How long typing waits, in remote search
330
+ * @param {() => string} [options.cacheKey] - Whose list the kept answer is
331
+ * @param {(item: object, ctx: {current: boolean}) => {id: string|number, name: string, detail?: string, icon?: string, badge?: string, title?: string, keywords?: string|string[], disabled?: boolean, removable?: boolean}} options.describe
332
+ * @param {(item: object) => void} [options.onPick] - A row other than the current one chosen
333
+ * @param {(item: object) => (boolean|void|Promise<boolean|void>)} [options.onRemove] -
334
+ * Offers a bin on each row whose `removable` is not false. Answering `false`
335
+ * keeps the row; rejecting keeps it and shows the error.
336
+ * @param {(ctx: {query: string}) => Array<{id: string, label: string, detail?: string, icon?: string, always?: boolean, run: (ctx: {query: string}) => void}>} [options.actions] -
337
+ * Rows after the items, narrowed by typing unless `always`.
338
+ * @param {(ctx: {query: string, shown: number, total: number|null}) => ({title?: string, count?: string}|null)} [options.heading]
339
+ * @param {(ctx: {current: object|null, row: object|null}) => ({heading?: string, icon?: string, name: string, detail?: string, open?: {label: string, icon?: string, run: Function}, columns?: number, tiles?: Array<{id: string, label: string, icon?: string, run: Function}>}|null)} [options.card]
340
+ * @param {() => ({start?: object[], end?: object[], keys?: Array<{keys: string[], label: string}>}|null)} [options.footer] -
341
+ * Buttons are `{ id, label, icon?, trailing?, primary?, run }`. `keys` are drawn
342
+ * only when the panel is centred.
343
+ * @param {object} [options.labels] - Replaces any of the default strings
344
+ * @param {(open: boolean) => void} [options.onOpenChange]
345
+ */
346
+ export function createPickerSwitcher({
347
+ anchor = null,
348
+ placement = 'over',
349
+ minWidth = 520,
350
+ visibleRows = 0,
351
+ className = '',
352
+ label = '',
353
+ listLabel = '',
354
+ placeholder = '',
355
+ glyph: inputGlyph = 'search',
356
+ load,
357
+ search = 'local',
358
+ pageSize = 20,
359
+ debounceMs = 200,
360
+ cacheKey = () => '',
361
+ describe,
362
+ onPick = null,
363
+ onRemove = null,
364
+ actions = null,
365
+ heading = null,
366
+ card = null,
367
+ footer = null,
368
+ labels: labelOverrides = {},
369
+ onOpenChange = null,
370
+ }) {
371
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides };
372
+ const remote = search === 'remote';
373
+ const uid = `twm-wspicker-${++nextUid}`;
374
+
375
+ let isOpen = false;
376
+ let destroyed = false;
377
+ let current = null;
378
+ /** What the last answer held, in its order. */
379
+ let items = [];
380
+ let total = null;
381
+ let status = null;
382
+ /** The query the drawn items answer, and the newest one asked. */
383
+ let answered = '';
384
+ let asked = '';
385
+ let seq = 0;
386
+ let abort = null;
387
+ let loading = false;
388
+ let loadingMore = false;
389
+ let failure = null;
390
+ let timer = null;
391
+ /** Enter came before the answer to what was typed. */
392
+ let pickFirst = false;
393
+ /** Which row to highlight when the next answer lands. */
394
+ let selectId = null;
395
+ /** An `onRemove` promise is unsettled. */
396
+ let busy = false;
397
+ /** Where focus was when it opened. */
398
+ let opener = null;
399
+ /** Every place the arrows stop, in order. */
400
+ let stops = [];
401
+ let activeKey = null;
402
+ let activeNode = null;
403
+ /** The arrows chose the highlight; an answer does not move it. */
404
+ let moved = false;
405
+ let nextEntryId = 0;
406
+ const kept = new Map();
407
+
408
+ const idOf = (item) => String(describe(item, { current: false }).id);
409
+ const isCurrent = (item) => current != null && idOf(item) === idOf(current);
410
+ const modelOf = (item) => describe(item, { current: isCurrent(item) });
411
+ const typed = () => input.value.trim();
412
+
413
+ // ── the panel, built once ───────────────────────────────────────────
414
+ const backdrop = part('div', 'twm-wspicker-backdrop');
415
+ const panel = part('div', `twm-wspicker ${className}`.trim());
416
+ panel.setAttribute('role', 'dialog');
417
+ if (label) panel.setAttribute('aria-label', label);
418
+
419
+ const searchRow = part('div', 'twm-wspicker__search');
420
+ const input = part('input', 'twm-wspicker__input');
421
+ input.type = 'text';
422
+ input.autocomplete = 'off';
423
+ input.spellcheck = false;
424
+ input.placeholder = placeholder;
425
+ if (placeholder) input.setAttribute('aria-label', placeholder);
426
+ input.setAttribute('role', 'combobox');
427
+ input.setAttribute('aria-expanded', 'true');
428
+ input.setAttribute('aria-autocomplete', 'list');
429
+ input.setAttribute('aria-controls', `${uid}-list`);
430
+ searchRow.append(glyph(inputGlyph, 'twm-wspicker__glyph'), input);
431
+
432
+ const head = part('div', 'twm-wspicker__head');
433
+ const headTitle = part('span', 'twm-wspicker__title');
434
+ const headCount = part('span', 'twm-wspicker__count');
435
+ head.append(headTitle, headCount);
436
+
437
+ const scroller = part('div', 'twm-wspicker__scroll');
438
+ if (visibleRows > 0) {
439
+ scroller.classList.add('twm-wspicker__scroll--capped');
440
+ scroller.style.setProperty('--twm-wspicker-rows', String(visibleRows));
441
+ }
442
+ const list = part('div', 'twm-wspicker__list');
443
+ list.id = `${uid}-list`;
444
+ list.setAttribute('role', 'listbox');
445
+ if (listLabel) list.setAttribute('aria-label', listLabel);
446
+ const note = part('p', 'twm-wspicker__note');
447
+ note.setAttribute('role', 'status');
448
+ const more = button('twm-btn twm-wspicker__more');
449
+ const moreLabel = part('span', 'twm-wspicker__more-label');
450
+ more.append(glyph('expand_more'), moreLabel);
451
+ const actionList = part('div', 'twm-wspicker__actions');
452
+ actionList.setAttribute('role', 'listbox');
453
+ scroller.append(list, note, more, actionList);
454
+
455
+ const cardEl = part('section', 'twm-wspicker__card');
456
+ const cardHeading = part('h3', 'twm-wspicker__card-heading');
457
+ const cardHead = part('div', 'twm-wspicker__card-head');
458
+ const cardIcon = glyph('', 'twm-wspicker__card-icon');
459
+ const cardText = part('span', 'twm-wspicker__card-text');
460
+ const cardName = part('span', 'twm-wspicker__card-name');
461
+ const cardDetail = part('span', 'twm-wspicker__card-detail');
462
+ cardText.append(cardName, cardDetail);
463
+ const cardOpen = button('twm-btn twm-wspicker__card-open');
464
+ const cardOpenIcon = glyph('');
465
+ const cardOpenLabel = part('span', '');
466
+ cardOpen.append(cardOpenIcon, cardOpenLabel);
467
+ cardHead.append(cardIcon, cardText, cardOpen);
468
+ const tiles = part('div', 'twm-wspicker__tiles');
469
+ cardEl.append(cardHeading, cardHead, tiles);
470
+
471
+ const foot = part('div', 'twm-wspicker__foot');
472
+ const footStatus = part('span', 'twm-wspicker__status');
473
+ const footStart = part('div', 'twm-wspicker__foot-group');
474
+ const footEnd = part('div', 'twm-wspicker__foot-group');
475
+ const footKeys = part('span', 'twm-wspicker__keys');
476
+ foot.append(footStart, footStatus, footEnd, footKeys);
477
+
478
+ panel.append(searchRow, head, scroller, cardEl, foot);
479
+
480
+ // ── rows, the items' and the action rows' ───────────────────────────
481
+ function buildRow() {
482
+ const node = part('div', 'twm-wspicker__row');
483
+ node.id = `${uid}-row-${++nextEntryId}`;
484
+ node.setAttribute('role', 'option');
485
+ const icon = glyph('', 'twm-wspicker__row-icon');
486
+ const text = part('span', 'twm-wspicker__row-text');
487
+ const name = part('span', 'twm-wspicker__row-name');
488
+ const detail = part('span', 'twm-wspicker__row-detail');
489
+ text.append(name, detail);
490
+ const badge = part('span', 'twm-wspicker__row-badge');
491
+ const check = glyph('check', 'twm-wspicker__row-check');
492
+ const bin = button('twm-wspicker__row-remove');
493
+ bin.tabIndex = -1;
494
+ bin.append(glyph('delete'));
495
+ node.append(icon, text, badge, check, bin);
496
+ const entry = { node, icon, name, detail, badge, check, bin, run: null, remove: null };
497
+ node.addEventListener('click', () => entry.run?.());
498
+ bin.addEventListener('click', (event) => {
499
+ event.stopPropagation();
500
+ entry.remove?.();
501
+ });
502
+ return entry;
503
+ }
504
+
505
+ function paintRow(entry, { name, detail, icon, badge, title, disabled = false, isCurrentRow = false }) {
506
+ setText(entry.icon, icon);
507
+ setText(entry.name, name);
508
+ setText(entry.detail, detail);
509
+ show(entry.detail, !!detail);
510
+ setText(entry.badge, badge);
511
+ show(entry.badge, !!badge);
512
+ show(entry.check, isCurrentRow);
513
+ entry.node.classList.toggle('twm-wspicker__row--current', isCurrentRow);
514
+ entry.node.classList.toggle('twm-wspicker__row--disabled', disabled);
515
+ entry.node.setAttribute('aria-selected', String(isCurrentRow));
516
+ if (disabled) entry.node.setAttribute('aria-disabled', 'true');
517
+ else entry.node.removeAttribute('aria-disabled');
518
+ entry.node.title = title ?? (detail ? `${name} — ${detail}` : name);
519
+ }
520
+
521
+ const syncRows = keyedChildren(list, buildRow, (entry, item) => {
522
+ const model = modelOf(item);
523
+ const disabled = !!model.disabled;
524
+ paintRow(entry, { ...model, disabled, isCurrentRow: isCurrent(item) });
525
+ entry.run = disabled ? null : () => pick(item);
526
+ const removable = !!onRemove && model.removable !== false;
527
+ entry.remove = removable ? () => removeItem(item) : null;
528
+ show(entry.bin, removable);
529
+ if (removable) {
530
+ const words = labels.remove(model.name);
531
+ entry.bin.setAttribute('aria-label', words);
532
+ entry.bin.title = words;
533
+ }
534
+ });
535
+
536
+ const syncActions = keyedChildren(actionList, buildRow, (entry, action) => {
537
+ paintRow(entry, { name: action.label, detail: action.detail, icon: action.icon, title: action.title });
538
+ entry.run = () => runVerb(action.run);
539
+ show(entry.bin, false);
540
+ });
541
+
542
+ /** What typing leaves of the answer: names ahead of other matches. */
543
+ function shownItems() {
544
+ const query = typed().toLowerCase();
545
+ if (remote || !query) return items;
546
+ const names = [];
547
+ const others = [];
548
+ for (const item of items) {
549
+ const { name, detail, keywords } = modelOf(item);
550
+ if (String(name).toLowerCase().includes(query)) names.push(item);
551
+ else if ([detail, keywords].flat().some((text) => String(text ?? '').toLowerCase().includes(query))) others.push(item);
552
+ }
553
+ return [...names, ...others];
554
+ }
555
+
556
+ function shownActions() {
557
+ if (!actions) return [];
558
+ const query = typed();
559
+ const lower = query.toLowerCase();
560
+ return actions({ query }).filter((action) => action.always || !lower
561
+ || String(action.label).toLowerCase().includes(lower));
562
+ }
563
+
564
+ // ── the card and the footer ─────────────────────────────────────────
565
+ function buildVerb(hook) {
566
+ return () => {
567
+ const node = button(`twm-btn ${hook}`);
568
+ const icon = glyph('');
569
+ const text = part('span', '');
570
+ const trailing = glyph('');
571
+ node.append(icon, text, trailing);
572
+ const entry = { node, icon, text, trailing, run: null };
573
+ node.addEventListener('click', () => entry.run?.());
574
+ return entry;
575
+ };
576
+ }
577
+
578
+ function paintVerb(entry, verb) {
579
+ setText(entry.icon, verb.icon);
580
+ setText(entry.text, verb.label);
581
+ setText(entry.trailing, verb.trailing);
582
+ entry.node.classList.toggle('twm-btn--primary', !!verb.primary);
583
+ entry.node.title = verb.label;
584
+ entry.run = () => runVerb(verb.run);
585
+ }
586
+
587
+ const syncTiles = keyedChildren(tiles, buildVerb('twm-wspicker__tile'), paintVerb);
588
+ const syncFootStart = keyedChildren(footStart, buildVerb('twm-wspicker__foot-btn'), paintVerb);
589
+ const syncFootEnd = keyedChildren(footEnd, buildVerb('twm-wspicker__foot-btn'), paintVerb);
590
+ let drawnTiles = new Map();
591
+ let drawnFoot = new Map();
592
+ let cardOpenRun = null;
593
+
594
+ cardOpen.addEventListener('click', () => cardOpenRun?.());
595
+
596
+ function renderCard() {
597
+ const row = current == null ? null : items.find((item) => isCurrent(item)) ?? null;
598
+ const model = card ? card({ current, row }) : null;
599
+ show(cardEl, !!model);
600
+ drawnTiles = syncTiles(model?.tiles || [], (tile) => tile.id);
601
+ cardOpenRun = model?.open ? () => runVerb(model.open.run) : null;
602
+ show(cardOpen, !!model?.open);
603
+ if (!model) return;
604
+ setText(cardHeading, model.heading);
605
+ show(cardHeading, !!model.heading);
606
+ setText(cardIcon, model.icon);
607
+ setText(cardName, model.name);
608
+ setText(cardDetail, model.detail);
609
+ cardName.title = model.name;
610
+ setText(cardOpenIcon, model.open?.icon);
611
+ setText(cardOpenLabel, model.open?.label);
612
+ tiles.style.setProperty('--twm-wspicker-columns', String(model.columns || 2));
613
+ }
614
+
615
+ function renderFooter() {
616
+ const model = footer ? footer() : null;
617
+ const start = syncFootStart(model?.start || [], (verb) => verb.id);
618
+ const end = syncFootEnd(model?.end || [], (verb) => verb.id);
619
+ drawnFoot = new Map([...[...start].map(([key, entry]) => [`start:${key}`, entry]),
620
+ ...[...end].map(([key, entry]) => [`end:${key}`, entry])]);
621
+ footKeys.replaceChildren(...(model?.keys || []).map(({ keys, label: words }) => {
622
+ const hint = part('span', 'twm-wspicker__key-hint');
623
+ hint.append(...keys.map((key) => part('kbd', '', key)), ` ${words}`);
624
+ return hint;
625
+ }));
626
+ const { text = '', error = false } = typeof status === 'string' ? { text: status } : status ?? {};
627
+ setText(footStatus, text);
628
+ show(footStatus, !!text);
629
+ footStatus.classList.toggle('twm-wspicker__status--error', !!error);
630
+ const keysShown = footKeys.childElementCount > 0 && panel.classList.contains('twm-wspicker--centered');
631
+ show(foot, !!text || keysShown || drawnFoot.size > 0);
632
+ }
633
+
634
+ // ── the highlight ───────────────────────────────────────────────────
635
+ function collectStops(rowEntries, actionEntries, shown) {
636
+ const next = [];
637
+ for (const item of shown) {
638
+ const entry = rowEntries.get(idOf(item));
639
+ if (entry?.run) next.push({ key: `row:${idOf(item)}`, node: entry.node, run: entry.run, remove: entry.remove, primary: true });
640
+ }
641
+ if (!more.hidden) next.push({ key: 'more', node: more, run: () => loadMore({ byKey: true }) });
642
+ for (const [key, entry] of actionEntries) next.push({ key: `action:${key}`, node: entry.node, run: entry.run, primary: true });
643
+ if (!cardEl.hidden) {
644
+ if (!cardOpen.hidden) next.push({ key: 'card-open', node: cardOpen, run: () => cardOpenRun?.() });
645
+ for (const [key, entry] of drawnTiles) next.push({ key: `tile:${key}`, node: entry.node, run: entry.run });
646
+ }
647
+ for (const [key, entry] of drawnFoot) next.push({ key: `foot:${key}`, node: entry.node, run: entry.run });
648
+ stops = next;
649
+ }
650
+
651
+ function paintActive() {
652
+ const stop = stops.find((s) => s.key === activeKey) ?? null;
653
+ if (activeNode && activeNode !== stop?.node) activeNode.classList.remove('twm-wspicker__stop--active');
654
+ activeNode = stop?.node ?? null;
655
+ activeNode?.classList.add('twm-wspicker__stop--active');
656
+ if (activeNode?.id) input.setAttribute('aria-activedescendant', activeNode.id);
657
+ else input.removeAttribute('aria-activedescendant');
658
+ }
659
+
660
+ function revealActive() {
661
+ activeNode?.scrollIntoView?.({ block: 'nearest' });
662
+ }
663
+
664
+ function settleActive() {
665
+ let reveal = false;
666
+ if (selectId != null && stops.some((s) => s.key === `row:${selectId}`)) {
667
+ activeKey = `row:${selectId}`;
668
+ moved = true;
669
+ selectId = null;
670
+ reveal = true;
671
+ }
672
+ if (!moved || !stops.some((s) => s.key === activeKey)) {
673
+ reveal = moved;
674
+ moved = false;
675
+ activeKey = stops.find((s) => s.primary)?.key ?? null;
676
+ }
677
+ paintActive();
678
+ if (reveal) revealActive();
679
+ }
680
+
681
+ function move(step) {
682
+ if (!stops.length) return;
683
+ const at = stops.findIndex((s) => s.key === activeKey);
684
+ const next = at === -1 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
685
+ activeKey = stops[next].key;
686
+ moved = true;
687
+ paintActive();
688
+ revealActive();
689
+ }
690
+
691
+ // ── drawing ─────────────────────────────────────────────────────────
692
+ function render() {
693
+ const shown = shownItems();
694
+ const query = remote ? answered : typed();
695
+ const rowEntries = syncRows(shown, idOf);
696
+ const actionEntries = syncActions(shownActions(), (action) => action.id);
697
+
698
+ const words = heading ? heading({ query, shown: shown.length, total }) : null;
699
+ show(head, !!(words?.title || words?.count));
700
+ setText(headTitle, words?.title);
701
+ setText(headCount, words?.count);
702
+
703
+ let said = '';
704
+ if (failure) said = failure;
705
+ else if (!shown.length && (loading || total == null)) said = labels.loading;
706
+ else if (!shown.length) said = query ? labels.noMatch(query) : labels.empty();
707
+ setText(note, said);
708
+ show(note, !!said);
709
+ note.classList.toggle('twm-wspicker__note--error', !!failure);
710
+
711
+ const remaining = remote && total != null ? Math.max(0, total - items.length) : 0;
712
+ show(more, remaining > 0 && !failure);
713
+ if (remaining > 0) {
714
+ setText(moreLabel, labels.loadMore({ next: Math.min(pageSize, remaining), shown: items.length, total }));
715
+ }
716
+ more.disabled = loading || loadingMore;
717
+
718
+ collectStops(rowEntries, actionEntries, shown);
719
+ settleActive();
720
+ }
721
+
722
+ function renderEverything() {
723
+ renderCard();
724
+ renderFooter();
725
+ render();
726
+ }
727
+
728
+ // ── asking ──────────────────────────────────────────────────────────
729
+ function normalize(answer) {
730
+ if (Array.isArray(answer)) return { items: answer, total: answer.length, status: null };
731
+ const list_ = answer?.items || [];
732
+ return {
733
+ items: list_,
734
+ total: Number.isFinite(answer?.total) ? answer.total : list_.length,
735
+ status: answer?.status ?? null,
736
+ };
737
+ }
738
+
739
+ function unique(all) {
740
+ const seen = new Set();
741
+ return all.filter((item) => {
742
+ const id = idOf(item);
743
+ if (seen.has(id)) return false;
744
+ seen.add(id);
745
+ return true;
746
+ });
747
+ }
748
+
749
+ async function ask(query) {
750
+ const mine = ++seq;
751
+ asked = query;
752
+ abort?.abort();
753
+ abort = new AbortController();
754
+ const { signal } = abort;
755
+ loading = true;
756
+ failure = null;
757
+ loadingMore = false;
758
+ render();
759
+ try {
760
+ const answer = normalize(await load({ query, offset: 0, limit: pageSize, signal }));
761
+ if (destroyed || mine !== seq) return;
762
+ items = unique(answer.items);
763
+ total = answer.total;
764
+ status = answer.status;
765
+ if (query !== answered) scroller.scrollTop = 0;
766
+ answered = query;
767
+ loading = false;
768
+ if (!query) kept.set(cacheKey(), { items: [...items], total, status });
769
+ renderEverything();
770
+ if (placementNow() === 'below') place();
771
+ if (pickFirst) {
772
+ pickFirst = false;
773
+ stops.find((s) => s.primary)?.run();
774
+ }
775
+ } catch (err) {
776
+ if (destroyed || mine !== seq || err?.name === 'AbortError') return;
777
+ loading = false;
778
+ pickFirst = false;
779
+ items = [];
780
+ total = null;
781
+ status = null;
782
+ answered = query;
783
+ failure = labels.failed(err);
784
+ renderEverything();
785
+ }
786
+ }
787
+
788
+ async function loadMore({ byKey = false } = {}) {
789
+ if (loading || loadingMore || total == null || items.length >= total) return;
790
+ const mine = seq;
791
+ loadingMore = true;
792
+ render();
793
+ let added = [];
794
+ try {
795
+ const answer = normalize(await load({ query: answered, offset: items.length, limit: pageSize, signal: abort?.signal }));
796
+ if (destroyed || mine !== seq) return;
797
+ const seen = new Set(items.map(idOf));
798
+ added = unique(answer.items).filter((item) => !seen.has(idOf(item)));
799
+ items = items.concat(added);
800
+ // A server that ignores the paging hands back what is drawn: there
801
+ // is no more, whatever it said before.
802
+ total = added.length && Number.isFinite(answer.total) ? answer.total : items.length;
803
+ if (answer.status != null) status = answer.status;
804
+ } catch (err) {
805
+ if (destroyed || mine !== seq || err?.name === 'AbortError') return;
806
+ failure = labels.failedMore(err);
807
+ }
808
+ loadingMore = false;
809
+ // The highlight stays on Load more while there is more, else goes to
810
+ // the first row it brought that can be chosen.
811
+ const stayed = activeKey === 'more';
812
+ if (stayed && items.length >= total) {
813
+ const first = added.find((item) => !modelOf(item).disabled);
814
+ if (first) {
815
+ activeKey = `row:${idOf(first)}`;
816
+ moved = true;
817
+ }
818
+ }
819
+ renderEverything();
820
+ if (byKey && stayed) revealActive();
821
+ }
822
+
823
+ more.addEventListener('click', () => loadMore());
824
+
825
+ function setBusy(waiting) {
826
+ busy = waiting;
827
+ panel.classList.toggle('twm-wspicker--waiting', waiting);
828
+ backdrop.classList.toggle('twm-wspicker-backdrop--waiting', waiting);
829
+ }
830
+
831
+ async function removeItem(item) {
832
+ if (busy) return;
833
+ const removedKey = `row:${idOf(item)}`;
834
+ const at = stops.findIndex((s) => s.key === removedKey);
835
+ const following = stops.slice(at + 1).find((s) => s.key.startsWith('row:'))
836
+ ?? stops.slice(0, Math.max(at, 0)).reverse().find((s) => s.key.startsWith('row:'));
837
+ // Before the call: a dialog it opens takes focus before it returns.
838
+ setBusy(true);
839
+ let outcome;
840
+ try {
841
+ outcome = await onRemove(item);
842
+ } catch (err) {
843
+ setBusy(false);
844
+ if (isOpen) {
845
+ failure = labels.failedRemove(err);
846
+ render();
847
+ input.focus();
848
+ }
849
+ return;
850
+ }
851
+ setBusy(false);
852
+ if (!isOpen) return;
853
+ if (outcome !== false) {
854
+ items = items.filter((other) => idOf(other) !== idOf(item));
855
+ if (total != null) total = Math.max(0, total - 1);
856
+ if (activeKey === removedKey && following) {
857
+ activeKey = following.key;
858
+ moved = true;
859
+ }
860
+ ask(remote ? answered : '');
861
+ }
862
+ input.focus();
863
+ }
864
+
865
+ // ── choosing ────────────────────────────────────────────────────────
866
+ function pick(item) {
867
+ const same = isCurrent(item);
868
+ close({ restoreFocus: true });
869
+ if (!same) onPick?.(item);
870
+ }
871
+
872
+ function runVerb(run) {
873
+ const query = typed();
874
+ close({ restoreFocus: true });
875
+ run?.({ query });
876
+ }
877
+
878
+ function enter() {
879
+ const query = typed();
880
+ if (remote && query !== asked) {
881
+ clearTimeout(timer);
882
+ timer = null;
883
+ pickFirst = true;
884
+ ask(query);
885
+ return;
886
+ }
887
+ if (loading && !moved) {
888
+ pickFirst = true;
889
+ return;
890
+ }
891
+ stops.find((s) => s.key === activeKey)?.run();
892
+ }
893
+
894
+ // ── placing ─────────────────────────────────────────────────────────
895
+ function placementNow() {
896
+ if (!anchor?.isConnected) return 'centered';
897
+ return anchor.getBoundingClientRect().width > 0 ? placement : 'centered';
898
+ }
899
+
900
+ function place() {
901
+ const mode = placementNow();
902
+ for (const name of ['over', 'below', 'centered']) {
903
+ panel.classList.toggle(`twm-wspicker--${name}`, mode === name);
904
+ }
905
+ show(backdrop, mode === 'centered');
906
+ for (const side of ['top', 'bottom', 'left', 'right', 'width', 'maxHeight', 'overflowY', 'position']) panel.style[side] = '';
907
+ if (mode === 'centered') return;
908
+ const r = anchor.getBoundingClientRect();
909
+ const width = Math.min(Math.max(r.width, minWidth), window.innerWidth - 2 * PANEL_EDGE);
910
+ panel.style.width = `${width}px`;
911
+ if (mode === 'below') {
912
+ ActionDropdown.position(anchor, panel);
913
+ return;
914
+ }
915
+ const left = r.left + r.width / 2 - width / 2;
916
+ panel.style.left = `${Math.max(PANEL_EDGE, Math.min(left, window.innerWidth - width - PANEL_EDGE))}px`;
917
+ // A field in the lower half (a bottom bar) grows upward, its bottom edge
918
+ // where the field's is, because a list hanging below it has no room.
919
+ if (r.top + r.height / 2 > window.innerHeight / 2) {
920
+ panel.style.bottom = `${Math.max(2, window.innerHeight - r.bottom - OVER_LIFT)}px`;
921
+ } else {
922
+ panel.style.top = `${Math.max(2, r.top - OVER_LIFT)}px`;
923
+ }
924
+ }
925
+
926
+ // ── opening and closing ─────────────────────────────────────────────
927
+ function openPanel({ select = null } = {}) {
928
+ if (isOpen || destroyed) return;
929
+ isOpen = true;
930
+ opener = document.activeElement;
931
+ selectId = select == null ? null : String(select);
932
+ input.value = '';
933
+ clearTimeout(timer);
934
+ timer = null;
935
+ pickFirst = false;
936
+ moved = false;
937
+ activeKey = null;
938
+ const last = kept.get(cacheKey());
939
+ items = last ? [...last.items] : [];
940
+ total = last ? last.total : null;
941
+ status = last ? last.status : null;
942
+ answered = '';
943
+ asked = '';
944
+ failure = null;
945
+ document.body.append(backdrop, panel);
946
+ place();
947
+ renderEverything();
948
+ scroller.scrollTop = 0;
949
+ document.addEventListener('mousedown', onDocumentDown, true);
950
+ document.addEventListener('keydown', onDocumentKey);
951
+ window.addEventListener('resize', place);
952
+ input.focus();
953
+ onOpenChange?.(true);
954
+ ask('');
955
+ }
956
+
957
+ function close({ restoreFocus = false } = {}) {
958
+ if (!isOpen) return;
959
+ isOpen = false;
960
+ clearTimeout(timer);
961
+ timer = null;
962
+ pickFirst = false;
963
+ selectId = null;
964
+ // An answer that lands after the panel closed paints nothing.
965
+ seq += 1;
966
+ abort?.abort();
967
+ loading = false;
968
+ loadingMore = false;
969
+ setBusy(false);
970
+ panel.remove();
971
+ backdrop.remove();
972
+ document.removeEventListener('mousedown', onDocumentDown, true);
973
+ document.removeEventListener('keydown', onDocumentKey);
974
+ window.removeEventListener('resize', place);
975
+ onOpenChange?.(false);
976
+ if (restoreFocus && !destroyed) {
977
+ const back = opener?.isConnected && opener !== document.body ? opener : anchor;
978
+ back?.focus?.();
979
+ }
980
+ opener = null;
981
+ }
982
+
983
+ /** In the capture phase, so a surface that stops the event still closes it. */
984
+ function onDocumentDown(event) {
985
+ if (busy || panel.contains(event.target) || anchor?.contains?.(event.target)) return;
986
+ close();
987
+ }
988
+
989
+ /** Esc with the focus outside the panel: on the anchor, say. */
990
+ function onDocumentKey(event) {
991
+ if (busy || event.key !== 'Escape' || panel.contains(event.target)) return;
992
+ event.preventDefault();
993
+ event.stopPropagation();
994
+ close({ restoreFocus: true });
995
+ }
996
+
997
+ // The caret stays in the input whatever is pressed in the panel.
998
+ panel.addEventListener('mousedown', (event) => {
999
+ if (event.target !== input) event.preventDefault();
1000
+ });
1001
+
1002
+ input.addEventListener('input', () => {
1003
+ moved = false;
1004
+ scroller.scrollTop = 0;
1005
+ if (remote) {
1006
+ clearTimeout(timer);
1007
+ const query = typed();
1008
+ timer = setTimeout(() => {
1009
+ timer = null;
1010
+ if (query !== asked) ask(query);
1011
+ }, debounceMs);
1012
+ }
1013
+ render();
1014
+ });
1015
+
1016
+ panel.addEventListener('keydown', (event) => {
1017
+ if (event.isComposing || busy) return;
1018
+ const { key, shiftKey, target } = event;
1019
+ const handled = () => {
1020
+ event.preventDefault();
1021
+ event.stopPropagation();
1022
+ };
1023
+ if (key === 'Escape') {
1024
+ handled();
1025
+ close({ restoreFocus: true });
1026
+ } else if (key === 'ArrowDown' || key === 'ArrowUp') {
1027
+ handled();
1028
+ if (document.activeElement !== input) input.focus();
1029
+ move(key === 'ArrowDown' ? 1 : -1);
1030
+ } else if (key === 'Enter' && target === input) {
1031
+ handled();
1032
+ enter();
1033
+ } else if (key === 'Delete' && shiftKey && target === input) {
1034
+ handled();
1035
+ stops.find((s) => s.key === activeKey)?.remove?.();
1036
+ } else if (key === 'Backspace' && target !== input) {
1037
+ // A window manager's keymap reads Backspace on anything that is not
1038
+ // a field as "up one level"; a button of this panel is not a field.
1039
+ handled();
1040
+ }
1041
+ });
1042
+
1043
+ panel.addEventListener('focusout', (event) => {
1044
+ const to = event.relatedTarget;
1045
+ if (!busy && to && !panel.contains(to) && !anchor?.contains?.(to)) close();
1046
+ });
1047
+
1048
+ return {
1049
+ panel,
1050
+ input,
1051
+ get isOpen() { return isOpen; },
1052
+ get current() { return current; },
1053
+ open: openPanel,
1054
+ close,
1055
+ toggle() {
1056
+ if (isOpen) close({ restoreFocus: true });
1057
+ else openPanel();
1058
+ },
1059
+ /** Which one the window is in, whether or not an answer holds it. */
1060
+ setCurrent(item) {
1061
+ current = item ?? null;
1062
+ if (isOpen) renderEverything();
1063
+ },
1064
+ /** Ask `actions`, `card` and `footer` again: who may do what changed. */
1065
+ refresh() {
1066
+ if (isOpen) renderEverything();
1067
+ },
1068
+ /** Ask `load` again: the list changed behind the switcher's back. */
1069
+ reload() {
1070
+ if (isOpen) ask(remote ? typed() : '');
1071
+ },
1072
+ destroy() {
1073
+ close();
1074
+ destroyed = true;
1075
+ },
1076
+ };
1077
+ }
1078
+
1079
+ /**
1080
+ * The field and the switcher together: a click on the field, or the chord,
1081
+ * opens the switcher in the field's place; `setCurrent` names the current item
1082
+ * on the field and marks it in the list.
1083
+ *
1084
+ * `chord` is none by default. Given (`'Ctrl+P'`; Ctrl also answers to Cmd), it
1085
+ * is taken on `window` in the capture phase, ahead of an embedded editor and of
1086
+ * the browser's own use of the key, and it is the key hint unless
1087
+ * `field.keyHint` says otherwise. It still works while the field is hidden for
1088
+ * want of room.
1089
+ *
1090
+ * @param {HTMLElement} hostEl - Where the field mounts
1091
+ * @param {object} options - `createPickerSwitcher`'s, and:
1092
+ * @param {object} [options.field] - `mountPickerField`'s. `field.placeholder` is
1093
+ * its name while nothing is current; `field.icon`, when given, is its icon
1094
+ * whatever is current, and otherwise the current item's `icon` is.
1095
+ * @param {string} [options.chord]
1096
+ */
1097
+ export function createWorkspacePicker(hostEl, { field: fieldOptions = {}, chord = null, ...switcherOptions }) {
1098
+ const { placeholder = '', icon: fixedIcon, ...mountOptions } = fieldOptions;
1099
+ const parsed = chord ? parseChord(chord) : null;
1100
+ const field = mountPickerField(hostEl, {
1101
+ ariaLabel: switcherOptions.label,
1102
+ keyHint: chord ?? '',
1103
+ ...mountOptions,
1104
+ onActivate: () => switcher.toggle(),
1105
+ });
1106
+ const switcher = createPickerSwitcher({
1107
+ ...switcherOptions,
1108
+ anchor: field.el,
1109
+ onOpenChange: (open) => {
1110
+ field.setOpen(open);
1111
+ switcherOptions.onOpenChange?.(open);
1112
+ },
1113
+ });
1114
+
1115
+ const paint = () => {
1116
+ const item = switcher.current;
1117
+ const model = item == null ? null : switcherOptions.describe(item, { current: true });
1118
+ field.update({
1119
+ name: model ? model.name : placeholder,
1120
+ icon: fixedIcon ?? model?.icon ?? '',
1121
+ muted: !model,
1122
+ });
1123
+ };
1124
+ paint();
1125
+
1126
+ const onKeydown = (event) => {
1127
+ if (!chordMatches(parsed, event)) return;
1128
+ event.preventDefault();
1129
+ event.stopPropagation();
1130
+ if (!event.repeat) switcher.toggle();
1131
+ };
1132
+ if (parsed) window.addEventListener('keydown', onKeydown, true);
1133
+
1134
+ return {
1135
+ field,
1136
+ switcher,
1137
+ get el() { return field.el; },
1138
+ get isOpen() { return switcher.isOpen; },
1139
+ get current() { return switcher.current; },
1140
+ open: switcher.open,
1141
+ close: switcher.close,
1142
+ toggle: switcher.toggle,
1143
+ refresh: switcher.refresh,
1144
+ reload: switcher.reload,
1145
+ setCurrent(item) {
1146
+ switcher.setCurrent(item);
1147
+ paint();
1148
+ },
1149
+ destroy() {
1150
+ if (parsed) window.removeEventListener('keydown', onKeydown, true);
1151
+ switcher.destroy();
1152
+ field.destroy();
1153
+ },
1154
+ };
1155
+ }