Skip to content

Instantly share code, notes, and snippets.

@ktmud
Created July 12, 2024 15:37
Show Gist options
  • Save ktmud/6b6d8fe64ca642f917efc08fb02e1fdc to your computer and use it in GitHub Desktop.
Save ktmud/6b6d8fe64ca642f917efc08fb02e1fdc to your computer and use it in GitHub Desktop.
Useful hooks
import { useEffect, useState } from 'react';
/**
* Load data from an async function and update a state.
*/
export default function useAsyncState<TValue>(loader: Promise<TValue> | (() => Promise<TValue>)) {
const [value, setValue] = useState<TValue>();
useEffect(() => {
const promise = typeof loader === 'function' ? loader() : loader;
promise.then(setValue);
}, [loader]);
return value;
}
import type { RefObject } from 'react';
import React, { useLayoutEffect, useRef } from 'react';
type ElementOrRef = HTMLElement | RefObject<HTMLElement>;
type CSSSelector = string;
type ElementSelector = CSSSelector | ElementOrRef | null;
/**
* Type guard for excluding `null` and `undefined` values.
*/
function isNotNullOrUndefined<T>(value: T | null | undefined): value is T {
return value != null;
}
export interface UseFocusedElementsOptions {
ignoreElements?: ElementSelector[];
default?: ElementSelector;
root?: ElementSelector;
checkClick?: boolean;
}
const getElements = (elem?: ElementSelector, root?: ElementSelector): HTMLElement[] => {
const rootElem = root ? getElements(root)[0] : document.body;
if (typeof elem === 'string') {
return Array.from((rootElem || document).querySelectorAll(elem));
}
return [elem && 'current' in elem ? elem.current : elem].filter(isNotNullOrUndefined);
};
/**
* Find which elements from a list of elements have themselves or a descendant
* in focus.
*
* @param trackElements - list of child element to observe. Must be memoized.
* @param options.default - default focused element on initial load.
* @param options.root - the root element your elements are in. Provide to make
* sure hook is triggered when your elements are first mounted. Otherwise
* defaults document.body.
* @param options.checkClick - whether to check click event, too. If your track
* element is not focusable, use `checkClick` to detect a user's intention
* for "want to focus".
* @returns an array of elements in the trackElements that have focus.
*/
export default function useFocusedElements(
trackElements: ElementSelector[],
{
ignoreElements,
root = document.body,
checkClick: needToCheckClick = true,
default: defaultFocused,
}: UseFocusedElementsOptions = {},
): HTMLElement[] | null {
const [elementsInFocus, setElementsInFocus_] = React.useState<HTMLElement[] | null>(null);
const candidateElementsRef = useRef<HTMLElement[]>();
// Update state (which triggers renrender) in a timeout to give other event
// handlers registered by child components a chance to propagate.
const setElementsInFocus: typeof setElementsInFocus_ = React.useCallback((...args) => {
setTimeout(() => {
setElementsInFocus_(...args);
}, 10);
}, []);
useLayoutEffect(() => {
const rootContainer = getElements(root)[0];
if (!rootContainer || trackElements.length < 1) {
return;
}
// find track element that is in focus
const findActiveTrackElements = (childOrSelf: Element | null) => {
// Only find candidate elements on first click
// We can't pre-query the elements on mount because they may have not been
// rendered yet (think lazily loaded components).
if (childOrSelf && childOrSelf !== document.body && rootContainer.contains(childOrSelf)) {
if (!candidateElementsRef.current) {
candidateElementsRef.current = trackElements
.map((selector) => getElements(selector, root))
.flat();
}
return candidateElementsRef.current.filter((elem) => {
return elem === childOrSelf || elem.contains(childOrSelf);
});
}
return null;
};
// Ignore clicks on elements that are in `ignoreElements`.
//
// This is useful when you want to show something on click, but do not
// want to trigger a re-render when users click on the conditionally
// displayed content.
const isIgnored = (elem: HTMLElement) =>
(ignoreElements || [])
.map((selector) => getElements(selector))
.flat()
.some((ignoreElem) => ignoreElem === elem || ignoreElem.contains(elem));
let focusChecked = false;
const handleFocus = () => {
const focusedElem = document.activeElement as HTMLElement;
if (isIgnored(focusedElem)) {
return;
}
setElementsInFocus(findActiveTrackElements(focusedElem));
focusChecked = true;
setTimeout(() => {
focusChecked = false;
});
};
const handleClick = (event: MouseEvent) => {
const clickedElem = event.target as HTMLElement;
if (isIgnored(clickedElem)) {
return;
}
if (!focusChecked) {
// don't check again if focus event already triggered, this skips
// an unnecessary re-rendering
setElementsInFocus(findActiveTrackElements(clickedElem));
}
};
const focusedElements = findActiveTrackElements(document.activeElement);
if (focusedElements) {
setElementsInFocus(focusedElements);
}
// Reset candidate elements whenever the tracking options changes.
// This forces the first focus/click to re-find the elements.
candidateElementsRef.current = undefined;
// `useCapture=true` to make sure we get the event before other handlers
// since they may preventDefault() and stopPropagation().
document.addEventListener('focus', handleFocus, true);
if (needToCheckClick) {
document.addEventListener('click', handleClick, true);
}
return () => {
document.removeEventListener('focus', handleFocus, true);
if (needToCheckClick) {
document.removeEventListener('click', handleClick, true);
}
};
}, [defaultFocused, ignoreElements, needToCheckClick, root, setElementsInFocus, trackElements]);
return elementsInFocus;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment