@kubex/zinc 1.1.108 → 1.1.110

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.
@@ -47,8 +47,10 @@ Use the `disabled` attribute to disable all options within a group. Disabled gro
47
47
 
48
48
  When used inside a `<zn-select>` with the `search` attribute, group headers automatically hide when all of their child options are filtered out by the search query.
49
49
 
50
+ The `label` is matched by the search too: a query matching a group label keeps every option in that group visible, so searching `europe` returns the whole European group even though no option label contains the word. Group-label and option-label matches combine, so `japan` still narrows to the single option within Asia.
51
+
50
52
  ```html:preview
51
- <zn-select label="Select a country" search placeholder="Type to search...">
53
+ <zn-select label="Select a country" search placeholder="Try 'europe' or 'japan'...">
52
54
  <zn-opt-group label="North America">
53
55
  <zn-option value="us">United States</zn-option>
54
56
  <zn-option value="ca">Canada</zn-option>
@@ -148,7 +148,7 @@ Use the `clearable` attribute to make the control clearable. The clear button on
148
148
 
149
149
  ### Search
150
150
 
151
- Use the `search` attribute to allow users to type into the select to filter the list of options. When the dropdown opens, the input becomes editable and options are filtered as you type using case-insensitive substring matching against labels and values.
151
+ Use the `search` attribute to allow users to type into the select to filter the list of options. When the dropdown opens, the input becomes editable and options are filtered as you type using case-insensitive substring matching against labels and values. Group labels are matched too — see [Grouping with Search](#grouping-with-search).
152
152
 
153
153
  ```html:preview
154
154
  <zn-select label="Search for a country" search placeholder="Type to search...">
@@ -642,8 +642,10 @@ Use `<zn-opt-group>` to group options under labeled headers, similar to `<optgro
642
642
 
643
643
  When `search` is enabled, group headers automatically hide when all of their child options are filtered out.
644
644
 
645
+ Group labels are searchable as well: typing text that matches a `<zn-opt-group>` label keeps every option in that group visible, so searching `europe` returns the whole European group even though no option label contains the word.
646
+
645
647
  ```html:preview
646
- <zn-select label="Select a country" search placeholder="Type to search...">
648
+ <zn-select label="Select a country" search placeholder="Try 'europe' or 'japan'...">
647
649
  <zn-opt-group label="North America">
648
650
  <zn-option value="us">United States</zn-option>
649
651
  <zn-option value="ca">Canada</zn-option>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.108",
3
+ "version": "1.1.110",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -1,4 +1,4 @@
1
- import {type CSSResultGroup, html, unsafeCSS} from 'lit';
1
+ import {type CSSResultGroup, html, nothing, unsafeCSS} from 'lit';
2
2
  import {property} from 'lit/decorators.js';
3
3
  import {watch} from '../../internal/watch';
4
4
  import ZincElement from '../../internal/zinc-element';
@@ -50,9 +50,9 @@ export default class ZnOptGroup extends ZincElement {
50
50
  render() {
51
51
  return html`
52
52
  <div part="base" class="opt-group">
53
- <div part="label" class="opt-group__label" aria-hidden="true">
54
- ${this.label}
55
- </div>
53
+ ${this.label
54
+ ? html`<div part="label" class="opt-group__label" aria-hidden="true">${this.label}</div>`
55
+ : nothing}
56
56
  <div class="opt-group__options" role="none">
57
57
  <slot @slotchange=${this.handleSlotChange}></slot>
58
58
  </div>
@@ -8,30 +8,29 @@
8
8
  display: none !important;
9
9
  }
10
10
 
11
+ :host(:not(:first-child)) {
12
+ border-block-start: solid var(--zn-panel-border-width) var(--zn-panel-border-color);
13
+ margin-block-start: var(--zn-spacing-2x-small);
14
+ }
15
+
11
16
  .opt-group__label {
12
17
  font-family: var(--zn-font-sans);
13
18
  font-size: var(--zn-font-size-x-small);
14
19
  font-weight: var(--zn-font-weight-semibold);
15
20
  line-height: var(--zn-line-height-normal);
16
- letter-spacing: var(--zn-letter-spacing-normal);
17
- color: var(--zn-color-neutral-500);
21
+ letter-spacing: var(--zn-letter-spacing-loose);
18
22
  text-transform: uppercase;
19
- padding-block: var(--zn-spacing-2x-small);
20
- padding-inline: var(--zn-spacing-large);
23
+ color: var(--zn-color-neutral-600);
24
+ padding-block: var(--zn-spacing-x-small) var(--zn-spacing-2x-small);
25
+ padding-inline: var(--zn-spacing-small);
21
26
  user-select: none;
22
27
  -webkit-user-select: none;
23
28
  }
24
29
 
25
- :host(:not(:first-of-type)) .opt-group__label {
26
- border-block-start: solid var(--zn-panel-border-width) var(--zn-panel-border-color);
27
- margin-block-start: var(--zn-spacing-x-small);
28
- padding-block-start: var(--zn-spacing-small);
29
- }
30
-
31
30
  .opt-group__options {
32
31
  display: block;
33
32
  }
34
33
 
35
- :host([disabled]) {
34
+ :host([disabled]) .opt-group__label {
36
35
  opacity: 0.5;
37
36
  }
@@ -268,6 +268,7 @@ export default class ZnPopup extends ZincElement {
268
268
  if (!this.anchorEl) {
269
269
  return;
270
270
  }
271
+ this.cleanup?.();
271
272
  this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
272
273
  this.reposition();
273
274
  });
@@ -406,7 +407,9 @@ export default class ZnPopup extends ZincElement {
406
407
 
407
408
  if (!this.popup?.isConnected) return;
408
409
 
409
- this.popup.showPopover();
410
+ if (!this.popup.matches(':popover-open')) {
411
+ this.popup.showPopover();
412
+ }
410
413
 
411
414
  if (this.arrow) {
412
415
  const arrowX = middlewareData.arrow!.x;
@@ -537,7 +540,7 @@ export default class ZnPopup extends ZincElement {
537
540
  <div
538
541
  id="popup"
539
542
  part="popup"
540
- popover
543
+ popover="manual"
541
544
  class="${classMap({
542
545
  popup: true,
543
546
  'popup--active': this.active,
@@ -832,10 +832,21 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
832
832
  return;
833
833
  }
834
834
 
835
+ // A group whose label matches keeps its whole set of options, so searching "citrus" surfaces the group.
836
+ const matchedGroups = new Set(
837
+ this.getAllOptGroups().filter(group => group.label.toLowerCase().includes(searchQuery))
838
+ );
839
+
835
840
  allOptions.forEach(option => {
836
841
  // Don't un-hide selected options that are only in the DOM for value preservation
837
842
  if (option.selected && option.hidden) return;
838
843
 
844
+ const group = option.closest<ZnOptGroup>('zn-opt-group');
845
+ if (group && matchedGroups.has(group)) {
846
+ option.hidden = false;
847
+ return;
848
+ }
849
+
839
850
  const label = option.getTextLabel().toLowerCase();
840
851
  const value = option.value.toLowerCase();
841
852
  const matches = label.includes(searchQuery) || value.includes(searchQuery);
@@ -407,9 +407,11 @@
407
407
  font-size: var(--zn-font-size-x-small);
408
408
  font-weight: var(--zn-font-weight-semibold);
409
409
  line-height: var(--zn-line-height-normal);
410
- letter-spacing: var(--zn-letter-spacing-normal);
411
- padding-block: var(--zn-spacing-2x-small);
412
- padding-inline: var(--zn-spacing-2x-large);
410
+ letter-spacing: var(--zn-letter-spacing-loose);
411
+ text-transform: uppercase;
412
+ color: var(--zn-color-neutral-600);
413
+ padding-block: var(--zn-spacing-x-small) var(--zn-spacing-2x-small);
414
+ padding-inline: var(--zn-spacing-small);
413
415
  user-select: none;
414
416
  -webkit-user-select: none;
415
417
  }
@@ -1,5 +1,5 @@
1
1
  import '../../../dist/zn.min.js';
2
- import {expect, fixture, html} from '@open-wc/testing';
2
+ import {expect, fixture, html, waitUntil} from '@open-wc/testing';
3
3
  import type ZnOption from '../option/option.component';
4
4
  import type ZnSelect from './select.component';
5
5
 
@@ -163,6 +163,65 @@ describe('<zn-select>', () => {
163
163
  });
164
164
  });
165
165
 
166
+ describe('search across opt-group labels', () => {
167
+ let el: ZnSelect;
168
+
169
+ const type = async (text: string) => {
170
+ const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
171
+ displayInput.value = text;
172
+ displayInput.dispatchEvent(new Event('input'));
173
+ await el.updateComplete;
174
+ };
175
+
176
+ const visibleValues = () =>
177
+ [...el.querySelectorAll<ZnOption>('zn-option')].filter(o => !o.hidden).map(o => o.value);
178
+
179
+ beforeEach(async () => {
180
+ el = await fixture<ZnSelect>(html`
181
+ <zn-select search>
182
+ <zn-opt-group label="Citrus">
183
+ <zn-option value="orange">Orange</zn-option>
184
+ <zn-option value="lemon">Lemon</zn-option>
185
+ </zn-opt-group>
186
+ <zn-opt-group label="Berries">
187
+ <zn-option value="strawberry">Strawberry</zn-option>
188
+ <zn-option value="blueberry">Blueberry</zn-option>
189
+ </zn-opt-group>
190
+ <zn-option value="citrus-punch">Citrus Punch</zn-option>
191
+ </zn-select>
192
+ `);
193
+ await el.updateComplete;
194
+ await el.show();
195
+ await el.updateComplete;
196
+ });
197
+
198
+ it('keeps every option in a group whose label matches, alongside option-label matches', async () => {
199
+ await type('citrus');
200
+
201
+ expect(visibleValues()).to.deep.equal(['orange', 'lemon', 'citrus-punch']);
202
+ });
203
+
204
+ it('matches group labels case-insensitively', async () => {
205
+ await type('BERR');
206
+
207
+ expect(visibleValues()).to.deep.equal(['strawberry', 'blueberry']);
208
+ });
209
+
210
+ it('keeps the matching group visible and hides the others', async () => {
211
+ await type('citrus');
212
+
213
+ const groups = el.querySelectorAll('zn-opt-group');
214
+ expect(groups[0].hidden, 'Citrus group').to.be.false;
215
+ expect(groups[1].hidden, 'Berries group').to.be.true;
216
+ });
217
+
218
+ it('still matches option labels inside a non-matching group', async () => {
219
+ await type('lemon');
220
+
221
+ expect(visibleValues()).to.deep.equal(['lemon']);
222
+ });
223
+ });
224
+
166
225
  describe('Escape key', () => {
167
226
  it('blurs the display input when dropdown is closed and Escape is pressed', async () => {
168
227
  const el = await fixture<ZnSelect>(html`<zn-select></zn-select>`);
@@ -765,4 +824,44 @@ describe('<zn-select>', () => {
765
824
  expect(getComputedStyle(addRow).fontSize).to.equal('11px');
766
825
  });
767
826
  });
827
+
828
+ it('does not flicker the open listbox when a second select is opened', async () => {
829
+ const wrapper = await fixture(html`
830
+ <div>
831
+ <zn-select id="first">
832
+ <zn-option value="apple">Apple</zn-option>
833
+ <zn-option value="banana">Banana</zn-option>
834
+ </zn-select>
835
+ <zn-select id="second">
836
+ <zn-option value="cherry">Cherry</zn-option>
837
+ <zn-option value="date">Date</zn-option>
838
+ </zn-select>
839
+ </div>
840
+ `);
841
+
842
+ const first = wrapper.querySelector<ZnSelect>('#first')!;
843
+ const second = wrapper.querySelector<ZnSelect>('#second')!;
844
+ await Promise.all([first.updateComplete, second.updateComplete]);
845
+
846
+ const popoverOf = (el: ZnSelect) =>
847
+ el.shadowRoot!.querySelector('zn-popup')!.shadowRoot!.querySelector<HTMLElement>('#popup')!;
848
+
849
+ const states: string[] = [];
850
+ popoverOf(first).addEventListener('beforetoggle', (event: Event) =>
851
+ states.push((event as ToggleEvent).newState));
852
+
853
+ await first.show();
854
+ states.length = 0;
855
+
856
+ // Mimic a click on the second select: the document mousedown closes the first, the combobox
857
+ // mousedown opens the second
858
+ document.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, composed: true}));
859
+ second.shadowRoot!.querySelector('.select__combobox')!
860
+ .dispatchEvent(new MouseEvent('mousedown', {bubbles: true, composed: true}));
861
+ await second.updateComplete;
862
+ await waitUntil(() => !first.open && states.includes('closed'));
863
+
864
+ expect(states).to.deep.equal(['closed']);
865
+ expect(popoverOf(second).matches(':popover-open')).to.be.true;
866
+ });
768
867
  });