From c9b67e348c96c327b4b6119d9b9c8fc08bcdbdd3 Mon Sep 17 00:00:00 2001 From: Richard Helm <86777660+RichardHelm@users.noreply.github.com> Date: Mon, 25 Nov 2024 16:26:20 +0000 Subject: [PATCH] chore(menu, menu-item): decouple from FAST Foundation (VIV-2021) (#2026) * Decouple menu from FAST Foundation * Fix linter warning --- .../src/lib/menu-item/menu-item.spec.ts | 8 + .../components/src/lib/menu-item/menu-item.ts | 213 ++++++++++- libs/components/src/lib/menu/menu.spec.ts | 220 +++++++++-- libs/components/src/lib/menu/menu.ts | 348 ++++++++++++++++-- 4 files changed, 710 insertions(+), 79 deletions(-) diff --git a/libs/components/src/lib/menu-item/menu-item.spec.ts b/libs/components/src/lib/menu-item/menu-item.spec.ts index e595f974ac..c3708bec12 100644 --- a/libs/components/src/lib/menu-item/menu-item.spec.ts +++ b/libs/components/src/lib/menu-item/menu-item.spec.ts @@ -341,6 +341,14 @@ describe('vwc-menu-item', () => { expect(changeSpy).toHaveBeenCalled(); }); + it('should not fire "change" event on click when disabled', async () => { + element.disabled = true; + + element.click(); + + expect(changeSpy).not.toHaveBeenCalled(); + }); + it('should fire "change" event on spacebar press', async () => { pressKey(keySpace); diff --git a/libs/components/src/lib/menu-item/menu-item.ts b/libs/components/src/lib/menu-item/menu-item.ts index c3592bcb55..ad9b0c8950 100644 --- a/libs/components/src/lib/menu-item/menu-item.ts +++ b/libs/components/src/lib/menu-item/menu-item.ts @@ -1,10 +1,12 @@ -import { attr, observable } from '@microsoft/fast-element'; +import { attr, DOM, observable } from '@microsoft/fast-element'; import { + AnchoredRegion, applyMixins, - MenuItem as FastMenuItem, MenuItemRole as FastMenuItemRole, + FoundationElement, + getDirection, } from '@microsoft/fast-foundation'; -import { keyEnter, keySpace } from '@microsoft/fast-web-utilities'; +import { Direction, keyEnter, keySpace } from '@microsoft/fast-web-utilities'; import { keyArrowLeft, keyArrowRight, @@ -18,10 +20,13 @@ export const MenuItemRole = { presentation: 'presentation', } as const; +export type MenuItemRole = typeof MenuItemRole[keyof typeof MenuItemRole]; + export enum CheckAppearance { Normal = 'normal', TickOnly = 'tick-only', } + /** * Types of fab connotation. * @@ -42,7 +47,194 @@ export type MenuItemConnotation = Extract< * @event {CustomEvent} change - Fires a custom 'change' event when a non-submenu item with a role of `menuitemcheckbox`, `menuitemradio`, or `menuitem` is invoked * @vueModel modelValue checked change `(event.target as HTMLInputElement).checked` */ -export class MenuItem extends FastMenuItem { +export class MenuItem extends FoundationElement { + /** + * The disabled state of the element. + * + * @public + * @remarks + * HTML Attribute: disabled + */ + @attr({ mode: 'boolean' }) + disabled!: boolean; + + /** + * The expanded state of the element. + * + * @public + * @remarks + * HTML Attribute: expanded + */ + @attr({ mode: 'boolean' }) + expanded!: boolean; + + /** + * @internal + */ + expandedChanged() { + if (this.$fastController.isConnected) { + if (this.submenu === undefined) { + return; + } + if (this.expanded === false) { + (this.submenu as Menu).collapseExpandedItem(); + } else { + this.currentDirection = getDirection(this); + } + this.$emit('expanded-change', this, { bubbles: false }); + } + } + + /** + * The role of the element. + * + * @public + * @remarks + * HTML Attribute: role + */ + @attr + // eslint-disable-next-line @nrwl/nx/workspace/no-attribute-default-value + override role: MenuItemRole = MenuItemRole.menuitem; + + /** + * The checked value of the element. + * + * @public + * @remarks + * HTML Attribute: checked + */ + @attr({ mode: 'boolean' }) + checked!: boolean; + + /** + * @internal + */ + checkedChanged() { + if (this.$fastController.isConnected) { + this.$emit('change'); + } + } + + /** + * reference to the anchored region + * + * @internal + */ + @observable + submenuRegion!: AnchoredRegion; + + /** + * @internal + */ + @observable + hasSubmenu = false; + + /** + * Track current direction to pass to the anchored region + * + * @internal + */ + @observable + currentDirection: Direction = Direction.ltr; + + /** + * @internal + */ + @observable + submenu: Element | undefined; + + private observer: MutationObserver | undefined; + + /** + * @internal + */ + override connectedCallback(): void { + super.connectedCallback(); + DOM.queueUpdate(() => { + this.updateSubmenu(); + }); + + this.observer = new MutationObserver(this.updateSubmenu); + } + + /** + * @internal + */ + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.submenu = undefined; + if (this.observer !== undefined) { + this.observer.disconnect(); + this.observer = undefined; + } + } + + /** + * @internal + */ + handleMenuItemClick = (e: MouseEvent): boolean => { + if (e.defaultPrevented || this.disabled) { + return false; + } + + this.invoke(); + return false; + }; + + /** + * @internal + */ + handleMouseOver = (_: MouseEvent): boolean => { + if (this.disabled || !this.hasSubmenu || this.expanded) { + return false; + } + + this.expanded = true; + + return false; + }; + + /** + * @internal + */ + handleMouseOut = (_: MouseEvent): boolean => { + if (!this.expanded || this.contains(document.activeElement)) { + return false; + } + + this.expanded = false; + + return false; + }; + + private invoke = () => { + if (this.disabled) { + return; + } + + switch (this.role) { + case MenuItemRole.menuitemcheckbox: + this.checked = !this.checked; + break; + + case MenuItemRole.menuitem: + // update submenu + this.updateSubmenu(); + if (this.hasSubmenu) { + this.expanded = true; + } else { + this.$emit('change'); + } + break; + + case MenuItemRole.menuitemradio: + if (!this.checked) { + this.checked = true; + } + break; + } + }; + /** * Indicates the menu item's text. * @@ -110,12 +302,10 @@ export class MenuItem extends FastMenuItem { constructor() { super(); - (this as any).updateSubmenu = () => this.#updateSubmenu(); this.addEventListener('expanded-change', this.#expandedChange); - (this as any).handleMenuItemKeyDown = this.#handleMenuItemKeyDown; } - #updateSubmenu() { + private updateSubmenu() { for (const submenu of this.#submenuArray) { this.submenu = submenu as Menu; (this.submenu as Menu).anchor = this as MenuItem; @@ -137,7 +327,10 @@ export class MenuItem extends FastMenuItem { } } - #handleMenuItemKeyDown = (e: KeyboardEvent): boolean => { + /** + * @internal + */ + handleMenuItemKeyDown = (e: KeyboardEvent): boolean => { if (e.defaultPrevented) { return false; } @@ -145,7 +338,7 @@ export class MenuItem extends FastMenuItem { switch (e.key) { case keyEnter: case keySpace: - (this as any).invoke(); + this.invoke(); if (!this.disabled) { this.#emitSyntheticClick(); } @@ -153,8 +346,8 @@ export class MenuItem extends FastMenuItem { case keyArrowRight: //open/focus on submenu - (this as any).expandAndFocus(); if (this.hasSubmenu) { + this.expanded = true; this.#emitSyntheticClick(); } return false; diff --git a/libs/components/src/lib/menu/menu.spec.ts b/libs/components/src/lib/menu/menu.spec.ts index 119f875988..37c580cd1d 100644 --- a/libs/components/src/lib/menu/menu.spec.ts +++ b/libs/components/src/lib/menu/menu.spec.ts @@ -6,12 +6,19 @@ import { getBaseElement, } from '@vivid-nx/shared'; import { FoundationElementRegistry } from '@microsoft/fast-foundation'; -import { keyArrowDown, keyArrowUp } from '@microsoft/fast-web-utilities'; +import { + keyArrowDown, + keyArrowUp, + keyEnd, + keyHome, +} from '@microsoft/fast-web-utilities'; import type { Button } from '../button/button'; import type { Popup } from '../popup/popup.ts'; +import { type MenuItem } from '../menu-item/menu-item.ts'; import { Menu } from './menu'; import { menuDefinition } from './definition'; import '.'; +import '../menu-item'; const COMPONENT_TAG = 'vwc-menu'; @@ -20,6 +27,16 @@ describe('vwc-menu', () => { let popup: Popup; let anchor: Button; + function pressKey(key: string) { + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }) + ); + } + beforeEach(async () => { element = fixture(`<${COMPONENT_TAG}>`) as Menu; popup = element.shadowRoot?.querySelector('vwc-popup') as Popup; @@ -238,9 +255,9 @@ describe('vwc-menu', () => { element.appendChild(div); return div; } + it('should focus the first menuitem in the menu', async () => { const div = createMenuItem(); - await elementUpdated(element); element.focus(); @@ -250,7 +267,6 @@ describe('vwc-menu', () => { it('should set menu item tabindex to 0', async () => { const menuItem = createMenuItem(); - await elementUpdated(element); element.focus(); @@ -278,36 +294,85 @@ describe('vwc-menu', () => { expect(document.activeElement).toEqual(div); }); - it('should handle menu key down events', async () => { - function keyMoveAndGetActiveId(arrowEvent: Event) { - document.activeElement?.dispatchEvent(arrowEvent); - return document.activeElement?.id; - } - element.innerHTML = ` - - - - `; + describe('keyboard navigation', () => { + let item1: HTMLElement; + let item2: HTMLElement; + let item3: HTMLElement; + beforeEach(async () => { + item1 = createMenuItem(); + item2 = createMenuItem(); + item3 = createMenuItem(); + await elementUpdated(element); + }); - await elementUpdated(element); + it('should move focus to the next menuitem when pressing arrow down', async () => { + item1.focus(); - element.focus(); + pressKey(keyArrowDown); + + expect(document.activeElement).toBe(item2); + }); - const activeIdAfterKeyDown1 = keyMoveAndGetActiveId(arrowDownEvent); - const activeIdAfterKeyDown2 = keyMoveAndGetActiveId(arrowDownEvent); - const activeIdAfterKeyUp1 = keyMoveAndGetActiveId(arrowUpEvent); - const activeIdAfterKeyUp2 = keyMoveAndGetActiveId(arrowUpEvent); + it('should move focus to the previous menuitem when pressing arrow up', async () => { + item3.focus(); - expect(activeIdAfterKeyDown1).toEqual('id2'); - expect(activeIdAfterKeyDown2).toEqual('id3'); - expect(activeIdAfterKeyUp1).toEqual('id2'); - expect(activeIdAfterKeyUp2).toEqual('id1'); + pressKey(keyArrowUp); + + expect(document.activeElement).toBe(item2); + }); + + it('should move focus to the last menuitem when pressing end', async () => { + item1.focus(); + + pressKey(keyEnd); + + expect(document.activeElement).toBe(item3); + }); + + it('should move focus to the first menuitem when pressing home', async () => { + item3.focus(); + + pressKey(keyHome); + + expect(document.activeElement).toBe(item1); + }); + + it('should not prevent default of other keydown events', () => { + const keydownSpy = jest.fn(); + element.addEventListener('keydown', keydownSpy); + item1.focus(); + + pressKey('A'); + + expect(keydownSpy).toHaveBeenCalledWith( + expect.objectContaining({ defaultPrevented: false }) + ); + }); + + it('should ignore non-focusable elements', async () => { + element.insertBefore(document.createElement('div'), item3); + await elementUpdated(element); + item2.focus(); + + pressKey(keyArrowDown); + + expect(document.activeElement).toBe(item3); + }); + + it('should ignore cancelled keypress events', async () => { + item1.addEventListener('keydown', (e) => e.preventDefault()); + item1.focus(); + + pressKey(keyArrowDown); + + expect(document.activeElement).toBe(item1); + }); }); it('should reset tabindex to the first element on focusout event', async () => { function focusOnSecondItem() { element.focus(); - document.activeElement?.dispatchEvent(arrowDownEvent); + pressKey(keyArrowDown); } const menuFocusedElement = () => @@ -348,6 +413,12 @@ describe('vwc-menu', () => { expect(anchor.hasAttribute('tabindex')).toBe(false); expect(child1.getAttribute('tabindex')).toBe('-1'); }); + + it('should not throw when called in disconnected state', async () => { + element.remove(); + + expect(() => element.focus()).not.toThrow(); + }); }); describe('anchor', () => { @@ -579,15 +650,100 @@ describe('vwc-menu', () => { }); }); - const arrowUpEvent = new KeyboardEvent('keydown', { - key: keyArrowUp, - bubbles: true, - } as KeyboardEventInit); + describe('radio items', () => { + it('should uncheck other unseparated radiomenuitems when one is checked', async () => { + element.innerHTML = ` + +
+ + + +
+ + `; + await elementUpdated(element); + const menuItem = (id: string) => + element.querySelector(`#${id}`) as MenuItem; + + menuItem('id3').checked = true; + + expect(menuItem('id1').checked).toBe(true); + expect(menuItem('id2').checked).toBe(false); + expect(menuItem('id3').checked).toBe(true); + expect(menuItem('id4').checked).toBe(false); + expect(menuItem('id5').checked).toBe(true); + }); + + it('should ignore radiomenuitems outside of default slot', async () => { + const headerItem = document.createElement('vwc-menu-item') as MenuItem; + headerItem.role = 'menuitemradio'; + headerItem.slot = 'header'; + element.appendChild(headerItem); + const item = document.createElement('vwc-menu-item') as MenuItem; + item.role = 'menuitemradio'; + item.checked = true; + element.appendChild(item); + await elementUpdated(element); + + headerItem.checked = true; + + expect(item.checked).toBe(true); + }); + }); - const arrowDownEvent = new KeyboardEvent('keydown', { - key: keyArrowDown, - bubbles: true, - } as KeyboardEventInit); + describe('submenu', () => { + let item1: MenuItem; + let item2: MenuItem; + beforeEach(async () => { + element.innerHTML = ` + + + + + + + + + + + `; + item1 = element.querySelector('#item1') as MenuItem; + item2 = element.querySelector('#item2') as MenuItem; + await elementUpdated(element); + }); + + it('should collapse the expanded submenu when calling collapseExpandedItem()', async () => { + item1.expanded = true; + await elementUpdated(element); + + element.collapseExpandedItem(); + + expect(item1.expanded).toBe(false); + }); + + it('should collapse other submenus when one expands', async () => { + item1.expanded = true; + await elementUpdated(element); + + item2.expanded = true; + await elementUpdated(element); + + expect(item1.expanded).toBe(false); + }); + + it('should not collapse other submenus when expanded-change event is cancelled', async () => { + item1.expanded = true; + await elementUpdated(element); + item2.addEventListener('expanded-change', (e) => e.preventDefault(), { + capture: true, + }); + + item2.expanded = true; + await elementUpdated(element); + + expect(item1.expanded).toBe(true); + }); + }); function focusOutOfBody() { const focusOutEvent = new FocusEvent('focusout'); diff --git a/libs/components/src/lib/menu/menu.ts b/libs/components/src/lib/menu/menu.ts index a19a2a1140..05721185ef 100644 --- a/libs/components/src/lib/menu/menu.ts +++ b/libs/components/src/lib/menu/menu.ts @@ -1,7 +1,15 @@ import { attr, DOM, observable } from '@microsoft/fast-element'; -import { Menu as FastMenu } from '@microsoft/fast-foundation'; +import { FoundationElement, roleForMenuItem } from '@microsoft/fast-foundation'; import type { Placement, Strategy } from '@floating-ui/dom'; +import { + isHTMLElement, + keyArrowDown, + keyArrowUp, + keyEnd, + keyHome, +} from '@microsoft/fast-web-utilities'; import { type Anchored, anchored } from '../../shared/patterns/anchored'; +import { MenuItem, MenuItemRole } from '../menu-item/menu-item'; /** * @public @@ -14,7 +22,276 @@ import { type Anchored, anchored } from '../../shared/patterns/anchored'; * @event {CustomEvent} close - Fired when the menu is closed */ @anchored -export class Menu extends FastMenu { +export class Menu extends FoundationElement { + /** + * @internal + */ + @observable + items!: HTMLSlotElement; + + /** + * @internal + */ + itemsChanged() { + // only update children after the component is connected and + // the setItems has run on connectedCallback + // (menuItems is undefined until then) + if (this.$fastController.isConnected && this.menuItems !== undefined) { + this.setItems(); + } + } + + private menuItems: Element[] | undefined; + + private expandedItem: MenuItem | null = null; + + /** + * The index of the focusable element in the items array + * defaults to -1 + */ + private focusIndex = -1; + + private static focusableElementRoles: { [key: string]: string } = + roleForMenuItem; + + /** + * @internal + */ + override connectedCallback() { + super.connectedCallback(); + DOM.queueUpdate(() => { + // wait until children have had a chance to + // connect before setting/checking their props/attributes + this.setItems(); + }); + } + + /** + * @internal + */ + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeItemListeners(); + this.menuItems = undefined; + } + + /** + * Focuses the first item in the menu. + * + * @public + */ + override focus(): void { + this.setFocus(0, 1); + } + + /** + * Collapses any expanded menu items. + * + * @public + */ + collapseExpandedItem(): void { + if (this.expandedItem !== null) { + this.expandedItem.expanded = false; + this.expandedItem = null; + } + } + + /** + * @internal + */ + handleMenuKeyDown(e: KeyboardEvent): void | boolean { + if (e.defaultPrevented || this.menuItems === undefined) { + return; + } + switch (e.key) { + case keyArrowDown: + // go forward one index + this.setFocus(this.focusIndex + 1, 1); + return; + case keyArrowUp: + // go back one index + this.setFocus(this.focusIndex - 1, -1); + return; + case keyEnd: + // set focus on last item + this.setFocus(this.menuItems.length - 1, -1); + return; + case keyHome: + // set focus on first item + this.setFocus(0, 1); + return; + + default: + // if we are not handling the event, do not prevent default + return true; + } + } + + /** + * if focus is moving out of the menu, reset to a stable initial state + * @internal + */ + handleFocusOut = (e: FocusEvent) => { + if ( + !this.contains(e.relatedTarget as Element) && + this.menuItems !== undefined && + this.menuItems.length + ) { + this.collapseExpandedItem(); + // find our first focusable element + const focusIndex: number = this.menuItems.findIndex( + this.isFocusableElement + ); + // set the current focus index's tabindex to -1 + this.menuItems[this.focusIndex].setAttribute('tabindex', '-1'); + // set the first focusable element tabindex to 0 + this.menuItems[focusIndex].setAttribute('tabindex', '0'); + // set the focus index + this.focusIndex = focusIndex; + } + }; + + private handleItemFocus = (e: FocusEvent) => { + const targetItem: HTMLElement = e.target as HTMLElement; + + if ( + this.menuItems !== undefined && + targetItem !== this.menuItems[this.focusIndex] + ) { + this.menuItems[this.focusIndex].setAttribute('tabindex', '-1'); + this.focusIndex = this.menuItems.indexOf(targetItem); + targetItem.setAttribute('tabindex', '0'); + } + }; + + private handleExpandedChanged = (e: Event): void => { + if ( + e.defaultPrevented || + e.target === null || + this.menuItems === undefined || + this.menuItems.indexOf(e.target as Element) < 0 + ) { + return; + } + + e.preventDefault(); + const changedItem = e.target as MenuItem; + + // closing an expanded item without opening another + if ( + this.expandedItem !== null && + changedItem === this.expandedItem && + changedItem.expanded === false + ) { + this.expandedItem = null; + return; + } + + if (changedItem.expanded) { + if (this.expandedItem !== null && this.expandedItem !== changedItem) { + this.expandedItem.expanded = false; + } + this.menuItems[this.focusIndex].setAttribute('tabindex', '-1'); + this.expandedItem = changedItem; + this.focusIndex = this.menuItems.indexOf(changedItem); + changedItem.setAttribute('tabindex', '0'); + } + }; + + private removeItemListeners = (): void => { + if (this.menuItems !== undefined) { + this.menuItems.forEach((item) => { + item.removeEventListener('expanded-change', this.handleExpandedChanged); + item.removeEventListener( + 'focus', + this.handleItemFocus as EventListener + ); + }); + } + }; + + private setItems = () => { + const newItems = this.domChildren(); + + this.removeItemListeners(); + this.menuItems = newItems; + + const menuItems = this.menuItems.filter(this.isMenuItemElement); + + // if our focus index is not -1 we have items + if (menuItems.length) { + this.focusIndex = 0; + } + + menuItems.forEach((item: HTMLElement, index: number) => { + item.setAttribute('tabindex', index === 0 ? '0' : '-1'); + item.addEventListener('expanded-change', this.handleExpandedChanged); + item.addEventListener('focus', this.handleItemFocus); + }); + }; + + /** + * get an array of valid DOM children + */ + private domChildren(): Element[] { + return Array.from(this.children) + .filter((child) => !child.hasAttribute('hidden')) + .filter((child) => !child.hasAttribute('slot')); + } + + /** + * check if the item is a menu item + */ + private isMenuItemElement = (el: Element): el is HTMLElement => { + return ( + isHTMLElement(el) && + Object.prototype.hasOwnProperty.call( + Menu.focusableElementRoles, + el.getAttribute('role') as string + ) + ); + }; + + /** + * check if the item is focusable + */ + private isFocusableElement = (el: Element): el is HTMLElement => { + return this.isMenuItemElement(el); + }; + + private setFocus(focusIndex: number, adjustment: number): void { + if (this.menuItems === undefined) { + return; + } + + while (focusIndex >= 0 && focusIndex < this.menuItems.length) { + const child: Element = this.menuItems[focusIndex]; + + if (this.isFocusableElement(child)) { + // change the previous index to -1 + if ( + this.focusIndex > -1 && + this.menuItems.length >= this.focusIndex - 1 + ) { + this.menuItems[this.focusIndex].setAttribute('tabindex', '-1'); + } + + // update the focus index + this.focusIndex = focusIndex; + + // update the tabindex of next focusable element + child.setAttribute('tabindex', '0'); + + // focus the element + child.focus(); + + break; + } + + focusIndex += adjustment; + } + } + @attr({ attribute: 'aria-label' }) override ariaLabel: string | null = null; /** @@ -69,41 +346,6 @@ export class Menu extends FastMenu { } } - constructor() { - super(); - - const handleFocusOut = this.handleFocusOut; - this.handleFocusOut = (e: FocusEvent) => { - /** - * Fast menu doesn't support having arbitrary elements in the menu and handleFocusOut will throw if there are - * no menuitem children. Therefore, we need to skip calling it in that case. - */ - const privates = this as unknown as { - menuItems: Element[] | undefined; - isFocusableElement: (el: Element) => el is HTMLElement; - }; - const isSafeToCallSuper = privates.menuItems!.some( - privates.isFocusableElement - ); - if (!isSafeToCallSuper) { - return; - } - - handleFocusOut(e); - }; - - // Override Fast's domChildren method to filter out slotted elements like anchor - const privates = this as unknown as { - domChildren(): HTMLElement[]; - }; - const domChildren = privates.domChildren; - privates.domChildren = () => { - return domChildren - .call(this) - .filter((child) => !child.hasAttribute('slot')); - }; - } - /** * @internal */ @@ -165,6 +407,38 @@ export class Menu extends FastMenu { this.open = false; } + const changedMenuItem = e.target as MenuItem; + const changeItemIndex = this.menuItems!.indexOf(changedMenuItem); + + if (changeItemIndex === -1) { + return; + } + + if (changedMenuItem.role === 'menuitemradio' && changedMenuItem.checked) { + // Uncheck all other radio boxes + for (let i = changeItemIndex - 1; i >= 0; --i) { + const item = this.menuItems![i]; + const role: string | null = item.getAttribute('role'); + if (role === MenuItemRole.menuitemradio) { + (item as MenuItem).checked = false; + } + if (role === 'separator') { + break; + } + } + const maxIndex = this.menuItems!.length - 1; + for (let i = changeItemIndex + 1; i <= maxIndex; ++i) { + const item = this.menuItems![i]; + const role = item.getAttribute('role'); + if (role === MenuItemRole.menuitemradio) { + (item as MenuItem).checked = false; + } + if (role === 'separator') { + break; + } + } + } + return true; }