From dc951050c1a2330e55bfaba6fe445c9454dbaf1e Mon Sep 17 00:00:00 2001 From: Naman Goel <104068635+NamanGoel20@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:34:40 +0530 Subject: [PATCH] #CX-12946 : Explore and fix how multi select and grouping of selected records using new wc listbox component (#1855) * feat(cx-12946): update list and example * updating component * feat(cx-12946): added the border bottom logic * update * feat(cx-12946): with meny overlay * updating parent with overlay * updating overlay parent * updating grouping logic * feat(cx-12946): fixed multiselect logic * adding select all * feat(cx-12946): select all grouping and reset filter done * feat(cx-12946): fix of UI in sandbox * adding select all and unselect all * adding chevron to example * feat(cx-12946): pre-select issue resolved * updating css * updating select all logic * updating UI * updating UI * updating label and uts * adding UTs for multi select * fix(cx-12946): a11y fix for multiselect * fix(cx-12946): separator issue fix * fix(cx-12946): arrows, tab and space keyboard navi fixed * fix(cx-12946): updated unit test * fix(cx-12946): addressed review comment * fix(cx-12946): fix for sandbox issue * fix(cx-12946): made enter and space key for selection and deselection * fix(cx-12946): version update --------- Co-authored-by: Naman Goel Co-authored-by: nithishkumar98 Co-authored-by: nithishkumar98 <79409177+nithishkumar98@users.noreply.github.com> --- web-components/package.json | 2 +- .../components/ParentComponentGeneric.ts | 2 +- .../components/ParentComponentPreSelect.ts | 4 +- .../ParentComponentWithMdOverlay.ts | 252 ++++++++++++++++++ .../src/[sandbox]/examples/advance-list.ts | 2 + .../advance-list/AdvanceList.test.ts | 136 ++++++++-- .../components/advance-list/AdvanceList.ts | 168 +++++++----- .../advance-list/scss/AdvanceList.scss | 7 +- 8 files changed, 486 insertions(+), 87 deletions(-) create mode 100644 web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentWithMdOverlay.ts diff --git a/web-components/package.json b/web-components/package.json index dd5b9456c..938800cd0 100644 --- a/web-components/package.json +++ b/web-components/package.json @@ -1,6 +1,6 @@ { "name": "@momentum-ui/web-components", - "version": "2.17.0", + "version": "2.17.1", "author": "Yana Harris", "license": "MIT", "repository": "https://github.com/momentum-design/momentum-ui.git", diff --git a/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentGeneric.ts b/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentGeneric.ts index 3235a14fa..5a784193d 100644 --- a/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentGeneric.ts +++ b/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentGeneric.ts @@ -9,7 +9,7 @@ export namespace ParentComponentGeneric { @property({ type: Array }) items: any = []; @internalProperty() page = 1; @property({ type: Boolean }) isLoading = false; - @property({ type: String }) value = ""; + @property({ type: Array }) value: string[] = []; @property({ type: Boolean }) isError = false; @internalProperty() totalRecords = 60000; // Total count is set to 6000 diff --git a/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentPreSelect.ts b/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentPreSelect.ts index ab772b64c..ff9ec057d 100644 --- a/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentPreSelect.ts +++ b/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentPreSelect.ts @@ -9,7 +9,7 @@ export namespace ParentComponentPreSelect { @property({ type: Array }) items: any = []; @internalProperty() page = 1; @property({ type: Boolean }) isLoading = false; - @property({ type: String }) value = ""; + @property({ type: Array }) value: string[] = []; @property({ type: Boolean }) isError = false; @internalProperty() totalRecords = 0; @@ -36,7 +36,7 @@ export namespace ParentComponentPreSelect { this.totalRecords = 60000; this.page += 1; this.isLoading = false; - this.value = this.items[1].id; + this.value.push(this.items[1].id); } catch (err) { this.isLoading = false; this.isError = true; diff --git a/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentWithMdOverlay.ts b/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentWithMdOverlay.ts new file mode 100644 index 000000000..a653d6190 --- /dev/null +++ b/web-components/src/[sandbox]/examples/AdvanceList/components/ParentComponentWithMdOverlay.ts @@ -0,0 +1,252 @@ +import { html, internalProperty, LitElement, property, PropertyValues } from "lit-element"; +import "@/components/advance-list/AdvanceList"; +import "@/components/list/List"; +import "@/components/list/ListItem"; +import "@/components/menu-overlay/MenuOverlay"; +import { customElementWithCheck } from "@/mixins/CustomElementCheck"; + +export namespace ParentComponentWithMdOverlay { + @customElementWithCheck("parent-component-with-overlay") + export class ELEMENT extends LitElement { + @property({ type: Array }) items: any = []; + @internalProperty() page = 1; + @property({ type: Boolean }) isLoading = false; + @property({ type: Array }) value: string[] = []; + @property({ type: Boolean }) isError = false; + @property({ type: Boolean }) groupOnMultiSelect = true; + @internalProperty() totalRecords = 0; + @internalProperty() loadedRecords = 0; + @internalProperty() lastSelectedIdByOrder = ""; + @internalProperty() selectAllItems = false; + @internalProperty() isMenuOverlayOpen = false; + @internalProperty() disabledItems: string[] = []; + @internalProperty() inputIcon = "arrow-down-bold"; + @internalProperty() overlayTriggerPlaceholder = "Search field with tabs"; + + private counter = 0; + constructor() { + super(); + this.items = []; + this.page = 1; + this.isLoading = false; + this.isError = false; + this.loadMoreItems(); + } + + updated(changedProperties: PropertyValues) { + console.log("changedProperties", changedProperties); + if (changedProperties.has("value")) { + this.updatePlaceholder(); + } + } + + generateUUID() { + const baseUUID = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + + // Increment the counter and pad with zeros to ensure 3 characters + this.counter++; + const counterString = this.counter.toString().padStart(3, "0"); + + // Replace the last 3 characters of the UUID with the counter + return baseUUID.slice(0, -3) + counterString; + } + + async loadMoreItems() { + try { + this.isLoading = true; + const newItems = await this.fetchItems(this.page); + this.items = [...this.items, ...newItems]; + this.totalRecords = 60000; + this.page += 1; + this.value.push(this.items[1].id); + this.loadedRecords = this.items.length; + } catch (err) { + this.isError = true; + } finally { + this.isLoading = false; + } + } + + async fetchItems(page: number) { + const newItems = this.generateItems(page); + return newItems; + } + + private generateItems(page: number) { + const itemsPerPage = 2000; + return Array.from({ length: itemsPerPage }, (_, i) => { + const id = this.generateUUID(); // Generate a unique ID for each item + const itemName = `Item ${(page - 1) * itemsPerPage + i + 1}`; + const disabled = i % 2 === 0 ? "true" : ""; + if (disabled) { + this.disabledItems.push(id); + } + return { + name: itemName, + id, + index: i, + ariaLabel: itemName, + template: (data: any, index: number) => + html`` + }; + }); + } + + getOrderedItems() { + if (this.groupOnMultiSelect) { + const selectedItems = this.items.filter((item: any) => this.value.includes(item.id)); + const unselectedItems = this.items.filter((item: any) => !this.value.includes(item.id)); + + selectedItems.sort((a: any, b: any) => this.naturalSort(a.name, b.name)); + unselectedItems.sort((a: any, b: any) => this.naturalSort(a.name, b.name)); + + if (selectedItems.length > 0) { + this.lastSelectedIdByOrder = selectedItems[selectedItems.length - 1].id; + } else { + this.lastSelectedIdByOrder = ""; + } + return [...selectedItems, ...unselectedItems]; + } else { + return [...this.items].sort((a, b) => this.naturalSort(a.name, b.name)); + } + } + + private naturalSort(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); + } + + updatePlaceholder() { + const selectedItemsCount = this.value.length; + this.overlayTriggerPlaceholder = selectedItemsCount ? `${selectedItemsCount} selected` : "Search field with tabs"; + } + + private handleListItemChange(event: CustomEvent) { + this.value = event.detail.selected; + // Filter and sort the selected items based on the `name` property + const selectedItems = this.items + .filter((item: any) => this.value.includes(item.id)) + .sort((a: any, b: any) => this.naturalSort(a.name, b.name)); + + // Update `this.value` with the sorted IDs + this.value = selectedItems.map((item: any) => item.id); + + this.updatePlaceholder(); + if (this.value.length === this.items.length - this.disabledItems.length) { + this.selectAllItems = true; + } else { + this.selectAllItems = false; + } + document.dispatchEvent(new CustomEvent("on-widget-update")); + } + + updateSelectAllCheckboxOnClick() { + if (this.selectAllItems) { + this.resetFilter(); + } else { + this.selectAllItems = true; + } + } + + resetFilter() { + this.selectAllItems = false; + this.value = []; + } + + getSelectAllLabel() { + return this.items.length < 100 + ? "Select all" + : `Select ${this.loadedRecords - this.disabledItems.length} of ${this.totalRecords}`; + } + + render() { + return html` +

With overlay

+ + { + this.items = this.getOrderedItems(); + this.inputIcon = "arrow-up-bold"; + this.isMenuOverlayOpen = true; + document.dispatchEvent(new CustomEvent("on-widget-update")); + }} + @menu-overlay-close=${() => { + this.inputIcon = "arrow-down-bold"; + this.isMenuOverlayOpen = false; + }} + > + + + + +
+ +
+
+ ${this.getSelectAllLabel()} +
+ ${this.isMenuOverlayOpen + ? html` + + + ` + : html`` + } +
+
+ Reset the filter +
+
+
+ `; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "parent-component-with-overlay": ParentComponentWithMdOverlay.ELEMENT; + } +} diff --git a/web-components/src/[sandbox]/examples/advance-list.ts b/web-components/src/[sandbox]/examples/advance-list.ts index 877cd4794..a1462b35f 100644 --- a/web-components/src/[sandbox]/examples/advance-list.ts +++ b/web-components/src/[sandbox]/examples/advance-list.ts @@ -3,9 +3,11 @@ import { html } from "lit-element"; import "./AdvanceList/components/ParentComponentError"; import "./AdvanceList/components/ParentComponentGeneric"; import "./AdvanceList/components/ParentComponentPreSelect"; +import "./AdvanceList/components/ParentComponentWithMdOverlay"; export const advanceListTemplate = html`
+ diff --git a/web-components/src/components/advance-list/AdvanceList.test.ts b/web-components/src/components/advance-list/AdvanceList.test.ts index 64fa60f56..d2a28a55b 100644 --- a/web-components/src/components/advance-list/AdvanceList.test.ts +++ b/web-components/src/components/advance-list/AdvanceList.test.ts @@ -63,7 +63,7 @@ describe("advanceList Component", () => { .items=${initialItems} .isLoading=${false} .isError=${false} - .value=${"2"} + .value=${["2"]} ariaRoleList="listbox" ariaLabelList="state selector" .totalRecords=${100} @@ -119,6 +119,50 @@ describe("advanceList Component", () => { } }); + test("should select and unselect multiple items on click with multi select", async () => { + const items = el.shadowRoot?.querySelectorAll(".default-wrapper"); + expect(items).not.toBeNull(); + el.isMulti = true; + const firstItem = items?.[0] as HTMLElement; + const secondItem = items?.[1] as HTMLElement; + if (firstItem) { + firstItem.click(); + await el.updateComplete; + await nextFrame(); + expect(firstItem.getAttribute("selected")).toBe("true"); + } + if (secondItem) { + secondItem.click(); + await el.updateComplete; + await nextFrame(); + expect(firstItem.getAttribute("selected")).toBe("true"); + } + if (firstItem) { + firstItem.click(); + await el.updateComplete; + await nextFrame(); + expect(firstItem.getAttribute("selected")).toBe("false"); + } + }); + + test("should set selectAllItems as true on checking all items with multi select", async () => { + const items = Array.from(el.shadowRoot?.querySelectorAll(".default-wrapper") || []) as HTMLElement[]; + expect(items).not.toBeNull(); + el.isMulti = true; + items.forEach(async (item) => { + if (item) { + item.click(); + await el.updateComplete; + } + }); + expect(el.selectAllItems).toBe(true); + if (items[0]) { + items[0].click(); + await el.updateComplete; + expect(el.selectAllItems).toBe(false); + } + }); + test("should not select disabled item on click", async () => { const disabledId = el.items[10].id; (el as any).updateSelectedState(); @@ -141,14 +185,14 @@ describe("advanceList Component", () => { await el.updateComplete; await nextFrame(); - const arrowDownEvent = new KeyboardEvent("keydown", { key: "ArrowDown" }); + const arrowDownEvent = new KeyboardEvent("keydown", { code: "ArrowDown" }); el.handleKeyDown(arrowDownEvent); await nextFrame(); let currentIndex = el.items.findIndex((item) => item.id === el.activeId); expect(currentIndex).toBe(1); - const arrowUpEvent = new KeyboardEvent("keydown", { key: "ArrowUp" }); + const arrowUpEvent = new KeyboardEvent("keydown", { code: "ArrowUp" }); el.handleKeyDown(arrowUpEvent); await nextFrame(); @@ -159,39 +203,40 @@ describe("advanceList Component", () => { test("should handle ArrowDown for preselected value", async () => { el.items = createItems(1, 20); el.activeId = ""; - el.value = el.items[3].id; - + el.value = [el.items[3].id]; + el.requestUpdate(); await el.updateComplete; await nextFrame(); - const arrowDownEvent = new KeyboardEvent("keydown", { key: "ArrowDown" }); + const arrowDownEvent = new KeyboardEvent("keydown", { code: "ArrowDown" }); el.handleKeyDown(arrowDownEvent); await nextFrame(); - expect(el.activeId).toBe(el.items[4].id); + expect(el.activeId).toBe(el.items[3].id); let currentIndex = el.items.findIndex((item) => item.id === el.activeId); - expect(currentIndex).toBe(4); + expect(currentIndex).toBe(3); - const arrowUpEvent = new KeyboardEvent("keydown", { key: "ArrowUp" }); + const arrowUpEvent = new KeyboardEvent("keydown", { code: "ArrowUp" }); el.handleKeyDown(arrowUpEvent); await nextFrame(); - expect(el.activeId).toBe(el.items[3].id); + expect(el.activeId).toBe(el.items[2].id); currentIndex = el.items.findIndex((item) => item.id === el.activeId); - expect(currentIndex).toBe(3); + expect(currentIndex).toBe(2); }); test("should handle ArrowUp for preselected value", async () => { el.items = createItems(1, 20); el.activeId = ""; - el.value = el.items[4].id; // Preselect item 5 + el.value = [el.items[4].id]; // Preselect item 5 el.requestUpdate(); await el.updateComplete; await nextFrame(); - const arrowUpEvent = new KeyboardEvent("keydown", { key: "ArrowUp" }); + const arrowUpEvent = new KeyboardEvent("keydown", { code: "ArrowUp" }); + el.handleKeyDown(arrowUpEvent); el.handleKeyDown(arrowUpEvent); await nextFrame(); @@ -200,7 +245,7 @@ describe("advanceList Component", () => { let currentIndex = el.items.findIndex((item) => item.id === el.activeId); expect(currentIndex).toBe(3); - const arrowDownEvent = new KeyboardEvent("keydown", { key: "ArrowDown" }); + const arrowDownEvent = new KeyboardEvent("keydown", { code: "ArrowDown" }); el.handleKeyDown(arrowDownEvent); await nextFrame(); @@ -214,6 +259,25 @@ describe("advanceList Component", () => { expect(preselectedItem.getAttribute("tabindex")).toBe("0"); }); + test("should handle Tab for preselected value", async () => { + el.items = createItems(1, 20); + el.activeId = ""; + el.value = [el.items[4].id]; // Preselect item 5 + + el.requestUpdate(); + await el.updateComplete; + await nextFrame(); + + const tabEvent = new KeyboardEvent("keydown", { code: "Tab" }); + el.handleKeyDown(tabEvent); + await nextFrame(); + + // Assert activeId is now the 5th item + expect(el.activeId).toBe(el.items[4].id); + let currentIndex = el.items.findIndex((item) => item.id === el.activeId); + expect(currentIndex).toBe(4); + }); + test("should handle Enter key and select non-disabled item", async () => { el.items = createItems(1, 20); el.activeId = el.items[1].id; @@ -222,14 +286,47 @@ describe("advanceList Component", () => { await el.updateComplete; await nextFrame(); - const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); - el.handleKeyDown(enterEvent); + const enterKey = new KeyboardEvent("keydown", { code: "Enter" }); + el.handleKeyDown(enterKey); + await nextFrame(); + + const selectedItem = el.shadowRoot?.querySelector(`#item-${el.activeId}`) as HTMLElement; + expect(selectedItem?.classList.contains("selected")).toBe(true); + }); + + + test("should handle Space key and select non-disabled item", async () => { + el.items = createItems(1, 20); + el.activeId = el.items[1].id; + + el.requestUpdate(); + await el.updateComplete; + await nextFrame(); + + const enterSpace = new KeyboardEvent("keydown", { code: "Space" }); + el.handleKeyDown(enterSpace); await nextFrame(); const selectedItem = el.shadowRoot?.querySelector(`#item-${el.activeId}`) as HTMLElement; expect(selectedItem?.classList.contains("selected")).toBe(true); }); + test("should select all items if the selectAll flag is true", async () => { + el.items = createItems(1, 5); + el.selectAllItems = true; + el.requestUpdate(); + await el.updateComplete; + await nextFrame(); + + await el.updateComplete; + + el.items.forEach((item) => { + const selectedItem = el.shadowRoot?.querySelector(`#item-${item.id}`) as HTMLElement; + expect(selectedItem?.classList.contains("selected")).toBe(true); + }); + }); + + test("should not select disabled item on Enter key press", async () => { el.items = createItems(1, 20); el.activeId = el.items[1].id; @@ -243,8 +340,8 @@ describe("advanceList Component", () => { secondItem.setAttribute("aria-disabled", "true"); } - const enterEvent = new KeyboardEvent("keydown", { key: "Enter" }); - el.handleKeyDown(enterEvent); + const enterSpace = new KeyboardEvent("keydown", { code: "Space" }); + el.handleKeyDown(enterSpace); await nextFrame(); expect(secondItem?.classList.contains("selected")).toBe(false); @@ -253,7 +350,7 @@ describe("advanceList Component", () => { test("should update selected state and handle disabled items", async () => { el.items = createItems(1, 20); el.activeId = el.items[1].id; - el.selectedItemId = el.items[1].id; + el.selectedItemsIds = [el.items[1].id]; (el as any).updateSelectedState(); await nextFrame(); @@ -272,7 +369,6 @@ describe("advanceList Component", () => { }); describe("Accessibility and Error Handling", () => { - test("should apply correct ARIA role and label", async () => { const wrapper = el.shadowRoot?.querySelector(".md-advance-list-wrapper"); expect(wrapper?.getAttribute("role")).toBe("listbox"); diff --git a/web-components/src/components/advance-list/AdvanceList.ts b/web-components/src/components/advance-list/AdvanceList.ts index 70b05f262..8f67f998e 100644 --- a/web-components/src/components/advance-list/AdvanceList.ts +++ b/web-components/src/components/advance-list/AdvanceList.ts @@ -12,18 +12,23 @@ export namespace AdvanceList { export class ELEMENT extends FocusMixin(LitElement) { @property({ type: Array }) items: any[] = []; @property({ type: Boolean }) isLoading = false; - @property({ type: String }) selectedItemId = ""; - @property({ type: String }) value = ""; + @property({ type: Boolean, attribute: "is-multi" }) isMulti = false; + @property({ type: Boolean }) groupOnMultiSelect = false; + @property({ type: Array }) value: string[] = []; @property({ type: String }) ariaRoleList = "listbox"; @property({ type: String }) ariaRoleListItem = "option"; @property({ type: String }) ariaLabelList = ""; @property({ type: Boolean }) isError = false; @property({ type: String }) containerHeight = "292px"; + @property({ type: String }) lastSelectedIdByOrder = ""; + @property({ type: Boolean }) selectAllItems = false; + @property({ type: Array }) disabledItems: string[] = []; @queryAll("div.default-wrapper") lists?: HTMLDivElement[]; @query(".virtual-scroll") listContainer?: HTMLDivElement; @property({ type: Number }) totalRecords = 0; @internalProperty() scrollIndex = -1; @internalProperty() activeId = ""; + @internalProperty() selectedItemsIds: string[] = []; @internalProperty() isUserNavigated = false; // this flag is used to control scroll to index this will became true only when user navigated using keyboard connectedCallback(): void { @@ -47,28 +52,44 @@ export namespace AdvanceList { disconnectedCallback() { super.disconnectedCallback(); - // Clean up event listeners when the component is removed this.removeEventListener("click", this.handleClick); this.listContainer?.addEventListener("keydown", this.handleKeyDown); } protected firstUpdated(_changedProperties: PropertyValues): void { - // Add keydown event listener to the list container this.listContainer?.addEventListener("keydown", this.handleKeyDown); } updated(changedProperties: PropertyValues) { if (changedProperties.has("value")) { - // Update the selected item for the preselect this.requestUpdate().then(() => { - this.selectedItemId = `${this.value}`; + this.selectedItemsIds = this.value; this.updateSelectedState(); }); } + if (changedProperties.has("selectAllItems")) { + if (this.selectAllItems) { + this.selectedItemsIds = this.items + .filter((item) => !this.disabledItems.includes(item.id)) + .map((item) => item.id); + this.updateSelectedState(); + this.notifySelectedChange(); + } + } + } + + setCheckboxAttributes(isSelected: boolean, wrapper: HTMLElement) { + if (isSelected && (!wrapper.firstElementChild?.hasAttribute('disabled') || wrapper.firstElementChild?.getAttribute("aria-disabled") !== "true")) { + wrapper.querySelector("md-checkbox")?.setAttribute("checked", "true"); + } else { + wrapper.querySelector("md-checkbox")?.removeAttribute("checked"); + } } updateWrapperAttributes(wrapper: HTMLElement, isSelected: boolean) { - wrapper.classList.toggle("selected", isSelected); + this.isMulti + ? this.setCheckboxAttributes(isSelected, wrapper) + : wrapper.classList.toggle("selected", isSelected); wrapper.setAttribute("selected", isSelected.toString()); wrapper.setAttribute("aria-selected", isSelected.toString()); wrapper.setAttribute("tabindex", isSelected ? "0" : "-1"); @@ -77,10 +98,15 @@ export namespace AdvanceList { protected updateSelectedState() { const wrappers = Array.from(this.shadowRoot?.querySelectorAll(".default-wrapper") || []); wrappers.forEach((wrapper) => { - const isSelected = wrapper.id === `${prefixId}${this.selectedItemId}`; - // update the wrapper attributes + const isSelected = this.selectedItemsIds.some((id) => wrapper.id === `${prefixId}${id}`); this.updateWrapperAttributes(wrapper as HTMLElement, isSelected); + if (this.groupOnMultiSelect && this.value.length > 0 && this.items.length !== this.value.length && wrapper.id === `${prefixId}${this.lastSelectedIdByOrder}`) { + wrapper.classList.add("selected-border-bottom"); + } else { + wrapper.classList.remove("selected-border-bottom"); + } + //active item should be focusable if (wrapper.id === `${prefixId}${this.activeId}`) { wrapper.setAttribute("tabindex", "0"); @@ -90,7 +116,7 @@ export namespace AdvanceList { } // Handle pre-selection of the active item if none is currently active - if (this.activeId == "" && wrapper.id === `${prefixId}${this.value}`) { + if (this.activeId == "" && wrapper.id === `${prefixId}${this.value[0]}`) { wrapper.setAttribute("tabindex", "0"); } @@ -126,66 +152,83 @@ export namespace AdvanceList { isNextElemenentStatusIndicator(index: number) { return (this.isLoading || this.isError) && index === this.items.length - 2; } + handleKeyDown = (event: KeyboardEvent): void => { - switch (event.key) { - case Key.ArrowDown: - { - event.preventDefault(); - this.isUserNavigated = true; - // incase of preselected value - if (this.activeId === "" && this.value) { - this.activeId = this.value; - } - const currentIndex = this.items.findIndex((item) => item.id === this.activeId); - if (currentIndex < this.items.length - 1 && !this.isNextElemenentStatusIndicator(currentIndex)) { - this.scrollIndex = currentIndex + 1; - this.activeId = this.items[this.scrollIndex].id; - } + const { ArrowDown, ArrowUp, Tab, Space, Enter } = Key; + const { code } = event; + const isArrowDown = code === ArrowDown; + const isArrowUp = code === ArrowUp; + const isTab = code === Tab; + const isSpace = code === Space; + const isEnter = code === Enter; + + if (isArrowDown || isArrowUp) { + event.preventDefault(); + this.isUserNavigated = true; + + // In case of preselected value + if (this.activeId === "" && this.value.length > 0) { + this.activeId = this.value[0]; + return; + } else if (this.activeId === "" && !isArrowUp && this.items.length > 0) { + // Only for ArrowDown: Set to first item if no selection + this.activeId = this.items[0].id; + return; + } + + const currentIndex = this.items.findIndex((item) => item.id === this.activeId); + + if (isArrowDown) { + if (currentIndex < this.items.length - 1 && !this.isNextElemenentStatusIndicator(currentIndex)) { + this.scrollIndex = currentIndex + 1; + this.activeId = this.items[this.scrollIndex].id; } - break; - - case Key.ArrowUp: - { - event.preventDefault(); - this.isUserNavigated = true; - // in case of preselected value - if (this.activeId === "" && this.value) { - this.activeId = this.value; - } - const upIndex = this.items.findIndex((item) => item.id === this.activeId); - if (upIndex > 0) { - this.scrollIndex = upIndex - 1; - this.activeId = this.items[this.scrollIndex].id; - } + } else { // isArrowUp + if (currentIndex > 0) { + this.scrollIndex = currentIndex - 1; + this.activeId = this.items[this.scrollIndex].id; } - break; - - case Key.Enter: - { - event.preventDefault(); - if (this.activeId) { - const selectedItem = this.shadowRoot?.querySelector(`#${prefixId}${this.activeId}`); - if (selectedItem) { - const isDisabled = - selectedItem.getAttribute("aria-disabled") === "true" || selectedItem.hasAttribute("disabled"); - if (!isDisabled) { - this.selectItem(selectedItem as HTMLElement); - } - } + } + } else if (isTab) { + if (this.activeId === "" && this.value.length > 0) { + this.activeId = this.value[0]; + } + } else if (isSpace || isEnter) { + event.preventDefault(); + if (this.activeId) { + const selectedItem = this.shadowRoot?.querySelector(`#${prefixId}${this.activeId}`); + if (selectedItem) { + const isDisabled = + selectedItem.getAttribute("aria-disabled") === "true" || + selectedItem.hasAttribute("disabled"); + if (!isDisabled) { + this.updateItemSelection(selectedItem as HTMLElement); } } - break; + } + } + }; - default: - break; + updateItemForMultiSelect(activeId: string) { + const index = this.selectedItemsIds.indexOf(activeId); + if (index === -1) { + this.selectedItemsIds.push(this.activeId); + if (this.selectedItemsIds.length === this.items.length - this.disabledItems.length) { + this.selectAllItems = true; + } + } else { + this.selectedItemsIds.splice(index, 1); + this.selectAllItems = false; } - }; + } - selectItem(clickedItem: HTMLElement) { + updateItemSelection(clickedItem: HTMLElement) { if (!clickedItem) return; this.activeId = clickedItem.id.substring(clickedItem.id.indexOf("-") + 1); - this.selectedItemId = this.activeId; + this.isMulti + ? this.updateItemForMultiSelect(this.activeId) + : (this.selectedItemsIds = [this.activeId]); this.updateSelectedState(); this.notifySelectedChange(); } @@ -196,7 +239,7 @@ export namespace AdvanceList { const clickedItem = this.findClickedItem(event); if (clickedItem) { this.scrollIndex = parseInt(clickedItem.getAttribute("index") || "0"); - this.selectItem(clickedItem); + this.updateItemSelection(clickedItem); } } @@ -219,7 +262,7 @@ export namespace AdvanceList { notifySelectedChange() { this.dispatchEvent( new CustomEvent("list-item-change", { - detail: { selected: this.selectedItemId }, + detail: { selected: this.selectedItemsIds }, bubbles: true, composed: true }) @@ -252,7 +295,7 @@ export namespace AdvanceList { if (this.activeId) { return `${prefixId}${this.activeId}`; } else if (this.value) { - return `${prefixId}${this.value}`; + return `${prefixId}${this.value[0]}`; } else { return ""; } @@ -265,6 +308,7 @@ export namespace AdvanceList { class="md-advance-list-wrapper virtual-scroll" tabindex="0" aria-activedescendant=${this.getActiveDescendant()} + aria-multiselectable=${this.isMulti} aria-label=${this.ariaLabelList} role=${this.ariaRoleList} @rangechange=${this.handleRangeChange} diff --git a/web-components/src/components/advance-list/scss/AdvanceList.scss b/web-components/src/components/advance-list/scss/AdvanceList.scss index 78b4c0119..b4223bf81 100644 --- a/web-components/src/components/advance-list/scss/AdvanceList.scss +++ b/web-components/src/components/advance-list/scss/AdvanceList.scss @@ -79,4 +79,9 @@ .default-wrapper.selected:hover { background-color: var(--list-active-background); color: var(--list-active-text-color); -} \ No newline at end of file +} + +.selected-border-bottom { + border-bottom: 1px solid var(--md-gray-20); +} +