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 c5f881eb0..2a45b2988 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 d60fceeb7..31e5ba4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - #### Chip - A host `aria-expanded`, `aria-haspopup` and `aria-controls` now go to the action button, so a chip that opens a menu or a filter panel can tell its state to assistive technology. A selectable chip keeps its `aria-pressed`. - #### 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/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) + - `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) - #### Date time input, File input, Input, Mask input, Textarea - A host `role="combobox"`, `aria-expanded`, `aria-haspopup`, `aria-autocomplete`, `aria-controls` and `aria-activedescendant` now also go to the native input or textarea, so a page can make the field a combobox over its own listbox, for example in a command palette or a search with suggestions. The focus stays in the field, and the screen reader reads the active option. Another host role stays on the host, and so does `aria-expanded` without the combobox role, because a text box does not support it. @@ -73,6 +81,14 @@ 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 + - **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) + - **Behavioral change:** In single selection, `Enter` with the list closed now submits the form, as in a native input with a list, and the combo counts as a text field of the form. So a form with the combo and another text field needs a submit button. While the list is open, `Enter` still selects the active option and does not submit. In multiple selection, `Enter` does not submit, as in a native `select`. Before, `Enter` with the list closed did nothing. [#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) + - 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) - #### Date picker, Date range picker, Dialog - **Behavioral change:** The backdrop of the dialog is now the native `::backdrop` of the `` element, in the top layer. So it covers the full page, also when an ancestor has a `transform`, a `filter` or a stacking context, for example a dialog in a tab panel. Before, the backdrop was an element with `position: fixed`, and such an ancestor kept it inside its own area. The `backdrop` and `animating` parts of the dialog, and the `overlay` part of the date picker and the date range picker in dialog mode, are removed. Styles for these parts do nothing now. To style the backdrop, use `igc-dialog::part(base)::backdrop`, or `::part(dialog-base)::backdrop` on a date picker. The backdrop also covers a toast or a snackbar that opened before the dialog. When `open` is set to `false` directly, the backdrop goes away at once with the dialog. With reduced motion, the backdrop does not fade. - #### Date time input @@ -185,9 +201,17 @@ 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) + - 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 - 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 - 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) @@ -199,7 +223,12 @@ 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. 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) - 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. @@ -446,6 +475,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 f10551403..f068f44c1 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) @@ -184,6 +185,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 + +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()` 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/combo-data-source'; + +const binding = bindComboDataSource(combo, { + 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 }; + }, +}); + +binding.dispose(); // removes the listeners, aborts the request in flight and clears `totalItemCount` and `loading` +``` + ## Browser support | ![chrome_48x48] | ![firefox_48x48] | ![edge_48x48] | ![opera_48x48] | ![safari_48x48] | diff --git a/api-reference/igc-combo.md b/api-reference/igc-combo.md index 3e55f7cfd..4ee4a9baa 100644 --- a/api-reference/igc-combo.md +++ b/api-reference/igc-combo.md @@ -13,7 +13,8 @@ defineComponents(IgcComboComponent); ## Properties - `data`: `T[]`. Default: `[]`. No attribute. - 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. - `outlined`: `boolean`. Default: `false`. Attribute: `outlined`, reflected. Whether the control has an outlined appearance. - `singleSelect`: `boolean`. Default: `false`. Attribute: `single-select`, reflected. @@ -44,12 +45,34 @@ defineComponents(IgcComboComponent); An object that configures the filtering of the combo. - `caseSensitiveIcon`: `boolean`. Default: `false`. Attribute: `case-sensitive-icon`. 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. - `disableFiltering`: `boolean`. Default: `false`. Attribute: `disable-filtering`. Whether the filtering of the list of options is disabled. In multiple selection mode, it also hides the search input. - `disableClear`: `boolean`. Default: `false`. Attribute: `disable-clear`. Whether the clear button is hidden. +- `totalItemCount`: `number | undefined`. Attribute: `total-item-count`. + 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. +- `loading`: `boolean`. Default: `false`. Attribute: `loading`, reflected. + 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`: `boolean`. Default: `false`. Attribute: `show-select-all`. + 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`: `boolean`. Default: `false`. Attribute: `group-selection`. + 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`: `'immediate' | 'confirm'`. Default: `'immediate'`. Attribute: `selection-commit`. + 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`: `ComboItemTemplate`. No attribute. The template used for the content of each combo item. - `groupHeaderTemplate`: `ComboGroupHeaderTemplate`. No attribute. @@ -59,6 +82,10 @@ defineComponents(IgcComboComponent); field of an item. Without it, each value is the item itself. - `selection`: `T[]`. Read-only. No attribute. Returns the current selection as an array of objects as provided in the `data` source. +- `pendingValue`: `ComboValue[]`. Read-only. No attribute. + 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`. - `locale`: `string`. Attribute: `locale`. 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 @@ -111,11 +138,23 @@ defineComponents(IgcComboComponent); Removes focus from the component. - `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. + 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 = false): void` + Selects all the items that the list shows. It does not emit `igcChange`, + and does nothing in single selection mode. +- `deselectAllItems(ignoreFilter?: boolean = false): void` + Deselects all the items that the list shows. It does not emit `igcChange`. +- `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. - `reportValidity(): boolean` Checks validity. As for a native control, an invalid control emits `invalid` and takes the focus. It shows its own validation messages, not @@ -142,23 +181,30 @@ defineComponents(IgcComboComponent); ## Events - `igcChange` - Emitted before the user changes the selection. Cancel the event to keep the selection. + 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. +- `igcPendingChange` + Emitted in the confirm mode of `selectionCommit` before the user changes the pending selection. Cancel the event to keep the pending selection. - `igcOpening` Emitted before the user opens the list of options. Cancelable. - `igcOpened` Emitted after the list of options opens on a user action. - `igcClosing` - Emitted before the user closes the list of options. Cancelable. + Emitted before the user closes the list of options. Cancelable. The `trigger` of the detail is the user action that closes the list. - `igcClosed` - Emitted after the list of options closes on a user action. + 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` + 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` + 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. ## Slots - `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 of the combo. -- `footer`: Renders a container after the list of options of the combo. +- `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. - `empty`: Renders content when the combo dropdown list has no items/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 of the combo. - `toggle-icon`: Renders content that replaces the default toggle icon. - `clear-icon`: Renders content that replaces the default clear icon. @@ -184,17 +230,25 @@ defineComponents(IgcComboComponent); - `list`: The list of options box of the combo. - `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, 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`: The container holding the header content of the combo. - `footer`: The container holding the footer content of the combo. +- `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 of the combo. +- `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 properties +- `--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`. ## CSS custom states 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/public-api.json b/public-api.json index d2ad5f2c0..b7f545418 100644 --- a/public-api.json +++ b/public-api.json @@ -23,10 +23,13 @@ "ColorFormat", "ColorPickerMode", "ColorPickerResourceStringsEN", + "ComboCloseTrigger", + "ComboFilteringMode", "ComboGroupHeaderContext", "ComboGroupHeaderTemplate", "ComboItemTemplate", "ComboResourceStringsEN", + "ComboSelectionCommit", "ComboTemplateProps", "ContentOrientation", "CustomDateRange", @@ -105,8 +108,10 @@ "IgcColorPickerComponent", "IgcColorPickerComponentEventMap", "IgcComboChangeEventArgs", + "IgcComboClosingEventArgs", "IgcComboComponent", "IgcComboComponentEventMap", + "IgcComboSearchInputEventArgs", "IgcDatePickerComponent", "IgcDatePickerComponentEventMap", "IgcDateRangePickerComponent", @@ -1217,9 +1222,11 @@ "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", + "loading": "boolean", "locale": "string", "name": "string", "open": "boolean", @@ -1228,7 +1235,10 @@ "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", "value": "ComboValue[]", "value-key": "Keys | undefined" }, @@ -1246,23 +1256,29 @@ "form": "readonly HTMLFormElement | null", "groupHeaderTemplate": "ComboGroupHeaderTemplate", "groupKey": "Keys | string | undefined", + "groupSelection": "boolean", "groupSorting": "\"asc\" | \"desc\" | \"none\" ('asc' | 'desc' | 'none')", "invalid": "boolean", "itemTemplate": "ComboItemTemplate", "label": "string | undefined", + "loading": "boolean", "locale": "string", "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", "static tagName": "readonly string", + "totalItemCount": "number | undefined", "validationMessage": "readonly string", "validity": "readonly ValidityState", "value": "ComboValue[]", @@ -1271,12 +1287,16 @@ }, "methods": { "blur": "() => void", + "cancelSelection": "() => void", "checkValidity": "() => boolean", + "commitSelection": "() => void", "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", @@ -1286,8 +1306,11 @@ "igcChange": "unknown", "igcClosed": "unknown", "igcClosing": "unknown", + "igcDataRequest": "unknown", "igcOpened": "unknown", - "igcOpening": "unknown" + "igcOpening": "unknown", + "igcPendingChange": "unknown", + "igcSearchInput": "unknown" }, "slots": [ "clear-icon", @@ -1297,13 +1320,17 @@ "header", "helper-text", "invalid", + "loading", "prefix", "suffix", "toggle-icon", "value-missing" ], "cssParts": [ + "actions", "active", + "apply-button", + "cancel-button", "case-icon", "checkbox", "checkbox-indicator", @@ -1316,19 +1343,24 @@ "group-header", "header", "helper-text", + "indeterminate", "input", "item", "label", "list", "list-wrapper", + "loading", "native-input", + "placeholder", "prefix", "search-input", + "select-all", "selected", "suffix", "toggle-icon" ], "cssProperties": [ + "--ig-combo-item-indent", "--ig-helper-min-lines" ] }, 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/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/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index a25cdf06e..33c3dd478 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -34,7 +34,9 @@ import { withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { + simulateCancelableKeydown, simulateClick, + simulateComposingKeys, simulateInput, simulateKeyboard, simulateScroll, @@ -1269,6 +1271,22 @@ 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('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/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..2d41dbee7 100644 --- a/src/components/color-picker/spec.md +++ b/src/components/color-picker/spec.md @@ -58,6 +58,8 @@ | 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 | +| 14 | 2026-11-11 | The keys that an IME handles do nothing | ## Overview @@ -302,12 +304,12 @@ 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. | -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 @@ -452,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. +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-form.spec.ts b/src/components/combo/combo-form.spec.ts new file mode 100644 index 000000000..9e7ed96a5 --- /dev/null +++ b/src/components/combo/combo-form.spec.ts @@ -0,0 +1,449 @@ +import { elementUpdated, expect, html, waitUntil } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { spy } from 'sinon'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + type City, + cities, + comboStable, + openComboPopover, +} 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('Enter submission', () => { + const spec = createFormAssociatedTestBed>( + html` + + ` + ); + + let input: HTMLInputElement; + + beforeEach(async () => { + await spec.setup(IgcComboComponent.tagName); + await spec.setProperties({ singleSelect: true }, true); + input = spec.element.renderRoot + .querySelector('#target')! + .renderRoot.querySelector('input')!; + }); + + it('submits once on a real Enter in single selection with the list closed', async () => { + expect(await spec.pressEnter()).to.equal(1); + expect(spec.submitWithEnter(input)).to.be.true; + }); + + it('does not submit on Enter while the list is open', async () => { + await openComboPopover(spec.element); + expect(spec.submitWithEnter(input)).to.be.false; + }); + + it('selects the active option on Enter and submits on the next Enter', async () => { + spec.element.focus(); + await sendKeys({ type: 'sof' }); + await comboStable(spec.element); + + expect(await spec.pressEnter()).to.equal(0); + expect(spec.element.value).to.eql(['BG01']); + + await waitUntil(() => !spec.element.open); + expect(await spec.pressEnter()).to.equal(1); + }); + + it('does not submit in multiple selection, also with a submit button', async () => { + await spec.setProperties({ singleSelect: false }, true); + spec.form.insertAdjacentHTML('beforeend', ''); + + expect(await spec.pressEnter()).to.equal(0); + expect(spec.submitWithEnter(input)).to.be.false; + }); + + it('counts as a text field only in single selection', async () => { + spec.form.insertAdjacentHTML( + 'beforeend', + '' + ); + const other = spec.form.querySelector( + 'igc-input[name="other"]' + )!; + await elementUpdated(other); + const otherInput = other.renderRoot.querySelector('input')!; + + expect(spec.submitWithEnter(input)).to.be.false; + expect(spec.submitWithEnter(otherInput)).to.be.false; + + await spec.setProperties({ singleSelect: false }, true); + expect(spec.submitWithEnter(otherInput)).to.be.true; + }); + + it('leaves Enter on slotted content to that content', async () => { + const button = spec.element.querySelector('button')!; + const click = spy(); + button.addEventListener('click', click); + + expect(await spec.pressEnter(button)).to.equal(0); + expect(click.calledOnce).to.be.true; + }); + }); + + 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-item.ts b/src/components/combo/combo-item.ts index df91f53ab..7623028f3 100644 --- a/src/components/combo/combo-item.ts +++ b/src/components/combo/combo-item.ts @@ -40,7 +40,19 @@ export default class IgcComboItemComponent extends LitElement { public active = false; /** - * Determines whether the item hides its selection checkbox. + * Whether the checkbox of the item is indeterminate. The box and the + * 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 + */ + @property({ type: Boolean }) + public indeterminate = false; + + /** + * 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 */ @@ -54,17 +66,29 @@ 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}`, + }, }); } 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-remote.spec.ts b/src/components/combo/combo-remote.spec.ts new file mode 100644 index 000000000..20eca808a --- /dev/null +++ b/src/components/combo/combo-remote.spec.ts @@ -0,0 +1,901 @@ +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, +} 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; + }); + + describe('Case-sensitive icon', () => { + let icon: HTMLElement; + + beforeEach(async () => { + remote.caseSensitiveIcon = true; + await openComboPopover(remote); + icon = searchInput.querySelector('[part~="case-icon"]')!; + }); + + 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']); + }); + + 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 () => { + 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']); + }); + + 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); + + 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('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); + + 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..342f2a3bc --- /dev/null +++ b/src/components/combo/combo-selection.spec.ts @@ -0,0 +1,1027 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import { type SinonSpy, spy } from 'sinon'; +import { + arrowDown, + arrowUp, + enterKey, + escapeKey, + homeKey, + spaceBar, + tabKey, +} from '#internals/controllers/key-bindings.js'; +import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + ariaOf, + 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, + simulateComposingKeys, + 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('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'); + 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"]'); + + // 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: 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( + `${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()!).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' }, + ]); + }); + + 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()!).checked).to.equal('mixed'); + 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()!).checked).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(ariaOf(selectAll()!).checked).to.equal('mixed'); + 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(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'); + 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('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'); + + simulateClick(headers()[0]); + await comboStable(combo); + + expect(combo.value).to.eql(['BG01', 'BG02']); + expect(ariaOf(headers()[0]).checked).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(ariaOf(bulgaria).checked).to.equal('mixed'); + 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) => ariaOf(header).checked)).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('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); + 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 7d5351fd1..9591680b6 100644 --- a/src/components/combo/combo.spec.ts +++ b/src/components/combo/combo.spec.ts @@ -1,13 +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 { spy } from 'sinon'; - +import { match, type SinonSpy, spy } from 'sinon'; import { altKey, arrowDown, @@ -21,9 +15,17 @@ import { tabKey, } from '#internals/controllers/key-bindings.js'; import { defineComponents } from '#internals/definitions/defineComponents.js'; +import { + ariaOf, + type City, + cities, + comboStable, + openComboPopover, + items, + layoutComplete, +} from '#internals/testing/combo.spec.js'; import { runFocusBeforeRenderTests } from '#internals/testing/focus-before-render.spec.js'; import { - createFormAssociatedTestBed, runAriaProjectionTests, runExternalLabelAssociationTests, } from '#internals/testing/form-testbed.spec.js'; @@ -35,74 +37,26 @@ import { import { simulateBlur, simulateClick, + simulateComposingKeys, 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'; +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 { ComboItemTemplate } from './types.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', @@ -178,34 +132,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); @@ -480,6 +406,7 @@ describe('Combo', () => { expect(eventSpy).calledOnceWithExactly('igcClosing', { cancelable: true, + detail: { trigger: 'toggle' }, }); expect(eventSpy).not.calledWith('igcClosed'); }); @@ -707,6 +634,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 = { @@ -1215,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); @@ -1231,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); @@ -1897,6 +1884,473 @@ 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('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('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); + + 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 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; + apply.hidden = true; + await elementUpdated(combo); + + await tab(); + await elementUpdated(combo); + + expectFocus(buttonAfter, false); + }); + }); + + 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', () => { @@ -1936,6 +2390,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', () => { @@ -2040,6 +2510,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); @@ -2072,332 +2569,6 @@ describe('Combo', () => { }); }); - 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/combo.ts b/src/components/combo/combo.ts index 40a9bf1a7..96ef57069 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 { property, state } from 'lit/decorators.js'; +import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'; +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'; import { IgcBaseComboBoxComponent } from '#internals/bases/combo-box.js'; import { addAriaProjector } from '#internals/controllers/aria-projection.js'; import { addSlotController, setSlots } from '#internals/controllers/slot.js'; @@ -18,7 +19,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, @@ -27,29 +33,42 @@ import { import { bindIf } from '#internals/utils/lit.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'; +import IgcCircularProgressComponent from '../progress/circular-progress.js'; import IgcValidationContainerComponent from '../validation-container/validation-container.js'; -import type { VirtualScrollItemContext } from '../virtualization/types.js'; +import type { + VirtualScrollItemContext, + VirtualScrollState, +} from '../virtualization/types.js'; 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 { 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 { + ComboCloseTrigger, ComboGroupHeaderTemplate, ComboItemTemplate, ComboRecord, ComboRenderFunction, + ComboSelectionCommit, ComboValue, FilteringOptions, GroupingDirection, IgcComboChangeEventArgs, + IgcComboClosingEventArgs, IgcComboComponentEventMap, Item, Keys, @@ -62,6 +81,7 @@ const SLOTS = setSlots( 'header', 'footer', 'empty', + 'loading', 'helper-text', 'toggle-icon', 'clear-icon', @@ -85,8 +105,9 @@ const i18n = { * @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. * @slot toggle-icon - Renders content that replaces the default toggle icon. * @slot clear-icon - Renders content that replaces the default clear icon. @@ -94,11 +115,14 @@ const i18n = { * @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. - * @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. + * @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. @@ -116,20 +140,30 @@ const i18n = { * @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, 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. + * @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 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`. */ -@blazorAdditionalDependencies('IgcIconComponent, IgcInputComponent') +@blazorAdditionalDependencies( + 'IgcIconComponent, IgcInputComponent, IgcButtonComponent, IgcCircularProgressComponent' +) @blazorIndirectRender @shadowOptions({ delegatesFocus: true }) export default class IgcComboComponent< @@ -150,6 +184,8 @@ export default class IgcComboComponent< public static register(): void { registerComponent( IgcComboComponent, + IgcButtonComponent, + IgcCircularProgressComponent, IgcIconComponent, IgcVirtualScrollComponent, IgcComboItemComponent, @@ -160,13 +196,10 @@ 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; protected override readonly _formValue = createFormValueState< ComboValue[] @@ -201,7 +234,11 @@ 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( this, this._state, @@ -209,27 +246,36 @@ export default class IgcComboComponent< input: this._inputRef, search: this._searchRef, list: this._listRef, + // The flattened query also returns the default footer buttons. + slotted: (slot) => + this._slots.getAssignedElements(slot, { flatten: true }), interactions: { show: () => this._show(true), - hide: () => this._hide(true), - toggleSelection: (index: number) => this._selectAt(index, true), - select: (index: number) => this._selectAt(index, false), + hide: (trigger) => this._close(trigger), + 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(), }, } ); + private readonly _dataRequests = new ComboDataRequests( + this, + this._state, + (detail) => this.emitEvent('igcDataRequest', { detail }) + ); - private _index?: Map, number[]>; - private _indexSize = 0; private _displayKey?: Keys; private _placeholderSearch?: string; - private _selected: Set = new Set(); // The `displayKey` setter sets `filterKey`. Both keys are undefined here. private _filteringOptions: FilteringOptions = { filterKey: undefined, 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) { @@ -253,33 +299,26 @@ 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._selected) + 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 - /** 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>({ transform: ({ value }) => asArray(value), onChange: ({ host }) => { - host._index = undefined; + host._selection.invalidateIndex(); }, }) public data: T[] = []; @@ -369,7 +408,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; @@ -414,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 { @@ -423,7 +479,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 */ @@ -452,6 +509,56 @@ 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. 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 + */ + @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; + + /** + * 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; + + /** + * 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. + * @attr selection-commit + * @default 'immediate' + */ + @property({ attribute: 'selection-commit' }) + public selectionCommit: ComboSelectionCommit = 'immediate'; + /* blazorSuppress */ /** * The template used for the content of each combo item. @@ -504,9 +611,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._selection.dropPending(); + } + if (this.hasUpdated) { this._validate(); } @@ -524,7 +638,17 @@ 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); + } + + /** + * 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`. + */ + public get pendingValue(): ComboValue[] { + const pending = this._selection.pending; + return pending ? this._selection.valuesOf(pending) : this.value; } //#endregion @@ -534,7 +658,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. @@ -559,10 +683,22 @@ export default class IgcComboComponent< this._state.updateLocale(); } + // 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; + } + if ( props.has('groupKey') || props.has('groupSorting') || - props.has('filteringOptions') || + props.has('totalItemCount') || + props.has('showSelectAll') || + props.has('groupSelection') || + props.has('singleSelect') || props.has('data') ) { this._state.invalidate(); @@ -573,18 +709,56 @@ export default class IgcComboComponent< this._listRef.value?.requestUpdate(); } - // 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(); + + // 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. + if (!this.open) { + this._selection.dropPending(); } } + protected override updated(props: PropertyValues): void { + this._dataRequests.afterUpdate(props, this._listRef.value); + } + + protected override _popupElement(): Element | null { + return this._popup; + } + //#endregion - // #region Form Associated overrides + //#region Form Associated overrides + + protected override get __validators(): Validator>[] { + return comboValidators; + } + + /** Single selection is a text field, as an input with a list. */ + protected override get _blocksImplicitSubmission(): boolean { + return this.singleSelect; + } + + /** + * Enter submits only from single selection with the list closed, as in an + * input with a list. Multiple selection never submits, as a native select. + */ + protected override _handleEnterKey(event: KeyboardEvent): void { + if (this.singleSelect && !this.open) { + super._handleEnterKey(event); + } + } protected override _setDefaultValue(current: string | null): void { try { @@ -601,9 +775,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([ @@ -624,9 +798,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, ]); @@ -637,89 +814,43 @@ export default class IgcComboComponent< return canClose; } - private _setSingleSelectionDisplayValue(value: string): void { - if (this.singleSelect && this._inputRef.value) { - this._inputRef.value.value = value; - } - } - - //#endregion - - // #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; + /** Closes the list on a user action, which the close events report. */ + private _close(trigger: ComboCloseTrigger): Promise { + return this._hide(true, { trigger }); } /** - * 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. + * Applies the pending selection, then closes the list. A canceled + * `igcChange` keeps the list open. + * @returns Whether the list closes. */ - private _resolveItems(items: Item[]): T[] { - const index = this._dataIndex; - const positions = new Set(items.flatMap((item) => index.get(item) ?? [])); - - return Array.from(positions) - .sort((a, b) => a - b) - .map((position) => this.data[position]); + private async _applyAndClose(trigger: ComboCloseTrigger): Promise { + return this._commitPending(true) ? this._close(trigger) : false; } - private _resolveItemValue(record: T): Item { - return this.valueKey ? record[this.valueKey] : record; - } + //#endregion - /** 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; - } + //#region Selection flow - private _toValues(items: Iterable): ComboValue[] { - return Array.from(items, (item) => this._valueOf(item)); - } - - /** 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 { 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; } - private _emitSelectionChange(detail: IgcComboChangeEventArgs): boolean { - return this.emitEvent('igcChange', { cancelable: true, detail }); - } - - /** The selection value after `resolved` applies: the `igcChange` `newValue`. */ - private _previewValue(resolved: T[], selecting: boolean): ComboValue[] { - if (selecting) { - return this._toValues( - this.singleSelect ? resolved : new Set([...resolved, ...this._selected]) - ); + private _setSingleSelectionDisplayValue(value: string): void { + if (this.singleSelect && this._inputRef.value) { + this._inputRef.value.value = value; } + } - const removed = new Set(resolved); - - return this._toValues( - Iterator.from(this._selected).filter((item) => !removed.has(item)) - ); + private _emitSelectionChange( + detail: IgcComboChangeEventArgs, + event: 'igcChange' | 'igcPendingChange' = 'igcChange' + ): boolean { + return this.emitEvent(event, { cancelable: true, detail }); } /** @@ -739,127 +870,246 @@ 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); - if (singleSelect) { + this._selection.clear(); this._searchTerm = ''; + } else { + this._selection.selectAll(); } this.requestUpdate(); return true; } - // An empty deselection deselects every selected item. const resolved = isEmpty(collection) - ? this.selection - : this._resolveItems(collection); + ? Array.from(this._selection.current) + : this._selection.resolve(collection); + + if (!this._applySelection(resolved, type, emit)) { + return false; + } + + if (!selecting) { + this._selection.forget(collection); + } + + return true; + } + + /** + * 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, - type, - }) + !this._emitSelectionChange( + { + newValue: this._selection.preview(records, selecting), + items: records, + type, + }, + this._selection.pending ? 'igcPendingChange' : 'igcChange' + ) ) { return false; } // The change is committed, so drop the previous single selection and search term. - if (selecting && singleSelect) { - this._selected.clear(); - this._searchTerm = ''; + if (selecting && this.singleSelect) { + this._selection.clear(); + this._clearSearchTerm(emit); } - for (const item of resolved) { - selecting ? this._selected.add(item) : this._selected.delete(item); + this._selection.apply(records, selecting); + + if (selecting && this._selection.resolveValues(records)) { + this._projectSelection(); } this.requestUpdate(); return true; } - /** One-way sync from `_formValue` to `_selected`. */ + /** + * One-way sync from `_formValue` to the selection. A value that `data` does + * not hold keeps its selected record. + */ private _syncSelectionFromValue(): void { - this._selected.clear(); + 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; + values = first; + } - for (const value of values) { - // First match only - mirrors resolving a value against the data source - const position = index.get(value)?.[0]; + this._selection.syncFromValue(values); + this._projectSelection(); + // The list does not update for a new `value` on its own. + this._listRef.value?.requestUpdate(); + } - if (position !== undefined) { - this._selected.add(this.data[position]); - } - } + private _syncSelectionFromData(): void { + const known = this._selection.knownValues(); + + // Sync the selection when `data` comes after `value`. + if (!isEmpty(this.data) && !isEmpty(this.value)) { + this._withPristine(() => { + this._syncSelectionFromValue(); + this._formValue.setValueAndFormState(this.value); + }); } - this._projectSelection(); + this._selection.syncPending(known); } 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._selection.pending) { + this._formValue.setValueAndFormState(this._projectSelection()); + this._setSingleSelectionDisplayValue(this._displayValue); + this._validate(); + } + this._listRef.value?.requestUpdate(); } /** - * The record at `index`, or undefined for a group header. - * Guard on it: {@link _updateSelection} reads an empty collection as "select all". + * 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 _recordAt(index: number): T | undefined { - const record = this._state.dataState[index]; - return record && !record.header ? record.value : undefined; - } + private _commitPending(emit: boolean): boolean { + const pending = this._selection.pending; - /** Selects the record at `index`. With `toggle`, deselects a selected one. */ - private _selectAt(index: number, toggle: boolean): void { - const record = this._recordAt(index); + if (!pending) { + return true; + } - if (!record) { - return; + const items = this._selection.changes(pending); + const value = this._selection.valuesOf(pending); + + // A deselection can name a value without a record, which changes no record. + if (isEmpty(items) && sameItems(value, this.value)) { + return true; } - this._updateSelection( - this._resolveItemValue(record), - toggle && this._selected.has(record) ? 'deselection' : 'selection', - true - ); + // A copy, so a handler cannot change `value` through it. + if ( + emit && + !this._emitSelectionChange({ + newValue: [...value], + items, + type: 'commit', + }) + ) { + return false; + } + + // The `value` setter finds the records that `data` does not hold here. + this._selection.commit(pending); + this.value = value; + return true; + } + + 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 + // renders the selection. + this._state.hostUpdate(); + this.requestUpdate(); + + return this._state.shownItems(); + } + + /** + * Selects the record at `index`. With `toggle`, deselects a selected one. + * 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]; + + if (isItemRecord(record)) { + const item = record.value; + + // The record itself, as an item without a value would select all. + this._applySelection( + [item], + toggle && this._selection.current.has(item) + ? 'deselection' + : 'selection', + true + ); + } else if (record && this._state.isToggle(record)) { + this._toggleItemsOf(index); + } else { + return false; + } this._activeIndex = index; this._syncValueFromSelection(); + return true; + } + + /** + * 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 _toggleItemsOf(index: number): void { + const items = this._state.itemsOf(index); + const selected = this._selection.current; + const unselected = items.filter((item) => !selected.has(item)); + + this._applySelection( + unselected.length ? unselected : items, + unselected.length ? 'selection' : 'deselection', + true + ); } private _clearSelection(): void { if (this.singleSelect) { - this._searchTerm = ''; + 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 { - 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 ) @@ -868,38 +1118,64 @@ export default class IgcComboComponent< } } - // #endregion + //#endregion //#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 + // The update applies a new search text to the records before the active + // option changes. 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.firstOptionIndex; this._clearSingleSelection(); } protected override _handleBlur(): void { - if (isEmpty(this._selected)) { - this._searchTerm = ''; + if (isEmpty(this._selection.selected)) { + 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 { + this._dataRequests.searched(); + this.emitEvent('igcSearchInput', { detail: { searchText } }); + } + + private _handleListStateChange(event: CustomEvent): void { + event.stopPropagation(); + this._dataRequests.rendered(event.detail.endIndex); } private async _itemClickHandler(event: PointerEvent): Promise { @@ -910,26 +1186,57 @@ 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(); - 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(); this._activeIndex = -1; } + private _handleApply(): void { + this._applyAndClose('apply'); + } + + private _handleCancel(): void { + this.cancelSelection(); + this._close('cancel'); + } + private _handleCaseSensitivity(): void { 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 @@ -952,8 +1259,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. * @@ -1007,41 +1315,166 @@ 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(); + } + + /** + * 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._selection.dropPending(); + this._listRef.value?.requestUpdate(); + } + //#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> ) => { 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, + }); + + return this._state.isToggle(item) + ? this._renderToggleOption(item, index, content) + : html`${content}`; + } + + if (item.kind === 'select-all') { + return this._renderToggleOption( + item, + index, + this.resourceStrings.combo_select_all + ); } const active = this._activeIndex === index; - const selected = this._selected.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; + // The placeholders are last and share one record, so the index gives their position. + const position = placeholder + ? optionCount - context.count + index + 1 + : item.position; return html` - ${this.itemTemplate({ item: item.value })} + ${ + placeholder + ? // Bars of three lengths look like text. + html`` + : this.itemTemplate({ item: item.value }) + } `; }; + /** A toggle option: the select-all option or a group header option. */ + private _renderToggleOption( + { header, position }: ComboRecord, + index: number, + content: unknown + ): TemplateResult { + const state = this._state.selectionOf(index, this._selection.current); + const all = state === true; + const active = this._activeIndex === index; + + return html` + + ${content} + + `; + } + private _renderToggleIcon() { return html` + ${ + this.loading + ? html`` + : nothing + } + `; } @@ -1066,7 +1510,7 @@ export default class IgcComboComponent< part="clear-icon" role="button" @click=${this._handleClearIconClick} - ?hidden=${this.disableClear || isEmpty(this._selected)} + ?hidden=${this.disableClear || isEmpty(this.value)} aria-label=${ifDefined( this.resourceStrings.combo_clearItems_placeholder )} @@ -1087,6 +1531,8 @@ export default class IgcComboComponent< slot="anchor" exportparts="container: input, input: native-input, label, prefix, suffix" @click=${this._handleAnchorClick} + @keydown=${this._handleEnterKey} + @keypress=${this._handleEnterKey} placeholder=${ifDefined(this.placeholder)} label=${ifDefined(this.label)} @igcChange=${stopPropagation} @@ -1124,6 +1570,7 @@ export default class IgcComboComponent< placeholder=${this.placeholderSearch} exportparts="input: search-input" @igcInput=${this._handleSearchInput} + @igcChange=${stopPropagation} > - ${this.resourceStrings.combo_empty_message} + ${this.loading ? this.resourceStrings.combo_loading : nothing} + + ${this.resourceStrings.combo_empty_message} + `; } @@ -1160,6 +1610,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" @@ -1169,16 +1620,43 @@ export default class IgcComboComponent< .itemTemplate=${this._itemRenderer} ?hidden=${isEmpty(this._state.dataState)} @click=${this._itemClickHandler} + @igcStateChange=${this._handleListStateChange} + @igcDataRequest=${stopPropagation} > ${this._renderEmptyTemplate()}
- + + ${this.open && this._selection.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', @@ -1193,13 +1671,15 @@ export default class IgcComboComponent< flip same-width .scrollStrategy=${this.scrollStrategy} - @igcPopoverScrollClose=${this._handleClosing} + @igcPopoverScrollClose=${this._handleScrollClose} > ${this._renderMainInput()} ${this._renderList()} ${this._renderHelperText()} `; } + + //#endregion } declare global { 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..a0981e9b2 --- /dev/null +++ b/src/components/combo/controllers/data-requests.spec.ts @@ -0,0 +1,303 @@ +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), + checkVisibility: () => true, + scrollToIndex: async () => {}, +}; + +function setup(mode: ComboFilteringMode = 'remote') { + const request = spy(); + const host = { + 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); + + 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); + }); + + 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(); + + 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..ba47e52c2 --- /dev/null +++ b/src/components/combo/controllers/data-requests.ts @@ -0,0 +1,180 @@ +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'; + +const DATA_REQUEST_COUNT = 20; + +type DataRequestsHost = Pick< + IgcComboComponent, + 'open' | 'data' | 'filteringOptions' | 'valueKey' +>; + +type DataRequestsState = Pick< + DataState, + '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`. + * 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; + /** 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, + 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; + } + + /** 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; + this._check(); + } + + /** Call from the `updated()` of the host. */ + public afterUpdate( + props: PropertyValues>, + list?: DataRequestsList + ): 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._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. + */ + 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) + ), + }); + } +} 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 b2bae168f..c683e7b58 100644 --- a/src/components/combo/controllers/data.ts +++ b/src/components/combo/controllers/data.ts @@ -1,20 +1,61 @@ 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, + header: false, + position: -1, + kind: '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 { + return !!record && !record.header && !record.kind; +} + /* 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; @@ -24,7 +65,8 @@ 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; @@ -37,20 +79,50 @@ export class DataState implements ReactiveController { return this._dataState; } - /** The number of selectable options in {@link dataState}, excluding 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. */ + 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; + } + + /** Whether the group headers are options. See `groupSelection`. */ + public get selectableGroups(): boolean { + return ( + Boolean(this._host.groupKey) && + this._host.groupSelection && + !this._host.singleSelect + ); } - /** The index of the first selectable option in {@link dataState}, or `-1`. */ - public get firstItemIndex(): number { - return this._dataState.findIndex((record) => !record.header); + /** 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) { 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(); + } } } @@ -81,7 +153,7 @@ export class DataState implements ReactiveController { //#region Lifecycle and pipeline management - constructor(host: ComboHost) { + constructor(host: DataStateHost) { this._host = host; this._host.addController(this); } @@ -121,9 +193,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. - * 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( @@ -131,23 +205,134 @@ 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; } - 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; + + const head: ComboRecord[] = selectAll ? [SELECT_ALL] : []; + + this._optionCount = position + placeholders; + this._loadedLength = head.length + result.length; + + if (!(head.length || placeholders)) { + return result; + } - return result; + // `result` can be the indexed source, so build a new array. + const padded = head.concat(result); + padded.length += placeholders; + return padded.fill(PLACEHOLDER, this._loadedLength); } //#endregion //#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. + */ + private _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; + } + + /** 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: ReadonlySet + ): 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) { diff --git a/src/components/combo/controllers/navigation.ts b/src/components/combo/controllers/navigation.ts index d802ae23a..33b841d0a 100644 --- a/src/components/combo/controllers/navigation.ts +++ b/src/components/combo/controllers/navigation.ts @@ -13,20 +13,32 @@ import { spaceBar, tabKey, } 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 { 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'; + type ComboNavigationConfig = { input: Ref; search: Ref; list: Ref; + /** The elements assigned to a slot of the list, or its fallback content. */ + slotted: (slot: ListSlot) => Element[]; interactions: { show: () => Promise; - hide: () => Promise; + 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, then closes the list. Returns whether the list closes. */ + apply: (trigger: ComboCloseTrigger) => Promise; clearSelection: () => void; }; }; @@ -34,7 +46,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; @@ -42,8 +54,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 { @@ -51,7 +65,7 @@ export class ComboNavigationController { } private get _firstItem(): number { - return this._state.firstItemIndex; + return this._state.firstOptionIndex; } private get _lastItem(): number { @@ -63,7 +77,7 @@ export class ComboNavigationController { public active = -1; constructor( - host: ComboHost, + host: IgcComboComponent, state: DataState, config: ComboNavigationConfig ) { @@ -75,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, @@ -87,18 +105,20 @@ 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); + // A canceled keydown has no keypress, so a closed list leaves Enter to the form. + .set(enterKey, this._onEnter, { when: () => this._host.open }); addKeybindings(this._host, { skip, ref: this._config.search, bindingDefaults, }) - .set(arrowUp, this._onEscape) - .set(arrowDown, this._onSearchArrowDown); + .set(arrowUp, () => this._closeToInput('arrow-up')) + .set(arrowDown, this._onSearchArrowDown) + .set(enterKey, this._onSearchEnter); addKeybindings(this._host, { skip, @@ -126,40 +146,59 @@ export class ComboNavigationController { }; private _onEnter = async (): Promise => { - if (this.active > -1) { - if (this._host.singleSelect) { - this._config.interactions.select(this.active); - } - if (await this._config.interactions.hide()) { - this._input?.select(); - this._host.focus(); - } + // A placeholder has no item, so Enter on it does nothing. + if (this._state.dataState[this.active]?.kind === 'placeholder') { + return; + } + + // 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 (await this._config.interactions.apply('enter')) { + this._input?.select(); + this._host.focus(); } }; - 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 _onSearchEnter = async (): Promise => { + if (this._config.interactions.hasPending()) { + await this._onEnter(); + } + }; - if (this._host.singleSelect && this.active > -1) { - this._config.interactions.select(this.active); - } + private _onTab = async (event: KeyboardEvent): Promise => { + if (!this._host.open) { + return; + } + + if (focusTabStop(event, this._tabStops())) { + 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('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 => { @@ -190,9 +229,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._closeToInput('arrow-up'); return; } @@ -209,8 +247,39 @@ 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, and the empty and footer content. Empty without focusable slot + * content, so Tab closes the list. + */ + private _tabStops(): HTMLElement[] { + const focusable = (...slots: ListSlot[]) => + slots.flatMap((slot) => + Iterator.from(this._config.slotted(slot)) + .flatMap(getFocusableElements) + .toArray() + ); + const before = focusable('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!, ...before, ...list, ...after]; + } + /** - * 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 { @@ -228,7 +297,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/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..4ae4445bc --- /dev/null +++ b/src/components/combo/controllers/selection.ts @@ -0,0 +1,420 @@ +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; + /** 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; + } + + /** 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) { + if (!this._pending) { + this._pending = new Set(this._selected); + this._pendingUnresolved = new Set(this._unresolved); + } + + return this._pending; + } + + this.dropPending(); + 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 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. */ + 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)); + } + + 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` + * 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 = this._toValue(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.valuesOf( + this._host.singleSelect ? records : new Set([...current, ...records]) + ); + } + + const removed = new Set(records); + + return this.valuesOf( + 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); + } + + if (selecting) { + this._resolveUnresolved(records, this._targetUnresolved); + } + } + + /** + * 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 { + if (this.pending && this._unresolved.size) { + this._selected = this._recordsOf( + this._host.value, + new Set(records).union(this._selected), + this._unresolved + ); + 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; + } + + this._resolveUnresolved(all, this._targetUnresolved); + } + + /** Clears the committed selection. The pending selection does not change. */ + 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._unresolved = new Set(); + this._selected = this._recordsOf(values, this._selected, this._unresolved); + } + + /** + * 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._unresolved.size + ? 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, this._pendingUnresolved); + } + } + + /** 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[] = []; + const display: string[] = []; + + for (const item of this._selected) { + values.push(this._valueOf(item)); + display.push(String((displayKey ? item[displayKey] : undefined) ?? item)); + } + + 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`. `unresolved` gets the values without a record. + */ + private _recordsOf( + values: ComboValue[], + previous: ReadonlySet, + unresolved?: Set> + ): 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) { + // Only the first record of the value, unlike `resolve()`. + const position = index.get(value)?.[0]; + const record = + position === undefined + ? (byValue ??= this._byValue(previous)).get(value) + : data[position]; + + if (record !== undefined) { + records.add(record); + unresolved?.delete(value); + } else { + unresolved?.add(value); + } + } + + 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/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 4a7b75949..f77c9e894 100644 --- a/src/components/combo/spec.md +++ b/src/components/combo/spec.md @@ -14,12 +14,15 @@ - [Basic initialization](#basic-initialization) - [Data binding keys](#data-binding-keys) - [Single and multiple selection](#single-and-multiple-selection) + - [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) - [Form integration](#form-integration) + - [Remote data](#remote-data) - [Localization](#localization) - [Keyboard interactions](#keyboard-interactions) - [API](#api) @@ -32,13 +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) + - [ARIA](#aria) + - [Host ARIA](#host-aria) + - [Remote data](#remote-data-1) + - [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) - - [Host ARIA](#host-aria) - [Assumptions and limitations](#assumptions-and-limitations) - [Accessibility](#accessibility) - [ARIA roles and properties](#aria-roles-and-properties) @@ -58,72 +69,96 @@ | 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-30 | A new item or group header template re-renders the open list | -| 11 | 2026-11-02 | A `focus()` call before the first render applies after the render | +| 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 | +| 16 | 2026-10-30 | A new item or group header template re-renders the open list | +| 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 | +| 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 | +| 24 | 2026-11-11 | Enter submits the form from single selection with the list closed, as in an input with a list; multiple selection does not submit, as a native `select` | ## 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. -- 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 @@ -133,10 +168,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 @@ -161,13 +196,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 @@ -175,17 +210,49 @@ 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 of the data source +``` + +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). + +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 -combo.value; // ComboValue[] - the values -combo.selection; // T[] - the records from the data source +`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: + +- A click or Space on it selects the shown items that are not selected. When all the shown items are selected, it + deselects them. Before the change, it sends one cancelable `igcChange` with the items that change and the `selection` + or `deselection` type. When a handler cancels the event, no item changes. In confirm mode, it sends + `igcPendingChange` in place of `igcChange`. +- 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. + +```html + ``` #### Filtering @@ -203,16 +270,70 @@ 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. 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. 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 +of the search text, is not selected, and the header is no longer checked. + +```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 @@ -221,6 +342,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 @@ -229,18 +353,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 @@ -251,53 +374,153 @@ 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. +- In single selection, Enter with the list closed submits the form, as in a native input with a list. The + [rules of the input](../input/spec.md#form-integration) apply, and the combo counts as a text field. While the list is + open, Enter belongs to the list and does not submit. In multiple selection, Enter does not + submit, and the combo is not a text field, as a native `select`. Enter on slotted content belongs to + that content. 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. -### Localization +#### Remote data + +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' }; + +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` +}); +``` -The component renders several built-in strings, resolved through the library i18n mechanism and overridable per -instance through `locale` and `resourceStrings`: +**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; +- 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 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 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. +- 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 +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 +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/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. -| 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. | +### Localization + +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 is closed: -While the list of options is **not** visible: +| Keys | Result | +| --------------------------------------------------------------- | ------------------------------------------------------- | +| Arrow Down / Alt + Arrow Down | Opens the list. | +| A character key | In single selection, opens the list and filters it. | +| Enter | In single selection, submits the form. See [Form integration](#form-integration). | +| Escape | Clears the value. The combo keeps the focus. | -| 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. | +While the list is open: -While the list of options **is** visible: +| 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. 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. | -| 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. | -| 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. | +In multiple selection, the search input filters the list while it has the focus. -Typing while the component is focused filters the list down to the closest matches. +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 @@ -305,9 +528,10 @@ 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. | +| 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. | @@ -315,8 +539,13 @@ Typing while the component is focused filters the list down to the closest match | 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. | +| 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 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. | @@ -347,6 +576,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; @@ -369,8 +604,12 @@ 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. | +| 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. | @@ -381,19 +620,51 @@ 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, 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. | -| 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. | +| 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 { 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 { + 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 + | '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 + | 'apply' // the Apply button of the confirm mode + | 'cancel'; // the Cancel button of the confirm mode ``` +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 @@ -402,8 +673,9 @@ interface IgcComboChangeEventArgs { | `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. | | `toggle-icon` | Renders content that replaces the default toggle icon. | | `clear-icon` | Renders content that replaces the default clear icon. | @@ -430,13 +702,20 @@ interface IgcComboChangeEventArgs { | `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, 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 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 @@ -446,106 +725,264 @@ interface IgcComboChangeEventArgs { ### 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 -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, 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, the host ARIA and the focus before the + first render. +- [`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 use +`createFormAssociatedTestBed`, `runValidationContainerTests`, `runExternalLabelAssociationTests`, +`runAriaProjectionTests`, `runFocusBeforeRenderTests` 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. A new item or - group header template re-renders the open list. +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. 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. 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. - -### ARIA - -13. The anchor, the list, the items and the group headers expose their roles and states, and the selection summary - is announced. +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 + the combo. + +### Header and footer slots + +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. Escape from a slotted control closes the + list and focuses the main input, unless the control handles the key. + +### Close trigger + +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. ### Scroll strategy tests -14. `hide`, `scroll` and `close` behave as specified while an ancestor scrolls. +20. `hide`, `scroll` and `close` work as specified while an ancestor scrolls. ### Theming -15. The list keeps its stylesheet in the shadow root when the theme changes. +21. The list keeps its stylesheet in the shadow root when the theme changes. + +### ARIA + +22. The anchor, the list, the items and the group headers have their roles and states, and the selection summary is + announced. The options report the selection in `aria-checked` in multiple selection and in `aria-selected` in + single selection. + +### 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. +24. The shared focus suite (`runFocusBeforeRenderTests`): a `focus()` call right after the element connects focuses + the same element as a call after the first render. `blur()`, a later `focus()` on another element and a removal + that lasts until the render cancel the call, but a move in the same task keeps it. After the render, `focus()` + takes the focus at once and `blur()` removes it. A `focus()` call before the render on a component with + `disabled` set does not cancel an earlier call on another element. + +### Remote data + +25. 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. +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, 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 + 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. 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 + empty `data`. + +### Placeholders and loading + +30. 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. +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. 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. + +### Select all methods + +33. `selectAllItems()` and `deselectAllItems()` change only the items that the filtered list shows, also right after a + 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. + +### Select-all option + +34. The option comes first in multiple selection, before the group headers, with the "Select all" text and + `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, 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. +37. 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 + +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, `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. + +### Confirm mode + +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. +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`. 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 + selection with no events and keeps the list open. `cancelSelection()` and a new `value` replace it, and the same + `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`, 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. +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. +49. Tab moves from the list to Cancel and Apply, and Tab from Apply closes the list. ### 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. +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. +53. In single selection, a real or a synthetic Enter with the list closed submits the form once. With the + list open, Enter selects the active option and does not submit, and the next Enter submits. + Multiple selection does not submit, also with a submit button. Only single selection counts as a text field. A + slotted button gets its Enter, and the form does not submit. -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. +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 -19. Form integration in single selection mode, in multiple selection mode, and with data that arrives late. -20. Validation against the default value. +54. Form integration in single selection, in multiple selection, and with data that comes late. +55. 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. - -### Host ARIA +`runValidationContainerTests` makes these tests. -22. The shared host description suite: the host `aria-describedby` describes the native control after the helper - text, and follows a change and a removal. -23. The shared focus suite (`runFocusBeforeRenderTests`): a `focus()` call right after the element connects focuses - the same element as a call after the first render. `blur()`, a later `focus()` on another element and a removal - that lasts until the render cancel the call, but a move in the same task keeps it. After the render, `focus()` - takes the focus at once and `blur()` removes it. A `focus()` call before the render on a component with - `disabled` set does not cancel an earlier call on another element. +56. 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 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. ## Accessibility ### 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, the items an `option` role with their selected state, and the group headers a - `group` role. -- 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. 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 `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 + `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. diff --git a/src/components/combo/themes/combo-item.base.scss b/src/components/combo/themes/combo-item.base.scss index c8ba32ef0..be1ba87d3 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,19 @@ } } +// 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'); +} + +// 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 921567ef4..1818e6261 100644 --- a/src/components/combo/themes/combo.base.scss +++ b/src/components/combo/themes/combo.base.scss @@ -58,9 +58,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) { @@ -68,6 +72,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; @@ -82,12 +93,86 @@ font-size: rem(13px); } +// The toggle icon keeps its box while loading, and the spinner covers it. +[part~='toggle-icon'] { + display: grid; + place-items: center; + + igc-icon, + slot::slotted(*), + [part~='loading'] { + grid-area: 1 / 1; + } +} + +:host([loading]) slot[name='toggle-icon'] { + visibility: hidden; +} + +// 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); +} + [part~='group-header'], [part~='item'] { // Fix a bug in safari: https://github.com/IgniteUI/igniteui-webcomponents/issues/892 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); + --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 { + position: relative; + overflow: hidden; + block-size: 0.75em; + border-radius: rem(4px); + 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; + } +} + :host(:disabled), :host([disabled]) { pointer-events: none; @@ -131,4 +216,10 @@ [part~='selected'] { color: Highlight; } + + [part~='placeholder'] span, + [part~='placeholder']::part(checkbox)::after { + forced-color-adjust: none; + background: CanvasText; + } } \ No newline at end of file diff --git a/src/components/combo/themes/shared/combo.common.scss b/src/components/combo/themes/shared/combo.common.scss index 553adc477..e92e22177 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 936cac95f..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)); } @@ -70,3 +71,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/themes/shared/combo.indigo.scss b/src/components/combo/themes/shared/combo.indigo.scss index c68da99b0..14425b18b 100644 --- a/src/components/combo/themes/shared/combo.indigo.scss +++ b/src/components/combo/themes/shared/combo.indigo.scss @@ -57,7 +57,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)); } @@ -104,3 +105,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/src/components/combo/types.ts b/src/components/combo/types.ts index b59822731..4d0ae55e9 100644 --- a/src/components/combo/types.ts +++ b/src/components/combo/types.ts @@ -1,6 +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]; @@ -10,23 +10,33 @@ export type ComboRecord = { value: T; header: boolean; /** - * 1-based position among the visible options. `-1` for group headers. - * 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. */ group?: unknown; + /** + * 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 & - 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. */ @@ -37,6 +47,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,8 +71,36 @@ 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, `selection` an item click in single selection mode, and `apply` and + * `cancel` the buttons of the confirm mode. + */ +export type ComboCloseTrigger = + | 'enter' + | 'escape' + | 'tab' + | 'arrow-up' + | 'outside-click' + | 'scroll' + | 'toggle' + | 'selection' + | 'apply' + | 'cancel'; + +export interface IgcComboClosingEventArgs { + /** The user action that closes the list. */ + trigger: ComboCloseTrigger; +} + export interface IgcComboComponentEventMap { igcChange: CustomEvent; + igcPendingChange: CustomEvent; // For analyzer meta only: /* skipWCPrefix */ focus: FocusEvent; @@ -64,8 +108,10 @@ export interface IgcComboComponentEventMap { blur: FocusEvent; igcOpening: CustomEvent; igcOpened: CustomEvent; - igcClosing: CustomEvent; - igcClosed: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; + igcSearchInput: CustomEvent; + igcDataRequest: CustomEvent; } export type ComboItemTemplate = ( diff --git a/src/components/date-picker/date-picker.base.ts b/src/components/date-picker/date-picker.base.ts index d06194455..9450cf1e1 100644 --- a/src/components/date-picker/date-picker.base.ts +++ b/src/components/date-picker/date-picker.base.ts @@ -505,7 +505,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..10f8392fe 100644 --- a/src/components/date-picker/date-picker.spec.ts +++ b/src/components/date-picker/date-picker.spec.ts @@ -35,7 +35,9 @@ import { withDocumentSheets, } from '#internals/testing/helpers.spec.js'; import { + simulateCancelableKeydown, simulateClick, + simulateComposingKeys, simulateInput, simulateKeyboard, simulateScroll, @@ -1088,6 +1090,22 @@ 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('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 70fd14ef8..1b78c1033 100644 --- a/src/components/date-picker/spec.md +++ b/src/components/date-picker/spec.md @@ -68,7 +68,9 @@ | 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-11 | Enter submits as in a native form, after the `keypress`; Enter in slotted content does not submit a second time | +| 14 | 2026-11-06 | A closed picker does not cancel Escape | +| 15 | 2026-11-11 | The keys that an IME handles do nothing | +| 16 | 2026-11-11 | Enter submits as in a native form, after the `keypress`; Enter in slotted content does not submit a second time | ## Overview @@ -488,7 +490,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: @@ -505,7 +507,7 @@ When the input part of the component is focused: | Enter | Submits the owning form as a native text field does, 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). @@ -718,7 +720,8 @@ Grouped as `Uncommitted edits - issue #1346` in the suite. ### Interactions -34. Escape closes an open picker. +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-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 e74893b3b..f6f91ac46 100644 --- a/src/components/date-range-picker/spec.md +++ b/src/components/date-range-picker/spec.md @@ -69,7 +69,8 @@ | 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-11 | Enter submits as in a native form, after the `keypress`; Enter in slotted content does not submit a second time | +| 18 | 2026-11-06 | A closed picker does not cancel Escape | +| 19 | 2026-11-11 | Enter submits as in a native form, after the `keypress`; Enter in slotted content does not submit a second time | ## Overview @@ -414,7 +415,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: @@ -616,7 +617,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/components/date-time-input/date-time-input.spec.ts b/src/components/date-time-input/date-time-input.spec.ts index 4a5b3eccf..0417e0676 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 05a52959f..8b400b44b 100644 --- a/src/components/date-time-input/spec.md +++ b/src/components/date-time-input/spec.md @@ -67,7 +67,8 @@ | 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 | Enter submits as in a native form, after the `keypress` | +| 14 | 2026-11-11 | The keys that an IME handles do nothing | +| 15 | 2026-11-11 | Enter submits as in a native form, after the `keypress` | ## Overview @@ -578,6 +579,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 @@ -754,7 +756,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/input/spec.md b/src/components/input/spec.md index b3ede6deb..d837e47a0 100644 --- a/src/components/input/spec.md +++ b/src/components/input/spec.md @@ -70,6 +70,7 @@ | 13 | 2026-11-02 | A `focus()` call before the first render applies after the render | | 14 | 2026-11-06 | A canceled Enter `keydown`, or the Enter that commits an IME composition, does not submit the form | | 15 | 2026-11-11 | Enter submits as in a native form: through the default button, from the only text field when the form has no submit button, after the `keypress`, and not when a listener disables the input | +| 16 | 2026-11-11 | The combo in single selection is a text field | ## Overview @@ -341,7 +342,8 @@ validity flags and slot names. `click` listener can cancel the submission, and the button is the `submitter` of the `submit` event. A disabled default button blocks the submission. An `igc-button` with `type="submit"` and no `href` counts as a submit button. - A form with no submit button submits only when it has one text field. Text fields are the text-like native inputs, - also disabled ones, and the input, mask input, date time input, date picker and date range picker. + also disabled ones, and the input, mask input, date time input, date picker, date range picker and the combo in + single selection. - A `keydown` or `keypress` listener on the host or an ancestor that calls `preventDefault()` stops the submission, and so does a listener that disables the input. A real key press submits after its `keypress`. An Enter that code dispatches submits after its `keydown`, and its `keypress`, if any, does nothing. diff --git a/src/components/mask-input/spec.md b/src/components/mask-input/spec.md index e0d994747..03e0af294 100644 --- a/src/components/mask-input/spec.md +++ b/src/components/mask-input/spec.md @@ -59,7 +59,8 @@ | 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 | Enter submits as in a native form, after the `keypress` | +| 12 | 2026-11-11 | The keys that an IME handles do nothing | +| 13 | 2026-11-11 | Enter submits as in a native form, after the `keypress` | ## Overview @@ -309,7 +310,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/progress/circular-progress.spec.ts b/src/components/progress/circular-progress.spec.ts index 8d58598ee..fad4285bd 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,145 @@ 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; + } + + 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 updateProgress('indeterminate', true); + + 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 updateProgress('indeterminate', true); + + 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/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 e1a49f3a0..d3c41f587 100644 --- a/src/components/progress/spec.md +++ b/src/components/progress/spec.md @@ -47,6 +47,8 @@ | 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 | +| 6 | 2026-11-06 | The linear indicator: reduced motion | ## Overview @@ -157,6 +159,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 +279,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 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 +331,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 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`. + ### 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/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 7e9e28b02..3f805e301 100644 --- a/src/components/progress/themes/circular/circular.progress.base.scss +++ b/src/components/progress/themes/circular/circular.progress.base.scss @@ -2,6 +2,13 @@ @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'] { display: inline-flex; align-items: center; @@ -10,6 +17,22 @@ position: relative; } +@include reduced-motion(); + +// 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 +108,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/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; 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/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`; } -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; @@ -482,6 +483,11 @@ export default class IgcVirtualScrollComponent< } else { this.scrollTo({ left: isLTR(this) ? offset : -offset, behavior }); } + + // `scroll` fires a frame later, so sync now. A hidden list keeps its offset. + if (this.checkVisibility()) { + this._handleScroll(); + } } private _currentAxisScroll(): number { @@ -577,6 +583,11 @@ export default class IgcVirtualScrollComponent< this._viewportSize = this._isVertical ? this.clientHeight : this.clientWidth; + + // A list that shows again can get another offset with no `scroll` event. + if (this._viewportSize) { + this._scrollPosition = this._currentAxisScroll(); + } } /** diff --git a/src/extras/combo-data-source.spec.ts b/src/extras/combo-data-source.spec.ts new file mode 100644 index 000000000..54fa1821f --- /dev/null +++ b/src/extras/combo-data-source.spec.ts @@ -0,0 +1,753 @@ +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 { suppressResizeObserverLoopError } from '#internals/testing/helpers.spec.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, + caseSensitive, + startIndex, + count, +}: ComboDataRequest): ComboDataPage { + const fold = (text: string) => (caseSensitive ? text : text.toLowerCase()); + const results = PLACES.filter(({ name }) => + fold(name).includes(fold(searchText)) + ); + + 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); + suppressResizeObserverLoopError(); + }); + + 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: '', caseSensitive: false, 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(PLACES.length - 1); + await settle(); + + // 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(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(); + + 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', + caseSensitive: false, + 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('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(); + + 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: '', + 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(); + 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: '', + caseSensitive: false, + 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; + }); + + describe('with a resolve that waits for the test', () => { + let answers: (() => void)[]; + + beforeEach(() => { + answers = []; + source.resolve = (values) => + new Promise((resolve) => { + answers.push(() => + resolve(PLACES.filter(({ id }) => values.includes(id))) + ); + }); + }); + + it('selects only the resolved values that the combo still holds', async () => { + combo.value = ['OSL', 'AMS']; + bind(); + await waitUntil(() => answers.length); + + combo.value = ['AMS']; + answers[0](); + await flush(); + + expect(combo.value).to.eql(['AMS']); + expect(ids(combo.selection)).to.eql(['AMS']); + }); + + it('selects the resolved values in the open list of confirm mode', async () => { + combo.selectionCommit = 'confirm'; + combo.value = ['OSL']; + bind(); + await waitUntil(() => answers.length); + + await open(); + answers[0](); + await flush(); + + expect(input.value).to.equal('Oslo'); + + combo.commitSelection(); + + expect(combo.value).to.eql(['OSL']); + }); + + it('selects a resolved value after a click of the user', async () => { + combo.value = ['OSL']; + bind(); + await waitUntil(() => answers.length); + + await open(); + await answer(0); + simulateClick( + combo.renderRoot.querySelector('[part~="item"]')! + ); + await flush(); + + answers[0](); + await flush(); + + expect(combo.value).to.have.members(['OSL', 'AMS']); + expect(input.value).to.include('Oslo'); + }); + + it('selects the answers of two resolves in flight', async () => { + combo.value = ['OSL']; + bind(); + await waitUntil(() => answers.length); + + combo.value = ['OSL', 'SOF']; + await open(); + await waitUntil(() => answers.length === 2); + + answers[0](); + await flush(); + answers[1](); + await flush(); + + expect(combo.value).to.have.members(['OSL', 'SOF']); + }); + }); + + 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..df320be8c --- /dev/null +++ b/src/extras/combo-data-source.ts @@ -0,0 +1,317 @@ +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; + /** 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; + /** + * 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; +} + +/** A search, without its page. */ +type Search = Pick; + +/** 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. 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; +} + +/** 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. A value that leaves `value` meanwhile is not + * selected. + */ + 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 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. + * `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, gives the combo back + * its filtering mode, and clears its `totalItemCount` and `loading`. + */ + 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`. + * 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 + * ```ts + * const binding = bindComboDataSource(combo, { + * 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 }; + * }, + * }); + * ``` + */ +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(); + 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; + + 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, count = pageSize): Promise { + search.stop(); + cancel(); + const current = new AbortController(); + request = current; + showBusy(); + // 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( + { ...query, startIndex, count }, + 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; + } + + loadedSearch = query; + 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); + } + return; + } finally { + if (request === current) { + request = undefined; + showBusy(); + } + } + + 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]); + } + + // The value can change while the source resolves. + const current = new Set(combo.value); + const selected = items.filter((item) => current.has(item[valueKey])); + + // `select()` with no items selects all of them. + if (selected.length) { + combo.select(selected); + } + } catch (error) { + for (const value of missing) { + requested.delete(value); + } + + if (!disposal.signal.aborted) { + onError?.(error); + } + } + } + + const handleSearchInput = ({ + detail, + }: CustomEvent): void => { + 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; + } + + latest = next; + cancel(); + + // A search in a closed list waits for the next opening. + if (combo.open) { + search.start(); + } else { + search.stop(); + stale = true; + } + + showBusy(); + }; + + const handleDataRequest = ({ + detail, + }: CustomEvent): void => { + // A pending search replaces the items. + if (!busy()) { + void load(detail.startIndex, Math.max(pageSize, detail.count)); + } + }; + + 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(); + cancel(); + disposal.abort(); + combo.filteringOptions = { mode }; + combo.totalItemCount = undefined; + combo.loading = false; + }, + }; +} diff --git a/src/index.ts b/src/index.ts index 012e7f9ff..9d579f4bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,13 +217,18 @@ export type { } from './components/stepper/common/types.js'; export type { IgcTreeSelectionEventArgs } from './components/tree/tree.common.js'; export type { + ComboCloseTrigger, + ComboFilteringMode, ComboGroupHeaderContext, ComboGroupHeaderTemplate, ComboItemTemplate, + ComboSelectionCommit, ComboTemplateProps, FilteringOptions, GroupingDirection, IgcComboChangeEventArgs, + IgcComboClosingEventArgs, + IgcComboSearchInputEventArgs, } 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 8f71e15ac..6eb6d51cb 100644 --- a/src/internals/bases/combo-box.ts +++ b/src/internals/bases/combo-box.ts @@ -110,8 +110,8 @@ export class IgcBaseComboBoxComponent extends LitElement { return this._toggleController.closing; } - 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/key-bindings.spec.ts b/src/internals/controllers/key-bindings.spec.ts index 01399b96f..ac0030fe5 100644 --- a/src/internals/controllers/key-bindings.spec.ts +++ b/src/internals/controllers/key-bindings.spec.ts @@ -7,7 +7,10 @@ import { } from '@open-wc/testing'; import { LitElement } from 'lit'; import { spy } from 'sinon'; -import { simulateKeyboard } from '../testing/simulate.spec.js'; +import { + simulateComposingKeys, + simulateKeyboard, +} from '../testing/simulate.spec.js'; import { addKeybindings, altKey, @@ -114,6 +117,14 @@ describe('Key bindings controller', () => { expect(instance.event).to.be.undefined; }); + it('skips a key that an IME handles, and does not cancel it', () => { + expect(simulateComposingKeys(instance, 'k', 'd')).to.be.false; + expect(instance.event).to.be.undefined; + + simulateKeyboard(instance, 'k'); + expect(instance.key).to.equal('k'); + }); + it('event handler modifiers - prevent default', () => { dispatch(instance, 'keydown', 'd'); expect(instance.event.type).to.equal('keydown'); diff --git a/src/internals/controllers/key-bindings.ts b/src/internals/controllers/key-bindings.ts index 51f44729c..cb3ef48a9 100644 --- a/src/internals/controllers/key-bindings.ts +++ b/src/internals/controllers/key-bindings.ts @@ -5,7 +5,7 @@ import { asArray, isEmpty, partition } from '../utils/arrays.js'; import { isElement } from '../utils/dom.js'; import { isFunction } from '../utils/types.js'; import { addHostListeners } from './host-listeners.js'; -import { enterKey, spaceBar } from './keys.js'; +import { enterKey, isComposingKey, spaceBar } from './keys.js'; // Re-exported from `./keys.js`, so a consumer keeps a single import. export * from './keys.js'; @@ -240,11 +240,11 @@ class KeyBindingController implements ReactiveController { //#region Private API /** - * Whether to ignore the event. The key has no binding, the event missed the - * observed element, or the skip configuration agrees. + * Whether to ignore the event. The key has no binding, an IME handles it, + * the event missed the observed element, or the skip configuration agrees. */ private _shouldSkip(event: KeyboardEvent, key: string): boolean { - if (!this._allowedKeys.has(key)) { + if (!this._allowedKeys.has(key) || isComposingKey(event)) { return true; } diff --git a/src/internals/controllers/toggle.ts b/src/internals/controllers/toggle.ts index fe1af9774..15333d060 100644 --- a/src/internals/controllers/toggle.ts +++ b/src/internals/controllers/toggle.ts @@ -90,9 +90,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, { @@ -110,7 +113,8 @@ class ToggleController { private async _setOpenState( open: boolean, - emitEvents: boolean + emitEvents: boolean, + detail?: unknown ): Promise { const host = this._host; const { transition } = this._options; @@ -119,7 +123,10 @@ class ToggleController { return false; } - if (emitEvents && !this._emit(open ? 'igcOpening' : 'igcClosing', true)) { + if ( + emitEvents && + !this._emit(open ? 'igcOpening' : 'igcClosing', true, detail) + ) { return false; } @@ -157,7 +164,7 @@ class ToggleController { return false; } - this._emit(open ? 'igcOpened' : 'igcClosed', false); + this._emit(open ? 'igcOpened' : 'igcClosed', false, detail); } return completed; @@ -185,12 +192,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. */ diff --git a/src/internals/mixins/mask-behavior.ts b/src/internals/mixins/mask-behavior.ts index d3fa8574c..f4c154d11 100644 --- a/src/internals/mixins/mask-behavior.ts +++ b/src/internals/mixins/mask-behavior.ts @@ -253,24 +253,23 @@ export function MaskBehaviorMixin< super(...args); // Assigning `input.value` clears the native undo stack, so the - // shortcuts read `_history`. Skip while an IME composition owns it. - const step = - (direction: 'undo' | 'redo') => - (event: KeyboardEvent): void => { - if (!event.isComposing) { - this._historyStep(direction); - } - }; + // shortcuts read `_history`. + const undo = (): void => { + this._historyStep('undo'); + }; + const redo = (): void => { + this._historyStep('redo'); + }; addKeybindings(this, { skip: () => this.readOnly, bindingDefaults: { repeat: true }, }) - .set([ctrlKey, 'z'], step('undo')) - .set([metaKey, 'z'], step('undo')) - .set([ctrlKey, 'y'], step('redo')) - .set([ctrlKey, shiftKey, 'z'], step('redo')) - .set([metaKey, shiftKey, 'z'], step('redo')); + .set([ctrlKey, 'z'], undo) + .set([metaKey, 'z'], undo) + .set([ctrlKey, 'y'], redo) + .set([ctrlKey, shiftKey, 'z'], redo) + .set([metaKey, shiftKey, 'z'], redo); } //#endregion diff --git a/src/internals/testing/combo.spec.ts b/src/internals/testing/combo.spec.ts new file mode 100644 index 000000000..b13391a00 --- /dev/null +++ b/src/internals/testing/combo.spec.ts @@ -0,0 +1,90 @@ +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. */ + +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[]; + +/** 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( + 'igc-virtual-scroll' + ) as IgcVirtualScrollComponent + ).layoutComplete; diff --git a/src/internals/testing/simulate.spec.ts b/src/internals/testing/simulate.spec.ts index a73d3b7aa..d1c4e428e 100644 --- a/src/internals/testing/simulate.spec.ts +++ b/src/internals/testing/simulate.spec.ts @@ -181,6 +181,46 @@ 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, + }) + ); +} + +/** + * Dispatches a cancelable `keydown` of each key that an IME handles: with + * `isComposing`, then with `keyCode` 229, as Safari sends it after + * `compositionend`. Returns whether a listener canceled one of them. + */ +export function simulateComposingKeys( + node: Element, + ...keys: string[] +): boolean { + let canceled = false; + + for (const init of [{ isComposing: true }, { keyCode: 229 }]) { + for (const key of keys) { + canceled ||= !node.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + composed: true, + cancelable: true, + ...init, + }) + ); + } + } + + return canceled; +} + /** Simulates scrolling for a given element. */ export async function simulateScroll( node: Element, 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; } diff --git a/stories/combo.stories.ts b/stories/combo.stories.ts index 06c91dfd0..992721ce7 100644 --- a/stories/combo.stories.ts +++ b/stories/combo.stories.ts @@ -12,6 +12,12 @@ import { IgcInputComponent, defineComponents, } from 'igniteui-webcomponents'; +import { + bindComboDataSource, + type ComboDataBinding, + type ComboDataRequest, + type ComboDataSource, +} from 'igniteui-webcomponents/extras/combo-data-source'; import { registerMaterialIcons } from './story-icons.js'; import { delay, @@ -52,10 +58,13 @@ const metadata: Meta = { actions: { handles: [ 'igcChange', + 'igcPendingChange', 'igcOpening', 'igcOpened', 'igcClosing', 'igcClosed', + 'igcSearchInput', + 'igcDataRequest', ], }, }, @@ -129,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' } }, }, @@ -146,6 +155,41 @@ 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. 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' } }, + }, + 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' } }, + }, + 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' } }, + }, + 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 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' } }, + }, locale: { type: 'string', description: @@ -202,6 +246,10 @@ const metadata: Meta = { caseSensitiveIcon: false, disableFiltering: false, disableClear: false, + loading: false, + showSelectAll: false, + groupSelection: false, + selectionCommit: 'immediate', required: false, disabled: false, invalid: false, @@ -249,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; /** @@ -259,6 +308,37 @@ 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. 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; + /** + * 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; + /** + * 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; + /** + * 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. + */ + 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 @@ -309,7 +389,7 @@ const styles = html` max-width: 36rem; } - .cmb-stack :is(h3, p, table) { + .cmb-stack :is(h3, h4, p, table) { margin: 0; } @@ -325,6 +405,39 @@ 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); + } + + .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; + } `; @@ -344,6 +457,8 @@ const cities: City[] = [ ]; export const Default: Story = { + // The local data has no remote total. + argTypes: { totalItemCount: { table: { disable: true } } }, parameters: { docs: { description: { @@ -373,6 +488,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} @@ -385,6 +501,9 @@ export const Default: Story = { ?disabled=${args.disabled} ?invalid=${args.invalid} ?single-select=${args.singleSelect} + ?show-select-all=${args.showSelectAll} + ?group-selection=${args.groupSelection} + ?loading=${args.loading} > @@ -671,7 +790,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.', }, }, }, @@ -712,12 +831,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]; @@ -738,27 +851,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; - }
- Show all - Reset + Reset
@@ -785,6 +877,431 @@ 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. With `selection-commit="confirm"`, the selected labels wait until you press Apply, or Enter in the list. Then one `igcChange` gives all of them in `newValue`, and the list shows the issues with one of them. Cancel, Escape, Tab out of the list, or a click outside or on the input drops the changes. While the list is open, the input keeps the applied labels. The clear button clears the filter at once, also while the list is open, and so does Escape with the list closed. `show-select-all` selects the labels that the search shows: type "e", then select all.', + }, + }, + }, + render: () => { + let applied: string[] = []; + + 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}`)} +
  • + ` + )} +
+ `; + }); + + // Apply, Enter, the clear button, and Escape with the list closed. + const changed = ({ detail }: CustomEvent) => { + applied = detail.newValue as string[]; + update(); + }; + + return html` + ${styles} + +
+

Issues

+ + + Apply the labels with Apply, or with Enter in the list. + + +
+
+ `; + }, +}; + +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; + 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; @@ -833,7 +1350,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, 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.', }, }, }, @@ -848,32 +1365,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.

      @@ -902,6 +1413,233 @@ 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 })); + +/** A fake server. It searches the names and returns new objects, as parsed JSON does. */ +async function fetchCities( + { searchText, caseSensitive, startIndex, count }: ComboDataRequest, + latency: number +): Promise<{ items: City[]; total: number }> { + await delay(latency); + + const fold = (text: string) => + caseSensitive ? text : text.toLocaleLowerCase(); + const text = fold(searchText); + const results = europeanCities.filter(({ name }) => + fold(name).includes(text) + ); + + return { + items: results + .slice(startIndex, startIndex + count) + .map((city) => ({ ...city })), + total: results.length, + }; +} + +export const RemoteData: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'A city picker that searches and pages on a server through `bindComboDataSource()` from `igniteui-webcomponents/extras/combo-data-source`, 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.', + }, + }, + }, + render: () => { + const log = createRef(); + + const write = (line: string) => { + const item = document.createElement('li'); + item.textContent = line; + log.value?.prepend(item); + }; + + /** 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 (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(request, 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 = + (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. + + A slower server, so the loading state shows longer. + + +
      +
      +

      Server log

      +
        +
        +
        +
        + `; + }, +}; + const labels = ['bug', 'feature', 'documentation', 'performance', 'security']; export const Form: Story = {