From 884c3a204b0302434497945443008657ebaf0d7d Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Thu, 4 Aug 2022 14:24:47 -0700 Subject: [PATCH] feat(menusurface): Add `flipMenuHorizontally` property, add unit tests. PiperOrigin-RevId: 465404408 --- menu/lib/menu.ts | 8 +- menusurface/lib/constants.ts | 8 +- menusurface/lib/foundation.ts | 6 +- menusurface/lib/menu-surface.ts | 42 +++----- menusurface/menu-surface_test.ts | 161 +++++++++++++++++++++++++++++++ testing/events.ts | 21 ++++ 6 files changed, 209 insertions(+), 37 deletions(-) create mode 100644 menusurface/menu-surface_test.ts create mode 100644 testing/events.ts diff --git a/menu/lib/menu.ts b/menu/lib/menu.ts index 13ed347177..feeda581ee 100644 --- a/menu/lib/menu.ts +++ b/menu/lib/menu.ts @@ -17,7 +17,7 @@ import {property, query} from 'lit/decorators'; import {List} from '../../list/lib/list'; import {ListItem} from '../../list/lib/listitem/list-item'; -import {Corner, MenuCorner, MenuSurface} from '../../menusurface/lib/menu-surface'; +import {Corner, MenuSurface} from '../../menusurface/lib/menu-surface'; import {MDCMenuAdapter} from './adapter'; import {DefaultFocusState as DefaultFocusStateEnum} from './constants'; @@ -54,7 +54,7 @@ export abstract class Menu extends LitElement { // TODO(b/240174946): Add aria-label support. // @property({type: String}) ariaLabel: string|null = null; - @property({type: String}) corner: Corner = 'TOP_START'; + @property({type: String}) corner: Corner = 'BOTTOM_START'; @property({type: Number}) x: number|null = null; @@ -70,7 +70,7 @@ export abstract class Menu extends LitElement { @property({type: Boolean}) fullwidth = false; - @property({type: String}) menuCorner: MenuCorner = 'START'; + @property({type: Boolean}) flipMenuHorizontally = false; @property({type: Boolean}) stayOpenOnBodyClick: boolean = false; @@ -126,7 +126,7 @@ export abstract class Menu extends LitElement { .absolute=${this.absolute} .fixed=${this.fixed} .fullwidth=${this.fullwidth} - .menuCorner=${this.menuCorner} + .flipMenuHorizontally=${this.flipMenuHorizontally} ?stayOpenOnBodyClick=${this.stayOpenOnBodyClick} class="md3-menu md3-menu-surface" @closed=${this.onClosed} diff --git a/menusurface/lib/constants.ts b/menusurface/lib/constants.ts index e58fc34b66..c3a3992ce4 100644 --- a/menusurface/lib/constants.ts +++ b/menusurface/lib/constants.ts @@ -63,10 +63,10 @@ const numbers = { * Enum for bits in the {@see Corner) bitmap. */ enum CornerBit { - BOTTOM = 1, - CENTER = 2, - RIGHT = 4, - FLIP_RTL = 8, + BOTTOM = 1, // 0001 + CENTER = 2, // 0010 + RIGHT = 4, // 0100 + FLIP_RTL = 8, // 1000 } /** diff --git a/menusurface/lib/foundation.ts b/menusurface/lib/foundation.ts index 092b11a804..15f8e7ef43 100644 --- a/menusurface/lib/foundation.ts +++ b/menusurface/lib/foundation.ts @@ -139,7 +139,7 @@ export class MDCMenuSurfaceFoundation { } /** - * Flip menu corner horizontally. + * Flips menu corner horizontally. */ flipCornerHorizontally() { this.originCorner = this.originCorner ^ CornerBit.RIGHT; @@ -313,7 +313,7 @@ export class MDCMenuSurfaceFoundation { // Compute measurements for autoposition methods reuse. this.measurements = this.getAutoLayoutmeasurements(); - const corner = this.getoriginCorner(); + const corner = this.getOriginCorner(); const maxMenuSurfaceHeight = this.getMenuSurfaceMaxHeight(corner); const verticalAlignment = this.hasBit(corner, CornerBit.BOTTOM) ? 'bottom' : 'top'; @@ -399,7 +399,7 @@ export class MDCMenuSurfaceFoundation { * Only LEFT or RIGHT bit is used to position the menu surface ignoring RTL * context. E.g., menu surface will be positioned from right side on TOP_END. */ - private getoriginCorner(): Corner { + private getOriginCorner(): Corner { let corner = this.originCorner; const {viewportDistance, anchorSize, surfaceSize} = this.measurements; diff --git a/menusurface/lib/menu-surface.ts b/menusurface/lib/menu-surface.ts index 3eb65f019b..04887ea0cb 100644 --- a/menusurface/lib/menu-surface.ts +++ b/menusurface/lib/menu-surface.ts @@ -21,7 +21,6 @@ import {MDCMenuSurfaceFoundation} from './foundation'; export type Corner = keyof typeof CornerEnum; export type AnchorableElement = HTMLElement&{anchor: Element | null}; -export type MenuCorner = 'START'|'END'; // tslint:disable:no-bitwise @@ -120,43 +119,34 @@ export abstract class MenuSurface extends LitElement { } } }) - protected bitwiseCorner: CornerEnum = CornerEnum.TOP_START; - protected previousMenuCorner: MenuCorner|null = null; - // must be defined before observer of anchor corner for initialization - @property({type: String}) - @observer(function(this: MenuSurface, value: MenuCorner) { - if (this.mdcFoundation) { - const isValidValue = value === 'START' || value === 'END'; - const isFirstTimeSet = this.previousMenuCorner === null; - const cornerChanged = - !isFirstTimeSet && value !== this.previousMenuCorner; - const initiallySetToEnd = isFirstTimeSet && value === 'END'; - - if (isValidValue && (cornerChanged || initiallySetToEnd)) { - this.bitwiseCorner = this.bitwiseCorner ^ CornerBit.RIGHT; - this.mdcFoundation.flipCornerHorizontally(); - this.previousMenuCorner = value; - } + protected previousFlipMenuHorizontally = false; + + /** + * Whether to align the menu surface to the opposite side of the default + * alignment. + */ + @observer(function(this: MenuSurface, flipMenuHorizontally: boolean) { + if (!this.mdcFoundation) return; + + if (this.previousFlipMenuHorizontally !== flipMenuHorizontally) { + this.mdcFoundation.flipCornerHorizontally(); } + this.previousFlipMenuHorizontally = flipMenuHorizontally; }) - menuCorner: MenuCorner = 'START'; + @property({type: Boolean}) + flipMenuHorizontally = false; @property({type: String}) @observer(function(this: MenuSurface, value: Corner) { if (this.mdcFoundation) { if (value) { - let newCorner = stringToCorner[value]; - if (this.menuCorner === 'END') { - newCorner = newCorner ^ CornerBit.RIGHT; - } - - this.bitwiseCorner = newCorner; + this.bitwiseCorner = stringToCorner[value]; } } }) - corner: Corner = 'TOP_START'; + corner: Corner = 'BOTTOM_START'; @state() protected styleTop = ''; @state() protected styleLeft = ''; diff --git a/menusurface/menu-surface_test.ts b/menusurface/menu-surface_test.ts new file mode 100644 index 0000000000..261ef9eca2 --- /dev/null +++ b/menusurface/menu-surface_test.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './menu-surface'; +import '../list/list-item'; + +import {Environment} from '@material/web/testing/environment'; +import {listenOnce} from '@material/web/testing/events'; +import {html} from 'lit'; +import {styleMap} from 'lit/directives/style-map'; + +import {MdMenuSurface} from './menu-surface'; + +const ANCHOR_WIDTH = '100px'; +const ANCHOR_HEIGHT = '50px'; + +describe('menu surface tests', () => { + const env = new Environment(); + let surface: MdMenuSurface; + let root: HTMLElement; // Inner root element to which styling is applied. + + beforeEach(async () => { + const el = env.render(getMenuSurfaceTemplate()); + surface = el.querySelector('md-menu-surface')!; + const button = el.querySelector('button')!; + surface.anchor = button; + await surface.updateComplete; + root = surface.shadowRoot!.querySelector('.md3-menu-surface')!; + }); + + describe('menu positioning options', () => { + it('Corner.TOP_START positions menu correctly', async () => { + surface.corner = 'TOP_START'; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe('0px'); + expect(root.style.left).toBe('0px'); + }); + + it('Corner.TOP_END positions menu correctly', async () => { + surface.corner = 'TOP_END'; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe('0px'); + expect(root.style.left).toBe(ANCHOR_WIDTH); + }); + + it('Corner.BOTTOM_START positions menu correctly', async () => { + surface.corner = 'BOTTOM_START'; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe(ANCHOR_HEIGHT); + expect(root.style.left).toBe('0px'); + }); + + it('Corner.BOTTOM_END positions menu correctly', async () => { + surface.corner = 'BOTTOM_END'; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe(ANCHOR_HEIGHT); + expect(root.style.left).toBe(ANCHOR_WIDTH); + }); + + it('`flipMenuHorizontally` flips TOP_START corner', async () => { + surface.corner = 'TOP_START'; + surface.flipMenuHorizontally = true; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe('0px'); + expect(root.style.right).toBe('0px'); + }); + + it('`flipMenuHorizontally` flips TOP_END corner', async () => { + surface.corner = 'TOP_END'; + surface.flipMenuHorizontally = true; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe('0px'); + expect(root.style.right).toBe(ANCHOR_WIDTH); + }); + + it('`flipMenuHorizontally` flips BOTTOM_START corner', async () => { + surface.corner = 'BOTTOM_START'; + surface.flipMenuHorizontally = true; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe(ANCHOR_HEIGHT); + expect(root.style.right).toBe('0px'); + }); + + it('`flipMenuHorizontally` flips BOTTOM_END corner', async () => { + surface.corner = 'BOTTOM_END'; + surface.flipMenuHorizontally = true; + await surface.updateComplete; + surface.show(); + // TOOD(b/241244423): Waiting for the event fired can be removed when + // this bug is fixed. + await listenOnce(surface, 'opened'); + await surface.updateComplete; + + expect(root.style.top).toBe(ANCHOR_HEIGHT); + expect(root.style.right).toBe(ANCHOR_WIDTH); + }); + }); +}); + +function getMenuSurfaceTemplate(propsInit: Partial = {}) { + return html` +
+ + + Menu surface content + +
+ `; +} diff --git a/testing/events.ts b/testing/events.ts new file mode 100644 index 0000000000..c18038cc18 --- /dev/null +++ b/testing/events.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Adds an event listener for `eventName` on the given element. + * @return Promise that resolves when `eventName` has been fired on the element. + */ +export function listenOnce( + element: HTMLElement, eventName: string): Promise { + return new Promise((res) => { + const listener = (e: CustomEvent) => { + element.removeEventListener(eventName, listener as EventListener); + res(e); + }; + + element.addEventListener(eventName, listener as EventListener); + }); +} \ No newline at end of file