Skip to content

Commit 4cddac0

Browse files
authored
feat: improve logic for modal/flyout lifecycle handling (#3215)
1 parent 4c9e776 commit 4cddac0

File tree

8 files changed

+124
-109
lines changed

8 files changed

+124
-109
lines changed

.changeset/rich-zoos-type.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@hashicorp/design-system-components": patch
3+
---
4+
5+
<!-- START components/modal -->
6+
`Modal` - Refactored the component to not use `ember-render-modifiers` which fixes issues where the DOM may not be cleaned up when the Modal is closed.
7+
<!-- END -->
8+
9+
<!-- START components/flyout -->
10+
`Flyout` - Refactored the component to not use `ember-render-modifiers` which fixes issues where the DOM may not be cleaned up when the Flyout is closed.
11+
<!-- END -->

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
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 }}
1211
{{focus-trap isActive=this._isOpen focusTrapOptions=(hash onDeactivate=this.onDismiss clickOutsideDeactivates=true)}}
1312
>

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

Lines changed: 34 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ 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';
1314

1415
import type { HdsFlyoutSizes } from './types.ts';
1516

@@ -65,14 +66,6 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
6566
private _body!: HTMLElement;
6667
private _bodyInitialOverflowValue = '';
6768

68-
/**
69-
* Sets the size of the flyout
70-
* Accepted values: medium, large
71-
*
72-
* @param size
73-
* @type {string}
74-
* @default 'medium'
75-
*/
7669
get size(): HdsFlyoutSizes {
7770
const { size = DEFAULT_SIZE } = this.args;
7871

@@ -86,18 +79,10 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
8679
return size;
8780
}
8881

89-
/**
90-
* Calculates the unique ID to assign to the title
91-
*/
9282
get id(): string {
9383
return getElementId(this);
9484
}
9585

96-
/**
97-
* Get the class names to apply to the component.
98-
* @method classNames
99-
* @return {string} The "class" attribute to apply to the component.
100-
*/
10186
get classNames(): string {
10287
const classes = ['hds-flyout'];
10388

@@ -113,10 +98,32 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
11398
}
11499

115100
this._isOpen = false;
101+
102+
// Reset page `overflow` property
103+
if (this._body) {
104+
this._body.style.removeProperty('overflow');
105+
if (this._bodyInitialOverflowValue === '') {
106+
if (this._body.style.length === 0) {
107+
this._body.removeAttribute('style');
108+
}
109+
} else {
110+
this._body.style.setProperty(
111+
'overflow',
112+
this._bodyInitialOverflowValue
113+
);
114+
}
115+
}
116+
117+
// Return focus to a specific element (if provided)
118+
if (this.args.returnFocusTo) {
119+
const initiator = document.getElementById(this.args.returnFocusTo);
120+
if (initiator) {
121+
initiator.focus();
122+
}
123+
}
116124
}
117125

118-
@action
119-
didInsert(element: HTMLDialogElement): void {
126+
private _registerDialog = modifier((element: HTMLDialogElement) => {
120127
// Store references of `<dialog>` and `<body>` elements
121128
this._element = element;
122129
this._body = document.body;
@@ -135,19 +142,21 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
135142
if (!this._element.open) {
136143
this.open();
137144
}
138-
}
139145

140-
@action
141-
willDestroyNode(): void {
142-
if (this._element) {
143-
this._element.removeEventListener(
146+
return () => {
147+
// if the <dialog> is removed from the dom while open we emulate the close event
148+
if (this._isOpen) {
149+
this._element?.dispatchEvent(new Event('close'));
150+
}
151+
152+
this._element?.removeEventListener(
144153
'close',
145154
// eslint-disable-next-line @typescript-eslint/unbound-method
146155
this.registerOnCloseCallback,
147156
true
148157
);
149-
}
150-
}
158+
};
159+
});
151160

152161
@action
153162
open(): void {
@@ -169,7 +178,6 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
169178
async onDismiss(): Promise<void> {
170179
// allow ember test helpers to be aware of when the `close` event fires
171180
// when using `click` or other helpers from '@ember/test-helpers'
172-
// Notice: this code will get stripped out in production builds (DEBUG evaluates to `true` in dev/test builds, but `false` in prod builds)
173181
if (this._element.open) {
174182
const token = waiter.beginAsync();
175183
const listener = () => {
@@ -181,28 +189,5 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
181189

182190
// Make flyout dialog invisible using the native `close` method
183191
this._element.close();
184-
185-
// Reset page `overflow` property
186-
if (this._body) {
187-
this._body.style.removeProperty('overflow');
188-
if (this._bodyInitialOverflowValue === '') {
189-
if (this._body.style.length === 0) {
190-
this._body.removeAttribute('style');
191-
}
192-
} else {
193-
this._body.style.setProperty(
194-
'overflow',
195-
this._bodyInitialOverflowValue
196-
);
197-
}
198-
}
199-
200-
// Return focus to a specific element (if provided)
201-
if (this.args.returnFocusTo) {
202-
const initiator = document.getElementById(this.args.returnFocusTo);
203-
if (initiator) {
204-
initiator.focus();
205-
}
206-
}
207192
}
208193
}

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: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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 { modifier } from 'ember-modifier';
1213

1314
import type { WithBoundArgs } from '@glint/template';
1415
import type { HdsModalSizes, HdsModalColors } from './types.ts';
@@ -61,6 +62,7 @@ export default class HdsModal extends Component<HdsModalSignature> {
6162
private _element!: HTMLDialogElement;
6263
private _body!: HTMLElement;
6364
private _bodyInitialOverflowValue = '';
65+
private _clickOutsideToDismissHandler!: (event: MouseEvent) => void;
6466

6567
get isDismissDisabled(): boolean {
6668
return this.args.isDismissDisabled ?? false;
@@ -128,11 +130,33 @@ export default class HdsModal extends Component<HdsModalSignature> {
128130
}
129131
} else {
130132
this._isOpen = false;
133+
134+
// Reset page `overflow` property
135+
if (this._body) {
136+
this._body.style.removeProperty('overflow');
137+
if (this._bodyInitialOverflowValue === '') {
138+
if (this._body.style.length === 0) {
139+
this._body.removeAttribute('style');
140+
}
141+
} else {
142+
this._body.style.setProperty(
143+
'overflow',
144+
this._bodyInitialOverflowValue
145+
);
146+
}
147+
}
148+
149+
// Return focus to a specific element (if provided)
150+
if (this.args.returnFocusTo) {
151+
const initiator = document.getElementById(this.args.returnFocusTo);
152+
if (initiator) {
153+
initiator.focus();
154+
}
155+
}
131156
}
132157
}
133158

134-
@action
135-
didInsert(element: HTMLDialogElement): void {
159+
private _registerDialog = modifier((element: HTMLDialogElement) => {
136160
// Store references of `<dialog>` and `<body>` elements
137161
this._element = element;
138162
this._body = document.body;
@@ -151,19 +175,43 @@ export default class HdsModal extends Component<HdsModalSignature> {
151175
if (!this._element.open) {
152176
this.open();
153177
}
154-
}
155178

156-
@action
157-
willDestroyNode(): void {
158-
if (this._element) {
159-
this._element.removeEventListener(
179+
// Note: because the Modal has the `@isDismissedDisabled` argument, we need to add our own click outside to dismiss logic. This is because `ember-focus-trap` treats the `focusTrapOptions` as static, so we can't update it dynamically if `@isDismissDisabled` changes.
180+
this._clickOutsideToDismissHandler = (event: MouseEvent) => {
181+
// check if the click is outside the modal and the modal is open
182+
if (!this._element.contains(event.target as Node) && this._isOpen) {
183+
if (!this.isDismissDisabled) {
184+
// here we use `void` because `onDismiss` is an async function, but in reality we don't need to handle the result or wait for its completion
185+
void this.onDismiss();
186+
}
187+
}
188+
};
189+
190+
document.addEventListener('click', this._clickOutsideToDismissHandler, {
191+
capture: true,
192+
passive: false,
193+
});
194+
195+
return () => {
196+
// if the <dialog> is removed from the dom while open we emulate the close event
197+
if (this._isOpen) {
198+
this._element?.dispatchEvent(new Event('close'));
199+
}
200+
201+
this._element?.removeEventListener(
160202
'close',
161203
// eslint-disable-next-line @typescript-eslint/unbound-method
162204
this.registerOnCloseCallback,
163205
true
164206
);
165-
}
166-
}
207+
208+
document.removeEventListener(
209+
'click',
210+
this._clickOutsideToDismissHandler,
211+
true
212+
);
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
}

showcase/app/templates/page-components/flyout.hbs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -332,14 +332,12 @@
332332
method.</p>
333333
<p class="hds-typography-body-200 hds-foreground-primary" {{style margin-top="12px"}}>This is equivalent to a
334334
manual dismiss (<code>Esc</code>
335-
key, click outsite, click dismiss button) because they're all calling the same function, which invokes the
335+
key, click outside, click dismiss button) because they're all calling the same function, which invokes the
336336
native
337337
<code>close()</code>
338338
method of the
339339
<code>Dialog</code>
340-
HTML element, who then will cause the
341-
<code>willDestroyNode</code>
342-
action to execute.</p>
340+
HTML element.</p>
343341
</F.Body>
344342
<F.Footer as |F|>
345343
<Hds::Button type="button" @text="Confirm" {{on "click" F.close}} />
@@ -363,9 +361,9 @@
363361
flyout from the DOM.</p>
364362
<p class="hds-typography-body-200 hds-foreground-primary" {{style margin-top="12px"}}>This is not equivalent to
365363
a manual dismiss (<code>Esc</code>
366-
key, click outsite, click dismiss button) because it will trigger directly the
367-
<code>willDestroyNode</code>
368-
action.</p>
364+
key, click outside, click dismiss button) because it will trigger directly the
365+
<code>_registerDialog</code>
366+
modifier's cleanup function.</p>
369367
</F.Body>
370368
<F.Footer>
371369
<Hds::Button
@@ -394,9 +392,9 @@
394392
the associated action will remove the flyout from the DOM.</p>
395393
<p class="hds-typography-body-200 hds-foreground-primary" {{style margin="12px 0 32px"}}>This is not equivalent
396394
to a manual dismiss (<code>Esc</code>
397-
key, click outsite, click dismiss button) because it will trigger directly the
398-
<code>willDestroyNode</code>
399-
action.</p>
395+
key, click outside, click dismiss button) because it will directly trigger the
396+
<code>_registerDialog</code>
397+
modifier's cleanup function.</p>
400398
<form id="deactivate-flyout-on-submit__form" {{on "submit" this.deactivateFlyoutOnSubmit}}>
401399
<Hds::Form::TextInput::Field name="deactivate-flyout-on-submit__input" as |F|>
402400
<F.Label>Fill in this input</F.Label>

0 commit comments

Comments
 (0)