Skip to content

Commit

Permalink
feat(overlays): add contentElementToFocus config option to change the…
Browse files Browse the repository at this point in the history
… content element that receives focus on show
  • Loading branch information
gerjanvangeest committed Nov 19, 2024
1 parent 0bee6e3 commit 9fb5fc8
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/famous-books-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lion/ui': patch
---

[overlays] add contentElementToFocus config option to change the content element that receives focus on show
149 changes: 148 additions & 1 deletion docs/fundamentals/systems/overlays/configuration.md
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`
<h1 class="header">
<slot name="frame-header"></slot>
<button type="button" @click="${() => this.dispatchEvent(new Event('close-overlay'))}">
</button>
</h1>
<slot name="frame-content"></slot>
`;
}
}

class MyOverlay extends OverlayMixin(LitElement) {
_defineOverlayConfig() {
return {
...withModalDialogConfig(),
contentElementToFocus: this._overlayContentNode?.bodyNode,
};
}

render() {
return html`
<slot name="invoker"></slot>
<div id="overlay-content-node-wrapper">
<slot name="content"></slot>
</div>
`;
}
}
customElements.define('my-overlay-frame', MyOverlayFrame);
customElements.define('my-overlay', MyOverlay);

export const contentElementToFocus = () => {
return previewHtml`
<my-overlay>
<button slot="invoker">Click me to open the overlay!</button>
<my-overlay-frame slot="content">
<span slot="frame-header">Heading</span>
<div slot="frame-content">
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
</div>
</my-overlay-frame>
</my-overlay>
`;
};
```
## elementToFocusAfterHide
HTMLElement. Will `.focus()` the HTMLElement passed to this property. By default, this is the `invokerNode`.
Expand Down
27 changes: 22 additions & 5 deletions packages/ui/components/overlays/src/OverlayController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -299,15 +316,15 @@ 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() {
return /** @type {boolean} */ (this.config?.isBlocking);
}

/**
* 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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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}`;
}

Expand Down Expand Up @@ -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);
Expand Down
17 changes: 11 additions & 6 deletions packages/ui/components/overlays/src/utils/contain-focus.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,26 +69,31 @@ 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} */
let rootElementMutationObserver;

// 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
Expand Down
53 changes: 53 additions & 0 deletions packages/ui/components/overlays/test/OverlayController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,59 @@ describe('OverlayController', () => {
});
});

describe('contentElementToFocus', () => {
it('focuses root of the content by default', async () => {
const contentNode = /** @type {HTMLElement} */ (await fixture('<div><input /></div>'));
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('<div><div id="content"><input /><div></div>')
);
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('<div><input autofocus /></div>')
);
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('<div><input /></div>'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function simulateTabInWindow(elToRecieveFocus) {
}

const interactionElementsNode = renderLitAsNode(html`
<div>
<div id="contentEl">
<button id="el1">Button</button>
<a id="el2" href="#">foo</a>
<div id="el3" tabindex="0"></div>
Expand Down Expand Up @@ -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]'));
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/components/overlays/types/OverlayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down

0 comments on commit 9fb5fc8

Please sign in to comment.