Skip to content

Commit

Permalink
Prepare version 6.1.2
Browse files Browse the repository at this point in the history
  • Loading branch information
KittyGiraudel committed Jul 22, 2023
1 parent 55a1688 commit c0c9b9e
Show file tree
Hide file tree
Showing 10 changed files with 328 additions and 1,815 deletions.
56 changes: 56 additions & 0 deletions cypress/fixtures/a11y-dialog.d.ts
@@ -0,0 +1,56 @@
export type A11yDialogEvent = 'show' | 'hide' | 'destroy';
export type A11yDialogInstance = InstanceType<typeof A11yDialog>;
export default class A11yDialog {
private $el;
private id;
private previouslyFocused;
shown: boolean;
constructor(element: HTMLElement);
/**
* Destroy the current instance (after making sure the dialog has been hidden)
* and remove all associated listeners from dialog openers and closers
*/
destroy(): A11yDialogInstance;
/**
* Show the dialog element, trap the current focus within it, listen for some
* specific key presses and fire all registered callbacks for `show` event
*/
show(event?: Event): A11yDialogInstance;
/**
* Hide the dialog element, restore the focus to the previously active
* element, stop listening for some specific key presses and fire all
* registered callbacks for `hide` event
*/
hide(event?: Event): A11yDialogInstance;
/**
* Register a new callback for the given event type
*/
on(type: A11yDialogEvent, handler: EventListener, options?: AddEventListenerOptions): A11yDialogInstance;
/**
* Unregister an existing callback for the given event type
*/
off(type: A11yDialogEvent, handler: EventListener, options?: AddEventListenerOptions): A11yDialogInstance;
/**
* Dispatch a custom event from the DOM element associated with this dialog.
* This allows authors to listen for and respond to the events in their own
* code
*/
private fire;
/**
* Add a delegated event listener for when elememts that open or close the
* dialog are clicked, and call `show` or `hide`, respectively
*/
private handleTriggerClicks;
/**
* Private event handler used when listening to some specific key presses
* (namely ESC and TAB)
*/
private bindKeypress;
/**
* If the dialog is shown and the focus is not within a dialog element (either
* this one or another one in case of nested dialogs) or attribute, move it
* back to the dialog container
* See: https://github.com/KittyGiraudel/a11y-dialog/issues/177
*/
private maintainFocus;
}
19 changes: 19 additions & 0 deletions cypress/fixtures/dom-utils.d.ts
@@ -0,0 +1,19 @@
/**
* Set the focus to the first element with `autofocus` with the element or the
* element itself.
*/
export declare function moveFocusToDialog(el: HTMLElement): void;
/**
* Get the first and last focusable elements in a given tree.
*/
export declare function getFocusableEdges(el: HTMLElement): readonly [HTMLElement | null, HTMLElement | null];
/**
* Get the active element, accounting for Shadow DOM subtrees.
* @author Cory LaViska
* @see: https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/
*/
export declare function getActiveElement(root?: Document | ShadowRoot): Element | null;
/**
* Trap the focus inside the given element
*/
export declare function trapTabKey(el: HTMLElement, event: KeyboardEvent): void;
214 changes: 214 additions & 0 deletions cypress/fixtures/dom-utils.js
@@ -0,0 +1,214 @@
const not = {
inert: ':not([inert]):not([inert] *)',
negTabIndex: ':not([tabindex^="-"])',
disabled: ':not(:disabled)',
};

var focusableSelectors = [
`a[href]${not.inert}${not.negTabIndex}`,
`area[href]${not.inert}${not.negTabIndex}`,
`input:not([type="hidden"]):not([type="radio"])${not.inert}${not.negTabIndex}${not.disabled}`,
`input[type="radio"]${not.inert}${not.negTabIndex}${not.disabled}`,
`select${not.inert}${not.negTabIndex}${not.disabled}`,
`textarea${not.inert}${not.negTabIndex}${not.disabled}`,
`button${not.inert}${not.negTabIndex}${not.disabled}`,
`details${not.inert} > summary:first-of-type${not.negTabIndex}`,
// Discard until Firefox supports `:has()`
// See: https://github.com/KittyGiraudel/focusable-selectors/issues/12
// `details:not(:has(> summary))${not.inert}${not.negTabIndex}`,
`iframe${not.inert}${not.negTabIndex}`,
`audio[controls]${not.inert}${not.negTabIndex}`,
`video[controls]${not.inert}${not.negTabIndex}`,
`[contenteditable]${not.inert}${not.negTabIndex}`,
`[tabindex]${not.inert}${not.negTabIndex}`,
];

/**
* Set the focus to the first element with `autofocus` with the element or the
* element itself.
*/
function moveFocusToDialog(el) {
const focused = (el.querySelector('[autofocus]') || el);
focused.focus();
}
/**
* Get the first and last focusable elements in a given tree.
*/
function getFocusableEdges(el) {
// Check for a focusable element within the subtree of `el`.
const first = findFocusableElement(el, true);
// Only if we find the first element do we need to look for the last one. If
// there’s no last element, we set `last` as a reference to `first` so that
// the returned array is always of length 2.
const last = first ? findFocusableElement(el, false) || first : null;
return [first, last];
}
/**
* Find the first focusable element inside the given node if `forward` is truthy
* or the last focusable element otherwise.
*/
function findFocusableElement(node, forward) {
// If we’re walking forward, check if this node is focusable, and return it
// immediately if it is.
if (forward && isFocusable(node))
return node;
// We should only search the subtree of this node if it can have focusable
// children.
if (canHaveFocusableChildren(node)) {
// Start walking the DOM tree, looking for focusable elements.
// Case 1: If this node has a shadow root, search it recursively.
if (node.shadowRoot) {
// Descend into this subtree.
let next = getNextChildEl(node.shadowRoot, forward);
// Traverse siblings, searching the subtree of each one
// for focusable elements.
while (next) {
const focusableEl = findFocusableElement(next, forward);
if (focusableEl)
return focusableEl;
next = getNextSiblingEl(next, forward);
}
}
// Case 2: If this node is a slot for a Custom Element, search its assigned
// nodes recursively.
else if (node.localName === 'slot') {
const assignedElements = node.assignedElements({
flatten: true,
});
if (!forward)
assignedElements.reverse();
for (const assignedElement of assignedElements) {
const focusableEl = findFocusableElement(assignedElement, forward);
if (focusableEl)
return focusableEl;
}
}
// Case 3: this is a regular Light DOM node. Search its subtree.
else {
// Descend into this subtree.
let next = getNextChildEl(node, forward);
// Traverse siblings, searching the subtree of each one
// for focusable elements.
while (next) {
const focusableEl = findFocusableElement(next, forward);
if (focusableEl)
return focusableEl;
next = getNextSiblingEl(next, forward);
}
}
}
// If we’re walking backward, we want to check the node’s entire subtree
// before checking the node itself. If this node is focusable, return it.
if (!forward && isFocusable(node))
return node;
return null;
}
function getNextChildEl(node, forward) {
return forward ? node.firstElementChild : node.lastElementChild;
}
function getNextSiblingEl(el, forward) {
return forward ? el.nextElementSibling : el.previousElementSibling;
}
/**
* Determine if an element is hidden from the user.
*/
const isHidden = (el) => {
// Browsers hide all non-<summary> descendants of closed <details> elements
// from user interaction, but those non-<summary> elements may still match our
// focusable-selectors and may still have dimensions, so we need a special
// case to ignore them.
if (el.matches('details:not([open]) *') &&
!el.matches('details>summary:first-of-type'))
return true;
// If this element has no painted dimensions, it's hidden.
return !(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
};
/**
* Determine if an element is focusable and has user-visible painted dimensions.
*/
const isFocusable = (el) => {
// A shadow host that delegates focus will never directly receive focus,
// even with `tabindex=0`. Consider our <fancy-button> custom element, which
// delegates focus to its shadow button:
//
// <fancy-button tabindex="0">
// #shadow-root
// <button><slot></slot></button>
// </fancy-button>
//
// The browser acts as as if there is only one focusable element – the shadow
// button. Our library should behave the same way.
if (el.shadowRoot?.delegatesFocus)
return false;
return el.matches(focusableSelectors.join(',')) && !isHidden(el);
};
/**
* Determine if an element can have focusable children. Useful for bailing out
* early when walking the DOM tree.
* @example
* This div is inert, so none of its children can be focused, even though they
* meet our criteria for what is focusable. Once we check the div, we can skip
* the rest of the subtree.
* ```html
* <div inert>
* <button>Button</button>
* <a href="#">Link</a>
* </div>
* ```
*/
function canHaveFocusableChildren(el) {
// The browser will never send focus into a Shadow DOM if the host element
// has a negative tabindex. This applies to both slotted Light DOM Shadow DOM
// children
if (el.shadowRoot && el.getAttribute('tabindex') === '-1')
return false;
// Elemments matching this selector are either hidden entirely from the user,
// or are visible but unavailable for interaction. Their descentants can never
// receive focus.
return !el.matches(':disabled,[hidden],[inert]');
}
/**
* Get the active element, accounting for Shadow DOM subtrees.
* @author Cory LaViska
* @see: https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/
*/
function getActiveElement(root = document) {
const activeEl = root.activeElement;
if (!activeEl)
return null;
// If there’s a shadow root, recursively find the active element within it.
// If the recursive call returns null, return the active element
// of the top-level Document.
if (activeEl.shadowRoot)
return getActiveElement(activeEl.shadowRoot) || document.activeElement;
// If not, we can just return the active element
return activeEl;
}
/**
* Trap the focus inside the given element
*/
function trapTabKey(el, event) {
const [firstFocusableChild, lastFocusableChild] = getFocusableEdges(el);
// If there are no focusable children in the dialog, prevent the user from
// tabbing out of it
if (!firstFocusableChild)
return event.preventDefault();
const activeElement = getActiveElement();
// If the SHIFT key is pressed while tabbing (moving backwards) and the
// currently focused item is the first one, move the focus to the last
// focusable item from the dialog element
if (event.shiftKey && activeElement === firstFocusableChild) {
// @ts-ignore: we know that `lastFocusableChild` is not null here
lastFocusableChild.focus();
event.preventDefault();
}
// If the SHIFT key is not pressed (moving forwards) and the currently focused
// item is the last one, move the focus to the first focusable item from the
// dialog element
else if (!event.shiftKey && activeElement === lastFocusableChild) {
firstFocusableChild.focus();
event.preventDefault();
}
}

export { getActiveElement, getFocusableEdges, moveFocusToDialog, trapTabKey };
2 changes: 2 additions & 0 deletions cypress/fixtures/index.d.ts
@@ -0,0 +1,2 @@
import A11yDialog from './a11y-dialog';
export default A11yDialog;
2 changes: 1 addition & 1 deletion dist/a11y-dialog.esm.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c0c9b9e

Please sign in to comment.