From 97728bcd4060192d9378eee4ec397d8cb92a9581 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 07:37:05 +0200 Subject: [PATCH 01/32] fix(combo): keep the inner events of the search input and the list inside The igcChange of the search input no longer reaches listeners on the combo. The igcStateChange and igcDataRequest of the inner list stop at the list. Closes #2530. --- CHANGELOG.md | 3 ++ src/components/combo/combo.spec.ts | 74 +++++++++++++++++++++++++++++- src/components/combo/combo.ts | 3 ++ src/components/combo/spec.md | 23 ++++++---- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e33b484..fc4b212d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The Markdown renderer no longer uses `marked-shiki`, so it is no longer a peer dependency. You can remove it from your dependencies. - #### Chip - The `end` slot now renders after the `suffix` slot, at the end of the chip, as the `start` slot renders before the `prefix` slot. Before, it rendered before the `suffix` slot. +- #### Combo + - The `igcStateChange` and `igcDataRequest` events of the list inside the combo no longer leave the combo. A listener on the combo for these events no longer gets them. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Highlight - Setting `searchText` or `caseSensitive` to its current value no longer runs the search again, so the active match stays. Before, a framework that sets each property on each render, such as React, moved the active match back to the first match on each render. After the content changes, call `search()` to search again. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) - #### Library @@ -155,6 +157,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The native input no longer has `aria-disabled`. Its native `disabled` state is sufficient. [#2432](https://github.com/IgniteUI/igniteui-webcomponents/pull/2432) - The removal of the `locale` attribute no longer causes a `TypeError`. [#2475](https://github.com/IgniteUI/igniteui-webcomponents/pull/2475) - Setting `singleSelect` or `disableFiltering` to the value that the combo already has no longer clears the search text and the filter. Before, a framework that sets each property on each render, such as React, cleared the search text after each key, and the list did not filter. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) + - The `igcChange` of the search input no longer leaves the combo. Before, a listener on the combo got a second `igcChange` with the search text as its `detail` when the search input lost the focus. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Date picker - `stepUp()` and `stepDown()` now update `value` and the form value at once, also while the input has the focus. Before, only the input showed the new date. - A `value` that you set from code, also with `stepUp()` or `stepDown()`, no longer emits `igcChange` on a later blur, in any mode. diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index b580da732..735660e15 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -5,8 +5,9 @@ import { html, nextFrame, } from '@open-wc/testing'; +import { resetMouse, sendKeys, sendMouse } from '@web/test-runner-commands'; import { setCurrentI18n } from 'igniteui-i18n-core'; -import { spy } from 'sinon'; +import { match, spy } from 'sinon'; import { altKey, @@ -44,6 +45,7 @@ import { ValidityHelpers, } from '#internals/testing/validity-helpers.spec.js'; import { firstOf } from '#internals/utils/arrays.js'; +import { getCenterPoint } from '#internals/utils/dom.js'; import { configureTheme } from '#theming/config.js'; import type IgcInputComponent from '../input/input.js'; import type IgcVirtualScrollComponent from '../virtualization/virtualization.js'; @@ -1879,6 +1881,76 @@ describe('Combo', () => { expect(combo.value).to.be.empty; expect(input.value).to.equal(''); }); + + describe('Inner events', () => { + async function clickAt(x: number, y: number): Promise { + await sendMouse({ + type: 'click', + position: [Math.round(x), Math.round(y)], + }); + } + + async function clickOn(element: Element): Promise { + const { x, y } = getCenterPoint(element); + await clickAt(x, y); + } + + // The name, the action that blurs the search input, and the number of + // selection changes that the action makes. + const blurActions: [string, () => Promise, number][] = [ + ['Tab', () => sendKeys({ press: tabKey }), 0], + ['Escape', () => sendKeys({ press: escapeKey }), 0], + ['Arrow Down', () => sendKeys({ press: arrowDown }), 0], + [ + 'a click outside', + () => clickAt(window.innerWidth - 5, window.innerHeight - 5), + 0, + ], + ['an item click', () => clickOn(items(combo)[0]), 1], + ]; + + afterEach(async () => { + await resetMouse(); + }); + + for (const [name, blur, changes] of blurActions) { + it(`keeps the igcChange of the search input inside on ${name}`, async () => { + const searchSpy = spy(); + const comboSpy = spy(); + + searchInput.addEventListener('igcChange', searchSpy); + combo.addEventListener('igcChange', comboSpy); + + await openComboPopover(combo); + expect(isFocused(searchInput)).to.be.true; + + await sendKeys({ type: 'o' }); + await comboStable(combo); + await blur(); + await comboStable(combo); + + expect(searchSpy).calledOnceWith(match({ detail: 'o' })); + expect(comboSpy.callCount).to.equal(changes); + expect(comboSpy).not.calledWithMatch({ detail: match.string }); + }); + } + + it('keeps the events of the virtual scroll inside', async () => { + const listSpy = spy(); + const comboSpy = spy(); + + for (const name of ['igcStateChange', 'igcDataRequest']) { + list.addEventListener(name, listSpy); + combo.addEventListener(name, comboSpy); + } + + await openComboPopover(combo); + + expect(listSpy).calledWithMatch({ type: 'igcStateChange' }); + expect(listSpy).calledWithMatch({ type: 'igcDataRequest' }); + expect(comboSpy).not.called; + }); + }); }); describe('Scroll strategy', () => { diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index f3a6700f6..d6a91b2cf 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -1116,6 +1116,7 @@ export default class IgcComboComponent< placeholder=${this.placeholderSearch} exportparts="input: search-input" @igcInput=${this._handleSearchInput} + @igcChange=${stopPropagation} > ${this._renderEmptyTemplate()} diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index b4fffba5e..cb2cdf5de 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -58,6 +58,7 @@ | 7 | 2026-10-12 | The group header template gets the group `key`; its `item` is deprecated | | 8 | 2026-10-21 | API descriptions aligned with the JSDoc. | | 9 | 2026-10-26 | Setting `singleSelect` or `disableFiltering` again keeps the search text | +| 10 | 2026-10-26 | The inner `igcChange`, `igcStateChange` and `igcDataRequest` events stay inside | ## Overview @@ -471,43 +472,45 @@ helpers from [`src/internals/testing`](../../internals/testing). The groups belo 10. Opening, closing and the keyboard navigation of the list, including Arrow Down at the end of the matches. 11. The clear action and `disableClear`. 12. `igcChange` carries the new value, the items and the change type, and a cancel keeps the selection. +13. The `igcChange` of the search input and the `igcStateChange` and `igcDataRequest` events of the list do not + leave the combo. ### ARIA -13. The anchor, the list, the items and the group headers expose their roles and states, and the selection summary +14. The anchor, the list, the items and the group headers expose their roles and states, and the selection summary is announced. ### Scroll strategy tests -14. `hide`, `scroll` and `close` behave as specified while an ancestor scrolls. +15. `hide`, `scroll` and `close` behave as specified while an ancestor scrolls. ### Theming -15. The list keeps its stylesheet in the shadow root when the theme changes. +16. The list keeps its stylesheet in the shadow root when the theme changes. ### Form integration tests -16. Is form associated, submits its selection, and is reset with the form, including the single-selection input. -17. Reflects the disabled state of an ancestor `fieldset`. -18. Fulfils the required and custom constraints. +17. Is form associated, submits its selection, and is reset with the form, including the single-selection input. +18. Reflects the disabled state of an ancestor `fieldset`. +19. Fulfils the required and custom constraints. The focus after the form checks and the `invalid` event rules come from the form-associated mixin. The [input suite](../input/spec.md#form-validity-checks) covers them. ### defaultValue -19. Form integration in single selection mode, in multiple selection mode, and with data that arrives late. -20. Validation against the default value. +20. Form integration in single selection mode, in multiple selection mode, and with data that arrives late. +21. Validation against the default value. ### Validation message slots Generated by `runValidationContainerTests`. -21. The `value-missing`, `custom-error` and `invalid` slots render for their matching state. +22. The `value-missing`, `custom-error` and `invalid` slots render for their matching state. ### Host ARIA -22. The shared host description suite: the host `aria-describedby` describes the native control after the helper +23. The shared host description suite: the host `aria-describedby` describes the native control after the helper text, and follows a change and a removal. ## Assumptions and limitations From 6fd035faa43423dcfe49f38a386435dc34869020 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 07:38:26 +0200 Subject: [PATCH 02/32] fix(combo): move Tab through the focusable content of the list slots While the list is open, Tab and Shift+Tab move through the filtering input, the header content, the list, the empty content and the footer content, and close the list past either end. getFocusableElements in internals/utils/dom walks the flat tree, and the focus trap uses it. Closes #2529. Refs #2533. --- CHANGELOG.md | 1 + src/components/combo/combo.spec.ts | 216 ++++++++++++++++++ src/components/combo/combo.ts | 1 + .../combo/controllers/navigation.ts | 77 +++++-- src/components/combo/spec.md | 14 +- src/components/focus-trap/focus-trap.ts | 108 +-------- src/components/focus-trap/spec.md | 8 +- src/internals/utils/dom.ts | 55 ++++- 8 files changed, 356 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4b212d0..ff029c55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The native input no longer has `aria-disabled`. Its native `disabled` state is sufficient. [#2432](https://github.com/IgniteUI/igniteui-webcomponents/pull/2432) - The removal of the `locale` attribute no longer causes a `TypeError`. [#2475](https://github.com/IgniteUI/igniteui-webcomponents/pull/2475) - Setting `singleSelect` or `disableFiltering` to the value that the combo already has no longer clears the search text and the filter. Before, a framework that sets each property on each render, such as React, cleared the search text after each key, and the list did not filter. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) + - Tab and Shift+Tab now move through the focusable content of the `header`, `empty` and `footer` slots while the list is open. Before, Tab closed the list, so a keyboard user could not reach a button in these slots. Tab from the last footer element, and Shift+Tab from the search input, close the list and move the focus on, as before. [#2529](https://github.com/IgniteUI/igniteui-webcomponents/issues/2529) - The `igcChange` of the search input no longer leaves the combo. Before, a listener on the combo got a second `igcChange` with the search text as its `detail` when the search input lost the focus. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Date picker - `stepUp()` and `stepDown()` now update `value` and the form value at once, also while the input has the focus. Before, only the input showed the new date. diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 735660e15..308008242 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -47,6 +47,7 @@ import { import { firstOf } from '#internals/utils/arrays.js'; import { getCenterPoint } from '#internals/utils/dom.js'; import { configureTheme } from '#theming/config.js'; +import IgcButtonComponent from '../button/button.js'; import type IgcInputComponent from '../input/input.js'; import type IgcVirtualScrollComponent from '../virtualization/virtualization.js'; import type IgcComboHeaderComponent from './combo-header.js'; @@ -1953,6 +1954,221 @@ describe('Combo', () => { }); }); + describe('Header and footer slots', () => { + let header: HTMLButtonElement; + let cancel: IgcButtonComponent; + let apply: IgcButtonComponent; + let buttonBefore: HTMLButtonElement; + let buttonAfter: HTMLButtonElement; + + const tab = () => sendKeys({ press: tabKey }); + const shiftTab = () => sendKeys({ press: `${shiftKey}+${tabKey}` }); + + function expectFocus(element: Element, open = true): void { + expect(isFocused(element)).to.be.true; + expect(combo.open).to.equal(open); + } + + async function openInSingleSelection(): Promise { + await combo.hide(); + combo.singleSelect = true; + input.focus(); + await openComboPopover(combo); + } + + before(() => { + defineComponents(IgcButtonComponent); + }); + + beforeEach(async () => { + const container = await fixture(html` +
+ + + +
+ Cancel + Apply +
+
+ +
+ `); + + combo = container.querySelector>( + IgcComboComponent.tagName + )!; + header = container.querySelector('[slot="header"]')!; + cancel = container.querySelector('#cancel')!; + apply = container.querySelector('#apply')!; + buttonBefore = container.querySelector('#before')!; + buttonAfter = container.querySelector('#after')!; + input = combo.renderRoot.querySelector('igc-input#target')!; + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + + await openComboPopover(combo); + }); + + it('is accessible.', async () => { + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + await expect(combo).to.be.accessible(axeReflectedRelationsOptions); + }); + + it('moves with Tab from the search input through the header, the list and the footer', async () => { + expectFocus(searchInput); + + await tab(); + expectFocus(header); + + await tab(); + expectFocus(list); + + await tab(); + expectFocus(cancel); + + await tab(); + expectFocus(apply); + }); + + it('moves back with Shift+Tab from the footer to the search input', async () => { + apply.focus(); + + await shiftTab(); + expectFocus(cancel); + + await shiftTab(); + expectFocus(list); + + await shiftTab(); + expectFocus(header); + + await shiftTab(); + expectFocus(searchInput); + }); + + it('closes the list and moves the focus on with Tab from the last footer control', async () => { + apply.focus(); + + await tab(); + await elementUpdated(combo); + + expectFocus(buttonAfter, false); + }); + + it('closes the list and moves the focus back with Shift+Tab from the search input', async () => { + await shiftTab(); + await elementUpdated(combo); + + expectFocus(buttonBefore, false); + }); + + it('starts from the main input in single selection', async () => { + await openInSingleSelection(); + expectFocus(input); + + await tab(); + expectFocus(header); + + await shiftTab(); + expectFocus(input); + }); + + it('selects the active item when Tab leaves the list in single selection', async () => { + await openInSingleSelection(); + + simulateKeyboard(input, arrowDown); + await elementUpdated(combo); + apply.focus(); + + await tab(); + await elementUpdated(combo); + + expect(combo.value).to.eql(['BG01']); + expectFocus(buttonAfter, false); + }); + + it('reaches the content of the empty slot when the list is empty', async () => { + const create = document.createElement('button'); + create.slot = 'empty'; + create.textContent = 'Create'; + combo.append(create); + combo.data = []; + await elementUpdated(combo); + + await tab(); + expectFocus(header); + + await tab(); + expectFocus(create); + + await tab(); + expectFocus(cancel); + }); + + it('moves out of a stop inside another stop', async () => { + const wrapper = document.createElement('div'); + const inner = document.createElement('button'); + wrapper.slot = 'header'; + wrapper.tabIndex = 0; + wrapper.append(inner); + combo.prepend(wrapper); + await elementUpdated(combo); + + inner.focus(); + + await tab(); + expectFocus(header); + + await shiftTab(); + expectFocus(inner); + + await shiftTab(); + expectFocus(wrapper); + }); + + it('does not stop on a host that delegates the focus', async () => { + cancel.tabIndex = 0; + apply.focus(); + + await shiftTab(); + expectFocus(cancel); + + await shiftTab(); + expectFocus(list); + }); + + it('skips a control with `visibility: hidden`', async () => { + header.style.visibility = 'hidden'; + list.focus(); + + await shiftTab(); + expectFocus(searchInput); + }); + + it('leaves a Tab that a slotted control handles to that control', async () => { + header.addEventListener('keydown', (event) => event.preventDefault()); + header.focus(); + + await tab(); + expectFocus(header); + }); + + it('closes the list with Tab from the search input when the slots have no focusable content', async () => { + header.remove(); + cancel.disabled = true; + apply.hidden = true; + await elementUpdated(combo); + + await tab(); + await elementUpdated(combo); + + expectFocus(buttonAfter, false); + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index d6a91b2cf..59b400cc7 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -208,6 +208,7 @@ export default class IgcComboComponent< input: this._inputRef, search: this._searchRef, list: this._listRef, + slotted: (slot) => this._slots.getAssignedElements(slot), interactions: { show: () => this._show(true), hide: () => this._hide(true), diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index d802ae23a..70cff85bf 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -13,6 +13,8 @@ import { spaceBar, tabKey, } from '#internals/controllers/key-bindings.js'; +import { isEmpty } from '#internals/utils/arrays.js'; +import { getFocusableElements } from '#internals/utils/dom.js'; import type IgcInputComponent from '../../input/input.js'; import type IgcVirtualScrollComponent from '../../virtualization/virtualization.js'; import type { ComboHost } from '../types.js'; @@ -22,6 +24,8 @@ type ComboNavigationConfig = { input: Ref; search: Ref; list: Ref; + /** The elements assigned to a slot of the list. */ + slotted: (slot: 'header' | 'empty' | 'footer') => Element[]; interactions: { show: () => Promise; hide: () => Promise; @@ -42,8 +46,10 @@ export class ComboNavigationController { return this._config.input.value; } + /** The search input while it shows. */ private get _searchInput(): IgcInputComponent | undefined { - return this._config.search.value; + const search = this._config.search.value; + return search?.checkVisibility() ? search : undefined; } private get _list(): IgcVirtualScrollComponent | undefined { @@ -137,19 +143,39 @@ export class ComboNavigationController { } }; - private _onTab = async ({ shiftKey }: KeyboardEvent): Promise => { - if (this._host.open) { - if (shiftKey) { - // Focus the main input before the default Shift+Tab action. - this._host.focus(); - } + private _onTab = async (event: KeyboardEvent): Promise => { + // A slotted control that handles Tab prevents the default action. + if (!this._host.open || event.defaultPrevented) { + return; + } - if (this._host.singleSelect && this.active > -1) { - this._config.interactions.select(this.active); - } + const stops = this._tabStops(); + // The innermost stop on the path, for a stop inside another stop. + const focused = event + .composedPath() + .find((node) => stops.includes(node as HTMLElement)); + const current = stops.indexOf(focused as HTMLElement); + const next = + current > -1 ? stops[current + (event.shiftKey ? -1 : 1)] : undefined; + + next?.focus(); + + // A stop that does not take the focus leaves Tab to the browser. + if (next?.matches(':focus')) { + event.preventDefault(); + return; + } - await this._config.interactions.hide(); + if (event.shiftKey) { + // Focus the main input before the default Shift+Tab action. + this._host.focus(); } + + if (this._host.singleSelect && this.active > -1) { + this._config.interactions.select(this.active); + } + + await this._config.interactions.hide(); }; private _onEscape = async (): Promise => { @@ -190,9 +216,8 @@ export class ComboNavigationController { private _onArrowUp = (): void => { if (this.active === this._firstItem) { // Focus the search input when it shows. Otherwise, close the list. - this._searchInput?.checkVisibility() - ? this._searchInput?.focus() - : this._onEscape(); + const search = this._searchInput; + search ? search.focus() : this._onEscape(); return; } @@ -209,6 +234,30 @@ export class ComboNavigationController { //#region Internal helper methods + /** + * The Tab order of the open list: the filtering input, the header content, + * the list, the empty content and the footer content. Empty without + * focusable slot content, so Tab closes the list. + */ + private _tabStops(): HTMLElement[] { + const [header, empty, footer] = ( + ['header', 'empty', 'footer'] as const + ).map((slot) => + Iterator.from(this._config.slotted(slot)) + .flatMap(getFocusableElements) + .toArray() + ); + + if ([header, empty, footer].every(isEmpty)) { + return []; + } + + const input = this._searchInput ?? this._input; + const list = this._list?.checkVisibility() ? [this._list] : []; + + return [input!, ...header, ...list, ...empty, ...footer]; + } + /** * The only write path for the active index. The host mirrors it as * `_activeIndex`, so the update request carries the old value. diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index cb2cdf5de..5feff9bc3 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -59,6 +59,7 @@ | 8 | 2026-10-21 | API descriptions aligned with the JSDoc. | | 9 | 2026-10-26 | Setting `singleSelect` or `disableFiltering` again keeps the search text | | 10 | 2026-10-26 | The inner `igcChange`, `igcStateChange` and `igcDataRequest` events stay inside | +| 11 | 2026-10-26 | Tab moves through the focusable content of the header, empty and footer slots | ## Overview @@ -294,7 +295,7 @@ While the list of options **is** visible: | Enter | Selects the active option in single selection mode, and closes the list in all modes. | | Home / End | Activates the first or the last option. | | Escape | Closes the list. The combo stays focused. | -| Tab / Shift + Tab | Closes the list and moves focus on. | +| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | Typing while the component is focused filters the list down to the closest matches. @@ -513,6 +514,17 @@ Generated by `runValidationContainerTests`. 23. The shared host description suite: the host `aria-describedby` describes the native control after the helper text, and follows a change and a removal. +### Header and footer slots + +24. Tab moves from the search input to the header content, the list and the footer content, and Shift+Tab moves + back. With an empty list, Tab reaches the content of the `empty` slot. The accessibility audit passes with + focusable slot content. +25. Tab from the last footer control and Shift+Tab from the search input close the list and move the focus out of + the combo. In single selection, the main input starts the order, and Tab selects the active item when it leaves. +26. Without focusable slot content, Tab from the search input closes the list and moves the focus on. +27. Tab moves out of a stop inside another stop, does not stop on a host that delegates the focus or on a control + with `visibility: hidden`, and leaves a Tab that a slotted control handles to that control. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. diff --git a/src/components/focus-trap/focus-trap.ts b/src/components/focus-trap/focus-trap.ts index c285cfb23..3e577a692 100644 --- a/src/components/focus-trap/focus-trap.ts +++ b/src/components/focus-trap/focus-trap.ts @@ -2,8 +2,8 @@ import { css, html, LitElement, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { registerComponent } from '#internals/definitions/register.js'; +import { getFocusableElements } from '#internals/utils/dom.js'; import { addSafeEventListener } from '#internals/utils/events.js'; -import { isDefined } from '#internals/utils/types.js'; /* blazorSuppress */ /** @@ -44,7 +44,7 @@ export default class IgcFocusTrapComponent extends LitElement { /** An array of focusable elements including elements in Shadow roots */ public get focusableElements() { - return Array.from(getFocusableElements(this)); + return Iterator.from(this.children).flatMap(getFocusableElements).toArray(); } constructor() { @@ -90,107 +90,3 @@ declare global { 'igc-focus-trap': IgcFocusTrapComponent; } } - -const defaultSelectors = [ - '[tabindex]', - 'a[href]', - 'button', - 'input', - 'select', - 'textarea', -]; - -function isHiddenOrDisabled(node: HTMLElement) { - return ( - node.hasAttribute('hidden') || - node.hasAttribute('inert') || - node.hasAttribute('disabled') || - (node.hasAttribute('aria-hidden') && - node.getAttribute('aria-hidden') !== 'false') - ); -} - -function isContentEditable(node: HTMLElement) { - return ( - node.hasAttribute('contenteditable') && - node.getAttribute('contenteditable') !== 'false' - ); -} - -function isFocusable(node: HTMLElement) { - if (isHiddenOrDisabled(node)) { - return false; - } - - if (isContentEditable(node)) { - return true; - } - - if (node.tabIndex === -1) { - return false; - } - - return defaultSelectors.some((selector) => node.matches(selector)); -} - -/** Tree walker filter. Rejects a hidden, disabled or cached node and its subtree. */ -function shouldSkipElements(node: Node, cache?: WeakSet) { - const element = node as HTMLElement; - - return isHiddenOrDisabled(element) || cache?.has(element) - ? NodeFilter.FILTER_REJECT - : NodeFilter.FILTER_ACCEPT; -} - -/** Returns the slotted elements and the parent element containing the slot */ -function getSlottedElements(node: HTMLElement) { - const slot = node as HTMLSlotElement; - const elements = slot.assignedElements() as HTMLElement[]; - return { elements, parent: elements.at(0)?.parentElement }; -} - -function* getFocusableElements( - root: HTMLElement | ShadowRoot, - cache = new WeakSet() -): Generator { - if (!isDefined(globalThis.document)) { - return; - } - - let node: T; - - const visitor = document.createTreeWalker( - root, - NodeFilter.SHOW_ELEMENT, - (node) => shouldSkipElements(node, cache) - ); - - while ((node = visitor.nextNode() as T)) { - if (cache.has(node)) { - continue; - } - - if (node.shadowRoot) { - yield* getFocusableElements(node.shadowRoot, cache); - continue; - } - - if (node.tagName === 'SLOT') { - const { elements, parent } = getSlottedElements(node); - - // The slotted elements share a parent, so walk it once. - if (parent) { - yield* getFocusableElements(parent, cache); - } - - for (const element of elements) { - cache.add(element); - } - continue; - } - - if (isFocusable(node)) { - yield node; - } - } -} diff --git a/src/components/focus-trap/spec.md b/src/components/focus-trap/spec.md index 6ad0f9e1a..e4bc02810 100644 --- a/src/components/focus-trap/spec.md +++ b/src/components/focus-trap/spec.md @@ -39,6 +39,7 @@ | Version | Date | Notes | | ------: | ---------- | --------------------- | | 1 | 2026-09-21 | Initial specification | +| 2 | 2026-10-26 | Flat-tree order; only rendered elements | ## Overview @@ -112,8 +113,11 @@ The content is projected into the default slot. The trap is active unless `disab #### Collecting the focusable elements -`focusableElements` returns the focusable content in document order, descending into shadow roots, so a projected -custom element contributes what it renders. Content-editable elements are part of the set as well. +`focusableElements` returns the focusable content in flat-tree order: it descends into shadow roots and goes from a +slot to its assigned elements, so a projected custom element contributes what it renders. Content-editable elements +are part of the set as well. A hidden, inert, disabled or `aria-hidden` element leaves out its subtree. An element +that is not rendered or has `visibility: hidden` is left out, and so is a host that delegates the focus: its inner +controls are in the set instead. #### Moving the focus diff --git a/src/internals/utils/dom.ts b/src/internals/utils/dom.ts index 495c1daa3..0cbf1d849 100644 --- a/src/internals/utils/dom.ts +++ b/src/internals/utils/dom.ts @@ -254,7 +254,7 @@ export function scrollIntoView( } const TABBABLE_SELECTOR = - 'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'; + 'a[href], button, input, textarea, select, details, summary, iframe, audio[controls], video[controls], [tabindex]:not([tabindex="-1"])'; /** * Returns the focused element, also inside open shadow roots. The search starts in `root`, or in @@ -280,6 +280,59 @@ export function getTabbables(root: HTMLElement): HTMLElement[] { : [...descendants]; } +/** Whether `element` and its subtree are out of the focus order. */ +function isHiddenOrDisabled(element: Element): boolean { + return ( + element.hasAttribute('hidden') || + element.hasAttribute('inert') || + element.hasAttribute('disabled') || + (element.getAttribute('aria-hidden') ?? 'false') !== 'false' + ); +} + +function isFocusable(element: HTMLElement): boolean { + const editable = + (element.getAttribute('contenteditable') ?? 'false') !== 'false'; + + return ( + (editable || + (element.matches(TABBABLE_SELECTOR) && + element.tabIndex > -1 && + !element.matches(':disabled'))) && + element.checkVisibility({ visibilityProperty: true }) + ); +} + +/** + * Yields `root` and its descendants that Tab reaches, in flat-tree order. + * Unlike {@link getTabbables}, it enters open shadow roots and goes from a + * slot to its assigned elements. + */ +export function* getFocusableElements(root: Element): Generator { + if (isHiddenOrDisabled(root)) { + return; + } + + // A host that delegates the focus is not a stop of its own. + if ( + root instanceof HTMLElement && + !root.shadowRoot?.delegatesFocus && + isFocusable(root) + ) { + yield root; + } + + const children = + root.shadowRoot?.children ?? + (root instanceof HTMLSlotElement + ? root.assignedElements({ flatten: true }) + : root.children); + + for (const child of children) { + yield* getFocusableElements(child); + } +} + /** * Applies the given CSS declarations to the inline style of the element. * From 32501bd9dfe2966dbdf67f8ef27a5046275451e0 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 07:40:01 +0200 Subject: [PATCH 03/32] feat(combo): report the trigger that closes the list igcClosing and igcClosed carry { trigger }. ToggleController.hide takes a detail for one call. Refs #2533. --- CHANGELOG.md | 2 + public-api.json | 2 + src/components/combo/combo.spec.ts | 163 +++++++++++++++++- src/components/combo/combo.ts | 36 +++- .../combo/controllers/navigation.ts | 26 +-- src/components/combo/spec.md | 31 +++- src/components/combo/types.ts | 23 ++- src/index.ts | 2 + src/internals/bases/combo-box.ts | 4 +- src/internals/controllers/toggle.ts | 24 ++- 10 files changed, 279 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff029c55f..7dbbc4353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Chat - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - #### Combo + - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input - `directory` lets the user select a folder instead of files. The selection holds all files in the folder and its subfolders, and `webkitRelativePath` gives the path of each file from the folder. The control shows the folder name. In Chromium, a multipart form submission sends the relative path as the file name. [#2481](https://github.com/IgniteUI/igniteui-webcomponents/pull/2481) @@ -61,6 +62,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Chip - The `end` slot now renders after the `suffix` slot, at the end of the chip, as the `start` slot renders before the `prefix` slot. Before, it rendered before the `suffix` slot. - #### Combo + - `igcClosing` and `igcClosed` are typed `CustomEvent`, not `CustomEvent`. In TypeScript, change a handler that declares its parameter as `CustomEvent`. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The `igcStateChange` and `igcDataRequest` events of the list inside the combo no longer leave the combo. A listener on the combo for these events no longer gets them. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Highlight - Setting `searchText` or `caseSensitive` to its current value no longer runs the search again, so the active match stays. Before, a framework that sets each property on each render, such as React, moved the active match back to the first match on each render. After the content changes, call `search()` to search again. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) diff --git a/public-api.json b/public-api.json index d3209a244..41f65eee8 100644 --- a/public-api.json +++ b/public-api.json @@ -23,6 +23,7 @@ "ColorFormat", "ColorPickerMode", "ColorPickerResourceStringsEN", + "ComboCloseTrigger", "ComboGroupHeaderContext", "ComboGroupHeaderTemplate", "ComboItemTemplate", @@ -104,6 +105,7 @@ "IgcColorPickerComponent", "IgcColorPickerComponentEventMap", "IgcComboChangeEventArgs", + "IgcComboClosingEventArgs", "IgcComboComponent", "IgcComboComponentEventMap", "IgcDatePickerComponent", diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 308008242..1d2c47e1b 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -7,7 +7,7 @@ import { } from '@open-wc/testing'; import { resetMouse, sendKeys, sendMouse } from '@web/test-runner-commands'; import { setCurrentI18n } from 'igniteui-i18n-core'; -import { match, spy } from 'sinon'; +import { match, type SinonSpy, spy } from 'sinon'; import { altKey, @@ -53,7 +53,7 @@ import type IgcVirtualScrollComponent from '../virtualization/virtualization.js' import type IgcComboHeaderComponent from './combo-header.js'; import type IgcComboItemComponent from './combo-item.js'; import IgcComboComponent from './combo.js'; -import type { ComboItemTemplate } from './types.js'; +import type { ComboCloseTrigger, ComboItemTemplate } from './types.js'; describe('Combo', () => { type City = { @@ -482,6 +482,7 @@ describe('Combo', () => { expect(eventSpy).calledOnceWithExactly('igcClosing', { cancelable: true, + detail: { trigger: 'toggle' }, }); expect(eventSpy).not.calledWith('igcClosed'); }); @@ -2169,6 +2170,148 @@ describe('Combo', () => { }); }); + describe('Close trigger', () => { + let eventSpy: SinonSpy; + + function expectTrigger(trigger: ComboCloseTrigger): void { + expect(combo.open).to.be.false; + expect(eventSpy.withArgs('igcClosing')).calledOnceWith('igcClosing', { + cancelable: true, + detail: { trigger }, + }); + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger }, + }); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + + input = combo.renderRoot.querySelector('igc-input#target')!; + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + + await openComboPopover(combo); + }); + + const actions: [ComboCloseTrigger, string, () => unknown][] = [ + [ + 'enter', + 'Enter in the list', + () => { + simulateKeyboard(list, arrowDown); + simulateKeyboard(list, enterKey); + }, + ], + ['escape', 'Escape in the list', () => simulateKeyboard(list, escapeKey)], + [ + 'enter', + 'Enter in the main input', + () => { + simulateKeyboard(list, arrowDown); + simulateKeyboard(input, enterKey); + }, + ], + ['tab', 'Tab in the list', () => simulateKeyboard(list, tabKey)], + [ + 'tab', + 'Shift+Tab in the list', + () => simulateKeyboard(list, [shiftKey, tabKey]), + ], + [ + 'arrow-up', + 'Arrow Up in the main input', + () => simulateKeyboard(input, arrowUp), + ], + [ + 'arrow-up', + 'Arrow Up in the search input', + () => simulateKeyboard(searchInput, arrowUp), + ], + ['outside-click', 'a click outside', () => simulateClick(document.body)], + [ + 'arrow-up', + 'Arrow Up at the first item without the search input', + async () => { + combo.disableFiltering = true; + await elementUpdated(combo); + simulateKeyboard(list, arrowDown); + simulateKeyboard(list, arrowUp); + }, + ], + ['toggle', 'a click on the input', () => simulateClick(input)], + [ + 'selection', + 'an item click in single selection', + async () => { + combo.singleSelect = true; + await comboStable(combo); + simulateClick(items(combo)[0]); + }, + ], + ]; + + for (const [trigger, name, action] of actions) { + it(`reports \`${trigger}\` on ${name}`, async () => { + await action(); + await elementUpdated(combo); + + expectTrigger(trigger); + }); + } + + it('keeps the trigger of a close in progress', async () => { + simulateKeyboard(list, escapeKey); + simulateClick(document.body); + await elementUpdated(combo); + + expectTrigger('escape'); + expect(eventSpy).not.calledWithMatch('igcClosing', { + detail: { trigger: 'outside-click' }, + }); + }); + + it('reports the trigger of a later close after a canceled one', async () => { + combo.addEventListener('igcClosing', (event) => event.preventDefault(), { + once: true, + }); + + simulateKeyboard(list, escapeKey); + await elementUpdated(combo); + expect(combo.open).to.be.true; + + eventSpy.resetHistory(); + simulateClick(document.body); + await elementUpdated(combo); + + expectTrigger('outside-click'); + }); + + it('keeps the detail of the open events empty', async () => { + await combo.hide(); + simulateClick(input); + await elementUpdated(combo); + + expect(combo.open).to.be.true; + expect(eventSpy).calledWith('igcOpening', { cancelable: true }); + expect(eventSpy).calledWith('igcOpened', {}); + }); + + it('emits no events on `hide()`', async () => { + await combo.hide(); + + expect(combo.open).to.be.false; + expect(eventSpy).not.called; + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; @@ -2206,6 +2349,22 @@ describe('Combo', () => { expect(eventSpy.firstCall).calledWith('igcClosing'); expect(eventSpy.lastCall).calledWith('igcClosed'); }); + + it('reports the `scroll` trigger with the `close` strategy', async () => { + const eventSpy = spy(combo, 'emitEvent'); + + combo.scrollStrategy = 'close'; + await openComboPopover(combo); + await simulateScroll(container, { top: 200 }); + + expect(eventSpy).calledWith('igcClosing', { + cancelable: true, + detail: { trigger: 'scroll' }, + }); + expect(eventSpy).calledWith('igcClosed', { + detail: { trigger: 'scroll' }, + }); + }); }); describe('Theming', () => { diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 59b400cc7..f6cc2f617 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -41,6 +41,7 @@ import { ComboNavigationController } from './controllers/navigation.js'; import { styles } from './themes/combo.base.css.js'; import { styles as shared } from './themes/shared/combo.common.css.js'; import type { + ComboCloseTrigger, ComboGroupHeaderTemplate, ComboItemTemplate, ComboRecord, @@ -49,6 +50,7 @@ import type { FilteringOptions, GroupingDirection, IgcComboChangeEventArgs, + IgcComboClosingEventArgs, IgcComboComponentEventMap, Item, Keys, @@ -96,8 +98,8 @@ const i18n = { * @fires igcChange - Emitted before the user changes the selection. Cancel the event to keep the selection. * @fires igcOpening - Emitted before the user opens the list of options. Cancelable. * @fires igcOpened - Emitted after the list of options opens on a user action. - * @fires igcClosing - Emitted before the user closes the list of options. Cancelable. - * @fires igcClosed - Emitted after the list of options closes on a user action. + * @fires igcClosing - Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. + * @fires igcClosed - Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. * * @csspart label - The encapsulated text label of the combo. * @csspart input - The main input field of the combo. @@ -211,7 +213,7 @@ export default class IgcComboComponent< slotted: (slot) => this._slots.getAssignedElements(slot), interactions: { show: () => this._show(true), - hide: () => this._hide(true), + hide: (trigger) => this._close(trigger), toggleSelection: (index: number) => this._selectAt(index, true), select: (index: number) => this._selectAt(index, false), clearSelection: () => this._clearSelection(), @@ -619,9 +621,12 @@ export default class IgcComboComponent< return canOpen; } - protected override async _hide(emitEvent = false): Promise { + protected override async _hide( + emitEvent = false, + detail?: IgcComboClosingEventArgs + ): Promise { const [canClose, _] = await Promise.all([ - super._hide(emitEvent), + super._hide(emitEvent, detail), this.updateComplete, ]); @@ -632,6 +637,11 @@ export default class IgcComboComponent< return canClose; } + /** Closes the list on a user action, which the close events report. */ + private _close(trigger: ComboCloseTrigger): Promise { + return this._hide(true, { trigger }); + } + private _setSingleSelectionDisplayValue(value: string): void { if (this.singleSelect && this._inputRef.value) { this._inputRef.value.value = value; @@ -909,12 +919,24 @@ export default class IgcComboComponent< if (this.singleSelect) { this._inputRef.value?.focus(); - await this._hide(true); + await this._close('selection'); } else { this._searchRef.value?.focus(); } } + protected override _handleAnchorClick(): void { + this.open ? this._close('toggle') : this._show(true); + } + + protected override _handleClosing(): void { + this._close('outside-click'); + } + + private _handleScrollClose(): void { + this._close('scroll'); + } + private _handleClearIconClick(event: PointerEvent): void { event.stopPropagation(); this._clearSelection(); @@ -1189,7 +1211,7 @@ export default class IgcComboComponent< flip same-width .scrollStrategy=${this.scrollStrategy} - @igcPopoverScrollClose=${this._handleClosing} + @igcPopoverScrollClose=${this._handleScrollClose} > ${this._renderMainInput()} ${this._renderList()} diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index 70cff85bf..85b6153eb 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -17,7 +17,7 @@ import { isEmpty } from '#internals/utils/arrays.js'; import { getFocusableElements } from '#internals/utils/dom.js'; import type IgcInputComponent from '../../input/input.js'; import type IgcVirtualScrollComponent from '../../virtualization/virtualization.js'; -import type { ComboHost } from '../types.js'; +import type { ComboCloseTrigger, ComboHost } from '../types.js'; import type { DataState } from './data.js'; type ComboNavigationConfig = { @@ -28,7 +28,7 @@ type ComboNavigationConfig = { slotted: (slot: 'header' | 'empty' | 'footer') => Element[]; interactions: { show: () => Promise; - hide: () => Promise; + hide: (trigger: ComboCloseTrigger) => Promise; toggleSelection: (index: number) => void; select: (index: number) => void; clearSelection: () => void; @@ -93,7 +93,7 @@ export class ComboNavigationController { ref: this._config.input, bindingDefaults, }) - .set(arrowUp, this._config.interactions.hide) + .set(arrowUp, () => this._config.interactions.hide('arrow-up')) .set([altKey, arrowDown], this._onMainInputArrowDown) .set(arrowDown, this._onMainInputArrowDown) .set(enterKey, this._onEnter); @@ -103,7 +103,7 @@ export class ComboNavigationController { ref: this._config.search, bindingDefaults, }) - .set(arrowUp, this._onEscape) + .set(arrowUp, () => this._closeToInput('arrow-up')) .set(arrowDown, this._onSearchArrowDown); addKeybindings(this._host, { @@ -136,7 +136,7 @@ export class ComboNavigationController { if (this._host.singleSelect) { this._config.interactions.select(this.active); } - if (await this._config.interactions.hide()) { + if (await this._config.interactions.hide('enter')) { this._input?.select(); this._host.focus(); } @@ -175,17 +175,15 @@ export class ComboNavigationController { this._config.interactions.select(this.active); } - await this._config.interactions.hide(); + await this._config.interactions.hide('tab'); }; - private _onEscape = async (): Promise => { + private _onEscape = (): Promise => { if (!this._host.open) { this._config.interactions.clearSelection(); } - if (await this._config.interactions.hide()) { - this._input?.focus(); - } + return this._closeToInput('escape'); }; private _onMainInputArrowDown = async (): Promise => { @@ -217,7 +215,7 @@ export class ComboNavigationController { if (this.active === this._firstItem) { // Focus the search input when it shows. Otherwise, close the list. const search = this._searchInput; - search ? search.focus() : this._onEscape(); + search ? search.focus() : this._closeToInput('arrow-up'); return; } @@ -234,6 +232,12 @@ export class ComboNavigationController { //#region Internal helper methods + private async _closeToInput(trigger: ComboCloseTrigger): Promise { + if (await this._config.interactions.hide(trigger)) { + this._input?.focus(); + } + } + /** * The Tab order of the open list: the filtering input, the header content, * the list, the empty content and the footer content. Empty without diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 5feff9bc3..3cdace65a 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -60,6 +60,7 @@ | 9 | 2026-10-26 | Setting `singleSelect` or `disableFiltering` again keeps the search text | | 10 | 2026-10-26 | The inner `igcChange`, `igcStateChange` and `igcDataRequest` events stay inside | | 11 | 2026-10-26 | Tab moves through the focusable content of the header, empty and footer slots | +| 12 | 2026-10-26 | `igcClosing` and `igcClosed` report the `trigger` that closes the list | ## Overview @@ -384,8 +385,8 @@ type ComboGroupHeaderTemplate = (props: { | igcChange | true | Emitted before the user changes the selection. Cancel the event to keep the selection. | | igcOpening | true | Emitted before the user opens the list of options. Cancelable. | | igcOpened | false | Emitted after the list of options opens on a user action. | -| igcClosing | true | Emitted before the user closes the list of options. Cancelable. | -| igcClosed | false | Emitted after the list of options closes on a user action. | +| igcClosing | true | Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. | +| igcClosed | false | Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. | ```typescript interface IgcComboChangeEventArgs { @@ -393,8 +394,26 @@ interface IgcComboChangeEventArgs { items: T[]; type: 'selection' | 'deselection' | 'addition'; } + +interface IgcComboClosingEventArgs { + trigger: ComboCloseTrigger; +} + +type ComboCloseTrigger = + | 'enter' // Enter in the list or the main input + | 'escape' // Escape + | 'tab' // Tab or Shift+Tab out of the combo + | 'arrow-up' // Arrow Up in an input, or at the first item without the search input + | 'outside-click' // a click outside the combo + | 'scroll' // a scroll of a container with the `close` scroll strategy + | 'toggle' // a click on the input + | 'selection'; // an item click in single selection ``` +The open events keep an empty `detail`. `hide()`, `toggle()` and a change of `open` emit no events. An app that +stages a selection in its own footer can save `value` on `igcOpening`, commit on its Apply button or on the `enter` +trigger, and put the saved value back on the other triggers of `igcClosed`. + ### Slots | Name | Description | @@ -525,6 +544,14 @@ Generated by `runValidationContainerTests`. 27. Tab moves out of a stop inside another stop, does not stop on a host that delegates the focus or on a control with `visibility: hidden`, and leaves a Tab that a slotted control handles to that control. +### Close trigger + +28. `igcClosing` and `igcClosed` report the same `trigger` once for Enter in the list and in the main input, Escape, + Tab and Shift+Tab, Arrow Up in each input and at the first item, a click outside, a click on the input, an item + click in single selection, and a scroll with the `close` strategy. A second close while one is in progress does + not change the trigger, and a close after a canceled one reports its own trigger. +29. The open events keep an empty `detail`, and `hide()` emits no events. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index b59822731..960d6e65e 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -55,6 +55,25 @@ export interface IgcComboChangeEventArgs { type: ComboChangeType; } +/** + * The user action that closes the list of options. `toggle` is a click on the + * input, and `selection` an item click in single selection mode. + */ +export type ComboCloseTrigger = + | 'enter' + | 'escape' + | 'tab' + | 'arrow-up' + | 'outside-click' + | 'scroll' + | 'toggle' + | 'selection'; + +export interface IgcComboClosingEventArgs { + /** The user action that closes the list. */ + trigger: ComboCloseTrigger; +} + export interface IgcComboComponentEventMap { igcChange: CustomEvent; // For analyzer meta only: @@ -64,8 +83,8 @@ export interface IgcComboComponentEventMap { blur: FocusEvent; igcOpening: CustomEvent; igcOpened: CustomEvent; - igcClosing: CustomEvent; - igcClosed: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; } export type ComboItemTemplate = ( diff --git a/src/index.ts b/src/index.ts index 012e7f9ff..a2bb33050 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,6 +217,7 @@ export type { } from './components/stepper/common/types.js'; export type { IgcTreeSelectionEventArgs } from './components/tree/tree.common.js'; export type { + ComboCloseTrigger, ComboGroupHeaderContext, ComboGroupHeaderTemplate, ComboItemTemplate, @@ -224,6 +225,7 @@ export type { FilteringOptions, GroupingDirection, IgcComboChangeEventArgs, + IgcComboClosingEventArgs, } from './components/combo/types.js'; export type { IconMeta, diff --git a/src/internals/bases/combo-box.ts b/src/internals/bases/combo-box.ts index d092e1620..ae9d4f3a7 100644 --- a/src/internals/bases/combo-box.ts +++ b/src/internals/bases/combo-box.ts @@ -70,8 +70,8 @@ export class IgcBaseComboBoxComponent extends LitElement { this._hide(true); } - protected _hide(emitEvent = false): Promise { - return this._toggleController.hide(emitEvent); + protected _hide(emitEvent = false, detail?: unknown): Promise { + return this._toggleController.hide(emitEvent, detail); } protected _show(emitEvent = false): Promise { diff --git a/src/internals/controllers/toggle.ts b/src/internals/controllers/toggle.ts index ca81cc5f9..d1af7b9ff 100644 --- a/src/internals/controllers/toggle.ts +++ b/src/internals/controllers/toggle.ts @@ -76,9 +76,12 @@ class ToggleController { * Sends the event through the host `emitEvent`. Consumers and framework * wrappers observe that one method. */ - private _emit(name: keyof ToggleEventMap, cancelable: boolean): boolean { + private _emit( + name: keyof ToggleEventMap, + cancelable: boolean, + detail: unknown = this._options.detail?.call(this._host) + ): boolean { const host = this._host; - const detail = this._options.detail?.call(host); // Omit the falsy keys, so `emitEvent` applies its own defaults. return host.emitEvent(name, { @@ -89,7 +92,8 @@ class ToggleController { private async _setOpenState( open: boolean, - emitEvents: boolean + emitEvents: boolean, + detail?: unknown ): Promise { const host = this._host; const { transition } = this._options; @@ -98,7 +102,10 @@ class ToggleController { return false; } - if (emitEvents && !this._emit(open ? 'igcOpening' : 'igcClosing', true)) { + if ( + emitEvents && + !this._emit(open ? 'igcOpening' : 'igcClosing', true, detail) + ) { return false; } @@ -133,7 +140,7 @@ class ToggleController { return false; } - this._emit(open ? 'igcOpened' : 'igcClosed', false); + this._emit(open ? 'igcOpened' : 'igcClosed', false, detail); } return completed; @@ -156,12 +163,13 @@ class ToggleController { /** * Closes the host, and sends `igcClosing` and `igcClosed` when `emitEvents` - * is true. Returns `false` when the host is already closed or closing, the + * is true. A `detail` other than `undefined` replaces the one of the + * `detail` option in both events. Returns `false` when the host is already closed or closing, the * closing event is canceled, a later request supersedes this one, or the * transition is interrupted. */ - public hide(emitEvents = false): Promise { - return this._setOpenState(false, emitEvents); + public hide(emitEvents = false, detail?: unknown): Promise { + return this._setOpenState(false, emitEvents, detail); } /** Opens the host, or closes it, based on its current state. */ From fe4aad411d75e99e1eaae845a4858cf8962b79e9 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 07:41:12 +0200 Subject: [PATCH 04/32] feat(combo): add the primitives for remote data filteringOptions.mode 'remote', igcSearchInput, igcDataRequest in remote mode, and a selection that a new data keeps. The igcInput of the inner inputs stays inside. Refs #2519. --- CHANGELOG.md | 5 +- public-api.json | 6 +- src/components/combo/combo.spec.ts | 381 ++++++++++++++++++ src/components/combo/combo.ts | 206 ++++++++-- src/components/combo/controllers/data.ts | 6 +- src/components/combo/operations/filter.ts | 2 +- src/components/combo/spec.md | 83 +++- src/components/combo/types.ts | 15 + .../virtualization/virtualization.ts | 3 +- src/index.ts | 2 + stories/combo.stories.ts | 165 ++++++++ 11 files changed, 839 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbbc4353..1f1d3babc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Chat - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - #### Combo + - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input @@ -62,8 +63,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Chip - The `end` slot now renders after the `suffix` slot, at the end of the chip, as the `start` slot renders before the `prefix` slot. Before, it rendered before the `suffix` slot. - #### Combo + - **Behavioral change:** A new `data` keeps the selection. Before, a selected item that the new `data` did not hold dropped out of `selection` and the input text, although `value` kept it, and the next selection by the user removed it from `value` too. Now it stays selected until the user or the application deselects it. To clear the selection with the data, as for cascading combos, set `value` to `[]`. `select()` also selects an object that `data` does not hold, so an application can select the records of a saved filter before their page loads; with `valueKey`, the object resolves by its key first. Without an argument, `select()` keeps the selected items that `data` does not hold. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - The `igcInput` of the search input, and of the main input in single selection, no longer leaves the combo. Use `igcSearchInput`. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `igcClosing` and `igcClosed` are typed `CustomEvent`, not `CustomEvent`. In TypeScript, change a handler that declares its parameter as `CustomEvent`. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - - The `igcStateChange` and `igcDataRequest` events of the list inside the combo no longer leave the combo. A listener on the combo for these events no longer gets them. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) + - The `igcStateChange` and `igcDataRequest` events of the list inside the combo no longer leave the combo. For more data on scroll, use the `igcDataRequest` of the combo in remote filtering mode. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Highlight - Setting `searchText` or `caseSensitive` to its current value no longer runs the search again, so the active match stays. Before, a framework that sets each property on each render, such as React, moved the active match back to the first match on each render. After the content changes, call `search()` to search again. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) - #### Library diff --git a/public-api.json b/public-api.json index 41f65eee8..55a48e774 100644 --- a/public-api.json +++ b/public-api.json @@ -24,6 +24,7 @@ "ColorPickerMode", "ColorPickerResourceStringsEN", "ComboCloseTrigger", + "ComboFilteringMode", "ComboGroupHeaderContext", "ComboGroupHeaderTemplate", "ComboItemTemplate", @@ -108,6 +109,7 @@ "IgcComboClosingEventArgs", "IgcComboComponent", "IgcComboComponentEventMap", + "IgcComboSearchInputEventArgs", "IgcDatePickerComponent", "IgcDatePickerComponentEventMap", "IgcDateRangePickerComponent", @@ -1276,8 +1278,10 @@ "igcChange": "unknown", "igcClosed": "unknown", "igcClosing": "unknown", + "igcDataRequest": "unknown", "igcOpened": "unknown", - "igcOpening": "unknown" + "igcOpening": "unknown", + "igcSearchInput": "unknown" }, "slots": [ "clear-icon", diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 1d2c47e1b..5fa7b7dd0 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -35,6 +35,7 @@ import { import { simulateBlur, simulateClick, + simulateInput, simulateKeyboard, simulatePointerDown, simulateScroll, @@ -2312,6 +2313,386 @@ describe('Combo', () => { }); }); + describe('Remote data', () => { + type Place = { id: string; name: string; letter: string }; + + const PLACES = [ + ['AMS', 'Amsterdam'], + ['ATH', 'Athens'], + ['BAR', 'Barcelona'], + ['BEL', 'Belgrade'], + ['BRA', 'Bratislava'], + ['BRU', 'Brussels'], + ['BUC', 'Bucharest'], + ['BUD', 'Budapest'], + ['CPH', 'Copenhagen'], + ['DUB', 'Dublin'], + ['BER', 'Berlin'], + ['BRN', 'Bern'], + ['BGO', 'Bergen'], + ['ALB', 'Alberobello'], + ['HEL', 'Helsinki'], + ['LIS', 'Lisbon'], + ['LON', 'London'], + ['MAD', 'Madrid'], + ['OSL', 'Oslo'], + ['PAR', 'Paris'], + ] as const; + + /** A fake server. Each response has new objects, as parsed JSON has. */ + function search(text: string, skip = 0, top = 10): Place[] { + return PLACES.filter(([, name]) => + name.toLowerCase().includes(text.toLowerCase()) + ) + .slice(skip, skip + top) + .map(([id, name]) => ({ id, name, letter: name[0] })); + } + + let remote: IgcComboComponent; + let eventSpy: SinonSpy; + let searchNative: HTMLInputElement; + let mainNative: HTMLInputElement; + + const checked = () => + items(remote) + .filter((item) => item.selected) + .map((item) => item.textContent?.trim()); + + const itemFor = (name: string) => + items(remote).find((item) => item.textContent?.trim() === name)!; + + const details = (name: string) => + eventSpy.withArgs(name).args.map(([, init]) => init.detail); + + const searchEvents = () => + details('igcSearchInput').map(({ searchText }) => searchText); + + beforeEach(async () => { + remote = await fixture>( + html`` + ); + remote.setAttribute('filtering-options', '{"mode": "remote"}'); + await elementUpdated(remote); + + input = remote.renderRoot.querySelector('igc-input#target')!; + searchInput = remote.renderRoot.querySelector('[part="search-input"]')!; + list = remote.renderRoot.querySelector('igc-virtual-scroll')!; + mainNative = input.renderRoot.querySelector('input')!; + searchNative = searchInput.renderRoot.querySelector('input')!; + eventSpy = spy(remote, 'emitEvent'); + }); + + it('takes the mode from the `filtering-options` attribute', () => { + expect(remote.filteringOptions.mode).to.equal('remote'); + expect(remote.filteringOptions.filterKey).to.equal('name'); + }); + + it('emits igcSearchInput when the user changes the search text', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'ber' }); + simulateInput(searchNative, { value: '' }); + + expect(searchEvents()).to.eql(['ber', '']); + }); + + it('does not filter the options in remote mode', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'ber' }); + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + expect(searchInput.value).to.equal('ber'); + + remote.filteringOptions = { mode: 'local' }; + await comboStable(remote); + + expect(items(remote)).lengthOf(0); + }); + + it('emits igcSearchInput in local mode too', async () => { + remote.filteringOptions = { mode: 'local' }; + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'bu' }); + + expect(searchEvents()).to.eql(['bu']); + }); + + it('emits igcSearchInput from the main input in single selection, also when it is cleared', async () => { + remote.singleSelect = true; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'ber' }); + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + + simulateInput(mainNative, { value: '' }); + await elementUpdated(remote); + + expect(searchEvents()).to.eql(['ber', '']); + }); + + it('emits igcSearchInput when a user action clears the search text, not on a property change', async () => { + remote.singleSelect = true; + remote.filteringOptions = { mode: 'local' }; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'a' }); + await comboStable(remote); + + simulateKeyboard(list, enterKey); + await elementUpdated(remote); + expect(remote.value).to.eql(['AMS']); + + simulateInput(mainNative, { value: 'b' }); + await elementUpdated(remote); + remote.disableFiltering = true; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'c' }); + await elementUpdated(remote); + + expect(searchEvents()).to.eql(['a', '', 'b']); + }); + + it('clears the search input and reports it when a blur clears the search text', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'xyz' }); + await elementUpdated(remote); + + simulateBlur(remote); + await elementUpdated(remote); + + expect(searchInput.value).to.equal(''); + expect(searchEvents()).to.eql(['xyz', '']); + }); + + it('activates no option of the old results on input in single selection', async () => { + remote.singleSelect = true; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'sof' }); + await comboStable(remote); + + simulateKeyboard(input, enterKey); + await elementUpdated(remote); + + expect(items(remote).some((item) => item.active)).to.be.false; + expect(remote.value).to.be.empty; + }); + + it('hides the case-sensitive icon in remote mode', async () => { + remote.caseSensitiveIcon = true; + await elementUpdated(remote); + + const icon = searchInput.querySelector('[part~="case-icon"]')!; + expect(icon.slot).to.be.empty; + + remote.filteringOptions = { mode: 'local' }; + await elementUpdated(remote); + + expect(icon.slot).to.equal('suffix'); + }); + + it('keeps the igcInput of the inputs inside', async () => { + const inputSpy = spy(); + remote.addEventListener('igcInput', inputSpy); + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'ber' }); + + remote.singleSelect = true; + await elementUpdated(remote); + simulateInput(mainNative, { value: 'ber' }); + + expect(inputSpy).not.called; + }); + + it('keeps the selection when a search replaces the data (issue 2519)', async () => { + await openComboPopover(remote); + + // 1. Check Amsterdam and Athens. + simulateClick(itemFor('Amsterdam')); + simulateClick(itemFor('Athens')); + await comboStable(remote); + expect(remote.value).to.eql(['AMS', 'ATH']); + + // 2. A search for "ber" replaces the data. + remote.data = search('ber'); + await comboStable(remote); + + expect(remote.value).to.eql(['AMS', 'ATH']); + expect(remote.selection.map(({ id }) => id)).to.eql(['AMS', 'ATH']); + expect(input.value).to.equal('Amsterdam, Athens'); + + // 3. Check Berlin. + simulateClick(itemFor('Berlin')); + await comboStable(remote); + expect(remote.value).to.eql(['AMS', 'ATH', 'BER']); + + // 4. Clearing the search loads the first page again. + remote.data = search(''); + await comboStable(remote); + + expect(remote.value).to.eql(['AMS', 'ATH', 'BER']); + expect(remote.selection.map(({ id }) => id)).to.eql([ + 'AMS', + 'ATH', + 'BER', + ]); + expect(remote.selection[0]).to.equal(remote.data[0]); + expect(checked()).to.eql(['Amsterdam', 'Athens']); + expect(input.value).to.equal('Amsterdam, Athens, Berlin'); + }); + + it('deselects a selected item that the data does not hold', async () => { + remote.select(['AMS', 'ATH']); + remote.data = search('ber'); + await elementUpdated(remote); + + remote.deselect('AMS'); + await elementUpdated(remote); + + expect(remote.value).to.eql(['ATH']); + expect(input.value).to.equal('Athens'); + }); + + it('selects an object that the data does not hold', async () => { + const oslo = { id: 'OSL', name: 'Oslo', letter: 'O' }; + + remote.select(oslo); + await elementUpdated(remote); + + expect(remote.value).to.eql(['OSL']); + expect(remote.selection).to.eql([oslo]); + expect(input.value).to.equal('Oslo'); + + remote.data = search('oslo'); + await openComboPopover(remote); + + expect(remote.selection[0]).to.equal(remote.data[0]); + expect(checked()).to.eql(['Oslo']); + }); + + it('selects an object that the data does not hold without a value key', async () => { + const oslo = { id: 'OSL', name: 'Oslo', letter: 'O' }; + + remote.valueKey = undefined; + remote.select(oslo); + await elementUpdated(remote); + + expect(remote.value).to.eql([oslo]); + expect(remote.selection).to.eql([oslo]); + }); + + it('selects all the data and keeps the other selected items with select()', async () => { + remote.select('AMS'); + remote.data = search('ber'); + await elementUpdated(remote); + + remote.select(); + await elementUpdated(remote); + + expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB', 'AMS']); + }); + + it('selects each value one time with select() before a new data syncs', async () => { + remote.data = search('ber'); + remote.value = ['BER']; + remote.data = search('ber'); + remote.select(); + await elementUpdated(remote); + + expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB']); + }); + + it('emits igcDataRequest near the end of the data, in data coordinates', async () => { + remote.groupKey = 'letter'; + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.eql([{ startIndex: 10, count: 20 }]); + }); + + it('does not repeat igcDataRequest until the data grows', async () => { + await openComboPopover(remote); + + remote.data = [...remote.data]; + await openComboPopover(remote); + + expect(details('igcDataRequest')).lengthOf(1); + + remote.data = [...remote.data, ...search('', 10, 5)]; + await comboStable(remote); + list.scrollToIndex(14); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 15, count: 20 }, + ]); + }); + + it('requests the next page of a new search with the same length', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'b' }); + remote.data = search('b'); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('requests the next page when the data is replaced without a search', async () => { + await openComboPopover(remote); + + remote.data = search(''); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('requests the same page again when the list opens again', async () => { + await openComboPopover(remote); + await remote.hide(); + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('does not emit igcDataRequest in local mode, while closed or without data', async () => { + await comboStable(remote); + expect(details('igcDataRequest')).to.be.empty; + + remote.data = []; + await openComboPopover(remote); + expect(details('igcDataRequest')).to.be.empty; + + await remote.hide(); + remote.filteringOptions = { mode: 'local' }; + remote.data = search(''); + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.be.empty; + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index f6cc2f617..155ad7b3f 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -24,6 +24,7 @@ import { stopPropagation, } from '#internals/utils/events.js'; import { bindIf } from '#internals/utils/lit.js'; +import { isObject } from '#internals/utils/types.js'; import type { Validator } from '#internals/validators.js'; import { all } from '#themes/combo/themes/themes.js'; import { addThemingController } from '#theming/theming-controller.js'; @@ -32,8 +33,13 @@ import IgcIconComponent from '../icon/icon.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; -import type { VirtualScrollItemContext } from '../virtualization/types.js'; -import IgcVirtualScrollComponent from '../virtualization/virtualization.js'; +import type { + VirtualScrollItemContext, + VirtualScrollState, +} from '../virtualization/types.js'; +import IgcVirtualScrollComponent, { + REMOTE_SCROLLING_THRESHOLD, +} from '../virtualization/virtualization.js'; import IgcComboHeaderComponent from './combo-header.js'; import IgcComboItemComponent from './combo-item.js'; import { DataState } from './controllers/data.js'; @@ -75,6 +81,8 @@ const i18n = { defaultEN: ComboResourceStringsEN, }; +const DATA_REQUEST_COUNT = 20; + /* blazorSupportsVisualChildren */ /** * The combo lets the user select one or more options from a list. Unlike the select, it shows the options in a @@ -100,6 +108,8 @@ const i18n = { * @fires igcOpened - Emitted after the list of options opens on a user action. * @fires igcClosing - Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. * @fires igcClosed - Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. + * @fires igcSearchInput - Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. + * @fires igcDataRequest - Emitted in remote filtering mode when the user scrolls near the end of the options. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes, or the list opens again. * * @csspart label - The encapsulated text label of the combo. * @csspart input - The main input field of the combo. @@ -226,6 +236,10 @@ export default class IgcComboComponent< private _displayKey?: Keys; private _placeholderSearch?: string; private _selected: Set = new Set(); + /** The length of `data` at the last `igcDataRequest`. */ + private _lastDataRequest?: number; + /** The index of the last rendered option, from the list. */ + private _lastRenderedIndex = -1; // The `displayKey` setter sets `filterKey`. Both keys are undefined here. private _filteringOptions: FilteringOptions = { filterKey: undefined, @@ -275,7 +289,10 @@ export default class IgcComboComponent< //#region Public attributes and properties - /** The data source used to generate the list of options. */ + /** + * The data source used to generate the list of options. A new data source + * keeps the selection, also the selected items that it does not hold. + */ /* treatAsRef */ @property({ attribute: false }) @coercedProperty>({ @@ -579,6 +596,26 @@ export default class IgcComboComponent< } } + protected override updated(props: PropertyValues): void { + const opened = props.has('open') && this.open; + const replaced = + props.has('data') && props.get('data')?.[0] !== this.data[0]; + + // An opening, or a replaced `data` rather than an appended one, starts a + // new sequence of requests. Opening again also retries a failed request. + if (opened || replaced) { + this._lastDataRequest = undefined; + } + + // The list reports a changed window after its own update, and an + // unchanged one not at all, so check against the window after it. + if (opened || props.has('data')) { + void this._listRef.value?.updateComplete.then(() => + this._checkDataRequest() + ); + } + } + //#endregion // #region Form Associated overrides @@ -642,6 +679,30 @@ export default class IgcComboComponent< return this._hide(true, { trigger }); } + /** + * Requests more data while the open list renders near the end of `data`. + * An empty `data` waits for the first page from the application. + */ + private _checkDataRequest(): void { + const { length } = this.data; + + if ( + !this.open || + !length || + this.filteringOptions.mode !== 'remote' || + this._lastDataRequest === length || + this._lastRenderedIndex < + this._state.dataState.length - REMOTE_SCROLLING_THRESHOLD + ) { + return; + } + + this._lastDataRequest = length; + this.emitEvent('igcDataRequest', { + detail: { startIndex: length, count: DATA_REQUEST_COUNT }, + }); + } + private _setSingleSelectionDisplayValue(value: string): void { if (this.singleSelect && this._inputRef.value) { this._inputRef.value.value = value; @@ -668,16 +729,65 @@ export default class IgcComboComponent< } /** - * Resolves value keys or object references to data records, in data-source order. - * A repeated value resolves one time, so the change event has no duplicates. + * Resolves values or objects to records: the records of `data` in + * data-source order, then the selected records and the objects that `data` + * does not hold. A repeated value resolves one time, so the change event + * has no duplicates. */ private _resolveItems(items: Item[]): T[] { const index = this._dataIndex; - const positions = new Set(items.flatMap((item) => index.get(item) ?? [])); + const positions = new Set(); + const others = new Set(); + let selected: Map, T> | undefined; + + for (const item of items) { + const value = isObject(item) ? this._valueOf(item as T) : item; + const found = index.get(value); + + if (found) { + for (const position of found) { + positions.add(position); + } + continue; + } + + selected ??= this._selectedByValue(); + const record = + selected.get(value) ?? (isObject(item) ? (item as T) : undefined); + + if (record) { + others.add(record); + } + } + + return [ + ...Array.from(positions) + .sort((a, b) => a - b) + .map((position) => this.data[position]), + ...others, + ]; + } + + /** The selected records whose values `data` does not hold. */ + private _selectedOutsideData(): Set { + const index = this._dataIndex; + + return new Set( + Iterator.from(this._selected).filter( + (record) => !index.has(this._resolveItemValue(record)) + ) + ); + } - return Array.from(positions) - .sort((a, b) => a - b) - .map((position) => this.data[position]); + private _selectedByValue( + selected: Set = this._selected + ): Map, T> { + return new Map( + Iterator.from(selected).map((record): [ComboValue, T] => [ + this._valueOf(record), + record, + ]) + ); } private _resolveItemValue(record: T): Item { @@ -744,7 +854,9 @@ export default class IgcComboComponent< if (isEmpty(collection) && selecting) { // Selects all items without an event, or clears a single selection. - this._selected = singleSelect ? new Set() : new Set(this.data); + this._selected = singleSelect + ? new Set() + : new Set(this.data).union(this._selectedOutsideData()); if (singleSelect) { this._searchTerm = ''; @@ -773,7 +885,7 @@ export default class IgcComboComponent< // The change is committed, so drop the previous single selection and search term. if (selecting && singleSelect) { this._selected.clear(); - this._searchTerm = ''; + this._clearSearchTerm(emit); } for (const item of resolved) { @@ -784,9 +896,14 @@ export default class IgcComboComponent< return true; } - /** One-way sync from `_formValue` to `_selected`. */ + /** + * One-way sync from `_formValue` to `_selected`. A value that `data` does + * not hold keeps its selected record. + */ private _syncSelectionFromValue(): void { - this._selected.clear(); + const selected = this._selected; + let previous: Map, T> | undefined; + this._selected = new Set(); if (!isEmpty(this._formValue.value)) { const values = this.singleSelect @@ -802,9 +919,13 @@ export default class IgcComboComponent< for (const value of values) { // First match only - mirrors resolving a value against the data source const position = index.get(value)?.[0]; + const record = + position === undefined + ? (previous ??= this._selectedByValue(selected)).get(value) + : this.data[position]; - if (position !== undefined) { - this._selected.add(this.data[position]); + if (record !== undefined) { + this._selected.add(record); } } } @@ -848,7 +969,7 @@ export default class IgcComboComponent< private _clearSelection(): void { if (this.singleSelect) { - this._searchTerm = ''; + this._clearSearchTerm(true); this._clearSingleSelection(); } else { this._updateSelection([], 'deselection', true); @@ -877,34 +998,59 @@ export default class IgcComboComponent< //#region Event handlers - protected async _handleMainInput({ - detail, - }: CustomEvent): Promise { + protected async _handleMainInput(event: CustomEvent): Promise { + event.stopPropagation(); this._setTouchedState(); void this._show(true); // In single selection the main input filters, so honor `disableFiltering` here. if (!this.disableFiltering) { - this._searchTerm = detail; + this._searchTerm = event.detail; + this._emitSearchInput(event.detail); } // wait for the dataState to update after filtering await this.updateComplete; - this._activeIndex = this._state.firstItemIndex; + // Remote results come later, so no option of the old results becomes active. + this._activeIndex = + this.filteringOptions.mode === 'remote' ? -1 : this._state.firstItemIndex; this._clearSingleSelection(); } protected override _handleBlur(): void { if (isEmpty(this._selected)) { - this._searchTerm = ''; + this._clearSearchTerm(true); this._setSingleSelectionDisplayValue(''); } super._handleBlur(); } - protected _handleSearchInput({ detail }: CustomEvent): void { - this._searchTerm = detail; + protected _handleSearchInput(event: CustomEvent): void { + event.stopPropagation(); + this._searchTerm = event.detail; + this._emitSearchInput(event.detail); + } + + /** With `emit`, a user action clears the text, so `igcSearchInput` reports it. */ + private _clearSearchTerm(emit: boolean): void { + if (emit && this._searchTerm) { + this._emitSearchInput(''); + } + + this._searchTerm = ''; + } + + private _emitSearchInput(searchText: string): void { + // A new search starts a new sequence of data requests. + this._lastDataRequest = undefined; + this.emitEvent('igcSearchInput', { detail: { searchText } }); + } + + private _handleListStateChange(event: CustomEvent): void { + event.stopPropagation(); + this._lastRenderedIndex = event.detail.endIndex; + this._checkDataRequest(); } private async _itemClickHandler(event: PointerEvent): Promise { @@ -967,8 +1113,9 @@ export default class IgcComboComponent< /** * Selects options in the list by reference or by `valueKey` value. It does - * not emit `igcChange`. Without an argument, it selects all items, or - * clears the selection in single selection mode. + * not emit `igcChange`. An object that `data` does not hold is selected as + * it is. Without an argument, it selects all the items of `data`, or clears + * the selection in single selection mode. * @param { Item | Item[] } items - One or more items to select. Pass multiple items as an array. * With a `valueKey`, pass the value of an item instead of its reference. * @@ -1142,7 +1289,10 @@ export default class IgcComboComponent< @igcChange=${stopPropagation} > diff --git a/src/components/combo/controllers/data.ts b/src/components/combo/controllers/data.ts index b2bae168f..17723d405 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -50,7 +50,11 @@ export class DataState implements ReactiveController { public set searchTerm(value: string) { if (this._searchTerm !== value) { this._searchTerm = value; - this.invalidate(); + + // In remote mode, the term does not change the records. + if (this.filteringOptions.mode !== 'remote') { + this.invalidate(); + } } } diff --git a/src/components/combo/operations/filter.ts b/src/components/combo/operations/filter.ts index 7349c54af..8f4a348f5 100644 --- a/src/components/combo/operations/filter.ts +++ b/src/components/combo/operations/filter.ts @@ -59,7 +59,7 @@ export default class FilterDataOperation { this._syncCache(filteringOptions); - if (!searchTerm) { + if (!searchTerm || filteringOptions.mode === 'remote') { return data; } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 3cdace65a..c993b0566 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -20,6 +20,7 @@ - [Validation](#validation) - [Labeling from the light DOM](#labeling-from-the-light-dom) - [Form integration](#form-integration) + - [Remote data](#remote-data) - [Localization](#localization) - [Keyboard interactions](#keyboard-interactions) - [API](#api) @@ -61,6 +62,7 @@ | 10 | 2026-10-26 | The inner `igcChange`, `igcStateChange` and `igcDataRequest` events stay inside | | 11 | 2026-10-26 | Tab moves through the focusable content of the header, empty and footer slots | | 12 | 2026-10-26 | `igcClosing` and `igcClosed` report the `trigger` that closes the list | +| 13 | 2026-10-26 | Remote data: `igcSearchInput`, `filteringOptions.mode`, `igcDataRequest`; a new `data` keeps the selection | ## Overview @@ -261,6 +263,48 @@ the form, and `reportValidity()` moves it to the control when it is invalid. `fo `checkValidity()` do not move the focus. The `invalid` event comes only from these checks and from a failed submit, not while the user edits the control. +#### Remote data + +A combo can show a large remote data source one page at a time. With `filteringOptions.mode` set to `remote`, the +combo does not filter the options. It keeps the search input and reports the text, and the application sets `data` to +the results of the server: + +```typescript +combo.filteringOptions = { mode: 'remote' }; + +combo.addEventListener('igcOpening', () => { + if (!combo.data.length) load(searchText, 0); +}); + +combo.addEventListener('igcSearchInput', ({ detail }) => { + searchText = detail.searchText; + load(searchText, 0); // replaces `data` with the first page; debounce it +}); + +combo.addEventListener('igcDataRequest', ({ detail }) => { + load(searchText, detail.startIndex, detail.count); // appends to `data` +}); +``` + +- `igcSearchInput` comes on each change of the text of the search input, and of the main input in single selection, + also when the user clears the text. It also comes, with an empty text, when a user action clears the text: a + selection in single selection mode, a blur with nothing selected, or the clear button. It does not come when a + property change clears the text (`singleSelect`, `disableFiltering`), or with `disableFiltering`. It comes in both + modes. +- In remote mode, `igcDataRequest` comes when the open list renders one of the last five options of `data`. Its + `startIndex` is `data.length`, also with grouping, and `count` is the number of items to append. It does not come + again until the length of `data` changes, a new `data` replaces the first record, or a new search text comes, so a + source with no more items gets one request. An empty `data` gets no request: the first page of a search is the job + of the application. An answer is stale when `data.length` no longer equals its `startIndex`, for example after a + new search. +- A new `data` keeps the selection. A selected item that the new data does not hold stays in `value`, in `selection` + and in the text of the input, and a later page or search that holds it again shows it checked. `select()` selects an + object that `data` does not hold as it is, so the application can select the records of a saved filter before + their page loads. With `valueKey`, an object resolves by its key first. Without an argument, `select()` selects all + the items of `data` and keeps the other selected items. +- In remote mode, the search input has no case-sensitive icon, and in single selection the input activates no option + of the old results, so Enter does not select an option that the server did not return for the text. + ### Localization The component renders several built-in strings, resolved through the library i18n mechanism and overridable per @@ -306,7 +350,7 @@ Typing while the component is focused filters the list down to the closest match | Property | Attribute | Reflected | Type | Default | Description | | ---------------------- | -------------------------- | --------- | ----------------------------------- | ----------------- | ---------------------------------------------------------------- | -| data | - | No | `T[]` | `[]` | The data source used to generate the list of options. | +| data | - | No | `T[]` | `[]` | The data source used to generate the list of options. A new data source keeps the selection, also the selected items that it does not hold. | | value | value | No | `ComboValue[]` | `[]` | The values of the selected items. With a `valueKey`, each value is that field of an item. Without it, each value is the item itself. | | selection | - | No | `T[]` | `[]` | Read-only. The current selection as records of the data source. | | valueKey | value-key | No | `Keys \| undefined` | - | The key in the data source used when selecting items. | @@ -348,6 +392,12 @@ interface FilteringOptions { caseSensitive?: boolean; /** When true, the filter distinguishes between accented letters and their base letters. */ matchDiacritics?: boolean; + /** + * Where the filtering runs. With `remote`, the combo does not filter the + * options: the application sets `data` on `igcSearchInput`, and appends to + * it on `igcDataRequest`. Default is `local`. + */ + mode?: 'local' | 'remote'; } type ComboItemTemplate = (props: { item: T }) => TemplateResult; @@ -370,7 +420,7 @@ type ComboGroupHeaderTemplate = (props: { | show | `(): Promise` | Opens the component. It does not emit events. The promise resolves to `false` when the component is already open, or when a later call replaces this one. | | hide | `(): Promise` | Closes the component. It does not emit events. The promise resolves to `false` when the component is already closed, or when a later call replaces this one. | | toggle | `(): Promise` | Opens the component when it is closed, and closes it when it is open. It does not emit events. The promise resolves to `true` when the state changes. | -| select | `(items?: Item \| Item[]): void` | Selects options in the list by reference or by `valueKey` value. It does not emit `igcChange`. Without an argument, it selects all items, or clears the selection in single selection mode. | +| select | `(items?: Item \| Item[]): void` | Selects options in the list by reference or by `valueKey` value. It does not emit `igcChange`. An object that `data` does not hold is selected as it is. Without an argument, it selects all the items of `data`, or clears the selection in single selection mode. | | deselect | `(items?: Item \| Item[]): void` | Deselects options in the list by reference or by `valueKey` value. It does not emit `igcChange`. Without an argument, it deselects all items. | | focus | `(options?: FocusOptions): void` | Sets focus on the component. | | blur | `(): void` | Removes focus from the component. | @@ -387,6 +437,8 @@ type ComboGroupHeaderTemplate = (props: { | igcOpened | false | Emitted after the list of options opens on a user action. | | igcClosing | true | Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. | | igcClosed | false | Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. | +| igcSearchInput | false | Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. | +| igcDataRequest | false | Emitted in remote filtering mode when the user scrolls near the end of the options. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes. | ```typescript interface IgcComboChangeEventArgs { @@ -399,6 +451,15 @@ interface IgcComboClosingEventArgs { trigger: ComboCloseTrigger; } +interface IgcComboSearchInputEventArgs { + searchText: string; +} + +interface VirtualScrollDataRequest { + startIndex: number; // `data.length` + count: number; +} + type ComboCloseTrigger = | 'enter' // Enter in the list or the main input | 'escape' // Escape @@ -552,6 +613,24 @@ Generated by `runValidationContainerTests`. not change the trigger, and a close after a canceled one reports its own trigger. 29. The open events keep an empty `detail`, and `hide()` emits no events. +### Remote data + +30. The mode comes from the `filtering-options` attribute. In remote mode, the search input and the main input do not + filter the options; switching back to `local` filters them. +31. `igcSearchInput` reports each change of the search text, in both modes, and of the main input in single selection, + also when it is cleared, and an empty text when a selection or a blur clears it; a blur also clears the search + input. It does not come on a property change or with `disableFiltering`. The `igcInput` of the inputs does not + leave the combo. In remote mode, the case-sensitive icon hides and an input in single selection activates no + option. +32. The repro of issue 2519: a search that replaces `data` keeps the selected items in `value`, `selection` and the + input text, a later selection adds to them, and the first page shows them checked again with its own records. +33. `deselect()` removes a selected item that `data` does not hold; `select()` selects an object that `data` does not + hold, with and without `valueKey`; `select()` without an argument keeps the other selected items, and selects + each value one time also before a new `data` syncs. +34. `igcDataRequest` comes near the end of `data` with `startIndex` equal to `data.length`, also with grouping. It + does not repeat until `data` grows, and a new search, or a new `data` without a search, with a page of the same + length gets its own request. It does not come in local mode, while the list is closed, or with an empty `data`. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index 960d6e65e..7c280c697 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -1,4 +1,5 @@ import type { ReactiveControllerHost, TemplateResult } from 'lit'; +import type { VirtualScrollDataRequest } from '../virtualization/types.js'; import type { VirtualScrollItemTemplate } from '../virtualization/virtualization.js'; import type IgcComboComponent from './combo.js'; @@ -27,6 +28,7 @@ export type ComboRenderFunction = VirtualScrollItemTemplate< ComboRecord >; export type ComboValue = T | Values; +export type ComboFilteringMode = 'local' | 'remote'; /* jsonAPIPlainObject */ /** Filtering options for the combo component. */ @@ -37,6 +39,12 @@ export interface FilteringOptions { caseSensitive?: boolean; /** When true, the filter distinguishes between accented letters and their base letters. */ matchDiacritics?: boolean; + /** + * Where the filtering runs. With `remote`, the combo does not filter the + * options: the application sets `data` on `igcSearchInput`, and appends to + * it on `igcDataRequest`. Default is `local`. + */ + mode?: ComboFilteringMode; } export interface GroupingOptions { @@ -55,6 +63,11 @@ export interface IgcComboChangeEventArgs { type: ComboChangeType; } +export interface IgcComboSearchInputEventArgs { + /** The text of the search input, or of the main input in single selection mode. */ + searchText: string; +} + /** * The user action that closes the list of options. `toggle` is a click on the * input, and `selection` an item click in single selection mode. @@ -85,6 +98,8 @@ export interface IgcComboComponentEventMap { igcOpened: CustomEvent; igcClosing: CustomEvent; igcClosed: CustomEvent; + igcSearchInput: CustomEvent; + igcDataRequest: CustomEvent; } export type ComboItemTemplate = ( diff --git a/src/components/virtualization/virtualization.ts b/src/components/virtualization/virtualization.ts index c445d2cf6..1238d25f9 100644 --- a/src/components/virtualization/virtualization.ts +++ b/src/components/virtualization/virtualization.ts @@ -35,7 +35,8 @@ export interface IgcVirtualScrollComponentEventMap { igcDataRequest: CustomEvent; } -const REMOTE_SCROLLING_THRESHOLD = 5; +/** How close the rendered window comes to the end of the items before a data request. */ +export const REMOTE_SCROLLING_THRESHOLD = 5; const MAX_LAYOUT_SETTLE_PASSES = 20; const MAX_SCROLL_CORRECTION_PASSES = 5; const SCROLL_END_TIMEOUT_MS = 2000; diff --git a/src/index.ts b/src/index.ts index a2bb33050..5962221e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,6 +218,7 @@ export type { export type { IgcTreeSelectionEventArgs } from './components/tree/tree.common.js'; export type { ComboCloseTrigger, + ComboFilteringMode, ComboGroupHeaderContext, ComboGroupHeaderTemplate, ComboItemTemplate, @@ -226,6 +227,7 @@ export type { GroupingDirection, IgcComboChangeEventArgs, IgcComboClosingEventArgs, + IgcComboSearchInputEventArgs, } from './components/combo/types.js'; export type { IconMeta, diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 06c91dfd0..9a4b8e0a0 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -9,7 +9,9 @@ import { IgcCircularProgressComponent, type IgcComboChangeEventArgs, IgcComboComponent, + type IgcComboSearchInputEventArgs, IgcInputComponent, + type VirtualScrollDataRequest, defineComponents, } from 'igniteui-webcomponents'; import { registerMaterialIcons } from './story-icons.js'; @@ -902,6 +904,169 @@ export const LazyLoading: Story = { }, }; +const europeanCities: City[] = ( + [ + ['AMS', 'Amsterdam', 'Netherlands'], + ['ATH', 'Athens', 'Greece'], + ['BAR', 'Barcelona', 'Spain'], + ['BEL', 'Belgrade', 'Serbia'], + ['BER', 'Berlin', 'Germany'], + ['BGO', 'Bergen', 'Norway'], + ['BRN', 'Bern', 'Switzerland'], + ['BOR', 'Bordeaux', 'France'], + ['BRA', 'Bratislava', 'Slovakia'], + ['BRU', 'Brussels', 'Belgium'], + ['BUC', 'Bucharest', 'Romania'], + ['BUD', 'Budapest', 'Hungary'], + ['CPH', 'Copenhagen', 'Denmark'], + ['DUB', 'Dublin', 'Ireland'], + ['EDI', 'Edinburgh', 'United Kingdom'], + ['FLO', 'Florence', 'Italy'], + ['FRA', 'Frankfurt', 'Germany'], + ['GDN', 'Gdańsk', 'Poland'], + ['GVA', 'Geneva', 'Switzerland'], + ['HAM', 'Hamburg', 'Germany'], + ['HEL', 'Helsinki', 'Finland'], + ['KRK', 'Kraków', 'Poland'], + ['LIS', 'Lisbon', 'Portugal'], + ['LJU', 'Ljubljana', 'Slovenia'], + ['LON', 'London', 'United Kingdom'], + ['LYS', 'Lyon', 'France'], + ['MAD', 'Madrid', 'Spain'], + ['MIL', 'Milan', 'Italy'], + ['MUC', 'Munich', 'Germany'], + ['NAP', 'Naples', 'Italy'], + ['OSL', 'Oslo', 'Norway'], + ['PAR', 'Paris', 'France'], + ['PLV', 'Plovdiv', 'Bulgaria'], + ['POR', 'Porto', 'Portugal'], + ['PRG', 'Prague', 'Czechia'], + ['RIG', 'Riga', 'Latvia'], + ['ROM', 'Rome', 'Italy'], + ['ROT', 'Rotterdam', 'Netherlands'], + ['SEV', 'Seville', 'Spain'], + ['SOF', 'Sofia', 'Bulgaria'], + ['STO', 'Stockholm', 'Sweden'], + ['TAL', 'Tallinn', 'Estonia'], + ['VAR', 'Varna', 'Bulgaria'], + ['VCE', 'Venice', 'Italy'], + ['VIE', 'Vienna', 'Austria'], + ['VNO', 'Vilnius', 'Lithuania'], + ['WAW', 'Warsaw', 'Poland'], + ['ZAG', 'Zagreb', 'Croatia'], + ['ZRH', 'Zürich', 'Switzerland'], + ] as const +).map(([id, name, country]) => ({ id, name, country })); + +const cityPageSize = 10; + +/** A fake server. It returns one page of new objects, as parsed JSON does. */ +async function fetchCities(searchText: string, skip: number): Promise { + await delay(400); + + const text = searchText.toLocaleLowerCase(); + + return europeanCities + .filter(({ name }) => name.toLocaleLowerCase().includes(text)) + .slice(skip, skip + cityPageSize) + .map((city) => ({ ...city })); +} + +export const RemoteData: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'A city picker that searches and pages on a server, ten cities at a time. With `filteringOptions.mode` set to `remote`, the combo does not filter the options. `igcOpening` loads the first page, `igcSearchInput` loads the first page of the search after a 300 ms pause in the typing, and `igcDataRequest` appends the next page when the list scrolls near its end. A newer search drops the answers to older requests. Select a few cities, then search for others: the selection stays, also for the cities that the new results do not hold. The log shows each request and answer.', + }, + }, + }, + render: () => { + const combo = createRef>(); + const log = createRef(); + let searchText = ''; + let search = 0; + let timer: ReturnType | undefined; + + const write = (line: string) => { + const item = document.createElement('li'); + item.textContent = line; + log.value!.prepend(item); + }; + + // The server answers with one page, whatever `count` the combo asks for. + const load = async (skip: number) => { + const current = search; + write(`Request: "${searchText}" from item ${skip + 1}`); + + const page = await fetchCities(searchText, skip); + + const target = combo.value!; + + // Drop the answer to an older search, or for a `data` that changed since. + if (current !== search || (skip && target.data.length !== skip)) { + return; + } + + target.data = skip ? [...target.data, ...page] : page; + write(`Answer: ${page.length} cities, ${target.data.length} loaded`); + }; + + const onOpening = () => { + if (!combo.value!.data.length) { + void load(0); + } + }; + + const onSearch = (event: CustomEvent) => { + searchText = event.detail.searchText.trim(); + search++; + clearTimeout(timer); + timer = setTimeout(() => load(0), 300); + }; + + const onRequest = (event: CustomEvent) => { + void load(event.detail.startIndex); + }; + + const onChange = (event: CustomEvent) => { + write(`Value: ${event.detail.newValue.join(', ') || 'none'}`); + }; + + return html` + ${styles} + +
+

Cities from the server

+ + No city matches your search. + +
    +
    + `; + }, +}; + const labels = ['bug', 'feature', 'documentation', 'performance', 'security']; export const Form: Story = { From 11eb5211e9af1303d8ac123cb595cbc9c9d78dd3 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 07:42:43 +0200 Subject: [PATCH 05/32] feat(extras): add bindComboDataSource Binds a combo to a server that searches and pages, on the public combo API. Refs #2519. --- CHANGELOG.md | 1 + README.md | 23 ++ src/components/combo/spec.md | 10 +- src/extras/combo-data-source.spec.ts | 453 +++++++++++++++++++++++++++ src/extras/combo-data-source.ts | 261 +++++++++++++++ src/extras/index.ts | 1 + stories/combo.stories.ts | 98 +++--- 7 files changed, 792 insertions(+), 55 deletions(-) create mode 100644 src/extras/combo-data-source.spec.ts create mode 100644 src/extras/combo-data-source.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1d3babc..e7a452f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input diff --git a/README.md b/README.md index f46e2f75b..80c1a5821 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [Dock Manager - EXCLUSIVE FEATURE](#dock-manager---exclusive-feature) - [Quick start](#quick-start) - [Bundling a single theme](#bundling-a-single-theme) +- [Extras](#extras) - [Browser support](#browser-support) - [Tooling](#tooling) - [Editor metadata](#editor-metadata) @@ -182,6 +183,28 @@ The condition names are `igc-theme-material`, `igc-theme-bootstrap`, `igc-theme- - Load the global theme style sheet as before, for example `igniteui-webcomponents/themes/light/bootstrap.css`. - Without a condition, the bundle contains all four themes, as before. +## Extras + +`igniteui-webcomponents/extras` has opt-in add-ons that build on the public API of the components. A bundle that does not import them does not contain them. + +- `createMarkdownRenderer()` renders the chat messages from Markdown, with syntax highlighting. +- `bindComboDataSource()` binds a combo to a server that searches and pages. + +```ts +import { bindComboDataSource } from 'igniteui-webcomponents/extras'; + +const binding = bindComboDataSource(combo, { + load: async ({ searchText, startIndex, count }, signal) => { + const query = `q=${encodeURIComponent(searchText)}&skip=${startIndex}&top=${count}`; + const response = await fetch(`/api/cities?${query}`, { signal }); + const { items, total } = await response.json(); + return { items, totalCount: total }; + }, +}); + +binding.dispose(); // removes the listeners and aborts the request in flight +``` + ## Browser support | ![chrome_48x48] | ![firefox_48x48] | ![edge_48x48] | ![opera_48x48] | ![safari_48x48] | diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index c993b0566..24bf2240f 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -293,8 +293,8 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { modes. - In remote mode, `igcDataRequest` comes when the open list renders one of the last five options of `data`. Its `startIndex` is `data.length`, also with grouping, and `count` is the number of items to append. It does not come - again until the length of `data` changes, a new `data` replaces the first record, or a new search text comes, so a - source with no more items gets one request. An empty `data` gets no request: the first page of a search is the job + again until the length of `data` changes, a new `data` replaces the first record, a new search text comes, or the + list opens again, so a source with no more items gets one request per opening. An empty `data` gets no request: the first page of a search is the job of the application. An answer is stale when `data.length` no longer equals its `startIndex`, for example after a new search. - A new `data` keeps the selection. A selected item that the new data does not hold stays in `value`, in `selection` @@ -305,6 +305,9 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { - In remote mode, the search input has no case-sensitive icon, and in single selection the input activates no option of the old results, so Enter does not select an option that the server did not return for the text. +`bindComboDataSource()` from `igniteui-webcomponents/extras` implements this flow on the public API. See the +`RemoteData` story. + ### Localization The component renders several built-in strings, resolved through the library i18n mechanism and overridable per @@ -629,7 +632,8 @@ Generated by `runValidationContainerTests`. each value one time also before a new `data` syncs. 34. `igcDataRequest` comes near the end of `data` with `startIndex` equal to `data.length`, also with grouping. It does not repeat until `data` grows, and a new search, or a new `data` without a search, with a page of the same - length gets its own request. It does not come in local mode, while the list is closed, or with an empty `data`. + length gets its own request, and so does a new opening. It does not come in local mode, while the list is + closed, or with an empty `data`. ## Assumptions and limitations diff --git a/src/extras/combo-data-source.spec.ts b/src/extras/combo-data-source.spec.ts new file mode 100644 index 000000000..1dfb5af1d --- /dev/null +++ b/src/extras/combo-data-source.spec.ts @@ -0,0 +1,453 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, + waitUntil, +} from '@open-wc/testing'; +import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + simulateClick, + simulateInput, +} from '#internals/testing/simulate.spec.js'; +import IgcComboComponent from '../components/combo/combo.js'; +import type IgcInputComponent from '../components/input/input.js'; +import type IgcVirtualScrollComponent from '../components/virtualization/virtualization.js'; +import { + bindComboDataSource, + type ComboDataBinding, + type ComboDataPage, + type ComboDataRequest, + type ComboDataSource, + type ComboDataSourceOptions, +} from './combo-data-source.js'; + +type Place = { id: string; name: string }; + +const PLACES: Place[] = [ + ['AMS', 'Amsterdam'], + ['ATH', 'Athens'], + ['BAR', 'Barcelona'], + ['BEL', 'Belgrade'], + ['BRA', 'Bratislava'], + ['BRU', 'Brussels'], + ['BUC', 'Bucharest'], + ['BUD', 'Budapest'], + ['CPH', 'Copenhagen'], + ['DUB', 'Dublin'], + ['BER', 'Berlin'], + ['BRN', 'Bern'], + ['BGO', 'Bergen'], + ['ALB', 'Alberobello'], + ['HEL', 'Helsinki'], + ['LIS', 'Lisbon'], + ['LON', 'London'], + ['MAD', 'Madrid'], + ['OSL', 'Oslo'], + ['PAR', 'Paris'], + ['PRG', 'Prague'], + ['RIG', 'Riga'], + ['ROM', 'Rome'], + ['SOF', 'Sofia'], +].map(([id, name]) => ({ id, name })); + +/** The results of a search, as new objects, as parsed JSON gives them. */ +function page({ + searchText, + startIndex, + count, +}: ComboDataRequest): ComboDataPage { + const results = PLACES.filter(({ name }) => + name.toLowerCase().includes(searchText.toLowerCase()) + ); + + return { + items: results + .slice(startIndex, startIndex + count) + .map((place) => ({ ...place })), + totalCount: results.length, + }; +} + +type PendingRequest = { + request: ComboDataRequest; + signal: AbortSignal; + answer: () => void; + fail: (error: unknown) => void; +}; + +describe('Combo data source binding', () => { + let combo: IgcComboComponent; + let input: IgcInputComponent; + let searchNative: HTMLInputElement; + let requests: PendingRequest[]; + let source: ComboDataSource; + let binding: ComboDataBinding; + + /** Lets an answer reach the combo, and the combo render it. */ + async function flush(): Promise { + await Promise.resolve(); + await elementUpdated(combo); + await elementUpdated(combo); + } + + /** Waits for the list to render, so that it can request the next page. */ + async function settle(): Promise { + await flush(); + await nextFrame(); + await ( + combo.renderRoot.querySelector( + 'igc-virtual-scroll' + ) as IgcVirtualScrollComponent + ).layoutComplete; + await nextFrame(); + } + + async function answer(index: number): Promise { + requests[index].answer(); + await settle(); + } + + async function open(): Promise { + simulateClick(input); + await elementUpdated(combo); + } + + const bind = (options?: ComboDataSourceOptions) => + (binding = bindComboDataSource(combo, source, { + pageSize: 10, + ...options, + })); + + const ids = (items: Place[]) => items.map(({ id }) => id); + + before(() => { + defineComponents(IgcComboComponent); + }); + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + input = combo.renderRoot.querySelector('igc-input#target')!; + searchNative = combo.renderRoot + .querySelector('[part="search-input"]')! + .renderRoot.querySelector('input')!; + + requests = []; + source = { + load: (request, signal) => + new Promise((resolve, reject) => { + requests.push({ + request, + signal, + answer: () => resolve(page(request)), + fail: reject, + }); + }), + }; + }); + + afterEach(() => { + binding?.dispose(); + }); + + it('sets the remote mode and loads the first page on open', async () => { + bind(); + expect(combo.filteringOptions.mode).to.equal('remote'); + + await open(); + + expect(requests.map(({ request }) => request)).to.eql([ + { searchText: '', startIndex: 0, count: 10 }, + ]); + + await answer(0); + + expect(ids(combo.data)).to.eql(ids(PLACES.slice(0, 10))); + }); + + it('appends the next pages near the end and stops at the total count', async () => { + const list = + combo.renderRoot.querySelector( + 'igc-virtual-scroll' + )!; + + bind(); + await open(); + await answer(0); + await answer(1); + list.scrollToIndex(19); + await settle(); + await answer(2); + list.scrollToIndex(PLACES.length - 1); + await settle(); + + expect(requests.map(({ request }) => request.startIndex)).to.eql([ + 0, 10, 20, + ]); + expect(requests[1].request.count).to.equal(10); + expect(ids(combo.data)).to.eql(ids(PLACES)); + }); + + it('does not load on open with `loadOnOpen: false`', async () => { + bind({ loadOnOpen: false }); + await open(); + + expect(requests).to.be.empty; + }); + + describe('Search', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('waits for a pause in the typing and aborts the request in flight', async () => { + bind({ debounce: 300 }); + await open(); + + simulateInput(searchNative, { value: 'b' }); + simulateInput(searchNative, { value: 'be' }); + simulateInput(searchNative, { value: 'ber ' }); + + expect(requests[0].signal.aborted).to.be.true; + + clock.tick(299); + expect(requests).lengthOf(1); + + clock.tick(1); + expect(requests[1].request).to.eql({ + searchText: 'ber', + startIndex: 0, + count: 10, + }); + + requests[1].answer(); + requests[0].answer(); + await flush(); + + expect(ids(combo.data)).to.eql(['BER', 'BRN', 'BGO', 'ALB']); + }); + + it('keeps the selection across searches', async () => { + bind({ debounce: 0 }); + await open(); + requests[0].answer(); + await flush(); + + combo.select(['AMS', 'ATH']); + simulateInput(searchNative, { value: 'ber' }); + clock.tick(0); + requests.at(-1)!.answer(); + await flush(); + + expect(ids(combo.data)).to.eql(['BER', 'BRN', 'BGO', 'ALB']); + expect(combo.value).to.eql(['AMS', 'ATH']); + expect(ids(combo.selection)).to.eql(['AMS', 'ATH']); + }); + + it('reports a failed request and keeps the items', async () => { + const onError = spy(); + const error = new Error('The server is down.'); + + bind({ debounce: 0, onError }); + await open(); + requests[0].answer(); + await flush(); + + simulateInput(searchNative, { value: 'ber' }); + clock.tick(0); + requests.at(-1)!.fail(error); + await flush(); + + expect(onError).calledOnceWith(error); + expect(ids(combo.data)).to.eql(ids(PLACES.slice(0, 10))); + }); + + it('does not load again for the same trimmed text', async () => { + bind({ debounce: 0 }); + await open(); + + simulateInput(searchNative, { value: 'ber' }); + clock.tick(0); + simulateInput(searchNative, { value: 'ber ' }); + clock.tick(0); + + expect(requests.map(({ request }) => request.searchText)).to.eql([ + '', + 'ber', + ]); + }); + + it('continues the loaded search after a failed search', async () => { + bind({ debounce: 0, onError: () => {} }); + await open(); + requests[0].answer(); + await flush(); + + simulateInput(searchNative, { value: 'ber' }); + clock.tick(0); + requests.at(-1)!.fail(new Error('The server is down.')); + await flush(); + + combo.dispatchEvent( + new CustomEvent('igcDataRequest', { + detail: { startIndex: 10, count: 20 }, + }) + ); + + expect(requests.at(-1)!.request).to.eql({ + searchText: '', + startIndex: 10, + count: 10, + }); + }); + + it('loads a search of a closed list on the next opening', async () => { + bind({ debounce: 0 }); + await open(); + requests[0].answer(); + await flush(); + await combo.hide(); + + combo.dispatchEvent( + new CustomEvent('igcSearchInput', { detail: { searchText: 'ber' } }) + ); + clock.tick(0); + const before = requests.length; + + await open(); + + expect(requests).lengthOf(before + 1); + expect(requests.at(-1)!.request.searchText).to.equal('ber'); + }); + }); + + it('selects the items of preset values one time', async () => { + const resolve = spy(async (values: unknown[]) => + PLACES.filter(({ id }) => values.includes(id)).map((place) => ({ + ...place, + })) + ); + + combo.value = ['OSL', 'AMS']; + source.resolve = resolve; + bind(); + await waitUntil(() => input.value === 'Amsterdam, Oslo'); + + expect(resolve).calledOnce; + expect(resolve.firstCall.args[0]).to.eql(['OSL', 'AMS']); + expect(combo.value).to.have.members(['OSL', 'AMS']); + + await open(); + await answer(0); + + expect(resolve).calledOnce; + expect(combo.value).to.have.members(['OSL', 'AMS']); + }); + + it('selects the items of values set later when the list opens', async () => { + source.resolve = async (values) => + PLACES.filter(({ id }) => values.includes(id)); + bind(); + await open(); + await answer(0); + await combo.hide(); + + combo.value = ['OSL']; + await open(); + await waitUntil(() => input.value === 'Oslo'); + + expect(ids(combo.selection)).to.eql(['OSL']); + }); + + it('loads the first page again on refresh', async () => { + bind(); + await open(); + await answer(0); + + const refresh = binding.refresh(); + + expect(requests.at(-1)!.request).to.eql({ + searchText: '', + startIndex: 0, + count: 10, + }); + + requests.at(-1)!.answer(); + await refresh; + + expect(ids(combo.data)).to.eql(ids(PLACES.slice(0, 10))); + }); + + it('does not select all the items when resolve finds none', async () => { + const resolve = spy(async () => []); + + source.resolve = resolve; + bind(); + await open(); + await answer(0); + await combo.hide(); + + combo.value = ['XXX']; + await open(); + await waitUntil(() => resolve.called); + await flush(); + + expect(combo.selection).to.be.empty; + }); + + it('calls resolve again after a failure', async () => { + const onError = spy(); + let fails = true; + + source.resolve = async (values) => { + if (fails) { + fails = false; + throw new Error('The server is down.'); + } + return PLACES.filter(({ id }) => values.includes(id)); + }; + + combo.value = ['OSL']; + bind({ onError }); + await waitUntil(() => onError.called); + + await open(); + await waitUntil(() => input.value === 'Oslo'); + + expect(ids(combo.selection)).to.eql(['OSL']); + }); + + it('gives the mode back and ignores refresh after dispose', async () => { + bind(); + binding.dispose(); + await binding.refresh(); + + expect(combo.filteringOptions.mode).to.be.undefined; + expect(requests).to.be.empty; + }); + + it('removes its listeners and aborts the request in flight on dispose', async () => { + bind(); + await open(); + + binding.dispose(); + expect(requests[0].signal.aborted).to.be.true; + + await combo.hide(); + await open(); + simulateInput(searchNative, { value: 'ber' }); + await nextFrame(); + + expect(requests).lengthOf(1); + }); +}); diff --git a/src/extras/combo-data-source.ts b/src/extras/combo-data-source.ts new file mode 100644 index 000000000..053e806ee --- /dev/null +++ b/src/extras/combo-data-source.ts @@ -0,0 +1,261 @@ +import { createTimer } from '#internals/timing.js'; +import type IgcComboComponent from '../components/combo/combo.js'; +import type { IgcComboSearchInputEventArgs } from '../components/combo/types.js'; +import type { VirtualScrollDataRequest } from '../components/virtualization/types.js'; + +/** A request for one page of the results of a search. */ +export interface ComboDataRequest { + /** The search text, trimmed. It is empty for all the items. */ + searchText: string; + /** The index in the results of the first item to load. */ + startIndex: number; + /** The number of items to load. The source can return fewer. */ + count: number; +} + +/** One page of the results of a search. */ +export interface ComboDataPage { + /** The items of the page. */ + items: T[]; + /** The number of all the results, when the source knows it. No request goes past it. */ + totalCount?: number; +} + +/** A remote source that searches and pages on the server. */ +export interface ComboDataSource { + /** Loads one page. Pass `signal` to `fetch`, so that a newer search aborts the request. */ + load( + request: ComboDataRequest, + signal: AbortSignal + ): Promise>; + /** + * Loads the items of the selected values that the loaded pages do not hold, + * such as the values of a saved filter. It runs when the binding starts, + * after each load and when the list opens, one time per value. It needs a + * `valueKey` on the combo. + */ + resolve?(values: unknown[], signal: AbortSignal): Promise; +} + +export interface ComboDataSourceOptions { + /** The pause in the typing, in milliseconds, before a search loads. Default is `300`. */ + debounce?: number; + /** The number of items that each request loads. Default is `50`. */ + pageSize?: number; + /** + * Whether the first page loads when the user opens the list with no items. + * `show()` emits no events, so call `refresh()` after it. Default is `true`. + */ + loadOnOpen?: boolean; + /** Gets the error of a failed request. The combo keeps its items. */ + onError?: (error: unknown) => void; +} + +export interface ComboDataBinding { + /** Loads the first page of the current search text again. */ + refresh(): Promise; + /** + * Removes the listeners, aborts the requests in flight, and gives the combo + * back its filtering mode. + */ + dispose(): void; +} + +/** + * Binds a combo to a remote source. The binding sets `filteringOptions.mode` + * to `remote`. It loads the first page when the list opens with no items and + * after a pause in the typing, and appends the next page on `igcDataRequest`, + * up to the total count of the source. A newer search aborts the older + * requests, and an answer for items that changed since is dropped. + * + * @example + * ```ts + * const binding = bindComboDataSource(combo, { + * load: async ({ searchText, startIndex, count }, signal) => { + * const query = `q=${encodeURIComponent(searchText)}&skip=${startIndex}&top=${count}`; + * const response = await fetch(`/api/cities?${query}`, { signal }); + * const { items, total } = await response.json(); + * return { items, totalCount: total }; + * }, + * }); + * ``` + */ +export function bindComboDataSource( + combo: IgcComboComponent, + source: ComboDataSource, + { + debounce = 300, + pageSize = 50, + loadOnOpen = true, + onError, + }: ComboDataSourceOptions = {} +): ComboDataBinding { + const disposal = new AbortController(); + const search = createTimer(() => void load(0), debounce); + const { mode } = combo.filteringOptions; + // The values asked for: in flight, or not found by the source. + const requested = new Set(); + // The search of the next first page, and the search of the items in `data`. + let searchText = ''; + let loaded = { searchText, totalCount: Number.POSITIVE_INFINITY }; + // Whether the search text changed while the list was closed. + let stale = false; + let request: AbortController | undefined; + + const busy = (): boolean => request !== undefined || search.active; + + /** Loads a page. A newer load aborts it, so only the latest one applies. */ + async function load(startIndex: number): Promise { + search.stop(); + request?.abort(); + const current = new AbortController(); + request = current; + // A next page continues the search of the loaded items. + const text = startIndex ? loaded.searchText : searchText; + + try { + const page = await source.load( + { searchText: text, startIndex, count: pageSize }, + current.signal + ); + + // A next page applies only to the items that it was requested for. + if ( + current.signal.aborted || + (startIndex && combo.data.length !== startIndex) + ) { + return; + } + + loaded = { + searchText: text, + totalCount: page.totalCount ?? Number.POSITIVE_INFINITY, + }; + combo.data = startIndex ? [...combo.data, ...page.items] : page.items; + } catch (error) { + if (!current.signal.aborted) { + onError?.(error); + } + return; + } finally { + if (request === current) { + request = undefined; + } + } + + await resolveMissing(); + } + + /** Selects the items of the values that have none. */ + async function resolveMissing(): Promise { + const { valueKey } = combo; + + if (!(source.resolve && valueKey)) { + return; + } + + // The combo matches the selection to a new `data` in its update. + await combo.updateComplete; + + if (combo.value.length === combo.selection.length) { + return; + } + + const known = new Set( + combo.selection.map((item) => item[valueKey]) + ); + const missing = combo.value.filter( + (value) => !(known.has(value) || requested.has(value)) + ); + + if (!missing.length) { + return; + } + + for (const value of missing) { + requested.add(value); + } + + try { + const items = await source.resolve(missing, disposal.signal); + + if (disposal.signal.aborted) { + return; + } + + // A found value can be missing again later, for example after a deselection. + for (const item of items) { + requested.delete(item[valueKey]); + } + + // `select()` with no items selects all of them. + if (items.length) { + combo.select(items); + } + } catch (error) { + for (const value of missing) { + requested.delete(value); + } + + if (!disposal.signal.aborted) { + onError?.(error); + } + } + } + + const handleSearchInput = ({ + detail, + }: CustomEvent): void => { + const text = detail.searchText.trim(); + + if (text === searchText) { + return; + } + + searchText = text; + request?.abort(); + + // A search in a closed list waits for the next opening. + if (combo.open) { + search.start(); + } else { + search.stop(); + stale = true; + } + }; + + const handleDataRequest = ({ + detail, + }: CustomEvent): void => { + // A pending search replaces the items, and nothing comes after `totalCount`. + if (!busy() && detail.startIndex < loaded.totalCount) { + void load(detail.startIndex); + } + }; + + const handleOpening = (): void => { + if ((stale || (loadOnOpen && !combo.data.length)) && !busy()) { + void load(0); + } + + stale = false; + void resolveMissing(); + }; + + const listener = { signal: disposal.signal }; + combo.addEventListener('igcSearchInput', handleSearchInput, listener); + combo.addEventListener('igcDataRequest', handleDataRequest, listener); + combo.addEventListener('igcOpening', handleOpening, listener); + combo.filteringOptions = { mode: 'remote' }; + void resolveMissing(); + + return { + refresh: () => (disposal.signal.aborted ? Promise.resolve() : load(0)), + dispose() { + search.stop(); + request?.abort(); + disposal.abort(); + combo.filteringOptions = { mode }; + }, + }; +} diff --git a/src/extras/index.ts b/src/extras/index.ts index bbcf52e3d..541a326aa 100644 --- a/src/extras/index.ts +++ b/src/extras/index.ts @@ -1 +1,2 @@ export * from './chat-markdown-renderer.js'; +export * from './combo-data-source.js'; diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 9a4b8e0a0..90d964877 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -9,11 +9,14 @@ import { IgcCircularProgressComponent, type IgcComboChangeEventArgs, IgcComboComponent, - type IgcComboSearchInputEventArgs, IgcInputComponent, - type VirtualScrollDataRequest, defineComponents, } from 'igniteui-webcomponents'; +import { + bindComboDataSource, + type ComboDataBinding, + type ComboDataSource, +} from 'igniteui-webcomponents/extras'; import { registerMaterialIcons } from './story-icons.js'; import { delay, @@ -958,18 +961,23 @@ const europeanCities: City[] = ( ] as const ).map(([id, name, country]) => ({ id, name, country })); -const cityPageSize = 10; - -/** A fake server. It returns one page of new objects, as parsed JSON does. */ -async function fetchCities(searchText: string, skip: number): Promise { +/** A fake server. It searches the names and returns new objects, as parsed JSON does. */ +async function fetchCities( + searchText: string, + skip: number, + top: number +): Promise<{ items: City[]; total: number }> { await delay(400); const text = searchText.toLocaleLowerCase(); + const results = europeanCities.filter(({ name }) => + name.toLocaleLowerCase().includes(text) + ); - return europeanCities - .filter(({ name }) => name.toLocaleLowerCase().includes(text)) - .slice(skip, skip + cityPageSize) - .map((city) => ({ ...city })); + return { + items: results.slice(skip, skip + top).map((city) => ({ ...city })), + total: results.length, + }; } export const RemoteData: Story = { @@ -978,56 +986,46 @@ export const RemoteData: Story = { docs: { description: { story: - 'A city picker that searches and pages on a server, ten cities at a time. With `filteringOptions.mode` set to `remote`, the combo does not filter the options. `igcOpening` loads the first page, `igcSearchInput` loads the first page of the search after a 300 ms pause in the typing, and `igcDataRequest` appends the next page when the list scrolls near its end. A newer search drops the answers to older requests. Select a few cities, then search for others: the selection stays, also for the cities that the new results do not hold. The log shows each request and answer.', + 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, ten cities at a time. Open the list, scroll to its end to load more, then type to search. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The log shows each request and answer.', }, }, }, render: () => { - const combo = createRef>(); const log = createRef(); - let searchText = ''; - let search = 0; - let timer: ReturnType | undefined; + let binding: ComboDataBinding | undefined; const write = (line: string) => { const item = document.createElement('li'); item.textContent = line; - log.value!.prepend(item); - }; - - // The server answers with one page, whatever `count` the combo asks for. - const load = async (skip: number) => { - const current = search; - write(`Request: "${searchText}" from item ${skip + 1}`); - - const page = await fetchCities(searchText, skip); - - const target = combo.value!; - - // Drop the answer to an older search, or for a `data` that changed since. - if (current !== search || (skip && target.data.length !== skip)) { - return; - } - - target.data = skip ? [...target.data, ...page] : page; - write(`Answer: ${page.length} cities, ${target.data.length} loaded`); + log.value?.prepend(item); }; - const onOpening = () => { - if (!combo.value!.data.length) { - void load(0); - } - }; - - const onSearch = (event: CustomEvent) => { - searchText = event.detail.searchText.trim(); - search++; - clearTimeout(timer); - timer = setTimeout(() => load(0), 300); + const source: ComboDataSource = { + load: async ({ searchText, startIndex, count }, signal) => { + write(`Request: "${searchText}", ${count} from item ${startIndex + 1}`); + + const { items, total } = await fetchCities( + searchText, + startIndex, + count + ); + + write( + signal.aborted + ? `Aborted: "${searchText}"` + : `Answer: ${items.length} of ${total} cities` + ); + return { items, totalCount: total }; + }, }; - const onRequest = (event: CustomEvent) => { - void load(event.detail.startIndex); + const bind = (element?: Element) => { + binding?.dispose(); + binding = element + ? bindComboDataSource(element as IgcComboComponent, source, { + pageSize: 10, + }) + : undefined; }; const onChange = (event: CustomEvent) => { @@ -1048,15 +1046,11 @@ export const RemoteData: Story = {

    Cities from the server

    No city matches your search. From 783b44ac1902ac26b8a9b878b6a343b6e2099c3f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 07:43:36 +0200 Subject: [PATCH 06/32] feat(combo): add selectAllItems and deselectAllItems Refs #2535. --- CHANGELOG.md | 1 + public-api.json | 2 + src/components/combo/combo.spec.ts | 74 ++++++++++++++++++++++++++++++ src/components/combo/combo.ts | 66 ++++++++++++++++++++++++-- src/components/combo/spec.md | 9 ++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a452f60..66e9d94b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input diff --git a/public-api.json b/public-api.json index 55a48e774..58e0a2b3f 100644 --- a/public-api.json +++ b/public-api.json @@ -1265,10 +1265,12 @@ "blur": "() => void", "checkValidity": "() => boolean", "deselect": "(items?: Item | Item[]) => void", + "deselectAllItems": "(ignoreFilter?: boolean) => void", "focus": "(options?: FocusOptions) => void", "hide": "() => Promise", "reportValidity": "() => boolean", "select": "(items?: Item | Item[]) => void", + "selectAllItems": "(ignoreFilter?: boolean) => void", "setCustomValidity": "(message: string) => void", "show": "() => Promise", "static register": "() => void", diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 5fa7b7dd0..9f97943f4 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -2693,6 +2693,80 @@ describe('Combo', () => { }); }); + describe('Select all methods', () => { + async function search(text: string): Promise { + searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); + await elementUpdated(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + }); + + it('selects and deselects the items that the list shows', async () => { + const eventSpy = spy(combo, 'emitEvent'); + + await search('o'); + combo.selectAllItems(); + + expect(combo.value).to.eql(['BG01', 'BG02', 'US01', 'US02', 'US03']); + + await search('s'); + combo.deselectAllItems(); + + expect(combo.value).to.eql(['BG02', 'US01']); + expect(eventSpy).not.calledWith('igcChange'); + }); + + it('selects and deselects all the items with `ignoreFilter`', async () => { + await search('sof'); + combo.selectAllItems(true); + + expect(combo.value).lengthOf(cities.length); + + combo.deselectAllItems(true); + + expect(combo.value).to.be.empty; + }); + + it('changes nothing when the list shows no items', async () => { + combo.select('BG01'); + await search('xyz'); + + combo.selectAllItems(); + combo.deselectAllItems(); + + expect(combo.value).to.eql(['BG01']); + }); + + it('acts on a new data at once', () => { + combo.data = cities.slice(0, 2); + combo.selectAllItems(); + + expect(combo.value).to.eql(['BG01', 'BG02']); + }); + + it('selects nothing in single selection, and deselects a shown item', async () => { + combo.singleSelect = true; + combo.select('US02'); + await elementUpdated(combo); + + combo.selectAllItems(); + expect(combo.value).to.eql(['US02']); + + combo.deselectAllItems(); + expect(combo.value).to.be.empty; + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 155ad7b3f..f6c03702a 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -871,11 +871,26 @@ export default class IgcComboComponent< ? this.selection : this._resolveItems(collection); + return this._applySelection(resolved, type, emit); + } + + /** + * Selects or deselects exactly `records`. With `emit`, `igcChange` can + * cancel the change. + * @returns Whether the change was committed. + */ + private _applySelection( + records: T[], + type: 'selection' | 'deselection', + emit: boolean + ): boolean { + const selecting = type === 'selection'; + if ( emit && !this._emitSelectionChange({ - newValue: this._previewValue(resolved, selecting), - items: resolved, + newValue: this._previewValue(records, selecting), + items: records, type, }) ) { @@ -883,12 +898,12 @@ export default class IgcComboComponent< } // The change is committed, so drop the previous single selection and search term. - if (selecting && singleSelect) { + if (selecting && this.singleSelect) { this._selected.clear(); this._clearSearchTerm(emit); } - for (const item of resolved) { + for (const item of records) { selecting ? this._selected.add(item) : this._selected.delete(item); } @@ -940,6 +955,16 @@ export default class IgcComboComponent< this._listRef.value?.requestUpdate(); } + /** The items that the list shows, without the group headers. */ + private _shownItems(): T[] { + // Applies a change of `data` or of the search text that no update has applied yet. + this._state.hostUpdate(); + + return this._state.dataState + .filter((record) => !record.header) + .map((record) => record.value); + } + /** * The record at `index`, or undefined for a group header. * Guard on it: {@link _updateSelection} reads an empty collection as "select all". @@ -1169,6 +1194,39 @@ export default class IgcComboComponent< this._syncValueFromSelection(); } + /** + * Selects all the items that the list shows. It does not emit `igcChange`, + * and does nothing in single selection mode. + * @param {boolean} [ignoreFilter=false] - Whether to select all the items of `data`. + */ + public selectAllItems(ignoreFilter = false): void { + if (this.singleSelect) { + return; + } + + if (ignoreFilter) { + this.select(); + return; + } + + this._applySelection(this._shownItems(), 'selection', false); + this._syncValueFromSelection(); + } + + /** + * Deselects all the items that the list shows. It does not emit `igcChange`. + * @param {boolean} [ignoreFilter=false] - Whether to deselect all the items. + */ + public deselectAllItems(ignoreFilter = false): void { + if (ignoreFilter) { + this.deselect(); + return; + } + + this._applySelection(this._shownItems(), 'deselection', false); + this._syncValueFromSelection(); + } + //#endregion protected _itemRenderer: ComboRenderFunction = ( diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 24bf2240f..fd7078f4d 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -63,6 +63,7 @@ | 11 | 2026-10-26 | Tab moves through the focusable content of the header, empty and footer slots | | 12 | 2026-10-26 | `igcClosing` and `igcClosed` report the `trigger` that closes the list | | 13 | 2026-10-26 | Remote data: `igcSearchInput`, `filteringOptions.mode`, `igcDataRequest`; a new `data` keeps the selection | +| 14 | 2026-10-27 | `selectAllItems()` and `deselectAllItems()` act on the items that the list shows | ## Overview @@ -425,6 +426,8 @@ type ComboGroupHeaderTemplate = (props: { | toggle | `(): Promise` | Opens the component when it is closed, and closes it when it is open. It does not emit events. The promise resolves to `true` when the state changes. | | select | `(items?: Item \| Item[]): void` | Selects options in the list by reference or by `valueKey` value. It does not emit `igcChange`. An object that `data` does not hold is selected as it is. Without an argument, it selects all the items of `data`, or clears the selection in single selection mode. | | deselect | `(items?: Item \| Item[]): void` | Deselects options in the list by reference or by `valueKey` value. It does not emit `igcChange`. Without an argument, it deselects all items. | +| selectAllItems | `(ignoreFilter?: boolean): void` | Selects all the items that the list shows. It does not emit `igcChange`, and does nothing in single selection mode. With `ignoreFilter`, it selects all the items of `data`. | +| deselectAllItems | `(ignoreFilter?: boolean): void` | Deselects all the items that the list shows. It does not emit `igcChange`. With `ignoreFilter`, it deselects all the items. | | focus | `(options?: FocusOptions): void` | Sets focus on the component. | | blur | `(): void` | Removes focus from the component. | | checkValidity | `(): boolean` | Checks validity and emits `invalid` when the control is invalid. Returns `true` when the control is valid. | @@ -635,6 +638,12 @@ Generated by `runValidationContainerTests`. length gets its own request, and so does a new opening. It does not come in local mode, while the list is closed, or with an empty `data`. +### Select all methods + +35. `selectAllItems()` and `deselectAllItems()` change only the items that the filtered list shows, also right after + a change of `data` or of the search text, emit no `igcChange`, and change nothing when the list shows no items. With `ignoreFilter`, they act on all the items. In + single selection, `selectAllItems()` does nothing and `deselectAllItems()` deselects a shown item. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. From e533746afd4b9c8bb226a53ceddabf47bc9c4158 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 10:58:38 +0200 Subject: [PATCH 07/32] chore(deps): update igniteui-i18n-core and igniteui-i18n-resources to 2.0.0-rc.2 The combo needs the combo_loading and combo_select_all strings. --- package-lock.json | 20 ++++++++++---------- package.json | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d293b657..6083349f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@floating-ui/dom": "^1.8.0", "@lit/context": "^1.1.6", - "igniteui-i18n-core": "^2.0.0-rc.1", + "igniteui-i18n-core": "^2.0.0-rc.2", "lit": "^3.3.3" }, "devDependencies": { @@ -38,7 +38,7 @@ "fast-check": "^4.10.2", "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", - "igniteui-i18n-resources": "^2.0.0-rc.1", + "igniteui-i18n-resources": "^2.0.0-rc.2", "igniteui-theming": "^30.0.0", "keep-a-changelog": "^3.2.0", "lint-staged": "^17.6.0", @@ -8801,12 +8801,12 @@ } }, "node_modules/igniteui-i18n-core": { - "version": "2.0.0-rc.1", - "resolved": "https://registry.npmjs.org/igniteui-i18n-core/-/igniteui-i18n-core-2.0.0-rc.1.tgz", - "integrity": "sha512-pW0DCH6wtuh1gx6joehXit946GaRS3GSA/8MBDMlUf1hMQ575afyGtBmzkFqDSd5G0eOYb2xX9MzQ2zSo0+x4w==", + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/igniteui-i18n-core/-/igniteui-i18n-core-2.0.0-rc.2.tgz", + "integrity": "sha512-a0rqmHT1nlY0LpVBB9++/B1gAnvDsSPinw5F2y/cARDv3sHpODMlKTkrn2Uzyi52VYu6pthEhBFebMjUmAmLbA==", "license": "MIT", "peerDependencies": { - "igniteui-i18n-resources": "2.0.0-rc.1" + "igniteui-i18n-resources": "2.0.0-rc.2" }, "peerDependenciesMeta": { "igniteui-i18n-resources": { @@ -8815,13 +8815,13 @@ } }, "node_modules/igniteui-i18n-resources": { - "version": "2.0.0-rc.1", - "resolved": "https://registry.npmjs.org/igniteui-i18n-resources/-/igniteui-i18n-resources-2.0.0-rc.1.tgz", - "integrity": "sha512-y88+6cBJ605d7cvTbMh0p6gjxt1v6PovAV6wRgF0kHu80fCNY29qZnGE7p5qTYwS15AFgy9MogXLu/6dt5d77w==", + "version": "2.0.0-rc.2", + "resolved": "https://registry.npmjs.org/igniteui-i18n-resources/-/igniteui-i18n-resources-2.0.0-rc.2.tgz", + "integrity": "sha512-jPepzXNUUdAN+Du0xCqN1ov9uogQBx7f5vYuKvzfAKWAbA7ldOqkOrsI3dJ1gtlAECPoD3gBTwD6j3WVsNZHHQ==", "devOptional": true, "license": "MIT", "dependencies": { - "igniteui-i18n-core": "2.0.0-rc.1" + "igniteui-i18n-core": "2.0.0-rc.2" } }, "node_modules/igniteui-theming": { diff --git a/package.json b/package.json index 46c377cde..60d0274ab 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "dependencies": { "@floating-ui/dom": "^1.8.0", "@lit/context": "^1.1.6", - "igniteui-i18n-core": "^2.0.0-rc.1", + "igniteui-i18n-core": "^2.0.0-rc.2", "lit": "^3.3.3" }, "devDependencies": { @@ -97,7 +97,7 @@ "fast-check": "^4.10.2", "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", - "igniteui-i18n-resources": "^2.0.0-rc.1", + "igniteui-i18n-resources": "^2.0.0-rc.2", "igniteui-theming": "^30.0.0", "keep-a-changelog": "^3.2.0", "lint-staged": "^17.6.0", From e30b992b4545f2321da9787a653df4923eaafa4d Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 10:59:12 +0200 Subject: [PATCH 08/32] feat(combo): add totalItemCount and loading In remote mode, totalItemCount adds a placeholder option for each item to load, and igcDataRequest asks for the rendered placeholders, up to the total. loading shows a progress bar that the loading slot can replace. Refs #2519. --- CHANGELOG.md | 1 + public-api.json | 7 + src/components/combo/combo.spec.ts | 192 ++++++++++++++++++ src/components/combo/combo.ts | 110 ++++++++-- src/components/combo/controllers/data.ts | 60 +++++- .../combo/controllers/navigation.ts | 34 ++-- src/components/combo/spec.md | 51 ++++- src/components/combo/themes/combo.base.scss | 59 ++++++ src/components/combo/types.ts | 2 + 9 files changed, 469 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66e9d94b4..ec3668299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. `loading` shows a progress bar above the list and hides the empty message. The `loading` slot replaces the bar, and the `loading` and `placeholder` parts style them. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) diff --git a/public-api.json b/public-api.json index 58e0a2b3f..ceff7a1b1 100644 --- a/public-api.json +++ b/public-api.json @@ -1212,6 +1212,7 @@ "group-sorting": "\"asc\" | \"desc\" | \"none\" ('asc' | 'desc' | 'none')", "invalid": "boolean", "label": "string | undefined", + "loading": "boolean", "locale": "string", "name": "string", "open": "boolean", @@ -1221,6 +1222,7 @@ "required": "boolean", "scroll-strategy": "PopoverScrollStrategy ('close' | 'hide' | 'scroll')", "single-select": "boolean", + "total-item-count": "number | undefined", "value": "ComboValue[]", "value-key": "Keys | undefined" }, @@ -1242,6 +1244,7 @@ "invalid": "boolean", "itemTemplate": "ComboItemTemplate", "label": "string | undefined", + "loading": "boolean", "locale": "string", "name": "string", "open": "boolean", @@ -1255,6 +1258,7 @@ "singleSelect": "boolean", "static formAssociated": "readonly boolean", "static tagName": "readonly string", + "totalItemCount": "number | undefined", "validationMessage": "readonly string", "validity": "readonly ValidityState", "value": "ComboValue[]", @@ -1293,6 +1297,7 @@ "header", "helper-text", "invalid", + "loading", "prefix", "suffix", "toggle-icon", @@ -1317,7 +1322,9 @@ "label", "list", "list-wrapper", + "loading", "native-input", + "placeholder", "prefix", "search-input", "selected", diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 9f97943f4..4cb978b06 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -2110,6 +2110,27 @@ describe('Combo', () => { expectFocus(cancel); }); + it('reaches the content of the loading slot before the header while loading', async () => { + const stop = document.createElement('button'); + stop.slot = 'loading'; + stop.textContent = 'Stop'; + combo.append(stop); + combo.loading = true; + await elementUpdated(combo); + + await tab(); + expectFocus(stop); + + await tab(); + expectFocus(header); + + combo.loading = false; + await elementUpdated(combo); + await shiftTab(); + + expectFocus(searchInput); + }); + it('moves out of a stop inside another stop', async () => { const wrapper = document.createElement('div'); const inner = document.createElement('button'); @@ -2691,6 +2712,177 @@ describe('Combo', () => { expect(details('igcDataRequest')).to.be.empty; }); + + describe('Placeholders and loading', () => { + const placeholders = () => + items(remote).filter((item) => item.part.contains('placeholder')); + + const loadingPart = () => + remote.renderRoot.querySelector('[part="loading"]')!; + + it('pads the list to `totalItemCount` with placeholder options in remote mode', async () => { + remote.totalItemCount = 12; + await openComboPopover(remote); + list.scrollToIndex(11); + await comboStable(remote); + + const [first, second] = placeholders(); + + expect(items(remote)).lengthOf(12); + expect(placeholders()).lengthOf(2); + expect(items(remote)[0].getAttribute('aria-setsize')).to.equal('12'); + expect(first.getAttribute('aria-posinset')).to.equal('11'); + expect(second.getAttribute('aria-posinset')).to.equal('12'); + expect(second.getAttribute('aria-setsize')).to.equal('12'); + expect(second.getAttribute('aria-disabled')).to.equal('true'); + expect(second.getAttribute('aria-label')).to.equal('Loading'); + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 2 }, + ]); + + await expect(remote).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + for (const total of [Number.POSITIVE_INFINITY, Number.NaN]) { + remote.totalItemCount = total; + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + } + + remote.totalItemCount = 12; + remote.filteringOptions = { mode: 'local' }; + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + }); + + it('shows the empty message, not placeholders, for an empty data', async () => { + remote.data = []; + remote.totalItemCount = 12; + await openComboPopover(remote); + + expect(items(remote)).to.be.empty; + expect( + remote.renderRoot.querySelector('[part="empty"]')!.checkVisibility() + ).to.be.true; + }); + + it('requests more items when `totalItemCount` no longer stops them', async () => { + remote.totalItemCount = 10; + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.be.empty; + + // No placeholders come, so the list reports no change. + remote.totalItemCount = undefined; + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + ]); + }); + + it('requests the rendered placeholders, up to `totalItemCount`', async () => { + remote.totalItemCount = 100; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + await comboStable(remote); + + remote.data = [...remote.data, ...search('', 10, 5)]; + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 15, count: 85 }, + ]); + + remote.totalItemCount = 20; + remote.data = [...remote.data, ...search('', 15, 5)]; + await comboStable(remote); + + expect(details('igcDataRequest')).lengthOf(2); + }); + + it('does not select a placeholder', async () => { + remote.totalItemCount = 12; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + simulateKeyboard(list, spaceBar); + simulateClick(placeholders().at(-1)!); + await elementUpdated(remote); + + expect(remote.value).to.be.empty; + + remote.selectAllItems(); + + expect(remote.value).to.eql(search('').map(({ id }) => id)); + }); + + it('keeps the single selection list open on Enter or a click on a placeholder', async () => { + remote.singleSelect = true; + remote.totalItemCount = 12; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + simulateKeyboard(list, enterKey); + simulateClick(placeholders().at(-1)!); + await elementUpdated(remote); + + expect(details('igcClosing')).to.be.empty; + expect(remote.open).to.be.true; + }); + + it('shows a progress bar and hides the empty message while loading', async () => { + const empty = remote.renderRoot.querySelector('[part="empty"]')!; + const bar = loadingPart().querySelector('[role="progressbar"]')!; + + remote.data = []; + remote.loading = true; + await openComboPopover(remote); + + expect(remote).to.have.attribute('loading'); + expect(bar.checkVisibility()).to.be.true; + expect(bar.getAttribute('aria-label')).to.equal('Loading'); + expect(empty.checkVisibility()).to.be.false; + + await expect(remote).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + remote.loading = false; + await elementUpdated(remote); + + expect(bar.checkVisibility()).to.be.false; + expect(empty.checkVisibility()).to.be.true; + }); + + it('does not move the list when the default bar shows', async () => { + await openComboPopover(remote); + const { top } = list.getBoundingClientRect(); + + remote.loading = true; + await elementUpdated(remote); + + expect(list.getBoundingClientRect().top).to.equal(top); + }); + + it('replaces the progress bar with the `loading` slot', async () => { + const custom = document.createElement('span'); + custom.slot = 'loading'; + remote.append(custom); + remote.loading = true; + await openComboPopover(remote); + + const slot = loadingPart().querySelector('slot')!; + + expect(slot.assignedElements()).to.eql([custom]); + expect(custom.checkVisibility()).to.be.true; + }); + }); }); describe('Select all methods', () => { diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index f6c03702a..c0b0f55b5 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -42,7 +42,7 @@ import IgcVirtualScrollComponent, { } from '../virtualization/virtualization.js'; import IgcComboHeaderComponent from './combo-header.js'; import IgcComboItemComponent from './combo-item.js'; -import { DataState } from './controllers/data.js'; +import { DataState, isItemRecord } from './controllers/data.js'; import { ComboNavigationController } from './controllers/navigation.js'; import { styles } from './themes/combo.base.css.js'; import { styles as shared } from './themes/shared/combo.common.css.js'; @@ -69,6 +69,7 @@ const SLOTS = setSlots( 'header', 'footer', 'empty', + 'loading', 'helper-text', 'toggle-icon', 'clear-icon', @@ -96,6 +97,7 @@ const DATA_REQUEST_COUNT = 20; * @slot header - Renders a container before the list of options of the combo. * @slot footer - Renders a container after the list of options of the combo. * @slot empty - Renders content when the combo dropdown list has no items/data. + * @slot loading - Renders content that replaces the default progress bar while `loading` is set. * @slot helper-text - Renders content below the input of the combo. * @slot toggle-icon - Renders content that replaces the default toggle icon. * @slot clear-icon - Renders content that replaces the default clear icon. @@ -109,7 +111,7 @@ const DATA_REQUEST_COUNT = 20; * @fires igcClosing - Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. * @fires igcClosed - Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. * @fires igcSearchInput - Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. - * @fires igcDataRequest - Emitted in remote filtering mode when the user scrolls near the end of the options. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes, or the list opens again. + * @fires igcDataRequest - Emitted in remote filtering mode when the user scrolls near the end of the options, or to a placeholder of `totalItemCount`. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes, or the list opens again. * * @csspart label - The encapsulated text label of the combo. * @csspart input - The main input field of the combo. @@ -135,6 +137,8 @@ const DATA_REQUEST_COUNT = 20; * @csspart header - The container holding the header content of the combo. * @csspart footer - The container holding the footer content of the combo. * @csspart empty - The container holding the empty content of the combo. + * @csspart loading - The container of the progress bar that shows while `loading` is set. + * @csspart placeholder - Added to the item part of each item to load, which `totalItemCount` adds. Its bar has the color of the text. * * @cssproperty --ig-helper-min-lines - The minimum number of lines below the control for the helper text and the validation messages: `1`, `2` or `3`. A message that shows then uses space that is already there, so the content below does not move. Set it on a container, such as a form, to apply it to all the controls in it. Default is `0`. * @@ -471,6 +475,24 @@ export default class IgcComboComponent< @property({ type: Boolean, attribute: 'disable-clear' }) public disableClear = false; + /** + * The number of all the items of a remote source, when the source knows it. + * In remote filtering mode, the list shows a placeholder for each item that + * `data` does not hold yet, so the scrollbar shows the full list. + * @attr total-item-count + */ + @property({ type: Number, attribute: 'total-item-count' }) + public totalItemCount?: number; + + /** + * Whether the combo loads options. The list then shows a progress bar, + * which the `loading` slot can replace, and hides the empty message. + * @attr loading + * @default false + */ + @property({ type: Boolean, reflect: true }) + public loading = false; + /* blazorSuppress */ /** * The template used for the content of each combo item. @@ -582,6 +604,7 @@ export default class IgcComboComponent< props.has('groupKey') || props.has('groupSorting') || props.has('filteringOptions') || + props.has('totalItemCount') || props.has('data') ) { this._state.invalidate(); @@ -609,7 +632,7 @@ export default class IgcComboComponent< // The list reports a changed window after its own update, and an // unchanged one not at all, so check against the window after it. - if (opened || props.has('data')) { + if (opened || props.has('data') || props.has('totalItemCount')) { void this._listRef.value?.updateComplete.then(() => this._checkDataRequest() ); @@ -680,26 +703,37 @@ export default class IgcComboComponent< } /** - * Requests more data while the open list renders near the end of `data`. - * An empty `data` waits for the first page from the application. + * Requests more data while the open list renders near the end of `data`, + * or renders placeholders, up to `totalItemCount`. The `count` covers the + * rendered placeholders. An empty `data` waits for the first page from the + * application. */ private _checkDataRequest(): void { const { length } = this.data; + const { loadedLength, totalCount } = this._state; if ( !this.open || !length || this.filteringOptions.mode !== 'remote' || this._lastDataRequest === length || - this._lastRenderedIndex < - this._state.dataState.length - REMOTE_SCROLLING_THRESHOLD + length >= totalCount || + this._lastRenderedIndex < loadedLength - REMOTE_SCROLLING_THRESHOLD ) { return; } + const rendered = this._lastRenderedIndex - loadedLength + 1; + this._lastDataRequest = length; this.emitEvent('igcDataRequest', { - detail: { startIndex: length, count: DATA_REQUEST_COUNT }, + detail: { + startIndex: length, + count: Math.min( + totalCount - length, + Math.max(DATA_REQUEST_COUNT, rendered) + ), + }, }); } @@ -955,31 +989,34 @@ export default class IgcComboComponent< this._listRef.value?.requestUpdate(); } - /** The items that the list shows, without the group headers. */ + /** The items that the list shows, without the group headers and the placeholders. */ private _shownItems(): T[] { // Applies a change of `data` or of the search text that no update has applied yet. this._state.hostUpdate(); return this._state.dataState - .filter((record) => !record.header) + .filter(isItemRecord) .map((record) => record.value); } /** - * The record at `index`, or undefined for a group header. + * The item at `index`, or undefined for a group header or a placeholder. * Guard on it: {@link _updateSelection} reads an empty collection as "select all". */ private _recordAt(index: number): T | undefined { const record = this._state.dataState[index]; - return record && !record.header ? record.value : undefined; + return isItemRecord(record) ? record.value : undefined; } - /** Selects the record at `index`. With `toggle`, deselects a selected one. */ - private _selectAt(index: number, toggle: boolean): void { + /** + * Selects the record at `index`. With `toggle`, deselects a selected one. + * @returns Whether `index` holds an item. + */ + private _selectAt(index: number, toggle: boolean): boolean { const record = this._recordAt(index); if (!record) { - return; + return false; } this._updateSelection( @@ -990,6 +1027,7 @@ export default class IgcComboComponent< this._activeIndex = index; this._syncValueFromSelection(); + return true; } private _clearSelection(): void { @@ -1086,7 +1124,11 @@ export default class IgcComboComponent< } this._setTouchedState(); - this._selectAt(target.index, true); + + // A placeholder selects nothing, so a click keeps the list open. + if (!this._selectAt(target.index, true)) { + return; + } if (this.singleSelect) { this._inputRef.value?.focus(); @@ -1244,20 +1286,33 @@ export default class IgcComboComponent< const active = this._activeIndex === index; const selected = this._selected.has(item.value); + // One template for both, so a row keeps its element when its item loads. + const placeholder = item.kind === 'placeholder'; + const { itemCount } = this._state; + // The placeholders are last and share one record, so the index gives their position. + const position = placeholder + ? itemCount - context.count + index + 1 + : item.position; return html` - ${this.itemTemplate({ item: item.value })} + ${ + placeholder + ? html`` + : this.itemTemplate({ item: item.value }) + } `; }; @@ -1366,7 +1421,10 @@ export default class IgcComboComponent< private _renderEmptyTemplate() { return html` -
    +
    ${this.resourceStrings.combo_empty_message}
    `; @@ -1376,6 +1434,14 @@ export default class IgcComboComponent< return html`
    ${this._renderSearchInput()} +
    + + + +
    diff --git a/src/components/combo/controllers/data.ts b/src/components/combo/controllers/data.ts index 17723d405..ec562bf27 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -11,6 +11,21 @@ import type { Keys, } from '../types.js'; +/** The record of each item to load. See `totalItemCount`. */ +const PLACEHOLDER: ComboRecord = Object.freeze({ + value: undefined as never, + header: false, + position: -1, + kind: 'placeholder', +}); + +/** Whether the record holds an item: not a group header or a placeholder. */ +export function isItemRecord( + record?: ComboRecord +): boolean { + return !!record && !record.header && !record.kind; +} + /* blazorSuppress */ export class DataState implements ReactiveController { //#region Internal state @@ -25,6 +40,7 @@ export class DataState implements ReactiveController { private _dataState: ComboRecord[] = []; private _itemCount = 0; + private _loadedLength = 0; private _searchTerm = ''; private _dirty = true; @@ -37,12 +53,29 @@ export class DataState implements ReactiveController { return this._dataState; } - /** The number of selectable options in {@link dataState}, excluding group headers. */ + /** The number of options in {@link dataState}, with the placeholders and without the group headers. */ public get itemCount(): number { return this._itemCount; } - /** The index of the first selectable option in {@link dataState}, or `-1`. */ + /** The number of records in {@link dataState} before the placeholders. */ + public get loadedLength(): number { + return this._loadedLength; + } + + /** + * `totalItemCount` as a whole number in remote mode. `Infinity` while it is + * not set or not a finite number, or in local mode. + */ + public get totalCount(): number { + const total = Math.floor(this._host.totalItemCount ?? Number.NaN); + + return this.filteringOptions.mode === 'remote' && Number.isFinite(total) + ? total + : Number.POSITIVE_INFINITY; + } + + /** The index of the first option in {@link dataState}, which can be a placeholder, or `-1`. */ public get firstItemIndex(): number { return this._dataState.findIndex((record) => !record.header); } @@ -126,8 +159,10 @@ export class DataState implements ReactiveController { /** * Filters and groups the indexed source, then numbers the visible options, - * so `aria-posinset` and `aria-setsize` skip the group headers. - * Only this controller owns the records, so `position` changes in place. + * so `aria-posinset` and `aria-setsize` skip the group headers. Appends a + * placeholder for each item of {@link totalCount} that a non-empty `data` + * does not hold. Only this controller owns the records, so `position` + * changes in place. */ private _apply(records: ComboRecord[]): ComboRecord[] { const result = this._grouping.apply( @@ -143,9 +178,22 @@ export class DataState implements ReactiveController { } } - this._itemCount = position; + // The application loads the first page, so an empty `data` shows the + // empty message, not placeholders that no request fills. + const missing = records.length ? this.totalCount - records.length : 0; + const placeholders = Number.isFinite(missing) && missing > 0 ? missing : 0; + + this._itemCount = position + placeholders; + this._loadedLength = result.length; + + if (!placeholders) { + return result; + } - return result; + // `result` can be the indexed source, so pad a copy. + const padded = result.slice(); + padded.length += placeholders; + return padded.fill(PLACEHOLDER, result.length); } //#endregion diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index 85b6153eb..778f0e304 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -20,12 +20,14 @@ import type IgcVirtualScrollComponent from '../../virtualization/virtualization. import type { ComboCloseTrigger, ComboHost } from '../types.js'; import type { DataState } from './data.js'; +type ListSlot = 'loading' | 'header' | 'empty' | 'footer'; + type ComboNavigationConfig = { input: Ref; search: Ref; list: Ref; /** The elements assigned to a slot of the list. */ - slotted: (slot: 'header' | 'empty' | 'footer') => Element[]; + slotted: (slot: ListSlot) => Element[]; interactions: { show: () => Promise; hide: (trigger: ComboCloseTrigger) => Promise; @@ -132,6 +134,11 @@ export class ComboNavigationController { }; private _onEnter = async (): Promise => { + // A placeholder has no item, so Enter on it does nothing. + if (this._state.dataState[this.active]?.kind === 'placeholder') { + return; + } + if (this.active > -1) { if (this._host.singleSelect) { this._config.interactions.select(this.active); @@ -239,27 +246,28 @@ export class ComboNavigationController { } /** - * The Tab order of the open list: the filtering input, the header content, - * the list, the empty content and the footer content. Empty without + * The Tab order of the open list: the filtering input, the loading and + * header content, the list, and the empty and footer content. Empty without * focusable slot content, so Tab closes the list. */ private _tabStops(): HTMLElement[] { - const [header, empty, footer] = ( - ['header', 'empty', 'footer'] as const - ).map((slot) => - Iterator.from(this._config.slotted(slot)) - .flatMap(getFocusableElements) - .toArray() - ); - - if ([header, empty, footer].every(isEmpty)) { + const focusable = (...slots: ListSlot[]) => + slots.flatMap((slot) => + Iterator.from(this._config.slotted(slot)) + .flatMap(getFocusableElements) + .toArray() + ); + const before = focusable('loading', 'header'); + const after = focusable('empty', 'footer'); + + if (isEmpty(before) && isEmpty(after)) { return []; } const input = this._searchInput ?? this._input; const list = this._list?.checkVisibility() ? [this._list] : []; - return [input!, ...header, ...list, ...empty, ...footer]; + return [input!, ...before, ...list, ...after]; } /** diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index fd7078f4d..2b420855d 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -40,6 +40,11 @@ - [defaultValue](#defaultvalue) - [Validation message slots](#validation-message-slots) - [Host ARIA](#host-aria) + - [Header and footer slots](#header-and-footer-slots) + - [Close trigger](#close-trigger) + - [Remote data](#remote-data-1) + - [Select all methods](#select-all-methods) + - [Placeholders and loading](#placeholders-and-loading) - [Assumptions and limitations](#assumptions-and-limitations) - [Accessibility](#accessibility) - [ARIA roles and properties](#aria-roles-and-properties) @@ -64,6 +69,7 @@ | 12 | 2026-10-26 | `igcClosing` and `igcClosed` report the `trigger` that closes the list | | 13 | 2026-10-26 | Remote data: `igcSearchInput`, `filteringOptions.mode`, `igcDataRequest`; a new `data` keeps the selection | | 14 | 2026-10-27 | `selectAllItems()` and `deselectAllItems()` act on the items that the list shows | +| 15 | 2026-10-27 | `totalItemCount` placeholders and `loading` | ## Overview @@ -305,6 +311,19 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { the items of `data` and keeps the other selected items. - In remote mode, the search input has no case-sensitive icon, and in single selection the input activates no option of the old results, so Enter does not select an option that the server did not return for the text. +- `totalItemCount` is the number of all the results, when the server knows it. In remote mode, the list adds a + placeholder option for each result that a non-empty `data` does not hold yet, after the groups, so the scrollbar + shows the full list. An empty `data` shows the empty message, because the first page is the job of the application. + A placeholder has the `placeholder` part and the accessible name "Loading", is disabled, and counts in + `aria-setsize`. The keyboard can move to it, but Enter and Space on it do nothing, a click does not select it or + close the list, and the selection methods skip it. `igcDataRequest` then also comes when the list renders a + placeholder, for example after End or a fast scroll. Its `count` covers the rendered placeholders and does not go + past `totalItemCount`, and no request comes when `data` holds `totalItemCount` items. A total that is not a finite + number counts as unknown. `data` grows only at its end, so a jump far into the placeholders requests all the items up + to the jump. +- `loading` shows an indeterminate progress bar, named "Loading", above the header. The default bar overlays the top + of the content below it, so it does not move the list. While `loading` is set, the empty message hides. Content in + the `loading` slot replaces the bar, and Tab reaches focusable content in the slot before the header content. `bindComboDataSource()` from `igniteui-webcomponents/extras` implements this flow on the public API. See the `RemoteData` story. @@ -321,6 +340,7 @@ instance through `locale` and `resourceStrings`: | `combo_clearItems_placeholder` | Clear Selection | The label of the clear action. | | `combo_aria_label_options` | Selected options | The accessible name of the selection. | | `combo_aria_label_no_options` | No options selected | The accessible name of an empty selection. | +| `combo_loading` | Loading | The accessible name of the progress bar and of the placeholder options. | ### Keyboard interactions @@ -341,10 +361,10 @@ While the list of options **is** visible: | Arrow Down | Activates the next option; at the last option it does nothing. | | Arrow Up | Activates the previous option; from the first option it returns to the input. | | Space | Selects or deselects the active option. | -| Enter | Selects the active option in single selection mode, and closes the list in all modes. | +| Enter | Selects the active option in single selection mode, and closes the list in all modes. On a placeholder of `totalItemCount`, it does nothing. | | Home / End | Activates the first or the last option. | | Escape | Closes the list. The combo stays focused. | -| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | +| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the loading content, the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | Typing while the component is focused filters the list down to the closest matches. @@ -366,6 +386,8 @@ Typing while the component is focused filters the list down to the closest match | disableFiltering | disable-filtering | No | `boolean` | false | Whether the filtering of the list of options is disabled. In multiple selection mode, it also hides the search input. | | caseSensitiveIcon | case-sensitive-icon | No | `boolean` | false | Whether the search input shows the icon that toggles case-sensitive filtering. | | disableClear | disable-clear | No | `boolean` | false | Whether the clear button is hidden. | +| totalItemCount | total-item-count | No | `number \| undefined` | - | The number of all the items of a remote source, when the source knows it. In remote filtering mode, the list shows a placeholder for each item that `data` does not hold yet. | +| loading | loading | Yes | `boolean` | false | Whether the combo loads options. The list then shows a progress bar, which the `loading` slot can replace, and hides the empty message. | | itemTemplate | - | No | `ComboItemTemplate` | - | The template for the content of each combo item. | | groupHeaderTemplate | - | No | `ComboGroupHeaderTemplate` | - | The template for the content of each group header. | | open | open | Yes | `boolean` | false | Whether the component is open. | @@ -444,7 +466,7 @@ type ComboGroupHeaderTemplate = (props: { | igcClosing | true | Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. | | igcClosed | false | Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. | | igcSearchInput | false | Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. | -| igcDataRequest | false | Emitted in remote filtering mode when the user scrolls near the end of the options. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes. | +| igcDataRequest | false | Emitted in remote filtering mode when the user scrolls near the end of the options, or to a placeholder of `totalItemCount`. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes. | ```typescript interface IgcComboChangeEventArgs { @@ -490,6 +512,7 @@ trigger, and put the saved value back on the other triggers of `igcClosed`. | `header` | Renders a container before the list of options. | | `footer` | Renders a container after the list of options. | | `empty` | Renders content when the list has no items or data. | +| `loading` | Renders content that replaces the default progress bar while `loading` is set. | | `helper-text` | Renders content below the input. | | `toggle-icon` | Renders content that replaces the default toggle icon. | | `clear-icon` | Renders content that replaces the default clear icon. | @@ -523,6 +546,8 @@ trigger, and put the saved value back on the other triggers of `igcClosed`. | `checked` | Added to the checkbox and checkbox-indicator parts when the checkbox is checked. | | `header` / `footer` | The containers holding the header and footer content. | | `empty` | The container holding the empty content. | +| `loading` | The container of the progress bar that shows while `loading` is set. | +| `placeholder` | Added to the item part of each item to load, which `totalItemCount` adds. Its bar has the color of the text. | ### CSS custom states @@ -603,8 +628,8 @@ Generated by `runValidationContainerTests`. ### Header and footer slots 24. Tab moves from the search input to the header content, the list and the footer content, and Shift+Tab moves - back. With an empty list, Tab reaches the content of the `empty` slot. The accessibility audit passes with - focusable slot content. + back. With an empty list, Tab reaches the content of the `empty` slot, and while `loading` is set, the content of + the `loading` slot before the header content. The accessibility audit passes with focusable slot content. 25. Tab from the last footer control and Shift+Tab from the search input close the list and move the focus out of the combo. In single selection, the main input starts the order, and Tab selects the active item when it leaves. 26. Without focusable slot content, Tab from the search input closes the list and moves the focus on. @@ -644,6 +669,18 @@ Generated by `runValidationContainerTests`. a change of `data` or of the search text, emit no `igcChange`, and change nothing when the list shows no items. With `ignoreFilter`, they act on all the items. In single selection, `selectAllItems()` does nothing and `deselectAllItems()` deselects a shown item. +### Placeholders and loading + +36. In remote mode, `totalItemCount` pads the list with placeholder options after the groups. They count in + `aria-setsize`, take the next positions, are disabled and named "Loading", and pass the accessibility audit. Local + mode, an empty `data` and a total that is not a finite number show no placeholders. +37. `igcDataRequest` covers the rendered placeholders, for example after End, does not go past `totalItemCount`, stops + when `data` holds `totalItemCount` items, and comes when the total no longer stops it. Space, a click and + `selectAllItems()` do not select a placeholder, and Enter or a click on one does not close the list in single + selection. +38. `loading` shows the named progress bar without moving the list, hides the empty message and passes the + accessibility audit. The `loading` slot replaces the bar, and Tab reaches its content before the header content. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. @@ -660,7 +697,9 @@ Generated by `runValidationContainerTests`. - The anchor exposes a `combobox` role with `aria-haspopup`, the expanded state, the relation to the list and the active descendant, all projected onto its native input as element references. - The list has a `listbox` role, the items an `option` role with their selected state, and the group headers a - `group` role. + `group` role. A placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a + `progressbar` named "Loading" shows above the list. The listbox has no `aria-busy`, so a screen reader continues to + read the loaded options while the next page loads. - The current selection is announced through a label resolved from the resource strings, which also covers the empty case. - The helper text and the validation messages are referenced through `aria-describedby`. diff --git a/src/components/combo/themes/combo.base.scss b/src/components/combo/themes/combo.base.scss index 95d9687b0..24b9b7d68 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -90,12 +90,65 @@ font-size: rem(13px); } +[part~='loading'] { + position: relative; + + // The default bar overlays the content below it, so it does not move it. + [role='progressbar'] { + position: absolute; + inset-inline: 0; + z-index: 1; + block-size: rem(4px); + overflow: hidden; + + &::before { + content: ''; + display: block; + inline-size: 40%; + block-size: 100%; + background: color(primary, 500); + animation: loading 1.5s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + inline-size: 100%; + animation: none; + } + } + } +} + +:host(:dir(rtl)) [part~='loading'] [role='progressbar'] { + scale: -1 1; +} + +@keyframes loading { + from { + translate: -100%; + } + + to { + translate: 250%; + } +} + [part~='group-header'], [part~='item'] { // Fix a bug in safari: https://github.com/IgniteUI/igniteui-webcomponents/issues/892 inset-inline-start: 0; } +[part~='placeholder'] { + pointer-events: none; + + span { + inline-size: 60%; + block-size: 0.75em; + border-radius: rem(4px); + background: currentcolor; + opacity: 0.2; + } +} + :host(:disabled), :host([disabled]) { pointer-events: none; @@ -139,4 +192,10 @@ [part~='selected'] { color: Highlight; } + + [part~='loading'] [role='progressbar']::before, + [part~='placeholder'] span { + forced-color-adjust: none; + background: CanvasText; + } } \ No newline at end of file diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index 7c280c697..a8fbeaf95 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -17,6 +17,8 @@ export type ComboRecord = { position: number; /** The group value of a header record. Not set on the other records. */ group?: unknown; + /** Set on a record with no item: a placeholder for an item to load. */ + kind?: 'placeholder'; }; export type ComboHost = ReactiveControllerHost & From 2f8739025ffc2afe9d0e31b8ee1f8923a6186dd0 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 11:00:06 +0200 Subject: [PATCH 09/32] feat(extras): set totalItemCount and loading from bindComboDataSource The binding keeps the total of each search, ends the results on an empty page, and loads max(pageSize, count) items per request. Refs #2519. --- CHANGELOG.md | 2 +- README.md | 4 +- src/extras/combo-data-source.spec.ts | 113 +++++++++++++++++++++++++-- src/extras/combo-data-source.ts | 75 +++++++++++++----- stories/combo.stories.ts | 4 +- 5 files changed, 165 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3668299..0aac885e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. `loading` shows a progress bar above the list and hides the empty message. The `loading` slot replaces the bar, and the `loading` and `placeholder` parts style them. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) diff --git a/README.md b/README.md index 80c1a5821..5995760d6 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ The condition names are `igc-theme-material`, `igc-theme-bootstrap`, `igc-theme- `igniteui-webcomponents/extras` has opt-in add-ons that build on the public API of the components. A bundle that does not import them does not contain them. - `createMarkdownRenderer()` renders the chat messages from Markdown, with syntax highlighting. -- `bindComboDataSource()` binds a combo to a server that searches and pages. +- `bindComboDataSource()` binds a combo to a server that searches and pages. It sets `totalItemCount` from the total of the server, so the list shows a placeholder row for each item to load, and `loading` while a search waits or a page loads. ```ts import { bindComboDataSource } from 'igniteui-webcomponents/extras'; @@ -202,7 +202,7 @@ const binding = bindComboDataSource(combo, { }, }); -binding.dispose(); // removes the listeners and aborts the request in flight +binding.dispose(); // removes the listeners, aborts the request in flight and clears `totalItemCount` and `loading` ``` ## Browser support diff --git a/src/extras/combo-data-source.spec.ts b/src/extras/combo-data-source.spec.ts index 1dfb5af1d..4069090cf 100644 --- a/src/extras/combo-data-source.spec.ts +++ b/src/extras/combo-data-source.spec.ts @@ -8,6 +8,7 @@ import { } from '@open-wc/testing'; import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { suppressResizeObserverLoopError } from '#internals/testing/helpers.spec.js'; import { simulateClick, simulateInput, @@ -125,6 +126,7 @@ describe('Combo data source binding', () => { before(() => { defineComponents(IgcComboComponent); + suppressResizeObserverLoopError(); }); beforeEach(async () => { @@ -179,19 +181,98 @@ describe('Combo data source binding', () => { await open(); await answer(0); await answer(1); - list.scrollToIndex(19); - await settle(); - await answer(2); list.scrollToIndex(PLACES.length - 1); await settle(); - expect(requests.map(({ request }) => request.startIndex)).to.eql([ - 0, 10, 20, + // The second request loads all the placeholders that the list renders. + expect( + requests.map(({ request }) => [request.startIndex, request.count]) + ).to.eql([ + [0, 10], + [10, 14], ]); - expect(requests[1].request.count).to.equal(10); expect(ids(combo.data)).to.eql(ids(PLACES)); }); + it('sets `totalItemCount` and `loading`, and clears them on dispose', async () => { + bind({ pageSize: 30 }); + await open(); + + expect(combo.loading).to.be.true; + + await answer(0); + + expect(combo.loading).to.be.false; + expect(combo.totalItemCount).to.equal(PLACES.length); + + void binding.refresh(); + expect(combo.loading).to.be.true; + + binding.dispose(); + + expect(combo.loading).to.be.false; + expect(combo.totalItemCount).to.be.undefined; + }); + + it('keeps the total of the first page for a next page without one', async () => { + source.load = async (request) => ({ + items: page(request).items, + totalCount: request.startIndex ? undefined : PLACES.length, + }); + bind(); + await open(); + await waitUntil(() => combo.data.length === PLACES.length); + + expect(combo.totalItemCount).to.equal(PLACES.length); + }); + + it('pages without a total and ends the results on an empty page', async () => { + const load = source.load; + source.load = (request, signal) => + load(request, signal).then(({ items }) => ({ items })); + + bind(); + await open(); + await answer(0); + await answer(1); + await answer(2); + + expect( + requests.map(({ request }) => [request.startIndex, request.count]) + ).to.eql([ + [0, 10], + [10, 20], + [24, 20], + ]); + expect(combo.totalItemCount).to.equal(PLACES.length); + }); + + it('loads at least `pageSize` items', async () => { + bind({ pageSize: 30 }); + await open(); + await answer(0); + + combo.dispatchEvent( + new CustomEvent('igcDataRequest', { + detail: { startIndex: PLACES.length, count: 5 }, + }) + ); + + expect(requests.at(-1)!.request.count).to.equal(30); + }); + + it('keeps `loading` when an aborted request ends after a newer one starts', async () => { + bind(); + await open(); + void binding.refresh(); + + requests[0].answer(); + await flush(); + + expect(requests[0].signal.aborted).to.be.true; + expect(combo.loading).to.be.true; + }); + it('does not load on open with `loadOnOpen: false`', async () => { bind({ loadOnOpen: false }); await open(); @@ -272,6 +353,24 @@ describe('Combo data source binding', () => { expect(ids(combo.data)).to.eql(ids(PLACES.slice(0, 10))); }); + it('shows `loading` while a search waits and loads', async () => { + bind({ debounce: 300, pageSize: 30 }); + await open(); + requests[0].answer(); + await flush(); + + expect(combo.loading).to.be.false; + + simulateInput(searchNative, { value: 'ber' }); + expect(combo.loading).to.be.true; + + clock.tick(300); + requests.at(-1)!.answer(); + await flush(); + + expect(combo.loading).to.be.false; + }); + it('does not load again for the same trimmed text', async () => { bind({ debounce: 0 }); await open(); @@ -307,7 +406,7 @@ describe('Combo data source binding', () => { expect(requests.at(-1)!.request).to.eql({ searchText: '', startIndex: 10, - count: 10, + count: 20, }); }); diff --git a/src/extras/combo-data-source.ts b/src/extras/combo-data-source.ts index 053e806ee..1eecfc48d 100644 --- a/src/extras/combo-data-source.ts +++ b/src/extras/combo-data-source.ts @@ -9,7 +9,11 @@ export interface ComboDataRequest { searchText: string; /** The index in the results of the first item to load. */ startIndex: number; - /** The number of items to load. The source can return fewer. */ + /** + * The number of items to load. It can be large after a jump, such as End. + * The source can return fewer, for example up to the page limit of the + * server, and the combo asks again for the rest. + */ count: number; } @@ -17,7 +21,12 @@ export interface ComboDataRequest { export interface ComboDataPage { /** The items of the page. */ items: T[]; - /** The number of all the results, when the source knows it. No request goes past it. */ + /** + * The number of all the results, when the source knows it. The combo shows a + * placeholder for each result to load, and no request starts after it. A + * next page without it keeps the current total, and a next page with no + * items ends the results. + */ totalCount?: number; } @@ -40,7 +49,11 @@ export interface ComboDataSource { export interface ComboDataSourceOptions { /** The pause in the typing, in milliseconds, before a search loads. Default is `300`. */ debounce?: number; - /** The number of items that each request loads. Default is `50`. */ + /** + * The smallest number of items that a request loads. A request for the + * placeholders that the list renders after a jump, such as End, loads all + * the items up to them. Default is `50`. + */ pageSize?: number; /** * Whether the first page loads when the user opens the list with no items. @@ -55,8 +68,8 @@ export interface ComboDataBinding { /** Loads the first page of the current search text again. */ refresh(): Promise; /** - * Removes the listeners, aborts the requests in flight, and gives the combo - * back its filtering mode. + * Removes the listeners, aborts the requests in flight, gives the combo back + * its filtering mode, and clears its `totalItemCount` and `loading`. */ dispose(): void; } @@ -64,8 +77,9 @@ export interface ComboDataBinding { /** * Binds a combo to a remote source. The binding sets `filteringOptions.mode` * to `remote`. It loads the first page when the list opens with no items and - * after a pause in the typing, and appends the next page on `igcDataRequest`, - * up to the total count of the source. A newer search aborts the older + * after a pause in the typing, and appends the next page on `igcDataRequest`. + * It sets `totalItemCount` to the total count of the source, and `loading` + * while a search waits or a page loads. A newer search aborts the older * requests, and an answer for items that changed since is dropped. * * @example @@ -97,25 +111,35 @@ export function bindComboDataSource( const requested = new Set(); // The search of the next first page, and the search of the items in `data`. let searchText = ''; - let loaded = { searchText, totalCount: Number.POSITIVE_INFINITY }; + let loadedSearchText = ''; // Whether the search text changed while the list was closed. let stale = false; let request: AbortController | undefined; const busy = (): boolean => request !== undefined || search.active; + const showBusy = (): void => { + combo.loading = busy(); + }; + + /** Aborts the request in flight, so its answer and its end do not apply. */ + const cancel = (): void => { + request?.abort(); + request = undefined; + }; /** Loads a page. A newer load aborts it, so only the latest one applies. */ - async function load(startIndex: number): Promise { + async function load(startIndex: number, count = pageSize): Promise { search.stop(); - request?.abort(); + cancel(); const current = new AbortController(); request = current; + showBusy(); // A next page continues the search of the loaded items. - const text = startIndex ? loaded.searchText : searchText; + const text = startIndex ? loadedSearchText : searchText; try { const page = await source.load( - { searchText: text, startIndex, count: pageSize }, + { searchText: text, startIndex, count }, current.signal ); @@ -127,11 +151,15 @@ export function bindComboDataSource( return; } - loaded = { - searchText: text, - totalCount: page.totalCount ?? Number.POSITIVE_INFINITY, - }; + loadedSearchText = text; combo.data = startIndex ? [...combo.data, ...page.items] : page.items; + + if (!startIndex || page.totalCount != null) { + combo.totalItemCount = page.totalCount; + } else if (!page.items.length) { + // An empty next page ends the results. + combo.totalItemCount = startIndex; + } } catch (error) { if (!current.signal.aborted) { onError?.(error); @@ -140,6 +168,7 @@ export function bindComboDataSource( } finally { if (request === current) { request = undefined; + showBusy(); } } @@ -213,7 +242,7 @@ export function bindComboDataSource( } searchText = text; - request?.abort(); + cancel(); // A search in a closed list waits for the next opening. if (combo.open) { @@ -222,14 +251,16 @@ export function bindComboDataSource( search.stop(); stale = true; } + + showBusy(); }; const handleDataRequest = ({ detail, }: CustomEvent): void => { - // A pending search replaces the items, and nothing comes after `totalCount`. - if (!busy() && detail.startIndex < loaded.totalCount) { - void load(detail.startIndex); + // A pending search replaces the items. + if (!busy()) { + void load(detail.startIndex, Math.max(pageSize, detail.count)); } }; @@ -253,9 +284,11 @@ export function bindComboDataSource( refresh: () => (disposal.signal.aborted ? Promise.resolve() : load(0)), dispose() { search.stop(); - request?.abort(); + cancel(); disposal.abort(); combo.filteringOptions = { mode }; + combo.totalItemCount = undefined; + combo.loading = false; }, }; } diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 90d964877..6e3246e6c 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -986,7 +986,7 @@ export const RemoteData: Story = { docs: { description: { story: - 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, ten cities at a time. Open the list, scroll to its end to load more, then type to search. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The log shows each request and answer.', + 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, twenty cities at a time. Open the list, scroll to its end to load more, then type to search. The list has a placeholder row for each city to load, so the scrollbar shows all the results, and a progress bar shows while a search waits or a page loads. Press End to jump to the last city: one request loads all the cities up to it. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The log shows each request and answer.', }, }, }, @@ -1023,7 +1023,7 @@ export const RemoteData: Story = { binding?.dispose(); binding = element ? bindComboDataSource(element as IgcComboComponent, source, { - pageSize: 10, + pageSize: 20, }) : undefined; }; From 16b528dae68b58ba3100bd7e8d2025f8c7afae9c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 11:00:52 +0200 Subject: [PATCH 10/32] feat(combo): add the select-all option showSelectAll puts a Select all option first in multiple selection. It selects or deselects the shown items with one igcChange, and its checkbox exports the indeterminate part. Refs #2535. --- CHANGELOG.md | 1 + public-api.json | 4 + src/components/combo/combo-item.ts | 16 +- src/components/combo/combo.spec.ts | 204 +++++++++++++++++++++++ src/components/combo/combo.ts | 127 +++++++++++--- src/components/combo/controllers/data.ts | 35 ++-- src/components/combo/spec.md | 37 +++- src/components/combo/types.ts | 9 +- stories/combo.stories.ts | 43 +++++ 9 files changed, 440 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aac885e5..31d328385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. `loading` shows a progress bar above the list and hides the empty message. The `loading` slot replaces the bar, and the `loading` and `placeholder` parts style them. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input diff --git a/public-api.json b/public-api.json index ceff7a1b1..861f33fc8 100644 --- a/public-api.json +++ b/public-api.json @@ -1221,6 +1221,7 @@ "placeholder-search": "string", "required": "boolean", "scroll-strategy": "PopoverScrollStrategy ('close' | 'hide' | 'scroll')", + "show-select-all": "boolean", "single-select": "boolean", "total-item-count": "number | undefined", "value": "ComboValue[]", @@ -1255,6 +1256,7 @@ "resourceStrings": "Required", "scrollStrategy": "PopoverScrollStrategy ('close' | 'hide' | 'scroll')", "selection": "readonly T[]", + "showSelectAll": "boolean", "singleSelect": "boolean", "static formAssociated": "readonly boolean", "static tagName": "readonly string", @@ -1317,6 +1319,7 @@ "group-header", "header", "helper-text", + "indeterminate", "input", "item", "label", @@ -1327,6 +1330,7 @@ "placeholder", "prefix", "search-input", + "select-all", "selected", "suffix", "toggle-icon" diff --git a/src/components/combo/combo-item.ts b/src/components/combo/combo-item.ts index df91f53ab..084aa6aa4 100644 --- a/src/components/combo/combo-item.ts +++ b/src/components/combo/combo-item.ts @@ -39,6 +39,15 @@ export default class IgcComboItemComponent extends LitElement { @property({ type: Boolean, reflect: true }) public active = false; + /** + * Whether the checkbox of the item is indeterminate. The box and the + * indicator of the checkbox then also export the `indeterminate` part. + * @attr indeterminate + * @default false + */ + @property({ type: Boolean }) + public indeterminate = false; + /** * Determines whether the item hides its selection checkbox. * @attr hide-checkbox @@ -59,12 +68,17 @@ export default class IgcComboItemComponent extends LitElement { } private _renderCheckbox() { + const parts = this.indeterminate + ? 'control: checkbox, control: indeterminate, indicator: checkbox-indicator, indicator: indeterminate, checked' + : 'control: checkbox, indicator: checkbox-indicator, checked'; + return html`
    `; diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 4cb978b06..c7cee27ca 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -9,6 +9,7 @@ import { resetMouse, sendKeys, sendMouse } from '@web/test-runner-commands'; import { setCurrentI18n } from 'igniteui-i18n-core'; import { match, type SinonSpy, spy } from 'sinon'; +import { internalsOf } from '#internals/controllers/internals.js'; import { altKey, arrowDown, @@ -2784,6 +2785,25 @@ describe('Combo', () => { ]); }); + it('leaves the select-all option out of the requests and the positions', async () => { + remote.showSelectAll = true; + remote.totalItemCount = 12; + await openComboPopover(remote); + list.scrollToIndex(list.data.length - 1); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 2 }, + ]); + expect(placeholders().at(-1)!.getAttribute('aria-posinset')).to.equal( + '12' + ); + + remote.selectAllItems(); + + expect(remote.value).to.eql(search('').map(({ id }) => id)); + }); + it('requests the rendered placeholders, up to `totalItemCount`', async () => { remote.totalItemCount = 100; await openComboPopover(remote); @@ -2959,6 +2979,190 @@ describe('Combo', () => { }); }); + describe('Select-all option', () => { + let eventSpy: SinonSpy; + + const selectAll = () => + list.querySelector('[part~="select-all"]'); + + const ariaOf = (item: Element) => ({ + selected: internalsOf(item)?.getARIA('ariaSelected'), + checked: internalsOf(item)?.getARIA('ariaChecked') ?? null, + }); + + // The cities whose name has an "o". + const ids = ['BG01', 'BG02', 'US01', 'US02', 'US03']; + + const changes = () => + eventSpy.withArgs('igcChange').args.map(([, { detail }]) => ({ + ...detail, + items: detail.items.map(({ id }: City) => id), + })); + + async function search(text: string): Promise { + searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); + await comboStable(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + await openComboPopover(combo); + }); + + it('comes first in multiple selection and does not count as an option', async () => { + const first = list.querySelector('[part~="item"], igc-combo-header'); + const [, option] = items(combo); + + expect(first).to.equal(selectAll()); + expect(first!.textContent?.trim()).to.equal('Select all'); + expect(ariaOf(first!)).to.eql({ selected: 'false', checked: null }); + expect(first!.hasAttribute('aria-checked')).to.be.false; + expect(first!.hasAttribute('aria-setsize')).to.be.false; + expect(option.getAttribute('aria-posinset')).to.equal('1'); + expect(option.getAttribute('aria-setsize')).to.equal(`${cities.length}`); + + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + combo.singleSelect = true; + await comboStable(combo); + + expect(selectAll()).to.be.null; + }); + + it('does not show when the list shows no items', async () => { + await search('xyz'); + + expect(selectAll()).to.be.null; + expect( + combo.renderRoot.querySelector('[part="empty"]')!.checkVisibility() + ).to.be.true; + }); + + it('selects the shown items, then deselects them, with one igcChange each', async () => { + await search('o'); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(combo.value).to.eql(ids); + expect(ariaOf(selectAll()!).selected).to.equal('true'); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(combo.value).to.be.empty; + expect(changes()).to.eql([ + { newValue: ids, items: ids, type: 'selection' }, + { newValue: [], items: ids, type: 'deselection' }, + ]); + }); + + it('shows the mixed state and selects only the other shown items', async () => { + await search('o'); + const [first, ...others] = ids; + + combo.select(first); + await comboStable(combo); + + const checkbox = selectAll()!.renderRoot.querySelector('igc-checkbox')!; + const marginOf = (item: Element, part: string) => + getComputedStyle( + item + .shadowRoot!.querySelector('igc-checkbox')! + .renderRoot.querySelector(`[part~="${part}"]`)! + ).scrollMarginTop; + const style = document.createElement('style'); + style.textContent = + 'igc-combo::part(checkbox indeterminate), igc-combo::part(checkbox-indicator indeterminate) { scroll-margin-top: 7px; }'; + document.head.append(style); + + expect(ariaOf(selectAll()!).selected).to.equal('false'); + expect(checkbox.indeterminate).to.be.true; + expect(marginOf(selectAll()!, 'control')).to.equal('7px'); + expect(marginOf(selectAll()!, 'indicator')).to.equal('7px'); + expect(marginOf(items(combo)[1], 'control')).to.equal('0px'); + + style.remove(); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(changes().map(({ items }) => items)).to.eql([others]); + expect(ariaOf(selectAll()!).selected).to.equal('true'); + }); + + it('follows a new value while the list is open', async () => { + combo.value = ['BG01']; + await comboStable(combo); + + expect( + selectAll()!.renderRoot.querySelector('igc-checkbox')!.indeterminate + ).to.be.true; + expect(items(combo).filter((item) => item.selected)).lengthOf(1); + }); + + it('resets the active option when it shows or hides', async () => { + // The first city. Without the option, its index is the next city. + simulateKeyboard(list, homeKey); + simulateKeyboard(list, arrowDown); + await comboStable(combo); + + expect(items(combo).filter((item) => item.active)).lengthOf(1); + + combo.showSelectAll = false; + await comboStable(combo); + + expect(items(combo).some((item) => item.active)).to.be.false; + }); + + it('selects only the clicked item when the item has no value', async () => { + combo.data = [{ name: 'Nowhere', country: 'Other' } as City, ...cities]; + await comboStable(combo); + + simulateClick( + items(combo).find((item) => item.textContent?.trim() === 'Nowhere')! + ); + await comboStable(combo); + + expect(combo.selection.map(({ name }) => name)).to.eql(['Nowhere']); + }); + + it('keeps the selection when igcChange is canceled', async () => { + combo.addEventListener('igcChange', (event) => event.preventDefault()); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(combo.value).to.be.empty; + }); + + it('is a keyboard stop that Space toggles', async () => { + simulateKeyboard(list, homeKey); + simulateKeyboard(list, spaceBar); + await comboStable(combo); + + expect(combo.value).lengthOf(cities.length); + expect(selectAll()!.active).to.be.true; + + simulateKeyboard(list, arrowUp); + + expect(isFocused(searchInput)).to.be.true; + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index c0b0f55b5..bec2fdfcb 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -134,6 +134,8 @@ const DATA_REQUEST_COUNT = 20; * @csspart checkbox - The checkbox of each item in the list. * @csspart checkbox-indicator - The checkbox indicator of each item in the list. * @csspart checked - Added to the checkbox and checkbox-indicator parts when the checkbox is checked. + * @csspart indeterminate - Added to the checkbox and checkbox-indicator parts of the select-all option while only some of the shown items are selected. + * @csspart select-all - Added to the item part of the select-all option. * @csspart header - The container holding the header content of the combo. * @csspart footer - The container holding the footer content of the combo. * @csspart empty - The container holding the empty content of the combo. @@ -493,6 +495,16 @@ export default class IgcComboComponent< @property({ type: Boolean, reflect: true }) public loading = false; + /** + * Whether the list starts with an option that selects all the items that it + * shows, or deselects them when all of them are selected. Only in multiple + * selection mode. + * @attr show-select-all + * @default false + */ + @property({ type: Boolean, attribute: 'show-select-all' }) + public showSelectAll = false; + /* blazorSuppress */ /** * The template used for the content of each combo item. @@ -600,11 +612,18 @@ export default class IgcComboComponent< this._state.updateLocale(); } + // The select-all option moves the other records by one. + if (props.has('showSelectAll') && this.hasUpdated) { + this._activeIndex = -1; + } + if ( props.has('groupKey') || props.has('groupSorting') || props.has('filteringOptions') || props.has('totalItemCount') || + props.has('showSelectAll') || + props.has('singleSelect') || props.has('data') ) { this._state.invalidate(); @@ -980,6 +999,8 @@ export default class IgcComboComponent< } this._projectSelection(); + // The list does not update for a new `value` on its own. + this._listRef.value?.requestUpdate(); } protected _syncValueFromSelection(): void { @@ -989,7 +1010,7 @@ export default class IgcComboComponent< this._listRef.value?.requestUpdate(); } - /** The items that the list shows, without the group headers and the placeholders. */ + /** The items that the list shows. See {@link isItemRecord}. */ private _shownItems(): T[] { // Applies a change of `data` or of the search text that no update has applied yet. this._state.hostUpdate(); @@ -999,37 +1020,79 @@ export default class IgcComboComponent< .map((record) => record.value); } - /** - * The item at `index`, or undefined for a group header or a placeholder. - * Guard on it: {@link _updateSelection} reads an empty collection as "select all". - */ - private _recordAt(index: number): T | undefined { - const record = this._state.dataState[index]; - return isItemRecord(record) ? record.value : undefined; - } - /** * Selects the record at `index`. With `toggle`, deselects a selected one. - * @returns Whether `index` holds an item. + * The select-all option toggles the shown items. + * @returns Whether `index` holds an item or the select-all option. */ private _selectAt(index: number, toggle: boolean): boolean { - const record = this._recordAt(index); + const record = this._state.dataState[index]; - if (!record) { + if (isItemRecord(record)) { + const item = record.value; + + // The record itself, as an item without a value would select all. + this._applySelection( + [item], + toggle && this._selected.has(item) ? 'deselection' : 'selection', + true + ); + } else if (record?.kind === 'select-all') { + this._toggleShownItems(); + } else { return false; } - this._updateSelection( - this._resolveItemValue(record), - toggle && this._selected.has(record) ? 'deselection' : 'selection', - true - ); - this._activeIndex = index; this._syncValueFromSelection(); return true; } + /** + * Selects the shown items, or deselects them when all of them are selected, + * with one `igcChange` for the items that change. + */ + private _toggleShownItems(): void { + const shown = this._shownItems(); + const unselected = shown.filter((item) => !this._selected.has(item)); + + this._applySelection( + unselected.length ? unselected : shown, + unselected.length ? 'selection' : 'deselection', + true + ); + } + + /** + * Whether all (`true`), none (`false`) or some (`mixed`) of the shown items + * are selected. One pass that stops at the first mix, because the select-all + * row renders often. + */ + private _shownSelection(): boolean | 'mixed' { + let some = false; + let every = true; + + if (this._selected.size) { + for (const record of this._state.dataState) { + if (!isItemRecord(record)) { + continue; + } + + if (this._selected.has(record.value)) { + some = true; + } else { + every = false; + } + + if (some && !every) { + return 'mixed'; + } + } + } + + return some; + } + private _clearSelection(): void { if (this.singleSelect) { this._clearSearchTerm(true); @@ -1285,6 +1348,11 @@ export default class IgcComboComponent< } const active = this._activeIndex === index; + + if (item.kind === 'select-all') { + return this._renderSelectAll(index, active); + } + const selected = this._selected.has(item.value); // One template for both, so a row keeps its element when its item loads. const placeholder = item.kind === 'placeholder'; @@ -1317,6 +1385,27 @@ export default class IgcComboComponent< `; }; + private _renderSelectAll(index: number, active: boolean): TemplateResult { + // Like the other options, it reports only `aria-selected`, so the mixed + // state shows only on its checkbox. + const state = this._shownSelection(); + const all = state === true; + + return html` + + ${this.resourceStrings.combo_select_all} + + `; + } + private _renderToggleIcon() { return html` = Object.freeze({ kind: 'placeholder', }); -/** Whether the record holds an item: not a group header or a placeholder. */ +/** The record of the select-all option. See `showSelectAll`. */ +const SELECT_ALL: ComboRecord = Object.freeze({ + value: undefined as never, + header: false, + position: -1, + kind: 'select-all', +}); + +/** Whether the record holds an item of `data`. */ export function isItemRecord( record?: ComboRecord ): boolean { @@ -75,7 +83,7 @@ export class DataState implements ReactiveController { : Number.POSITIVE_INFINITY; } - /** The index of the first option in {@link dataState}, which can be a placeholder, or `-1`. */ + /** The index of the first record that is not a group header, or `-1`. */ public get firstItemIndex(): number { return this._dataState.findIndex((record) => !record.header); } @@ -159,10 +167,10 @@ export class DataState implements ReactiveController { /** * Filters and groups the indexed source, then numbers the visible options, - * so `aria-posinset` and `aria-setsize` skip the group headers. Appends a - * placeholder for each item of {@link totalCount} that a non-empty `data` - * does not hold. Only this controller owns the records, so `position` - * changes in place. + * so `aria-posinset` and `aria-setsize` skip the group headers. Puts the + * select-all option first, and appends a placeholder for each item of + * {@link totalCount} that a non-empty `data` does not hold. Only this + * controller owns the records, so `position` changes in place. */ private _apply(records: ComboRecord[]): ComboRecord[] { const result = this._grouping.apply( @@ -183,17 +191,22 @@ export class DataState implements ReactiveController { const missing = records.length ? this.totalCount - records.length : 0; const placeholders = Number.isFinite(missing) && missing > 0 ? missing : 0; + const selectAll = + position > 0 && this._host.showSelectAll && !this._host.singleSelect; + + const head: ComboRecord[] = selectAll ? [SELECT_ALL] : []; + this._itemCount = position + placeholders; - this._loadedLength = result.length; + this._loadedLength = head.length + result.length; - if (!placeholders) { + if (!(head.length || placeholders)) { return result; } - // `result` can be the indexed source, so pad a copy. - const padded = result.slice(); + // `result` can be the indexed source, so build a new array. + const padded = head.concat(result); padded.length += placeholders; - return padded.fill(PLACEHOLDER, result.length); + return padded.fill(PLACEHOLDER, this._loadedLength); } //#endregion diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 2b420855d..16ab3a6ec 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -45,6 +45,7 @@ - [Remote data](#remote-data-1) - [Select all methods](#select-all-methods) - [Placeholders and loading](#placeholders-and-loading) + - [Select-all option](#select-all-option) - [Assumptions and limitations](#assumptions-and-limitations) - [Accessibility](#accessibility) - [ARIA roles and properties](#aria-roles-and-properties) @@ -70,6 +71,7 @@ | 13 | 2026-10-26 | Remote data: `igcSearchInput`, `filteringOptions.mode`, `igcDataRequest`; a new `data` keeps the selection | | 14 | 2026-10-27 | `selectAllItems()` and `deselectAllItems()` act on the items that the list shows | | 15 | 2026-10-27 | `totalItemCount` placeholders and `loading` | +| 16 | 2026-10-27 | The select-all option (`showSelectAll`) and the `select-all` and `indeterminate` parts | ## Overview @@ -198,6 +200,16 @@ combo.value; // ComboValue[] - the values combo.selection; // T[] - the records from the data source ``` +`selectAllItems()` and `deselectAllItems()` act on the items that the filtered list shows, without `igcChange`. In +multiple selection, `show-select-all` puts a "Select all" option first in the list. A click or Space on it selects the +shown items, or deselects them when all of them are selected, with one `igcChange` for the items that change. Its +checkbox is checked when all the shown items are selected and indeterminate when only some are. It is not counted in +`aria-setsize`, and the list shows it only while it shows items. With `totalItemCount`, it acts on the loaded items. + +```html + +``` + #### Filtering ```typescript @@ -341,6 +353,7 @@ instance through `locale` and `resourceStrings`: | `combo_aria_label_options` | Selected options | The accessible name of the selection. | | `combo_aria_label_no_options` | No options selected | The accessible name of an empty selection. | | `combo_loading` | Loading | The accessible name of the progress bar and of the placeholder options. | +| `combo_select_all` | Select all | The text of the select-all option. | ### Keyboard interactions @@ -360,7 +373,7 @@ While the list of options **is** visible: | ------------------------------------------- | ------------------------------------------------------------------------------ | | Arrow Down | Activates the next option; at the last option it does nothing. | | Arrow Up | Activates the previous option; from the first option it returns to the input. | -| Space | Selects or deselects the active option. | +| Space | Selects or deselects the active option. On the select-all option, selects the shown items, or deselects them when all of them are selected. | | Enter | Selects the active option in single selection mode, and closes the list in all modes. On a placeholder of `totalItemCount`, it does nothing. | | Home / End | Activates the first or the last option. | | Escape | Closes the list. The combo stays focused. | @@ -388,6 +401,7 @@ Typing while the component is focused filters the list down to the closest match | disableClear | disable-clear | No | `boolean` | false | Whether the clear button is hidden. | | totalItemCount | total-item-count | No | `number \| undefined` | - | The number of all the items of a remote source, when the source knows it. In remote filtering mode, the list shows a placeholder for each item that `data` does not hold yet. | | loading | loading | Yes | `boolean` | false | Whether the combo loads options. The list then shows a progress bar, which the `loading` slot can replace, and hides the empty message. | +| showSelectAll | show-select-all | No | `boolean` | false | Whether the list starts with an option that selects all the items that it shows, or deselects them when all of them are selected. Only in multiple selection mode. | | itemTemplate | - | No | `ComboItemTemplate` | - | The template for the content of each combo item. | | groupHeaderTemplate | - | No | `ComboGroupHeaderTemplate` | - | The template for the content of each group header. | | open | open | Yes | `boolean` | false | Whether the component is open. | @@ -544,6 +558,8 @@ trigger, and put the saved value back on the other triggers of `igcClosed`. | `checkbox` | The checkbox of each item in the list. | | `checkbox-indicator` | The checkbox indicator of each item in the list. | | `checked` | Added to the checkbox and checkbox-indicator parts when the checkbox is checked. | +| `indeterminate` | Added to the checkbox and checkbox-indicator parts of the select-all option while only some of the shown items are selected. | +| `select-all` | Added to the item part of the select-all option. | | `header` / `footer` | The containers holding the header and footer content. | | `empty` | The container holding the empty content. | | `loading` | The container of the progress bar that shows while `loading` is set. | @@ -681,6 +697,20 @@ Generated by `runValidationContainerTests`. 38. `loading` shows the named progress bar without moving the list, hides the empty message and passes the accessibility audit. The `loading` slot replaces the bar, and Tab reaches its content before the header content. +### Select-all option + +39. The option comes first in multiple selection, before the group headers, with the "Select all" text and + `aria-selected`. It does not count in `aria-posinset` or `aria-setsize`, passes the accessibility audit, and does not + show in single selection or while the list shows no items. +40. A click selects the shown items, and a second click deselects them, with one `igcChange` each. With some shown + items selected, the option is not selected, its checkbox is indeterminate with the `indeterminate` part, and a click + selects only the other shown items. A canceled `igcChange` keeps the selection. A new `value` while the list is + open updates the option and the items. +41. Home reaches the option, Space toggles it, and Arrow Up from it moves to the search input. Showing or hiding the + option clears the active option. A click on an item without a value selects only that item. +42. With `totalItemCount` in remote mode, the option does not change the requests or the positions of the + placeholders, and the selection methods select only the loaded items. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. @@ -697,7 +727,10 @@ Generated by `runValidationContainerTests`. - The anchor exposes a `combobox` role with `aria-haspopup`, the expanded state, the relation to the list and the active descendant, all projected onto its native input as element references. - The list has a `listbox` role, the items an `option` role with their selected state, and the group headers a - `group` role. A placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a + `group` role. The select-all option is selected while all the shown items are selected. It has no `aria-checked`, + because a listbox uses one of `aria-selected` and `aria-checked` for all its options, so its mixed state shows only on + its checkbox. A + placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a `progressbar` named "Loading" shows above the list. The listbox has no `aria-busy`, so a screen reader continues to read the loaded options while the next page loads. - The current selection is announced through a label resolved from the resource strings, which also covers the empty diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index a8fbeaf95..8599f2b6c 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -11,14 +11,17 @@ export type ComboRecord = { value: T; header: boolean; /** - * 1-based position among the visible options. `-1` for group headers. + * 1-based position among the visible options. `-1` for the other records. * Each pipeline run sets it again. */ position: number; /** The group value of a header record. Not set on the other records. */ group?: unknown; - /** Set on a record with no item: a placeholder for an item to load. */ - kind?: 'placeholder'; + /** + * Set on a record with no item: a placeholder for an item to load, or the + * select-all option. + */ + kind?: 'placeholder' | 'select-all'; }; export type ComboHost = ReactiveControllerHost & diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 6e3246e6c..0f3a93227 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -61,6 +61,8 @@ const metadata: Meta = { 'igcOpened', 'igcClosing', 'igcClosed', + 'igcSearchInput', + 'igcDataRequest', ], }, }, @@ -151,6 +153,26 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + totalItemCount: { + type: 'number', + description: + 'The number of all the items of a remote source, when the source knows it.\nIn remote filtering mode, the list shows a placeholder for each item that\n`data` does not hold yet, so the scrollbar shows the full list.', + control: 'number', + }, + loading: { + type: 'boolean', + description: + 'Whether the combo loads options. The list then shows a progress bar,\nwhich the `loading` slot can replace, and hides the empty message.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + showSelectAll: { + type: 'boolean', + description: + 'Whether the list starts with an option that selects all the items that it\nshows, or deselects them when all of them are selected. Only in multiple\nselection mode.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, locale: { type: 'string', description: @@ -207,6 +229,8 @@ const metadata: Meta = { caseSensitiveIcon: false, disableFiltering: false, disableClear: false, + loading: false, + showSelectAll: false, required: false, disabled: false, invalid: false, @@ -264,6 +288,23 @@ interface IgcComboArgs { disableFiltering: boolean; /** Whether the clear button is hidden. */ disableClear: boolean; + /** + * The number of all the items of a remote source, when the source knows it. + * In remote filtering mode, the list shows a placeholder for each item that + * `data` does not hold yet, so the scrollbar shows the full list. + */ + totalItemCount: number; + /** + * Whether the combo loads options. The list then shows a progress bar, + * which the `loading` slot can replace, and hides the empty message. + */ + loading: boolean; + /** + * Whether the list starts with an option that selects all the items that it + * shows, or deselects them when all of them are selected. Only in multiple + * selection mode. + */ + showSelectAll: boolean; /** * The locale of the component. Without it, the component takes its own * `lang`, else the locale of the nearest ancestor that has a `locale` (a @@ -390,6 +431,8 @@ export const Default: Story = { ?disabled=${args.disabled} ?invalid=${args.invalid} ?single-select=${args.singleSelect} + ?show-select-all=${args.showSelectAll} + ?loading=${args.loading} > From 6167fae3832959de2ef53011465cfab9ef63958a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 13:03:51 +0200 Subject: [PATCH 11/32] fix(combo): show loading as a spinner in the input and placeholders as skeleton rows While loading, a spinner covers the toggle icon, which keeps its box, so the input does not change its width, and an empty list shows Loading instead of the empty message. The loading part is the spinner and the loading slot replaces it. Each placeholder is the skeleton of an item: the box of the checkbox and a bar with a wave, in the text color of the placeholder part. When the list closes while the focus is in it, for example on hide() f rom a footer button, the focus moves to the main input. --- CHANGELOG.md | 3 +- src/components/combo/combo.spec.ts | 125 +++++++++++++----- src/components/combo/combo.ts | 56 +++++--- .../combo/controllers/navigation.ts | 10 +- src/components/combo/spec.md | 44 +++--- src/components/combo/themes/combo.base.scss | 104 ++++++++++----- 6 files changed, 230 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d328385..01c9fafde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. `loading` shows a progress bar above the list and hides the empty message. The `loading` slot replaces the bar, and the `loading` and `placeholder` parts style them. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) @@ -167,6 +167,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The removal of the `locale` attribute no longer causes a `TypeError`. [#2475](https://github.com/IgniteUI/igniteui-webcomponents/pull/2475) - Setting `singleSelect` or `disableFiltering` to the value that the combo already has no longer clears the search text and the filter. Before, a framework that sets each property on each render, such as React, cleared the search text after each key, and the list did not filter. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) - Tab and Shift+Tab now move through the focusable content of the `header`, `empty` and `footer` slots while the list is open. Before, Tab closed the list, so a keyboard user could not reach a button in these slots. Tab from the last footer element, and Shift+Tab from the search input, close the list and move the focus on, as before. [#2529](https://github.com/IgniteUI/igniteui-webcomponents/issues/2529) + - When the list closes while the focus is in it, for example on `hide()` from a button in the footer, the focus moves to the main input. Before, the closed list could not keep the focus, and it went to the document. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The `igcChange` of the search input no longer leaves the combo. Before, a listener on the combo got a second `igcChange` with the search text as its `detail` when the search input lost the focus. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Date picker - `stepUp()` and `stepDown()` now update `value` and the form value at once, also while the input has the focus. Before, only the input showed the new date. diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index c7cee27ca..e63d68baa 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -32,6 +32,7 @@ import { axeReflectedRelationsOptions, isFocused, suppressResizeObserverLoopError, + withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { simulateBlur, @@ -2061,6 +2062,27 @@ describe('Combo', () => { expectFocus(buttonAfter, false); }); + it('moves the focus to the input when the app closes the list from a footer control', async () => { + apply.focus(); + await combo.hide(); + + expectFocus(input, false); + }); + + it('moves the focus to the input when `open` turns off with the focus in the search input', async () => { + combo.open = false; + await elementUpdated(combo); + + expectFocus(input, false); + }); + + it('keeps the focus outside the list when the list closes', async () => { + buttonAfter.focus(); + await combo.hide(); + + expectFocus(buttonAfter, false); + }); + it('closes the list and moves the focus back with Shift+Tab from the search input', async () => { await shiftTab(); await elementUpdated(combo); @@ -2111,27 +2133,6 @@ describe('Combo', () => { expectFocus(cancel); }); - it('reaches the content of the loading slot before the header while loading', async () => { - const stop = document.createElement('button'); - stop.slot = 'loading'; - stop.textContent = 'Stop'; - combo.append(stop); - combo.loading = true; - await elementUpdated(combo); - - await tab(); - expectFocus(stop); - - await tab(); - expectFocus(header); - - combo.loading = false; - await elementUpdated(combo); - await shiftTab(); - - expectFocus(searchInput); - }); - it('moves out of a stop inside another stop', async () => { const wrapper = document.createElement('div'); const inner = document.createElement('button'); @@ -2719,7 +2720,7 @@ describe('Combo', () => { items(remote).filter((item) => item.part.contains('placeholder')); const loadingPart = () => - remote.renderRoot.querySelector('[part="loading"]')!; + remote.renderRoot.querySelector('[part="loading"]'); it('pads the list to `totalItemCount` with placeholder options in remote mode', async () => { remote.totalItemCount = 12; @@ -2856,18 +2857,34 @@ describe('Combo', () => { expect(remote.open).to.be.true; }); - it('shows a progress bar and hides the empty message while loading', async () => { - const empty = remote.renderRoot.querySelector('[part="empty"]')!; - const bar = loadingPart().querySelector('[role="progressbar"]')!; + it('shows a spinner in place of the toggle icon and a loading message in an empty list', async () => { + const empty = + remote.renderRoot.querySelector('[part="empty"]')!; + const toggle = remote.renderRoot.querySelector( + '[part~="toggle-icon"]' + )!; + const icon = toggle.querySelector('igc-icon')!; + const { width } = toggle.getBoundingClientRect(); remote.data = []; remote.loading = true; await openComboPopover(remote); + const spinner = loadingPart()!; + expect(remote).to.have.attribute('loading'); - expect(bar.checkVisibility()).to.be.true; - expect(bar.getAttribute('aria-label')).to.equal('Loading'); - expect(empty.checkVisibility()).to.be.false; + expect(toggle.contains(spinner)).to.be.true; + expect(spinner.checkVisibility()).to.be.true; + expect(icon.checkVisibility({ visibilityProperty: true })).to.be.false; + expect(toggle.getBoundingClientRect().width).to.equal(width); + const center = getCenterPoint(icon); + const { x, y } = getCenterPoint(spinner); + + expect(x).to.be.closeTo(center.x, 0.5); + expect(y).to.be.closeTo(center.y, 0.5); + expect(spinner.getAttribute('role')).to.equal('progressbar'); + expect(spinner.getAttribute('aria-label')).to.equal('Loading'); + expect(empty.innerText).to.equal('Loading'); await expect(remote).shadowDom.to.be.accessible( axeReflectedRelationsOptions @@ -2876,11 +2893,12 @@ describe('Combo', () => { remote.loading = false; await elementUpdated(remote); - expect(bar.checkVisibility()).to.be.false; - expect(empty.checkVisibility()).to.be.true; + expect(spinner.checkVisibility()).to.be.false; + expect(icon.checkVisibility({ visibilityProperty: true })).to.be.true; + expect(empty.innerText).to.equal('The list is empty'); }); - it('does not move the list when the default bar shows', async () => { + it('does not move the list when the spinner shows', async () => { await openComboPopover(remote); const { top } = list.getBoundingClientRect(); @@ -2890,17 +2908,58 @@ describe('Combo', () => { expect(list.getBoundingClientRect().top).to.equal(top); }); - it('replaces the progress bar with the `loading` slot', async () => { + it('takes the colors of the spinner and the skeletons from their parts', async () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync( + 'igc-combo::part(loading) { color: rgb(1, 2, 3); } igc-combo::part(placeholder) { color: rgb(4, 5, 6); }' + ); + const reference = await fixture( + html`
    ` + ); + + await withDocumentSheets([sheet], async () => { + remote.totalItemCount = 12; + remote.loading = true; + await openComboPopover(remote); + list.scrollToIndex(11); + await comboStable(remote); + + const placeholder = placeholders().at(-1)!; + const skeleton = placeholder.querySelector('span')!; + const box = placeholder.renderRoot + .querySelector('igc-checkbox')! + .renderRoot.querySelector('[part~="control"]')!; + + expect( + getComputedStyle(loadingPart()!).borderInlineEndColor + ).to.equal('rgb(1, 2, 3)'); + const { backgroundColor } = getComputedStyle(reference); + + expect(getComputedStyle(skeleton).backgroundColor).to.equal( + backgroundColor + ); + expect(getComputedStyle(box, '::after').backgroundColor).to.equal( + backgroundColor + ); + }); + }); + + it('replaces the spinner with the `loading` slot', async () => { const custom = document.createElement('span'); custom.slot = 'loading'; remote.append(custom); remote.loading = true; await openComboPopover(remote); - const slot = loadingPart().querySelector('slot')!; + const slot = remote.renderRoot.querySelector( + 'slot[name="loading"]' + )!; expect(slot.assignedElements()).to.eql([custom]); expect(custom.checkVisibility()).to.be.true; + expect(loadingPart()!.checkVisibility()).to.be.false; }); }); }); diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index bec2fdfcb..78c4555fb 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -1,8 +1,9 @@ import { ComboResourceStringsEN } from 'igniteui-i18n-core'; -import { html, type PropertyValues, type TemplateResult } from 'lit'; +import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; import { IgcBaseComboBoxComponent } from '#internals/bases/combo-box.js'; import { addAriaProjector } from '#internals/controllers/aria-projection.js'; import { addSlotController, setSlots } from '#internals/controllers/slot.js'; @@ -97,7 +98,7 @@ const DATA_REQUEST_COUNT = 20; * @slot header - Renders a container before the list of options of the combo. * @slot footer - Renders a container after the list of options of the combo. * @slot empty - Renders content when the combo dropdown list has no items/data. - * @slot loading - Renders content that replaces the default progress bar while `loading` is set. + * @slot loading - Renders content that replaces the default spinner while `loading` is set. It is in the input, so a click on it toggles the list, as on the toggle icon. * @slot helper-text - Renders content below the input of the combo. * @slot toggle-icon - Renders content that replaces the default toggle icon. * @slot clear-icon - Renders content that replaces the default clear icon. @@ -139,8 +140,8 @@ const DATA_REQUEST_COUNT = 20; * @csspart header - The container holding the header content of the combo. * @csspart footer - The container holding the footer content of the combo. * @csspart empty - The container holding the empty content of the combo. - * @csspart loading - The container of the progress bar that shows while `loading` is set. - * @csspart placeholder - Added to the item part of each item to load, which `totalItemCount` adds. Its bar has the color of the text. + * @csspart loading - The spinner that replaces the toggle icon while `loading` is set. It has the color of the text. + * @csspart placeholder - Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. * * @cssproperty --ig-helper-min-lines - The minimum number of lines below the control for the helper text and the validation messages: `1`, `2` or `3`. A message that shows then uses space that is already there, so the content below does not move. Set it on a container, such as a form, to apply it to all the controls in it. Default is `0`. * @@ -487,8 +488,9 @@ export default class IgcComboComponent< public totalItemCount?: number; /** - * Whether the combo loads options. The list then shows a progress bar, - * which the `loading` slot can replace, and hides the empty message. + * Whether the combo loads options. A spinner, which the `loading` slot can + * replace, then takes the place of the toggle icon, and an empty list shows + * a loading message instead of the empty message. * @attr loading * @default false */ @@ -612,6 +614,16 @@ export default class IgcComboComponent< this._state.updateLocale(); } + // The closed list is inert, so the focus in it would fall to the + // document. Move it to the input first. + if ( + props.has('open') && + !this.open && + this.renderRoot.querySelector('[part="list-wrapper"]:focus-within') + ) { + this.focus({ preventScroll: true }); + } + // The select-all option moves the other records by one. if (props.has('showSelectAll') && this.hasUpdated) { this._activeIndex = -1; @@ -1374,11 +1386,14 @@ export default class IgcComboComponent< .index=${index} ?active=${active} ?selected=${selected} - ?hide-checkbox=${placeholder || this.singleSelect} + ?hide-checkbox=${this.singleSelect} > ${ placeholder - ? html`` + ? // Bars of three lengths look like text. + html`` : this.itemTemplate({ item: item.value }) } @@ -1419,6 +1434,13 @@ export default class IgcComboComponent< slot: 'toggle-icon', icon: this.open ? 'input_collapse' : 'input_expand', })} + + +
    `; } @@ -1510,11 +1532,11 @@ export default class IgcComboComponent< private _renderEmptyTemplate() { return html` -
    - ${this.resourceStrings.combo_empty_message} +
    + ${this.loading ? this.resourceStrings.combo_loading : nothing} + + ${this.resourceStrings.combo_empty_message} +
    `; } @@ -1523,14 +1545,6 @@ export default class IgcComboComponent< return html`
    ${this._renderSearchInput()} -
    - - - -
    diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index 778f0e304..e5aea36d2 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -20,7 +20,7 @@ import type IgcVirtualScrollComponent from '../../virtualization/virtualization. import type { ComboCloseTrigger, ComboHost } from '../types.js'; import type { DataState } from './data.js'; -type ListSlot = 'loading' | 'header' | 'empty' | 'footer'; +type ListSlot = 'header' | 'empty' | 'footer'; type ComboNavigationConfig = { input: Ref; @@ -246,9 +246,9 @@ export class ComboNavigationController { } /** - * The Tab order of the open list: the filtering input, the loading and - * header content, the list, and the empty and footer content. Empty without - * focusable slot content, so Tab closes the list. + * The Tab order of the open list: the filtering input, the header content, + * the list, and the empty and footer content. Empty without focusable slot + * content, so Tab closes the list. */ private _tabStops(): HTMLElement[] { const focusable = (...slots: ListSlot[]) => @@ -257,7 +257,7 @@ export class ComboNavigationController { .flatMap(getFocusableElements) .toArray() ); - const before = focusable('loading', 'header'); + const before = focusable('header'); const after = focusable('empty', 'footer'); if (isEmpty(before) && isEmpty(after)) { diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 16ab3a6ec..a3b49824c 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -70,8 +70,9 @@ | 12 | 2026-10-26 | `igcClosing` and `igcClosed` report the `trigger` that closes the list | | 13 | 2026-10-26 | Remote data: `igcSearchInput`, `filteringOptions.mode`, `igcDataRequest`; a new `data` keeps the selection | | 14 | 2026-10-27 | `selectAllItems()` and `deselectAllItems()` act on the items that the list shows | -| 15 | 2026-10-27 | `totalItemCount` placeholders and `loading` | +| 15 | 2026-10-27 | `totalItemCount` skeleton placeholders and the `loading` spinner | | 16 | 2026-10-27 | The select-all option (`showSelectAll`) and the `select-all` and `indeterminate` parts | +| 17 | 2026-10-27 | The focus moves to the main input when the list closes around it | ## Overview @@ -326,16 +327,19 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { - `totalItemCount` is the number of all the results, when the server knows it. In remote mode, the list adds a placeholder option for each result that a non-empty `data` does not hold yet, after the groups, so the scrollbar shows the full list. An empty `data` shows the empty message, because the first page is the job of the application. - A placeholder has the `placeholder` part and the accessible name "Loading", is disabled, and counts in - `aria-setsize`. The keyboard can move to it, but Enter and Space on it do nothing, a click does not select it or + A placeholder is the skeleton of an item: the box of the checkbox in multiple selection, and a bar where the text + starts, with a wave that sweeps across it (none with reduced motion). It has the `placeholder` part and the + accessible name "Loading", is disabled, and counts in `aria-setsize`. The keyboard can move to it, but Enter and Space on it do nothing, a click does not select it or close the list, and the selection methods skip it. `igcDataRequest` then also comes when the list renders a placeholder, for example after End or a fast scroll. Its `count` covers the rendered placeholders and does not go past `totalItemCount`, and no request comes when `data` holds `totalItemCount` items. A total that is not a finite number counts as unknown. `data` grows only at its end, so a jump far into the placeholders requests all the items up to the jump. -- `loading` shows an indeterminate progress bar, named "Loading", above the header. The default bar overlays the top - of the content below it, so it does not move the list. While `loading` is set, the empty message hides. Content in - the `loading` slot replaces the bar, and Tab reaches focusable content in the slot before the header content. +- `loading` shows a spinner, named "Loading", in place of the toggle icon, so it shows also while the list is closed, + for example during a search in single selection. The spinner covers the box of the toggle icon, so the input does + not change its width. An empty list shows "Loading" instead of the empty message. Content in the `loading` slot + replaces the spinner. It is in the input, so a click on it toggles the list, as on the toggle icon, and Tab does not + reach it while the list is open. The busy state shows in the field, not in the list, so the list does not move. `bindComboDataSource()` from `igniteui-webcomponents/extras` implements this flow on the public API. See the `RemoteData` story. @@ -352,7 +356,7 @@ instance through `locale` and `resourceStrings`: | `combo_clearItems_placeholder` | Clear Selection | The label of the clear action. | | `combo_aria_label_options` | Selected options | The accessible name of the selection. | | `combo_aria_label_no_options` | No options selected | The accessible name of an empty selection. | -| `combo_loading` | Loading | The accessible name of the progress bar and of the placeholder options. | +| `combo_loading` | Loading | The accessible name of the spinner and of the placeholder options, and the message of an empty list while loading. | | `combo_select_all` | Select all | The text of the select-all option. | ### Keyboard interactions @@ -377,7 +381,7 @@ While the list of options **is** visible: | Enter | Selects the active option in single selection mode, and closes the list in all modes. On a placeholder of `totalItemCount`, it does nothing. | | Home / End | Activates the first or the last option. | | Escape | Closes the list. The combo stays focused. | -| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the loading content, the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | +| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | Typing while the component is focused filters the list down to the closest matches. @@ -400,7 +404,7 @@ Typing while the component is focused filters the list down to the closest match | caseSensitiveIcon | case-sensitive-icon | No | `boolean` | false | Whether the search input shows the icon that toggles case-sensitive filtering. | | disableClear | disable-clear | No | `boolean` | false | Whether the clear button is hidden. | | totalItemCount | total-item-count | No | `number \| undefined` | - | The number of all the items of a remote source, when the source knows it. In remote filtering mode, the list shows a placeholder for each item that `data` does not hold yet. | -| loading | loading | Yes | `boolean` | false | Whether the combo loads options. The list then shows a progress bar, which the `loading` slot can replace, and hides the empty message. | +| loading | loading | Yes | `boolean` | false | Whether the combo loads options. A spinner, which the `loading` slot can replace, then takes the place of the toggle icon, and an empty list shows a loading message instead of the empty message. | | showSelectAll | show-select-all | No | `boolean` | false | Whether the list starts with an option that selects all the items that it shows, or deselects them when all of them are selected. Only in multiple selection mode. | | itemTemplate | - | No | `ComboItemTemplate` | - | The template for the content of each combo item. | | groupHeaderTemplate | - | No | `ComboGroupHeaderTemplate` | - | The template for the content of each group header. | @@ -515,7 +519,8 @@ type ComboCloseTrigger = The open events keep an empty `detail`. `hide()`, `toggle()` and a change of `open` emit no events. An app that stages a selection in its own footer can save `value` on `igcOpening`, commit on its Apply button or on the `enter` -trigger, and put the saved value back on the other triggers of `igcClosed`. +trigger, and put the saved value back on the other triggers of `igcClosed`. When the list closes while the focus is in +it, also on `hide()` from such a button, the focus moves to the main input, because the closed list cannot keep it. ### Slots @@ -526,7 +531,7 @@ trigger, and put the saved value back on the other triggers of `igcClosed`. | `header` | Renders a container before the list of options. | | `footer` | Renders a container after the list of options. | | `empty` | Renders content when the list has no items or data. | -| `loading` | Renders content that replaces the default progress bar while `loading` is set. | +| `loading` | Renders content that replaces the default spinner while `loading` is set. It is in the input, so a click on it toggles the list, as on the toggle icon. | | `helper-text` | Renders content below the input. | | `toggle-icon` | Renders content that replaces the default toggle icon. | | `clear-icon` | Renders content that replaces the default clear icon. | @@ -562,8 +567,8 @@ trigger, and put the saved value back on the other triggers of `igcClosed`. | `select-all` | Added to the item part of the select-all option. | | `header` / `footer` | The containers holding the header and footer content. | | `empty` | The container holding the empty content. | -| `loading` | The container of the progress bar that shows while `loading` is set. | -| `placeholder` | Added to the item part of each item to load, which `totalItemCount` adds. Its bar has the color of the text. | +| `loading` | The spinner that replaces the toggle icon while `loading` is set. It has the color of the text. | +| `placeholder` | Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. | ### CSS custom states @@ -644,10 +649,12 @@ Generated by `runValidationContainerTests`. ### Header and footer slots 24. Tab moves from the search input to the header content, the list and the footer content, and Shift+Tab moves - back. With an empty list, Tab reaches the content of the `empty` slot, and while `loading` is set, the content of - the `loading` slot before the header content. The accessibility audit passes with focusable slot content. + back. With an empty list, Tab reaches the content of the `empty` slot. The accessibility audit passes with + focusable slot content. 25. Tab from the last footer control and Shift+Tab from the search input close the list and move the focus out of the combo. In single selection, the main input starts the order, and Tab selects the active item when it leaves. + `hide()` from a footer control, or `open` set to `false` with the focus in the search input, moves the focus to + the main input. A focus outside the list stays where it is. 26. Without focusable slot content, Tab from the search input closes the list and moves the focus on. 27. Tab moves out of a stop inside another stop, does not stop on a host that delegates the focus or on a control with `visibility: hidden`, and leaves a Tab that a slotted control handles to that control. @@ -694,8 +701,9 @@ Generated by `runValidationContainerTests`. when `data` holds `totalItemCount` items, and comes when the total no longer stops it. Space, a click and `selectAllItems()` do not select a placeholder, and Enter or a click on one does not close the list in single selection. -38. `loading` shows the named progress bar without moving the list, hides the empty message and passes the - accessibility audit. The `loading` slot replaces the bar, and Tab reaches its content before the header content. +38. `loading` shows the named spinner over the hidden toggle icon, without a change of the toggle width or a move of + the list, and "Loading" in an empty list, and passes the accessibility audit. The `loading` slot replaces the spinner. The spinner and the skeletons + take their colors from the `loading` and `placeholder` parts. ### Select-all option @@ -731,7 +739,7 @@ Generated by `runValidationContainerTests`. because a listbox uses one of `aria-selected` and `aria-checked` for all its options, so its mixed state shows only on its checkbox. A placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a - `progressbar` named "Loading" shows above the list. The listbox has no `aria-busy`, so a screen reader continues to + `progressbar` named "Loading" takes the place of the toggle icon. The listbox has no `aria-busy`, so a screen reader continues to read the loaded options while the next page loads. - The current selection is announced through a label resolved from the resource strings, which also covers the empty case. diff --git a/src/components/combo/themes/combo.base.scss b/src/components/combo/themes/combo.base.scss index 24b9b7d68..1570b1a93 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -90,44 +90,40 @@ font-size: rem(13px); } -[part~='loading'] { - position: relative; - - // The default bar overlays the content below it, so it does not move it. - [role='progressbar'] { - position: absolute; - inset-inline: 0; - z-index: 1; - block-size: rem(4px); - overflow: hidden; - - &::before { - content: ''; - display: block; - inline-size: 40%; - block-size: 100%; - background: color(primary, 500); - animation: loading 1.5s ease-in-out infinite; +// The toggle icon keeps its box while loading, and the spinner covers it. +[part~='toggle-icon'] { + display: grid; + place-items: center; - @media (prefers-reduced-motion: reduce) { - inline-size: 100%; - animation: none; - } - } + igc-icon, + slot::slotted(*), + [part~='loading'] { + grid-area: 1 / 1; } } -:host(:dir(rtl)) [part~='loading'] [role='progressbar'] { - scale: -1 1; +:host([loading]) slot[name='toggle-icon'] { + visibility: hidden; } -@keyframes loading { - from { - translate: -100%; +[part~='loading'] { + display: block; + box-sizing: border-box; + inline-size: rem(16px); + block-size: rem(16px); + border: rem(2px) solid currentcolor; + border-block-start-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation-duration: 2.4s; } +} +@keyframes spin { to { - translate: 250%; + rotate: 1turn; } } @@ -137,15 +133,49 @@ inset-inline-start: 0; } +// The skeleton of an item to load: the box of the checkbox and a bar. [part~='placeholder'] { + --_skeleton: color-mix(in srgb, currentcolor 16%, transparent); + --ig-checkbox-empty-color: transparent; + --ig-checkbox-empty-fill-color: transparent; + pointer-events: none; + // The pseudo-element is the box in all the themes. + &::part(checkbox)::after { + background: var(--_skeleton); + transition: none; + } + span { - inline-size: 60%; + position: relative; + overflow: hidden; block-size: 0.75em; border-radius: rem(4px); - background: currentcolor; - opacity: 0.2; + background: var(--_skeleton); + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, var(--_skeleton), transparent); + translate: -100% 0; + animation: wave 1.6s ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + display: none; + } + } + } +} + +:host(:dir(rtl)) [part~='placeholder'] span::after { + animation-direction: reverse; +} + +@keyframes wave { + to { + translate: 100% 0; } } @@ -193,8 +223,14 @@ color: Highlight; } - [part~='loading'] [role='progressbar']::before, - [part~='placeholder'] span { + [part~='loading'] { + forced-color-adjust: none; + border-color: CanvasText; + border-block-start-color: transparent; + } + + [part~='placeholder'] span, + [part~='placeholder']::part(checkbox)::after { forced-color-adjust: none; background: CanvasText; } From 824517b828a2ef09b0bb3d77270fb8acbc242d66 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 13:05:06 +0200 Subject: [PATCH 12/32] docs(stories): show the remote, loading, select-all and staged selection features of the combo RemoteData shows a server log and a combo with styled loading parts. LazyLoading sets loading and replaces the spinner through the loading slot. ColumnChooser uses the select-all option. T he new StagedFilter story applies labels on Apply or Enter and puts them back on the other close triggers. --- stories/combo.stories.ts | 422 +++++++++++++++++++++++++++++++-------- 1 file changed, 342 insertions(+), 80 deletions(-) diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 0f3a93227..0346cc606 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -8,6 +8,7 @@ import { IgcButtonComponent, IgcCircularProgressComponent, type IgcComboChangeEventArgs, + type IgcComboClosingEventArgs, IgcComboComponent, IgcInputComponent, defineComponents, @@ -162,7 +163,7 @@ const metadata: Meta = { loading: { type: 'boolean', description: - 'Whether the combo loads options. The list then shows a progress bar,\nwhich the `loading` slot can replace, and hides the empty message.', + 'Whether the combo loads options. A spinner, which the `loading` slot can\nreplace, then takes the place of the toggle icon, and an empty list shows\na loading message instead of the empty message.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -295,8 +296,9 @@ interface IgcComboArgs { */ totalItemCount: number; /** - * Whether the combo loads options. The list then shows a progress bar, - * which the `loading` slot can replace, and hides the empty message. + * Whether the combo loads options. A spinner, which the `loading` slot can + * replace, then takes the place of the toggle icon, and an empty list shows + * a loading message instead of the empty message. */ loading: boolean; /** @@ -390,6 +392,8 @@ const cities: City[] = [ ]; export const Default: Story = { + // The local data has no remote total. + argTypes: { totalItemCount: { table: { disable: true } } }, parameters: { docs: { description: { @@ -719,7 +723,7 @@ export const ColumnChooser: Story = { docs: { description: { story: - 'A column chooser for a table of orders. The list is short, so `disable-filtering` removes the search input. The Order column always shows, and it is not in the list. `igcChange` gives `newValue`, and the story renders the table from it. The buttons use the methods: "Show all" calls `select()` with no argument, which selects all the options, and "Reset" sets `value` to the default columns. A method or a new `value` does not send `igcChange`, so the buttons render the table themselves.', + 'A column chooser for a table of orders. The list is short, so `disable-filtering` removes the search input. The Order column always shows, and it is not in the list. `show-select-all` puts "Select all" first in the list: it shows all the columns, or hides them when all of them show, and its checkbox is indeterminate while only some of them show. `igcChange` gives `newValue`, and the story renders the table from it. "Reset" sets `value` to the default columns. A new `value` does not send `igcChange`, so the button renders the table itself.', }, }, }, @@ -760,12 +764,6 @@ export const ColumnChooser: Story = { update(); }; - const showAll = () => { - combo.value!.select(); - visible = combo.value!.value as string[]; - update(); - }; - const reset = () => { combo.value!.value = [...defaultColumns]; visible = [...defaultColumns]; @@ -818,14 +816,14 @@ export const ColumnChooser: Story = { ${ref(combo)} label="Columns" disable-filtering + show-select-all value-key="key" display-key="label" .data=${columns} .value=${visible} @igcChange=${change} > - Show all - Reset + Reset
    @@ -833,6 +831,204 @@ export const ColumnChooser: Story = { }, }; +const issueLabels = [ + 'accessibility', + 'bug', + 'design', + 'documentation', + 'feature', + 'good first issue', + 'help wanted', + 'performance', + 'question', + 'security', + 'testing', +]; + +const issues = [ + { + id: 512, + title: 'Hidden focus ring in high contrast', + labels: ['accessibility', 'bug'], + }, + { + id: 509, + title: 'A dark theme for the date picker', + labels: ['design', 'feature'], + }, + { + id: 507, + title: 'Document the grid shortcuts', + labels: ['documentation', 'good first issue'], + }, + { + id: 503, + title: 'Slow scroll of long lists on phones', + labels: ['bug', 'performance'], + }, + { + id: 498, + title: 'Sanitize the HTML of chat messages', + labels: ['security'], + }, + { + id: 495, + title: 'Screen readers read the price twice', + labels: ['accessibility', 'help wanted'], + }, + { + id: 490, + title: 'How to load the options from a server?', + labels: ['question'], + }, + { + id: 486, + title: 'End-to-end tests for the checkout', + labels: ['help wanted', 'testing'], + }, + { id: 482, title: 'Fix the date format in exports', labels: ['bug'] }, + { id: 478, title: 'Export the report as PDF', labels: ['feature'] }, +]; + +export const StagedFilter: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'An issue list with a label filter that applies only on demand. Select labels, then press Apply, or Enter on an option: the list shows the issues with one of them. Cancel, Escape, Tab out of the list, or a click outside or on the input puts the applied labels back. `igcClosed` gives the user action that closes the list in `detail.trigger`, so the story applies the labels on `enter` and puts them back on the other triggers. The buttons call `hide()`, which sends no events, so they apply or put back the labels themselves. The focus then moves to the input of the combo. They are in the `footer` slot, and Tab moves to them from the search input and the list. With the list closed, the clear button and Escape clear the filter at once. `show-select-all` selects the labels that the search shows: type "e", then select all.', + }, + }, + }, + render: () => { + let applied: string[] = []; + const combo = createRef(); + + const { mount, update } = renderInto(() => { + const shown = applied.length + ? issues.filter(({ labels }) => labels.some((l) => applied.includes(l))) + : issues; + + return html` +

    + ${ + applied.length + ? `${shown.length} of ${issues.length} issues with ${applied.join(', ')}` + : `All ${issues.length} issues` + } +

    +
      + ${shown.map( + ({ id, title, labels }) => html` +
    • + #${id} ${title} + ${labels.map((label) => html`${label}`)} +
    • + ` + )} +
    + `; + }); + + const apply = () => { + applied = [...(combo.value!.value as string[])]; + update(); + }; + + const restore = () => { + combo.value!.value = [...applied]; + }; + + const closed = ({ detail }: CustomEvent) => + detail.trigger === 'enter' ? apply() : restore(); + + // The clear button and Escape clear the labels also with the list closed, + // and then the change applies at once. + const changed = ({ detail }: CustomEvent) => { + if (!combo.value!.open) { + applied = detail.newValue as string[]; + update(); + } + }; + + const button = (action: () => void) => () => { + action(); + combo.value!.hide(); + }; + + return html` + ${styles} + +
    +

    Issues

    + + + Apply the labels with Apply, or with Enter in the list. + + + +
    +
    + `; + }, +}; + interface Airport { code: string; label: string; @@ -881,7 +1077,7 @@ export const LazyLoading: Story = { docs: { description: { story: - 'A flight search that loads the airports from the server when the list opens for the first time. `igcOpening` starts the request, and until the data comes, the `empty` slot shows a spinner and a loading message. After the load, the same slot tells that the search found nothing. `filteringOptions` sets `filterKey` to a key that joins the code, the city and the airport name, so "lhr", "london" and "heathrow" all find Heathrow. The status message tells a screen reader user when the airports load.', + 'A flight search that loads the airports from the server when the list opens for the first time. `igcOpening` starts the request and sets `loading`, so the empty list shows "Loading", and a spinner takes the place of the toggle icon. The `loading` slot replaces the default spinner with a circular progress. After the load, the `empty` slot tells that the search found nothing. `filteringOptions` sets `filterKey` to a key that joins the code, the city and the airport name, so "lhr", "london" and "heathrow" all find Heathrow. The status message tells a screen reader user when the airports load.', }, }, }, @@ -896,32 +1092,29 @@ export const LazyLoading: Story = { const combo = event.target as IgcComboComponent; const output = status.value!; - const empty = combo.querySelector('[slot="empty"]')!; requested = true; + combo.loading = true; output.textContent = 'Loading the airports…'; await delay(1200); combo.data = airports; - empty.textContent = 'No airport matches your search.'; + combo.loading = false; output.textContent = `${airports.length} airports loaded.`; }; return html` ${styles} -
    +

    Find a flight

    -
    - - - Loading the airports… - -
    + + No airport matches your search.

    @@ -1008,9 +1198,10 @@ const europeanCities: City[] = ( async function fetchCities( searchText: string, skip: number, - top: number + top: number, + latency: number ): Promise<{ items: City[]; total: number }> { - await delay(400); + await delay(latency); const text = searchText.toLocaleLowerCase(); const results = europeanCities.filter(({ name }) => @@ -1029,13 +1220,12 @@ export const RemoteData: Story = { docs: { description: { story: - 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, twenty cities at a time. Open the list, scroll to its end to load more, then type to search. The list has a placeholder row for each city to load, so the scrollbar shows all the results, and a progress bar shows while a search waits or a page loads. Press End to jump to the last city: one request loads all the cities up to it. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The log shows each request and answer.', + 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, twenty cities at a time. Open the list, scroll to its end to load more, then type to search. The list has a skeleton row for each city to load, so the scrollbar shows all the results, and a spinner takes the place of the toggle icon while a search waits or a page loads. Press End to jump to the last city: one request loads all the cities up to it. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The second combo has a slower server and styles its loading parts: `::part(loading)` sets the color and the width of the spinner, and `::part(placeholder)` sets the color of the skeleton rows. The server log shows each request and answer of both combos.', }, }, }, render: () => { const log = createRef(); - let binding: ComboDataBinding | undefined; const write = (line: string) => { const item = document.createElement('li'); @@ -1043,62 +1233,134 @@ export const RemoteData: Story = { log.value?.prepend(item); }; - const source: ComboDataSource = { - load: async ({ searchText, startIndex, count }, signal) => { - write(`Request: "${searchText}", ${count} from item ${startIndex + 1}`); - - const { items, total } = await fetchCities( - searchText, - startIndex, - count - ); - - write( - signal.aborted - ? `Aborted: "${searchText}"` - : `Answer: ${items.length} of ${total} cities` - ); - return { items, totalCount: total }; - }, - }; - - const bind = (element?: Element) => { - binding?.dispose(); - binding = element - ? bindComboDataSource(element as IgcComboComponent, source, { - pageSize: 20, - }) - : undefined; + /** A ref callback that binds the combo, and disposes the binding when the combo goes. */ + const binder = (name: string, latency: number) => { + let binding: ComboDataBinding | undefined; + + const source: ComboDataSource = { + load: async ({ searchText, startIndex, count }, signal) => { + const query = searchText ? `"${searchText}"` : 'all cities'; + write( + `${name}: request ${startIndex + 1}–${startIndex + count} of ${query}` + ); + + const { items, total } = await fetchCities( + searchText, + startIndex, + count, + latency + ); + + write( + signal.aborted + ? `${name}: aborted the request of ${query}` + : `${name}: ${items.length} of ${total} cities` + ); + return { items, totalCount: total }; + }, + }; + + return (element?: Element) => { + binding?.dispose(); + binding = element + ? bindComboDataSource(element as IgcComboComponent, source, { + pageSize: 20, + }) + : undefined; + }; }; - const onChange = (event: CustomEvent) => { - write(`Value: ${event.detail.newValue.join(', ') || 'none'}`); - }; + const onChange = + (name: string) => (event: CustomEvent) => { + write(`${name}: value ${event.detail.newValue.join(', ') || 'none'}`); + }; return html` ${styles} -
    +

    Cities from the server

    - - No city matches your search. - -
      +
      +
      + + No city matches your search. + + + No city matches your search. + + A slower server, so the loading state shows longer. + + +
      +
      +

      Server log

      +
        +
        +
        `; }, From b0a7a924796f98880e7f151fcca56d985d004e35 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 13:15:21 +0200 Subject: [PATCH 13/32] fix(combo): give igcChange newValue the order of the value that follows --- CHANGELOG.md | 1 + src/components/combo/combo.spec.ts | 16 ++++++++++++++++ src/components/combo/combo.ts | 2 +- src/components/combo/spec.md | 3 ++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c9fafde..8c1753e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -168,6 +168,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Setting `singleSelect` or `disableFiltering` to the value that the combo already has no longer clears the search text and the filter. Before, a framework that sets each property on each render, such as React, cleared the search text after each key, and the list did not filter. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) - Tab and Shift+Tab now move through the focusable content of the `header`, `empty` and `footer` slots while the list is open. Before, Tab closed the list, so a keyboard user could not reach a button in these slots. Tab from the last footer element, and Shift+Tab from the search input, close the list and move the focus on, as before. [#2529](https://github.com/IgniteUI/igniteui-webcomponents/issues/2529) - When the list closes while the focus is in it, for example on `hide()` from a button in the footer, the focus moves to the main input. Before, the closed list could not keep the focus, and it went to the document. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) + - `igcChange.newValue` now has the order of the `value` that follows the change: the selected values first, then the new ones. Before, it listed the new values first, so an app that saved `newValue` as a staged selection did not match `value` after the change. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The `igcChange` of the search input no longer leaves the combo. Before, a listener on the combo got a second `igcChange` with the search text as its `detail` when the search input lost the focus. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - #### Date picker - `stepUp()` and `stepDown()` now update `value` and the form value at once, also while the input has the focus. Before, only the input showed the new date. diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index e63d68baa..f9ba66f27 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -713,6 +713,22 @@ describe('Combo', () => { expect(newValue).to.eql(combo.value); }); + it('lists the new items in newValue after the selected ones, as value does', async () => { + let newValue: unknown; + combo.addEventListener('igcChange', (event: CustomEvent) => { + newValue = event.detail.newValue; + }); + + combo.select(['BG02']); + await openComboPopover(combo); + + firstOf(items(combo)).click(); + await elementUpdated(combo); + + expect(combo.value).to.eql(['BG02', 'BG01']); + expect(newValue).to.eql(combo.value); + }); + it('should fire igcChange deselection type event on mouse click', async () => { const eventSpy = spy(combo, 'emitEvent'); const args = { diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 78c4555fb..a115e1c99 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -891,7 +891,7 @@ export default class IgcComboComponent< private _previewValue(resolved: T[], selecting: boolean): ComboValue[] { if (selecting) { return this._toValues( - this.singleSelect ? resolved : new Set([...resolved, ...this._selected]) + this.singleSelect ? resolved : new Set([...this._selected, ...resolved]) ); } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index a3b49824c..325ade1b7 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -73,6 +73,7 @@ | 15 | 2026-10-27 | `totalItemCount` skeleton placeholders and the `loading` spinner | | 16 | 2026-10-27 | The select-all option (`showSelectAll`) and the `select-all` and `indeterminate` parts | | 17 | 2026-10-27 | The focus moves to the main input when the list closes around it | +| 18 | 2026-10-27 | `igcChange.newValue` has the order of the `value` that follows | ## Overview @@ -478,7 +479,7 @@ type ComboGroupHeaderTemplate = (props: { | Name | Cancellable | Description | | ---------- | ----------- | ------------------------------------------------- | -| igcChange | true | Emitted before the user changes the selection. Cancel the event to keep the selection. | +| igcChange | true | Emitted before the user changes the selection. Cancel the event to keep the selection. `newValue` is the `value` after the change, in the same order: the selected values first, then the new ones. | | igcOpening | true | Emitted before the user opens the list of options. Cancelable. | | igcOpened | false | Emitted after the list of options opens on a user action. | | igcClosing | true | Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. | From 5ca92fa51083ba4db950665fc4bd3a7c553bc4fd Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 14:23:25 +0200 Subject: [PATCH 14/32] feat(combo): add group selection groupSelection makes each group header an option in multiple selection with groupKey. A click or Space on it selects the shown items of its group, or deselects them when all of them are selected, with one igcChange, and its checkbox is indeterminate while only some are selected. The header keeps the text style of a group header. The select-all option and the header options now have aria-posinset and aria-setsize, because Chromium moves the positions after an option without them. --- CHANGELOG.md | 3 +- public-api.json | 2 + src/components/combo/combo.spec.ts | 203 +++++++++++++++++- src/components/combo/combo.ts | 146 +++++++++---- src/components/combo/controllers/data.ts | 78 +++++-- .../combo/controllers/navigation.ts | 4 +- src/components/combo/spec.md | 49 +++-- .../combo/themes/combo-item.base.scss | 11 + .../combo/themes/shared/combo.fluent.scss | 4 + src/components/combo/types.ts | 5 +- stories/combo.stories.ts | 15 ++ 11 files changed, 434 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1753e60..7c5d0e95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input diff --git a/public-api.json b/public-api.json index 861f33fc8..a35feceac 100644 --- a/public-api.json +++ b/public-api.json @@ -1209,6 +1209,7 @@ "display-key": "unknown", "filtering-options": "FilteringOptions", "group-key": "Keys | string | undefined", + "group-selection": "boolean", "group-sorting": "\"asc\" | \"desc\" | \"none\" ('asc' | 'desc' | 'none')", "invalid": "boolean", "label": "string | undefined", @@ -1241,6 +1242,7 @@ "form": "readonly HTMLFormElement | null", "groupHeaderTemplate": "ComboGroupHeaderTemplate", "groupKey": "Keys | string | undefined", + "groupSelection": "boolean", "groupSorting": "\"asc\" | \"desc\" | \"none\" ('asc' | 'desc' | 'none')", "invalid": "boolean", "itemTemplate": "ComboItemTemplate", diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index f9ba66f27..7310c522b 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -2802,7 +2802,7 @@ describe('Combo', () => { ]); }); - it('leaves the select-all option out of the requests and the positions', async () => { + it('leaves the select-all option out of the requests and counts it in the positions', async () => { remote.showSelectAll = true; remote.totalItemCount = 12; await openComboPopover(remote); @@ -2813,7 +2813,7 @@ describe('Combo', () => { { startIndex: 10, count: 2 }, ]); expect(placeholders().at(-1)!.getAttribute('aria-posinset')).to.equal( - '12' + '13' ); remote.selectAllItems(); @@ -3095,7 +3095,7 @@ describe('Combo', () => { await openComboPopover(combo); }); - it('comes first in multiple selection and does not count as an option', async () => { + it('comes first in multiple selection and counts as the first option', async () => { const first = list.querySelector('[part~="item"], igc-combo-header'); const [, option] = items(combo); @@ -3103,9 +3103,11 @@ describe('Combo', () => { expect(first!.textContent?.trim()).to.equal('Select all'); expect(ariaOf(first!)).to.eql({ selected: 'false', checked: null }); expect(first!.hasAttribute('aria-checked')).to.be.false; - expect(first!.hasAttribute('aria-setsize')).to.be.false; - expect(option.getAttribute('aria-posinset')).to.equal('1'); - expect(option.getAttribute('aria-setsize')).to.equal(`${cities.length}`); + expect(first!.getAttribute('aria-posinset')).to.equal('1'); + expect(option.getAttribute('aria-posinset')).to.equal('2'); + expect(option.getAttribute('aria-setsize')).to.equal( + `${cities.length + 1}` + ); await expect(combo).shadowDom.to.be.accessible( axeReflectedRelationsOptions @@ -3238,6 +3240,195 @@ describe('Combo', () => { }); }); + describe('Group selection', () => { + let eventSpy: SinonSpy; + + const headers = () => [ + ...list.querySelectorAll('[part~="group-header"]'), + ]; + + const checkboxOf = (item: Element) => + item.shadowRoot!.querySelector('igc-checkbox')!; + + const changes = () => + eventSpy.withArgs('igcChange').args.map(([, { detail }]) => ({ + ...detail, + items: detail.items.map(({ id }: City) => id), + })); + + async function search(text: string): Promise { + searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); + await comboStable(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + await openComboPopover(combo); + }); + + it('renders the group headers as options with a checkbox in multiple selection', async () => { + const [bulgaria] = headers(); + const [first] = items(combo); + + expect(headers()).lengthOf(2); + expect(bulgaria.localName).to.equal('igc-combo-item'); + expect(bulgaria.textContent?.trim()).to.equal('Bulgaria'); + expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); + expect(checkboxOf(bulgaria)).to.exist; + // The two headers count as options. + expect(bulgaria.getAttribute('aria-posinset')).to.equal('1'); + expect(first.getAttribute('aria-posinset')).to.equal('2'); + expect(first.getAttribute('aria-setsize')).to.equal( + `${cities.length + 2}` + ); + + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + combo.singleSelect = true; + await comboStable(combo); + + expect(headers().map(({ localName }) => localName)).to.eql([ + 'igc-combo-header', + 'igc-combo-header', + ]); + expect(items(combo)[0].getAttribute('aria-posinset')).to.equal('1'); + expect(items(combo)[0].getAttribute('aria-setsize')).to.equal( + `${cities.length}` + ); + }); + + it('selects the shown items of the group, then deselects them, with one igcChange each', async () => { + await search('o'); + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'BG02']); + expect(internalsOf(headers()[0])?.getARIA('ariaSelected')).to.equal( + 'true' + ); + expect(combo.open).to.be.true; + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(combo.value).to.be.empty; + expect(changes()).to.eql([ + { + newValue: ['BG01', 'BG02'], + items: ['BG01', 'BG02'], + type: 'selection', + }, + { newValue: [], items: ['BG01', 'BG02'], type: 'deselection' }, + ]); + }); + + it('shows the mixed state and selects only the other items of the group', async () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync( + 'igc-combo::part(checkbox indeterminate) { scroll-margin-top: 7px; }' + ); + + combo.select('BG01'); + await comboStable(combo); + + const [bulgaria, unitedStates] = headers(); + + await withDocumentSheets([sheet], () => { + const control = + checkboxOf(bulgaria).renderRoot.querySelector('[part~="control"]')!; + + expect(getComputedStyle(control).scrollMarginTop).to.equal('7px'); + }); + + expect(checkboxOf(bulgaria).indeterminate).to.be.true; + expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); + expect(checkboxOf(unitedStates).indeterminate).to.be.false; + + simulateClick(bulgaria); + await comboStable(combo); + + expect(changes().map(({ items }) => items)).to.eql([['BG02', 'BG03']]); + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + expect(checkboxOf(headers()[0]).checked).to.be.true; + }); + + it('stops on the group headers with the arrow keys and Home, and Space toggles them', async () => { + simulateKeyboard(list, homeKey); + simulateKeyboard(list, spaceBar); + await comboStable(combo); + + expect(headers()[0].active).to.be.true; + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + + for (let i = 0; i < 4; i++) { + simulateKeyboard(list, arrowDown); + } + await comboStable(combo); + + expect(headers()[1].active).to.be.true; + + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + }); + + it('works with the select-all option', async () => { + combo.showSelectAll = true; + await comboStable(combo); + + simulateClick(list.querySelector('[part~="select-all"]')!); + await comboStable(combo); + + expect( + headers().map((header) => internalsOf(header)?.getARIA('ariaSelected')) + ).to.eql(['true', 'true']); + }); + + it('lists an item that data holds twice one time in igcChange', async () => { + combo.data = [cities[0], ...cities]; + await comboStable(combo); + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(changes()[0].items).to.eql(['BG01', 'BG02', 'BG03']); + }); + + it('resets the active option when group selection turns off', async () => { + simulateKeyboard(list, homeKey); + await comboStable(combo); + + expect(headers()[0].active).to.be.true; + + combo.groupSelection = false; + await comboStable(combo); + + expect(headers()[0].localName).to.equal('igc-combo-header'); + expect(items(combo).some((item) => item.active)).to.be.false; + + simulateKeyboard(list, homeKey); + await comboStable(combo); + + expect(items(combo)[0].active).to.be.true; + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index a115e1c99..63424b3ec 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -130,12 +130,12 @@ const DATA_REQUEST_COUNT = 20; * @csspart list - The list of options box of the combo. * @csspart item - Each item in the list of options. * @csspart group-header - Each group header in the list of options. - * @csspart active - Added to the item part when the item is active, and to the case-icon part while case-sensitive filtering is on. - * @csspart selected - Added to the item part when the item is selected. + * @csspart active - Added to the item part when the item is active, to the group-header part when the header is an active option of `groupSelection`, and to the case-icon part while case-sensitive filtering is on. + * @csspart selected - Added to the item part when the item is selected, and to the group-header part of `groupSelection` when all the shown items of the group are selected. * @csspart checkbox - The checkbox of each item in the list. * @csspart checkbox-indicator - The checkbox indicator of each item in the list. * @csspart checked - Added to the checkbox and checkbox-indicator parts when the checkbox is checked. - * @csspart indeterminate - Added to the checkbox and checkbox-indicator parts of the select-all option while only some of the shown items are selected. + * @csspart indeterminate - Added to the checkbox and checkbox-indicator parts of the select-all option, or of a group header of `groupSelection`, while only some of its shown items are selected. * @csspart select-all - Added to the item part of the select-all option. * @csspart header - The container holding the header content of the combo. * @csspart footer - The container holding the footer content of the combo. @@ -507,6 +507,16 @@ export default class IgcComboComponent< @property({ type: Boolean, attribute: 'show-select-all' }) public showSelectAll = false; + /** + * Whether a group header is an option that selects the items of its group + * that the list shows, or deselects them when all of them are selected. + * Only in multiple selection mode with `groupKey`. + * @attr group-selection + * @default false + */ + @property({ type: Boolean, attribute: 'group-selection' }) + public groupSelection = false; + /* blazorSuppress */ /** * The template used for the content of each combo item. @@ -624,8 +634,12 @@ export default class IgcComboComponent< this.focus({ preventScroll: true }); } - // The select-all option moves the other records by one. - if (props.has('showSelectAll') && this.hasUpdated) { + // The select-all option moves the other records by one, and an active + // group header can stop being an option. + if ( + (props.has('showSelectAll') || props.has('groupSelection')) && + this.hasUpdated + ) { this._activeIndex = -1; } @@ -635,6 +649,7 @@ export default class IgcComboComponent< props.has('filteringOptions') || props.has('totalItemCount') || props.has('showSelectAll') || + props.has('groupSelection') || props.has('singleSelect') || props.has('data') ) { @@ -1022,20 +1037,35 @@ export default class IgcComboComponent< this._listRef.value?.requestUpdate(); } - /** The items that the list shows. See {@link isItemRecord}. */ + /** The items of the records from `start` to `end`, each one time. See {@link isItemRecord}. */ + private _itemsIn(start: number, end: number): T[] { + const records = this._state.dataState; + const items = new Set(); + + for (let i = start; i < end; i++) { + if (isItemRecord(records[i])) { + items.add(records[i].value); + } + } + + return [...items]; + } + + /** The items that the list shows. */ private _shownItems(): T[] { - // Applies a change of `data` or of the search text that no update has applied yet. + // Applies a change of `data` or of the search text that no update has + // applied yet. The update gives the new records to the list before it + // renders the selection. this._state.hostUpdate(); + this.requestUpdate(); - return this._state.dataState - .filter(isItemRecord) - .map((record) => record.value); + return this._itemsIn(0, this._state.loadedLength); } /** * Selects the record at `index`. With `toggle`, deselects a selected one. - * The select-all option toggles the shown items. - * @returns Whether `index` holds an item or the select-all option. + * A toggle option toggles its items. See {@link DataState.isToggle}. + * @returns Whether `index` holds an item or a toggle option. */ private _selectAt(index: number, toggle: boolean): boolean { const record = this._state.dataState[index]; @@ -1049,8 +1079,8 @@ export default class IgcComboComponent< toggle && this._selected.has(item) ? 'deselection' : 'selection', true ); - } else if (record?.kind === 'select-all') { - this._toggleShownItems(); + } else if (record && this._state.isToggle(record)) { + this._toggleItemsOf(index); } else { return false; } @@ -1061,31 +1091,34 @@ export default class IgcComboComponent< } /** - * Selects the shown items, or deselects them when all of them are selected, - * with one `igcChange` for the items that change. + * Selects the items of the toggle option at `index`, or deselects them when + * all of them are selected, with one `igcChange` for the items that change. */ - private _toggleShownItems(): void { - const shown = this._shownItems(); - const unselected = shown.filter((item) => !this._selected.has(item)); + private _toggleItemsOf(index: number): void { + const items = this._itemsIn(index + 1, this._state.endOf(index)); + const unselected = items.filter((item) => !this._selected.has(item)); this._applySelection( - unselected.length ? unselected : shown, + unselected.length ? unselected : items, unselected.length ? 'selection' : 'deselection', true ); } /** - * Whether all (`true`), none (`false`) or some (`mixed`) of the shown items - * are selected. One pass that stops at the first mix, because the select-all - * row renders often. + * Whether all (`true`), none (`false`) or some (`mixed`) of the items of the + * toggle option at `index` are selected. Stops at the first mix, because + * these options render often. */ - private _shownSelection(): boolean | 'mixed' { + private _selectionOf(index: number): boolean | 'mixed' { + const records = this._state.dataState; let some = false; let every = true; if (this._selected.size) { - for (const record of this._state.dataState) { + for (let i = index + 1, end = this._state.endOf(index); i < end; i++) { + const record = records[i]; + if (!isItemRecord(record)) { continue; } @@ -1152,7 +1185,9 @@ export default class IgcComboComponent< // Remote results come later, so no option of the old results becomes active. this._activeIndex = - this.filteringOptions.mode === 'remote' ? -1 : this._state.firstItemIndex; + this.filteringOptions.mode === 'remote' + ? -1 + : this._state.firstOptionIndex; this._clearSingleSelection(); } @@ -1352,33 +1387,42 @@ export default class IgcComboComponent< const { value: item, index } = context; if (this.groupKey && item.header) { - return html` - - ${this.groupHeaderTemplate({ key: item.group, item: item.value })} - - `; - } + const content = this.groupHeaderTemplate({ + key: item.group, + item: item.value, + }); - const active = this._activeIndex === index; + return this._state.isToggle(item) + ? this._renderToggleOption(item, index, content) + : html`${content}`; + } if (item.kind === 'select-all') { - return this._renderSelectAll(index, active); + return this._renderToggleOption( + item, + index, + this.resourceStrings.combo_select_all + ); } + const active = this._activeIndex === index; + const selected = this._selected.has(item.value); // One template for both, so a row keeps its element when its item loads. const placeholder = item.kind === 'placeholder'; - const { itemCount } = this._state; + const { optionCount } = this._state; // The placeholders are last and share one record, so the index gives their position. const position = placeholder - ? itemCount - context.count + index + 1 + ? optionCount - context.count + index + 1 : item.position; return html` , + index: number, + content: unknown + ): TemplateResult { + const state = this._selectionOf(index); const all = state === true; + const active = this._activeIndex === index; return html` - ${this.resourceStrings.combo_select_all} + ${content} `; } diff --git a/src/components/combo/controllers/data.ts b/src/components/combo/controllers/data.ts index 40cf9c46f..599cd14aa 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -47,7 +47,7 @@ export class DataState implements ReactiveController { private _source?: T[]; private _dataState: ComboRecord[] = []; - private _itemCount = 0; + private _optionCount = 0; private _loadedLength = 0; private _searchTerm = ''; private _dirty = true; @@ -61,9 +61,9 @@ export class DataState implements ReactiveController { return this._dataState; } - /** The number of options in {@link dataState}, with the placeholders and without the group headers. */ - public get itemCount(): number { - return this._itemCount; + /** The number of options in {@link dataState}: the `aria-setsize` of each option. See {@link isOption}. */ + public get optionCount(): number { + return this._optionCount; } /** The number of records in {@link dataState} before the placeholders. */ @@ -83,9 +83,14 @@ export class DataState implements ReactiveController { : Number.POSITIVE_INFINITY; } - /** The index of the first record that is not a group header, or `-1`. */ - public get firstItemIndex(): number { - return this._dataState.findIndex((record) => !record.header); + /** Whether the group headers are options. See `groupSelection`. */ + private get _selectableGroups(): boolean { + return this._host.groupSelection && !this._host.singleSelect; + } + + /** The index of the first option, or `-1`. See {@link isOption}. */ + public get firstOptionIndex(): number { + return this._dataState.findIndex((record) => this.isOption(record)); } public set searchTerm(value: string) { @@ -166,11 +171,11 @@ export class DataState implements ReactiveController { } /** - * Filters and groups the indexed source, then numbers the visible options, - * so `aria-posinset` and `aria-setsize` skip the group headers. Puts the - * select-all option first, and appends a placeholder for each item of - * {@link totalCount} that a non-empty `data` does not hold. Only this - * controller owns the records, so `position` changes in place. + * Filters and groups the indexed source, then numbers the options for + * `aria-posinset`. Puts the select-all option first, and appends a + * placeholder for each item of {@link totalCount} that a non-empty `data` + * does not hold. Only this controller owns the records, so `position` + * changes in place. */ private _apply(records: ComboRecord[]): ComboRecord[] { const result = this._grouping.apply( @@ -178,12 +183,15 @@ export class DataState implements ReactiveController { this ); - let position = 0; + // A group shows only with items, so a result shows items. + const selectAll = + result.length > 0 && this._host.showSelectAll && !this._host.singleSelect; + + // The select-all option is the first option. + let position = selectAll ? 1 : 0; for (const record of result) { - if (!record.header) { - record.position = ++position; - } + record.position = this.isOption(record) ? ++position : -1; } // The application loads the first page, so an empty `data` shows the @@ -191,12 +199,9 @@ export class DataState implements ReactiveController { const missing = records.length ? this.totalCount - records.length : 0; const placeholders = Number.isFinite(missing) && missing > 0 ? missing : 0; - const selectAll = - position > 0 && this._host.showSelectAll && !this._host.singleSelect; - const head: ComboRecord[] = selectAll ? [SELECT_ALL] : []; - this._itemCount = position + placeholders; + this._optionCount = position + placeholders; this._loadedLength = head.length + result.length; if (!(head.length || placeholders)) { @@ -213,6 +218,39 @@ export class DataState implements ReactiveController { //#region Public API for host component + /** Whether the record is an option: not a group header, unless {@link _selectableGroups}. */ + public isOption(record: ComboRecord): boolean { + return !record.header || this._selectableGroups; + } + + /** + * Whether the record is an option that toggles the shown items after it: + * the select-all option, or a group header with {@link _selectableGroups}. + */ + public isToggle(record: ComboRecord): boolean { + return ( + record.kind === 'select-all' || (record.header && this._selectableGroups) + ); + } + + /** + * The end of the records that the toggle option at `index` toggles: the + * next group header, or the end of the loaded records. + */ + public endOf(index: number): number { + if (!this._dataState[index].header) { + return this._loadedLength; + } + + let end = index + 1; + + while (end < this._loadedLength && !this._dataState[end].header) { + end++; + } + + return end; + } + /** Only group sorting uses the collator, so the data changes only if it exists. */ public updateLocale(): void { if (this._compareCollator) { diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index e5aea36d2..c3948c333 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -59,7 +59,7 @@ export class ComboNavigationController { } private get _firstItem(): number { - return this._state.firstItemIndex; + return this._state.firstOptionIndex; } private get _lastItem(): number { @@ -289,7 +289,7 @@ export class ComboNavigationController { const items = this._state.dataState; for (let i = this.active + delta; i >= 0 && i < items.length; i += delta) { - if (!items[i].header) { + if (this._state.isOption(items[i])) { this._setActive(i); return; } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 325ade1b7..71150d404 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -46,6 +46,7 @@ - [Select all methods](#select-all-methods) - [Placeholders and loading](#placeholders-and-loading) - [Select-all option](#select-all-option) + - [Group selection](#group-selection) - [Assumptions and limitations](#assumptions-and-limitations) - [Accessibility](#accessibility) - [ARIA roles and properties](#aria-roles-and-properties) @@ -74,6 +75,8 @@ | 16 | 2026-10-27 | The select-all option (`showSelectAll`) and the `select-all` and `indeterminate` parts | | 17 | 2026-10-27 | The focus moves to the main input when the list closes around it | | 18 | 2026-10-27 | `igcChange.newValue` has the order of the `value` that follows | +| 19 | 2026-10-27 | Group selection (`groupSelection`); the group headers have no `group` role | +| 20 | 2026-10-27 | The select-all option and the group header options count in `aria-posinset` and `aria-setsize` | ## Overview @@ -205,8 +208,9 @@ combo.selection; // T[] - the records from the data source `selectAllItems()` and `deselectAllItems()` act on the items that the filtered list shows, without `igcChange`. In multiple selection, `show-select-all` puts a "Select all" option first in the list. A click or Space on it selects the shown items, or deselects them when all of them are selected, with one `igcChange` for the items that change. Its -checkbox is checked when all the shown items are selected and indeterminate when only some are. It is not counted in -`aria-setsize`, and the list shows it only while it shows items. With `totalItemCount`, it acts on the loaded items. +checkbox is checked when all the shown items are selected and indeterminate when only some are. It is the first +option in `aria-posinset` and `aria-setsize`, and the list shows it only while it shows items. With `totalItemCount`, +it acts on the loaded items. ```html @@ -238,6 +242,14 @@ When a group key is set, the options are grouped by it. Items whose group key re aggregated into an `Other` group. `groupSorting` controls the direction - `asc` by default, `desc`, or `none` - and the comparison uses the collation of `locale`, also after a change of the global locale. +In multiple selection, `group-selection` makes each group header an option that works like the select-all option +for the items of its group that the list shows. The header keeps the text style of a group header, and the arrow +keys and Home stop on it. + +```html + +``` + #### Templating ```typescript @@ -378,7 +390,7 @@ While the list of options **is** visible: | ------------------------------------------- | ------------------------------------------------------------------------------ | | Arrow Down | Activates the next option; at the last option it does nothing. | | Arrow Up | Activates the previous option; from the first option it returns to the input. | -| Space | Selects or deselects the active option. On the select-all option, selects the shown items, or deselects them when all of them are selected. | +| Space | Selects or deselects the active option. On the select-all option, or on a group header of `groupSelection`, selects its shown items, or deselects them when all of them are selected. | | Enter | Selects the active option in single selection mode, and closes the list in all modes. On a placeholder of `totalItemCount`, it does nothing. | | Home / End | Activates the first or the last option. | | Escape | Closes the list. The combo stays focused. | @@ -407,6 +419,7 @@ Typing while the component is focused filters the list down to the closest match | totalItemCount | total-item-count | No | `number \| undefined` | - | The number of all the items of a remote source, when the source knows it. In remote filtering mode, the list shows a placeholder for each item that `data` does not hold yet. | | loading | loading | Yes | `boolean` | false | Whether the combo loads options. A spinner, which the `loading` slot can replace, then takes the place of the toggle icon, and an empty list shows a loading message instead of the empty message. | | showSelectAll | show-select-all | No | `boolean` | false | Whether the list starts with an option that selects all the items that it shows, or deselects them when all of them are selected. Only in multiple selection mode. | +| groupSelection | group-selection | No | `boolean` | false | Whether a group header is an option that selects the items of its group that the list shows, or deselects them when all of them are selected. Only in multiple selection mode with `groupKey`. | | itemTemplate | - | No | `ComboItemTemplate` | - | The template for the content of each combo item. | | groupHeaderTemplate | - | No | `ComboGroupHeaderTemplate` | - | The template for the content of each group header. | | open | open | Yes | `boolean` | false | Whether the component is open. | @@ -559,12 +572,12 @@ it, also on `hide()` from such a button, the focus moves to the main input, beca | `list` | The list of options box. | | `item` | Each item in the list of options. | | `group-header` | Each group header in the list of options. | -| `active` | Added to the item part when the item is active, and to the case-icon part while case-sensitive filtering is on. | -| `selected` | Added to the item part when the item is selected. | +| `active` | Added to the item part when the item is active, to the group-header part when the header is an active option of `groupSelection`, and to the case-icon part while case-sensitive filtering is on. | +| `selected` | Added to the item part when the item is selected, and to the group-header part of `groupSelection` when all the shown items of the group are selected. | | `checkbox` | The checkbox of each item in the list. | | `checkbox-indicator` | The checkbox indicator of each item in the list. | | `checked` | Added to the checkbox and checkbox-indicator parts when the checkbox is checked. | -| `indeterminate` | Added to the checkbox and checkbox-indicator parts of the select-all option while only some of the shown items are selected. | +| `indeterminate` | Added to the checkbox and checkbox-indicator parts of the select-all option, or of a group header of `groupSelection`, while only some of its shown items are selected. | | `select-all` | Added to the item part of the select-all option. | | `header` / `footer` | The containers holding the header and footer content. | | `empty` | The container holding the empty content. | @@ -709,8 +722,8 @@ Generated by `runValidationContainerTests`. ### Select-all option 39. The option comes first in multiple selection, before the group headers, with the "Select all" text and - `aria-selected`. It does not count in `aria-posinset` or `aria-setsize`, passes the accessibility audit, and does not - show in single selection or while the list shows no items. + `aria-selected`. It is the first option in `aria-posinset` and `aria-setsize`, passes the accessibility audit, and + does not show in single selection or while the list shows no items. 40. A click selects the shown items, and a second click deselects them, with one `igcChange` each. With some shown items selected, the option is not selected, its checkbox is indeterminate with the `indeterminate` part, and a click selects only the other shown items. A canceled `igcChange` keeps the selection. A new `value` while the list is @@ -720,6 +733,17 @@ Generated by `runValidationContainerTests`. 42. With `totalItemCount` in remote mode, the option does not change the requests or the positions of the placeholders, and the selection methods select only the loaded items. +### Group selection + +43. In multiple selection, the group headers are options with a checkbox and `aria-selected`, they count in + `aria-posinset` and `aria-setsize`, and the accessibility audit passes. In single selection, they are headers + again, and the items take the positions back. +44. A click on a header selects the shown items of its group, and a second click deselects them, with one + `igcChange` each, and the list stays open. With some of them selected, the checkbox is indeterminate with the + `indeterminate` part, and a click selects only the others. +45. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. With the select-all + option, a click on it selects the headers too. Turning group selection off clears an active header. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. @@ -735,10 +759,11 @@ Generated by `runValidationContainerTests`. - The anchor exposes a `combobox` role with `aria-haspopup`, the expanded state, the relation to the list and the active descendant, all projected onto its native input as element references. -- The list has a `listbox` role, the items an `option` role with their selected state, and the group headers a - `group` role. The select-all option is selected while all the shown items are selected. It has no `aria-checked`, - because a listbox uses one of `aria-selected` and `aria-checked` for all its options, so its mixed state shows only on - its checkbox. A +- The list has a `listbox` role, and the items an `option` role with their selected state. The group headers have no + role, except with `groupSelection`, where they are options. The select-all option is selected while all the shown + items are selected, and a group header option while all the shown items of its group are. They have no + `aria-checked`, because a listbox uses one of `aria-selected` and `aria-checked` for all its options, so their mixed + state shows only on their checkbox. A placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a `progressbar` named "Loading" takes the place of the toggle icon. The listbox has no `aria-busy`, so a screen reader continues to read the loaded options while the next page loads. diff --git a/src/components/combo/themes/combo-item.base.scss b/src/components/combo/themes/combo-item.base.scss index c8ba32ef0..3c1b49b1d 100644 --- a/src/components/combo/themes/combo-item.base.scss +++ b/src/components/combo/themes/combo-item.base.scss @@ -1,5 +1,8 @@ @use 'styles/utilities' as *; @use '../../dropdown/themes/dropdown-item.base' as *; +@use '../../dropdown/themes/light/themes' as dropdown-theme; + +$dropdown-theme: dropdown-theme.$material; :host { // Overwrite dropdown-header default size. @@ -18,6 +21,14 @@ } } +// A group header option keeps the text style of a group header. The item +// theme comes after this sheet, so its active, hover and selected colors win. +:host([part~='group-header']) { + @include type-style('overline'); + + color: var-get($dropdown-theme, 'header-text-color'); +} + igc-checkbox { pointer-events: none; } diff --git a/src/components/combo/themes/shared/combo.fluent.scss b/src/components/combo/themes/shared/combo.fluent.scss index 936cac95f..040a6e136 100644 --- a/src/components/combo/themes/shared/combo.fluent.scss +++ b/src/components/combo/themes/shared/combo.fluent.scss @@ -70,3 +70,7 @@ $dropdown-theme: dropdown-theme.$fluent; background: transparent; } } + +igc-combo-item[part~='group-header'] { + @include type-style('subtitle-2'); +} diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index 8599f2b6c..bb10e27a3 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -11,8 +11,9 @@ export type ComboRecord = { value: T; header: boolean; /** - * 1-based position among the visible options. `-1` for the other records. - * Each pipeline run sets it again. + * 1-based position among the options, for `aria-posinset`. `-1` for the + * other records and for the shared select-all and placeholder records, + * whose positions come from their index. Each pipeline run sets it again. */ position: number; /** The group value of a header record. Not set on the other records. */ diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 0346cc606..2f163b272 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -174,6 +174,13 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + groupSelection: { + type: 'boolean', + description: + 'Whether a group header is an option that selects the items of its group\nthat the list shows, or deselects them when all of them are selected.\nOnly in multiple selection mode with `groupKey`.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, locale: { type: 'string', description: @@ -232,6 +239,7 @@ const metadata: Meta = { disableClear: false, loading: false, showSelectAll: false, + groupSelection: false, required: false, disabled: false, invalid: false, @@ -307,6 +315,12 @@ interface IgcComboArgs { * selection mode. */ showSelectAll: boolean; + /** + * Whether a group header is an option that selects the items of its group + * that the list shows, or deselects them when all of them are selected. + * Only in multiple selection mode with `groupKey`. + */ + groupSelection: boolean; /** * The locale of the component. Without it, the component takes its own * `lang`, else the locale of the nearest ancestor that has a `locale` (a @@ -436,6 +450,7 @@ export const Default: Story = { ?invalid=${args.invalid} ?single-select=${args.singleSelect} ?show-select-all=${args.showSelectAll} + ?group-selection=${args.groupSelection} ?loading=${args.loading} > From 6f93906ffcdd0dddf86373a08a3874ae3ad970ad Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 14:50:49 +0200 Subject: [PATCH 15/32] docs(stories): show the group selection of the combo --- stories/combo.stories.ts | 129 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 2f163b272..445dbed45 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -371,7 +371,7 @@ const styles = html` max-width: 36rem; } - .cmb-stack :is(h3, p, table) { + .cmb-stack :is(h3, h4, p, table) { margin: 0; } @@ -387,6 +387,18 @@ const styles = html` align-items: center; gap: 0.5rem; } + + .cmb-list { + display: grid; + margin: 0; + padding: 0; + list-style: none; + } + + .cmb-list > li { + display: flex; + border-block-end: 1px solid var(--ig-gray-300); + } `; @@ -932,7 +944,7 @@ export const StagedFilter: Story = { : `All ${issues.length} issues` }

        -
          +
            ${shown.map( ({ id, title, labels }) => html`
          • @@ -986,20 +998,14 @@ export const StagedFilter: Story = { } .cmb-issues { - display: grid; gap: 0.5rem; - margin: 0; - padding: 0; - list-style: none; } .cmb-issues li { - display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; padding-block-end: 0.5rem; - border-block-end: 1px solid var(--ig-gray-300); } .cmb-issues li > span:first-child { @@ -1044,6 +1050,113 @@ export const StagedFilter: Story = { }, }; +interface Permission { + id: string; + name: string; + area: string; +} + +const permissions: Permission[] = [ + { id: 'billing-view', name: 'View invoices', area: 'Billing' }, + { id: 'billing-export', name: 'Export invoices', area: 'Billing' }, + { id: 'billing-pay', name: 'Change the payment method', area: 'Billing' }, + { id: 'members-view', name: 'View members', area: 'Members' }, + { id: 'members-invite', name: 'Invite members', area: 'Members' }, + { id: 'members-roles', name: 'Change member roles', area: 'Members' }, + { id: 'projects-create', name: 'Create projects', area: 'Projects' }, + { id: 'projects-archive', name: 'Archive projects', area: 'Projects' }, + { id: 'projects-delete', name: 'Delete projects', area: 'Projects' }, + { id: 'reports-view', name: 'View reports', area: 'Reports' }, + { id: 'reports-export', name: 'Export reports', area: 'Reports' }, +]; + +const areas = Map.groupBy(permissions, ({ area }) => area); + +/** The access of a role to an area with `total` permissions. */ +function accessTo(count: number, total: number): string { + if (count === total) { + return 'Full access'; + } + + return count ? `${count} of ${total} permissions` : 'No access'; +} + +export const RolePermissions: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'The permissions of a role, grouped by area. `group-selection` makes each area header an option: a click or Space on it grants all the permissions of the area, or takes them away when the role has all of them, with one `igcChange`. The checkbox of the header is indeterminate while the role has only some of them. The arrow keys stop on the headers. A header acts on the permissions that the list shows: search "export", then click Billing, and only "Export invoices" changes. `show-select-all` adds "Select all" for all the shown permissions. The summary renders from `newValue` of `igcChange`.', + }, + }, + }, + render: () => { + let granted = [ + 'billing-view', + 'members-view', + 'members-invite', + 'members-roles', + 'reports-view', + ]; + + const { mount, update } = renderInto(() => + [...areas].map(([area, list]) => { + const count = list.filter(({ id }) => granted.includes(id)).length; + + return html` +
          • + ${area} + ${accessTo(count, list.length)} +
          • + `; + }) + ); + + const change = ({ detail }: CustomEvent) => { + granted = detail.newValue as string[]; + update(); + }; + + return html` + ${styles} + +
            +

            Role: Project manager

            + + + Select an area to grant its shown permissions, or to take them away. + + +

            Access by area

            +
              +
              + `; + }, +}; + interface Airport { code: string; label: string; From d55bae751172fb0f37d6c05617f86e02d4c744a5 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 15:54:51 +0200 Subject: [PATCH 16/32] feat(combo): add a confirm mode for the selection With selection-commit="confirm", the open list keeps a pending selection. Each toggle emits the cancelable igcPendingChange, Enter applies the pending selection with one igcChange of the new commit type, and any other close drops it. The selection methods act on the pending selection while it exists; pendingValue, commitSelection() and cancelSelection() serve Apply and Cancel buttons in the footer. --- CHANGELOG.md | 1 + public-api.json | 7 + src/components/combo/combo.spec.ts | 354 ++++++++++++++++++ src/components/combo/combo.ts | 329 +++++++++++++--- .../combo/controllers/navigation.ts | 35 +- src/components/combo/spec.md | 83 +++- src/components/combo/types.ts | 9 +- src/index.ts | 1 + stories/combo.stories.ts | 18 + 9 files changed, 758 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5d0e95f..febae346a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `selectionCommit` set to `confirm` holds the changes of the user while the list is open, in multiple selection mode. A toggle changes a pending selection and emits the cancelable `igcPendingChange`. Enter applies the pending selection with one `igcChange`, whose new `commit` type lists the items that it selects or deselects. Any other close drops it. While the list is open, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change the pending selection. `pendingValue` gives its values, `commitSelection()` applies it, and `cancelSelection()` puts `value` back in it, for example from Apply and Cancel buttons in the `footer` slot. These methods emit no events. The new `ComboSelectionCommit` type describes the property. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input diff --git a/public-api.json b/public-api.json index a35feceac..635bb5c83 100644 --- a/public-api.json +++ b/public-api.json @@ -29,6 +29,7 @@ "ComboGroupHeaderTemplate", "ComboItemTemplate", "ComboResourceStringsEN", + "ComboSelectionCommit", "ComboTemplateProps", "ContentOrientation", "CustomDateRange", @@ -1222,6 +1223,7 @@ "placeholder-search": "string", "required": "boolean", "scroll-strategy": "PopoverScrollStrategy ('close' | 'hide' | 'scroll')", + "selection-commit": "ComboSelectionCommit ('confirm' | 'immediate')", "show-select-all": "boolean", "single-select": "boolean", "total-item-count": "number | undefined", @@ -1252,12 +1254,14 @@ "name": "string", "open": "boolean", "outlined": "boolean", + "pendingValue": "readonly ComboValue[]", "placeholder": "string | undefined", "placeholderSearch": "string", "required": "boolean", "resourceStrings": "Required", "scrollStrategy": "PopoverScrollStrategy ('close' | 'hide' | 'scroll')", "selection": "readonly T[]", + "selectionCommit": "ComboSelectionCommit ('confirm' | 'immediate')", "showSelectAll": "boolean", "singleSelect": "boolean", "static formAssociated": "readonly boolean", @@ -1271,7 +1275,9 @@ }, "methods": { "blur": "() => void", + "cancelSelection": "() => void", "checkValidity": "() => boolean", + "commitSelection": "() => void", "deselect": "(items?: Item | Item[]) => void", "deselectAllItems": "(ignoreFilter?: boolean) => void", "focus": "(options?: FocusOptions) => void", @@ -1291,6 +1297,7 @@ "igcDataRequest": "unknown", "igcOpened": "unknown", "igcOpening": "unknown", + "igcPendingChange": "unknown", "igcSearchInput": "unknown" }, "slots": [ diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 7310c522b..642ca939a 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -3429,6 +3429,360 @@ describe('Combo', () => { }); }); + describe('Confirm mode', () => { + const other = { id: 'XX01', name: 'Elsewhere', country: '', zip: '' }; + let eventSpy: SinonSpy; + + const changes = (event: 'igcChange' | 'igcPendingChange' = 'igcChange') => + eventSpy.withArgs(event).args.map(([, { detail }]) => ({ + ...detail, + items: detail.items.map(({ id }: City) => id), + })); + + const selectedNames = () => + items(combo) + .filter((item) => item.selected) + .map((item) => item.textContent?.trim()); + + async function click(index: number): Promise { + simulateClick(items(combo)[index]); + await comboStable(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + input = combo.renderRoot.querySelector('igc-input#target')!; + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + await openComboPopover(combo); + }); + + it('stages the toggles in the open list and keeps the value', async () => { + await click(1); + await click(0); + + expect(selectedNames()).to.eql(['Plovdiv']); + expect(combo.pendingValue).to.eql(['BG02']); + expect(combo.value).to.eql(['BG01']); + expect(combo.selection.map(({ id }) => id)).to.eql(['BG01']); + expect(input.value).to.equal('Sofia'); + expect(changes()).to.be.empty; + expect(changes('igcPendingChange')).to.eql([ + { newValue: ['BG01', 'BG02'], items: ['BG02'], type: 'selection' }, + { newValue: ['BG02'], items: ['BG01'], type: 'deselection' }, + ]); + }); + + it('applies the pending selection on Enter with one igcChange', async () => { + simulateKeyboard(list, arrowDown); + simulateKeyboard(list, spaceBar); + simulateKeyboard(list, arrowDown); + simulateKeyboard(list, spaceBar); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG02']); + expect(input.value).to.equal('Plovdiv'); + expect(changes()).to.eql([ + { newValue: ['BG02'], items: ['BG02', 'BG01'], type: 'commit' }, + ]); + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger: 'enter' }, + }); + }); + + it('applies the pending selection on Enter in the search input and without an active option', async () => { + // A handler that changes `newValue` does not change `value`. + combo.addEventListener( + 'igcChange', + ({ detail }) => detail.newValue.push('US01'), + { once: true } + ); + + await click(1); + simulateKeyboard(searchInput, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG01', 'BG02']); + + await openComboPopover(combo); + combo.select('BG03'); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); + }); + + it('closes on Enter without igcChange when nothing changes', async () => { + await click(1); + await click(1); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(changes()).to.be.empty; + }); + + it('keeps the list open with the pending selection when igcChange is canceled', async () => { + combo.addEventListener('igcChange', (event) => event.preventDefault(), { + once: true, + }); + + await click(1); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.value).to.eql(['BG01']); + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + }); + + it('keeps the pending selection when igcPendingChange is canceled', async () => { + combo.addEventListener( + 'igcPendingChange', + (event) => event.preventDefault(), + { once: true } + ); + + await click(1); + + expect(combo.pendingValue).to.eql(['BG01']); + expect(selectedNames()).to.eql(['Sofia']); + }); + + it('drops the pending selection on the other closes', async () => { + const closes = [ + () => simulateKeyboard(list, escapeKey), + () => simulateClick(input), + () => combo.hide(), + ]; + + for (const close of closes) { + await openComboPopover(combo); + await click(1); + + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + + await close(); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.pendingValue).to.eql(['BG01']); + } + + await openComboPopover(combo); + + expect(selectedNames()).to.eql(['Sofia']); + expect(changes()).to.be.empty; + + await click(1); + combo.open = false; + await comboStable(combo); + combo.open = true; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['BG01']); + }); + + it('stages the selection methods, and commitSelection() applies them without events', async () => { + combo.select('BG02'); + combo.deselect('BG01'); + await comboStable(combo); + + expect(selectedNames()).to.eql(['Plovdiv']); + expect(combo.pendingValue).to.eql(['BG02']); + + combo.selectAllItems(); + expect(combo.pendingValue).lengthOf(cities.length); + + combo.deselect(); + expect(combo.pendingValue).to.be.empty; + + combo.select(other); + combo.select(); + expect(combo.pendingValue).lengthOf(cities.length + 1); + + combo.deselect('XX01'); + expect(combo.pendingValue).lengthOf(cities.length); + + combo.deselectAllItems(); + combo.select(['BG03', other]); + expect(combo.value).to.eql(['BG01']); + + combo.commitSelection(); + await comboStable(combo); + + expect(combo.value).to.eql(['BG03', 'XX01']); + expect(input.value).to.equal('Varna, Elsewhere'); + expect(combo.open).to.be.true; + expect(selectedNames()).to.eql(['Varna']); + expect(eventSpy).not.calledWith('igcChange'); + expect(eventSpy).not.calledWith('igcPendingChange'); + }); + + it('keeps the pending selection on the same value, and puts the value back with cancelSelection() and a new value', async () => { + await click(1); + combo.value = ['BG01']; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + + combo.cancelSelection(); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.pendingValue).to.eql(['BG01']); + expect(selectedNames()).to.eql(['Sofia']); + + await click(1); + combo.value = ['US01']; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['US01']); + expect(selectedNames()).to.eql(['New York']); + }); + + it('clears the value and the pending selection with the clear icon', async () => { + await click(1); + simulateClick(combo.renderRoot.querySelector('[part="clear-icon"]')!); + await comboStable(combo); + + expect(combo.value).to.be.empty; + expect(combo.pendingValue).to.be.empty; + expect(selectedNames()).to.be.empty; + expect(changes()).to.eql([ + { newValue: [], items: ['BG01'], type: 'deselection' }, + ]); + }); + + it('gives the pending selection the records of its values in a new data', async () => { + await click(1); + + combo.data = cities.map((city) => ({ ...city })); + await comboStable(combo); + + expect(selectedNames()).to.eql(['Sofia', 'Plovdiv']); + + await click(1); + + expect(combo.pendingValue).to.eql(['BG01']); + }); + + it('keeps the values of value without a record, and resolves them with select()', async () => { + combo.value = ['BG01', 'XX01']; + await click(1); + + expect(combo.value).to.eql(['BG01', 'XX01']); + + combo.select(other); + await comboStable(combo); + + expect(input.value).to.equal('Sofia, Elsewhere'); + expect(combo.value).to.eql(['BG01', 'XX01']); + expect(combo.pendingValue).to.eql(['BG01', 'BG02', 'XX01']); + + // A second record of the value does not join the selection. + combo.deselect('XX01'); + combo.select({ ...other }); + await comboStable(combo); + + expect(input.value).to.equal('Sofia, Elsewhere'); + + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(changes().map(({ items }) => items)).to.eql([['BG02']]); + expect(combo.selection.map(({ id }) => id)).to.eql([ + 'BG01', + 'BG02', + 'XX01', + ]); + }); + + it('acts on the value right after a close, and on the pending selection right after an opening', async () => { + void combo.hide(); + combo.deselect('BG01'); + + expect(combo.value).to.be.empty; + + void combo.show(); + combo.select('BG02'); + + expect(combo.value).to.be.empty; + expect(combo.pendingValue).to.eql(['BG02']); + }); + + it('gives the pending selection the records of a new data for the values of value', async () => { + const later = { id: 'XX02', name: 'Later', country: '', zip: '' }; + + combo.value = ['BG01', 'XX02']; + await click(1); + + combo.data = [...cities, later]; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['BG01', 'BG02', 'XX02']); + + await click(1); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'XX02']); + expect(changes()).to.be.empty; + }); + + it('stages the select-all option and the group header options', async () => { + combo.showSelectAll = true; + combo.groupKey = 'country'; + combo.groupSelection = true; + await comboStable(combo); + + const selectAll = list.querySelector( + '[part~="select-all"]' + )!; + + simulateClick(selectAll); + await comboStable(combo); + + expect(combo.pendingValue).to.have.members(cities.map(({ id }) => id)); + expect(selectAll.selected).to.be.true; + + simulateClick(list.querySelector('[part~="group-header"]')!); + await comboStable(combo); + + expect(combo.pendingValue).to.have.members(['US01', 'US02', 'US03']); + expect(combo.value).to.eql(['BG01']); + expect(changes()).to.be.empty; + expect(changes('igcPendingChange').map(({ type }) => type)).to.eql([ + 'selection', + 'deselection', + ]); + }); + + it('applies each change at once in single selection', async () => { + combo.singleSelect = true; + await comboStable(combo); + + await click(1); + + expect(combo.value).to.eql(['BG02']); + expect(combo.pendingValue).to.eql(['BG02']); + expect(changes().map(({ type }) => type)).to.eql(['selection']); + }); + }); + describe('Scroll strategy', () => { let container: HTMLDivElement; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 63424b3ec..8ac0d7f68 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -18,7 +18,12 @@ import { createFormValueState } from '#internals/mixins/forms/form-value.js'; import { I18nMixin } from '#internals/mixins/i18n.js'; import { partMap } from '#internals/part-map.js'; import { renderSlottedIcon } from '#internals/templates/slotted-icon.js'; -import { asArray, firstOf, isEmpty } from '#internals/utils/arrays.js'; +import { + asArray, + firstOf, + isEmpty, + sameItems, +} from '#internals/utils/arrays.js'; import { addSafeEventListener, getElementFromPath, @@ -53,6 +58,7 @@ import type { ComboItemTemplate, ComboRecord, ComboRenderFunction, + ComboSelectionCommit, ComboValue, FilteringOptions, GroupingDirection, @@ -106,7 +112,8 @@ const DATA_REQUEST_COUNT = 20; * @slot custom-error - Renders content when setCustomValidity(message) is set. * @slot invalid - Renders content when the component is in invalid state (validity.valid = false). * - * @fires igcChange - Emitted before the user changes the selection. Cancel the event to keep the selection. + * @fires igcChange - Emitted before the user changes the selection, or applies the pending selection of the confirm mode (`type: 'commit'`). Cancel the event to keep the selection. + * @fires igcPendingChange - Emitted in the confirm mode of `selectionCommit` before the user changes the pending selection. Cancel the event to keep the pending selection. * @fires igcOpening - Emitted before the user opens the list of options. Cancelable. * @fires igcOpened - Emitted after the list of options opens on a user action. * @fires igcClosing - Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. @@ -233,6 +240,8 @@ export default class IgcComboComponent< hide: (trigger) => this._close(trigger), toggleSelection: (index: number) => this._selectAt(index, true), select: (index: number) => this._selectAt(index, false), + hasPending: () => this._pendingSelection !== undefined, + commit: () => this._commitPending(true), clearSelection: () => this._clearSelection(), }, } @@ -243,6 +252,8 @@ export default class IgcComboComponent< private _displayKey?: Keys; private _placeholderSearch?: string; private _selected: Set = new Set(); + /** Read it through {@link _pendingSelection}. */ + private _pending?: Set; /** The length of `data` at the last `igcDataRequest`. */ private _lastDataRequest?: number; /** The index of the last rendered option, from the list. */ @@ -276,6 +287,24 @@ export default class IgcComboComponent< @state() private _displayValue = ''; + /** + * The pending selection, which exists while the list is open in the confirm + * mode of `selectionCommit`. It starts as a copy of `_selected`. + */ + private get _pendingSelection(): Set | undefined { + if (this.open && this.selectionCommit === 'confirm' && !this.singleSelect) { + return (this._pending ??= new Set(this._selected)); + } + + this._pending = undefined; + return undefined; + } + + /** The selection that the list shows and the user changes. */ + private get _listSelection(): Set { + return this._pendingSelection ?? this._selected; + } + private _itemId(index: number): string { const position = index + 1; return this.id ? `${this.id}-item-${position}` : `item-${position}`; @@ -517,6 +546,17 @@ export default class IgcComboComponent< @property({ type: Boolean, attribute: 'group-selection' }) public groupSelection = false; + /** + * When the user's changes of the selection apply in multiple selection + * mode. With `confirm`, they change a pending selection while the list is + * open, and Enter applies it with one `igcChange`. Any other close drops it. + * While it exists, the selection methods change it too. + * @attr selection-commit + * @default 'immediate' + */ + @property({ attribute: 'selection-commit' }) + public selectionCommit: ComboSelectionCommit = 'immediate'; + /* blazorSuppress */ /** * The template used for the content of each combo item. @@ -569,9 +609,16 @@ export default class IgcComboComponent< /* @tsTwoWayProperty (true, "Change", "Detail.NewValue", false) */ @property({ type: Array }) public set value(items: ComboValue[]) { + const previous = this.value; this._formValue.setValueAndFormState(items); this._syncSelectionFromValue(); + // A new value replaces the pending selection. The same value, for example + // from a render of the parent, keeps it. + if (!sameItems(previous, this.value)) { + this._pending = undefined; + } + if (this.hasUpdated) { this._validate(); } @@ -592,6 +639,16 @@ export default class IgcComboComponent< return Array.from(this._selected); } + /** + * The values of the pending selection in the confirm mode of + * `selectionCommit`, which Enter or `commitSelection()` applies. Without a + * pending selection, the same as `value`. + */ + public get pendingValue(): ComboValue[] { + const pending = this._pendingSelection; + return pending ? this._toValues(pending) : this.value; + } + //#endregion //#region Life-cycle @@ -656,12 +713,13 @@ export default class IgcComboComponent< this._state.invalidate(); } - // Sync the selection when `data` comes after `value`. - if (props.has('data') && !isEmpty(this.data) && !isEmpty(this.value)) { - this._withPristine(() => { - this._syncSelectionFromValue(); - this._formValue.setValueAndFormState(this.value); - }); + if (props.has('data')) { + this._syncSelectionFromData(); + } + + // The list can open again before anything reads the pending selection. + if (!this.open) { + this._pending = undefined; } } @@ -831,7 +889,10 @@ export default class IgcComboComponent< continue; } - selected ??= this._selectedByValue(); + // A pending record of the value comes before a committed one. + selected ??= this._selectedByValue( + this._selected.union(this._listSelection) + ); const record = selected.get(value) ?? (isObject(item) ? (item as T) : undefined); @@ -853,15 +914,13 @@ export default class IgcComboComponent< const index = this._dataIndex; return new Set( - Iterator.from(this._selected).filter( + Iterator.from(this._listSelection).filter( (record) => !index.has(this._resolveItemValue(record)) ) ); } - private _selectedByValue( - selected: Set = this._selected - ): Map, T> { + private _selectedByValue(selected: Set): Map, T> { return new Map( Iterator.from(selected).map((record): [ComboValue, T] => [ this._valueOf(record), @@ -898,22 +957,27 @@ export default class IgcComboComponent< return values; } - private _emitSelectionChange(detail: IgcComboChangeEventArgs): boolean { - return this.emitEvent('igcChange', { cancelable: true, detail }); + private _emitSelectionChange( + detail: IgcComboChangeEventArgs, + event: 'igcChange' | 'igcPendingChange' = 'igcChange' + ): boolean { + return this.emitEvent(event, { cancelable: true, detail }); } - /** The selection value after `resolved` applies: the `igcChange` `newValue`. */ + /** The list selection value after `resolved` applies: the `newValue` of the change event. */ private _previewValue(resolved: T[], selecting: boolean): ComboValue[] { + const selected = this._listSelection; + if (selecting) { return this._toValues( - this.singleSelect ? resolved : new Set([...this._selected, ...resolved]) + this.singleSelect ? resolved : new Set([...selected, ...resolved]) ); } const removed = new Set(resolved); return this._toValues( - Iterator.from(this._selected).filter((item) => !removed.has(item)) + Iterator.from(selected).filter((item) => !removed.has(item)) ); } @@ -934,12 +998,13 @@ export default class IgcComboComponent< if (isEmpty(collection) && selecting) { // Selects all items without an event, or clears a single selection. - this._selected = singleSelect - ? new Set() - : new Set(this.data).union(this._selectedOutsideData()); - if (singleSelect) { + this._selected = new Set(); this._searchTerm = ''; + } else if (this._pendingSelection) { + this._pending = new Set(this.data).union(this._selectedOutsideData()); + } else { + this._selected = new Set(this.data).union(this._selectedOutsideData()); } this.requestUpdate(); @@ -948,7 +1013,7 @@ export default class IgcComboComponent< // An empty deselection deselects every selected item. const resolved = isEmpty(collection) - ? this.selection + ? Array.from(this._listSelection) : this._resolveItems(collection); return this._applySelection(resolved, type, emit); @@ -968,11 +1033,14 @@ export default class IgcComboComponent< if ( emit && - !this._emitSelectionChange({ - newValue: this._previewValue(records, selecting), - items: records, - type, - }) + !this._emitSelectionChange( + { + newValue: this._previewValue(records, selecting), + items: records, + type, + }, + this._pendingSelection ? 'igcPendingChange' : 'igcChange' + ) ) { return false; } @@ -983,60 +1051,167 @@ export default class IgcComboComponent< this._clearSearchTerm(emit); } + const selected = this._listSelection; + for (const item of records) { - selecting ? this._selected.add(item) : this._selected.delete(item); + selecting ? selected.add(item) : selected.delete(item); + } + + if (selecting && this._pendingSelection) { + this._resolveValues(records); } this.requestUpdate(); return true; } + /** + * Adds the records of the values of `value` that have none to `_selected`, + * as a new `data` does. For example, a remote source gives them to + * `select()` while the list has a pending selection. + */ + private _resolveValues(records: T[]): void { + // `_selected` has at most one record for each value. + if (this._selected.size < this.value.length) { + this._selected = this._recordsOf( + this.value, + new Set(records).union(this._selected) + ); + this._projectSelection(); + } + } + /** * One-way sync from `_formValue` to `_selected`. A value that `data` does * not hold keeps its selected record. */ private _syncSelectionFromValue(): void { - const selected = this._selected; - let previous: Map, T> | undefined; - this._selected = new Set(); + let values = this._formValue.value; - if (!isEmpty(this._formValue.value)) { - const values = this.singleSelect - ? asArray(firstOf(this._formValue.value)) - : this._formValue.value; + if (this.singleSelect && !isEmpty(values)) { + const first = asArray(firstOf(values)); - if (this.singleSelect && values.length !== this._formValue.value.length) { - this._formValue.setValueAndFormState(values); + if (first.length !== values.length) { + this._formValue.setValueAndFormState(first); } - const index = this._dataIndex; - - for (const value of values) { - // First match only - mirrors resolving a value against the data source - const position = index.get(value)?.[0]; - const record = - position === undefined - ? (previous ??= this._selectedByValue(selected)).get(value) - : this.data[position]; - - if (record !== undefined) { - this._selected.add(record); - } - } + values = first; } + this._selected = this._recordsOf(values, this._selected); this._projectSelection(); // The list does not update for a new `value` on its own. this._listRef.value?.requestUpdate(); } + /** + * Gives the selection the records of a new `data`. The pending selection + * also gets the records that `data` gives to the values of `value` that had + * none, because the user could not deselect them. + */ + private _syncSelectionFromData(): void { + const pending = this._pendingSelection; + // Only a value without a record can get a new record. + const known = + pending && this._selected.size < this.value.length + ? new Set(this._toValues(this._selected)) + : undefined; + + // Sync the selection when `data` comes after `value`. + if (!isEmpty(this.data) && !isEmpty(this.value)) { + this._withPristine(() => { + this._syncSelectionFromValue(); + this._formValue.setValueAndFormState(this.value); + }); + } + + if (pending) { + const values = this._toValues(pending); + + if (known) { + values.push( + ...this._toValues(this._selected).filter((value) => !known.has(value)) + ); + } + + this._pending = this._recordsOf(values, pending); + } + } + + /** The records of `values`. A value that `data` does not hold keeps its record in `previous`. */ + private _recordsOf(values: ComboValue[], previous: Set): Set { + const records = new Set(); + + if (isEmpty(values)) { + return records; + } + + const index = this._dataIndex; + let byValue: Map, T> | undefined; + + for (const value of values) { + // First match only - mirrors resolving a value against the data source + const position = index.get(value)?.[0]; + const record = + position === undefined + ? (byValue ??= this._selectedByValue(previous)).get(value) + : this.data[position]; + + if (record !== undefined) { + records.add(record); + } + } + + return records; + } + protected _syncValueFromSelection(): void { - this._formValue.setValueAndFormState(this._projectSelection()); - this._setSingleSelectionDisplayValue(this._displayValue); - this._validate(); + // A pending selection does not change `value`, which can hold values + // without a record. See `_commitPending`. + if (!this._pendingSelection) { + this._formValue.setValueAndFormState(this._projectSelection()); + this._setSingleSelectionDisplayValue(this._displayValue); + this._validate(); + } + this._listRef.value?.requestUpdate(); } + /** + * Applies the pending selection to `value`. With `emit`, one `igcChange` of + * the `commit` type can cancel it. + * @returns Whether the pending selection is applied. + */ + private _commitPending(emit: boolean): boolean { + const pending = this._pendingSelection; + + if (!pending) { + return true; + } + + const items = [...pending.symmetricDifference(this._selected)]; + + if (isEmpty(items)) { + return true; + } + + if ( + emit && + !this._emitSelectionChange({ + newValue: this._toValues(pending), + items, + type: 'commit', + }) + ) { + return false; + } + + // The `value` setter finds the records that `data` does not hold here. + this._selected = pending; + this.value = this._toValues(pending); + return true; + } + /** The items of the records from `start` to `end`, each one time. See {@link isItemRecord}. */ private _itemsIn(start: number, end: number): T[] { const records = this._state.dataState; @@ -1076,7 +1251,7 @@ export default class IgcComboComponent< // The record itself, as an item without a value would select all. this._applySelection( [item], - toggle && this._selected.has(item) ? 'deselection' : 'selection', + toggle && this._listSelection.has(item) ? 'deselection' : 'selection', true ); } else if (record && this._state.isToggle(record)) { @@ -1096,7 +1271,8 @@ export default class IgcComboComponent< */ private _toggleItemsOf(index: number): void { const items = this._itemsIn(index + 1, this._state.endOf(index)); - const unselected = items.filter((item) => !this._selected.has(item)); + const selected = this._listSelection; + const unselected = items.filter((item) => !selected.has(item)); this._applySelection( unselected.length ? unselected : items, @@ -1112,10 +1288,11 @@ export default class IgcComboComponent< */ private _selectionOf(index: number): boolean | 'mixed' { const records = this._state.dataState; + const selected = this._listSelection; let some = false; let every = true; - if (this._selected.size) { + if (selected.size) { for (let i = index + 1, end = this._state.endOf(index); i < end; i++) { const record = records[i]; @@ -1123,7 +1300,7 @@ export default class IgcComboComponent< continue; } - if (this._selected.has(record.value)) { + if (selected.has(record.value)) { some = true; } else { every = false; @@ -1139,6 +1316,21 @@ export default class IgcComboComponent< } private _clearSelection(): void { + // The clear icon is on the input, which shows `value`, so it clears + // `value` at once, and the pending selection with it. + if (this._pendingSelection) { + if ( + this._emitSelectionChange({ + newValue: [], + items: this.selection, + type: 'deselection', + }) + ) { + this.value = []; + } + return; + } + if (this.singleSelect) { this._clearSearchTerm(true); this._clearSingleSelection(); @@ -1379,6 +1571,23 @@ export default class IgcComboComponent< this._syncValueFromSelection(); } + /** + * Applies the pending selection of the confirm mode to `value`. It does not + * emit `igcChange` or close the list. + */ + public commitSelection(): void { + this._commitPending(false); + } + + /** + * Puts `value` back as the pending selection of the confirm mode. It does + * not close the list. + */ + public cancelSelection(): void { + this._pending = undefined; + this._listRef.value?.requestUpdate(); + } + //#endregion protected _itemRenderer: ComboRenderFunction = ( @@ -1409,7 +1618,7 @@ export default class IgcComboComponent< const active = this._activeIndex === index; - const selected = this._selected.has(item.value); + const selected = this._listSelection.has(item.value); // One template for both, so a row keeps its element when its item loads. const placeholder = item.kind === 'placeholder'; const { optionCount } = this._state; diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index c3948c333..d7891478c 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -33,6 +33,10 @@ type ComboNavigationConfig = { hide: (trigger: ComboCloseTrigger) => Promise; toggleSelection: (index: number) => void; select: (index: number) => void; + /** Whether the confirm mode has a pending selection. */ + hasPending: () => boolean; + /** Applies the pending selection. Returns whether it is applied. */ + commit: () => boolean; clearSelection: () => void; }; }; @@ -106,7 +110,8 @@ export class ComboNavigationController { bindingDefaults, }) .set(arrowUp, () => this._closeToInput('arrow-up')) - .set(arrowDown, this._onSearchArrowDown); + .set(arrowDown, this._onSearchArrowDown) + .set(enterKey, this._onSearchEnter); addKeybindings(this._host, { skip, @@ -139,14 +144,26 @@ export class ComboNavigationController { return; } - if (this.active > -1) { - if (this._host.singleSelect) { - this._config.interactions.select(this.active); - } - if (await this._config.interactions.hide('enter')) { - this._input?.select(); - this._host.focus(); - } + // In confirm mode, Enter applies the pending selection also without an active option. + if (this.active < 0 && !this._config.interactions.hasPending()) { + return; + } + + if (this._host.singleSelect) { + this._config.interactions.select(this.active); + } + if (!this._config.interactions.commit()) { + return; + } + if (await this._config.interactions.hide('enter')) { + this._input?.select(); + this._host.focus(); + } + }; + + private _onSearchEnter = async (): Promise => { + if (this._config.interactions.hasPending()) { + await this._onEnter(); } }; diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 71150d404..f141ec27f 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -14,6 +14,7 @@ - [Basic initialization](#basic-initialization) - [Data binding keys](#data-binding-keys) - [Single and multiple selection](#single-and-multiple-selection) + - [Confirm mode](#confirm-mode) - [Filtering](#filtering) - [Grouping](#grouping) - [Templating](#templating) @@ -47,6 +48,7 @@ - [Placeholders and loading](#placeholders-and-loading) - [Select-all option](#select-all-option) - [Group selection](#group-selection) + - [Confirm mode](#confirm-mode-1) - [Assumptions and limitations](#assumptions-and-limitations) - [Accessibility](#accessibility) - [ARIA roles and properties](#aria-roles-and-properties) @@ -77,6 +79,7 @@ | 18 | 2026-10-27 | `igcChange.newValue` has the order of the `value` that follows | | 19 | 2026-10-27 | Group selection (`groupSelection`); the group headers have no `group` role | | 20 | 2026-10-27 | The select-all option and the group header options count in `aria-posinset` and `aria-setsize` | +| 21 | 2026-10-27 | Confirm mode (`selectionCommit`): a pending selection, `igcPendingChange` and the `commit` change type | ## Overview @@ -124,6 +127,8 @@ As an end-user, I expect to be able to: - clear my selection with a single action. - move through the options with the arrow keys and with Home and End. - select and deselect the active option with Space. +- pick several options and apply them together with Enter, or drop them with Escape, when the + application asks for a confirmation. - close the list with Escape without losing my selection. - be told when the field is required and left empty. @@ -141,6 +146,7 @@ As a developer, I expect to be able to: - localize the built-in strings of the component. - mark the control as required and provide my own validation messages. - be notified when the selection changes, and on each open and close transition. +- hold the changes of the user while the list is open, and apply them with one change event. ## Functionality @@ -216,6 +222,37 @@ it acts on the loaded items. ``` +#### Confirm mode + +In multiple selection, `selection-commit="confirm"` holds the changes of the user while the list is open. The list +opens with a pending selection, a copy of the selection. A click, Space, the select-all option and a group header +option change only the pending selection, with a cancelable `igcPendingChange` each. `value`, the form value, the +input text and `selection` keep the selection. Enter, also in the search input and without an active option, applies +the pending selection with one cancelable `igcChange` of the `commit` type, whose `items` are the items that it +selects or deselects, and then closes the list. A canceled `igcChange` keeps the list open. A canceled `igcClosing` +also keeps it open, with the applied selection. Any other close drops the pending selection. The clear icon clears `value` at once, +and the pending selection with it. + +While the pending selection exists, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change it, +so the app controls in the header and footer slots stage too. A new `value` replaces it. `pendingValue` gives its +values, `commitSelection()` applies it, and `cancelSelection()` puts `value` back in it. These methods emit no events +and do not close the list, so an app that binds `value` through `igcChange` reads `value` after `commitSelection()`. + +```html + +
              + Apply +
              +
              +``` + +```typescript +apply.addEventListener('click', () => { + combo.commitSelection(); + combo.hide(); +}); +``` + #### Filtering ```typescript @@ -391,9 +428,9 @@ While the list of options **is** visible: | Arrow Down | Activates the next option; at the last option it does nothing. | | Arrow Up | Activates the previous option; from the first option it returns to the input. | | Space | Selects or deselects the active option. On the select-all option, or on a group header of `groupSelection`, selects its shown items, or deselects them when all of them are selected. | -| Enter | Selects the active option in single selection mode, and closes the list in all modes. On a placeholder of `totalItemCount`, it does nothing. | +| Enter | Selects the active option in single selection mode, and closes the list in all modes. In confirm mode, it first applies the pending selection, also from the search input and without an active option, and a canceled `igcChange` keeps the list open. On a placeholder of `totalItemCount`, it does nothing. | | Home / End | Activates the first or the last option. | -| Escape | Closes the list. The combo stays focused. | +| Escape | Closes the list. The combo stays focused. In confirm mode, the pending selection is dropped. | | Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | Typing while the component is focused filters the list down to the closest matches. @@ -407,6 +444,7 @@ Typing while the component is focused filters the list down to the closest match | data | - | No | `T[]` | `[]` | The data source used to generate the list of options. A new data source keeps the selection, also the selected items that it does not hold. | | value | value | No | `ComboValue[]` | `[]` | The values of the selected items. With a `valueKey`, each value is that field of an item. Without it, each value is the item itself. | | selection | - | No | `T[]` | `[]` | Read-only. The current selection as records of the data source. | +| pendingValue | - | No | `ComboValue[]` | `[]` | Read-only. The values of the pending selection in the confirm mode of `selectionCommit`, which Enter or `commitSelection()` applies. Without a pending selection, the same as `value`. | | valueKey | value-key | No | `Keys \| undefined` | - | The key in the data source used when selecting items. | | displayKey | display-key | No | `Keys \| undefined` | - | The key in the data source used to display items in the list. Falls back to `valueKey`. | | groupKey | group-key | No | `Keys \| string \| undefined` | - | The key in the data source used to group items in the list. | @@ -420,6 +458,7 @@ Typing while the component is focused filters the list down to the closest match | loading | loading | Yes | `boolean` | false | Whether the combo loads options. A spinner, which the `loading` slot can replace, then takes the place of the toggle icon, and an empty list shows a loading message instead of the empty message. | | showSelectAll | show-select-all | No | `boolean` | false | Whether the list starts with an option that selects all the items that it shows, or deselects them when all of them are selected. Only in multiple selection mode. | | groupSelection | group-selection | No | `boolean` | false | Whether a group header is an option that selects the items of its group that the list shows, or deselects them when all of them are selected. Only in multiple selection mode with `groupKey`. | +| selectionCommit | selection-commit | No | `'immediate' \| 'confirm'` | `immediate` | When the user's changes of the selection apply in multiple selection mode. With `confirm`, they change a pending selection while the list is open, and Enter applies it with one `igcChange`. Any other close drops it. While it exists, the selection methods change it too. | | itemTemplate | - | No | `ComboItemTemplate` | - | The template for the content of each combo item. | | groupHeaderTemplate | - | No | `ComboGroupHeaderTemplate` | - | The template for the content of each group header. | | open | open | Yes | `boolean` | false | Whether the component is open. | @@ -482,6 +521,8 @@ type ComboGroupHeaderTemplate = (props: { | deselect | `(items?: Item \| Item[]): void` | Deselects options in the list by reference or by `valueKey` value. It does not emit `igcChange`. Without an argument, it deselects all items. | | selectAllItems | `(ignoreFilter?: boolean): void` | Selects all the items that the list shows. It does not emit `igcChange`, and does nothing in single selection mode. With `ignoreFilter`, it selects all the items of `data`. | | deselectAllItems | `(ignoreFilter?: boolean): void` | Deselects all the items that the list shows. It does not emit `igcChange`. With `ignoreFilter`, it deselects all the items. | +| commitSelection | `(): void` | Applies the pending selection of the confirm mode to `value`. It does not emit `igcChange` or close the list. | +| cancelSelection | `(): void` | Puts `value` back as the pending selection of the confirm mode. It does not close the list. | | focus | `(options?: FocusOptions): void` | Sets focus on the component. | | blur | `(): void` | Removes focus from the component. | | checkValidity | `(): boolean` | Checks validity and emits `invalid` when the control is invalid. Returns `true` when the control is valid. | @@ -492,7 +533,8 @@ type ComboGroupHeaderTemplate = (props: { | Name | Cancellable | Description | | ---------- | ----------- | ------------------------------------------------- | -| igcChange | true | Emitted before the user changes the selection. Cancel the event to keep the selection. `newValue` is the `value` after the change, in the same order: the selected values first, then the new ones. | +| igcChange | true | Emitted before the user changes the selection, or applies the pending selection of the confirm mode (`type: 'commit'`). Cancel the event to keep the selection. `newValue` is the `value` after the change, in the same order: the selected values first, then the new ones. | +| igcPendingChange | true | Emitted in the confirm mode of `selectionCommit` before the user changes the pending selection. Cancel the event to keep the pending selection. The detail is the `igcChange` detail for the pending selection. | | igcOpening | true | Emitted before the user opens the list of options. Cancelable. | | igcOpened | false | Emitted after the list of options opens on a user action. | | igcClosing | true | Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. | @@ -504,7 +546,8 @@ type ComboGroupHeaderTemplate = (props: { interface IgcComboChangeEventArgs { newValue: ComboValue[]; items: T[]; - type: 'selection' | 'deselection' | 'addition'; + // `commit`: the items that the pending selection selects or deselects. + type: 'selection' | 'deselection' | 'addition' | 'commit'; } interface IgcComboClosingEventArgs { @@ -531,10 +574,9 @@ type ComboCloseTrigger = | 'selection'; // an item click in single selection ``` -The open events keep an empty `detail`. `hide()`, `toggle()` and a change of `open` emit no events. An app that -stages a selection in its own footer can save `value` on `igcOpening`, commit on its Apply button or on the `enter` -trigger, and put the saved value back on the other triggers of `igcClosed`. When the list closes while the focus is in -it, also on `hide()` from such a button, the focus moves to the main input, because the closed list cannot keep it. +The open events keep an empty `detail`. `hide()`, `toggle()` and a change of `open` emit no events. To stage a +selection, see [Confirm mode](#confirm-mode). When the list closes while the focus is in it, also on `hide()` from a +footer button, the focus moves to the main input, because the closed list cannot keep it. ### Slots @@ -744,6 +786,24 @@ Generated by `runValidationContainerTests`. 45. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. With the select-all option, a click on it selects the headers too. Turning group selection off clears an active header. +### Confirm mode + +46. In confirm mode, a click and Space change the pending selection with `igcPendingChange`. The list and + `pendingValue` show it, and `value`, `selection` and the input text keep the selection. A canceled + `igcPendingChange` keeps the pending selection. +47. Enter applies the pending selection with one `igcChange` of the `commit` type with the changed items, and closes + the list with the `enter` trigger, also in the search input and without an active option. Without a change, it + closes the list with no `igcChange`. A canceled `igcChange` keeps the list open with the pending selection. A + handler that changes `newValue` does not change `value`. +48. Escape, a click on the input, `hide()` and `open` set to `false` drop the pending selection, with no `igcChange`. +49. The selection methods change the pending selection, also with an item that `data` does not hold, from the moment + the list opens, and the value from the moment it closes. `commitSelection()` applies the pending selection without + events and keeps the list open. `cancelSelection()` and a new `value` replace it, and the same `value` keeps it. +50. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending + selection by value, and adds the values of `value` that it gives a record. A value without a record stays in + `value` until a commit, and `select()` gives it a record in the input text. The select-all option and the group header options change the pending selection. In single + selection, each change applies at once. + ## Assumptions and limitations - The options come from the `data` array; projected item elements are not supported. @@ -752,6 +812,10 @@ Generated by `runValidationContainerTests`. - The list is virtualized, so an option that is not rendered has no DOM node; work with the data records rather than with the item elements. - Without a `valueKey`, the selection is reported as whole records rather than as primitive values. +- A user change sets `value` from the selected records, also a commit of the confirm mode. So a value that has no + record in `data` leaves `value`. The `resolve` option of `bindComboDataSource()` gives such values their records. +- `singleSelect` overrides `selectionCommit`: in single selection, each change applies at once. A change of + `selectionCommit` or `singleSelect` while the list is open drops the pending selection. ## Accessibility @@ -763,7 +827,8 @@ Generated by `runValidationContainerTests`. role, except with `groupSelection`, where they are options. The select-all option is selected while all the shown items are selected, and a group header option while all the shown items of its group are. They have no `aria-checked`, because a listbox uses one of `aria-selected` and `aria-checked` for all its options, so their mixed - state shows only on their checkbox. A + state shows only on their checkbox. In confirm mode, the selected state of the options is the pending selection, + and the input shows `value`. A placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a `progressbar` named "Loading" takes the place of the toggle icon. The listbox has no `aria-busy`, so a screen reader continues to read the loaded options while the next page loads. diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index bb10e27a3..54cf18765 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -29,12 +29,18 @@ export type ComboHost = ReactiveControllerHost & IgcComboComponent; export type GroupingDirection = 'asc' | 'desc' | 'none'; -export type ComboChangeType = 'selection' | 'deselection' | 'addition'; +/** `commit` applies the pending selection of the confirm mode. See `selectionCommit`. */ +export type ComboChangeType = + | 'selection' + | 'deselection' + | 'addition' + | 'commit'; export type ComboRenderFunction = VirtualScrollItemTemplate< ComboRecord >; export type ComboValue = T | Values; export type ComboFilteringMode = 'local' | 'remote'; +export type ComboSelectionCommit = 'immediate' | 'confirm'; /* jsonAPIPlainObject */ /** Filtering options for the combo component. */ @@ -95,6 +101,7 @@ export interface IgcComboClosingEventArgs { export interface IgcComboComponentEventMap { igcChange: CustomEvent; + igcPendingChange: CustomEvent; // For analyzer meta only: /* skipWCPrefix */ focus: FocusEvent; diff --git a/src/index.ts b/src/index.ts index 5962221e0..9d579f4bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -222,6 +222,7 @@ export type { ComboGroupHeaderContext, ComboGroupHeaderTemplate, ComboItemTemplate, + ComboSelectionCommit, ComboTemplateProps, FilteringOptions, GroupingDirection, diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 445dbed45..022984015 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -58,6 +58,7 @@ const metadata: Meta = { actions: { handles: [ 'igcChange', + 'igcPendingChange', 'igcOpening', 'igcOpened', 'igcClosing', @@ -181,6 +182,14 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + selectionCommit: { + type: { name: 'enum', value: ['immediate', 'confirm'] }, + description: + "When the user's changes of the selection apply in multiple selection\nmode. With `confirm`, they change a pending selection while the list is\nopen, and Enter applies it with one `igcChange`. Any other close drops it.\nWhile it exists, the selection methods change it too.", + options: ['immediate', 'confirm'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'immediate' } }, + }, locale: { type: 'string', description: @@ -240,6 +249,7 @@ const metadata: Meta = { loading: false, showSelectAll: false, groupSelection: false, + selectionCommit: 'immediate', required: false, disabled: false, invalid: false, @@ -321,6 +331,13 @@ interface IgcComboArgs { * Only in multiple selection mode with `groupKey`. */ groupSelection: boolean; + /** + * When the user's changes of the selection apply in multiple selection + * mode. With `confirm`, they change a pending selection while the list is + * open, and Enter applies it with one `igcChange`. Any other close drops it. + * While it exists, the selection methods change it too. + */ + selectionCommit: 'immediate' | 'confirm'; /** * The locale of the component. Without it, the component takes its own * `lang`, else the locale of the nearest ancestor that has a `locale` (a @@ -449,6 +466,7 @@ export const Default: Story = { .placeholderSearch=${args.placeholderSearch} .data=${cities} .groupSorting=${args.groupSorting} + .selectionCommit=${args.selectionCommit} .scrollStrategy=${args.scrollStrategy} ?case-sensitive-icon=${args.caseSensitiveIcon} ?disable-filtering=${args.disableFiltering} From 8fda147007eba98d1400b887660ac08940d273ff Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 17:31:49 +0200 Subject: [PATCH 17/32] feat(combo): add Cancel and Apply buttons to the confirm mode --- CHANGELOG.md | 4 +- package-lock.json | 20 +-- package.json | 4 +- public-api.json | 3 + scripts/_package.json | 4 +- src/components/combo/combo.spec.ts | 125 ++++++++++++++++++ src/components/combo/combo.ts | 87 ++++++++++-- .../combo/controllers/navigation.ts | 11 +- src/components/combo/spec.md | 34 ++++- src/components/combo/themes/combo.base.scss | 13 +- .../combo/themes/shared/combo.common.scss | 4 + .../combo/themes/shared/combo.fluent.scss | 3 +- .../combo/themes/shared/combo.indigo.scss | 3 +- src/components/combo/types.ts | 7 +- stories/combo.stories.ts | 6 +- 15 files changed, 277 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index febae346a..86c5bc656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - - `selectionCommit` set to `confirm` holds the changes of the user while the list is open, in multiple selection mode. A toggle changes a pending selection and emits the cancelable `igcPendingChange`. Enter applies the pending selection with one `igcChange`, whose new `commit` type lists the items that it selects or deselects. Any other close drops it. While the list is open, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change the pending selection. `pendingValue` gives its values, `commitSelection()` applies it, and `cancelSelection()` puts `value` back in it, for example from Apply and Cancel buttons in the `footer` slot. These methods emit no events. The new `ComboSelectionCommit` type describes the property. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input) or `selection` (an item click in single selection). So an app that stages a selection can tell Apply (`enter`) from Cancel (`escape`). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) + - `selectionCommit` set to `confirm` holds the changes of the user while the list is open, in multiple selection mode. A toggle changes a pending selection and emits the cancelable `igcPendingChange`. Enter applies the pending selection with one `igcChange`, whose new `commit` type lists the items that it selects or deselects. Any other close drops it. While the list is open, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change the pending selection. The footer shows Cancel and Apply, with the texts of the `combo_cancel` and `combo_apply` resource strings. Apply applies the pending selection as Enter does, and Cancel drops it. The `actions`, `cancel-button` and `apply-button` parts style them. Content in the `footer` slot replaces them: `pendingValue` gives the pending values, `commitSelection()` applies them, and `cancelSelection()` puts `value` back. These methods emit no events. The new `ComboSelectionCommit` type describes the property. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) + - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input), `selection` (an item click in single selection), or `apply` and `cancel` (the buttons of the confirm mode). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) - #### File input - `directory` lets the user select a folder instead of files. The selection holds all files in the folder and its subfolders, and `webkitRelativePath` gives the path of each file from the folder. The control shows the folder name. In Chromium, a multipart form submission sends the relative path as the file name. [#2481](https://github.com/IgniteUI/igniteui-webcomponents/pull/2481) diff --git a/package-lock.json b/package-lock.json index 6083349f0..d039bf216 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@floating-ui/dom": "^1.8.0", "@lit/context": "^1.1.6", - "igniteui-i18n-core": "^2.0.0-rc.2", + "igniteui-i18n-core": "^2.0.0-rc.3", "lit": "^3.3.3" }, "devDependencies": { @@ -38,7 +38,7 @@ "fast-check": "^4.10.2", "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", - "igniteui-i18n-resources": "^2.0.0-rc.2", + "igniteui-i18n-resources": "^2.0.0-rc.3", "igniteui-theming": "^30.0.0", "keep-a-changelog": "^3.2.0", "lint-staged": "^17.6.0", @@ -8801,12 +8801,12 @@ } }, "node_modules/igniteui-i18n-core": { - "version": "2.0.0-rc.2", - "resolved": "https://registry.npmjs.org/igniteui-i18n-core/-/igniteui-i18n-core-2.0.0-rc.2.tgz", - "integrity": "sha512-a0rqmHT1nlY0LpVBB9++/B1gAnvDsSPinw5F2y/cARDv3sHpODMlKTkrn2Uzyi52VYu6pthEhBFebMjUmAmLbA==", + "version": "2.0.0-rc.3", + "resolved": "https://registry.npmjs.org/igniteui-i18n-core/-/igniteui-i18n-core-2.0.0-rc.3.tgz", + "integrity": "sha512-Ogb/5YKfoJJ92SmL7YfexYqG3/5Vtn/PBSr5raMNXV3OMTUsGlop/OGh+q7Xzh/YJ/3rR1chrU/5ePmuUxyfCA==", "license": "MIT", "peerDependencies": { - "igniteui-i18n-resources": "2.0.0-rc.2" + "igniteui-i18n-resources": "2.0.0-rc.3" }, "peerDependenciesMeta": { "igniteui-i18n-resources": { @@ -8815,13 +8815,13 @@ } }, "node_modules/igniteui-i18n-resources": { - "version": "2.0.0-rc.2", - "resolved": "https://registry.npmjs.org/igniteui-i18n-resources/-/igniteui-i18n-resources-2.0.0-rc.2.tgz", - "integrity": "sha512-jPepzXNUUdAN+Du0xCqN1ov9uogQBx7f5vYuKvzfAKWAbA7ldOqkOrsI3dJ1gtlAECPoD3gBTwD6j3WVsNZHHQ==", + "version": "2.0.0-rc.3", + "resolved": "https://registry.npmjs.org/igniteui-i18n-resources/-/igniteui-i18n-resources-2.0.0-rc.3.tgz", + "integrity": "sha512-/Yc5Y7wU38FS3JKwLt7GRxDRM1cuZ3aeiPOshOZvJ7rZiPuSUvu8n0LpW1AWlnxwksnZsksyWlnFNFKT8VUC3A==", "devOptional": true, "license": "MIT", "dependencies": { - "igniteui-i18n-core": "2.0.0-rc.2" + "igniteui-i18n-core": "2.0.0-rc.3" } }, "node_modules/igniteui-theming": { diff --git a/package.json b/package.json index 60d0274ab..c9ef05fb5 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "dependencies": { "@floating-ui/dom": "^1.8.0", "@lit/context": "^1.1.6", - "igniteui-i18n-core": "^2.0.0-rc.2", + "igniteui-i18n-core": "^2.0.0-rc.3", "lit": "^3.3.3" }, "devDependencies": { @@ -97,7 +97,7 @@ "fast-check": "^4.10.2", "husky": "^9.1.7", "ig-typedoc-theme": "^7.0.1", - "igniteui-i18n-resources": "^2.0.0-rc.2", + "igniteui-i18n-resources": "^2.0.0-rc.3", "igniteui-theming": "^30.0.0", "keep-a-changelog": "^3.2.0", "lint-staged": "^17.6.0", diff --git a/public-api.json b/public-api.json index 635bb5c83..6ca7866e0 100644 --- a/public-api.json +++ b/public-api.json @@ -1315,7 +1315,10 @@ "value-missing" ], "cssParts": [ + "actions", "active", + "apply-button", + "cancel-button", "case-icon", "checkbox", "checkbox-indicator", diff --git a/scripts/_package.json b/scripts/_package.json index 7a5d5f319..5ff896724 100644 --- a/scripts/_package.json +++ b/scripts/_package.json @@ -102,13 +102,13 @@ "dependencies": { "@floating-ui/dom": "^1.8.0", "@lit/context": "^1.1.0", - "igniteui-i18n-core": "^2.0.0-rc.0", + "igniteui-i18n-core": "^2.0.0-rc.3", "lit": "^3.3.0", "tslib": "^2.8.1" }, "peerDependencies": { "dompurify": "^3.4.0", - "igniteui-i18n-resources": "^2.0.0-rc.0", + "igniteui-i18n-resources": "^2.0.0-rc.3", "marked": "^18.0.0", "shiki": "^4.3.0" }, diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 642ca939a..8e6fdde80 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -3449,6 +3449,12 @@ describe('Combo', () => { await comboStable(combo); } + const actions = () => [ + ...combo.renderRoot.querySelectorAll( + '[part="actions"] igc-button' + ), + ]; + beforeEach(async () => { combo = await fixture>( html` { ]); }); + it('renders Cancel and Apply as the default footer content', async () => { + const [cancel, apply] = actions(); + + expect(cancel.textContent?.trim()).to.equal('Cancel'); + expect(apply.textContent?.trim()).to.equal('Apply'); + expect(cancel.getAttribute('part')).to.equal('cancel-button'); + expect(apply.getAttribute('part')).to.equal('apply-button'); + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + combo.resourceStrings = { combo_apply: 'OK' }; + await comboStable(combo); + + expect(apply.textContent?.trim()).to.equal('OK'); + + try { + configureTheme('indigo'); + await comboStable(combo); + + expect([cancel.variant, apply.variant]).to.eql([ + 'outlined', + 'contained', + ]); + } finally { + configureTheme('bootstrap'); + } + + const footer = document.createElement('div'); + footer.slot = 'footer'; + combo.append(footer); + await comboStable(combo); + + expect(apply.checkVisibility()).to.be.false; + + footer.remove(); + await comboStable(combo); + + expect(apply.checkVisibility()).to.be.true; + + await combo.hide(); + + expect(actions()).to.be.empty; + + await openComboPopover(combo); + combo.selectionCommit = 'immediate'; + await comboStable(combo); + + expect(actions()).to.be.empty; + }); + + it('applies the pending selection with Apply and closes the list', async () => { + combo.addEventListener('igcChange', (event) => event.preventDefault(), { + once: true, + }); + + await click(1); + simulateClick(actions()[1]); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.value).to.eql(['BG01']); + + simulateClick(actions()[1]); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG01', 'BG02']); + expect(isFocused(input)).to.be.true; + expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger: 'apply' }, + }); + }); + + it('drops the pending selection with Cancel and closes the list', async () => { + combo.addEventListener('igcClosing', (event) => event.preventDefault(), { + once: true, + }); + + await click(1); + simulateClick(actions()[0]); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.pendingValue).to.eql(['BG01']); + + await click(1); + eventSpy.resetHistory(); + simulateClick(actions()[0]); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.pendingValue).to.eql(['BG01']); + expect(changes()).to.be.empty; + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger: 'cancel' }, + }); + }); + + it('moves with Tab from the list to Cancel and Apply, and then closes the list', async () => { + const [cancel, apply] = actions(); + + list.focus(); + await sendKeys({ press: tabKey }); + + expect(isFocused(cancel)).to.be.true; + + await sendKeys({ press: tabKey }); + + expect(isFocused(apply)).to.be.true; + + await sendKeys({ press: tabKey }); + await comboStable(combo); + + expect(combo.open).to.be.false; + }); + it('applies each change at once in single selection', async () => { combo.singleSelect = true; await comboStable(combo); @@ -3780,6 +3904,7 @@ describe('Combo', () => { expect(combo.value).to.eql(['BG02']); expect(combo.pendingValue).to.eql(['BG02']); expect(changes().map(({ type }) => type)).to.eql(['selection']); + expect(actions()).to.be.empty; }); }); diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 8ac0d7f68..b33477ecd 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -33,8 +33,12 @@ import { bindIf } from '#internals/utils/lit.js'; import { isObject } from '#internals/utils/types.js'; import type { Validator } from '#internals/validators.js'; import { all } from '#themes/combo/themes/themes.js'; -import { addThemingController } from '#theming/theming-controller.js'; +import { + addThemingController, + type ThemingController, +} from '#theming/theming-controller.js'; import { styles as componentBase } from '../../styles/common/component.css.js'; +import IgcButtonComponent from '../button/button.js'; import IgcIconComponent from '../icon/icon.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -102,7 +106,7 @@ const DATA_REQUEST_COUNT = 20; * @slot prefix - Renders content before the input of the combo. * @slot suffix - Renders content after the input of the combo. * @slot header - Renders a container before the list of options of the combo. - * @slot footer - Renders a container after the list of options of the combo. + * @slot footer - Renders a container after the list of options of the combo. In the confirm mode of `selectionCommit`, its default content is Cancel and Apply. * @slot empty - Renders content when the combo dropdown list has no items/data. * @slot loading - Renders content that replaces the default spinner while `loading` is set. It is in the input, so a click on it toggles the list, as on the toggle icon. * @slot helper-text - Renders content below the input of the combo. @@ -146,6 +150,9 @@ const DATA_REQUEST_COUNT = 20; * @csspart select-all - Added to the item part of the select-all option. * @csspart header - The container holding the header content of the combo. * @csspart footer - The container holding the footer content of the combo. + * @csspart actions - The container of the Cancel and Apply buttons of the confirm mode. + * @csspart cancel-button - The Cancel button of the confirm mode. + * @csspart apply-button - The Apply button of the confirm mode. * @csspart empty - The container holding the empty content of the combo. * @csspart loading - The spinner that replaces the toggle icon while `loading` is set. It has the color of the text. * @csspart placeholder - Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. @@ -154,7 +161,9 @@ const DATA_REQUEST_COUNT = 20; * * @cssstate ig-invalid - Set while the control shows its invalid state, that is while `invalid` returns `true`. */ -@blazorAdditionalDependencies('IgcIconComponent, IgcInputComponent') +@blazorAdditionalDependencies( + 'IgcIconComponent, IgcInputComponent, IgcButtonComponent' +) @blazorIndirectRender @shadowOptions({ delegatesFocus: true }) export default class IgcComboComponent< @@ -175,6 +184,7 @@ export default class IgcComboComponent< public static register(): void { registerComponent( IgcComboComponent, + IgcButtonComponent, IgcIconComponent, IgcVirtualScrollComponent, IgcComboItemComponent, @@ -192,6 +202,7 @@ export default class IgcComboComponent< } private readonly _slots = addSlotController(this, { slots: SLOTS }); + private readonly _themes: ThemingController; protected override readonly _formValue = createFormValueState< ComboValue[] @@ -234,14 +245,16 @@ export default class IgcComboComponent< input: this._inputRef, search: this._searchRef, list: this._listRef, - slotted: (slot) => this._slots.getAssignedElements(slot), + // The flattened query also returns the default footer buttons. + slotted: (slot) => + this._slots.getAssignedElements(slot, { flatten: true }), interactions: { show: () => this._show(true), hide: (trigger) => this._close(trigger), toggleSelection: (index: number) => this._selectAt(index, true), select: (index: number) => this._selectAt(index, false), hasPending: () => this._pendingSelection !== undefined, - commit: () => this._commitPending(true), + apply: (trigger) => this._applyAndClose(trigger), clearSelection: () => this._clearSelection(), }, } @@ -292,7 +305,7 @@ export default class IgcComboComponent< * mode of `selectionCommit`. It starts as a copy of `_selected`. */ private get _pendingSelection(): Set | undefined { - if (this.open && this.selectionCommit === 'confirm' && !this.singleSelect) { + if (this.open && this._confirmMode) { return (this._pending ??= new Set(this._selected)); } @@ -300,6 +313,10 @@ export default class IgcComboComponent< return undefined; } + private get _confirmMode(): boolean { + return this.selectionCommit === 'confirm' && !this.singleSelect; + } + /** The selection that the list shows and the user changes. */ private get _listSelection(): Set { return this._pendingSelection ?? this._selected; @@ -549,8 +566,8 @@ export default class IgcComboComponent< /** * When the user's changes of the selection apply in multiple selection * mode. With `confirm`, they change a pending selection while the list is - * open, and Enter applies it with one `igcChange`. Any other close drops it. - * While it exists, the selection methods change it too. + * open, and Enter or the Apply button applies it with one `igcChange`. Any + * other close drops it. While it exists, the selection methods change it too. * @attr selection-commit * @default 'immediate' */ @@ -641,8 +658,8 @@ export default class IgcComboComponent< /** * The values of the pending selection in the confirm mode of - * `selectionCommit`, which Enter or `commitSelection()` applies. Without a - * pending selection, the same as `value`. + * `selectionCommit`, which Enter, the Apply button or `commitSelection()` + * applies. Without a pending selection, the same as `value`. */ public get pendingValue(): ComboValue[] { const pending = this._pendingSelection; @@ -656,7 +673,7 @@ export default class IgcComboComponent< constructor() { super(); - addThemingController(this, all); + this._themes = addThemingController(this, all); // Projects combobox semantics onto the native input. `aria-activedescendant` // stays on the listbox, which has focus during list navigation. @@ -682,10 +699,11 @@ export default class IgcComboComponent< } // The closed list is inert, so the focus in it would fall to the - // document. Move it to the input first. + // document. Move it to the input first. A server render has no render root. if ( props.has('open') && !this.open && + this.hasUpdated && this.renderRoot.querySelector('[part="list-wrapper"]:focus-within') ) { this.focus({ preventScroll: true }); @@ -1458,6 +1476,24 @@ export default class IgcComboComponent< this._activeIndex = -1; } + /** + * Applies the pending selection, then closes the list. A canceled + * `igcChange` keeps the list open. + * @returns Whether the list closes. + */ + private async _applyAndClose(trigger: ComboCloseTrigger): Promise { + return this._commitPending(true) ? this._close(trigger) : false; + } + + private _handleApply(): void { + this._applyAndClose('apply'); + } + + private _handleCancel(): void { + this.cancelSelection(); + this._close('cancel'); + } + private _handleCaseSensitivity(): void { this.filteringOptions = { caseSensitive: !this.filteringOptions.caseSensitive, @@ -1837,12 +1873,37 @@ export default class IgcComboComponent< ${this._renderEmptyTemplate()}
              - + + ${this.open && this._confirmMode ? this._renderActions() : nothing} +
              `; } + private _renderActions(): TemplateResult { + const indigo = this._themes.theme === 'indigo'; + + return html` +
              + + ${this.resourceStrings.combo_cancel} + + + ${this.resourceStrings.combo_apply} + +
              + `; + } + private _renderHelperText(): TemplateResult { return IgcValidationContainerComponent.create(this, { id: 'combo-helper-text', diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index d7891478c..b08ae1718 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -26,7 +26,7 @@ type ComboNavigationConfig = { input: Ref; search: Ref; list: Ref; - /** The elements assigned to a slot of the list. */ + /** The elements assigned to a slot of the list, or its fallback content. */ slotted: (slot: ListSlot) => Element[]; interactions: { show: () => Promise; @@ -35,8 +35,8 @@ type ComboNavigationConfig = { select: (index: number) => void; /** Whether the confirm mode has a pending selection. */ hasPending: () => boolean; - /** Applies the pending selection. Returns whether it is applied. */ - commit: () => boolean; + /** Applies the pending selection, then closes the list. Returns whether the list closes. */ + apply: (trigger: ComboCloseTrigger) => Promise; clearSelection: () => void; }; }; @@ -152,10 +152,7 @@ export class ComboNavigationController { if (this._host.singleSelect) { this._config.interactions.select(this.active); } - if (!this._config.interactions.commit()) { - return; - } - if (await this._config.interactions.hide('enter')) { + if (await this._config.interactions.apply('enter')) { this._input?.select(); this._host.focus(); } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index f141ec27f..8dedfd524 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -80,6 +80,7 @@ | 19 | 2026-10-27 | Group selection (`groupSelection`); the group headers have no `group` role | | 20 | 2026-10-27 | The select-all option and the group header options count in `aria-posinset` and `aria-setsize` | | 21 | 2026-10-27 | Confirm mode (`selectionCommit`): a pending selection, `igcPendingChange` and the `commit` change type | +| 22 | 2026-10-27 | Confirm mode: Cancel and Apply as the default footer content, and the `cancel` and `apply` triggers | ## Overview @@ -233,10 +234,16 @@ selects or deselects, and then closes the list. A canceled `igcChange` keeps the also keeps it open, with the applied selection. Any other close drops the pending selection. The clear icon clears `value` at once, and the pending selection with it. +The footer shows Cancel and Apply while the list is open, and Tab reaches them after the list. Apply applies the +pending selection as Enter does, and Cancel drops it, also when an `igcClosing` handler keeps the list open. They +close the list with the `apply` and `cancel` triggers. In Indigo, Cancel is `outlined` and Apply `contained`; in the +other themes both are `flat`. Content in the `footer` slot replaces them. + While the pending selection exists, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change it, so the app controls in the header and footer slots stage too. A new `value` replaces it. `pendingValue` gives its values, `commitSelection()` applies it, and `cancelSelection()` puts `value` back in it. These methods emit no events and do not close the list, so an app that binds `value` through `igcChange` reads `value` after `commitSelection()`. +A footer of the app uses them in place of the built-in buttons: ```html @@ -408,6 +415,8 @@ instance through `locale` and `resourceStrings`: | `combo_aria_label_no_options` | No options selected | The accessible name of an empty selection. | | `combo_loading` | Loading | The accessible name of the spinner and of the placeholder options, and the message of an empty list while loading. | | `combo_select_all` | Select all | The text of the select-all option. | +| `combo_cancel` | Cancel | The text of the Cancel button of the confirm mode. | +| `combo_apply` | Apply | The text of the Apply button of the confirm mode. | ### Keyboard interactions @@ -431,7 +440,7 @@ While the list of options **is** visible: | Enter | Selects the active option in single selection mode, and closes the list in all modes. In confirm mode, it first applies the pending selection, also from the search input and without an active option, and a canceled `igcChange` keeps the list open. On a placeholder of `totalItemCount`, it does nothing. | | Home / End | Activates the first or the last option. | | Escape | Closes the list. The combo stays focused. In confirm mode, the pending selection is dropped. | -| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. Past either end, or without focusable slot content, closes the list and moves the focus on. | +| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content, which is Cancel and Apply in confirm mode. Past either end, or without focusable slot content, closes the list and moves the focus on. | Typing while the component is focused filters the list down to the closest matches. @@ -444,7 +453,7 @@ Typing while the component is focused filters the list down to the closest match | data | - | No | `T[]` | `[]` | The data source used to generate the list of options. A new data source keeps the selection, also the selected items that it does not hold. | | value | value | No | `ComboValue[]` | `[]` | The values of the selected items. With a `valueKey`, each value is that field of an item. Without it, each value is the item itself. | | selection | - | No | `T[]` | `[]` | Read-only. The current selection as records of the data source. | -| pendingValue | - | No | `ComboValue[]` | `[]` | Read-only. The values of the pending selection in the confirm mode of `selectionCommit`, which Enter or `commitSelection()` applies. Without a pending selection, the same as `value`. | +| pendingValue | - | No | `ComboValue[]` | `[]` | Read-only. The values of the pending selection in the confirm mode of `selectionCommit`, which Enter, the Apply button or `commitSelection()` applies. Without a pending selection, the same as `value`. | | valueKey | value-key | No | `Keys \| undefined` | - | The key in the data source used when selecting items. | | displayKey | display-key | No | `Keys \| undefined` | - | The key in the data source used to display items in the list. Falls back to `valueKey`. | | groupKey | group-key | No | `Keys \| string \| undefined` | - | The key in the data source used to group items in the list. | @@ -458,7 +467,7 @@ Typing while the component is focused filters the list down to the closest match | loading | loading | Yes | `boolean` | false | Whether the combo loads options. A spinner, which the `loading` slot can replace, then takes the place of the toggle icon, and an empty list shows a loading message instead of the empty message. | | showSelectAll | show-select-all | No | `boolean` | false | Whether the list starts with an option that selects all the items that it shows, or deselects them when all of them are selected. Only in multiple selection mode. | | groupSelection | group-selection | No | `boolean` | false | Whether a group header is an option that selects the items of its group that the list shows, or deselects them when all of them are selected. Only in multiple selection mode with `groupKey`. | -| selectionCommit | selection-commit | No | `'immediate' \| 'confirm'` | `immediate` | When the user's changes of the selection apply in multiple selection mode. With `confirm`, they change a pending selection while the list is open, and Enter applies it with one `igcChange`. Any other close drops it. While it exists, the selection methods change it too. | +| selectionCommit | selection-commit | No | `'immediate' \| 'confirm'` | `immediate` | When the user's changes of the selection apply in multiple selection mode. With `confirm`, they change a pending selection while the list is open, and Enter or the Apply button applies it with one `igcChange`. Any other close drops it. While it exists, the selection methods change it too. | | itemTemplate | - | No | `ComboItemTemplate` | - | The template for the content of each combo item. | | groupHeaderTemplate | - | No | `ComboGroupHeaderTemplate` | - | The template for the content of each group header. | | open | open | Yes | `boolean` | false | Whether the component is open. | @@ -571,7 +580,9 @@ type ComboCloseTrigger = | 'outside-click' // a click outside the combo | 'scroll' // a scroll of a container with the `close` scroll strategy | 'toggle' // a click on the input - | 'selection'; // an item click in single selection + | 'selection' // an item click in single selection + | 'apply' // the Apply button of the confirm mode + | 'cancel'; // the Cancel button of the confirm mode ``` The open events keep an empty `detail`. `hide()`, `toggle()` and a change of `open` emit no events. To stage a @@ -585,7 +596,7 @@ footer button, the focus moves to the main input, because the closed list cannot | `prefix` | Renders content before the input of the combo. | | `suffix` | Renders content after the input of the combo. | | `header` | Renders a container before the list of options. | -| `footer` | Renders a container after the list of options. | +| `footer` | Renders a container after the list of options. In the confirm mode of `selectionCommit`, its default content is Cancel and Apply. | | `empty` | Renders content when the list has no items or data. | | `loading` | Renders content that replaces the default spinner while `loading` is set. It is in the input, so a click on it toggles the list, as on the toggle icon. | | `helper-text` | Renders content below the input. | @@ -622,6 +633,9 @@ footer button, the focus moves to the main input, because the closed list cannot | `indeterminate` | Added to the checkbox and checkbox-indicator parts of the select-all option, or of a group header of `groupSelection`, while only some of its shown items are selected. | | `select-all` | Added to the item part of the select-all option. | | `header` / `footer` | The containers holding the header and footer content. | +| `actions` | The container of the Cancel and Apply buttons of the confirm mode. | +| `cancel-button` | The Cancel button of the confirm mode. | +| `apply-button` | The Apply button of the confirm mode. | | `empty` | The container holding the empty content. | | `loading` | The spinner that replaces the toggle icon while `loading` is set. It has the color of the text. | | `placeholder` | Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. | @@ -801,8 +815,14 @@ Generated by `runValidationContainerTests`. events and keeps the list open. `cancelSelection()` and a new `value` replace it, and the same `value` keeps it. 50. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending selection by value, and adds the values of `value` that it gives a record. A value without a record stays in - `value` until a commit, and `select()` gives it a record in the input text. The select-all option and the group header options change the pending selection. In single - selection, each change applies at once. + `value` until a commit, and `select()` gives it a record in the input text. The select-all option and the group + header options change the pending selection. In single selection, each change applies at once. +51. The footer shows Cancel and Apply with the `combo_cancel` and `combo_apply` texts, and the accessibility audit + passes. Footer content replaces them, and immediate mode and single selection do not show them. +52. Apply applies the pending selection with one `igcChange` and closes the list with the `apply` trigger, and the + focus moves to the main input. A canceled `igcChange` keeps the list open. Cancel drops the pending selection and + closes the list with the `cancel` trigger. +53. Tab moves from the list to Cancel and Apply, and from Apply closes the list. ## Assumptions and limitations diff --git a/src/components/combo/themes/combo.base.scss b/src/components/combo/themes/combo.base.scss index 1570b1a93..005ae1834 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -66,9 +66,13 @@ } } -[part='filter-input'] { +[part='filter-input'], +[part~='actions'] { padding-inline: pad-inline(rem(4px), rem(8px), rem(16px)); padding-block: pad-block(rem(8px)); +} + +[part='filter-input'] { z-index: 26; igc-input::part(input) { @@ -76,6 +80,13 @@ } } +[part~='actions'] { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: rem(8px); +} + [part='list'] { height: calc(var(--size) * var(--item-count)); outline-style: none; diff --git a/src/components/combo/themes/shared/combo.common.scss b/src/components/combo/themes/shared/combo.common.scss index 4bfacdc8a..b32c9ce11 100644 --- a/src/components/combo/themes/shared/combo.common.scss +++ b/src/components/combo/themes/shared/combo.common.scss @@ -21,6 +21,10 @@ $dropdown-theme: dropdown-theme.$material; border-bottom: rem(1px) dashed var-get($theme, 'search-separator-border-color'); } +[part~='actions'] { + border-top: rem(1px) dashed var-get($theme, 'search-separator-border-color'); +} + [part='empty'] { color: var-get($theme, 'empty-list-placeholder-color'); background: var-get($theme, 'empty-list-background'); diff --git a/src/components/combo/themes/shared/combo.fluent.scss b/src/components/combo/themes/shared/combo.fluent.scss index 040a6e136..8cc033a10 100644 --- a/src/components/combo/themes/shared/combo.fluent.scss +++ b/src/components/combo/themes/shared/combo.fluent.scss @@ -15,7 +15,8 @@ $dropdown-theme: dropdown-theme.$fluent; } } -[part='filter-input'] { +[part='filter-input'], +[part~='actions'] { padding: pad(rem(2px), rem(4px), rem(8px)); } diff --git a/src/components/combo/themes/shared/combo.indigo.scss b/src/components/combo/themes/shared/combo.indigo.scss index 0c7186f37..a6b9e23f2 100644 --- a/src/components/combo/themes/shared/combo.indigo.scss +++ b/src/components/combo/themes/shared/combo.indigo.scss @@ -69,7 +69,8 @@ $dropdown-theme: dropdown-theme.$indigo; min-height: calc(var(--size) * var(--item-count) + rem(16px)) !important; } -[part='filter-input'] { +[part='filter-input'], +[part~='actions'] { padding-inline: pad-inline(rem(8px), rem(12px), rem(12px)); padding-block: pad-block(rem(12px)); } diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index 54cf18765..0263420ba 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -82,7 +82,8 @@ export interface IgcComboSearchInputEventArgs { /** * The user action that closes the list of options. `toggle` is a click on the - * input, and `selection` an item click in single selection mode. + * input, `selection` an item click in single selection mode, and `apply` and + * `cancel` the buttons of the confirm mode. */ export type ComboCloseTrigger = | 'enter' @@ -92,7 +93,9 @@ export type ComboCloseTrigger = | 'outside-click' | 'scroll' | 'toggle' - | 'selection'; + | 'selection' + | 'apply' + | 'cancel'; export interface IgcComboClosingEventArgs { /** The user action that closes the list. */ diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 022984015..ca5fed176 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -185,7 +185,7 @@ const metadata: Meta = { selectionCommit: { type: { name: 'enum', value: ['immediate', 'confirm'] }, description: - "When the user's changes of the selection apply in multiple selection\nmode. With `confirm`, they change a pending selection while the list is\nopen, and Enter applies it with one `igcChange`. Any other close drops it.\nWhile it exists, the selection methods change it too.", + "When the user's changes of the selection apply in multiple selection\nmode. With `confirm`, they change a pending selection while the list is\nopen, and Enter or the Apply button applies it with one `igcChange`. Any\nother close drops it. While it exists, the selection methods change it too.", options: ['immediate', 'confirm'], control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'immediate' } }, @@ -334,8 +334,8 @@ interface IgcComboArgs { /** * When the user's changes of the selection apply in multiple selection * mode. With `confirm`, they change a pending selection while the list is - * open, and Enter applies it with one `igcChange`. Any other close drops it. - * While it exists, the selection methods change it too. + * open, and Enter or the Apply button applies it with one `igcChange`. Any + * other close drops it. While it exists, the selection methods change it too. */ selectionCommit: 'immediate' | 'confirm'; /** From 577b28fd9daf3b7c396431c566fb0237a147cb0b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 18:08:31 +0200 Subject: [PATCH 18/32] refactor(combo): show loading with igc-circular-progress The loading spinner in place of the toggle icon is now an indeterminate igc-circular-progress instead of a CSS spinner, in the text color and the size of the icon, centered on the icon. It renders only while loading is set. --- CHANGELOG.md | 2 +- src/components/combo/combo.spec.ts | 31 ++++++++++++++++++--- src/components/combo/combo.ts | 20 ++++++++----- src/components/combo/spec.md | 14 ++++++---- src/components/combo/themes/combo.base.scss | 29 ++++--------------- stories/combo.stories.ts | 4 ++- 6 files changed, 58 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86c5bc656..65ce2000f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner, an indeterminate circular progress, in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 8e6fdde80..8d3668d11 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -2898,7 +2898,7 @@ describe('Combo', () => { expect(x).to.be.closeTo(center.x, 0.5); expect(y).to.be.closeTo(center.y, 0.5); - expect(spinner.getAttribute('role')).to.equal('progressbar'); + expect(internalsOf(spinner)?.getARIA('role')).to.equal('progressbar'); expect(spinner.getAttribute('aria-label')).to.equal('Loading'); expect(empty.innerText).to.equal('Loading'); @@ -2914,6 +2914,27 @@ describe('Combo', () => { expect(empty.innerText).to.equal('The list is empty'); }); + it('centers the ring of the spinner on the toggle icon, and removes the spinner after loading', async () => { + const icon = remote.renderRoot.querySelector( + '[part~="toggle-icon"] igc-icon' + )!; + + remote.loading = true; + await openComboPopover(remote); + + const ring = loadingPart()!.shadowRoot!.querySelector('[part~="svg"]')!; + const center = getCenterPoint(icon); + const { x, y } = getCenterPoint(ring); + + expect(x).to.be.closeTo(center.x, 0.5); + expect(y).to.be.closeTo(center.y, 0.5); + + remote.loading = false; + await elementUpdated(remote); + + expect(loadingPart()).to.be.null; + }); + it('does not move the list when the spinner shows', async () => { await openComboPopover(remote); const { top } = list.getBoundingClientRect(); @@ -2948,9 +2969,11 @@ describe('Combo', () => { .querySelector('igc-checkbox')! .renderRoot.querySelector('[part~="control"]')!; - expect( - getComputedStyle(loadingPart()!).borderInlineEndColor - ).to.equal('rgb(1, 2, 3)'); + const stop = loadingPart()!.shadowRoot!.querySelector( + '[part~="gradient_start"]' + )!; + + expect(getComputedStyle(stop).stopColor).to.equal('rgb(1, 2, 3)'); const { backgroundColor } = getComputedStyle(reference); expect(getComputedStyle(skeleton).backgroundColor).to.equal( diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index b33477ecd..a93d45cae 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -42,6 +42,7 @@ import IgcButtonComponent from '../button/button.js'; import IgcIconComponent from '../icon/icon.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; +import IgcCircularProgressComponent from '../progress/circular-progress.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import type { VirtualScrollItemContext, @@ -154,7 +155,7 @@ const DATA_REQUEST_COUNT = 20; * @csspart cancel-button - The Cancel button of the confirm mode. * @csspart apply-button - The Apply button of the confirm mode. * @csspart empty - The container holding the empty content of the combo. - * @csspart loading - The spinner that replaces the toggle icon while `loading` is set. It has the color of the text. + * @csspart loading - The indeterminate circular progress that replaces the toggle icon while `loading` is set. It has the color of the text. * @csspart placeholder - Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. * * @cssproperty --ig-helper-min-lines - The minimum number of lines below the control for the helper text and the validation messages: `1`, `2` or `3`. A message that shows then uses space that is already there, so the content below does not move. Set it on a container, such as a form, to apply it to all the controls in it. Default is `0`. @@ -162,7 +163,7 @@ const DATA_REQUEST_COUNT = 20; * @cssstate ig-invalid - Set while the control shows its invalid state, that is while `invalid` returns `true`. */ @blazorAdditionalDependencies( - 'IgcIconComponent, IgcInputComponent, IgcButtonComponent' + 'IgcIconComponent, IgcInputComponent, IgcButtonComponent, IgcCircularProgressComponent' ) @blazorIndirectRender @shadowOptions({ delegatesFocus: true }) @@ -185,6 +186,7 @@ export default class IgcComboComponent< registerComponent( IgcComboComponent, IgcButtonComponent, + IgcCircularProgressComponent, IgcIconComponent, IgcVirtualScrollComponent, IgcComboItemComponent, @@ -1740,11 +1742,15 @@ export default class IgcComboComponent< icon: this.open ? 'input_collapse' : 'input_expand', })} - + ${ + this.loading + ? html`` + : nothing + } `; diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 8dedfd524..e3f0c906b 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -81,6 +81,7 @@ | 20 | 2026-10-27 | The select-all option and the group header options count in `aria-posinset` and `aria-setsize` | | 21 | 2026-10-27 | Confirm mode (`selectionCommit`): a pending selection, `igcPendingChange` and the `commit` change type | | 22 | 2026-10-27 | Confirm mode: Cancel and Apply as the default footer content, and the `cancel` and `apply` triggers | +| 23 | 2026-10-27 | The `loading` spinner is an indeterminate `igc-circular-progress` | ## Overview @@ -392,11 +393,12 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { past `totalItemCount`, and no request comes when `data` holds `totalItemCount` items. A total that is not a finite number counts as unknown. `data` grows only at its end, so a jump far into the placeholders requests all the items up to the jump. -- `loading` shows a spinner, named "Loading", in place of the toggle icon, so it shows also while the list is closed, - for example during a search in single selection. The spinner covers the box of the toggle icon, so the input does - not change its width. An empty list shows "Loading" instead of the empty message. Content in the `loading` slot - replaces the spinner. It is in the input, so a click on it toggles the list, as on the toggle icon, and Tab does not - reach it while the list is open. The busy state shows in the field, not in the list, so the list does not move. +- `loading` shows a spinner, an indeterminate `igc-circular-progress` named "Loading", in place of the toggle icon, + so it shows also while the list is closed, for example during a search in single selection. The spinner covers the + box of the toggle icon, so the input does not change its width. An empty list shows "Loading" instead of the empty + message. Content in the `loading` slot replaces the spinner. It is in the input, so a click on it toggles the list, + as on the toggle icon, and Tab does not reach it while the list is open. The busy state shows in the field, not in + the list, so the list does not move. `bindComboDataSource()` from `igniteui-webcomponents/extras` implements this flow on the public API. See the `RemoteData` story. @@ -637,7 +639,7 @@ footer button, the focus moves to the main input, because the closed list cannot | `cancel-button` | The Cancel button of the confirm mode. | | `apply-button` | The Apply button of the confirm mode. | | `empty` | The container holding the empty content. | -| `loading` | The spinner that replaces the toggle icon while `loading` is set. It has the color of the text. | +| `loading` | The indeterminate circular progress that replaces the toggle icon while `loading` is set. It has the color of the text. | | `placeholder` | Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. | ### CSS custom states diff --git a/src/components/combo/themes/combo.base.scss b/src/components/combo/themes/combo.base.scss index 005ae1834..bc4637edd 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -117,25 +117,14 @@ visibility: hidden; } +// The size of the icon, in the color of the text. A flex box has no line box +// around the ring, so the ring is on the center of the icon. [part~='loading'] { - display: block; - box-sizing: border-box; - inline-size: rem(16px); - block-size: rem(16px); - border: rem(2px) solid currentcolor; - border-block-start-color: transparent; - border-radius: 50%; - animation: spin 0.8s linear infinite; - - @media (prefers-reduced-motion: reduce) { - animation-duration: 2.4s; - } -} + --ig-circular-bar-diameter: #{rem(16px)}; + --ig-circular-bar-fill-color-default: currentcolor; + --ig-circular-bar-track-color: color-mix(in srgb, currentcolor 16%, transparent); -@keyframes spin { - to { - rotate: 1turn; - } + display: flex; } [part~='group-header'], @@ -234,12 +223,6 @@ color: Highlight; } - [part~='loading'] { - forced-color-adjust: none; - border-color: CanvasText; - border-block-start-color: transparent; - } - [part~='placeholder'] span, [part~='placeholder']::part(checkbox)::after { forced-color-adjust: none; diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index ca5fed176..21521dbf0 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -1223,7 +1223,7 @@ export const LazyLoading: Story = { docs: { description: { story: - 'A flight search that loads the airports from the server when the list opens for the first time. `igcOpening` starts the request and sets `loading`, so the empty list shows "Loading", and a spinner takes the place of the toggle icon. The `loading` slot replaces the default spinner with a circular progress. After the load, the `empty` slot tells that the search found nothing. `filteringOptions` sets `filterKey` to a key that joins the code, the city and the airport name, so "lhr", "london" and "heathrow" all find Heathrow. The status message tells a screen reader user when the airports load.', + 'A flight search that loads the airports from the server when the list opens for the first time. `igcOpening` starts the request and sets `loading`, so the empty list shows "Loading", and a spinner takes the place of the toggle icon. The `loading` slot replaces the default spinner, which has the color of the text, with a larger circular progress in the primary color. After the load, the `empty` slot tells that the search found nothing. `filteringOptions` sets `filterKey` to a key that joins the code, the city and the airport name, so "lhr", "london" and "heathrow" all find Heathrow. The status message tells a screen reader user when the airports load.', }, }, }, @@ -1255,6 +1255,8 @@ export const LazyLoading: Story = {
              Date: Tue, 27 Oct 2026 18:42:28 +0200 Subject: [PATCH 19/32] "docs(stories): show the confirm mode of the combo --- stories/combo.stories.ts | 249 +++++++++++++++++++++++++++++---------- 1 file changed, 187 insertions(+), 62 deletions(-) diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 21521dbf0..02b3c1a74 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -8,7 +8,6 @@ import { IgcButtonComponent, IgcCircularProgressComponent, type IgcComboChangeEventArgs, - type IgcComboClosingEventArgs, IgcComboComponent, IgcInputComponent, defineComponents, @@ -416,6 +415,27 @@ const styles = html` display: flex; border-block-end: 1px solid var(--ig-gray-300); } + + .cmb-table { + overflow-x: auto; + } + + .cmb-table table { + border-collapse: collapse; + width: 100%; + } + + .cmb-table caption { + text-align: start; + padding-block-end: 0.5rem; + } + + .cmb-table :is(th, td) { + padding: 0.5rem; + border-block-end: 1px solid var(--ig-gray-300); + text-align: start; + white-space: nowrap; + } `; @@ -829,27 +849,6 @@ export const ColumnChooser: Story = { .cmb-columns igc-combo { flex: 1 1 18rem; } - - .cmb-table { - overflow-x: auto; - } - - .cmb-table table { - border-collapse: collapse; - width: 100%; - } - - .cmb-table caption { - text-align: start; - padding-block-end: 0.5rem; - } - - .cmb-table :is(th, td) { - padding: 0.5rem; - border-block-end: 1px solid var(--ig-gray-300); - text-align: start; - white-space: nowrap; - }
              { let applied: string[] = []; - const combo = createRef(); const { mount, update } = renderInto(() => { const shown = applied.length @@ -975,30 +973,10 @@ export const StagedFilter: Story = { `; }); - const apply = () => { - applied = [...(combo.value!.value as string[])]; - update(); - }; - - const restore = () => { - combo.value!.value = [...applied]; - }; - - const closed = ({ detail }: CustomEvent) => - detail.trigger === 'enter' ? apply() : restore(); - - // The clear button and Escape clear the labels also with the list closed, - // and then the change applies at once. + // Apply, Enter, the clear button, and Escape with the list closed. const changed = ({ detail }: CustomEvent) => { - if (!combo.value!.open) { - applied = detail.newValue as string[]; - update(); - } - }; - - const button = (action: () => void) => () => { - action(); - combo.value!.hide(); + applied = detail.newValue as string[]; + update(); }; return html` @@ -1008,13 +986,6 @@ export const StagedFilter: Story = { max-width: 40rem; } - .cmb-filter-footer { - display: flex; - justify-content: end; - gap: 0.5rem; - padding: 0.5rem; - } - .cmb-issues { gap: 0.5rem; } @@ -1043,24 +1014,17 @@ export const StagedFilter: Story = { >

              Issues

              Apply the labels with Apply, or with Enter in the list. -
              @@ -1068,6 +1032,167 @@ export const StagedFilter: Story = { }, }; +interface Order { + id: number; + customer: string; + status: string; +} + +const orderStatuses = [ + 'Pending', + 'Paid', + 'Shipped', + 'Delivered', + 'Returned', + 'Canceled', +]; + +const statusOrders: Order[] = [ + { id: 1041, customer: 'Ana Silva', status: 'Paid' }, + { id: 1042, customer: 'Ben Okafor', status: 'Shipped' }, + { id: 1043, customer: 'Chen Wei', status: 'Pending' }, + { id: 1044, customer: 'Dana Novak', status: 'Delivered' }, + { id: 1045, customer: 'Eli Cohen', status: 'Returned' }, + { id: 1046, customer: 'Fatima Zahra', status: 'Paid' }, + { id: 1047, customer: 'Goran Petrov', status: 'Canceled' }, + { id: 1048, customer: 'Hana Sato', status: 'Shipped' }, + { id: 1049, customer: 'Ivan Horvat', status: 'Delivered' }, + { id: 1050, customer: 'Julia Rossi', status: 'Pending' }, +]; + +export const StatusFilter: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'An order table with a status filter in confirm mode, whose `footer` slot content replaces the built-in Cancel and Apply. Clear calls `deselect()`, which changes the pending selection, not `value`. Apply calls `commitSelection()` and `hide()`. These methods send no events, so the story applies `value` itself; Enter in the list still applies with `igcChange`. The footer reads `pendingValue` when the list opens and after each `igcPendingChange`, and Apply is off while the pending statuses are the applied ones.', + }, + }, + }, + render: () => { + const combo = createRef(); + let applied: string[] = []; + + const footer = renderInto(() => { + const pending = (combo.value?.pendingValue ?? []) as string[]; + const dirty = + pending.length !== applied.length || + pending.some((status) => !applied.includes(status)); + + return html` + + ${ + pending.length + ? `${pending.length} of ${orderStatuses.length} statuses` + : 'All statuses' + } + + + Clear + + Apply + `; + }); + + const table = renderInto(() => { + const shown = applied.length + ? statusOrders.filter(({ status }) => applied.includes(status)) + : statusOrders; + + return html` +

              + ${ + applied.length + ? `${shown.length} of ${statusOrders.length} orders: ${applied.join(', ')}` + : `All ${statusOrders.length} orders` + } +

              + + + + + + + + + + ${shown.map( + ({ id, customer, status }) => html` + + + + + + ` + )} + +
              OrderCustomerStatus
              #${id}${customer}${status}
              + `; + }); + + // The combo changes the pending selection after the event, so read it + // after the update. + const refresh = async () => { + await combo.value!.updateComplete; + footer.update(); + }; + + const changed = ({ detail }: CustomEvent) => { + applied = detail.newValue as string[]; + table.update(); + void refresh(); + }; + + // The methods change the pending selection at once, without events. + const clear = () => { + combo.value!.deselect(); + footer.update(); + }; + + const apply = () => { + combo.value!.commitSelection(); + applied = [...(combo.value!.value as string[])]; + table.update(); + combo.value!.hide(); + }; + + return html` + ${styles} + +
              +

              Orders

              + + + +
              +
              + `; + }, +}; + interface Permission { id: string; name: string; From 6974aa2a417c80d5040be0664beb47deb9ea8ef6 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 19:17:47 +0200 Subject: [PATCH 20/32] refactor(combo): move the igcDataRequest paging to ComboDataRequests The paging logic of igcDataRequest moves from combo.ts to a plain class with its own unit specs. The class reads a narrow view of the host and of DataState, and the host sends the event through a request callback. No public API or behavior change. --- src/components/combo/combo.ts | 74 +------ .../combo/controllers/data-requests.spec.ts | 198 ++++++++++++++++++ .../combo/controllers/data-requests.ts | 108 ++++++++++ 3 files changed, 316 insertions(+), 64 deletions(-) create mode 100644 src/components/combo/controllers/data-requests.spec.ts create mode 100644 src/components/combo/controllers/data-requests.ts diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index a93d45cae..36a549c75 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -48,11 +48,10 @@ import type { VirtualScrollItemContext, VirtualScrollState, } from '../virtualization/types.js'; -import IgcVirtualScrollComponent, { - REMOTE_SCROLLING_THRESHOLD, -} from '../virtualization/virtualization.js'; +import IgcVirtualScrollComponent from '../virtualization/virtualization.js'; import IgcComboHeaderComponent from './combo-header.js'; import IgcComboItemComponent from './combo-item.js'; +import { ComboDataRequests } from './controllers/data-requests.js'; import { DataState, isItemRecord } from './controllers/data.js'; import { ComboNavigationController } from './controllers/navigation.js'; import { styles } from './themes/combo.base.css.js'; @@ -94,8 +93,6 @@ const i18n = { defaultEN: ComboResourceStringsEN, }; -const DATA_REQUEST_COUNT = 20; - /* blazorSupportsVisualChildren */ /** * The combo lets the user select one or more options from a list. Unlike the select, it shows the options in a @@ -261,6 +258,11 @@ export default class IgcComboComponent< }, } ); + private readonly _dataRequests = new ComboDataRequests( + this, + this._state, + (detail) => this.emitEvent('igcDataRequest', { detail }) + ); private _index?: Map, number[]>; private _indexSize = 0; @@ -269,10 +271,6 @@ export default class IgcComboComponent< private _selected: Set = new Set(); /** Read it through {@link _pendingSelection}. */ private _pending?: Set; - /** The length of `data` at the last `igcDataRequest`. */ - private _lastDataRequest?: number; - /** The index of the last rendered option, from the list. */ - private _lastRenderedIndex = -1; // The `displayKey` setter sets `filterKey`. Both keys are undefined here. private _filteringOptions: FilteringOptions = { filterKey: undefined, @@ -744,23 +742,7 @@ export default class IgcComboComponent< } protected override updated(props: PropertyValues): void { - const opened = props.has('open') && this.open; - const replaced = - props.has('data') && props.get('data')?.[0] !== this.data[0]; - - // An opening, or a replaced `data` rather than an appended one, starts a - // new sequence of requests. Opening again also retries a failed request. - if (opened || replaced) { - this._lastDataRequest = undefined; - } - - // The list reports a changed window after its own update, and an - // unchanged one not at all, so check against the window after it. - if (opened || props.has('data') || props.has('totalItemCount')) { - void this._listRef.value?.updateComplete.then(() => - this._checkDataRequest() - ); - } + this._dataRequests.afterUpdate(props, this._listRef.value); } //#endregion @@ -826,41 +808,6 @@ export default class IgcComboComponent< return this._hide(true, { trigger }); } - /** - * Requests more data while the open list renders near the end of `data`, - * or renders placeholders, up to `totalItemCount`. The `count` covers the - * rendered placeholders. An empty `data` waits for the first page from the - * application. - */ - private _checkDataRequest(): void { - const { length } = this.data; - const { loadedLength, totalCount } = this._state; - - if ( - !this.open || - !length || - this.filteringOptions.mode !== 'remote' || - this._lastDataRequest === length || - length >= totalCount || - this._lastRenderedIndex < loadedLength - REMOTE_SCROLLING_THRESHOLD - ) { - return; - } - - const rendered = this._lastRenderedIndex - loadedLength + 1; - - this._lastDataRequest = length; - this.emitEvent('igcDataRequest', { - detail: { - startIndex: length, - count: Math.min( - totalCount - length, - Math.max(DATA_REQUEST_COUNT, rendered) - ), - }, - }); - } - private _setSingleSelectionDisplayValue(value: string): void { if (this.singleSelect && this._inputRef.value) { this._inputRef.value.value = value; @@ -1428,14 +1375,13 @@ export default class IgcComboComponent< private _emitSearchInput(searchText: string): void { // A new search starts a new sequence of data requests. - this._lastDataRequest = undefined; + this._dataRequests.reset(); this.emitEvent('igcSearchInput', { detail: { searchText } }); } private _handleListStateChange(event: CustomEvent): void { event.stopPropagation(); - this._lastRenderedIndex = event.detail.endIndex; - this._checkDataRequest(); + this._dataRequests.rendered(event.detail.endIndex); } private async _itemClickHandler(event: PointerEvent): Promise { diff --git a/src/components/combo/controllers/data-requests.spec.ts b/src/components/combo/controllers/data-requests.spec.ts new file mode 100644 index 000000000..8869c92ed --- /dev/null +++ b/src/components/combo/controllers/data-requests.spec.ts @@ -0,0 +1,198 @@ +import { aTimeout, expect } from '@open-wc/testing'; +import type { PropertyValues } from 'lit'; +import { type SinonSpy, spy } from 'sinon'; + +import type IgcComboComponent from '../combo.js'; +import type { ComboFilteringMode } from '../types.js'; +import { ComboDataRequests } from './data-requests.js'; + +type Item = { id: number }; + +const items = (count: number, start = 0): Item[] => + Array.from({ length: count }, (_, i) => ({ id: start + i })); + +const list = { updateComplete: Promise.resolve(true) }; + +function setup(mode: ComboFilteringMode = 'remote') { + const request = spy(); + const host = { + open: true, + data: items(10), + filteringOptions: { filterKey: undefined, mode }, + }; + const state = { loadedLength: 10, totalCount: 100 }; + const requests = new ComboDataRequests(host, state, request); + + return { host, state, requests, request }; +} + +function changed( + entries: [string, unknown][] +): PropertyValues> { + return new Map(entries) as PropertyValues>; +} + +function requested(request: SinonSpy) { + return request.args.map(([detail]) => detail); +} + +describe('Combo data requests', () => { + it('requests the next items when the window comes near the end of data', () => { + const { requests, request } = setup(); + + requests.rendered(4); + expect(request.called).to.be.false; + + requests.rendered(5); + expect(request.calledOnce).to.be.true; + expect(requested(request)).to.eql([{ startIndex: 10, count: 20 }]); + }); + + it('does not request in local mode', () => { + const { requests, request } = setup('local'); + + requests.rendered(9); + expect(request.called).to.be.false; + }); + + it('does not request while the list is closed', () => { + const { host, requests, request } = setup(); + + host.open = false; + requests.rendered(9); + expect(request.called).to.be.false; + }); + + it('waits for the first page of an empty data', () => { + const { host, state, requests, request } = setup(); + + host.data = []; + state.loadedLength = 0; + requests.rendered(9); + expect(request.called).to.be.false; + }); + + it('does not request when data holds all the items', () => { + const { state, requests, request } = setup(); + + state.totalCount = 10; + requests.rendered(9); + expect(request.called).to.be.false; + }); + + it('covers the rendered placeholders with the count', () => { + const { requests, request } = setup(); + + requests.rendered(39); + expect(requested(request)).to.eql([{ startIndex: 10, count: 30 }]); + }); + + it('stops the count at the total', () => { + const { state, requests, request } = setup(); + + state.totalCount = 25; + requests.rendered(9); + expect(requested(request)).to.eql([{ startIndex: 10, count: 15 }]); + }); + + it('requests one time for each length of data', () => { + const { host, state, requests, request } = setup(); + + requests.rendered(9); + requests.rendered(9); + expect(request.callCount).to.equal(1); + + host.data = [...host.data, ...items(10, 10)]; + state.loadedLength = 20; + requests.rendered(19); + expect(requested(request)).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 20, count: 20 }, + ]); + }); + + it('requests the same length again after a reset', () => { + const { requests, request } = setup(); + + requests.rendered(9); + requests.reset(); + requests.rendered(9); + expect(request.callCount).to.equal(2); + }); + + it('starts again and checks after the list update when the list opens', async () => { + const { requests, request } = setup(); + + requests.rendered(9); + requests.afterUpdate(changed([['open', false]]), list); + expect(request.callCount).to.equal(1); + + await aTimeout(0); + expect(request.callCount).to.equal(2); + }); + + it('keeps the sequence and does not check when the list closes', async () => { + const { host, requests, request } = setup(); + + requests.rendered(9); + host.open = false; + requests.afterUpdate(changed([['open', true]]), list); + host.open = true; + + await aTimeout(0); + requests.rendered(9); + expect(request.callCount).to.equal(1); + }); + + it('does not check for a change of other properties', async () => { + const { host, requests, request } = setup(); + + host.open = false; + requests.rendered(9); + host.open = true; + requests.afterUpdate(changed([['label', 'City']]), list); + + await aTimeout(0); + expect(request.called).to.be.false; + }); + + it('starts again when data is replaced', async () => { + const { host, requests, request } = setup(); + + requests.rendered(9); + const previous = host.data; + host.data = items(10, 100); + requests.afterUpdate(changed([['data', previous]]), list); + + await aTimeout(0); + expect(requested(request)).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('keeps the sequence when data starts with the same item', async () => { + const { host, requests, request } = setup(); + + requests.rendered(9); + const previous = host.data; + host.data = [...previous]; + requests.afterUpdate(changed([['data', previous]]), list); + + await aTimeout(0); + expect(request.callCount).to.equal(1); + }); + + it('checks after the list update when the total changes', async () => { + const { state, requests, request } = setup(); + + state.totalCount = 10; + requests.rendered(9); + expect(request.called).to.be.false; + + state.totalCount = 100; + requests.afterUpdate(changed([['totalItemCount', 10]]), list); + await aTimeout(0); + expect(request.callCount).to.equal(1); + }); +}); diff --git a/src/components/combo/controllers/data-requests.ts b/src/components/combo/controllers/data-requests.ts new file mode 100644 index 000000000..ef5673c86 --- /dev/null +++ b/src/components/combo/controllers/data-requests.ts @@ -0,0 +1,108 @@ +import type { PropertyValues, ReactiveElement } from 'lit'; +import type { VirtualScrollDataRequest } from '../../virtualization/types.js'; +import { REMOTE_SCROLLING_THRESHOLD } from '../../virtualization/virtualization.js'; +import type IgcComboComponent from '../combo.js'; +import type { DataState } from './data.js'; + +const DATA_REQUEST_COUNT = 20; + +type DataRequestsHost = Pick< + IgcComboComponent, + 'open' | 'data' | 'filteringOptions' +>; + +type DataRequestsState = Pick< + DataState, + 'loadedLength' | 'totalCount' +>; + +/** + * Requests more data in remote filtering mode while the open list renders + * near the end of `data`, or renders placeholders, up to `totalItemCount`. + * The host emits each request as `igcDataRequest`. + */ +export class ComboDataRequests { + private readonly _host: DataRequestsHost; + private readonly _state: DataRequestsState; + private readonly _request: (detail: VirtualScrollDataRequest) => void; + + /** The length of `data` at the last request. */ + private _lastDataRequest?: number; + /** The index of the last rendered option, from the list. */ + private _lastRenderedIndex = -1; + + constructor( + host: DataRequestsHost, + state: DataRequestsState, + request: (detail: VirtualScrollDataRequest) => void + ) { + this._host = host; + this._state = state; + this._request = request; + } + + /** Starts a new sequence of requests. */ + public reset(): void { + this._lastDataRequest = undefined; + } + + /** Takes the end of the window that the list renders. */ + public rendered(endIndex: number): void { + this._lastRenderedIndex = endIndex; + this._check(); + } + + /** Call from the `updated()` of the host. */ + public afterUpdate( + props: PropertyValues>, + list?: Pick + ): void { + const { open, data } = this._host; + const opened = props.has('open') && open; + const replaced = props.has('data') && props.get('data')?.[0] !== data[0]; + + // An opening, or a replaced `data` rather than an appended one, starts a + // new sequence of requests. Opening again also retries a failed request. + if (opened || replaced) { + this.reset(); + } + + // The list reports a changed window after its own update, and an + // unchanged one not at all, so check against the window after it. + if (opened || props.has('data') || props.has('totalItemCount')) { + void list?.updateComplete.then(() => this._check()); + } + } + + /** + * The `count` covers the rendered placeholders. An empty `data` waits for + * the first page from the application. + */ + private _check(): void { + const { open, data, filteringOptions } = this._host; + const { length } = data; + const { loadedLength, totalCount } = this._state; + + if ( + !open || + !length || + filteringOptions.mode !== 'remote' || + this._lastDataRequest === length || + length >= totalCount || + this._lastRenderedIndex < loadedLength - REMOTE_SCROLLING_THRESHOLD + ) { + return; + } + + const placeholders = this._lastRenderedIndex - loadedLength + 1; + + this._lastDataRequest = length; + this._request({ + startIndex: length, + count: Math.min( + totalCount - length, + Math.max(DATA_REQUEST_COUNT, placeholders) + ), + }); + } +} From a498566f3bd3696bf4207523a6827e5b248feda7 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 27 Oct 2026 19:34:56 +0200 Subject: [PATCH 21/32] refactor(combo): move the toggle option ranges to DataState The items and the selection state of a toggle option (the select-all option and a group header) now come from DataState: itemsOf(index), selectionOf(index, selected) and shownItems(). DataState reads a narrow view of the host, so its new unit specs use a plain object. The host keeps the update flush before it reads the shown items. No public API or behavior change. --- src/components/combo/combo.ts | 54 +--- src/components/combo/controllers/data.spec.ts | 241 ++++++++++++++++++ src/components/combo/controllers/data.ts | 83 +++++- 3 files changed, 323 insertions(+), 55 deletions(-) create mode 100644 src/components/combo/controllers/data.spec.ts diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 36a549c75..b7ef561da 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -1179,20 +1179,6 @@ export default class IgcComboComponent< return true; } - /** The items of the records from `start` to `end`, each one time. See {@link isItemRecord}. */ - private _itemsIn(start: number, end: number): T[] { - const records = this._state.dataState; - const items = new Set(); - - for (let i = start; i < end; i++) { - if (isItemRecord(records[i])) { - items.add(records[i].value); - } - } - - return [...items]; - } - /** The items that the list shows. */ private _shownItems(): T[] { // Applies a change of `data` or of the search text that no update has @@ -1201,7 +1187,7 @@ export default class IgcComboComponent< this._state.hostUpdate(); this.requestUpdate(); - return this._itemsIn(0, this._state.loadedLength); + return this._state.shownItems(); } /** @@ -1237,7 +1223,7 @@ export default class IgcComboComponent< * all of them are selected, with one `igcChange` for the items that change. */ private _toggleItemsOf(index: number): void { - const items = this._itemsIn(index + 1, this._state.endOf(index)); + const items = this._state.itemsOf(index); const selected = this._listSelection; const unselected = items.filter((item) => !selected.has(item)); @@ -1248,40 +1234,6 @@ export default class IgcComboComponent< ); } - /** - * Whether all (`true`), none (`false`) or some (`mixed`) of the items of the - * toggle option at `index` are selected. Stops at the first mix, because - * these options render often. - */ - private _selectionOf(index: number): boolean | 'mixed' { - const records = this._state.dataState; - const selected = this._listSelection; - let some = false; - let every = true; - - if (selected.size) { - for (let i = index + 1, end = this._state.endOf(index); i < end; i++) { - const record = records[i]; - - if (!isItemRecord(record)) { - continue; - } - - if (selected.has(record.value)) { - some = true; - } else { - every = false; - } - - if (some && !every) { - return 'mixed'; - } - } - } - - return some; - } - private _clearSelection(): void { // The clear icon is on the input, which shows `value`, so it clears // `value` at once, and the pending selection with it. @@ -1647,7 +1599,7 @@ export default class IgcComboComponent< index: number, content: unknown ): TemplateResult { - const state = this._selectionOf(index); + const state = this._state.selectionOf(index, this._listSelection); const all = state === true; const active = this._activeIndex === index; diff --git a/src/components/combo/controllers/data.spec.ts b/src/components/combo/controllers/data.spec.ts new file mode 100644 index 000000000..88e7fd6c5 --- /dev/null +++ b/src/components/combo/controllers/data.spec.ts @@ -0,0 +1,241 @@ +import { expect } from '@open-wc/testing'; +import { spy } from 'sinon'; + +import type { ComboRecord, FilteringOptions } from '../types.js'; +import { DataState, type DataStateHost } from './data.js'; + +type City = { id: number; name: string; country: string }; + +const cities: City[] = [ + { id: 1, name: 'Sofia', country: 'Bulgaria' }, + { id: 2, name: 'Plovdiv', country: 'Bulgaria' }, + { id: 3, name: 'Berlin', country: 'Germany' }, + { id: 4, name: 'Hamburg', country: 'Germany' }, + { id: 5, name: 'Paris', country: 'France' }, +]; +const [sofia, plovdiv, , , paris] = cities; + +const remote: FilteringOptions = { filterKey: 'name', mode: 'remote' }; + +function setup(overrides: Partial> = {}) { + const host: DataStateHost = { + addController: () => {}, + requestUpdate: spy(), + data: cities, + locale: 'en', + filteringOptions: { + filterKey: 'name', + caseSensitive: false, + matchDiacritics: false, + }, + valueKey: 'id', + displayKey: 'name', + groupKey: undefined, + groupSorting: 'asc', + totalItemCount: undefined, + showSelectAll: false, + groupSelection: false, + singleSelect: false, + ...overrides, + }; + const state = new DataState(host); + + state.hostUpdate(); + return { host, state }; +} + +/** A short name for each record: the city name, `#group` for a header, or the kind. */ +function names(records: readonly ComboRecord[]): string[] { + return records.map((record) => + record.header ? `#${record.group}` : (record.kind ?? record.value.name) + ); +} + +function positions(records: readonly ComboRecord[]): number[] { + return records.map((record) => record.position); +} + +describe('Combo data state', () => { + it('numbers the options in the order of data', () => { + const { state } = setup(); + + expect(names(state.dataState)).to.eql([ + 'Sofia', + 'Plovdiv', + 'Berlin', + 'Hamburg', + 'Paris', + ]); + expect(positions(state.dataState)).to.eql([1, 2, 3, 4, 5]); + expect(state.optionCount).to.equal(5); + expect(state.loadedLength).to.equal(5); + }); + + it('filters by the search term in the next update', () => { + const { host, state } = setup(); + + state.searchTerm = 'r'; + expect(host.requestUpdate).to.have.been.calledOnce; + expect(state.dataState).to.have.length(5); + + state.hostUpdate(); + expect(names(state.dataState)).to.eql(['Berlin', 'Hamburg', 'Paris']); + expect(positions(state.dataState)).to.eql([1, 2, 3]); + }); + + it('keeps all the records in remote mode', () => { + const { state } = setup({ filteringOptions: remote }); + + state.searchTerm = 'r'; + state.hostUpdate(); + expect(state.dataState).to.have.length(5); + }); + + it('puts a header before each group, and the headers are not options', () => { + const { state } = setup({ groupKey: 'country' }); + + expect(names(state.dataState)).to.eql([ + '#Bulgaria', + 'Sofia', + 'Plovdiv', + '#France', + 'Paris', + '#Germany', + 'Berlin', + 'Hamburg', + ]); + expect(positions(state.dataState)).to.eql([-1, 1, 2, -1, 3, -1, 4, 5]); + expect(state.firstOptionIndex).to.equal(1); + expect(state.isOption(state.dataState[0])).to.be.false; + expect(state.isToggle(state.dataState[0])).to.be.false; + }); + + it('makes the headers toggle options with groupSelection', () => { + const { state } = setup({ groupKey: 'country', groupSelection: true }); + const [header, item] = state.dataState; + + expect(positions(state.dataState)).to.eql([1, 2, 3, 4, 5, 6, 7, 8]); + expect(state.optionCount).to.equal(8); + expect(state.firstOptionIndex).to.equal(0); + expect(state.isOption(header)).to.be.true; + expect(state.isToggle(header)).to.be.true; + expect(state.isToggle(item)).to.be.false; + }); + + it('does not make the headers options in single selection', () => { + const { state } = setup({ + groupKey: 'country', + groupSelection: true, + singleSelect: true, + }); + + expect(state.isOption(state.dataState[0])).to.be.false; + expect(state.optionCount).to.equal(5); + }); + + it('puts the select-all option first, only in multiple selection and with items', () => { + const { state } = setup({ showSelectAll: true }); + const [selectAll] = state.dataState; + + expect(names(state.dataState)[0]).to.equal('select-all'); + expect(positions(state.dataState).slice(1)).to.eql([2, 3, 4, 5, 6]); + expect(state.optionCount).to.equal(6); + expect(state.loadedLength).to.equal(6); + expect(state.isToggle(selectAll)).to.be.true; + + state.searchTerm = 'xyz'; + state.hostUpdate(); + expect(state.dataState).to.be.empty; + + expect( + names(setup({ showSelectAll: true, singleSelect: true }).state.dataState) + ).not.to.include('select-all'); + }); + + it('appends a placeholder for each item to load in remote mode', () => { + const { state } = setup({ filteringOptions: remote, totalItemCount: 8 }); + + expect(names(state.dataState).slice(5)).to.eql([ + 'placeholder', + 'placeholder', + 'placeholder', + ]); + expect(state.loadedLength).to.equal(5); + expect(state.optionCount).to.equal(8); + + expect(setup({ totalItemCount: 8 }).state.dataState).to.have.length(5); + expect( + setup({ filteringOptions: remote, totalItemCount: 8, data: [] }).state + .dataState + ).to.be.empty; + }); + + it('counts the total only in remote mode and as a whole number', () => { + expect( + setup({ filteringOptions: remote, totalItemCount: 7.9 }).state.totalCount + ).to.equal(7); + expect( + setup({ filteringOptions: remote, totalItemCount: Number.NaN }).state + .totalCount + ).to.equal(Number.POSITIVE_INFINITY); + expect(setup({ totalItemCount: 7 }).state.totalCount).to.equal( + Number.POSITIVE_INFINITY + ); + }); + + it('gives a group header the items up to the next header', () => { + const { state } = setup({ groupKey: 'country', groupSelection: true }); + + expect(state.itemsOf(0)).to.eql([sofia, plovdiv]); + expect(state.itemsOf(5).map(({ name }) => name)).to.eql([ + 'Berlin', + 'Hamburg', + ]); + }); + + it('gives the select-all option all the loaded items, one time each', () => { + const { state } = setup({ + data: [sofia, sofia, plovdiv], + filteringOptions: remote, + showSelectAll: true, + totalItemCount: 10, + }); + + expect(state.itemsOf(0)).to.eql([sofia, plovdiv]); + }); + + it('tells whether none, some or all the items of a toggle option are selected', () => { + const { state } = setup({ groupKey: 'country', groupSelection: true }); + + expect(state.selectionOf(0, new Set())).to.be.false; + expect(state.selectionOf(0, new Set([paris]))).to.be.false; + expect(state.selectionOf(0, new Set([sofia]))).to.equal('mixed'); + expect(state.selectionOf(0, new Set([sofia, plovdiv]))).to.be.true; + }); + + it('gives the select-all option the state of all the loaded items', () => { + const { state } = setup({ + filteringOptions: remote, + showSelectAll: true, + totalItemCount: 10, + }); + + expect(state.selectionOf(0, new Set(cities))).to.be.true; + expect(state.selectionOf(0, new Set([plovdiv]))).to.equal('mixed'); + }); + + it('limits the toggle ranges and the shown items to the filtered records', () => { + const { state } = setup({ groupKey: 'country', groupSelection: true }); + + state.searchTerm = 'a'; + state.hostUpdate(); + + expect(state.itemsOf(0)).to.eql([sofia]); + expect(state.selectionOf(0, new Set([sofia]))).to.be.true; + expect(state.shownItems().map(({ name }) => name)).to.eql([ + 'Sofia', + 'Paris', + 'Hamburg', + ]); + }); +}); diff --git a/src/components/combo/controllers/data.ts b/src/components/combo/controllers/data.ts index 599cd14aa..486671a65 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -1,16 +1,34 @@ import type { ReactiveController } from 'lit'; import { resolveLocale } from '#internals/i18n/locale.js'; +import type IgcComboComponent from '../combo.js'; import FilterDataOperation from '../operations/filter.js'; import GroupDataOperation from '../operations/group.js'; import type { - ComboHost, ComboRecord, FilteringOptions, GroupingOptions, Keys, } from '../types.js'; +/** The fields of the combo that the data state reads. */ +export type DataStateHost = Pick< + IgcComboComponent, + | 'addController' + | 'requestUpdate' + | 'data' + | 'locale' + | 'filteringOptions' + | 'valueKey' + | 'displayKey' + | 'groupKey' + | 'groupSorting' + | 'totalItemCount' + | 'showSelectAll' + | 'groupSelection' + | 'singleSelect' +>; + /** The record of each item to load. See `totalItemCount`. */ const PLACEHOLDER: ComboRecord = Object.freeze({ value: undefined as never, @@ -37,7 +55,7 @@ export function isItemRecord( /* blazorSuppress */ export class DataState implements ReactiveController { //#region Internal state - private readonly _host: ComboHost; + private readonly _host: DataStateHost; private readonly _filtering = new FilterDataOperation(); private readonly _grouping = new GroupDataOperation(); private _compareCollator?: Intl.Collator; @@ -131,7 +149,7 @@ export class DataState implements ReactiveController { //#region Lifecycle and pipeline management - constructor(host: ComboHost) { + constructor(host: DataStateHost) { this._host = host; this._host.addController(this); } @@ -237,7 +255,7 @@ export class DataState implements ReactiveController { * The end of the records that the toggle option at `index` toggles: the * next group header, or the end of the loaded records. */ - public endOf(index: number): number { + private _endOf(index: number): number { if (!this._dataState[index].header) { return this._loadedLength; } @@ -251,6 +269,63 @@ export class DataState implements ReactiveController { return end; } + /** The items of the records from `start` to `end`, each one time. See {@link isItemRecord}. */ + private _itemsIn(start: number, end: number): T[] { + const records = this._dataState; + const items = new Set(); + + for (let i = start; i < end; i++) { + if (isItemRecord(records[i])) { + items.add(records[i].value); + } + } + + return [...items]; + } + + /** The items that the list shows. */ + public shownItems(): T[] { + return this._itemsIn(0, this._loadedLength); + } + + /** The items that the toggle option at `index` toggles. See {@link isToggle}. */ + public itemsOf(index: number): T[] { + return this._itemsIn(index + 1, this._endOf(index)); + } + + /** + * Whether all (`true`), none (`false`) or some (`mixed`) of the items of the + * toggle option at `index` are in `selected`. Stops at the first mix, + * because these options render often. + */ + public selectionOf(index: number, selected: Set): boolean | 'mixed' { + const records = this._dataState; + let some = false; + let every = true; + + if (selected.size) { + for (let i = index + 1, end = this._endOf(index); i < end; i++) { + const record = records[i]; + + if (!isItemRecord(record)) { + continue; + } + + if (selected.has(record.value)) { + some = true; + } else { + every = false; + } + + if (some && !every) { + return 'mixed'; + } + } + } + + return some; + } + /** Only group sorting uses the collator, so the data changes only if it exists. */ public updateLocale(): void { if (this._compareCollator) { From 4ac4689dd9a1bdbbadbfdc9a1487afc4a8541fe3 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 28 Oct 2026 10:50:51 +0200 Subject: [PATCH 22/32] refactor(combo): move the selection state to ComboSelection The committed and pending selections, the value index and the selection math move from combo.ts to a plain class with its own unit specs. The class sends no events and exposes the sets as read-only views, so only its methods change them. combo.ts keeps the flow: the change events, the form value, the display text and the search text. isEmpty now takes read-only sets and maps. No public API or behavior change. --- src/components/combo/combo.ts | 301 +++------------- src/components/combo/controllers/data.ts | 5 +- .../combo/controllers/selection.spec.ts | 304 ++++++++++++++++ src/components/combo/controllers/selection.ts | 334 ++++++++++++++++++ src/internals/utils/arrays.ts | 2 +- 5 files changed, 690 insertions(+), 256 deletions(-) create mode 100644 src/components/combo/controllers/selection.spec.ts create mode 100644 src/components/combo/controllers/selection.ts diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index b7ef561da..2031930f6 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -30,7 +30,6 @@ import { stopPropagation, } from '#internals/utils/events.js'; import { bindIf } from '#internals/utils/lit.js'; -import { isObject } from '#internals/utils/types.js'; import type { Validator } from '#internals/validators.js'; import { all } from '#themes/combo/themes/themes.js'; import { @@ -54,6 +53,7 @@ import IgcComboItemComponent from './combo-item.js'; import { ComboDataRequests } from './controllers/data-requests.js'; import { DataState, isItemRecord } from './controllers/data.js'; import { ComboNavigationController } from './controllers/navigation.js'; +import { ComboSelection } from './controllers/selection.js'; import { styles } from './themes/combo.base.css.js'; import { styles as shared } from './themes/shared/combo.common.css.js'; import type { @@ -237,6 +237,7 @@ export default class IgcComboComponent< private readonly _listRef = createRef(); private readonly _state = new DataState(this); + private readonly _selection = new ComboSelection(this); private readonly _navigation = new ComboNavigationController( this, this._state, @@ -252,7 +253,7 @@ export default class IgcComboComponent< hide: (trigger) => this._close(trigger), toggleSelection: (index: number) => this._selectAt(index, true), select: (index: number) => this._selectAt(index, false), - hasPending: () => this._pendingSelection !== undefined, + hasPending: () => this._selection.pending !== undefined, apply: (trigger) => this._applyAndClose(trigger), clearSelection: () => this._clearSelection(), }, @@ -264,13 +265,8 @@ export default class IgcComboComponent< (detail) => this.emitEvent('igcDataRequest', { detail }) ); - private _index?: Map, number[]>; - private _indexSize = 0; private _displayKey?: Keys; private _placeholderSearch?: string; - private _selected: Set = new Set(); - /** Read it through {@link _pendingSelection}. */ - private _pending?: Set; // The `displayKey` setter sets `filterKey`. Both keys are undefined here. private _filteringOptions: FilteringOptions = { filterKey: undefined, @@ -300,28 +296,6 @@ export default class IgcComboComponent< @state() private _displayValue = ''; - /** - * The pending selection, which exists while the list is open in the confirm - * mode of `selectionCommit`. It starts as a copy of `_selected`. - */ - private get _pendingSelection(): Set | undefined { - if (this.open && this._confirmMode) { - return (this._pending ??= new Set(this._selected)); - } - - this._pending = undefined; - return undefined; - } - - private get _confirmMode(): boolean { - return this.selectionCommit === 'confirm' && !this.singleSelect; - } - - /** The selection that the list shows and the user changes. */ - private get _listSelection(): Set { - return this._pendingSelection ?? this._selected; - } - private _itemId(index: number): string { const position = index + 1; return this.id ? `${this.id}-item-${position}` : `item-${position}`; @@ -333,7 +307,7 @@ export default class IgcComboComponent< } private get _mainAriaLabel(): string { - return isEmpty(this._selected) + return isEmpty(this._selection.selected) ? this.resourceStrings.combo_aria_label_no_options : this.resourceStrings.combo_aria_label_options; } @@ -351,7 +325,7 @@ export default class IgcComboComponent< @coercedProperty>({ transform: ({ value }) => asArray(value), onChange: ({ host }) => { - host._index = undefined; + host._selection.invalidateIndex(); }, }) public data: T[] = []; @@ -441,7 +415,7 @@ export default class IgcComboComponent< @coercedProperty | undefined, IgcComboComponent>({ onChange: ({ value, host }) => { host._displayKey = host._displayKey ?? value; - host._index = undefined; + host._selection.invalidateIndex(); }, }) public valueKey?: Keys = undefined; @@ -633,7 +607,7 @@ export default class IgcComboComponent< // A new value replaces the pending selection. The same value, for example // from a render of the parent, keeps it. if (!sameItems(previous, this.value)) { - this._pending = undefined; + this._selection.dropPending(); } if (this.hasUpdated) { @@ -653,7 +627,7 @@ export default class IgcComboComponent< * Returns the current selection as an array of objects as provided in the `data` source. */ public get selection(): T[] { - return Array.from(this._selected); + return Array.from(this._selection.selected); } /** @@ -662,8 +636,8 @@ export default class IgcComboComponent< * applies. Without a pending selection, the same as `value`. */ public get pendingValue(): ComboValue[] { - const pending = this._pendingSelection; - return pending ? this._toValues(pending) : this.value; + const pending = this._selection.pending; + return pending ? this._selection.toValues(pending) : this.value; } //#endregion @@ -737,7 +711,7 @@ export default class IgcComboComponent< // The list can open again before anything reads the pending selection. if (!this.open) { - this._pending = undefined; + this._selection.dropPending(); } } @@ -818,109 +792,11 @@ export default class IgcComboComponent< // #region Selection helpers - /** - * Maps each value to the positions of its records, in data-source order. - * The size check finds in-place growth or shrink, but not a replaced element. - */ - private get _dataIndex(): Map, number[]> { - if (!this._index || this._indexSize !== this.data.length) { - this._index = Map.groupBy(this.data.keys(), (position) => - this._resolveItemValue(this.data[position]) - ); - this._indexSize = this.data.length; - } - - return this._index; - } - - /** - * Resolves values or objects to records: the records of `data` in - * data-source order, then the selected records and the objects that `data` - * does not hold. A repeated value resolves one time, so the change event - * has no duplicates. - */ - private _resolveItems(items: Item[]): T[] { - const index = this._dataIndex; - const positions = new Set(); - const others = new Set(); - let selected: Map, T> | undefined; - - for (const item of items) { - const value = isObject(item) ? this._valueOf(item as T) : item; - const found = index.get(value); - - if (found) { - for (const position of found) { - positions.add(position); - } - continue; - } - - // A pending record of the value comes before a committed one. - selected ??= this._selectedByValue( - this._selected.union(this._listSelection) - ); - const record = - selected.get(value) ?? (isObject(item) ? (item as T) : undefined); - - if (record) { - others.add(record); - } - } - - return [ - ...Array.from(positions) - .sort((a, b) => a - b) - .map((position) => this.data[position]), - ...others, - ]; - } - - /** The selected records whose values `data` does not hold. */ - private _selectedOutsideData(): Set { - const index = this._dataIndex; - - return new Set( - Iterator.from(this._listSelection).filter( - (record) => !index.has(this._resolveItemValue(record)) - ) - ); - } - - private _selectedByValue(selected: Set): Map, T> { - return new Map( - Iterator.from(selected).map((record): [ComboValue, T] => [ - this._valueOf(record), - record, - ]) - ); - } - - private _resolveItemValue(record: T): Item { - return this.valueKey ? record[this.valueKey] : record; - } - - /** The `valueKey` property of `item`, or `item` when that property is not set. */ - private _valueOf(item: T): ComboValue { - return (this.valueKey ? item[this.valueKey] : undefined) ?? item; - } - - private _toValues(items: Iterable): ComboValue[] { - return Array.from(items, (item) => this._valueOf(item)); - } - /** Recomputes {@link _displayValue} and returns the selection values in one pass. */ private _projectSelection(): ComboValue[] { - const { displayKey } = this; - const values: ComboValue[] = []; - const display: string[] = []; - - for (const item of this._selected) { - values.push(this._valueOf(item)); - display.push(String((displayKey ? item[displayKey] : undefined) ?? item)); - } + const { values, text } = this._selection.project(); - this._displayValue = display.join(', '); + this._displayValue = text; return values; } @@ -931,23 +807,6 @@ export default class IgcComboComponent< return this.emitEvent(event, { cancelable: true, detail }); } - /** The list selection value after `resolved` applies: the `newValue` of the change event. */ - private _previewValue(resolved: T[], selecting: boolean): ComboValue[] { - const selected = this._listSelection; - - if (selecting) { - return this._toValues( - this.singleSelect ? resolved : new Set([...selected, ...resolved]) - ); - } - - const removed = new Set(resolved); - - return this._toValues( - Iterator.from(selected).filter((item) => !removed.has(item)) - ); - } - /** * An empty collection selects or deselects all. Single selection only clears. * @returns Whether the change was committed. @@ -966,12 +825,10 @@ export default class IgcComboComponent< if (isEmpty(collection) && selecting) { // Selects all items without an event, or clears a single selection. if (singleSelect) { - this._selected = new Set(); + this._selection.clear(); this._searchTerm = ''; - } else if (this._pendingSelection) { - this._pending = new Set(this.data).union(this._selectedOutsideData()); } else { - this._selected = new Set(this.data).union(this._selectedOutsideData()); + this._selection.selectAll(); } this.requestUpdate(); @@ -980,8 +837,8 @@ export default class IgcComboComponent< // An empty deselection deselects every selected item. const resolved = isEmpty(collection) - ? Array.from(this._listSelection) - : this._resolveItems(collection); + ? Array.from(this._selection.current) + : this._selection.resolve(collection); return this._applySelection(resolved, type, emit); } @@ -1002,11 +859,11 @@ export default class IgcComboComponent< emit && !this._emitSelectionChange( { - newValue: this._previewValue(records, selecting), + newValue: this._selection.preview(records, selecting), items: records, type, }, - this._pendingSelection ? 'igcPendingChange' : 'igcChange' + this._selection.pending ? 'igcPendingChange' : 'igcChange' ) ) { return false; @@ -1014,18 +871,14 @@ export default class IgcComboComponent< // The change is committed, so drop the previous single selection and search term. if (selecting && this.singleSelect) { - this._selected.clear(); + this._selection.clear(); this._clearSearchTerm(emit); } - const selected = this._listSelection; - - for (const item of records) { - selecting ? selected.add(item) : selected.delete(item); - } + this._selection.apply(records, selecting); - if (selecting && this._pendingSelection) { - this._resolveValues(records); + if (selecting && this._selection.resolveValues(records)) { + this._projectSelection(); } this.requestUpdate(); @@ -1033,23 +886,7 @@ export default class IgcComboComponent< } /** - * Adds the records of the values of `value` that have none to `_selected`, - * as a new `data` does. For example, a remote source gives them to - * `select()` while the list has a pending selection. - */ - private _resolveValues(records: T[]): void { - // `_selected` has at most one record for each value. - if (this._selected.size < this.value.length) { - this._selected = this._recordsOf( - this.value, - new Set(records).union(this._selected) - ); - this._projectSelection(); - } - } - - /** - * One-way sync from `_formValue` to `_selected`. A value that `data` does + * One-way sync from `_formValue` to the selection. A value that `data` does * not hold keeps its selected record. */ private _syncSelectionFromValue(): void { @@ -1065,24 +902,15 @@ export default class IgcComboComponent< values = first; } - this._selected = this._recordsOf(values, this._selected); + this._selection.syncFromValue(values); this._projectSelection(); // The list does not update for a new `value` on its own. this._listRef.value?.requestUpdate(); } - /** - * Gives the selection the records of a new `data`. The pending selection - * also gets the records that `data` gives to the values of `value` that had - * none, because the user could not deselect them. - */ + /** Gives the selection the records of a new `data`. */ private _syncSelectionFromData(): void { - const pending = this._pendingSelection; - // Only a value without a record can get a new record. - const known = - pending && this._selected.size < this.value.length - ? new Set(this._toValues(this._selected)) - : undefined; + const known = this._selection.knownValues(); // Sync the selection when `data` comes after `value`. if (!isEmpty(this.data) && !isEmpty(this.value)) { @@ -1092,50 +920,13 @@ export default class IgcComboComponent< }); } - if (pending) { - const values = this._toValues(pending); - - if (known) { - values.push( - ...this._toValues(this._selected).filter((value) => !known.has(value)) - ); - } - - this._pending = this._recordsOf(values, pending); - } - } - - /** The records of `values`. A value that `data` does not hold keeps its record in `previous`. */ - private _recordsOf(values: ComboValue[], previous: Set): Set { - const records = new Set(); - - if (isEmpty(values)) { - return records; - } - - const index = this._dataIndex; - let byValue: Map, T> | undefined; - - for (const value of values) { - // First match only - mirrors resolving a value against the data source - const position = index.get(value)?.[0]; - const record = - position === undefined - ? (byValue ??= this._selectedByValue(previous)).get(value) - : this.data[position]; - - if (record !== undefined) { - records.add(record); - } - } - - return records; + this._selection.syncPending(known); } protected _syncValueFromSelection(): void { // A pending selection does not change `value`, which can hold values // without a record. See `_commitPending`. - if (!this._pendingSelection) { + if (!this._selection.pending) { this._formValue.setValueAndFormState(this._projectSelection()); this._setSingleSelectionDisplayValue(this._displayValue); this._validate(); @@ -1150,13 +941,13 @@ export default class IgcComboComponent< * @returns Whether the pending selection is applied. */ private _commitPending(emit: boolean): boolean { - const pending = this._pendingSelection; + const pending = this._selection.pending; if (!pending) { return true; } - const items = [...pending.symmetricDifference(this._selected)]; + const items = this._selection.changes(pending); if (isEmpty(items)) { return true; @@ -1165,7 +956,7 @@ export default class IgcComboComponent< if ( emit && !this._emitSelectionChange({ - newValue: this._toValues(pending), + newValue: this._selection.toValues(pending), items, type: 'commit', }) @@ -1174,8 +965,8 @@ export default class IgcComboComponent< } // The `value` setter finds the records that `data` does not hold here. - this._selected = pending; - this.value = this._toValues(pending); + this._selection.commit(pending); + this.value = this._selection.toValues(pending); return true; } @@ -1204,7 +995,9 @@ export default class IgcComboComponent< // The record itself, as an item without a value would select all. this._applySelection( [item], - toggle && this._listSelection.has(item) ? 'deselection' : 'selection', + toggle && this._selection.current.has(item) + ? 'deselection' + : 'selection', true ); } else if (record && this._state.isToggle(record)) { @@ -1224,7 +1017,7 @@ export default class IgcComboComponent< */ private _toggleItemsOf(index: number): void { const items = this._state.itemsOf(index); - const selected = this._listSelection; + const selected = this._selection.current; const unselected = items.filter((item) => !selected.has(item)); this._applySelection( @@ -1237,7 +1030,7 @@ export default class IgcComboComponent< private _clearSelection(): void { // The clear icon is on the input, which shows `value`, so it clears // `value` at once, and the pending selection with it. - if (this._pendingSelection) { + if (this._selection.pending) { if ( this._emitSelectionChange({ newValue: [], @@ -1260,14 +1053,14 @@ export default class IgcComboComponent< } private _clearSingleSelection(): void { - const [selection] = this._selected; + const [selection] = this._selection.selected; // Reset the form state directly, not through `_syncValueFromSelection`, to // keep the text the user types in the main input. if ( selection && this._updateSelection( - this._resolveItemValue(selection), + this._selection.keyOf(selection), 'deselection', true ) @@ -1303,7 +1096,7 @@ export default class IgcComboComponent< } protected override _handleBlur(): void { - if (isEmpty(this._selected)) { + if (isEmpty(this._selection.selected)) { this._clearSearchTerm(true); this._setSingleSelectionDisplayValue(''); } @@ -1520,7 +1313,7 @@ export default class IgcComboComponent< * not close the list. */ public cancelSelection(): void { - this._pending = undefined; + this._selection.dropPending(); this._listRef.value?.requestUpdate(); } @@ -1554,7 +1347,7 @@ export default class IgcComboComponent< const active = this._activeIndex === index; - const selected = this._listSelection.has(item.value); + const selected = this._selection.current.has(item.value); // One template for both, so a row keeps its element when its item loads. const placeholder = item.kind === 'placeholder'; const { optionCount } = this._state; @@ -1599,7 +1392,7 @@ export default class IgcComboComponent< index: number, content: unknown ): TemplateResult { - const state = this._state.selectionOf(index, this._listSelection); + const state = this._state.selectionOf(index, this._selection.current); const all = state === true; const active = this._activeIndex === index; @@ -1661,7 +1454,7 @@ export default class IgcComboComponent< part="clear-icon" role="button" @click=${this._handleClearIconClick} - ?hidden=${this.disableClear || isEmpty(this._selected)} + ?hidden=${this.disableClear || isEmpty(this._selection.selected)} aria-label=${ifDefined( this.resourceStrings.combo_clearItems_placeholder )} @@ -1778,7 +1571,7 @@ export default class IgcComboComponent< ${this._renderEmptyTemplate()}
              - ${this.open && this._confirmMode ? this._renderActions() : nothing} + ${this.open && this._selection.confirmMode ? this._renderActions() : nothing}
              diff --git a/src/components/combo/controllers/data.ts b/src/components/combo/controllers/data.ts index 486671a65..a7a4df2fb 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -298,7 +298,10 @@ export class DataState implements ReactiveController { * toggle option at `index` are in `selected`. Stops at the first mix, * because these options render often. */ - public selectionOf(index: number, selected: Set): boolean | 'mixed' { + public selectionOf( + index: number, + selected: ReadonlySet + ): boolean | 'mixed' { const records = this._dataState; let some = false; let every = true; diff --git a/src/components/combo/controllers/selection.spec.ts b/src/components/combo/controllers/selection.spec.ts new file mode 100644 index 000000000..aa7e1d4ca --- /dev/null +++ b/src/components/combo/controllers/selection.spec.ts @@ -0,0 +1,304 @@ +import { expect } from '@open-wc/testing'; + +import { ComboSelection, type ComboSelectionHost } from './selection.js'; + +type City = { id: number | null; name: string }; + +const cities: City[] = [ + { id: 1, name: 'Sofia' }, + { id: 2, name: 'Plovdiv' }, + { id: 3, name: 'Varna' }, +]; +const [sofia, plovdiv, varna] = cities; +/** A record that `data` does not hold, for example from a remote source. */ +const burgas: City = { id: 9, name: 'Burgas' }; + +function setup(overrides: Partial> = {}) { + const host: ComboSelectionHost = { + data: cities, + valueKey: 'id', + displayKey: 'name', + value: [], + singleSelect: false, + open: false, + selectionCommit: 'immediate', + ...overrides, + }; + + return { host, selection: new ComboSelection(host) }; +} + +/** Opens the list in the confirm mode, so the selection has a pending part. */ +function confirming(overrides: Partial> = {}) { + return setup({ open: true, selectionCommit: 'confirm', ...overrides }); +} + +describe('Combo selection', () => { + describe('Values', () => { + it('reads the value from valueKey, or uses the record', () => { + const keyed = setup().selection; + const plain = setup({ valueKey: undefined }).selection; + + expect(keyed.toValues([sofia])).to.eql([1]); + expect(keyed.keyOf(sofia)).to.equal(1); + expect(plain.toValues([sofia])[0]).to.equal(sofia); + expect(plain.keyOf(sofia)).to.equal(sofia); + }); + + it('uses the record as the value of a nullish field, but not as its key', () => { + const { selection } = setup(); + const unknown: City = { id: null, name: 'Unknown' }; + + expect(selection.toValues([unknown])[0]).to.equal(unknown); + expect(selection.keyOf(unknown)).to.be.null; + }); + + it('gives the values and the display text of the committed selection', () => { + const { selection } = setup(); + + selection.apply([varna, sofia], true); + expect(selection.project()).to.eql({ + values: [3, 1], + text: 'Varna, Sofia', + }); + }); + }); + + describe('Resolve', () => { + it('resolves values and objects to the records of data in data order, one time each', () => { + const { selection } = setup(); + + expect(selection.resolve([3, 1, { ...plovdiv }, 1])).to.eql([ + sofia, + plovdiv, + varna, + ]); + expect(selection.resolve([3])[0]).to.equal(varna); + }); + + it('takes every record of a value that data repeats', () => { + const copy = { ...sofia }; + const { selection } = setup({ data: [sofia, plovdiv, copy] }); + + expect(selection.resolve([1])).to.eql([sofia, copy]); + }); + + it('puts the selected records and the objects that data does not hold last', () => { + const { selection } = setup(); + const ruse: City = { id: 7, name: 'Ruse' }; + + selection.apply([burgas], true); + const resolved = selection.resolve([ruse, 9, 2, 42]); + + expect(resolved).to.eql([plovdiv, ruse, burgas]); + expect(resolved[2]).to.equal(burgas); + }); + + it('prefers the pending record of a value to the committed one', () => { + const { selection } = confirming(); + const newer: City = { ...burgas }; + + selection.commit(new Set([burgas])); + selection.apply([burgas], false); + selection.apply([newer], true); + + expect(selection.resolve([9])[0]).to.equal(newer); + }); + }); + + describe('Pending selection', () => { + it('exists only while the list is open in the confirm mode of multiple selection', () => { + expect(setup().selection.pending).to.be.undefined; + expect(setup({ open: true }).selection.pending).to.be.undefined; + expect(setup({ selectionCommit: 'confirm' }).selection.pending).to.be + .undefined; + expect(confirming({ singleSelect: true }).selection.pending).to.be + .undefined; + expect(confirming().selection.confirmMode).to.be.true; + }); + + it('starts as a copy of the committed selection on the first read', () => { + const { selection } = confirming(); + + selection.commit(new Set([sofia])); + const pending = selection.pending!; + + expect(pending).not.to.equal(selection.selected); + expect([...pending]).to.eql([sofia]); + expect(selection.pending).to.equal(pending); + expect(selection.current).to.equal(pending); + }); + + it('takes the changes and leaves the committed selection as it is', () => { + const { selection } = confirming(); + + selection.commit(new Set([sofia])); + selection.apply([plovdiv], true); + selection.apply([sofia], false); + + expect([...selection.current]).to.eql([plovdiv]); + expect([...selection.selected]).to.eql([sofia]); + }); + + it('is dropped when the list closes, or on request', () => { + const { host, selection } = confirming(); + + selection.apply([sofia], true); + host.open = false; + expect(selection.pending).to.be.undefined; + + host.open = true; + expect([...selection.pending!]).to.be.empty; + + selection.apply([sofia], true); + selection.dropPending(); + expect([...selection.pending!]).to.be.empty; + }); + }); + + describe('Changes', () => { + it('previews the selected values first, then the new ones', () => { + const { selection } = setup(); + + selection.apply([varna], true); + expect(selection.preview([sofia, varna], true)).to.eql([3, 1]); + expect(selection.preview([varna], false)).to.eql([]); + }); + + it('previews only the new value in single selection', () => { + const { selection } = setup({ singleSelect: true }); + + selection.apply([varna], true); + expect(selection.preview([sofia], true)).to.eql([1]); + }); + + it('selects all the records of data and keeps the selected records outside it', () => { + const { selection } = setup(); + + selection.apply([burgas], true); + selection.selectAll(); + expect([...selection.selected]).to.eql([...cities, burgas]); + }); + + it('selects all into the pending selection', () => { + const { selection } = confirming(); + + selection.selectAll(); + expect([...selection.pending!]).to.eql(cities); + expect(selection.selected).to.be.empty; + }); + + it('gives the committed selection the records of values that had none', () => { + const { selection } = confirming({ value: [1, 9] }); + + selection.syncFromValue([1, 9]); + expect([...selection.selected]).to.eql([sofia]); + + selection.apply([burgas], true); + expect(selection.resolveValues([burgas])).to.be.true; + expect([...selection.selected]).to.eql([sofia, burgas]); + expect(selection.resolveValues([plovdiv])).to.be.false; + }); + + it('resolves values only while the list has a pending selection', () => { + const { selection } = setup({ value: [1, 9] }); + + selection.syncFromValue([1, 9]); + expect(selection.resolveValues([burgas])).to.be.false; + expect([...selection.selected]).to.eql([sofia]); + }); + + it('lists the records that the pending selection adds or removes, and commits it', () => { + const { selection } = confirming(); + + selection.commit(new Set([sofia, plovdiv])); + expect(selection.changes(selection.pending!)).to.be.empty; + + selection.apply([sofia], false); + selection.apply([varna], true); + + const pending = selection.pending!; + expect(selection.changes(pending)).to.eql([varna, sofia]); + + selection.commit(pending); + expect([...selection.selected]).to.eql([plovdiv, varna]); + expect(selection.selected).not.to.equal(pending); + }); + + it('clears the committed selection', () => { + const { selection } = setup(); + + selection.apply([sofia], true); + selection.clear(); + expect(selection.selected).to.be.empty; + }); + }); + + describe('Sync', () => { + it('gives each value its first record and keeps the records outside data', () => { + const copy = { ...sofia }; + const { selection } = setup({ data: [sofia, plovdiv, copy] }); + + selection.apply([burgas], true); + selection.syncFromValue([1, 9, 42]); + const [first, second, ...rest] = selection.selected; + + expect(first).to.equal(sofia); + expect(second).to.equal(burgas); + expect(rest).to.be.empty; + }); + + it('finds the records of data that grows in place', () => { + const data = [sofia]; + const { selection } = setup({ data }); + + selection.syncFromValue([2]); + expect(selection.selected).to.be.empty; + + data.push(plovdiv); + selection.syncFromValue([2]); + expect([...selection.selected]).to.eql([plovdiv]); + }); + + it('finds a replaced record only after the index is invalidated', () => { + const data = [sofia, plovdiv]; + const { selection } = setup({ data }); + const replaced: City = { id: 5, name: 'Pleven' }; + + selection.syncFromValue([1]); + data[1] = replaced; + selection.syncFromValue([5]); + expect(selection.selected).to.be.empty; + + selection.invalidateIndex(); + selection.syncFromValue([5]); + expect([...selection.selected]).to.eql([replaced]); + }); + + it('gives the pending selection the records that new data gives to its values', () => { + const { host, selection } = confirming({ value: [1, 9] }); + + selection.syncFromValue([1, 9]); + selection.apply([plovdiv], true); + + host.data = [...cities, burgas]; + selection.invalidateIndex(); + const known = selection.knownValues(); + + expect([...known!]).to.eql([1]); + selection.syncFromValue(host.value); + selection.syncPending(known); + + expect([...selection.selected]).to.eql([sofia, burgas]); + expect([...selection.pending!]).to.eql([sofia, plovdiv, burgas]); + }); + + it('has no known values without a pending selection, or when every value has a record', () => { + expect(setup({ value: [1, 9] }).selection.knownValues()).to.be.undefined; + + const { selection } = confirming({ value: [1] }); + selection.syncFromValue([1]); + expect(selection.knownValues()).to.be.undefined; + }); + }); +}); diff --git a/src/components/combo/controllers/selection.ts b/src/components/combo/controllers/selection.ts new file mode 100644 index 000000000..c9a40e12d --- /dev/null +++ b/src/components/combo/controllers/selection.ts @@ -0,0 +1,334 @@ +import { isEmpty } from '#internals/utils/arrays.js'; +import { isObject } from '#internals/utils/types.js'; +import type IgcComboComponent from '../combo.js'; +import type { ComboValue, Item } from '../types.js'; + +/** The fields of the combo that the selection reads. */ +export type ComboSelectionHost = Pick< + IgcComboComponent, + | 'data' + | 'valueKey' + | 'displayKey' + | 'value' + | 'singleSelect' + | 'open' + | 'selectionCommit' +>; + +/** + * The selected records of the combo: the committed selection, and the pending + * selection of the confirm mode while the list is open. It resolves values and + * objects to the records of `data`, and keeps the selected records that `data` + * does not hold. It sends no events. + */ +export class ComboSelection { + private readonly _host: ComboSelectionHost; + private _index?: Map, number[]>; + private _indexSize = 0; + private _selected: Set = new Set(); + /** Read it through {@link pending}. */ + private _pending?: Set; + + constructor(host: ComboSelectionHost) { + this._host = host; + } + + /** The committed selection: `selection`, `value` and the text of the input. */ + public get selected(): ReadonlySet { + return this._selected; + } + + public get confirmMode(): boolean { + return this._host.selectionCommit === 'confirm' && !this._host.singleSelect; + } + + /** + * The pending selection, which exists while the list is open in the confirm + * mode of `selectionCommit`. It starts as a copy of {@link selected}. + */ + public get pending(): ReadonlySet | undefined { + return this._lazyPending; + } + + /** The selection that the list shows and the user changes. */ + public get current(): ReadonlySet { + return this._target; + } + + private get _lazyPending(): Set | undefined { + if (this._host.open && this.confirmMode) { + return (this._pending ??= new Set(this._selected)); + } + + this._pending = undefined; + return undefined; + } + + /** The set that the changes of the user go to. See {@link current}. */ + private get _target(): Set { + return this._lazyPending ?? this._selected; + } + + /** The next read of {@link pending} copies {@link selected} again. */ + public dropPending(): void { + this._pending = undefined; + } + + /** Call when `data` or `valueKey` is set. */ + public invalidateIndex(): void { + this._index = undefined; + } + + /** + * Maps each value to the positions of its records, in data-source order. + * The size check finds in-place growth or shrink, but not a replaced element. + */ + private get _dataIndex(): Map, number[]> { + const { data } = this._host; + + if (!this._index || this._indexSize !== data.length) { + this._index = Map.groupBy(data.keys(), (position) => + this.keyOf(data[position]) + ); + this._indexSize = data.length; + } + + return this._index; + } + + /** The key of the record in the value index. Unlike {@link _valueOf}, it has no fallback. */ + public keyOf(record: T): Item { + const { valueKey } = this._host; + return valueKey ? record[valueKey] : record; + } + + /** The `valueKey` property of `item`, or `item` when that property is not set. */ + private _valueOf(item: T): ComboValue { + const { valueKey } = this._host; + return (valueKey ? item[valueKey] : undefined) ?? item; + } + + public toValues(items: Iterable): ComboValue[] { + return Array.from(items, (item) => this._valueOf(item)); + } + + /** + * Resolves values or objects to records: the records of `data` in + * data-source order, then the selected records and the objects that `data` + * does not hold. A repeated value resolves one time, so the change event + * has no duplicates. + */ + public resolve(items: Item[]): T[] { + const { data } = this._host; + const index = this._dataIndex; + const positions = new Set(); + const others = new Set(); + let selected: Map, T> | undefined; + + for (const item of items) { + const value = isObject(item) ? this._valueOf(item as T) : item; + const found = index.get(value); + + if (found) { + for (const position of found) { + positions.add(position); + } + continue; + } + + // A pending record of the value comes before a committed one. + selected ??= this._byValue(this._selected.union(this.current)); + const record = + selected.get(value) ?? (isObject(item) ? (item as T) : undefined); + + if (record) { + others.add(record); + } + } + + return [ + ...Array.from(positions) + .sort((a, b) => a - b) + .map((position) => data[position]), + ...others, + ]; + } + + /** The values of {@link current} after `records` apply: the `newValue` of the change event. */ + public preview(records: T[], selecting: boolean): ComboValue[] { + const current = this.current; + + if (selecting) { + return this.toValues( + this._host.singleSelect ? records : new Set([...current, ...records]) + ); + } + + const removed = new Set(records); + + return this.toValues( + Iterator.from(current).filter((item) => !removed.has(item)) + ); + } + + /** Adds `records` to {@link current}, or removes them. */ + public apply(records: T[], selecting: boolean): void { + const target = this._target; + + for (const item of records) { + selecting ? target.add(item) : target.delete(item); + } + } + + /** + * While the list has a pending selection, adds the records of the values of + * `value` that have none to {@link selected}, as a new `data` does. For + * example, a remote source gives them to `select()`. + * @returns Whether {@link selected} changed. + */ + public resolveValues(records: T[]): boolean { + const { value } = this._host; + + // `selected` has at most one record for each value. + if (this.pending && this._selected.size < value.length) { + this._selected = this._recordsOf( + value, + new Set(records).union(this._selected) + ); + return true; + } + + return false; + } + + /** Selects all the records of `data`, and keeps the selected records that `data` does not hold. */ + public selectAll(): void { + const all = new Set(this._host.data).union(this._selectedOutsideData()); + + if (this.pending) { + this._pending = all; + } else { + this._selected = all; + } + } + + /** Clears the committed selection. */ + public clear(): void { + this._selected = new Set(); + } + + /** The records that `pending` adds to {@link selected} or removes from it. */ + public changes(pending: ReadonlySet): T[] { + return [...pending.symmetricDifference(this._selected)]; + } + + /** + * Makes `pending`, as the host read it before the change event, the + * committed selection. The `value` setter then gives each value its record. + */ + public commit(pending: ReadonlySet): void { + this._selected = new Set(pending); + } + + /** Gives {@link selected} the records of `values`. A value that `data` does not hold keeps its record. */ + public syncFromValue(values: ComboValue[]): void { + this._selected = this._recordsOf(values, this._selected); + } + + /** + * Before a new `data`: the values that have a record, while the list has a + * pending selection and some values of `value` have none. Only a value + * without a record can get a new record. Pass it to {@link syncPending}. + */ + public knownValues(): Set> | undefined { + return this.pending && this._selected.size < this._host.value.length + ? new Set(this.toValues(this._selected)) + : undefined; + } + + /** + * After a new `data` and the sync of {@link selected} with `value`: gives the + * pending selection the records of the new `data`, and the records that the + * values outside `known` got, because the user could not deselect them. + */ + public syncPending(known: Set> | undefined): void { + const pending = this.pending; + + if (pending) { + const values = this.toValues(pending); + + if (known) { + values.push( + ...this.toValues(this._selected).filter((value) => !known.has(value)) + ); + } + + this._pending = this._recordsOf(values, pending); + } + } + + /** The values and the display text of {@link selected}, in one pass. */ + public project(): { values: ComboValue[]; text: string } { + const { displayKey } = this._host; + const values: ComboValue[] = []; + const display: string[] = []; + + for (const item of this._selected) { + values.push(this._valueOf(item)); + display.push(String((displayKey ? item[displayKey] : undefined) ?? item)); + } + + return { values, text: display.join(', ') }; + } + + /** The records of `values`. A value that `data` does not hold keeps its record in `previous`. */ + private _recordsOf( + values: ComboValue[], + previous: ReadonlySet + ): Set { + const records = new Set(); + + if (isEmpty(values)) { + return records; + } + + const { data } = this._host; + const index = this._dataIndex; + let byValue: Map, T> | undefined; + + for (const value of values) { + // First match only - mirrors resolving a value against the data source + const position = index.get(value)?.[0]; + const record = + position === undefined + ? (byValue ??= this._byValue(previous)).get(value) + : data[position]; + + if (record !== undefined) { + records.add(record); + } + } + + return records; + } + + /** The selected records whose values `data` does not hold. */ + private _selectedOutsideData(): Set { + const index = this._dataIndex; + + return new Set( + Iterator.from(this.current).filter( + (record) => !index.has(this.keyOf(record)) + ) + ); + } + + private _byValue(selected: ReadonlySet): Map, T> { + return new Map( + Iterator.from(selected).map((record): [ComboValue, T] => [ + this._valueOf(record), + record, + ]) + ); + } +} diff --git a/src/internals/utils/arrays.ts b/src/internals/utils/arrays.ts index 32c15c2e3..f8137803e 100644 --- a/src/internals/utils/arrays.ts +++ b/src/internals/utils/arrays.ts @@ -31,7 +31,7 @@ export function* chunk(arr: T[], size: number): Generator { /** Returns whether a given collection is empty. */ export function isEmpty( - x: ArrayLike | Set | Map + x: ArrayLike | ReadonlySet | ReadonlyMap ): boolean { return 'length' in x ? x.length < 1 : x.size < 1; } From 355591205c1c9d3ddd5cec53f2c0777399033f10 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 28 Oct 2026 11:03:41 +0200 Subject: [PATCH 23/32] refactor(combo): tidy the regions of combo.ts combo.ts uses one region style and groups its members by concern: open and close, the selection flow, the event handlers and the rendering. ComboHost is gone, so types.ts no longer imports combo.ts, and the navigation controller takes the combo type. A few internal comments are shorter or more exact. No public API or behavior change. --- src/components/combo/combo.ts | 84 ++++++++++--------- .../combo/controllers/navigation.ts | 9 +- src/components/combo/controllers/selection.ts | 4 +- src/components/combo/types.ts | 6 +- 4 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 2031930f6..5d74e6a52 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -194,11 +194,7 @@ export default class IgcComboComponent< ); } - // #region Internal state and controllers - - protected override get __validators(): Validator>[] { - return comboValidators; - } + //#region Internal state and controllers private readonly _slots = addSlotController(this, { slots: SLOTS }); private readonly _themes: ThemingController; @@ -251,8 +247,8 @@ export default class IgcComboComponent< interactions: { show: () => this._show(true), hide: (trigger) => this._close(trigger), - toggleSelection: (index: number) => this._selectAt(index, true), - select: (index: number) => this._selectAt(index, false), + toggleSelection: (index) => this._selectAt(index, true), + select: (index) => this._selectAt(index, false), hasPending: () => this._selection.pending !== undefined, apply: (trigger) => this._applyAndClose(trigger), clearSelection: () => this._clearSelection(), @@ -296,23 +292,13 @@ export default class IgcComboComponent< @state() private _displayValue = ''; - private _itemId(index: number): string { - const position = index + 1; - return this.id ? `${this.id}-item-${position}` : `item-${position}`; - } - - /** Derived from {@link _activeIndex}, so it cannot go stale. */ - private get _activeDescendant(): string | undefined { - return this._activeIndex > -1 ? this._itemId(this._activeIndex) : undefined; - } - private get _mainAriaLabel(): string { return isEmpty(this._selection.selected) ? this.resourceStrings.combo_aria_label_no_options : this.resourceStrings.combo_aria_label_options; } - // #endregion + //#endregion //#region Public attributes and properties @@ -721,7 +707,11 @@ export default class IgcComboComponent< //#endregion - // #region Form Associated overrides + //#region Form Associated overrides + + protected override get __validators(): Validator>[] { + return comboValidators; + } protected override _setDefaultValue(current: string | null): void { try { @@ -738,9 +728,9 @@ export default class IgcComboComponent< this._inputRef.value?.['_resetValidationState'](); } - // #endregion + //#endregion - //#region Overrides for combo box behavior + //#region Open and close protected override async _show(emitEvent = false): Promise { const [canOpen, _] = await Promise.all([ @@ -782,17 +772,20 @@ export default class IgcComboComponent< return this._hide(true, { trigger }); } - private _setSingleSelectionDisplayValue(value: string): void { - if (this.singleSelect && this._inputRef.value) { - this._inputRef.value.value = value; - } + /** + * Applies the pending selection, then closes the list. A canceled + * `igcChange` keeps the list open. + * @returns Whether the list closes. + */ + private async _applyAndClose(trigger: ComboCloseTrigger): Promise { + return this._commitPending(true) ? this._close(trigger) : false; } //#endregion - // #region Selection helpers + //#region Selection flow - /** Recomputes {@link _displayValue} and returns the selection values in one pass. */ + /** Sets {@link _displayValue} and returns the values of the selection. */ private _projectSelection(): ComboValue[] { const { values, text } = this._selection.project(); @@ -800,6 +793,12 @@ export default class IgcComboComponent< return values; } + private _setSingleSelectionDisplayValue(value: string): void { + if (this.singleSelect && this._inputRef.value) { + this._inputRef.value.value = value; + } + } + private _emitSelectionChange( detail: IgcComboChangeEventArgs, event: 'igcChange' | 'igcPendingChange' = 'igcChange' @@ -835,7 +834,6 @@ export default class IgcComboComponent< return true; } - // An empty deselection deselects every selected item. const resolved = isEmpty(collection) ? Array.from(this._selection.current) : this._selection.resolve(collection); @@ -908,7 +906,6 @@ export default class IgcComboComponent< this._listRef.value?.requestUpdate(); } - /** Gives the selection the records of a new `data`. */ private _syncSelectionFromData(): void { const known = this._selection.knownValues(); @@ -970,7 +967,6 @@ export default class IgcComboComponent< return true; } - /** The items that the list shows. */ private _shownItems(): T[] { // Applies a change of `data` or of the search text that no update has // applied yet. The update gives the new records to the list before it @@ -1069,7 +1065,7 @@ export default class IgcComboComponent< } } - // #endregion + //#endregion //#region Event handlers @@ -1084,7 +1080,8 @@ export default class IgcComboComponent< this._emitSearchInput(event.detail); } - // wait for the dataState to update after filtering + // The update applies a new search text to the records before the active + // option changes. await this.updateComplete; // Remote results come later, so no option of the old results becomes active. @@ -1169,15 +1166,6 @@ export default class IgcComboComponent< this._activeIndex = -1; } - /** - * Applies the pending selection, then closes the list. A canceled - * `igcChange` keeps the list open. - * @returns Whether the list closes. - */ - private async _applyAndClose(trigger: ComboCloseTrigger): Promise { - return this._commitPending(true) ? this._close(trigger) : false; - } - private _handleApply(): void { this._applyAndClose('apply'); } @@ -1319,6 +1307,18 @@ export default class IgcComboComponent< //#endregion + //#region Rendering + + private _itemId(index: number): string { + const position = index + 1; + return this.id ? `${this.id}-item-${position}` : `item-${position}`; + } + + /** Derived from {@link _activeIndex}, so it cannot go stale. */ + private get _activeDescendant(): string | undefined { + return this._activeIndex > -1 ? this._itemId(this._activeIndex) : undefined; + } + protected _itemRenderer: ComboRenderFunction = ( context: VirtualScrollItemContext> ) => { @@ -1622,6 +1622,8 @@ export default class IgcComboComponent< ${this._renderHelperText()} `; } + + //#endregion } declare global { diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index b08ae1718..d985a5503 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -17,7 +17,8 @@ import { isEmpty } from '#internals/utils/arrays.js'; import { getFocusableElements } from '#internals/utils/dom.js'; import type IgcInputComponent from '../../input/input.js'; import type IgcVirtualScrollComponent from '../../virtualization/virtualization.js'; -import type { ComboCloseTrigger, ComboHost } from '../types.js'; +import type IgcComboComponent from '../combo.js'; +import type { ComboCloseTrigger } from '../types.js'; import type { DataState } from './data.js'; type ListSlot = 'header' | 'empty' | 'footer'; @@ -44,7 +45,7 @@ type ComboNavigationConfig = { export class ComboNavigationController { //#region Internal state - private readonly _host: ComboHost; + private readonly _host: IgcComboComponent; private readonly _state: DataState; private readonly _config: ComboNavigationConfig; @@ -75,7 +76,7 @@ export class ComboNavigationController { public active = -1; constructor( - host: ComboHost, + host: IgcComboComponent, state: DataState, config: ComboNavigationConfig ) { @@ -285,7 +286,7 @@ export class ComboNavigationController { } /** - * The only write path for the active index. The host mirrors it as + * Sets the active index from the keyboard. The host mirrors it as * `_activeIndex`, so the update request carries the old value. */ private _setActive(index: number): void { diff --git a/src/components/combo/controllers/selection.ts b/src/components/combo/controllers/selection.ts index c9a40e12d..e72e5e689 100644 --- a/src/components/combo/controllers/selection.ts +++ b/src/components/combo/controllers/selection.ts @@ -212,7 +212,7 @@ export class ComboSelection { } } - /** Clears the committed selection. */ + /** Clears the committed selection. The pending selection does not change. */ public clear(): void { this._selected = new Set(); } @@ -297,7 +297,7 @@ export class ComboSelection { let byValue: Map, T> | undefined; for (const value of values) { - // First match only - mirrors resolving a value against the data source + // Only the first record of the value, unlike `resolve()`. const position = index.get(value)?.[0]; const record = position === undefined diff --git a/src/components/combo/types.ts b/src/components/combo/types.ts index 0263420ba..4d0ae55e9 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -1,7 +1,6 @@ -import type { ReactiveControllerHost, TemplateResult } from 'lit'; +import type { TemplateResult } from 'lit'; import type { VirtualScrollDataRequest } from '../virtualization/types.js'; import type { VirtualScrollItemTemplate } from '../virtualization/virtualization.js'; -import type IgcComboComponent from './combo.js'; export type Keys = keyof T; export type Values = T[keyof T]; @@ -25,9 +24,6 @@ export type ComboRecord = { kind?: 'placeholder' | 'select-all'; }; -export type ComboHost = ReactiveControllerHost & - IgcComboComponent; - export type GroupingDirection = 'asc' | 'desc' | 'none'; /** `commit` applies the pending selection of the confirm mode. See `selectionCommit`. */ export type ComboChangeType = From 04cccd04df46dfa005ec4d4ee255bcdb2c574dee Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 28 Oct 2026 11:31:21 +0200 Subject: [PATCH 24/32] test(combo): split combo.spec.ts into four files The 4,474-line combo spec moves into combo.spec.ts (the component, slots, close trigger, scroll strategy, theming and ARIA), combo-remote.spec.ts, combo-selection.spec.ts and combo-form.spec.ts. The shared data and helpers move to src/internals/testing/combo.spec.ts. Each file keeps the root describe('Combo'), so all test titles stay the same, and the moved blocks do not change. The form file gets an axe spec for a failed submit. spec.md lists the new files. --- src/components/combo/combo-form.spec.ts | 362 +++ src/components/combo/combo-remote.spec.ts | 692 ++++++ src/components/combo/combo-selection.spec.ts | 978 ++++++++ src/components/combo/combo.spec.ts | 2279 ++---------------- src/components/combo/spec.md | 23 +- src/internals/testing/combo.spec.ts | 83 + 6 files changed, 2279 insertions(+), 2138 deletions(-) create mode 100644 src/components/combo/combo-form.spec.ts create mode 100644 src/components/combo/combo-remote.spec.ts create mode 100644 src/components/combo/combo-selection.spec.ts create mode 100644 src/internals/testing/combo.spec.ts diff --git a/src/components/combo/combo-form.spec.ts b/src/components/combo/combo-form.spec.ts new file mode 100644 index 000000000..c772616d7 --- /dev/null +++ b/src/components/combo/combo-form.spec.ts @@ -0,0 +1,362 @@ +import { elementUpdated, expect, html } from '@open-wc/testing'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { type City, cities } from '#internals/testing/combo.spec.js'; +import { createFormAssociatedTestBed } from '#internals/testing/form-testbed.spec.js'; +import { + axeReflectedRelationsOptions, + suppressResizeObserverLoopError, +} from '#internals/testing/helpers.spec.js'; +import { simulateBlur } from '#internals/testing/simulate.spec.js'; +import { + runValidationContainerTests, + type ValidationContainerTestsParams, + ValidityHelpers, +} from '#internals/testing/validity-helpers.spec.js'; +import { firstOf } from '#internals/utils/arrays.js'; +import type IgcInputComponent from '../input/input.js'; +import IgcComboComponent from './combo.js'; + +describe('Combo', () => { + before(() => { + defineComponents(IgcComboComponent); + suppressResizeObserverLoopError(); + }); + + describe('Form integration', () => { + const spec = createFormAssociatedTestBed>( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcComboComponent.tagName); + spec.setAttributes({ value: JSON.stringify(['BG01', 'BG02']) }); + }); + + it('is form associated', () => { + expect(spec.element.form).to.equal(spec.form); + }); + + it('is not associated on submit if no value', () => { + spec.setProperties({ value: [] }); + spec.assertSubmitHasValue(null); + }); + + it('is associated on submit with value-key (single)', () => { + spec.setProperties({ singleSelect: true }); + spec.setProperties({ value: ['BG01', 'BG02'] }); + spec.assertSubmitHasValue('BG01'); + }); + + it('is associated on submit with value-key (multiple)', () => { + spec.assertSubmitHasValue('BG01'); + spec.assertSubmitHasValues(['BG01', 'BG02']); + }); + + it('is associated on submit without value-key (single)', () => { + const [first, second, _] = cities; + + spec.setProperties({ valueKey: undefined, singleSelect: true }); + spec.element.select([first, second]); + + expect(spec.formData.getAll(spec.element.name)).lengthOf(1); + spec.assertSubmitPasses(); + }); + + it('is associated on submit without value-key (multiple)', () => { + const [first, second, _] = cities; + + spec.setProperties({ valueKey: undefined }); + spec.element.select([first, second]); + + expect(spec.formData.getAll(spec.element.name)).lengthOf(2); + spec.assertSubmitPasses(); + }); + + it('is correctly reset on form reset (multiple)', () => { + const initial = spec.element.value; + + spec.setAttributes({ value: JSON.stringify(['BG01', 'BG02']) }); + spec.setProperties({ value: [] }); + + spec.reset(); + expect(spec.element.value).to.eql(initial); + }); + + it('is correctly reset on form reset (single)', () => { + // Initial value is a multiple array. The combo defaults to the first item + const initial = [firstOf(spec.element.value)]; + + spec.setProperties({ singleSelect: true }); + spec.setProperties({ value: ['US01'] }); + + expect(spec.element.value).to.eql(['US01']); + + spec.reset(); + expect(spec.element.value).to.eql(initial); + }); + + it('should reset to the new default value after setAttribute() call (multiple)', () => { + spec.setAttributes({ value: JSON.stringify(['US01', 'US02']) }); + spec.setProperties({ value: [] }); + + spec.reset(); + expect(spec.element.value).to.eql(['US01', 'US02']); + spec.assertSubmitHasValues(spec.element.value); + }); + + it('should reset to the new default value after setAttribute() call (single)', () => { + spec.setProperties({ singleSelect: true }); + spec.setAttributes({ value: JSON.stringify(['US01']) }); + spec.setProperties({ value: [] }); + + spec.reset(); + + expect(spec.element.value).to.eql(['US01']); + spec.assertSubmitHasValue('US01'); + }); + + it('should handle invalid JSON in value attribute gracefully', () => { + const defaultValue = spec.element.defaultValue; + + spec.setAttributes({ value: 'invalid' }); + expect(spec.element.value).to.be.empty; + expect(spec.element.defaultValue).to.equal(defaultValue); + }); + + it('should handle empty string in value attribute gracefully', () => { + spec.setAttributes({ value: '' }); + expect(spec.element.value).to.be.empty; + expect(spec.element.defaultValue).to.be.empty; + }); + + it('should handle null in value attribute gracefully', () => { + spec.setAttributes({ value: null as any }); + expect(spec.element.value).to.be.empty; + expect(spec.element.defaultValue).to.be.empty; + }); + + it('reflects disabled ancestor state', () => { + spec.setAncestorDisabledState(true); + expect(spec.element.disabled).to.be.true; + + spec.setAncestorDisabledState(false); + expect(spec.element.disabled).to.be.false; + }); + + it('fulfils required constraint', () => { + spec.setProperties({ value: [], required: true }); + spec.assertSubmitFails(); + + spec.setProperties({ value: ['BG01', 'BG02'] }); + spec.assertSubmitPasses(); + }); + + it('fulfils custom constraint', () => { + spec.element.setCustomValidity('invalid'); + spec.assertSubmitFails(); + + spec.element.setCustomValidity(''); + spec.assertSubmitPasses(); + }); + + it('clears the invalid styles of the single-select input on form reset', async () => { + spec.setAttributes({ value: '[]' }); + spec.setProperties({ singleSelect: true, required: true }); + await elementUpdated(spec.element); + + const target = + spec.element.renderRoot.querySelector('#target')!; + + simulateBlur(target.renderRoot.querySelector('input')!); + await elementUpdated(spec.element); + ValidityHelpers.hasInvalidStyles(target).to.be.true; + + spec.reset(); + await elementUpdated(spec.element); + + ValidityHelpers.hasInvalidStyles(spec.element).to.be.false; + ValidityHelpers.hasInvalidStyles(target).to.be.false; + }); + + it('keeps the text of the single-select input on form reset', async () => { + spec.setProperties({ singleSelect: true }); + await elementUpdated(spec.element); + + const target = + spec.element.renderRoot.querySelector('#target')!; + expect(target.value).to.equal('Sofia'); + + spec.reset(); + await elementUpdated(spec.element); + + expect(target.value).to.equal('Sofia'); + }); + + it('is accessible while a submit fails', async () => { + spec.setProperties({ label: 'Cities', required: true, value: [] }); + spec.assertSubmitFails(); + await elementUpdated(spec.element); + expect(spec.element.invalid).to.be.true; + + await expect(spec.element).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + await expect(spec.element).to.be.accessible(axeReflectedRelationsOptions); + }); + }); + + describe('defaultValue', () => { + const value = ['BG01', 'BG02']; + + describe('Form integration (single)', () => { + const spec = createFormAssociatedTestBed>(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcComboComponent.tagName); + }); + + it('correct initial state', () => { + spec.assertIsPristine(); + expect(spec.element.value).to.eql([firstOf(value)]); + }); + + it('is correctly submitted', () => { + spec.assertSubmitHasValue(firstOf(value)); + }); + + it('is correctly reset', () => { + spec.setProperties({ value: [] }); + spec.reset(); + + expect(spec.element.value).to.eql([firstOf(value)]); + }); + }); + + describe('Form integration (multiple)', () => { + const spec = createFormAssociatedTestBed>(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcComboComponent.tagName); + }); + + it('correct initial state', () => { + spec.assertIsPristine(); + expect(spec.element.value).to.eql(value); + }); + + it('is correctly submitted', () => { + spec.assertSubmitHasValues(value); + }); + + it('is correctly reset', () => { + spec.setProperties({ value: [] }); + spec.reset(); + + expect(spec.element.value).to.eql(value); + }); + }); + + describe('Form integration with delayed data', () => { + const spec = createFormAssociatedTestBed>(html` + + `); + + function initDataDefaultValue(single = false) { + spec.element.singleSelect = single; + // assign data after init to simulate async data + spec.element.data = cities; + spec.element.defaultValue = value; + } + + beforeEach(async () => { + await spec.setup(IgcComboComponent.tagName); + }); + + it('correct initial state (single)', () => { + initDataDefaultValue(true); + spec.assertIsPristine(); + expect(spec.element.value).to.eql([firstOf(value)]); + }); + + it('correct initial state (multiple)', () => { + initDataDefaultValue(); + spec.assertIsPristine(); + expect(spec.element.value).to.eql(value); + }); + + it('is correctly submitted (single)', () => { + initDataDefaultValue(true); + spec.assertSubmitHasValue(firstOf(value)); + }); + + it('is correctly submitted (multiple)', () => { + initDataDefaultValue(); + spec.assertSubmitHasValues(value); + }); + }); + + describe('Validation', () => { + const spec = createFormAssociatedTestBed>(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcComboComponent.tagName); + }); + + it('fails required validation', () => { + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes required validation', () => { + spec.setProperties({ defaultValue: value }); + spec.assertIsPristine(); + + spec.assertSubmitHasValues(value); + }); + }); + }); + + describe('Validation message slots', () => { + it('', async () => { + const testParameters: ValidationContainerTestsParams[] = + [ + { slots: ['valueMissing'], props: { required: true } }, + { slots: ['customError'] }, + { slots: ['invalid'], props: { required: true } }, + ]; + + await runValidationContainerTests(IgcComboComponent, testParameters); + }); + }); +}); diff --git a/src/components/combo/combo-remote.spec.ts b/src/components/combo/combo-remote.spec.ts new file mode 100644 index 000000000..ba61f12d0 --- /dev/null +++ b/src/components/combo/combo-remote.spec.ts @@ -0,0 +1,692 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { type SinonSpy, spy } from 'sinon'; +import { internalsOf } from '#internals/controllers/internals.js'; +import { + endKey, + enterKey, + spaceBar, +} from '#internals/controllers/key-bindings.js'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + comboStable, + openComboPopover, + items, +} from '#internals/testing/combo.spec.js'; +import { + axeReflectedRelationsOptions, + suppressResizeObserverLoopError, + withDocumentSheets, +} from '#internals/testing/helpers.spec.js'; +import { + simulateBlur, + simulateClick, + simulateInput, + simulateKeyboard, +} from '#internals/testing/simulate.spec.js'; +import { getCenterPoint } from '#internals/utils/dom.js'; +import type IgcInputComponent from '../input/input.js'; +import type IgcVirtualScrollComponent from '../virtualization/virtualization.js'; +import IgcComboComponent from './combo.js'; + +describe('Combo', () => { + let input: IgcInputComponent; + let searchInput: IgcInputComponent; + + let list: IgcVirtualScrollComponent; + + before(() => { + defineComponents(IgcComboComponent); + suppressResizeObserverLoopError(); + }); + + describe('Remote data', () => { + type Place = { id: string; name: string; letter: string }; + + const PLACES = [ + ['AMS', 'Amsterdam'], + ['ATH', 'Athens'], + ['BAR', 'Barcelona'], + ['BEL', 'Belgrade'], + ['BRA', 'Bratislava'], + ['BRU', 'Brussels'], + ['BUC', 'Bucharest'], + ['BUD', 'Budapest'], + ['CPH', 'Copenhagen'], + ['DUB', 'Dublin'], + ['BER', 'Berlin'], + ['BRN', 'Bern'], + ['BGO', 'Bergen'], + ['ALB', 'Alberobello'], + ['HEL', 'Helsinki'], + ['LIS', 'Lisbon'], + ['LON', 'London'], + ['MAD', 'Madrid'], + ['OSL', 'Oslo'], + ['PAR', 'Paris'], + ] as const; + + /** A fake server. Each response has new objects, as parsed JSON has. */ + function search(text: string, skip = 0, top = 10): Place[] { + return PLACES.filter(([, name]) => + name.toLowerCase().includes(text.toLowerCase()) + ) + .slice(skip, skip + top) + .map(([id, name]) => ({ id, name, letter: name[0] })); + } + + let remote: IgcComboComponent; + let eventSpy: SinonSpy; + let searchNative: HTMLInputElement; + let mainNative: HTMLInputElement; + + const checked = () => + items(remote) + .filter((item) => item.selected) + .map((item) => item.textContent?.trim()); + + const itemFor = (name: string) => + items(remote).find((item) => item.textContent?.trim() === name)!; + + const details = (name: string) => + eventSpy.withArgs(name).args.map(([, init]) => init.detail); + + const searchEvents = () => + details('igcSearchInput').map(({ searchText }) => searchText); + + beforeEach(async () => { + remote = await fixture>( + html`` + ); + remote.setAttribute('filtering-options', '{"mode": "remote"}'); + await elementUpdated(remote); + + input = remote.renderRoot.querySelector('igc-input#target')!; + searchInput = remote.renderRoot.querySelector('[part="search-input"]')!; + list = remote.renderRoot.querySelector('igc-virtual-scroll')!; + mainNative = input.renderRoot.querySelector('input')!; + searchNative = searchInput.renderRoot.querySelector('input')!; + eventSpy = spy(remote, 'emitEvent'); + }); + + it('takes the mode from the `filtering-options` attribute', () => { + expect(remote.filteringOptions.mode).to.equal('remote'); + expect(remote.filteringOptions.filterKey).to.equal('name'); + }); + + it('emits igcSearchInput when the user changes the search text', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'ber' }); + simulateInput(searchNative, { value: '' }); + + expect(searchEvents()).to.eql(['ber', '']); + }); + + it('does not filter the options in remote mode', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'ber' }); + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + expect(searchInput.value).to.equal('ber'); + + remote.filteringOptions = { mode: 'local' }; + await comboStable(remote); + + expect(items(remote)).lengthOf(0); + }); + + it('emits igcSearchInput in local mode too', async () => { + remote.filteringOptions = { mode: 'local' }; + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'bu' }); + + expect(searchEvents()).to.eql(['bu']); + }); + + it('emits igcSearchInput from the main input in single selection, also when it is cleared', async () => { + remote.singleSelect = true; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'ber' }); + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + + simulateInput(mainNative, { value: '' }); + await elementUpdated(remote); + + expect(searchEvents()).to.eql(['ber', '']); + }); + + it('emits igcSearchInput when a user action clears the search text, not on a property change', async () => { + remote.singleSelect = true; + remote.filteringOptions = { mode: 'local' }; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'a' }); + await comboStable(remote); + + simulateKeyboard(list, enterKey); + await elementUpdated(remote); + expect(remote.value).to.eql(['AMS']); + + simulateInput(mainNative, { value: 'b' }); + await elementUpdated(remote); + remote.disableFiltering = true; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'c' }); + await elementUpdated(remote); + + expect(searchEvents()).to.eql(['a', '', 'b']); + }); + + it('clears the search input and reports it when a blur clears the search text', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'xyz' }); + await elementUpdated(remote); + + simulateBlur(remote); + await elementUpdated(remote); + + expect(searchInput.value).to.equal(''); + expect(searchEvents()).to.eql(['xyz', '']); + }); + + it('activates no option of the old results on input in single selection', async () => { + remote.singleSelect = true; + await elementUpdated(remote); + + simulateInput(mainNative, { value: 'sof' }); + await comboStable(remote); + + simulateKeyboard(input, enterKey); + await elementUpdated(remote); + + expect(items(remote).some((item) => item.active)).to.be.false; + expect(remote.value).to.be.empty; + }); + + it('hides the case-sensitive icon in remote mode', async () => { + remote.caseSensitiveIcon = true; + await elementUpdated(remote); + + const icon = searchInput.querySelector('[part~="case-icon"]')!; + expect(icon.slot).to.be.empty; + + remote.filteringOptions = { mode: 'local' }; + await elementUpdated(remote); + + expect(icon.slot).to.equal('suffix'); + }); + + it('keeps the igcInput of the inputs inside', async () => { + const inputSpy = spy(); + remote.addEventListener('igcInput', inputSpy); + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'ber' }); + + remote.singleSelect = true; + await elementUpdated(remote); + simulateInput(mainNative, { value: 'ber' }); + + expect(inputSpy).not.called; + }); + + it('keeps the selection when a search replaces the data (issue 2519)', async () => { + await openComboPopover(remote); + + // 1. Check Amsterdam and Athens. + simulateClick(itemFor('Amsterdam')); + simulateClick(itemFor('Athens')); + await comboStable(remote); + expect(remote.value).to.eql(['AMS', 'ATH']); + + // 2. A search for "ber" replaces the data. + remote.data = search('ber'); + await comboStable(remote); + + expect(remote.value).to.eql(['AMS', 'ATH']); + expect(remote.selection.map(({ id }) => id)).to.eql(['AMS', 'ATH']); + expect(input.value).to.equal('Amsterdam, Athens'); + + // 3. Check Berlin. + simulateClick(itemFor('Berlin')); + await comboStable(remote); + expect(remote.value).to.eql(['AMS', 'ATH', 'BER']); + + // 4. Clearing the search loads the first page again. + remote.data = search(''); + await comboStable(remote); + + expect(remote.value).to.eql(['AMS', 'ATH', 'BER']); + expect(remote.selection.map(({ id }) => id)).to.eql([ + 'AMS', + 'ATH', + 'BER', + ]); + expect(remote.selection[0]).to.equal(remote.data[0]); + expect(checked()).to.eql(['Amsterdam', 'Athens']); + expect(input.value).to.equal('Amsterdam, Athens, Berlin'); + }); + + it('deselects a selected item that the data does not hold', async () => { + remote.select(['AMS', 'ATH']); + remote.data = search('ber'); + await elementUpdated(remote); + + remote.deselect('AMS'); + await elementUpdated(remote); + + expect(remote.value).to.eql(['ATH']); + expect(input.value).to.equal('Athens'); + }); + + it('selects an object that the data does not hold', async () => { + const oslo = { id: 'OSL', name: 'Oslo', letter: 'O' }; + + remote.select(oslo); + await elementUpdated(remote); + + expect(remote.value).to.eql(['OSL']); + expect(remote.selection).to.eql([oslo]); + expect(input.value).to.equal('Oslo'); + + remote.data = search('oslo'); + await openComboPopover(remote); + + expect(remote.selection[0]).to.equal(remote.data[0]); + expect(checked()).to.eql(['Oslo']); + }); + + it('selects an object that the data does not hold without a value key', async () => { + const oslo = { id: 'OSL', name: 'Oslo', letter: 'O' }; + + remote.valueKey = undefined; + remote.select(oslo); + await elementUpdated(remote); + + expect(remote.value).to.eql([oslo]); + expect(remote.selection).to.eql([oslo]); + }); + + it('selects all the data and keeps the other selected items with select()', async () => { + remote.select('AMS'); + remote.data = search('ber'); + await elementUpdated(remote); + + remote.select(); + await elementUpdated(remote); + + expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB', 'AMS']); + }); + + it('selects each value one time with select() before a new data syncs', async () => { + remote.data = search('ber'); + remote.value = ['BER']; + remote.data = search('ber'); + remote.select(); + await elementUpdated(remote); + + expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB']); + }); + + it('emits igcDataRequest near the end of the data, in data coordinates', async () => { + remote.groupKey = 'letter'; + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.eql([{ startIndex: 10, count: 20 }]); + }); + + it('does not repeat igcDataRequest until the data grows', async () => { + await openComboPopover(remote); + + remote.data = [...remote.data]; + await openComboPopover(remote); + + expect(details('igcDataRequest')).lengthOf(1); + + remote.data = [...remote.data, ...search('', 10, 5)]; + await comboStable(remote); + list.scrollToIndex(14); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 15, count: 20 }, + ]); + }); + + it('requests the next page of a new search with the same length', async () => { + await openComboPopover(remote); + + simulateInput(searchNative, { value: 'b' }); + remote.data = search('b'); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('requests the next page when the data is replaced without a search', async () => { + await openComboPopover(remote); + + remote.data = search(''); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('requests the same page again when the list opens again', async () => { + await openComboPopover(remote); + await remote.hide(); + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + + it('does not emit igcDataRequest in local mode, while closed or without data', async () => { + await comboStable(remote); + expect(details('igcDataRequest')).to.be.empty; + + remote.data = []; + await openComboPopover(remote); + expect(details('igcDataRequest')).to.be.empty; + + await remote.hide(); + remote.filteringOptions = { mode: 'local' }; + remote.data = search(''); + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.be.empty; + }); + + describe('Placeholders and loading', () => { + const placeholders = () => + items(remote).filter((item) => item.part.contains('placeholder')); + + const loadingPart = () => + remote.renderRoot.querySelector('[part="loading"]'); + + it('pads the list to `totalItemCount` with placeholder options in remote mode', async () => { + remote.totalItemCount = 12; + await openComboPopover(remote); + list.scrollToIndex(11); + await comboStable(remote); + + const [first, second] = placeholders(); + + expect(items(remote)).lengthOf(12); + expect(placeholders()).lengthOf(2); + expect(items(remote)[0].getAttribute('aria-setsize')).to.equal('12'); + expect(first.getAttribute('aria-posinset')).to.equal('11'); + expect(second.getAttribute('aria-posinset')).to.equal('12'); + expect(second.getAttribute('aria-setsize')).to.equal('12'); + expect(second.getAttribute('aria-disabled')).to.equal('true'); + expect(second.getAttribute('aria-label')).to.equal('Loading'); + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 2 }, + ]); + + await expect(remote).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + for (const total of [Number.POSITIVE_INFINITY, Number.NaN]) { + remote.totalItemCount = total; + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + } + + remote.totalItemCount = 12; + remote.filteringOptions = { mode: 'local' }; + await comboStable(remote); + + expect(items(remote)).lengthOf(10); + }); + + it('shows the empty message, not placeholders, for an empty data', async () => { + remote.data = []; + remote.totalItemCount = 12; + await openComboPopover(remote); + + expect(items(remote)).to.be.empty; + expect( + remote.renderRoot.querySelector('[part="empty"]')!.checkVisibility() + ).to.be.true; + }); + + it('requests more items when `totalItemCount` no longer stops them', async () => { + remote.totalItemCount = 10; + await openComboPopover(remote); + + expect(details('igcDataRequest')).to.be.empty; + + // No placeholders come, so the list reports no change. + remote.totalItemCount = undefined; + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + ]); + }); + + it('leaves the select-all option out of the requests and counts it in the positions', async () => { + remote.showSelectAll = true; + remote.totalItemCount = 12; + await openComboPopover(remote); + list.scrollToIndex(list.data.length - 1); + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 2 }, + ]); + expect(placeholders().at(-1)!.getAttribute('aria-posinset')).to.equal( + '13' + ); + + remote.selectAllItems(); + + expect(remote.value).to.eql(search('').map(({ id }) => id)); + }); + + it('requests the rendered placeholders, up to `totalItemCount`', async () => { + remote.totalItemCount = 100; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + await comboStable(remote); + + remote.data = [...remote.data, ...search('', 10, 5)]; + await comboStable(remote); + + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 15, count: 85 }, + ]); + + remote.totalItemCount = 20; + remote.data = [...remote.data, ...search('', 15, 5)]; + await comboStable(remote); + + expect(details('igcDataRequest')).lengthOf(2); + }); + + it('does not select a placeholder', async () => { + remote.totalItemCount = 12; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + simulateKeyboard(list, spaceBar); + simulateClick(placeholders().at(-1)!); + await elementUpdated(remote); + + expect(remote.value).to.be.empty; + + remote.selectAllItems(); + + expect(remote.value).to.eql(search('').map(({ id }) => id)); + }); + + it('keeps the single selection list open on Enter or a click on a placeholder', async () => { + remote.singleSelect = true; + remote.totalItemCount = 12; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + simulateKeyboard(list, enterKey); + simulateClick(placeholders().at(-1)!); + await elementUpdated(remote); + + expect(details('igcClosing')).to.be.empty; + expect(remote.open).to.be.true; + }); + + it('shows a spinner in place of the toggle icon and a loading message in an empty list', async () => { + const empty = + remote.renderRoot.querySelector('[part="empty"]')!; + const toggle = remote.renderRoot.querySelector( + '[part~="toggle-icon"]' + )!; + const icon = toggle.querySelector('igc-icon')!; + const { width } = toggle.getBoundingClientRect(); + + remote.data = []; + remote.loading = true; + await openComboPopover(remote); + + const spinner = loadingPart()!; + + expect(remote).to.have.attribute('loading'); + expect(toggle.contains(spinner)).to.be.true; + expect(spinner.checkVisibility()).to.be.true; + expect(icon.checkVisibility({ visibilityProperty: true })).to.be.false; + expect(toggle.getBoundingClientRect().width).to.equal(width); + const center = getCenterPoint(icon); + const { x, y } = getCenterPoint(spinner); + + expect(x).to.be.closeTo(center.x, 0.5); + expect(y).to.be.closeTo(center.y, 0.5); + expect(internalsOf(spinner)?.getARIA('role')).to.equal('progressbar'); + expect(spinner.getAttribute('aria-label')).to.equal('Loading'); + expect(empty.innerText).to.equal('Loading'); + + await expect(remote).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + remote.loading = false; + await elementUpdated(remote); + + expect(spinner.checkVisibility()).to.be.false; + expect(icon.checkVisibility({ visibilityProperty: true })).to.be.true; + expect(empty.innerText).to.equal('The list is empty'); + }); + + it('centers the ring of the spinner on the toggle icon, and removes the spinner after loading', async () => { + const icon = remote.renderRoot.querySelector( + '[part~="toggle-icon"] igc-icon' + )!; + + remote.loading = true; + await openComboPopover(remote); + + const ring = loadingPart()!.shadowRoot!.querySelector('[part~="svg"]')!; + const center = getCenterPoint(icon); + const { x, y } = getCenterPoint(ring); + + expect(x).to.be.closeTo(center.x, 0.5); + expect(y).to.be.closeTo(center.y, 0.5); + + remote.loading = false; + await elementUpdated(remote); + + expect(loadingPart()).to.be.null; + }); + + it('does not move the list when the spinner shows', async () => { + await openComboPopover(remote); + const { top } = list.getBoundingClientRect(); + + remote.loading = true; + await elementUpdated(remote); + + expect(list.getBoundingClientRect().top).to.equal(top); + }); + + it('takes the colors of the spinner and the skeletons from their parts', async () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync( + 'igc-combo::part(loading) { color: rgb(1, 2, 3); } igc-combo::part(placeholder) { color: rgb(4, 5, 6); }' + ); + const reference = await fixture( + html`
              ` + ); + + await withDocumentSheets([sheet], async () => { + remote.totalItemCount = 12; + remote.loading = true; + await openComboPopover(remote); + list.scrollToIndex(11); + await comboStable(remote); + + const placeholder = placeholders().at(-1)!; + const skeleton = placeholder.querySelector('span')!; + const box = placeholder.renderRoot + .querySelector('igc-checkbox')! + .renderRoot.querySelector('[part~="control"]')!; + + const stop = loadingPart()!.shadowRoot!.querySelector( + '[part~="gradient_start"]' + )!; + + expect(getComputedStyle(stop).stopColor).to.equal('rgb(1, 2, 3)'); + const { backgroundColor } = getComputedStyle(reference); + + expect(getComputedStyle(skeleton).backgroundColor).to.equal( + backgroundColor + ); + expect(getComputedStyle(box, '::after').backgroundColor).to.equal( + backgroundColor + ); + }); + }); + + it('replaces the spinner with the `loading` slot', async () => { + const custom = document.createElement('span'); + custom.slot = 'loading'; + remote.append(custom); + remote.loading = true; + await openComboPopover(remote); + + const slot = remote.renderRoot.querySelector( + 'slot[name="loading"]' + )!; + + expect(slot.assignedElements()).to.eql([custom]); + expect(custom.checkVisibility()).to.be.true; + expect(loadingPart()!.checkVisibility()).to.be.false; + }); + }); + }); +}); diff --git a/src/components/combo/combo-selection.spec.ts b/src/components/combo/combo-selection.spec.ts new file mode 100644 index 000000000..5b370b462 --- /dev/null +++ b/src/components/combo/combo-selection.spec.ts @@ -0,0 +1,978 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { type SinonSpy, spy } from 'sinon'; +import { internalsOf } from '#internals/controllers/internals.js'; +import { + arrowDown, + arrowUp, + enterKey, + escapeKey, + homeKey, + spaceBar, + tabKey, +} from '#internals/controllers/key-bindings.js'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + type City, + cities, + comboStable, + openComboPopover, + items, +} from '#internals/testing/combo.spec.js'; +import { + axeReflectedRelationsOptions, + isFocused, + suppressResizeObserverLoopError, + withDocumentSheets, +} from '#internals/testing/helpers.spec.js'; +import { + simulateClick, + simulateKeyboard, +} from '#internals/testing/simulate.spec.js'; +import { configureTheme } from '#theming/config.js'; +import type IgcButtonComponent from '../button/button.js'; +import type IgcInputComponent from '../input/input.js'; +import type IgcVirtualScrollComponent from '../virtualization/virtualization.js'; +import type IgcComboItemComponent from './combo-item.js'; +import IgcComboComponent from './combo.js'; + +describe('Combo', () => { + let input: IgcInputComponent; + let searchInput: IgcInputComponent; + + let combo: IgcComboComponent; + let list: IgcVirtualScrollComponent; + + before(() => { + defineComponents(IgcComboComponent); + suppressResizeObserverLoopError(); + }); + + describe('Select all methods', () => { + async function search(text: string): Promise { + searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); + await elementUpdated(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + }); + + it('selects and deselects the items that the list shows', async () => { + const eventSpy = spy(combo, 'emitEvent'); + + await search('o'); + combo.selectAllItems(); + + expect(combo.value).to.eql(['BG01', 'BG02', 'US01', 'US02', 'US03']); + + await search('s'); + combo.deselectAllItems(); + + expect(combo.value).to.eql(['BG02', 'US01']); + expect(eventSpy).not.calledWith('igcChange'); + }); + + it('selects and deselects all the items with `ignoreFilter`', async () => { + await search('sof'); + combo.selectAllItems(true); + + expect(combo.value).lengthOf(cities.length); + + combo.deselectAllItems(true); + + expect(combo.value).to.be.empty; + }); + + it('changes nothing when the list shows no items', async () => { + combo.select('BG01'); + await search('xyz'); + + combo.selectAllItems(); + combo.deselectAllItems(); + + expect(combo.value).to.eql(['BG01']); + }); + + it('acts on a new data at once', () => { + combo.data = cities.slice(0, 2); + combo.selectAllItems(); + + expect(combo.value).to.eql(['BG01', 'BG02']); + }); + + it('selects nothing in single selection, and deselects a shown item', async () => { + combo.singleSelect = true; + combo.select('US02'); + await elementUpdated(combo); + + combo.selectAllItems(); + expect(combo.value).to.eql(['US02']); + + combo.deselectAllItems(); + expect(combo.value).to.be.empty; + }); + }); + + describe('Select-all option', () => { + let eventSpy: SinonSpy; + + const selectAll = () => + list.querySelector('[part~="select-all"]'); + + const ariaOf = (item: Element) => ({ + selected: internalsOf(item)?.getARIA('ariaSelected'), + checked: internalsOf(item)?.getARIA('ariaChecked') ?? null, + }); + + // The cities whose name has an "o". + const ids = ['BG01', 'BG02', 'US01', 'US02', 'US03']; + + const changes = () => + eventSpy.withArgs('igcChange').args.map(([, { detail }]) => ({ + ...detail, + items: detail.items.map(({ id }: City) => id), + })); + + async function search(text: string): Promise { + searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); + await comboStable(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + await openComboPopover(combo); + }); + + it('comes first in multiple selection and counts as the first option', async () => { + const first = list.querySelector('[part~="item"], igc-combo-header'); + const [, option] = items(combo); + + expect(first).to.equal(selectAll()); + expect(first!.textContent?.trim()).to.equal('Select all'); + expect(ariaOf(first!)).to.eql({ selected: 'false', checked: null }); + expect(first!.hasAttribute('aria-checked')).to.be.false; + expect(first!.getAttribute('aria-posinset')).to.equal('1'); + expect(option.getAttribute('aria-posinset')).to.equal('2'); + expect(option.getAttribute('aria-setsize')).to.equal( + `${cities.length + 1}` + ); + + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + combo.singleSelect = true; + await comboStable(combo); + + expect(selectAll()).to.be.null; + }); + + it('does not show when the list shows no items', async () => { + await search('xyz'); + + expect(selectAll()).to.be.null; + expect( + combo.renderRoot.querySelector('[part="empty"]')!.checkVisibility() + ).to.be.true; + }); + + it('selects the shown items, then deselects them, with one igcChange each', async () => { + await search('o'); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(combo.value).to.eql(ids); + expect(ariaOf(selectAll()!).selected).to.equal('true'); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(combo.value).to.be.empty; + expect(changes()).to.eql([ + { newValue: ids, items: ids, type: 'selection' }, + { newValue: [], items: ids, type: 'deselection' }, + ]); + }); + + it('shows the mixed state and selects only the other shown items', async () => { + await search('o'); + const [first, ...others] = ids; + + combo.select(first); + await comboStable(combo); + + const checkbox = selectAll()!.renderRoot.querySelector('igc-checkbox')!; + const marginOf = (item: Element, part: string) => + getComputedStyle( + item + .shadowRoot!.querySelector('igc-checkbox')! + .renderRoot.querySelector(`[part~="${part}"]`)! + ).scrollMarginTop; + const style = document.createElement('style'); + style.textContent = + 'igc-combo::part(checkbox indeterminate), igc-combo::part(checkbox-indicator indeterminate) { scroll-margin-top: 7px; }'; + document.head.append(style); + + expect(ariaOf(selectAll()!).selected).to.equal('false'); + expect(checkbox.indeterminate).to.be.true; + expect(marginOf(selectAll()!, 'control')).to.equal('7px'); + expect(marginOf(selectAll()!, 'indicator')).to.equal('7px'); + expect(marginOf(items(combo)[1], 'control')).to.equal('0px'); + + style.remove(); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(changes().map(({ items }) => items)).to.eql([others]); + expect(ariaOf(selectAll()!).selected).to.equal('true'); + }); + + it('follows a new value while the list is open', async () => { + combo.value = ['BG01']; + await comboStable(combo); + + expect( + selectAll()!.renderRoot.querySelector('igc-checkbox')!.indeterminate + ).to.be.true; + expect(items(combo).filter((item) => item.selected)).lengthOf(1); + }); + + it('resets the active option when it shows or hides', async () => { + // The first city. Without the option, its index is the next city. + simulateKeyboard(list, homeKey); + simulateKeyboard(list, arrowDown); + await comboStable(combo); + + expect(items(combo).filter((item) => item.active)).lengthOf(1); + + combo.showSelectAll = false; + await comboStable(combo); + + expect(items(combo).some((item) => item.active)).to.be.false; + }); + + it('selects only the clicked item when the item has no value', async () => { + combo.data = [{ name: 'Nowhere', country: 'Other' } as City, ...cities]; + await comboStable(combo); + + simulateClick( + items(combo).find((item) => item.textContent?.trim() === 'Nowhere')! + ); + await comboStable(combo); + + expect(combo.selection.map(({ name }) => name)).to.eql(['Nowhere']); + }); + + it('keeps the selection when igcChange is canceled', async () => { + combo.addEventListener('igcChange', (event) => event.preventDefault()); + + simulateClick(selectAll()!); + await comboStable(combo); + + expect(combo.value).to.be.empty; + }); + + it('is a keyboard stop that Space toggles', async () => { + simulateKeyboard(list, homeKey); + simulateKeyboard(list, spaceBar); + await comboStable(combo); + + expect(combo.value).lengthOf(cities.length); + expect(selectAll()!.active).to.be.true; + + simulateKeyboard(list, arrowUp); + + expect(isFocused(searchInput)).to.be.true; + }); + }); + + describe('Group selection', () => { + let eventSpy: SinonSpy; + + const headers = () => [ + ...list.querySelectorAll('[part~="group-header"]'), + ]; + + const checkboxOf = (item: Element) => + item.shadowRoot!.querySelector('igc-checkbox')!; + + const changes = () => + eventSpy.withArgs('igcChange').args.map(([, { detail }]) => ({ + ...detail, + items: detail.items.map(({ id }: City) => id), + })); + + async function search(text: string): Promise { + searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); + await comboStable(combo); + } + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + await openComboPopover(combo); + }); + + it('renders the group headers as options with a checkbox in multiple selection', async () => { + const [bulgaria] = headers(); + const [first] = items(combo); + + expect(headers()).lengthOf(2); + expect(bulgaria.localName).to.equal('igc-combo-item'); + expect(bulgaria.textContent?.trim()).to.equal('Bulgaria'); + expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); + expect(checkboxOf(bulgaria)).to.exist; + // The two headers count as options. + expect(bulgaria.getAttribute('aria-posinset')).to.equal('1'); + expect(first.getAttribute('aria-posinset')).to.equal('2'); + expect(first.getAttribute('aria-setsize')).to.equal( + `${cities.length + 2}` + ); + + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + combo.singleSelect = true; + await comboStable(combo); + + expect(headers().map(({ localName }) => localName)).to.eql([ + 'igc-combo-header', + 'igc-combo-header', + ]); + expect(items(combo)[0].getAttribute('aria-posinset')).to.equal('1'); + expect(items(combo)[0].getAttribute('aria-setsize')).to.equal( + `${cities.length}` + ); + }); + + it('selects the shown items of the group, then deselects them, with one igcChange each', async () => { + await search('o'); + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'BG02']); + expect(internalsOf(headers()[0])?.getARIA('ariaSelected')).to.equal( + 'true' + ); + expect(combo.open).to.be.true; + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(combo.value).to.be.empty; + expect(changes()).to.eql([ + { + newValue: ['BG01', 'BG02'], + items: ['BG01', 'BG02'], + type: 'selection', + }, + { newValue: [], items: ['BG01', 'BG02'], type: 'deselection' }, + ]); + }); + + it('shows the mixed state and selects only the other items of the group', async () => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync( + 'igc-combo::part(checkbox indeterminate) { scroll-margin-top: 7px; }' + ); + + combo.select('BG01'); + await comboStable(combo); + + const [bulgaria, unitedStates] = headers(); + + await withDocumentSheets([sheet], () => { + const control = + checkboxOf(bulgaria).renderRoot.querySelector('[part~="control"]')!; + + expect(getComputedStyle(control).scrollMarginTop).to.equal('7px'); + }); + + expect(checkboxOf(bulgaria).indeterminate).to.be.true; + expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); + expect(checkboxOf(unitedStates).indeterminate).to.be.false; + + simulateClick(bulgaria); + await comboStable(combo); + + expect(changes().map(({ items }) => items)).to.eql([['BG02', 'BG03']]); + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + expect(checkboxOf(headers()[0]).checked).to.be.true; + }); + + it('stops on the group headers with the arrow keys and Home, and Space toggles them', async () => { + simulateKeyboard(list, homeKey); + simulateKeyboard(list, spaceBar); + await comboStable(combo); + + expect(headers()[0].active).to.be.true; + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + + for (let i = 0; i < 4; i++) { + simulateKeyboard(list, arrowDown); + } + await comboStable(combo); + + expect(headers()[1].active).to.be.true; + + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + }); + + it('works with the select-all option', async () => { + combo.showSelectAll = true; + await comboStable(combo); + + simulateClick(list.querySelector('[part~="select-all"]')!); + await comboStable(combo); + + expect( + headers().map((header) => internalsOf(header)?.getARIA('ariaSelected')) + ).to.eql(['true', 'true']); + }); + + it('lists an item that data holds twice one time in igcChange', async () => { + combo.data = [cities[0], ...cities]; + await comboStable(combo); + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(changes()[0].items).to.eql(['BG01', 'BG02', 'BG03']); + }); + + it('resets the active option when group selection turns off', async () => { + simulateKeyboard(list, homeKey); + await comboStable(combo); + + expect(headers()[0].active).to.be.true; + + combo.groupSelection = false; + await comboStable(combo); + + expect(headers()[0].localName).to.equal('igc-combo-header'); + expect(items(combo).some((item) => item.active)).to.be.false; + + simulateKeyboard(list, homeKey); + await comboStable(combo); + + expect(items(combo)[0].active).to.be.true; + }); + }); + + describe('Confirm mode', () => { + const other = { id: 'XX01', name: 'Elsewhere', country: '', zip: '' }; + let eventSpy: SinonSpy; + + const changes = (event: 'igcChange' | 'igcPendingChange' = 'igcChange') => + eventSpy.withArgs(event).args.map(([, { detail }]) => ({ + ...detail, + items: detail.items.map(({ id }: City) => id), + })); + + const selectedNames = () => + items(combo) + .filter((item) => item.selected) + .map((item) => item.textContent?.trim()); + + async function click(index: number): Promise { + simulateClick(items(combo)[index]); + await comboStable(combo); + } + + const actions = () => [ + ...combo.renderRoot.querySelectorAll( + '[part="actions"] igc-button' + ), + ]; + + beforeEach(async () => { + combo = await fixture>( + html`` + ); + input = combo.renderRoot.querySelector('igc-input#target')!; + searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; + list = combo.renderRoot.querySelector('igc-virtual-scroll')!; + eventSpy = spy(combo, 'emitEvent'); + await openComboPopover(combo); + }); + + it('stages the toggles in the open list and keeps the value', async () => { + await click(1); + await click(0); + + expect(selectedNames()).to.eql(['Plovdiv']); + expect(combo.pendingValue).to.eql(['BG02']); + expect(combo.value).to.eql(['BG01']); + expect(combo.selection.map(({ id }) => id)).to.eql(['BG01']); + expect(input.value).to.equal('Sofia'); + expect(changes()).to.be.empty; + expect(changes('igcPendingChange')).to.eql([ + { newValue: ['BG01', 'BG02'], items: ['BG02'], type: 'selection' }, + { newValue: ['BG02'], items: ['BG01'], type: 'deselection' }, + ]); + }); + + it('applies the pending selection on Enter with one igcChange', async () => { + simulateKeyboard(list, arrowDown); + simulateKeyboard(list, spaceBar); + simulateKeyboard(list, arrowDown); + simulateKeyboard(list, spaceBar); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG02']); + expect(input.value).to.equal('Plovdiv'); + expect(changes()).to.eql([ + { newValue: ['BG02'], items: ['BG02', 'BG01'], type: 'commit' }, + ]); + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger: 'enter' }, + }); + }); + + it('applies the pending selection on Enter in the search input and without an active option', async () => { + // A handler that changes `newValue` does not change `value`. + combo.addEventListener( + 'igcChange', + ({ detail }) => detail.newValue.push('US01'), + { once: true } + ); + + await click(1); + simulateKeyboard(searchInput, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG01', 'BG02']); + + await openComboPopover(combo); + combo.select('BG03'); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); + expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); + }); + + it('closes on Enter without igcChange when nothing changes', async () => { + await click(1); + await click(1); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(changes()).to.be.empty; + }); + + it('keeps the list open with the pending selection when igcChange is canceled', async () => { + combo.addEventListener('igcChange', (event) => event.preventDefault(), { + once: true, + }); + + await click(1); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.value).to.eql(['BG01']); + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + }); + + it('keeps the pending selection when igcPendingChange is canceled', async () => { + combo.addEventListener( + 'igcPendingChange', + (event) => event.preventDefault(), + { once: true } + ); + + await click(1); + + expect(combo.pendingValue).to.eql(['BG01']); + expect(selectedNames()).to.eql(['Sofia']); + }); + + it('drops the pending selection on the other closes', async () => { + const closes = [ + () => simulateKeyboard(list, escapeKey), + () => simulateClick(input), + () => combo.hide(), + ]; + + for (const close of closes) { + await openComboPopover(combo); + await click(1); + + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + + await close(); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.pendingValue).to.eql(['BG01']); + } + + await openComboPopover(combo); + + expect(selectedNames()).to.eql(['Sofia']); + expect(changes()).to.be.empty; + + await click(1); + combo.open = false; + await comboStable(combo); + combo.open = true; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['BG01']); + }); + + it('stages the selection methods, and commitSelection() applies them without events', async () => { + combo.select('BG02'); + combo.deselect('BG01'); + await comboStable(combo); + + expect(selectedNames()).to.eql(['Plovdiv']); + expect(combo.pendingValue).to.eql(['BG02']); + + combo.selectAllItems(); + expect(combo.pendingValue).lengthOf(cities.length); + + combo.deselect(); + expect(combo.pendingValue).to.be.empty; + + combo.select(other); + combo.select(); + expect(combo.pendingValue).lengthOf(cities.length + 1); + + combo.deselect('XX01'); + expect(combo.pendingValue).lengthOf(cities.length); + + combo.deselectAllItems(); + combo.select(['BG03', other]); + expect(combo.value).to.eql(['BG01']); + + combo.commitSelection(); + await comboStable(combo); + + expect(combo.value).to.eql(['BG03', 'XX01']); + expect(input.value).to.equal('Varna, Elsewhere'); + expect(combo.open).to.be.true; + expect(selectedNames()).to.eql(['Varna']); + expect(eventSpy).not.calledWith('igcChange'); + expect(eventSpy).not.calledWith('igcPendingChange'); + }); + + it('keeps the pending selection on the same value, and puts the value back with cancelSelection() and a new value', async () => { + await click(1); + combo.value = ['BG01']; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + + combo.cancelSelection(); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.pendingValue).to.eql(['BG01']); + expect(selectedNames()).to.eql(['Sofia']); + + await click(1); + combo.value = ['US01']; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['US01']); + expect(selectedNames()).to.eql(['New York']); + }); + + it('clears the value and the pending selection with the clear icon', async () => { + await click(1); + simulateClick(combo.renderRoot.querySelector('[part="clear-icon"]')!); + await comboStable(combo); + + expect(combo.value).to.be.empty; + expect(combo.pendingValue).to.be.empty; + expect(selectedNames()).to.be.empty; + expect(changes()).to.eql([ + { newValue: [], items: ['BG01'], type: 'deselection' }, + ]); + }); + + it('gives the pending selection the records of its values in a new data', async () => { + await click(1); + + combo.data = cities.map((city) => ({ ...city })); + await comboStable(combo); + + expect(selectedNames()).to.eql(['Sofia', 'Plovdiv']); + + await click(1); + + expect(combo.pendingValue).to.eql(['BG01']); + }); + + it('keeps the values of value without a record, and resolves them with select()', async () => { + combo.value = ['BG01', 'XX01']; + await click(1); + + expect(combo.value).to.eql(['BG01', 'XX01']); + + combo.select(other); + await comboStable(combo); + + expect(input.value).to.equal('Sofia, Elsewhere'); + expect(combo.value).to.eql(['BG01', 'XX01']); + expect(combo.pendingValue).to.eql(['BG01', 'BG02', 'XX01']); + + // A second record of the value does not join the selection. + combo.deselect('XX01'); + combo.select({ ...other }); + await comboStable(combo); + + expect(input.value).to.equal('Sofia, Elsewhere'); + + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(changes().map(({ items }) => items)).to.eql([['BG02']]); + expect(combo.selection.map(({ id }) => id)).to.eql([ + 'BG01', + 'BG02', + 'XX01', + ]); + }); + + it('acts on the value right after a close, and on the pending selection right after an opening', async () => { + void combo.hide(); + combo.deselect('BG01'); + + expect(combo.value).to.be.empty; + + void combo.show(); + combo.select('BG02'); + + expect(combo.value).to.be.empty; + expect(combo.pendingValue).to.eql(['BG02']); + }); + + it('gives the pending selection the records of a new data for the values of value', async () => { + const later = { id: 'XX02', name: 'Later', country: '', zip: '' }; + + combo.value = ['BG01', 'XX02']; + await click(1); + + combo.data = [...cities, later]; + await comboStable(combo); + + expect(combo.pendingValue).to.eql(['BG01', 'BG02', 'XX02']); + + await click(1); + simulateKeyboard(list, enterKey); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'XX02']); + expect(changes()).to.be.empty; + }); + + it('stages the select-all option and the group header options', async () => { + combo.showSelectAll = true; + combo.groupKey = 'country'; + combo.groupSelection = true; + await comboStable(combo); + + const selectAll = list.querySelector( + '[part~="select-all"]' + )!; + + simulateClick(selectAll); + await comboStable(combo); + + expect(combo.pendingValue).to.have.members(cities.map(({ id }) => id)); + expect(selectAll.selected).to.be.true; + + simulateClick(list.querySelector('[part~="group-header"]')!); + await comboStable(combo); + + expect(combo.pendingValue).to.have.members(['US01', 'US02', 'US03']); + expect(combo.value).to.eql(['BG01']); + expect(changes()).to.be.empty; + expect(changes('igcPendingChange').map(({ type }) => type)).to.eql([ + 'selection', + 'deselection', + ]); + }); + + it('renders Cancel and Apply as the default footer content', async () => { + const [cancel, apply] = actions(); + + expect(cancel.textContent?.trim()).to.equal('Cancel'); + expect(apply.textContent?.trim()).to.equal('Apply'); + expect(cancel.getAttribute('part')).to.equal('cancel-button'); + expect(apply.getAttribute('part')).to.equal('apply-button'); + await expect(combo).shadowDom.to.be.accessible( + axeReflectedRelationsOptions + ); + + combo.resourceStrings = { combo_apply: 'OK' }; + await comboStable(combo); + + expect(apply.textContent?.trim()).to.equal('OK'); + + try { + configureTheme('indigo'); + await comboStable(combo); + + expect([cancel.variant, apply.variant]).to.eql([ + 'outlined', + 'contained', + ]); + } finally { + configureTheme('bootstrap'); + } + + const footer = document.createElement('div'); + footer.slot = 'footer'; + combo.append(footer); + await comboStable(combo); + + expect(apply.checkVisibility()).to.be.false; + + footer.remove(); + await comboStable(combo); + + expect(apply.checkVisibility()).to.be.true; + + await combo.hide(); + + expect(actions()).to.be.empty; + + await openComboPopover(combo); + combo.selectionCommit = 'immediate'; + await comboStable(combo); + + expect(actions()).to.be.empty; + }); + + it('applies the pending selection with Apply and closes the list', async () => { + combo.addEventListener('igcChange', (event) => event.preventDefault(), { + once: true, + }); + + await click(1); + simulateClick(actions()[1]); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.value).to.eql(['BG01']); + + simulateClick(actions()[1]); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.value).to.eql(['BG01', 'BG02']); + expect(isFocused(input)).to.be.true; + expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger: 'apply' }, + }); + }); + + it('drops the pending selection with Cancel and closes the list', async () => { + combo.addEventListener('igcClosing', (event) => event.preventDefault(), { + once: true, + }); + + await click(1); + simulateClick(actions()[0]); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.pendingValue).to.eql(['BG01']); + + await click(1); + eventSpy.resetHistory(); + simulateClick(actions()[0]); + await comboStable(combo); + + expect(combo.open).to.be.false; + expect(combo.pendingValue).to.eql(['BG01']); + expect(changes()).to.be.empty; + expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { + detail: { trigger: 'cancel' }, + }); + }); + + it('moves with Tab from the list to Cancel and Apply, and then closes the list', async () => { + const [cancel, apply] = actions(); + + list.focus(); + await sendKeys({ press: tabKey }); + + expect(isFocused(cancel)).to.be.true; + + await sendKeys({ press: tabKey }); + + expect(isFocused(apply)).to.be.true; + + await sendKeys({ press: tabKey }); + await comboStable(combo); + + expect(combo.open).to.be.false; + }); + + it('applies each change at once in single selection', async () => { + combo.singleSelect = true; + await comboStable(combo); + + await click(1); + + expect(combo.value).to.eql(['BG02']); + expect(combo.pendingValue).to.eql(['BG02']); + expect(changes().map(({ type }) => type)).to.eql(['selection']); + expect(actions()).to.be.empty; + }); + }); +}); diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 8d3668d11..b30ac4eb9 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -1,15 +1,7 @@ -import { - elementUpdated, - expect, - fixture, - html, - nextFrame, -} from '@open-wc/testing'; +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { resetMouse, sendKeys, sendMouse } from '@web/test-runner-commands'; import { setCurrentI18n } from 'igniteui-i18n-core'; import { match, type SinonSpy, spy } from 'sinon'; - -import { internalsOf } from '#internals/controllers/internals.js'; import { altKey, arrowDown, @@ -24,7 +16,14 @@ import { } from '#internals/controllers/key-bindings.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; import { - createFormAssociatedTestBed, + type City, + cities, + comboStable, + openComboPopover, + items, + layoutComplete, +} from '#internals/testing/combo.spec.js'; +import { runAriaProjectionTests, runExternalLabelAssociationTests, } from '#internals/testing/form-testbed.spec.js'; @@ -32,21 +31,15 @@ import { axeReflectedRelationsOptions, isFocused, suppressResizeObserverLoopError, - withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { simulateBlur, simulateClick, - simulateInput, simulateKeyboard, simulatePointerDown, simulateScroll, } from '#internals/testing/simulate.spec.js'; -import { - runValidationContainerTests, - type ValidationContainerTestsParams, - ValidityHelpers, -} from '#internals/testing/validity-helpers.spec.js'; +import { ValidityHelpers } from '#internals/testing/validity-helpers.spec.js'; import { firstOf } from '#internals/utils/arrays.js'; import { getCenterPoint } from '#internals/utils/dom.js'; import { configureTheme } from '#theming/config.js'; @@ -54,60 +47,13 @@ import IgcButtonComponent from '../button/button.js'; import type IgcInputComponent from '../input/input.js'; import type IgcVirtualScrollComponent from '../virtualization/virtualization.js'; import type IgcComboHeaderComponent from './combo-header.js'; -import type IgcComboItemComponent from './combo-item.js'; import IgcComboComponent from './combo.js'; import type { ComboCloseTrigger, ComboItemTemplate } from './types.js'; describe('Combo', () => { - type City = { - id: string; - name: string; - country: string; - zip: string; - }; - let input: IgcInputComponent; let searchInput: IgcInputComponent; - const cities: City[] = [ - { - id: 'BG01', - name: 'Sofia', - country: 'Bulgaria', - zip: '1000', - }, - { - id: 'BG02', - name: 'Plovdiv', - country: 'Bulgaria', - zip: '4000', - }, - { - id: 'BG03', - name: 'Varna', - country: 'Bulgaria', - zip: '9000', - }, - { - id: 'US01', - name: 'New York', - country: 'United States', - zip: '10001', - }, - { - id: 'US02', - name: 'Boston', - country: 'United States', - zip: '02108', - }, - { - id: 'US03', - name: 'San Francisco', - country: 'United States', - zip: '94103', - }, - ]; - const citiesWithDiacritics = [ { id: 'US01', @@ -183,34 +129,6 @@ describe('Combo', () => { let list: IgcVirtualScrollComponent; let options: IgcVirtualScrollComponent; - async function comboStable(combo: IgcComboComponent) { - await elementUpdated(combo); - await nextFrame(); - await layoutComplete(combo); - } - - async function openComboPopover( - combo: IgcComboComponent - ) { - await combo.show(); - await nextFrame(); - await layoutComplete(combo); - } - - const items = (combo: IgcComboComponent) => - [ - ...combo.renderRoot - .querySelector('igc-virtual-scroll')! - .querySelectorAll('[part~="item"]'), - ] as IgcComboItemComponent[]; - - const layoutComplete = (combo: IgcComboComponent) => - ( - combo.renderRoot.querySelector( - 'igc-virtual-scroll' - ) as IgcVirtualScrollComponent - ).layoutComplete; - const filterCombo = async (term: string) => { input.dispatchEvent(new CustomEvent('igcInput', { detail: term })); await elementUpdated(combo); @@ -2352,2100 +2270,195 @@ describe('Combo', () => { }); }); - describe('Remote data', () => { - type Place = { id: string; name: string; letter: string }; - - const PLACES = [ - ['AMS', 'Amsterdam'], - ['ATH', 'Athens'], - ['BAR', 'Barcelona'], - ['BEL', 'Belgrade'], - ['BRA', 'Bratislava'], - ['BRU', 'Brussels'], - ['BUC', 'Bucharest'], - ['BUD', 'Budapest'], - ['CPH', 'Copenhagen'], - ['DUB', 'Dublin'], - ['BER', 'Berlin'], - ['BRN', 'Bern'], - ['BGO', 'Bergen'], - ['ALB', 'Alberobello'], - ['HEL', 'Helsinki'], - ['LIS', 'Lisbon'], - ['LON', 'London'], - ['MAD', 'Madrid'], - ['OSL', 'Oslo'], - ['PAR', 'Paris'], - ] as const; - - /** A fake server. Each response has new objects, as parsed JSON has. */ - function search(text: string, skip = 0, top = 10): Place[] { - return PLACES.filter(([, name]) => - name.toLowerCase().includes(text.toLowerCase()) - ) - .slice(skip, skip + top) - .map(([id, name]) => ({ id, name, letter: name[0] })); - } - - let remote: IgcComboComponent; - let eventSpy: SinonSpy; - let searchNative: HTMLInputElement; - let mainNative: HTMLInputElement; - - const checked = () => - items(remote) - .filter((item) => item.selected) - .map((item) => item.textContent?.trim()); - - const itemFor = (name: string) => - items(remote).find((item) => item.textContent?.trim() === name)!; - - const details = (name: string) => - eventSpy.withArgs(name).args.map(([, init]) => init.detail); - - const searchEvents = () => - details('igcSearchInput').map(({ searchText }) => searchText); + describe('Scroll strategy', () => { + let container: HTMLDivElement; beforeEach(async () => { - remote = await fixture>( - html`` - ); - remote.setAttribute('filtering-options', '{"mode": "remote"}'); - await elementUpdated(remote); - - input = remote.renderRoot.querySelector('igc-input#target')!; - searchInput = remote.renderRoot.querySelector('[part="search-input"]')!; - list = remote.renderRoot.querySelector('igc-virtual-scroll')!; - mainNative = input.renderRoot.querySelector('input')!; - searchNative = searchInput.renderRoot.querySelector('input')!; - eventSpy = spy(remote, 'emitEvent'); - }); - - it('takes the mode from the `filtering-options` attribute', () => { - expect(remote.filteringOptions.mode).to.equal('remote'); - expect(remote.filteringOptions.filterKey).to.equal('name'); - }); - - it('emits igcSearchInput when the user changes the search text', async () => { - await openComboPopover(remote); - - simulateInput(searchNative, { value: 'ber' }); - simulateInput(searchNative, { value: '' }); - - expect(searchEvents()).to.eql(['ber', '']); - }); - - it('does not filter the options in remote mode', async () => { - await openComboPopover(remote); - - simulateInput(searchNative, { value: 'ber' }); - await comboStable(remote); - - expect(items(remote)).lengthOf(10); - expect(searchInput.value).to.equal('ber'); - - remote.filteringOptions = { mode: 'local' }; - await comboStable(remote); - - expect(items(remote)).lengthOf(0); - }); - - it('emits igcSearchInput in local mode too', async () => { - remote.filteringOptions = { mode: 'local' }; - await openComboPopover(remote); - - simulateInput(searchNative, { value: 'bu' }); - - expect(searchEvents()).to.eql(['bu']); - }); - - it('emits igcSearchInput from the main input in single selection, also when it is cleared', async () => { - remote.singleSelect = true; - await elementUpdated(remote); - - simulateInput(mainNative, { value: 'ber' }); - await comboStable(remote); - - expect(items(remote)).lengthOf(10); - - simulateInput(mainNative, { value: '' }); - await elementUpdated(remote); - - expect(searchEvents()).to.eql(['ber', '']); - }); - - it('emits igcSearchInput when a user action clears the search text, not on a property change', async () => { - remote.singleSelect = true; - remote.filteringOptions = { mode: 'local' }; - await elementUpdated(remote); - - simulateInput(mainNative, { value: 'a' }); - await comboStable(remote); - - simulateKeyboard(list, enterKey); - await elementUpdated(remote); - expect(remote.value).to.eql(['AMS']); - - simulateInput(mainNative, { value: 'b' }); - await elementUpdated(remote); - remote.disableFiltering = true; - await elementUpdated(remote); - - simulateInput(mainNative, { value: 'c' }); - await elementUpdated(remote); - - expect(searchEvents()).to.eql(['a', '', 'b']); - }); - - it('clears the search input and reports it when a blur clears the search text', async () => { - await openComboPopover(remote); - - simulateInput(searchNative, { value: 'xyz' }); - await elementUpdated(remote); - - simulateBlur(remote); - await elementUpdated(remote); - - expect(searchInput.value).to.equal(''); - expect(searchEvents()).to.eql(['xyz', '']); + container = await fixture(html` +
              + +
              + `); + combo = container.querySelector>( + IgcComboComponent.tagName + )!; }); - it('activates no option of the old results on input in single selection', async () => { - remote.singleSelect = true; - await elementUpdated(remote); - - simulateInput(mainNative, { value: 'sof' }); - await comboStable(remote); - - simulateKeyboard(input, enterKey); - await elementUpdated(remote); + it('`scroll` behavior', async () => { + combo.scrollStrategy = 'scroll'; + await openComboPopover(combo); + await simulateScroll(container, { top: 200 }); - expect(items(remote).some((item) => item.active)).to.be.false; - expect(remote.value).to.be.empty; + expect(combo.open).to.be.true; }); - it('hides the case-sensitive icon in remote mode', async () => { - remote.caseSensitiveIcon = true; - await elementUpdated(remote); - - const icon = searchInput.querySelector('[part~="case-icon"]')!; - expect(icon.slot).to.be.empty; + it('`close` behavior', async () => { + const eventSpy = spy(combo, 'emitEvent'); - remote.filteringOptions = { mode: 'local' }; - await elementUpdated(remote); + combo.scrollStrategy = 'close'; + await openComboPopover(combo); + await simulateScroll(container, { top: 200 }); - expect(icon.slot).to.equal('suffix'); + expect(combo.open).to.be.false; + expect(eventSpy.firstCall).calledWith('igcClosing'); + expect(eventSpy.lastCall).calledWith('igcClosed'); }); - it('keeps the igcInput of the inputs inside', async () => { - const inputSpy = spy(); - remote.addEventListener('igcInput', inputSpy); - await openComboPopover(remote); - - simulateInput(searchNative, { value: 'ber' }); + it('reports the `scroll` trigger with the `close` strategy', async () => { + const eventSpy = spy(combo, 'emitEvent'); - remote.singleSelect = true; - await elementUpdated(remote); - simulateInput(mainNative, { value: 'ber' }); + combo.scrollStrategy = 'close'; + await openComboPopover(combo); + await simulateScroll(container, { top: 200 }); - expect(inputSpy).not.called; + expect(eventSpy).calledWith('igcClosing', { + cancelable: true, + detail: { trigger: 'scroll' }, + }); + expect(eventSpy).calledWith('igcClosed', { + detail: { trigger: 'scroll' }, + }); }); + }); - it('keeps the selection when a search replaces the data (issue 2519)', async () => { - await openComboPopover(remote); - - // 1. Check Amsterdam and Athens. - simulateClick(itemFor('Amsterdam')); - simulateClick(itemFor('Athens')); - await comboStable(remote); - expect(remote.value).to.eql(['AMS', 'ATH']); - - // 2. A search for "ber" replaces the data. - remote.data = search('ber'); - await comboStable(remote); - - expect(remote.value).to.eql(['AMS', 'ATH']); - expect(remote.selection.map(({ id }) => id)).to.eql(['AMS', 'ATH']); - expect(input.value).to.equal('Amsterdam, Athens'); + describe('Theming', () => { + function hasListStyleSheet(): boolean { + return Array.from(combo.shadowRoot!.adoptedStyleSheets).some((sheet) => + Array.from(sheet.cssRules).some((rule) => + rule.cssText.startsWith(':where(igc-virtual-scroll)') + ) + ); + } - // 3. Check Berlin. - simulateClick(itemFor('Berlin')); - await comboStable(remote); - expect(remote.value).to.eql(['AMS', 'ATH', 'BER']); + it('keeps the style sheet of the list on a theme change', async () => { + combo = await fixture>(html` + + `); + expect(hasListStyleSheet()).to.be.true; - // 4. Clearing the search loads the first page again. - remote.data = search(''); - await comboStable(remote); + try { + configureTheme('material'); + await elementUpdated(combo); - expect(remote.value).to.eql(['AMS', 'ATH', 'BER']); - expect(remote.selection.map(({ id }) => id)).to.eql([ - 'AMS', - 'ATH', - 'BER', - ]); - expect(remote.selection[0]).to.equal(remote.data[0]); - expect(checked()).to.eql(['Amsterdam', 'Athens']); - expect(input.value).to.equal('Amsterdam, Athens, Berlin'); + expect(hasListStyleSheet()).to.be.true; + } finally { + configureTheme('bootstrap'); + } }); + }); - it('deselects a selected item that the data does not hold', async () => { - remote.select(['AMS', 'ATH']); - remote.data = search('ber'); - await elementUpdated(remote); - - remote.deselect('AMS'); - await elementUpdated(remote); + describe('ARIA', () => { + beforeEach(async () => { + combo = await fixture>( + html`` + ); - expect(remote.value).to.eql(['ATH']); - expect(input.value).to.equal('Athens'); + options = combo.renderRoot.querySelector( + '[part="list"]' + ) as IgcVirtualScrollComponent; + input = combo.renderRoot.querySelector( + 'igc-input#target' + ) as IgcInputComponent; }); - it('selects an object that the data does not hold', async () => { - const oslo = { id: 'OSL', name: 'Oslo', letter: 'O' }; - - remote.select(oslo); - await elementUpdated(remote); - - expect(remote.value).to.eql(['OSL']); - expect(remote.selection).to.eql([oslo]); - expect(input.value).to.equal('Oslo'); - - remote.data = search('oslo'); - await openComboPopover(remote); - - expect(remote.selection[0]).to.equal(remote.data[0]); - expect(checked()).to.eql(['Oslo']); - }); + it('names the input with the selection status only while no label names it', async () => { + const native = input.renderRoot.querySelector('input')!; - it('selects an object that the data does not hold without a value key', async () => { - const oslo = { id: 'OSL', name: 'Oslo', letter: 'O' }; + expect(native.getAttribute('aria-label')).to.equal( + combo.resourceStrings.combo_aria_label_no_options + ); - remote.valueKey = undefined; - remote.select(oslo); - await elementUpdated(remote); + combo.label = 'Cities'; + await elementUpdated(combo); + await elementUpdated(input); - expect(remote.value).to.eql([oslo]); - expect(remote.selection).to.eql([oslo]); + expect(native.hasAttribute('aria-label')).to.be.false; }); - it('selects all the data and keeps the other selected items with select()', async () => { - remote.select('AMS'); - remote.data = search('ber'); - await elementUpdated(remote); + it('leaves the disabled state to the native input', async () => { + const native = input.renderRoot.querySelector('input')!; - remote.select(); - await elementUpdated(remote); + combo.disabled = true; + await elementUpdated(combo); + await elementUpdated(input); - expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB', 'AMS']); + expect(native.disabled).to.be.true; + expect(native.hasAttribute('aria-disabled')).to.be.false; }); - it('selects each value one time with select() before a new data syncs', async () => { - remote.data = search('ber'); - remote.value = ['BER']; - remote.data = search('ber'); - remote.select(); - await elementUpdated(remote); - - expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB']); - }); + it('should report posinset/setsize excluding group headers', async () => { + await openComboPopover(combo); - it('emits igcDataRequest near the end of the data, in data coordinates', async () => { - remote.groupKey = 'letter'; - await openComboPopover(remote); + const rendered = items(combo); + expect(rendered).lengthOf(cities.length); + expect(headerItems(combo)).lengthOf(2); - expect(details('igcDataRequest')).to.eql([{ startIndex: 10, count: 20 }]); + for (const [index, item] of rendered.entries()) { + expect(item.getAttribute('aria-posinset')).to.equal(`${index + 1}`); + expect(item.getAttribute('aria-setsize')).to.equal(`${cities.length}`); + } }); - it('does not repeat igcDataRequest until the data grows', async () => { - await openComboPopover(remote); - - remote.data = [...remote.data]; - await openComboPopover(remote); - - expect(details('igcDataRequest')).lengthOf(1); + it('should renumber posinset/setsize when the options are filtered', async () => { + await openComboPopover(combo); + await filterCombo('a'); - remote.data = [...remote.data, ...search('', 10, 5)]; - await comboStable(remote); - list.scrollToIndex(14); - await comboStable(remote); + const rendered = items(combo); + expect(rendered.length).to.be.lessThan(cities.length); - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 20 }, - { startIndex: 15, count: 20 }, - ]); + for (const [index, item] of rendered.entries()) { + expect(item.getAttribute('aria-posinset')).to.equal(`${index + 1}`); + expect(item.getAttribute('aria-setsize')).to.equal( + `${rendered.length}` + ); + } }); - it('requests the next page of a new search with the same length', async () => { - await openComboPopover(remote); + it('prefixes the option ids with the id of the combo', async () => { + combo.id = 'cities'; + await openComboPopover(combo); - simulateInput(searchNative, { value: 'b' }); - remote.data = search('b'); - await comboStable(remote); + const ids = items(combo).map((item) => item.id); - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 20 }, - { startIndex: 10, count: 20 }, - ]); + expect(ids).to.not.be.empty; + expect(new Set(ids).size).to.equal(ids.length); + for (const id of ids) { + expect(id).to.match(/^cities-item-\d+$/); + } }); - it('requests the next page when the data is replaced without a search', async () => { - await openComboPopover(remote); - - remote.data = search(''); - await comboStable(remote); - - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 20 }, - { startIndex: 10, count: 20 }, - ]); - }); + it('should point aria-activedescendant at the active option and drop it on close', async () => { + await openComboPopover(combo); - it('requests the same page again when the list opens again', async () => { - await openComboPopover(remote); - await remote.hide(); - await openComboPopover(remote); + expect(options.getAttribute('aria-activedescendant')).to.be.null; - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 20 }, - { startIndex: 10, count: 20 }, - ]); - }); + simulateKeyboard(options, arrowDown); + await comboStable(combo); - it('does not emit igcDataRequest in local mode, while closed or without data', async () => { - await comboStable(remote); - expect(details('igcDataRequest')).to.be.empty; + const active = items(combo).find((item) => item.active)!; + expect(active).to.not.be.undefined; + expect(options.getAttribute('aria-activedescendant')).to.equal(active.id); - remote.data = []; - await openComboPopover(remote); - expect(details('igcDataRequest')).to.be.empty; - - await remote.hide(); - remote.filteringOptions = { mode: 'local' }; - remote.data = search(''); - await openComboPopover(remote); - - expect(details('igcDataRequest')).to.be.empty; - }); - - describe('Placeholders and loading', () => { - const placeholders = () => - items(remote).filter((item) => item.part.contains('placeholder')); - - const loadingPart = () => - remote.renderRoot.querySelector('[part="loading"]'); - - it('pads the list to `totalItemCount` with placeholder options in remote mode', async () => { - remote.totalItemCount = 12; - await openComboPopover(remote); - list.scrollToIndex(11); - await comboStable(remote); - - const [first, second] = placeholders(); - - expect(items(remote)).lengthOf(12); - expect(placeholders()).lengthOf(2); - expect(items(remote)[0].getAttribute('aria-setsize')).to.equal('12'); - expect(first.getAttribute('aria-posinset')).to.equal('11'); - expect(second.getAttribute('aria-posinset')).to.equal('12'); - expect(second.getAttribute('aria-setsize')).to.equal('12'); - expect(second.getAttribute('aria-disabled')).to.equal('true'); - expect(second.getAttribute('aria-label')).to.equal('Loading'); - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 2 }, - ]); - - await expect(remote).shadowDom.to.be.accessible( - axeReflectedRelationsOptions - ); - - for (const total of [Number.POSITIVE_INFINITY, Number.NaN]) { - remote.totalItemCount = total; - await comboStable(remote); - - expect(items(remote)).lengthOf(10); - } - - remote.totalItemCount = 12; - remote.filteringOptions = { mode: 'local' }; - await comboStable(remote); - - expect(items(remote)).lengthOf(10); - }); - - it('shows the empty message, not placeholders, for an empty data', async () => { - remote.data = []; - remote.totalItemCount = 12; - await openComboPopover(remote); - - expect(items(remote)).to.be.empty; - expect( - remote.renderRoot.querySelector('[part="empty"]')!.checkVisibility() - ).to.be.true; - }); - - it('requests more items when `totalItemCount` no longer stops them', async () => { - remote.totalItemCount = 10; - await openComboPopover(remote); - - expect(details('igcDataRequest')).to.be.empty; - - // No placeholders come, so the list reports no change. - remote.totalItemCount = undefined; - await comboStable(remote); - - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 20 }, - ]); - }); - - it('leaves the select-all option out of the requests and counts it in the positions', async () => { - remote.showSelectAll = true; - remote.totalItemCount = 12; - await openComboPopover(remote); - list.scrollToIndex(list.data.length - 1); - await comboStable(remote); - - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 2 }, - ]); - expect(placeholders().at(-1)!.getAttribute('aria-posinset')).to.equal( - '13' - ); - - remote.selectAllItems(); - - expect(remote.value).to.eql(search('').map(({ id }) => id)); - }); - - it('requests the rendered placeholders, up to `totalItemCount`', async () => { - remote.totalItemCount = 100; - await openComboPopover(remote); - - simulateKeyboard(list, endKey); - await comboStable(remote); - - remote.data = [...remote.data, ...search('', 10, 5)]; - await comboStable(remote); - - expect(details('igcDataRequest')).to.eql([ - { startIndex: 10, count: 20 }, - { startIndex: 15, count: 85 }, - ]); - - remote.totalItemCount = 20; - remote.data = [...remote.data, ...search('', 15, 5)]; - await comboStable(remote); - - expect(details('igcDataRequest')).lengthOf(2); - }); - - it('does not select a placeholder', async () => { - remote.totalItemCount = 12; - await openComboPopover(remote); - - simulateKeyboard(list, endKey); - simulateKeyboard(list, spaceBar); - simulateClick(placeholders().at(-1)!); - await elementUpdated(remote); - - expect(remote.value).to.be.empty; - - remote.selectAllItems(); - - expect(remote.value).to.eql(search('').map(({ id }) => id)); - }); - - it('keeps the single selection list open on Enter or a click on a placeholder', async () => { - remote.singleSelect = true; - remote.totalItemCount = 12; - await openComboPopover(remote); - - simulateKeyboard(list, endKey); - simulateKeyboard(list, enterKey); - simulateClick(placeholders().at(-1)!); - await elementUpdated(remote); - - expect(details('igcClosing')).to.be.empty; - expect(remote.open).to.be.true; - }); - - it('shows a spinner in place of the toggle icon and a loading message in an empty list', async () => { - const empty = - remote.renderRoot.querySelector('[part="empty"]')!; - const toggle = remote.renderRoot.querySelector( - '[part~="toggle-icon"]' - )!; - const icon = toggle.querySelector('igc-icon')!; - const { width } = toggle.getBoundingClientRect(); - - remote.data = []; - remote.loading = true; - await openComboPopover(remote); - - const spinner = loadingPart()!; - - expect(remote).to.have.attribute('loading'); - expect(toggle.contains(spinner)).to.be.true; - expect(spinner.checkVisibility()).to.be.true; - expect(icon.checkVisibility({ visibilityProperty: true })).to.be.false; - expect(toggle.getBoundingClientRect().width).to.equal(width); - const center = getCenterPoint(icon); - const { x, y } = getCenterPoint(spinner); - - expect(x).to.be.closeTo(center.x, 0.5); - expect(y).to.be.closeTo(center.y, 0.5); - expect(internalsOf(spinner)?.getARIA('role')).to.equal('progressbar'); - expect(spinner.getAttribute('aria-label')).to.equal('Loading'); - expect(empty.innerText).to.equal('Loading'); - - await expect(remote).shadowDom.to.be.accessible( - axeReflectedRelationsOptions - ); - - remote.loading = false; - await elementUpdated(remote); - - expect(spinner.checkVisibility()).to.be.false; - expect(icon.checkVisibility({ visibilityProperty: true })).to.be.true; - expect(empty.innerText).to.equal('The list is empty'); - }); - - it('centers the ring of the spinner on the toggle icon, and removes the spinner after loading', async () => { - const icon = remote.renderRoot.querySelector( - '[part~="toggle-icon"] igc-icon' - )!; - - remote.loading = true; - await openComboPopover(remote); - - const ring = loadingPart()!.shadowRoot!.querySelector('[part~="svg"]')!; - const center = getCenterPoint(icon); - const { x, y } = getCenterPoint(ring); - - expect(x).to.be.closeTo(center.x, 0.5); - expect(y).to.be.closeTo(center.y, 0.5); - - remote.loading = false; - await elementUpdated(remote); - - expect(loadingPart()).to.be.null; - }); - - it('does not move the list when the spinner shows', async () => { - await openComboPopover(remote); - const { top } = list.getBoundingClientRect(); - - remote.loading = true; - await elementUpdated(remote); - - expect(list.getBoundingClientRect().top).to.equal(top); - }); - - it('takes the colors of the spinner and the skeletons from their parts', async () => { - const sheet = new CSSStyleSheet(); - sheet.replaceSync( - 'igc-combo::part(loading) { color: rgb(1, 2, 3); } igc-combo::part(placeholder) { color: rgb(4, 5, 6); }' - ); - const reference = await fixture( - html`
              ` - ); - - await withDocumentSheets([sheet], async () => { - remote.totalItemCount = 12; - remote.loading = true; - await openComboPopover(remote); - list.scrollToIndex(11); - await comboStable(remote); - - const placeholder = placeholders().at(-1)!; - const skeleton = placeholder.querySelector('span')!; - const box = placeholder.renderRoot - .querySelector('igc-checkbox')! - .renderRoot.querySelector('[part~="control"]')!; - - const stop = loadingPart()!.shadowRoot!.querySelector( - '[part~="gradient_start"]' - )!; - - expect(getComputedStyle(stop).stopColor).to.equal('rgb(1, 2, 3)'); - const { backgroundColor } = getComputedStyle(reference); - - expect(getComputedStyle(skeleton).backgroundColor).to.equal( - backgroundColor - ); - expect(getComputedStyle(box, '::after').backgroundColor).to.equal( - backgroundColor - ); - }); - }); - - it('replaces the spinner with the `loading` slot', async () => { - const custom = document.createElement('span'); - custom.slot = 'loading'; - remote.append(custom); - remote.loading = true; - await openComboPopover(remote); - - const slot = remote.renderRoot.querySelector( - 'slot[name="loading"]' - )!; - - expect(slot.assignedElements()).to.eql([custom]); - expect(custom.checkVisibility()).to.be.true; - expect(loadingPart()!.checkVisibility()).to.be.false; - }); - }); - }); - - describe('Select all methods', () => { - async function search(text: string): Promise { - searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); - await elementUpdated(combo); - } - - beforeEach(async () => { - combo = await fixture>( - html`` - ); - searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; - }); - - it('selects and deselects the items that the list shows', async () => { - const eventSpy = spy(combo, 'emitEvent'); - - await search('o'); - combo.selectAllItems(); - - expect(combo.value).to.eql(['BG01', 'BG02', 'US01', 'US02', 'US03']); - - await search('s'); - combo.deselectAllItems(); - - expect(combo.value).to.eql(['BG02', 'US01']); - expect(eventSpy).not.calledWith('igcChange'); - }); - - it('selects and deselects all the items with `ignoreFilter`', async () => { - await search('sof'); - combo.selectAllItems(true); - - expect(combo.value).lengthOf(cities.length); - - combo.deselectAllItems(true); - - expect(combo.value).to.be.empty; - }); - - it('changes nothing when the list shows no items', async () => { - combo.select('BG01'); - await search('xyz'); - - combo.selectAllItems(); - combo.deselectAllItems(); - - expect(combo.value).to.eql(['BG01']); - }); - - it('acts on a new data at once', () => { - combo.data = cities.slice(0, 2); - combo.selectAllItems(); - - expect(combo.value).to.eql(['BG01', 'BG02']); - }); - - it('selects nothing in single selection, and deselects a shown item', async () => { - combo.singleSelect = true; - combo.select('US02'); - await elementUpdated(combo); - - combo.selectAllItems(); - expect(combo.value).to.eql(['US02']); - - combo.deselectAllItems(); - expect(combo.value).to.be.empty; - }); - }); - - describe('Select-all option', () => { - let eventSpy: SinonSpy; - - const selectAll = () => - list.querySelector('[part~="select-all"]'); - - const ariaOf = (item: Element) => ({ - selected: internalsOf(item)?.getARIA('ariaSelected'), - checked: internalsOf(item)?.getARIA('ariaChecked') ?? null, - }); - - // The cities whose name has an "o". - const ids = ['BG01', 'BG02', 'US01', 'US02', 'US03']; - - const changes = () => - eventSpy.withArgs('igcChange').args.map(([, { detail }]) => ({ - ...detail, - items: detail.items.map(({ id }: City) => id), - })); - - async function search(text: string): Promise { - searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); - await comboStable(combo); - } - - beforeEach(async () => { - combo = await fixture>( - html`` - ); - searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; - list = combo.renderRoot.querySelector('igc-virtual-scroll')!; - eventSpy = spy(combo, 'emitEvent'); - await openComboPopover(combo); - }); - - it('comes first in multiple selection and counts as the first option', async () => { - const first = list.querySelector('[part~="item"], igc-combo-header'); - const [, option] = items(combo); - - expect(first).to.equal(selectAll()); - expect(first!.textContent?.trim()).to.equal('Select all'); - expect(ariaOf(first!)).to.eql({ selected: 'false', checked: null }); - expect(first!.hasAttribute('aria-checked')).to.be.false; - expect(first!.getAttribute('aria-posinset')).to.equal('1'); - expect(option.getAttribute('aria-posinset')).to.equal('2'); - expect(option.getAttribute('aria-setsize')).to.equal( - `${cities.length + 1}` - ); - - await expect(combo).shadowDom.to.be.accessible( - axeReflectedRelationsOptions - ); - - combo.singleSelect = true; - await comboStable(combo); - - expect(selectAll()).to.be.null; - }); - - it('does not show when the list shows no items', async () => { - await search('xyz'); - - expect(selectAll()).to.be.null; - expect( - combo.renderRoot.querySelector('[part="empty"]')!.checkVisibility() - ).to.be.true; - }); - - it('selects the shown items, then deselects them, with one igcChange each', async () => { - await search('o'); - - simulateClick(selectAll()!); - await comboStable(combo); - - expect(combo.value).to.eql(ids); - expect(ariaOf(selectAll()!).selected).to.equal('true'); - - simulateClick(selectAll()!); - await comboStable(combo); - - expect(combo.value).to.be.empty; - expect(changes()).to.eql([ - { newValue: ids, items: ids, type: 'selection' }, - { newValue: [], items: ids, type: 'deselection' }, - ]); - }); - - it('shows the mixed state and selects only the other shown items', async () => { - await search('o'); - const [first, ...others] = ids; - - combo.select(first); - await comboStable(combo); - - const checkbox = selectAll()!.renderRoot.querySelector('igc-checkbox')!; - const marginOf = (item: Element, part: string) => - getComputedStyle( - item - .shadowRoot!.querySelector('igc-checkbox')! - .renderRoot.querySelector(`[part~="${part}"]`)! - ).scrollMarginTop; - const style = document.createElement('style'); - style.textContent = - 'igc-combo::part(checkbox indeterminate), igc-combo::part(checkbox-indicator indeterminate) { scroll-margin-top: 7px; }'; - document.head.append(style); - - expect(ariaOf(selectAll()!).selected).to.equal('false'); - expect(checkbox.indeterminate).to.be.true; - expect(marginOf(selectAll()!, 'control')).to.equal('7px'); - expect(marginOf(selectAll()!, 'indicator')).to.equal('7px'); - expect(marginOf(items(combo)[1], 'control')).to.equal('0px'); - - style.remove(); - - simulateClick(selectAll()!); - await comboStable(combo); - - expect(changes().map(({ items }) => items)).to.eql([others]); - expect(ariaOf(selectAll()!).selected).to.equal('true'); - }); - - it('follows a new value while the list is open', async () => { - combo.value = ['BG01']; - await comboStable(combo); - - expect( - selectAll()!.renderRoot.querySelector('igc-checkbox')!.indeterminate - ).to.be.true; - expect(items(combo).filter((item) => item.selected)).lengthOf(1); - }); - - it('resets the active option when it shows or hides', async () => { - // The first city. Without the option, its index is the next city. - simulateKeyboard(list, homeKey); - simulateKeyboard(list, arrowDown); - await comboStable(combo); - - expect(items(combo).filter((item) => item.active)).lengthOf(1); - - combo.showSelectAll = false; - await comboStable(combo); - - expect(items(combo).some((item) => item.active)).to.be.false; - }); - - it('selects only the clicked item when the item has no value', async () => { - combo.data = [{ name: 'Nowhere', country: 'Other' } as City, ...cities]; - await comboStable(combo); - - simulateClick( - items(combo).find((item) => item.textContent?.trim() === 'Nowhere')! - ); - await comboStable(combo); - - expect(combo.selection.map(({ name }) => name)).to.eql(['Nowhere']); - }); - - it('keeps the selection when igcChange is canceled', async () => { - combo.addEventListener('igcChange', (event) => event.preventDefault()); - - simulateClick(selectAll()!); - await comboStable(combo); - - expect(combo.value).to.be.empty; - }); - - it('is a keyboard stop that Space toggles', async () => { - simulateKeyboard(list, homeKey); - simulateKeyboard(list, spaceBar); - await comboStable(combo); - - expect(combo.value).lengthOf(cities.length); - expect(selectAll()!.active).to.be.true; - - simulateKeyboard(list, arrowUp); - - expect(isFocused(searchInput)).to.be.true; - }); - }); - - describe('Group selection', () => { - let eventSpy: SinonSpy; - - const headers = () => [ - ...list.querySelectorAll('[part~="group-header"]'), - ]; - - const checkboxOf = (item: Element) => - item.shadowRoot!.querySelector('igc-checkbox')!; - - const changes = () => - eventSpy.withArgs('igcChange').args.map(([, { detail }]) => ({ - ...detail, - items: detail.items.map(({ id }: City) => id), - })); - - async function search(text: string): Promise { - searchInput.dispatchEvent(new CustomEvent('igcInput', { detail: text })); - await comboStable(combo); - } - - beforeEach(async () => { - combo = await fixture>( - html`` - ); - searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; - list = combo.renderRoot.querySelector('igc-virtual-scroll')!; - eventSpy = spy(combo, 'emitEvent'); - await openComboPopover(combo); - }); - - it('renders the group headers as options with a checkbox in multiple selection', async () => { - const [bulgaria] = headers(); - const [first] = items(combo); - - expect(headers()).lengthOf(2); - expect(bulgaria.localName).to.equal('igc-combo-item'); - expect(bulgaria.textContent?.trim()).to.equal('Bulgaria'); - expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); - expect(checkboxOf(bulgaria)).to.exist; - // The two headers count as options. - expect(bulgaria.getAttribute('aria-posinset')).to.equal('1'); - expect(first.getAttribute('aria-posinset')).to.equal('2'); - expect(first.getAttribute('aria-setsize')).to.equal( - `${cities.length + 2}` - ); - - await expect(combo).shadowDom.to.be.accessible( - axeReflectedRelationsOptions - ); - - combo.singleSelect = true; - await comboStable(combo); - - expect(headers().map(({ localName }) => localName)).to.eql([ - 'igc-combo-header', - 'igc-combo-header', - ]); - expect(items(combo)[0].getAttribute('aria-posinset')).to.equal('1'); - expect(items(combo)[0].getAttribute('aria-setsize')).to.equal( - `${cities.length}` - ); - }); - - it('selects the shown items of the group, then deselects them, with one igcChange each', async () => { - await search('o'); - - simulateClick(headers()[0]); - await comboStable(combo); - - expect(combo.value).to.eql(['BG01', 'BG02']); - expect(internalsOf(headers()[0])?.getARIA('ariaSelected')).to.equal( - 'true' - ); - expect(combo.open).to.be.true; - - simulateClick(headers()[0]); - await comboStable(combo); - - expect(combo.value).to.be.empty; - expect(changes()).to.eql([ - { - newValue: ['BG01', 'BG02'], - items: ['BG01', 'BG02'], - type: 'selection', - }, - { newValue: [], items: ['BG01', 'BG02'], type: 'deselection' }, - ]); - }); - - it('shows the mixed state and selects only the other items of the group', async () => { - const sheet = new CSSStyleSheet(); - sheet.replaceSync( - 'igc-combo::part(checkbox indeterminate) { scroll-margin-top: 7px; }' - ); - - combo.select('BG01'); - await comboStable(combo); - - const [bulgaria, unitedStates] = headers(); - - await withDocumentSheets([sheet], () => { - const control = - checkboxOf(bulgaria).renderRoot.querySelector('[part~="control"]')!; - - expect(getComputedStyle(control).scrollMarginTop).to.equal('7px'); - }); - - expect(checkboxOf(bulgaria).indeterminate).to.be.true; - expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); - expect(checkboxOf(unitedStates).indeterminate).to.be.false; - - simulateClick(bulgaria); - await comboStable(combo); - - expect(changes().map(({ items }) => items)).to.eql([['BG02', 'BG03']]); - expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); - expect(checkboxOf(headers()[0]).checked).to.be.true; - }); - - it('stops on the group headers with the arrow keys and Home, and Space toggles them', async () => { - simulateKeyboard(list, homeKey); - simulateKeyboard(list, spaceBar); - await comboStable(combo); - - expect(headers()[0].active).to.be.true; - expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); - - for (let i = 0; i < 4; i++) { - simulateKeyboard(list, arrowDown); - } - await comboStable(combo); - - expect(headers()[1].active).to.be.true; - - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); - }); - - it('works with the select-all option', async () => { - combo.showSelectAll = true; - await comboStable(combo); - - simulateClick(list.querySelector('[part~="select-all"]')!); - await comboStable(combo); - - expect( - headers().map((header) => internalsOf(header)?.getARIA('ariaSelected')) - ).to.eql(['true', 'true']); - }); - - it('lists an item that data holds twice one time in igcChange', async () => { - combo.data = [cities[0], ...cities]; - await comboStable(combo); - - simulateClick(headers()[0]); - await comboStable(combo); - - expect(changes()[0].items).to.eql(['BG01', 'BG02', 'BG03']); - }); - - it('resets the active option when group selection turns off', async () => { - simulateKeyboard(list, homeKey); - await comboStable(combo); - - expect(headers()[0].active).to.be.true; - - combo.groupSelection = false; - await comboStable(combo); - - expect(headers()[0].localName).to.equal('igc-combo-header'); - expect(items(combo).some((item) => item.active)).to.be.false; - - simulateKeyboard(list, homeKey); - await comboStable(combo); - - expect(items(combo)[0].active).to.be.true; - }); - }); - - describe('Confirm mode', () => { - const other = { id: 'XX01', name: 'Elsewhere', country: '', zip: '' }; - let eventSpy: SinonSpy; - - const changes = (event: 'igcChange' | 'igcPendingChange' = 'igcChange') => - eventSpy.withArgs(event).args.map(([, { detail }]) => ({ - ...detail, - items: detail.items.map(({ id }: City) => id), - })); - - const selectedNames = () => - items(combo) - .filter((item) => item.selected) - .map((item) => item.textContent?.trim()); - - async function click(index: number): Promise { - simulateClick(items(combo)[index]); - await comboStable(combo); - } - - const actions = () => [ - ...combo.renderRoot.querySelectorAll( - '[part="actions"] igc-button' - ), - ]; - - beforeEach(async () => { - combo = await fixture>( - html`` - ); - input = combo.renderRoot.querySelector('igc-input#target')!; - searchInput = combo.renderRoot.querySelector('[part="search-input"]')!; - list = combo.renderRoot.querySelector('igc-virtual-scroll')!; - eventSpy = spy(combo, 'emitEvent'); - await openComboPopover(combo); - }); - - it('stages the toggles in the open list and keeps the value', async () => { - await click(1); - await click(0); - - expect(selectedNames()).to.eql(['Plovdiv']); - expect(combo.pendingValue).to.eql(['BG02']); - expect(combo.value).to.eql(['BG01']); - expect(combo.selection.map(({ id }) => id)).to.eql(['BG01']); - expect(input.value).to.equal('Sofia'); - expect(changes()).to.be.empty; - expect(changes('igcPendingChange')).to.eql([ - { newValue: ['BG01', 'BG02'], items: ['BG02'], type: 'selection' }, - { newValue: ['BG02'], items: ['BG01'], type: 'deselection' }, - ]); - }); - - it('applies the pending selection on Enter with one igcChange', async () => { - simulateKeyboard(list, arrowDown); - simulateKeyboard(list, spaceBar); - simulateKeyboard(list, arrowDown); - simulateKeyboard(list, spaceBar); - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(combo.value).to.eql(['BG02']); - expect(input.value).to.equal('Plovdiv'); - expect(changes()).to.eql([ - { newValue: ['BG02'], items: ['BG02', 'BG01'], type: 'commit' }, - ]); - expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { - detail: { trigger: 'enter' }, - }); - }); - - it('applies the pending selection on Enter in the search input and without an active option', async () => { - // A handler that changes `newValue` does not change `value`. - combo.addEventListener( - 'igcChange', - ({ detail }) => detail.newValue.push('US01'), - { once: true } - ); - - await click(1); - simulateKeyboard(searchInput, enterKey); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(combo.value).to.eql(['BG01', 'BG02']); - - await openComboPopover(combo); - combo.select('BG03'); - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(combo.value).to.eql(['BG01', 'BG02', 'BG03']); - expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); - }); - - it('closes on Enter without igcChange when nothing changes', async () => { - await click(1); - await click(1); - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(changes()).to.be.empty; - }); - - it('keeps the list open with the pending selection when igcChange is canceled', async () => { - combo.addEventListener('igcChange', (event) => event.preventDefault(), { - once: true, - }); - - await click(1); - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(combo.open).to.be.true; - expect(combo.value).to.eql(['BG01']); - expect(combo.pendingValue).to.eql(['BG01', 'BG02']); - }); - - it('keeps the pending selection when igcPendingChange is canceled', async () => { - combo.addEventListener( - 'igcPendingChange', - (event) => event.preventDefault(), - { once: true } - ); - - await click(1); - - expect(combo.pendingValue).to.eql(['BG01']); - expect(selectedNames()).to.eql(['Sofia']); - }); - - it('drops the pending selection on the other closes', async () => { - const closes = [ - () => simulateKeyboard(list, escapeKey), - () => simulateClick(input), - () => combo.hide(), - ]; - - for (const close of closes) { - await openComboPopover(combo); - await click(1); - - expect(combo.pendingValue).to.eql(['BG01', 'BG02']); - - await close(); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(combo.pendingValue).to.eql(['BG01']); - } - - await openComboPopover(combo); - - expect(selectedNames()).to.eql(['Sofia']); - expect(changes()).to.be.empty; - - await click(1); - combo.open = false; - await comboStable(combo); - combo.open = true; - await comboStable(combo); - - expect(combo.pendingValue).to.eql(['BG01']); - }); - - it('stages the selection methods, and commitSelection() applies them without events', async () => { - combo.select('BG02'); - combo.deselect('BG01'); - await comboStable(combo); - - expect(selectedNames()).to.eql(['Plovdiv']); - expect(combo.pendingValue).to.eql(['BG02']); - - combo.selectAllItems(); - expect(combo.pendingValue).lengthOf(cities.length); - - combo.deselect(); - expect(combo.pendingValue).to.be.empty; - - combo.select(other); - combo.select(); - expect(combo.pendingValue).lengthOf(cities.length + 1); - - combo.deselect('XX01'); - expect(combo.pendingValue).lengthOf(cities.length); - - combo.deselectAllItems(); - combo.select(['BG03', other]); - expect(combo.value).to.eql(['BG01']); - - combo.commitSelection(); - await comboStable(combo); - - expect(combo.value).to.eql(['BG03', 'XX01']); - expect(input.value).to.equal('Varna, Elsewhere'); - expect(combo.open).to.be.true; - expect(selectedNames()).to.eql(['Varna']); - expect(eventSpy).not.calledWith('igcChange'); - expect(eventSpy).not.calledWith('igcPendingChange'); - }); - - it('keeps the pending selection on the same value, and puts the value back with cancelSelection() and a new value', async () => { - await click(1); - combo.value = ['BG01']; - await comboStable(combo); - - expect(combo.pendingValue).to.eql(['BG01', 'BG02']); - - combo.cancelSelection(); - await comboStable(combo); - - expect(combo.open).to.be.true; - expect(combo.pendingValue).to.eql(['BG01']); - expect(selectedNames()).to.eql(['Sofia']); - - await click(1); - combo.value = ['US01']; - await comboStable(combo); - - expect(combo.pendingValue).to.eql(['US01']); - expect(selectedNames()).to.eql(['New York']); - }); - - it('clears the value and the pending selection with the clear icon', async () => { - await click(1); - simulateClick(combo.renderRoot.querySelector('[part="clear-icon"]')!); - await comboStable(combo); - - expect(combo.value).to.be.empty; - expect(combo.pendingValue).to.be.empty; - expect(selectedNames()).to.be.empty; - expect(changes()).to.eql([ - { newValue: [], items: ['BG01'], type: 'deselection' }, - ]); - }); - - it('gives the pending selection the records of its values in a new data', async () => { - await click(1); - - combo.data = cities.map((city) => ({ ...city })); - await comboStable(combo); - - expect(selectedNames()).to.eql(['Sofia', 'Plovdiv']); - - await click(1); - - expect(combo.pendingValue).to.eql(['BG01']); - }); - - it('keeps the values of value without a record, and resolves them with select()', async () => { - combo.value = ['BG01', 'XX01']; - await click(1); - - expect(combo.value).to.eql(['BG01', 'XX01']); - - combo.select(other); - await comboStable(combo); - - expect(input.value).to.equal('Sofia, Elsewhere'); - expect(combo.value).to.eql(['BG01', 'XX01']); - expect(combo.pendingValue).to.eql(['BG01', 'BG02', 'XX01']); - - // A second record of the value does not join the selection. - combo.deselect('XX01'); - combo.select({ ...other }); - await comboStable(combo); - - expect(input.value).to.equal('Sofia, Elsewhere'); - - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(changes().map(({ items }) => items)).to.eql([['BG02']]); - expect(combo.selection.map(({ id }) => id)).to.eql([ - 'BG01', - 'BG02', - 'XX01', - ]); - }); - - it('acts on the value right after a close, and on the pending selection right after an opening', async () => { - void combo.hide(); - combo.deselect('BG01'); - - expect(combo.value).to.be.empty; - - void combo.show(); - combo.select('BG02'); - - expect(combo.value).to.be.empty; - expect(combo.pendingValue).to.eql(['BG02']); - }); - - it('gives the pending selection the records of a new data for the values of value', async () => { - const later = { id: 'XX02', name: 'Later', country: '', zip: '' }; - - combo.value = ['BG01', 'XX02']; - await click(1); - - combo.data = [...cities, later]; - await comboStable(combo); - - expect(combo.pendingValue).to.eql(['BG01', 'BG02', 'XX02']); - - await click(1); - simulateKeyboard(list, enterKey); - await comboStable(combo); - - expect(combo.value).to.eql(['BG01', 'XX02']); - expect(changes()).to.be.empty; - }); - - it('stages the select-all option and the group header options', async () => { - combo.showSelectAll = true; - combo.groupKey = 'country'; - combo.groupSelection = true; - await comboStable(combo); - - const selectAll = list.querySelector( - '[part~="select-all"]' - )!; - - simulateClick(selectAll); - await comboStable(combo); - - expect(combo.pendingValue).to.have.members(cities.map(({ id }) => id)); - expect(selectAll.selected).to.be.true; - - simulateClick(list.querySelector('[part~="group-header"]')!); - await comboStable(combo); - - expect(combo.pendingValue).to.have.members(['US01', 'US02', 'US03']); - expect(combo.value).to.eql(['BG01']); - expect(changes()).to.be.empty; - expect(changes('igcPendingChange').map(({ type }) => type)).to.eql([ - 'selection', - 'deselection', - ]); - }); - - it('renders Cancel and Apply as the default footer content', async () => { - const [cancel, apply] = actions(); - - expect(cancel.textContent?.trim()).to.equal('Cancel'); - expect(apply.textContent?.trim()).to.equal('Apply'); - expect(cancel.getAttribute('part')).to.equal('cancel-button'); - expect(apply.getAttribute('part')).to.equal('apply-button'); - await expect(combo).shadowDom.to.be.accessible( - axeReflectedRelationsOptions - ); - - combo.resourceStrings = { combo_apply: 'OK' }; - await comboStable(combo); - - expect(apply.textContent?.trim()).to.equal('OK'); - - try { - configureTheme('indigo'); - await comboStable(combo); - - expect([cancel.variant, apply.variant]).to.eql([ - 'outlined', - 'contained', - ]); - } finally { - configureTheme('bootstrap'); - } - - const footer = document.createElement('div'); - footer.slot = 'footer'; - combo.append(footer); - await comboStable(combo); - - expect(apply.checkVisibility()).to.be.false; - - footer.remove(); - await comboStable(combo); - - expect(apply.checkVisibility()).to.be.true; - - await combo.hide(); - - expect(actions()).to.be.empty; - - await openComboPopover(combo); - combo.selectionCommit = 'immediate'; - await comboStable(combo); - - expect(actions()).to.be.empty; - }); - - it('applies the pending selection with Apply and closes the list', async () => { - combo.addEventListener('igcChange', (event) => event.preventDefault(), { - once: true, - }); - - await click(1); - simulateClick(actions()[1]); - await comboStable(combo); - - expect(combo.open).to.be.true; - expect(combo.value).to.eql(['BG01']); - - simulateClick(actions()[1]); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(combo.value).to.eql(['BG01', 'BG02']); - expect(isFocused(input)).to.be.true; - expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); - expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { - detail: { trigger: 'apply' }, - }); - }); - - it('drops the pending selection with Cancel and closes the list', async () => { - combo.addEventListener('igcClosing', (event) => event.preventDefault(), { - once: true, - }); - - await click(1); - simulateClick(actions()[0]); - await comboStable(combo); - - expect(combo.open).to.be.true; - expect(combo.pendingValue).to.eql(['BG01']); - - await click(1); - eventSpy.resetHistory(); - simulateClick(actions()[0]); - await comboStable(combo); - - expect(combo.open).to.be.false; - expect(combo.pendingValue).to.eql(['BG01']); - expect(changes()).to.be.empty; - expect(eventSpy.withArgs('igcClosed')).calledOnceWith('igcClosed', { - detail: { trigger: 'cancel' }, - }); - }); - - it('moves with Tab from the list to Cancel and Apply, and then closes the list', async () => { - const [cancel, apply] = actions(); - - list.focus(); - await sendKeys({ press: tabKey }); - - expect(isFocused(cancel)).to.be.true; - - await sendKeys({ press: tabKey }); - - expect(isFocused(apply)).to.be.true; - - await sendKeys({ press: tabKey }); - await comboStable(combo); - - expect(combo.open).to.be.false; - }); - - it('applies each change at once in single selection', async () => { - combo.singleSelect = true; - await comboStable(combo); - - await click(1); - - expect(combo.value).to.eql(['BG02']); - expect(combo.pendingValue).to.eql(['BG02']); - expect(changes().map(({ type }) => type)).to.eql(['selection']); - expect(actions()).to.be.empty; - }); - }); - - describe('Scroll strategy', () => { - let container: HTMLDivElement; - - beforeEach(async () => { - container = await fixture(html` -
              - -
              - `); - combo = container.querySelector>( - IgcComboComponent.tagName - )!; - }); - - it('`scroll` behavior', async () => { - combo.scrollStrategy = 'scroll'; - await openComboPopover(combo); - await simulateScroll(container, { top: 200 }); - - expect(combo.open).to.be.true; - }); - - it('`close` behavior', async () => { - const eventSpy = spy(combo, 'emitEvent'); - - combo.scrollStrategy = 'close'; - await openComboPopover(combo); - await simulateScroll(container, { top: 200 }); - - expect(combo.open).to.be.false; - expect(eventSpy.firstCall).calledWith('igcClosing'); - expect(eventSpy.lastCall).calledWith('igcClosed'); - }); - - it('reports the `scroll` trigger with the `close` strategy', async () => { - const eventSpy = spy(combo, 'emitEvent'); - - combo.scrollStrategy = 'close'; - await openComboPopover(combo); - await simulateScroll(container, { top: 200 }); - - expect(eventSpy).calledWith('igcClosing', { - cancelable: true, - detail: { trigger: 'scroll' }, - }); - expect(eventSpy).calledWith('igcClosed', { - detail: { trigger: 'scroll' }, - }); - }); - }); - - describe('Theming', () => { - function hasListStyleSheet(): boolean { - return Array.from(combo.shadowRoot!.adoptedStyleSheets).some((sheet) => - Array.from(sheet.cssRules).some((rule) => - rule.cssText.startsWith(':where(igc-virtual-scroll)') - ) - ); - } - - it('keeps the style sheet of the list on a theme change', async () => { - combo = await fixture>(html` - - `); - expect(hasListStyleSheet()).to.be.true; - - try { - configureTheme('material'); - await elementUpdated(combo); - - expect(hasListStyleSheet()).to.be.true; - } finally { - configureTheme('bootstrap'); - } - }); - }); - - describe('ARIA', () => { - beforeEach(async () => { - combo = await fixture>( - html`` - ); - - options = combo.renderRoot.querySelector( - '[part="list"]' - ) as IgcVirtualScrollComponent; - input = combo.renderRoot.querySelector( - 'igc-input#target' - ) as IgcInputComponent; - }); - - it('names the input with the selection status only while no label names it', async () => { - const native = input.renderRoot.querySelector('input')!; - - expect(native.getAttribute('aria-label')).to.equal( - combo.resourceStrings.combo_aria_label_no_options - ); - - combo.label = 'Cities'; - await elementUpdated(combo); - await elementUpdated(input); - - expect(native.hasAttribute('aria-label')).to.be.false; - }); - - it('leaves the disabled state to the native input', async () => { - const native = input.renderRoot.querySelector('input')!; - - combo.disabled = true; - await elementUpdated(combo); - await elementUpdated(input); - - expect(native.disabled).to.be.true; - expect(native.hasAttribute('aria-disabled')).to.be.false; - }); - - it('should report posinset/setsize excluding group headers', async () => { - await openComboPopover(combo); - - const rendered = items(combo); - expect(rendered).lengthOf(cities.length); - expect(headerItems(combo)).lengthOf(2); - - for (const [index, item] of rendered.entries()) { - expect(item.getAttribute('aria-posinset')).to.equal(`${index + 1}`); - expect(item.getAttribute('aria-setsize')).to.equal(`${cities.length}`); - } - }); - - it('should renumber posinset/setsize when the options are filtered', async () => { - await openComboPopover(combo); - await filterCombo('a'); - - const rendered = items(combo); - expect(rendered.length).to.be.lessThan(cities.length); - - for (const [index, item] of rendered.entries()) { - expect(item.getAttribute('aria-posinset')).to.equal(`${index + 1}`); - expect(item.getAttribute('aria-setsize')).to.equal( - `${rendered.length}` - ); - } - }); - - it('prefixes the option ids with the id of the combo', async () => { - combo.id = 'cities'; - await openComboPopover(combo); - - const ids = items(combo).map((item) => item.id); - - expect(ids).to.not.be.empty; - expect(new Set(ids).size).to.equal(ids.length); - for (const id of ids) { - expect(id).to.match(/^cities-item-\d+$/); - } - }); - - it('should point aria-activedescendant at the active option and drop it on close', async () => { - await openComboPopover(combo); - - expect(options.getAttribute('aria-activedescendant')).to.be.null; - - simulateKeyboard(options, arrowDown); - await comboStable(combo); - - const active = items(combo).find((item) => item.active)!; - expect(active).to.not.be.undefined; - expect(options.getAttribute('aria-activedescendant')).to.equal(active.id); - - await combo.hide(); - await elementUpdated(combo); + await combo.hide(); + await elementUpdated(combo); expect(options.getAttribute('aria-activedescendant')).to.be.null; }); }); - describe('Form integration', () => { - const spec = createFormAssociatedTestBed>( - html`` - ); - - beforeEach(async () => { - await spec.setup(IgcComboComponent.tagName); - spec.setAttributes({ value: JSON.stringify(['BG01', 'BG02']) }); - }); - - it('is form associated', () => { - expect(spec.element.form).to.equal(spec.form); - }); - - it('is not associated on submit if no value', () => { - spec.setProperties({ value: [] }); - spec.assertSubmitHasValue(null); - }); - - it('is associated on submit with value-key (single)', () => { - spec.setProperties({ singleSelect: true }); - spec.setProperties({ value: ['BG01', 'BG02'] }); - spec.assertSubmitHasValue('BG01'); - }); - - it('is associated on submit with value-key (multiple)', () => { - spec.assertSubmitHasValue('BG01'); - spec.assertSubmitHasValues(['BG01', 'BG02']); - }); - - it('is associated on submit without value-key (single)', () => { - const [first, second, _] = cities; - - spec.setProperties({ valueKey: undefined, singleSelect: true }); - spec.element.select([first, second]); - - expect(spec.formData.getAll(spec.element.name)).lengthOf(1); - spec.assertSubmitPasses(); - }); - - it('is associated on submit without value-key (multiple)', () => { - const [first, second, _] = cities; - - spec.setProperties({ valueKey: undefined }); - spec.element.select([first, second]); - - expect(spec.formData.getAll(spec.element.name)).lengthOf(2); - spec.assertSubmitPasses(); - }); - - it('is correctly reset on form reset (multiple)', () => { - const initial = spec.element.value; - - spec.setAttributes({ value: JSON.stringify(['BG01', 'BG02']) }); - spec.setProperties({ value: [] }); - - spec.reset(); - expect(spec.element.value).to.eql(initial); - }); - - it('is correctly reset on form reset (single)', () => { - // Initial value is a multiple array. The combo defaults to the first item - const initial = [firstOf(spec.element.value)]; - - spec.setProperties({ singleSelect: true }); - spec.setProperties({ value: ['US01'] }); - - expect(spec.element.value).to.eql(['US01']); - - spec.reset(); - expect(spec.element.value).to.eql(initial); - }); - - it('should reset to the new default value after setAttribute() call (multiple)', () => { - spec.setAttributes({ value: JSON.stringify(['US01', 'US02']) }); - spec.setProperties({ value: [] }); - - spec.reset(); - expect(spec.element.value).to.eql(['US01', 'US02']); - spec.assertSubmitHasValues(spec.element.value); - }); - - it('should reset to the new default value after setAttribute() call (single)', () => { - spec.setProperties({ singleSelect: true }); - spec.setAttributes({ value: JSON.stringify(['US01']) }); - spec.setProperties({ value: [] }); - - spec.reset(); - - expect(spec.element.value).to.eql(['US01']); - spec.assertSubmitHasValue('US01'); - }); - - it('should handle invalid JSON in value attribute gracefully', () => { - const defaultValue = spec.element.defaultValue; - - spec.setAttributes({ value: 'invalid' }); - expect(spec.element.value).to.be.empty; - expect(spec.element.defaultValue).to.equal(defaultValue); - }); - - it('should handle empty string in value attribute gracefully', () => { - spec.setAttributes({ value: '' }); - expect(spec.element.value).to.be.empty; - expect(spec.element.defaultValue).to.be.empty; - }); - - it('should handle null in value attribute gracefully', () => { - spec.setAttributes({ value: null as any }); - expect(spec.element.value).to.be.empty; - expect(spec.element.defaultValue).to.be.empty; - }); - - it('reflects disabled ancestor state', () => { - spec.setAncestorDisabledState(true); - expect(spec.element.disabled).to.be.true; - - spec.setAncestorDisabledState(false); - expect(spec.element.disabled).to.be.false; - }); - - it('fulfils required constraint', () => { - spec.setProperties({ value: [], required: true }); - spec.assertSubmitFails(); - - spec.setProperties({ value: ['BG01', 'BG02'] }); - spec.assertSubmitPasses(); - }); - - it('fulfils custom constraint', () => { - spec.element.setCustomValidity('invalid'); - spec.assertSubmitFails(); - - spec.element.setCustomValidity(''); - spec.assertSubmitPasses(); - }); - - it('clears the invalid styles of the single-select input on form reset', async () => { - spec.setAttributes({ value: '[]' }); - spec.setProperties({ singleSelect: true, required: true }); - await elementUpdated(spec.element); - - const target = - spec.element.renderRoot.querySelector('#target')!; - - simulateBlur(target.renderRoot.querySelector('input')!); - await elementUpdated(spec.element); - ValidityHelpers.hasInvalidStyles(target).to.be.true; - - spec.reset(); - await elementUpdated(spec.element); - - ValidityHelpers.hasInvalidStyles(spec.element).to.be.false; - ValidityHelpers.hasInvalidStyles(target).to.be.false; - }); - - it('keeps the text of the single-select input on form reset', async () => { - spec.setProperties({ singleSelect: true }); - await elementUpdated(spec.element); - - const target = - spec.element.renderRoot.querySelector('#target')!; - expect(target.value).to.equal('Sofia'); - - spec.reset(); - await elementUpdated(spec.element); - - expect(target.value).to.equal('Sofia'); - }); - }); - - describe('defaultValue', () => { - const value = ['BG01', 'BG02']; - - describe('Form integration (single)', () => { - const spec = createFormAssociatedTestBed>(html` - - `); - - beforeEach(async () => { - await spec.setup(IgcComboComponent.tagName); - }); - - it('correct initial state', () => { - spec.assertIsPristine(); - expect(spec.element.value).to.eql([firstOf(value)]); - }); - - it('is correctly submitted', () => { - spec.assertSubmitHasValue(firstOf(value)); - }); - - it('is correctly reset', () => { - spec.setProperties({ value: [] }); - spec.reset(); - - expect(spec.element.value).to.eql([firstOf(value)]); - }); - }); - - describe('Form integration (multiple)', () => { - const spec = createFormAssociatedTestBed>(html` - - `); - - beforeEach(async () => { - await spec.setup(IgcComboComponent.tagName); - }); - - it('correct initial state', () => { - spec.assertIsPristine(); - expect(spec.element.value).to.eql(value); - }); - - it('is correctly submitted', () => { - spec.assertSubmitHasValues(value); - }); - - it('is correctly reset', () => { - spec.setProperties({ value: [] }); - spec.reset(); - - expect(spec.element.value).to.eql(value); - }); - }); - - describe('Form integration with delayed data', () => { - const spec = createFormAssociatedTestBed>(html` - - `); - - function initDataDefaultValue(single = false) { - spec.element.singleSelect = single; - // assign data after init to simulate async data - spec.element.data = cities; - spec.element.defaultValue = value; - } - - beforeEach(async () => { - await spec.setup(IgcComboComponent.tagName); - }); - - it('correct initial state (single)', () => { - initDataDefaultValue(true); - spec.assertIsPristine(); - expect(spec.element.value).to.eql([firstOf(value)]); - }); - - it('correct initial state (multiple)', () => { - initDataDefaultValue(); - spec.assertIsPristine(); - expect(spec.element.value).to.eql(value); - }); - - it('is correctly submitted (single)', () => { - initDataDefaultValue(true); - spec.assertSubmitHasValue(firstOf(value)); - }); - - it('is correctly submitted (multiple)', () => { - initDataDefaultValue(); - spec.assertSubmitHasValues(value); - }); - }); - - describe('Validation', () => { - const spec = createFormAssociatedTestBed>(html` - - `); - - beforeEach(async () => { - await spec.setup(IgcComboComponent.tagName); - }); - - it('fails required validation', () => { - spec.assertIsPristine(); - spec.assertSubmitFails(); - }); - - it('passes required validation', () => { - spec.setProperties({ defaultValue: value }); - spec.assertIsPristine(); - - spec.assertSubmitHasValues(value); - }); - }); - }); - - describe('Validation message slots', () => { - it('', async () => { - const testParameters: ValidationContainerTestsParams[] = - [ - { slots: ['valueMissing'], props: { required: true } }, - { slots: ['customError'] }, - { slots: ['invalid'], props: { required: true } }, - ]; - - await runValidationContainerTests(IgcComboComponent, testParameters); - }); - }); - runExternalLabelAssociationTests({ tagName: IgcComboComponent.tagName, getNativeInput: (host) => diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index e3f0c906b..f8eacaad3 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -656,10 +656,22 @@ footer button, the focus moves to the main input, because the closed list cannot ## Test scenarios -The suite lives in [`combo.spec.ts`](./combo.spec.ts) and runs in a real browser through `@web/test-runner` with -`@open-wc/testing` fixtures and assertions. It reuses `createFormAssociatedTestBed`, -`runValidationContainerTests`, `runExternalLabelAssociationTests`, `runAriaProjectionTests` and the `simulate*` -helpers from [`src/internals/testing`](../../internals/testing). The groups below mirror the `describe` blocks. +The suite runs in a real browser through `@web/test-runner` with `@open-wc/testing` fixtures and assertions. It +has four files: + +- [`combo.spec.ts`](./combo.spec.ts): the component, the header and footer slots, the close trigger, the scroll + strategy, theming, ARIA, and the shared suites for the external label and the host ARIA. +- [`combo-remote.spec.ts`](./combo-remote.spec.ts): remote data, placeholders and loading. +- [`combo-selection.spec.ts`](./combo-selection.spec.ts): the select-all methods and option, group selection and + the confirm mode. +- [`combo-form.spec.ts`](./combo-form.spec.ts): form integration, `defaultValue` and the validation message slots. + +The files share their data and helpers through +[`src/internals/testing/combo.spec.ts`](../../internals/testing/combo.spec.ts). They also reuse +`createFormAssociatedTestBed`, `runValidationContainerTests`, `runExternalLabelAssociationTests`, +`runAriaProjectionTests` and the `simulate*` helpers from [`src/internals/testing`](../../internals/testing). The +unit specs of the controllers in [`controllers`](./controllers) test the data state, the data requests and the +selection model without a browser fixture. The groups below mirror the `describe` blocks. ### Component @@ -697,7 +709,8 @@ helpers from [`src/internals/testing`](../../internals/testing). The groups belo 17. Is form associated, submits its selection, and is reset with the form, including the single-selection input. 18. Reflects the disabled state of an ancestor `fieldset`. -19. Fulfils the required and custom constraints. +19. Fulfils the required and custom constraints, and is accessible while a submit fails on the required + constraint. The focus after the form checks and the `invalid` event rules come from the form-associated mixin. The [input suite](../input/spec.md#form-validity-checks) covers them. diff --git a/src/internals/testing/combo.spec.ts b/src/internals/testing/combo.spec.ts new file mode 100644 index 000000000..12a6b5a7a --- /dev/null +++ b/src/internals/testing/combo.spec.ts @@ -0,0 +1,83 @@ +import { elementUpdated, nextFrame } from '@open-wc/testing'; + +import type IgcComboItemComponent from '../../components/combo/combo-item.js'; +import type IgcComboComponent from '../../components/combo/combo.js'; +import type IgcVirtualScrollComponent from '../../components/virtualization/virtualization.js'; + +/* The data and the helpers that the combo spec files share. */ + +export type City = { + id: string; + name: string; + country: string; + zip: string; +}; + +export const cities: City[] = [ + { + id: 'BG01', + name: 'Sofia', + country: 'Bulgaria', + zip: '1000', + }, + { + id: 'BG02', + name: 'Plovdiv', + country: 'Bulgaria', + zip: '4000', + }, + { + id: 'BG03', + name: 'Varna', + country: 'Bulgaria', + zip: '9000', + }, + { + id: 'US01', + name: 'New York', + country: 'United States', + zip: '10001', + }, + { + id: 'US02', + name: 'Boston', + country: 'United States', + zip: '02108', + }, + { + id: 'US03', + name: 'San Francisco', + country: 'United States', + zip: '94103', + }, +]; + +export async function comboStable( + combo: IgcComboComponent +) { + await elementUpdated(combo); + await nextFrame(); + await layoutComplete(combo); +} + +export async function openComboPopover( + combo: IgcComboComponent +) { + await combo.show(); + await nextFrame(); + await layoutComplete(combo); +} + +export const items = (combo: IgcComboComponent) => + [ + ...combo.renderRoot + .querySelector('igc-virtual-scroll')! + .querySelectorAll('[part~="item"]'), + ] as IgcComboItemComponent[]; + +export const layoutComplete = (combo: IgcComboComponent) => + ( + combo.renderRoot.querySelector( + 'igc-virtual-scroll' + ) as IgcVirtualScrollComponent + ).layoutComplete; From fff871be0ba9ac8e6d6efda2ab8db1529f678a22 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 28 Oct 2026 12:00:52 +0200 Subject: [PATCH 25/32] docs(combo): consolidate the specification in STE Merge the revision entries of this branch into five, one for each feature, and reword the specification in Simplified Technical English. Select all gets its own section, the confirm mode and the remote data sections use short labelled bullets, and the test scenarios follow the four spec files. Correct three statements: aria-activedescendant is on the listbox, a character key filters only in single selection, and Enter needs an active option outside the confirm mode. --- src/components/combo/spec.md | 784 ++++++++++++++++++----------------- 1 file changed, 406 insertions(+), 378 deletions(-) diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index f8eacaad3..5127d7480 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -14,9 +14,10 @@ - [Basic initialization](#basic-initialization) - [Data binding keys](#data-binding-keys) - [Single and multiple selection](#single-and-multiple-selection) - - [Confirm mode](#confirm-mode) + - [Select all](#select-all) - [Filtering](#filtering) - [Grouping](#grouping) + - [Confirm mode](#confirm-mode) - [Templating](#templating) - [Validation](#validation) - [Labeling from the light DOM](#labeling-from-the-light-dom) @@ -34,21 +35,21 @@ - [CSS custom properties](#css-custom-properties) - [Test scenarios](#test-scenarios) - [Component](#component) - - [ARIA](#aria) + - [Header and footer slots](#header-and-footer-slots) + - [Close trigger](#close-trigger) - [Scroll strategy tests](#scroll-strategy-tests) - [Theming](#theming) - - [Form integration tests](#form-integration-tests) - - [defaultValue](#defaultvalue) - - [Validation message slots](#validation-message-slots) + - [ARIA](#aria) - [Host ARIA](#host-aria) - - [Header and footer slots](#header-and-footer-slots) - - [Close trigger](#close-trigger) - [Remote data](#remote-data-1) - - [Select all methods](#select-all-methods) - [Placeholders and loading](#placeholders-and-loading) + - [Select all methods](#select-all-methods) - [Select-all option](#select-all-option) - [Group selection](#group-selection) - [Confirm mode](#confirm-mode-1) + - [Form integration tests](#form-integration-tests) + - [defaultValue](#defaultvalue) + - [Validation message slots](#validation-message-slots) - [Assumptions and limitations](#assumptions-and-limitations) - [Accessibility](#accessibility) - [ARIA roles and properties](#aria-roles-and-properties) @@ -68,86 +69,86 @@ | 7 | 2026-10-12 | The group header template gets the group `key`; its `item` is deprecated | | 8 | 2026-10-21 | API descriptions aligned with the JSDoc. | | 9 | 2026-10-26 | Setting `singleSelect` or `disableFiltering` again keeps the search text | -| 10 | 2026-10-26 | The inner `igcChange`, `igcStateChange` and `igcDataRequest` events stay inside | -| 11 | 2026-10-26 | Tab moves through the focusable content of the header, empty and footer slots | -| 12 | 2026-10-26 | `igcClosing` and `igcClosed` report the `trigger` that closes the list | -| 13 | 2026-10-26 | Remote data: `igcSearchInput`, `filteringOptions.mode`, `igcDataRequest`; a new `data` keeps the selection | -| 14 | 2026-10-27 | `selectAllItems()` and `deselectAllItems()` act on the items that the list shows | -| 15 | 2026-10-27 | `totalItemCount` skeleton placeholders and the `loading` spinner | -| 16 | 2026-10-27 | The select-all option (`showSelectAll`) and the `select-all` and `indeterminate` parts | -| 17 | 2026-10-27 | The focus moves to the main input when the list closes around it | -| 18 | 2026-10-27 | `igcChange.newValue` has the order of the `value` that follows | -| 19 | 2026-10-27 | Group selection (`groupSelection`); the group headers have no `group` role | -| 20 | 2026-10-27 | The select-all option and the group header options count in `aria-posinset` and `aria-setsize` | -| 21 | 2026-10-27 | Confirm mode (`selectionCommit`): a pending selection, `igcPendingChange` and the `commit` change type | -| 22 | 2026-10-27 | Confirm mode: Cancel and Apply as the default footer content, and the `cancel` and `apply` triggers | -| 23 | 2026-10-27 | The `loading` spinner is an indeterminate `igc-circular-progress` | +| 10 | 2026-10-26 | The inner events stay inside; Tab moves through the slot content; `igcClosing` and `igcClosed` report the close `trigger` | +| 11 | 2026-10-27 | Remote data: `filteringOptions.mode`, `igcSearchInput`, `igcDataRequest`, `totalItemCount` placeholders and `loading`; a new `data` keeps the selection | +| 12 | 2026-10-27 | Select all (`selectAllItems()`, `deselectAllItems()`, `showSelectAll`) and group selection (`groupSelection`); these options count in `aria-posinset` and `aria-setsize`, and the group headers have no `group` role | +| 13 | 2026-10-27 | Confirm mode (`selectionCommit`): a pending selection, `igcPendingChange`, the `commit` change type, and Cancel and Apply | +| 14 | 2026-10-27 | The focus moves to the main input when the list closes around it; `igcChange.newValue` has the order of `value` | +| 15 | 2026-10-28 | Text in STE; `aria-activedescendant` is on the listbox; the character keys filter only in single selection; the test scenarios follow the spec files | ## Overview -The `igc-combo` provides a list of options from which the end-user makes a selection. In contrast to -[`igc-select`](../select/spec.md), it renders its options in a virtualized list, so it can show thousands of options -at once, and it supports selecting more than one of them. +The `igc-combo` shows a list of options. The user selects one or more of them. Unlike +[`igc-select`](../select/spec.md), the combo shows its options in a virtualized list, so the list can hold thousands +of options. -The component binds to an array of objects rather than to projected items, filters the options as the end-user -types, groups them by a key, and lets the application template both the items and the group headers. It is a -form-associated custom element with `required` and custom validation. +The combo binds to an array of objects, not to child elements. It filters the options while the user types, and groups +them by a key. The application can set templates for the items and the group headers. It is a form-associated custom +element with `required` and custom validation. ### Key features -- **Complex data binding**: an array of objects with a value key, a display key and a group key. -- **Virtualized list**: thousands of options render without a performance cost. -- **Single and multiple selection**, with checkboxes and a selection summary in the input. -- **Filtering**: case sensitivity and diacritics matching are configurable, and filtering can be disabled. -- **Grouping** by a key, with configurable sort direction and a locale aware comparison. -- **Templating** of each item and each group header. -- **Localization** of the built-in strings through the library resource strings. -- **Form association and validation**, with declarative validation message slots. +- **Data binding**: an array of objects, with a value key, a display key and a group key. +- **Virtualized list**: thousands of options without a performance cost. +- **Single and multiple selection**: checkboxes in the list, and a summary of the selection in the input. +- **Select all and group selection**: one option selects all the items that the list shows, or the items of a group. +- **Confirm mode**: the user changes a pending selection, and applies it with one change event. +- **Filtering**: configurable case sensitivity and diacritics matching. The application can disable the filtering, or + filter on its server. +- **Remote data**: page requests, placeholders for the items to load, and a loading state. +- **Grouping** by a key, with a sort direction and a locale-aware comparison. +- **Templates** for the items and the group headers. +- **Localization** of the built-in strings. +- **Form association and validation**, with validation message slots. ### Acceptance criteria -- The component must bind to an array of objects and render its options from configurable keys. -- It must support selecting one or many options, and expose both the raw values and the selected records. -- It must filter the list as the end-user types, with configurable case sensitivity and diacritics matching. +- The combo must bind to an array of objects and show its options from configurable keys. +- It must let the user select one or more options, and give the selected values and the selected records. +- It must filter the list while the user types, with configurable case sensitivity and diacritics matching. - It must group the options by a key, with a configurable sort direction. -- It must render its options virtualized, so large data sources stay responsive. -- It must let the application template the items and the group headers. -- It must participate in form submission, reset and validation, and render the validation message slots. -- It must provide full keyboard support for opening, filtering, navigating and selecting. -- The element must be integrated and themeable with the theming mechanism of the library. -- The element must be WAI-ARIA compliant and support RTL layouts without additional configuration. +- It must virtualize the list, so a large data source stays responsive. +- It must let the application load the options from a server, one page at a time. +- It must let the application hold the changes of the user until the user applies them. +- It must let the application set templates for the items and the group headers. +- It must take part in form submission, reset and validation, and show the validation message slots. +- It must give full keyboard support to open the list, filter, move through the options and select. +- It must use the theming mechanism of the library. +- It must obey WAI-ARIA and support RTL layouts without more configuration. ## User stories ### End-user stories -As an end-user, I expect to be able to: +As an end user, I want to: -- open a list of options and see them grouped where that applies. -- type to narrow the list down to the options I am looking for. -- select one or several options, and see what I have selected in the field. -- clear my selection with a single action. -- move through the options with the arrow keys and with Home and End. +- open the list of options and see the options in their groups. +- type to make the list shorter. +- select one or more options, and see my selection in the field. +- clear my selection with one action. +- move through the options with the arrow keys, Home and End. - select and deselect the active option with Space. -- pick several options and apply them together with Enter, or drop them with Escape, when the - application asks for a confirmation. -- close the list with Escape without losing my selection. -- be told when the field is required and left empty. +- select all the options that the list shows, or all the options of a group, with one action. +- confirm a set of changes with Enter or Apply, or drop them with Escape or Cancel. +- scroll through a long remote list and see which options still load. +- close the list with Escape and keep my selection. +- know when the field is required and empty. ### Developer stories -As a developer, I expect to be able to: +As a developer, I want to: -- bind the component to an array of objects and pick the value, display and group keys. +- bind the combo to an array of objects and select the value, display and group keys. - switch between single and multiple selection. -- configure or disable the filtering, including case sensitivity and diacritics. -- sort the groups ascending or descending. -- template the items and the group headers. -- select and deselect options programmatically, by reference or by value key. -- read the selection both as values and as the original records. -- localize the built-in strings of the component. -- mark the control as required and provide my own validation messages. -- be notified when the selection changes, and on each open and close transition. +- configure or disable the filtering, with case sensitivity and diacritics. +- sort the groups in ascending or descending order, or keep the order of the data. +- set templates for the items and the group headers. +- select and deselect options from code, by record or by value. +- read the selection as values and as the records of the data source. +- load the options from a server one page at a time, and keep the selection across the pages and the searches. +- localize the built-in strings. +- make the control required and give my own validation messages. +- get an event when the selection changes, and on each open and close, with the user action that closes the list. - hold the changes of the user while the list is open, and apply them with one change event. ## Functionality @@ -158,10 +159,10 @@ As a developer, I expect to be able to: [End-to-end user experience prototype](https://www.figma.com/proto/kJxlQPb78W2C4S2g0gDjbs/Combo-Design-Handoff?node-id=1%3A3209&scaling=min-zoom&page-id=0%3A1&starting-point-node-id=1%3A3209&show-proto-sidebar=1) -The anchor renders like an input with the current selection, a clear icon and a toggle icon. Opening the list shows -the options, each with a checkbox in multiple selection mode, grouped under their headers. A search input filters -the list; in single selection mode the filtering happens in the main input instead. An empty result renders the -empty message. +The anchor looks like an input. It shows the selection, a clear icon and a toggle icon. The open list shows the +options under their group headers. In multiple selection, each option has a checkbox, and a search input above the +list filters the options. In single selection, the main input filters the options. When no option matches, the list +shows the empty message. ### Developer experience @@ -186,13 +187,13 @@ combo.groupKey = 'country'; #### Data binding keys -| Key | Purpose | -| ------------ | -------------------------------------------------------------------------------- | -| `valueKey` | The key used when selecting items and when reporting `value`. | -| `displayKey` | The key rendered in the list and in the input. | -| `groupKey` | The key the options are grouped by. | +| Key | Purpose | +| ------------ | ----------------------------------------------------------------------------------- | +| `valueKey` | The field of a record that `value` and the selection methods use. | +| `displayKey` | The field of a record that the list and the input show. Without it, `valueKey`. | +| `groupKey` | The field of a record that groups the options. | -Without a `valueKey`, the selection is reported as the records themselves. +Without a `valueKey`, `value` contains the records. #### Single and multiple selection @@ -200,65 +201,38 @@ Without a `valueKey`, the selection is reported as the records themselves. ``` -Multiple selection is the default: items carry checkboxes and the input summarizes the selection. `single-select` -switches to one selection and moves the filtering into the main input. +Multiple selection is the default. The items have checkboxes, and the input shows the text of all the selected items. +`single-select` permits one selection, and the main input filters the options. ```typescript -combo.select(['sof', 'plv']); // by value key -combo.select(); // everything +combo.select(['sof', 'plv']); // by value +combo.select(); // all the items of `data` combo.deselect(['sof']); -combo.deselect(); // everything +combo.deselect(); // all the selected items -combo.value; // ComboValue[] - the values -combo.selection; // T[] - the records from the data source +combo.value; // ComboValue[]: the values +combo.selection; // T[]: the records of the data source ``` -`selectAllItems()` and `deselectAllItems()` act on the items that the filtered list shows, without `igcChange`. In -multiple selection, `show-select-all` puts a "Select all" option first in the list. A click or Space on it selects the -shown items, or deselects them when all of them are selected, with one `igcChange` for the items that change. Its -checkbox is checked when all the shown items are selected and indeterminate when only some are. It is the first -option in `aria-posinset` and `aria-setsize`, and the list shows it only while it shows items. With `totalItemCount`, -it acts on the loaded items. +The selection methods send no `igcChange`. In single selection, `select()` without an argument clears the selection. A +user action sends one cancelable `igcChange` before the selection changes. For the confirm mode, see +[Confirm mode](#confirm-mode). -```html - -``` +#### Select all -#### Confirm mode +`selectAllItems()` and `deselectAllItems()` act on the items that the list shows, after the filter. They send no +`igcChange`. With `ignoreFilter`, they act on all the items. -In multiple selection, `selection-commit="confirm"` holds the changes of the user while the list is open. The list -opens with a pending selection, a copy of the selection. A click, Space, the select-all option and a group header -option change only the pending selection, with a cancelable `igcPendingChange` each. `value`, the form value, the -input text and `selection` keep the selection. Enter, also in the search input and without an active option, applies -the pending selection with one cancelable `igcChange` of the `commit` type, whose `items` are the items that it -selects or deselects, and then closes the list. A canceled `igcChange` keeps the list open. A canceled `igcClosing` -also keeps it open, with the applied selection. Any other close drops the pending selection. The clear icon clears `value` at once, -and the pending selection with it. - -The footer shows Cancel and Apply while the list is open, and Tab reaches them after the list. Apply applies the -pending selection as Enter does, and Cancel drops it, also when an `igcClosing` handler keeps the list open. They -close the list with the `apply` and `cancel` triggers. In Indigo, Cancel is `outlined` and Apply `contained`; in the -other themes both are `flat`. Content in the `footer` slot replaces them. - -While the pending selection exists, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change it, -so the app controls in the header and footer slots stage too. A new `value` replaces it. `pendingValue` gives its -values, `commitSelection()` applies it, and `cancelSelection()` puts `value` back in it. These methods emit no events -and do not close the list, so an app that binds `value` through `igcChange` reads `value` after `commitSelection()`. -A footer of the app uses them in place of the built-in buttons: +In multiple selection, `show-select-all` adds a "Select all" option at the top of the list: -```html - -
              - Apply -
              -
              -``` +- A click or Space on it selects the items that the list shows. When all of them are selected, it deselects them. One + `igcChange` reports the items that change. +- Its checkbox is checked when all the shown items are selected, and indeterminate when only some of them are. +- It is the first option in `aria-posinset` and `aria-setsize`. The list shows it only while the list shows items. +- With `totalItemCount`, it acts only on the loaded items. -```typescript -apply.addEventListener('click', () => { - combo.commitSelection(); - combo.hide(); -}); +```html + ``` #### Filtering @@ -276,25 +250,65 @@ combo.filteringOptions = { ``` -`caseSensitiveIcon` renders a toggle in the filtering input, and `disableFiltering` removes the filtering entirely. +`caseSensitiveIcon` shows a toggle for case-sensitive filtering in the search input. `disableFiltering` stops the +filtering, and in multiple selection it also hides the search input. -Setting `singleSelect` or `disableFiltering` to its current value keeps the search text, so a framework that sets -each property on each render does not clear it. +A set of `singleSelect` or `disableFiltering` to its current value keeps the search text. So a framework that sets +each property on each render does not clear the text. To filter on a server, see [Remote data](#remote-data). #### Grouping -When a group key is set, the options are grouped by it. Items whose group key resolves to `null` or `undefined` are -aggregated into an `Other` group. `groupSorting` controls the direction - `asc` by default, `desc`, or `none` - and -the comparison uses the collation of `locale`, also after a change of the global locale. +With a `groupKey`, the combo groups the options by that field. The items without a value in that field go into an +`Other` group. `groupSorting` sets the order of the groups: `asc` (the default), `desc`, or `none` for the order of +`data`. The comparison uses the collation of `locale`, also after a change of the global locale. -In multiple selection, `group-selection` makes each group header an option that works like the select-all option -for the items of its group that the list shows. The header keeps the text style of a group header, and the arrow -keys and Home stop on it. +In multiple selection, `group-selection` makes each group header an option. It works as the select-all option for the +shown items of its group. The header keeps its text style, and the arrow keys and Home stop on it. ```html ``` +#### Confirm mode + +In multiple selection, `selection-commit="confirm"` holds the changes of the user while the list is open. + +- **Pending selection.** The list opens with a copy of the selection. A click, Space, the select-all option and a + group header option change only this copy, and each change sends a cancelable `igcPendingChange`. `value`, the form + value, the input text and `selection` do not change. +- **Apply.** Enter applies the pending selection, also in the search input and without an active option. It sends one + cancelable `igcChange` of the `commit` type, with the items that it selects or deselects, and then closes the list. + A canceled `igcChange` keeps the list open with the pending selection. A canceled `igcClosing` keeps the list open + with the applied selection. +- **Drop.** All other closes drop the pending selection. The clear icon clears `value` at once, and the pending + selection with it. +- **Buttons.** While the list is open, the footer shows Cancel and Apply, and Tab reaches them after the list. Apply + works as Enter. Cancel drops the pending selection, also when an `igcClosing` handler keeps the list open. They + close the list with the `apply` and `cancel` triggers. In Indigo, Cancel is `outlined` and Apply is `contained`. In + the other themes, both are `flat`. Content in the `footer` slot replaces them. +- **Methods.** While the pending selection exists, `select()`, `deselect()`, `selectAllItems()` and + `deselectAllItems()` change it. So the app controls in the header and footer slots also stage changes. A new `value` + replaces the pending selection. `pendingValue` gives its values, `commitSelection()` applies it, and + `cancelSelection()` puts `value` back into it. These methods send no events and do not close the list. So an app + that binds `value` through `igcChange` must read `value` after `commitSelection()`. + +An app footer can use these methods in place of the built-in buttons: + +```html + +
              + Apply +
              +
              +``` + +```typescript +apply.addEventListener('click', () => { + combo.commitSelection(); + combo.hide(); +}); +``` + #### Templating ```typescript @@ -302,6 +316,9 @@ combo.itemTemplate = ({ item }) => html`${item.name} - ${item.country}`; combo.groupHeaderTemplate = (group) => html`${group.key}`; ``` +The item template gets the record. The group header template gets the group `key`, which is `Other` for the items +without a group value. Its `item` property is deprecated. + #### Validation ```html @@ -310,18 +327,17 @@ combo.groupHeaderTemplate = (group) => html`${group.key}`; ``` -See the [validation container specification](../validation-container/spec.md) for the message slot mechanism. +For the message slots, see the [validation container specification](../validation-container/spec.md). #### Labeling from the light DOM -Besides the `label` property, the control can be labelled by a `label` element in the light DOM, through `for` or by -nesting it. An IDREF does not cross a shadow boundary, so the combo resolves its labels through `ElementInternals` -and projects them - together with its `role`, `aria-haspopup`, `aria-expanded`, `aria-controls` and -`aria-activedescendant` - onto the native input of its anchor, as element references. +A `label` element in the light DOM can also label the control, through `for` or as a parent. An IDREF does not cross a +shadow boundary. So the combo gets its labels through `ElementInternals`, and sets them on the native input of its +anchor as element references. It also sets its `role`, `aria-haspopup`, `aria-expanded` and `aria-controls` there. -The name follows the [naming order](../input/spec.md#naming-order), so the host `aria-labelledby` and `aria-label` also -name the control. The selection status text names the input only while no `label` element, `label` property or host ARIA -names it. +The name follows the [naming order](../input/spec.md#naming-order), so the host `aria-labelledby` and `aria-label` +also name the control. The text of the selection status names the input only when no `label` element, `label` property +or host ARIA names it. #### Form integration @@ -332,20 +348,20 @@ names it. ``` -- The selection is submitted under `name`. -- A form reset restores `defaultValue` and clears the invalid styles of the control and its input. -- An invalid control blocks submission. +- The form submits the selection under `name`. +- A form reset restores `defaultValue`, and removes the invalid styles of the control and its input. +- An invalid control stops the submission. As for a native control, a failed submit or `form.reportValidity()` moves the focus to the first invalid control of -the form, and `reportValidity()` moves it to the control when it is invalid. `form.checkValidity()` and +the form. `reportValidity()` moves the focus to the control when it is invalid. `form.checkValidity()` and `checkValidity()` do not move the focus. The `invalid` event comes only from these checks and from a failed submit, not while the user edits the control. #### Remote data -A combo can show a large remote data source one page at a time. With `filteringOptions.mode` set to `remote`, the -combo does not filter the options. It keeps the search input and reports the text, and the application sets `data` to -the results of the server: +The combo can show a large remote source one page at a time. With `filteringOptions.mode` set to `remote`, the combo +does not filter the options. It keeps the search input and reports the text. The application sets `data` to the +results of the server: ```typescript combo.filteringOptions = { mode: 'remote' }; @@ -364,87 +380,99 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { }); ``` -- `igcSearchInput` comes on each change of the text of the search input, and of the main input in single selection, - also when the user clears the text. It also comes, with an empty text, when a user action clears the text: a - selection in single selection mode, a blur with nothing selected, or the clear button. It does not come when a - property change clears the text (`singleSelect`, `disableFiltering`), or with `disableFiltering`. It comes in both - modes. -- In remote mode, `igcDataRequest` comes when the open list renders one of the last five options of `data`. Its - `startIndex` is `data.length`, also with grouping, and `count` is the number of items to append. It does not come - again until the length of `data` changes, a new `data` replaces the first record, a new search text comes, or the - list opens again, so a source with no more items gets one request per opening. An empty `data` gets no request: the first page of a search is the job - of the application. An answer is stale when `data.length` no longer equals its `startIndex`, for example after a - new search. -- A new `data` keeps the selection. A selected item that the new data does not hold stays in `value`, in `selection` - and in the text of the input, and a later page or search that holds it again shows it checked. `select()` selects an - object that `data` does not hold as it is, so the application can select the records of a saved filter before - their page loads. With `valueKey`, an object resolves by its key first. Without an argument, `select()` selects all - the items of `data` and keeps the other selected items. -- In remote mode, the search input has no case-sensitive icon, and in single selection the input activates no option - of the old results, so Enter does not select an option that the server did not return for the text. -- `totalItemCount` is the number of all the results, when the server knows it. In remote mode, the list adds a - placeholder option for each result that a non-empty `data` does not hold yet, after the groups, so the scrollbar - shows the full list. An empty `data` shows the empty message, because the first page is the job of the application. - A placeholder is the skeleton of an item: the box of the checkbox in multiple selection, and a bar where the text - starts, with a wave that sweeps across it (none with reduced motion). It has the `placeholder` part and the - accessible name "Loading", is disabled, and counts in `aria-setsize`. The keyboard can move to it, but Enter and Space on it do nothing, a click does not select it or - close the list, and the selection methods skip it. `igcDataRequest` then also comes when the list renders a - placeholder, for example after End or a fast scroll. Its `count` covers the rendered placeholders and does not go - past `totalItemCount`, and no request comes when `data` holds `totalItemCount` items. A total that is not a finite - number counts as unknown. `data` grows only at its end, so a jump far into the placeholders requests all the items up - to the jump. -- `loading` shows a spinner, an indeterminate `igc-circular-progress` named "Loading", in place of the toggle icon, - so it shows also while the list is closed, for example during a search in single selection. The spinner covers the - box of the toggle icon, so the input does not change its width. An empty list shows "Loading" instead of the empty - message. Content in the `loading` slot replaces the spinner. It is in the input, so a click on it toggles the list, - as on the toggle icon, and Tab does not reach it while the list is open. The busy state shows in the field, not in - the list, so the list does not move. - -`bindComboDataSource()` from `igniteui-webcomponents/extras` implements this flow on the public API. See the -`RemoteData` story. +**Search text.** `igcSearchInput` comes in both modes: +- on each change of the text in the search input, or in the main input of single selection, also a clear; +- with an empty text, when a selection in single selection, a blur with no selection or the clear button clears the + text. + +It does not come when a property change clears the text (`singleSelect`, `disableFiltering`), or with +`disableFiltering`. + +In remote mode, the search input has no case-sensitive icon. In single selection, a text in the main input activates +no option of the old results. So Enter does not select an option that the server did not return for the text. + +**Page requests.** In remote mode, `igcDataRequest` comes when the open list shows one of the last five options of +`data`. +- `startIndex` is `data.length`, also with groups. `count` is the number of items to append. +- It comes again only after a change of the length of `data`, a new first record, a new search text or a new opening. + So a source with no more items gets one request for each opening. +- An empty `data` gets no request, because the application loads the first page of a search. +- An answer is stale when `data.length` is not equal to its `startIndex`, for example after a new search. + **Selection.** A new `data` keeps the selection. A selected item that the new `data` does not hold stays in `value`, + in `selection` and in the input text. A later page or search that holds it shows it as selected again. `select()` + selects an object that `data` does not hold as it is. So the application can select the records of a saved filter + before their page loads. With `valueKey`, the combo finds an object by its key first. Without an argument, + `select()` selects all the items of `data` and keeps the other selected items. + +**Placeholders.** `totalItemCount` is the number of all the results, when the server knows it. In remote mode, the +list adds a placeholder option after the groups for each result that a non-empty `data` does not hold. So the +scrollbar shows the full list. An empty `data` shows the empty message, because the application loads the first page. + +- A placeholder is the skeleton of an item: the box of the checkbox in multiple selection, and a bar where the text + starts. A wave moves across the bar, except with reduced motion. +- It has the `placeholder` part and the accessible name "Loading". It is disabled and counts in `aria-setsize`. +- The keyboard can move to it, but Enter and Space do nothing on it. A click does not select it or close the list, and + the selection methods skip it. +- `igcDataRequest` also comes when the list shows a placeholder, for example after End or a fast scroll. Its `count` + covers the shown placeholders, up to `totalItemCount`. No request comes when `data` holds `totalItemCount` items. +- A total that is not a finite number counts as unknown. +- `data` grows only at its end, so a jump far into the placeholders requests all the items up to the jump. + +**Loading.** `loading` shows a spinner, an indeterminate `igc-circular-progress` named "Loading", in place of the +toggle icon. + +- The spinner also shows while the list is closed, for example during a search in single selection. +- It covers the box of the toggle icon, so the width of the input does not change. +- An empty list shows "Loading" in place of the empty message. +- Content in the `loading` slot replaces the spinner. The slot is in the input, so a click on it toggles the list, as + on the toggle icon. Tab does not reach it while the list is open. +- The busy state shows in the field, not in the list, so the list does not move. + +`bindComboDataSource()` from `igniteui-webcomponents/extras` does this flow with the public API. See the `RemoteData` +story. ### Localization -The component renders several built-in strings, resolved through the library i18n mechanism and overridable per -instance through `locale` and `resourceStrings`: - -| Resource string | Default (`en`) | Usage | -| ----------------------------------- | ----------------------- | ------------------------------------------- | -| `combo_empty_message` | The list is empty | Shown when the list has no items. | -| `combo_filter_search_placeholder` | Enter a Search Term | The placeholder of the search input. | -| `combo_clearItems_placeholder` | Clear Selection | The label of the clear action. | -| `combo_aria_label_options` | Selected options | The accessible name of the selection. | -| `combo_aria_label_no_options` | No options selected | The accessible name of an empty selection. | -| `combo_loading` | Loading | The accessible name of the spinner and of the placeholder options, and the message of an empty list while loading. | -| `combo_select_all` | Select all | The text of the select-all option. | -| `combo_cancel` | Cancel | The text of the Cancel button of the confirm mode. | -| `combo_apply` | Apply | The text of the Apply button of the confirm mode. | +The combo shows built-in strings from the i18n mechanism of the library. `locale` and `resourceStrings` change them +for one instance: + +| Resource string | Default (`en`) | Usage | +| --------------------------------- | ------------------- | ------------------------------------------------------------------------------------- | +| `combo_empty_message` | The list is empty | The text of an empty list. | +| `combo_filter_search_placeholder` | Enter a Search Term | The placeholder of the search input. | +| `combo_clearItems_placeholder` | Clear Selection | The label of the clear icon. | +| `combo_aria_label_options` | Selected options | The accessible name of the selection. | +| `combo_aria_label_no_options` | No options selected | The accessible name of an empty selection. | +| `combo_loading` | Loading | The accessible name of the spinner and of the placeholders, and the text of an empty list while loading. | +| `combo_select_all` | Select all | The text of the select-all option. | +| `combo_cancel` | Cancel | The text of the Cancel button of the confirm mode. | +| `combo_apply` | Apply | The text of the Apply button of the confirm mode. | ### Keyboard interactions -The component must be focused for any of the following to apply. +These keys apply while the combo has the focus. -While the list of options is **not** visible: +While the list is closed: -| Key combination | Result | -| ------------------------------------------- | ---------------------------------------------------- | -| Arrow Down / Alt + Arrow Down | Opens the list of options. | -| Any character key | Opens the list and starts filtering. | -| Escape | Clears the value. The combo stays focused. | +| Keys | Result | +| --------------------------------------------------------------- | ------------------------------------------------------- | +| Arrow Down / Alt + Arrow Down | Opens the list. | +| A character key | In single selection, opens the list and filters it. | +| Escape | Clears the value. The combo keeps the focus. | -While the list of options **is** visible: +While the list is open: -| Key combination | Result | -| ------------------------------------------- | ------------------------------------------------------------------------------ | -| Arrow Down | Activates the next option; at the last option it does nothing. | -| Arrow Up | Activates the previous option; from the first option it returns to the input. | -| Space | Selects or deselects the active option. On the select-all option, or on a group header of `groupSelection`, selects its shown items, or deselects them when all of them are selected. | -| Enter | Selects the active option in single selection mode, and closes the list in all modes. In confirm mode, it first applies the pending selection, also from the search input and without an active option, and a canceled `igcChange` keeps the list open. On a placeholder of `totalItemCount`, it does nothing. | -| Home / End | Activates the first or the last option. | -| Escape | Closes the list. The combo stays focused. In confirm mode, the pending selection is dropped. | -| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content, which is Cancel and Apply in confirm mode. Past either end, or without focusable slot content, closes the list and moves the focus on. | +| Keys | Result | +| --------------------------------------------------- | ------ | +| Arrow Down | Activates the next option. At the last option, it does nothing. | +| Arrow Up | Activates the previous option. From the first option, it moves the focus to the search input, or closes the list when the search input does not show. | +| Space | Selects or deselects the active option. On the select-all option, or on a group header of `groupSelection`, it selects the shown items, or deselects them when all of them are selected. | +| Enter | On an active option, closes the list, and in single selection first selects the option. In confirm mode, it first applies the pending selection, also from the search input and without an active option, and a canceled `igcChange` keeps the list open. On a placeholder, it does nothing. | +| Home / End | Activates the first or the last option. | +| Escape | Closes the list. The combo keeps the focus. In confirm mode, it drops the pending selection. | +| Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. In confirm mode, the footer content is Cancel and Apply. Past the first or the last stop, or without focusable slot content, it closes the list and moves the focus on. | -Typing while the component is focused filters the list down to the closest matches. +In multiple selection, the search input filters the list while it has the focus. ## API @@ -586,10 +614,9 @@ type ComboCloseTrigger = | 'apply' // the Apply button of the confirm mode | 'cancel'; // the Cancel button of the confirm mode ``` - -The open events keep an empty `detail`. `hide()`, `toggle()` and a change of `open` emit no events. To stage a -selection, see [Confirm mode](#confirm-mode). When the list closes while the focus is in it, also on `hide()` from a -footer button, the focus moves to the main input, because the closed list cannot keep it. +The open events have an empty `detail`. `hide()`, `toggle()` and a change of `open` send no events. To stage a +selection, see [Confirm mode](#confirm-mode). When the list closes while it has the focus, also on `hide()` from a +footer button, the focus moves to the main input. The closed list cannot keep the focus. ### Slots @@ -656,197 +683,199 @@ footer button, the focus moves to the main input, because the closed list cannot ## Test scenarios -The suite runs in a real browser through `@web/test-runner` with `@open-wc/testing` fixtures and assertions. It -has four files: +The suite runs in a real browser through `@web/test-runner`, with `@open-wc/testing` fixtures and assertions. It has +four files, and the groups below follow them in this order: - [`combo.spec.ts`](./combo.spec.ts): the component, the header and footer slots, the close trigger, the scroll strategy, theming, ARIA, and the shared suites for the external label and the host ARIA. - [`combo-remote.spec.ts`](./combo-remote.spec.ts): remote data, placeholders and loading. -- [`combo-selection.spec.ts`](./combo-selection.spec.ts): the select-all methods and option, group selection and - the confirm mode. +- [`combo-selection.spec.ts`](./combo-selection.spec.ts): the select-all methods and option, group selection and the + confirm mode. - [`combo-form.spec.ts`](./combo-form.spec.ts): form integration, `defaultValue` and the validation message slots. The files share their data and helpers through -[`src/internals/testing/combo.spec.ts`](../../internals/testing/combo.spec.ts). They also reuse +[`src/internals/testing/combo.spec.ts`](../../internals/testing/combo.spec.ts). They also use `createFormAssociatedTestBed`, `runValidationContainerTests`, `runExternalLabelAssociationTests`, -`runAriaProjectionTests` and the `simulate*` helpers from [`src/internals/testing`](../../internals/testing). The -unit specs of the controllers in [`controllers`](./controllers) test the data state, the data requests and the -selection model without a browser fixture. The groups below mirror the `describe` blocks. +`runAriaProjectionTests` and the `simulate*` helpers from [`src/internals/testing`](../../internals/testing). The unit +specs in [`controllers`](./controllers) test the data state, the data requests and the selection model without a +browser fixture. ### Component 1. Rendering, the default state and the accessibility audit. 2. Data binding through `data`, `valueKey`, `displayKey` and `groupKey`, also when the `data` array changes in place. -3. Single and multiple selection, including `select` and `deselect` with and without arguments. -4. `value` and `selection` report the values and the records. -5. Filtering, including case sensitivity, diacritics matching and `disableFiltering`. Setting `singleSelect` or - `disableFiltering` again to its value keeps the search text and the filter. -6. Grouping, the `Other` group for unresolved keys, and the sort directions. -7. Item and group header templating. The group header template gets the group `key` with its own type, also for - number keys and the `Other` group, and a template that reads the deprecated `item` still renders. +3. Single and multiple selection, with `select` and `deselect` with and without arguments. +4. `value` and `selection` give the values and the records. +5. Filtering, with case sensitivity, diacritics matching and `disableFiltering`. A set of `singleSelect` or + `disableFiltering` to its current value keeps the search text and the filter. +6. Grouping, the `Other` group for the items without a group value, and the sort directions. +7. The item and group header templates. The group header template gets the group `key` with its own type, also for + number keys and the `Other` group. A template that reads the deprecated `item` still renders. 8. The empty message and the `empty` slot. 9. Localization through `locale` and `resourceStrings`. -10. Opening, closing and the keyboard navigation of the list, including Arrow Down at the end of the matches. -11. The clear action and `disableClear`. -12. `igcChange` carries the new value, the items and the change type, and a cancel keeps the selection. -13. The `igcChange` of the search input and the `igcStateChange` and `igcDataRequest` events of the list do not - leave the combo. - -### ARIA - -14. The anchor, the list, the items and the group headers expose their roles and states, and the selection summary - is announced. - -### Scroll strategy tests - -15. `hide`, `scroll` and `close` behave as specified while an ancestor scrolls. +10. Open, close and the keyboard navigation of the list, also Arrow Down at the end of the matches. +11. The clear icon and `disableClear`. +12. `igcChange` gives the new value, the items and the change type, and a cancel keeps the selection. +13. The `igcChange` of the search input, and the `igcStateChange` and `igcDataRequest` events of the list, stay inside + the combo. -### Theming +### Header and footer slots -16. The list keeps its stylesheet in the shadow root when the theme changes. +14. Tab moves from the search input to the header content, the list and the footer content, and Shift+Tab moves back. + With an empty list, Tab reaches the content of the `empty` slot. The accessibility audit passes with focusable + slot content. +15. Tab from the last footer control and Shift+Tab from the search input close the list and move the focus out of the + combo. In single selection, the main input starts the order, and Tab selects the active item when it leaves. + `hide()` from a footer control, or `open` set to `false` with the focus in the search input, moves the focus to + the main input. A focus outside the list stays where it is. +16. Without focusable slot content, Tab from the search input closes the list and moves the focus on. +17. Tab moves out of a stop inside another stop. It does not stop on a host that delegates the focus or on a control + with `visibility: hidden`. A slotted control that handles Tab keeps it. -### Form integration tests +### Close trigger -17. Is form associated, submits its selection, and is reset with the form, including the single-selection input. -18. Reflects the disabled state of an ancestor `fieldset`. -19. Fulfils the required and custom constraints, and is accessible while a submit fails on the required - constraint. +18. `igcClosing` and `igcClosed` report the same `trigger` one time for each of these actions: Enter in the list and + in the main input, Escape, Tab and Shift+Tab, Arrow Up in each input and at the first item, a click outside, a + click on the input, an item click in single selection, and a scroll with the `close` strategy. A second close + while one is in progress does not change the trigger, and a close after a canceled close reports its own trigger. +19. The open events have an empty `detail`, and `hide()` sends no events. -The focus after the form checks and the `invalid` event rules come from the form-associated mixin. The -[input suite](../input/spec.md#form-validity-checks) covers them. +### Scroll strategy tests -### defaultValue +20. `hide`, `scroll` and `close` work as specified while an ancestor scrolls. -20. Form integration in single selection mode, in multiple selection mode, and with data that arrives late. -21. Validation against the default value. +### Theming -### Validation message slots +21. The list keeps its stylesheet in the shadow root when the theme changes. -Generated by `runValidationContainerTests`. +### ARIA -22. The `value-missing`, `custom-error` and `invalid` slots render for their matching state. +22. The anchor, the list, the items and the group headers have their roles and states, and the selection summary is + announced. ### Host ARIA -23. The shared host description suite: the host `aria-describedby` describes the native control after the helper - text, and follows a change and a removal. - -### Header and footer slots - -24. Tab moves from the search input to the header content, the list and the footer content, and Shift+Tab moves - back. With an empty list, Tab reaches the content of the `empty` slot. The accessibility audit passes with - focusable slot content. -25. Tab from the last footer control and Shift+Tab from the search input close the list and move the focus out of - the combo. In single selection, the main input starts the order, and Tab selects the active item when it leaves. - `hide()` from a footer control, or `open` set to `false` with the focus in the search input, moves the focus to - the main input. A focus outside the list stays where it is. -26. Without focusable slot content, Tab from the search input closes the list and moves the focus on. -27. Tab moves out of a stop inside another stop, does not stop on a host that delegates the focus or on a control - with `visibility: hidden`, and leaves a Tab that a slotted control handles to that control. - -### Close trigger - -28. `igcClosing` and `igcClosed` report the same `trigger` once for Enter in the list and in the main input, Escape, - Tab and Shift+Tab, Arrow Up in each input and at the first item, a click outside, a click on the input, an item - click in single selection, and a scroll with the `close` strategy. A second close while one is in progress does - not change the trigger, and a close after a canceled one reports its own trigger. -29. The open events keep an empty `detail`, and `hide()` emits no events. +23. The shared host description suite: the host `aria-describedby` describes the native control after the helper text, + and follows a change and a removal. ### Remote data -30. The mode comes from the `filtering-options` attribute. In remote mode, the search input and the main input do not - filter the options; switching back to `local` filters them. -31. `igcSearchInput` reports each change of the search text, in both modes, and of the main input in single selection, - also when it is cleared, and an empty text when a selection or a blur clears it; a blur also clears the search - input. It does not come on a property change or with `disableFiltering`. The `igcInput` of the inputs does not - leave the combo. In remote mode, the case-sensitive icon hides and an input in single selection activates no - option. -32. The repro of issue 2519: a search that replaces `data` keeps the selected items in `value`, `selection` and the - input text, a later selection adds to them, and the first page shows them checked again with its own records. -33. `deselect()` removes a selected item that `data` does not hold; `select()` selects an object that `data` does not - hold, with and without `valueKey`; `select()` without an argument keeps the other selected items, and selects - each value one time also before a new `data` syncs. -34. `igcDataRequest` comes near the end of `data` with `startIndex` equal to `data.length`, also with grouping. It - does not repeat until `data` grows, and a new search, or a new `data` without a search, with a page of the same - length gets its own request, and so does a new opening. It does not come in local mode, while the list is - closed, or with an empty `data`. +24. The mode comes from the `filtering-options` attribute. In remote mode, the search input and the main input do not + filter the options. A change back to `local` filters them. +25. `igcSearchInput` reports each change of the search text in both modes, and of the main input in single selection, + also when the text is cleared. It reports an empty text when a selection or a blur clears the text, and a blur + also clears the search input. It does not come on a property change or with `disableFiltering`. The `igcInput` of + the inputs stays inside the combo. In remote mode, the case-sensitive icon hides, and an input in single selection + activates no option. +26. The repro of issue 2519: a search that replaces `data` keeps the selected items in `value`, `selection` and the + input text. A later selection adds to them, and the first page shows them as selected again with its own records. +27. `deselect()` removes a selected item that `data` does not hold. `select()` selects an object that `data` does not + hold, with and without `valueKey`. `select()` without an argument keeps the other selected items, and selects each + value one time, also before a new `data` syncs. +28. `igcDataRequest` comes near the end of `data` with `startIndex` equal to `data.length`, also with groups. It does + not come again until `data` grows. A new search, a new `data` without a search with a page of the same length, and + a new opening each get their own request. It does not come in local mode, while the list is closed, or with an + empty `data`. -### Select all methods +### Placeholders and loading -35. `selectAllItems()` and `deselectAllItems()` change only the items that the filtered list shows, also right after - a change of `data` or of the search text, emit no `igcChange`, and change nothing when the list shows no items. With `ignoreFilter`, they act on all the items. In - single selection, `selectAllItems()` does nothing and `deselectAllItems()` deselects a shown item. +29. In remote mode, `totalItemCount` adds placeholder options after the groups. They count in `aria-setsize`, take the + next positions, are disabled, have the name "Loading", and pass the accessibility audit. Local mode, an empty + `data` and a total that is not a finite number show no placeholders. +30. `igcDataRequest` covers the shown placeholders, for example after End, and does not go past `totalItemCount`. It + stops when `data` holds `totalItemCount` items, and comes again when the total no longer stops it. Space, a click + and `selectAllItems()` do not select a placeholder. In single selection, Enter or a click on a placeholder does + not close the list. +31. `loading` shows the named spinner over the hidden toggle icon, and "Loading" in an empty list. The toggle width + does not change, and the list does not move. It passes the accessibility audit. The `loading` slot replaces the + spinner. The spinner and the skeletons get their colors from the `loading` and `placeholder` parts. -### Placeholders and loading +### Select all methods -36. In remote mode, `totalItemCount` pads the list with placeholder options after the groups. They count in - `aria-setsize`, take the next positions, are disabled and named "Loading", and pass the accessibility audit. Local - mode, an empty `data` and a total that is not a finite number show no placeholders. -37. `igcDataRequest` covers the rendered placeholders, for example after End, does not go past `totalItemCount`, stops - when `data` holds `totalItemCount` items, and comes when the total no longer stops it. Space, a click and - `selectAllItems()` do not select a placeholder, and Enter or a click on one does not close the list in single - selection. -38. `loading` shows the named spinner over the hidden toggle icon, without a change of the toggle width or a move of - the list, and "Loading" in an empty list, and passes the accessibility audit. The `loading` slot replaces the spinner. The spinner and the skeletons - take their colors from the `loading` and `placeholder` parts. +32. `selectAllItems()` and `deselectAllItems()` change only the items that the filtered list shows, also right after a + change of `data` or of the search text. They send no `igcChange`, and change nothing when the list shows no items. + With `ignoreFilter`, they act on all the items. In single selection, `selectAllItems()` does nothing and + `deselectAllItems()` deselects a shown item. ### Select-all option -39. The option comes first in multiple selection, before the group headers, with the "Select all" text and - `aria-selected`. It is the first option in `aria-posinset` and `aria-setsize`, passes the accessibility audit, and - does not show in single selection or while the list shows no items. -40. A click selects the shown items, and a second click deselects them, with one `igcChange` each. With some shown - items selected, the option is not selected, its checkbox is indeterminate with the `indeterminate` part, and a click - selects only the other shown items. A canceled `igcChange` keeps the selection. A new `value` while the list is - open updates the option and the items. -41. Home reaches the option, Space toggles it, and Arrow Up from it moves to the search input. Showing or hiding the - option clears the active option. A click on an item without a value selects only that item. -42. With `totalItemCount` in remote mode, the option does not change the requests or the positions of the - placeholders, and the selection methods select only the loaded items. +33. The option comes first in multiple selection, before the group headers, with the "Select all" text and + `aria-selected`. It is the first option in `aria-posinset` and `aria-setsize`, and it passes the accessibility + audit. It does not show in single selection or while the list shows no items. +34. A click selects the shown items, and a second click deselects them, with one `igcChange` each. When only some + shown items are selected, the option is not selected, and its checkbox is indeterminate with the `indeterminate` + part. A click then selects only the other shown items. A canceled `igcChange` keeps the selection. A new `value` + while the list is open updates the option and the items. +35. Home reaches the option, Space toggles it, and Arrow Up from it moves to the search input. A change of + `showSelectAll` clears the active option. A click on an item without a value selects only that item. +36. With `totalItemCount` in remote mode, the option does not change the requests or the positions of the + placeholders. The selection methods select only the loaded items. ### Group selection -43. In multiple selection, the group headers are options with a checkbox and `aria-selected`, they count in +37. In multiple selection, the group headers are options with a checkbox and `aria-selected`. They count in `aria-posinset` and `aria-setsize`, and the accessibility audit passes. In single selection, they are headers - again, and the items take the positions back. -44. A click on a header selects the shown items of its group, and a second click deselects them, with one - `igcChange` each, and the list stays open. With some of them selected, the checkbox is indeterminate with the - `indeterminate` part, and a click selects only the others. -45. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. With the select-all - option, a click on it selects the headers too. Turning group selection off clears an active header. + again, and the items take their positions back. +38. A click on a header selects the shown items of its group, and a second click deselects them, with one `igcChange` + each. The list stays open. With some of them selected, the checkbox is indeterminate with the `indeterminate` + part, and a click selects only the others. +39. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. A click on the + select-all option also selects the headers. When group selection goes off, an active header stops being active. ### Confirm mode -46. In confirm mode, a click and Space change the pending selection with `igcPendingChange`. The list and - `pendingValue` show it, and `value`, `selection` and the input text keep the selection. A canceled - `igcPendingChange` keeps the pending selection. -47. Enter applies the pending selection with one `igcChange` of the `commit` type with the changed items, and closes - the list with the `enter` trigger, also in the search input and without an active option. Without a change, it - closes the list with no `igcChange`. A canceled `igcChange` keeps the list open with the pending selection. A - handler that changes `newValue` does not change `value`. -48. Escape, a click on the input, `hide()` and `open` set to `false` drop the pending selection, with no `igcChange`. -49. The selection methods change the pending selection, also with an item that `data` does not hold, from the moment - the list opens, and the value from the moment it closes. `commitSelection()` applies the pending selection without - events and keeps the list open. `cancelSelection()` and a new `value` replace it, and the same `value` keeps it. -50. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending +40. A click and Space change the pending selection with `igcPendingChange`. The list and `pendingValue` show it, and + `value`, `selection` and the input text keep the selection. A canceled `igcPendingChange` keeps the pending + selection. +41. Enter applies the pending selection with one `igcChange` of the `commit` type with the changed items. Then it + closes the list with the `enter` trigger. This also occurs in the search input and without an active option. + Without a change, it closes the list with no `igcChange`. A canceled `igcChange` keeps the list open with the + pending selection. A handler that changes `newValue` does not change `value`. +42. Escape, a click on the input, `hide()` and `open` set to `false` drop the pending selection, with no `igcChange`. +43. From the moment the list opens, the selection methods change the pending selection, also with an item that `data` + does not hold. From the moment the list closes, they change the value. `commitSelection()` applies the pending + selection with no events and keeps the list open. `cancelSelection()` and a new `value` replace it, and the same + `value` keeps it. +44. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending selection by value, and adds the values of `value` that it gives a record. A value without a record stays in `value` until a commit, and `select()` gives it a record in the input text. The select-all option and the group header options change the pending selection. In single selection, each change applies at once. -51. The footer shows Cancel and Apply with the `combo_cancel` and `combo_apply` texts, and the accessibility audit - passes. Footer content replaces them, and immediate mode and single selection do not show them. -52. Apply applies the pending selection with one `igcChange` and closes the list with the `apply` trigger, and the - focus moves to the main input. A canceled `igcChange` keeps the list open. Cancel drops the pending selection and - closes the list with the `cancel` trigger. -53. Tab moves from the list to Cancel and Apply, and from Apply closes the list. +45. The footer shows Cancel and Apply with the `combo_cancel` and `combo_apply` texts, and the accessibility audit + passes. Footer content replaces them, and the immediate mode and single selection do not show them. +46. Apply applies the pending selection with one `igcChange`, closes the list with the `apply` trigger, and moves the + focus to the main input. A canceled `igcChange` keeps the list open. Cancel drops the pending selection and closes + the list with the `cancel` trigger. +47. Tab moves from the list to Cancel and Apply, and Tab from Apply closes the list. + +### Form integration tests + +48. The combo is form associated, submits its selection, and resets with the form, also the input of single selection. +49. It shows the disabled state of an ancestor `fieldset`. +50. It obeys the required and custom constraints, and passes the accessibility audit while a submit fails on the + required constraint. + +The form-associated mixin gives the focus after the form checks and the rules of the `invalid` event. The +[input suite](../input/spec.md#form-validity-checks) tests them. + +### defaultValue + +51. Form integration in single selection, in multiple selection, and with data that comes late. +52. Validation against the default value. + +### Validation message slots + +`runValidationContainerTests` makes these tests. + +53. The `value-missing`, `custom-error` and `invalid` slots show for their state. ## Assumptions and limitations -- The options come from the `data` array; projected item elements are not supported. -- Adding custom values from the input is not exposed as a public property, although the change event type allows for - an `addition` kind. -- The list is virtualized, so an option that is not rendered has no DOM node; work with the data records rather than - with the item elements. -- Without a `valueKey`, the selection is reported as whole records rather than as primitive values. +- The options come from the `data` array. The combo does not support child item elements. +- The combo has no public property to add a custom value from the input, but the change event type has an `addition` + kind. +- The list is virtualized, so an option that the list does not show has no DOM node. Use the data records, not the + item elements. +- Without a `valueKey`, `value` contains whole records, not primitive values. - A user change sets `value` from the selected records, also a commit of the confirm mode. So a value that has no record in `data` leaves `value`. The `resolve` option of `bindComboDataSource()` gives such values their records. - `singleSelect` overrides `selectionCommit`: in single selection, each change applies at once. A change of @@ -856,28 +885,27 @@ Generated by `runValidationContainerTests`. ### ARIA roles and properties -- The anchor exposes a `combobox` role with `aria-haspopup`, the expanded state, the relation to the list and the - active descendant, all projected onto its native input as element references. -- The list has a `listbox` role, and the items an `option` role with their selected state. The group headers have no - role, except with `groupSelection`, where they are options. The select-all option is selected while all the shown - items are selected, and a group header option while all the shown items of its group are. They have no - `aria-checked`, because a listbox uses one of `aria-selected` and `aria-checked` for all its options, so their mixed - state shows only on their checkbox. In confirm mode, the selected state of the options is the pending selection, - and the input shows `value`. A - placeholder of `totalItemCount` is a disabled option named "Loading". While `loading` is set, a - `progressbar` named "Loading" takes the place of the toggle icon. The listbox has no `aria-busy`, so a screen reader continues to - read the loaded options while the next page loads. -- The current selection is announced through a label resolved from the resource strings, which also covers the empty - case. -- The helper text and the validation messages are referenced through `aria-describedby`. -- An external light DOM `label` is projected onto the native input of the anchor as an element reference. +- The native input of the anchor has the `combobox` role, `aria-haspopup`, `aria-expanded` and `aria-controls`, as + element references. The listbox has `aria-activedescendant`, because it has the focus during list navigation. +- The list has the `listbox` role, and the items have the `option` role with `aria-selected`. +- The group headers have no role. With `groupSelection`, they are options. +- The select-all option is selected while all the shown items are selected. A group header option is selected while + all the shown items of its group are selected. They have no `aria-checked`, because a listbox uses only one of + `aria-selected` and `aria-checked` for all its options. So their mixed state shows only on their checkbox. +- In confirm mode, the selected state of the options is the pending selection, and the input shows `value`. +- A placeholder of `totalItemCount` is a disabled option with the name "Loading". +- While `loading` is set, a `progressbar` with the name "Loading" replaces the toggle icon. The listbox has no + `aria-busy`, so a screen reader continues to read the loaded options while the next page loads. +- A label from the resource strings announces the selection, also when it is empty. +- `aria-describedby` refers to the helper text and the validation messages. +- An external light DOM `label` goes to the native input of the anchor as an element reference. - A host `aria-describedby` describes the native control after the helper text, by element reference. - The native input has no `aria-disabled`. Its native `disabled` state is enough. ### Keyboard support -Already covered by the [relevant section of the specification](#keyboard-interactions). +See [Keyboard interactions](#keyboard-interactions). ### Right to Left support -The component works in a Right-to-Left context without additional setup or configuration. +The combo works in a right-to-left context without more configuration. From 826720604f39eb5323266f299c321d88b9bd4f72 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 6 Nov 2026 06:46:16 +0200 Subject: [PATCH 26/32] feat(combo): show the case-sensitive icon in remote mode - A click on the icon with a search text emits igcSearchInput again, so a remote app reads filteringOptions.caseSensitive and searches again. A filtering options change in remote mode does not rebuild the records. - bindComboDataSource() gives caseSensitive to load(), and a case change from the icon loads the first page again. - Spec: say that the select-all igcChange is cancelable, that groups have no entry in the selection, and that the app removes deleted remote selections. --- CHANGELOG.md | 4 +- README.md | 4 +- src/components/combo/combo-remote.spec.ts | 52 +++++++-- src/components/combo/combo.ts | 26 +++-- src/components/combo/spec.md | 55 ++++++---- src/extras/combo-data-source.spec.ts | 125 +++++++++++++++++++++- src/extras/combo-data-source.ts | 46 +++++--- stories/combo.stories.ts | 32 +++--- 8 files changed, 274 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66206bf46..3da4bb13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `focusInput()` moves the focus to the text area, for example after the application sets `draftMessage`. It returns a promise. In a new chat, it waits for the first render, and in a server-rendered chat, for the render after the hydration. It does nothing when a custom `input` renderer replaces the text area. [#2474](https://github.com/IgniteUI/igniteui-webcomponents/pull/2474) - `igcNavigate` is emitted when a plain click or Enter opens a link in a message in the same tab, before the browser navigates. Cancel it to route on the client. The detail has the message and the absolute URL. The event does not bubble and is not composed, so listen to it on the chat. - #### Combo - - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text, and the same text when the case-sensitive icon changes the case. The icon also shows in remote mode, so the application reads `filteringOptions.caseSensitive` for the server search. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner, an indeterminate circular progress, in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. `load` gets the search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) diff --git a/README.md b/README.md index 5995760d6..f041b6ea8 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,8 @@ The condition names are `igc-theme-material`, `igc-theme-bootstrap`, `igc-theme- import { bindComboDataSource } from 'igniteui-webcomponents/extras'; const binding = bindComboDataSource(combo, { - load: async ({ searchText, startIndex, count }, signal) => { - const query = `q=${encodeURIComponent(searchText)}&skip=${startIndex}&top=${count}`; + load: async ({ searchText, caseSensitive, startIndex, count }, signal) => { + const query = `q=${encodeURIComponent(searchText)}&case=${caseSensitive}&skip=${startIndex}&top=${count}`; const response = await fetch(`/api/cities?${query}`, { signal }); const { items, total } = await response.json(); return { items, totalCount: total }; diff --git a/src/components/combo/combo-remote.spec.ts b/src/components/combo/combo-remote.spec.ts index ba61f12d0..9d749f134 100644 --- a/src/components/combo/combo-remote.spec.ts +++ b/src/components/combo/combo-remote.spec.ts @@ -215,17 +215,53 @@ describe('Combo', () => { expect(remote.value).to.be.empty; }); - it('hides the case-sensitive icon in remote mode', async () => { - remote.caseSensitiveIcon = true; - await elementUpdated(remote); + describe('Case-sensitive icon', () => { + let icon: HTMLElement; - const icon = searchInput.querySelector('[part~="case-icon"]')!; - expect(icon.slot).to.be.empty; + beforeEach(async () => { + remote.caseSensitiveIcon = true; + await openComboPopover(remote); + icon = searchInput.querySelector('[part~="case-icon"]')!; + }); - remote.filteringOptions = { mode: 'local' }; - await elementUpdated(remote); + it('shows in remote mode', () => { + expect(icon.slot).to.equal('suffix'); + expect(icon.checkVisibility()).to.be.true; + }); + + it('reports the search text again with the new case', async () => { + simulateInput(searchNative, { value: 'ber' }); + simulateClick(icon); + await comboStable(remote); + + expect(remote.filteringOptions.caseSensitive).to.be.true; + expect(searchEvents()).to.eql(['ber', 'ber']); + // The server filters, so the options stay. + expect(items(remote)).lengthOf(10); + + simulateClick(icon); + await elementUpdated(remote); + + expect(remote.filteringOptions.caseSensitive).to.be.false; + expect(searchEvents()).to.eql(['ber', 'ber', 'ber']); + }); - expect(icon.slot).to.equal('suffix'); + it('does not report an empty search text', async () => { + simulateClick(icon); + await elementUpdated(remote); + + expect(remote.filteringOptions.caseSensitive).to.be.true; + expect(searchEvents()).to.be.empty; + }); + + it('reports the search text again in local mode', async () => { + remote.filteringOptions = { mode: 'local' }; + simulateInput(searchNative, { value: 'ber' }); + simulateClick(icon); + await elementUpdated(remote); + + expect(searchEvents()).to.eql(['ber', 'ber']); + }); }); it('keeps the igcInput of the inputs inside', async () => { diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index e54d26c19..9631396c3 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -121,8 +121,8 @@ const i18n = { * @fires igcOpened - Emitted after the list of options opens on a user action. * @fires igcClosing - Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. * @fires igcClosed - Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. - * @fires igcSearchInput - Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. - * @fires igcDataRequest - Emitted in remote filtering mode when the user scrolls near the end of the options, or to a placeholder of `totalItemCount`. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes, or the list opens again. + * @fires igcSearchInput - Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. Also emitted with the same text when the case-sensitive icon changes the case of a search text. + * @fires igcDataRequest - Emitted in remote filtering mode when the user scrolls near the end of the options, or to a placeholder of `totalItemCount`. Append the requested items to `data`. It is not emitted again until the length of `data`, the search text or its case changes, or the list opens again. * * @csspart label - The encapsulated text label of the combo. * @csspart input - The main input field of the combo. @@ -456,7 +456,8 @@ export default class IgcComboComponent< /** * Whether the search input shows the icon that toggles case-sensitive - * filtering. + * filtering. In remote filtering mode, read `filteringOptions.caseSensitive` + * when you send the search to the server. * @attr case-sensitive-icon * @default false */ @@ -679,10 +680,16 @@ export default class IgcComboComponent< this._activeIndex = -1; } + // In remote mode, the filtering options do not change the records. + const filtering = + props.has('filteringOptions') && + (this.filteringOptions.mode !== 'remote' || + props.get('filteringOptions')?.mode !== 'remote'); + if ( props.has('groupKey') || props.has('groupSorting') || - props.has('filteringOptions') || + filtering || props.has('totalItemCount') || props.has('showSelectAll') || props.has('groupSelection') || @@ -1185,6 +1192,12 @@ export default class IgcComboComponent< this.filteringOptions = { caseSensitive: !this.filteringOptions.caseSensitive, }; + + // Emit after the change, so a remote app reads the new case. An empty text + // matches all items in any case. + if (this._searchTerm) { + this._emitSearchInput(this._searchTerm); + } } //#endregion @@ -1523,10 +1536,7 @@ export default class IgcComboComponent< @igcChange=${stopPropagation} > ``` @@ -385,26 +393,33 @@ combo.addEventListener('igcDataRequest', ({ detail }) => { **Search text.** `igcSearchInput` comes in both modes: - on each change of the text in the search input, or in the main input of single selection, also a clear; - with an empty text, when a selection in single selection, a blur with no selection or the clear button clears the - text. + text; +- with the same text, when the case-sensitive icon changes the case of a search text that is not empty. It does not come when a property change clears the text (`singleSelect`, `disableFiltering`), or with `disableFiltering`. -In remote mode, the search input has no case-sensitive icon. In single selection, a text in the main input activates -no option of the old results. So Enter does not select an option that the server did not return for the text. +In remote mode, the server does the filtering, so the application reads `filteringOptions.caseSensitive` when it sends +the search. The case-sensitive icon changes this option, and `igcSearchInput` then reports the text again. In single +selection, a text in the main input activates no option of the old results. So Enter does not select an option that the +server did not return for the text. **Page requests.** In remote mode, `igcDataRequest` comes when the open list shows one of the last five options of `data`. - `startIndex` is `data.length`, also with groups. `count` is the number of items to append. -- It comes again only after a change of the length of `data`, a new first record, a new search text or a new opening. - So a source with no more items gets one request for each opening. +- It comes again only after a change of the length of `data`, a new first record, a new search text or case, or a new + opening. So a source with no more items gets one request for each opening. - An empty `data` gets no request, because the application loads the first page of a search. - An answer is stale when `data.length` is not equal to its `startIndex`, for example after a new search. - **Selection.** A new `data` keeps the selection. A selected item that the new `data` does not hold stays in `value`, - in `selection` and in the input text. A later page or search that holds it shows it as selected again. `select()` - selects an object that `data` does not hold as it is. So the application can select the records of a saved filter - before their page loads. With `valueKey`, the combo finds an object by its key first. Without an argument, - `select()` selects all the items of `data` and keeps the other selected items. + +**Selection.** A new `data` keeps the selection. A selected item that the new `data` does not hold stays in `value`, in +`selection` and in the input text. A later page or search that holds it shows it as selected again. The combo cannot +know if the server deleted the item or only did not send its page. So when the application loads live data again, it +must remove the selected items that the server no longer has, for example with `deselect()`. + +`select()` selects an object that `data` does not hold as it is. So the application can select the records of a saved +filter before their page loads. With `valueKey`, the combo finds an object by its key first. Without an argument, +`select()` selects all the items of `data` and keeps the other selected items. **Placeholders.** `totalItemCount` is the number of all the results, when the server knows it. In remote mode, the list adds a placeholder option after the groups for each result that a non-empty `data` does not hold. So the @@ -430,8 +445,9 @@ toggle icon. on the toggle icon. Tab does not reach it while the list is open. - The busy state shows in the field, not in the list, so the list does not move. -`bindComboDataSource()` from `igniteui-webcomponents/extras` does this flow with the public API. See the `RemoteData` -story. +`bindComboDataSource()` from `igniteui-webcomponents/extras` does this flow with the public API. Its `load()` gets the +search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. +After a change of `filteringOptions.caseSensitive` in code, call `refresh()`. See the `RemoteData` story. ### Localization @@ -493,7 +509,7 @@ In multiple selection, the search input filters the list while it has the focus. | singleSelect | single-select | Yes | `boolean` | false | Whether only one item can be selected. In this mode, the main input filters the list of options. | | filteringOptions | filtering-options | No | `FilteringOptions` | - | Configures the filtering of the combo. | | disableFiltering | disable-filtering | No | `boolean` | false | Whether the filtering of the list of options is disabled. In multiple selection mode, it also hides the search input. | -| caseSensitiveIcon | case-sensitive-icon | No | `boolean` | false | Whether the search input shows the icon that toggles case-sensitive filtering. | +| caseSensitiveIcon | case-sensitive-icon | No | `boolean` | false | Whether the search input shows the icon that toggles case-sensitive filtering. In remote filtering mode, read `filteringOptions.caseSensitive` when you send the search to the server. | | disableClear | disable-clear | No | `boolean` | false | Whether the clear button is hidden. | | totalItemCount | total-item-count | No | `number \| undefined` | - | The number of all the items of a remote source, when the source knows it. In remote filtering mode, the list shows a placeholder for each item that `data` does not hold yet. | | loading | loading | Yes | `boolean` | false | Whether the combo loads options. A spinner, which the `loading` slot can replace, then takes the place of the toggle icon, and an empty list shows a loading message instead of the empty message. | @@ -580,8 +596,8 @@ type ComboGroupHeaderTemplate = (props: { | igcOpened | false | Emitted after the list of options opens on a user action. | | igcClosing | true | Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. | | igcClosed | false | Emitted after the list of options closes on a user action. The `trigger` of the detail is the user action that closes the list. | -| igcSearchInput | false | Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. | -| igcDataRequest | false | Emitted in remote filtering mode when the user scrolls near the end of the options, or to a placeholder of `totalItemCount`. Append the requested items to `data`. It is not emitted again until the length of `data` or the search text changes. | +| igcSearchInput | false | Emitted when the user changes the text of the search input, or of the main input in single selection mode, and when a selection, a blur or the clear button clears the text. Also emitted with the same text when the case-sensitive icon changes the case of a search text. | +| igcDataRequest | false | Emitted in remote filtering mode when the user scrolls near the end of the options, or to a placeholder of `totalItemCount`. Append the requested items to `data`. It is not emitted again until the length of `data`, the search text or its case changes, or the list opens again. | ```typescript interface IgcComboChangeEventArgs { @@ -774,8 +790,9 @@ state, the data requests and the selection model without a browser fixture. 26. `igcSearchInput` reports each change of the search text in both modes, and of the main input in single selection, also when the text is cleared. It reports an empty text when a selection or a blur clears the text, and a blur also clears the search input. It does not come on a property change or with `disableFiltering`. The `igcInput` of - the inputs stays inside the combo. In remote mode, the case-sensitive icon hides, and an input in single selection - activates no option. + the inputs stays inside the combo. In remote mode, an input in single selection activates no option. The + case-sensitive icon shows in remote mode. In both modes, a click on it reports the search text again with the new + case, but does not report an empty text. 27. The repro of issue 2519: a search that replaces `data` keeps the selected items in `value`, `selection` and the input text. A later selection adds to them, and the first page shows them as selected again with its own records. 28. `deselect()` removes a selected item that `data` does not hold. `select()` selects an object that `data` does not diff --git a/src/extras/combo-data-source.spec.ts b/src/extras/combo-data-source.spec.ts index 4069090cf..7f73a38e4 100644 --- a/src/extras/combo-data-source.spec.ts +++ b/src/extras/combo-data-source.spec.ts @@ -57,11 +57,13 @@ const PLACES: Place[] = [ /** The results of a search, as new objects, as parsed JSON gives them. */ function page({ searchText, + caseSensitive, startIndex, count, }: ComboDataRequest): ComboDataPage { + const fold = (text: string) => (caseSensitive ? text : text.toLowerCase()); const results = PLACES.filter(({ name }) => - name.toLowerCase().includes(searchText.toLowerCase()) + fold(name).includes(fold(searchText)) ); return { @@ -163,7 +165,7 @@ describe('Combo data source binding', () => { await open(); expect(requests.map(({ request }) => request)).to.eql([ - { searchText: '', startIndex: 0, count: 10 }, + { searchText: '', caseSensitive: false, startIndex: 0, count: 10 }, ]); await answer(0); @@ -307,6 +309,7 @@ describe('Combo data source binding', () => { clock.tick(1); expect(requests[1].request).to.eql({ searchText: 'ber', + caseSensitive: false, startIndex: 0, count: 10, }); @@ -405,11 +408,128 @@ describe('Combo data source binding', () => { expect(requests.at(-1)!.request).to.eql({ searchText: '', + caseSensitive: false, startIndex: 10, count: 20, }); }); + it('loads the first page again when the case-sensitive icon changes the case', async () => { + combo.caseSensitiveIcon = true; + bind({ debounce: 0 }); + await open(); + requests[0].answer(); + await flush(); + + simulateInput(searchNative, { value: 'b' }); + clock.tick(0); + requests.at(-1)!.answer(); + await flush(); + + simulateClick(combo.renderRoot.querySelector('[part~="case-icon"]')!); + clock.tick(0); + + expect(requests.at(-1)!.request).to.eql({ + searchText: 'b', + caseSensitive: true, + startIndex: 0, + count: 10, + }); + + requests.at(-1)!.answer(); + await flush(); + + expect(ids(combo.data)).to.eql(['DUB', 'ALB', 'LIS']); + }); + + it('pages with the case of the loaded search, and reads the case again on refresh', async () => { + combo.filteringOptions = { caseSensitive: true }; + bind({ debounce: 0 }); + await open(); + requests[0].answer(); + await flush(); + + combo.filteringOptions = { caseSensitive: false }; + combo.dispatchEvent( + new CustomEvent('igcDataRequest', { + detail: { startIndex: 10, count: 20 }, + }) + ); + + expect(requests.at(-1)!.request.caseSensitive).to.be.true; + + const refresh = binding.refresh(); + + expect(requests.at(-1)!.request.caseSensitive).to.be.false; + + requests.at(-1)!.answer(); + await refresh; + }); + + describe('after a load with a case that the app set', () => { + let icon: HTMLElement; + + beforeEach(async () => { + combo.caseSensitiveIcon = true; + bind({ debounce: 0 }); + await open(); + requests[0].answer(); + await flush(); + icon = combo.renderRoot.querySelector('[part~="case-icon"]')!; + }); + + it('loads again when the icon gives back the case of refresh()', async () => { + simulateInput(searchNative, { value: 'b' }); + clock.tick(0); + requests.at(-1)!.answer(); + await flush(); + + combo.filteringOptions = { caseSensitive: true }; + const refresh = binding.refresh(); + + expect(requests.at(-1)!.request.caseSensitive).to.be.true; + + requests.at(-1)!.answer(); + await refresh; + const before = requests.length; + + simulateClick(icon); + clock.tick(0); + + expect(requests).lengthOf(before + 1); + expect(requests.at(-1)!.request).to.include({ + searchText: 'b', + caseSensitive: false, + }); + }); + + it('loads again when the icon gives back the case of a debounced load', async () => { + simulateInput(searchNative, { value: 'b' }); + combo.filteringOptions = { caseSensitive: true }; + clock.tick(0); + + expect(requests.at(-1)!.request.caseSensitive).to.be.true; + + simulateClick(icon); + clock.tick(0); + + expect(requests.at(-1)!.request).to.include({ + searchText: 'b', + caseSensitive: false, + }); + }); + + it('does not load again for a case change of a whitespace text', async () => { + const before = requests.length; + + simulateInput(searchNative, { value: ' ' }); + simulateClick(icon); + clock.tick(0); + + expect(requests).lengthOf(before); + }); + }); + it('loads a search of a closed list on the next opening', async () => { bind({ debounce: 0 }); await open(); @@ -477,6 +597,7 @@ describe('Combo data source binding', () => { expect(requests.at(-1)!.request).to.eql({ searchText: '', + caseSensitive: false, startIndex: 0, count: 10, }); diff --git a/src/extras/combo-data-source.ts b/src/extras/combo-data-source.ts index 1eecfc48d..04a147668 100644 --- a/src/extras/combo-data-source.ts +++ b/src/extras/combo-data-source.ts @@ -7,6 +7,8 @@ import type { VirtualScrollDataRequest } from '../components/virtualization/type export interface ComboDataRequest { /** The search text, trimmed. It is empty for all the items. */ searchText: string; + /** Whether the search is case sensitive: `filteringOptions.caseSensitive` of the combo. */ + caseSensitive: boolean; /** The index in the results of the first item to load. */ startIndex: number; /** @@ -17,6 +19,9 @@ export interface ComboDataRequest { count: number; } +/** A search, without its page. */ +type Search = Pick; + /** One page of the results of a search. */ export interface ComboDataPage { /** The items of the page. */ @@ -85,8 +90,8 @@ export interface ComboDataBinding { * @example * ```ts * const binding = bindComboDataSource(combo, { - * load: async ({ searchText, startIndex, count }, signal) => { - * const query = `q=${encodeURIComponent(searchText)}&skip=${startIndex}&top=${count}`; + * load: async ({ searchText, caseSensitive, startIndex, count }, signal) => { + * const query = `q=${encodeURIComponent(searchText)}&case=${caseSensitive}&skip=${startIndex}&top=${count}`; * const response = await fetch(`/api/cities?${query}`, { signal }); * const { items, total } = await response.json(); * return { items, totalCount: total }; @@ -109,10 +114,11 @@ export function bindComboDataSource( const { mode } = combo.filteringOptions; // The values asked for: in flight, or not found by the source. const requested = new Set(); - // The search of the next first page, and the search of the items in `data`. - let searchText = ''; - let loadedSearchText = ''; - // Whether the search text changed while the list was closed. + const caseOf = (): boolean => combo.filteringOptions.caseSensitive ?? false; + // The search of the latest first page, and the search of the items in `data`. + let latest: Search = { searchText: '', caseSensitive: caseOf() }; + let loadedSearch = latest; + // Whether the search changed while the list was closed. let stale = false; let request: AbortController | undefined; @@ -134,12 +140,16 @@ export function bindComboDataSource( const current = new AbortController(); request = current; showBusy(); - // A next page continues the search of the loaded items. - const text = startIndex ? loadedSearchText : searchText; + // A next page continues the loaded search. A first page reads the case now, + // so `refresh()` applies a case that the app set. + if (!startIndex) { + latest = { ...latest, caseSensitive: caseOf() }; + } + const query = startIndex ? loadedSearch : latest; try { const page = await source.load( - { searchText: text, startIndex, count }, + { ...query, startIndex, count }, current.signal ); @@ -151,7 +161,7 @@ export function bindComboDataSource( return; } - loadedSearchText = text; + loadedSearch = query; combo.data = startIndex ? [...combo.data, ...page.items] : page.items; if (!startIndex || page.totalCount != null) { @@ -235,13 +245,21 @@ export function bindComboDataSource( const handleSearchInput = ({ detail, }: CustomEvent): void => { - const text = detail.searchText.trim(); - - if (text === searchText) { + const next: Search = { + searchText: detail.searchText.trim(), + caseSensitive: caseOf(), + }; + + // The case-sensitive icon reports the same text with a new case. An empty + // text matches all items in any case. + if ( + next.searchText === latest.searchText && + (!next.searchText || next.caseSensitive === latest.caseSensitive) + ) { return; } - searchText = text; + latest = next; cancel(); // A search in a closed list waits for the next opening. diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 02b3c1a74..87c926257 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -15,6 +15,7 @@ import { import { bindComboDataSource, type ComboDataBinding, + type ComboDataRequest, type ComboDataSource, } from 'igniteui-webcomponents/extras'; import { registerMaterialIcons } from './story-icons.js'; @@ -1469,20 +1470,22 @@ const europeanCities: City[] = ( /** A fake server. It searches the names and returns new objects, as parsed JSON does. */ async function fetchCities( - searchText: string, - skip: number, - top: number, + { searchText, caseSensitive, startIndex, count }: ComboDataRequest, latency: number ): Promise<{ items: City[]; total: number }> { await delay(latency); - const text = searchText.toLocaleLowerCase(); + const fold = (text: string) => + caseSensitive ? text : text.toLocaleLowerCase(); + const text = fold(searchText); const results = europeanCities.filter(({ name }) => - name.toLocaleLowerCase().includes(text) + fold(name).includes(text) ); return { - items: results.slice(skip, skip + top).map((city) => ({ ...city })), + items: results + .slice(startIndex, startIndex + count) + .map((city) => ({ ...city })), total: results.length, }; } @@ -1493,7 +1496,7 @@ export const RemoteData: Story = { docs: { description: { story: - 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, twenty cities at a time. Open the list, scroll to its end to load more, then type to search. The list has a skeleton row for each city to load, so the scrollbar shows all the results, and a spinner takes the place of the toggle icon while a search waits or a page loads. Press End to jump to the last city: one request loads all the cities up to it. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The second combo has a slower server and styles its loading parts: `::part(loading)` sets the color and the width of the spinner, and `::part(placeholder)` sets the color of the skeleton rows. The server log shows each request and answer of both combos.', + 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras`, twenty cities at a time. Open the list, scroll to its end to load more, then type to search. The list has a skeleton row for each city to load, so the scrollbar shows all the results, and a spinner takes the place of the toggle icon while a search waits or a page loads. Press End to jump to the last city: one request loads all the cities up to it. Select a few cities and search for others: the selection stays, also for the cities that the new results do not hold. The case icon of the first combo makes the server search match the case, and loads the results again. The second combo has a slower server and styles its loading parts: `::part(loading)` sets the color and the width of the spinner, and `::part(placeholder)` sets the color of the skeleton rows. The server log shows each request and answer of both combos.', }, }, }, @@ -1511,18 +1514,16 @@ export const RemoteData: Story = { let binding: ComboDataBinding | undefined; const source: ComboDataSource = { - load: async ({ searchText, startIndex, count }, signal) => { - const query = searchText ? `"${searchText}"` : 'all cities'; + load: async (request, signal) => { + const { searchText, caseSensitive, startIndex, count } = request; + const query = searchText + ? `"${searchText}"${caseSensitive ? ' (match case)' : ''}` + : 'all cities'; write( `${name}: request ${startIndex + 1}–${startIndex + count} of ${query}` ); - const { items, total } = await fetchCities( - searchText, - startIndex, - count, - latency - ); + const { items, total } = await fetchCities(request, latency); write( signal.aborted @@ -1602,6 +1603,7 @@ export const RemoteData: Story = { label="Cities" placeholder="Pick cities" placeholder-search="Search cities" + case-sensitive-icon value-key="id" display-key="name" @igcChange=${onChange('Cities')} From 6bd45c71276d8777723a336cf79b9946de3dc1ab Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 6 Nov 2026 06:59:59 +0200 Subject: [PATCH 27/32] feat(combo): indent the items under group header options - With groupSelection, the items are one level in under their header, as in the tree. The select-all option stays at the header level. - The new --ig-combo-item-indent CSS property changes the indent. - Stories: sync the caseSensitiveIcon description with its JSDoc. --- CHANGELOG.md | 2 +- public-api.json | 1 + src/components/combo/combo-selection.spec.ts | 34 ++++++++++++++ src/components/combo/combo.ts | 2 + src/components/combo/controllers/data.ts | 16 ++++--- src/components/combo/spec.md | 47 +++++++++++-------- .../combo/themes/combo-item.base.scss | 5 ++ src/components/combo/themes/combo.base.scss | 5 ++ .../combo/themes/shared/combo.indigo.scss | 5 ++ stories/combo.stories.ts | 5 +- 10 files changed, 93 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da4bb13d..ce946ae51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. `load` gets the search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. The items are one level in under their header, as in the tree, and `--ig-combo-item-indent` changes the indent. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `selectionCommit` set to `confirm` holds the changes of the user while the list is open, in multiple selection mode. A toggle changes a pending selection and emits the cancelable `igcPendingChange`. Enter applies the pending selection with one `igcChange`, whose new `commit` type lists the items that it selects or deselects. Any other close drops it. While the list is open, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change the pending selection. The footer shows Cancel and Apply, with the texts of the `combo_cancel` and `combo_apply` resource strings. Apply applies the pending selection as Enter does, and Cancel drops it. The `actions`, `cancel-button` and `apply-button` parts style them. Content in the `footer` slot replaces them: `pendingValue` gives the pending values, `commitSelection()` applies them, and `cancelSelection()` puts `value` back. These methods emit no events. The new `ComboSelectionCommit` type describes the property. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input), `selection` (an item click in single selection), or `apply` and `cancel` (the buttons of the confirm mode). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) diff --git a/public-api.json b/public-api.json index 0db3043f9..b3184ad20 100644 --- a/public-api.json +++ b/public-api.json @@ -1360,6 +1360,7 @@ "toggle-icon" ], "cssProperties": [ + "--ig-combo-item-indent", "--ig-helper-min-lines" ] }, diff --git a/src/components/combo/combo-selection.spec.ts b/src/components/combo/combo-selection.spec.ts index 5b370b462..da82d20a0 100644 --- a/src/components/combo/combo-selection.spec.ts +++ b/src/components/combo/combo-selection.spec.ts @@ -378,6 +378,40 @@ describe('Combo', () => { ); }); + it('indents the items one level under the group header options, by `--ig-combo-item-indent`', async () => { + const start = (item: Element) => + checkboxOf(item).getBoundingClientRect().left; + const indent = (item: Element) => + getComputedStyle(item).getPropertyValue('--_combo-item-indent'); + + combo.showSelectAll = true; + await comboStable(combo); + + const [header] = headers(); + const [selectAll, first] = items(combo); + + expect(selectAll.part.contains('select-all')).to.be.true; + expect(start(selectAll)).to.equal(start(header)); + expect(start(first) - start(header)).to.be.closeTo(16, 0.5); + expect(list.scrollWidth).to.equal(list.clientWidth); + + combo.style.setProperty('--ig-combo-item-indent', '40px'); + + expect(start(first) - start(header)).to.be.closeTo(40, 0.5); + expect(start(selectAll)).to.equal(start(header)); + + combo.groupSelection = false; + await comboStable(combo); + + expect(indent(items(combo)[1])).to.be.empty; + + combo.groupSelection = true; + combo.groupKey = undefined; + await comboStable(combo); + + expect(indent(items(combo)[1])).to.be.empty; + }); + it('selects the shown items of the group, then deselects them, with one igcChange each', async () => { await search('o'); diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 9631396c3..40de438b7 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -156,6 +156,7 @@ const i18n = { * @csspart loading - The indeterminate circular progress that replaces the toggle icon while `loading` is set. It has the color of the text. * @csspart placeholder - Added to the item part of each item to load, which `totalItemCount` adds. Its skeleton has the color of the text. * + * @cssproperty --ig-combo-item-indent - The indent of the items under a group header option of `groupSelection`. Default is the indent of the tree: 12, 16 or 24 px by size, and 24 px in Indigo. * @cssproperty --ig-helper-min-lines - The minimum number of lines below the control for the helper text and the validation messages: `1`, `2` or `3`. A message that shows then uses space that is already there, so the content below does not move. Set it on a container, such as a form, to apply it to all the controls in it. Default is `0`. * * @cssstate ig-invalid - Set while the control shows its invalid state, that is while `invalid` returns `true`. @@ -1573,6 +1574,7 @@ export default class IgcComboComponent< aria-multiselectable=${!this.singleSelect} id="dropdown" part="list" + ?data-selectable-groups=${this._state.selectableGroups} role="listbox" tabindex="0" aria-labelledby="target" diff --git a/src/components/combo/controllers/data.ts b/src/components/combo/controllers/data.ts index a7a4df2fb..c683e7b58 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -102,8 +102,12 @@ export class DataState implements ReactiveController { } /** Whether the group headers are options. See `groupSelection`. */ - private get _selectableGroups(): boolean { - return this._host.groupSelection && !this._host.singleSelect; + public get selectableGroups(): boolean { + return ( + Boolean(this._host.groupKey) && + this._host.groupSelection && + !this._host.singleSelect + ); } /** The index of the first option, or `-1`. See {@link isOption}. */ @@ -236,18 +240,18 @@ export class DataState implements ReactiveController { //#region Public API for host component - /** Whether the record is an option: not a group header, unless {@link _selectableGroups}. */ + /** Whether the record is an option: not a group header, unless {@link selectableGroups}. */ public isOption(record: ComboRecord): boolean { - return !record.header || this._selectableGroups; + return !record.header || this.selectableGroups; } /** * Whether the record is an option that toggles the shown items after it: - * the select-all option, or a group header with {@link _selectableGroups}. + * the select-all option, or a group header with {@link selectableGroups}. */ public isToggle(record: ComboRecord): boolean { return ( - record.kind === 'select-all' || (record.header && this._selectableGroups) + record.kind === 'select-all' || (record.header && this.selectableGroups) ); } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 2066c054a..f22c985bb 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -79,6 +79,7 @@ | 17 | 2026-11-02 | A `focus()` call before the first render applies after the render | | 18 | 2026-11-06 | The select-all option sends one cancelable `igcChange`; a canceled one changes no item; a group has no entry in the selection; remote data: the application removes the selected items that the server deleted | | 19 | 2026-11-06 | Remote mode shows the case-sensitive icon; a case change reports the search text again | +| 20 | 2026-11-06 | With group selection, the items are one level in under their header, as in the tree | ## Overview @@ -269,7 +270,9 @@ With a `groupKey`, the combo groups the options by that field. The items without `data`. The comparison uses the collation of `locale`, also after a change of the global locale. In multiple selection, `group-selection` makes each group header an option. It works as the select-all option for the -shown items of its group. The header keeps its text style, and the arrow keys and Home stop on it. +shown items of its group. The header keeps its text style, and the arrow keys and Home stop on it. The items are one +level in under their header, as in the tree: 12, 16 or 24 px by size, and 24 px in Indigo. `--ig-combo-item-indent` +changes the indent. The select-all option stays at the level of the headers. A group has no entry in the selection: `value`, `selection`, the form value and `igcChange` hold only items. The header shows the state of its shown items at each render. So an item that the group shows later, after a change of `data` or @@ -695,9 +698,10 @@ footer button, the focus moves to the main input. The closed list cannot keep th ### CSS custom properties -| Property | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--ig-helper-min-lines` | The minimum number of lines below the control for the helper text and the validation messages: `1`, `2` or `3`. A message that shows then uses space that is already there, so the content below does not move. Set it on a container, such as a form, to apply it to all the controls in it. Default is `0`. | +| Property | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--ig-combo-item-indent` | The indent of the items under a group header option of `groupSelection`. Default is the indent of the tree: 12, 16 or 24 px by size, and 24 px in Indigo. | +| `--ig-helper-min-lines` | The minimum number of lines below the control for the helper text and the validation messages: `1`, `2` or `3`. A message that shows then uses space that is already there, so the content below does not move. Set it on a container, such as a form, to apply it to all the controls in it. Default is `0`. | ## Test scenarios @@ -842,42 +846,45 @@ state, the data requests and the selection model without a browser fixture. 38. In multiple selection, the group headers are options with a checkbox and `aria-selected`. They count in `aria-posinset` and `aria-setsize`, and the accessibility audit passes. In single selection, they are headers again, and the items take their positions back. -39. A click on a header selects the shown items of its group, and a second click deselects them, with one `igcChange` +39. The items are one level in under the header options, and `--ig-combo-item-indent` changes the indent. The + select-all option is at the level of the headers, and the list does not scroll sideways. Without group selection + or `groupKey`, the items have no indent. +40. A click on a header selects the shown items of its group, and a second click deselects them, with one `igcChange` each. The list stays open. With some of them selected, the checkbox is indeterminate with the `indeterminate` part, and a click selects only the others. -40. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. A click on the +41. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. A click on the select-all option also selects the headers. When group selection goes off, an active header stops being active. ### Confirm mode -41. A click and Space change the pending selection with `igcPendingChange`. The list and `pendingValue` show it, and +42. A click and Space change the pending selection with `igcPendingChange`. The list and `pendingValue` show it, and `value`, `selection` and the input text keep the selection. A canceled `igcPendingChange` keeps the pending selection. -42. Enter applies the pending selection with one `igcChange` of the `commit` type with the changed items. Then it +43. Enter applies the pending selection with one `igcChange` of the `commit` type with the changed items. Then it closes the list with the `enter` trigger. This also occurs in the search input and without an active option. Without a change, it closes the list with no `igcChange`. A canceled `igcChange` keeps the list open with the pending selection. A handler that changes `newValue` does not change `value`. -43. Escape, a click on the input, `hide()` and `open` set to `false` drop the pending selection, with no `igcChange`. -44. From the moment the list opens, the selection methods change the pending selection, also with an item that `data` +44. Escape, a click on the input, `hide()` and `open` set to `false` drop the pending selection, with no `igcChange`. +45. From the moment the list opens, the selection methods change the pending selection, also with an item that `data` does not hold. From the moment the list closes, they change the value. `commitSelection()` applies the pending selection with no events and keeps the list open. `cancelSelection()` and a new `value` replace it, and the same `value` keeps it. -45. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending +46. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending selection by value, and adds the values of `value` that it gives a record. A value without a record stays in `value` until a commit, and `select()` gives it a record in the input text. The select-all option and the group header options change the pending selection. In single selection, each change applies at once. -46. The footer shows Cancel and Apply with the `combo_cancel` and `combo_apply` texts, and the accessibility audit +47. The footer shows Cancel and Apply with the `combo_cancel` and `combo_apply` texts, and the accessibility audit passes. Footer content replaces them, and the immediate mode and single selection do not show them. -47. Apply applies the pending selection with one `igcChange`, closes the list with the `apply` trigger, and moves the +48. Apply applies the pending selection with one `igcChange`, closes the list with the `apply` trigger, and moves the focus to the main input. A canceled `igcChange` keeps the list open. Cancel drops the pending selection and closes the list with the `cancel` trigger. -48. Tab moves from the list to Cancel and Apply, and Tab from Apply closes the list. +49. Tab moves from the list to Cancel and Apply, and Tab from Apply closes the list. ### Form integration tests -49. The combo is form associated, submits its selection, and resets with the form, also the input of single selection. -50. It shows the disabled state of an ancestor `fieldset`. -51. It obeys the required and custom constraints, and passes the accessibility audit while a submit fails on the +50. The combo is form associated, submits its selection, and resets with the form, also the input of single selection. +51. It shows the disabled state of an ancestor `fieldset`. +52. It obeys the required and custom constraints, and passes the accessibility audit while a submit fails on the required constraint. The form-associated mixin gives the focus after the form checks and the rules of the `invalid` event. The @@ -885,14 +892,14 @@ The form-associated mixin gives the focus after the form checks and the rules of ### defaultValue -52. Form integration in single selection, in multiple selection, and with data that comes late. -53. Validation against the default value. +53. Form integration in single selection, in multiple selection, and with data that comes late. +54. Validation against the default value. ### Validation message slots `runValidationContainerTests` makes these tests. -54. The `value-missing`, `custom-error` and `invalid` slots show for their state. +55. The `value-missing`, `custom-error` and `invalid` slots show for their state. ## Assumptions and limitations diff --git a/src/components/combo/themes/combo-item.base.scss b/src/components/combo/themes/combo-item.base.scss index 3c1b49b1d..be1ba87d3 100644 --- a/src/components/combo/themes/combo-item.base.scss +++ b/src/components/combo/themes/combo-item.base.scss @@ -29,6 +29,11 @@ $dropdown-theme: dropdown-theme.$material; color: var-get($dropdown-theme, 'header-text-color'); } +// The combo sets the indent under a group header option. +[part='prefix'] { + margin-inline-start: var(--_combo-item-indent, 0); +} + igc-checkbox { pointer-events: none; } diff --git a/src/components/combo/themes/combo.base.scss b/src/components/combo/themes/combo.base.scss index 89e684e96..ec4dd9492 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -133,6 +133,11 @@ inset-inline-start: 0; } +// Under group header options, the items are one level in, as in the tree. +[data-selectable-groups] [part~='item']:not([part~='select-all']) { + --_combo-item-indent: var(--ig-combo-item-indent, #{sizable(rem(12px), rem(16px), rem(24px))}); +} + // The skeleton of an item to load: the box of the checkbox and a bar. [part~='placeholder'] { --_skeleton: color-mix(in srgb, currentcolor 16%, transparent); diff --git a/src/components/combo/themes/shared/combo.indigo.scss b/src/components/combo/themes/shared/combo.indigo.scss index ba23e8ec4..7f51aa866 100644 --- a/src/components/combo/themes/shared/combo.indigo.scss +++ b/src/components/combo/themes/shared/combo.indigo.scss @@ -117,3 +117,8 @@ $dropdown-theme: dropdown-theme.$indigo; background: transparent; } } + +// The indent of the Indigo tree. +[data-selectable-groups] [part~='item']:not([part~='select-all']) { + --_combo-item-indent: var(--ig-combo-item-indent, #{rem(24px)}); +} diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 87c926257..e228bab89 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -138,7 +138,7 @@ const metadata: Meta = { caseSensitiveIcon: { type: 'boolean', description: - 'Whether the search input shows the icon that toggles case-sensitive\nfiltering.', + 'Whether the search input shows the icon that toggles case-sensitive\nfiltering. In remote filtering mode, read `filteringOptions.caseSensitive`\nwhen you send the search to the server.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -297,7 +297,8 @@ interface IgcComboArgs { groupSorting: 'none' | 'asc' | 'desc'; /** * Whether the search input shows the icon that toggles case-sensitive - * filtering. + * filtering. In remote filtering mode, read `filteringOptions.caseSensitive` + * when you send the search to the server. */ caseSensitiveIcon: boolean; /** From f6ed20ff4918d38cd49c15a7dfac840b52ff5dda Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 6 Nov 2026 07:28:26 +0200 Subject: [PATCH 28/32] fix(circular-progress): respect reduced motion and forced colors - With reduced motion, the indeterminate animation runs at a third of its speed, and a new value shows at once. - In forced colors, the ring takes the text color and the track is GrayText, also with a custom gradient. - The host is an inline block with no line height, so its box is the ring. The combo and the LazyLoading story drop their display: flex. - Tree, chip and stepper size the ring with --ig-circular-bar-diameter. --- CHANGELOG.md | 4 + .../chip/themes/shared/chip.common.scss | 2 +- src/components/combo/themes/combo.base.scss | 5 +- .../progress/circular-progress.spec.ts | 148 ++++++++++++++++++ src/components/progress/spec.md | 34 +++- .../circular/circular.progress.base.scss | 37 ++++- .../shared/circular.progress.bootstrap.scss | 2 +- .../shared/circular.progress.fluent.scss | 2 +- .../stepper/themes/step/step.base.scss | 2 +- src/components/tree/themes/item.base.scss | 2 +- stories/combo.stories.ts | 2 - 11 files changed, 221 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce946ae51..6f6d64ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Circular progress, Linear progress - The default `aria-valuetext` is now the percentage that the label shows. Before, it was the value with a percent sign: "50%" for 50 of 200, while the label showed 25%. [#2427](https://github.com/IgniteUI/igniteui-webcomponents/pull/2427) - The percentage is now correct. It rounds to two decimals: 12.996% shows as 13%, not "12.100%", and 1.005% shows as 1.01%, not 1%. A fractional `max` also works: 0.25 of 0.5 shows as 50%, not 25%. [#2427](https://github.com/IgniteUI/igniteui-webcomponents/pull/2427) +- #### Circular progress + - With `prefers-reduced-motion: reduce`, the indeterminate animation now runs at a third of its speed, and a new value shows at once. Before, the motion did not change. + - In forced colors, the ring now has the text color and the track is `GrayText`, also with a custom gradient. Before, the ring kept the theme colors, so the track was almost invisible. + - The element is now an inline block of the size of the ring. Before, a line box around the ring made the element taller than the ring, so a small ring sat above the center of its parent. - #### Color picker, Combo, Date picker, Date range picker, Dropdown, Select, Slider, Tooltip - In browsers without CSS anchor positioning, a popup inside an ancestor with CSS `zoom` is now placed at its anchor. Before, the zoom moved it away from the anchor, and its arrow was off center. - In browsers without CSS anchor positioning, a popup whose anchor is in a modal dialog or another top-layer element now stays at the anchor while the page scrolls. Before, it moved with the page for a frame. diff --git a/src/components/chip/themes/shared/chip.common.scss b/src/components/chip/themes/shared/chip.common.scss index f2f87e2d5..8c2fe70bb 100644 --- a/src/components/chip/themes/shared/chip.common.scss +++ b/src/components/chip/themes/shared/chip.common.scss @@ -68,7 +68,7 @@ $theme: $material; } ::slotted(igc-circular-progress) { - --diameter: #{sizable(rem(14px), rem(18px), rem(24px))}; + --ig-circular-bar-diameter: #{sizable(rem(14px), rem(18px), rem(24px))}; } } diff --git a/src/components/combo/themes/combo.base.scss b/src/components/combo/themes/combo.base.scss index ec4dd9492..1019c524c 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -117,14 +117,11 @@ visibility: hidden; } -// The size of the icon, in the color of the text. A flex box has no line box -// around the ring, so the ring is on the center of the icon. +// The size of the icon, in the color of the text. [part~='loading'] { --ig-circular-bar-diameter: #{rem(16px)}; --ig-circular-bar-fill-color-default: currentcolor; --ig-circular-bar-track-color: color-mix(in srgb, currentcolor 16%, transparent); - - display: flex; } [part~='group-header'], diff --git a/src/components/progress/circular-progress.spec.ts b/src/components/progress/circular-progress.spec.ts index 8d58598ee..06295b11c 100644 --- a/src/components/progress/circular-progress.spec.ts +++ b/src/components/progress/circular-progress.spec.ts @@ -2,12 +2,16 @@ import { elementUpdated, expect, fixture, + fixtureSync, html, nextFrame, } from '@open-wc/testing'; +import { emulateMedia } from '@web/test-runner-commands'; import { internalsOf } from '#internals/controllers/internals.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { allowMotion } from '#internals/testing/helpers.spec.js'; import { firstOf } from '#internals/utils/arrays.js'; +import { configureTheme } from '#theming/config.js'; import IgcCircularGradientComponent from './circular-gradient.js'; import IgcCircularProgressComponent from './circular-progress.js'; @@ -249,6 +253,150 @@ describe('Circular progress component', () => { }); }); + describe('Styles', () => { + const style = (part: string) => + getComputedStyle(progress.renderRoot.querySelector(`[part~="${part}"]`)!); + + /** The indeterminate durations of the ring and of its fill, by theme. */ + function durations(): Record { + const result: Record = {}; + + try { + for (const theme of ['bootstrap', 'material', 'fluent'] as const) { + configureTheme(theme); + result[theme] = [ + style('svg').animationDuration, + style('fill').animationDuration, + ]; + } + } finally { + configureTheme('bootstrap'); + } + + return result; + } + + async function setIndeterminate(): Promise { + progress.indeterminate = true; + await elementUpdated(progress); + } + + beforeEach(async () => { + progress = await fixture(html` + + `); + }); + + it('takes the box of the ring, and follows text-align as a block', () => { + const ring = getDOM(progress).svg; + const center = ({ left, width }: DOMRect) => left + width / 2; + + expect(progress.getBoundingClientRect().height).to.equal( + ring.getBoundingClientRect().height + ); + + const grid = fixtureSync( + html`
              ` + ); + grid.append(progress); + + expect(progress.getBoundingClientRect().height).to.equal( + ring.getBoundingClientRect().height + ); + expect(center(ring.getBoundingClientRect())).to.be.closeTo( + center(grid.getBoundingClientRect()), + 0.5 + ); + }); + + it('runs the indeterminate animation at a third of its speed with reduced motion', async () => { + await setIndeterminate(); + + expect(durations()).to.eql({ + bootstrap: ['2.25s', '0s'], + material: ['9s', '4.5s'], + fluent: ['0s', '6s'], + }); + }); + + it('shows a new value at once with reduced motion', () => { + expect(style('fill').transitionDuration).to.equal('0s'); + }); + + describe('with motion', () => { + allowMotion(); + + it('runs the indeterminate animation at full speed', async () => { + await setIndeterminate(); + + expect(durations()).to.eql({ + bootstrap: ['0.75s', '0s'], + material: ['3s', '1.5s'], + fluent: ['0s', '2s'], + }); + }); + + it('animates a new value', () => { + expect(style('fill').transitionDuration).to.equal('0.5s'); + }); + }); + + describe('in forced colors', () => { + before(() => emulateMedia({ forcedColors: 'active' })); + after(() => emulateMedia({ forcedColors: 'none' })); + + function expectSystemColors(stops: Iterable): void { + const reference = fixtureSync( + html`
              ` + ); + + expect(style('track').stroke).to.equal( + getComputedStyle(reference).color + ); + + for (const stop of stops) { + expect(getComputedStyle(stop).stopColor).to.equal( + getComputedStyle(progress).color + ); + expect(getComputedStyle(stop).stopOpacity).to.equal('1'); + } + } + + it('draws the ring in the text color and the track in GrayText', () => { + // Not the text color, so the test fails without the rule. + progress.style.setProperty( + '--ig-circular-bar-fill-color-default', + 'rgb(255, 0, 0)' + ); + + expectSystemColors(progress.renderRoot.querySelectorAll('stop')); + }); + + it('draws a custom gradient in the text color', async () => { + progress = await fixture(html` + + + + `); + const custom = + progress.renderRoot.querySelectorAll( + 'stop:not([part])' + ); + + expect(custom).lengthOf(1); + expectSystemColors(custom); + }); + }); + }); + describe('Accessibility', () => { const getARIA = (name: T) => internalsOf(progress)?.getARIA(name); diff --git a/src/components/progress/spec.md b/src/components/progress/spec.md index e1a49f3a0..172d4bb5d 100644 --- a/src/components/progress/spec.md +++ b/src/components/progress/spec.md @@ -47,6 +47,7 @@ | 2 | 2026-09-23 | Limit the variant parts to the linear progress | | 3 | 2026-10-01 | Announce the shown percentage, round it to two decimals | | 4 | 2026-10-21 | API descriptions aligned with the JSDoc. | +| 5 | 2026-11-06 | The circular indicator: reduced motion, forced colors, the box of the ring | ## Overview @@ -157,6 +158,11 @@ The default label shows the value as a percentage of `max`, rounded to two decim `striped` gives the fill a striped look, and `labelAlign` places the default label at `top`, `bottom`, `top-start`, `top-end`, `bottom-start` or `bottom-end`. +#### Circular specifics + +The circular indicator is an inline block with no line height, so its box is the size of the ring. As a block, the +ring still follows `text-align`. `--ig-circular-bar-diameter` sets the size of the ring. + #### Circular gradients Projecting `igc-circular-gradient` elements in the `gradient` slot replaces the solid fill. Each one becomes an @@ -272,18 +278,27 @@ Both suites are structured the same way, so the scenarios below apply to both in 13. The circular indicator renders slotted gradient stops. +### Styles + +14. The circular indicator takes the box of the ring, and as a block it follows `text-align`. +15. With reduced motion, the indeterminate animation of the circular indicator runs at a third of its speed in each + theme, and a new value shows at once. +16. With motion, the same animations run at full speed, and a new value animates. +17. In forced colors, the ring and a custom gradient of the circular indicator have the text color, and the track is + `GrayText`. + ### ARIA state -14. The value text is the shown percentage of `max`, for example `25%` for `50` of `200`. -15. The value text keeps two decimals (`1.05%`) and rounds as the label does (`13%` for `12.996`, `1.01%` for +18. The value text is the shown percentage of `max`, for example `25%` for `50` of `200`. +19. The value text keeps two decimals (`1.05%`) and rounds as the label does (`13%` for `12.996`, `1.01%` for `1.005`), also in the fill. -16. The value text handles a fractional `max` (`50%` for `0.25` of `0.5`) and a `max` of `0` (`0%`). -17. The value text is the `labelFormat` text when one is set. -18. The indeterminate mode reports no value and no value text. +20. The value text handles a fractional `max` (`50%` for `0.25` of `0.5`) and a `max` of `0` (`0%`). +21. The value text is the `labelFormat` text when one is set. +22. The indeterminate mode reports no value and no value text. ### Issues -19. Issue #1083 — a value set at initialization is not reset by the first render. +23. Issue #1083 — a value set at initialization is not reset by the first render. ### Not covered by the suite @@ -315,6 +330,13 @@ Both suites are structured the same way, so the scenarios below apply to both in Not applicable; the indicators are not interactive. +### Reduced motion and forced colors + +- With `prefers-reduced-motion: reduce`, the indeterminate animation of the circular indicator runs at a third of + its speed, so it still shows activity, and a new value shows at once. +- Forced colors do not change SVG paint. So in forced colors, the ring of the circular indicator and a custom + gradient take the text color, and the track is `GrayText`. + ### Right to Left support The components work in a Right-to-Left context without additional setup or configuration. The direction of the diff --git a/src/components/progress/themes/circular/circular.progress.base.scss b/src/components/progress/themes/circular/circular.progress.base.scss index 7e9e28b02..3e9a457de 100644 --- a/src/components/progress/themes/circular/circular.progress.base.scss +++ b/src/components/progress/themes/circular/circular.progress.base.scss @@ -2,7 +2,16 @@ @use '../animations' as *; @use '../vars' as *; +// With no line height, the box of the host is the ring. As a block, the ring +// still follows text-align. +:host { + display: inline-block; + line-height: 0; +} + [part~='base'] { + --_motion-factor: 1; + display: inline-flex; align-items: center; justify-content: center; @@ -10,6 +19,29 @@ position: relative; } +// Slow the indeterminate animation, but do not stop it, so it still shows +// activity. !important overrides the inline duration from `animationDuration`. +@media (prefers-reduced-motion: reduce) { + [part~='base'] { + --_motion-factor: 3; + --_transition-duration: 0ms !important; + } +} + +// Forced colors do not change SVG paint. `stop` also matches a custom gradient. +@media (forced-colors: active) { + :host { + [part~='track'] { + stroke: GrayText; + } + + stop { + stop-color: currentcolor; + stop-opacity: 1; + } + } +} + [part~='label'] { display: flex; align-items: center; @@ -85,11 +117,12 @@ [part~='svg'][part~='indeterminate'] { transform-origin: 50% 50%; - animation: 3s linear 0s infinite $animation-direction none running igc-rotate-center; + animation: calc(3s * var(--_motion-factor)) linear 0s infinite $animation-direction none running igc-rotate-center; [part~='fill'] { stroke-dashoffset: calc(#{$circumference} * 2); - animation: igc-indeterminate-accordion 1.5s cubic-bezier(0, 0.085, 0.68, 0.53) $animation-direction infinite; + animation: igc-indeterminate-accordion calc(1.5s * var(--_motion-factor)) cubic-bezier(0, 0.085, 0.68, 0.53) + $animation-direction infinite; } } diff --git a/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss b/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss index f5b2f19d2..32bc2ff4f 100644 --- a/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss +++ b/src/components/progress/themes/circular/shared/circular.progress.bootstrap.scss @@ -17,7 +17,7 @@ $theme: $bootstrap; } [part~='svg'][part~='indeterminate'] { - animation-duration: .75s; + animation-duration: calc(.75s * var(--_motion-factor)); [part~='fill'] { stroke-dashoffset: 60% !important; diff --git a/src/components/progress/themes/circular/shared/circular.progress.fluent.scss b/src/components/progress/themes/circular/shared/circular.progress.fluent.scss index 99e1bbafd..67482e3e9 100644 --- a/src/components/progress/themes/circular/shared/circular.progress.fluent.scss +++ b/src/components/progress/themes/circular/shared/circular.progress.fluent.scss @@ -26,7 +26,7 @@ $theme: $fluent; [part~='fill'] { stroke-linecap: round; - animation: 2s linear 0s infinite normal none running igc-indeterminate-circular-fluent + animation: calc(2s * var(--_motion-factor)) linear 0s infinite normal none running igc-indeterminate-circular-fluent; } } diff --git a/src/components/stepper/themes/step/step.base.scss b/src/components/stepper/themes/step/step.base.scss index 2f60771fe..40a0fc501 100644 --- a/src/components/stepper/themes/step/step.base.scss +++ b/src/components/stepper/themes/step/step.base.scss @@ -236,7 +236,7 @@ } ::slotted([slot='indicator']) { - --diameter: calc(var(--indicator-size) - #{rem(2px)}); + --ig-circular-bar-diameter: calc(var(--indicator-size) - #{rem(2px)}); --ig-icon-size: #{rem(18px)}; } diff --git a/src/components/tree/themes/item.base.scss b/src/components/tree/themes/item.base.scss index 1cd03ae9f..98ef1192c 100644 --- a/src/components/tree/themes/item.base.scss +++ b/src/components/tree/themes/item.base.scss @@ -9,7 +9,7 @@ contain: layout; igc-circular-progress { - --diameter: #{sizable(rem(18px), rem(20px), rem(24px))}; + --ig-circular-bar-diameter: #{sizable(rem(18px), rem(20px), rem(24px))}; } } diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index e228bab89..067f3ea91 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -1382,8 +1382,6 @@ export const LazyLoading: Story = {
              Date: Fri, 6 Nov 2026 07:42:13 +0200 Subject: [PATCH 29/32] fix(linear-progress): respect reduced motion - With reduced motion, the indeterminate bars run at a third of their speed, and a new value shows at once. A duration set on the host or on the bar parts is slowed too. - A shared reduced-motion() mixin in the progress animations keeps the circular and the linear indicators in step. --- CHANGELOG.md | 2 +- .../progress/circular-progress.spec.ts | 9 +- .../progress/linear-progress.spec.ts | 82 +++++++++++++++++++ src/components/progress/spec.md | 9 +- .../progress/themes/animations.scss | 15 ++++ .../circular/circular.progress.base.scss | 11 +-- .../themes/linear/linear.progress.base.scss | 13 ++- .../linear/shared/linear.progress.fluent.scss | 1 - 8 files changed, 115 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6d64ea8..d4e243ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,8 +189,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Circular progress, Linear progress - The default `aria-valuetext` is now the percentage that the label shows. Before, it was the value with a percent sign: "50%" for 50 of 200, while the label showed 25%. [#2427](https://github.com/IgniteUI/igniteui-webcomponents/pull/2427) - The percentage is now correct. It rounds to two decimals: 12.996% shows as 13%, not "12.100%", and 1.005% shows as 1.01%, not 1%. A fractional `max` also works: 0.25 of 0.5 shows as 50%, not 25%. [#2427](https://github.com/IgniteUI/igniteui-webcomponents/pull/2427) -- #### Circular progress - With `prefers-reduced-motion: reduce`, the indeterminate animation now runs at a third of its speed, and a new value shows at once. Before, the motion did not change. +- #### Circular progress - In forced colors, the ring now has the text color and the track is `GrayText`, also with a custom gradient. Before, the ring kept the theme colors, so the track was almost invisible. - The element is now an inline block of the size of the ring. Before, a line box around the ring made the element taller than the ring, so a small ring sat above the center of its parent. - #### Color picker, Combo, Date picker, Date range picker, Dropdown, Select, Slider, Tooltip diff --git a/src/components/progress/circular-progress.spec.ts b/src/components/progress/circular-progress.spec.ts index 06295b11c..fad4285bd 100644 --- a/src/components/progress/circular-progress.spec.ts +++ b/src/components/progress/circular-progress.spec.ts @@ -276,11 +276,6 @@ describe('Circular progress component', () => { return result; } - async function setIndeterminate(): Promise { - progress.indeterminate = true; - await elementUpdated(progress); - } - beforeEach(async () => { progress = await fixture(html` { }); it('runs the indeterminate animation at a third of its speed with reduced motion', async () => { - await setIndeterminate(); + await updateProgress('indeterminate', true); expect(durations()).to.eql({ bootstrap: ['2.25s', '0s'], @@ -331,7 +326,7 @@ describe('Circular progress component', () => { allowMotion(); it('runs the indeterminate animation at full speed', async () => { - await setIndeterminate(); + await updateProgress('indeterminate', true); expect(durations()).to.eql({ bootstrap: ['0.75s', '0s'], diff --git a/src/components/progress/linear-progress.spec.ts b/src/components/progress/linear-progress.spec.ts index e69ba3a16..6c6d4aa57 100644 --- a/src/components/progress/linear-progress.spec.ts +++ b/src/components/progress/linear-progress.spec.ts @@ -7,7 +7,12 @@ import { } from '@open-wc/testing'; import { internalsOf } from '#internals/controllers/internals.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + allowMotion, + withDocumentSheets, +} from '#internals/testing/helpers.spec.js'; import { firstOf } from '#internals/utils/arrays.js'; +import { configureTheme } from '#theming/config.js'; import IgcLinearProgressComponent from './linear-progress.js'; describe('Linear progress component', () => { @@ -255,6 +260,83 @@ describe('Linear progress component', () => { }); }); + describe('Styles', () => { + /** The animation durations of the two bars and of their `::after`, by theme. */ + function durations(): Record { + const bars = progress.renderRoot.querySelectorAll( + '[part~="indeterminate"]' + ); + const result: Record = {}; + + try { + for (const theme of ['bootstrap', 'fluent'] as const) { + configureTheme(theme); + result[theme] = [...bars].flatMap((bar) => [ + getComputedStyle(bar).animationDuration, + getComputedStyle(bar, '::after').animationDuration, + ]); + } + } finally { + configureTheme('bootstrap'); + } + + return result; + } + + beforeEach(async () => { + progress = await fixture( + html`` + ); + }); + + it('runs the indeterminate animation at a third of its speed with reduced motion', async () => { + await updateProgress('indeterminate', true); + + expect(durations()).to.eql({ + bootstrap: ['6s', '6s', '6s', '6s'], + fluent: ['6s', '6s', '6s', '6s'], + }); + + // A duration set on the bars is slowed too. + const sheet = new CSSStyleSheet(); + sheet.replaceSync( + 'igc-linear-progress::part(fill) { --linear-animation-duration: 1s; }' + ); + + await withDocumentSheets([sheet], () => { + expect(durations()).to.eql({ + bootstrap: ['3s', '3s', '3s', '3s'], + fluent: ['3s', '3s', '3s', '3s'], + }); + }); + }); + + it('shows a new value at once with reduced motion', () => { + expect( + getComputedStyle(getDOM(progress).fill).transitionDuration + ).to.equal('0s'); + }); + + describe('with motion', () => { + allowMotion(); + + it('runs the indeterminate animation at full speed', async () => { + await updateProgress('indeterminate', true); + + expect(durations()).to.eql({ + bootstrap: ['2s', '2s', '2s', '2s'], + fluent: ['2s', '2s', '2s', '2s'], + }); + }); + + it('animates a new value', () => { + expect( + getComputedStyle(getDOM(progress).fill).transitionDuration + ).to.equal('0.5s'); + }); + }); + }); + describe('Accessibility', () => { const getARIA = (name: T) => internalsOf(progress)?.getARIA(name); diff --git a/src/components/progress/spec.md b/src/components/progress/spec.md index 172d4bb5d..d3c41f587 100644 --- a/src/components/progress/spec.md +++ b/src/components/progress/spec.md @@ -48,6 +48,7 @@ | 3 | 2026-10-01 | Announce the shown percentage, round it to two decimals | | 4 | 2026-10-21 | API descriptions aligned with the JSDoc. | | 5 | 2026-11-06 | The circular indicator: reduced motion, forced colors, the box of the ring | +| 6 | 2026-11-06 | The linear indicator: reduced motion | ## Overview @@ -281,8 +282,8 @@ Both suites are structured the same way, so the scenarios below apply to both in ### Styles 14. The circular indicator takes the box of the ring, and as a block it follows `text-align`. -15. With reduced motion, the indeterminate animation of the circular indicator runs at a third of its speed in each - theme, and a new value shows at once. +15. With reduced motion, the indeterminate animation runs at a third of its speed in each theme, and a new value + shows at once. 16. With motion, the same animations run at full speed, and a new value animates. 17. In forced colors, the ring and a custom gradient of the circular indicator have the text color, and the track is `GrayText`. @@ -332,8 +333,8 @@ Not applicable; the indicators are not interactive. ### Reduced motion and forced colors -- With `prefers-reduced-motion: reduce`, the indeterminate animation of the circular indicator runs at a third of - its speed, so it still shows activity, and a new value shows at once. +- With `prefers-reduced-motion: reduce`, the indeterminate animation of both indicators runs at a third of its + speed, so it still shows activity, and a new value shows at once. - Forced colors do not change SVG paint. So in forced colors, the ring of the circular indicator and a custom gradient take the text color, and the track is `GrayText`. diff --git a/src/components/progress/themes/animations.scss b/src/components/progress/themes/animations.scss index e0ee919a1..d41a3a4a4 100644 --- a/src/components/progress/themes/animations.scss +++ b/src/components/progress/themes/animations.scss @@ -22,6 +22,21 @@ $easing-curves: ( @return map.get($easing-curves, $key); } +// Slows the indeterminate animations, but does not stop them, so they still show +// activity. `!important` overrides the inline duration from `animationDuration`. +@mixin reduced-motion { + [part~='base'] { + --_motion-factor: 1; + } + + @media (prefers-reduced-motion: reduce) { + [part~='base'] { + --_motion-factor: 3; + --_transition-duration: 0ms !important; + } + } +} + @keyframes igc-indeterminate-primary { 0% { transform: translateX(0); diff --git a/src/components/progress/themes/circular/circular.progress.base.scss b/src/components/progress/themes/circular/circular.progress.base.scss index 3e9a457de..3f805e301 100644 --- a/src/components/progress/themes/circular/circular.progress.base.scss +++ b/src/components/progress/themes/circular/circular.progress.base.scss @@ -10,8 +10,6 @@ } [part~='base'] { - --_motion-factor: 1; - display: inline-flex; align-items: center; justify-content: center; @@ -19,14 +17,7 @@ position: relative; } -// Slow the indeterminate animation, but do not stop it, so it still shows -// activity. !important overrides the inline duration from `animationDuration`. -@media (prefers-reduced-motion: reduce) { - [part~='base'] { - --_motion-factor: 3; - --_transition-duration: 0ms !important; - } -} +@include reduced-motion(); // Forced colors do not change SVG paint. `stop` also matches a custom gradient. @media (forced-colors: active) { diff --git a/src/components/progress/themes/linear/linear.progress.base.scss b/src/components/progress/themes/linear/linear.progress.base.scss index 9e1301687..a46e152b8 100644 --- a/src/components/progress/themes/linear/linear.progress.base.scss +++ b/src/components/progress/themes/linear/linear.progress.base.scss @@ -28,6 +28,8 @@ flex: 1 1 100%; } +@include reduced-motion(); + :host(:dir(rtl)[indeterminate]), :host([dir='rtl'][indeterminate]) { [part~='track'] { @@ -63,6 +65,9 @@ [part~='indeterminate'], [part~='indeterminate secondary'] { + // On the bars, so a duration set on their parts applies. + --_indeterminate-duration: calc(var(--linear-animation-duration) * var(--_motion-factor)); + width: 100% !important; height: inherit; position: absolute; @@ -81,20 +86,20 @@ [part~='indeterminate']:not([part~='secondary']) { transform: scale3d(0, 1, 1); - animation: igc-indeterminate-primary var(--linear-animation-duration) infinite linear; + animation: igc-indeterminate-primary var(--_indeterminate-duration) infinite linear; left: -145.1666%; &::after { - animation: igc-indeterminate-primary-scale var(--linear-animation-duration) infinite linear; + animation: igc-indeterminate-primary-scale var(--_indeterminate-duration) infinite linear; } } [part~='secondary'] { - animation: igc-indeterminate-secondary var(--linear-animation-duration) infinite linear; + animation: igc-indeterminate-secondary var(--_indeterminate-duration) infinite linear; left: -54.8888%; &::after { - animation: igc-indeterminate-secondary-scale var(--linear-animation-duration) infinite linear; + animation: igc-indeterminate-secondary-scale var(--_indeterminate-duration) infinite linear; width: 100%; height: inherit; } diff --git a/src/components/progress/themes/linear/shared/linear.progress.fluent.scss b/src/components/progress/themes/linear/shared/linear.progress.fluent.scss index 80957fbf6..ad48ab17f 100644 --- a/src/components/progress/themes/linear/shared/linear.progress.fluent.scss +++ b/src/components/progress/themes/linear/shared/linear.progress.fluent.scss @@ -36,7 +36,6 @@ $track-color: color(gray, 200); width: 33% !important; min-width: 33%; animation-name: igc-indeterminate-bar-fluent; - animation-duration: var(--linear-animation-duration); animation-timing-function: ease; animation-iteration-count: infinite; left: auto; From 95539092c548f8fbd56ca6a4d101d6fd6eab77e3 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 6 Nov 2026 09:08:41 +0200 Subject: [PATCH 30/32] feat(combo): report aria-checked in multiple selection - In multiple selection, the options report the state of their checkbox in aria-checked, not aria-selected, as the APG listbox pattern allows. The select-all and group header options report mixed while only some of their items are selected. - Single selection keeps aria-selected. - Shared ariaOf spec helper; spec.md revision 21; CHANGELOG. --- CHANGELOG.md | 5 +-- src/components/combo/combo-item.ts | 16 ++++++++-- src/components/combo/combo-selection.spec.ts | 33 +++++++++----------- src/components/combo/combo.spec.ts | 28 +++++++++++++++++ src/components/combo/combo.ts | 6 +--- src/components/combo/spec.md | 25 ++++++++------- src/internals/testing/combo.spec.ts | 7 +++++ 7 files changed, 80 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e243ab8..c4722a2f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner, an indeterminate circular progress, in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. `load` gets the search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options, it reports `aria-selected`, which is `true` while all the shown items are selected. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. The items are one level in under their header, as in the tree, and `--ig-combo-item-indent` changes the indent. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options in multiple selection, it reports `aria-checked`, which is `true` while all the shown items are selected and `mixed` while only some of them are. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) + - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. Like the select-all option, it reports `aria-checked`, which is `mixed` while only some of the shown items of its group are selected. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. The items are one level in under their header, as in the tree, and `--ig-combo-item-indent` changes the indent. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `selectionCommit` set to `confirm` holds the changes of the user while the list is open, in multiple selection mode. A toggle changes a pending selection and emits the cancelable `igcPendingChange`. Enter applies the pending selection with one `igcChange`, whose new `commit` type lists the items that it selects or deselects. Any other close drops it. While the list is open, `select()`, `deselect()`, `selectAllItems()` and `deselectAllItems()` change the pending selection. The footer shows Cancel and Apply, with the texts of the `combo_cancel` and `combo_apply` resource strings. Apply applies the pending selection as Enter does, and Cancel drops it. The `actions`, `cancel-button` and `apply-button` parts style them. Content in the `footer` slot replaces them: `pendingValue` gives the pending values, `commitSelection()` applies them, and `cancelSelection()` puts `value` back. These methods emit no events. The new `ComboSelectionCommit` type describes the property. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - `igcClosing` and `igcClosed` have a `detail` with the `trigger` that closes the list: `enter`, `escape`, `tab`, `arrow-up`, `outside-click`, `scroll`, `toggle` (a click on the input), `selection` (an item click in single selection), or `apply` and `cancel` (the buttons of the confirm mode). The new `IgcComboClosingEventArgs` and `ComboCloseTrigger` types describe the detail. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The group header template gets `key`: the `groupKey` value that the items of the group share. The new `ComboGroupHeaderTemplate` and `ComboGroupHeaderContext` types describe the template and its context. Current templates continue to work. [#2462](https://github.com/IgniteUI/igniteui-webcomponents/issues/2462) [#2468](https://github.com/IgniteUI/igniteui-webcomponents/pull/2468) @@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The `end` slot now renders after the `suffix` slot, at the end of the chip, as the `start` slot renders before the `prefix` slot. Before, it rendered before the `suffix` slot. - #### Combo - **Behavioral change:** A new `data` keeps the selection. Before, a selected item that the new `data` did not hold dropped out of `selection` and the input text, although `value` kept it, and the next selection by the user removed it from `value` too. Now it stays selected until the user or the application deselects it. To clear the selection with the data, as for cascading combos, set `value` to `[]`. `select()` also selects an object that `data` does not hold, so an application can select the records of a saved filter before their page loads; with `valueKey`, the object resolves by its key first. Without an argument, `select()` keeps the selected items that `data` does not hold. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - In multiple selection, the options report the state of their checkbox in `aria-checked`, not in `aria-selected`, as the WAI-ARIA listbox pattern allows for lists with checkboxes. So screen readers announce them as checked or not checked, as their checkboxes show. In single selection, the options keep `aria-selected`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - The `igcInput` of the search input, and of the main input in single selection, no longer leaves the combo. Use `igcSearchInput`. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `igcClosing` and `igcClosed` are typed `CustomEvent`, not `CustomEvent`. In TypeScript, change a handler that declares its parameter as `CustomEvent`. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The `igcStateChange` and `igcDataRequest` events of the list inside the combo no longer leave the combo. For more data on scroll, use the `igcDataRequest` of the combo in remote filtering mode. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) diff --git a/src/components/combo/combo-item.ts b/src/components/combo/combo-item.ts index 084aa6aa4..7623028f3 100644 --- a/src/components/combo/combo-item.ts +++ b/src/components/combo/combo-item.ts @@ -41,7 +41,8 @@ export default class IgcComboItemComponent extends LitElement { /** * Whether the checkbox of the item is indeterminate. The box and the - * indicator of the checkbox then also export the `indeterminate` part. + * indicator of the checkbox then also export the `indeterminate` part, and + * the item reports `aria-checked="mixed"` while it shows the checkbox. * @attr indeterminate * @default false */ @@ -49,7 +50,9 @@ export default class IgcComboItemComponent extends LitElement { public indeterminate = false; /** - * Determines whether the item hides its selection checkbox. + * Determines whether the item hides its selection checkbox. Without the + * checkbox, the item reports its selection in `aria-selected`, and with it, + * in `aria-checked`. * @attr hide-checkbox * @default false */ @@ -63,7 +66,14 @@ export default class IgcComboItemComponent extends LitElement { addInternalsController(this, { initialARIA: { role: 'option' }, reflectRole: true, - aria: () => ({ ariaSelected: `${this.selected}` }), + // A listbox uses only one of the two states for all its options. + aria: () => + this.hideCheckbox + ? { ariaSelected: `${this.selected}`, ariaChecked: null } + : { + ariaSelected: null, + ariaChecked: this.indeterminate ? 'mixed' : `${this.selected}`, + }, }); } diff --git a/src/components/combo/combo-selection.spec.ts b/src/components/combo/combo-selection.spec.ts index da82d20a0..e956ab9aa 100644 --- a/src/components/combo/combo-selection.spec.ts +++ b/src/components/combo/combo-selection.spec.ts @@ -1,7 +1,6 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import { type SinonSpy, spy } from 'sinon'; -import { internalsOf } from '#internals/controllers/internals.js'; import { arrowDown, arrowUp, @@ -13,6 +12,7 @@ import { } from '#internals/controllers/key-bindings.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; import { + ariaOf, type City, cities, comboStable, @@ -128,11 +128,6 @@ describe('Combo', () => { const selectAll = () => list.querySelector('[part~="select-all"]'); - const ariaOf = (item: Element) => ({ - selected: internalsOf(item)?.getARIA('ariaSelected'), - checked: internalsOf(item)?.getARIA('ariaChecked') ?? null, - }); - // The cities whose name has an "o". const ids = ['BG01', 'BG02', 'US01', 'US02', 'US03']; @@ -169,8 +164,7 @@ describe('Combo', () => { expect(first).to.equal(selectAll()); expect(first!.textContent?.trim()).to.equal('Select all'); - expect(ariaOf(first!)).to.eql({ selected: 'false', checked: null }); - expect(first!.hasAttribute('aria-checked')).to.be.false; + expect(ariaOf(first!)).to.eql({ selected: null, checked: 'false' }); expect(first!.getAttribute('aria-posinset')).to.equal('1'); expect(option.getAttribute('aria-posinset')).to.equal('2'); expect(option.getAttribute('aria-setsize')).to.equal( @@ -203,12 +197,13 @@ describe('Combo', () => { await comboStable(combo); expect(combo.value).to.eql(ids); - expect(ariaOf(selectAll()!).selected).to.equal('true'); + expect(ariaOf(selectAll()!).checked).to.equal('true'); simulateClick(selectAll()!); await comboStable(combo); expect(combo.value).to.be.empty; + expect(ariaOf(selectAll()!).checked).to.equal('false'); expect(changes()).to.eql([ { newValue: ids, items: ids, type: 'selection' }, { newValue: [], items: ids, type: 'deselection' }, @@ -234,7 +229,7 @@ describe('Combo', () => { 'igc-combo::part(checkbox indeterminate), igc-combo::part(checkbox-indicator indeterminate) { scroll-margin-top: 7px; }'; document.head.append(style); - expect(ariaOf(selectAll()!).selected).to.equal('false'); + expect(ariaOf(selectAll()!).checked).to.equal('mixed'); expect(checkbox.indeterminate).to.be.true; expect(marginOf(selectAll()!, 'control')).to.equal('7px'); expect(marginOf(selectAll()!, 'indicator')).to.equal('7px'); @@ -246,7 +241,7 @@ describe('Combo', () => { await comboStable(combo); expect(changes().map(({ items }) => items)).to.eql([others]); - expect(ariaOf(selectAll()!).selected).to.equal('true'); + expect(ariaOf(selectAll()!).checked).to.equal('true'); }); it('follows a new value while the list is open', async () => { @@ -256,6 +251,7 @@ describe('Combo', () => { expect( selectAll()!.renderRoot.querySelector('igc-checkbox')!.indeterminate ).to.be.true; + expect(ariaOf(selectAll()!).checked).to.equal('mixed'); expect(items(combo).filter((item) => item.selected)).lengthOf(1); }); @@ -352,7 +348,7 @@ describe('Combo', () => { expect(headers()).lengthOf(2); expect(bulgaria.localName).to.equal('igc-combo-item'); expect(bulgaria.textContent?.trim()).to.equal('Bulgaria'); - expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); + expect(ariaOf(bulgaria)).to.eql({ selected: null, checked: 'false' }); expect(checkboxOf(bulgaria)).to.exist; // The two headers count as options. expect(bulgaria.getAttribute('aria-posinset')).to.equal('1'); @@ -419,9 +415,7 @@ describe('Combo', () => { await comboStable(combo); expect(combo.value).to.eql(['BG01', 'BG02']); - expect(internalsOf(headers()[0])?.getARIA('ariaSelected')).to.equal( - 'true' - ); + expect(ariaOf(headers()[0]).checked).to.equal('true'); expect(combo.open).to.be.true; simulateClick(headers()[0]); @@ -457,7 +451,7 @@ describe('Combo', () => { }); expect(checkboxOf(bulgaria).indeterminate).to.be.true; - expect(internalsOf(bulgaria)?.getARIA('ariaSelected')).to.equal('false'); + expect(ariaOf(bulgaria).checked).to.equal('mixed'); expect(checkboxOf(unitedStates).indeterminate).to.be.false; simulateClick(bulgaria); @@ -497,9 +491,10 @@ describe('Combo', () => { simulateClick(list.querySelector('[part~="select-all"]')!); await comboStable(combo); - expect( - headers().map((header) => internalsOf(header)?.getARIA('ariaSelected')) - ).to.eql(['true', 'true']); + expect(headers().map((header) => ariaOf(header).checked)).to.eql([ + 'true', + 'true', + ]); }); it('lists an item that data holds twice one time in igcChange', async () => { diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 3a79c0b6d..b36d7c339 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -16,6 +16,7 @@ import { } from '#internals/controllers/key-bindings.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; import { + ariaOf, type City, cities, comboStable, @@ -2445,6 +2446,33 @@ describe('Combo', () => { } }); + it('reports the selection in aria-checked with checkboxes and in aria-selected without them', async () => { + const expectARIA = (checkboxes: boolean) => { + const rendered = items(combo); + + expect(rendered.some((item) => item.selected)).to.be.true; + expect(rendered.map(ariaOf)).to.eql( + rendered.map(({ selected }) => + checkboxes + ? { selected: null, checked: `${selected}` } + : { selected: `${selected}`, checked: null } + ) + ); + }; + + combo.value = ['BG01']; + await openComboPopover(combo); + expectARIA(true); + + combo.singleSelect = true; + await comboStable(combo); + expectARIA(false); + + combo.singleSelect = false; + await comboStable(combo); + expectARIA(true); + }); + it('prefixes the option ids with the id of the combo', async () => { combo.id = 'cities'; await openComboPopover(combo); diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 40de438b7..95c4cfd84 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -1404,11 +1404,7 @@ export default class IgcComboComponent< `; }; - /** - * A toggle option: the select-all option or a group header option. Like the - * other options, it reports only `aria-selected`, so the mixed state of its - * items shows only on its checkbox. - */ + /** A toggle option: the select-all option or a group header option. */ private _renderToggleOption( { header, position }: ComboRecord, index: number, diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index f22c985bb..c2e22579f 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -80,6 +80,7 @@ | 18 | 2026-11-06 | The select-all option sends one cancelable `igcChange`; a canceled one changes no item; a group has no entry in the selection; remote data: the application removes the selected items that the server deleted | | 19 | 2026-11-06 | Remote mode shows the case-sensitive icon; a case change reports the search text again | | 20 | 2026-11-06 | With group selection, the items are one level in under their header, as in the tree | +| 21 | 2026-11-06 | In multiple selection, the options report `aria-checked`; the select-all and group header options report `mixed` | ## Overview @@ -775,7 +776,8 @@ state, the data requests and the selection model without a browser fixture. ### ARIA 22. The anchor, the list, the items and the group headers have their roles and states, and the selection summary is - announced. + announced. The options report the selection in `aria-checked` in multiple selection and in `aria-selected` in + single selection. ### Host ARIA @@ -830,11 +832,11 @@ state, the data requests and the selection model without a browser fixture. ### Select-all option 34. The option comes first in multiple selection, before the group headers, with the "Select all" text and - `aria-selected`. It is the first option in `aria-posinset` and `aria-setsize`, and it passes the accessibility + `aria-checked`. It is the first option in `aria-posinset` and `aria-setsize`, and it passes the accessibility audit. It does not show in single selection or while the list shows no items. 35. A click selects the shown items, and a second click deselects them, with one `igcChange` each. When only some - shown items are selected, the option is not selected, and its checkbox is indeterminate with the `indeterminate` - part. A click then selects only the other shown items. A canceled `igcChange` keeps the selection. A new `value` + shown items are selected, the option is not selected, its `aria-checked` is `mixed`, and its checkbox is + indeterminate with the `indeterminate` part. A click then selects only the other shown items. A canceled `igcChange` keeps the selection. A new `value` while the list is open updates the option and the items. 36. Home reaches the option, Space toggles it, and Arrow Up from it moves to the search input. A change of `showSelectAll` clears the active option. A click on an item without a value selects only that item. @@ -843,15 +845,15 @@ state, the data requests and the selection model without a browser fixture. ### Group selection -38. In multiple selection, the group headers are options with a checkbox and `aria-selected`. They count in +38. In multiple selection, the group headers are options with a checkbox and `aria-checked`. They count in `aria-posinset` and `aria-setsize`, and the accessibility audit passes. In single selection, they are headers again, and the items take their positions back. 39. The items are one level in under the header options, and `--ig-combo-item-indent` changes the indent. The select-all option is at the level of the headers, and the list does not scroll sideways. Without group selection or `groupKey`, the items have no indent. 40. A click on a header selects the shown items of its group, and a second click deselects them, with one `igcChange` - each. The list stays open. With some of them selected, the checkbox is indeterminate with the `indeterminate` - part, and a click selects only the others. + each. The list stays open. With some of them selected, `aria-checked` is `mixed`, the checkbox is + indeterminate with the `indeterminate` part, and a click selects only the others. 41. Home and the arrow keys stop on the headers, Space toggles them, and Enter closes the list. A click on the select-all option also selects the headers. When group selection goes off, an active header stops being active. @@ -920,11 +922,12 @@ The form-associated mixin gives the focus after the form checks and the rules of - The native input of the anchor has the `combobox` role, `aria-haspopup`, `aria-expanded` and `aria-controls`, as element references. The listbox has `aria-activedescendant`, because it has the focus during list navigation. -- The list has the `listbox` role, and the items have the `option` role with `aria-selected`. +- The list has the `listbox` role, and the items have the `option` role. In multiple selection, the options show a + checkbox and report its state in `aria-checked`. In single selection, they report `aria-selected`. A listbox uses + only one of the two states for all its options. - The group headers have no role. With `groupSelection`, they are options. -- The select-all option is selected while all the shown items are selected. A group header option is selected while - all the shown items of its group are selected. They have no `aria-checked`, because a listbox uses only one of - `aria-selected` and `aria-checked` for all its options. So their mixed state shows only on their checkbox. +- The `aria-checked` of the select-all option is `true` while all the shown items are selected, and `mixed` while + only some of them are selected. A group header option does the same for the shown items of its group. - In confirm mode, the selected state of the options is the pending selection, and the input shows `value`. - A placeholder of `totalItemCount` is a disabled option with the name "Loading". - While `loading` is set, a `progressbar` with the name "Loading" replaces the toggle icon. The listbox has no diff --git a/src/internals/testing/combo.spec.ts b/src/internals/testing/combo.spec.ts index 12a6b5a7a..b13391a00 100644 --- a/src/internals/testing/combo.spec.ts +++ b/src/internals/testing/combo.spec.ts @@ -3,6 +3,7 @@ import { elementUpdated, nextFrame } from '@open-wc/testing'; import type IgcComboItemComponent from '../../components/combo/combo-item.js'; import type IgcComboComponent from '../../components/combo/combo.js'; import type IgcVirtualScrollComponent from '../../components/virtualization/virtualization.js'; +import { internalsOf } from '../controllers/internals.js'; /* The data and the helpers that the combo spec files share. */ @@ -75,6 +76,12 @@ export const items = (combo: IgcComboComponent) => .querySelectorAll('[part~="item"]'), ] as IgcComboItemComponent[]; +/** The selection state that an option reports to assistive technology. */ +export const ariaOf = (item: Element) => ({ + selected: internalsOf(item)?.getARIA('ariaSelected'), + checked: internalsOf(item)?.getARIA('ariaChecked'), +}); + export const layoutComplete = (combo: IgcComboComponent) => ( combo.renderRoot.querySelector( From 3f4021774b1faa5d8aea03cf677487ad4746e81c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 6 Nov 2026 20:18:04 +0200 Subject: [PATCH 31/32] fix(combo, pickers): honor handled Escape; reuse shared Tab helpers - Combo: _onTab uses focusTabStop, and the base class returns the focus on close (_popupElement). A slotted control that handles Tab or Escape keeps the key. - Date picker, date range picker, color picker: a closed picker no longer cancels Escape, so an open combo, dropdown or select around it closes. - simulateCancelableKeydown testing helper. --- CHANGELOG.md | 4 +++- .../color-picker/color-picker.spec.ts | 8 +++++++ src/components/color-picker/color-picker.ts | 2 +- src/components/color-picker/spec.md | 5 ++-- src/components/combo/combo.spec.ts | 19 +++++++++++++++ src/components/combo/combo.ts | 20 +++++++--------- .../combo/controllers/navigation.ts | 24 +++++++------------ src/components/combo/spec.md | 6 +++-- .../date-picker/date-picker.base.ts | 2 +- .../date-picker/date-picker.spec.ts | 8 +++++++ src/components/date-picker/spec.md | 5 ++-- .../date-range-picker-single.spec.ts | 8 +++++++ src/components/date-range-picker/spec.md | 5 ++-- src/internals/testing/simulate.spec.ts | 12 ++++++++++ 14 files changed, 89 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf29f7ac4..6b3febf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -203,6 +203,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Color picker, Combo, Date picker, Date range picker, Dropdown, Select, Slider, Tooltip - In browsers without CSS anchor positioning, a popup inside an ancestor with CSS `zoom` is now placed at its anchor. Before, the zoom moved it away from the anchor, and its arrow was off center. - In browsers without CSS anchor positioning, a popup whose anchor is in a modal dialog or another top-layer element now stays at the anchor while the page scrolls. Before, it moved with the page for a frame. +- #### Color picker, Date picker, Date range picker + - While closed, the picker no longer cancels Escape. Before, a dropdown, a select or a combo list with the picker in its content stayed open when the user pressed Escape in the closed picker. - #### Color picker - The initial `value` is now in the notation of `format` when the `value` attribute comes before the `format` attribute. Before, `value` and the form data stayed in hex until the color changed. [#2413](https://github.com/IgniteUI/igniteui-webcomponents/pull/2413) - The HSL saturation no longer becomes infinite for a very small saturation at full value. [#2409](https://github.com/IgniteUI/igniteui-webcomponents/pull/2409) @@ -214,7 +216,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The native input no longer has `aria-disabled`. Its native `disabled` state is sufficient. [#2432](https://github.com/IgniteUI/igniteui-webcomponents/pull/2432) - The removal of the `locale` attribute no longer causes a `TypeError`. [#2475](https://github.com/IgniteUI/igniteui-webcomponents/pull/2475) - Setting `singleSelect` or `disableFiltering` to the value that the combo already has no longer clears the search text and the filter. Before, a framework that sets each property on each render, such as React, cleared the search text after each key, and the list did not filter. [#2516](https://github.com/IgniteUI/igniteui-webcomponents/issues/2516) - - Tab and Shift+Tab now move through the focusable content of the `header`, `empty` and `footer` slots while the list is open. Before, Tab closed the list, so a keyboard user could not reach a button in these slots. Tab from the last footer element, and Shift+Tab from the search input, close the list and move the focus on, as before. [#2529](https://github.com/IgniteUI/igniteui-webcomponents/issues/2529) + - Tab and Shift+Tab now move through the focusable content of the `header`, `empty` and `footer` slots while the list is open. Before, Tab closed the list, so a keyboard user could not reach a button in these slots. Tab from the last footer element, and Shift+Tab from the search input, close the list and move the focus on, as before. Escape from this content no longer closes the list when the content handled the key. [#2529](https://github.com/IgniteUI/igniteui-webcomponents/issues/2529) - When the list closes while the focus is in it, for example on `hide()` from a button in the footer, the focus moves to the main input. Before, the closed list could not keep the focus, and it went to the document. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - `igcChange.newValue` now has the order of the `value` that follows the change: the selected values first, then the new ones. Before, it listed the new values first, so an app that saved `newValue` as a staged selection did not match `value` after the change. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The `igcChange` of the search input no longer leaves the combo. Before, a listener on the combo got a second `igcChange` with the search text as its `detail` when the search input lost the focus. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index a25cdf06e..9f29c82f0 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -34,6 +34,7 @@ import { withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { + simulateCancelableKeydown, simulateClick, simulateInput, simulateKeyboard, @@ -1269,6 +1270,13 @@ describe('Color picker', () => { expect(isFocused(anchor)).to.be.true; }); + it('cancels Escape only while open', async () => { + expect(simulateCancelableKeydown(picker, escapeKey)).to.be.false; + + await picker.show(); + expect(simulateCancelableKeydown(picker, escapeKey)).to.be.true; + }); + it('opens with Alt+ArrowDown and closes with Alt+ArrowUp', async () => { simulateKeyboard(picker, [altKey, arrowDown]); await elementUpdated(picker); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 42d5965ba..64126e1c0 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -321,7 +321,7 @@ export default class IgcColorPickerComponent extends I18nMixin( addSafeEventListener(this, 'focusout', this._handleFocusOut); addKeybindings(this, { skip: () => this.disabled }) - .set(escapeKey, this._handleKeyboardClosing) + .set(escapeKey, this._handleKeyboardClosing, { when: () => this.open }) .set([altKey, arrowDown], this._handleAnchorClick) .set([altKey, arrowUp], this._handleKeyboardClosing); diff --git a/src/components/color-picker/spec.md b/src/components/color-picker/spec.md index 57a56a008..e1a6fb1ff 100644 --- a/src/components/color-picker/spec.md +++ b/src/components/color-picker/spec.md @@ -58,6 +58,7 @@ | 10 | 2026-10-23 | The color picker makes a stacking context, so the z-index of the focus ring of the anchor does not compete with the layers of the page | | 11 | 2026-11-02 | The events of the inner inputs, format select and canvas stay inside the picker | | 12 | 2026-11-02 | A `focus()` call before the first render applies after the render; `focus()` and `blur()` in the API | +| 13 | 2026-11-06 | A closed picker does not cancel Escape | ## Overview @@ -302,7 +303,7 @@ Color values are locale-independent CSS strings. | Key combination | Result | | ------------------------------------------ | ---------------------------------------------------------------------------------------- | | Alt + Arrow Down | Opens the picker dropdown. | -| Escape | Closes the dropdown and returns focus to the anchor. | +| Escape | Closes the dropdown and returns focus to the anchor. While the dropdown is closed, it does not cancel the key. | | Alt + Arrow Up | Closes the dropdown and returns focus to the anchor. | | Arrow keys | With the canvas marker focused, moves it one step in the pressed direction, updating the saturation and value. | | Tab | Moves focus between the interactive elements inside the open picker; focus is trapped while open. | @@ -452,7 +453,7 @@ The component suite reuses `createFormAssociatedTestBed`, `runValidationContaine events of the format select, and the `igcColorPicked` of the canvas do not reach a listener on the picker. 11. Swatches - rendering the grid and applying a swatch. 12. Copy and eye dropper, in environments with and without platform support. -13. Open and close - the pointer, keyboard and outside-click paths. +13. Open and close - the pointer, keyboard and outside-click paths. A closed picker does not cancel Escape. 14. Input mode - the anchor input, its label and its prefix. 15. Rendering of the picker surface rows, and the direction of the slider tracks in LTR and RTL. 16. Form association, the touched state, `defaultValue`, the initial format, and validation. diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index b36d7c339..52ccaac7f 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -2134,6 +2134,25 @@ describe('Combo', () => { expectFocus(header); }); + it('closes the list with Escape from a slotted control and focuses the input', async () => { + header.focus(); + + await sendKeys({ press: escapeKey }); + await elementUpdated(combo); + + expectFocus(input, false); + }); + + it('leaves an Escape that a slotted control handles to that control', async () => { + header.addEventListener('keydown', (event) => event.preventDefault()); + header.focus(); + + await sendKeys({ press: escapeKey }); + await elementUpdated(combo); + + expectFocus(header); + }); + it('closes the list with Tab from the search input when the slots have no focusable content', async () => { header.remove(); cancel.disabled = true; diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index 95c4cfd84..dbf5b146b 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -1,6 +1,6 @@ import { ComboResourceStringsEN } from 'igniteui-i18n-core'; import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'; -import { property, state } from 'lit/decorators.js'; +import { property, query, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -234,6 +234,9 @@ export default class IgcComboComponent< private readonly _listRef = createRef(); + @query('[part="list-wrapper"]', true) + private readonly _popup!: HTMLDivElement | null; + private readonly _state = new DataState(this); private readonly _selection = new ComboSelection(this); private readonly _navigation = new ComboNavigationController( @@ -661,17 +664,6 @@ export default class IgcComboComponent< this._state.updateLocale(); } - // The closed list is inert, so the focus in it would fall to the - // document. Move it to the input first. A server render has no render root. - if ( - props.has('open') && - !this.open && - this.hasUpdated && - this.renderRoot.querySelector('[part="list-wrapper"]:focus-within') - ) { - this.focus({ preventScroll: true }); - } - // The select-all option moves the other records by one, and an active // group header can stop being an option. if ( @@ -719,6 +711,10 @@ export default class IgcComboComponent< this._dataRequests.afterUpdate(props, this._listRef.value); } + protected override _popupElement(): Element | null { + return this._popup; + } + //#endregion //#region Form Associated overrides diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index d985a5503..1215edda8 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -15,6 +15,7 @@ import { } from '#internals/controllers/key-bindings.js'; import { isEmpty } from '#internals/utils/arrays.js'; import { getFocusableElements } from '#internals/utils/dom.js'; +import { focusTabStop } from '#internals/utils/events.js'; import type IgcInputComponent from '../../input/input.js'; import type IgcVirtualScrollComponent from '../../virtualization/virtualization.js'; import type IgcComboComponent from '../combo.js'; @@ -88,7 +89,11 @@ export class ComboNavigationController { const bindingDefaults: KeyBindingOptions = { repeat: true }; const skip = (): boolean => this._host.disabled; - addKeybindings(this._host, { skip, bindingDefaults }) + // A slotted control that handles Tab or Escape keeps the key. + addKeybindings(this._host, { + skip: (_, event) => skip() || event.defaultPrevented, + bindingDefaults, + }) .set(tabKey, this._onTab, { preventDefault: false }) .set([shiftKey, tabKey], this._onTab, { preventDefault: false, @@ -166,24 +171,11 @@ export class ComboNavigationController { }; private _onTab = async (event: KeyboardEvent): Promise => { - // A slotted control that handles Tab prevents the default action. - if (!this._host.open || event.defaultPrevented) { + if (!this._host.open) { return; } - const stops = this._tabStops(); - // The innermost stop on the path, for a stop inside another stop. - const focused = event - .composedPath() - .find((node) => stops.includes(node as HTMLElement)); - const current = stops.indexOf(focused as HTMLElement); - const next = - current > -1 ? stops[current + (event.shiftKey ? -1 : 1)] : undefined; - - next?.focus(); - - // A stop that does not take the focus leaves Tab to the browser. - if (next?.matches(':focus')) { + if (focusTabStop(event, this._tabStops())) { event.preventDefault(); return; } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index c2e22579f..74d175857 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -81,6 +81,7 @@ | 19 | 2026-11-06 | Remote mode shows the case-sensitive icon; a case change reports the search text again | | 20 | 2026-11-06 | With group selection, the items are one level in under their header, as in the tree | | 21 | 2026-11-06 | In multiple selection, the options report `aria-checked`; the select-all and group header options report `mixed` | +| 22 | 2026-11-06 | Escape from the slot content keeps the list open when the content handled the key | ## Overview @@ -491,7 +492,7 @@ While the list is open: | Space | Selects or deselects the active option. On the select-all option, or on a group header of `groupSelection`, it selects the shown items, or deselects them when all of them are selected. | | Enter | On an active option, closes the list, and in single selection first selects the option. In confirm mode, it first applies the pending selection, also from the search input and without an active option, and a canceled `igcChange` keeps the list open. On a placeholder, it does nothing. | | Home / End | Activates the first or the last option. | -| Escape | Closes the list. The combo keeps the focus. In confirm mode, it drops the pending selection. | +| Escape | Closes the list. The combo keeps the focus. In confirm mode, it drops the pending selection. When a slotted control handled the key (`defaultPrevented`), the list stays open. | | Tab / Shift + Tab | Moves through the search input (the main input in single selection), the header content, the list, the empty content and the footer content. In confirm mode, the footer content is Cancel and Apply. Past the first or the last stop, or without focusable slot content, it closes the list and moves the focus on. | In multiple selection, the search input filters the list while it has the focus. @@ -755,7 +756,8 @@ state, the data requests and the selection model without a browser fixture. the main input. A focus outside the list stays where it is. 16. Without focusable slot content, Tab from the search input closes the list and moves the focus on. 17. Tab moves out of a stop inside another stop. It does not stop on a host that delegates the focus or on a control - with `visibility: hidden`. A slotted control that handles Tab keeps it. + with `visibility: hidden`. A slotted control that handles Tab keeps it. Escape from a slotted control closes the + list and focuses the main input, unless the control handles the key. ### Close trigger diff --git a/src/components/date-picker/date-picker.base.ts b/src/components/date-picker/date-picker.base.ts index 609b7065e..cd637b4e1 100644 --- a/src/components/date-picker/date-picker.base.ts +++ b/src/components/date-picker/date-picker.base.ts @@ -501,7 +501,7 @@ export abstract class IgcDatePickerBaseComponent< }) .set([altKey, arrowDown], this._handleAnchorClick) .set([altKey, arrowUp], this._onEscapeKey) - .set(escapeKey, this._onEscapeKey); + .set(escapeKey, this._onEscapeKey, { when: () => this.open }); } protected override willUpdate(changedProperties: PropertyValues): void { diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index d8b78be93..25fb70513 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -35,6 +35,7 @@ import { withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { + simulateCancelableKeydown, simulateClick, simulateInput, simulateKeyboard, @@ -1088,6 +1089,13 @@ describe('Date picker', () => { expect(eventSpy).calledWith('igcClosed'); }); + it('cancels Escape only while open', async () => { + expect(simulateCancelableKeydown(picker, escapeKey)).to.be.false; + + await picker.show(); + expect(simulateCancelableKeydown(picker, escapeKey)).to.be.true; + }); + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp - dropdown mode', async () => { const eventSpy = spy(picker, 'emitEvent'); expect(picker.open).to.be.false; diff --git a/src/components/date-picker/spec.md b/src/components/date-picker/spec.md index 3d67ea535..c0222456b 100644 --- a/src/components/date-picker/spec.md +++ b/src/components/date-picker/spec.md @@ -68,6 +68,7 @@ | 11 | 2026-11-02 | A `focus()` call before the first render applies after the render; `focus()` and `blur()` in the API | | 12 | 2026-11-04 | In dialog mode, an open and a close play the animations of the dialog; a canceled `igcClosing` keeps the dialog open | | 13 | 2026-11-06 | A canceled Enter `keydown`, or the Enter that commits an IME composition, does not submit the form | +| 14 | 2026-11-06 | A closed picker does not cancel Escape | ## Overview @@ -484,7 +485,7 @@ As long as focus is within any part of the date picker: | Key combination | Result | | ----------------- | ------------------------------------------------------------------------------------------------------ | -| Escape | If the picker is shown, closes it and returns focus to the input part. Otherwise it is a no-op. | +| Escape | If the picker is shown, closes it and returns focus to the input part. Otherwise it is a no-op and does not cancel the key. | When the input part of the component is focused: @@ -714,7 +715,7 @@ Grouped as `Uncommitted edits - issue #1346` in the suite. ### Interactions -34. Escape closes an open picker. +34. Escape closes an open picker. A closed picker does not cancel Escape. 35. Alt + Arrow Down opens and Alt + Arrow Up closes the picker, in both modes. 36. A value set from code, also with `stepUp`, is not reported as a change on a later blur, in the editable, diff --git a/src/components/date-range-picker/date-range-picker-single.spec.ts b/src/components/date-range-picker/date-range-picker-single.spec.ts index c1bc072e8..3903f88e9 100644 --- a/src/components/date-range-picker/date-range-picker-single.spec.ts +++ b/src/components/date-range-picker/date-range-picker-single.spec.ts @@ -24,6 +24,7 @@ import { } from '#internals/testing/form-testbed.spec.js'; import { isFocused } from '#internals/testing/helpers.spec.js'; import { + simulateCancelableKeydown, simulateClick, simulateInput, simulateKeyboard, @@ -871,6 +872,13 @@ describe('Date range picker - single input', () => { }); describe('Interactions with the inputs and the open and clear buttons', () => { + it('cancels Escape only while open', async () => { + expect(simulateCancelableKeydown(picker, escapeKey)).to.be.false; + + await picker.show(); + expect(simulateCancelableKeydown(picker, escapeKey)).to.be.true; + }); + it('should not open the picker when clicking the input in dropdown mode', async () => { const input = picker.renderRoot.querySelector( IgcDateRangeInputComponent.tagName diff --git a/src/components/date-range-picker/spec.md b/src/components/date-range-picker/spec.md index 51784f61e..1f7200f0b 100644 --- a/src/components/date-range-picker/spec.md +++ b/src/components/date-range-picker/spec.md @@ -69,6 +69,7 @@ | 15 | 2026-11-02 | A `focus()` call before the first render applies after the render; `focus()` and `blur()` in the API | | 16 | 2026-11-04 | In dialog mode, an open and a close play the animations of the dialog; a canceled `igcClosing` keeps the dialog open | | 17 | 2026-11-05 | Done and Cancel change the range only when the close goes on; Done emits `igcChange` after `igcClosing` | +| 18 | 2026-11-06 | A closed picker does not cancel Escape | ## Overview @@ -407,7 +408,7 @@ As long as focus is within any part of the picker: | Key combination | Result | | ----------------- | ------------------------------------------------------------------------------------------------ | -| Escape | If the picker is shown, closes it and returns focus to the input part. Otherwise it is a no-op. | +| Escape | If the picker is shown, closes it and returns focus to the input part. Otherwise it is a no-op and does not cancel the key. | When an input part is focused: @@ -608,7 +609,7 @@ input and two inputs suites run with reduced motion, so the dialog does not fade 11. Methods. `stepUp` and `stepDown` of the range input update its value at once, also while it is focused and empty. A value set from code while the input is focused is not reported on blur. 12. Interactions - selection through the calendar, interactions with the input and the open and clear buttons, and - the readonly state. + the readonly state. A closed picker does not cancel Escape. 13. Undo and redo of the masked range text. 14. Slots. diff --git a/src/internals/testing/simulate.spec.ts b/src/internals/testing/simulate.spec.ts index a73d3b7aa..2de6b61b7 100644 --- a/src/internals/testing/simulate.spec.ts +++ b/src/internals/testing/simulate.spec.ts @@ -181,6 +181,18 @@ export function simulateKeyboard( } } +/** Dispatches a cancelable `keydown` of `key` and returns whether a listener canceled it. */ +export function simulateCancelableKeydown(node: Element, key: string): boolean { + return !node.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + }) + ); +} + /** Simulates scrolling for a given element. */ export async function simulateScroll( node: Element, From dc59c5c202b0a3ce297a085962f0808437706979 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 11 Nov 2026 22:23:47 +0200 Subject: [PATCH 32/32] fix(combo): address PR #2545 review; shared IME guard - Extras: bindComboDataSource moves to igniteui-webcomponents/extras/combo-data-source, so it no longer pulls in the chat Markdown peers. - Combo, remote mode: new results (the first data after a search, a shorter data, another first valueKey) clear the active option and show from the top, also in a list that opens later. An appended page keeps the place. - Combo, selection: a value without a record stays in value until a change names it (deselect, the clear icon, a new value). A resolve() answer selects only values that are still in value. - Combo: filteringOptions applies at once. A write of the same options does not update the combo. - Key bindings: keys that an IME handles are skipped for every component (combo, date time input, date picker, color picker, mask input). - Virtual scroll: a programmatic scroll and a resize sync the cached offset for the next render. --- .agents/context/project.md | 3 +- .github/CODING_GUIDELINES.md | 2 +- CHANGELOG.md | 7 +- README.md | 8 +- package.json | 3 +- scripts/_package.json | 4 + .../color-picker/color-picker.spec.ts | 10 + src/components/color-picker/spec.md | 6 +- src/components/combo/combo-remote.spec.ts | 173 ++++++++++++++++++ src/components/combo/combo-selection.spec.ts | 20 ++ src/components/combo/combo.spec.ts | 45 +++++ src/components/combo/combo.ts | 93 ++++++---- .../combo/controllers/data-requests.spec.ts | 107 ++++++++++- .../combo/controllers/data-requests.ts | 80 +++++++- src/components/combo/controllers/selection.ts | 122 ++++++++++-- src/components/combo/spec.md | 52 ++++-- .../date-picker/date-picker.spec.ts | 10 + src/components/date-picker/spec.md | 6 +- .../date-time-input/date-time-input.spec.ts | 15 ++ src/components/date-time-input/spec.md | 5 +- src/components/mask-input/spec.md | 3 +- src/components/virtualization/spec.md | 8 +- .../virtualization/virtualization.spec.ts | 21 +++ .../virtualization/virtualization.ts | 10 + src/extras/combo-data-source.spec.ts | 80 ++++++++ src/extras/combo-data-source.ts | 11 +- src/extras/index.ts | 1 - .../controllers/key-bindings.spec.ts | 13 +- src/internals/controllers/key-bindings.ts | 8 +- src/internals/mixins/mask-behavior.ts | 25 ++- src/internals/testing/simulate.spec.ts | 28 +++ stories/combo.stories.ts | 4 +- 32 files changed, 876 insertions(+), 107 deletions(-) diff --git a/.agents/context/project.md b/.agents/context/project.md index d30c436cb..6a0e27a6c 100644 --- a/.agents/context/project.md +++ b/.agents/context/project.md @@ -23,7 +23,8 @@ Read them before making changes; if they disagree with this file, they win. - `src/internals/`, `src/theming/`, `src/animations/`: shared code, imported only through the `#internals/*`, `#theming/*` and `#animations/*` aliases. The theme aggregator of a component is imported through `#themes/*`. -- `src/extras/`: opt-in add-ons, published as `igniteui-webcomponents/extras` +- `src/extras/`: opt-in add-ons, published under `igniteui-webcomponents/extras`. A new add-on gets its own + subpath export, so an import pulls in only its own dependencies - `src/styles/`: global SCSS utilities, mixins and themes - `src/index.ts`: the public entry point of the package - `stories/`: Storybook stories, one per component tag diff --git a/.github/CODING_GUIDELINES.md b/.github/CODING_GUIDELINES.md index 8ea58d483..2553dba9d 100644 --- a/.github/CODING_GUIDELINES.md +++ b/.github/CODING_GUIDELINES.md @@ -29,7 +29,7 @@ src/ ├── animations/ # Animation players and presets → #animations/* ├── components/ # One directory per component, each with its own spec.md -├── extras/ # Opt-in add-ons, published as `igniteui-webcomponents/extras` +├── extras/ # Opt-in add-ons, published under `igniteui-webcomponents/extras` ├── internals/ # Shared building blocks, never public API → #internals/* ├── styles/ # Global SCSS: utilities, mixins, themes ├── theming/ # Theming controller and types → #theming/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3febf95..9adb58c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Combo - Remote data: with `filteringOptions.mode` set to `remote`, the combo does not filter the options and keeps the search input. `igcSearchInput` reports the search text in `detail.searchText`, in both modes, so the application can search on the server and set `data` to the results. It also reports an empty text when a selection, a blur or the clear button clears the text, and the same text when the case-sensitive icon changes the case. The icon also shows in remote mode, so the application reads `filteringOptions.caseSensitive` for the server search. In remote mode, `igcDataRequest` asks for more items when the user scrolls near the end of `data`: append `count` items at `startIndex`, which is `data.length`. The new `ComboFilteringMode` and `IgcComboSearchInputEventArgs` types describe the mode and the detail. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `totalItemCount` gives the number of all the items of a remote source. In remote mode, the list shows a placeholder option for each item that `data` does not hold yet, so the scrollbar shows the full list, and `igcDataRequest` asks for the placeholders that the list renders, up to the total. Each placeholder is the skeleton of an item, with a wave that sweeps across it. `loading` shows a spinner, an indeterminate circular progress, in place of the toggle icon, and an empty list shows "Loading" instead of the empty message. The `loading` slot replaces the spinner. The spinner and the skeletons have the text color of the `loading` and `placeholder` parts, so `::part(loading)` and `::part(placeholder)` can set their colors. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - - `bindComboDataSource()` in `igniteui-webcomponents/extras` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. `load` gets the search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - `bindComboDataSource()` in `igniteui-webcomponents/extras/combo-data-source` binds a combo to a server that searches and pages: you give it a `load` function that returns one page, and an optional `resolve` for the items of preset values. `load` gets the search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. It sets `totalItemCount` from the total of the pages, and `loading` while a search waits or a page loads. A request loads at least `pageSize` items, and after a jump, such as End, all the items up to it. See "Extras" in the README. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `selectAllItems()` and `deselectAllItems()` select and deselect the items that the list shows after the filter. With `ignoreFilter`, they act on all the items of `data`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `showSelectAll` puts a "Select all" option first in the list in multiple selection mode. It selects the items that the list shows after the filter, or deselects them when all of them are selected, with one `igcChange`. While only some of them are selected, its checkbox is indeterminate. Like the other options in multiple selection, it reports `aria-checked`, which is `true` while all the shown items are selected and `mixed` while only some of them are. It is the first option in `aria-posinset` and `aria-setsize`. The `select-all` part styles the option, and the `indeterminate` part its checkbox. The text comes from the `combo_select_all` resource string. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - `groupSelection` makes each group header an option that works like the select-all option for the items of its group that the list shows, in multiple selection mode with `groupKey`. The header keeps the text style of a group header and the `group-header` part, and gets the `selected` and `active` parts and the checkbox parts with `indeterminate`. Like the select-all option, it reports `aria-checked`, which is `mixed` while only some of the shown items of its group are selected. It counts in `aria-posinset` and `aria-setsize`, and the arrow keys and Home stop on it. The items are one level in under their header, as in the tree, and `--ig-combo-item-indent` changes the indent. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) @@ -83,6 +83,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The `end` slot now renders after the `suffix` slot, at the end of the chip, as the `start` slot renders before the `prefix` slot. Before, it rendered before the `suffix` slot. - #### Combo - **Behavioral change:** A new `data` keeps the selection. Before, a selected item that the new `data` did not hold dropped out of `selection` and the input text, although `value` kept it, and the next selection by the user removed it from `value` too. Now it stays selected until the user or the application deselects it. To clear the selection with the data, as for cascading combos, set `value` to `[]`. `select()` also selects an object that `data` does not hold, so an application can select the records of a saved filter before their page loads; with `valueKey`, the object resolves by its key first. Without an argument, `select()` keeps the selected items that `data` does not hold. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) + - **Behavioral change:** In multiple selection, a value of `value` that has no record in `data`, such as a value of a saved filter that a remote page does not hold yet, now stays in `value` when the user or a method changes other items. Before, the next change of the selection removed it, although the user could not see or deselect it. `deselect()` with that value or without an argument, `deselectAllItems(true)`, the clear icon and a new `value` remove it. [#2545](https://github.com/IgniteUI/igniteui-webcomponents/pull/2545) - In multiple selection, the options report the state of their checkbox in `aria-checked`, not in `aria-selected`, as the WAI-ARIA listbox pattern allows for lists with checkboxes. So screen readers announce them as checked or not checked, as their checkboxes show. In single selection, the options keep `aria-selected`. [#2535](https://github.com/IgniteUI/igniteui-webcomponents/issues/2535) - The `igcInput` of the search input, and of the main input in single selection, no longer leaves the combo. Use `igcSearchInput`. [#2519](https://github.com/IgniteUI/igniteui-webcomponents/issues/2519) - `igcClosing` and `igcClosed` are typed `CustomEvent`, not `CustomEvent`. In TypeScript, change a handler that declares its parameter as `CustomEvent`. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) @@ -203,6 +204,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Color picker, Combo, Date picker, Date range picker, Dropdown, Select, Slider, Tooltip - In browsers without CSS anchor positioning, a popup inside an ancestor with CSS `zoom` is now placed at its anchor. Before, the zoom moved it away from the anchor, and its arrow was off center. - In browsers without CSS anchor positioning, a popup whose anchor is in a modal dialog or another top-layer element now stays at the anchor while the page scrolls. Before, it moved with the page for a frame. +- #### Color picker, Combo, Date picker, Date time input, Mask input + - The keys that an IME handles, such as the Enter that commits a composition, no longer act in these components. Before, the Enter that accepted a candidate in the main input of a single-selection combo selected the active option and closed the list, Escape closed the combo, the date picker and the color picker, and the arrow keys spun the date time input. In the mask input, Ctrl+Z and Ctrl+Y in a composition no longer cancel the key. [#2545](https://github.com/IgniteUI/igniteui-webcomponents/pull/2545) - #### Color picker, Date picker, Date range picker - While closed, the picker no longer cancels Escape. Before, a dropdown, a select or a combo list with the picker in its content stayed open when the user pressed Escape in the closed picker. - #### Color picker @@ -221,6 +224,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `igcChange.newValue` now has the order of the `value` that follows the change: the selected values first, then the new ones. Before, it listed the new values first, so an app that saved `newValue` as a staged selection did not match `value` after the change. [#2533](https://github.com/IgniteUI/igniteui-webcomponents/issues/2533) - The `igcChange` of the search input no longer leaves the combo. Before, a listener on the combo got a second `igcChange` with the search text as its `detail` when the search input lost the focus. [#2530](https://github.com/IgniteUI/igniteui-webcomponents/issues/2530) - A new `itemTemplate` or `groupHeaderTemplate` now re-renders the open list. Before, the items kept the old content until the list rendered again for another reason, such as a `data` change or a move of the active item. [#2555](https://github.com/IgniteUI/igniteui-webcomponents/issues/2555) + - Setting `filteringOptions` to the options that the combo already has no longer updates the combo. Before, a framework that sets each property on each render, such as React, made the combo filter and group the items again on each render. [#2545](https://github.com/IgniteUI/igniteui-webcomponents/pull/2545) - #### Date picker - `stepUp()` and `stepDown()` now update `value` and the form value at once, also while the input has the focus. Before, only the input showed the new date. - A `value` that you set from code, also with `stepUp()` or `stepDown()`, no longer emits `igcChange` on a later blur, in any mode. @@ -465,6 +469,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `layoutComplete` now resolves after the rendered items are measured. Before, it could resolve first. So `scrollToIndex` stopped its correction early, and an item with a size different from the estimate landed up to tens of pixels from the requested edge. [#2410](https://github.com/IgniteUI/igniteui-webcomponents/pull/2410) - A list larger than the maximum scroll size of the browser now shows its last items at the end of the scroll range, and `scrollToIndex` puts the item at the requested edge. The items also move evenly during a scroll. Before, the last items were out of reach, the item landed tens of pixels off, and the items jumped some pixels each time the rendered window changed. [#2410](https://github.com/IgniteUI/igniteui-webcomponents/pull/2410) - A new `data` array with new items at the rendered indices now measures those items again. Before, an item element that kept its index was not measured again. So the scroll size and the item positions used the estimate until the item left the rendered window. [#2412](https://github.com/IgniteUI/igniteui-webcomponents/pull/2412) + - A `data` change in the same task as `scrollToIndex()` now renders at the new offset. Before, the list rendered the window of the old offset until the `scroll` event. [#2545](https://github.com/IgniteUI/igniteui-webcomponents/pull/2545) ### Security - #### Tile manager diff --git a/README.md b/README.md index f98343589..9a80d53fe 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,13 @@ The condition names are `igc-theme-material`, `igc-theme-bootstrap`, `igc-theme- ## Extras -`igniteui-webcomponents/extras` has opt-in add-ons that build on the public API of the components. A bundle that does not import them does not contain them. +The extras are opt-in add-ons that build on the public API of the components. A bundle that does not import them does not contain them. Each one has its own entry point, so it needs only its own dependencies. -- `createMarkdownRenderer()` renders the chat messages from Markdown, with syntax highlighting. -- `bindComboDataSource()` binds a combo to a server that searches and pages. It sets `totalItemCount` from the total of the server, so the list shows a placeholder row for each item to load, and `loading` while a search waits or a page loads. +- `createMarkdownRenderer()` from `igniteui-webcomponents/extras` renders the chat messages from Markdown, with syntax highlighting. It imports the optional peer dependencies `marked` and `dompurify`, and loads `shiki` for the highlighting. +- `bindComboDataSource()` from `igniteui-webcomponents/extras/combo-data-source` binds a combo to a server that searches and pages. It sets `totalItemCount` from the total of the server, so the list shows a placeholder row for each item to load, and `loading` while a search waits or a page loads. ```ts -import { bindComboDataSource } from 'igniteui-webcomponents/extras'; +import { bindComboDataSource } from 'igniteui-webcomponents/extras/combo-data-source'; const binding = bindComboDataSource(combo, { load: async ({ searchText, caseSensitive, startIndex, count }, signal) => { diff --git a/package.json b/package.json index e756b2a00..b12ebb897 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "exports": { ".": "./src/index.js", "./types": "./src/types.js", - "./extras": "./src/extras/index.js" + "./extras": "./src/extras/index.js", + "./extras/combo-data-source": "./src/extras/combo-data-source.js" }, "imports": { "#internals/*.js": "./src/internals/*.ts", diff --git a/scripts/_package.json b/scripts/_package.json index 891016b08..bca0e4fb1 100644 --- a/scripts/_package.json +++ b/scripts/_package.json @@ -14,6 +14,10 @@ "./extras": { "types": "./extras/index.d.ts", "default": "./extras/index.js" + }, + "./extras/combo-data-source": { + "types": "./extras/combo-data-source.d.ts", + "default": "./extras/combo-data-source.js" } }, "imports": { diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index 9f29c82f0..33c3dd478 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -36,6 +36,7 @@ import { import { simulateCancelableKeydown, simulateClick, + simulateComposingKeys, simulateInput, simulateKeyboard, simulateScroll, @@ -1277,6 +1278,15 @@ describe('Color picker', () => { expect(simulateCancelableKeydown(picker, escapeKey)).to.be.true; }); + it('stays open on an Escape that an IME handles', async () => { + await picker.show(); + + simulateComposingKeys(picker, escapeKey); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + }); + it('opens with Alt+ArrowDown and closes with Alt+ArrowUp', async () => { simulateKeyboard(picker, [altKey, arrowDown]); await elementUpdated(picker); diff --git a/src/components/color-picker/spec.md b/src/components/color-picker/spec.md index e1a6fb1ff..2d41dbee7 100644 --- a/src/components/color-picker/spec.md +++ b/src/components/color-picker/spec.md @@ -59,6 +59,7 @@ | 11 | 2026-11-02 | The events of the inner inputs, format select and canvas stay inside the picker | | 12 | 2026-11-02 | A `focus()` call before the first render applies after the render; `focus()` and `blur()` in the API | | 13 | 2026-11-06 | A closed picker does not cancel Escape | +| 14 | 2026-11-11 | The keys that an IME handles do nothing | ## Overview @@ -308,7 +309,7 @@ Color values are locale-independent CSS strings. | Arrow keys | With the canvas marker focused, moves it one step in the pressed direction, updating the saturation and value. | | Tab | Moves focus between the interactive elements inside the open picker; focus is trapped while open. | -All of the above are skipped while the component is `disabled`. +All of the above are skipped while the component is `disabled`. A key that an IME handles, such as the Enter that commits a composition, does nothing here. ## API @@ -453,7 +454,8 @@ The component suite reuses `createFormAssociatedTestBed`, `runValidationContaine events of the format select, and the `igcColorPicked` of the canvas do not reach a listener on the picker. 11. Swatches - rendering the grid and applying a swatch. 12. Copy and eye dropper, in environments with and without platform support. -13. Open and close - the pointer, keyboard and outside-click paths. A closed picker does not cancel Escape. +13. Open and close - the pointer, keyboard and outside-click paths. A closed picker does not cancel Escape. An + Escape that an IME handles keeps the picker open. 14. Input mode - the anchor input, its label and its prefix. 15. Rendering of the picker surface rows, and the direction of the slider tracks in LTR and RTL. 16. Form association, the touched state, `defaultValue`, the initial format, and validation. diff --git a/src/components/combo/combo-remote.spec.ts b/src/components/combo/combo-remote.spec.ts index 9d749f134..20eca808a 100644 --- a/src/components/combo/combo-remote.spec.ts +++ b/src/components/combo/combo-remote.spec.ts @@ -2,6 +2,7 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { type SinonSpy, spy } from 'sinon'; import { internalsOf } from '#internals/controllers/internals.js'; import { + arrowDown, endKey, enterKey, spaceBar, @@ -376,6 +377,110 @@ describe('Combo', () => { expect(remote.value).to.eql(['BER', 'BRN', 'BGO', 'ALB']); }); + describe('A value without a record', () => { + const clearIcon = () => + remote.renderRoot.querySelector('[part="clear-icon"]')!; + + beforeEach(() => { + remote.value = ['AMS', 'XXX']; + }); + + it('stays in value through a click and the selection methods', async () => { + await openComboPopover(remote); + simulateClick(itemFor('Athens')); + await elementUpdated(remote); + + expect(details('igcChange')[0].newValue).to.eql(['AMS', 'XXX', 'ATH']); + expect(remote.value).to.eql(['AMS', 'XXX', 'ATH']); + + remote.select('BAR'); + remote.deselect('AMS'); + remote.selectAllItems(); + remote.deselectAllItems(); + + expect(remote.value).to.eql(['XXX']); + expect(remote.selection).to.be.empty; + }); + + it('leaves value when a deselection names it, and on the clear icon', async () => { + remote.deselect('XXX'); + + expect(remote.value).to.eql(['AMS']); + + remote.value = ['AMS', 'XXX']; + remote.deselect(); + + expect(remote.value).to.be.empty; + + remote.value = ['AMS', 'XXX']; + await elementUpdated(remote); + simulateClick(clearIcon()); + await elementUpdated(remote); + + expect(details('igcChange').at(-1).newValue).to.be.empty; + expect(remote.value).to.be.empty; + }); + + it('leaves value on a selection in single selection', async () => { + remote.singleSelect = true; + remote.value = ['XXX']; + remote.select('AMS'); + + expect(remote.value).to.eql(['AMS']); + }); + + it('lists each value one time', async () => { + remote.value = ['AMS', 'AMS', 'XXX', 'XXX']; + await openComboPopover(remote); + simulateClick(itemFor('Athens')); + await elementUpdated(remote); + + expect(remote.value).to.eql(['AMS', 'XXX', 'ATH']); + }); + + it('shows the clear icon also when only values without a record remain', async () => { + remote.value = ['XXX']; + await elementUpdated(remote); + + expect(clearIcon().hidden).to.be.false; + + simulateClick(clearIcon()); + await elementUpdated(remote); + + expect(remote.value).to.be.empty; + }); + + it('leaves value on a commit of a deselection that names it, and stays on a cancel', async () => { + remote.selectionCommit = 'confirm'; + await openComboPopover(remote); + + remote.deselect('XXX'); + expect(remote.pendingValue).to.eql(['AMS']); + + remote.cancelSelection(); + remote.select('BAR'); + expect(remote.pendingValue).to.eql(['AMS', 'XXX', 'BAR']); + + remote.cancelSelection(); + remote.deselect('XXX'); + remote.commitSelection(); + expect(remote.value).to.eql(['AMS']); + }); + + it('stays after a commit of the confirm mode', async () => { + remote.selectionCommit = 'confirm'; + await openComboPopover(remote); + simulateClick(itemFor('Athens')); + await elementUpdated(remote); + + expect(remote.pendingValue).to.eql(['AMS', 'XXX', 'ATH']); + + remote.commitSelection(); + + expect(remote.value).to.eql(['AMS', 'XXX', 'ATH']); + }); + }); + it('emits igcDataRequest near the end of the data, in data coordinates', async () => { remote.groupKey = 'letter'; await openComboPopover(remote); @@ -566,6 +671,74 @@ describe('Combo', () => { expect(details('igcDataRequest')).lengthOf(2); }); + it('shows new results from the top, with no active option', async () => { + remote.totalItemCount = 100; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + await comboStable(remote); + + expect(list.scrollTop).to.be.greaterThan(0); + + simulateInput(searchNative, { value: 'b' }); + remote.data = search('b'); + await comboStable(remote); + + expect(list.scrollTop).to.equal(0); + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + + simulateKeyboard(list, arrowDown); + await comboStable(remote); + + expect(items(remote)[0].active).to.be.true; + }); + + it('keeps the place for a copy of the same records, and not after a search', async () => { + remote.totalItemCount = 100; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + await comboStable(remote); + + const { scrollTop } = list; + remote.data = search(''); + await comboStable(remote); + + expect(list.scrollTop).to.equal(scrollTop); + expect(items(remote).some((item) => item.active)).to.be.true; + + // The same first record, after a search. + const [first, ...rest] = remote.data; + simulateInput(searchNative, { value: 'a' }); + remote.data = [first, ...rest.reverse()]; + await comboStable(remote); + + expect(list.scrollTop).to.equal(0); + }); + + it('shows new results that come while the list is closed from the top', async () => { + remote.totalItemCount = 100; + await openComboPopover(remote); + + simulateKeyboard(list, endKey); + await comboStable(remote); + await remote.hide(); + + remote.data = search('b'); + await elementUpdated(remote); + await openComboPopover(remote); + await comboStable(remote); + + expect(list.scrollTop).to.equal(0); + expect(details('igcDataRequest')).to.eql([ + { startIndex: 10, count: 20 }, + { startIndex: 10, count: 20 }, + ]); + }); + it('does not select a placeholder', async () => { remote.totalItemCount = 12; await openComboPopover(remote); diff --git a/src/components/combo/combo-selection.spec.ts b/src/components/combo/combo-selection.spec.ts index e956ab9aa..342f2a3bc 100644 --- a/src/components/combo/combo-selection.spec.ts +++ b/src/components/combo/combo-selection.spec.ts @@ -27,6 +27,7 @@ import { } from '#internals/testing/helpers.spec.js'; import { simulateClick, + simulateComposingKeys, simulateKeyboard, } from '#internals/testing/simulate.spec.js'; import { configureTheme } from '#theming/config.js'; @@ -109,6 +110,14 @@ describe('Combo', () => { expect(combo.value).to.eql(['BG01', 'BG02']); }); + it('acts on new filtering options at once', async () => { + await search('s'); + combo.filteringOptions = { caseSensitive: true }; + combo.selectAllItems(); + + expect(combo.value).to.eql(['US02', 'US03']); + }); + it('selects nothing in single selection, and deselects a shown item', async () => { combo.singleSelect = true; combo.select('US02'); @@ -628,6 +637,17 @@ describe('Combo', () => { expect(changes().map(({ type }) => type)).to.eql(['commit', 'commit']); }); + it('leaves the keys of an IME composition in the search input to the IME', async () => { + await click(1); + + simulateComposingKeys(searchInput, enterKey, arrowUp, escapeKey); + await comboStable(combo); + + expect(combo.open).to.be.true; + expect(combo.pendingValue).to.eql(['BG01', 'BG02']); + expect(changes()).to.be.empty; + }); + it('closes on Enter without igcChange when nothing changes', async () => { await click(1); await click(1); diff --git a/src/components/combo/combo.spec.ts b/src/components/combo/combo.spec.ts index 52ccaac7f..9591680b6 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -37,6 +37,7 @@ import { import { simulateBlur, simulateClick, + simulateComposingKeys, simulateKeyboard, simulatePointerDown, simulateScroll, @@ -1157,6 +1158,36 @@ describe('Combo', () => { expect(items(combo)).lengthOf(1); }); + it('does not update for the same filtering options', async () => { + const options = combo.filteringOptions; + await elementUpdated(combo); + + combo.filteringOptions = { ...options }; + + expect(combo.filteringOptions).to.equal(options); + expect(combo.isUpdatePending).to.be.false; + + combo.filteringOptions = { caseSensitive: true }; + + expect(combo.isUpdatePending).to.be.true; + }); + + it('applies filtering options that changed in place', async () => { + await elementUpdated(combo); + + const options = combo.filteringOptions; + options.caseSensitive = true; + combo.filteringOptions = { ...options }; + + expect(combo.isUpdatePending).to.be.true; + + await elementUpdated(combo); + combo.filteringOptions.matchDiacritics = true; + combo.filteringOptions = { caseSensitive: true }; + + expect(combo.isUpdatePending).to.be.true; + }); + it('should select the first matched item upon pressing enter after search', async () => { combo.singleSelect = true; await elementUpdated(combo); @@ -1173,6 +1204,20 @@ describe('Combo', () => { expect(firstOf(combo.value)).to.equal('BG01'); }); + it('leaves the Enter that commits an IME composition in the main input to the IME', async () => { + combo.singleSelect = true; + await elementUpdated(combo); + + await openComboPopover(combo); + await filterCombo('sof'); + + simulateComposingKeys(input, enterKey); + await elementUpdated(combo); + + expect(combo.value).to.be.empty; + expect(combo.open).to.be.true; + }); + it('should select only one item at a time in single selection mode', async () => { combo.singleSelect = true; await elementUpdated(combo); diff --git a/src/components/combo/combo.ts b/src/components/combo/combo.ts index dbf5b146b..2c8ad7a07 100644 --- a/src/components/combo/combo.ts +++ b/src/components/combo/combo.ts @@ -274,6 +274,8 @@ export default class IgcComboComponent< caseSensitive: false, matchDiacritics: false, }; + // The options of the last write. A change of the object in place does not change them. + private _appliedFiltering = { ...this._filteringOptions }; @state() private set _activeIndex(index: number) { @@ -451,7 +453,24 @@ export default class IgcComboComponent< */ @property({ type: Object, attribute: 'filtering-options' }) public set filteringOptions(value: Partial>) { + const applied = this._appliedFiltering; + const same = (options: Partial>) => + Object.entries(options).every( + ([key, option]) => applied[key as keyof FilteringOptions] === option + ); + + // The same options keep the object, so nothing updates. + if (same(value ?? {}) && same(this._filteringOptions)) { + return; + } + this._filteringOptions = { ...this._filteringOptions, ...value }; + this._appliedFiltering = { ...this._filteringOptions }; + + // Now, so selectAllItems() sees the new filter. Remote mode does not filter. + if (applied.mode !== 'remote' || this._filteringOptions.mode !== 'remote') { + this._state.invalidate(); + } } public get filteringOptions(): FilteringOptions { @@ -629,7 +648,7 @@ export default class IgcComboComponent< */ public get pendingValue(): ComboValue[] { const pending = this._selection.pending; - return pending ? this._selection.toValues(pending) : this.value; + return pending ? this._selection.valuesOf(pending) : this.value; } //#endregion @@ -673,16 +692,9 @@ export default class IgcComboComponent< this._activeIndex = -1; } - // In remote mode, the filtering options do not change the records. - const filtering = - props.has('filteringOptions') && - (this.filteringOptions.mode !== 'remote' || - props.get('filteringOptions')?.mode !== 'remote'); - if ( props.has('groupKey') || props.has('groupSorting') || - filtering || props.has('totalItemCount') || props.has('showSelectAll') || props.has('groupSelection') || @@ -699,6 +711,16 @@ export default class IgcComboComponent< if (props.has('data')) { this._syncSelectionFromData(); + + // New remote results start with no active option. + if ( + this._dataRequests.dataChanged( + props.get('data') ?? [], + this._listRef.value + ) + ) { + this._activeIndex = -1; + } } // The list can open again before anything reads the pending selection. @@ -848,7 +870,15 @@ export default class IgcComboComponent< ? Array.from(this._selection.current) : this._selection.resolve(collection); - return this._applySelection(resolved, type, emit); + if (!this._applySelection(resolved, type, emit)) { + return false; + } + + if (!selecting) { + this._selection.forget(collection); + } + + return true; } /** @@ -955,15 +985,18 @@ export default class IgcComboComponent< } const items = this._selection.changes(pending); + const value = this._selection.valuesOf(pending); - if (isEmpty(items)) { + // A deselection can name a value without a record, which changes no record. + if (isEmpty(items) && sameItems(value, this.value)) { return true; } + // A copy, so a handler cannot change `value` through it. if ( emit && !this._emitSelectionChange({ - newValue: this._selection.toValues(pending), + newValue: [...value], items, type: 'commit', }) @@ -973,7 +1006,7 @@ export default class IgcComboComponent< // The `value` setter finds the records that `data` does not hold here. this._selection.commit(pending); - this.value = this._selection.toValues(pending); + this.value = value; return true; } @@ -1034,28 +1067,23 @@ export default class IgcComboComponent< } private _clearSelection(): void { - // The clear icon is on the input, which shows `value`, so it clears - // `value` at once, and the pending selection with it. - if (this._selection.pending) { - if ( - this._emitSelectionChange({ - newValue: [], - items: this.selection, - type: 'deselection', - }) - ) { - this.value = []; - } - return; - } - if (this.singleSelect) { this._clearSearchTerm(true); this._clearSingleSelection(); - } else { - this._updateSelection([], 'deselection', true); + this._syncValueFromSelection(); + return; + } + + // Clears `value`, the pending selection and the unresolved values. + if ( + this._emitSelectionChange({ + newValue: [], + items: this.selection, + type: 'deselection', + }) + ) { + this.value = []; } - this._syncValueFromSelection(); } private _clearSingleSelection(): void { @@ -1126,8 +1154,7 @@ export default class IgcComboComponent< } private _emitSearchInput(searchText: string): void { - // A new search starts a new sequence of data requests. - this._dataRequests.reset(); + this._dataRequests.searched(); this.emitEvent('igcSearchInput', { detail: { searchText } }); } @@ -1468,7 +1495,7 @@ export default class IgcComboComponent< part="clear-icon" role="button" @click=${this._handleClearIconClick} - ?hidden=${this.disableClear || isEmpty(this._selection.selected)} + ?hidden=${this.disableClear || isEmpty(this.value)} aria-label=${ifDefined( this.resourceStrings.combo_clearItems_placeholder )} diff --git a/src/components/combo/controllers/data-requests.spec.ts b/src/components/combo/controllers/data-requests.spec.ts index 8869c92ed..a0981e9b2 100644 --- a/src/components/combo/controllers/data-requests.spec.ts +++ b/src/components/combo/controllers/data-requests.spec.ts @@ -11,7 +11,11 @@ type Item = { id: number }; const items = (count: number, start = 0): Item[] => Array.from({ length: count }, (_, i) => ({ id: start + i })); -const list = { updateComplete: Promise.resolve(true) }; +const list = { + updateComplete: Promise.resolve(true), + checkVisibility: () => true, + scrollToIndex: async () => {}, +}; function setup(mode: ComboFilteringMode = 'remote') { const request = spy(); @@ -19,6 +23,7 @@ function setup(mode: ComboFilteringMode = 'remote') { open: true, data: items(10), filteringOptions: { filterKey: undefined, mode }, + valueKey: 'id' as keyof Item | undefined, }; const state = { loadedLength: 10, totalCount: 100 }; const requests = new ComboDataRequests(host, state, request); @@ -183,6 +188,106 @@ describe('Combo data requests', () => { expect(request.callCount).to.equal(1); }); + describe('new results', () => { + const scrolled = () => { + const scrollToIndex = spy(async (_index: number) => {}); + return { ...list, scrollToIndex }; + }; + + it('scroll a shown list to the top at once', () => { + const { host, requests } = setup(); + const shown = scrolled(); + const previous = host.data; + + host.data = items(10, 100); + + expect(requests.dataChanged(previous, shown)).to.be.true; + expect(shown.scrollToIndex.calledOnceWith(0)).to.be.true; + }); + + it('come after a search, also with the same first record, or in a shorter data', () => { + const { host, requests } = setup(); + const previous = host.data; + + requests.searched(); + host.data = [previous[0], ...previous.slice(1).reverse()]; + expect(requests.dataChanged(previous, scrolled())).to.be.true; + + host.data = previous.slice(0, 5); + expect(requests.dataChanged(previous, scrolled())).to.be.true; + }); + + it('keep the place for a page appended after a search that did not load', () => { + const { host, requests } = setup(); + const previous = host.data; + + requests.searched(); + host.data = [...previous, ...items(10, 10)]; + + expect(requests.dataChanged(previous, scrolled())).to.be.false; + }); + + it('need a value key to compare the first records, and a previous data', () => { + const { host, requests } = setup(); + const previous = host.data; + + host.valueKey = undefined; + host.data = items(10, 100); + expect(requests.dataChanged(previous, scrolled())).to.be.false; + + host.valueKey = 'id'; + expect(requests.dataChanged([], scrolled())).to.be.false; + }); + + it('keep the place for a copy of the same records and an appended page', () => { + const { host, requests } = setup(); + const previous = host.data; + const shown = scrolled(); + + host.data = items(10); + expect(requests.dataChanged(previous, shown)).to.be.false; + + host.data = [...previous, ...items(10, 10)]; + expect(requests.dataChanged(previous, shown)).to.be.false; + expect(shown.scrollToIndex.called).to.be.false; + }); + + it('do not move the list in local mode', () => { + const { host, requests } = setup('local'); + const previous = host.data; + + requests.searched(); + host.data = items(10, 100); + + expect(requests.dataChanged(previous, scrolled())).to.be.false; + }); + + it('scroll a hidden list to the top when it opens, before the check', async () => { + const { host, requests, request } = setup(); + let visible = false; + const hidden = { ...scrolled(), checkVisibility: () => visible }; + const previous = host.data; + + host.data = items(10, 100); + requests.dataChanged(previous, hidden); + expect(hidden.scrollToIndex.called).to.be.false; + + visible = true; + requests.rendered(9); + requests.afterUpdate(changed([['open', false]]), hidden); + await aTimeout(0); + + expect(hidden.scrollToIndex.calledOnceWith(0)).to.be.true; + expect(hidden.scrollToIndex.firstCall.calledBefore(request.lastCall)).to + .be.true; + + requests.afterUpdate(changed([['open', false]]), hidden); + await aTimeout(0); + + expect(hidden.scrollToIndex.calledOnce).to.be.true; + }); + }); + it('checks after the list update when the total changes', async () => { const { state, requests, request } = setup(); diff --git a/src/components/combo/controllers/data-requests.ts b/src/components/combo/controllers/data-requests.ts index ef5673c86..ba47e52c2 100644 --- a/src/components/combo/controllers/data-requests.ts +++ b/src/components/combo/controllers/data-requests.ts @@ -1,5 +1,6 @@ -import type { PropertyValues, ReactiveElement } from 'lit'; +import type { PropertyValues } from 'lit'; import type { VirtualScrollDataRequest } from '../../virtualization/types.js'; +import type IgcVirtualScrollComponent from '../../virtualization/virtualization.js'; import { REMOTE_SCROLLING_THRESHOLD } from '../../virtualization/virtualization.js'; import type IgcComboComponent from '../combo.js'; import type { DataState } from './data.js'; @@ -8,7 +9,7 @@ const DATA_REQUEST_COUNT = 20; type DataRequestsHost = Pick< IgcComboComponent, - 'open' | 'data' | 'filteringOptions' + 'open' | 'data' | 'filteringOptions' | 'valueKey' >; type DataRequestsState = Pick< @@ -16,6 +17,11 @@ type DataRequestsState = Pick< 'loadedLength' | 'totalCount' >; +type DataRequestsList = Pick< + IgcVirtualScrollComponent, + 'updateComplete' | 'checkVisibility' | 'scrollToIndex' +>; + /** * Requests more data in remote filtering mode while the open list renders * near the end of `data`, or renders placeholders, up to `totalItemCount`. @@ -30,6 +36,10 @@ export class ComboDataRequests { private _lastDataRequest?: number; /** The index of the last rendered option, from the list. */ private _lastRenderedIndex = -1; + /** Whether a search came after the last `data`. */ + private _searched = false; + /** Whether a hidden list scrolls to the top when it opens. */ + private _toTopOnOpen = false; constructor( host: DataRequestsHost, @@ -46,6 +56,54 @@ export class ComboDataRequests { this._lastDataRequest = undefined; } + /** Starts the sequence of a new search. */ + public searched(): void { + this.reset(); + this._searched = true; + } + + /** + * Call from the `willUpdate()` of the host for a new `data`. Remote results + * show from the top when they are new: the first `data` after a search, a + * shorter `data`, or another first `valueKey`. A page appended to the same + * records keeps the place, also after a search that the application did not + * load. Without `valueKey`, a copy of the records is not new. + * @returns Whether `data` holds new results. + */ + public dataChanged(old: T[], list?: DataRequestsList): boolean { + const { data, filteringOptions, valueKey } = this._host; + const searched = this._searched; + const last = old.length - 1; + const appended = + data.length > last && data[0] === old[0] && data[last] === old[last]; + const firstKey = (records: T[]) => + valueKey ? records[0]?.[valueKey] : undefined; + const isNew = + filteringOptions.mode === 'remote' && + old.length > 0 && + !appended && + (searched || + data.length < old.length || + firstKey(data) !== firstKey(old)); + + this._searched = false; + + if (isNew) { + this._showFromTop(list); + } + + return isNew; + } + + /** A hidden list cannot scroll, so it scrolls when it opens. */ + private _showFromTop(list?: DataRequestsList): void { + if (list?.checkVisibility()) { + void list.scrollToIndex(0); + } else { + this._toTopOnOpen = true; + } + } + /** Takes the end of the window that the list renders. */ public rendered(endIndex: number): void { this._lastRenderedIndex = endIndex; @@ -55,7 +113,7 @@ export class ComboDataRequests { /** Call from the `updated()` of the host. */ public afterUpdate( props: PropertyValues>, - list?: Pick + list?: DataRequestsList ): void { const { open, data } = this._host; const opened = props.has('open') && open; @@ -70,10 +128,24 @@ export class ComboDataRequests { // The list reports a changed window after its own update, and an // unchanged one not at all, so check against the window after it. if (opened || props.has('data') || props.has('totalItemCount')) { - void list?.updateComplete.then(() => this._check()); + void list?.updateComplete.then(() => this._checkAfter(list, opened)); } } + /** Checks after a pending scroll to the top, against the new window. */ + private async _checkAfter( + list: DataRequestsList, + opened: boolean + ): Promise { + if (opened && this._toTopOnOpen) { + this._toTopOnOpen = false; + this._showFromTop(list); + await list.updateComplete; + } + + this._check(); + } + /** * The `count` covers the rendered placeholders. An empty `data` waits for * the first page from the application. diff --git a/src/components/combo/controllers/selection.ts b/src/components/combo/controllers/selection.ts index e72e5e689..4ae4445bc 100644 --- a/src/components/combo/controllers/selection.ts +++ b/src/components/combo/controllers/selection.ts @@ -28,6 +28,10 @@ export class ComboSelection { private _selected: Set = new Set(); /** Read it through {@link pending}. */ private _pending?: Set; + /** The values of `value` that have no record. See {@link valuesOf}. */ + private _unresolved = new Set>(); + /** The unresolved values of the pending selection, which a deselection can name. */ + private _pendingUnresolved?: Set>; constructor(host: ComboSelectionHost) { this._host = host; @@ -57,10 +61,15 @@ export class ComboSelection { private get _lazyPending(): Set | undefined { if (this._host.open && this.confirmMode) { - return (this._pending ??= new Set(this._selected)); + if (!this._pending) { + this._pending = new Set(this._selected); + this._pendingUnresolved = new Set(this._unresolved); + } + + return this._pending; } - this._pending = undefined; + this.dropPending(); return undefined; } @@ -69,9 +78,15 @@ export class ComboSelection { return this._lazyPending ?? this._selected; } + /** The unresolved values that go with {@link current}. */ + private get _targetUnresolved(): Set> { + return this._lazyPending ? this._pendingUnresolved! : this._unresolved; + } + /** The next read of {@link pending} copies {@link selected} again. */ public dropPending(): void { this._pending = undefined; + this._pendingUnresolved = undefined; } /** Call when `data` or `valueKey` is set. */ @@ -112,6 +127,62 @@ export class ComboSelection { return Array.from(items, (item) => this._valueOf(item)); } + private _toValue(item: Item): ComboValue { + return isObject(item) ? this._valueOf(item as T) : item; + } + + /** + * The values of `records`, and the values of `value` that have no record in + * their places. A change keeps those until it names them. Multiple selection + * only. + */ + public valuesOf(records: Iterable): ComboValue[] { + return this._withUnresolved(this.toValues(records), this._targetUnresolved); + } + + private _withUnresolved( + values: ComboValue[], + unresolved: ReadonlySet> + ): ComboValue[] { + if (this._host.singleSelect || !unresolved.size) { + return values; + } + + const next = new Set(values); + const kept = new Set( + this._host.value.filter( + (value) => unresolved.has(value) || next.has(value) + ) + ); + + return [...kept, ...values.filter((value) => !kept.has(value))]; + } + + /** Drops the values without a record that a deselection names, or all of them. */ + public forget(items: Item[]): void { + const unresolved = this._targetUnresolved; + + if (isEmpty(items)) { + unresolved.clear(); + return; + } + + for (const item of items) { + unresolved.delete(this._toValue(item)); + } + } + + private _resolveUnresolved( + records: Iterable, + unresolved: Set> + ): void { + if (unresolved.size) { + for (const record of records) { + unresolved.delete(this._valueOf(record)); + } + } + } + /** * Resolves values or objects to records: the records of `data` in * data-source order, then the selected records and the objects that `data` @@ -126,7 +197,7 @@ export class ComboSelection { let selected: Map, T> | undefined; for (const item of items) { - const value = isObject(item) ? this._valueOf(item as T) : item; + const value = this._toValue(item); const found = index.get(value); if (found) { @@ -159,14 +230,14 @@ export class ComboSelection { const current = this.current; if (selecting) { - return this.toValues( + return this.valuesOf( this._host.singleSelect ? records : new Set([...current, ...records]) ); } const removed = new Set(records); - return this.toValues( + return this.valuesOf( Iterator.from(current).filter((item) => !removed.has(item)) ); } @@ -178,6 +249,10 @@ export class ComboSelection { for (const item of records) { selecting ? target.add(item) : target.delete(item); } + + if (selecting) { + this._resolveUnresolved(records, this._targetUnresolved); + } } /** @@ -187,13 +262,11 @@ export class ComboSelection { * @returns Whether {@link selected} changed. */ public resolveValues(records: T[]): boolean { - const { value } = this._host; - - // `selected` has at most one record for each value. - if (this.pending && this._selected.size < value.length) { + if (this.pending && this._unresolved.size) { this._selected = this._recordsOf( - value, - new Set(records).union(this._selected) + this._host.value, + new Set(records).union(this._selected), + this._unresolved ); return true; } @@ -210,6 +283,8 @@ export class ComboSelection { } else { this._selected = all; } + + this._resolveUnresolved(all, this._targetUnresolved); } /** Clears the committed selection. The pending selection does not change. */ @@ -232,7 +307,8 @@ export class ComboSelection { /** Gives {@link selected} the records of `values`. A value that `data` does not hold keeps its record. */ public syncFromValue(values: ComboValue[]): void { - this._selected = this._recordsOf(values, this._selected); + this._unresolved = new Set(); + this._selected = this._recordsOf(values, this._selected, this._unresolved); } /** @@ -241,7 +317,7 @@ export class ComboSelection { * without a record can get a new record. Pass it to {@link syncPending}. */ public knownValues(): Set> | undefined { - return this.pending && this._selected.size < this._host.value.length + return this.pending && this._unresolved.size ? new Set(this.toValues(this._selected)) : undefined; } @@ -263,11 +339,11 @@ export class ComboSelection { ); } - this._pending = this._recordsOf(values, pending); + this._pending = this._recordsOf(values, pending, this._pendingUnresolved); } } - /** The values and the display text of {@link selected}, in one pass. */ + /** The values and the display text of {@link selected}, in one pass. See {@link valuesOf}. */ public project(): { values: ComboValue[]; text: string } { const { displayKey } = this._host; const values: ComboValue[] = []; @@ -278,13 +354,20 @@ export class ComboSelection { display.push(String((displayKey ? item[displayKey] : undefined) ?? item)); } - return { values, text: display.join(', ') }; + return { + values: this._withUnresolved(values, this._unresolved), + text: display.join(', '), + }; } - /** The records of `values`. A value that `data` does not hold keeps its record in `previous`. */ + /** + * The records of `values`. A value that `data` does not hold keeps its record + * in `previous`. `unresolved` gets the values without a record. + */ private _recordsOf( values: ComboValue[], - previous: ReadonlySet + previous: ReadonlySet, + unresolved?: Set> ): Set { const records = new Set(); @@ -306,6 +389,9 @@ export class ComboSelection { if (record !== undefined) { records.add(record); + unresolved?.delete(value); + } else { + unresolved?.add(value); } } diff --git a/src/components/combo/spec.md b/src/components/combo/spec.md index 74d175857..ce2e9f4c8 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -82,6 +82,7 @@ | 20 | 2026-11-06 | With group selection, the items are one level in under their header, as in the tree | | 21 | 2026-11-06 | In multiple selection, the options report `aria-checked`; the select-all and group header options report `mixed` | | 22 | 2026-11-06 | Escape from the slot content keeps the list open when the content handled the key | +| 23 | 2026-11-11 | The keys that an IME handles do nothing in the combo; new remote results show from the top with no active option, also in a list that opens; a value without a record stays in `value` until a change names it; the selection methods use a new `filteringOptions` at once, and the same options do not update the combo; `bindComboDataSource()` has its own entry point | ## Overview @@ -225,10 +226,19 @@ The selection methods send no `igcChange`. In single selection, `select()` witho user action sends one cancelable `igcChange` before the selection changes. For the confirm mode, see [Confirm mode](#confirm-mode). +A value of `value` without a record in `data` or in the selection, for example a value of a saved filter that a remote +page does not hold yet, has no option, so the user cannot deselect it. In multiple selection, it stays in `value` when +the user or a method changes other items, also in `igcChange.newValue`, `pendingValue` and a commit of the confirm +mode. `deselect()` with that value or without an argument, `deselectAllItems(true)`, the clear icon and a new `value` +remove it. In the confirm mode, such a deselection changes the pending selection, so a commit applies it and a cancel +keeps the value. In single selection, a selection replaces it. It is not in `selection` or in the input text, but the +clear icon shows for it. `value` lists each value one time. When `data` or `select()` gives it a record, it is a +selected item again. + #### Select all -`selectAllItems()` and `deselectAllItems()` act on the items that the list shows, after the filter. They send no -`igcChange`. With `ignoreFilter`, they act on all the items. +`selectAllItems()` and `deselectAllItems()` act on the items that the list shows, after the filter, also right after a +change of `data`, of the search text or of `filteringOptions`. They send no `igcChange`. With `ignoreFilter`, they act on all the items. In multiple selection, `show-select-all` adds a "Select all" option at the top of the list: @@ -416,6 +426,11 @@ server did not return for the text. opening. So a source with no more items gets one request for each opening. - An empty `data` gets no request, because the application loads the first page of a search. - An answer is stale when `data.length` is not equal to its `startIndex`, for example after a new search. +- New results show from the top, with no active option: the first `data` after a search, and a `data` that is + shorter or starts with another record. Records compare by `valueKey`; without it, only a search and a shorter `data` + count. So the list does not request pages from the offset of the old results. A list that is closed shows them from + the top when it opens. A page appended to the same records, also after a search that the application did not load, + and a copy of the same records keep the place. **Selection.** A new `data` keeps the selection. A selected item that the new `data` does not hold stays in `value`, in `selection` and in the input text. A later page or search that holds it shows it as selected again. The combo cannot @@ -450,7 +465,7 @@ toggle icon. on the toggle icon. Tab does not reach it while the list is open. - The busy state shows in the field, not in the list, so the list does not move. -`bindComboDataSource()` from `igniteui-webcomponents/extras` does this flow with the public API. Its `load()` gets the +`bindComboDataSource()` from `igniteui-webcomponents/extras/combo-data-source` does this flow with the public API. Its `load()` gets the search text and `caseSensitive`. A new search text or a click on the case-sensitive icon loads the first page again. After a change of `filteringOptions.caseSensitive` in code, call `refresh()`. See the `RemoteData` story. @@ -497,6 +512,9 @@ While the list is open: In multiple selection, the search input filters the list while it has the focus. +A key that an IME handles does nothing in the combo, for example the Enter that commits a composition or the Escape +that cancels it. + ## API ### Properties and attributes @@ -732,14 +750,16 @@ state, the data requests and the selection model without a browser fixture. 3. Single and multiple selection, with `select` and `deselect` with and without arguments. 4. `value` and `selection` give the values and the records. 5. Filtering, with case sensitivity, diacritics matching and `disableFiltering`. A set of `singleSelect` or - `disableFiltering` to its current value keeps the search text and the filter. + `disableFiltering` to its current value keeps the search text and the filter. The same filtering options do not + update the combo, and options that changed in place apply. 6. Grouping, the `Other` group for the items without a group value, and the sort directions. 7. The item and group header templates. The group header template gets the group `key` with its own type, also for number keys and the `Other` group. A template that reads the deprecated `item` still renders. A new item or group header template re-renders the open list. 8. The empty message and the `empty` slot. 9. Localization through `locale` and `resourceStrings`. -10. Open, close and the keyboard navigation of the list, also Arrow Down at the end of the matches. +10. Open, close and the keyboard navigation of the list, also Arrow Down at the end of the matches. In single + selection, the Enter of an IME composition in the main input does not select an option. 11. The clear icon and `disableClear`. 12. `igcChange` gives the new value, the items and the change type, and a cancel keeps the selection. 13. The `igcChange` of the search input, and the `igcStateChange` and `igcDataRequest` events of the list, stay inside @@ -805,7 +825,11 @@ state, the data requests and the selection model without a browser fixture. input text. A later selection adds to them, and the first page shows them as selected again with its own records. 28. `deselect()` removes a selected item that `data` does not hold. `select()` selects an object that `data` does not hold, with and without `valueKey`. `select()` without an argument keeps the other selected items, and selects each - value one time, also before a new `data` syncs. + value one time, also before a new `data` syncs. A value without a record stays in `value` through a click, the + selection methods and a commit of the confirm mode, also in `igcChange.newValue` and `pendingValue`. It leaves + `value` when a deselection names it, on `deselect()` without an argument, on the clear icon and on a selection + in single selection. Each value shows one time, the clear icon shows for such values, and in the confirm mode a + commit applies a deselection that names one, and a cancel keeps it. 29. `igcDataRequest` comes near the end of `data` with `startIndex` equal to `data.length`, also with groups. It does not come again until `data` grows. A new search, a new `data` without a search with a page of the same length, and a new opening each get their own request. It does not come in local mode, while the list is closed, or with an @@ -819,7 +843,10 @@ state, the data requests and the selection model without a browser fixture. 31. `igcDataRequest` covers the shown placeholders, for example after End, and does not go past `totalItemCount`. It stops when `data` holds `totalItemCount` items, and comes again when the total no longer stops it. Space, a click and `selectAllItems()` do not select a placeholder. In single selection, Enter or a click on a placeholder does - not close the list. + not close the list. New results show from the top with no active option, also after a search with the same + first record and in a list that opens after them, so the next request covers the top of the list. A copy of the + same records keeps the place. The unit specs of the data requests cover an appended page after a search, the + rule without `valueKey` and a first `data`. 32. `loading` shows the named spinner over the hidden toggle icon, and "Loading" in an empty list. The toggle width does not change, and the list does not move. It passes the accessibility audit. The `loading` slot replaces the spinner. The spinner and the skeletons get their colors from the `loading` and `placeholder` parts. @@ -827,7 +854,7 @@ state, the data requests and the selection model without a browser fixture. ### Select all methods 33. `selectAllItems()` and `deselectAllItems()` change only the items that the filtered list shows, also right after a - change of `data` or of the search text. They send no `igcChange`, and change nothing when the list shows no items. + change of `data`, of the search text or of `filteringOptions`. They send no `igcChange`, and change nothing when the list shows no items. With `ignoreFilter`, they act on all the items. In single selection, `selectAllItems()` does nothing and `deselectAllItems()` deselects a shown item. @@ -867,7 +894,8 @@ state, the data requests and the selection model without a browser fixture. 43. Enter applies the pending selection with one `igcChange` of the `commit` type with the changed items. Then it closes the list with the `enter` trigger. This also occurs in the search input and without an active option. Without a change, it closes the list with no `igcChange`. A canceled `igcChange` keeps the list open with the - pending selection. A handler that changes `newValue` does not change `value`. + pending selection. A handler that changes `newValue` does not change `value`. Enter, Arrow Up and Escape of an IME + composition in the search input keep the list open with the pending selection. 44. Escape, a click on the input, `hide()` and `open` set to `false` drop the pending selection, with no `igcChange`. 45. From the moment the list opens, the selection methods change the pending selection, also with an item that `data` does not hold. From the moment the list closes, they change the value. `commitSelection()` applies the pending @@ -875,7 +903,7 @@ state, the data requests and the selection model without a browser fixture. `value` keeps it. 46. The clear icon clears `value` and the pending selection with one `igcChange`. A new `data` keeps the pending selection by value, and adds the values of `value` that it gives a record. A value without a record stays in - `value` until a commit, and `select()` gives it a record in the input text. The select-all option and the group + `value`, also after a commit, and `select()` gives it a record in the input text. The select-all option and the group header options change the pending selection. In single selection, each change applies at once. 47. The footer shows Cancel and Apply with the `combo_cancel` and `combo_apply` texts, and the accessibility audit passes. Footer content replaces them, and the immediate mode and single selection do not show them. @@ -913,8 +941,8 @@ The form-associated mixin gives the focus after the form checks and the rules of - The list is virtualized, so an option that the list does not show has no DOM node. Use the data records, not the item elements. - Without a `valueKey`, `value` contains whole records, not primitive values. -- A user change sets `value` from the selected records, also a commit of the confirm mode. So a value that has no - record in `data` leaves `value`. The `resolve` option of `bindComboDataSource()` gives such values their records. +- A value without a record has no option and no text in the input. The `resolve` option of `bindComboDataSource()` + gives such values their records. - `singleSelect` overrides `selectionCommit`: in single selection, each change applies at once. A change of `selectionCommit` or `singleSelect` while the list is open drops the pending selection. diff --git a/src/components/date-picker/date-picker.spec.ts b/src/components/date-picker/date-picker.spec.ts index 25fb70513..10f8392fe 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -37,6 +37,7 @@ import { import { simulateCancelableKeydown, simulateClick, + simulateComposingKeys, simulateInput, simulateKeyboard, simulateScroll, @@ -1096,6 +1097,15 @@ describe('Date picker', () => { expect(simulateCancelableKeydown(picker, escapeKey)).to.be.true; }); + it('stays open on an Escape that an IME handles', async () => { + await picker.show(); + + simulateComposingKeys(picker, escapeKey); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + }); + it('should open the calendar picker on Alt + ArrowDown and close it on Alt + ArrowUp - dropdown mode', async () => { const eventSpy = spy(picker, 'emitEvent'); expect(picker.open).to.be.false; diff --git a/src/components/date-picker/spec.md b/src/components/date-picker/spec.md index c0222456b..c7ff1cc16 100644 --- a/src/components/date-picker/spec.md +++ b/src/components/date-picker/spec.md @@ -69,6 +69,7 @@ | 12 | 2026-11-04 | In dialog mode, an open and a close play the animations of the dialog; a canceled `igcClosing` keeps the dialog open | | 13 | 2026-11-06 | A canceled Enter `keydown`, or the Enter that commits an IME composition, does not submit the form | | 14 | 2026-11-06 | A closed picker does not cancel Escape | +| 15 | 2026-11-11 | The keys that an IME handles do nothing | ## Overview @@ -502,7 +503,7 @@ When the input part of the component is focused: | Enter | Submits the owning form, if any and if the picker is valid. | All of the above are no-ops while the component is `readonly` or `disabled`; the editing subset is additionally a -no-op while the component is `non-editable` or in **dialog** mode. +no-op while the component is `non-editable` or in **dialog** mode. A key that an IME handles, such as the Enter that commits a composition, does nothing here. When focus is within the calendar, the keyboard navigation follows the [keyboard interactions of the calendar](../calendar/spec.md#keyboard-interactions). @@ -715,7 +716,8 @@ Grouped as `Uncommitted edits - issue #1346` in the suite. ### Interactions -34. Escape closes an open picker. A closed picker does not cancel Escape. +34. Escape closes an open picker, except when an IME handles it. A closed picker does not cancel + Escape. 35. Alt + Arrow Down opens and Alt + Arrow Up closes the picker, in both modes. 36. A value set from code, also with `stepUp`, is not reported as a change on a later blur, in the editable, diff --git a/src/components/date-time-input/date-time-input.spec.ts b/src/components/date-time-input/date-time-input.spec.ts index 1bcda9107..6656137d6 100644 --- a/src/components/date-time-input/date-time-input.spec.ts +++ b/src/components/date-time-input/date-time-input.spec.ts @@ -25,6 +25,7 @@ import { } from '#internals/testing/form-testbed.spec.js'; import { isFocused } from '#internals/testing/helpers.spec.js'; import { + simulateComposingKeys, simulateInput, simulateKeyboard, simulateWheel, @@ -1002,6 +1003,20 @@ describe('Date Time Input component', () => { expect(element.value.getFullYear()).to.equal(value.getFullYear() + 1); }); + it('does not spin on an arrow key that an IME handles', async () => { + const value = new Date(2020, 2, 3); + element.value = value; + + element.focus(); + await elementUpdated(element); + const text = input.value; + + simulateComposingKeys(input, arrowUp); + await elementUpdated(element); + + expect(input.value).to.equal(text); + }); + it('ArrowDown should stepDown correctly', async () => { const value = new Date(2020, 2, 3); element.value = value; diff --git a/src/components/date-time-input/spec.md b/src/components/date-time-input/spec.md index 98b1ba44a..aa2ac662d 100644 --- a/src/components/date-time-input/spec.md +++ b/src/components/date-time-input/spec.md @@ -67,6 +67,7 @@ | 11 | 2026-11-02 | A `focus()` call before the first render applies after the render | | 12 | 2026-11-06 | A value set from code is not a user change, also while focused, so no `igcChange` reports it; `filled` only while the input shows more than the placeholder or the empty mask | | 13 | 2026-11-06 | A canceled Enter `keydown`, or the Enter that commits an IME composition, does not submit the form | +| 14 | 2026-11-11 | The keys that an IME handles do nothing | ## Overview @@ -575,6 +576,7 @@ localized by the application. All of the above are no-ops while the editor is `readonly`. Modified arrow presses other than the ones listed - for example Alt + Arrow Up - are ignored, so they stay available to composing components. +A key that an IME handles, such as the Enter that commits a composition, does nothing here. ## API @@ -751,7 +753,8 @@ Grouped as `Uncommitted edits - issue #1346` in the suite. ### Keyboard, wheel and pointer -30. Arrow Up and Arrow Down spin the focused part, and are a no-op while read-only. +30. Arrow Up and Arrow Down spin the focused part, and are a no-op while read-only and when + an IME handles them. 31. Alt + arrow presses are a no-op. 32. The caret does not move away from the focused part when `stepUp` or `stepDown` are invoked, and the value updates at once, also for a part that is not in the input format and over a keyboard draft. diff --git a/src/components/mask-input/spec.md b/src/components/mask-input/spec.md index f7f52a6a5..ce81fe729 100644 --- a/src/components/mask-input/spec.md +++ b/src/components/mask-input/spec.md @@ -59,6 +59,7 @@ | 9 | 2026-10-23 | The mask input makes a stacking context, so the z-index of the input and the container does not compete with the layers of the page | | 10 | 2026-10-28 | Forward the host combobox state, as the input does | | 11 | 2026-11-02 | A `focus()` call before the first render applies after the render | +| 12 | 2026-11-11 | The keys that an IME handles do nothing | ## Overview @@ -304,7 +305,7 @@ the UI. The `label`, `placeholder` and helper text are provided by the applicati | Ctrl + Y | Redoes the last undone step. | | Ctrl + Shift + Z / Cmd + Shift + Z | Redoes the last undone step. | -All other native text-editing keys apply, as the component delegates focus to the inner native input. +All other native text-editing keys apply, as the component delegates focus to the inner native input. A key that an IME handles, such as the Enter that commits a composition, does nothing here. ## API diff --git a/src/components/virtualization/spec.md b/src/components/virtualization/spec.md index 77ad3babc..a5cd6d315 100644 --- a/src/components/virtualization/spec.md +++ b/src/components/virtualization/spec.md @@ -60,6 +60,7 @@ | 7 | 2026-09-30 | A `data` change measures the rendered items again from the first changed index | | 8 | 2026-10-21 | API descriptions aligned with the JSDoc. | | 8 | 2026-10-05 | The stylesheet comes back after other code replaces the sheets of the tree scope | +| 9 | 2026-11-11 | `scrollToIndex` renders the new window in the same task; a hidden list keeps its window | ## Overview @@ -219,6 +220,10 @@ Items outside the rendered window carry only an estimate, so the first jump can point are then measured and the offset is corrected, repeatedly, until it is stable. The returned promise resolves on the final offset, and a caller that only needs the approximate scroll can ignore it. +The list renders the window of the new offset in the same task, before the `scroll` event. So a `data` change right +after the call renders at the new offset. A hidden list does not scroll: it keeps its window, and the browser gives +it its old offset when it shows. + #### Infinite scrolling When the rendered window comes within five items of the end of `data`, `igcDataRequest` is emitted with the first @@ -362,7 +367,8 @@ scope, the component adopts its stylesheet again on its next update. ### Public API -10. `scrollToIndex` sets `scrollTop` in the vertical orientation and `scrollLeft` in the horizontal one. +10. `scrollToIndex` sets `scrollTop` in the vertical orientation and `scrollLeft` in the horizontal one. It renders + the new window before the `scroll` event, and a call on a hidden list keeps the window. 11. It settles on the last index instead of waiting out the scroll timeout, and does nothing for `block: nearest` when the item is already in view, including an item that already fills the viewport. 12. It keeps the requested index aligned once the real item sizes differ from the estimate, including a far-away diff --git a/src/components/virtualization/virtualization.spec.ts b/src/components/virtualization/virtualization.spec.ts index c803f9424..66614d0e2 100644 --- a/src/components/virtualization/virtualization.spec.ts +++ b/src/components/virtualization/virtualization.spec.ts @@ -393,6 +393,27 @@ describe('VirtualScroll', () => { expect(el.scrollLeft).to.be.greaterThan(0); }); + it('renders the window of scrollToIndex before the scroll event', async () => { + const el = await createFixedScroll(); + + void el.scrollToIndex(500); + await elementUpdated(el); + + expect(el.querySelector('[data-vs-index="500"]')).to.exist; + }); + + it('keeps its window when scrollToIndex runs while it is hidden', async () => { + const el = await createFixedScroll(); + await el.scrollToIndex(500); + + el.style.display = 'none'; + void el.scrollToIndex(100); + await elementUpdated(el); + el.style.display = ''; + + expect(el.querySelector('[data-vs-index="500"]')).to.exist; + }); + it('settles at the last index instead of waiting out the scroll timeout', async () => { const el = await fixture>( html`