Skip to content

Commit

Permalink
fix: correct abiity to restore focus on any focusable, fixes #54
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed Feb 7, 2024
1 parent 8144f2e commit 81ba288
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 41 deletions.
28 changes: 27 additions & 1 deletion __tests__/focusMerge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe('FocusMerge', () => {
expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('3');
});

it('autofocus - should pick first available focusable if pointed', () => {
it('autofocus - should pick first available focusable if pointed by AUTOFOCUS', () => {
document.body.innerHTML = `
<div id="d1">
<span ${FOCUS_AUTO}>
Expand All @@ -85,6 +85,32 @@ describe('FocusMerge', () => {
expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('1');
});

describe('return behavior', () => {
beforeEach(() => {
document.body.innerHTML = `
<button id="d0" tabindex="0">base</button>
<div id="d1">
<button id="d2"tabindex="-1">1</button>
<span id="d3">2</span>
<button>3</button>
</div>
`;
});

it('should first tabbable', () => {
expect(focusSolver(querySelector('#d1'), null)!.node.innerHTML).toBe('3');
});

it('should focusable if pointed', () => {
expect(focusSolver(querySelector('#d1'), querySelector('#d2'))!.node.innerHTML).toBe('1');
});

it('should first tabbable if target lost', () => {
// TODO: this test might corrected by smarter returnFocus
expect(focusSolver(querySelector('#d1'), querySelector('#d3'))!.node.innerHTML).toBe('3');
});
});

describe('data-autofocus', () => {
it('autofocus - should pick first available focusable if pointed directly', () => {
document.body.innerHTML = `
Expand Down
39 changes: 24 additions & 15 deletions __tests__/focusMerge.unit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,50 @@ describe('focus Merge order', () => {

it('handle zero values', () => {
// cycle via left
expect(newFocus([], [], undefined, 0)).toBe(NEW_FOCUS);
expect(newFocus([], [], [], undefined, 0)).toBe(NEW_FOCUS);
});

it('should move from start to end', () => {
// cycle via left
expect(newFocus([2, 3, 4], [1, 2, 3, 4, 5], 1, 2)).toBe(2);
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 2)).toBe(2);
});

it('should move from end to start', () => {
// cycle via right
expect(newFocus([2, 3, 4], [1, 2, 3, 4, 5], 5, 4)).toBe(0);
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 5, 4)).toBe(0);
});

it('should keep direction of move', () => {
// cycle via left
expect(newFocus([2, 4, 6], [1, 2, 3, 4, 5, 6], 5, 4)).toBe(2);
expect(newFocus([2, 4, 6], [2, 4, 6], [1, 2, 3, 4, 5, 6], 5, 4)).toBe(2);
});

it('should jump back', () => {
// jump back
expect(newFocus([2, 3, 4], [1, 2, 3, 4, 5], 1, 4)).toBe(2);
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 4)).toBe(2);
// jump back
expect(newFocus([2, 3, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1);
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1);
});

describe('if land on guard', () => {
it('(back) 4 -> 0 -> 4', () => {
// jump to the last
expect(newFocus([2, 3, 4], [guard, 2, 3, 4, 5], guard, 4)).toBe(2);
expect(newFocus([2, 3, 4], [2, 3, 4], [guard, 2, 3, 4, 5], guard, 4)).toBe(2);
});

it('(back) 3 -> 0 -> 4', () => {
// jump to the last
expect(newFocus([2, 3, 4], [guard, 2, 3, 4, 5], guard, 3)).toBe(2);
expect(newFocus([2, 3, 4], [2, 3, 4], [guard, 2, 3, 4, 5], guard, 3)).toBe(2);
});

it('(forward) 3 -> 5 -> 1', () => {
// jump to the last
expect(newFocus([2, 3, 4], [1, 2, 3, 4, guard], guard, 4)).toBe(0);
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, guard], guard, 4)).toBe(0);
});

it('(forward) 4 -> 5 -> 1', () => {
// jump to the last
expect(newFocus([2, 3, 4], [2, 2, 3, 4, guard], guard, 3)).toBe(0);
expect(newFocus([2, 3, 4], [2, 3, 4], [2, 2, 3, 4, guard], guard, 3)).toBe(0);
});
});

Expand All @@ -68,26 +68,35 @@ describe('focus Merge order', () => {

it('picks active radio to left', () => {
const innerNodes = [radio1, radioChecked, 4];
expect(newFocus(innerNodes, [1, ...innerNodes, 5], 5, 4)).toBe(1);
expect(newFocus(innerNodes, innerNodes, [1, ...innerNodes, 5], 5, 4)).toBe(1);
});

it('picks active radio to right', () => {
const innerNodes = [1, radio1, radioChecked, radio2];
expect(newFocus(innerNodes, [0, ...innerNodes, 5], 0, 1)).toBe(2);
expect(newFocus(innerNodes, innerNodes, [0, ...innerNodes, 5], 0, 1)).toBe(2);
});

it('jump out via last node', () => {
const innerNodes = [1, radioChecked];
expect(newFocus(innerNodes, [0, ...innerNodes, 5], 5, radioChecked)).toBe(0);
expect(newFocus(innerNodes, innerNodes, [0, ...innerNodes, 5], 5, radioChecked)).toBe(0);
});

it('jump out via unchecked node', () => {
// radio1 and radio2 should be invisible to algo
const innerNodes = [1, radioChecked, radio1, radio2];
expect(newFocus(innerNodes, [0, ...innerNodes, 5], 5, radioChecked)).toBe(0);
expect(newFocus(innerNodes, innerNodes, [0, ...innerNodes, 5], 5, radioChecked)).toBe(0);
});
});

it('should select auto focused', () => {
expect(newFocus([2, 3, 4], [1, 2, 3, 4, 5], 1, 0)).toBe(NEW_FOCUS);
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 0)).toBe(NEW_FOCUS);
});

it('should restore last tabbable', () => {
expect(newFocus([2, 3, 4], [2, 3, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1);
});

it('should restore last focusable', () => {
expect(newFocus([2, 3, 4], [2, 4], [1, 2, 3, 4, 5], 1, 3)).toBe(1);
});
});
7 changes: 6 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
export const focusOn = (
target: Element | HTMLFrameElement | HTMLElement,
target: Element | HTMLFrameElement | HTMLElement | null,
focusOptions?: FocusOptions | undefined
): void => {
if (!target) {
// not clear how, but is possible https://github.com/theKashey/focus-lock/issues/53
return;
}

if ('focus' in target) {
target.focus(focusOptions);
}
Expand Down
19 changes: 9 additions & 10 deletions src/focusSolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NEW_FOCUS, newFocus } from './solver';
import { getFocusableNodes, getTabbableNodes } from './utils/DOMutils';
import { getFocusableNodes } from './utils/DOMutils';
import { getAllAffectedNodes } from './utils/all-affected';
import { asArray, getFirst } from './utils/array';
import { pickAutofocus } from './utils/auto-focus';
Expand Down Expand Up @@ -38,24 +38,23 @@ export const focusSolver = (
const visibilityCache = new Map();

const anyFocusable = getFocusableNodes(entries, visibilityCache);
let innerElements = getTabbableNodes(entries, visibilityCache).filter(({ node }) => isNotAGuard(node));
const innerElements = anyFocusable.filter(({ node }) => isNotAGuard(node));

if (!innerElements[0]) {
innerElements = anyFocusable;

if (!innerElements[0]) {
return undefined;
}
return undefined;
}

const outerNodes = getFocusableNodes([commonParent], visibilityCache).map(({ node }) => node);
const orderedInnerElements = reorderNodes(outerNodes, innerElements);
const innerNodes = orderedInnerElements.map(({ node }) => node);

const newId = newFocus(innerNodes, outerNodes, activeElement, lastNode as HTMLElement);
// collect inner focusable and separately tabbables
const innerFocusables = orderedInnerElements.map(({ node }) => node);
const innerTabbable = orderedInnerElements.filter(({ tabIndex }) => tabIndex >= 0).map(({ node }) => node);

const newId = newFocus(innerFocusables, innerTabbable, outerNodes, activeElement, lastNode as HTMLElement);

if (newId === NEW_FOCUS) {
const focusNode = pickAutofocus(anyFocusable, innerNodes, allParentAutofocusables(entries, visibilityCache));
const focusNode = pickAutofocus(anyFocusable, innerTabbable, allParentAutofocusables(entries, visibilityCache));

if (focusNode) {
return { node: focusNode };
Expand Down
29 changes: 21 additions & 8 deletions src/solver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ export const NEW_FOCUS = 'NEW_FOCUS';

/**
* Main solver for the "find next focus" question
* @param innerNodes
* @param innerNodes - used to control "return focus"
* @param innerTabbables - used to control "autofocus"
* @param outerNodes
* @param activeElement
* @param lastNode
* @returns {number|string|undefined|*}
*/
export const newFocus = (
innerNodes: HTMLElement[],
innerTabbables: HTMLElement[],
outerNodes: HTMLElement[],
activeElement: HTMLElement | undefined,
lastNode: HTMLElement | null
Expand All @@ -31,6 +33,22 @@ export const newFocus = (
const activeIndex = activeElement !== undefined ? outerNodes.indexOf(activeElement) : -1;
const lastIndex = lastNode ? outerNodes.indexOf(lastNode) : activeIndex;
const lastNodeInside = lastNode ? innerNodes.indexOf(lastNode) : -1;

// no active focus (or focus is on the body)
if (activeIndex === -1) {
// known fallback
if (lastNodeInside !== -1) {
return lastNodeInside;
}

return NEW_FOCUS;
}

// new focus, nothing to calculate
if (lastNodeInside === -1) {
return NEW_FOCUS;
}

const indexDiff = activeIndex - lastIndex;
const firstNodeIndex = outerNodes.indexOf(firstFocus);
const lastNodeIndex = outerNodes.indexOf(lastFocus);
Expand All @@ -39,13 +57,8 @@ export const newFocus = (
const correctedIndex = activeElement !== undefined ? correctedNodes.indexOf(activeElement) : -1;
const correctedIndexDiff = correctedIndex - (lastNode ? correctedNodes.indexOf(lastNode) : activeIndex);

const returnFirstNode = pickFocusable(innerNodes, 0);
const returnLastNode = pickFocusable(innerNodes, cnt - 1);

// new focus
if (activeIndex === -1 || lastNodeInside === -1) {
return NEW_FOCUS;
}
const returnFirstNode = pickFocusable(innerNodes, innerTabbables[0]);
const returnLastNode = pickFocusable(innerNodes, innerTabbables[innerTabbables.length - 1]);

// old focus
if (!indexDiff && lastNodeInside >= 0) {
Expand Down
8 changes: 2 additions & 6 deletions src/utils/firstFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ export const pickFirstFocus = (nodes: HTMLElement[]): HTMLElement | undefined =>
return nodes[0];
};

export const pickFocusable = (nodes: HTMLElement[], index: number): number => {
if (nodes.length > 1) {
return nodes.indexOf(correctNode(nodes[index], nodes));
}

return index;
export const pickFocusable = (nodes: HTMLElement[], node: HTMLElement): number => {
return nodes.indexOf(correctNode(node, nodes));
};

0 comments on commit 81ba288

Please sign in to comment.