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

Make dialog and drawer more resilient to upcoming CloseWatcher API #1848

Merged
merged 5 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Clean up dialog/drawer properly if cancel event skipped",
"packageName": "@ni/nimble-components",
"email": "7282195+m-akinc@users.noreply.github.com",
"dependentChangeType": "patch"
}
29 changes: 25 additions & 4 deletions packages/nimble-components/src/dialog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ export class Dialog<CloseReason = void> extends FoundationElement {
throw new Error('Dialog is not open');
}
this.dialogElement.close();
this.resolveShow!(reason);
this.resolveShow = undefined;
this.doResolveShow(reason);
}

public slottedFooterElementsChanged(
Expand All @@ -125,11 +124,33 @@ export class Dialog<CloseReason = void> extends FoundationElement {
if (this.preventDismiss) {
event.preventDefault();
} else {
this.resolveShow!(UserDismissed);
this.resolveShow = undefined;
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
8 changes: 7 additions & 1 deletion packages/nimble-components/src/dialog/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
elevation3BoxShadow,
dialogSmallWidth,
dialogSmallHeight,
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
dialogSmallMaxHeight
dialogSmallMaxHeight,
borderHoverColor
} from '../theme-provider/design-tokens';
import {
modalBackdropColorThemeColorStatic,
Expand All @@ -25,6 +26,7 @@ import {
import { Theme } from '../theme-provider/types';
import { themeBehavior } from '../utilities/style/theme';
import { accessiblyHidden } from '../utilities/style/accessibly-hidden';
import { focusVisible } from '../utilities/style/focus';

export const styles = css`
${display('grid')}
Expand All @@ -44,6 +46,10 @@ export const styles = css`
display: flex;
}

dialog${focusVisible} {
outline: 2px solid ${borderHoverColor};
}

header {
min-height: 48px;
padding: 24px 24px 0px 24px;
Expand Down
1 change: 1 addition & 0 deletions packages/nimble-components/src/dialog/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const template = html<Dialog>`
role="dialog"
part="control"
@cancel="${(x, c) => x.cancelHandler(c.event)}"
@close="${x => x.closeHandler()}"
aria-labelledby="header"
>
<header id="header">
Expand Down
16 changes: 16 additions & 0 deletions packages/nimble-components/src/dialog/tests/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,22 @@ describe('Dialog', () => {
await disconnect();
});

// This can potentially happen if the dialog is implemented with the CloseWatcher API
it('should resolve promise with UserDismissed when only close event fired', async () => {
const { element, connect, disconnect } = await setup();
await connect();
const dialogPromise = element.show();
await waitForUpdatesAsync();
// Simulate user dismiss events in browser
nativeDialogElement(element).dispatchEvent(new Event('close'));
await waitForUpdatesAsync();

await expectAsync(dialogPromise).toBeResolvedTo(UserDismissed);
expect(element.open).toBeFalse();

await disconnect();
});

it('should dismiss an attempted cancel event when prevent-dismiss is enabled', async () => {
const { element, connect, disconnect } = await setup(true);
await connect();
Expand Down
27 changes: 25 additions & 2 deletions packages/nimble-components/src/drawer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,30 @@ export class Drawer<CloseReason = void> extends FoundationElement {
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);
}
}

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 {
Expand Down Expand Up @@ -126,8 +150,7 @@ export class Drawer<CloseReason = void> extends FoundationElement {
this.dialog.classList.remove('closing');
this.dialog.close();
this.closing = false;
this.resolveShow!(this.closeReason);
this.resolveShow = undefined;
this.doResolveShow(this.closeReason);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/nimble-components/src/drawer/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const template = html<Drawer>`
${ref('dialog')}
aria-label="${x => x.ariaLabel}"
@cancel="${(x, c) => x.cancelHandler(c.event)}"
@close="${x => x.closeHandler()}"
>
<div class="dialog-contents">
<slot></slot>
Expand Down
9 changes: 9 additions & 0 deletions packages/nimble-components/src/drawer/tests/drawer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ describe('Drawer', () => {
expect(element.open).toBeFalse();
});

// This can potentially happen if the dialog is implemented with the CloseWatcher API
it('should resolve promise with UserDismissed when only close event fired', async () => {
const promise = element.show();
// Simulate user dismiss events in browser
nativeDialogElement(element).dispatchEvent(new Event('close'));
await expectAsync(promise).toBeResolvedTo(UserDismissed);
expect(element.open).toBeFalse();
});

it('throws calling show() a second time', async () => {
void element.show();
await expectAsync(element.show()).toBeRejectedWithError();
Expand Down
Loading