Skip to content

Commit 169a099

Browse files
author
shleewhite
committed
feat: improve logic for modal/flyout lifecycle handling
1 parent e99e488 commit 169a099

File tree

4 files changed

+104
-47
lines changed

4 files changed

+104
-47
lines changed

packages/components/src/components/hds/flyout/index.hbs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
class={{this.classNames}}
77
...attributes
88
aria-labelledby={{this.id}}
9-
{{did-insert this.didInsert}}
10-
{{will-destroy this.willDestroyNode}}
9+
{{this._registerDialog}}
1110
{{! @glint-expect-error - https://github.com/josemarluedke/ember-focus-trap/issues/86 }}
12-
{{focus-trap isActive=this._isOpen focusTrapOptions=(hash onDeactivate=this.onDismiss clickOutsideDeactivates=true)}}
11+
{{focus-trap isActive=this._isOpen focusTrapOptions=(hash onDeactivate=this.onDismiss)}}
1312
>
1413
<:header>
1514
{{yield

packages/components/src/components/hds/flyout/index.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { assert } from '@ember/debug';
1010
import { getElementId } from '../../../utils/hds-get-element-id.ts';
1111
import { buildWaiter } from '@ember/test-waiters';
1212
import type { WithBoundArgs } from '@glint/template';
13+
import { modifier } from 'ember-modifier';
14+
import { registerDestructor } from '@ember/destroyable';
15+
import type Owner from '@ember/owner';
1316

1417
import type { HdsFlyoutSizes } from './types.ts';
1518

@@ -64,6 +67,27 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
6467
_element!: HTMLDialogElement;
6568
private _body!: HTMLElement;
6669
private _bodyInitialOverflowValue = '';
70+
private _clickHandler!: (event: MouseEvent) => void;
71+
72+
constructor(owner: Owner, args: HdsFlyoutSignature['Args']) {
73+
super(owner, args);
74+
75+
registerDestructor(this, (): void => {
76+
// if the <dialog> is removed from the dom while open we emulate the close event
77+
if (this._element && this._isOpen) {
78+
this._element.dispatchEvent(new Event('close'));
79+
80+
this._element.removeEventListener(
81+
'close',
82+
// eslint-disable-next-line @typescript-eslint/unbound-method
83+
this.registerOnCloseCallback,
84+
true
85+
);
86+
}
87+
88+
document.removeEventListener('click', this._clickHandler, true);
89+
});
90+
}
6791

6892
/**
6993
* Sets the size of the flyout
@@ -115,8 +139,7 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
115139
this._isOpen = false;
116140
}
117141

118-
@action
119-
didInsert(element: HTMLDialogElement): void {
142+
private _registerDialog = modifier((element: HTMLDialogElement) => {
120143
// Store references of `<dialog>` and `<body>` elements
121144
this._element = element;
122145
this._body = document.body;
@@ -135,7 +158,19 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
135158
if (!this._element.open) {
136159
this.open();
137160
}
138-
}
161+
162+
this._clickHandler = (event: MouseEvent) => {
163+
// check if the click is outside the flyout and the flyout is open
164+
if (!this._element.contains(event.target as Node) && this._isOpen) {
165+
void this.onDismiss();
166+
}
167+
};
168+
169+
document.addEventListener('click', this._clickHandler, {
170+
capture: true,
171+
passive: false,
172+
});
173+
});
139174

140175
@action
141176
willDestroyNode(): void {

packages/components/src/components/hds/modal/index.hbs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
class={{this.classNames}}
77
...attributes
88
aria-labelledby={{this.id}}
9-
{{did-insert this.didInsert}}
10-
{{will-destroy this.willDestroyNode}}
9+
{{this._registerDialog}}
1110
{{! @glint-expect-error - https://github.com/josemarluedke/ember-focus-trap/issues/86 }}
12-
{{focus-trap isActive=this._isOpen focusTrapOptions=(hash onDeactivate=this.onDismiss clickOutsideDeactivates=true)}}
11+
{{focus-trap isActive=this._isOpen focusTrapOptions=(hash onDeactivate=this.onDismiss)}}
1312
>
1413
<:header>
1514
{{yield

packages/components/src/components/hds/modal/index.ts

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import { action } from '@ember/object';
99
import { assert } from '@ember/debug';
1010
import { getElementId } from '../../../utils/hds-get-element-id.ts';
1111
import { buildWaiter } from '@ember/test-waiters';
12+
import { registerDestructor } from '@ember/destroyable';
13+
import { modifier } from 'ember-modifier';
1214

1315
import type { WithBoundArgs } from '@glint/template';
16+
import type Owner from '@ember/owner';
1417
import type { HdsModalSizes, HdsModalColors } from './types.ts';
1518

1619
import HdsDialogPrimitiveHeaderComponent from '../dialog-primitive/header.ts';
@@ -61,6 +64,27 @@ export default class HdsModal extends Component<HdsModalSignature> {
6164
private _element!: HTMLDialogElement;
6265
private _body!: HTMLElement;
6366
private _bodyInitialOverflowValue = '';
67+
private _clickHandler!: (event: MouseEvent) => void;
68+
69+
constructor(owner: Owner, args: HdsModalSignature['Args']) {
70+
super(owner, args);
71+
72+
registerDestructor(this, (): void => {
73+
// if the <dialog> is removed from the dom while open we emulate the close event
74+
if (this._element && this._isOpen) {
75+
this._element.dispatchEvent(new Event('close'));
76+
77+
this._element.removeEventListener(
78+
'close',
79+
// eslint-disable-next-line @typescript-eslint/unbound-method
80+
this.registerOnCloseCallback,
81+
true
82+
);
83+
}
84+
85+
document.removeEventListener('click', this._clickHandler, true);
86+
});
87+
}
6488

6589
get isDismissDisabled(): boolean {
6690
return this.args.isDismissDisabled ?? false;
@@ -128,11 +152,33 @@ export default class HdsModal extends Component<HdsModalSignature> {
128152
}
129153
} else {
130154
this._isOpen = false;
155+
156+
// Reset page `overflow` property
157+
if (this._body) {
158+
this._body.style.removeProperty('overflow');
159+
if (this._bodyInitialOverflowValue === '') {
160+
if (this._body.style.length === 0) {
161+
this._body.removeAttribute('style');
162+
}
163+
} else {
164+
this._body.style.setProperty(
165+
'overflow',
166+
this._bodyInitialOverflowValue
167+
);
168+
}
169+
}
170+
171+
// Return focus to a specific element (if provided)
172+
if (this.args.returnFocusTo) {
173+
const initiator = document.getElementById(this.args.returnFocusTo);
174+
if (initiator) {
175+
initiator.focus();
176+
}
177+
}
131178
}
132179
}
133180

134-
@action
135-
didInsert(element: HTMLDialogElement): void {
181+
private _registerDialog = modifier((element: HTMLDialogElement) => {
136182
// Store references of `<dialog>` and `<body>` elements
137183
this._element = element;
138184
this._body = document.body;
@@ -151,19 +197,21 @@ export default class HdsModal extends Component<HdsModalSignature> {
151197
if (!this._element.open) {
152198
this.open();
153199
}
154-
}
155200

156-
@action
157-
willDestroyNode(): void {
158-
if (this._element) {
159-
this._element.removeEventListener(
160-
'close',
161-
// eslint-disable-next-line @typescript-eslint/unbound-method
162-
this.registerOnCloseCallback,
163-
true
164-
);
165-
}
166-
}
201+
this._clickHandler = (event: MouseEvent) => {
202+
// check if the click is outside the modal and the modal is open
203+
if (!this._element.contains(event.target as Node) && this._isOpen) {
204+
if (!this.isDismissDisabled) {
205+
void this.onDismiss();
206+
}
207+
}
208+
};
209+
210+
document.addEventListener('click', this._clickHandler, {
211+
capture: true,
212+
passive: false,
213+
});
214+
});
167215

168216
@action
169217
open(): void {
@@ -185,7 +233,6 @@ export default class HdsModal extends Component<HdsModalSignature> {
185233
async onDismiss(): Promise<void> {
186234
// allow ember test helpers to be aware of when the `close` event fires
187235
// when using `click` or other helpers from '@ember/test-helpers'
188-
// Notice: this code will get stripped out in production builds (DEBUG evaluates to `true` in dev/test builds, but `false` in prod builds)
189236
if (this._element.open) {
190237
const token = waiter.beginAsync();
191238
const listener = () => {
@@ -197,28 +244,5 @@ export default class HdsModal extends Component<HdsModalSignature> {
197244

198245
// Make modal dialog invisible using the native `close` method
199246
this._element.close();
200-
201-
// Reset page `overflow` property
202-
if (this._body) {
203-
this._body.style.removeProperty('overflow');
204-
if (this._bodyInitialOverflowValue === '') {
205-
if (this._body.style.length === 0) {
206-
this._body.removeAttribute('style');
207-
}
208-
} else {
209-
this._body.style.setProperty(
210-
'overflow',
211-
this._bodyInitialOverflowValue
212-
);
213-
}
214-
}
215-
216-
// Return focus to a specific element (if provided)
217-
if (this.args.returnFocusTo) {
218-
const initiator = document.getElementById(this.args.returnFocusTo);
219-
if (initiator) {
220-
initiator.focus();
221-
}
222-
}
223247
}
224248
}

0 commit comments

Comments
 (0)