@kubex/zinc 1.0.25 → 1.0.99
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/.claude/project.md +556 -0
- package/.github/workflows/js_build_and_deploy.yaml +9 -10
- package/dist/custom-elements.json +13575 -8856
- package/dist/vscode.html-custom-data.json +583 -48
- package/dist/web-types.json +1441 -181
- package/dist/zn.d.ts +1645 -660
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +1412 -893
- package/docs/_utilities/code-previews.cjs +10 -1
- package/docs/assets/scripts/docs.js +42 -18
- package/docs/data/data-table.json +229 -66
- package/docs/data/empty.json +6 -0
- package/docs/data/products-table.json +41 -193
- package/docs/pages/components/action-bar.md +121 -6
- package/docs/pages/components/alert.md +118 -15
- package/docs/pages/components/animated-button.md +299 -0
- package/docs/pages/components/audio-select.md +534 -0
- package/docs/pages/components/bulk-actions.md +79 -5
- package/docs/pages/components/button-group.md +235 -27
- package/docs/pages/components/button.md +139 -1
- package/docs/pages/components/chart.md +365 -9
- package/docs/pages/components/checkbox.md +387 -58
- package/docs/pages/components/chip.md +253 -33
- package/docs/pages/components/collapsible.md +393 -46
- package/docs/pages/components/cols.md +152 -30
- package/docs/pages/components/confirm.md +182 -16
- package/docs/pages/components/copy-button.md +169 -6
- package/docs/pages/components/data-select.md +194 -15
- package/docs/pages/components/data-table-filter.md +96 -5
- package/docs/pages/components/data-table-search.md +451 -0
- package/docs/pages/components/data-table-sort.md +66 -5
- package/docs/pages/components/data-table.md +727 -66
- package/docs/pages/components/datepicker.md +402 -7
- package/docs/pages/components/defined-label.md +120 -5
- package/docs/pages/components/dialog.md +339 -59
- package/docs/pages/components/dropdown.md +366 -11
- package/docs/pages/components/editor.md +604 -7
- package/docs/pages/components/empty-state.md +315 -16
- package/docs/pages/components/expanding-action.md +212 -28
- package/docs/pages/components/file.md +421 -12
- package/docs/pages/components/filter-container.md +158 -5
- package/docs/pages/components/filter-wrapper.md +167 -6
- package/docs/pages/components/form-group.md +383 -6
- package/docs/pages/components/hover-container.md +237 -6
- package/docs/pages/components/icon-picker.md +121 -0
- package/docs/pages/components/icon.md +454 -48
- package/docs/pages/components/inline-edit.md +787 -19
- package/docs/pages/components/input-group.md +207 -13
- package/docs/pages/components/input.md +194 -0
- package/docs/pages/components/item.md +18 -0
- package/docs/pages/components/key-container.md +115 -0
- package/docs/pages/components/key.md +101 -0
- package/docs/pages/components/menu.md +512 -6
- package/docs/pages/components/note.md +239 -36
- package/docs/pages/components/opt-group.md +107 -0
- package/docs/pages/components/pagination.md +332 -6
- package/docs/pages/components/pane.md +222 -6
- package/docs/pages/components/panel.md +213 -26
- package/docs/pages/components/popup.md +1354 -6
- package/docs/pages/components/priority-list.md +233 -0
- package/docs/pages/components/progress-bar.md +387 -6
- package/docs/pages/components/radio-group.md +242 -5
- package/docs/pages/components/radio.md +474 -6
- package/docs/pages/components/rating.md +331 -7
- package/docs/pages/components/reveal.md +524 -6
- package/docs/pages/components/scroll-container.md +552 -6
- package/docs/pages/components/select.md +762 -160
- package/docs/pages/components/skeleton.md +473 -30
- package/docs/pages/components/slideout.md +261 -5
- package/docs/pages/components/sp.md +99 -41
- package/docs/pages/components/split-button.md +347 -9
- package/docs/pages/components/split-pane.md +445 -6
- package/docs/pages/components/stat.md +549 -9
- package/docs/pages/components/status-indicator.md +230 -1
- package/docs/pages/components/stepper.md +563 -6
- package/docs/pages/components/tabs.md +439 -13
- package/docs/pages/components/textarea.md +415 -17
- package/docs/pages/components/tile.md +385 -26
- package/docs/pages/components/toggle.md +424 -6
- package/docs/pages/components/tooltip.md +367 -0
- package/docs/pages/components/translation-group.md +210 -0
- package/docs/pages/components/translations.md +319 -19
- package/docs/pages/components/vertical-stepper.md +401 -8
- package/docs/pages/components/well.md +300 -3
- package/docs/pages/getting-started/form-controls.md +162 -0
- package/docs/pages/index.md +116 -15
- package/package.json +5 -6
- package/scss/_global-spacing.scss +4 -0
- package/scss/_root.scss +2 -2
- package/scss/boot.scss +19 -0
- package/scss/shared/layout.scss +5 -0
- package/scss/themes/_light.scss +3 -3
- package/src/components/alert/alert.scss +1 -0
- package/src/components/animated-button/README.md +306 -0
- package/src/components/animated-button/animated-button.component.ts +229 -0
- package/src/components/animated-button/animated-button.scss +228 -0
- package/src/components/animated-button/animated-button.test.ts +143 -0
- package/src/components/animated-button/index.ts +12 -0
- package/src/components/button/button.component.ts +5 -0
- package/src/components/button-group/button-group.component.ts +25 -13
- package/src/components/button-group/button-group.scss +6 -0
- package/src/components/checkbox/checkbox.component.ts +4 -0
- package/src/components/checkbox/checkbox.scss +7 -4
- package/src/components/checkbox-group/checkbox-group.scss +7 -0
- package/src/components/chip/chip.component.ts +7 -2
- package/src/components/chip/chip.scss +16 -2
- package/src/components/collapsible/collapsible.component.ts +27 -20
- package/src/components/cols/cols.component.ts +14 -2
- package/src/components/cols/cols.scss +6 -12
- package/src/components/confirm/confirm.component.ts +7 -0
- package/src/components/copy-button/copy-button.component.ts +12 -11
- package/src/components/data-select/data-select.component.ts +215 -44
- package/src/components/data-select/data-select.scss +66 -6
- package/src/components/data-select/providers/currency-data-provider.ts +20 -6
- package/src/components/data-select/providers/provider.ts +1 -0
- package/src/components/data-select/providers/us-state-data-provider.ts +68 -0
- package/src/components/data-table/data-table.component.ts +81 -22
- package/src/components/data-table-search/data-table-search.component.ts +184 -0
- package/src/components/data-table-search/data-table-search.scss +17 -0
- package/src/components/data-table-search/data-table-search.test.ts +11 -0
- package/src/components/data-table-search/index.ts +12 -0
- package/src/components/datepicker/datepicker.component.ts +347 -13
- package/src/components/dialog/dialog.component.ts +1 -1
- package/src/components/empty-state/empty-state.scss +6 -1
- package/src/components/header/header.scss +3 -3
- package/src/components/icon/icon.component.ts +23 -1
- package/src/components/icon/icon.scss +23 -0
- package/src/components/icon-picker/brand-icons.ts +24 -0
- package/src/components/icon-picker/icon-picker.component.ts +407 -0
- package/src/components/icon-picker/icon-picker.scss +205 -0
- package/src/components/icon-picker/index.ts +12 -0
- package/src/components/icon-picker/line-icons.ts +612 -0
- package/src/components/icon-picker/material-icons.ts +14543 -0
- package/src/components/inline-edit/inline-edit.component.ts +190 -46
- package/src/components/inline-edit/inline-edit.scss +29 -0
- package/src/components/inline-edit/inline-edit.test.ts +314 -1
- package/src/components/input/input.component.ts +324 -5
- package/src/components/input/input.scss +87 -0
- package/src/components/input-group/input-group.component.ts +28 -7
- package/src/components/input-group/input-group.scss +48 -10
- package/src/components/item/item.component.ts +24 -3
- package/src/components/item/item.scss +18 -0
- package/src/components/key/index.ts +12 -0
- package/src/components/key/key.component.ts +47 -0
- package/src/components/key/key.scss +38 -0
- package/src/components/key-container/index.ts +12 -0
- package/src/components/key-container/key-container.component.ts +142 -0
- package/src/components/key-container/key-container.scss +33 -0
- package/src/components/menu-item/menu-item.component.ts +20 -0
- package/src/components/menu-item/menu-item.scss +69 -2
- package/src/components/navbar/navbar.component.ts +103 -11
- package/src/components/navbar/navbar.scss +6 -1
- package/src/components/opt-group/index.ts +12 -0
- package/src/components/opt-group/opt-group.component.ts +74 -0
- package/src/components/opt-group/opt-group.scss +37 -0
- package/src/components/opt-group/opt-group.test.ts +27 -0
- package/src/components/option/option.component.ts +1 -16
- package/src/components/option/option.scss +4 -0
- package/src/components/panel/panel.component.ts +10 -8
- package/src/components/panel/panel.scss +20 -0
- package/src/components/priority-list/index.ts +12 -0
- package/src/components/priority-list/priority-list.component.ts +611 -0
- package/src/components/priority-list/priority-list.scss +185 -0
- package/src/components/priority-list/priority-list.test.ts +139 -0
- package/src/components/progress-bar/progress-bar.component.ts +22 -16
- package/src/components/radio/radio.scss +10 -3
- package/src/components/select/select.component.ts +812 -72
- package/src/components/select/select.scss +108 -2
- package/src/components/select/select.test.ts +147 -2
- package/src/components/settings-container/settings-container.component.ts +44 -19
- package/src/components/settings-container/settings-container.scss +1 -1
- package/src/components/skeleton/skeleton.component.ts +6 -1
- package/src/components/slideout/slideout.scss +8 -3
- package/src/components/sp/sp.component.ts +3 -1
- package/src/components/sp/sp.scss +19 -0
- package/src/components/stepper/stepper.component.ts +16 -10
- package/src/components/textarea/textarea.scss +0 -4
- package/src/components/tile/tile.component.ts +12 -3
- package/src/components/tile/tile.scss +1 -1
- package/src/components/timer/timer.component.ts +15 -2
- package/src/components/toggle/toggle.component.ts +29 -26
- package/src/components/toggle/toggle.scss +13 -1
- package/src/components/translation-group/index.ts +12 -0
- package/src/components/translation-group/translation-group.component.ts +207 -0
- package/src/components/translation-group/translation-group.scss +8 -0
- package/src/components/translations/translations.component.ts +90 -44
- package/src/components/translations/translations.scss +4 -0
- package/src/components/vertical-stepper/vertical-stepper.component.ts +14 -9
- package/src/events/events.ts +12 -7
- package/src/events/zn-language-change.ts +7 -0
- package/src/events/zn-purchase.ts +11 -0
- package/src/events/zn-redirect.ts +7 -0
- package/src/events/zn-reorder.ts +7 -0
- package/src/events/zn-search-change.ts +11 -0
- package/src/events/zn-submit.ts +1 -1
- package/src/form-control.scss +4 -0
- package/src/internal/form-navigation.ts +510 -0
- package/src/internal/form.ts +6 -0
- package/src/zinc.ts +91 -83
|
@@ -14,11 +14,12 @@ import {watch} from '../../internal/watch';
|
|
|
14
14
|
import ZincElement from '../../internal/zinc-element';
|
|
15
15
|
import ZnChip from "../chip";
|
|
16
16
|
import ZnIcon from "../icon";
|
|
17
|
+
import ZnOptGroup from "../opt-group";
|
|
18
|
+
import ZnOption from "../option";
|
|
17
19
|
import ZnPopup from "../popup";
|
|
18
20
|
import type {CSSResultGroup, PropertyValues, TemplateResult} from 'lit';
|
|
19
21
|
import type {ZincFormControl} from '../../internal/zinc-element';
|
|
20
22
|
import type {ZnRemoveEvent} from "../../events/zn-remove";
|
|
21
|
-
import type ZnOption from "../option";
|
|
22
23
|
|
|
23
24
|
import styles from './select.scss';
|
|
24
25
|
|
|
@@ -29,10 +30,12 @@ import styles from './select.scss';
|
|
|
29
30
|
* @since 1.0
|
|
30
31
|
*
|
|
31
32
|
* @dependency zn-icon
|
|
33
|
+
* @dependency zn-opt-group
|
|
34
|
+
* @dependency zn-option
|
|
32
35
|
* @dependency zn-popup
|
|
33
36
|
* @dependency zn-tag
|
|
34
37
|
*
|
|
35
|
-
* @slot - The listbox options. Must be `<zn-option>` elements. You can use `<zn-divider>` to group items visually.
|
|
38
|
+
* @slot - The listbox options. Must be `<zn-option>` elements. You can use `<zn-opt-group>` to group options under a labeled header, or `<zn-divider>` to group items visually.
|
|
36
39
|
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
|
|
37
40
|
* @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
|
|
38
41
|
* @slot context-note - Used to add contextual text that is displayed above the select, on the right. Alternatively, you can use the `context-note` attribute.
|
|
@@ -51,7 +54,11 @@ import styles from './select.scss';
|
|
|
51
54
|
* @event zn-hide - Emitted when the select's menu closes.
|
|
52
55
|
* @event zn-after-hide - Emitted after the select's menu closes and all animations are complete.
|
|
53
56
|
* @event zn-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
|
57
|
+
* @event zn-load - Emitted when options have been successfully loaded from the `src` URL.
|
|
58
|
+
* @event zn-error - Emitted when loading options from the `src` URL fails.
|
|
54
59
|
*
|
|
60
|
+
* @csspart search-loading - The container shown while a remote search request is in flight.
|
|
61
|
+
* @csspart max-results-indicator - The message shown when results are truncated by `max-results`.
|
|
55
62
|
* @csspart form-control - The form control that wraps the label, input, and help text.
|
|
56
63
|
* @csspart form-control-label - The label's wrapper.
|
|
57
64
|
* @csspart form-control-input - The select's wrapper.
|
|
@@ -73,6 +80,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
73
80
|
static styles: CSSResultGroup = unsafeCSS(styles);
|
|
74
81
|
static dependencies = {
|
|
75
82
|
'zn-icon': ZnIcon,
|
|
83
|
+
'zn-opt-group': ZnOptGroup,
|
|
84
|
+
'zn-option': ZnOption,
|
|
76
85
|
'zn-popup': ZnPopup,
|
|
77
86
|
'zn-tag': ZnChip
|
|
78
87
|
};
|
|
@@ -100,26 +109,86 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
100
109
|
@state() private valueHasChanged: boolean = false;
|
|
101
110
|
@state() private inputPrefix: boolean = false;
|
|
102
111
|
|
|
112
|
+
/** @internal - current search/filter text when search is enabled (lowercased for matching) */
|
|
113
|
+
private _searchQuery = '';
|
|
114
|
+
|
|
115
|
+
/** @internal - raw display value of the search input (preserves case for the input field) */
|
|
116
|
+
private _searchDisplayValue = '';
|
|
117
|
+
|
|
118
|
+
/** @internal - whether the "no matching options" empty state is visible */
|
|
119
|
+
@state() private _noResultsVisible = false;
|
|
120
|
+
|
|
121
|
+
/** @internal */
|
|
122
|
+
@state() private _fetchedOptions: ({ key: string; value: string; group?: undefined } | {
|
|
123
|
+
group: string;
|
|
124
|
+
options: { key: string; value: string }[]
|
|
125
|
+
})[] = [];
|
|
126
|
+
|
|
127
|
+
/** @internal */
|
|
128
|
+
@state() private _fetchLoading = false;
|
|
129
|
+
|
|
130
|
+
/** @internal */
|
|
131
|
+
@state() private _fetchError = '';
|
|
132
|
+
|
|
133
|
+
/** @internal */
|
|
134
|
+
private _fetchAbortController: AbortController | null = null;
|
|
135
|
+
|
|
136
|
+
/** @internal - debounce timer for remote search */
|
|
137
|
+
private _searchDebounceTimer: number | null = null;
|
|
138
|
+
|
|
139
|
+
/** @internal - whether a remote search fetch is in progress (distinct from initial load) */
|
|
140
|
+
@state() private _searchLoading = false;
|
|
141
|
+
|
|
142
|
+
/** @internal - the query string of the last successful remote search */
|
|
143
|
+
private _lastRemoteQuery: string | null = null;
|
|
144
|
+
|
|
145
|
+
/** @internal - whether the last remote result set was exhaustive (fewer than maxResults returned) */
|
|
146
|
+
private _lastRemoteExhaustive = false;
|
|
147
|
+
|
|
148
|
+
/** @internal - total number of results before maxResults truncation (0 = not truncated) */
|
|
149
|
+
@state() private _totalResultCount = 0;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @internal - tracks selected items by key/value during remote search so they survive
|
|
153
|
+
* when search results replace the fetched options list.
|
|
154
|
+
*/
|
|
155
|
+
private _selectedRemoteItems: { key: string; value: string }[] = [];
|
|
156
|
+
|
|
103
157
|
/** The name of the select, submitted as a name/value pair with form data. */
|
|
104
158
|
@property() name = '';
|
|
105
159
|
|
|
160
|
+
@property({type: Boolean, attribute: "non-removable"}) nonRemovable = false;
|
|
161
|
+
|
|
106
162
|
private _value: string | string[] = '';
|
|
107
163
|
|
|
108
164
|
get value() {
|
|
109
|
-
return this._value
|
|
165
|
+
return this._value;
|
|
110
166
|
}
|
|
111
167
|
|
|
112
168
|
/**
|
|
113
169
|
* The current value of the select, submitted as a name/value pair with form data. When `multiple` is enabled, the
|
|
114
|
-
* value
|
|
115
|
-
*
|
|
170
|
+
* value property will be an array. Values may contain spaces when using JSON array syntax in the attribute,
|
|
171
|
+
* e.g. `value='["my value", "their value"]'`. Space-delimited strings are still supported for backward compatibility.
|
|
116
172
|
*/
|
|
117
173
|
@state()
|
|
118
174
|
set value(val: string | string[]) {
|
|
119
175
|
if (this.multiple) {
|
|
120
|
-
|
|
176
|
+
if (Array.isArray(val)) {
|
|
177
|
+
// Already an array, keep as-is
|
|
178
|
+
} else if (val.trimStart().startsWith('[')) {
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(val) as unknown;
|
|
181
|
+
val = Array.isArray(parsed) ? parsed : val.split(" ");
|
|
182
|
+
} catch {
|
|
183
|
+
if (typeof val === "string") {
|
|
184
|
+
val = val.split(" ");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
val = val.split(" ");
|
|
189
|
+
}
|
|
121
190
|
} else {
|
|
122
|
-
val = Array.isArray(val) ? val.join(
|
|
191
|
+
val = Array.isArray(val) ? val.join(" ") : val;
|
|
123
192
|
}
|
|
124
193
|
|
|
125
194
|
if (this._value === val) {
|
|
@@ -128,6 +197,10 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
128
197
|
|
|
129
198
|
this.valueHasChanged = true;
|
|
130
199
|
this._value = val;
|
|
200
|
+
|
|
201
|
+
if (this.hasUpdated) {
|
|
202
|
+
this.updateDependencies();
|
|
203
|
+
}
|
|
131
204
|
}
|
|
132
205
|
|
|
133
206
|
/** The default value of the form control. Primarily used for resetting the form control. */
|
|
@@ -135,15 +208,30 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
135
208
|
attribute: 'value',
|
|
136
209
|
converter: {
|
|
137
210
|
fromAttribute: (value) => {
|
|
138
|
-
if (!value || Array.isArray(value)) return value
|
|
211
|
+
if (!value || Array.isArray(value)) return value;
|
|
212
|
+
|
|
213
|
+
// Auto-detect JSON array syntax: value='["my value", "their value"]'
|
|
214
|
+
if (value.trimStart().startsWith('[')) {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(value) as unknown;
|
|
217
|
+
if (Array.isArray(parsed)) return parsed as string[];
|
|
218
|
+
} catch {
|
|
219
|
+
// Not valid JSON, fall through to space-delimited parsing
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
139
223
|
return value.includes(' ') ? value.split(' ') : value;
|
|
140
224
|
},
|
|
141
225
|
toAttribute: (value) => {
|
|
226
|
+
if (Array.isArray(value) && value.some((v: string) => v.includes(' '))) {
|
|
227
|
+
return JSON.stringify(value);
|
|
228
|
+
}
|
|
142
229
|
return Array.isArray(value) ? value.join(' ') : value;
|
|
143
230
|
}
|
|
144
231
|
}
|
|
145
232
|
}) defaultValue: string | string[] = '';
|
|
146
233
|
|
|
234
|
+
|
|
147
235
|
/** The select's size. */
|
|
148
236
|
@property({reflect: true}) size: 'small' | 'medium' | 'large' = 'medium';
|
|
149
237
|
|
|
@@ -183,6 +271,9 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
183
271
|
/** Draws a pill-style select with rounded edges. */
|
|
184
272
|
@property({type: Boolean, reflect: true}) pill = false;
|
|
185
273
|
|
|
274
|
+
/** Enables search/filter functionality. When enabled, the user can type into the select to filter the visible options. */
|
|
275
|
+
@property({type: Boolean, reflect: true}) search = false;
|
|
276
|
+
|
|
186
277
|
/** The select's label. If you need to display HTML, use the `label` slot instead. */
|
|
187
278
|
@property() label = '';
|
|
188
279
|
|
|
@@ -232,19 +323,59 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
232
323
|
?pill=${this.pill}
|
|
233
324
|
size="x-small"
|
|
234
325
|
type="primary"
|
|
235
|
-
removable
|
|
236
326
|
@zn-remove=${(event: ZnRemoveEvent) => this.handleTagRemove(event, option)}>
|
|
237
327
|
${option.getTextLabel()}
|
|
238
|
-
|
|
239
|
-
|
|
328
|
+
${(!this.nonRemovable) ? html`
|
|
329
|
+
<zn-icon slot="action" src="close" size="16"
|
|
330
|
+
@click=${(e: any) => this.handleTagRemove(e as ZnRemoveEvent, option)}></zn-icon>` : nothing}
|
|
240
331
|
</zn-chip>
|
|
241
332
|
`;
|
|
242
333
|
};
|
|
243
334
|
|
|
335
|
+
/** Automatically select the first option if no value is set. */
|
|
336
|
+
@property({attribute: 'select-first', type: Boolean}) selectFirst = false;
|
|
337
|
+
|
|
244
338
|
@property() distinct = "";
|
|
245
339
|
|
|
246
340
|
@property() conditional = "";
|
|
247
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The URL to fetch options from. When set, the component fetches JSON from this URL and renders the results as
|
|
344
|
+
* options. The expected format is an array of objects with `key` and `value` properties:
|
|
345
|
+
* `[{"key": "us", "value": "United States"}, ...]`
|
|
346
|
+
* When not set, the component works exactly as before using slotted `<zn-option>` elements.
|
|
347
|
+
*/
|
|
348
|
+
@property({attribute: 'data-uri'}) dataUri: string;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Context data to send as a header when fetching options from the URL specified by the `src` property.
|
|
352
|
+
*/
|
|
353
|
+
@property({attribute: 'context-data'}) contextData: string;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The maximum number of options to display from a remote fetch response. Set to 0 for unlimited.
|
|
357
|
+
* Only applies when fetching from `data-uri`.
|
|
358
|
+
*/
|
|
359
|
+
@property({attribute: 'max-results', type: Number}) maxResults = 0;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Debounce delay in milliseconds for remote search requests. The component waits this long after the user
|
|
363
|
+
* stops typing before sending a request.
|
|
364
|
+
*/
|
|
365
|
+
@property({attribute: 'search-debounce', type: Number}) searchDebounce = 300;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The query parameter name appended to the `data-uri` URL for remote search requests.
|
|
369
|
+
* Defaults to `"q"`, e.g. `/api/items?q=search+term`. Set to a custom value like `"search"` or `"filter"` to match your API.
|
|
370
|
+
*/
|
|
371
|
+
@property({attribute: 'search-param'}) searchParam = 'q';
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* When set alongside `search` and `data-uri`, the component will not fetch options on initial load.
|
|
375
|
+
* Options are only fetched when the user starts typing a search query.
|
|
376
|
+
*/
|
|
377
|
+
@property({attribute: 'search-only', type: Boolean}) searchOnly = false;
|
|
378
|
+
|
|
248
379
|
/** Gets the validity state object */
|
|
249
380
|
get validity() {
|
|
250
381
|
return this.valueInput?.validity;
|
|
@@ -264,13 +395,23 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
264
395
|
this.open = false;
|
|
265
396
|
|
|
266
397
|
if (this.cacheKey) {
|
|
267
|
-
const cache
|
|
398
|
+
const cache = JSON.parse(localStorage.getItem('zn-linked-select-cache') || '{}') as Record<string, string | string[]>;
|
|
268
399
|
if (cache[this.cacheKey]) {
|
|
269
400
|
this.value = cache[this.cacheKey];
|
|
270
401
|
}
|
|
271
402
|
}
|
|
272
403
|
}
|
|
273
404
|
|
|
405
|
+
disconnectedCallback() {
|
|
406
|
+
super.disconnectedCallback();
|
|
407
|
+
this._fetchAbortController?.abort();
|
|
408
|
+
this._fetchAbortController = null;
|
|
409
|
+
if (this._searchDebounceTimer !== null) {
|
|
410
|
+
clearTimeout(this._searchDebounceTimer);
|
|
411
|
+
this._searchDebounceTimer = null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
274
415
|
private updateHasInputPrefix() {
|
|
275
416
|
const assigned = this.prefixSlot?.assignedElements({flatten: true}) || [];
|
|
276
417
|
this.inputPrefix = assigned.length > 0;
|
|
@@ -287,7 +428,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
287
428
|
this.closeWatcher = new CloseWatcher();
|
|
288
429
|
this.closeWatcher.onclose = () => {
|
|
289
430
|
if (this.open) {
|
|
290
|
-
this.hide();
|
|
431
|
+
this.hide().then();
|
|
291
432
|
this.displayInput.focus({preventScroll: true});
|
|
292
433
|
}
|
|
293
434
|
};
|
|
@@ -314,11 +455,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
314
455
|
this.emit('zn-blur');
|
|
315
456
|
}
|
|
316
457
|
|
|
317
|
-
private handleDocumentFocusIn = (event:
|
|
458
|
+
private handleDocumentFocusIn = (event: FocusEvent) => {
|
|
318
459
|
// Close when focusing out of the select
|
|
319
460
|
const path = event.composedPath();
|
|
320
461
|
if (this && !path.includes(this)) {
|
|
321
|
-
this.hide();
|
|
462
|
+
this.hide().then();
|
|
322
463
|
}
|
|
323
464
|
};
|
|
324
465
|
|
|
@@ -339,19 +480,19 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
339
480
|
if ((event.key === 'Escape' || event.key === 'Tab') && this.open && !this.closeWatcher) {
|
|
340
481
|
event.preventDefault();
|
|
341
482
|
event.stopPropagation();
|
|
342
|
-
this.hide();
|
|
483
|
+
this.hide().then();
|
|
343
484
|
this.displayInput.focus({preventScroll: true});
|
|
344
485
|
}
|
|
345
486
|
|
|
346
487
|
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
|
|
347
|
-
// buffer we _don't_ close it.
|
|
348
|
-
if (event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) {
|
|
488
|
+
// buffer we _don't_ close it. When search is enabled, space should type into the input, not toggle.
|
|
489
|
+
if (event.key === 'Enter' || (event.key === ' ' && !this.search && this.typeToSelectString === '')) {
|
|
349
490
|
event.preventDefault();
|
|
350
491
|
event.stopImmediatePropagation();
|
|
351
492
|
|
|
352
493
|
// If it's not open, open it
|
|
353
494
|
if (!this.open) {
|
|
354
|
-
this.show();
|
|
495
|
+
this.show().then();
|
|
355
496
|
return;
|
|
356
497
|
}
|
|
357
498
|
|
|
@@ -364,6 +505,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
364
505
|
this.setSelectedOptions(this.currentOption);
|
|
365
506
|
}
|
|
366
507
|
|
|
508
|
+
// Clear search after keyboard selection
|
|
509
|
+
if (this.search) {
|
|
510
|
+
this.clearSearch();
|
|
511
|
+
if (this.multiple) {
|
|
512
|
+
this.displayInput.value = '';
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
367
516
|
// Emit after updating
|
|
368
517
|
this.updateComplete.then(() => {
|
|
369
518
|
this.emit('zn-input');
|
|
@@ -371,8 +520,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
371
520
|
});
|
|
372
521
|
|
|
373
522
|
if (!this.multiple) {
|
|
374
|
-
this.hide();
|
|
523
|
+
this.hide().then();
|
|
375
524
|
this.displayInput.focus({preventScroll: true});
|
|
525
|
+
} else if (this.search) {
|
|
526
|
+
// In multi-select + search, re-focus the search input after keyboard selection
|
|
527
|
+
this.updateComplete.then(() => {
|
|
528
|
+
this.displayInput.value = '';
|
|
529
|
+
this.displayInput.focus({preventScroll: true});
|
|
530
|
+
});
|
|
376
531
|
}
|
|
377
532
|
}
|
|
378
533
|
|
|
@@ -381,8 +536,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
381
536
|
|
|
382
537
|
// Navigate options
|
|
383
538
|
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
|
384
|
-
const
|
|
385
|
-
const currentIndex =
|
|
539
|
+
const navOptions = this.search ? this.getVisibleOptions() : this.getAllOptions();
|
|
540
|
+
const currentIndex = navOptions.indexOf(this.currentOption);
|
|
386
541
|
let newIndex = Math.max(0, currentIndex);
|
|
387
542
|
|
|
388
543
|
// Prevent scrolling
|
|
@@ -390,7 +545,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
390
545
|
|
|
391
546
|
// Open it
|
|
392
547
|
if (!this.open) {
|
|
393
|
-
this.show();
|
|
548
|
+
this.show().then();
|
|
394
549
|
|
|
395
550
|
// If an option is already selected, stop here because we want that one to remain highlighted when the listbox
|
|
396
551
|
// opens for the first time
|
|
@@ -401,17 +556,22 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
401
556
|
|
|
402
557
|
if (event.key === 'ArrowDown') {
|
|
403
558
|
newIndex = currentIndex + 1;
|
|
404
|
-
if (newIndex >
|
|
559
|
+
if (newIndex > navOptions.length - 1) newIndex = 0;
|
|
405
560
|
} else if (event.key === 'ArrowUp') {
|
|
406
561
|
newIndex = currentIndex - 1;
|
|
407
|
-
if (newIndex < 0) newIndex =
|
|
562
|
+
if (newIndex < 0) newIndex = navOptions.length - 1;
|
|
408
563
|
} else if (event.key === 'Home') {
|
|
409
564
|
newIndex = 0;
|
|
410
565
|
} else if (event.key === 'End') {
|
|
411
|
-
newIndex =
|
|
566
|
+
newIndex = navOptions.length - 1;
|
|
412
567
|
}
|
|
413
568
|
|
|
414
|
-
this.setCurrentOption(
|
|
569
|
+
this.setCurrentOption(navOptions[newIndex]);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// When search is enabled, let the input handle keystrokes natively (they go through handleSearchInput)
|
|
573
|
+
if (this.search) {
|
|
574
|
+
return;
|
|
415
575
|
}
|
|
416
576
|
|
|
417
577
|
// All other "printable" keys trigger type to select
|
|
@@ -429,7 +589,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
429
589
|
return;
|
|
430
590
|
}
|
|
431
591
|
|
|
432
|
-
this.show();
|
|
592
|
+
this.show().then();
|
|
433
593
|
}
|
|
434
594
|
|
|
435
595
|
event.stopPropagation();
|
|
@@ -459,7 +619,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
459
619
|
// Close when clicking outside of the select
|
|
460
620
|
const path = event.composedPath();
|
|
461
621
|
if (this && !path.includes(this)) {
|
|
462
|
-
this.hide();
|
|
622
|
+
this.hide().then();
|
|
463
623
|
}
|
|
464
624
|
};
|
|
465
625
|
|
|
@@ -475,8 +635,15 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
475
635
|
const isExpandIcon = path.some(el => el instanceof Element && el.classList.contains('select__expand-icon'));
|
|
476
636
|
// if click is inside the prefix area (e.g. checkbox), don't toggle the select
|
|
477
637
|
const inPrefix = path.some(el => el instanceof Element && el.classList.contains('select__prefix'));
|
|
478
|
-
// Ignore disabled controls and clicks on tags (remove buttons)
|
|
479
|
-
if (((this.disabled || isIcon) && !isExpandIcon) || inPrefix) {
|
|
638
|
+
// Ignore disabled controls, fetch loading/error, and clicks on tags (remove buttons)
|
|
639
|
+
if (((this.disabled || this._fetchLoading || this._fetchError || isIcon) && !isExpandIcon) || inPrefix) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// When search is enabled, clicking the input area should open (not toggle) so the user can type
|
|
644
|
+
if (this.search && this.open && !isExpandIcon) {
|
|
645
|
+
event.preventDefault();
|
|
646
|
+
this.displayInput.focus({preventScroll: true});
|
|
480
647
|
return;
|
|
481
648
|
}
|
|
482
649
|
|
|
@@ -504,6 +671,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
504
671
|
event.stopPropagation();
|
|
505
672
|
|
|
506
673
|
if (this.value !== '') {
|
|
674
|
+
if (this.search) {
|
|
675
|
+
this.clearSearch();
|
|
676
|
+
this.displayInput.value = '';
|
|
677
|
+
}
|
|
678
|
+
|
|
507
679
|
this.setSelectedOptions([]);
|
|
508
680
|
this.displayInput.focus({preventScroll: true});
|
|
509
681
|
|
|
@@ -522,29 +694,157 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
522
694
|
event.preventDefault();
|
|
523
695
|
}
|
|
524
696
|
|
|
697
|
+
/** Whether the component is in remote-search mode (search + dataUri both set) */
|
|
698
|
+
private get _isRemoteSearch() {
|
|
699
|
+
return this.search && !!this.dataUri;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Handles text input on the display input for search/filter mode */
|
|
703
|
+
private handleSearchInput() {
|
|
704
|
+
if (!this.search) return;
|
|
705
|
+
|
|
706
|
+
this._searchDisplayValue = this.displayInput.value;
|
|
707
|
+
this._searchQuery = this._searchDisplayValue.toLowerCase();
|
|
708
|
+
|
|
709
|
+
// Open the dropdown when the user starts typing
|
|
710
|
+
if (!this.open) {
|
|
711
|
+
this.show().then();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (this._isRemoteSearch) {
|
|
715
|
+
// Cancel any pending request when the query changes
|
|
716
|
+
if (this._searchDebounceTimer !== null) {
|
|
717
|
+
clearTimeout(this._searchDebounceTimer);
|
|
718
|
+
this._searchDebounceTimer = null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Empty query — just show all current options, no fetch needed
|
|
722
|
+
if (!this._searchQuery) {
|
|
723
|
+
this.filterOptions();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// If the new query is a refinement of the last one and we know the server had no more
|
|
728
|
+
// results to give (i.e. it returned fewer than maxResults), we can filter locally instead
|
|
729
|
+
// of making another round-trip.
|
|
730
|
+
const canFilterLocally = this._lastRemoteExhaustive
|
|
731
|
+
&& this._lastRemoteQuery !== null
|
|
732
|
+
&& this._searchQuery.startsWith(this._lastRemoteQuery);
|
|
733
|
+
|
|
734
|
+
if (canFilterLocally) {
|
|
735
|
+
this.filterOptions();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Remote search: debounce and fetch from server
|
|
740
|
+
this._noResultsVisible = false;
|
|
741
|
+
this._searchDebounceTimer = window.setTimeout(() => {
|
|
742
|
+
this._searchDebounceTimer = null;
|
|
743
|
+
this.fetchOptions(this._searchQuery).then();
|
|
744
|
+
}, this.searchDebounce);
|
|
745
|
+
} else {
|
|
746
|
+
// Local search: filter existing options
|
|
747
|
+
this.filterOptions();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Filters visible options based on the current search query */
|
|
752
|
+
private filterOptions() {
|
|
753
|
+
const allOptions = this.getAllOptions();
|
|
754
|
+
const searchQuery = this._searchQuery;
|
|
755
|
+
|
|
756
|
+
if (!searchQuery) {
|
|
757
|
+
// Show all options and opt-groups
|
|
758
|
+
allOptions.forEach(option => {
|
|
759
|
+
option.hidden = false;
|
|
760
|
+
});
|
|
761
|
+
this.getAllOptGroups().forEach(group => {
|
|
762
|
+
group.hidden = false;
|
|
763
|
+
});
|
|
764
|
+
this._noResultsVisible = false;
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
allOptions.forEach(option => {
|
|
769
|
+
// Don't un-hide selected options that are only in the DOM for value preservation
|
|
770
|
+
if (option.selected && option.hidden) return;
|
|
771
|
+
|
|
772
|
+
const label = option.getTextLabel().toLowerCase();
|
|
773
|
+
const value = option.value.toLowerCase();
|
|
774
|
+
const matches = label.includes(searchQuery) || value.includes(searchQuery);
|
|
775
|
+
option.hidden = !matches;
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// Update opt-group visibility based on whether any child options are visible
|
|
779
|
+
this.getAllOptGroups().forEach(group => group.updateVisibility());
|
|
780
|
+
|
|
781
|
+
// Show empty state when no non-selected options match (selected-but-hidden items are value anchors, not results)
|
|
782
|
+
this._noResultsVisible = allOptions.filter(o => !o.selected).every(option => option.hidden);
|
|
783
|
+
|
|
784
|
+
// Reset current option if it's now hidden
|
|
785
|
+
if (this.currentOption && this.currentOption.hidden) {
|
|
786
|
+
const firstVisible = allOptions.find(o => !o.hidden && !o.disabled);
|
|
787
|
+
if (firstVisible) {
|
|
788
|
+
this.setCurrentOption(firstVisible);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Clears the search query and shows all options */
|
|
794
|
+
private clearSearch() {
|
|
795
|
+
this._searchQuery = '';
|
|
796
|
+
this._searchDisplayValue = '';
|
|
797
|
+
this._noResultsVisible = false;
|
|
798
|
+
|
|
799
|
+
// Cancel any pending remote search
|
|
800
|
+
if (this._searchDebounceTimer !== null) {
|
|
801
|
+
clearTimeout(this._searchDebounceTimer);
|
|
802
|
+
this._searchDebounceTimer = null;
|
|
803
|
+
}
|
|
804
|
+
this._searchLoading = false;
|
|
805
|
+
|
|
806
|
+
const allOptions = this.getAllOptions();
|
|
807
|
+
allOptions.forEach(option => {
|
|
808
|
+
option.hidden = false;
|
|
809
|
+
});
|
|
810
|
+
// Also unhide any opt-groups that were hidden during filtering
|
|
811
|
+
this.getAllOptGroups().forEach(group => {
|
|
812
|
+
group.hidden = false;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
525
816
|
private handleOptionClick(event: MouseEvent) {
|
|
526
817
|
const target = event.target as HTMLElement;
|
|
527
818
|
const option = target.closest('zn-option');
|
|
528
819
|
const oldValue = this.value;
|
|
529
820
|
|
|
530
821
|
if (option && !option.disabled) {
|
|
531
|
-
this.valueHasChanged = true
|
|
822
|
+
this.valueHasChanged = true;
|
|
532
823
|
if (this.multiple) {
|
|
533
|
-
if (this.maxOptions > 0) {
|
|
534
|
-
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
this.toggleOptionSelection(option);
|
|
539
|
-
} else {
|
|
540
|
-
this.toggleOptionSelection(option)
|
|
824
|
+
if (this.maxOptions > 0 && this.selectedOptions.length >= this.maxOptions && !option.selected) {
|
|
825
|
+
return;
|
|
541
826
|
}
|
|
827
|
+
this.toggleOptionSelection(option);
|
|
542
828
|
} else {
|
|
543
829
|
this.setSelectedOptions(option);
|
|
544
830
|
}
|
|
545
831
|
|
|
832
|
+
// Clear search after selection when in search mode
|
|
833
|
+
if (this.search) {
|
|
834
|
+
this.clearSearch();
|
|
835
|
+
if (this.multiple) {
|
|
836
|
+
// Keep the search input clear for further typing
|
|
837
|
+
this.displayInput.value = '';
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
546
841
|
// Set focus after updating so the value is announced by screen readers
|
|
547
|
-
this.updateComplete.then(() =>
|
|
842
|
+
this.updateComplete.then(() => {
|
|
843
|
+
if (this.multiple && this.search && this.open) {
|
|
844
|
+
this.displayInput.value = '';
|
|
845
|
+
}
|
|
846
|
+
this.displayInput.focus({preventScroll: true});
|
|
847
|
+
});
|
|
548
848
|
|
|
549
849
|
if (this.value !== oldValue) {
|
|
550
850
|
// Emit after updating
|
|
@@ -555,7 +855,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
555
855
|
}
|
|
556
856
|
|
|
557
857
|
if (!this.multiple) {
|
|
558
|
-
this.hide();
|
|
858
|
+
this.hide().then();
|
|
559
859
|
this.displayInput.focus({preventScroll: true});
|
|
560
860
|
}
|
|
561
861
|
|
|
@@ -593,6 +893,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
593
893
|
}
|
|
594
894
|
}
|
|
595
895
|
}
|
|
896
|
+
|
|
897
|
+
// Auto-select the first option if selectFirst is enabled and no value is set
|
|
898
|
+
if (this.selectFirst && this.selectedOptions.length === 0 && allOptions.length > 0) {
|
|
899
|
+
this.setSelectedOptions(allOptions[0]);
|
|
900
|
+
this.valueHasChanged = true;
|
|
901
|
+
this.emit('zn-input');
|
|
902
|
+
this.emit('zn-change');
|
|
903
|
+
}
|
|
596
904
|
}
|
|
597
905
|
|
|
598
906
|
private handleTagRemove(event: ZnRemoveEvent, option: ZnOption) {
|
|
@@ -610,9 +918,27 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
610
918
|
}
|
|
611
919
|
}
|
|
612
920
|
|
|
613
|
-
// Gets an array of all <zn-option> elements
|
|
921
|
+
// Gets an array of all <zn-option> elements (both fetched in shadow DOM and slotted in light DOM)
|
|
614
922
|
private getAllOptions() {
|
|
615
|
-
|
|
923
|
+
const shadowOptions = this.shadowRoot
|
|
924
|
+
? [...this.shadowRoot.querySelectorAll<ZnOption>('.select__listbox zn-option')]
|
|
925
|
+
: [];
|
|
926
|
+
const slottedOptions = [...this.querySelectorAll<ZnOption>('zn-option')];
|
|
927
|
+
return [...shadowOptions, ...slottedOptions];
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Gets an array of all <zn-opt-group> elements (both in shadow DOM and slotted in light DOM)
|
|
931
|
+
private getAllOptGroups() {
|
|
932
|
+
const shadowGroups = this.shadowRoot
|
|
933
|
+
? [...this.shadowRoot.querySelectorAll<ZnOptGroup>('.select__listbox zn-opt-group')]
|
|
934
|
+
: [];
|
|
935
|
+
const slottedGroups = [...this.querySelectorAll<ZnOptGroup>('zn-opt-group')];
|
|
936
|
+
return [...shadowGroups, ...slottedGroups];
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Gets an array of visible (not hidden) <zn-option> elements, used by keyboard navigation when search is active
|
|
940
|
+
private getVisibleOptions() {
|
|
941
|
+
return this.getAllOptions().filter(option => !option.hidden);
|
|
616
942
|
}
|
|
617
943
|
|
|
618
944
|
// Gets the first <zn-option> element
|
|
@@ -631,13 +957,16 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
631
957
|
el.tabIndex = -1;
|
|
632
958
|
});
|
|
633
959
|
|
|
634
|
-
|
|
635
960
|
// Select the target option
|
|
636
961
|
if (option) {
|
|
637
962
|
this.currentOption = option;
|
|
638
963
|
option.current = true;
|
|
639
964
|
option.tabIndex = 0;
|
|
640
|
-
option.
|
|
965
|
+
// When search is active and the display input has focus, don't steal focus to the option.
|
|
966
|
+
// The user is typing in the search input and we only want to visually highlight the current option.
|
|
967
|
+
if (!(this.search && this.open && this.shadowRoot?.activeElement === this.displayInput)) {
|
|
968
|
+
option.focus();
|
|
969
|
+
}
|
|
641
970
|
}
|
|
642
971
|
}
|
|
643
972
|
|
|
@@ -675,23 +1004,35 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
675
1004
|
// Update selected options cache
|
|
676
1005
|
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
|
|
677
1006
|
|
|
1007
|
+
// In remote-search + multiple mode, track selected items so they persist across search result changes
|
|
1008
|
+
if (this._isRemoteSearch && this.multiple) {
|
|
1009
|
+
this._selectedRemoteItems = this.selectedOptions.map(el => ({
|
|
1010
|
+
key: el.value,
|
|
1011
|
+
value: el.getTextLabel()
|
|
1012
|
+
}));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
678
1015
|
// Keep a reference to the previous `valueHasChanged`. Changes made here don't count has changing the value.
|
|
679
1016
|
const cachedValueHasChanged: boolean = this.valueHasChanged;
|
|
680
1017
|
|
|
681
1018
|
// Update the value and display label
|
|
682
1019
|
if (this.multiple) {
|
|
683
|
-
this.getAllOptions().filter(el => el.multiple
|
|
1020
|
+
this.getAllOptions().filter(el => !el.multiple).forEach(el => {
|
|
1021
|
+
el.multiple = true;
|
|
1022
|
+
});
|
|
684
1023
|
|
|
685
1024
|
this.value = this.selectedOptions.map(el => el.value);
|
|
686
1025
|
|
|
687
|
-
if (this.
|
|
1026
|
+
if (this.value.length === 0) {
|
|
688
1027
|
// When no items are selected, keep the value empty so the placeholder shows
|
|
689
1028
|
this.displayLabel = '';
|
|
690
1029
|
} else {
|
|
691
1030
|
this.displayLabel = this.localize.term('numOptionsSelected', this.selectedOptions.length);
|
|
692
1031
|
}
|
|
693
1032
|
} else {
|
|
694
|
-
this.getAllOptions().filter(el => el.multiple
|
|
1033
|
+
this.getAllOptions().filter(el => el.multiple).forEach(el => {
|
|
1034
|
+
el.multiple = false;
|
|
1035
|
+
});
|
|
695
1036
|
const selectedOption = this.selectedOptions[0];
|
|
696
1037
|
this.value = selectedOption?.value ?? '';
|
|
697
1038
|
this.displayLabel = selectedOption?.getTextLabel() ?? '';
|
|
@@ -722,6 +1063,33 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
722
1063
|
});
|
|
723
1064
|
}
|
|
724
1065
|
|
|
1066
|
+
/**
|
|
1067
|
+
* Renders hidden <zn-option> elements for items that are currently selected but not present in
|
|
1068
|
+
* the latest `_fetchedOptions` (e.g. because a remote search narrowed the results). This keeps
|
|
1069
|
+
* them in the DOM so `handleDefaultSlotChange` can find and re-select them.
|
|
1070
|
+
*/
|
|
1071
|
+
private _renderSelectedRemoteItems() {
|
|
1072
|
+
if (!this._isRemoteSearch || !this.multiple || this._selectedRemoteItems.length === 0) {
|
|
1073
|
+
return nothing;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Build a set of keys present in the current fetched options
|
|
1077
|
+
const fetchedKeys = new Set<string>();
|
|
1078
|
+
for (const item of this._fetchedOptions) {
|
|
1079
|
+
if (item.group !== undefined) {
|
|
1080
|
+
for (const opt of item.options) fetchedKeys.add(opt.key);
|
|
1081
|
+
} else {
|
|
1082
|
+
fetchedKeys.add(item.key);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Render hidden options for selected items not in the fetched results
|
|
1087
|
+
return this._selectedRemoteItems
|
|
1088
|
+
.filter(item => !fetchedKeys.has(item.key))
|
|
1089
|
+
.map(item => html`
|
|
1090
|
+
<zn-option value=${item.key} hidden>${item.value}</zn-option>`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
725
1093
|
private handleInvalid(event: Event) {
|
|
726
1094
|
this.formControlController.setValidity(false);
|
|
727
1095
|
this.formControlController.emitInvalidEvent(event);
|
|
@@ -732,7 +1100,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
732
1100
|
|
|
733
1101
|
if (this.distinct !== "") {
|
|
734
1102
|
// if we are linked to another zn-data-select remove the selected values from that select from this select
|
|
735
|
-
const linkedSelect = document.querySelector(`zn-select[id="${this.distinct}"]`)
|
|
1103
|
+
const linkedSelect = document.querySelector<ZnSelect>(`zn-select[id="${this.distinct}"]`)!;
|
|
736
1104
|
if (linkedSelect) {
|
|
737
1105
|
linkedSelect.addEventListener('zn-change', () => {
|
|
738
1106
|
const linkedValues = Array.isArray(linkedSelect.value) ? linkedSelect.value : [linkedSelect.value];
|
|
@@ -753,30 +1121,269 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
753
1121
|
}
|
|
754
1122
|
|
|
755
1123
|
if (this.conditional !== "") {
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1124
|
+
const ids = this.conditional.split(',').map(id => id.trim());
|
|
1125
|
+
const conditionals: ZnSelect[] = [];
|
|
1126
|
+
|
|
1127
|
+
ids.forEach(id => {
|
|
1128
|
+
const elements = deepQuerySelectorAll(`#${id}`, document.documentElement, '') as ZnSelect[];
|
|
1129
|
+
conditionals.push(...elements);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
const initiallyDisabled = this.disabled;
|
|
1133
|
+
const checkConditionals = () => {
|
|
1134
|
+
const shouldDisable = conditionals.some(select => {
|
|
1135
|
+
let linkedValues = Array.isArray(select.value) ? select.value : [select.value];
|
|
761
1136
|
linkedValues = linkedValues.filter(v => v !== '');
|
|
762
|
-
|
|
763
|
-
this.disabled = linkedValues.length > 0;
|
|
1137
|
+
return linkedValues.length > 0;
|
|
764
1138
|
});
|
|
1139
|
+
this.disabled = initiallyDisabled || shouldDisable;
|
|
1140
|
+
};
|
|
765
1141
|
|
|
766
|
-
|
|
767
|
-
|
|
1142
|
+
conditionals.forEach(select => {
|
|
1143
|
+
select.addEventListener('zn-change', checkConditionals);
|
|
1144
|
+
select.addEventListener('zn-input', checkConditionals);
|
|
768
1145
|
});
|
|
1146
|
+
|
|
1147
|
+
// trigger the check once to initialize
|
|
1148
|
+
checkConditionals();
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (this.dataUri && !this.searchOnly) {
|
|
1152
|
+
this.fetchOptions().then();
|
|
769
1153
|
}
|
|
770
1154
|
|
|
771
1155
|
this.updateHasInputPrefix();
|
|
1156
|
+
this.updateDependencies();
|
|
772
1157
|
}
|
|
773
1158
|
|
|
774
1159
|
@watch('disabled', {waitUntilFirstUpdate: true})
|
|
775
1160
|
handleDisabledChange() {
|
|
776
|
-
// Close the listbox when the control is disabled
|
|
777
1161
|
if (this.disabled) {
|
|
1162
|
+
// Close the listbox and abort any in-progress fetch
|
|
778
1163
|
this.open = false;
|
|
779
|
-
this.handleOpenChange();
|
|
1164
|
+
this.handleOpenChange().then();
|
|
1165
|
+
this._fetchAbortController?.abort();
|
|
1166
|
+
this._fetchAbortController = null;
|
|
1167
|
+
this._fetchLoading = false;
|
|
1168
|
+
this._fetchError = '';
|
|
1169
|
+
} else if (this.dataUri && !this.searchOnly) {
|
|
1170
|
+
// Fetch options when enabled
|
|
1171
|
+
this.fetchOptions().then();
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
@watch(['dataUri', 'contextData'], {waitUntilFirstUpdate: true})
|
|
1176
|
+
handleSrcChange() {
|
|
1177
|
+
if (this.dataUri && !this.searchOnly) {
|
|
1178
|
+
this.fetchOptions().then();
|
|
1179
|
+
} else {
|
|
1180
|
+
this._fetchedOptions = [];
|
|
1181
|
+
this._fetchLoading = false;
|
|
1182
|
+
this._fetchError = '';
|
|
1183
|
+
this.handleDefaultSlotChange();
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private _handleFetchError(message: string) {
|
|
1188
|
+
this._fetchError = message;
|
|
1189
|
+
this._fetchLoading = false;
|
|
1190
|
+
this._fetchAbortController = null;
|
|
1191
|
+
this.emit('zn-error', {detail: {status: 400}});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Builds the localStorage cache key for a given search query */
|
|
1195
|
+
private _buildCacheKey(searchQuery?: string): string {
|
|
1196
|
+
const base = this.cacheKey || this.dataUri;
|
|
1197
|
+
return searchQuery ? `zn-select-search:${base}:${searchQuery}` : `zn-select-search:${base}`;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Reads cached results from localStorage */
|
|
1201
|
+
private _readCache(searchQuery?: string): typeof this._fetchedOptions | null {
|
|
1202
|
+
if (!this.cacheKey) return null;
|
|
1203
|
+
try {
|
|
1204
|
+
const raw = localStorage.getItem(this._buildCacheKey(searchQuery));
|
|
1205
|
+
if (raw) return JSON.parse(raw) as typeof this._fetchedOptions;
|
|
1206
|
+
} catch {
|
|
1207
|
+
// Ignore corrupt cache
|
|
1208
|
+
}
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Writes results to localStorage cache */
|
|
1213
|
+
private _writeCache(searchQuery: string | undefined, data: typeof this._fetchedOptions) {
|
|
1214
|
+
if (!this.cacheKey) return;
|
|
1215
|
+
try {
|
|
1216
|
+
localStorage.setItem(this._buildCacheKey(searchQuery), JSON.stringify(data));
|
|
1217
|
+
} catch {
|
|
1218
|
+
// Storage full or unavailable — silently ignore
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Parses raw JSON data into the internal fetched-options format.
|
|
1224
|
+
* Applies `maxResults` limiting to flat option arrays.
|
|
1225
|
+
*/
|
|
1226
|
+
private _parseOptions(data: unknown): typeof this._fetchedOptions | null {
|
|
1227
|
+
type FetchedOption = { key: string; value: string; group?: undefined };
|
|
1228
|
+
type FetchedGroup = { group: string; options: { key: string; value: string }[] };
|
|
1229
|
+
let options: (FetchedOption | FetchedGroup)[];
|
|
1230
|
+
|
|
1231
|
+
if (Array.isArray(data)) {
|
|
1232
|
+
const isGrouped = data.length > 0 && data.some(
|
|
1233
|
+
(item: unknown) => item && typeof item === 'object' && 'group' in (item as Record<string, unknown>)
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
if (isGrouped) {
|
|
1237
|
+
options = (data as Record<string, unknown>[]).map(item => {
|
|
1238
|
+
if ('group' in item && Array.isArray(item.options)) {
|
|
1239
|
+
return {
|
|
1240
|
+
group: String(item.group),
|
|
1241
|
+
options: item.options as { key: string; value: string }[]
|
|
1242
|
+
} as FetchedGroup;
|
|
1243
|
+
}
|
|
1244
|
+
return item as unknown as FetchedOption;
|
|
1245
|
+
});
|
|
1246
|
+
} else {
|
|
1247
|
+
options = data as FetchedOption[];
|
|
1248
|
+
}
|
|
1249
|
+
} else if (data && typeof data === 'object') {
|
|
1250
|
+
options = Object.entries(data as Record<string, unknown>).map(([key, value]) => ({
|
|
1251
|
+
key,
|
|
1252
|
+
value: String(value)
|
|
1253
|
+
}));
|
|
1254
|
+
} else {
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Apply max-results limit to flat (non-grouped) options
|
|
1259
|
+
if (this.maxResults > 0) {
|
|
1260
|
+
let count = 0;
|
|
1261
|
+
const limited: (FetchedOption | FetchedGroup)[] = [];
|
|
1262
|
+
for (const item of options) {
|
|
1263
|
+
if (count >= this.maxResults) break;
|
|
1264
|
+
if (item.group !== undefined) {
|
|
1265
|
+
const remaining = this.maxResults - count;
|
|
1266
|
+
const slicedOpts = item.options.slice(0, remaining);
|
|
1267
|
+
if (slicedOpts.length > 0) {
|
|
1268
|
+
limited.push({group: item.group, options: slicedOpts});
|
|
1269
|
+
count += slicedOpts.length;
|
|
1270
|
+
}
|
|
1271
|
+
} else {
|
|
1272
|
+
limited.push(item);
|
|
1273
|
+
count++;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
options = limited;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return options;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Fetches options from the URL specified by the `data-uri` property. Optionally appends a search query. */
|
|
1283
|
+
async fetchOptions(searchQuery?: string) {
|
|
1284
|
+
if (!this.dataUri || this.disabled) return;
|
|
1285
|
+
|
|
1286
|
+
const isRemoteSearch = !!searchQuery;
|
|
1287
|
+
|
|
1288
|
+
// Check cache first
|
|
1289
|
+
if (this.cacheKey) {
|
|
1290
|
+
const cached = this._readCache(searchQuery);
|
|
1291
|
+
if (cached) {
|
|
1292
|
+
this._fetchedOptions = cached;
|
|
1293
|
+
this._searchLoading = false;
|
|
1294
|
+
this._fetchLoading = false;
|
|
1295
|
+
this._totalResultCount = 0;
|
|
1296
|
+
this._noResultsVisible = cached.length === 0 && isRemoteSearch;
|
|
1297
|
+
this.emit('zn-load');
|
|
1298
|
+
this.updateComplete.then(() => this.handleDefaultSlotChange());
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
this._fetchAbortController?.abort();
|
|
1304
|
+
this._fetchAbortController = new AbortController();
|
|
1305
|
+
|
|
1306
|
+
if (isRemoteSearch) {
|
|
1307
|
+
this._searchLoading = true;
|
|
1308
|
+
} else {
|
|
1309
|
+
this._fetchLoading = true;
|
|
1310
|
+
}
|
|
1311
|
+
this._fetchError = '';
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
// Build the URL — append search query as a parameter for remote search
|
|
1315
|
+
let url = this.dataUri;
|
|
1316
|
+
if (searchQuery) {
|
|
1317
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
1318
|
+
url = `${url}${separator}${encodeURIComponent(this.searchParam)}=${encodeURIComponent(searchQuery)}`;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const response = await fetch(url, {
|
|
1322
|
+
signal: this._fetchAbortController.signal,
|
|
1323
|
+
credentials: 'same-origin',
|
|
1324
|
+
headers: {
|
|
1325
|
+
'x-kx-fetch-style': 'zn-select-options',
|
|
1326
|
+
'x-kx-context-data': this.contextData || ''
|
|
1327
|
+
},
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
if (!response.ok) {
|
|
1331
|
+
this._searchLoading = false;
|
|
1332
|
+
this._handleFetchError(response.statusText || `HTTP ${response.status}`);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const data: unknown = await response.json();
|
|
1337
|
+
|
|
1338
|
+
// Count raw results before maxResults limiting to determine exhaustiveness
|
|
1339
|
+
const rawCount = Array.isArray(data)
|
|
1340
|
+
? data.reduce((n: number, item: unknown) => {
|
|
1341
|
+
if (item && typeof item === 'object' && 'group' in (item as Record<string, unknown>)) {
|
|
1342
|
+
const group = item as Record<string, unknown>;
|
|
1343
|
+
return n + (Array.isArray(group.options) ? group.options.length : 0);
|
|
1344
|
+
}
|
|
1345
|
+
return n + 1;
|
|
1346
|
+
}, 0)
|
|
1347
|
+
: (data && typeof data === 'object') ? Object.keys(data as object).length : 0;
|
|
1348
|
+
|
|
1349
|
+
const options = this._parseOptions(data);
|
|
1350
|
+
|
|
1351
|
+
if (!options) {
|
|
1352
|
+
this._searchLoading = false;
|
|
1353
|
+
this._handleFetchError('Expected JSON array or object of options');
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
this._fetchedOptions = options;
|
|
1358
|
+
this._fetchLoading = false;
|
|
1359
|
+
this._searchLoading = false;
|
|
1360
|
+
this._fetchAbortController = null;
|
|
1361
|
+
this._noResultsVisible = options.length === 0 && isRemoteSearch;
|
|
1362
|
+
|
|
1363
|
+
// Track total vs displayed count for truncation indicator
|
|
1364
|
+
this._totalResultCount = this.maxResults > 0 && rawCount > this.maxResults ? rawCount : 0;
|
|
1365
|
+
|
|
1366
|
+
// Track whether the server's result set was exhaustive (returned fewer than the limit).
|
|
1367
|
+
// If so, further refinements of this query can be filtered client-side.
|
|
1368
|
+
// For the initial load (no search query), this tells us if we already have all results.
|
|
1369
|
+
this._lastRemoteQuery = searchQuery ?? '';
|
|
1370
|
+
this._lastRemoteExhaustive = this.maxResults > 0 ? rawCount < this.maxResults : true;
|
|
1371
|
+
|
|
1372
|
+
// Cache the results
|
|
1373
|
+
this._writeCache(searchQuery, options);
|
|
1374
|
+
|
|
1375
|
+
this.emit('zn-load');
|
|
1376
|
+
|
|
1377
|
+
this.updateComplete.then(() => {
|
|
1378
|
+
this.handleDefaultSlotChange();
|
|
1379
|
+
});
|
|
1380
|
+
} catch (error: unknown) {
|
|
1381
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
this._searchLoading = false;
|
|
1386
|
+
this._handleFetchError(error instanceof Error ? error.message : 'Failed to load options');
|
|
780
1387
|
}
|
|
781
1388
|
}
|
|
782
1389
|
|
|
@@ -806,6 +1413,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
806
1413
|
|
|
807
1414
|
// Select only the options that match the new value
|
|
808
1415
|
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
|
|
1416
|
+
this.updateDependencies();
|
|
809
1417
|
}
|
|
810
1418
|
|
|
811
1419
|
@watch('open', {waitUntilFirstUpdate: true})
|
|
@@ -814,6 +1422,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
814
1422
|
// Reset the current option
|
|
815
1423
|
this.setCurrentOption(null);
|
|
816
1424
|
|
|
1425
|
+
// When search is enabled, clear the display input to allow typing and show all options
|
|
1426
|
+
if (this.search) {
|
|
1427
|
+
this.clearSearch();
|
|
1428
|
+
this.displayInput.value = '';
|
|
1429
|
+
this.displayInput.readOnly = false;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
817
1432
|
// Show
|
|
818
1433
|
this.emit('zn-show');
|
|
819
1434
|
this.addOpenListeners();
|
|
@@ -827,6 +1442,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
827
1442
|
this.setCurrentOption(this.currentOption);
|
|
828
1443
|
});
|
|
829
1444
|
|
|
1445
|
+
// Focus the search input so the user can start typing immediately
|
|
1446
|
+
if (this.search) {
|
|
1447
|
+
requestAnimationFrame(() => {
|
|
1448
|
+
this.displayInput.focus({preventScroll: true});
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
|
|
830
1452
|
const {keyframes, options} = getAnimation(this, 'select.show', {dir: this.localize.dir()});
|
|
831
1453
|
await animateTo(this.popup.popup, keyframes, options);
|
|
832
1454
|
|
|
@@ -841,6 +1463,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
841
1463
|
this.emit('zn-hide');
|
|
842
1464
|
this.removeOpenListeners();
|
|
843
1465
|
|
|
1466
|
+
// When search is enabled, restore the display label and clear the search
|
|
1467
|
+
if (this.search) {
|
|
1468
|
+
this.clearSearch();
|
|
1469
|
+
this.displayInput.readOnly = true;
|
|
1470
|
+
// In multiple mode, tags show the selection so keep the input empty
|
|
1471
|
+
this.displayInput.value = this.multiple ? '' : this.displayLabel;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
844
1474
|
await stopAnimations(this);
|
|
845
1475
|
const {keyframes, options} = getAnimation(this, 'select.hide', {dir: this.localize.dir()});
|
|
846
1476
|
await animateTo(this.popup.popup, keyframes, options);
|
|
@@ -904,6 +1534,67 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
904
1534
|
this.displayInput.blur();
|
|
905
1535
|
}
|
|
906
1536
|
|
|
1537
|
+
private updateDependencies() {
|
|
1538
|
+
const root = this.getRootNode() as Document | ShadowRoot;
|
|
1539
|
+
if (!root?.querySelectorAll) return;
|
|
1540
|
+
|
|
1541
|
+
const dependentElements = root.querySelectorAll('[data-disable-on]');
|
|
1542
|
+
dependentElements.forEach(el => {
|
|
1543
|
+
const selector = el.getAttribute('data-disable-on');
|
|
1544
|
+
if (!selector) return;
|
|
1545
|
+
|
|
1546
|
+
let trigger: Element | null = null;
|
|
1547
|
+
try {
|
|
1548
|
+
trigger = root.querySelector(selector);
|
|
1549
|
+
} catch (e) {
|
|
1550
|
+
// no-op
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if (!trigger) {
|
|
1554
|
+
const idSelector = selector.startsWith('#') ? selector : `#${selector}`;
|
|
1555
|
+
try {
|
|
1556
|
+
trigger = root.querySelector(idSelector);
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
// no-op
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (trigger === this) {
|
|
1563
|
+
this.updateDependencyState(el as HTMLInputElement);
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
private updateDependencyState(target: HTMLInputElement) {
|
|
1569
|
+
const disableValue = target.getAttribute('data-disable-value');
|
|
1570
|
+
if (disableValue !== null) {
|
|
1571
|
+
const valuesToCheck = disableValue.split(',').map(v => v.trim());
|
|
1572
|
+
let shouldDisable: boolean;
|
|
1573
|
+
|
|
1574
|
+
if (Array.isArray(this.value)) {
|
|
1575
|
+
shouldDisable = this.value.some(v => valuesToCheck.includes(v));
|
|
1576
|
+
} else {
|
|
1577
|
+
shouldDisable = valuesToCheck.includes(String(this.value));
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
this.toggleDisabled(target, shouldDisable);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
private toggleDisabled(target: HTMLInputElement, disabled: boolean) {
|
|
1585
|
+
const reset = () => {
|
|
1586
|
+
if ('defaultValue' in target && typeof target.defaultValue !== 'undefined') {
|
|
1587
|
+
target.value = target.defaultValue;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
target.disabled = disabled;
|
|
1592
|
+
|
|
1593
|
+
if (disabled) {
|
|
1594
|
+
reset();
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
907
1598
|
render() {
|
|
908
1599
|
const hasLabelSlot = this.hasSlotController.test('label');
|
|
909
1600
|
const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip');
|
|
@@ -915,6 +1606,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
915
1606
|
const hasHelpText = this.helpText ? true : hasHelpTextSlot;
|
|
916
1607
|
const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0;
|
|
917
1608
|
const isPlaceholderVisible = this.placeholder && this.value.length === 0;
|
|
1609
|
+
const isFetchDisabled = this._fetchLoading || !!this._fetchError;
|
|
1610
|
+
const isDisabled = this.disabled || isFetchDisabled;
|
|
918
1611
|
|
|
919
1612
|
return html`
|
|
920
1613
|
<div
|
|
@@ -958,7 +1651,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
958
1651
|
'select--standard': true,
|
|
959
1652
|
'select--pill': this.pill,
|
|
960
1653
|
'select--open': this.open,
|
|
961
|
-
'select--disabled':
|
|
1654
|
+
'select--disabled': isDisabled,
|
|
962
1655
|
'select--multiple': this.multiple,
|
|
963
1656
|
'select--focused': this.hasFocus,
|
|
964
1657
|
'select--placeholder-visible': isPlaceholderVisible,
|
|
@@ -967,7 +1660,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
967
1660
|
'select--small': this.size === 'small',
|
|
968
1661
|
'select--medium': this.size === 'medium',
|
|
969
1662
|
'select--large': this.size === 'large',
|
|
970
|
-
'select--has-input-prefix': this.inputPrefix
|
|
1663
|
+
'select--has-input-prefix': this.inputPrefix,
|
|
1664
|
+
'select--search': this.search
|
|
971
1665
|
})}
|
|
972
1666
|
placement=${this.placement}
|
|
973
1667
|
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
|
@@ -991,23 +1685,25 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
991
1685
|
part="display-input"
|
|
992
1686
|
class="select__display-input"
|
|
993
1687
|
type="text"
|
|
994
|
-
placeholder=${this.placeholder}
|
|
995
|
-
.disabled=${
|
|
996
|
-
.value=${this.displayLabel}
|
|
1688
|
+
placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this.search && this.open ? (this.displayLabel || this.placeholder || 'Search...') : this.placeholder)}
|
|
1689
|
+
.disabled=${isDisabled}
|
|
1690
|
+
.value=${isFetchDisabled ? '' : (this.search && this.open) ? this._searchDisplayValue : (this.search && this.multiple) ? '' : this.displayLabel}
|
|
997
1691
|
autocomplete="off"
|
|
998
1692
|
spellcheck="false"
|
|
999
1693
|
autocapitalize="off"
|
|
1000
|
-
readonly
|
|
1694
|
+
?readonly=${!(this.search && this.open)}
|
|
1001
1695
|
aria-controls="listbox"
|
|
1002
1696
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
1003
1697
|
aria-haspopup="listbox"
|
|
1004
1698
|
aria-labelledby="label"
|
|
1005
|
-
aria-disabled=${
|
|
1699
|
+
aria-disabled=${isDisabled ? 'true' : 'false'}
|
|
1006
1700
|
aria-describedby="help-text"
|
|
1701
|
+
aria-autocomplete=${this.search ? 'list' : 'none'}
|
|
1007
1702
|
role="combobox"
|
|
1008
1703
|
tabindex="0"
|
|
1009
1704
|
@focus=${this.handleFocus}
|
|
1010
1705
|
@blur=${this.handleBlur}
|
|
1706
|
+
@input=${this.handleSearchInput}
|
|
1011
1707
|
/>
|
|
1012
1708
|
|
|
1013
1709
|
${this.multiple ? html`
|
|
@@ -1016,7 +1712,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1016
1712
|
<input
|
|
1017
1713
|
class="select__value-input"
|
|
1018
1714
|
type="text"
|
|
1019
|
-
disabled=${
|
|
1715
|
+
disabled=${isDisabled || nothing}
|
|
1020
1716
|
required=${this.required || nothing}
|
|
1021
1717
|
value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
|
|
1022
1718
|
tabindex="-1"
|
|
@@ -1041,9 +1737,17 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1041
1737
|
</button>`
|
|
1042
1738
|
: ''}
|
|
1043
1739
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1740
|
+
${this._fetchLoading
|
|
1741
|
+
? html`
|
|
1742
|
+
<zn-icon src="progress_activity" class="select__expand-icon select__spinner"></zn-icon>`
|
|
1743
|
+
: this._fetchError
|
|
1744
|
+
? html`
|
|
1745
|
+
<zn-icon src="error" class="select__expand-icon select__fetch-error-icon"></zn-icon>`
|
|
1746
|
+
: html`
|
|
1747
|
+
<slot name="expand-icon" part="expand-icon" class="select__expand-icon">
|
|
1748
|
+
<zn-icon src="keyboard_arrow_down"></zn-icon>
|
|
1749
|
+
</slot>`
|
|
1750
|
+
}
|
|
1047
1751
|
</div>
|
|
1048
1752
|
|
|
1049
1753
|
<div
|
|
@@ -1058,6 +1762,42 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1058
1762
|
@mouseup=${this.handleOptionClick}
|
|
1059
1763
|
@slotchange=${this.handleDefaultSlotChange}>
|
|
1060
1764
|
<slot></slot>
|
|
1765
|
+
${this._renderSelectedRemoteItems()}
|
|
1766
|
+
${this._fetchedOptions.map(item =>
|
|
1767
|
+
item.group !== undefined
|
|
1768
|
+
? html`
|
|
1769
|
+
<zn-opt-group label=${item.group}>
|
|
1770
|
+
${item.options.map(
|
|
1771
|
+
option => html`
|
|
1772
|
+
<zn-option value=${option.key}>${option.value}</zn-option>`
|
|
1773
|
+
)}
|
|
1774
|
+
</zn-opt-group>`
|
|
1775
|
+
: html`
|
|
1776
|
+
<zn-option value=${item.key}>${item.value}</zn-option>`
|
|
1777
|
+
)}
|
|
1778
|
+
${this._totalResultCount > 0
|
|
1779
|
+
? html`
|
|
1780
|
+
<div part="max-results-indicator" class="select__max-results-indicator">
|
|
1781
|
+
Showing ${this.maxResults} of ${this._totalResultCount} results — refine your search for more
|
|
1782
|
+
</div>`
|
|
1783
|
+
: nothing
|
|
1784
|
+
}
|
|
1785
|
+
${this._searchLoading
|
|
1786
|
+
? html`
|
|
1787
|
+
<div part="search-loading" class="select__search-loading">
|
|
1788
|
+
<zn-icon src="progress_activity" class="select__spinner"></zn-icon>
|
|
1789
|
+
Searching...
|
|
1790
|
+
</div>`
|
|
1791
|
+
: this.searchOnly && this._fetchedOptions.length === 0 && !this._searchQuery
|
|
1792
|
+
? html`
|
|
1793
|
+
<div part="empty-state" class="select__empty-state">
|
|
1794
|
+
Type to search...
|
|
1795
|
+
</div>`
|
|
1796
|
+
: html`
|
|
1797
|
+
<div part="empty-state" class="select__empty-state" ?hidden=${!this._noResultsVisible}>
|
|
1798
|
+
No matching options
|
|
1799
|
+
</div>`
|
|
1800
|
+
}
|
|
1061
1801
|
</div>
|
|
1062
1802
|
</zn-popup>
|
|
1063
1803
|
</div>
|