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
package/docs/README.md CHANGED
@@ -8,7 +8,7 @@ New here? Start with the [quickstart](quickstart.md). The [README](../README.md)
8
8
 
9
9
  ## Packages
10
10
 
11
- FlexDesk is nine entry points that share their code, so you load only what you use. [Packages and entry points](concepts/packages-and-entry-points.md) has the sizes and the reasoning.
11
+ FlexDesk is ten entry points that share their code, so you load only what you use. [Packages and entry points](concepts/packages-and-entry-points.md) has the sizes and the reasoning.
12
12
 
13
13
  | Entry | What is in it | Read |
14
14
  |---|---|---|
@@ -21,6 +21,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
21
21
  | `@flexdesk/editor` | Monaco loader, editor factory, tab bar, search, split panes | [Editor](components/editor.md) |
22
22
  | `@flexdesk/canvas` | The canvas kernel: a pannable, zoomable surface, the edge router, a graph layer | [Flow canvas editor](components/flow-canvas-editor.md) |
23
23
  | `@flexdesk/flow` | The flow-editor kit and the canvas, outline and lane editors | [Flow editors and the flow kit](components/flow-editors.md) |
24
+ | `@flexdesk/git` | A source-control panel, a commit graph and a diff viewer, over a written adapter contract | [Git panel, commit graph and diff viewer](components/git.md) |
24
25
 
25
26
  ## Components
26
27
 
@@ -48,8 +49,10 @@ FlexDesk is nine entry points that share their code, so you load only what you u
48
49
  - [Forms and dialogs](components/forms-and-dialogs.md): `openForm`, `openConfirm`, and every field type.
49
50
  - [Settings page](components/settings-view.md): a settings store's schema drawn as a page.
50
51
  - [Wizard](components/wizard.md): a multi-step flow, inline or in a window.
51
- - [Input fields: slider and tags](components/input-fields.md): `SliderField` and `createTagInput`.
52
- - [Menus](components/menus.md): `createMenuBar`, `ActionDropdown` and `CategoryPicker`.
52
+ - [Input fields: slider, tags, select and combobox](components/input-fields.md): `SliderField`, `createTagInput`, `createSelect` and `createCombobox`.
53
+ - [Filter builder](components/filter-builder.md): recursive AND/OR/NOT filters: the model, a mountable builder, a dialog, and the condition tree a server compiles.
54
+ - [Menus](components/menus.md): `createMenuBar`, `ActionDropdown`, `CategoryPicker`, `openPopover` and `positionAnchored`.
55
+ - [Workspace picker](components/workspace-picker.md): the top-bar field, its switcher and the recent-items store.
53
56
 
54
57
  ### Layout, content and feedback
55
58
 
@@ -59,6 +62,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
59
62
  - [Slide-out panel](components/slide-out-panel.md): `SlideOutPanel`, an inline panel that slides in over its host.
60
63
  - [Markdown and document outline](components/markdown-and-outline.md): `renderMarkdown` and `createDocumentOutline`.
61
64
  - [Conversation view](components/conversation.md): a transcript above a composer.
65
+ - [Assistant chat shell](components/chat-shell.md): `mountChatShell`, tabs, a model picker and streaming replies over a thin transport.
62
66
  - [Notifications and progress](components/notifications.md): toasts, history, the alert log, progress bars, lists and phases.
63
67
 
64
68
  ### Charts, editor and dashboards
@@ -66,6 +70,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
66
70
  - [Charts](components/charts.md): `@flexdesk/charts`, on Plotly.
67
71
  - [Editor](components/editor.md): `@flexdesk/editor`, on Monaco.
68
72
  - [Dashboard tiles](components/tiles.md): `@flexdesk/tiles`, a draggable widget grid.
73
+ - [Git panel, commit graph and diff viewer](components/git.md): `@flexdesk/git`, over a written adapter contract that any backend can implement.
69
74
 
70
75
  ### Flow editors
71
76
 
@@ -82,7 +87,7 @@ These have no page of their own: `ManagedWindow`, modals, `TreeView`, context me
82
87
 
83
88
  ## Concepts
84
89
 
85
- - [Packages and entry points](concepts/packages-and-entry-points.md): the nine entries, what they weigh, and why they are separate.
90
+ - [Packages and entry points](concepts/packages-and-entry-points.md): the ten entries, what they weigh, and why they are separate.
86
91
  - [Host port](concepts/host-port.md): how FlexDesk reaches storage, dialogs and native windows without touching them.
87
92
  - [Domain neutrality](concepts/domain-neutrality.md): storage keys, theme names and MIME types are yours; no application globals.
88
93
  - [State guard](concepts/state-guard.md): who wrote that `data-*` attribute.
@@ -8,7 +8,7 @@ The rules the reference pages state, gathered in one place. Each item says what
8
8
 
9
9
  **2. Start with the smallest host.** Pass `createHost` only the capabilities you have. A capability you leave out is absent and the library degrades instead of throwing: no native window buttons without `window`, a Blob download for CSV without `dialogs`. An in-memory `Map` is a perfectly good `state`, and `NULL_HOST` is a valid host with nothing behind it. See [the host port](concepts/host-port.md#the-host-port).
10
10
 
11
- **3. Load every entry from one copy.** The entries share code through chunks, so importing several does not give you several event buses or several copies of a module-level singleton. Keep it that way: map all entries to the same install or the same import map, rather than mixing a vendored `dist/` with a second bundled copy. See [packages and entry points](concepts/packages-and-entry-points.md#nine-entry-points).
11
+ **3. Load every entry from one copy.** The entries share code through chunks, so importing several does not give you several event buses or several copies of a module-level singleton. Keep it that way: map all entries to the same install or the same import map, rather than mixing a vendored `dist/` with a second bundled copy. See [packages and entry points](concepts/packages-and-entry-points.md#ten-entry-points).
12
12
 
13
13
  **4. Name your storage before anything reads it.** Every key FlexDesk writes is an option, with a generic default. Set them at boot, before the first read. Tile layouts are the sharp edge: the store is a singleton, and one built before you supply your key uses the default, so every layout your users saved becomes silently invisible, with no error. If users already have data under older keys, pass those keys explicitly and nothing is lost. See [storage keys and names](concepts/domain-neutrality.md#storage-keys-and-names-are-yours-0170) and [layouts are keyed by whatever you key them by](components/tiles.md#layouts-are-keyed-by-whatever-you-key-them-by).
14
14
 
@@ -36,7 +36,7 @@ The rules the reference pages state, gathered in one place. Each item says what
36
36
 
37
37
  **14. Restyle through the tokens.** Every rule reads its values from `tokens.css`, so swapping that one file restyles the shell. Library classes are `twm-` prefixed, and that prefix is stable; the editor components and the help modal keep the class names they were extracted with. Supply the icon font, or accept that icons show their text names. See [theming and CSS](concepts/styling.md#styling).
38
38
 
39
- **15. Run the state guard strictly in your own code.** `StateGuardService` defaults to `mode: 'warn'`. FlexDesk's own widgets run clean under `'strict'`, so choose strict for your code and add your application's keys on top of `DEFAULT_STATE_GUARD_CONFIG`: a key you write that no FlexDesk widget does is yours to list. The defaults also still allow some keys that no widget writes, which a strict guard lets through unless you filter them out ([what 0.18.0 removed and what stayed](concepts/state-guard.md#thirteen-application-keys-leave-the-defaults-0180)). See [state guard](concepts/state-guard.md#the-state-guard-who-wrote-that-attribute-0170) and [the default allow-list](concepts/state-guard.md#the-defaults-cover-flexdesks-own-keys-0170).
39
+ **15. Run the state guard strictly in your own code.** `StateGuardService` defaults to `mode: 'warn'`. FlexDesk's own widgets run clean under `'strict'`, so choose strict for your code and add your application's keys on top of `DEFAULT_STATE_GUARD_CONFIG`: a key you write that no FlexDesk widget does is yours to list. The defaults also still allow some keys that no widget writes, which a strict guard lets through unless you filter them out ([what 0.17.14 removed and what stayed](concepts/state-guard.md#thirteen-application-keys-leave-the-defaults-01714)). See [state guard](concepts/state-guard.md#the-state-guard-who-wrote-that-attribute-0170) and [the default allow-list](concepts/state-guard.md#the-defaults-cover-flexdesks-own-keys-0170).
40
40
 
41
41
  **16. Dispose what you create.** `shell.dispose()` removes the `?` shortcut and the desktop bar along with the rest of the shell. A `NotificationCenter`'s `dispose()` hands the toast helpers back. Menu bars, tables and lists have `dispose()` too.
42
42
 
@@ -0,0 +1,198 @@
1
+ # Assistant chat shell
2
+
3
+ > **Package:** `@flexdesk/widgets` · [All docs](../README.md)
4
+
5
+ A whole assistant chat over the [conversation view](conversation.md): conversation tabs, an optional mode toggle, a model picker, a reply that streams, Markdown bodies, time and copy on every message, and a slot for your own controls. It knows no provider and nothing of what a reply is about.
6
+
7
+ ## An assistant chat: `mountChatShell` (0.18.0)
8
+
9
+ ```js
10
+ import { mountChatShell } from '@flexdesk/widgets';
11
+
12
+ const chat = mountChatShell(el, {
13
+ transport: {
14
+ send(request, emit) { backend.ask(request, (e) => emit(toChatEvent(e))); },
15
+ cancel(request) { backend.stop(); },
16
+ },
17
+ modes: [{ id: 'ask', label: 'Ask' }, { id: 'act', label: 'Act' }],
18
+ models: { items: [{ id: 'large', label: 'Large' }, { id: 'small', label: 'Small' }], activeId: 'large' },
19
+ onModelSelect: (item) => chat.setModels({ activeId: item.id }),
20
+ renderControls: (el, message) => drawMyControls(el, message.meta),
21
+ conversations: JSON.parse(localStorage.getItem('chat') ?? 'null'),
22
+ onChange: (snapshot) => localStorage.setItem('chat', JSON.stringify(snapshot)),
23
+ placeholder: 'Ask anything',
24
+ welcome: { icon: 'smart_toy', text: 'What can I help you with?' },
25
+ });
26
+ ```
27
+
28
+ The chat fills its host. It needs `flexdesk.css`, and the Material Symbols font for its icons.
29
+
30
+ | Option | Is |
31
+ |---|---|
32
+ | `transport` | **Required.** `{ send(request, emit), cancel?(request) }`; `cancel` may answer a promise, see [below](#a-backend-with-no-run-id) |
33
+ | `renderControls(el, message, chat)` | Fills the slot on an assistant message |
34
+ | `markdown` | Options for [`renderMarkdown`](markdown-and-outline.md) (`highlight`, `math`, `inlineRules`, ...) |
35
+ | `onChange(snapshot)` | Something worth keeping changed: a message arrived or finished, a conversation or its meta changed. Not called per text delta |
36
+ | `onConversationChange(conversation, reason)` | Another conversation is in front; `reason` is `new`, `switch`, `close` or `reset`. `conversation` is `{ id, title, conversationId, messages }` |
37
+ | `modes`, `mode`, `onModeChange(id)` | `[{ id, label, title? }]`. The labels are yours. No modes, no toggle |
38
+ | `models`, `onModelSelect(item)`, `onModelAction(action, item)` | See [The model picker](#the-model-picker) |
39
+ | `conversations` | A `snapshot()` to restore |
40
+ | `titleFrom(text)` | A new conversation's title, from its first message. Default: the first 30 characters |
41
+ | `formatTime(at, message)` | A message's time; `''` shows none. Default: the locale's hours and minutes |
42
+ | `placeholder`, `welcome: { icon?, text }` | The composer's placeholder; what an empty transcript shows |
43
+ | `labels` | Over `CHAT_SHELL_LABELS`, every text the chat shows |
44
+ | `eventBus`, `logger` | `eventBus` gets `CHAT_BUS_EVENTS` |
45
+
46
+ ### The transport
47
+
48
+ `send(request, emit)` starts a turn. A request is `{ text, tabId, conversationId, mode, model }`: `tabId` is the conversation's tab, `conversationId` is your backend's id (`null` until you give one), `model` is the picker's `activeId`.
49
+
50
+ `emit` takes normalised events, one at a time (`CHAT_TRANSPORT_EVENTS`):
51
+
52
+ | Event | Does |
53
+ |---|---|
54
+ | `{ type: 'text-delta', text }` | Adds to the reply, which re-renders |
55
+ | `{ type: 'tool-use', id?, name, label?, status? }` | Lists a tool call under the reply, or updates the one with that `id`. `status` is `running` (the default), `done` or `error`; `label` is shown instead of `name` |
56
+ | `{ type: 'message-end', text?, conversationId?, meta? }` | Ends the turn. `text` fills the reply only if nothing was streamed; `conversationId` is kept; `meta` is merged into the message's |
57
+ | `{ type: 'error', message }` | Ends the turn with an error message in the transcript |
58
+
59
+ Every `emit` belongs to its turn, and what it is handed after that turn ended or was cancelled is dropped. A `send` that throws, or answers a promise that rejects, is an `error` event. To tell the chat a backend id before the turn ends, call `setConversationId(request.tabId, id)`.
60
+
61
+ So a transport that gives each run its own callback, and calls that run's `emit`, needs no filtering of its own.
62
+
63
+ #### A backend with no run id
64
+
65
+ A backend that pushes to a global has one place to send a frame, and the frame does not say which run it is from. The adapter holds the latest `emit`, so the chat cannot tell an old run's chunk from the new reply's. Runs must therefore not overlap, and `cancel` is how: it answers a promise that settles when the cancelled run is over and the backend takes a new request. The next turn shows at once, with its typing dots, and its `send` is called when the promise settles. A promise that rejects is logged and counts as settled; one that never settles leaves the next turn waiting until the user stops it.
66
+
67
+ ```js
68
+ let emit = null;
69
+ let runEnded = null;
70
+ const ended = () => { runEnded?.(); runEnded = null; };
71
+ window.myBridge = {
72
+ onChunk: ({ chunk }) => emit?.({ type: 'text-delta', text: chunk }),
73
+ onDone: ({ text, id, changes }) => {
74
+ emit?.({ type: 'message-end', text, conversationId: id, meta: { changes } });
75
+ ended();
76
+ },
77
+ onError: ({ error }) => {
78
+ emit?.({ type: 'error', message: error });
79
+ ended();
80
+ },
81
+ };
82
+ const transport = {
83
+ async send(request, e) {
84
+ emit = e;
85
+ try {
86
+ const result = await api.ask(request);
87
+ if (!result.ok) throw new Error(result.error);
88
+ } catch (error) {
89
+ ended();
90
+ throw error;
91
+ }
92
+ },
93
+ cancel: () => new Promise((resolve) => {
94
+ runEnded = resolve;
95
+ api.stop();
96
+ }),
97
+ };
98
+ ```
99
+
100
+ While the run winds down, `emit` is still the cancelled turn's, so its late chunks and its report that it was cancelled are dropped, with no check of yours. The promise must not settle before the backend would accept a request: if the backend sends its last frame and only then frees itself, have it send the end of the run after that, and settle on that instead. A `cancel` that answers no promise says there is nothing to wait for, and `send` is called at once.
101
+
102
+ ### A turn
103
+
104
+ The user's message goes in, then a streamed assistant message that shows the typing dots while it is empty. While the turn runs, Send is Cancel and Esc cancels, as in the conversation view, and the New and Clear buttons are disabled. A turn ends in one of three ways:
105
+
106
+ - **`message-end`.** Running tools become `done`. The message gets its controls, its time and its copy button.
107
+ - **Stop.** Cancel, Esc or `chat.cancel()`. What arrived stays, running tools become `stopped`, and `transport.cancel(request)` is called once, unless the turn's `send` was still [waiting](#a-backend-with-no-run-id) and so never called. The chat can send again at once.
108
+ - **`error`.** As Stop without `transport.cancel`, and an error message follows. While it is the last message it carries *Retry*, which sends the user message before it again.
109
+
110
+ In all three, a reply left with no text, no tool call and no `meta` is taken out. `chat.isBusy()` answers at once.
111
+
112
+ ### Conversations
113
+
114
+ The chat owns the tabs: one per conversation, with a close mark while there is more than one, double-click or F2 to rename, a New button and a Clear button (which asks first when there is something to clear). A new conversation takes its title from its first message unless you renamed it. While a turn runs, the conversation in front cannot be left, closed or cleared; a switch is refused and the strip stays where it was.
115
+
116
+ `chat.snapshot()` is plain data to keep, and `conversations` takes it back:
117
+
118
+ ```js
119
+ { activeId, tabs: [{ id, title, conversationId, messages: [{ id, role, text, at?, tools?, meta? }] }] }
120
+ ```
121
+
122
+ A reply still empty is not in a snapshot, and a tool that was `running` when a snapshot was taken comes back `stopped`.
123
+
124
+ | Method | Does |
125
+ |---|---|
126
+ | `send(text)`, `cancel()` → bool | As the user would; false when refused |
127
+ | `isBusy()`, `messages()`, `snapshot()` | A turn is running; the messages in front; everything to keep |
128
+ | `activeConversation()`, `newConversation()` → id, `switchTo(id)`, `close(id)`, `rename(id, title)`, `reset()` | The tabs. `reset` empties the one in front. Refused ones answer false (`newConversation`: null) |
129
+ | `setConversationId(tabId, id)` | Your backend's id for a conversation |
130
+ | `setMessages(tabId, messages)` | Replaces a conversation's messages, when your backend knows better. The one a turn is writing to refuses |
131
+ | `updateMeta(id, patch)`, `refreshControls(id)`, `controlsSlot(id)` | A message's controls, below |
132
+ | `mode()`, `setMode(id)`, `setModels(patch)` | The bar |
133
+ | `getText()`, `setText(text)`, `focus()`, `destroy()` | The composer, and teardown. `destroy` cancels a running turn |
134
+ | `root`, `stateMachine` | The element, and the machine below |
135
+
136
+ ### Your controls on a message
137
+
138
+ Each assistant message has a slot between its tool calls and its footer. `renderControls(el, message, chat)` fills an emptied `el` when the reply has finished, and again when the message comes back on a tab switch or a restore, after `updateMeta(id, patch)`, and on `refreshControls(id)`. It is not called while a reply streams. Whatever it needs to survive goes in `message.meta`, which is kept in snapshots and handed back untouched:
139
+
140
+ ```js
141
+ renderControls(el, message, chat) {
142
+ if (!message.meta?.changes) return;
143
+ const button = document.createElement('button');
144
+ button.textContent = message.meta.undone ? 'Redo' : 'Undo';
145
+ button.onclick = () => chat.updateMeta(message.id, { undone: !message.meta.undone });
146
+ el.append(button);
147
+ }
148
+ ```
149
+
150
+ A `renderControls` that throws is logged and the chat carries on.
151
+
152
+ ### The model picker
153
+
154
+ An [`ActionDropdown`](menus.md) on a button in the bar. It is controlled: choosing an entry calls your callback, and the picker changes when you call `setModels`. `setModels` takes any of these; what you leave out stays.
155
+
156
+ | Key | Is |
157
+ |---|---|
158
+ | `items` | Entries: `{ id, label, description?, icon? }` (a model), `{ action, label, description?, icon? }` (a command), `{ separator: true }`. Labels and descriptions are text, never markup |
159
+ | `activeId` | The model marked, and `request.model` |
160
+ | `label` | The button's text. Default: the active item's label, then `labels.selectModel` |
161
+ | `status` | `{ state?: 'ready' \| 'pending', label? }`: a dot on the button, its tooltip the label. None without `status` |
162
+
163
+ A model calls `onModelSelect(item)`; a command calls `onModelAction(action, item)`. You get your own item back, with whatever else you put on it. With no items and no label there is no button; with no modes either there is no bar.
164
+
165
+ ### The state machine
166
+
167
+ `ChatStateMachine` is the conversation lifecycle with no view in it, exported for a chat of your own. `chat.stateMachine` is the shell's.
168
+
169
+ | State | Goes to |
170
+ |---|---|
171
+ | `idle` | `waiting` (`sendMessage`) |
172
+ | `waiting` | `streaming` (`streamStart`), `idle` (`streamComplete`, `cancel`, `reset`), `error` (`error`) |
173
+ | `streaming` | `idle` (`streamComplete`, `cancel`, `reset`), `error` (`error`) |
174
+ | `error` | `waiting` (`sendMessage`), `idle` (`reset`) |
175
+
176
+ `newConversation()` and `loadConversation(id)` go back to `idle` from anywhere. `canSend()`, `isProcessing()`, `mode`, `setMode(mode)`, `conversationId` and `lastError` read and set the rest; a mode is any string of yours.
177
+
178
+ Calls are queued in order, because text can arrive in the same tick as the request that asked for it, and each answers a promise of `{ transitioned, reason? }`. A transition the state does not have is refused, not thrown. So `getState()` lags a call by some microtasks, and is `null` until the machine has entered `idle`; `settled()` resolves when everything called so far has taken effect.
179
+
180
+ `new ChatStateMachine({ eventBus, logger, mode, onChange })`: `onChange({ state, conversationId, mode })` hears every state entered. `eventBus` gets `CHAT_BUS_EVENTS`:
181
+
182
+ | Constant | Event | Payload |
183
+ |---|---|---|
184
+ | `STATE_CHANGED` | `chat:state:changed` | `{ state, conversationId, mode }` |
185
+ | `MESSAGE_SENT` | `chat:message:sent` | `{ conversationId, text, mode }` |
186
+ | `STREAM_COMPLETE` | `chat:stream:completed` | `{ conversationId, text, meta }` from the shell |
187
+ | `MODE_CHANGED` | `chat:mode:changed` | `{ mode }` |
188
+ | `CONVERSATION_NEW` | `chat:conversation:new` | `{}` |
189
+ | `CONVERSATION_LOADED` | `chat:conversation:loaded` | `{ conversationId }` |
190
+ | `ERROR` | `chat:error:raised` | `{ conversationId, message, detail }` |
191
+
192
+ ### Classes
193
+
194
+ The block is `twm-chat-shell`, around the conversation view's `twm-chat`: `__tab-bar`, `__tabs`, `__tab-btn` (`--clear`), `__rename`, `__conversation`, `__welcome`, `__welcome-icon`, `__welcome-text`, `__typing`, `__tools`, `__tool` (`--running`, `--done`, `--error`, `--stopped`), `__tool-icon`, `__tool-label`, `__controls`, `__footer`, `__time`, `__copy` (`--done`), `__retry`, `__bar`, `__bar-spacer`, `__modes`, `__mode` (`--on`), `__model-btn`, `__model-label`, `__model-chevron`, `__model-menu`, `__status` (`--ready`, `--pending`).
195
+
196
+ A message's role is the view's modifier, `twm-chat__msg--<role>`; the shell uses `user`, `assistant` and `error`. Every rule reads FlexDesk's tokens. Nothing is stored in a `data-` attribute beyond the tab strip's `data-tab-id` and the dropdown's own keys.
197
+
198
+ `tests/chat_shell.test.mjs` and `tests/chat_state_machine.test.mjs` cover all of this in jsdom.
File without changes
@@ -26,6 +26,7 @@ view.setBusy(false);
26
26
  | Method | Does |
27
27
  |---|---|
28
28
  | `append({ id?, role, text, meta? })` → id | Adds a message; `role` becomes `twm-chat__msg--<role>` |
29
+ | `remove(id)` → bool | Takes one message out ([below](#taking-one-message-out-remove-0180)) |
29
30
  | `replaceAll(messages)`, `clear()` | Restores a transcript (and scrolls to its end), or empties it |
30
31
  | `beginStream(message?)` → id, `appendChunk(text)`, `endStream()` | A message that grows; each chunk re-renders that message's body and nothing else |
31
32
  | `slot(id)` | The element below a message's body, yours to fill (tool output, buttons); a re-render leaves it alone |
@@ -70,3 +71,12 @@ view.setBusy(false);
70
71
 
71
72
  `tests/conversation_view.test.mjs` drives all of it in jsdom with a fake
72
73
  scroll geometry.
74
+
75
+ ## Taking one message out: `remove` (0.18.0)
76
+
77
+ `view.remove(id)` takes one message out and answers `true`, or `false` for an
78
+ unknown id. Every other message keeps its row and its slot, so it costs no
79
+ re-render and moves no reader, where `replaceAll` of the rest redrew all of them
80
+ and scrolled to the end. An open stream on that message is over: `streamingId()`
81
+ is `null` and later chunks go nowhere. It is how the
82
+ [assistant chat shell](chat-shell.md) takes out a reply that ended empty.
File without changes
File without changes
File without changes
@@ -0,0 +1,246 @@
1
+ # Filter builder
2
+
3
+ > **Package:** `@flexdesk/widgets` · **See also:** [Forms and dialogs](forms-and-dialogs.md) · [All docs](../README.md)
4
+
5
+ Recursive AND/OR/NOT filters. `createFilterModel` binds your field catalogue into a matcher, a describer and a validator, with no DOM. `createFilterBuilder` edits an expression anywhere you mount it, and `openFilterDialog` opens the same builder in a modal with a name, an icon and your live preview. FlexDesk knows nothing of what is filtered: fields, values and previews are yours.
6
+
7
+ ## The model: `createFilterModel` (0.18.0)
8
+
9
+ ```js
10
+ import { createFilterModel } from '@flexdesk/widgets';
11
+
12
+ const model = createFilterModel({
13
+ noun: 'order',
14
+ fields: [
15
+ { key: 'customer', label: 'Customer', type: 'text' },
16
+ { key: 'state', label: 'State', type: 'enum', options: ['open', { value: 'shut', label: 'Closed' }] },
17
+ { key: 'total', label: 'Total', type: 'number', get: (row) => row.amount.total },
18
+ { key: 'placed', label: 'Placed', type: 'date' },
19
+ { key: 'labels', label: 'Labels', type: 'set' },
20
+ { key: 'paid', label: 'Paid', type: 'bool' },
21
+ ],
22
+ });
23
+
24
+ const expr = {
25
+ kind: 'group', op: 'AND', children: [
26
+ { kind: 'clause', field: 'state', op: 'is', value: 'open' },
27
+ { kind: 'group', op: 'OR', negate: true, children: [
28
+ { kind: 'clause', field: 'total', op: 'gt', value: 1000 },
29
+ { kind: 'clause', field: 'labels', op: 'contains', value: 'rush' },
30
+ ] },
31
+ ],
32
+ };
33
+
34
+ rows.filter(model.matcherFor(expr));
35
+ model.describeFilter(expr); // "State is open AND NOT (Total > 1000 OR Labels contains rush)"
36
+ model.validateFilter(expr); // { ok: true, errors: [] }
37
+ ```
38
+
39
+ ### The expression
40
+
41
+ A group is `{ kind: 'group', op: 'AND' | 'OR', negate?: true, children: [...] }`, nested to any depth. A condition is `{ kind: 'clause', field, op, value? }`. It is plain JSON: store it as it is, or use `serializeFilter(expr)` and `parseFilter(text)`. `emptyFilter()` is a new empty AND group, and `cloneFilter(expr)` a deep copy. A node of any other kind is kept and never interpreted; it matches nothing and `validateFilter` reports it.
42
+
43
+ An empty AND matches everything. An empty OR matches everything too, unless the model is created with `emptyOr: 'nothing'`. `negate` flips either. `countConditions(expr)` counts the conditions in an expression; for a condition tree, see [below](#a-consumer-that-keeps-trees).
44
+
45
+ ### Fields
46
+
47
+ | Property | |
48
+ |---|---|
49
+ | `key`, `label`, `type` | `type` is `text`, `enum`, `number`, `date`, `set` (the value is a list) or `bool`. |
50
+ | `get(row)` | The COMPARABLE value, which need not be the displayed one. Default: `row[key]`. |
51
+ | `options` | An `enum` field's values, and a `set` field's when they are a fixed list: bare values, `{ value, label }` or `{ id, label }`. It may be a getter over live data; it is read at use. `filterOptions(field)` normalises them. |
52
+ | `operators` | Operator ids this field is offered, replacing its type's list. |
53
+ | `editor` | A custom value editor ([below](#a-custom-value-editor-0180)). |
54
+ | `hidden` | Not offered for a new condition. A condition already on it keeps working. |
55
+ | `group` | The field select's `<optgroup>` caption. |
56
+ | `time` | On a `date` field: compare instants and draw a date-and-time box, rather than compare days. |
57
+
58
+ Text comparison ignores case. A date compares by day: `'YYYY-MM-DD'`, and anything after the 10th character is ignored.
59
+
60
+ ### Operators
61
+
62
+ `FILTER_OPERATORS` is the catalogue by id, and `FILTER_TYPE_OPERATORS` what each type is offered by default:
63
+
64
+ | Type | Default operators |
65
+ |---|---|
66
+ | `text` | `is`, `is_not`, `contains`, `not_contains`, `starts_with`, `ends_with`, `one_of`, `none_of`, `is_empty`, `is_not_empty` |
67
+ | `enum` | `is`, `is_not`, `one_of`, `none_of`, `is_empty`, `is_not_empty` |
68
+ | `number` | `is`, `is_not`, `gt`, `lt`, `gte`, `lte`, `between`, `one_of`, `none_of`, `is_empty`, `is_not_empty` |
69
+ | `date` | `is`, `is_not`, `before`, `after`, `between`, `in_last_days`, `is_empty`, `is_not_empty` |
70
+ | `set` | `contains`, `not_contains`, `one_of`, `none_of`, `all_of`, `is_empty`, `is_not_empty` |
71
+ | `bool` | `is_true`, `is_false`, `is_empty` |
72
+
73
+ Also in the catalogue, for a field's `operators`: `is_null` and `is_not_null` (no value at all, as against empty), `gt`, `gte`, `lt` and `lte` on a date.
74
+
75
+ The `operators` option of `createFilterModel` adds an operator or relabels one. An id FlexDesk knows keeps what you leave out; a new one needs an `arity` (0 no value, 1 one, 2 a range). `multi: true` makes the value a list. `match(rowValue, clauseValue, row)` is what `matcherFor` runs for it; without one, a condition using it matches nothing here, which is fine for an operator only a server runs:
76
+
77
+ ```js
78
+ const model = createFilterModel({
79
+ fields: [{ key: 'owner', label: 'Owner', type: 'text', operators: ['is', 'is_me', 'is_empty'] }],
80
+ operators: [
81
+ { id: 'is', label: 'equals' },
82
+ { id: 'is_me', label: 'is me', arity: 0, match: (value) => value === me },
83
+ ],
84
+ });
85
+ ```
86
+
87
+ A field naming an operator the catalogue lacks throws when the model is created.
88
+
89
+ ### What the model answers
90
+
91
+ | | |
92
+ |---|---|
93
+ | `matcherFor(expr)` | `(row) => boolean`, compiled once and cached by the expression's JSON. An unknown field or operator matches nothing and warns. |
94
+ | `evaluateFilter(expr, row)` | One row. |
95
+ | `describeFilter(expr, { formatValue })` | One line of words. `formatValue(value, field)` names each value (a key shown as a name, say). An unknown field is said: `⚠ unknown field “gone”`; the `unknownName(key)` option of `createFilterModel` shapes the key shown. |
96
+ | `validateFilter(expr)` | `{ ok, errors }`, listing every problem, not the first. |
97
+ | `unknownFields(expr)` | The keys it names that the catalogue lacks, for a marker wherever a saved filter is listed. |
98
+ | `pruneFilter(expr)` | A copy without empty groups and conditions with nothing to compare against, or null when nothing is left. A condition the catalogue cannot read is kept. |
99
+ | `operatorsFor(key)`, `operatorById(id)`, `newClause(key?)`, `defaultValue(field, op)`, `fieldByKey(key)` | What the builder uses. |
100
+
101
+ `coerceFilterValue(field, text)` is the one place a control's string becomes a value. A `number` field writes a number when the string is a plain decimal a double holds exactly. It leaves the string alone when converting would change the filter: an empty box, a half-typed `-`, or a decimal wider than a double.
102
+
103
+ ## The condition tree (0.18.0)
104
+
105
+ A server that compiles conditions usually wants a different spelling. `toConditionTree(expr)` and `fromConditionTree(tree)` convert to and from:
106
+
107
+ ```js
108
+ { op: 'and', children: [
109
+ { column: 'state', operator: 'eq', value: 'open' },
110
+ { op: 'not', children: [{ op: 'or', children: [
111
+ { column: 'total', operator: 'gt', value: 1000 },
112
+ { column: 'labels', operator: 'contains', value: 'rush' },
113
+ ] }] },
114
+ ] }
115
+ ```
116
+
117
+ | Expression | Tree |
118
+ |---|---|
119
+ | `{ kind: 'group', op: 'AND', children }` | `{ op: 'and', children }` |
120
+ | `{ kind: 'group', op: 'OR', children }` | `{ op: 'or', children }` |
121
+ | `negate: true` on a group of one child | `{ op: 'not', children: [child] }` |
122
+ | `negate: true` on any other group | `{ op: 'not', children: [{ op, children }] }` |
123
+ | `{ kind: 'clause', field, op, value }` | `{ column, operator, value }` |
124
+ | `{ kind: 'opaque', node }` | `node`, untouched |
125
+ | `is`, `is_not` | `eq`, `ne` |
126
+ | `one_of`, `none_of` | `in`, `not_in` |
127
+ | `is_empty`, `is_not_empty` | `is_blank`, `is_not_blank` |
128
+ | `before`, `after` | `lt`, `gt` (one way: reading back gives `lt`, `gt`) |
129
+ | any other operator id | the same id |
130
+
131
+ What it preserves:
132
+
133
+ - **A tree comes back identical.** `toConditionTree(fromConditionTree(tree))` equals `tree`, up to the case of `op` and `operator`, which the conversion lower-cases. That includes an operator nobody knows, a `value` of any shape, keys it has never heard of on a node, and a missing `value`. A node it cannot read (an unknown `op`, a `not` of anything but one child) is carried as `{ kind: 'opaque', node }`, which the builder shows read-only.
134
+ - **An expression comes back the same predicate.** The ids may differ in two ways: `before`/`after` read back as `lt`/`gt`, and a negated group of one child reads back as a negated AND.
135
+ - `fromConditionTree(null)` is the empty filter. To send "no filter" as null: `const kept = model.pruneFilter(expr); return kept && toConditionTree(kept);`.
136
+
137
+ Nothing else differs, except what an empty OR means: the model's default is "everything", and a compiler following SQL's identities says "nothing", so a model whose expressions go to one is created with `emptyOr: 'nothing'`.
138
+
139
+ ### A consumer that keeps trees
140
+
141
+ Everything in the model takes expressions. A bare tree is not one, and is not refused: `countConditions({ op: 'and', children: [a, b] })` is 1, as is the same call on an empty group. Convert first:
142
+
143
+ ```js
144
+ const count = (tree) => countConditions(fromConditionTree(tree)); // 2 for that tree, 0 for an empty one or null
145
+ ```
146
+
147
+ **The server's operators stay the consumer's.** `FILTER_OPERATORS` is keyed by FlexDesk's ids (`is`, `one_of`, `is_empty`), not a tree's (`eq`, `in`, `is_blank`), and it knows nothing of operators a server adds. FlexDesk has no list of what your server compiles, so that list, and any check that it agrees with the server, stays with you, under the server's ids. Converting a bare condition gives the id to build the model from:
148
+
149
+ ```js
150
+ const ourId = (id) => fromConditionTree({ column: '', operator: id }).op; // 'eq' -> 'is'
151
+
152
+ const model = createFilterModel({
153
+ fields: columns.map((c) => ({ key: c.name, label: c.title, type: c.type, operators: offeredFor(c).map(ourId) })),
154
+ operators: Object.entries(SERVER_OPERATORS).map(([id, [label, arity]]) => ({ id: ourId(id), label, arity })),
155
+ emptyOr: 'nothing',
156
+ });
157
+ ```
158
+
159
+ `toConditionTree` sends each one back under the server's id. An arity that disagrees with FlexDesk's is taken as given, and draws the wrong control, so a consumer's own test should compare `FILTER_OPERATORS[ourId(id)]?.arity` with its list.
160
+
161
+ ## The builder: `createFilterBuilder` (0.18.0)
162
+
163
+ ```js
164
+ import { createFilterBuilder } from '@flexdesk/widgets';
165
+
166
+ const builder = createFilterBuilder(hostEl, {
167
+ model,
168
+ value: expr, // copied; omit for an empty group
169
+ onChange: (next) => apply(next), // a copy, after each change
170
+ });
171
+ builder.getValue();
172
+ builder.setValue(other); // does not call onChange
173
+ builder.validate();
174
+ builder.focus();
175
+ builder.destroy();
176
+ ```
177
+
178
+ Each group has an AND/OR toggle, NOT, `+ Condition` and `+ Group`; each condition a field select, an operator select and a value control drawn from the field's type and the operator. Removing the last condition of a nested group removes the group; the root may stay empty. A structural change redraws the tree and puts the focus back on what you were using; typing redraws nothing, so the caret stays.
179
+
180
+ **It rewrites nothing it cannot read.** A condition on a field the catalogue lacks, an operator it lacks, an operator this field is not offered, an `enum` value no longer offered and a carried node all stay exactly as they are until someone changes or removes them: the selects show them as their own entry (`⚠ unknown field “gone”`, `made_up (unknown)`), and a value it cannot interpret is shown read-only. Opening and closing a builder leaves the expression byte-identical, with one exception: an `enum` condition with no value takes the first option, because a select cannot show nothing.
181
+
182
+ ### A custom value editor (0.18.0)
183
+
184
+ `field.editor` replaces the value control. It is a factory, called each time the value cell is drawn:
185
+
186
+ ```js
187
+ {
188
+ key: 'owner', label: 'Owner', type: 'text',
189
+ operators: ['is', 'is_not', 'is_empty'],
190
+ editor: ({ field, op, value, label, onChange }) => {
191
+ const picker = createPersonPicker({ value, ariaLabel: label, onPick: (p) => onChange(p?.id ?? '') });
192
+ return { el: picker.el, destroy: () => picker.destroy() };
193
+ },
194
+ }
195
+ ```
196
+
197
+ Return `{ el, destroy? }`; `destroy` is called when the cell is redrawn or the builder destroyed, so an editor with a floating list can take it off the page. Return nothing to get the default control for that operator. It is not called for an operator with no value. For a `multi` or range operator, `value` and `onChange` deal in the whole list. To show a stored key as a name in the description, pass `formatValue` to `describeFilter`.
198
+
199
+ ## The dialog: `openFilterDialog` (0.18.0)
200
+
201
+ The builder in a [modal](forms-and-dialogs.md#windows-closing-focus-and-esc-01716), so its chrome, focus trap, Esc handling and stacking are every other modal's.
202
+
203
+ ```js
204
+ const result = await openFilterDialog({
205
+ model,
206
+ value: saved?.expr,
207
+ title: saved ? 'Edit filter' : 'New filter',
208
+ name: { value: saved?.label ?? '', placeholder: 'e.g. Large open orders' },
209
+ iconChoice: { value: saved?.icon, options: ['flag', 'star', 'bolt'] },
210
+ confirmDiscard: true,
211
+ submitLabel: 'Save',
212
+ onSubmit: (r) => store.save({ id: saved?.id, label: r.name, icon: r.icon, expr: r.expr }),
213
+ actions: saved ? [{
214
+ label: 'Delete', value: 'delete', danger: true,
215
+ confirm: { title: 'Delete this filter?', message: 'This cannot be undone.', confirmLabel: 'Delete', danger: true },
216
+ run: () => store.remove(saved.id),
217
+ }] : [],
218
+ preview: (host, { expr, signal }) => {
219
+ const hits = rows.filter(model.matcherFor(expr));
220
+ host.textContent = `${hits.length} of ${rows.length} orders match`;
221
+ },
222
+ });
223
+ // null when dismissed; else { action: 'submit' | 'clear' | yours, expr, name?, icon? }
224
+ ```
225
+
226
+ | Option | |
227
+ |---|---|
228
+ | `model`, `value` | As the builder's. `value` is copied, so dismissing loses nothing. |
229
+ | `seedField` | A field key: adds a new condition on it, for "filter by this". |
230
+ | `title`, `icon`, `width`, `height` | The window. |
231
+ | `head` | An element drawn above the conditions. |
232
+ | `name` | `{ value, label, placeholder, required }` adds a name box. `required` defaults to true and holds the submit button off until it is filled. The result's `name` is trimmed. |
233
+ | `iconChoice` | `{ value, options }` adds a searchable grid over those Material Symbols names. |
234
+ | `preview` | `(host, { expr, sentence, signal })`: yours to draw into. Called on open and, debounced, after each change, while the expression can be submitted; otherwise the slot is emptied and hidden and the errors are shown. `signal` aborts when this drawing is superseded or the dialog closes, for a preview that asks a server. The dialog shows `sentence` above the slot. |
235
+ | `requireValid` | Default true: the submit button is off, and the errors are listed, while `validateFilter` objects. False submits regardless. |
236
+ | `prune` | Validate, preview and submit `model.pruneFilter(...)`, so a half-built row is dropped rather than refused. `expr` is null when nothing is left. |
237
+ | `confirmDiscard` | Ask before Cancel, Esc or the X throws away a change. |
238
+ | `submitLabel`, `onSubmit` | `onSubmit(result)` may be async: until it settles every button is off and Esc and the X do nothing, so the answer always says what happened. If it throws, the message is shown and the dialog stays. A promise that never settles holds the dialog open, so give a call that can hang its own timeout. |
239
+ | `clearLabel` | Adds a button answering `{ action: 'clear', expr: null }`, for a caller to whom "no filter" and "leave it alone" are different answers. |
240
+ | `actions` | More buttons, before Cancel: `{ label, value, danger, icon, confirm, run }`. `confirm` is `openConfirm`'s options, asked first, and `run` behaves as `onSubmit` does. |
241
+
242
+ `confirmDiscard` asks through [`openModal`'s `beforeClose`](forms-and-dialogs.md#a-modal-that-can-refuse-to-close-beforeclose-0180).
243
+
244
+ ## Styling
245
+
246
+ Classes are `twm-filter-builder*` and `twm-filter-dialog*`. Every rule reads tokens; retheme through those.
File without changes
File without changes
File without changes
File without changes
@@ -99,6 +99,20 @@ submit with "onCreate is not a function".
99
99
  `tests/open_form.test.mjs` mounts the real `openForm` in jsdom and covers all
100
100
  four, plus a plain form submitting as before.
101
101
 
102
+ #### It is `createCombobox` (0.18.0)
103
+
104
+ The field draws with [`createCombobox`](input-fields.md#a-text-box-that-also-offers-a-list-createcombobox-0180)
105
+ instead of an `<input list>` and a `<datalist>`, so its list shows each option's label and
106
+ `description`, and looks like the rest of the app. What it submits has not changed.
107
+
108
+ - **Enter with the list open closes the list; the next one submits.** Escape closes the list and
109
+ leaves the form open. No row is active until the arrow keys pick one, so Enter keeps what was typed.
110
+ - The markup differs: no `<datalist>`, and `.twm-combobox__hint` has no `data-for`. Read it
111
+ under its row, `[data-field-row="kind"] .twm-combobox__hint`. The input is still `[name="kind"]`.
112
+
113
+ A plain `select` stays a native `<select>`: `selectBox` is shared with the charts,
114
+ the settings page and the flow kit, so drawing a form's with `createSelect` would remove no code.
115
+
102
116
  ### Options: typed, unavailable, grouped, and changed while open (0.17.0)
103
117
 
104
118
  ```js
@@ -310,6 +324,14 @@ the caller's own DOM.
310
324
  `tests/confirm_escaping.test.mjs` opens each of them in jsdom with hostile
311
325
  names and labels.
312
326
 
327
+ ## A modal that can refuse to close: `beforeClose` (0.18.0)
328
+
329
+ `openModal({ beforeClose })` takes `() => boolean | Promise<boolean>`, as
330
+ `ManagedWindow`'s does. It is asked when Esc or the X would dismiss the modal, and
331
+ answering false keeps it open. An action button does not ask it. A modal it lets
332
+ close resolves `null`, as any dismissal does. `openFilterDialog`'s `confirmDiscard`
333
+ is built on it.
334
+
313
335
  ## Windows: closing, focus and Esc (0.17.16)
314
336
 
315
337
  Every dialog above is a `ManagedWindow`, and so is any window you build