diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d57e9..ec019ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [2.9.8](https://github.com/theKashey/react-focus-lock/compare/v2.9.7...v2.9.8) (2024-02-09) + + +### Bug Fixes + +* improve focus-lock behavior for exotic components ([557f8cd](https://github.com/theKashey/react-focus-lock/commit/557f8cdb1440a0781a6aa0be560d4ee3ee73e365)) + + + ## [2.9.7](https://github.com/theKashey/react-focus-lock/compare/v2.9.6...v2.9.7) (2024-01-23) diff --git a/UI/UI.d.ts b/UI/UI.d.ts index 754a1d7..716610b 100644 --- a/UI/UI.d.ts +++ b/UI/UI.d.ts @@ -35,4 +35,72 @@ export class InFocusGuard extends React.Component { /** * Moves focus inside a given node */ -export function useFocusInside(node: React.RefObject): void; \ No newline at end of file +export function useFocusInside(node: React.RefObject): void; + +export type FocusOptions = { + /** + * enables focus cycle + * @default true + */ + cycle?: boolean; + /** + * limits focusables to tabbables (tabindex>=0) elements only + * @default true + */ + onlyTabbable?:boolean +} + +export type FocusControl = { + /** + * moves focus to the current scope, can be considered as autofocus + */ + autofocus():void; + /** + * focuses the next element in the scope. + * If active element is not in the scope, autofocus will be triggered first + */ + focusNext(options:FocusOptions):void; + /** + * focuses the prev element in the scope. + * If active element is not in the scope, autofocus will be triggered first + */ + focusPrev():void; +} + + +/** + * returns FocusControl over the union given elements, one or many + * - can be used outside of FocusLock + */ +export function useFocusController(...shards: HTMLElement[]):FocusControl; + +/** + * returns FocusControl over the current FocusLock + * - can be used only within FocusLock + * - can be used by disabled FocusLock + */ +export function useFocusScope():FocusControl + +/** + * returns information about FocusState of a given node + * @example + * ```tsx + * const {active, ref, onFocus} = useFocusState(); + * return
{active ? 'is focused' : 'not focused'}
+ * ``` + */ +export function useFocusState():{ + /** + * is currently focused, or is focus is inside + */ + active: boolean; + /** + * focus handled. SHALL be passed to the node down + */ + onFocus: React.FocusEventHandler; + /** + * reference to the node + * only required to capture current status of the node + */ + ref: React.RefObject; +} \ No newline at end of file diff --git a/_tests/FocusLock.spec.js b/_tests/FocusLock.spec.js index 5fcd211..74866de 100644 --- a/_tests/FocusLock.spec.js +++ b/_tests/FocusLock.spec.js @@ -6,7 +6,7 @@ import { expect } from 'chai'; import { mount as emount, configure as configureEnzyme } from 'enzyme'; import sinon from 'sinon'; import EnzymeReactAdapter from 'enzyme-adapter-react-16'; -import FocusLock, { AutoFocusInside, MoveFocusInside } from '../src/index'; +import FocusLock, { AutoFocusInside, MoveFocusInside, useFocusScope } from '../src/index'; configureEnzyme({ adapter: new EnzymeReactAdapter() }); @@ -46,21 +46,21 @@ describe('react-focus-lock', () => { let mountPoint = []; const mount = (code) => { const wrapper = emount(code, { - attachTo: document.getElementById('root') + attachTo: document.getElementById('root'), }); mountPoint.push(wrapper); return wrapper; }; beforeEach(() => { - document.body.innerHTML='
'; + document.body.innerHTML = '
'; mountPoint = []; }); afterEach(() => { - mountPoint.forEach(wrapper => { + mountPoint.forEach((wrapper) => { try { - wrapper.unmount() - } catch (e){ + wrapper.unmount(); + } catch (e) { } }); @@ -193,7 +193,7 @@ describe('react-focus-lock', () => { text {this.state.focused && } @@ -590,7 +590,7 @@ d-action describe('AutoFocus', () => { it('Should not focus by default', () => { mount(
-text + text text
); @@ -600,7 +600,7 @@ text it('AutoFocus do nothing without FocusLock', () => { mount(
-text + text text
@@ -612,7 +612,7 @@ text mount(
-text + text text
@@ -624,7 +624,7 @@ text it('MoveFocusInside works without FocusLock', async () => { mount(
-text + text text
@@ -638,7 +638,7 @@ text mount(
-text + text text
@@ -1121,6 +1121,35 @@ text }); }); + describe('Control', () => { + it('moves focus with control hook', async () => { + let control; + const Capture = () => { + control = useFocusScope(); + return null; + }; + mount( +
+ + + + + +
, + ); + + control.autofocus(); + await tick(); + expect(document.activeElement.innerHTML).to.be.equal('button1'); + control.focusNext(); + await tick(); + expect(document.activeElement.innerHTML).to.be.equal('button2'); + control.focusPrev(); + await tick(); + expect(document.activeElement.innerHTML).to.be.equal('button1'); + }); + }); + describe('Hooks', () => { it('onActivation/Deactivation', () => { const onActivation = sinon.spy(); diff --git a/package.json b/package.json index 15795f1..3a541d6 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "homepage": "https://github.com/theKashey/react-focus-lock#readme", "dependencies": { "@babel/runtime": "^7.0.0", - "focus-lock": "^1.0.1", + "focus-lock": "^1.1.0", "prop-types": "^15.6.2", "react-clientside-effect": "^1.2.6", "use-callback-ref": "^1.3.0", diff --git a/src/Lock.js b/src/Lock.js index 66d69b1..d9ca9e6 100644 --- a/src/Lock.js +++ b/src/Lock.js @@ -8,6 +8,7 @@ import { useMergeRefs } from 'use-callback-ref'; import { useEffect } from 'react'; import { hiddenGuard } from './FocusGuard'; import { mediumFocus, mediumBlur, mediumSidecar } from './medium'; +import { focusScope } from './scope'; const emptyArray = []; @@ -17,6 +18,8 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) { const isActive = React.useRef(false); const originalFocusedElement = React.useRef(null); + const [, update] = React.useState({}); + const { children, disabled = false, @@ -53,6 +56,7 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) { onActivationCallback(observed.current); } isActive.current = true; + update(); }, [onActivationCallback]); const onDeactivation = React.useCallback(() => { @@ -60,6 +64,7 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) { if (onDeactivationCallback) { onDeactivationCallback(observed.current); } + update(); }, [onDeactivationCallback]); useEffect(() => { @@ -135,6 +140,13 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) { const mergedRef = useMergeRefs([parentRef, setObserveNode]); + const focusScopeValue = React.useMemo(() => ({ + observed, + shards, + enabled: !disabled, + active: isActive.current, + }), [disabled, isActive.current, shards, realObserved]); + return ( {hasLeadingGuards && [ @@ -170,7 +182,9 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) { onBlur={onBlur} onFocus={onFocus} > - {children} + + {children} + { hasTailingGuards diff --git a/src/Trap.js b/src/Trap.js index c4aacaa..f330667 100644 --- a/src/Trap.js +++ b/src/Trap.js @@ -3,9 +3,12 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import withSideEffect from 'react-clientside-effect'; import { - moveFocusInside, focusInside, focusIsHidden, expandFocusableNodes, + moveFocusInside, focusInside, + focusIsHidden, expandFocusableNodes, + focusNextElement, + focusPrevElement, } from 'focus-lock'; -import { deferAction } from './util'; +import { deferAction, extractRef } from './util'; import { mediumFocus, mediumBlur, mediumEffect } from './medium'; const focusOnBody = () => ( @@ -59,8 +62,6 @@ function autoGuard(startIndex, end, step, allNodes) { } } -const extractRef = ref => ((ref && 'current' in ref) ? ref.current : ref); - const focusWasOutside = (crossFrameOption) => { if (crossFrameOption) { // with cross frame return true for any value @@ -245,6 +246,8 @@ mediumBlur.assignMedium(onBlur); mediumEffect.assignMedium(cb => cb({ moveFocusInside, focusInside, + focusNextElement, + focusPrevElement, })); export default withSideEffect( diff --git a/src/UI.js b/src/UI.js index 1868686..5f53b9b 100644 --- a/src/UI.js +++ b/src/UI.js @@ -4,6 +4,9 @@ import MoveFocusInside, { useFocusInside } from './MoveFocusInside'; import FreeFocusInside from './FreeFocusInside'; import InFocusGuard from './FocusGuard'; +import { useFocusController, useFocusScope } from './use-focus-scope'; +import { useFocusState } from './use-focus-state'; + export { AutoFocusInside, MoveFocusInside, @@ -12,6 +15,10 @@ export { FocusLockUI, useFocusInside, + + useFocusController, + useFocusScope, + useFocusState, }; export default FocusLockUI; diff --git a/src/nano-events.js b/src/nano-events.js new file mode 100644 index 0000000..7a074cc --- /dev/null +++ b/src/nano-events.js @@ -0,0 +1,26 @@ +/** + * @fileoverview this is a copy of https://github.com/ai/nanoevents + * as a temp measure to avoid breaking changes in node/compilation + */ + +export const createNanoEvents = () => ({ + emit(event, ...args) { + for ( + let i = 0, + callbacks = this.events[event] || [], + { length } = callbacks; + i < length; + // eslint-disable-next-line no-plusplus + i++ + ) { + callbacks[i](...args); + } + }, + events: {}, + on(event, cb) { + (this.events[event] ||= []).push(cb); + return () => { + this.events[event] = this.events[event]?.filter(i => cb !== i); + }; + }, +}); diff --git a/src/scope.js b/src/scope.js new file mode 100644 index 0000000..b596479 --- /dev/null +++ b/src/scope.js @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const focusScope = createContext(undefined); diff --git a/src/use-focus-scope.js b/src/use-focus-scope.js new file mode 100644 index 0000000..7348d1b --- /dev/null +++ b/src/use-focus-scope.js @@ -0,0 +1,48 @@ +import { useContext, useMemo, useRef } from 'react'; +import { focusScope } from './scope'; +import { mediumEffect } from './medium'; +import { extractRef } from './util'; + +const collapseRefs = shards => ( + shards.map(extractRef).filter(Boolean) +); + +const withMedium = fn => mediumEffect.useMedium(fn); +export const useFocusController = (...shards) => { + if (!shards.length) { + throw new Error('useFocusController requires at least one target element'); + } + const ref = useRef(shards); + ref.current = shards; + + return useMemo(() => ({ + autofocus(focusOptions = {}) { + withMedium(car => car.moveFocusInside(collapseRefs(ref.current), null, focusOptions)); + }, + focusNext(options) { + withMedium((car) => { + car.moveFocusInside(collapseRefs(ref.current), null); + car.focusNextElement( + document.activeElement, + { scope: collapseRefs(ref.current), ...options }, + ); + }); + }, + focusPrev(options) { + withMedium((car) => { + car.moveFocusInside(collapseRefs(ref.current), null); + car.focusPrevElement( + document.activeElement, + { scope: collapseRefs(ref.current), ...options }, + ); + }); + }, + }), []); +}; +export const useFocusScope = () => { + const scope = useContext(focusScope); + if (!scope) { + throw new Error('FocusLock is required to operate with FocusScope'); + } + return useFocusController(scope.observed, ...scope.shards); +}; diff --git a/src/use-focus-state.js b/src/use-focus-state.js new file mode 100644 index 0000000..0e335cc --- /dev/null +++ b/src/use-focus-state.js @@ -0,0 +1,33 @@ +import { + useCallback, useRef, useState, useEffect, +} from 'react'; +import { createNanoEvents } from './nano-events'; + +const mainbus = createNanoEvents(); + +export const useFocusState = () => { + const [marker] = useState({}); + const [active, setActive] = useState(false); + const ref = useRef(null); + + const onFocus = useCallback(() => { + mainbus.emit('focus', marker); + }, []); + + useEffect(() => { + setActive( + ref.current === document.activeElement + || ref.current.contains(document.activeElement), + ); + }, []); + + useEffect(() => mainbus.on('focus', (event) => { + setActive(event === marker); + }), []); + + return { + active, + onFocus, + ref, + }; +}; diff --git a/src/util.js b/src/util.js index c11da39..6f6d38a 100644 --- a/src/util.js +++ b/src/util.js @@ -7,3 +7,5 @@ export const inlineProp = (name, value) => { obj[name] = value; return obj; }; + +export const extractRef = ref => ((ref && 'current' in ref) ? ref.current : ref); diff --git a/stories/control.js b/stories/control.js new file mode 100644 index 0000000..f954b07 --- /dev/null +++ b/stories/control.js @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from 'react'; +import FocusLock, { useFocusScope } from '../src/index'; +import { useFocusState } from '../src/use-focus-state'; + +const ControlTrap = () => { + const { autofocus, focusNext, focusPrev } = useFocusScope(); + + useEffect(() => { + autofocus(); + }, []); + + const onKey = (event) => { + if (event.key === 'ArrowDown') { + focusNext(); + } + if (event.key === 'ArrowUp') { + focusPrev(); + } + }; + + return ( +
+ + + + +
+ ); +}; + + +const FocusButton = ({ children }) => { + const { active, onFocus, ref } = useFocusState(); + return ; +}; +const RowingFocusTrap = () => { + const { autofocus, focusNext, focusPrev } = useFocusScope(); + + useEffect(() => { + autofocus(); + }, []); + + const onKey = (event) => { + if (event.key === 'ArrowDown') { + focusNext({ onlyTabbable: false }); + } + if (event.key === 'ArrowUp') { + focusPrev({ onlyTabbable: false }); + } + }; + + const { active, onFocus, ref } = useFocusState(); + + return ( +
+ + Button1 + Button2 + Button3 + Button4 +
+ ); +}; + +const ControlledFocusButton = ({ children, onFocus: reportFocused, isActive }) => { + const { active, onFocus, ref } = useFocusState(); + useEffect(() => { + if (active) { + reportFocused(); + } + }, [active]); + + return ; +}; +const ConstantRowingFocusTrap = () => { + const { focusNext, focusPrev } = useFocusScope(); + + const [focused, setFocused] = useState(1); + + const onKey = (event) => { + if (event.key === 'ArrowDown') { + focusNext({ onlyTabbable: false }); + } + if (event.key === 'ArrowUp') { + focusPrev({ onlyTabbable: false }); + } + }; + + + return ( +
+ setFocused(1)} + isActive={focused === 1} + > + Button1 + + setFocused(2)} + isActive={focused === 2} + > + Button2 + + setFocused(3)} + isActive={focused === 3} + > + Button3 + + setFocused(4)} + isActive={focused === 4} + > + Button4 + +
+ ); +}; + +export const ControlTrapExample = () => ( +
+ + + + + + +
+); + +export const RowingFocusExample = () => ( + + + + +); + +export const GroupRowingFocusExample = () => ( +
+ + + + + + +
+); diff --git a/stories/index.js b/stories/index.js index 6d54544..30c4e82 100644 --- a/stories/index.js +++ b/stories/index.js @@ -1,12 +1,10 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; -import { linkTo } from '@storybook/addon-links'; import DefaultAll from './Default'; -import {IFrame, SandboxedIFrame} from './Iframe'; +import { IFrame, SandboxedIFrame } from './Iframe'; import SideCar from './sideCar'; import TabIndex from './TabIndex'; import AutoFocus from './Autofocus'; @@ -21,9 +19,10 @@ import { PortalCase, ShardPortalCase } from './Portal'; import { MUISelect, MUISelectWhite } from './MUI'; import Fight from './FocusFighting'; import { StyledComponent, StyledSection } from './Custom'; -import { AutoDisabledForm, DisabledForm, DisabledFormWithTabIndex } from './Disabled'; +import { DisabledForm, DisabledFormWithTabIndex } from './Disabled'; import { FormOverride, Video } from './Exotic'; import { TabbableParent } from './TabbableParent'; +import { ControlTrapExample, GroupRowingFocusExample, RowingFocusExample } from './control'; const frameStyle = { width: '400px', @@ -85,3 +84,8 @@ storiesOf('Exotic', module) .add('sidecar', () => ) .add('tabbable parent', () => ) .add('form override', () => ); + +storiesOf('FocusScope', module) + .add('keyboard navigation', () => ) + .add('keyboard navigation with rowing tab index', () => ) + .add('keyboard navigation with persistent rowing tab index', () => ); diff --git a/yarn.lock b/yarn.lock index b987ac6..b2fda10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5919,13 +5919,20 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -focus-lock@^1.0.0, focus-lock@^1.0.1: +focus-lock@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-1.0.1.tgz#df141640a93917413738733f5f5d6008f149cb0e" integrity sha512-4YEXPTg9tATXwaVTpzHxT7GgO+Xot+VAoBSfXakUXXvQIXID0xv43GkZNDXkzWLYW1L9hrbHZ1CQjfT/svK9zA== dependencies: tslib "^2.0.3" +focus-lock@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/focus-lock/-/focus-lock-1.1.0.tgz#74fe85dc1a15360cc1a694de8e686c691e0dd5ed" + integrity sha512-7j9OR+mxVmGcfxpCU7qKT09R32TX4jlic2/iWT5pm58SYWB1lbREpb7hEkrXxx9MzkenDBOAGatBEcMbJ143UQ== + dependencies: + tslib "^2.0.3" + for-each@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"