@kubex/zinc 1.0.113 → 1.0.115
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/.github/workflows/eleventy_build.yml +5 -5
- package/.github/workflows/js_build_and_deploy.yaml +1 -1
- package/dist/custom-elements.json +2422 -190
- package/dist/vscode.html-custom-data.json +86 -5
- package/dist/web-types.json +251 -9
- package/dist/zn.d.ts +518 -340
- package/dist/zn.min.css +1 -1
- package/dist/zn.min.js +960 -882
- package/docs/eleventy.config.cjs +4 -2
- package/docs/pages/components/header.md +13 -0
- package/docs/pages/components/inline-edit.md +20 -0
- package/docs/pages/components/item.md +12 -1
- package/docs/pages/components/page.md +149 -0
- package/docs/pages/components/progress-bar.md +16 -0
- package/docs/pages/components/select.md +25 -0
- package/docs/pages/components/split-pane.md +6 -7
- package/docs/pages/components/tabs.md +59 -9
- package/package.json +8 -5
- package/scripts/build.js +40 -19
- package/scss/_global-spacing.scss +12 -1
- package/scss/_root.scss +34 -2
- package/scss/mixins/_spacing-mixins.scss +1 -1
- package/scss/shared/layout.scss +15 -2
- package/scss/themes/_light.scss +1 -1
- package/src/components/absolute-container/absolute-container.component.ts +9 -25
- package/src/components/animated-button/animated-button.test.ts +8 -8
- package/src/components/button/button.component.ts +3 -1
- package/src/components/button/button.scss +5 -0
- package/src/components/button-menu/button-menu.component.ts +36 -21
- package/src/components/button-menu/button-menu.test.ts +55 -1
- package/src/components/chart/chart.component.ts +7 -7
- package/src/components/collapsible/collapsible.component.ts +7 -14
- package/src/components/cols/cols.scss +1 -1
- package/src/components/content-block/content-block.component.ts +11 -33
- package/src/components/data-select/data-select.component.ts +7 -11
- package/src/components/data-select/data-select.scss +1 -0
- package/src/components/data-table/data-table.component.ts +10 -14
- package/src/components/dialog/dialog.scss +4 -0
- package/src/components/editor/modules/toolbar/toolbar.component.ts +5 -9
- package/src/components/expanding-action/expanding-action.component.ts +37 -43
- package/src/components/form-group/form-group.component.ts +32 -27
- package/src/components/form-group/form-group.scss +1 -0
- package/src/components/header/header.component.ts +6 -5
- package/src/components/header/header.scss +28 -26
- package/src/components/inline-edit/inline-edit.component.ts +13 -1
- package/src/components/inline-edit/inline-edit.test.ts +50 -0
- package/src/components/input/input.test.ts +77 -0
- package/src/components/input-group/input-group.test.ts +2 -10
- package/src/components/item/item.component.ts +12 -6
- package/src/components/item/item.scss +1 -1
- package/src/components/item/item.test.ts +8 -0
- package/src/components/navbar/navbar.component.ts +222 -48
- package/src/components/navbar/navbar.scss +134 -21
- package/src/components/navbar/navbar.test.ts +114 -9
- package/src/components/option/option.scss +5 -7
- package/src/components/option/option.test.ts +30 -0
- package/src/components/page/index.ts +13 -0
- package/src/components/page/page.component.ts +406 -0
- package/src/components/page/page.scss +325 -0
- package/src/components/page/page.test.ts +397 -0
- package/src/components/page-nav/page-nav.scss +7 -5
- package/src/components/pane/pane.component.ts +2 -2
- package/src/components/pane/pane.scss +0 -1
- package/src/components/pane/pane.test.ts +31 -0
- package/src/components/panel/panel.scss +5 -16
- package/src/components/progress-bar/progress-bar.component.ts +5 -2
- package/src/components/progress-bar/progress-bar.scss +17 -1
- package/src/components/progress-bar/progress-bar.test.ts +12 -0
- package/src/components/scroll-container/scroll-container.component.ts +22 -21
- package/src/components/select/select.component.ts +243 -39
- package/src/components/select/select.scss +36 -5
- package/src/components/select/select.test.ts +533 -0
- package/src/components/settings-container/settings-container.component.ts +15 -19
- package/src/components/settings-container/settings-container.scss +8 -8
- package/src/components/sidebar/sidebar.component.ts +11 -11
- package/src/components/simple-chart/simple-chart.component.ts +6 -7
- package/src/components/sp/sp.scss +46 -15
- package/src/components/sp/sp.test.ts +15 -0
- package/src/components/split-pane/split-pane.component.ts +153 -26
- package/src/components/split-pane/split-pane.scss +89 -13
- package/src/components/split-pane/split-pane.test.ts +187 -0
- package/src/components/style/style.component.ts +8 -0
- package/src/components/tab/index.ts +13 -0
- package/src/components/tab/tab.component.ts +25 -0
- package/src/components/tab/tab.scss +7 -0
- package/src/components/table/table.component.ts +7 -2
- package/src/components/table/table.scss +1 -0
- package/src/components/tabs/tabs.component.ts +75 -70
- package/src/components/tabs/tabs.scss +1 -1
- package/src/components/textarea/textarea.component.ts +5 -8
- package/src/components/tile/tile.scss +2 -2
- package/src/components/translation-group/translation-group.component.ts +15 -14
- package/src/components/translations/translations.component.ts +18 -17
- package/src/components/translations/translations.scss +1 -0
- package/src/components/translations/translations.test.ts +3 -2
- package/src/internal/form.ts +234 -0
- package/src/internal/theme.ts +26 -46
- package/src/internal/zinc-element.ts +12 -7
- package/src/utilities/form.ts +1 -0
- package/src/wc.scss +1 -1
- package/src/zinc.ts +3 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {type CSSResultGroup, html, unsafeCSS, PropertyValues} from 'lit';
|
|
2
|
-
import
|
|
2
|
+
import {MutationController} from '@lit-labs/observers/mutation-controller.js';
|
|
3
3
|
import {property, query} from "lit/decorators.js";
|
|
4
|
+
import {ResizeController} from '@lit-labs/observers/resize-controller.js';
|
|
5
|
+
import ZincElement from '../../internal/zinc-element';
|
|
4
6
|
|
|
5
7
|
import styles from './scroll-container.scss';
|
|
6
8
|
|
|
@@ -43,33 +45,32 @@ export default class ZnScrollContainer extends ZincElement {
|
|
|
43
45
|
this.container.scrollTop = this.container.scrollHeight;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
private _footerResizeObserver
|
|
48
|
+
private readonly _footerResizeObserver = new ResizeController(this, {
|
|
49
|
+
target: null,
|
|
50
|
+
callback: () => {
|
|
51
|
+
this.style.setProperty('--zn-scroll-footer-height', `${this.footer?.clientHeight ?? 0}px`);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
private readonly _domObserver = new MutationController(this, {
|
|
56
|
+
target: null,
|
|
57
|
+
config: {childList: true, subtree: true},
|
|
58
|
+
callback: () => {
|
|
59
|
+
setTimeout(() => this.scrollEnd(), 100);
|
|
60
|
+
if (this.footer) {
|
|
61
|
+
this.style.setProperty('--zn-scroll-footer-height', `${this.footer.clientHeight}px`);
|
|
62
|
+
this._footerResizeObserver.observe(this.footer);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
});
|
|
47
66
|
|
|
48
67
|
connectedCallback() {
|
|
49
68
|
super.connectedCallback();
|
|
50
69
|
if (this.startScrolled) {
|
|
51
|
-
|
|
52
|
-
setTimeout(this.scrollEnd.bind(this), 100);
|
|
53
|
-
if (this.footer) {
|
|
54
|
-
// Initialize height immediately
|
|
55
|
-
this.style.setProperty('--zn-scroll-footer-height', `${this.footer.clientHeight}px`);
|
|
56
|
-
// Attach ResizeObserver to watch for size changes
|
|
57
|
-
this._footerResizeObserver = new ResizeObserver(() => {
|
|
58
|
-
this.style.setProperty('--zn-scroll-footer-height', `${this.footer?.clientHeight ?? 0}px`);
|
|
59
|
-
});
|
|
60
|
-
this._footerResizeObserver.observe(this.footer);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
observer.observe(this, {childList: true, subtree: true});
|
|
70
|
+
this._domObserver.observe(this);
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
disconnectedCallback() {
|
|
68
|
-
super.disconnectedCallback();
|
|
69
|
-
this._footerResizeObserver?.disconnect();
|
|
70
|
-
this._footerResizeObserver = undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
74
|
render() {
|
|
74
75
|
return html`
|
|
75
76
|
<div class="container">
|
|
@@ -113,12 +113,16 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
113
113
|
/** @internal - current search/filter text when search is enabled (lowercased for matching) */
|
|
114
114
|
private _searchQuery = '';
|
|
115
115
|
|
|
116
|
-
/** @internal - raw display value of the search input (preserves case for the input field)
|
|
117
|
-
|
|
116
|
+
/** @internal - raw display value of the search input (preserves case for the input field). Reactive so the
|
|
117
|
+
* free-text "Add" row re-renders on every keystroke, not just when the match/no-match state changes. */
|
|
118
|
+
@state() private _searchDisplayValue = '';
|
|
118
119
|
|
|
119
120
|
/** @internal - whether the "no matching options" empty state is visible */
|
|
120
121
|
@state() private _noResultsVisible = false;
|
|
121
122
|
|
|
123
|
+
/** @internal - whether the free-text "Add" row is the active keyboard-navigation target */
|
|
124
|
+
@state() private _addOptionActive = false;
|
|
125
|
+
|
|
122
126
|
/** @internal */
|
|
123
127
|
@state() private _fetchedOptions: ({ key: string; value: string; group?: undefined } | {
|
|
124
128
|
group: string;
|
|
@@ -275,6 +279,14 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
275
279
|
/** Enables search/filter functionality. When enabled, the user can type into the select to filter the visible options. */
|
|
276
280
|
@property({type: Boolean, reflect: true}) search = false;
|
|
277
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Allows the user to enter values that are not in the options list ("free text"). Implies the editable,
|
|
284
|
+
* filtering input behavior of `search`. A typed value that doesn't match an existing option is committed
|
|
285
|
+
* by pressing Enter, clicking the "Add" row, or blurring the field, and becomes a selected option — a tag
|
|
286
|
+
* when `multiple` is enabled. The committed value is both the option's value and its label.
|
|
287
|
+
*/
|
|
288
|
+
@property({type: Boolean, reflect: true, attribute: 'free-text'}) freeText = false;
|
|
289
|
+
|
|
278
290
|
/** The select's label. If you need to display HTML, use the `label` slot instead. */
|
|
279
291
|
@property() label = '';
|
|
280
292
|
|
|
@@ -477,6 +489,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
477
489
|
return;
|
|
478
490
|
}
|
|
479
491
|
|
|
492
|
+
// Escape cancels uncommitted free-text rather than letting the close path commit it. Done here (before
|
|
493
|
+
// either the CloseWatcher or the branch below closes the dropdown) so it applies on every browser.
|
|
494
|
+
if (event.key === 'Escape' && this.open && this.freeText && this._searchDisplayValue.trim()) {
|
|
495
|
+
this.clearSearch();
|
|
496
|
+
this.displayInput.value = '';
|
|
497
|
+
}
|
|
498
|
+
|
|
480
499
|
// Close when pressing escape
|
|
481
500
|
if ((event.key === 'Escape' || event.key === 'Tab') && this.open && !this.closeWatcher) {
|
|
482
501
|
event.preventDefault();
|
|
@@ -494,7 +513,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
494
513
|
|
|
495
514
|
// Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
|
|
496
515
|
// buffer we _don't_ close it. When search is enabled, space should type into the input, not toggle.
|
|
497
|
-
if (event.key === 'Enter' || (event.key === ' ' && !this.
|
|
516
|
+
if (event.key === 'Enter' || (event.key === ' ' && !this._isTypeable && this.typeToSelectString === '')) {
|
|
498
517
|
event.preventDefault();
|
|
499
518
|
event.stopImmediatePropagation();
|
|
500
519
|
|
|
@@ -504,6 +523,15 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
504
523
|
return;
|
|
505
524
|
}
|
|
506
525
|
|
|
526
|
+
// Free-text: commit when the Add row is the active target, or when no real option is highlighted
|
|
527
|
+
if (this.freeText) {
|
|
528
|
+
const addActive = this._addOptionActive && this._freeTextAddValue !== null;
|
|
529
|
+
const usableCurrent = !addActive && this.currentOption && !this.currentOption.disabled && !this.currentOption.hidden;
|
|
530
|
+
if (!usableCurrent && this.commitFreeText()) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
507
535
|
// If it is open, update the value based on the current selection and close it
|
|
508
536
|
if (this.currentOption && !this.currentOption.disabled) {
|
|
509
537
|
this.valueHasChanged = true;
|
|
@@ -514,7 +542,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
514
542
|
}
|
|
515
543
|
|
|
516
544
|
// Clear search after keyboard selection
|
|
517
|
-
if (this.
|
|
545
|
+
if (this._isTypeable) {
|
|
518
546
|
this.clearSearch();
|
|
519
547
|
if (this.multiple) {
|
|
520
548
|
this.displayInput.value = '';
|
|
@@ -530,8 +558,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
530
558
|
if (!this.multiple) {
|
|
531
559
|
this.hide().then();
|
|
532
560
|
this.displayInput.focus({preventScroll: true});
|
|
533
|
-
} else if (this.
|
|
534
|
-
// In multi-select +
|
|
561
|
+
} else if (this._isTypeable) {
|
|
562
|
+
// In multi-select + typeable, re-focus the search input after keyboard selection
|
|
535
563
|
this.updateComplete.then(() => {
|
|
536
564
|
this.displayInput.value = '';
|
|
537
565
|
this.displayInput.focus({preventScroll: true});
|
|
@@ -542,12 +570,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
542
570
|
return;
|
|
543
571
|
}
|
|
544
572
|
|
|
545
|
-
// Navigate options
|
|
573
|
+
// Navigate options (and the free-text "Add" row, when shown)
|
|
546
574
|
if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
|
547
|
-
const navOptions = this.search ? this.getVisibleOptions() : this.getAllOptions();
|
|
548
|
-
const currentIndex = navOptions.indexOf(this.currentOption);
|
|
549
|
-
let newIndex = Math.max(0, currentIndex);
|
|
550
|
-
|
|
551
575
|
// Prevent scrolling
|
|
552
576
|
event.preventDefault();
|
|
553
577
|
|
|
@@ -562,23 +586,43 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
562
586
|
}
|
|
563
587
|
}
|
|
564
588
|
|
|
589
|
+
const navOptions = this._isTypeable ? this.getVisibleOptions() : this.getAllOptions();
|
|
590
|
+
const hasAdd = this.freeText && this._freeTextAddValue !== null;
|
|
591
|
+
|
|
592
|
+
// The Add row, when shown, sits at the top of the listbox and is the first navigable target.
|
|
593
|
+
const ADD = 'add';
|
|
594
|
+
const targets: (ZnOption | typeof ADD)[] = hasAdd ? [ADD, ...navOptions] : [...navOptions];
|
|
595
|
+
if (targets.length === 0) return;
|
|
596
|
+
|
|
597
|
+
const currentTarget: ZnOption | typeof ADD | null =
|
|
598
|
+
this._addOptionActive && hasAdd ? ADD : (this.currentOption ?? null);
|
|
599
|
+
const currentIndex = currentTarget === null ? -1 : targets.indexOf(currentTarget);
|
|
600
|
+
let newIndex = Math.max(0, currentIndex);
|
|
601
|
+
|
|
565
602
|
if (event.key === 'ArrowDown') {
|
|
566
603
|
newIndex = currentIndex + 1;
|
|
567
|
-
if (newIndex >
|
|
604
|
+
if (newIndex > targets.length - 1) newIndex = 0;
|
|
568
605
|
} else if (event.key === 'ArrowUp') {
|
|
569
606
|
newIndex = currentIndex - 1;
|
|
570
|
-
if (newIndex < 0) newIndex =
|
|
607
|
+
if (newIndex < 0) newIndex = targets.length - 1;
|
|
571
608
|
} else if (event.key === 'Home') {
|
|
572
609
|
newIndex = 0;
|
|
573
610
|
} else if (event.key === 'End') {
|
|
574
|
-
newIndex =
|
|
611
|
+
newIndex = targets.length - 1;
|
|
575
612
|
}
|
|
576
613
|
|
|
577
|
-
|
|
614
|
+
const next = targets[newIndex];
|
|
615
|
+
if (next === ADD) {
|
|
616
|
+
this._addOptionActive = true;
|
|
617
|
+
this.setCurrentOption(null);
|
|
618
|
+
} else {
|
|
619
|
+
this._addOptionActive = false;
|
|
620
|
+
this.setCurrentOption(next);
|
|
621
|
+
}
|
|
578
622
|
}
|
|
579
623
|
|
|
580
|
-
// When
|
|
581
|
-
if (this.
|
|
624
|
+
// When typeable, let the input handle keystrokes natively (they go through handleSearchInput)
|
|
625
|
+
if (this._isTypeable) {
|
|
582
626
|
return;
|
|
583
627
|
}
|
|
584
628
|
|
|
@@ -648,8 +692,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
648
692
|
return;
|
|
649
693
|
}
|
|
650
694
|
|
|
651
|
-
// When
|
|
652
|
-
if (this.
|
|
695
|
+
// When typeable, clicking the input area should open (not toggle) so the user can type
|
|
696
|
+
if (this._isTypeable && this.open && !isExpandIcon) {
|
|
653
697
|
event.preventDefault();
|
|
654
698
|
this.displayInput.focus({preventScroll: true});
|
|
655
699
|
return;
|
|
@@ -678,8 +722,10 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
678
722
|
private handleClearClick(event: MouseEvent) {
|
|
679
723
|
event.stopPropagation();
|
|
680
724
|
|
|
681
|
-
if (this.value
|
|
682
|
-
|
|
725
|
+
if (this.value.length > 0) {
|
|
726
|
+
this.valueHasChanged = true;
|
|
727
|
+
|
|
728
|
+
if (this._isTypeable) {
|
|
683
729
|
this.clearSearch();
|
|
684
730
|
this.displayInput.value = '';
|
|
685
731
|
}
|
|
@@ -711,9 +757,17 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
711
757
|
return this.search && !!this.dataUri;
|
|
712
758
|
}
|
|
713
759
|
|
|
760
|
+
/** Whether the display input is editable and filters options (search or free-text). */
|
|
761
|
+
private get _isTypeable() {
|
|
762
|
+
return this.search || this.freeText;
|
|
763
|
+
}
|
|
764
|
+
|
|
714
765
|
/** Handles text input on the display input for search/filter mode */
|
|
715
766
|
private handleSearchInput() {
|
|
716
|
-
if (!this.
|
|
767
|
+
if (!this._isTypeable) return;
|
|
768
|
+
|
|
769
|
+
// A new keystroke resets the Add-row keyboard highlight
|
|
770
|
+
this._addOptionActive = false;
|
|
717
771
|
|
|
718
772
|
this._searchDisplayValue = this.displayInput.value;
|
|
719
773
|
this._searchQuery = this._searchDisplayValue.toLowerCase();
|
|
@@ -807,6 +861,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
807
861
|
this._searchQuery = '';
|
|
808
862
|
this._searchDisplayValue = '';
|
|
809
863
|
this._noResultsVisible = false;
|
|
864
|
+
this._addOptionActive = false;
|
|
810
865
|
|
|
811
866
|
// Cancel any pending remote search
|
|
812
867
|
if (this._searchDebounceTimer !== null) {
|
|
@@ -825,8 +880,126 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
825
880
|
});
|
|
826
881
|
}
|
|
827
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Commits the current typed text as a selected value when `free-text` is enabled. A selected `<zn-option>`
|
|
885
|
+
* (value = label = trimmed text) is created in the light DOM so the value flows through the existing
|
|
886
|
+
* tag/value/selection machinery and appears as a deselectable row in the listbox. Returns `true` when
|
|
887
|
+
* something was committed, `false` when there was nothing to commit.
|
|
888
|
+
*/
|
|
889
|
+
private commitFreeText(refocus = true): boolean {
|
|
890
|
+
if (!this.freeText) return false;
|
|
891
|
+
|
|
892
|
+
const text = (this.displayInput?.value ?? '').trim();
|
|
893
|
+
if (!text) return false;
|
|
894
|
+
|
|
895
|
+
this.valueHasChanged = true;
|
|
896
|
+
|
|
897
|
+
// If the text matches an existing option (label or value, case-insensitive), select it instead of
|
|
898
|
+
// creating a duplicate free-text option.
|
|
899
|
+
const lower = text.toLowerCase();
|
|
900
|
+
const existing = this.getAllOptions().find(
|
|
901
|
+
o => o.value.toLowerCase() === lower || o.getTextLabel().toLowerCase() === lower
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const option = existing ?? this._createFreeTextOption(text);
|
|
905
|
+
|
|
906
|
+
if (this.multiple) {
|
|
907
|
+
// Add to the existing selection and keep the dropdown open for more entry
|
|
908
|
+
this.toggleOptionSelection(option, true);
|
|
909
|
+
} else {
|
|
910
|
+
this.setSelectedOptions(option);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
this.clearSearch();
|
|
914
|
+
|
|
915
|
+
this.updateComplete.then(() => {
|
|
916
|
+
this.emit('zn-input');
|
|
917
|
+
this.emit('zn-change');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// When committed via blur/close, the dropdown is already closing and focus has left — don't fight it.
|
|
921
|
+
if (!refocus) {
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (this.multiple) {
|
|
926
|
+
// Clear the input so the user can type the next value
|
|
927
|
+
this.displayInput.value = '';
|
|
928
|
+
this.displayInput.focus({preventScroll: true});
|
|
929
|
+
} else {
|
|
930
|
+
this.hide().then();
|
|
931
|
+
this.displayInput.focus({preventScroll: true});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* The trimmed pending text to offer as a free-text "Add" row, or `null` when no Add row should be shown
|
|
939
|
+
* (free-text disabled, dropdown closed, input empty, or the text exactly matches an existing option).
|
|
940
|
+
*/
|
|
941
|
+
private get _freeTextAddValue(): string | null {
|
|
942
|
+
if (!this.freeText || !this.open) return null;
|
|
943
|
+
|
|
944
|
+
const text = this._searchDisplayValue.trim();
|
|
945
|
+
if (!text) return null;
|
|
946
|
+
|
|
947
|
+
const lower = text.toLowerCase();
|
|
948
|
+
const exact = this.getAllOptions().some(
|
|
949
|
+
o => o.value.toLowerCase() === lower || o.getTextLabel().toLowerCase() === lower
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
return exact ? null : text;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** Commits the typed value when the "Add" row is pressed, keeping focus on the input. */
|
|
956
|
+
private handleAddOptionMouseDown(event: MouseEvent) {
|
|
957
|
+
// Keep focus on the input but DON'T commit here. Committing on mousedown would clear the search and
|
|
958
|
+
// collapse the listbox, so the following mouseup would land on (and select) whichever option shifted
|
|
959
|
+
// under the cursor. Instead the commit happens on mouseup in handleOptionClick.
|
|
960
|
+
event.preventDefault();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* In free-text mode, creates a hidden `<zn-option>` for any value that has no matching option, so values
|
|
965
|
+
* set via the `value`/`defaultValue` attribute (e.g. previously-saved custom entries) render as tags or
|
|
966
|
+
* the display value on load. No-op when free-text is disabled.
|
|
967
|
+
*/
|
|
968
|
+
private _materialiseFreeTextValues(values: string[]) {
|
|
969
|
+
if (!this.freeText) return;
|
|
970
|
+
|
|
971
|
+
const existingValues = new Set(this.getAllOptions().map(o => o.value));
|
|
972
|
+
values.forEach(value => {
|
|
973
|
+
if (value === '' || existingValues.has(value)) return;
|
|
974
|
+
this._createFreeTextOption(value);
|
|
975
|
+
existingValues.add(value);
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Creates a free-text-marked `<zn-option>` in the light DOM (value = label) and returns it. The option is
|
|
981
|
+
* a normal, visible, selectable row so the user can deselect a custom value from the dropdown like any
|
|
982
|
+
* other option; the `data-free-text` marker lets us drop it from the DOM once it's no longer selected.
|
|
983
|
+
*/
|
|
984
|
+
private _createFreeTextOption(value: string): ZnOption {
|
|
985
|
+
const option = document.createElement('zn-option') as ZnOption;
|
|
986
|
+
option.value = value;
|
|
987
|
+
option.textContent = value;
|
|
988
|
+
option.setAttribute('data-free-text', '');
|
|
989
|
+
this.appendChild(option);
|
|
990
|
+
return option;
|
|
991
|
+
}
|
|
992
|
+
|
|
828
993
|
private handleOptionClick(event: MouseEvent) {
|
|
829
994
|
const target = event.target as HTMLElement;
|
|
995
|
+
|
|
996
|
+
// The free-text "Add" row lives in the listbox and commits on this same mouseup, so it can never
|
|
997
|
+
// also trigger an option selection.
|
|
998
|
+
if (target.closest('.select__add-option')) {
|
|
999
|
+
this.commitFreeText();
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
830
1003
|
const option = target.closest('zn-option');
|
|
831
1004
|
const oldValue = this.value;
|
|
832
1005
|
|
|
@@ -841,8 +1014,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
841
1014
|
this.setSelectedOptions(option);
|
|
842
1015
|
}
|
|
843
1016
|
|
|
844
|
-
// Clear search after selection when in search mode
|
|
845
|
-
if (this.
|
|
1017
|
+
// Clear search after selection when in search/free-text mode
|
|
1018
|
+
if (this._isTypeable) {
|
|
846
1019
|
this.clearSearch();
|
|
847
1020
|
if (this.multiple) {
|
|
848
1021
|
// Keep the search input clear for further typing
|
|
@@ -852,7 +1025,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
852
1025
|
|
|
853
1026
|
// Set focus after updating so the value is announced by screen readers
|
|
854
1027
|
this.updateComplete.then(() => {
|
|
855
|
-
if (this.multiple && this.
|
|
1028
|
+
if (this.multiple && this._isTypeable && this.open) {
|
|
856
1029
|
this.displayInput.value = '';
|
|
857
1030
|
}
|
|
858
1031
|
this.displayInput.focus({preventScroll: true});
|
|
@@ -891,8 +1064,11 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
891
1064
|
// Check for duplicate values in menu items
|
|
892
1065
|
allOptions.forEach(option => values.push(option.value));
|
|
893
1066
|
|
|
894
|
-
//
|
|
895
|
-
this.
|
|
1067
|
+
// In free-text mode, materialise any value with no matching option so it renders as a tag/value
|
|
1068
|
+
this._materialiseFreeTextValues(value);
|
|
1069
|
+
|
|
1070
|
+
// Select only the options that match the new value (re-query in case options were materialised)
|
|
1071
|
+
this.setSelectedOptions(this.getAllOptions().filter(el => value.includes(el.value)));
|
|
896
1072
|
|
|
897
1073
|
// of check if an option has selected attribute set initially
|
|
898
1074
|
if (!this.valueHasChanged) {
|
|
@@ -920,6 +1096,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
920
1096
|
event.stopImmediatePropagation();
|
|
921
1097
|
|
|
922
1098
|
if (!this.disabled) {
|
|
1099
|
+
// Deselecting drops the backing element for free-text values via selectionChanged's cleanup.
|
|
923
1100
|
this.toggleOptionSelection(option, false);
|
|
924
1101
|
|
|
925
1102
|
// Emit after updating
|
|
@@ -976,7 +1153,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
976
1153
|
option.tabIndex = 0;
|
|
977
1154
|
// When search is active and the display input has focus, don't steal focus to the option.
|
|
978
1155
|
// The user is typing in the search input and we only want to visually highlight the current option.
|
|
979
|
-
if (!(this.
|
|
1156
|
+
if (!(this._isTypeable && this.open && this.shadowRoot?.activeElement === this.displayInput)) {
|
|
980
1157
|
option.focus();
|
|
981
1158
|
}
|
|
982
1159
|
}
|
|
@@ -1016,6 +1193,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1016
1193
|
// Update selected options cache
|
|
1017
1194
|
this.selectedOptions = this.getAllOptions().filter(el => el.selected);
|
|
1018
1195
|
|
|
1196
|
+
// Free-text options exist only to anchor a selected value. Drop any that are no longer selected — e.g.
|
|
1197
|
+
// when a single-select value is replaced or the selection is cleared, the previous custom entry should
|
|
1198
|
+
// disappear entirely rather than linger as an orphaned option.
|
|
1199
|
+
this.querySelectorAll<ZnOption>('[data-free-text]').forEach(option => {
|
|
1200
|
+
if (!option.selected) option.remove();
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1019
1203
|
// In remote-search + multiple mode, track selected items so they persist across search result changes
|
|
1020
1204
|
if (this._isRemoteSearch && this.multiple) {
|
|
1021
1205
|
this._selectedRemoteItems = this.selectedOptions.map(el => ({
|
|
@@ -1408,8 +1592,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1408
1592
|
// Reset the current option
|
|
1409
1593
|
this.setCurrentOption(null);
|
|
1410
1594
|
|
|
1411
|
-
// When
|
|
1412
|
-
if (this.
|
|
1595
|
+
// When typeable, clear the display input to allow typing and show all options
|
|
1596
|
+
if (this._isTypeable) {
|
|
1413
1597
|
this.clearSearch();
|
|
1414
1598
|
this.displayInput.value = '';
|
|
1415
1599
|
this.displayInput.readOnly = false;
|
|
@@ -1429,7 +1613,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1429
1613
|
});
|
|
1430
1614
|
|
|
1431
1615
|
// Focus the search input so the user can start typing immediately
|
|
1432
|
-
if (this.
|
|
1616
|
+
if (this._isTypeable) {
|
|
1433
1617
|
requestAnimationFrame(() => {
|
|
1434
1618
|
this.displayInput.focus({preventScroll: true});
|
|
1435
1619
|
});
|
|
@@ -1449,8 +1633,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1449
1633
|
this.emit('zn-hide');
|
|
1450
1634
|
this.removeOpenListeners();
|
|
1451
1635
|
|
|
1452
|
-
// When
|
|
1453
|
-
if (this.
|
|
1636
|
+
// When typeable, restore the display label and clear the search
|
|
1637
|
+
if (this._isTypeable) {
|
|
1638
|
+
// Commit any pending free-text before clearing it (blur/close-to-commit). Don't refocus —
|
|
1639
|
+
// the dropdown is closing because focus left the control.
|
|
1640
|
+
if (this.freeText && this._searchDisplayValue.trim()) {
|
|
1641
|
+
this.commitFreeText(false);
|
|
1642
|
+
}
|
|
1454
1643
|
this.clearSearch();
|
|
1455
1644
|
this.displayInput.readOnly = true;
|
|
1456
1645
|
// In multiple mode, tags show the selection so keep the input empty
|
|
@@ -1647,7 +1836,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1647
1836
|
'select--medium': this.size === 'medium',
|
|
1648
1837
|
'select--large': this.size === 'large',
|
|
1649
1838
|
'select--has-input-prefix': this.inputPrefix,
|
|
1650
|
-
'select--search': this.
|
|
1839
|
+
'select--search': this._isTypeable
|
|
1651
1840
|
})}
|
|
1652
1841
|
placement=${this.placement}
|
|
1653
1842
|
strategy=${this.hoist ? 'fixed' : 'absolute'}
|
|
@@ -1671,20 +1860,20 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1671
1860
|
part="display-input"
|
|
1672
1861
|
class="select__display-input"
|
|
1673
1862
|
type="text"
|
|
1674
|
-
placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this.
|
|
1863
|
+
placeholder=${this._fetchLoading ? 'Loading...' : this._fetchError ? this._fetchError : (this._isTypeable && this.open ? (this.multiple ? (this.selectedOptions.length ? '' : (this.placeholder || 'Search...')) : (this.displayLabel || this.placeholder || 'Search...')) : this.placeholder)}
|
|
1675
1864
|
.disabled=${isDisabled}
|
|
1676
|
-
.value=${isFetchDisabled ? '' : (this.
|
|
1865
|
+
.value=${isFetchDisabled ? '' : (this._isTypeable && this.open) ? this._searchDisplayValue : (this._isTypeable && this.multiple) ? '' : this.displayLabel}
|
|
1677
1866
|
autocomplete="off"
|
|
1678
1867
|
spellcheck="false"
|
|
1679
1868
|
autocapitalize="off"
|
|
1680
|
-
?readonly=${!(this.
|
|
1869
|
+
?readonly=${!(this._isTypeable && this.open)}
|
|
1681
1870
|
aria-controls="listbox"
|
|
1682
1871
|
aria-expanded=${this.open ? 'true' : 'false'}
|
|
1683
1872
|
aria-haspopup="listbox"
|
|
1684
1873
|
aria-labelledby="label"
|
|
1685
1874
|
aria-disabled=${isDisabled ? 'true' : 'false'}
|
|
1686
1875
|
aria-describedby="help-text"
|
|
1687
|
-
aria-autocomplete=${this.
|
|
1876
|
+
aria-autocomplete=${this._isTypeable ? 'list' : 'none'}
|
|
1688
1877
|
role="combobox"
|
|
1689
1878
|
tabindex="0"
|
|
1690
1879
|
@focus=${this.handleFocus}
|
|
@@ -1747,6 +1936,20 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1747
1936
|
tabindex="-1"
|
|
1748
1937
|
@mouseup=${this.handleOptionClick}
|
|
1749
1938
|
@slotchange=${this.handleDefaultSlotChange}>
|
|
1939
|
+
${this._freeTextAddValue !== null
|
|
1940
|
+
? html`
|
|
1941
|
+
<div
|
|
1942
|
+
part="add-option"
|
|
1943
|
+
class=${classMap({
|
|
1944
|
+
'select__add-option': true,
|
|
1945
|
+
'select__add-option--current': this._addOptionActive
|
|
1946
|
+
})}
|
|
1947
|
+
role="option"
|
|
1948
|
+
aria-selected=${this._addOptionActive ? 'true' : 'false'}
|
|
1949
|
+
@mousedown=${this.handleAddOptionMouseDown}>
|
|
1950
|
+
Add "${this._freeTextAddValue}"
|
|
1951
|
+
</div>`
|
|
1952
|
+
: nothing}
|
|
1750
1953
|
<slot></slot>
|
|
1751
1954
|
${this._renderSelectedRemoteItems()}
|
|
1752
1955
|
${this._fetchedOptions.map(item =>
|
|
@@ -1780,7 +1983,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1780
1983
|
Type to search...
|
|
1781
1984
|
</div>`
|
|
1782
1985
|
: html`
|
|
1783
|
-
<div part="empty-state" class="select__empty-state"
|
|
1986
|
+
<div part="empty-state" class="select__empty-state"
|
|
1987
|
+
?hidden=${!this._noResultsVisible || this._freeTextAddValue !== null}>
|
|
1784
1988
|
No matching options
|
|
1785
1989
|
</div>`
|
|
1786
1990
|
}
|
|
@@ -127,6 +127,15 @@
|
|
|
127
127
|
flex-wrap: wrap;
|
|
128
128
|
gap: var(--zn-spacing-2x-small);
|
|
129
129
|
padding-inline-start: 0;
|
|
130
|
+
/* Allow the control to grow with the wrapped tag rows instead of clipping them, and pack the rows
|
|
131
|
+
from the top with a little vertical padding. align-self stops the popup from stretching the
|
|
132
|
+
combobox to a fixed height, so it sizes to its (wrapped) content. */
|
|
133
|
+
overflow: visible;
|
|
134
|
+
align-content: flex-start;
|
|
135
|
+
align-items: center;
|
|
136
|
+
align-self: flex-start;
|
|
137
|
+
height: auto;
|
|
138
|
+
padding-block: var(--zn-spacing-2x-small);
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
.select__value-input {
|
|
@@ -149,13 +158,21 @@
|
|
|
149
158
|
margin-inline-start: var(--zn-spacing-2x-small);
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
/* The tags are rendered into the shadow (not slotted), so target them as descendants. Only the remove
|
|
162
|
+
"X" is clickable (pointer); the chip body isn't, so give it a default cursor rather than letting it
|
|
163
|
+
inherit the combobox's text cursor in search/free-text mode. */
|
|
164
|
+
.select__tags zn-chip {
|
|
165
|
+
cursor: default;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.select__tags zn-icon[slot="action"] {
|
|
169
|
+
cursor: pointer;
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
.select--disabled .select__tags,
|
|
157
|
-
.select--disabled .select__tags
|
|
158
|
-
|
|
173
|
+
.select--disabled .select__tags zn-chip,
|
|
174
|
+
.select--disabled .select__tags zn-icon[slot="action"] {
|
|
175
|
+
cursor: not-allowed;
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
/* Standard selects */
|
|
@@ -374,7 +391,6 @@
|
|
|
374
391
|
border: solid var(--zn-panel-border-width) var(--zn-panel-border-color);
|
|
375
392
|
border-radius: var(--zn-border-radius-medium);
|
|
376
393
|
margin: var(--zn-spacing-x-small) 0;
|
|
377
|
-
padding-block: var(--zn-spacing-x-small);
|
|
378
394
|
padding-inline: 0;
|
|
379
395
|
overflow: auto;
|
|
380
396
|
overscroll-behavior: none;
|
|
@@ -442,6 +458,21 @@
|
|
|
442
458
|
}
|
|
443
459
|
}
|
|
444
460
|
|
|
461
|
+
/* Free-text "Add" row */
|
|
462
|
+
.select__add-option {
|
|
463
|
+
display: flex;
|
|
464
|
+
align-items: center;
|
|
465
|
+
padding: var(--zn-spacing-x-small) var(--zn-spacing-small);
|
|
466
|
+
color: var(--zn-input-color);
|
|
467
|
+
font-size: var(--zn-font-size-small);
|
|
468
|
+
cursor: pointer;
|
|
469
|
+
|
|
470
|
+
&:hover,
|
|
471
|
+
&.select__add-option--current {
|
|
472
|
+
background-color: var(--zn-color-primary-100);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
445
476
|
/* Search mode */
|
|
446
477
|
.select--search .select__combobox {
|
|
447
478
|
cursor: text;
|