flexdesk 0.17.18 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7547 -5891
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +30 -1
  6. package/dist/charts.js +83 -13
  7. package/dist/charts.js.map +2 -2
  8. package/dist/chunk-3BLIVOFD.js +778 -0
  9. package/dist/chunk-3BLIVOFD.js.map +7 -0
  10. package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
  11. package/dist/chunk-4X4SY3CP.js.map +7 -0
  12. package/dist/chunk-DNJ3N4T4.js +82 -0
  13. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  14. package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
  15. package/dist/chunk-HL3IIHU3.js.map +7 -0
  16. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  17. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  18. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  19. package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
  20. package/dist/chunk-NFZJ6P42.js.map +7 -0
  21. package/dist/chunk-PAI2ZUWV.js +260 -0
  22. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  23. package/dist/chunk-Q7BALEPX.js +527 -0
  24. package/dist/chunk-Q7BALEPX.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
  26. package/dist/chunk-QRBOIUU3.js.map +7 -0
  27. package/dist/chunk-QXAOTGCC.js +40 -0
  28. package/dist/chunk-QXAOTGCC.js.map +7 -0
  29. package/dist/chunk-UKP22RUG.js +219 -0
  30. package/dist/chunk-UKP22RUG.js.map +7 -0
  31. package/dist/chunk-VLOAI3HS.js +43 -0
  32. package/dist/chunk-VLOAI3HS.js.map +7 -0
  33. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  34. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7547 -5891
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +30 -1
  46. package/dist/widgets.js +4146 -1199
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/charts.md +40 -0
  53. package/docs/components/chat-shell.md +204 -0
  54. package/docs/components/conversation.md +10 -0
  55. package/docs/components/filter-builder.md +292 -0
  56. package/docs/components/forms-and-dialogs.md +30 -0
  57. package/docs/components/git.md +355 -0
  58. package/docs/components/input-fields.md +191 -2
  59. package/docs/components/keymap.md +8 -0
  60. package/docs/components/menus.md +86 -2
  61. package/docs/components/panels-and-desktops.md +16 -5
  62. package/docs/components/shell.md +108 -1
  63. package/docs/components/workspace-picker.md +193 -0
  64. package/docs/concepts/building-and-packaging.md +2 -2
  65. package/docs/concepts/packages-and-entry-points.md +14 -13
  66. package/docs/concepts/state-guard.md +3 -3
  67. package/docs/quickstart.md +1 -1
  68. package/git.js +13 -0
  69. package/package.json +6 -1
  70. package/src/charts/plot_config_editor.js +92 -6
  71. package/src/charts/plot_grid.js +6 -1
  72. package/src/flow/kit/dom.js +28 -119
  73. package/src/flow/kit/step_picker.js +1 -1
  74. package/src/flow/kit/value_picker.js +1 -1
  75. package/src/flow/outline/menu.js +1 -1
  76. package/src/git/diff_viewer.js +212 -0
  77. package/src/git/diff_window.js +51 -0
  78. package/src/git/git_graph.js +401 -0
  79. package/src/git/git_ops.js +61 -0
  80. package/src/git/git_panel.js +755 -0
  81. package/src/git/index.js +9 -0
  82. package/src/git/line_diff.js +202 -0
  83. package/src/tiles/tile_base.js +5 -4
  84. package/src/tiling/desktops.js +10 -8
  85. package/src/tiling/keymap.js +6 -5
  86. package/src/tiling/kind_taxonomy.js +15 -2
  87. package/src/tiling/shell.js +124 -17
  88. package/src/tiling/tile_renderer.js +2 -3
  89. package/src/ui/components/action_dropdown.js +4 -78
  90. package/src/ui/components/anchored_layer.js +255 -0
  91. package/src/ui/components/chat_shell.js +926 -0
  92. package/src/ui/components/chat_state_machine.js +223 -0
  93. package/src/ui/components/choice_controls.js +45 -2
  94. package/src/ui/components/color_field.js +3 -2
  95. package/src/ui/components/color_list_field.js +3 -2
  96. package/src/ui/components/combobox.js +422 -0
  97. package/src/ui/components/context_menu.js +8 -2
  98. package/src/ui/components/conversation_view.js +17 -2
  99. package/src/ui/components/data_table.js +2 -2
  100. package/src/ui/components/filter_builder.js +791 -0
  101. package/src/ui/components/filter_model.js +682 -0
  102. package/src/ui/components/inline_renamer.js +10 -5
  103. package/src/ui/components/managed_window.js +24 -47
  104. package/src/ui/components/modal.js +104 -117
  105. package/src/ui/components/option_list.js +160 -0
  106. package/src/ui/components/popover.js +94 -0
  107. package/src/ui/components/recent_items.js +169 -0
  108. package/src/ui/components/select.js +322 -0
  109. package/src/ui/components/tag_input.js +77 -8
  110. package/src/ui/components/workspace_picker.js +1253 -0
  111. package/src/ui/utils/fire_event.js +17 -0
  112. package/src/ui/utils/focusable.js +25 -0
  113. package/src/ui/utils/modal_host.js +27 -0
  114. package/src/ui/utils/tooltip_service.js +81 -14
  115. package/widgets.js +12 -2
  116. package/css/base.css +0 -10547
  117. package/css/overrides.css +0 -2596
  118. package/dist/chunk-2Z2ENHR3.js.map +0 -7
  119. package/dist/chunk-7JI7HHSY.js +0 -107
  120. package/dist/chunk-7JI7HHSY.js.map +0 -7
  121. package/dist/chunk-7OL6F2AM.js.map +0 -7
  122. package/dist/chunk-MIW7D6WC.js +0 -526
  123. package/dist/chunk-MIW7D6WC.js.map +0 -7
  124. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  125. package/dist/chunk-XJNADRGR.js.map +0 -7
  126. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,292 @@
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, groupBrackets })` | One line of words. [What it says](#what-a-sentence-says-0181). |
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, and so is a range with one end filled. |
99
+ | `partialRanges(expr)` | What is said of each range with one end filled: `Count between: needs two values`. [The dialog refuses one](#a-range-with-one-end-filled-is-refused-0181). |
100
+ | `moveClause(clause, key)` | The condition on another field. [What it keeps](#what-a-condition-keeps-when-its-field-changes-0181). Null when the catalogue has no such field. |
101
+ | `operatorsFor(key)`, `operatorById(id)`, `newClause(key?)`, `defaultValue(field, op)`, `fieldByKey(key)` | What the builder uses. |
102
+
103
+ `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.
104
+
105
+ ### What a sentence says (0.18.1)
106
+
107
+ `formatValue(value, field)` names each value (a key shown as a name, say); without it a value is shown as itself.
108
+
109
+ - **Every value of a list**, under any operator: `Tags contains a, b`, not the first alone. A saved condition or one from elsewhere may hold several under an operator that takes one.
110
+ - **A field the catalogue does not know is said, with what the condition asks:** `⚠ unknown field “gone” is x`. The `unknownName(key)` option of `createFilterModel` shapes the key shown. `formatValue` is not asked about it, because there is no field to hand it. An operator the catalogue does not know is said as its id, with its value.
111
+ - **Brackets** go where the sentence would misread without them: a nested group of two or more, and whatever a NOT covers: `A AND (B OR C)`, `NOT (A)`. With `groupBrackets: true` every group of two or more has them, the root too, and a NOT of one thing has none: `(A AND (B OR C))`, `NOT A`. That is for a surface that reads a group by its brackets.
112
+
113
+ ## The condition tree (0.18.0)
114
+
115
+ A server that compiles conditions usually wants a different spelling. `toConditionTree(expr)` and `fromConditionTree(tree)` convert to and from:
116
+
117
+ ```js
118
+ { op: 'and', children: [
119
+ { column: 'state', operator: 'eq', value: 'open' },
120
+ { op: 'not', children: [{ op: 'or', children: [
121
+ { column: 'total', operator: 'gt', value: 1000 },
122
+ { column: 'labels', operator: 'contains', value: 'rush' },
123
+ ] }] },
124
+ ] }
125
+ ```
126
+
127
+ | Expression | Tree |
128
+ |---|---|
129
+ | `{ kind: 'group', op: 'AND', children }` | `{ op: 'and', children }` |
130
+ | `{ kind: 'group', op: 'OR', children }` | `{ op: 'or', children }` |
131
+ | `negate: true` on a group of one child | `{ op: 'not', children: [child] }` |
132
+ | `negate: true` on any other group | `{ op: 'not', children: [{ op, children }] }` |
133
+ | `{ kind: 'clause', field, op, value }` | `{ column, operator, value }` |
134
+ | `{ kind: 'opaque', node }` | `node`, untouched |
135
+ | `is`, `is_not` | `eq`, `ne` |
136
+ | `one_of`, `none_of` | `in`, `not_in` |
137
+ | `is_empty`, `is_not_empty` | `is_blank`, `is_not_blank` |
138
+ | `before`, `after` | `lt`, `gt` (one way: reading back gives `lt`, `gt`) |
139
+ | any other operator id | the same id |
140
+
141
+ What it preserves:
142
+
143
+ - **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.
144
+ - **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.
145
+ - `fromConditionTree(null)` is the empty filter. To send "no filter" as null: `const kept = model.pruneFilter(expr); return kept && toConditionTree(kept);`.
146
+
147
+ 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'`.
148
+
149
+ ### A consumer that keeps trees
150
+
151
+ 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:
152
+
153
+ ```js
154
+ const count = (tree) => countConditions(fromConditionTree(tree)); // 2 for that tree, 0 for an empty one or null
155
+ ```
156
+
157
+ **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:
158
+
159
+ ```js
160
+ const ourId = (id) => fromConditionTree({ column: '', operator: id }).op; // 'eq' -> 'is'
161
+
162
+ const model = createFilterModel({
163
+ fields: columns.map((c) => ({ key: c.name, label: c.title, type: c.type, operators: offeredFor(c).map(ourId) })),
164
+ operators: Object.entries(SERVER_OPERATORS).map(([id, [label, arity]]) => ({ id: ourId(id), label, arity })),
165
+ emptyOr: 'nothing',
166
+ });
167
+ ```
168
+
169
+ `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.
170
+
171
+ ## The builder: `createFilterBuilder` (0.18.0)
172
+
173
+ ```js
174
+ import { createFilterBuilder } from '@flexdesk/widgets';
175
+
176
+ const builder = createFilterBuilder(hostEl, {
177
+ model,
178
+ value: expr, // copied; omit for an empty group
179
+ onChange: (next) => apply(next), // a copy, after each change
180
+ });
181
+ builder.getValue();
182
+ builder.setValue(other); // does not call onChange
183
+ builder.validate();
184
+ builder.focus();
185
+ builder.destroy();
186
+ ```
187
+
188
+ 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.
189
+
190
+ **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.
191
+
192
+ ### What a condition keeps when its field changes (0.18.1)
193
+
194
+ Choosing another field for a condition goes through `model.moveClause(clause, key)`:
195
+
196
+ - The **operator** stays when the new field offers it. Otherwise the condition starts on the field's first operator, as `newClause(key)` does.
197
+ - The **value** stays when it still fits: it passes `validateFilter` on the new field and, where the editor offers a list (an `enum` field, a `multi` operator on a field with `options`), it is on that list. Otherwise it starts as `defaultValue(field, op)`. `validateFilter` checks every value of a `multi` operator as it does a single one, so `one_of ['abc']` on a number field is refused and does not move there. A value that does fit is kept as it stands: `is '5'` moved to a number field is still the string `'5'`.
198
+
199
+ A condition whose field or operator cannot be drawn shows the value it holds read-only: an unknown field, an unknown operator, or a known operator its field does not offer. A box drawn for that operator would look empty over a value the result still carries. A custom editor is not asked about one. An unknown field's whole warning is the field select's tooltip, and the select takes its width from it where the row can spare that.
200
+
201
+ ### A custom value editor (0.18.0)
202
+
203
+ `field.editor` replaces the value control. It is a factory, called each time the value cell is drawn:
204
+
205
+ ```js
206
+ {
207
+ key: 'owner', label: 'Owner', type: 'text',
208
+ operators: ['is', 'is_not', 'is_empty'],
209
+ editor: ({ field, op, value, label, onChange }) => {
210
+ const picker = createPersonPicker({ value, ariaLabel: label, onPick: (p) => onChange(p?.id ?? '') });
211
+ return { el: picker.el, destroy: () => picker.destroy() };
212
+ },
213
+ }
214
+ ```
215
+
216
+ 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`.
217
+
218
+ ## The dialog: `openFilterDialog` (0.18.0)
219
+
220
+ 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.
221
+
222
+ ```js
223
+ const result = await openFilterDialog({
224
+ model,
225
+ value: saved?.expr,
226
+ title: saved ? 'Edit filter' : 'New filter',
227
+ name: { value: saved?.label ?? '', placeholder: 'e.g. Large open orders' },
228
+ iconChoice: { value: saved?.icon, options: ['flag', 'star', 'bolt'] },
229
+ confirmDiscard: true,
230
+ submitLabel: 'Save',
231
+ onSubmit: (r) => store.save({ id: saved?.id, label: r.name, icon: r.icon, expr: r.expr }),
232
+ actions: saved ? [{
233
+ label: 'Delete', value: 'delete', danger: true,
234
+ confirm: { title: 'Delete this filter?', message: 'This cannot be undone.', confirmLabel: 'Delete', danger: true },
235
+ run: () => store.remove(saved.id),
236
+ }] : [],
237
+ preview: (host, { expr, signal }) => {
238
+ const hits = rows.filter(model.matcherFor(expr));
239
+ host.textContent = `${hits.length} of ${rows.length} orders match`;
240
+ },
241
+ });
242
+ // null when dismissed; else { action: 'submit' | 'clear' | yours, expr, name?, icon? }
243
+ ```
244
+
245
+ | Option | |
246
+ |---|---|
247
+ | `model`, `value` | As the builder's. `value` is copied, so dismissing loses nothing. |
248
+ | `seedField` | A field key: adds a new condition on it, for "filter by this". [Beside a negated root](#a-seed-beside-a-negated-root-0181), not inside it. |
249
+ | `title`, `icon`, `width`, `height` | The window. |
250
+ | `head` | An element drawn above the conditions. |
251
+ | `name` | `{ value, label, placeholder, required }` adds a name box, which opens with focus and [its text selected](#the-name-box-opens-selected-0181). `required` defaults to true and holds the submit button off until it is filled. The result's `name` is trimmed. |
252
+ | `iconChoice` | `{ value, options }` adds a searchable grid over those Material Symbols names. |
253
+ | `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. |
254
+ | `requireValid` | Default true: the submit button is off, and the errors are listed, while `validateFilter` objects. False submits regardless, except [a range with one end filled](#a-range-with-one-end-filled-is-refused-0181). |
255
+ | `prune` | Validate, preview and submit `model.pruneFilter(...)`, so a half-built row is dropped rather than refused. `expr` is null when nothing is left. |
256
+ | `confirmDiscard` | Ask before Cancel, Esc or the X throws away a change. |
257
+ | `dismissOnBackdrop` | [A click on the dimmed page beside the dialog dismisses it](#a-click-beside-the-dialog-dismissonbackdrop-0181), as Esc does. |
258
+ | `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. |
259
+ | `clearLabel` | Adds a button answering `{ action: 'clear', expr: null }`, for a caller to whom "no filter" and "leave it alone" are different answers. |
260
+ | `actions` | More buttons, before Cancel: `{ label, value, danger, icon, confirm, run }`. `confirm` is `openConfirm`'s options, asked first, [with a `message` that may be computed](#a-question-that-quotes-what-was-typed-0181), and `run` behaves as `onSubmit` does. |
261
+
262
+ `confirmDiscard` asks through [`openModal`'s `beforeClose`](forms-and-dialogs.md#a-modal-that-can-refuse-to-close-beforeclose-0180).
263
+
264
+ ### A range with one end filled is refused (0.18.1)
265
+
266
+ `between` with `[5, null]` is not blank, so `prune` keeps it, and it matches nothing: with `requireValid: false` it used to leave the dialog, and a filter that a server compiles to `BETWEEN 5 AND NULL` emptied the view with no word why. The submit button is now off and the dialog says `Count between: needs two values`, whatever `requireValid` and `prune` say. A range with both ends blank is still a half-built row, which `prune` drops.
267
+
268
+ `model.pruneFilter` keeps such a range for a caller without the dialog, and `model.partialRanges(expr)` names them.
269
+
270
+ ### A seed beside a negated root (0.18.1)
271
+
272
+ A new condition narrows what the filter already says. Pushed into a negated root it would become `NOT (title is x AND qty is 7)`, and rows the filter excluded would come back. So `seedField` on a negated root wraps it: `(NOT (title is x)) AND qty is 7`.
273
+
274
+ ### The name box opens selected (0.18.1)
275
+
276
+ The name box takes focus when the dialog opens, with its text selected: editing a name usually means replacing it, and `Mine (copy)` always does. The rest of the library's dialogs keep the caret at the end, and [`openModal` leaves focus where `onMount` put it](forms-and-dialogs.md#focus-placed-by-onmount-stays-0181).
277
+
278
+ ### A click beside the dialog: `dismissOnBackdrop` (0.18.1)
279
+
280
+ `openFilterDialog({ dismissOnBackdrop: true })` dismisses on a click on the dimmed page beside it, through [`openModal`'s option of that name](forms-and-dialogs.md#a-click-beside-a-modal-dismissonbackdrop-0181). `confirmDiscard` still asks, and an action in flight still holds it open.
281
+
282
+ ### A question that quotes what was typed (0.18.1)
283
+
284
+ `confirm.message` may be `(result) => string`, called when the question is asked with what the dialog would answer (`{ action, expr, name?, icon? }`):
285
+
286
+ ```js
287
+ confirm: { title: 'Delete this filter?', message: ({ name }) => `Delete “${name}”?`, confirmLabel: 'Delete', danger: true },
288
+ ```
289
+
290
+ ## Styling
291
+
292
+ Classes are `twm-filter-builder*` and `twm-filter-dialog*`. Every rule reads tokens; retheme through those.
@@ -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,22 @@ 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
+
335
+ ## A click beside a modal: `dismissOnBackdrop` (0.18.1)
336
+
337
+ `openModal({ dismissOnBackdrop: true })` dismisses on a click on the dimmed page beside the modal, as Esc does: `beforeClose` is asked, and the modal resolves `null`. A drag that starts in the modal and ends on the page is no click on the backdrop. The press takes no focus from the modal, so when `beforeClose` refuses, the caret is where it was. Default `false`: the backdrop only blocks, which is what it has always done, whatever older comments in the source said.
338
+
339
+ ## Focus placed by `onMount` stays (0.18.1)
340
+
341
+ `openModal` focuses the first field, or the primary button, a frame after `onMount` ran. It now leaves focus alone when `onMount` already put it inside the modal, with its selection. Before, the first field took it and the caret went to the end.
342
+
313
343
  ## Windows: closing, focus and Esc (0.17.16)
314
344
 
315
345
  Every dialog above is a `ManagedWindow`, and so is any window you build