From a9be7a4d1a39386141a28bd6e02cc0083c595880 Mon Sep 17 00:00:00 2001 From: Anton Korzunov Date: Thu, 15 Feb 2024 17:35:52 +1100 Subject: [PATCH] fix: correct d.ts exports --- UI/UI.d.ts | 7 +++- _tests/hooks.spec.js | 58 ++++++++++++++++++++++++++++++- react-focus-lock.d.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ src/index.js | 3 ++ src/use-focus-state.js | 28 +++++++++++---- 5 files changed, 167 insertions(+), 8 deletions(-) diff --git a/UI/UI.d.ts b/UI/UI.d.ts index 932571e..57f636e 100644 --- a/UI/UI.d.ts +++ b/UI/UI.d.ts @@ -83,6 +83,11 @@ export function useFocusController(...shards: HTMLElement[]):FocusControl; */ export function useFocusScope():FocusControl + +export type FocusCallbacks = { + onFocus():void; + onBlur():void; +} /** * returns information about FocusState of a given node * @example @@ -91,7 +96,7 @@ export function useFocusScope():FocusControl * return
{active ? 'is focused' : 'not focused'}
* ``` */ -export function useFocusState():{ +export function useFocusState(callbacks?: FocusCallbacks ):{ /** * is currently focused, or is focus is inside */ diff --git a/_tests/hooks.spec.js b/_tests/hooks.spec.js index 88b155c..0aa9c4e 100644 --- a/_tests/hooks.spec.js +++ b/_tests/hooks.spec.js @@ -3,7 +3,8 @@ import { render, } from '@testing-library/react'; import { expect } from 'chai'; -import { useFocusController } from '../src/UI'; +import sinon from 'sinon'; +import { useFocusController, useFocusState } from '../src/UI'; describe('Hooks w/o sidecar', () => { it('controls focus', async () => { @@ -27,4 +28,59 @@ describe('Hooks w/o sidecar', () => { await p; expect(document.activeElement).to.be.equal(document.getElementById('b1')); }); + + it('focus tracking', async () => { + const Capture = ({ children, id, ...callbacks }) => { + const { onFocus, ref, active } = useFocusState(callbacks); + return ( + + ); + }; + const onparentblur = sinon.spy(); + + const onfocus = sinon.spy(); + const onblur = sinon.spy(); + + const Suite = () => { + const { onFocus } = useFocusState({ onBlur: onparentblur }); + return ( + <> +
+ test1 + test2 + test3 +
+ '); + sinon.assert.notCalled(onfocus); + sinon.assert.notCalled(onblur); + + document.getElementById('2').focus(); + expect(container.innerHTML).to.be.equal('
'); + sinon.assert.calledOnce(onfocus); + sinon.assert.notCalled(onblur); + + document.getElementById('3').focus(); + expect(container.innerHTML).to.be.equal('
'); + sinon.assert.calledOnce(onfocus); + sinon.assert.calledOnce(onblur); + + sinon.assert.notCalled(onparentblur); + + document.getElementById('0').focus(); + expect(container.innerHTML).to.be.equal('
'); + // blur on parent will be called only once, this is important + sinon.assert.calledOnce(onparentblur); + }); }); diff --git a/react-focus-lock.d.ts b/react-focus-lock.d.ts index 4c83dc2..53104bb 100644 --- a/react-focus-lock.d.ts +++ b/react-focus-lock.d.ts @@ -30,4 +30,83 @@ export class FreeFocusInside extends React.Component { * Secures the focus around the node */ export class InFocusGuard extends React.Component { +} + +/** + * Moves focus inside a given node + */ +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():Promise; + /** + * focuses the next element in the scope. + * If active element is not in the scope, autofocus will be triggered first + */ + focusNext(options:FocusOptions):Promise; + /** + * focuses the prev element in the scope. + * If active element is not in the scope, autofocus will be triggered first + */ + focusPrev():Promise; +} + + +/** + * returns FocusControl over the union given elements, one or many + * - can be used outside of FocusLock + * @see {@link useFocusScope} for use cases inside 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 + * @see {@link useFocusController} for use cases outside of FocusLock + */ +export function useFocusScope():FocusControl + +export type FocusCallbacks = { + onFocus():void; + onBlur():void; +} +/** + * returns information about FocusState of a given node + * @example + * ```tsx + * const {active, ref, onFocus} = useFocusState(); + * return
{active ? 'is focused' : 'not focused'}
+ * ``` + */ +export function useFocusState(callbacks?: FocusCallbacks ):{ + /** + * 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/src/index.js b/src/index.js index 6ab1f91..5ba608f 100644 --- a/src/index.js +++ b/src/index.js @@ -2,4 +2,7 @@ import FocusLock from './Combination'; export * from './UI'; +// no named export yes, as it will interfere with eslint rules +// export { FocusLock }; + export default FocusLock; diff --git a/src/use-focus-state.js b/src/use-focus-state.js index 795fd41..8abec01 100644 --- a/src/use-focus-state.js +++ b/src/use-focus-state.js @@ -41,20 +41,24 @@ const getFocusState = (target, current) => { return 'within-boundary'; }; -export const useFocusState = () => { +export const useFocusState = (callbacks = {}) => { const [active, setActive] = useState(false); const [state, setState] = useState(''); const ref = useRef(null); const focusState = useRef({}); + const stateTracker = useRef(false); // initial focus useEffect(() => { if (ref.current) { - setActive( - ref.current === document.activeElement - || ref.current.contains(document.activeElement), - ); + const isAlreadyFocused = ref.current === document.activeElement + || ref.current.contains(document.activeElement); + setActive(isAlreadyFocused); setState(getFocusState(document.activeElement, ref.current)); + + if (isAlreadyFocused && callbacks.onFocus) { + callbacks.onFocus(); + } } }, []); @@ -75,8 +79,20 @@ export const useFocusState = () => { }); const fin = mainbus.on('assign', () => { // focus event propagation is ended - setActive(focusState.current.focused || false); + const newState = focusState.current.focused || false; + setActive(newState); setState(focusState.current.state || ''); + + if (newState !== stateTracker.current) { + stateTracker.current = newState; + if (newState) { + // eslint-disable-next-line no-unused-expressions + callbacks.onFocus && callbacks.onFocus(); + } else { + // eslint-disable-next-line no-unused-expressions + callbacks.onBlur && callbacks.onBlur(); + } + } }); return () => { fout();