Skip to content

Commit 6b6c22e

Browse files
authored
Merge pull request #2497 from hashicorp/modal-flyout-return-focus-to
`Modal`/` Flyout` - Add `returnFocusTo` argument for consumer-side focus management on close
2 parents ecd77a9 + 392cd8e commit 6b6c22e

File tree

11 files changed

+275
-1
lines changed

11 files changed

+275
-1
lines changed

.changeset/brown-eagles-fry.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@hashicorp/design-system-components": minor
3+
---
4+
5+
`Modal` - added `returnFocusTo` argument to control where the browser focus is returned once the modal is closed
6+
7+
`Flyout` - added `returnFocusTo` argument to control where the browser focus is returned once the flyout is closed

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

+9
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const SIZES: string[] = Object.values(HdsFlyoutSizesValues);
2828
export interface HdsFlyoutSignature {
2929
Args: {
3030
size?: HdsFlyoutSizes;
31+
returnFocusTo?: string;
3132
onOpen?: () => void;
3233
onClose?: (event: Event) => void;
3334
};
@@ -187,5 +188,13 @@ export default class HdsFlyout extends Component<HdsFlyoutSignature> {
187188
this.body.style.setProperty('overflow', this.bodyInitialOverflowValue);
188189
}
189190
}
191+
192+
// Return focus to a specific element (if provided)
193+
if (this.args.returnFocusTo) {
194+
const initiator = document.getElementById(this.args.returnFocusTo);
195+
if (initiator) {
196+
initiator.focus();
197+
}
198+
}
190199
}
191200
}

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

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface HdsModalSignature {
3131
isDismissDisabled?: boolean;
3232
size?: HdsModalSizes;
3333
color?: HdsModalColors;
34+
returnFocusTo?: string;
3435
onOpen?: () => void;
3536
onClose?: (event: Event) => void;
3637
};
@@ -205,5 +206,13 @@ export default class HdsModal extends Component<HdsModalSignature> {
205206
this.body.style.setProperty('overflow', this.bodyInitialOverflowValue);
206207
}
207208
}
209+
210+
// Return focus to a specific element (if provided)
211+
if (this.args.returnFocusTo) {
212+
const initiator = document.getElementById(this.args.returnFocusTo);
213+
if (initiator) {
214+
initiator.focus();
215+
}
216+
}
208217
}
209218
}

showcase/app/controllers/components/flyout.js

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { tracked } from '@glimmer/tracking';
1010
export default class FlyoutController extends Controller {
1111
@tracked mediumFlyoutActive = false;
1212
@tracked largeFlyoutActive = false;
13+
@tracked dropdownInitiatedFlyoutActive = false;
14+
@tracked dropdownInitiatedWithReturnedFocusFlyoutActive = false;
1315

1416
@action
1517
activateFlyout(Flyout) {

showcase/app/controllers/components/modal.js

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export default class ModalController extends Controller {
1818
@tracked superselectModalActive3 = false;
1919
@tracked dismissDisabledModalActive = false;
2020
@tracked isDismissDisabled;
21+
@tracked dropdownInitiatedModalActive = false;
22+
@tracked dropdownInitiatedWithReturnedFocusModalActive = false;
2123

2224
@action
2325
activateModal(modal) {

showcase/app/templates/components/flyout.hbs

+60
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,64 @@
251251
</F.Footer>
252252
</Hds::Flyout>
253253
{{/if}}
254+
255+
<br />
256+
<br />
257+
258+
<Hds::Dropdown @listPosition="bottom-left" @isInline={{true}} as |D|>
259+
<D.ToggleButton @color="secondary" @size="small" @text="Open flyout via dropdown" />
260+
<D.Interactive {{on "click" (fn this.activateFlyout "dropdownInitiatedFlyoutActive")}}>
261+
Open flyout
262+
</D.Interactive>
263+
</Hds::Dropdown>
264+
265+
{{#if this.dropdownInitiatedFlyoutActive}}
266+
<Hds::Flyout
267+
id="dropdown-initiated-flyout"
268+
@onClose={{fn this.deactivateFlyout "dropdownInitiatedFlyoutActive"}}
269+
as |M|
270+
>
271+
<M.Header>
272+
Flyout title
273+
</M.Header>
274+
<M.Body>
275+
<p class="hds-typography-body-300 hds-foreground-primary">Flyout content</p>
276+
</M.Body>
277+
<M.Footer as |F|>
278+
<Hds::Button type="button" @text="Confirm" {{on "click" F.close}} />
279+
</M.Footer>
280+
</Hds::Flyout>
281+
{{/if}}
282+
283+
<Hds::Dropdown @listPosition="bottom-left" @isInline={{true}} as |D|>
284+
<D.ToggleButton
285+
id="dropdown-initiated-flyout-with-returned-focus-toggle"
286+
@color="secondary"
287+
@size="small"
288+
@text="Open flyout via dropdown (with returned focus)"
289+
/>
290+
<D.Interactive {{on "click" (fn this.activateFlyout "dropdownInitiatedWithReturnedFocusFlyoutActive")}}>
291+
Open flyout
292+
</D.Interactive>
293+
</Hds::Dropdown>
294+
295+
{{#if this.dropdownInitiatedWithReturnedFocusFlyoutActive}}
296+
<Hds::Flyout
297+
id="dropdown-initiated-flyout-with-returned-focus"
298+
@onClose={{fn this.deactivateFlyout "dropdownInitiatedWithReturnedFocusFlyoutActive"}}
299+
@returnFocusTo="dropdown-initiated-flyout-with-returned-focus-toggle"
300+
as |M|
301+
>
302+
<M.Header>
303+
Flyout title
304+
</M.Header>
305+
<M.Body>
306+
<p class="hds-typography-body-300 hds-foreground-primary">Flyout content</p>
307+
</M.Body>
308+
<M.Footer as |F|>
309+
<Hds::Button type="button" @text="Confirm" {{on "click" F.close}} />
310+
</M.Footer>
311+
</Hds::Flyout>
312+
{{/if}}
313+
254314
</section>

showcase/app/templates/components/modal.hbs

+59
Original file line numberDiff line numberDiff line change
@@ -509,5 +509,64 @@
509509
</Hds::Modal>
510510
{{/if}}
511511

512+
<br />
513+
<br />
514+
515+
<Hds::Dropdown @listPosition="bottom-left" @isInline={{true}} as |D|>
516+
<D.ToggleButton @color="secondary" @size="small" @text="Open modal via dropdown" />
517+
<D.Interactive {{on "click" (fn this.activateModal "dropdownInitiatedModalActive")}}>
518+
Open modal
519+
</D.Interactive>
520+
</Hds::Dropdown>
521+
522+
{{#if this.dropdownInitiatedModalActive}}
523+
<Hds::Modal
524+
id="dropdown-initiated-modal"
525+
@onClose={{fn this.deactivateModal "dropdownInitiatedModalActive"}}
526+
as |M|
527+
>
528+
<M.Header>
529+
Modal title
530+
</M.Header>
531+
<M.Body>
532+
<p class="hds-typography-body-300 hds-foreground-primary">Modal content</p>
533+
</M.Body>
534+
<M.Footer as |F|>
535+
<Hds::Button type="button" @text="Confirm" {{on "click" F.close}} />
536+
</M.Footer>
537+
</Hds::Modal>
538+
{{/if}}
539+
540+
<Hds::Dropdown @listPosition="bottom-left" @isInline={{true}} as |D|>
541+
<D.ToggleButton
542+
id="dropdown-initiated-modal-with-returned-focus-toggle"
543+
@color="secondary"
544+
@size="small"
545+
@text="Open modal via dropdown (with returned focus)"
546+
/>
547+
<D.Interactive {{on "click" (fn this.activateModal "dropdownInitiatedWithReturnedFocusModalActive")}}>
548+
Open modal
549+
</D.Interactive>
550+
</Hds::Dropdown>
551+
552+
{{#if this.dropdownInitiatedWithReturnedFocusModalActive}}
553+
<Hds::Modal
554+
id="dropdown-initiated-modal-with-returned-focus"
555+
@onClose={{fn this.deactivateModal "dropdownInitiatedWithReturnedFocusModalActive"}}
556+
@returnFocusTo="dropdown-initiated-modal-with-returned-focus-toggle"
557+
as |M|
558+
>
559+
<M.Header>
560+
Modal title
561+
</M.Header>
562+
<M.Body>
563+
<p class="hds-typography-body-300 hds-foreground-primary">Modal content</p>
564+
</M.Body>
565+
<M.Footer as |F|>
566+
<Hds::Button type="button" @text="Confirm" {{on "click" F.close}} />
567+
</M.Footer>
568+
</Hds::Modal>
569+
{{/if}}
570+
512571
</section>
513572
{{! template-lint-enable no-autofocus-attribute }}

showcase/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"ember-power-select": "^8.2.0",
8181
"ember-qunit": "^8.1.0",
8282
"ember-resolver": "^11.0.1",
83+
"ember-set-helper": "^3.0.1",
8384
"ember-source": "~5.9.0",
8485
"ember-source-channel-url": "^3.0.0",
8586
"ember-style-modifier": "^4.4.0",

showcase/tests/integration/components/hds/flyout/index-test.js

+57
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,63 @@ module('Integration | Component | hds/flyout/index', function (hooks) {
160160
assert.dom('button.hds-flyout__dismiss').isFocused();
161161
});
162162

163+
test('it returns focus to the element that initiated the open event, if is still in the DOM', async function (assert) {
164+
await render(
165+
hbs`<button id="test-button" type="button" {{on "click" (set this "showFlyout" true)}}>open flyout</button>
166+
{{#if this.showFlyout}}
167+
<Hds::Flyout id="test-flyout" as |M|>
168+
<M.Header>Title</M.Header>
169+
</Hds::Flyout>
170+
{{/if}}
171+
`
172+
);
173+
await click('#test-button');
174+
assert.true(this.showFlyout);
175+
await click('button.hds-flyout__dismiss');
176+
assert.dom('#test-button').isFocused();
177+
});
178+
179+
// not sure how to reach the `body` element, it says "body is not a valid root element"
180+
skip('it returns focus to the `body` element, if the one that initiated the open event not anymore in the DOM', async function (assert) {
181+
await render(
182+
hbs`<Hds::Dropdown as |D|>
183+
<D.ToggleButton id="test-toggle" @text="open flyout" />
184+
<D.Interactive id="test-interactive" {{on "click" (set this "showFlyout" true)}}>open flyout</D.Interactive>
185+
</Hds::Dropdown>
186+
{{#if this.showFlyout}}
187+
<Hds::Flyout id="test-flyout" as |M|>
188+
<M.Header>Title</M.Header>
189+
</Hds::Flyout>
190+
{{/if}}
191+
`
192+
);
193+
await click('#test-toggle');
194+
await click('#test-interactive');
195+
assert.true(this.showFlyout);
196+
await click('button.hds-flyout__dismiss');
197+
assert.dom('body', 'body').isFocused();
198+
});
199+
200+
test('it returns focus to a specific element if provided via`@returnFocusTo`', async function (assert) {
201+
await render(
202+
hbs`<Hds::Dropdown as |D|>
203+
<D.ToggleButton id="test-toggle" @text="open flyout" />
204+
<D.Interactive id="test-interactive" {{on "click" (set this "showFlyout" true)}}>open flyout</D.Interactive>
205+
</Hds::Dropdown>
206+
{{#if this.showFlyout}}
207+
<Hds::Flyout id="test-flyout" @returnFocusTo="test-toggle" as |M|>
208+
<M.Header>Title</M.Header>
209+
</Hds::Flyout>
210+
{{/if}}
211+
`
212+
);
213+
await click('#test-toggle');
214+
await click('#test-interactive');
215+
assert.true(this.showFlyout);
216+
await click('button.hds-flyout__dismiss');
217+
assert.dom('#test-toggle').isFocused();
218+
});
219+
163220
// CALLBACKS
164221

165222
test('it should call `onOpen` function if provided', async function (assert) {

showcase/tests/integration/components/hds/modal/index-test.js

+58-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: MPL-2.0
44
*/
55

6-
import { module, test } from 'qunit';
6+
import { module, test, skip } from 'qunit';
77
import { setupRenderingTest } from 'ember-qunit';
88
import {
99
click,
@@ -194,6 +194,63 @@ module('Integration | Component | hds/modal/index', function (hooks) {
194194
assert.dom('button.hds-modal__dismiss').isFocused();
195195
});
196196

197+
test('it returns focus to the element that initiated the open event, if is still in the DOM', async function (assert) {
198+
await render(
199+
hbs`<button id="test-button" type="button" {{on "click" (set this "showModal" true)}}>open modal</button>
200+
{{#if this.showModal}}
201+
<Hds::Modal id="test-modal" as |M|>
202+
<M.Header>Title</M.Header>
203+
</Hds::Modal>
204+
{{/if}}
205+
`
206+
);
207+
await click('#test-button');
208+
assert.true(this.showModal);
209+
await click('button.hds-modal__dismiss');
210+
assert.dom('#test-button').isFocused();
211+
});
212+
213+
// not sure how to reach the `body` element, it says "body is not a valid root element"
214+
skip('it returns focus to the `body` element, if the one that initiated the open event not anymore in the DOM', async function (assert) {
215+
await render(
216+
hbs`<Hds::Dropdown as |D|>
217+
<D.ToggleButton id="test-toggle" @text="open modal" />
218+
<D.Interactive id="test-interactive" {{on "click" (set this "showModal" true)}}>open modal</D.Interactive>
219+
</Hds::Dropdown>
220+
{{#if this.showModal}}
221+
<Hds::Modal id="test-modal" as |M|>
222+
<M.Header>Title</M.Header>
223+
</Hds::Modal>
224+
{{/if}}
225+
`
226+
);
227+
await click('#test-toggle');
228+
await click('#test-interactive');
229+
assert.true(this.showModal);
230+
await click('button.hds-modal__dismiss');
231+
assert.dom('body', 'body').isFocused();
232+
});
233+
234+
test('it returns focus to a specific element if provided via`@returnFocusTo`', async function (assert) {
235+
await render(
236+
hbs`<Hds::Dropdown as |D|>
237+
<D.ToggleButton id="test-toggle" @text="open modal" />
238+
<D.Interactive id="test-interactive" {{on "click" (set this "showModal" true)}}>open modal</D.Interactive>
239+
</Hds::Dropdown>
240+
{{#if this.showModal}}
241+
<Hds::Modal id="test-modal" @returnFocusTo="test-toggle" as |M|>
242+
<M.Header>Title</M.Header>
243+
</Hds::Modal>
244+
{{/if}}
245+
`
246+
);
247+
await click('#test-toggle');
248+
await click('#test-interactive');
249+
assert.true(this.showModal);
250+
await click('button.hds-modal__dismiss');
251+
assert.dom('#test-toggle').isFocused();
252+
});
253+
197254
// CALLBACKS
198255

199256
test('it should call `onOpen` function if provided', async function (assert) {

yarn.lock

+11
Original file line numberDiff line numberDiff line change
@@ -13547,6 +13547,16 @@ __metadata:
1354713547
languageName: node
1354813548
linkType: hard
1354913549

13550+
"ember-set-helper@npm:^3.0.1":
13551+
version: 3.0.1
13552+
resolution: "ember-set-helper@npm:3.0.1"
13553+
dependencies:
13554+
"@embroider/addon-shim": "npm:^1.8.7"
13555+
decorator-transforms: "npm:^1.0.1"
13556+
checksum: 10/c8a4204602e067835251f8b3bbb4e6b6cc762f054ebd43fbfe44a13cef40248466f4f2f6df310d1cd0490feeff18a013ac8e3b50322e884e8d008d9f1ddaa9cb
13557+
languageName: node
13558+
linkType: hard
13559+
1355013560
"ember-source-channel-url@npm:^3.0.0":
1355113561
version: 3.0.0
1355213562
resolution: "ember-source-channel-url@npm:3.0.0"
@@ -24902,6 +24912,7 @@ __metadata:
2490224912
ember-power-select: "npm:^8.2.0"
2490324913
ember-qunit: "npm:^8.1.0"
2490424914
ember-resolver: "npm:^11.0.1"
24915+
ember-set-helper: "npm:^3.0.1"
2490524916
ember-source: "npm:~5.9.0"
2490624917
ember-source-channel-url: "npm:^3.0.0"
2490724918
ember-style-modifier: "npm:^4.4.0"

0 commit comments

Comments
 (0)