Skip to content

Commit

Permalink
fix: implement focus control hooks
Browse files Browse the repository at this point in the history
  • Loading branch information
theKashey committed Feb 11, 2024
1 parent 5bce4ca commit b7e53d7
Show file tree
Hide file tree
Showing 15 changed files with 429 additions and 24 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)


Expand Down
70 changes: 69 additions & 1 deletion UI/UI.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,72 @@ export class InFocusGuard extends React.Component<InFocusGuardProps> {
/**
* Moves focus inside a given node
*/
export function useFocusInside(node: React.RefObject<HTMLElement>): void;
export function useFocusInside(node: React.RefObject<HTMLElement>): 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 <div ref={ref} onFocus={onFocus}>{active ? 'is focused' : 'not focused'}</div>
* ```
*/
export function useFocusState<T extends Element>():{
/**
* is currently focused, or is focus is inside
*/
active: boolean;
/**
* focus handled. SHALL be passed to the node down
*/
onFocus: React.FocusEventHandler<T>;
/**
* reference to the node
* only required to capture current status of the node
*/
ref: React.RefObject<T>;
}
53 changes: 41 additions & 12 deletions _tests/FocusLock.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
Expand Down Expand Up @@ -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='<div id="root"></div>';
document.body.innerHTML = '<div id="root"></div>';
mountPoint = [];
});

afterEach(() => {
mountPoint.forEach(wrapper => {
mountPoint.forEach((wrapper) => {
try {
wrapper.unmount()
} catch (e){
wrapper.unmount();
} catch (e) {

}
});
Expand Down Expand Up @@ -193,7 +193,7 @@ describe('react-focus-lock', () => {
<span className={`clickTarget${this.state.c}`} onClick={this.toggle} />
text
<button className="action2">
d-action
d-action
{this.state.c}
</button>
{this.state.focused && <Test />}
Expand Down Expand Up @@ -590,7 +590,7 @@ d-action
describe('AutoFocus', () => {
it('Should not focus by default', () => {
mount(<div>
text
text
<button>action</button>
text
</div>);
Expand All @@ -600,7 +600,7 @@ text
it('AutoFocus do nothing without FocusLock', () => {
mount(<AutoFocusInside>
<div>
text
text
<button>action</button>
text
</div>
Expand All @@ -612,7 +612,7 @@ text
mount(<FocusLock>
<AutoFocusInside>
<div>
text
text
<button>action</button>
text
</div>
Expand All @@ -624,7 +624,7 @@ text
it('MoveFocusInside works without FocusLock', async () => {
mount(<MoveFocusInside>
<div>
text
text
<button>action</button>
text
</div>
Expand All @@ -638,7 +638,7 @@ text
mount(<FocusLock>
<MoveFocusInside>
<div>
text
text
<button>action</button>
text
</div>
Expand Down Expand Up @@ -1121,6 +1121,35 @@ text
});
});

describe('Control', () => {
it('moves focus with control hook', async () => {
let control;
const Capture = () => {
control = useFocusScope();
return null;
};
mount(
<div>
<FocusLock disabled>
<button id="b1">button1</button>
<button id="b2">button2</button>
<Capture />
</FocusLock>
</div>,
);

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();
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/Lock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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,
Expand Down Expand Up @@ -53,13 +56,15 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) {
onActivationCallback(observed.current);
}
isActive.current = true;
update();
}, [onActivationCallback]);

const onDeactivation = React.useCallback(() => {
isActive.current = false;
if (onDeactivationCallback) {
onDeactivationCallback(observed.current);
}
update();
}, [onDeactivationCallback]);

useEffect(() => {
Expand Down Expand Up @@ -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 (
<React.Fragment>
{hasLeadingGuards && [
Expand Down Expand Up @@ -170,7 +182,9 @@ const FocusLock = React.forwardRef(function FocusLockUI(props, parentRef) {
onBlur={onBlur}
onFocus={onFocus}
>
{children}
<focusScope.Provider value={focusScopeValue}>
{children}
</focusScope.Provider>
</Container>
{
hasTailingGuards
Expand Down
11 changes: 7 additions & 4 deletions src/Trap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -245,6 +246,8 @@ mediumBlur.assignMedium(onBlur);
mediumEffect.assignMedium(cb => cb({
moveFocusInside,
focusInside,
focusNextElement,
focusPrevElement,
}));

export default withSideEffect(
Expand Down
7 changes: 7 additions & 0 deletions src/UI.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +15,10 @@ export {
FocusLockUI,

useFocusInside,

useFocusController,
useFocusScope,
useFocusState,
};

export default FocusLockUI;
26 changes: 26 additions & 0 deletions src/nano-events.js
Original file line number Diff line number Diff line change
@@ -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);
};
},
});
3 changes: 3 additions & 0 deletions src/scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createContext } from 'react';

export const focusScope = createContext(undefined);
Loading

0 comments on commit b7e53d7

Please sign in to comment.