From c29b83c0ad1badc089498b8f4666132e6d495763 Mon Sep 17 00:00:00 2001 From: James Taylor <146064280+TaylorJ76@users.noreply.github.com> Date: Mon, 18 Nov 2024 08:20:26 +0000 Subject: [PATCH] chore(accordion): remove foundation (VIV-2010) (#1996) * chore: wip * chore: remove start-end mixin * chore: updates accordion * chore: format * chore: eslint errors * chore: removes unneeded ignore comments * chore: removes unneeded ignore comments * chore: removed start-end mixin * chore: updates test * chore: fixes single expand code coverage * chore: adds empty accordion test * chore: update tests * fix: allows different items to be set as expanded * Add test for keypress on slotted content * Refactor activeItemIndex handling * Add test for empty setItems() * Add test for bubbled change events --------- Co-authored-by: Richard Helm --- .../accordion-item/accordion-item.template.ts | 2 +- .../src/lib/accordion-item/accordion-item.ts | 64 +++++- .../src/lib/accordion/accordion.spec.ts | 74 ++++++- .../components/src/lib/accordion/accordion.ts | 202 +++++++++++++++++- 4 files changed, 325 insertions(+), 17 deletions(-) diff --git a/libs/components/src/lib/accordion-item/accordion-item.template.ts b/libs/components/src/lib/accordion-item/accordion-item.template.ts index ae58172c41..2d37a25f5a 100644 --- a/libs/components/src/lib/accordion-item/accordion-item.template.ts +++ b/libs/components/src/lib/accordion-item/accordion-item.template.ts @@ -17,7 +17,7 @@ const header = (context: ElementDefinitionContext, hTag: string) => { id="${(x) => x.id}" aria-expanded="${(x) => x.expanded}" aria-controls="${(x) => x.id}-panel" - @click="${(x, c) => x.clickHandler(c.event as MouseEvent)}" + @click="${(x) => x.clickHandler()}" ${ref('expandbutton')} > diff --git a/libs/components/src/lib/accordion-item/accordion-item.ts b/libs/components/src/lib/accordion-item/accordion-item.ts index 02afd113c0..82c02dfdc0 100644 --- a/libs/components/src/lib/accordion-item/accordion-item.ts +++ b/libs/components/src/lib/accordion-item/accordion-item.ts @@ -1,8 +1,5 @@ -import { attr } from '@microsoft/fast-element'; -import { - applyMixins, - AccordionItem as FASTAccordionItem, -} from '@microsoft/fast-foundation'; +import { attr, nullableNumberConverter } from '@microsoft/fast-element'; +import { applyMixins, FoundationElement } from '@microsoft/fast-foundation'; import { AffixIconWithTrailing } from '../../shared/patterns/affix'; import type { Size } from '../enums.js'; @@ -20,7 +17,44 @@ export type AccordionItemSize = Extract; * @slot icon - Add an icon to the component. * @event {CustomEvent} change - Fires a custom 'change' event when the button is invoked */ -export class AccordionItem extends FASTAccordionItem { +export class AccordionItem extends FoundationElement { + /** + * Configures the {@link https://www.w3.org/TR/wai-aria-1.1/#aria-level | level} of the + * heading element. + * + * @defaultValue 2 + * @public + * @remarks + * HTML attribute: heading-level + */ + @attr({ + attribute: 'heading-level', + mode: 'fromView', + converter: nullableNumberConverter, + }) + headinglevel: 1 | 2 | 3 | 4 | 5 | 6 = 2; + + /** + * Expands or collapses the item. + * + * @public + * @remarks + * HTML attribute: expanded + */ + @attr({ mode: 'boolean' }) + expanded = false; + + /** + * The item ID + * + * @public + * @remarks + * HTML Attribute: id + */ + @attr + // @ts-expect-error Type is incorrectly non-optional + id: string; + /** * * @@ -54,6 +88,24 @@ export class AccordionItem extends FASTAccordionItem { * HTML Attribute: size */ @attr size?: AccordionItemSize; + + /** + * @internal + */ + // @ts-expect-error Type is incorrectly non-optional + expandbutton: HTMLElement; + + /** + * @internal + */ + clickHandler = () => { + this.expanded = !this.expanded; + this.change(); + }; + + private change = (): void => { + this.$emit('change'); + }; } export interface AccordionItem extends AffixIconWithTrailing {} diff --git a/libs/components/src/lib/accordion/accordion.spec.ts b/libs/components/src/lib/accordion/accordion.spec.ts index a928f2ed8e..dbfb430def 100644 --- a/libs/components/src/lib/accordion/accordion.spec.ts +++ b/libs/components/src/lib/accordion/accordion.spec.ts @@ -15,6 +15,8 @@ const COMPONENT_HTML = ` `; +const EMPTY_COMPONENT_HTML = `<${COMPONENT_TAG} id="tested">`; + describe('vwc-accordion', () => { function triggerAccordionUpdate() { const newItem = document.createElement( @@ -52,6 +54,21 @@ describe('vwc-accordion', () => { }); }); + describe('empty', () => { + it('should not set the accordionIds property', async () => { + element = (await fixture(EMPTY_COMPONENT_HTML)) as Accordion; + await elementUpdated(element); + expect(element.accordionIds).toBe(undefined); + }); + + it('should handle all accordion items being removed without error', async () => { + expect(() => { + accordionItem1.remove(); + accordionItem2.remove(); + }).not.toThrow(); + }); + }); + describe('expandmode', () => { it('should allow only one accordion item expanded when set to "single"', async () => { element.expandmode = 'single'; @@ -83,7 +100,21 @@ describe('vwc-accordion', () => { expect(accordionItem2.expanded).toBeTruthy(); }); - it('should always open the first accordion-item::DOCUMENTED BUG SHOULD FAIL ONCE FIXED IN FAST! ', async function () { + it('should open the first accordion-item if none of the others are set to expanded', async function () { + element = (await fixture( + `<${COMPONENT_TAG} expand-mode="single"> +

content

+

content

+ ` + )) as Accordion; + await elementUpdated(element); + + expect(element.expandmode).toBe('single'); + expect((element.children[0] as AccordionItem).expanded).toBeTruthy(); + expect((element.children[1] as AccordionItem).expanded).toBeFalsy(); + }); + + it('should open the accordion-item with expanded set', async function () { element = (await fixture( `<${COMPONENT_TAG} expand-mode="single">

content

@@ -92,6 +123,20 @@ describe('vwc-accordion', () => { )) as Accordion; await elementUpdated(element); + expect(element.expandmode).toBe('single'); + expect((element.children[0] as AccordionItem).expanded).toBeFalsy(); + expect((element.children[1] as AccordionItem).expanded).toBeTruthy(); + }); + + it('should open the first accordion-item with expanded set', async function () { + element = (await fixture( + `<${COMPONENT_TAG} expand-mode="single"> +

content

+

content

+ ` + )) as Accordion; + await elementUpdated(element); + expect(element.expandmode).toBe('single'); expect((element.children[0] as AccordionItem).expanded).toBeTruthy(); expect((element.children[1] as AccordionItem).expanded).toBeFalsy(); @@ -178,6 +223,18 @@ describe('vwc-accordion', () => { ); expect(accordionItem2.contains(document.activeElement)).toBeTruthy(); }); + + it('should ignore key presses on accordion item slotted content', async () => { + const button = document.createElement('button'); + accordionItem1.appendChild(button); + button.focus(); + + button.dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true }) + ); + + expect(document.activeElement).toBe(button); + }); }); describe('accordion-item focus', () => { @@ -199,6 +256,15 @@ describe('vwc-accordion', () => { }); }); + it('should ignore change events bubbled up from slotted accordion item content', () => { + const input = document.createElement('button'); + accordionItem2.appendChild(input); + + input.dispatchEvent(new Event('change', { bubbles: true })); + + expect(element.activeid).toBe('item1'); + }); + describe('a11y', () => { it('should pass HTML a11y test', async () => { expect(await axe(element)).toHaveNoViolations(); @@ -207,11 +273,11 @@ describe('vwc-accordion', () => { it('should set aria-disabled on active item in single mode', async function () { element = (await fixture(` <${COMPONENT_TAG} id="tested"> -

content

-

content

+

content

+

content

`)) as Accordion; await elementUpdated(element); - accordionItem1 = element.querySelector('#item1') as AccordionItem; + accordionItem1 = element.querySelector('#item2') as AccordionItem; expect(accordionItem1.hasAttribute('aria-disabled')).toBe(true); }); diff --git a/libs/components/src/lib/accordion/accordion.ts b/libs/components/src/lib/accordion/accordion.ts index 4c3fa300a0..eae0785060 100644 --- a/libs/components/src/lib/accordion/accordion.ts +++ b/libs/components/src/lib/accordion/accordion.ts @@ -1,8 +1,36 @@ +import { attr, observable } from '@microsoft/fast-element'; import { - AccordionExpandMode, - Accordion as FastAccordion, -} from '@microsoft/fast-foundation'; -import type { AccordionItem } from '../accordion-item/accordion-item'; + keyArrowDown, + keyArrowUp, + keyEnd, + keyHome, + wrapInBounds, +} from '@microsoft/fast-web-utilities'; +import { FoundationElement } from '@microsoft/fast-foundation'; +import { AccordionItem } from '../accordion-item/accordion-item'; + +/** + * Expand mode for {@link Accordion} + * @public + */ +export const AccordionExpandMode = { + /** + * Designates only a single {@link @microsoft/fast-foundation#(AccordionItem:class) } can be open a time. + */ + single: 'single', + + /** + * Designates multiple {@link @microsoft/fast-foundation#(AccordionItem:class) | AccordionItems} can be open simultaneously. + */ + multi: 'multi', +} as const; + +/** + * Type for the {@link Accordion} Expand Mode + * @public + */ +export type AccordionExpandMode = + typeof AccordionExpandMode[keyof typeof AccordionExpandMode]; /** * @public @@ -10,7 +38,7 @@ import type { AccordionItem } from '../accordion-item/accordion-item'; * @event {CustomEvent} change - Fires a custom 'change' event when the active item changes * @slot - Default slot. */ -export class Accordion extends FastAccordion { +export class Accordion extends FoundationElement { /** * Controls the expand mode of the Accordion, either allowing * single or multiple item expansion. @@ -19,7 +47,169 @@ export class Accordion extends FastAccordion { * @remarks * HTML attribute: expand-mode */ - override expandmode: AccordionExpandMode = AccordionExpandMode.single; + @attr({ attribute: 'expand-mode' }) + /* eslint-disable-next-line @nrwl/nx/workspace/no-attribute-default-value */ + expandmode: AccordionExpandMode = AccordionExpandMode.single; + + /** + * @internal + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + accordionItems: HTMLElement[]; + + /** + * @internal + */ + accordionItemsChanged(oldValue: HTMLElement[]): void { + if (this.$fastController.isConnected) { + this.removeItemListeners(oldValue); + this.setItems(); + } + } + + // @ts-expect-error Type is incorrectly non-optional + activeid: string | null; + activeItemIndex = 0; + // @ts-expect-error Type is incorrectly non-optional + accordionIds: Array; + + private change = (): void => { + this.$emit('change', this.activeid); + }; + + private findExpandedItem(): AccordionItem | null { + for (let item = 0; item < this.accordionItems.length; item++) { + if (this.accordionItems[item].hasAttribute('expanded') === true) { + return this.accordionItems[item] as AccordionItem; + } + } + return null; + } + + private setItems = (): void => { + if (this.accordionItems.length === 0) { + return; + } + this.accordionIds = this.getItemIds(); + this.activeid = this.accordionIds[this.activeItemIndex] as string; + this.accordionItems.forEach((item: HTMLElement, index: number) => { + if (item instanceof AccordionItem) { + item.addEventListener('change', this.activeItemChange); + if (this.isSingleExpandMode()) { + const expandedItem = this.findExpandedItem(); + if (expandedItem === null && index === 0) { + item.expanded = true; + } else { + item !== this.findExpandedItem() + ? (item.expanded = false) + : (item.expanded = true); + } + } + } + const itemId: string | null = this.accordionIds[index]; + item.setAttribute( + 'id', + typeof itemId !== 'string' ? `accordion-${index + 1}` : itemId + ); + item.addEventListener('keydown', this.handleItemKeyDown); + }); + if (this.isSingleExpandMode()) { + const expandedItem: AccordionItem | null = + this.findExpandedItem() ?? (this.accordionItems[0] as AccordionItem); + expandedItem.setAttribute('aria-disabled', 'true'); + } + }; + + private resetItems(): void { + this.accordionItems.forEach((item: any) => { + item.expanded = false; + }); + } + + private removeItemListeners = (oldValue: any): void => { + oldValue.forEach((item: HTMLElement) => { + item.removeEventListener('change', this.activeItemChange); + item.removeEventListener('keydown', this.handleItemKeyDown); + }); + }; + + private activeItemChange = (event: Event): void => { + if (event.defaultPrevented || event.target !== event.currentTarget) { + return; + } + + event.preventDefault(); + const selectedItem = event.target as AccordionItem; + + this.activeid = selectedItem.getAttribute('id'); + if (this.isSingleExpandMode()) { + this.resetItems(); + selectedItem.expanded = true; + selectedItem.setAttribute('aria-disabled', 'true'); + this.accordionItems.forEach((item: HTMLElement) => { + if (!item.hasAttribute('disabled') && item.id !== this.activeid) { + item.removeAttribute('aria-disabled'); + } + }); + } + this.activeItemIndex = Array.from(this.accordionItems).indexOf( + selectedItem + ); + this.change(); + }; + + private getItemIds(): Array { + return this.accordionItems.map((accordionItem: HTMLElement) => { + return accordionItem.getAttribute('id'); + }); + } + + private isSingleExpandMode(): boolean { + return this.expandmode !== AccordionExpandMode.multi; + } + + private handleItemKeyDown = (event: KeyboardEvent): void => { + // only handle the keydown if the event target is the accordion item + // prevents arrow keys from moving focus to accordion headers when focus is on accordion item panel content + if (event.target !== event.currentTarget) { + return; + } + this.accordionIds = this.getItemIds(); + switch (event.key) { + case keyArrowUp: + event.preventDefault(); + this.adjust(event.target as AccordionItem, -1); + break; + case keyArrowDown: + event.preventDefault(); + this.adjust(event.target as AccordionItem, 1); + break; + case keyHome: + this.focusItem(0); + break; + case keyEnd: + this.focusItem(this.accordionItems.length - 1); + break; + } + }; + + private adjust(item: AccordionItem, adjustment: number): void { + this.focusItem( + wrapInBounds( + 0, + this.accordionItems.length - 1, + this.accordionItems.indexOf(item) + adjustment + ) + ); + } + + private focusItem(index: number): void { + const element: HTMLElement = this.accordionItems[index]; + if (element instanceof AccordionItem) { + element.expandbutton.focus(); + } + } closeAll(): void { if (this.expandmode === AccordionExpandMode.multi) {