Skip to content

Commit

Permalink
fix: check <slot> elements for assignedElements to allow wrapping f…
Browse files Browse the repository at this point in the history
…ocus-trapped elements (#1537)

* fix: internal logic for tabbable checks slotted elements

* prettier

* add better note for generators

* prettier

* fix tests

* prettier

* prettier

* fix tabbable test for safari

* prettier

* Update src/internal/tabbable.ts

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>

* Update src/internal/modal.ts

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>

* Update src/internal/tabbable.ts

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>

---------

Co-authored-by: Cory LaViska <cory@abeautifulsite.net>
  • Loading branch information
KonnorRogers and claviska committed Aug 23, 2023
1 parent 43d1f9e commit ae010c3
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 8 deletions.
1 change: 1 addition & 0 deletions docs/pages/resources/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
- Added `--isolatedModules` and `--verbatimModuleSyntax` to `tsconfig.json`. For anyone directly importing event types, they no longer provide a default export due to these options being enabled. For people using the `events/event.js` file directly, there is no change.
- 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 focus prior to the first tab key.
*/
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 slot's children will always be picked up *if* they're within the `root` element.
* However, there is an edge case when, if the `root` is wrapped by another shadow DOM, it won't grab the children.
* 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;
// });
}

0 comments on commit ae010c3

Please sign in to comment.