@zeyos/zx 4.3.4 → 5.0.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.
- package/CHANGELOG.md +438 -0
- package/README.md +49 -10
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +217 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
- package/dist/types/components/avatar/avatar.d.ts +46 -7
- package/dist/types/components/badge/badge.d.ts +21 -1
- package/dist/types/components/button/button.d.ts +23 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +11 -3
- package/dist/types/components/card-view/record-card.d.ts +7 -1
- package/dist/types/components/chart/chart.d.ts +10 -0
- package/dist/types/components/checklist/checklist.d.ts +9 -0
- package/dist/types/components/code-editor/code-editor.d.ts +149 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- package/dist/types/components/datebox/date-range-box.d.ts +5 -1
- package/dist/types/components/datebox/datebox.d.ts +7 -2
- package/dist/types/components/dialog/dialog.d.ts +75 -6
- package/dist/types/components/dropdown/dropdown.d.ts +2 -2
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/field/field.d.ts +24 -2
- package/dist/types/components/fieldset/fieldset.d.ts +19 -1
- package/dist/types/components/file-item/file-item.d.ts +262 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/form/form.d.ts +12 -1
- package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
- package/dist/types/components/launcher/launcher.d.ts +278 -26
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +37 -42
- package/dist/types/components/message/message.d.ts +126 -17
- package/dist/types/components/modal/modal.d.ts +61 -2
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- package/dist/types/components/select/color.d.ts +12 -0
- package/dist/types/components/select/glyphs.d.ts +124 -0
- package/dist/types/components/select/select.d.ts +15 -3
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
- package/dist/types/components/tabbox/tabbox.d.ts +16 -0
- package/dist/types/components/table/table.d.ts +190 -1
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/tree/tree.d.ts +13 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/breakpoint.d.ts +1 -1
- package/dist/types/core/icons.d.ts +37 -22
- package/dist/types/core/motion.d.ts +59 -0
- package/dist/types/core/overlay-host.d.ts +15 -1
- package/dist/types/index.d.ts +16 -3
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/types/zeyos/connect.d.ts +6 -0
- package/dist/types/zeyos/form.d.ts +50 -9
- package/dist/types/zeyos/index.d.ts +1 -1
- package/dist/types/zeyos/query.d.ts +79 -0
- package/dist/types/zeyos/schema.d.ts +24 -1
- package/dist/types/zeyos/table.d.ts +100 -14
- package/dist/types/zeyos/view.d.ts +10 -0
- package/dist/zx-zeyos.esm.js +2206 -489
- package/dist/zx-zeyos.esm.min.js +56 -1
- package/dist/zx.css +3322 -724
- package/dist/zx.esm.js +12244 -5809
- package/dist/zx.esm.min.js +58 -3
- package/dist/zx.global.js +12246 -5811
- package/dist/zx.global.min.js +58 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1683 -146
- package/docs/llms.md +777 -110
- package/docs/llms.txt +14 -8
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.css +35 -10
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +602 -0
- package/src/components/activity-list/activity-list.css +99 -0
- package/src/components/activity-list/activity-list.js +409 -0
- package/src/components/app-icon/app-icon.css +1 -1
- package/src/components/app-rail/app-rail.css +191 -22
- package/src/components/app-sidebar/app-sidebar.css +349 -24
- package/src/components/app-sidebar/app-sidebar.js +86 -11
- package/src/components/aurora/aurora.css +5 -3
- package/src/components/avatar/avatar.css +32 -7
- package/src/components/avatar/avatar.js +72 -8
- package/src/components/badge/badge.css +54 -2
- package/src/components/badge/badge.js +29 -2
- package/src/components/button/button.css +74 -8
- package/src/components/button/button.js +44 -1
- package/src/components/calendar/calendar.css +46 -46
- package/src/components/calendar/calendar.js +33 -3
- package/src/components/card/card.css +2 -2
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +10 -7
- package/src/components/card-view/card-view.js +25 -9
- package/src/components/card-view/record-card.js +7 -3
- package/src/components/chart/chart.css +4 -1
- package/src/components/chart/chart.js +12 -0
- package/src/components/check-button/check-button.css +10 -2
- package/src/components/checklist/checklist.css +9 -2
- package/src/components/checklist/checklist.js +11 -5
- package/src/components/checklist/field-adapter.js +3 -1
- package/src/components/code-editor/code-editor.css +68 -0
- package/src/components/code-editor/code-editor.js +306 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +87 -5
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/copy/copy-button.css +6 -0
- package/src/components/copy/copy-input.css +8 -3
- package/src/components/data-filter/data-filter.css +5 -4
- package/src/components/date-picker/date-picker.css +6 -6
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/date-range-picker.css +3 -3
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.css +1 -1
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/datebox/date-range-box.css +79 -2
- package/src/components/datebox/date-range-box.js +13 -2
- package/src/components/datebox/datebox.css +79 -2
- package/src/components/datebox/datebox.js +13 -2
- package/src/components/dialog/dialog.css +26 -0
- package/src/components/dialog/dialog.js +174 -70
- package/src/components/dropdown/dropdown.css +1 -1
- package/src/components/dropdown/dropdown.js +7 -5
- package/src/components/empty-state/empty-state.css +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- package/src/components/field/field.css +28 -9
- package/src/components/field/field.js +68 -26
- package/src/components/field-upload/field-upload.css +1 -1
- package/src/components/fieldset/fieldset.css +5 -3
- package/src/components/fieldset/fieldset.js +28 -5
- package/src/components/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +505 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +205 -47
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/finder/finder.css +15 -13
- package/src/components/form/form.css +5 -4
- package/src/components/form/form.js +11 -2
- package/src/components/grid/grid.css +1 -1
- package/src/components/groupbox/groupbox.css +1 -1
- package/src/components/kanban-view/kanban-card.js +247 -0
- package/src/components/kanban-view/kanban-view.css +226 -8
- package/src/components/kanban-view/kanban-view.js +255 -12
- package/src/components/launcher/launcher.css +107 -25
- package/src/components/launcher/launcher.js +557 -63
- package/src/components/list-toolbar/list-toolbar.css +194 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/master-panel/master-panel.css +2 -0
- package/src/components/menu-button/menu-button.css +87 -5
- package/src/components/menu-button/menu-button.js +86 -46
- package/src/components/message/message.css +38 -5
- package/src/components/message/message.js +230 -17
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +159 -3
- package/src/components/navigation-bar/navigation-bar.css +95 -11
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/number-field/field-adapter.js +1 -0
- package/src/components/number-field/number-field.css +70 -0
- package/src/components/number-field/number-field.js +2 -0
- package/src/components/pagination/pagination.css +8 -6
- package/src/components/panel/panel.css +1 -1
- package/src/components/panel/panel.js +13 -2
- package/src/components/rating/rating.css +7 -0
- package/src/components/search/search.js +17 -5
- package/src/components/select/color.js +22 -0
- package/src/components/select/field-adapter.js +6 -1
- package/src/components/select/glyphs.js +165 -0
- package/src/components/select/select.css +138 -45
- package/src/components/select/select.js +86 -47
- package/src/components/sheet/sheet.css +11 -8
- package/src/components/slider/slider.css +16 -2
- package/src/components/slider/slider.js +1 -1
- package/src/components/sort-control/sort-control.css +20 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +208 -0
- package/src/components/stat-tile/stat-tile.js +601 -0
- package/src/components/tabbox/tabbox.css +32 -27
- package/src/components/tabbox/tabbox.js +17 -2
- package/src/components/table/table.css +155 -31
- package/src/components/table/table.js +386 -33
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +86 -6
- package/src/components/tag-picker/tag-picker.js +25 -8
- package/src/components/timebox/timebox.css +22 -2
- package/src/components/timebox/timebox.js +18 -5
- package/src/components/toggle/toggle.css +12 -1
- package/src/components/toolbar/toolbar.js +2 -2
- package/src/components/tooltip/tooltip.css +4 -0
- package/src/components/tooltip/tooltip.js +10 -3
- package/src/components/tree/tree.css +25 -16
- package/src/components/tree/tree.js +31 -0
- package/src/components/value-list/value-list.css +3 -3
- package/src/components/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/breakpoint.js +1 -1
- package/src/core/icons.js +259 -57
- package/src/core/motion.js +147 -0
- package/src/core/overlay-host.js +21 -2
- package/src/index.js +30 -3
- package/src/internal/app-rail.js +18 -1
- package/src/internal/menu-items.js +142 -0
- package/src/zeyos/connect.js +6 -2
- package/src/zeyos/form.js +67 -27
- package/src/zeyos/index.js +1 -1
- package/src/zeyos/query.js +153 -0
- package/src/zeyos/schema.js +144 -27
- package/src/zeyos/table.js +68 -5
- package/src/zeyos/view.js +6 -0
- package/styles/base.css +56 -0
- package/styles/icons.css +11 -1
- package/styles/print.css +192 -0
- package/styles/tokens/dark.css +113 -37
- package/styles/tokens/global.css +121 -27
- package/styles/tokens/semantic.css +100 -31
- package/styles/tokens/themes.css +7 -6
- package/styles/zx.css +11 -1
- package/styles/tokens/density.css +0 -9
|
@@ -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** at any spacing. 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
|
+
}
|