Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: check <slot> elements for assignedElements to allow wrapping focus-trapped elements #1537

Merged
merged 12 commits into from
Aug 23, 2023
1 change: 1 addition & 0 deletions docs/pages/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti

- Added support for submenus in `<sl-menu-item>` [#1410]
- Added the `--submenu-offset` custom property to `<sl-menu-item>` [#1410]
- Fixed an issue with focus trapping elements like `<sl-dialog>` when wrapped by other elements not checking the assigned elements of `<slot>`s. [#1537]
- Fixed type issues with the `ref` attribute in React Wrappers. [#1526]
- Fixed a regression that caused `<sl-radio-button>` to render incorrectly with gaps [#1523]
- Improved expand/collapse behavior of `<sl-tree>` to work more like users expect [#1521]
Expand Down
22 changes: 22 additions & 0 deletions src/internal/active-elements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Use a generator so we can iterate and possibly break early.
* @example
* // to operate like a regular array. This kinda nullifies generator benefits, but worth knowing if you need the whole array.
* const allActiveElements = [...activeElements()]
*
* // Early return
* for (const activeElement of activeElements()) {
* if (<cond>) {
* break; // Break the loop, dont need to iterate over the whole array or store an array in memory!
* }
* }
*/
export function* activeElements(activeElement: Element | null = document.activeElement): Generator<Element> {
if (activeElement === null || activeElement === undefined) return;

yield activeElement;

if ('shadowRoot' in activeElement && activeElement.shadowRoot && activeElement.shadowRoot.mode !== 'closed') {
yield* activeElements(activeElement.shadowRoot.activeElement);
}
}
20 changes: 19 additions & 1 deletion src/internal/modal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { activeElements } from './active-elements.js';
import { getTabbableElements } from './tabbable.js';

let activeModals: HTMLElement[] = [];
Expand Down Expand Up @@ -55,6 +56,20 @@ export default class Modal {
return getTabbableElements(this.element).findIndex(el => el === this.currentFocus);
}

/**
* Checks if the `startElement` is already focused. This is important if the modal already
* has an existing focused prior to the first tab key.
KonnorRogers marked this conversation as resolved.
Show resolved Hide resolved
*/
startElementAlreadyFocused(startElement: HTMLElement) {
for (const activeElement of activeElements()) {
if (startElement === activeElement) {
return true;
}
}

return false;
}

handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return;

Expand All @@ -68,7 +83,10 @@ export default class Modal {

const tabbableElements = getTabbableElements(this.element);
const start = tabbableElements[0];
let focusIndex = this.currentFocusIndex;

// Sometimes we programmatically focus the first element in a modal.
// Lets make sure the start element isn't already focused.
let focusIndex = this.startElementAlreadyFocused(start) ? 0 : this.currentFocusIndex;

if (focusIndex === -1) {
this.currentFocus = start;
Expand Down
147 changes: 147 additions & 0 deletions src/internal/tabbable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { elementUpdated, expect, fixture } from '@open-wc/testing';

import '../../dist/shoelace.js';
import { activeElements } from './active-elements.js';
import { html } from 'lit';
import { sendKeys } from '@web/test-runner-commands';

async function holdShiftKey(callback: () => Promise<void>) {
await sendKeys({ down: 'Shift' });
await callback();
await sendKeys({ up: 'Shift' });
}

const tabKey =
navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('HeadlessChrome') ? 'Alt+Tab' : 'Tab';

// Simple helper to turn the activeElements generator into an array
function activeElementsArray() {
return [...activeElements()];
}

function getDeepestActiveElement() {
return activeElementsArray().pop();
}

window.customElements.define(
'tab-test-1',
class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot!.innerHTML = `
<sl-drawer>
<slot name="label" slot="label"></slot>

<slot></slot>

<slot name="footer" slot="footer"></slot>
</sl-drawer>
`;
}
}
);

it('Should allow tabbing to slotted elements', async () => {
const el = await fixture(html`
<tab-test-1>
<div slot="label">
<sl-button id="focus-1">Focus 1</sl-button>
</div>

<div>
<!-- Focus 2 lives as the close-button from <sl-drawer> -->
<sl-button id="focus-3">Focus 3</sl-button>
<button id="focus-4">Focus 4</sl-button>
<input id="focus-5" value="Focus 5">
</div>

<div slot="footer">
<div id="focus-6" tabindex="0">Focus 6</div>
<button tabindex="-1">No Focus</button>
</div>
</tab-test-1>
`);

const drawer = el.shadowRoot?.querySelector('sl-drawer');

if (drawer === null || drawer === undefined) throw Error('Could not find drawer inside of the test element');

await drawer.show();

await elementUpdated(drawer);

const focusZero = drawer.shadowRoot?.querySelector("[role='dialog']");

if (focusZero === null || focusZero === undefined) throw Error('Could not find dialog panel inside <sl-drawer>');

const focusOne = el.querySelector('#focus-1');
const focusTwo = drawer.shadowRoot?.querySelector("[part~='close-button']");

if (focusTwo === null || focusTwo === undefined) throw Error('Could not find close button inside <sl-drawer>');

const focusThree = el.querySelector('#focus-3');
const focusFour = el.querySelector('#focus-4');
const focusFive = el.querySelector('#focus-5');
const focusSix = el.querySelector('#focus-6');

// When we open drawer, we should be focused on the panel to start.
expect(getDeepestActiveElement()).to.equal(focusZero);

await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusOne);

// When we hit the <Tab> key we should go to the "close button" on the drawer
await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusTwo);

await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusThree);

await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusFour);

await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusFive);

await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusSix);

// Now we should loop back to #panel
await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusZero);

// Now we should loop back to #panel
await sendKeys({ press: tabKey });
expect(activeElementsArray()).to.include(focusOne);

// Let's reset and try from starting point 0 and go backwards.
await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusZero);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusSix);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusFive);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusFour);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusThree);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusTwo);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusOne);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusZero);

await holdShiftKey(async () => await sendKeys({ press: tabKey }));
expect(activeElementsArray()).to.include(focusSix);
});
44 changes: 37 additions & 7 deletions src/internal/tabbable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,36 @@ export function getTabbableBoundary(root: HTMLElement | ShadowRoot) {

export function getTabbableElements(root: HTMLElement | ShadowRoot) {
const allElements: HTMLElement[] = [];
const tabbableElements: HTMLElement[] = [];

function walk(el: HTMLElement | ShadowRoot) {
if (el instanceof Element) {
allElements.push(el);
// if the element has "inert" we can just no-op it.
if (el.hasAttribute('inert')) {
return;
}

if (!allElements.includes(el)) {
allElements.push(el);
}

if (!tabbableElements.includes(el) && isTabbable(el)) {
tabbableElements.push(el);
}

/**
* This looks funky. Basically a slots children will always be picked up *if* they're within the `root` element.
KonnorRogers marked this conversation as resolved.
Show resolved Hide resolved
* However, there is an edge case if the `root` is wrapped by another shadowDOM, it won't grab the children.
KonnorRogers marked this conversation as resolved.
Show resolved Hide resolved
* This fixes that fun edge case.
*/
const slotChildrenOutsideRootElement = (slotElement: HTMLSlotElement) =>
(slotElement.getRootNode({ composed: true }) as ShadowRoot | null)?.host !== root;

if (el instanceof HTMLSlotElement && slotChildrenOutsideRootElement(el)) {
el.assignedElements({ flatten: true }).forEach((assignedEl: HTMLElement) => {
walk(assignedEl);
});
}

if (el.shadowRoot !== null && el.shadowRoot.mode === 'open') {
walk(el.shadowRoot);
Expand All @@ -86,10 +112,14 @@ export function getTabbableElements(root: HTMLElement | ShadowRoot) {
// Collect all elements including the root
walk(root);

return allElements.filter(isTabbable).sort((a, b) => {
// Make sure we sort by tabindex.
const aTabindex = Number(a.getAttribute('tabindex')) || 0;
const bTabindex = Number(b.getAttribute('tabindex')) || 0;
return bTabindex - aTabindex;
});
return tabbableElements;

// Is this worth having? Most sorts will always add increased overhead. And positive tabindexes shouldn't really be used.
// So is it worth being right? Or fast?
// return allElements.filter(isTabbable).sort((a, b) => {
// // Make sure we sort by tabindex.
// const aTabindex = Number(a.getAttribute('tabindex')) || 0;
// const bTabindex = Number(b.getAttribute('tabindex')) || 0;
// return bTabindex - aTabindex;
// });
}