Skip to content

Commit

Permalink
feat: introduce FOCUS_NO_AUTOFOCUS
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed May 1, 2022
1 parent 2bc8ee3 commit 5c2dc8f
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 27 deletions.
2 changes: 1 addition & 1 deletion .size-limit
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[
{
path: "dist/es2015/index.js",
limit: "2.6 kB"
limit: "2.7 kB"
}
]
4 changes: 2 additions & 2 deletions .size.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"name": "dist/es2015/index.js",
"passed": true,
"size": 2653
"passed": false,
"size": 2726
}
]
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ This one is about managing the focus.

I'v got a good [article about focus management, dialogs and WAI-ARIA](https://medium.com/@antonkorzunov/its-a-focus-trap-699a04d66fb5).

# Declarative control

`Focus-lock` provides not only API to be called by some other scripts, but also a way one can leave instructions inside HTML markup
to amend focus behavior in a desired way.

These are data-attributes one can add on the elements:

- `data-focus-lock` to create a focus group (scattered focus)
- `data-focus-lock-disabled` marks such group as disables and removes from the list
- `data-no-focus-lock` focus-lock will ignore focus inside marked area
- `data-autofocus-inside` focus-lock will try to autofocus elements within selected area
- `data-no-autofocus` focus-lock will not autofocus any node within marked area

These markers are available as `import * as markers from 'focus-lock/constants'`

# Focus fighting

It is possible, that more that one "focus management system" is present on the site.
Expand Down
14 changes: 14 additions & 0 deletions __tests__/focusMerge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ describe('FocusMerge', () => {
expect(focusMerge(querySelector('#d1'), null)!.node.innerHTML).toBe('2');
});

it('autofocus - should pick first available tabbable | first ignored', () => {
document.body.innerHTML = `
<div id="d1">
<span>
<button tabindex="-1">1</button>
</span>
<button data-no-autofocus>2</button>
<button>3</button>
</div>
`;

expect(focusMerge(querySelector('#d1'), null)!.node.innerHTML).toBe('3');
});

it('autofocus - should pick first available focusable if pointed', () => {
document.body.innerHTML = `
<div id="d1">
Expand Down
47 changes: 41 additions & 6 deletions __tests__/focusables.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,56 @@
import { filterFocusable } from '../src/utils/DOMutils';
import { FOCUS_ALLOW, FOCUS_DISABLED, FOCUS_NO_AUTOFOCUS } from '../src/constants';
import { filterAutoFocusable, getAllTabbableNodes } from '../src/utils/DOMutils';

describe('focusables', () => {
it('should remove disabled buttons', () => {
document.body.innerHTML = `
<button>normal button</button>
<button disabled>disabled button</button>
<button disabled>disabled button (remove)</button>
<button aria-disabled="true">aria-disabled button</button>
<button style="display: none">hidden button</button>
<button style="display: none">hidden button (remove)</button>
`;

expect(
filterFocusable(Array.from(document.body.querySelectorAll('*')), new Map()).map((el) => el.textContent)
).toEqual([
const cache = new Map();
const nodes = getAllTabbableNodes([document.body], cache).map(({ node }) => node);

expect(nodes.map((el) => el.textContent)).toEqual([
// because it's normal button
'normal button',
// because it's "marked" as disabled for ARIA only
'aria-disabled button',
]);
});
});

describe('auto-focusables', () => {
it('should pick correct first autofocus', () => {
document.body.innerHTML = `
<button>normal button</button>
<button disabled>disabled button (remove)</button>
<button aria-disabled="true">aria-disabled button</button>
<button ${FOCUS_NO_AUTOFOCUS}="true">skip autofocus (remove)</button>
<button tabindex="-1">tabindex-1 button</button>
<div ${FOCUS_ALLOW}="true">
<button>normal button in allow group</button>
</div>
<div ${FOCUS_DISABLED}="true">
<button>normal button in disabled group</button>
</div>
<div ${FOCUS_NO_AUTOFOCUS}="true">
<button>normal button in no autofocus group (remove)</button>
</div>
`;

const cache = new Map();
const nodes = getAllTabbableNodes([document.body], cache).map(({ node }) => node);

expect(filterAutoFocusable(nodes).map((el) => el.textContent)).toEqual([
// hoisted
'tabindex-1 button',
'normal button',
'aria-disabled button',
'normal button in allow group',
'normal button in disabled group',
]);
});
});
18 changes: 18 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
/**
* defines a focus group
*/
export const FOCUS_GROUP = 'data-focus-lock';
/**
* disables element discovery inside a group marked by key
*/
export const FOCUS_DISABLED = 'data-focus-lock-disabled';
/**
* allows uncontrolled focus within the marked area, effectively disabling focus lock for it's content
*/
export const FOCUS_ALLOW = 'data-no-focus-lock';
/**
* instructs autofocus engine to pick default autofocus inside a given node
* can be set on the element or container
*/
export const FOCUS_AUTO = 'data-autofocus-inside';
/**
* instructs autofocus to ignore elements within a given node
* can be set on the element or container
*/
export const FOCUS_NO_AUTOFOCUS = 'data-no-autofocus';
13 changes: 8 additions & 5 deletions src/focusMerge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NEW_FOCUS, newFocus } from './solver';
import { getAllTabbableNodes, getTabbableNodes } from './utils/DOMutils';
import { filterAutoFocusable, getAllTabbableNodes, getTabbableNodes } from './utils/DOMutils';
import { getAllAffectedNodes } from './utils/all-affected';
import { pickFirstFocus } from './utils/firstFocus';
import { getActiveElement } from './utils/getActiveElement';
Expand Down Expand Up @@ -50,12 +50,15 @@ export const getFocusMerge = (
const newId = newFocus(innerNodes, outerNodes, activeElement, lastNode as HTMLElement);

if (newId === NEW_FOCUS) {
const autoFocusable = anyFocusable
.map(({ node }) => node)
.filter(findAutoFocused(allParentAutofocusables(entries, visibilityCache)));
const autoFocusable = filterAutoFocusable(anyFocusable.map(({ node }) => node)).filter(
findAutoFocused(allParentAutofocusables(entries, visibilityCache))
);

return {
node: autoFocusable && autoFocusable.length ? pickFirstFocus(autoFocusable) : pickFirstFocus(innerNodes),
node:
autoFocusable && autoFocusable.length
? pickFirstFocus(autoFocusable)
: pickFirstFocus(filterAutoFocusable(innerNodes)),
};
}

Expand Down
15 changes: 14 additions & 1 deletion src/utils/DOMutils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { toArray } from './array';
import { isVisibleCached, notHiddenInput, VisibilityCache } from './is';
import { isAutoFocusAllowedCached, isVisibleCached, notHiddenInput, VisibilityCache } from './is';
import { NodeIndex, orderByTabIndex } from './tabOrder';
import { getFocusables, getParentAutofocusables } from './tabUtils';

/**
* given list of focusable elements keeps the ones user can interact with
* @param nodes
* @param visibilityCache
*/
export const filterFocusable = (nodes: HTMLElement[], visibilityCache: VisibilityCache): HTMLElement[] =>
toArray(nodes)
.filter((node) => isVisibleCached(visibilityCache, node))
.filter((node) => notHiddenInput(node));

export const filterAutoFocusable = (nodes: HTMLElement[], cache: VisibilityCache = new Map()): HTMLElement[] =>
toArray(nodes).filter((node) => isAutoFocusAllowedCached(cache, node));

/**
* only tabbable ones
* (but with guards which would be ignored)
Expand All @@ -26,6 +34,11 @@ export const getTabbableNodes = (
export const getAllTabbableNodes = (topNodes: Element[], visibilityCache: VisibilityCache): NodeIndex[] =>
orderByTabIndex(filterFocusable(getFocusables(topNodes), visibilityCache), false);

/**
* return list of nodes which are expected to be auto-focused
* @param topNode
* @param visibilityCache
*/
export const parentAutofocusables = (topNode: Element, visibilityCache: VisibilityCache): Element[] =>
filterFocusable(getParentAutofocusables(topNode), visibilityCache);

Expand Down
49 changes: 37 additions & 12 deletions src/utils/is.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FOCUS_NO_AUTOFOCUS } from '../constants';

const isElementHidden = (node: Element): boolean => {
// we can measure only "elements"
// consider others as "visible"
Expand All @@ -18,19 +20,19 @@ const isElementHidden = (node: Element): boolean => {

type CheckParentCallback = (node: Element | undefined) => boolean;

const isVisibleUncached = (node: Element | undefined, checkParent: CheckParentCallback): boolean =>
!node ||
const getParentNode = (node: Element): Element | undefined =>
// DOCUMENT_FRAGMENT_NODE can also point on ShadowRoot. In this case .host will point on the next node
node.parentNode && node.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(node.parentNode as any).host
: node.parentNode;

const isTopNode = (node: Element): boolean =>
// @ts-ignore
node === document ||
(node && node.nodeType === Node.DOCUMENT_NODE) ||
(!isElementHidden(node) &&
checkParent(
// DOCUMENT_FRAGMENT_NODE can also point on ShadowRoot. In this case .host will point on the next node
node.parentNode && node.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(node.parentNode as any).host
: node.parentNode
));
node === document || (node && node.nodeType === Node.DOCUMENT_NODE);

const isVisibleUncached = (node: Element | undefined, checkParent: CheckParentCallback): boolean =>
!node || isTopNode(node) || (!isElementHidden(node) && checkParent(getParentNode(node)));

export type VisibilityCache = Map<Element | undefined, boolean>;

Expand All @@ -47,6 +49,22 @@ export const isVisibleCached = (visibilityCache: VisibilityCache, node: Element
return result;
};

const isAutoFocusAllowedUncached = (node: Element | undefined, checkParent: CheckParentCallback) =>
node && !isTopNode(node) ? (isAutoFocusAllowed(node) ? checkParent(getParentNode(node)) : false) : true;

export const isAutoFocusAllowedCached = (cache: VisibilityCache, node: Element | undefined): boolean => {
const cached = cache.get(node);

if (cached !== undefined) {
return cached;
}

const result = isAutoFocusAllowedUncached(node, isAutoFocusAllowedCached.bind(undefined, cache));
cache.set(node, result);

return result;
};

export const getDataset = (node: Element): HTMLElement['dataset'] | undefined =>
// @ts-ignore
node.dataset;
Expand All @@ -59,6 +77,13 @@ export const isRadioElement = (node: Element): node is HTMLInputElement =>

export const notHiddenInput = (node: Element): boolean =>
!((isHTMLInputElement(node) || isHTMLButtonElement(node)) && (node.type === 'hidden' || node.disabled));

export const isAutoFocusAllowed = (node: Element): boolean => {
const attribute = node.getAttribute(FOCUS_NO_AUTOFOCUS);

return ![true, 'true', ''].includes(attribute as never);
};

export const isGuard = (node: Element | undefined): boolean => Boolean(node && getDataset(node)?.focusGuard);
export const isNotAGuard = (node: Element | undefined): boolean => !isGuard(node);

Expand Down
5 changes: 5 additions & 0 deletions src/utils/parenting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,10 @@ export const getTopCommonParent = (
return topCommon as unknown as Element;
};

/**
* return list of nodes which are expected to be autofocused inside a given top nodes
* @param entries
* @param visibilityCache
*/
export const allParentAutofocusables = (entries: Element[], visibilityCache: VisibilityCache): Element[] =>
entries.reduce((acc, node) => acc.concat(parentAutofocusables(node, visibilityCache)), [] as Element[]);
4 changes: 4 additions & 0 deletions src/utils/tabUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const getFocusables = (parents: Element[], withGuards?: boolean): HTMLEle
[] as HTMLElement[]
);

/**
* return a list of focusable nodes within an area marked as "auto-focusable"
* @param parent
*/
export const getParentAutofocusables = (parent: Element): HTMLElement[] => {
const parentFocus = parent.querySelectorAll<Element>(`[${FOCUS_AUTO}]`);

Expand Down

0 comments on commit 5c2dc8f

Please sign in to comment.