@zeyos/zx 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -0
- package/dist/types/compat/base.d.ts +156 -0
- package/dist/types/compat/globals.d.ts +20 -0
- package/dist/types/compat/index.d.ts +128 -0
- package/dist/types/compat/map/basic.d.ts +175 -0
- package/dist/types/compat/map/data.d.ts +102 -0
- package/dist/types/compat/map/date-time.d.ts +80 -0
- package/dist/types/compat/map/factory.d.ts +9 -0
- package/dist/types/compat/map/form.d.ts +98 -0
- package/dist/types/compat/map/helpers.d.ts +12 -0
- package/dist/types/compat/map/http.d.ts +36 -0
- package/dist/types/compat/map/layout.d.ts +106 -0
- package/dist/types/compat/map/options.d.ts +80 -0
- package/dist/types/compat/map/select.d.ts +95 -0
- package/dist/types/compat/map/stubs.d.ts +32 -0
- package/dist/types/compat/map/utilities.d.ts +37 -0
- package/dist/types/compat-entry.d.ts +5 -0
- package/dist/types/components/badge/badge.d.ts +46 -0
- package/dist/types/components/breadcrumb/breadcrumb.d.ts +200 -0
- package/dist/types/components/button/button.d.ts +42 -0
- package/dist/types/components/check-button/check-button.d.ts +98 -0
- package/dist/types/components/checklist/checklist.d.ts +143 -0
- package/dist/types/components/checklist/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +130 -0
- package/dist/types/components/copy/copy-button.d.ts +48 -0
- package/dist/types/components/copy/copy-input.d.ts +91 -0
- package/dist/types/components/data-filter/data-filter.d.ts +250 -0
- package/dist/types/components/data-filter/filter-core.d.ts +52 -0
- package/dist/types/components/date-picker/date-picker.d.ts +137 -0
- package/dist/types/components/date-picker/date-range-picker.d.ts +350 -0
- package/dist/types/components/date-picker/field-adapter.d.ts +6 -0
- package/dist/types/components/date-picker/month-picker.d.ts +75 -0
- package/dist/types/components/date-picker/range-field-adapter.d.ts +7 -0
- package/dist/types/components/date-picker/time-picker.d.ts +127 -0
- package/dist/types/components/datebox/date-range-box.d.ts +218 -0
- package/dist/types/components/datebox/datebox.d.ts +160 -0
- package/dist/types/components/datebox/field-adapter.d.ts +5 -0
- package/dist/types/components/dialog/dialog.d.ts +284 -0
- package/dist/types/components/dropdown/dropdown.d.ts +118 -0
- package/dist/types/components/empty-state/empty-state.d.ts +38 -0
- package/dist/types/components/field/field.d.ts +231 -0
- package/dist/types/components/field-upload/field-adapter.d.ts +5 -0
- package/dist/types/components/field-upload/field-upload.d.ts +213 -0
- package/dist/types/components/fieldset/fieldset.d.ts +125 -0
- package/dist/types/components/finder/finder.d.ts +252 -0
- package/dist/types/components/form/form.d.ts +194 -0
- package/dist/types/components/groupbox/groupbox.d.ts +89 -0
- package/dist/types/components/layout/layout.d.ts +132 -0
- package/dist/types/components/loading/inline-loading.d.ts +86 -0
- package/dist/types/components/loading/progress-bar.d.ts +168 -0
- package/dist/types/components/loading/spinner.d.ts +34 -0
- package/dist/types/components/master-panel/master-panel.d.ts +112 -0
- package/dist/types/components/menu-button/menu-button.d.ts +142 -0
- package/dist/types/components/message/message.d.ts +212 -0
- package/dist/types/components/modal/modal.d.ts +105 -0
- package/dist/types/components/multi-value-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +66 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +229 -0
- package/dist/types/components/number-field/field-adapter.d.ts +5 -0
- package/dist/types/components/number-field/number-field.d.ts +292 -0
- package/dist/types/components/pagination/pagination.d.ts +267 -0
- package/dist/types/components/panel/panel.d.ts +194 -0
- package/dist/types/components/permission/permission.d.ts +93 -0
- package/dist/types/components/rating/field-adapter.d.ts +5 -0
- package/dist/types/components/rating/rating.d.ts +213 -0
- package/dist/types/components/search/search.d.ts +98 -0
- package/dist/types/components/select/field-adapter.d.ts +5 -0
- package/dist/types/components/select/filter.d.ts +11 -0
- package/dist/types/components/select/select.d.ts +235 -0
- package/dist/types/components/skeleton/skeleton.d.ts +97 -0
- package/dist/types/components/slider/field-adapter.d.ts +5 -0
- package/dist/types/components/slider/slider.d.ts +216 -0
- package/dist/types/components/split-view/split-view.d.ts +335 -0
- package/dist/types/components/stepper/stepper.d.ts +314 -0
- package/dist/types/components/stepper/steps.d.ts +131 -0
- package/dist/types/components/tabbox/tabbox.d.ts +230 -0
- package/dist/types/components/table/sort.d.ts +49 -0
- package/dist/types/components/table/table.d.ts +872 -0
- package/dist/types/components/tag-picker/field-adapter.d.ts +5 -0
- package/dist/types/components/tag-picker/tag-picker.d.ts +375 -0
- package/dist/types/components/timebox/field-adapter.d.ts +5 -0
- package/dist/types/components/timebox/timebox.d.ts +121 -0
- package/dist/types/components/toggle/field-adapter.d.ts +5 -0
- package/dist/types/components/toggle/toggle.d.ts +94 -0
- package/dist/types/components/toolbar/toolbar.d.ts +230 -0
- package/dist/types/components/tooltip/tooltip.d.ts +183 -0
- package/dist/types/components/tree/hierarchy.d.ts +113 -0
- package/dist/types/components/tree/tree.d.ts +383 -0
- package/dist/types/components/truncate/truncate.d.ts +61 -0
- package/dist/types/components/value-list/field-adapter.d.ts +5 -0
- package/dist/types/components/value-list/value-list.d.ts +143 -0
- package/dist/types/core/breakpoint.d.ts +68 -0
- package/dist/types/core/component.d.ts +115 -0
- package/dist/types/core/date.d.ts +50 -0
- package/dist/types/core/dom.d.ts +86 -0
- package/dist/types/core/export.d.ts +70 -0
- package/dist/types/core/fontawesome.d.ts +107 -0
- package/dist/types/core/format.d.ts +107 -0
- package/dist/types/core/http.d.ts +122 -0
- package/dist/types/core/i18n.d.ts +35 -0
- package/dist/types/core/icons.d.ts +137 -0
- package/dist/types/core/keyboard.d.ts +72 -0
- package/dist/types/core/position.d.ts +45 -0
- package/dist/types/core/storage.d.ts +46 -0
- package/dist/types/core/util.d.ts +117 -0
- package/dist/types/elements/define.d.ts +73 -0
- package/dist/types/elements/reflect.d.ts +27 -0
- package/dist/types/index.d.ts +79 -0
- package/dist/types/zeyos/connect.d.ts +65 -0
- package/dist/types/zeyos/form.d.ts +140 -0
- package/dist/types/zeyos/icons.d.ts +104 -0
- package/dist/types/zeyos/index.d.ts +12 -0
- package/dist/types/zeyos/modules.d.ts +110 -0
- package/dist/types/zeyos/query.d.ts +157 -0
- package/dist/types/zeyos/schema.d.ts +79 -0
- package/dist/types/zeyos/select.d.ts +167 -0
- package/dist/types/zeyos/table.d.ts +205 -0
- package/dist/zx-compat.esm.js +375 -39
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +372 -73
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +355 -49
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +68 -6
- package/dist/zx.esm.js +359 -73
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +359 -73
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +48 -2
- package/docs/llms.md +31 -0
- package/package.json +37 -9
- package/src/compat/base.js +291 -0
- package/src/compat/globals.js +165 -0
- package/src/compat/index.js +84 -0
- package/src/compat/map/basic.js +464 -0
- package/src/compat/map/data.js +232 -0
- package/src/compat/map/date-time.js +177 -0
- package/src/compat/map/factory.js +45 -0
- package/src/compat/map/form.js +330 -0
- package/src/compat/map/helpers.js +46 -0
- package/src/compat/map/http.js +117 -0
- package/src/compat/map/layout.js +256 -0
- package/src/compat/map/options.js +267 -0
- package/src/compat/map/select.js +198 -0
- package/src/compat/map/stubs.js +26 -0
- package/src/compat/map/utilities.js +113 -0
- package/src/compat-entry.js +11 -0
- package/src/components/badge/badge.css +147 -0
- package/src/components/badge/badge.js +92 -0
- package/src/components/breadcrumb/breadcrumb.css +105 -0
- package/src/components/breadcrumb/breadcrumb.js +331 -0
- package/src/components/button/button.css +104 -0
- package/src/components/button/button.js +76 -0
- package/src/components/check-button/check-button.css +66 -0
- package/src/components/check-button/check-button.js +167 -0
- package/src/components/checklist/checklist.css +65 -0
- package/src/components/checklist/checklist.js +282 -0
- package/src/components/checklist/field-adapter.js +46 -0
- package/src/components/context-menu/context-menu.css +54 -0
- package/src/components/context-menu/context-menu.js +334 -0
- package/src/components/copy/copy-button.css +27 -0
- package/src/components/copy/copy-button.js +78 -0
- package/src/components/copy/copy-input.css +59 -0
- package/src/components/copy/copy-input.js +123 -0
- package/src/components/data-filter/data-filter.css +53 -0
- package/src/components/data-filter/data-filter.js +400 -0
- package/src/components/data-filter/filter-core.js +128 -0
- package/src/components/date-picker/date-picker.css +181 -0
- package/src/components/date-picker/date-picker.js +500 -0
- package/src/components/date-picker/date-range-picker.css +264 -0
- package/src/components/date-picker/date-range-picker.js +990 -0
- package/src/components/date-picker/field-adapter.js +36 -0
- package/src/components/date-picker/month-picker.js +223 -0
- package/src/components/date-picker/range-field-adapter.js +31 -0
- package/src/components/date-picker/time-picker.css +191 -0
- package/src/components/date-picker/time-picker.js +406 -0
- package/src/components/datebox/date-range-box.css +128 -0
- package/src/components/datebox/date-range-box.js +492 -0
- package/src/components/datebox/datebox.css +108 -0
- package/src/components/datebox/datebox.js +358 -0
- package/src/components/datebox/field-adapter.js +34 -0
- package/src/components/dialog/dialog.css +74 -0
- package/src/components/dialog/dialog.js +450 -0
- package/src/components/dropdown/dropdown.css +24 -0
- package/src/components/dropdown/dropdown.js +288 -0
- package/src/components/empty-state/empty-state.css +80 -0
- package/src/components/empty-state/empty-state.js +92 -0
- package/src/components/field/field.css +127 -0
- package/src/components/field/field.js +605 -0
- package/src/components/field-upload/field-adapter.js +21 -0
- package/src/components/field-upload/field-upload.css +108 -0
- package/src/components/field-upload/field-upload.js +475 -0
- package/src/components/fieldset/fieldset.css +37 -0
- package/src/components/fieldset/fieldset.js +307 -0
- package/src/components/finder/finder.css +98 -0
- package/src/components/finder/finder.js +509 -0
- package/src/components/form/form.css +32 -0
- package/src/components/form/form.js +302 -0
- package/src/components/groupbox/groupbox.css +51 -0
- package/src/components/groupbox/groupbox.js +199 -0
- package/src/components/layout/layout.css +117 -0
- package/src/components/layout/layout.js +136 -0
- package/src/components/loading/inline-loading.css +34 -0
- package/src/components/loading/inline-loading.js +140 -0
- package/src/components/loading/progress-bar.css +115 -0
- package/src/components/loading/progress-bar.js +259 -0
- package/src/components/loading/spinner.css +58 -0
- package/src/components/loading/spinner.js +57 -0
- package/src/components/master-panel/master-panel.css +51 -0
- package/src/components/master-panel/master-panel.js +203 -0
- package/src/components/menu-button/menu-button.css +49 -0
- package/src/components/menu-button/menu-button.js +294 -0
- package/src/components/message/message.css +122 -0
- package/src/components/message/message.js +499 -0
- package/src/components/modal/modal.css +47 -0
- package/src/components/modal/modal.js +203 -0
- package/src/components/multi-value-editor/field-adapter.js +44 -0
- package/src/components/multi-value-editor/multi-value-editor.css +56 -0
- package/src/components/multi-value-editor/multi-value-editor.js +170 -0
- package/src/components/navigation-bar/navigation-bar.css +130 -0
- package/src/components/navigation-bar/navigation-bar.js +450 -0
- package/src/components/number-field/field-adapter.js +40 -0
- package/src/components/number-field/number-field.css +77 -0
- package/src/components/number-field/number-field.js +545 -0
- package/src/components/pagination/pagination.css +95 -0
- package/src/components/pagination/pagination.js +481 -0
- package/src/components/panel/panel.css +123 -0
- package/src/components/panel/panel.js +336 -0
- package/src/components/permission/permission.css +47 -0
- package/src/components/permission/permission.js +198 -0
- package/src/components/rating/field-adapter.js +34 -0
- package/src/components/rating/rating.css +97 -0
- package/src/components/rating/rating.js +395 -0
- package/src/components/search/search.css +70 -0
- package/src/components/search/search.js +193 -0
- package/src/components/select/field-adapter.js +24 -0
- package/src/components/select/filter.js +39 -0
- package/src/components/select/select.css +180 -0
- package/src/components/select/select.js +663 -0
- package/src/components/skeleton/skeleton.css +76 -0
- package/src/components/skeleton/skeleton.js +139 -0
- package/src/components/slider/field-adapter.js +36 -0
- package/src/components/slider/slider.css +181 -0
- package/src/components/slider/slider.js +399 -0
- package/src/components/split-view/split-view.css +139 -0
- package/src/components/split-view/split-view.js +806 -0
- package/src/components/stepper/stepper.css +234 -0
- package/src/components/stepper/stepper.js +459 -0
- package/src/components/stepper/steps.js +131 -0
- package/src/components/tabbox/tabbox.css +281 -0
- package/src/components/tabbox/tabbox.js +484 -0
- package/src/components/table/sort.js +85 -0
- package/src/components/table/table.css +364 -0
- package/src/components/table/table.js +1711 -0
- package/src/components/tag-picker/field-adapter.js +35 -0
- package/src/components/tag-picker/tag-picker.css +169 -0
- package/src/components/tag-picker/tag-picker.js +837 -0
- package/src/components/timebox/field-adapter.js +24 -0
- package/src/components/timebox/timebox.css +51 -0
- package/src/components/timebox/timebox.js +276 -0
- package/src/components/toggle/field-adapter.js +24 -0
- package/src/components/toggle/toggle.css +66 -0
- package/src/components/toggle/toggle.js +150 -0
- package/src/components/toolbar/toolbar.css +81 -0
- package/src/components/toolbar/toolbar.js +511 -0
- package/src/components/tooltip/tooltip.css +36 -0
- package/src/components/tooltip/tooltip.js +420 -0
- package/src/components/tree/hierarchy.js +151 -0
- package/src/components/tree/tree.css +99 -0
- package/src/components/tree/tree.js +784 -0
- package/src/components/truncate/truncate.css +20 -0
- package/src/components/truncate/truncate.js +92 -0
- package/src/components/value-list/field-adapter.js +24 -0
- package/src/components/value-list/value-list.css +108 -0
- package/src/components/value-list/value-list.js +336 -0
- package/src/core/breakpoint.js +150 -0
- package/src/core/component.js +292 -0
- package/src/core/date.js +216 -0
- package/src/core/dom.js +260 -0
- package/src/core/export.js +204 -0
- package/src/core/fontawesome.js +177 -0
- package/src/core/format.js +283 -0
- package/src/core/http.js +246 -0
- package/src/core/i18n.js +83 -0
- package/src/core/icons.js +283 -0
- package/src/core/keyboard.js +240 -0
- package/src/core/position.js +177 -0
- package/src/core/storage.js +175 -0
- package/src/core/util.js +336 -0
- package/src/elements/define.js +715 -0
- package/src/elements/reflect.js +79 -0
- package/src/index.js +131 -0
- package/src/zeyos/connect.js +142 -0
- package/src/zeyos/form.js +254 -0
- package/src/zeyos/icons.js +92 -0
- package/src/zeyos/index.js +21 -0
- package/src/zeyos/modules.js +269 -0
- package/src/zeyos/query.js +199 -0
- package/src/zeyos/schema.js +474 -0
- package/src/zeyos/select.js +175 -0
- package/src/zeyos/table.js +272 -0
- package/styles/base.css +124 -0
- package/styles/icons.css +34 -0
- package/styles/tokens/dark.css +84 -0
- package/styles/tokens/density.css +9 -0
- package/styles/tokens/global.css +80 -0
- package/styles/tokens/modules.css +109 -0
- package/styles/tokens/semantic.css +49 -0
- package/styles/zx.css +61 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
3
|
+
import { icon } from '../../core/icons.js';
|
|
4
|
+
import { position } from '../../core/position.js';
|
|
5
|
+
import { uid } from '../../core/util.js';
|
|
6
|
+
import { matchItems } from '../select/filter.js';
|
|
7
|
+
|
|
8
|
+
/** @typedef {Record<string, any>|string|number} TagItem */
|
|
9
|
+
/** @typedef {string|((item: TagItem) => unknown)} TagValueReader */
|
|
10
|
+
/** @typedef {string|((item: TagItem) => string)} TagLabelReader */
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} TagPickerOptions
|
|
13
|
+
* @property {TagItem[]} [items=[]] Selectable items.
|
|
14
|
+
* @property {unknown[]} [values=[]] Initially selected IDs.
|
|
15
|
+
* @property {TagValueReader} [valueKey='ID'] Item ID property or reader.
|
|
16
|
+
* @property {TagLabelReader} [labelKey='name'] Item label property or reader.
|
|
17
|
+
* @property {Array<TagValueReader>|null} [searchKeys=null] Fields searched by local filtering.
|
|
18
|
+
* @property {'local'|((query: string) => Promise<TagItem[]>|TagItem[])} [filter='local'] Filtering mode.
|
|
19
|
+
* @property {number} [minQuery=0] Minimum async query length.
|
|
20
|
+
* @property {number} [debounce=200] Async query delay in milliseconds.
|
|
21
|
+
* @property {boolean} [allowCreate=false] Whether unknown values may be created from the query.
|
|
22
|
+
* @property {number|null} [max=null] Maximum number of selected values.
|
|
23
|
+
* @property {boolean} [closeOnSelect=false] Whether picking an item closes the list.
|
|
24
|
+
* @property {string} [placeholder=''] Empty-state input text.
|
|
25
|
+
* @property {number} [listHeight=260] Maximum list height in pixels.
|
|
26
|
+
* @property {boolean} [disabled=false] Whether interaction is disabled.
|
|
27
|
+
* @property {boolean} [readonly=false] Whether tags may be added or removed.
|
|
28
|
+
* @property {((item: TagItem) => Node|string)|null} [renderItem=null] Option renderer.
|
|
29
|
+
* @property {((item: TagItem) => Node|string)|null} [renderTag=null] Tag-label renderer.
|
|
30
|
+
* @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
|
|
31
|
+
* @property {(event: CustomEvent<{values: unknown[], items: TagItem[]}>) => void} [onchange] Change listener.
|
|
32
|
+
* @property {(event: CustomEvent<{value: unknown, item: TagItem}>) => void} [onadd] Add listener.
|
|
33
|
+
* @property {(event: CustomEvent<{value: unknown, item: TagItem}>) => void} [onremove] Remove listener.
|
|
34
|
+
* @property {(event: CustomEvent<{value: unknown, item: TagItem}>) => void} [oncreate] Create listener.
|
|
35
|
+
* @property {(event: CustomEvent<{query: string}>) => void} [onquery] Async-query listener.
|
|
36
|
+
* @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open listener.
|
|
37
|
+
* @property {(event: CustomEvent<Record<string, never>>) => void} [onclose] Close listener.
|
|
38
|
+
* @property {(event: CustomEvent<{error: Error}>) => void} [onerror] Async-failure listener.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Multi-select combobox that renders its selection as removable tags inside the control
|
|
43
|
+
* (APG combobox with a multi-selectable listbox).
|
|
44
|
+
*
|
|
45
|
+
* It differs from the two neighbouring components on purpose: `ValueList` takes free text with no
|
|
46
|
+
* catalogue behind it, `Checklist` shows a fixed set in full, and `TagPicker` searches a large
|
|
47
|
+
* catalogue and keeps the chosen values visible in the control.
|
|
48
|
+
*
|
|
49
|
+
* @fires TagPicker#change
|
|
50
|
+
* @fires TagPicker#add
|
|
51
|
+
* @fires TagPicker#remove
|
|
52
|
+
* @fires TagPicker#create
|
|
53
|
+
* @fires TagPicker#query
|
|
54
|
+
* @fires TagPicker#open
|
|
55
|
+
* @fires TagPicker#close
|
|
56
|
+
* @fires TagPicker#error
|
|
57
|
+
* @extends {Component<TagPickerOptions>}
|
|
58
|
+
*/
|
|
59
|
+
export class TagPicker extends Component {
|
|
60
|
+
static cssName = 'tag-picker';
|
|
61
|
+
|
|
62
|
+
/** @type {TagPickerOptions} */
|
|
63
|
+
static defaults = {
|
|
64
|
+
items: [],
|
|
65
|
+
values: [],
|
|
66
|
+
valueKey: 'ID',
|
|
67
|
+
labelKey: 'name',
|
|
68
|
+
searchKeys: null,
|
|
69
|
+
filter: 'local',
|
|
70
|
+
minQuery: 0,
|
|
71
|
+
debounce: 200,
|
|
72
|
+
allowCreate: false,
|
|
73
|
+
max: null,
|
|
74
|
+
closeOnSelect: false,
|
|
75
|
+
placeholder: '',
|
|
76
|
+
listHeight: 260,
|
|
77
|
+
disabled: false,
|
|
78
|
+
readonly: false,
|
|
79
|
+
renderItem: null,
|
|
80
|
+
renderTag: null
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** @returns {HTMLElement} */
|
|
84
|
+
render() {
|
|
85
|
+
// render() runs inside the base constructor, before class-field initializers would run.
|
|
86
|
+
this._items = [];
|
|
87
|
+
this._selected = [];
|
|
88
|
+
this._visible = [];
|
|
89
|
+
this._entries = [];
|
|
90
|
+
this._activeIndex = -1;
|
|
91
|
+
this._open = false;
|
|
92
|
+
this._disabled = false;
|
|
93
|
+
this._readonly = false;
|
|
94
|
+
this._loading = false;
|
|
95
|
+
this._requestSequence = 0;
|
|
96
|
+
this._queryTimer = null;
|
|
97
|
+
this._position = null;
|
|
98
|
+
this._activeId = null;
|
|
99
|
+
this._createdRoot = this.el === null;
|
|
100
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(this.el);
|
|
101
|
+
|
|
102
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
103
|
+
this.el = root;
|
|
104
|
+
|
|
105
|
+
const listId = uid('zx-tag-picker-list');
|
|
106
|
+
const tags = h('span', { ref: 'tags', class: 'zx-tag-picker__tags' });
|
|
107
|
+
const input = h('input', {
|
|
108
|
+
ref: 'input',
|
|
109
|
+
class: 'zx-tag-picker__input',
|
|
110
|
+
type: 'text',
|
|
111
|
+
role: 'combobox',
|
|
112
|
+
autocomplete: 'off',
|
|
113
|
+
spellcheck: false,
|
|
114
|
+
ariaExpanded: 'false',
|
|
115
|
+
ariaControls: listId,
|
|
116
|
+
ariaAutocomplete: 'list',
|
|
117
|
+
ariaLabel: this._message('tagPicker.input', 'Add a value'),
|
|
118
|
+
placeholder: String(this.options.placeholder ?? '')
|
|
119
|
+
});
|
|
120
|
+
const toggle = h('button', {
|
|
121
|
+
ref: 'toggle',
|
|
122
|
+
class: 'zx-tag-picker__toggle',
|
|
123
|
+
type: 'button',
|
|
124
|
+
tabIndex: -1,
|
|
125
|
+
ariaLabel: this._message('tagPicker.toggle', 'Toggle options')
|
|
126
|
+
}, icon('chevron-down', { size: 15 }));
|
|
127
|
+
const control = h('div', { ref: 'control', class: 'zx-tag-picker__control' },
|
|
128
|
+
tags, input, toggle);
|
|
129
|
+
|
|
130
|
+
const list = h('div', {
|
|
131
|
+
ref: 'list',
|
|
132
|
+
class: 'zx-tag-picker__list',
|
|
133
|
+
id: listId,
|
|
134
|
+
role: 'listbox',
|
|
135
|
+
ariaMultiSelectable: 'true'
|
|
136
|
+
});
|
|
137
|
+
list.style.maxBlockSize = `${Math.max(0, Number(this.options.listHeight) || 0)}px`;
|
|
138
|
+
const panel = h('div', { ref: 'panel', class: 'zx-tag-picker__panel', popover: 'manual' }, list);
|
|
139
|
+
root.replaceChildren(control, panel);
|
|
140
|
+
|
|
141
|
+
this._items = Array.isArray(this.options.items) ? this.options.items.slice() : [];
|
|
142
|
+
this._visible = this._items.slice();
|
|
143
|
+
this._disabled = Boolean(this.options.disabled);
|
|
144
|
+
this._readonly = Boolean(this.options.readonly);
|
|
145
|
+
|
|
146
|
+
this.listen(control, 'pointerdown', (event) => {
|
|
147
|
+
// Clicking anywhere in the control focuses the input, except on a tag's remove button.
|
|
148
|
+
const target = /** @type {Element} */ (event.target);
|
|
149
|
+
if (target.closest('.zx-tag-picker__remove') || target.closest('.zx-tag-picker__toggle')) return;
|
|
150
|
+
if (event.target !== input) {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
this.focus();
|
|
153
|
+
}
|
|
154
|
+
if (!this._open) this.open();
|
|
155
|
+
});
|
|
156
|
+
// Tag removal is delegated: the tags are rebuilt on every change, so per-tag listeners would
|
|
157
|
+
// accumulate on the component's AbortController for elements that no longer exist.
|
|
158
|
+
this.listen(tags, 'click', (event) => {
|
|
159
|
+
const remove = /** @type {Element} */ (event.target).closest('.zx-tag-picker__remove');
|
|
160
|
+
if (!remove || this._disabled || this._readonly) return;
|
|
161
|
+
event.stopPropagation();
|
|
162
|
+
const index = [...tags.children].indexOf(remove.closest('.zx-tag-picker__tag'));
|
|
163
|
+
const item = this._selected[index];
|
|
164
|
+
if (item === undefined) return;
|
|
165
|
+
this.removeValue(this._valueOf(item));
|
|
166
|
+
this.focus();
|
|
167
|
+
});
|
|
168
|
+
this.listen(input, 'input', () => this._onInput());
|
|
169
|
+
this.listen(input, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
170
|
+
this.listen(input, 'blur', () => this.el.removeAttribute('data-focus'));
|
|
171
|
+
this.listen(input, 'focus', () => this.el.setAttribute('data-focus', ''));
|
|
172
|
+
this.listen(toggle, 'click', () => {
|
|
173
|
+
this.focus();
|
|
174
|
+
if (this._open) this.close();
|
|
175
|
+
else this.open();
|
|
176
|
+
});
|
|
177
|
+
this.listen(panel, 'pointerdown', (event) => event.preventDefault());
|
|
178
|
+
this.listen(panel, 'click', (event) => this._onOptionClick(/** @type {MouseEvent} */ (event)));
|
|
179
|
+
this.listen(document, 'pointerdown', (event) => {
|
|
180
|
+
if (this._open && !root.contains(/** @type {Node} */ (event.target))) this.close();
|
|
181
|
+
}, { capture: true });
|
|
182
|
+
|
|
183
|
+
this.setValues(this.options.values, { silent: true });
|
|
184
|
+
this._syncDisabled();
|
|
185
|
+
return root;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Selected IDs. @returns {unknown[]} */
|
|
189
|
+
get values() {
|
|
190
|
+
return this.getValues();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @param {unknown[]} next Selected IDs. */
|
|
194
|
+
set values(next) {
|
|
195
|
+
this.setValues(next);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns the selected IDs in selection order.
|
|
200
|
+
* @returns {unknown[]}
|
|
201
|
+
*/
|
|
202
|
+
getValues() {
|
|
203
|
+
return this._selected.map((item) => this._valueOf(item));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Returns the selected items.
|
|
208
|
+
* @returns {TagItem[]}
|
|
209
|
+
*/
|
|
210
|
+
getItems() {
|
|
211
|
+
return this._selected.slice();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Replaces the whole selection.
|
|
216
|
+
* @param {unknown[]} values Item IDs, or items.
|
|
217
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `change`.
|
|
218
|
+
* @returns {this}
|
|
219
|
+
* @fires TagPicker#change
|
|
220
|
+
*/
|
|
221
|
+
setValues(values, { silent = false } = {}) {
|
|
222
|
+
const list = Array.isArray(values) ? values : [];
|
|
223
|
+
const max = this.options.max === null || this.options.max === undefined
|
|
224
|
+
? Infinity
|
|
225
|
+
: Number(this.options.max);
|
|
226
|
+
const next = [];
|
|
227
|
+
for (const value of list) {
|
|
228
|
+
if (next.length >= max) break;
|
|
229
|
+
const item = this._resolve(value);
|
|
230
|
+
if (item !== null && !next.some((entry) => this._sameValue(entry, item))) next.push(item);
|
|
231
|
+
}
|
|
232
|
+
const changed = !sameSelection(next, this._selected, (item) => this._valueOf(item));
|
|
233
|
+
this._selected = next;
|
|
234
|
+
this._renderTags();
|
|
235
|
+
this._renderOptions();
|
|
236
|
+
if (changed && !silent) this._emitChange();
|
|
237
|
+
return this;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Adds one value to the selection.
|
|
242
|
+
* @param {unknown} value Item ID, or an item.
|
|
243
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress events.
|
|
244
|
+
* @returns {this}
|
|
245
|
+
* @fires TagPicker#add
|
|
246
|
+
* @fires TagPicker#change
|
|
247
|
+
*/
|
|
248
|
+
addValue(value, { silent = false } = {}) {
|
|
249
|
+
if (this.isFull()) return this;
|
|
250
|
+
const item = this._resolve(value);
|
|
251
|
+
if (item === null) return this;
|
|
252
|
+
if (this._selected.some((entry) => this._sameValue(entry, item))) return this;
|
|
253
|
+
this._selected.push(item);
|
|
254
|
+
this._renderTags();
|
|
255
|
+
this._renderOptions();
|
|
256
|
+
if (!silent) {
|
|
257
|
+
this.emit('add', { value: this._valueOf(item), item });
|
|
258
|
+
this._emitChange();
|
|
259
|
+
}
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Removes one value from the selection.
|
|
265
|
+
* @param {unknown} value Item ID, or an item.
|
|
266
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress events.
|
|
267
|
+
* @returns {this}
|
|
268
|
+
* @fires TagPicker#remove
|
|
269
|
+
* @fires TagPicker#change
|
|
270
|
+
*/
|
|
271
|
+
removeValue(value, { silent = false } = {}) {
|
|
272
|
+
const id = this._valueOf(this._resolve(value) ?? value);
|
|
273
|
+
const index = this._selected.findIndex((item) => Object.is(this._valueOf(item), id));
|
|
274
|
+
if (index < 0) return this;
|
|
275
|
+
const [item] = this._selected.splice(index, 1);
|
|
276
|
+
this._renderTags();
|
|
277
|
+
this._renderOptions();
|
|
278
|
+
if (!silent) {
|
|
279
|
+
this.emit('remove', { value: this._valueOf(item), item });
|
|
280
|
+
this._emitChange();
|
|
281
|
+
}
|
|
282
|
+
return this;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Clears the selection.
|
|
287
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `change`.
|
|
288
|
+
* @returns {this}
|
|
289
|
+
*/
|
|
290
|
+
clear({ silent = false } = {}) {
|
|
291
|
+
return this.setValues([], { silent });
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Restores the configured initial selection.
|
|
296
|
+
* @param {{silent?: boolean}} [options={}] Reset behavior.
|
|
297
|
+
* @returns {this}
|
|
298
|
+
*/
|
|
299
|
+
reset({ silent = false } = {}) {
|
|
300
|
+
return this.setValues(this.options.values, { silent });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Replaces the catalogue of selectable items.
|
|
305
|
+
* @param {TagItem[]} items Available items.
|
|
306
|
+
* @returns {this}
|
|
307
|
+
*/
|
|
308
|
+
setItems(items) {
|
|
309
|
+
// Invalidate any in-flight query first: a late response must not overwrite this catalogue.
|
|
310
|
+
this._cancelQuery();
|
|
311
|
+
this._items = Array.isArray(items) ? items.slice() : [];
|
|
312
|
+
if (typeof this.options.filter === 'function') this._visible = this._items.slice();
|
|
313
|
+
else this._applyLocalFilter(this.refs.input.value);
|
|
314
|
+
this._renderOptions();
|
|
315
|
+
this._position?.update();
|
|
316
|
+
return this;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Reports whether the configured maximum is reached. @returns {boolean} */
|
|
320
|
+
isFull() {
|
|
321
|
+
const max = this.options.max;
|
|
322
|
+
return max !== null && max !== undefined && this._selected.length >= Number(max);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Opens the option list. @returns {this} */
|
|
326
|
+
open() {
|
|
327
|
+
if (this._disabled || this._readonly || this._open) return this;
|
|
328
|
+
this._open = true;
|
|
329
|
+
this.el.setAttribute('data-state', 'open');
|
|
330
|
+
this.refs.input.setAttribute('aria-expanded', 'true');
|
|
331
|
+
this._renderOptions();
|
|
332
|
+
this._position = position(this.refs.control, /** @type {HTMLElement} */ (this.refs.panel), {
|
|
333
|
+
placement: 'bottom-start',
|
|
334
|
+
flip: true,
|
|
335
|
+
matchWidth: true
|
|
336
|
+
});
|
|
337
|
+
this._setActive(this._entries.length > 0 ? 0 : -1);
|
|
338
|
+
if (typeof this.options.filter === 'function') this._scheduleAsyncQuery(this.refs.input.value);
|
|
339
|
+
this.emit('open', {});
|
|
340
|
+
return this;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Closes the option list. @returns {this} */
|
|
344
|
+
close() {
|
|
345
|
+
if (!this._open) return this;
|
|
346
|
+
this._open = false;
|
|
347
|
+
this._cancelQuery();
|
|
348
|
+
this._position?.destroy();
|
|
349
|
+
this._position = null;
|
|
350
|
+
this.el.setAttribute('data-state', 'closed');
|
|
351
|
+
this.refs.input.setAttribute('aria-expanded', 'false');
|
|
352
|
+
this.refs.input.removeAttribute('aria-activedescendant');
|
|
353
|
+
this._activeIndex = -1;
|
|
354
|
+
this._activeId = null;
|
|
355
|
+
this.refs.input.value = '';
|
|
356
|
+
this._syncInputWidth();
|
|
357
|
+
this._applyLocalFilter('');
|
|
358
|
+
this.emit('close', {});
|
|
359
|
+
return this;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Focuses the text input. @returns {this} */
|
|
363
|
+
focus() {
|
|
364
|
+
this.refs.input.focus();
|
|
365
|
+
return this;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Enables user interaction. @returns {this} */
|
|
369
|
+
enable() {
|
|
370
|
+
this._disabled = false;
|
|
371
|
+
this._renderTags();
|
|
372
|
+
this._syncDisabled();
|
|
373
|
+
return this;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Disables user interaction and closes the list. @returns {this} */
|
|
377
|
+
disable() {
|
|
378
|
+
this.close();
|
|
379
|
+
this._disabled = true;
|
|
380
|
+
this._renderTags();
|
|
381
|
+
this._syncDisabled();
|
|
382
|
+
return this;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Sets the read-only state.
|
|
387
|
+
* @param {boolean} readonly Whether tags may be added or removed.
|
|
388
|
+
* @returns {this}
|
|
389
|
+
*/
|
|
390
|
+
setReadonly(readonly) {
|
|
391
|
+
if (readonly) this.close();
|
|
392
|
+
this._readonly = Boolean(readonly);
|
|
393
|
+
this._renderTags();
|
|
394
|
+
this._syncDisabled();
|
|
395
|
+
return this;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Cancels pending work and restores an enhanced target. @returns {void} */
|
|
399
|
+
destroy() {
|
|
400
|
+
this._cancelQuery();
|
|
401
|
+
this._position?.destroy();
|
|
402
|
+
this._position = null;
|
|
403
|
+
const root = this.el;
|
|
404
|
+
super.destroy();
|
|
405
|
+
if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* ------------------------------------------------------------------ rendering -- */
|
|
409
|
+
|
|
410
|
+
/** @returns {void} */
|
|
411
|
+
_renderTags() {
|
|
412
|
+
const tags = this._selected.map((item) => {
|
|
413
|
+
const label = this._labelOf(item);
|
|
414
|
+
const content = this.options.renderTag ? this.options.renderTag(item) : label;
|
|
415
|
+
const tag = h('span', { class: 'zx-tag-picker__tag' },
|
|
416
|
+
h('span', { class: 'zx-tag-picker__tag-label' }, content));
|
|
417
|
+
if (!this._readonly && !this._disabled) {
|
|
418
|
+
tag.append(h('button', {
|
|
419
|
+
class: 'zx-icon-btn zx-tag-picker__remove',
|
|
420
|
+
type: 'button',
|
|
421
|
+
ariaLabel: this._message('tagPicker.remove', 'Remove %1', [label])
|
|
422
|
+
}, icon('x', { size: 11 })));
|
|
423
|
+
}
|
|
424
|
+
return tag;
|
|
425
|
+
});
|
|
426
|
+
this.refs.tags.replaceChildren(...tags);
|
|
427
|
+
this.el.toggleAttribute('data-empty', this._selected.length === 0);
|
|
428
|
+
this._syncPlaceholder();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** @returns {void} */
|
|
432
|
+
_renderOptions() {
|
|
433
|
+
const list = this.refs.list;
|
|
434
|
+
this._entries = [];
|
|
435
|
+
const nodes = [];
|
|
436
|
+
|
|
437
|
+
for (const item of this._visible) {
|
|
438
|
+
const value = this._valueOf(item);
|
|
439
|
+
const selected = this._isSelected(value);
|
|
440
|
+
const option = h('div', {
|
|
441
|
+
class: 'zx-tag-picker__option',
|
|
442
|
+
id: uid('zx-tag-picker-option'),
|
|
443
|
+
role: 'option',
|
|
444
|
+
ariaSelected: String(selected)
|
|
445
|
+
},
|
|
446
|
+
h('span', { class: 'zx-tag-picker__check', ariaHidden: 'true' },
|
|
447
|
+
selected ? icon('check', { size: 13 }) : null),
|
|
448
|
+
h('span', { class: 'zx-tag-picker__option-label' },
|
|
449
|
+
this.options.renderItem ? this.options.renderItem(item) : this._labelOf(item)));
|
|
450
|
+
if (!selected && this.isFull()) option.setAttribute('aria-disabled', 'true');
|
|
451
|
+
this._entries.push({ item, element: option, create: false });
|
|
452
|
+
nodes.push(option);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const query = this.refs.input.value.trim();
|
|
456
|
+
if (this._canCreate(query)) {
|
|
457
|
+
const option = h('div', {
|
|
458
|
+
class: 'zx-tag-picker__option',
|
|
459
|
+
id: uid('zx-tag-picker-option'),
|
|
460
|
+
role: 'option',
|
|
461
|
+
ariaSelected: 'false',
|
|
462
|
+
dataset: { create: 'true' }
|
|
463
|
+
},
|
|
464
|
+
h('span', { class: 'zx-tag-picker__check', ariaHidden: 'true' }, icon('plus', { size: 13 })),
|
|
465
|
+
h('span', { class: 'zx-tag-picker__option-label' },
|
|
466
|
+
this._message('tagPicker.create', 'Create “%1”', [query])));
|
|
467
|
+
this._entries.push({ item: null, element: option, create: true });
|
|
468
|
+
nodes.push(option);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (nodes.length === 0) {
|
|
472
|
+
nodes.push(h('div', { class: 'zx-tag-picker__empty' },
|
|
473
|
+
this._loading
|
|
474
|
+
? this._message('tagPicker.loading', 'Loading…')
|
|
475
|
+
: this._message('tagPicker.empty', 'No matches')));
|
|
476
|
+
}
|
|
477
|
+
list.replaceChildren(...nodes);
|
|
478
|
+
// Re-anchor the active option by identity: the DOM ids are new on every render, so keeping
|
|
479
|
+
// the old numeric index would leave aria-activedescendant pointing at a removed node.
|
|
480
|
+
const restored = this._entries.findIndex((entry) => this._entryId(entry) === this._activeId);
|
|
481
|
+
this._setActive(restored >= 0 ? restored : (this._entries.length > 0 ? 0 : -1));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Stable identity for an option entry across re-renders.
|
|
486
|
+
* @param {{item: TagItem|null, create: boolean}} entry Entry.
|
|
487
|
+
* @returns {string}
|
|
488
|
+
*/
|
|
489
|
+
_entryId(entry) {
|
|
490
|
+
return entry.create ? '\u0000create' : String(this._valueOf(entry.item));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** @returns {void} */
|
|
494
|
+
_syncPlaceholder() {
|
|
495
|
+
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
496
|
+
// The placeholder would collide with the tags, so it only shows on an empty control.
|
|
497
|
+
input.placeholder = this._selected.length === 0 ? String(this.options.placeholder ?? '') : '';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* -------------------------------------------------------------- interaction -- */
|
|
501
|
+
|
|
502
|
+
/** @returns {void} */
|
|
503
|
+
_onInput() {
|
|
504
|
+
const query = this.refs.input.value;
|
|
505
|
+
this._syncInputWidth();
|
|
506
|
+
if (!this._open) this.open();
|
|
507
|
+
if (typeof this.options.filter === 'function') this._scheduleAsyncQuery(query);
|
|
508
|
+
else this._applyLocalFilter(query);
|
|
509
|
+
this._renderOptions();
|
|
510
|
+
this._setActive(this._entries.length > 0 ? 0 : -1);
|
|
511
|
+
this._position?.update();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
515
|
+
_onKeydown(event) {
|
|
516
|
+
if (this._disabled || this._readonly) return;
|
|
517
|
+
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
518
|
+
|
|
519
|
+
switch (event.key) {
|
|
520
|
+
case 'ArrowDown':
|
|
521
|
+
event.preventDefault();
|
|
522
|
+
if (!this._open) this.open();
|
|
523
|
+
else this._move(1);
|
|
524
|
+
return;
|
|
525
|
+
case 'ArrowUp':
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
if (this._open) this._move(-1);
|
|
528
|
+
return;
|
|
529
|
+
case 'Home':
|
|
530
|
+
if (this._open && input.value === '') {
|
|
531
|
+
event.preventDefault();
|
|
532
|
+
this._setActive(0);
|
|
533
|
+
}
|
|
534
|
+
return;
|
|
535
|
+
case 'End':
|
|
536
|
+
if (this._open && input.value === '') {
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
this._setActive(this._entries.length - 1);
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
case 'Enter': {
|
|
542
|
+
if (!this._open || this._activeIndex < 0) return;
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
this._commitEntry(this._entries[this._activeIndex]);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
case 'Escape':
|
|
548
|
+
if (this._open) {
|
|
549
|
+
event.preventDefault();
|
|
550
|
+
this.close();
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
case 'Backspace':
|
|
554
|
+
if (input.value === '' && this._selected.length > 0) {
|
|
555
|
+
event.preventDefault();
|
|
556
|
+
this.removeValue(this._valueOf(this._selected[this._selected.length - 1]));
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
case 'Tab':
|
|
560
|
+
if (this._open) this.close();
|
|
561
|
+
return;
|
|
562
|
+
default:
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
567
|
+
_onOptionClick(event) {
|
|
568
|
+
const element = /** @type {Element} */ (event.target).closest('.zx-tag-picker__option');
|
|
569
|
+
if (!element) return;
|
|
570
|
+
const entry = this._entries.find((candidate) => candidate.element === element);
|
|
571
|
+
if (entry) this._commitEntry(entry);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Applies the active entry: toggles an item, or creates one from the query.
|
|
576
|
+
* @param {{item: TagItem|null, element: HTMLElement, create: boolean}|undefined} entry Entry.
|
|
577
|
+
* @returns {void}
|
|
578
|
+
*/
|
|
579
|
+
_commitEntry(entry) {
|
|
580
|
+
if (!entry) return;
|
|
581
|
+
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
582
|
+
if (entry.create) {
|
|
583
|
+
const label = input.value.trim();
|
|
584
|
+
const item = this._createItem(label);
|
|
585
|
+
this._items = this._items.concat([item]);
|
|
586
|
+
this.addValue(item);
|
|
587
|
+
this.emit('create', { value: this._valueOf(item), item });
|
|
588
|
+
} else {
|
|
589
|
+
const value = this._valueOf(entry.item);
|
|
590
|
+
if (this._isSelected(value)) this.removeValue(value);
|
|
591
|
+
else if (!this.isFull()) this.addValue(entry.item);
|
|
592
|
+
else return;
|
|
593
|
+
}
|
|
594
|
+
input.value = '';
|
|
595
|
+
this._syncInputWidth();
|
|
596
|
+
this._applyLocalFilter('');
|
|
597
|
+
this._renderOptions();
|
|
598
|
+
if (this.options.closeOnSelect) this.close();
|
|
599
|
+
else {
|
|
600
|
+
this._setActive(this._entries.length > 0 ? 0 : -1);
|
|
601
|
+
this._position?.update();
|
|
602
|
+
}
|
|
603
|
+
this.focus();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Moves the active option. The APG combobox map stops at the ends rather than wrapping.
|
|
608
|
+
* @param {number} delta Signed step.
|
|
609
|
+
* @returns {void}
|
|
610
|
+
*/
|
|
611
|
+
_move(delta) {
|
|
612
|
+
if (this._entries.length === 0) return;
|
|
613
|
+
const next = this._activeIndex < 0
|
|
614
|
+
? (delta > 0 ? 0 : this._entries.length - 1)
|
|
615
|
+
: Math.min(this._entries.length - 1, Math.max(0, this._activeIndex + delta));
|
|
616
|
+
this._setActive(next);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** @param {number} index @returns {void} */
|
|
620
|
+
_setActive(index) {
|
|
621
|
+
this._activeIndex = index;
|
|
622
|
+
this._activeId = this._entries[index] ? this._entryId(this._entries[index]) : null;
|
|
623
|
+
for (const [position_, entry] of this._entries.entries()) {
|
|
624
|
+
const active = position_ === index;
|
|
625
|
+
entry.element.toggleAttribute('data-active', active);
|
|
626
|
+
if (active) {
|
|
627
|
+
this.refs.input.setAttribute('aria-activedescendant', entry.element.id);
|
|
628
|
+
entry.element.scrollIntoView({ block: 'nearest' });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (index < 0) this.refs.input.removeAttribute('aria-activedescendant');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/* ------------------------------------------------------------------ filtering -- */
|
|
635
|
+
|
|
636
|
+
/** @param {string} query @returns {void} */
|
|
637
|
+
_applyLocalFilter(query) {
|
|
638
|
+
if (typeof this.options.filter === 'function') return;
|
|
639
|
+
const keys = this.options.searchKeys ?? [this.options.labelKey];
|
|
640
|
+
this._visible = matchItems(this._items, query, keys);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** @param {string} query @returns {void} */
|
|
644
|
+
_scheduleAsyncQuery(query) {
|
|
645
|
+
this._cancelQuery();
|
|
646
|
+
const text = String(query ?? '');
|
|
647
|
+
if (text.length < Number(this.options.minQuery ?? 0)) {
|
|
648
|
+
this._visible = [];
|
|
649
|
+
this._renderOptions();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
// Results for the previous query must not stay clickable while a new one is pending.
|
|
653
|
+
this._visible = [];
|
|
654
|
+
this._setLoading(true);
|
|
655
|
+
this._renderOptions();
|
|
656
|
+
const delay = Math.max(0, Number(this.options.debounce) || 0);
|
|
657
|
+
this._queryTimer = setTimeout(() => {
|
|
658
|
+
this._queryTimer = null;
|
|
659
|
+
void this._runQuery(text);
|
|
660
|
+
}, delay);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** @param {string} query @returns {Promise<void>} */
|
|
664
|
+
async _runQuery(query) {
|
|
665
|
+
const sequence = this._requestSequence + 1;
|
|
666
|
+
this._requestSequence = sequence;
|
|
667
|
+
this._setLoading(true);
|
|
668
|
+
this.emit('query', { query });
|
|
669
|
+
try {
|
|
670
|
+
const result = await /** @type {Function} */ (this.options.filter)(query);
|
|
671
|
+
if (sequence !== this._requestSequence) return;
|
|
672
|
+
this._items = Array.isArray(result) ? result.slice() : [];
|
|
673
|
+
this._visible = this._items.slice();
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (sequence !== this._requestSequence) return;
|
|
676
|
+
this._visible = [];
|
|
677
|
+
this.emit('error', { error });
|
|
678
|
+
} finally {
|
|
679
|
+
if (sequence === this._requestSequence) {
|
|
680
|
+
this._setLoading(false);
|
|
681
|
+
this._renderOptions();
|
|
682
|
+
this._setActive(this._entries.length > 0 ? 0 : -1);
|
|
683
|
+
this._position?.update();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** @returns {void} */
|
|
689
|
+
_cancelQuery() {
|
|
690
|
+
if (this._queryTimer !== null) {
|
|
691
|
+
clearTimeout(this._queryTimer);
|
|
692
|
+
this._queryTimer = null;
|
|
693
|
+
}
|
|
694
|
+
this._requestSequence += 1;
|
|
695
|
+
this._setLoading(false);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** @param {boolean} loading @returns {void} */
|
|
699
|
+
_setLoading(loading) {
|
|
700
|
+
this._loading = loading;
|
|
701
|
+
this.el.toggleAttribute('data-loading', loading);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Reports whether an item is already selected.
|
|
706
|
+
* @param {unknown} value Item ID.
|
|
707
|
+
* @returns {boolean}
|
|
708
|
+
*/
|
|
709
|
+
_isSelected(value) {
|
|
710
|
+
return this._selected.some((item) => Object.is(this._valueOf(item), value));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* --------------------------------------------------------------------- items -- */
|
|
714
|
+
|
|
715
|
+
/** @param {string} query @returns {boolean} */
|
|
716
|
+
_canCreate(query) {
|
|
717
|
+
if (!this.options.allowCreate || query === '' || this.isFull()) return false;
|
|
718
|
+
const exists = (list) => list.some((item) => normalizeLabel(this._labelOf(item)) === normalizeLabel(query));
|
|
719
|
+
return !exists(this._items) && !exists(this._selected);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Builds a stand-in item for a value that is not in the catalogue.
|
|
724
|
+
* @param {string} label Visible label.
|
|
725
|
+
* @param {unknown} [value=label] ID to keep, so the original type survives.
|
|
726
|
+
* @returns {TagItem}
|
|
727
|
+
*/
|
|
728
|
+
_createItem(label, value = label) {
|
|
729
|
+
if (typeof this.options.valueKey !== 'string' || typeof this.options.labelKey !== 'string') {
|
|
730
|
+
return label;
|
|
731
|
+
}
|
|
732
|
+
return { [this.options.valueKey]: value, [this.options.labelKey]: label, created: true };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Resolves an ID or an item to a catalogue item, falling back to a bare value.
|
|
737
|
+
* @param {unknown} value ID or item.
|
|
738
|
+
* @returns {TagItem|null}
|
|
739
|
+
*/
|
|
740
|
+
_resolve(value) {
|
|
741
|
+
if (value === null || value === undefined) return null;
|
|
742
|
+
const id = this._valueOf(value);
|
|
743
|
+
// `find` results are compared against undefined, not truthiness: an item whose ID is 0 or ''
|
|
744
|
+
// is a real item.
|
|
745
|
+
const known = this._items.find((item) => Object.is(this._valueOf(item), id));
|
|
746
|
+
if (known !== undefined) return known;
|
|
747
|
+
const selected = this._selected.find((item) => Object.is(this._valueOf(item), id));
|
|
748
|
+
if (selected !== undefined) return selected;
|
|
749
|
+
if (typeof value === 'object') return /** @type {TagItem} */ (value);
|
|
750
|
+
// Unknown IDs still become tags so a stored selection survives a catalogue that has not
|
|
751
|
+
// loaded yet — keeping the original ID, so a later numeric 7 matches the placeholder 7.
|
|
752
|
+
return this._createItem(String(value), value);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** @param {TagItem} a @param {TagItem} b @returns {boolean} */
|
|
756
|
+
_sameValue(a, b) {
|
|
757
|
+
return Object.is(this._valueOf(a), this._valueOf(b));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** @param {unknown} item @returns {unknown} */
|
|
761
|
+
_valueOf(item) {
|
|
762
|
+
const key = this.options.valueKey;
|
|
763
|
+
if (typeof key === 'function') return key(/** @type {TagItem} */ (item));
|
|
764
|
+
if (item !== null && typeof item === 'object') return item[key];
|
|
765
|
+
return item;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** @param {unknown} item @returns {string} */
|
|
769
|
+
_labelOf(item) {
|
|
770
|
+
const key = this.options.labelKey;
|
|
771
|
+
if (typeof key === 'function') return String(key(/** @type {TagItem} */ (item)));
|
|
772
|
+
if (item !== null && typeof item === 'object') return String(item[key] ?? '');
|
|
773
|
+
return String(item ?? '');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** @returns {void} */
|
|
777
|
+
_emitChange() {
|
|
778
|
+
this.emit('change', { values: this.getValues(), items: this.getItems() });
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Grows the input to fit its text so the caret stays visible next to the tags. @returns {void} */
|
|
782
|
+
_syncInputWidth() {
|
|
783
|
+
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
784
|
+
const length = input.value.length;
|
|
785
|
+
input.style.inlineSize = length === 0 ? '' : `${Math.min(40, length + 1)}ch`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** @returns {void} */
|
|
789
|
+
_syncDisabled() {
|
|
790
|
+
const input = /** @type {HTMLInputElement} */ (this.refs.input);
|
|
791
|
+
input.disabled = this._disabled;
|
|
792
|
+
input.readOnly = this._readonly;
|
|
793
|
+
this.refs.toggle.disabled = this._disabled || this._readonly;
|
|
794
|
+
this.el.toggleAttribute('data-disabled', this._disabled);
|
|
795
|
+
this.el.toggleAttribute('data-readonly', this._readonly);
|
|
796
|
+
if (this._disabled) this.el.setAttribute('aria-disabled', 'true');
|
|
797
|
+
else this.el.removeAttribute('aria-disabled');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* @param {string} key Message key.
|
|
802
|
+
* @param {string} fallback Template used when the host has no translation.
|
|
803
|
+
* @param {unknown[]} [args=[]] Positional interpolation values.
|
|
804
|
+
* @returns {string}
|
|
805
|
+
*/
|
|
806
|
+
_message(key, fallback, args = []) {
|
|
807
|
+
const message = this.msg(key, ...args);
|
|
808
|
+
if (message !== key) return message;
|
|
809
|
+
return args.reduce((text, value, index) => text.replaceAll(`%${index + 1}`, String(value)), fallback);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Selection change. @event TagPicker#change @type {CustomEvent<{values: unknown[], items: TagItem[]}>} */
|
|
814
|
+
/** One value added. @event TagPicker#add @type {CustomEvent<{value: unknown, item: TagItem}>} */
|
|
815
|
+
/** One value removed. @event TagPicker#remove @type {CustomEvent<{value: unknown, item: TagItem}>} */
|
|
816
|
+
/** A value created from the query. @event TagPicker#create @type {CustomEvent<{value: unknown, item: TagItem}>} */
|
|
817
|
+
/** Async query started. @event TagPicker#query @type {CustomEvent<{query: string}>} */
|
|
818
|
+
/** Option list opened. @event TagPicker#open @type {CustomEvent<Record<string, never>>} */
|
|
819
|
+
/** Option list closed. @event TagPicker#close @type {CustomEvent<Record<string, never>>} */
|
|
820
|
+
/** An async filter rejected. @event TagPicker#error @type {CustomEvent<{error: Error}>} */
|
|
821
|
+
|
|
822
|
+
/** @param {string} value @returns {string} */
|
|
823
|
+
function normalizeLabel(value) {
|
|
824
|
+
return String(value ?? '').trim().toLocaleLowerCase();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* @template T
|
|
829
|
+
* @param {T[]} a
|
|
830
|
+
* @param {T[]} b
|
|
831
|
+
* @param {(item: T) => unknown} read
|
|
832
|
+
* @returns {boolean}
|
|
833
|
+
*/
|
|
834
|
+
function sameSelection(a, b, read) {
|
|
835
|
+
if (a.length !== b.length) return false;
|
|
836
|
+
return a.every((item, index) => Object.is(read(item), read(b[index])));
|
|
837
|
+
}
|