Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor common dialog/drawer code into a base class #1801

Closed
wants to merge 17 commits into from
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Refactor common dialog and drawer code into base class",
"packageName": "@ni/nimble-components",
"email": "7282195+m-akinc@users.noreply.github.com",
"dependentChangeType": "patch"
}
101 changes: 3 additions & 98 deletions packages/nimble-components/src/dialog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { attr, observable } from '@microsoft/fast-element';
import {
applyMixins,
ARIAGlobalStatesAndProperties,
DesignSystem,
FoundationElement
DesignSystem
} from '@microsoft/fast-foundation';
import { UserDismissed } from '../patterns/dialog/types';
import { Modal } from '../modal';
import { styles } from './styles';
import { template } from './template';

Expand All @@ -17,33 +17,15 @@ declare global {
}
}

/**
* This is a workaround for an incomplete definition of the native dialog element. Remove when using Typescript >=4.8.3.
* https://github.com/microsoft/TypeScript/issues/48267
* @internal
*/
export interface ExtendedDialog extends HTMLDialogElement {
showModal(): void;
close(): void;
}

/**
* A nimble-styled dialog.
*/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
export class Dialog<CloseReason = void> extends FoundationElement {
export class Dialog<CloseReason = void> extends Modal<CloseReason> {
// We want the member to match the name of the constant
// eslint-disable-next-line @typescript-eslint/naming-convention
public static readonly UserDismissed = UserDismissed;

/**
* @public
* @description
* Prevents dismissing the dialog via the Escape key
*/
@attr({ attribute: 'prevent-dismiss', mode: 'boolean' })
public preventDismiss = false;

/**
* @public
* @description
Expand All @@ -60,13 +42,6 @@ export class Dialog<CloseReason = void> extends FoundationElement {
@attr({ attribute: 'footer-hidden', mode: 'boolean' })
public footerHidden = false;

/**
* The ref to the internal dialog element.
*
* @internal
*/
public readonly dialogElement!: ExtendedDialog;

/** @internal */
@observable
public footerIsEmpty = true;
Expand All @@ -75,82 +50,12 @@ export class Dialog<CloseReason = void> extends FoundationElement {
@observable
public readonly slottedFooterElements?: HTMLElement[];

/**
* True if the dialog is open/showing, false otherwise
*/
public get open(): boolean {
return this.resolveShow !== undefined;
}

private resolveShow?: (reason: CloseReason | UserDismissed) => void;

/**
* Opens the dialog
* @returns Promise that is resolved when the dialog is closed. The value of the resolved Promise is the reason value passed to the close() method, or UserDismissed if the dialog was closed via the ESC key.
*/
public async show(): Promise<CloseReason | UserDismissed> {
if (this.open) {
throw new Error('Dialog is already open');
}
this.dialogElement.showModal();
return new Promise((resolve, _reject) => {
this.resolveShow = resolve;
});
}

/**
* Closes the dialog
* @param reason An optional value indicating how/why the dialog was closed.
*/
public close(reason: CloseReason): void {
if (!this.open) {
throw new Error('Dialog is not open');
}
this.dialogElement.close();
this.doResolveShow(reason);
}

public slottedFooterElementsChanged(
_prev: HTMLElement[] | undefined,
next: HTMLElement[] | undefined
): void {
this.footerIsEmpty = !next?.length;
}

/**
* @internal
*/
public cancelHandler(event: Event): boolean {
if (this.preventDismiss) {
event.preventDefault();
} else {
this.doResolveShow(UserDismissed);
}
return true;
}

/**
* @internal
*/
public closeHandler(): void {
if (this.resolveShow) {
// If
// - the browser implements dialogs with the CloseWatcher API, and
// - the user presses ESC without first interacting with the dialog (e.g. clicking, scrolling),
// the cancel event is not fired, but the close event still is, and the dialog just closes.
this.doResolveShow(UserDismissed);
}
}

private doResolveShow(reason: CloseReason | UserDismissed): void {
if (!this.resolveShow) {
throw new Error(
'Do not call doResolveShow unless there is a promise to resolve'
);
}
this.resolveShow(reason);
this.resolveShow = undefined;
}
}

// eslint-disable-next-line @typescript-eslint/no-empty-interface
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { html } from '@microsoft/fast-element';
import { fixture, Fixture } from '../../utilities/tests/fixture';
import { Dialog, dialogTag, ExtendedDialog, UserDismissed } from '..';
import { Dialog, dialogTag, UserDismissed } from '..';
import { waitForUpdatesAsync } from '../../testing/async-helpers';
import type { ExtendedDialog } from '../../modal';

// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
async function setup<CloseReason = void>(
Expand Down
115 changes: 21 additions & 94 deletions packages/nimble-components/src/drawer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { attr } from '@microsoft/fast-element';
import {
applyMixins,
ARIAGlobalStatesAndProperties,
DesignSystem,
FoundationElement
DesignSystem
} from '@microsoft/fast-foundation';
import { eventAnimationEnd } from '@microsoft/fast-web-utilities';
import type { ExtendedDialog } from '../dialog';
import { UserDismissed } from '../patterns/dialog/types';
import { Modal } from '../modal';
import { ModalState } from '../modal/types';
import { styles } from './styles';
import { template } from './template';
import { DrawerLocation } from './types';
Expand All @@ -25,132 +25,59 @@ declare global {
* which animates to be visible with a slide-in / slide-out animation.
*/
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
export class Drawer<CloseReason = void> extends FoundationElement {
export class Drawer<CloseReason = void> extends Modal<CloseReason> {
// We want the member to match the name of the constant
// eslint-disable-next-line @typescript-eslint/naming-convention
public static readonly UserDismissed = UserDismissed;

@attr
public location: DrawerLocation = DrawerLocation.right;

@attr({ attribute: 'prevent-dismiss', mode: 'boolean' })
public preventDismiss = false;

public dialog!: ExtendedDialog;
private closing = false;

private resolveShow?: (reason: CloseReason | UserDismissed) => void;
private closeReason!: CloseReason | UserDismissed;

/**
* True if the drawer is open, opening, or closing. Otherwise, false.
*/
public get open(): boolean {
return this.resolveShow !== undefined;
}

/**
* Opens the drawer
* @returns Promise that is resolved when the drawer finishes closing. The value of the resolved
* Promise is the reason value passed to the close() method, or UserDismissed if the drawer was
* closed via the ESC key.
*/
public async show(): Promise<CloseReason | UserDismissed> {
if (this.open) {
throw new Error('Drawer is already open');
}
this.openDialog();
return new Promise((resolve, _reject) => {
this.resolveShow = resolve;
});
}

/**
* Closes the drawer
* @param reason An optional value indicating how/why the drawer was closed.
*/
public close(reason: CloseReason): void {
if (!this.open || this.closing) {
throw new Error('Drawer is not open or already closing');
}
this.closeReason = reason;
this.closeDialog();
}

/**
* @internal
*/
public cancelHandler(event: Event): boolean {
public override cancelHandler(event: Event): boolean {
// Allowing the dialog to close itself bypasses the drawer's animation logic, so we
// should close the drawer ourselves when preventDismiss is false.
event.preventDefault();

if (!this.preventDismiss) {
this.closeReason = UserDismissed;
this.closeDialog();
}
return true;
}

/**
* @internal
*/
public closeHandler(): void {
if (this.resolveShow) {
// If
// - the browser implements dialogs with the CloseWatcher API, and
// - the user presses ESC without first interacting with the drawer (e.g. clicking, scrolling),
// the cancel event is not fired, but the close event still is, and the drawer just closes.
// The animation is never started, so there is no animation end listener to clean up.
this.doResolveShow(UserDismissed);
}
return super.cancelHandler(event);
}

private doResolveShow(reason: CloseReason | UserDismissed): void {
if (!this.resolveShow) {
throw new Error(
'Do not call doResolveShow unless there is a promise to resolve'
);
}
this.resolveShow(reason);
this.resolveShow = undefined;
}

private readonly animationEndHandlerFunction = (): void => this.animationEndHandler();

private openDialog(): void {
this.dialog.showModal();
protected override startOpening(): void {
super.startOpening();
this.triggerAnimation();
}

private closeDialog(): void {
this.closing = true;
protected override startClosing(reason: CloseReason | UserDismissed): void {
this.closeReason = reason;
this.triggerAnimation();
}

private readonly animationEndHandlerFunction = (): void => this.animationEndHandler();

private triggerAnimation(): void {
this.dialog.classList.add('animating');
if (this.closing) {
this.dialog.classList.add('closing');
this.dialogElement.classList.add('animating');
if (this.state === ModalState.closing) {
this.dialogElement.classList.add('closing');
}

this.dialog.addEventListener(
this.dialogElement.addEventListener(
eventAnimationEnd,
this.animationEndHandlerFunction
);
}

private animationEndHandler(): void {
this.dialog.removeEventListener(
this.dialogElement.removeEventListener(
eventAnimationEnd,
this.animationEndHandlerFunction
);
this.dialog.classList.remove('animating');
if (this.closing) {
this.dialog.classList.remove('closing');
this.dialog.close();
this.closing = false;
this.doResolveShow(this.closeReason);
this.dialogElement.classList.remove('animating');
if (this.state === ModalState.closing) {
this.dialogElement.classList.remove('closing');
this.finishClosing(this.closeReason);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nimble-components/src/drawer/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Drawer } from '.';

export const template = html<Drawer>`
<dialog
${ref('dialog')}
${ref('dialogElement')}
aria-label="${x => x.ariaLabel}"
@cancel="${(x, c) => x.cancelHandler(c.event)}"
@close="${x => x.closeHandler()}"
Expand Down
Loading
Loading