From 9fb5fc88ee65755036b8afa6aaf63b180251f142 Mon Sep 17 00:00:00 2001 From: gerjanvangeest Date: Tue, 19 Nov 2024 10:09:25 +0100 Subject: [PATCH] feat(overlays): add contentElementToFocus config option to change the content element that receives focus on show --- .changeset/famous-books-drop.md | 5 + .../systems/overlays/configuration.md | 149 +++++++++++++++++- .../overlays/src/OverlayController.js | 27 +++- .../overlays/src/utils/contain-focus.js | 17 +- .../overlays/test/OverlayController.test.js | 53 +++++++ .../test/utils-tests/contain-focus.test.js | 13 +- .../overlays/types/OverlayConfig.ts | 6 +- 7 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 .changeset/famous-books-drop.md diff --git a/.changeset/famous-books-drop.md b/.changeset/famous-books-drop.md new file mode 100644 index 0000000000..eeeddb18ab --- /dev/null +++ b/.changeset/famous-books-drop.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': patch +--- + +[overlays] add contentElementToFocus config option to change the content element that receives focus on show diff --git a/docs/fundamentals/systems/overlays/configuration.md b/docs/fundamentals/systems/overlays/configuration.md index ec7b91e4af..9a5d447fca 100644 --- a/docs/fundamentals/systems/overlays/configuration.md +++ b/docs/fundamentals/systems/overlays/configuration.md @@ -1,7 +1,7 @@ # Systems >> Overlays >> Configuration ||40 ```js script -import { html } from '@mdjs/mdjs-preview'; +import { html as previewHtml } from '@mdjs/mdjs-preview'; import './assets/demo-el-using-overlaymixin.mjs'; import './assets/applyDemoOverlayStyles.mjs'; import { withDropdownConfig, withModalDialogConfig, withTooltipConfig } from '@lion/ui/overlays.js'; @@ -220,6 +220,153 @@ export const hidesOnOutsideClick = () => { }; ``` +## contentElementToFocus + +In case of an sticky element inside an overlay with a lot of content you can set `contentElementToFocus` to make sure the scrollable element gets focused. + +```js preview-story +import { css, html, LitElement } from 'lit'; +import { uuid } from '@lion/ui/core.js'; +import { OverlayMixin } from '@lion/ui/overlays.js'; + +class MyOverlayFrame extends LitElement { + static get styles() { + return css` + :host { + display: flex; + flex-direction: column; + max-width: 600px; + max-height: 600px; + overflow: hidden; + background-color: white; + border: 1px solid black; + } + .header { + display: flex; + justify-content: space-between; + padding: 10px; + } + :host ::slotted([slot='frame-content']) { + overflow-y: auto; + padding: 10px; + } + `; + } + + get headerNode() { + return this.querySelector('[slot="frame-header"]'); + } + + get bodyNode() { + return this.querySelector('[slot="frame-content"]'); + } + + connectedCallback() { + super.connectedCallback(); + const frameId = uuid(); + this.headerNode.setAttribute('id', `frame-header-${frameId}`); + this.bodyNode.setAttribute('id', `frame-body-${frameId}`); + this.bodyNode.setAttribute('aria-labelledby', `frame-header-${frameId}`); + this.bodyNode.setAttribute('aria-describedby', `frame-body-${frameId}`); + } + + render() { + return html` +

+ + +

+ + `; + } +} + +class MyOverlay extends OverlayMixin(LitElement) { + _defineOverlayConfig() { + return { + ...withModalDialogConfig(), + contentElementToFocus: this._overlayContentNode?.bodyNode, + }; + } + + render() { + return html` + +
+ +
+ `; + } +} +customElements.define('my-overlay-frame', MyOverlayFrame); +customElements.define('my-overlay', MyOverlay); + +export const contentElementToFocus = () => { + return previewHtml` + + + + Heading +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Pulvinar mattis nunc sed blandit. Tellus + elementum sagittis vitae et. Orci sagittis eu volutpat odio facilisis mauris sit amet + massa. Scelerisque in dictum non consectetur a erat. In mollis nunc sed id semper risus + in. Ac ut consequat semper viverra. Dignissim cras tincidunt lobortis feugiat vivamus at + augue eget. Ultrices neque ornare aenean euismod elementum nisi. Lorem sed risus + ultricies tristique nulla aliquet enim tortor at. Rhoncus est pellentesque elit + ullamcorper dignissim cras tincidunt lobortis feugiat. +

+

+ Sagittis id consectetur purus ut faucibus pulvinar elementum integer. Ante in nibh + mauris cursus. Et netus et malesuada fames ac turpis. Id eu nisl nunc mi ipsum. Sagittis + orci a scelerisque purus. Placerat vestibulum lectus mauris ultrices eros in. In est + ante in nibh mauris cursus mattis. Sem viverra aliquet eget sit. Vulputate ut pharetra + sit amet aliquam id. Eu facilisis sed odio morbi quis commodo. +

+

+ Semper eget duis at tellus at urna. Diam quis enim lobortis scelerisque fermentum dui + faucibus in. Risus in hendrerit gravida rutrum quisque non tellus orci. Est ante in nibh + mauris cursus mattis molestie a iaculis. Semper quis lectus nulla at volutpat diam ut. + At auctor urna nunc id cursus metus aliquam eleifend. Dui faucibus in ornare quam + viverra. Netus et malesuada fames ac turpis egestas. Placerat in egestas erat imperdiet + sed euismod nisi porta. Volutpat lacus laoreet non curabitur gravida arcu ac tortor. + Donec et odio pellentesque diam volutpat commodo. In tellus integer feugiat scelerisque + varius morbi enim. Rhoncus mattis rhoncus urna neque viverra justo nec ultrices dui. A + cras semper auctor neque vitae tempus quam. Ipsum a arcu cursus vitae congue mauris. + Commodo ullamcorper a lacus vestibulum. +

+

+ Vulputate ut pharetra sit amet aliquam. Neque laoreet suspendisse interdum consectetur + libero id. Amet commodo nulla facilisi nullam vehicula ipsum a arcu. Risus viverra + adipiscing at in tellus. Cum sociis natoque penatibus et. Morbi tempus iaculis urna id + volutpat lacus. Integer enim neque volutpat ac tincidunt vitae semper quis. Elementum + sagittis vitae et leo duis ut diam quam nulla. Magna sit amet purus gravida quis blandit + turpis cursus. Maecenas pharetra convallis posuere morbi leo urna. Quis imperdiet massa + tincidunt nunc pulvinar sapien et ligula ullamcorper. Ullamcorper a lacus vestibulum sed + arcu non. +

+

+ Donec massa sapien faucibus et. Sed pulvinar proin gravida hendrerit lectus. Odio + facilisis mauris sit amet massa vitae tortor. Pharetra convallis posuere morbi leo urna + molestie at. Volutpat odio facilisis mauris sit amet massa vitae tortor condimentum. + Lobortis feugiat vivamus at augue eget arcu dictum. Morbi enim nunc faucibus a + pellentesque sit amet porttitor. Duis convallis convallis tellus id interdum velit + laoreet id donec. Montes nascetur ridiculus mus mauris. Et netus et malesuada fames ac + turpis egestas. Lacus sed turpis tincidunt id aliquet risus feugiat in. Sem integer + vitae justo eget magna fermentum. Purus in massa tempor nec. Elementum curabitur vitae + nunc sed velit dignissim. Felis imperdiet proin fermentum leo vel orci porta non. +

+
+
+
+ `; +}; +``` + ## elementToFocusAfterHide HTMLElement. Will `.focus()` the HTMLElement passed to this property. By default, this is the `invokerNode`. diff --git a/packages/ui/components/overlays/src/OverlayController.js b/packages/ui/components/overlays/src/OverlayController.js index 9879c49ef6..bbe546dd11 100644 --- a/packages/ui/components/overlays/src/OverlayController.js +++ b/packages/ui/components/overlays/src/OverlayController.js @@ -141,6 +141,8 @@ export class OverlayController extends EventTarget { invokerNode: config.invokerNode, backdropNode: config.backdropNode, referenceNode: undefined, + contentBodyShouldBeFocusable: false, + elementToFocusByDefaultOnShow: config.contentNode, elementToFocusAfterHide: config.invokerNode, inheritsReferenceWidth: 'none', hasBackdrop: false, @@ -232,6 +234,13 @@ export class OverlayController extends EventTarget { return this.config?.placementMode; } + /** + * @type {boolean} + */ + get contentBodyShouldBeFocusable() { + return this.config?.contentBodyShouldBeFocusable || false; + } + /** * The interactive element (usually a button) invoking the dialog or tooltip * @type {HTMLElement | undefined} @@ -280,6 +289,14 @@ export class OverlayController extends EventTarget { return /** @type {HTMLElement} */ (this.__backdropNode || this.config?.backdropNode); } + /** + * The element that should be called `.focus()` on show if no autofocus is set + * @type {HTMLElement} + */ + get contentElementToFocus() { + return /** @type {HTMLElement} */ (this.config?.contentElementToFocus || this.contentNode); + } + /** * The element that should be called `.focus()` on after dialog closes * @type {HTMLElement} @@ -299,7 +316,7 @@ export class OverlayController extends EventTarget { } /** - * Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController) + * Hides other overlays when multiple are opened (currently exclusive to globalOverlayController) * @type {boolean} */ get isBlocking() { @@ -307,7 +324,7 @@ export class OverlayController extends EventTarget { } /** - * Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController) + * Hides other overlays when multiple are opened (currently exclusive to globalOverlayController) * @type {boolean} */ get preventsScroll() { @@ -331,7 +348,7 @@ export class OverlayController extends EventTarget { } /** - * Hides the overlay when clicking next to it, exluding invoker + * Hides the overlay when clicking next to it, excluding invoker * @type {boolean} */ get hidesOnOutsideClick() { @@ -424,7 +441,7 @@ export class OverlayController extends EventTarget { * @param {number} value */ set elevation(value) { - // @ts-expect-error find out why config would/could be undfined + // @ts-expect-error find out why config would/could be undefined this.__wrappingDialogNode.style.zIndex = `${this.config.zIndex + value}`; } @@ -1114,7 +1131,7 @@ export class OverlayController extends EventTarget { if (this.manager) { this.manager.disableTrapsKeyboardFocusForAll(); } - this._containFocusHandler = containFocus(this.contentNode); + this._containFocusHandler = containFocus(this.contentNode, this.contentElementToFocus); this.__hasActiveTrapsKeyboardFocus = true; if (this.manager) { this.manager.informTrapsKeyboardFocusGotEnabled(this.placementMode); diff --git a/packages/ui/components/overlays/src/utils/contain-focus.js b/packages/ui/components/overlays/src/utils/contain-focus.js index 3fdf6e0c00..ea56baabc1 100644 --- a/packages/ui/components/overlays/src/utils/contain-focus.js +++ b/packages/ui/components/overlays/src/utils/contain-focus.js @@ -69,12 +69,18 @@ export function rotateFocus(rootElement, e) { * focusable element. * * @param {HTMLElement} rootElement The element to contain focus within + * @param {HTMLElement} [contentElementToFocus] The element to set focus on * @returns {{ disconnect: () => void }} handler with a disconnect callback */ -export function containFocus(rootElement) { +export function containFocus(rootElement, contentElementToFocus) { + if (!contentElementToFocus) { + contentElementToFocus = rootElement; + } + const focusableElements = getFocusableElements(rootElement); // Initial focus goes to first element with autofocus, or the root element - const initialFocus = focusableElements.find(e => e.hasAttribute('autofocus')) || rootElement; + const initialFocus = + focusableElements.find(e => e.hasAttribute('autofocus')) || contentElementToFocus; /** @type {HTMLElement} */ let tabDetectionElement; /** @type {MutationObserver} */ @@ -82,13 +88,12 @@ export function containFocus(rootElement) { // If root element will receive focus, it should have a tabindex of -1. // This makes it focusable through js, but it won't appear in the tab order - if (initialFocus === rootElement) { - rootElement.tabIndex = -1; - rootElement.style.setProperty('outline', 'none'); - } + contentElementToFocus.tabIndex = rootElement === contentElementToFocus ? -1 : 0; + rootElement.style.setProperty('outline', 'none'); // Focus first focusable element initialFocus.focus(); + initialFocus.scrollTo(0, 0); /** * Ensures focus stays inside root element on tab diff --git a/packages/ui/components/overlays/test/OverlayController.test.js b/packages/ui/components/overlays/test/OverlayController.test.js index 353374002c..cdb7e1da6e 100644 --- a/packages/ui/components/overlays/test/OverlayController.test.js +++ b/packages/ui/components/overlays/test/OverlayController.test.js @@ -1094,6 +1094,59 @@ describe('OverlayController', () => { }); }); + describe('contentElementToFocus', () => { + it('focuses root of the content by default', async () => { + const contentNode = /** @type {HTMLElement} */ (await fixture('
')); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + viewportConfig: { + placement: 'top-left', + }, + contentNode, + }); + + await ctrl.show(); + expect(document.activeElement).to.equal(contentNode); + }); + + it('focuses element defined by contentElementToFocus by if provided', async () => { + const contentNode = /** @type {HTMLElement} */ ( + await fixture('
') + ); + const contentElementToFocus = /** @type {HTMLElement} */ ( + contentNode.querySelector('#content') + ); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + viewportConfig: { + placement: 'top-left', + }, + contentNode, + contentElementToFocus, + }); + + await ctrl.show(); + expect(document.activeElement).to.equal(contentElementToFocus); + }); + + it('focuses on element with autofocus when available', async () => { + const contentNode = /** @type {HTMLElement} */ ( + await fixture('
') + ); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + viewportConfig: { + placement: 'top-left', + }, + contentNode, + }); + + await ctrl.show(); + const input = /** @type {HTMLInputElement} */ (contentNode.querySelector('input')); + expect(document.activeElement).to.equal(input); + }); + }); + describe('elementToFocusAfterHide', () => { it('focuses body when hiding by default', async () => { const contentNode = /** @type {HTMLElement} */ (await fixture('
')); diff --git a/packages/ui/components/overlays/test/utils-tests/contain-focus.test.js b/packages/ui/components/overlays/test/utils-tests/contain-focus.test.js index 2ad3c3fca1..3cc609d17b 100644 --- a/packages/ui/components/overlays/test/utils-tests/contain-focus.test.js +++ b/packages/ui/components/overlays/test/utils-tests/contain-focus.test.js @@ -24,7 +24,7 @@ function simulateTabInWindow(elToRecieveFocus) { } const interactionElementsNode = renderLitAsNode(html` -
+
foo
@@ -94,6 +94,17 @@ describe('containFocus()', () => { disconnect(); }); + it('starts focus at the specified content element when contentElementToFocus is given and there is no element with [autofocus]', async () => { + await fixture(lightDomTemplate); + const root = /** @type {HTMLElement} */ (document.getElementById('rootElement')); + const contentEl = /** @type {HTMLElement} */ (document.getElementById('contentEl')); + const { disconnect } = containFocus(root, contentEl); + + expect(getDeepActiveElement()).to.equal(contentEl); + expect(contentEl.getAttribute('tabindex')).to.equal('0'); + disconnect(); + }); + it('starts focus at the element with [autofocus] attribute', async () => { await fixture(lightDomAutofocusTemplate); const el = /** @type {HTMLElement} */ (document.querySelector('input[autofocus]')); diff --git a/packages/ui/components/overlays/types/OverlayConfig.ts b/packages/ui/components/overlays/types/OverlayConfig.ts index 6db671cd5f..8aba9b95b7 100644 --- a/packages/ui/components/overlays/types/OverlayConfig.ts +++ b/packages/ui/components/overlays/types/OverlayConfig.ts @@ -26,8 +26,10 @@ export interface OverlayConfig { contentNode?: HTMLElement; /** The wrapper element of contentNode, used to supply inline positioning styles. When a Popper arrow is needed, it acts as parent of the arrow node. Will be automatically created for global and non projected contentNodes. Required when used in shadow dom mode or when Popper arrow is supplied. Essential for allowing webcomponents to style their projected contentNodes */ contentWrapperNode?: HTMLElement; - /** The element that is placed behin the contentNode. When not provided and `hasBackdrop` is true, a backdropNode will be automatically created */ + /** The element that is placed behind the contentNode. When not provided and `hasBackdrop` is true, a backdropNode will be automatically created */ backdropNode?: HTMLElement; + /** The element that should be called `.focus()` on show if no autofocus is set */ + contentElementToFocus?: HTMLElement; /** The element that should be called `.focus()` on after dialog closes */ elementToFocusAfterHide?: HTMLElement; @@ -44,7 +46,7 @@ export interface OverlayConfig { trapsKeyboardFocus?: boolean; /** Hides the overlay when pressing [ esc ] */ hidesOnEsc?: boolean; - /** Hides the overlay when clicking next to it, exluding invoker */ + /** Hides the overlay when clicking next to it, excluding invoker */ hidesOnOutsideClick?: boolean; /** Hides the overlay when pressing esc, even when contentNode has no focus */ hidesOnOutsideEsc?: boolean;