diff --git a/packages/calcite-components/src/components/combobox-item/combobox-item.scss b/packages/calcite-components/src/components/combobox-item/combobox-item.scss index b249f792366..51d68826869 100644 --- a/packages/calcite-components/src/components/combobox-item/combobox-item.scss +++ b/packages/calcite-components/src/components/combobox-item/combobox-item.scss @@ -3,10 +3,13 @@ * * These properties can be overridden using the component's tag as selector. * + * @prop --calcite-combobox-item-border-color: Specifies the component's border color. * @prop --calcite-combobox-text-color: Specifies the component's text and `icon` color. * @prop --calcite-combobox-text-color-hover: Specifies the component's text and `icon` color when hovered. * @prop --calcite-combobox-item-background-color-active: Specifies the component's background color when active. * @prop --calcite-combobox-item-background-color-hover: Specifies the component's background color when hovered. + * @prop --calcite-combobox-item-shadow: Specifies the component's shadow. + * @prop --calcite-combobox-selected-icon-color: Specifies the component's selected indicator icon color. * @prop --calcite-combobox-description-text-color: Specifies the component's `description` and `shortHeading` text color. * @prop --calcite-combobox-description-text-color-press: Specifies the component's `description` and `shortHeading` text color when hovered. @@ -118,7 +121,8 @@ ul:focus { color: var(--calcite-color-border-input); } -:host([selected]) .icon { +:host([selected]) .icon, +:host([indeterminate]) .icon { color: var(--calcite-combobox-selected-icon-color, var(--calcite-color-brand)); } diff --git a/packages/calcite-components/src/components/combobox-item/combobox-item.tsx b/packages/calcite-components/src/components/combobox-item/combobox-item.tsx index 6737ecc2886..2d550d8cfd8 100644 --- a/packages/calcite-components/src/components/combobox-item/combobox-item.tsx +++ b/packages/calcite-components/src/components/combobox-item/combobox-item.tsx @@ -14,7 +14,7 @@ import { getIconScale, warnIfMissingRequiredProp } from "../../utils/component"; import { IconNameOrString } from "../icon/interfaces"; import { slotChangeHasContent } from "../../utils/dom"; import { highlightText } from "../../utils/text"; -import { CSS, SLOTS } from "./resources"; +import { CSS, ICONS, SLOTS } from "./resources"; import { styles } from "./combobox-item.scss"; declare global { @@ -158,6 +158,13 @@ export class ComboboxItem extends LitElement implements InteractiveComponent { * */ @property({ reflect: true }) itemHidden = false; + /** + * When `selectionMode` is `"multiple"` or `"ancestors"` and one or more, but not all `calcite-combobox-item`s are selected, displays an indeterminate "select all" checkbox. + * + * @private + */ + @property({ reflect: true }) indeterminate = false; + //#endregion //#region Events @@ -279,14 +286,16 @@ export class ComboboxItem extends LitElement implements InteractiveComponent { shortHeading, } = this; const isSingleSelect = isSingleLike(this.selectionMode); - const icon = disabled || isSingleSelect ? undefined : "check-square-f"; + const icon = disabled || isSingleSelect ? undefined : ICONS.checked; const selectionIcon = isSingleSelect ? this.selected - ? "circle-inset-large" - : "circle" - : this.selected - ? "check-square-f" - : "square"; + ? ICONS.selectedSingle + : ICONS.circle + : this.indeterminate + ? ICONS.indeterminate + : this.selected + ? ICONS.checked + : ICONS.unchecked; const headingText = heading || textLabel; const itemLabel = label || value; diff --git a/packages/calcite-components/src/components/combobox-item/resources.ts b/packages/calcite-components/src/components/combobox-item/resources.ts index ec6fdcf2275..da2e747af2e 100644 --- a/packages/calcite-components/src/components/combobox-item/resources.ts +++ b/packages/calcite-components/src/components/combobox-item/resources.ts @@ -15,6 +15,14 @@ export const CSS = { heading: "heading", }; +export const ICONS = { + checked: "check-square-f", + circle: "circle", + indeterminate: "minus-square-f", + selectedSingle: "circle-inset-large", + unchecked: "square", +}; + export const SLOTS = { contentEnd: "content-end", contentStart: "content-start", diff --git a/packages/calcite-components/src/components/combobox/assets/t9n/messages.en.json b/packages/calcite-components/src/components/combobox/assets/t9n/messages.en.json index c55e973fae1..9c98490d937 100644 --- a/packages/calcite-components/src/components/combobox/assets/t9n/messages.en.json +++ b/packages/calcite-components/src/components/combobox/assets/t9n/messages.en.json @@ -1,6 +1,7 @@ { "all": "All", "allSelected": "All selected", + "selectAll": "Select All", "clear": "Clear value", "removeTag": "Remove tag", "selected": "selected" diff --git a/packages/calcite-components/src/components/combobox/assets/t9n/messages.json b/packages/calcite-components/src/components/combobox/assets/t9n/messages.json index c55e973fae1..9c98490d937 100644 --- a/packages/calcite-components/src/components/combobox/assets/t9n/messages.json +++ b/packages/calcite-components/src/components/combobox/assets/t9n/messages.json @@ -1,6 +1,7 @@ { "all": "All", "allSelected": "All selected", + "selectAll": "Select All", "clear": "Clear value", "removeTag": "Remove tag", "selected": "selected" diff --git a/packages/calcite-components/src/components/combobox/combobox.e2e.ts b/packages/calcite-components/src/components/combobox/combobox.e2e.ts index 1c46a8e361c..b5bbd9f5660 100644 --- a/packages/calcite-components/src/components/combobox/combobox.e2e.ts +++ b/packages/calcite-components/src/components/combobox/combobox.e2e.ts @@ -752,7 +752,13 @@ describe("calcite-combobox", () => { `, ); - const item = await page.find("calcite-combobox-item"); + await page.waitForChanges(); + + const combobox = await page.find("calcite-combobox"); + await combobox.callMethod("componentOnReady"); + expect(combobox).not.toBeNull(); + + const item = await page.find("calcite-combobox-item#item-0"); let a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li`); expect(a11yItem).not.toBeNull(); @@ -794,7 +800,7 @@ describe("calcite-combobox", () => { item.setProperty("disabled", true); await page.waitForChanges(); await page.waitForTimeout(DEBOUNCE.nextTick); - a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li`); + a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(2)`); expect(a11yItem).toBeNull(); }); @@ -2975,6 +2981,227 @@ describe("calcite-combobox", () => { expect((await combobox.getProperty("selectedItems")).length).toBe(1); }); + describe("selectAllEnabled", async () => { + let page: E2EPage; + + beforeEach(async () => { + page = await newE2EPage(); + await page.setContent( + html` + + + + + + + + + + + `, + ); + await page.waitForChanges(); + }); + + async function testToggleAllItems( + page: E2EPage, + toggleAction: ([selectAll, combobox]: [E2EElement, E2EElement]) => Promise, + ): Promise { + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + expect(await combobox.getProperty("open")).toBe(true); + + const selectAll = await page.find(`calcite-combobox >>> .${CSS.selectAll}`); + await toggleAction([selectAll, combobox]); + + let allComboboxItems = await findAll(page, "calcite-combobox-item"); + for (const item of allComboboxItems) { + expect(await item.getProperty("selected")).toBe(true); + } + expect(await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`)).toBeDefined(); + + await toggleAction([selectAll, combobox]); + + allComboboxItems = await findAll(page, "calcite-combobox-item"); + for (const item of allComboboxItems) { + expect(await item.getProperty("selected")).toBe(false); + } + + const chip = await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`); + expect(chip.classList.contains(`${CSS.chipInvisible}`)).toBe(true); + } + + it("should toggle all items on and off with a click", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await testToggleAllItems(page, async ([selectAll, _combobox]) => { + await selectAll.click(); + }); + }); + + it("should toggle all items on and off with KeyDown press `enter`", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await testToggleAllItems(page, async ([_selectAll, combobox]) => { + await combobox.press("Enter"); + }); + }); + + it("indeterminate state", async () => { + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + expect(await combobox.getProperty("open")).toBe(true); + + await (await combobox.find("calcite-combobox-item[value=Sequoia]")).click(); + + const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`); + expect(await selectAll.getProperty("indeterminate")).toBe(true); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeDefined(); + + await (await combobox.find("calcite-combobox-item[value=Flowers]")).click(); + + expect(await selectAll.getProperty("indeterminate")).toBe(true); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Flowers]`)).toBeDefined(); + + const chip = await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`); + expect(chip.classList.contains(`${CSS.chipInvisible}`)).toBe(true); + + await selectAll.click(); + expect(await selectAll.getProperty("indeterminate")).toBe(false); + expect(await selectAll.getProperty("selected")).toBe(true); + + expect(await page.find(`calcite-combobox >>> calcite-chip.${CSS.allSelected}`)).toBeDefined(); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeNull(); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Flowers]`)).toBeNull(); + + const allComboboxItems = await findAll(page, "calcite-combobox-item"); + for (const item of allComboboxItems) { + expect(await item.getProperty("selected")).toBe(true); + } + }); + + async function testToggleListItems( + page: E2EPage, + toggleAction: ([listItem, combobox]: [E2EElement, E2EElement]) => Promise, + ): Promise { + const messages = await import("./assets/t9n/messages.json"); + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + expect(await combobox.getProperty("open")).toBe(true); + + const allComboboxItems = await findAll(page, "calcite-combobox-item"); + for (const item of allComboboxItems) { + item.setProperty("selected", true); + } + await page.waitForChanges(); + expect(await page.find(`calcite-combobox >>> calcite-chip[value="${messages.allSelected}"]`)).toBeDefined(); + + const listItem = await combobox.find("calcite-combobox-item[value=Sequoia]"); + await toggleAction([listItem, combobox]); + + const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`); + expect(await selectAll.getProperty("indeterminate")).toBe(true); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeDefined(); + + await toggleAction([listItem, combobox]); + + expect(await selectAll.getProperty("indeterminate")).toBe(false); + expect(await selectAll.getProperty("selected")).toBe(true); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeNull(); + + await toggleAction([listItem, combobox]); + + expect(await selectAll.getProperty("indeterminate")).toBe(true); + expect(await page.find(`calcite-combobox >>> calcite-chip[value=Sequoia]`)).toBeDefined(); + } + + it("should toggle indeterminate state to `All Selected` when list items are toggled with a click", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await testToggleListItems(page, async ([listItem, _combobox]) => { + await listItem.click(); + }); + }); + + it("should toggle indeterminate state to `All Selected` when list items are toggled with a keydown `Enter`", async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + await testToggleAllItems(page, async ([_listItem, combobox]) => { + await combobox.press("Enter"); + }); + }); + + it("should have indeterminate state when some items are initialized selected", async () => { + page = await newE2EPage(); + await page.setContent( + html` + + + + `, + ); + await page.waitForChanges(); + const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`); + expect(await selectAll.getProperty("indeterminate")).toBe(true); + }); + + it("should have selectAll state true when all items are initialized selected", async () => { + page = await newE2EPage(); + await page.setContent( + html` + + + + `, + ); + await page.waitForChanges(); + const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`); + expect(await selectAll.getProperty("selected")).toBe(true); + }); + + it("should bring back all the chips except `All Selected` when one item is deselected", async () => { + page = await newE2EPage(); + await page.setContent( + html` + + + + + `, + ); + await page.waitForChanges(); + const messages = await import("./assets/t9n/messages.json"); + + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + await page.waitForChanges(); + + const listItem = await combobox.find("calcite-combobox-item[value=Pine]"); + await listItem.click(); + + expect(await page.find(`calcite-combobox >>> calcite-chip[value="Trees"]`)).toBeDefined(); + expect(await page.find(`calcite-combobox >>> calcite-chip[value="Maple"]`)).toBeDefined(); + expect(await page.find(`calcite-combobox >>> calcite-chip[value="${messages.allSelected}"]`)).toBeNull(); + }); + + it("should update aria-selected on items when toggling 'Select All'", async () => { + const combobox = await page.find("calcite-combobox"); + await combobox.click(); + + const selectAll = await page.find(`calcite-combobox >>> calcite-combobox-item.${CSS.selectAll}`); + await selectAll.click(); + await page.waitForChanges(); + + let a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(2)`); + expect(await a11yItem.getProperty("ariaSelected")).toBe("true"); + + a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(3)`); + expect(await a11yItem.getProperty("ariaSelected")).toBe("true"); + + await selectAll.click(); + await page.waitForChanges(); + + a11yItem = await page.find(`calcite-combobox >>> ul.${CSS.screenReadersOnly} li:nth-of-type(2)`); + expect(await a11yItem.getProperty("ariaSelected")).toBe("false"); + }); + }); + describe("theme", () => { describe("default", () => { const comboboxHTML = html` diff --git a/packages/calcite-components/src/components/combobox/combobox.scss b/packages/calcite-components/src/components/combobox/combobox.scss index 4ef92f583f3..c9a3be92d48 100644 --- a/packages/calcite-components/src/components/combobox/combobox.scss +++ b/packages/calcite-components/src/components/combobox/combobox.scss @@ -110,8 +110,8 @@ gap: var(--calcite-internal-combobox-spacing-unit-s); margin-inline-end: var(--calcite-internal-combobox-spacing-unit-s); - &.selection-display-fit, - &.selection-display-single { + &.selection-display--fit, + &.selection-display--single { @apply flex-nowrap overflow-hidden; } } @@ -236,6 +236,16 @@ calcite-chip { @apply block; } +.select-all { + background-color: var(--calcite-combobox-item-background-color-active, var(--calcite-color-foreground-1)); + border-block-end-color: var(--calcite-combobox-item-border-color, var(--calcite-color-border-3)); + border-block-end-style: solid; + border-block-end-width: var(--calcite-border-width-sm); + inset-block-start: 0; + position: sticky; + z-index: var(--calcite-z-index-sticky); +} + @include disabled(); @include x-button( $background-color: "var(--calcite-close-background-color, var(--calcite-color-foreground-2))", diff --git a/packages/calcite-components/src/components/combobox/combobox.stories.ts b/packages/calcite-components/src/components/combobox/combobox.stories.ts index 90ae8e3aefc..13dfb3dc0e5 100644 --- a/packages/calcite-components/src/components/combobox/combobox.stories.ts +++ b/packages/calcite-components/src/components/combobox/combobox.stories.ts @@ -965,6 +965,47 @@ export const withDescriptionIconsAndContentSlots = (): string => html` `; +export const selectAllEnabledAndAllSelectedWithPlaceholder = (): string => html` +
+ + + + + + + + + +
+`; + +export const selectAllEnabledAndAllSelected = (): string => html` + + + + + + + + + +`; + +export const selectAllEnabledIndeterminate = (): string => html` + + + + + + + + + + + + +`; + export const withDescriptionShortLabelAndContentSlots = (): string => html` { item.filterTextMatchPattern = this.filterTextMatchPattern; }); @@ -207,8 +206,6 @@ export class Combobox private internalValueChangeFlag = false; - private items: HTMLCalciteComboboxItemElement["el"][] = []; - labelEl: Label["el"]; private listContainerEl: HTMLDivElement; @@ -265,7 +262,32 @@ export class Combobox @state() selectedVisibleChipsCount = 0; - //#endregion + @state() selectAllComboboxItemReferenceEl: HTMLCalciteComboboxItemElement; + + @state() items: HTMLCalciteComboboxItemElement["el"][] = []; + + @state() + get allSelected(): boolean { + return this.selectedItems.length === this.items.length; + } + + @state() + get indeterminate(): boolean { + return this.selectedItems.length > 0 && !this.allSelected; + } + + @state() + get keyboardNavItems(): HTMLCalciteComboboxItemElement["el"][] { + const { selectAllComboboxItemReferenceEl, items } = this; + + if (selectAllComboboxItemReferenceEl) { + return [selectAllComboboxItemReferenceEl, ...items.filter((item) => !item.disabled)]; + } + + return items.filter((item) => !item.disabled); + } + + // #endregion //#region Public Properties @@ -299,7 +321,9 @@ export class Combobox * * @readonly */ - @property() filteredItems: HTMLCalciteComboboxItemElement["el"][] = []; + @property() get filteredItems(): HTMLCalciteComboboxItemElement["el"][] { + return this.keyboardNavItems.filter((item) => !isHidden(item)); + } /** Specifies the component's fallback slotted content placement when it's initial placement has insufficient space available. */ @property() flipPlacements: FlipPlacement[]; @@ -364,6 +388,9 @@ export class Combobox /** Specifies the size of the component. */ @property({ reflect: true }) scale: Scale = "m"; + /** When `true` and `selectionMode` is `"multiple"` or `"ancestors"`, provides a checkbox for selecting all `calcite-combobox-item`s. */ + @property({ reflect: true }) selectAllEnabled = false; + /** * Specifies the component's selected items. * @@ -686,17 +713,39 @@ export class Combobox this.open = false; } + private handleSelectAll(isSelectAllTarget: boolean): void { + if (isSelectAllTarget) { + this.toggleSelectAll(); + } + + if (this.allSelected) { + this.selectedItems.forEach((item) => { + const chipEl = this.referenceEl.querySelector(`#${chipUidPrefix}${item.guid}`); + if (chipEl) { + this.hideChip(chipEl); + } + }); + } + } + private calciteComboboxItemChangeHandler( event: CustomEvent, ): void { if (this.ignoreSelectedEventsFlag) { return; } - const target = event.target as HTMLCalciteComboboxItemElement["el"]; + const isSelectAllTarget = event.composedPath().includes(this.selectAllComboboxItemReferenceEl); + + if (this.selectAllEnabled) { + this.handleSelectAll(isSelectAllTarget); + } + const newIndex = this.filteredItems.indexOf(target); this.updateActiveItemIndex(newIndex); this.toggleSelection(target, target.selected); + + this.selectedItems = this.getSelectedItems(); } private calciteInternalComboboxItemChangeHandler( @@ -749,6 +798,15 @@ export class Combobox ); } + private toggleSelectAll() { + const toggledValue = !this.allSelected; + this.selectedItems = this.items.filter((item) => { + item.selected = toggledValue; + return toggledValue; + }); + this.emitComboboxChange(); + } + private keyDownHandler(event: KeyboardEvent): void { if (this.readOnly) { return; @@ -790,11 +848,13 @@ export class Combobox if (this.open) { this.shiftActiveItemIndex(-1); } + this.scrollToActiveOrSelectedItem(); if (!this.comboboxInViewport()) { this.el.scrollIntoView(); } } + this.scrollToActiveOrSelectedItem(); break; case "ArrowDown": if (this.filteredItems.length) { @@ -805,6 +865,7 @@ export class Combobox this.open = true; this.ensureRecentSelectedItemIsActive(); } + this.scrollToActiveOrSelectedItem(); if (!this.comboboxInViewport()) { this.el.scrollIntoView(); @@ -855,6 +916,10 @@ export class Combobox const item = this.filteredItems[this.activeItemIndex]; this.toggleSelection(item, !item.selected); event.preventDefault(); + + if (this.selectAllEnabled) { + this.handleSelectAll(item === this.selectAllComboboxItemReferenceEl); + } } else if (this.activeChipIndex > -1) { this.removeActiveChip(); event.preventDefault(); @@ -975,6 +1040,10 @@ export class Combobox chipEls, availableHorizontalChipElSpace, chipContainerElGap, + }: { + chipEls: Chip["el"][]; + availableHorizontalChipElSpace: number; + chipContainerElGap: number; }): void { chipEls.forEach((chipEl: Chip["el"]) => { if (!chipEl.selected) { @@ -1030,6 +1099,24 @@ export class Combobox largestSelectedIndicatorChipWidth, }); + if (this.allSelected && this.selectAllEnabled) { + this.selectedItems.forEach((item) => { + const chipEl = this.referenceEl.querySelector(`#${chipUidPrefix}${item.guid}`); + if (chipEl) { + this.hideChip(chipEl); + } + }); + } + + if (this.indeterminate) { + this.selectedItems.forEach((item) => { + const chipEl = this.referenceEl.querySelector(`#${chipUidPrefix}${item.guid}`); + if (chipEl) { + this.showChip(chipEl); + } + }); + } + if (selectionDisplay === "fit") { const chipEls = Array.from(this.el.shadowRoot.querySelectorAll("calcite-chip")).filter( (chipEl) => chipEl.closable, @@ -1090,6 +1177,10 @@ export class Combobox connectFloatingUI(this); } + private setSelectAllComboboxItemReferenceEl(el: HTMLCalciteComboboxItemElement): void { + this.selectAllComboboxItemReferenceEl = el; + } + private setAllSelectedIndicatorChipEl(el: Chip["el"]): void { this.allSelectedIndicatorChipEl = el; } @@ -1173,27 +1264,35 @@ export class Combobox } if (this.isMulti()) { - item.selected = value; - this.updateAncestors(item); - this.selectedItems = this.getSelectedItems(); - this.emitComboboxChange(); - this.resetText(); - this.filterItems(""); + this.handleMultiSelection(item, value); } else { - this.ignoreSelectedEventsFlag = true; - this.items.forEach((el) => (el.selected = el === item ? value : false)); - this.ignoreSelectedEventsFlag = false; - this.selectedItems = this.getSelectedItems(); - this.emitComboboxChange(); - - if (this.textInput.value) { - this.textInput.value.value = getLabel(item); - } - this.open = false; - this.updateActiveItemIndex(-1); - this.resetText(); - this.filterItems(""); + this.handleSingleSelection(item, value); + } + } + + private handleMultiSelection(item: HTMLCalciteComboboxItemElement["el"], value: boolean): void { + item.selected = value; + this.updateAncestors(item); + this.selectedItems = this.getSelectedItems(); + this.emitComboboxChange(); + this.resetText(); + this.filterItems(""); + } + + private handleSingleSelection(item: HTMLCalciteComboboxItemElement["el"], value: boolean): void { + this.ignoreSelectedEventsFlag = true; + this.items.forEach((el) => (el.selected = el === item ? value : false)); + this.ignoreSelectedEventsFlag = false; + this.selectedItems = this.getSelectedItems(); + this.emitComboboxChange(); + + if (this.textInput.value) { + this.textInput.value.value = getLabel(item); } + this.open = false; + this.updateActiveItemIndex(-1); + this.resetText(); + this.filterItems(""); } private updateAncestors(item: HTMLCalciteComboboxItemElement["el"]): void { @@ -1216,10 +1315,6 @@ export class Combobox } } - private getFilteredItems(): HTMLCalciteComboboxItemElement["el"][] { - return this.filterText === "" ? this.items : this.items.filter((item) => !isHidden(item)); - } - private updateItems(): void { this.items = this.getItems(); this.groupItems = this.getGroupItems(); @@ -1230,7 +1325,6 @@ export class Combobox this.updateItemProps(); this.selectedItems = this.getSelectedItems(); - this.filteredItems = this.getFilteredItems(); } private updateItemProps(): void { @@ -1288,6 +1382,7 @@ export class Combobox const items: HTMLCalciteComboboxItemElement["el"][] = Array.from( this.el.querySelectorAll(ComboboxItemSelector), ); + return items.filter((item) => withDisabled || !item.disabled); } @@ -1367,6 +1462,17 @@ export class Combobox } item.scrollIntoView({ block: "nearest" }); + + const stickyElement = this.selectAllComboboxItemReferenceEl; + const stickyHeight = stickyElement?.offsetHeight || 0; + + const listContainer = this.listContainerEl; + const itemRect = item.getBoundingClientRect(); + const containerRect = listContainer.getBoundingClientRect(); + + if (itemRect.top < containerRect.top + stickyHeight) { + listContainer.scrollTop -= containerRect.top + stickyHeight - itemRect.top; + } } private shiftActiveItemIndex(delta: number): void { @@ -1388,15 +1494,12 @@ export class Combobox } }); this.activeDescendant = activeDescendant; + if (this.activeItemIndex > -1) { this.activeChipIndex = -1; } } - private isAllSelected(): boolean { - return this.getItems().length === this.getSelectedItems().length; - } - private isMulti(): boolean { return !isSingleLike(this.selectionMode); } @@ -1415,6 +1518,11 @@ export class Combobox private renderChips(): JsxNode { const { activeChipIndex, readOnly, scale, selectionMode, messages } = this; + + if (this.selectAllEnabled && this.allSelected) { + return null; + } + return this.selectedItems.map((item, i) => { const chipClasses = { [CSS.chip]: true, @@ -1457,42 +1565,17 @@ export class Combobox selectedVisibleChipsCount, setAllSelectedIndicatorChipEl, } = this; - const label = this.messages.allSelected; - return ( - - {label} - - ); - } + const label = compactSelectionDisplay ? this.messages.all : this.messages.allSelected; - private renderAllSelectedIndicatorChipCompact(): JsxNode { - const { compactSelectionDisplay, scale, selectedVisibleChipsCount } = this; - const label = this.messages.all || "All"; return ( 0) { chipInvisible = false; @@ -1530,7 +1613,7 @@ export class Combobox label = `${selectedItemsCount} ${this.messages.selected}`; } else if (selectionDisplay === "fit") { chipInvisible = !!( - (this.isAllSelected() && selectedVisibleChipsCount === 0) || + (this.allSelected && selectedVisibleChipsCount === 0) || selectedHiddenChipsCount === 0 ); label = @@ -1569,7 +1652,7 @@ export class Combobox if (compactSelectionDisplay) { const selectedItemsCount = getSelectedItems().length; - if (this.isAllSelected()) { + if (this.allSelected) { chipInvisible = true; } else if (selectionDisplay === "fit") { chipInvisible = !(selectedHiddenChipsCount > 0); @@ -1613,8 +1696,8 @@ export class Combobox {showLabel && ( @@ -1656,21 +1739,49 @@ export class Combobox } private renderListBoxOptions(): JsxNode { - return this.filteredItems.map((item) => ( -
  • - {item.heading || item.textLabel} -
  • - )); + const mappedListBoxOptions = this.filteredItems.map( + (item: HTMLCalciteComboboxItemElement["el"]) => { + return ( +
  • + {item.heading || item.textLabel} +
  • + ); + }, + ); + + if ( + this.selectAllEnabled && + this.selectionMode !== "single" && + this.selectionMode !== "single-persist" + ) { + const selectAllComboboxItem = ( +
  • + {this.messages.selectAll} +
  • + ); + + if (selectAllComboboxItem) { + mappedListBoxOptions.unshift(selectAllComboboxItem); + } + } + + return mappedListBoxOptions; } private renderFloatingUIContainer(): JsxNode { - const { setFloatingEl, setContainerEl, open } = this; + const { setFloatingEl, setContainerEl, open, scale } = this; const classes = { [CSS.listContainer]: true, [FloatingCSS.animation]: true, @@ -1681,6 +1792,22 @@ export class Combobox
      + {this.selectAllEnabled && + this.selectionMode !== "single" && + this.selectionMode !== "single-persist" && ( + + )}
    @@ -1756,12 +1883,15 @@ export class Combobox ref={this.setChipContainerEl} > {!singleSelectionMode && !singleSelectionDisplay && this.renderChips()} + {!singleSelectionMode && + !singleSelectionDisplay && + this.selectAllEnabled && + this.renderAllSelectedIndicatorChip()} {!singleSelectionMode && !allSelectionDisplay && [ this.renderSelectedIndicatorChip(), this.renderSelectedIndicatorChipCompact(), this.renderAllSelectedIndicatorChip(), - this.renderAllSelectedIndicatorChipCompact(), ]}
    + +
    +
    Multi select with Indeterminate Select All Enabled
    + +
    + label + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + label + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + label + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    Multi select (single selection-display)