@zeyos/zx 4.3.4 → 4.4.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 (128) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +7 -0
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar.d.ts +19 -0
  8. package/dist/types/components/card/card.d.ts +7 -0
  9. package/dist/types/components/card-view/card-view.d.ts +11 -3
  10. package/dist/types/components/card-view/record-card.d.ts +7 -1
  11. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  12. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  13. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  14. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  15. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  16. package/dist/types/components/dialog/dialog.d.ts +29 -0
  17. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  18. package/dist/types/components/file-item/file-item.d.ts +234 -0
  19. package/dist/types/components/file-list/file-list.d.ts +171 -0
  20. package/dist/types/components/filter/filter.d.ts +91 -36
  21. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  22. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  23. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  24. package/dist/types/components/launcher/launcher.d.ts +19 -12
  25. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  26. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  27. package/dist/types/components/message/message.d.ts +7 -0
  28. package/dist/types/components/modal/modal.d.ts +48 -0
  29. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  30. package/dist/types/components/panel/panel.d.ts +7 -0
  31. package/dist/types/components/search/search.d.ts +7 -0
  32. package/dist/types/components/select/select.d.ts +4 -0
  33. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  34. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  35. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  36. package/dist/types/components/table/table.d.ts +144 -0
  37. package/dist/types/components/table-view/table-view.d.ts +11 -5
  38. package/dist/types/components/timebox/timebox.d.ts +7 -0
  39. package/dist/types/components/value-list/value-list.d.ts +8 -0
  40. package/dist/types/components/view/record-view.d.ts +96 -5
  41. package/dist/types/core/icons.d.ts +17 -0
  42. package/dist/types/index.d.ts +11 -0
  43. package/dist/types/internal/app-rail.d.ts +10 -0
  44. package/dist/types/internal/menu-items.d.ts +134 -0
  45. package/dist/zx-zeyos.esm.js +616 -77
  46. package/dist/zx-zeyos.esm.min.js +8 -1
  47. package/dist/zx.css +2085 -238
  48. package/dist/zx.esm.js +6704 -1542
  49. package/dist/zx.esm.min.js +10 -3
  50. package/dist/zx.global.js +6704 -1542
  51. package/dist/zx.global.min.js +10 -3
  52. package/dist/zx.min.css +1 -1
  53. package/docs/api.json +1479 -127
  54. package/docs/llms.md +315 -10
  55. package/docs/llms.txt +11 -5
  56. package/package.json +1 -1
  57. package/src/components/activity-item/activity-item.css +137 -0
  58. package/src/components/activity-item/activity-item.js +599 -0
  59. package/src/components/activity-list/activity-list.css +64 -0
  60. package/src/components/activity-list/activity-list.js +375 -0
  61. package/src/components/app-rail/app-rail.css +227 -19
  62. package/src/components/app-sidebar/app-sidebar.css +414 -24
  63. package/src/components/app-sidebar/app-sidebar.js +65 -5
  64. package/src/components/button/button.css +8 -0
  65. package/src/components/button/button.js +6 -1
  66. package/src/components/calendar/calendar.js +33 -3
  67. package/src/components/card/card.js +12 -1
  68. package/src/components/card-view/card-view.css +1 -0
  69. package/src/components/card-view/card-view.js +25 -9
  70. package/src/components/card-view/record-card.js +7 -3
  71. package/src/components/code-editor/code-editor.css +67 -0
  72. package/src/components/code-editor/code-editor.js +288 -0
  73. package/src/components/code-editor/field-adapter.js +34 -0
  74. package/src/components/context-menu/context-menu.css +80 -3
  75. package/src/components/context-menu/context-menu.js +22 -30
  76. package/src/components/date-picker/date-picker.js +22 -8
  77. package/src/components/date-picker/month-picker.js +3 -3
  78. package/src/components/date-picker/time-picker.js +20 -8
  79. package/src/components/dialog/dialog.js +88 -66
  80. package/src/components/dropdown/dropdown.js +2 -2
  81. package/src/components/entity-ref/entity-ref.css +182 -0
  82. package/src/components/entity-ref/entity-ref.js +496 -0
  83. package/src/components/file-item/file-item.css +88 -0
  84. package/src/components/file-item/file-item.js +371 -0
  85. package/src/components/file-list/file-list.css +51 -0
  86. package/src/components/file-list/file-list.js +320 -0
  87. package/src/components/filter/filter.css +27 -1
  88. package/src/components/filter/filter.js +199 -45
  89. package/src/components/filter-panel/filter-panel.css +104 -0
  90. package/src/components/filter-panel/filter-panel.js +964 -0
  91. package/src/components/kanban-view/kanban-card.js +239 -0
  92. package/src/components/kanban-view/kanban-view.css +194 -0
  93. package/src/components/kanban-view/kanban-view.js +251 -8
  94. package/src/components/launcher/launcher.js +59 -27
  95. package/src/components/list-toolbar/list-toolbar.css +189 -0
  96. package/src/components/list-toolbar/list-toolbar.js +851 -0
  97. package/src/components/menu-button/menu-button.css +80 -3
  98. package/src/components/menu-button/menu-button.js +21 -37
  99. package/src/components/message/message.css +5 -2
  100. package/src/components/message/message.js +13 -2
  101. package/src/components/modal/modal.css +15 -0
  102. package/src/components/modal/modal.js +141 -0
  103. package/src/components/navigation-bar/navigation-bar.css +77 -2
  104. package/src/components/navigation-bar/navigation-bar.js +171 -4
  105. package/src/components/panel/panel.js +13 -2
  106. package/src/components/search/search.js +17 -5
  107. package/src/components/select/field-adapter.js +4 -1
  108. package/src/components/select/select.js +3 -0
  109. package/src/components/sort-control/sort-control.css +17 -0
  110. package/src/components/sort-control/sort-control.js +140 -0
  111. package/src/components/stat-tile/stat-tile.css +213 -0
  112. package/src/components/stat-tile/stat-tile.js +590 -0
  113. package/src/components/tabbox/tabbox.js +12 -1
  114. package/src/components/table/table.css +51 -2
  115. package/src/components/table/table.js +298 -19
  116. package/src/components/table-view/table-view.css +8 -2
  117. package/src/components/table-view/table-view.js +21 -6
  118. package/src/components/tag-picker/tag-picker.css +3 -1
  119. package/src/components/timebox/timebox.js +18 -5
  120. package/src/components/value-list/value-list.js +22 -6
  121. package/src/components/view/record-view.css +5 -0
  122. package/src/components/view/record-view.js +100 -7
  123. package/src/core/icons.js +74 -5
  124. package/src/index.js +25 -0
  125. package/src/internal/app-rail.js +19 -1
  126. package/src/internal/menu-items.js +142 -0
  127. package/styles/print.css +192 -0
  128. package/styles/zx.css +11 -0
@@ -0,0 +1,851 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
+ import { printf } from '../../core/i18n.js';
4
+ import { icon } from '../../core/icons.js';
5
+ import { isElement } from '../../core/util.js';
6
+ import { badge } from '../badge/badge.js';
7
+ import { Search } from '../search/search.js';
8
+
9
+ /**
10
+ * Built-in view shapes, so `views: ['auto', 'cards', 'table']` needs no labels of its own. A host
11
+ * that wants other words hands descriptors instead.
12
+ *
13
+ * `auto` deliberately carries no icon: no glyph in the set says "whatever fits", and an icon that
14
+ * has to be explained is worse than the word.
15
+ * @type {Readonly<Record<string, {icon: string|null, key: string, fallback: string}>>}
16
+ */
17
+ const VIEW_PRESETS = Object.freeze({
18
+ auto: { icon: null, key: 'listToolbar.viewAuto', fallback: 'Auto' },
19
+ cards: { icon: 'copy', key: 'listToolbar.viewCards', fallback: 'Cards' },
20
+ table: { icon: 'list', key: 'listToolbar.viewTable', fallback: 'Table' },
21
+ list: { icon: 'list', key: 'listToolbar.viewList', fallback: 'List' }
22
+ });
23
+
24
+ /**
25
+ * @typedef {Object} ListToolbarSearch
26
+ * @property {string} [placeholder] Field placeholder; also names the field. Defaults to the
27
+ * `listToolbar.search` message.
28
+ * @property {string} [value=''] Initial query.
29
+ * @property {number} [debounce=250] Debounce before `search` is emitted, in milliseconds.
30
+ * @property {boolean} [clearable=true] Whether the field shows its clear control.
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} ListToolbarTool
35
+ * @property {string} id Stable identifier, reported by `action` and addressed by `setBadge()`.
36
+ * @property {string} [label=''] Visible label, and the base of the accessible name.
37
+ * @property {string|null} [icon=null] Icon name from `icons.js`.
38
+ * @property {string|number|null} [badge=null] Badge content. `null` renders no badge; `0` is a
39
+ * value and renders one.
40
+ * @property {'neutral'|'accent'|'success'|'warning'|'danger'|'info'} [badgeKind='accent'] Badge intent.
41
+ * @property {boolean} [pressed] Present makes the tool a toggle (`aria-pressed`), which flips on
42
+ * activation; absent makes it a plain button.
43
+ * @property {boolean} [disabled=false] Whether the tool starts disabled.
44
+ * @property {string} [title] Native tooltip; defaults to the accessible name.
45
+ * @property {(event: MouseEvent) => void} [onclick] Callback, called after the `action` event.
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} ListToolbarView
50
+ * @property {string} id View identifier reported by `viewchange`.
51
+ * @property {string} [label] Visible label; omitted falls back to the built-in preset word.
52
+ * @property {string|null} [icon] Icon name from `icons.js`.
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} ListToolbarOptions
57
+ * @property {ListToolbarSearch|boolean} [search={}] Search field configuration; `false` omits it.
58
+ * @property {number|null} [count=null] Initial result count shown in the live status line.
59
+ * @property {((count: number) => string)|null} [countText=null] Formats the count line; omitted
60
+ * uses the `listToolbar.results` message (`'%1 results'`).
61
+ * @property {Array<string|ListToolbarView>} [views=[]] Shape switch entries; `[]` omits the switch.
62
+ * @property {string|null} [view=null] Initially selected shape; omitted selects the first entry.
63
+ * @property {ListToolbarTool[]} [tools=[]] Tool buttons, in display order.
64
+ * @property {string|null} [label=null] Accessible name of the bar; omitted uses the
65
+ * `listToolbar.label` message.
66
+ * @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
67
+ * @property {(event: CustomEvent<{value: string}>) => void} [onsearch] Search listener.
68
+ * @property {(event: CustomEvent<{view: string, previous: string|null}>) => void} [onviewchange] Shape listener.
69
+ * @property {(event: CustomEvent<{id: string, pressed: boolean|null, tool: Element}>) => void} [onaction] Tool listener.
70
+ */
71
+
72
+ /**
73
+ * The bar above a record view: one search field, a live result count, the tools that change what
74
+ * the list shows, and the shape switch. Every application writes this row, and most of them write
75
+ * it slightly wrong — this is the composition, not a new primitive. It builds on `Search` and
76
+ * `badge()` and reimplements neither.
77
+ *
78
+ * This is not `Toolbar`, which stays the right component for a row of commands. `Toolbar` is the
79
+ * APG toolbar pattern — one tab stop, arrow keys between controls — and it collapses whatever no
80
+ * longer fits into an overflow menu. Both are wrong here: the row contains a text input, where
81
+ * arrow keys must move the caret, and the first thing an overflow menu would swallow is the badge
82
+ * saying why the list is showing four rows out of twenty.
83
+ *
84
+ * Three properties are the reason it exists:
85
+ *
86
+ * - **A tool's badge is part of its accessible name.** A tool showing `2` announces as
87
+ * "Filters (2)"; the badge itself is `aria-hidden`, so the number is heard once, as part of the
88
+ * control, rather than found separately beside it.
89
+ * - **The count line is a live region**, and `setCount(null)` clears it. The previous number
90
+ * describes the previous filters, and a stale count during a load is worse than none.
91
+ * - **Tool buttons meet the 44 px target** in both densities. An icon button sized to its glyph is
92
+ * the control people miss twice before hitting once.
93
+ *
94
+ * `bind()` is optional sugar; the toolbar is fully functional without it.
95
+ * @fires ListToolbar#search
96
+ * @fires ListToolbar#viewchange
97
+ * @fires ListToolbar#action
98
+ * @extends {Component<ListToolbarOptions>}
99
+ */
100
+ export class ListToolbar extends Component {
101
+ static cssName = 'list-toolbar';
102
+
103
+ /** @type {Readonly<ListToolbarOptions>} */
104
+ static defaults = {
105
+ search: {},
106
+ count: null,
107
+ countText: null,
108
+ views: [],
109
+ view: null,
110
+ tools: [],
111
+ label: null
112
+ };
113
+
114
+ /**
115
+ * Creates the bar, or turns an existing element into one.
116
+ * @param {Element|string|null} [target=null] Existing container, selector, or null to create one.
117
+ * @param {ListToolbarOptions} [options={}] Toolbar options.
118
+ */
119
+ constructor(target = null, options = {}) {
120
+ super(target, options);
121
+
122
+ if (this.refs.tools) {
123
+ this.listen(this.refs.tools, 'click', (event) => this._toolClick(/** @type {MouseEvent} */ (event)));
124
+ }
125
+ if (this.refs.views) {
126
+ this.listen(this.refs.views, 'click', (event) => this._viewClick(/** @type {MouseEvent} */ (event)));
127
+ }
128
+ // Registered once rather than per `bind()`, so `unbind()` is a state change and never has to
129
+ // take a listener back off a view that may already be gone.
130
+ this.on('viewchange', (event) => {
131
+ const bound = this._bound;
132
+ const setter = bound?.capabilities.setShape;
133
+ if (setter) bound.view[setter](/** @type {CustomEvent} */ (event).detail.view);
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Builds the bar. Runs inside the base constructor, so every piece of instance state is
139
+ * initialized here rather than in a class field.
140
+ * @returns {HTMLElement}
141
+ */
142
+ render() {
143
+ this._createdRoot = this.el === null;
144
+ this._snapshot = this._createdRoot ? null : snapshotTarget(this.el);
145
+ this._destroyed = false;
146
+ /** @type {Map<string, ToolRecord>} */
147
+ this._tools = new Map();
148
+ this._views = normalizeViewList(this.options.views);
149
+ this._view = resolveView(this._views, this.options.view, null);
150
+ this._count = null;
151
+ /** @type {Search|null} */
152
+ this._search = null;
153
+ /** @type {{view: Record<string, any>, capabilities: RecordViewCapabilities}|null} */
154
+ this._bound = null;
155
+ /** @type {AdoptedElement[]} */
156
+ this._adopted = [];
157
+ /** @type {AdoptedElement|null} */
158
+ this._chooser = null;
159
+
160
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
161
+ this.el = root;
162
+ root.setAttribute('role', 'group');
163
+ root.setAttribute('aria-label',
164
+ this.options.label ?? this._message('listToolbar.label', 'List controls'));
165
+
166
+ /** @type {Node[]} */
167
+ const children = [];
168
+ const searchOptions = this._searchOptions();
169
+ if (searchOptions) {
170
+ this._search = new Search(null, searchOptions);
171
+ const field = /** @type {HTMLElement} */ (this._search.toElement());
172
+ field.classList.add('zx-list-toolbar__search');
173
+ // `Search` debounces its own `input`; `submit` is the reader pressing Enter, which should
174
+ // not wait for a timer that has already been reset by the keystroke before it.
175
+ this._search.on('input', (event) => this.emit('search', { value: readValue(event) }));
176
+ this._search.on('submit', (event) => this.emit('search', { value: readValue(event) }));
177
+ children.push(field);
178
+ }
179
+
180
+ // Always rendered and never hidden: a live region has to be in the accessibility tree before
181
+ // the text arrives, and toggling `display` on it is how announcements get lost. Empty, it is a
182
+ // zero-width item in a row that already ends in blank space.
183
+ children.push(h('span', {
184
+ class: 'zx-list-toolbar__count',
185
+ ref: 'count',
186
+ role: 'status',
187
+ ariaLive: 'polite',
188
+ ariaAtomic: 'true'
189
+ }));
190
+ children.push(h('span', { class: 'zx-list-toolbar__spacer' }));
191
+ children.push(h('div', { class: 'zx-list-toolbar__tools', ref: 'tools' }));
192
+ if (this._views.length > 0) {
193
+ children.push(h('div', {
194
+ class: 'zx-list-toolbar__views',
195
+ ref: 'views',
196
+ role: 'group',
197
+ ariaLabel: this._message('listToolbar.views', 'View')
198
+ }));
199
+ }
200
+ root.replaceChildren(...children);
201
+
202
+ this.setTools(this.options.tools);
203
+ this._syncViews();
204
+ this.setCount(this.options.count);
205
+ return root;
206
+ }
207
+
208
+ /**
209
+ * Replaces the count line. `null` clears it, which is the right answer while a request is in
210
+ * flight: the number on screen describes the filters the reader has just changed.
211
+ * @param {number|null} count Result count, or null.
212
+ * @returns {this}
213
+ */
214
+ setCount(count) {
215
+ this._count = countValue(count);
216
+ this.refs.count.textContent = countLineText(count, this._countFormat());
217
+ return this;
218
+ }
219
+
220
+ /**
221
+ * Returns the count currently shown, or null when the line is empty.
222
+ * @returns {number|null}
223
+ */
224
+ getCount() {
225
+ return this._count;
226
+ }
227
+
228
+ /**
229
+ * Replaces a tool's badge and its accessible name with it. `null` removes the badge.
230
+ * @param {string} id Tool id.
231
+ * @param {string|number|null} value Badge content; `0` is a value, `null` removes it.
232
+ * @returns {this}
233
+ */
234
+ setBadge(id, value) {
235
+ const record = this._tools.get(String(id));
236
+ if (!record) return this;
237
+ record.badge = normalizeToolBadge(value);
238
+ this._syncTool(record);
239
+ return this;
240
+ }
241
+
242
+ /**
243
+ * Sets the pressed state of a toggle tool. A tool declared without `pressed` is a plain button
244
+ * and is left alone.
245
+ * @param {string} id Tool id.
246
+ * @param {boolean} [pressed=true] Next state.
247
+ * @returns {this}
248
+ */
249
+ setPressed(id, pressed = true) {
250
+ const record = this._tools.get(String(id));
251
+ if (!record?.toggle) return this;
252
+ record.pressed = Boolean(pressed);
253
+ record.element.setAttribute('aria-pressed', record.pressed ? 'true' : 'false');
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * Enables or disables a tool, for instance while its panel is loading.
259
+ * @param {string} id Tool id.
260
+ * @param {boolean} [disabled=true] Next state.
261
+ * @returns {this}
262
+ */
263
+ setToolDisabled(id, disabled = true) {
264
+ const record = this._tools.get(String(id));
265
+ if (record) record.element.disabled = Boolean(disabled);
266
+ return this;
267
+ }
268
+
269
+ /**
270
+ * Replaces every tool. Elements adopted with `addTool()` and a chooser mounted by `bind()` stay
271
+ * where they are.
272
+ * @param {ListToolbarTool[]} tools Tool descriptors.
273
+ * @returns {this}
274
+ */
275
+ setTools(tools) {
276
+ for (const record of this._tools.values()) record.element.remove();
277
+ this._tools = new Map();
278
+ const elements = [];
279
+ for (const descriptor of normalizeTools(tools)) {
280
+ const record = this._createTool(descriptor);
281
+ this._tools.set(record.id, record);
282
+ elements.push(record.element);
283
+ }
284
+ // Prepended as one run, so the tools stay in declaration order and an adopted control — a
285
+ // field chooser `bind()` moved here — keeps its place at the trailing edge of the slot.
286
+ this.refs.tools.prepend(...elements);
287
+ return this;
288
+ }
289
+
290
+ /**
291
+ * Returns a tool's button element.
292
+ * @param {string} id Tool id.
293
+ * @returns {HTMLButtonElement|null}
294
+ */
295
+ getTool(id) {
296
+ return this._tools.get(String(id))?.element ?? null;
297
+ }
298
+
299
+ /**
300
+ * The element tools are mounted into. Hand it to a view as `fieldControls: {target}` to place
301
+ * that view's field chooser in this bar without calling `bind()`.
302
+ *
303
+ * A control mounted here by somebody else stays theirs: the toolbar puts back what it adopted
304
+ * through `addTool()` or `bind()`, and everything else goes wherever this slot goes. Use
305
+ * `addTool()` instead to hand the toolbar an element it should return on `destroy()`.
306
+ * @returns {HTMLElement}
307
+ */
308
+ getToolsElement() {
309
+ return /** @type {HTMLElement} */ (this.refs.tools);
310
+ }
311
+
312
+ /**
313
+ * Adopts a ready-made control into the tools slot. The element's original position is recorded,
314
+ * and `destroy()` puts it back.
315
+ * @param {Element} element Control to mount.
316
+ * @returns {this}
317
+ */
318
+ addTool(element) {
319
+ if (!isElement(element)) throw new TypeError('ListToolbar.addTool() needs an Element');
320
+ if (this.refs.tools.contains(element)) return this;
321
+ this._adopted.push(capturePosition(element));
322
+ this.refs.tools.append(element);
323
+ return this;
324
+ }
325
+
326
+ /**
327
+ * Selects a view shape. A value outside `views` is ignored, so the switch can only ever report
328
+ * one of the shapes it was configured with.
329
+ * @param {string} view View id.
330
+ * @param {{silent?: boolean}} [options={}] Set `silent` to suppress `viewchange`.
331
+ * @returns {this}
332
+ * @fires ListToolbar#viewchange
333
+ */
334
+ setView(view, { silent = false } = {}) {
335
+ const next = resolveView(this._views, view, this._view);
336
+ if (next === null || next === this._view) return this;
337
+ const previous = this._view;
338
+ this._view = next;
339
+ this._syncViews();
340
+ if (!silent) this.emit('viewchange', { view: next, previous });
341
+ return this;
342
+ }
343
+
344
+ /**
345
+ * Returns the selected view shape, or null when no shape switch is configured.
346
+ * @returns {string|null}
347
+ */
348
+ getView() {
349
+ return this._view;
350
+ }
351
+
352
+ /**
353
+ * Returns the current query, or an empty string when the field is omitted.
354
+ * @returns {string}
355
+ */
356
+ getSearch() {
357
+ return this._search ? this._search.get() : '';
358
+ }
359
+
360
+ /**
361
+ * Sets the query.
362
+ * @param {string} value Next query.
363
+ * @param {{silent?: boolean}} [options={}] Set `silent` to suppress `search`.
364
+ * @returns {this}
365
+ */
366
+ setSearch(value, options = {}) {
367
+ this._search?.set(value, options);
368
+ return this;
369
+ }
370
+
371
+ /**
372
+ * Focuses the search field, for a host that binds a "/" shortcut.
373
+ * @returns {this}
374
+ */
375
+ focusSearch() {
376
+ this._search?.focus();
377
+ return this;
378
+ }
379
+
380
+ /**
381
+ * Optional sugar for a `TableView` or `CardView`: it wires the shape switch to the view and
382
+ * mounts the view's field chooser into this bar.
383
+ *
384
+ * Nothing is imported and nothing is required — every capability is feature-detected, so a view
385
+ * that offers none of them still works and still gets `viewchange`, and the host swaps the view
386
+ * itself. See `recordViewCapabilities()` for what is looked for.
387
+ * @param {object} view A record view, or anything that quacks like one.
388
+ * @returns {this}
389
+ */
390
+ bind(view) {
391
+ if (view === null || typeof view !== 'object') {
392
+ throw new TypeError('ListToolbar.bind() needs a record view');
393
+ }
394
+ this.unbind();
395
+ const capabilities = recordViewCapabilities(view);
396
+ this._bound = { view: /** @type {Record<string, any>} */ (view), capabilities };
397
+ if (capabilities.getShape) {
398
+ // Adopted silently, and only when the view's shape is one this switch offers: `setView()`
399
+ // leaves the current shape alone for anything else.
400
+ const current = view[capabilities.getShape]();
401
+ if (current != null) this.setView(String(current), { silent: true });
402
+ }
403
+ this._mountFieldControls(view);
404
+ return this;
405
+ }
406
+
407
+ /**
408
+ * Releases a bound view and returns its field chooser to where it was found.
409
+ * @returns {this}
410
+ */
411
+ unbind() {
412
+ this._bound = null;
413
+ if (this._chooser) {
414
+ restorePosition(this._chooser, this.el);
415
+ this._chooser = null;
416
+ }
417
+ return this;
418
+ }
419
+
420
+ /**
421
+ * Destroys the search field, returns every adopted control to where it came from, and restores
422
+ * an enhanced target to the markup it had before the takeover.
423
+ * @returns {void}
424
+ */
425
+ destroy() {
426
+ if (this._destroyed) return;
427
+ this._destroyed = true;
428
+ this.unbind();
429
+ // Before the root is removed, or the adopted controls would go with it.
430
+ for (const entry of this._adopted.reverse()) restorePosition(entry, this.el);
431
+ this._adopted = [];
432
+ this._search?.destroy();
433
+ this._search = null;
434
+ const root = this.el;
435
+ super.destroy();
436
+ if (!this._createdRoot) restoreTarget(root, this._snapshot);
437
+ }
438
+
439
+ /* ------------------------------------------------------------------ internals -- */
440
+
441
+ /** @returns {import('../search/search.js').SearchOptions|null} The options `Search` is built with. */
442
+ _searchOptions() {
443
+ const configured = this.options.search;
444
+ if (configured === false || configured === null) return null;
445
+ const settings = configured === true || configured === undefined ? {} : configured;
446
+ if (typeof settings !== 'object' || Array.isArray(settings)) {
447
+ throw new TypeError('ListToolbar search must be an options object or false');
448
+ }
449
+ return {
450
+ ...settings,
451
+ placeholder: String(settings.placeholder ?? this._message('listToolbar.search', 'Search'))
452
+ };
453
+ }
454
+
455
+ /** @returns {(count: number) => string} The count formatter, host-supplied or translated. */
456
+ _countFormat() {
457
+ const custom = this.options.countText;
458
+ if (typeof custom === 'function') return (value) => String(custom(value));
459
+ return (value) => this._message('listToolbar.results', '%1 results', value.toLocaleString());
460
+ }
461
+
462
+ /**
463
+ * Builds one tool button.
464
+ * @param {Required<ListToolbarTool>} descriptor Normalized descriptor.
465
+ * @returns {ToolRecord}
466
+ */
467
+ _createTool(descriptor) {
468
+ const element = /** @type {HTMLButtonElement} */ (h('button', {
469
+ class: 'zx-list-toolbar__tool',
470
+ type: 'button',
471
+ disabled: descriptor.disabled,
472
+ dataset: { toolId: descriptor.id },
473
+ ariaPressed: descriptor.pressed === null ? null : String(descriptor.pressed)
474
+ }));
475
+ if (descriptor.icon) element.append(icon(descriptor.icon, { size: 16 }));
476
+ if (descriptor.label !== '') {
477
+ element.append(h('span', { class: 'zx-list-toolbar__tool-label' }, descriptor.label));
478
+ }
479
+
480
+ /** @type {ToolRecord} */
481
+ const record = {
482
+ id: descriptor.id,
483
+ element,
484
+ label: descriptor.label || descriptor.title || descriptor.id,
485
+ title: descriptor.title,
486
+ badge: descriptor.badge,
487
+ badgeKind: descriptor.badgeKind,
488
+ badgeEl: null,
489
+ toggle: descriptor.pressed !== null,
490
+ pressed: Boolean(descriptor.pressed),
491
+ onclick: descriptor.onclick
492
+ };
493
+ this._syncTool(record);
494
+ return record;
495
+ }
496
+
497
+ /**
498
+ * Rebuilds a tool's badge and the accessible name that carries it.
499
+ *
500
+ * The badge element is `aria-hidden`: the number it shows is already in the button's name, and a
501
+ * screen reader meeting both would announce "Filters 2 2".
502
+ * @param {ToolRecord} record Tool record.
503
+ * @returns {void}
504
+ */
505
+ _syncTool(record) {
506
+ record.badgeEl?.remove();
507
+ record.badgeEl = null;
508
+ if (record.badge !== null) {
509
+ const element = badge({ label: record.badge, kind: record.badgeKind, size: 'sm' });
510
+ element.classList.add('zx-list-toolbar__badge');
511
+ element.setAttribute('aria-hidden', 'true');
512
+ record.badgeEl = element;
513
+ record.element.append(element);
514
+ }
515
+ const name = toolAccessibleName(record.label, record.badge,
516
+ this._message('listToolbar.toolBadge', '%1 (%2)'));
517
+ record.element.setAttribute('aria-label', name);
518
+ record.element.setAttribute('title', record.title ?? name);
519
+ }
520
+
521
+ /**
522
+ * Reports a tool activation and flips a toggle tool.
523
+ * @param {MouseEvent} event Click event.
524
+ * @returns {void}
525
+ * @fires ListToolbar#action
526
+ */
527
+ _toolClick(event) {
528
+ const target = /** @type {Element|null} */ (event.target);
529
+ const element = target?.closest?.('[data-tool-id]');
530
+ if (!element || !this.refs.tools.contains(element)) return;
531
+ const record = this._tools.get(String(/** @type {HTMLElement} */ (element).dataset.toolId));
532
+ // A control adopted with `addTool()`, or a chooser `bind()` mounted, is not ours to report on.
533
+ if (!record) return;
534
+ if (record.toggle) this.setPressed(record.id, !record.pressed);
535
+ this.emit('action', {
536
+ id: record.id,
537
+ pressed: record.toggle ? record.pressed : null,
538
+ tool: record.element
539
+ });
540
+ record.onclick?.(event);
541
+ }
542
+
543
+ /**
544
+ * Selects the clicked shape.
545
+ * @param {MouseEvent} event Click event.
546
+ * @returns {void}
547
+ */
548
+ _viewClick(event) {
549
+ const target = /** @type {Element|null} */ (event.target);
550
+ const element = target?.closest?.('[data-view]');
551
+ if (!element || !this.refs.views.contains(element)) return;
552
+ this.setView(String(/** @type {HTMLElement} */ (element).dataset.view));
553
+ }
554
+
555
+ /** Rebuilds the shape switch, or just its pressed states. @returns {void} */
556
+ _syncViews() {
557
+ if (!this.refs.views) return;
558
+ const group = /** @type {HTMLElement} */ (this.refs.views);
559
+ if (group.childElementCount !== this._views.length) {
560
+ group.replaceChildren(...this._views.map((view) => {
561
+ const label = view.label ?? this._viewLabel(view.id);
562
+ const element = h('button', {
563
+ class: 'zx-list-toolbar__view',
564
+ type: 'button',
565
+ title: label,
566
+ dataset: { view: view.id }
567
+ });
568
+ const glyph = view.icon === undefined ? VIEW_PRESETS[view.id]?.icon ?? null : view.icon;
569
+ if (glyph) element.append(icon(glyph, { size: 16 }));
570
+ element.append(h('span', { class: 'zx-list-toolbar__view-label' }, label));
571
+ return element;
572
+ }));
573
+ }
574
+ for (const element of group.children) {
575
+ const selected = /** @type {HTMLElement} */ (element).dataset.view === this._view;
576
+ element.setAttribute('aria-pressed', selected ? 'true' : 'false');
577
+ }
578
+ }
579
+
580
+ /**
581
+ * @param {string} id View id.
582
+ * @returns {string} The translated preset word, or the id when the shape is the host's own.
583
+ */
584
+ _viewLabel(id) {
585
+ const preset = VIEW_PRESETS[id];
586
+ return preset ? this._message(preset.key, preset.fallback) : id;
587
+ }
588
+
589
+ /**
590
+ * Moves a bound view's field chooser into the tools slot. A view that mounted it here itself —
591
+ * `fieldControls: {target: toolbar.getToolsElement()}` — is already where it belongs and is left
592
+ * alone, and a view with `fieldControls: false` has none to move.
593
+ * @param {Record<string, any>} view Bound view.
594
+ * @returns {void}
595
+ */
596
+ _mountFieldControls(view) {
597
+ if (view.options?.fieldControls === false) return;
598
+ const element = this._findFieldControls(view);
599
+ if (!element || this.el.contains(element)) return;
600
+ this._chooser = capturePosition(element);
601
+ this.refs.tools.append(element);
602
+ }
603
+
604
+ /**
605
+ * Finds a view's field chooser without importing the view, newest accessor first.
606
+ * @param {Record<string, any>} view Bound view.
607
+ * @returns {Element|null}
608
+ */
609
+ _findFieldControls(view) {
610
+ if (typeof view.getFieldControls === 'function') {
611
+ const element = view.getFieldControls();
612
+ if (isElement(element)) return element;
613
+ }
614
+ if (isElement(view._viewFieldControls)) return view._viewFieldControls;
615
+ const root = typeof view.toElement === 'function' ? view.toElement() : null;
616
+ return isElement(root) ? root.querySelector('.zx-record-view__field-controls') : null;
617
+ }
618
+
619
+ /**
620
+ * Resolves a message through the host translator, falling back to the built-in English text.
621
+ * @param {string} key Message key.
622
+ * @param {string} fallback Built-in text, with `%1`-style placeholders.
623
+ * @param {...unknown} args Interpolation values.
624
+ * @returns {string}
625
+ */
626
+ _message(key, fallback, ...args) {
627
+ const message = this.msg(key, ...args);
628
+ return message === key ? printf(fallback, args) : message;
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Debounced query change, and the reader pressing Enter.
634
+ * @event ListToolbar#search
635
+ * @type {CustomEvent<{value: string}>}
636
+ */
637
+
638
+ /**
639
+ * Shape switch change.
640
+ * @event ListToolbar#viewchange
641
+ * @type {CustomEvent<{view: string, previous: string|null}>}
642
+ */
643
+
644
+ /**
645
+ * Tool activation. `pressed` is the new state of a toggle tool, and null for a plain button.
646
+ * @event ListToolbar#action
647
+ * @type {CustomEvent<{id: string, pressed: boolean|null, tool: Element}>}
648
+ */
649
+
650
+ /**
651
+ * @typedef {Object} ToolRecord
652
+ * @property {string} id Tool id.
653
+ * @property {HTMLButtonElement} element Rendered button.
654
+ * @property {string} label Name the badge is composed onto.
655
+ * @property {string|null} title Host-supplied tooltip.
656
+ * @property {string|null} badge Normalized badge content.
657
+ * @property {string} badgeKind Badge intent.
658
+ * @property {HTMLElement|null} badgeEl Rendered badge.
659
+ * @property {boolean} toggle Whether the tool carries `aria-pressed`.
660
+ * @property {boolean} pressed Current pressed state.
661
+ * @property {((event: MouseEvent) => void)|null} onclick Descriptor callback.
662
+ */
663
+
664
+ /** @typedef {{element: Element, parent: Node|null, next: Node|null}} AdoptedElement */
665
+
666
+ /**
667
+ * @typedef {Object} RecordViewCapabilities
668
+ * @property {boolean} isRecordView Whether the object publishes the record-view state surface.
669
+ * @property {string|null} setShape Method that changes the view shape, if any.
670
+ * @property {string|null} getShape Method that reads the view shape, if any.
671
+ * @property {boolean} events Whether the object emits component events.
672
+ * @property {boolean} fieldControls Whether the object offers its field chooser through a method.
673
+ */
674
+
675
+ /**
676
+ * Describes what `bind()` can wire on an object, by feature rather than by class.
677
+ *
678
+ * `ListToolbar` must not import `TableView` or `CardView`: it would hard-depend on two components
679
+ * a host may not use, and on their current shape. A record view is recognised instead by the state
680
+ * surface `RecordView` publishes — `setViewState`/`getViewState` plus the component event
681
+ * methods that carry `sortchange` and friends. Everything beyond that is optional and detected
682
+ * one method at a time, so a view that grows a shape switch later is wired the day it does.
683
+ *
684
+ * Pure: it reads types, never the DOM.
685
+ * @param {unknown} view Candidate view.
686
+ * @returns {RecordViewCapabilities}
687
+ */
688
+ export function recordViewCapabilities(view) {
689
+ const candidate = /** @type {Record<string, unknown>} */ (view);
690
+ const has = (name) => Boolean(candidate) && typeof candidate[name] === 'function';
691
+ const pick = (...names) => names.find(has) ?? null;
692
+ return {
693
+ isRecordView: has('setViewState') && has('getViewState') && has('on') && has('toElement'),
694
+ setShape: pick('setView', 'setViewShape'),
695
+ getShape: pick('getView', 'getViewShape'),
696
+ events: has('on') && has('emit'),
697
+ fieldControls: has('getFieldControls')
698
+ };
699
+ }
700
+
701
+ /**
702
+ * Normalizes badge content. `null`, `undefined`, `false`, and `''` mean no badge; everything else
703
+ * is text. `0` is a value: a tool that counts something and counts none of it still says so.
704
+ * @param {unknown} value Badge content.
705
+ * @returns {string|null}
706
+ */
707
+ export function normalizeToolBadge(value) {
708
+ if (value === null || value === undefined || value === false || value === '') return null;
709
+ return String(value);
710
+ }
711
+
712
+ /**
713
+ * Composes a tool's accessible name from its label and badge, so the badge is announced as part of
714
+ * the control rather than as a second thing to find beside it: "Filters (2)".
715
+ * @param {string} label Visible label.
716
+ * @param {string|null} badgeText Normalized badge content.
717
+ * @param {string} [template='%1 (%2)'] Composition template.
718
+ * @returns {string}
719
+ */
720
+ export function toolAccessibleName(label, badgeText, template = '%1 (%2)') {
721
+ const name = String(label ?? '');
722
+ if (badgeText === null || badgeText === undefined || badgeText === '') return name;
723
+ return printf(template, [name, String(badgeText)]);
724
+ }
725
+
726
+ /**
727
+ * Validates and copies tool descriptors.
728
+ * @param {ListToolbarTool[]} tools Raw descriptors.
729
+ * @returns {Required<ListToolbarTool>[]} Normalized descriptors.
730
+ */
731
+ export function normalizeTools(tools) {
732
+ if (tools != null && !Array.isArray(tools)) throw new TypeError('ListToolbar tools must be an array');
733
+ const seen = new Set();
734
+ const normalized = [];
735
+ for (const tool of tools ?? []) {
736
+ if (tool == null) continue;
737
+ if (typeof tool !== 'object' || Array.isArray(tool)) {
738
+ throw new TypeError('ListToolbar tools must be tool descriptors');
739
+ }
740
+ const id = String(tool.id ?? '').trim();
741
+ if (!id) throw new TypeError('Every ListToolbar tool needs an id');
742
+ if (seen.has(id)) throw new TypeError(`Duplicate ListToolbar tool: ${id}`);
743
+ seen.add(id);
744
+ normalized.push({
745
+ id,
746
+ label: String(tool.label ?? ''),
747
+ icon: tool.icon ?? null,
748
+ badge: normalizeToolBadge(tool.badge),
749
+ badgeKind: String(tool.badgeKind ?? 'accent'),
750
+ // null rather than false, so "a plain button" stays distinguishable from "a toggle that is
751
+ // currently off" all the way down to the rendered attribute.
752
+ pressed: tool.pressed === undefined ? null : Boolean(tool.pressed),
753
+ disabled: Boolean(tool.disabled),
754
+ title: tool.title === undefined ? null : String(tool.title),
755
+ onclick: typeof tool.onclick === 'function' ? tool.onclick : null
756
+ });
757
+ }
758
+ return /** @type {Required<ListToolbarTool>[]} */ (normalized);
759
+ }
760
+
761
+ /**
762
+ * Normalizes the shape switch entries. Strings name a built-in preset or a shape of the host's
763
+ * own; descriptors carry their own label and icon. Nullish and duplicate entries are dropped.
764
+ * @param {Array<string|ListToolbarView>} views Raw entries.
765
+ * @returns {Array<{id: string, label?: string, icon?: string|null}>}
766
+ */
767
+ export function normalizeViewList(views) {
768
+ if (views != null && !Array.isArray(views)) throw new TypeError('ListToolbar views must be an array');
769
+ const seen = new Set();
770
+ const normalized = [];
771
+ for (const view of views ?? []) {
772
+ if (view == null) continue;
773
+ const descriptor = typeof view === 'object' ? view : { id: view };
774
+ const id = String(descriptor.id ?? '').trim();
775
+ if (!id || seen.has(id)) continue;
776
+ seen.add(id);
777
+ const entry = /** @type {{id: string, label?: string, icon?: string|null}} */ ({ id });
778
+ if (descriptor.label !== undefined) entry.label = String(descriptor.label);
779
+ if (descriptor.icon !== undefined) entry.icon = descriptor.icon;
780
+ normalized.push(entry);
781
+ }
782
+ return normalized;
783
+ }
784
+
785
+ /**
786
+ * Resolves a requested shape against the configured list. A value the toolbar was not configured
787
+ * with is never selected — the switch can only report shapes the host said it can render.
788
+ * @param {Array<{id: string}>} views Normalized entries.
789
+ * @param {unknown} requested Requested shape.
790
+ * @param {string|null} current Shape to keep when the request is not on offer; `null` means the
791
+ * toolbar is choosing its initial shape and takes the first entry.
792
+ * @returns {string|null} A configured shape id, or null when there are none.
793
+ */
794
+ export function resolveView(views, requested, current) {
795
+ const ids = views.map((view) => view.id);
796
+ if (ids.length === 0) return null;
797
+ if (requested != null && ids.includes(String(requested))) return String(requested);
798
+ if (current === null) return ids[0];
799
+ return ids.includes(current) ? current : ids[0];
800
+ }
801
+
802
+ /**
803
+ * The text of the count line. Anything that is not a finite number — including `null` while a
804
+ * request is in flight — is an empty line rather than a stale one.
805
+ * @param {unknown} count Result count.
806
+ * @param {(count: number) => string} format Formatter for a real count.
807
+ * @returns {string}
808
+ */
809
+ export function countLineText(count, format) {
810
+ const value = countValue(count);
811
+ return value === null ? '' : String(format(value));
812
+ }
813
+
814
+ /** @param {unknown} count @returns {number|null} A finite count, or null. */
815
+ function countValue(count) {
816
+ if (count === null || count === undefined || count === '' || typeof count === 'boolean') return null;
817
+ const value = Number(count);
818
+ return Number.isFinite(value) ? value : null;
819
+ }
820
+
821
+ /** @param {Element} element @returns {AdoptedElement} Where an element was before it was adopted. */
822
+ function capturePosition(element) {
823
+ return { element, parent: element.parentNode, next: element.nextSibling };
824
+ }
825
+
826
+ /**
827
+ * Puts an adopted element back where it was found, or removes it when it had no place of its own.
828
+ *
829
+ * An element that is no longer inside the toolbar is left alone: a view that has been destroyed
830
+ * takes its own field chooser with it, and re-inserting that node from under it would resurrect a
831
+ * control nothing owns any more. A sibling that has since moved is no anchor either, so the
832
+ * element goes back to the end of its original parent rather than throwing.
833
+ * @param {AdoptedElement} entry Recorded position.
834
+ * @param {Element} root Toolbar root the element must still be inside.
835
+ * @returns {void}
836
+ */
837
+ function restorePosition(entry, root) {
838
+ const { element, parent, next } = entry;
839
+ if (!root?.contains(element)) return;
840
+ if (!parent) {
841
+ element.remove();
842
+ return;
843
+ }
844
+ if (next && next.parentNode === parent) parent.insertBefore(element, next);
845
+ else parent.appendChild(element);
846
+ }
847
+
848
+ /** @param {Event} event @returns {string} The value carried by a `Search` event. */
849
+ function readValue(event) {
850
+ return String(/** @type {CustomEvent<{value: string}>} */ (event).detail?.value ?? '');
851
+ }