Skip to content

Commit c961584

Browse files
committed
feat(core): introduce tag layout options and patterns
- Added `tagLayout` property to manage inline tag rendering, supporting values 'hidden' and 'wrap'. - Deprecated the `notags` attribute, recommending the use of `tag-layout="hidden"` instead. - Updated styles for handling multiple overflow states with new tag layout options. - Enhanced examples and tests to demonstrate new functionality and ensure accessibility compliance. Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent 56d8415 commit c961584

7 files changed

Lines changed: 245 additions & 15 deletions

File tree

projects/core/src/combobox/combobox.css

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@
4848
pointer-events: none;
4949
}
5050

51-
:host(:state(multiple-overflow)) .tags {
51+
:host(:state(multiple-overflow):not([tag-layout='wrap'])) .tags {
5252
opacity: 0;
5353
pointer-events: none;
5454
position: absolute;
5555
}
5656

57-
:host(:state(multiple-overflow)) .tags-label {
57+
:host(:state(multiple-overflow):not([tag-layout='wrap'])) .tags-label {
5858
display: flex;
5959
}
6060

@@ -156,3 +156,17 @@ nve-menu-item[part='create-option'] {
156156
text-transform: uppercase;
157157
}
158158
}
159+
160+
:host([tag-layout='wrap']:state(multiple-overflow)) {
161+
[input] {
162+
min-height: var(--height);
163+
height: fit-content;
164+
flex-direction: column;
165+
width: 100%;
166+
}
167+
168+
.tags {
169+
padding-block-start: var(--nve-ref-size-50);
170+
flex-wrap: wrap;
171+
}
172+
}

projects/core/src/combobox/combobox.examples.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,42 @@ export const MultiSelect = () => {
238238
`
239239
};
240240

241+
/**
242+
* @summary Multi-select that reorders selected options before unselected options after the combobox closes. Use when long filter lists need selected values to stay easy to review.
243+
*/
244+
export const SelectedFirst = () => {
245+
return html`
246+
<nve-combobox id="combobox-selected-first" style="width: 500px; --scroll-height: 220px">
247+
<label>label</label>
248+
<input type="search">
249+
<select multiple>
250+
<option value="status"></option>
251+
<option value="priority"></option>
252+
<option value="date"></option>
253+
<option value="session"></option>
254+
<option value="configuration"></option>
255+
<option value="contains"></option>
256+
<option value="includes"></option>
257+
<option value="user"></option>
258+
<option value="progress"></option>
259+
</select>
260+
<nve-control-message>message</nve-control-message>
261+
</nve-combobox>
262+
<script type="module">
263+
const combobox = document.querySelector('#combobox-selected-first');
264+
const select = combobox.querySelector('select');
265+
const optionOrder = new Map([...select.options].map((option, index) => [option, index]));
266+
combobox.addEventListener('open', () => {
267+
const selectedOrder = new Map([...select.selectedOptions].map((option, index) => [option, index]));
268+
Array.from(select.options).sort((a, b) =>
269+
Number(b.selected) - Number(a.selected) ||
270+
a.selected && b.selected ? selectedOrder.get(a) - selectedOrder.get(b) : optionOrder.get(a) - optionOrder.get(b)
271+
).forEach(option => select.append(option));
272+
});
273+
</script>
274+
`
275+
};
276+
241277
/**
242278
* @summary Combobox with an empty initial value using a disabled placeholder option. Use when no default selection exists and the user must make an explicit choice.
243279
*/
@@ -324,6 +360,28 @@ export const Overflow = () => {
324360
`
325361
};
326362

363+
/**
364+
* @summary Combobox with option to wrap tags when the parent container is too narrow. Input will span below the tags.
365+
* @tags test-case
366+
*/
367+
export const OverflowWrap = () => {
368+
return html`
369+
<nve-combobox tag-layout="wrap" style="width: 400px">
370+
<label>label</label>
371+
<input type="search">
372+
<select multiple>
373+
<option selected value="status"></option>
374+
<option selected value="priority"></option>
375+
<option selected value="date"></option>
376+
<option selected value="session"></option>
377+
<option selected value="configuration"></option>
378+
<option selected value="contains"></option>
379+
</select>
380+
<nve-control-message>message</nve-control-message>
381+
</nve-combobox>
382+
`
383+
};
384+
327385
/**
328386
* @summary Combobox handling of long option text in constrained width containers.
329387
* @tags test-case
@@ -461,8 +519,8 @@ export const DisabledOptions = () => {
461519
*/
462520
export const NoTags = () => {
463521
return html`
464-
<form id="notags" nve-layout="column gap:lg align:stretch">
465-
<nve-combobox notags>
522+
<form id="hidden-tags" nve-layout="column gap:lg align:stretch">
523+
<nve-combobox tag-layout="hidden">
466524
<label>label</label>
467525
<input type="search">
468526
<select multiple>
@@ -478,7 +536,7 @@ export const NoTags = () => {
478536
</div>
479537
</form>
480538
<script type="module">
481-
const form = document.querySelector('#notags');
539+
const form = document.querySelector('#hidden-tags');
482540
const select = form.querySelector('select');
483541
const tags = form.querySelector('#tags');
484542
updateTags();

projects/core/src/combobox/combobox.test.axe.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ describe(Combobox.metadata.tag, () => {
7474
removeFixture(multiFixture);
7575
});
7676

77-
it('should pass axe check with notags multi select', async () => {
78-
const notagsFixture = await createFixture(html`
79-
<nve-combobox notags>
77+
it('should pass axe check with hidden tag layout multi select', async () => {
78+
const hiddenTagsFixture = await createFixture(html`
79+
<nve-combobox tag-layout="hidden">
8080
<label>combobox</label>
8181
<input type="search" />
8282
<select multiple>
@@ -86,9 +86,9 @@ describe(Combobox.metadata.tag, () => {
8686
</select>
8787
</nve-combobox>
8888
`);
89-
await elementIsStable(notagsFixture.querySelector(Combobox.metadata.tag));
89+
await elementIsStable(hiddenTagsFixture.querySelector(Combobox.metadata.tag));
9090
const results = await runAxe([Combobox.metadata.tag]);
9191
expect(results.violations.length).toBe(0);
92-
removeFixture(notagsFixture);
92+
removeFixture(hiddenTagsFixture);
9393
});
9494
});

projects/core/src/combobox/combobox.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,36 @@ describe(`${Combobox.metadata.tag}: multi select`, () => {
725725
expect(select.selectedOptions.length).toBe(1);
726726
});
727727

728+
it('should not open dropdown when tags are pressed without wrap layout', async () => {
729+
const dropdown = element.shadowRoot.querySelector<Dropdown>(Dropdown.metadata.tag);
730+
const tags = element.shadowRoot.querySelector<HTMLElement>('.tags');
731+
732+
expect(dropdown.matches(':popover-open')).toBe(false);
733+
tags.dispatchEvent(new Event('pointerup', { bubbles: true }));
734+
735+
await new Promise(resolve => setTimeout(resolve, 0));
736+
await elementIsStable(element);
737+
738+
expect(dropdown.matches(':popover-open')).toBe(false);
739+
});
740+
741+
it('should focus input and open dropdown when wrap layout tags are pressed', async () => {
742+
element.tagLayout = 'wrap';
743+
await elementIsStable(element);
744+
745+
const dropdown = element.shadowRoot.querySelector<Dropdown>(Dropdown.metadata.tag);
746+
const tags = element.shadowRoot.querySelector<HTMLElement>('.tags');
747+
748+
expect(dropdown.matches(':popover-open')).toBe(false);
749+
tags.dispatchEvent(new Event('pointerup', { bubbles: true }));
750+
751+
await new Promise(resolve => setTimeout(resolve, 0));
752+
await elementIsStable(element);
753+
754+
expect(document.activeElement).toBe(input);
755+
expect(dropdown.matches(':popover-open')).toBe(true);
756+
});
757+
728758
it('should hide tags and display label when multiple is used and tags overflow container', async () => {
729759
expect(element.matches(':state(multiple-overflow)')).toBe(false);
730760
element.style.setProperty('--width', '100px');
@@ -740,13 +770,40 @@ describe(`${Combobox.metadata.tag}: multi select`, () => {
740770
expect(element.matches(':state(multiple-overflow)')).toBe(true);
741771
});
742772

743-
it('should not render inline tags when notags is used', async () => {
773+
it('should keep tags visible when wrap layout is used with overflow state', async () => {
774+
element.tagLayout = 'wrap';
775+
element._internals.states.add('multiple-overflow');
776+
await elementIsStable(element);
777+
778+
const tags = element.shadowRoot.querySelector<HTMLElement>('.tags');
779+
const tagsLabel = element.shadowRoot.querySelector<HTMLElement>('.tags-label');
780+
781+
expect(element.matches(':state(multiple-overflow)')).toBe(true);
782+
expect(getComputedStyle(tags).opacity).toBe('1');
783+
expect(getComputedStyle(tags).position).toBe('static');
784+
expect(getComputedStyle(tags).flexWrap).toBe('wrap');
785+
expect(getComputedStyle(tagsLabel).display).toBe('none');
786+
});
787+
788+
it('should not render inline tags when hidden tag layout is used', async () => {
789+
element.tagLayout = 'hidden';
790+
select.multiple = true;
791+
select.options[0].selected = true;
792+
select.options[1].selected = true;
793+
select.options[2].selected = true;
794+
await elementIsStable(element);
795+
expect(element.shadowRoot.querySelectorAll(Tag.metadata.tag).length).toBe(0);
796+
});
797+
798+
it('should support deprecated notags alias', async () => {
744799
element.notags = true;
745800
select.multiple = true;
746801
select.options[0].selected = true;
747802
select.options[1].selected = true;
748803
select.options[2].selected = true;
804+
749805
await elementIsStable(element);
806+
750807
expect(element.shadowRoot.querySelectorAll(Tag.metadata.tag).length).toBe(0);
751808
});
752809

@@ -1379,6 +1436,66 @@ describe(`${Combobox.metadata.tag}: notags property reflection`, () => {
13791436
});
13801437
});
13811438

1439+
describe(`${Combobox.metadata.tag}: tag-layout property reflection`, () => {
1440+
let fixture: HTMLElement;
1441+
let element: Combobox;
1442+
1443+
beforeEach(async () => {
1444+
fixture = await createFixture(html`
1445+
<nve-combobox>
1446+
<label>combobox</label>
1447+
<input type="search" />
1448+
<select multiple>
1449+
<option value="1">Option 1</option>
1450+
</select>
1451+
</nve-combobox>
1452+
`);
1453+
element = fixture.querySelector(Combobox.metadata.tag);
1454+
await elementIsStable(element);
1455+
});
1456+
1457+
afterEach(() => {
1458+
removeFixture(fixture);
1459+
});
1460+
1461+
it('should have undefined tagLayout by default', async () => {
1462+
expect(element.tagLayout).toBe(undefined);
1463+
expect(element.hasAttribute('tag-layout')).toBe(false);
1464+
});
1465+
1466+
it('should reflect tag-layout attribute when set via attribute', async () => {
1467+
element.setAttribute('tag-layout', 'wrap');
1468+
await elementIsStable(element);
1469+
1470+
expect(element.tagLayout).toBe('wrap');
1471+
expect(element.getAttribute('tag-layout')).toBe('wrap');
1472+
});
1473+
1474+
it('should reflect hidden tag-layout attribute when set via attribute', async () => {
1475+
element.setAttribute('tag-layout', 'hidden');
1476+
await elementIsStable(element);
1477+
1478+
expect(element.tagLayout).toBe('hidden');
1479+
expect(element.getAttribute('tag-layout')).toBe('hidden');
1480+
});
1481+
1482+
it('should reflect tag-layout attribute when set via property', async () => {
1483+
element.tagLayout = 'wrap';
1484+
await elementIsStable(element);
1485+
1486+
expect(element.tagLayout).toBe('wrap');
1487+
expect(element.getAttribute('tag-layout')).toBe('wrap');
1488+
});
1489+
1490+
it('should reflect hidden tag-layout attribute when set via property', async () => {
1491+
element.tagLayout = 'hidden';
1492+
await elementIsStable(element);
1493+
1494+
expect(element.tagLayout).toBe('hidden');
1495+
expect(element.getAttribute('tag-layout')).toBe('hidden');
1496+
});
1497+
});
1498+
13821499
describe(`${Combobox.metadata.tag}: disabled input`, () => {
13831500
let fixture: HTMLElement;
13841501
let element: Combobox;

projects/core/src/combobox/combobox.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,16 @@ export class Combobox extends Control implements ContainerElement {
6565
*/
6666
@property({ type: String, reflect: true }) container: 'flat';
6767

68-
/** Disable rendering of inline tags for many-item select */
68+
/**
69+
* @deprecated Use `tagLayout = 'hidden'` or `tag-layout="hidden"` instead.
70+
*
71+
* Disable rendering of inline tags for many-item select
72+
*/
6973
@property({ type: Boolean, reflect: true }) notags: boolean;
7074

75+
/** Manage inline tag rendering for many-item select */
76+
@property({ type: String, reflect: true, attribute: 'tag-layout' }) tagLayout: 'hidden' | 'wrap';
77+
7178
/** Enable creation of new options when the input value doesn't match any existing option. Dispatches a `create` event with `{ value }` detail. */
7279
@property({ type: Boolean, reflect: true, attribute: 'behavior-create' }) behaviorCreate: boolean;
7380

@@ -168,7 +175,7 @@ export class Combobox extends Control implements ContainerElement {
168175
protected _associateDatalist = false;
169176

170177
protected get prefixContent() {
171-
return this.#select?.multiple && !this.notags
178+
return this.#select?.multiple && !this.#tagLayoutIsHidden
172179
? html`
173180
<div class="tags-label" aria-hidden="true">${this.#select.selectedOptions.length} ${this.i18n.selected}</div>
174181
<div class="tags">
@@ -180,6 +187,10 @@ export class Combobox extends Control implements ContainerElement {
180187
: html`<slot name="prefix-icon"></slot>`;
181188
}
182189

190+
get #tagLayoutIsHidden() {
191+
return this.notags || this.tagLayout === 'hidden';
192+
}
193+
183194
get #largeOptionsList() {
184195
return (this.#datalist?.options?.length ?? 0) > 50;
185196
}
@@ -422,6 +433,12 @@ export class Combobox extends Control implements ContainerElement {
422433
}
423434

424435
#setupOpenKeyEvents() {
436+
this.#tags?.addEventListener('pointerup', () => {
437+
if (this.tagLayout !== 'wrap') return;
438+
this.input.focus();
439+
setTimeout(() => this.#openListBox(), 0);
440+
});
441+
425442
this.input.addEventListener('pointerdown', () => {
426443
this.#openListBox();
427444
});
@@ -544,7 +561,7 @@ export class Combobox extends Control implements ContainerElement {
544561
}
545562

546563
#setupOverflowListener() {
547-
if (this.#select?.multiple && !this.notags) {
564+
if (this.#select?.multiple && !this.#tagLayoutIsHidden) {
548565
if (this.#select.selectedOptions.length > 1) {
549566
// only calculate initial overflow if many tags exist
550567
this.#updateMultipleOverflow(this.#tags!.getBoundingClientRect().width);

projects/site/src/docs/about/migration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@ Before native HTML popovers, popovers required `behaviorTrigger` or `behavior-tr
121121
122122
{% endbefore-after %}
123123
124+
### Combobox No Tags <nve-badge status="warning">deprecated</nve-badge>
125+
126+
Elements deprecates the `notags` attribute. Use `tag-layout="hidden"` instead so all tag layout modes use the same attribute.
127+
128+
{% before-after %}
129+
130+
```html
131+
<nve-combobox notags></nve-combobox>
132+
```
133+
134+
```html
135+
<nve-combobox tag-layout="hidden"></nve-combobox>
136+
```
137+
138+
{% endbefore-after %}
139+
124140
### Layout Full <nve-badge status="warning">deprecated</nve-badge>
125141
126142
The `grow` property now uses `full` instead to avoid confusion with flexbox grow behavior.

0 commit comments

Comments
 (0)