@kubex/zinc 1.1.155 → 1.1.157
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/dist/custom-elements.json +1000 -975
- package/dist/vscode.html-custom-data.json +60 -60
- package/dist/web-types.json +137 -137
- package/dist/zn.d.ts +8 -0
- package/dist/zn.min.js +27 -23
- package/docs/pages/components/select.md +16 -0
- package/package.json +1 -1
- package/src/components/button/button.component.ts +10 -6
- package/src/components/button/button.scss +10 -2
- package/src/components/inline-edit/inline-edit.component.ts +2 -2
- package/src/components/inline-edit/inline-edit.scss +11 -1
- package/src/components/inline-edit/inline-edit.test.ts +24 -0
- package/src/components/reveal/reveal.component.ts +14 -0
- package/src/components/reveal/reveal.scss +10 -1
- package/src/components/reveal/reveal.test.ts +36 -0
- package/src/components/select/select.component.ts +21 -2
- package/src/components/select/select.scss +3 -2
- package/src/components/select/select.test.ts +59 -0
|
@@ -200,15 +200,23 @@
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
.button--with-icon.button--with-content:not(.button--icon-button) {
|
|
203
|
+
// Icon glyphs are drawn inset ~2 units inside their 24-unit box, so the icon
|
|
204
|
+
// is narrower than the space it takes. Pull that slack out on both sides so
|
|
205
|
+
// the gaps read as equal to the text edge opposite.
|
|
206
|
+
--icon-optical-inset: calc(var(--button-icon-size, 20px) / 12);
|
|
207
|
+
--icon-gap: calc(var(--zn-base-px) - var(--icon-optical-inset));
|
|
208
|
+
|
|
203
209
|
&.button--icon-left {
|
|
204
210
|
zn-icon {
|
|
205
|
-
margin-
|
|
211
|
+
margin-left: calc(var(--icon-optical-inset) * -1);
|
|
212
|
+
margin-right: var(--icon-gap);
|
|
206
213
|
}
|
|
207
214
|
}
|
|
208
215
|
|
|
209
216
|
&.button--icon-right {
|
|
210
217
|
zn-icon {
|
|
211
|
-
margin-left: var(--
|
|
218
|
+
margin-left: var(--icon-gap);
|
|
219
|
+
margin-right: calc(var(--icon-optical-inset) * -1);
|
|
212
220
|
}
|
|
213
221
|
}
|
|
214
222
|
}
|
|
@@ -368,8 +368,8 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
|
|
|
368
368
|
const hasHelpTextSlot = this.hasSlotController.test('help-text');
|
|
369
369
|
const hasHelpText = this.helpText ? true : hasHelpTextSlot;
|
|
370
370
|
|
|
371
|
-
//
|
|
372
|
-
if (Object.keys(this.options).length > 0) {
|
|
371
|
+
// An options list that renders empty is still a select — never silently fall back to free text
|
|
372
|
+
if (Object.keys(this.options).length > 0 || this.hasAttribute('options')) {
|
|
373
373
|
this.inputType = 'select';
|
|
374
374
|
}
|
|
375
375
|
|
|
@@ -59,6 +59,9 @@
|
|
|
59
59
|
|
|
60
60
|
&__input::part(form-control-input) {
|
|
61
61
|
margin: 0;
|
|
62
|
+
// Block wrapper round an inline-flex base adds baseline strut, leaving the input
|
|
63
|
+
// ~1.4px taller than the reveal that replaces it
|
|
64
|
+
display: flex;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
&__input {
|
|
@@ -133,7 +136,8 @@
|
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
&__reveal {
|
|
136
|
-
|
|
139
|
+
// Matches the height of the input it stands in for, so entering edit mode doesn't resize the row
|
|
140
|
+
line-height: var(--zn-input-height-medium);
|
|
137
141
|
color: rgb(var(--zn-text));
|
|
138
142
|
|
|
139
143
|
// Fill the row so hovering anywhere over the field reveals the value
|
|
@@ -199,4 +203,10 @@ zn-textarea::part(textarea) {
|
|
|
199
203
|
resize: none;
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
:host([size="small"]) .ai__reveal {
|
|
207
|
+
line-height: var(--zn-input-height-small);
|
|
208
|
+
}
|
|
202
209
|
|
|
210
|
+
:host([size="large"]) .ai__reveal {
|
|
211
|
+
line-height: var(--zn-input-height-large);
|
|
212
|
+
}
|
|
@@ -565,4 +565,28 @@ describe('<zn-inline-edit>', () => {
|
|
|
565
565
|
expect(submits, 'Enter belongs to the open menu, not the form').to.equal(0);
|
|
566
566
|
});
|
|
567
567
|
});
|
|
568
|
+
|
|
569
|
+
// The theme stylesheet isn't loaded in tests, so the sizing tokens the reveal and the input both
|
|
570
|
+
// key off have to be supplied for the comparison to mean anything.
|
|
571
|
+
it('should keep the same height whether it shows a reveal or the input it replaces', async () => {
|
|
572
|
+
const tokens = 'display: contents; --zn-input-height-medium: 36px; --zn-line-height-looser: 3;';
|
|
573
|
+
const plain = await fixture<HTMLDivElement>(html`
|
|
574
|
+
<div style="${tokens}">
|
|
575
|
+
<zn-inline-edit name="email" value="alan.jones@example.com"></zn-inline-edit>
|
|
576
|
+
</div>
|
|
577
|
+
`);
|
|
578
|
+
const masked = await fixture<HTMLDivElement>(html`
|
|
579
|
+
<div style="${tokens}">
|
|
580
|
+
<zn-inline-edit name="email" value="alan.jones@example.com"
|
|
581
|
+
display-value="\u2022\u2022\u2022\u2022\u2022@example.com"></zn-inline-edit>
|
|
582
|
+
</div>
|
|
583
|
+
`);
|
|
584
|
+
const [plainEdit, maskedEdit] = [plain, masked].map(el => el.querySelector<ZnInlineEdit>('zn-inline-edit')!);
|
|
585
|
+
await Promise.all([plainEdit.updateComplete, maskedEdit.updateComplete]);
|
|
586
|
+
|
|
587
|
+
const height = (el: ZnInlineEdit) =>
|
|
588
|
+
el.shadowRoot!.querySelector<HTMLElement>('.ai')!.getBoundingClientRect().height;
|
|
589
|
+
|
|
590
|
+
expect(height(maskedEdit)).to.equal(height(plainEdit));
|
|
591
|
+
});
|
|
568
592
|
});
|
|
@@ -44,6 +44,11 @@ export default class ZnReveal extends ZincElement {
|
|
|
44
44
|
/** Disables click-to-toggle so the value is only revealed on hover. Clicks still bubble to the parent. */
|
|
45
45
|
@property({type: Boolean, attribute: 'no-toggle'}) noToggle: boolean = false;
|
|
46
46
|
|
|
47
|
+
/** Nothing is hidden when the revealed value is what's already on show, so there is nothing to reveal. */
|
|
48
|
+
private get _isMasked(): boolean {
|
|
49
|
+
return this.revealed !== '' && this.revealed !== this.initial;
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
private _isRevealed: boolean = false;
|
|
48
53
|
private _isToggled: boolean = false;
|
|
49
54
|
private _hideTimer?: ReturnType<typeof setTimeout>;
|
|
@@ -102,10 +107,19 @@ export default class ZnReveal extends ZincElement {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
render() {
|
|
110
|
+
if (!this._isMasked) {
|
|
111
|
+
return html`
|
|
112
|
+
<div part="base" class="reveal reveal--static">
|
|
113
|
+
<span class="reveal__text">${this.initial || this.revealed}</span>
|
|
114
|
+
</div>
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
return html`
|
|
106
119
|
<div part="base"
|
|
107
120
|
class=${classMap({
|
|
108
121
|
'reveal': true,
|
|
122
|
+
'reveal--clickable': !this.noToggle,
|
|
109
123
|
'reveal--revealed': this._isRevealed,
|
|
110
124
|
'reveal--toggled': this._isToggled,
|
|
111
125
|
})}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
:host {
|
|
4
4
|
display: inline-block;
|
|
5
|
-
cursor: pointer;
|
|
6
5
|
}
|
|
7
6
|
|
|
8
7
|
|
|
@@ -13,6 +12,16 @@
|
|
|
13
12
|
overflow: hidden;
|
|
14
13
|
}
|
|
15
14
|
|
|
15
|
+
// Only a click that toggles earns the pointer: a static or hover-only reveal does nothing on click.
|
|
16
|
+
.reveal--clickable {
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// cursor inherits, so a static reveal has to opt out of a clickable container's pointer
|
|
21
|
+
.reveal--static {
|
|
22
|
+
cursor: auto;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
.reveal__text {
|
|
17
26
|
grid-area: stack;
|
|
18
27
|
line-height: inherit;
|
|
@@ -21,6 +21,42 @@ describe('<zn-reveal>', () => {
|
|
|
21
21
|
expect(container.classList.contains('reveal--revealed')).to.be.true;
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it('should render static text when the revealed value matches the initial', async () => {
|
|
25
|
+
const el = await fixture<ZnReveal>(html`
|
|
26
|
+
<zn-reveal initial="real" revealed="real"></zn-reveal>
|
|
27
|
+
`);
|
|
28
|
+
const container = el.shadowRoot!.querySelector<HTMLElement>('.reveal')!;
|
|
29
|
+
|
|
30
|
+
expect(container.classList.contains('reveal--static')).to.be.true;
|
|
31
|
+
|
|
32
|
+
container.click();
|
|
33
|
+
await el.updateComplete;
|
|
34
|
+
|
|
35
|
+
expect(container.classList.contains('reveal--revealed')).to.be.false;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should only show a pointer cursor when a click would toggle the value', async () => {
|
|
39
|
+
const cursorOf = (el: ZnReveal) =>
|
|
40
|
+
getComputedStyle(el.shadowRoot!.querySelector<HTMLElement>('.reveal')!).cursor;
|
|
41
|
+
|
|
42
|
+
const toggleable = await fixture<ZnReveal>(html`
|
|
43
|
+
<zn-reveal initial="****" revealed="real"></zn-reveal>
|
|
44
|
+
`);
|
|
45
|
+
// Inside a clickable row (an inline edit, say) the inherited pointer has to be shaken off
|
|
46
|
+
const unmasked = (await fixture<HTMLDivElement>(html`
|
|
47
|
+
<div style="cursor: pointer">
|
|
48
|
+
<zn-reveal initial="real" revealed="real"></zn-reveal>
|
|
49
|
+
</div>
|
|
50
|
+
`)).querySelector<ZnReveal>('zn-reveal')!;
|
|
51
|
+
const hoverOnly = await fixture<ZnReveal>(html`
|
|
52
|
+
<zn-reveal initial="****" revealed="real" no-toggle></zn-reveal>
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
expect(cursorOf(toggleable)).to.equal('pointer');
|
|
56
|
+
expect(cursorOf(unmasked)).to.not.equal('pointer');
|
|
57
|
+
expect(cursorOf(hoverOnly)).to.not.equal('pointer');
|
|
58
|
+
});
|
|
59
|
+
|
|
24
60
|
it('should not toggle on click when no-toggle is set', async () => {
|
|
25
61
|
const el = await fixture<ZnReveal>(html`
|
|
26
62
|
<zn-reveal initial="****" revealed="real" no-toggle></zn-reveal>
|
|
@@ -846,7 +846,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
846
846
|
group.hidden = false;
|
|
847
847
|
});
|
|
848
848
|
this.updateOptGroupSeparators();
|
|
849
|
-
this.
|
|
849
|
+
this.updateEmptyState();
|
|
850
850
|
return;
|
|
851
851
|
}
|
|
852
852
|
|
|
@@ -887,6 +887,19 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
887
887
|
}
|
|
888
888
|
}
|
|
889
889
|
|
|
890
|
+
/**
|
|
891
|
+
* Nothing for the user to pick: either the option list is empty or every option is filtered out.
|
|
892
|
+
* The search paths track this themselves, so this covers opening a dropdown with no query.
|
|
893
|
+
*/
|
|
894
|
+
private updateEmptyState() {
|
|
895
|
+
const options = this.getAllOptions();
|
|
896
|
+
this._noResultsVisible = options.length === 0 || options.every(option => option.hidden);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private get emptyStateText(): string {
|
|
900
|
+
return this._searchQuery ? 'No matching options' : 'No options available';
|
|
901
|
+
}
|
|
902
|
+
|
|
890
903
|
/** Clears the search query and shows all options */
|
|
891
904
|
private clearSearch() {
|
|
892
905
|
this._searchQuery = '';
|
|
@@ -1132,6 +1145,10 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1132
1145
|
this.emit('zn-input');
|
|
1133
1146
|
this.emit('zn-change');
|
|
1134
1147
|
}
|
|
1148
|
+
|
|
1149
|
+
if (this.open) {
|
|
1150
|
+
this.updateEmptyState();
|
|
1151
|
+
}
|
|
1135
1152
|
}
|
|
1136
1153
|
|
|
1137
1154
|
private handleTagRemove(event: ZnRemoveEvent, option: ZnOption) {
|
|
@@ -1653,6 +1670,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1653
1670
|
this.displayInput.readOnly = false;
|
|
1654
1671
|
}
|
|
1655
1672
|
|
|
1673
|
+
this.updateEmptyState();
|
|
1674
|
+
|
|
1656
1675
|
// Show
|
|
1657
1676
|
this.emit('zn-show');
|
|
1658
1677
|
this.addOpenListeners();
|
|
@@ -2041,7 +2060,7 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
2041
2060
|
: html`
|
|
2042
2061
|
<div part="empty-state" class="select__empty-state"
|
|
2043
2062
|
?hidden=${!this._noResultsVisible || this._freeTextAddValue !== null}>
|
|
2044
|
-
|
|
2063
|
+
${this.emptyStateText}
|
|
2045
2064
|
</div>`
|
|
2046
2065
|
}
|
|
2047
2066
|
</div>
|
|
@@ -444,7 +444,7 @@
|
|
|
444
444
|
align-items: center;
|
|
445
445
|
justify-content: center;
|
|
446
446
|
gap: var(--zn-spacing-small);
|
|
447
|
-
padding: var(--zn-spacing-
|
|
447
|
+
padding: var(--zn-spacing-small) var(--zn-spacing-medium);
|
|
448
448
|
color: var(--zn-color-neutral-500);
|
|
449
449
|
font-size: var(--zn-font-size-medium);
|
|
450
450
|
}
|
|
@@ -463,7 +463,8 @@
|
|
|
463
463
|
/* Empty state (no search results) */
|
|
464
464
|
.select__empty-state {
|
|
465
465
|
display: block;
|
|
466
|
-
|
|
466
|
+
// Keeps the one-line popup close to the height of a single option row
|
|
467
|
+
padding: var(--zn-spacing-small) var(--zn-spacing-medium);
|
|
467
468
|
text-align: center;
|
|
468
469
|
color: var(--zn-color-neutral-500);
|
|
469
470
|
font-size: var(--zn-font-size-medium);
|
|
@@ -978,3 +978,62 @@ describe('<zn-select> requires', () => {
|
|
|
978
978
|
expect(c.disabled, 'both filled').to.be.false;
|
|
979
979
|
});
|
|
980
980
|
});
|
|
981
|
+
|
|
982
|
+
describe('<zn-select> empty state', () => {
|
|
983
|
+
const emptyState = (el: ZnSelect) =>
|
|
984
|
+
el.shadowRoot!.querySelector<HTMLElement>('[part="empty-state"]')!;
|
|
985
|
+
|
|
986
|
+
it('should tell the user there is nothing to pick when opened with no options', async () => {
|
|
987
|
+
const el = await fixture<ZnSelect>(html`
|
|
988
|
+
<zn-select placeholder="Select a reason"></zn-select>`);
|
|
989
|
+
await el.show();
|
|
990
|
+
await el.updateComplete;
|
|
991
|
+
|
|
992
|
+
expect(emptyState(el).hidden).to.be.false;
|
|
993
|
+
expect(emptyState(el).textContent!.trim()).to.equal('No options available');
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it('should stay hidden when the select has options', async () => {
|
|
997
|
+
const el = await fixture<ZnSelect>(html`
|
|
998
|
+
<zn-select>
|
|
999
|
+
<zn-option value="apple">Apple</zn-option>
|
|
1000
|
+
</zn-select>`);
|
|
1001
|
+
await el.show();
|
|
1002
|
+
await el.updateComplete;
|
|
1003
|
+
|
|
1004
|
+
expect(emptyState(el).hidden).to.be.true;
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it('should distinguish a filtered-out list from an empty one', async () => {
|
|
1008
|
+
const el = await fixture<ZnSelect>(html`
|
|
1009
|
+
<zn-select search>
|
|
1010
|
+
<zn-option value="apple">Apple</zn-option>
|
|
1011
|
+
</zn-select>`);
|
|
1012
|
+
await el.show();
|
|
1013
|
+
await el.updateComplete;
|
|
1014
|
+
|
|
1015
|
+
const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
|
|
1016
|
+
displayInput.value = 'zzz';
|
|
1017
|
+
displayInput.dispatchEvent(new Event('input'));
|
|
1018
|
+
await el.updateComplete;
|
|
1019
|
+
|
|
1020
|
+
expect(emptyState(el).hidden).to.be.false;
|
|
1021
|
+
expect(emptyState(el).textContent!.trim()).to.equal('No matching options');
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it('should drop the empty state once options arrive while open', async () => {
|
|
1025
|
+
const el = await fixture<ZnSelect>(html`
|
|
1026
|
+
<zn-select></zn-select>`);
|
|
1027
|
+
await el.show();
|
|
1028
|
+
await el.updateComplete;
|
|
1029
|
+
expect(emptyState(el).hidden, 'empty before options').to.be.false;
|
|
1030
|
+
|
|
1031
|
+
const option = document.createElement('zn-option');
|
|
1032
|
+
option.value = 'apple';
|
|
1033
|
+
option.textContent = 'Apple';
|
|
1034
|
+
el.appendChild(option);
|
|
1035
|
+
await waitUntil(() => emptyState(el).hidden);
|
|
1036
|
+
|
|
1037
|
+
expect(emptyState(el).hidden).to.be.true;
|
|
1038
|
+
});
|
|
1039
|
+
});
|