diff --git a/src/hooks/tests/useIndexOfLastVisibleChild.test.jsx b/src/hooks/tests/useIndexOfLastVisibleChild.test.tsx similarity index 97% rename from src/hooks/tests/useIndexOfLastVisibleChild.test.jsx rename to src/hooks/tests/useIndexOfLastVisibleChild.test.tsx index 9a104f1589..d47377072d 100644 --- a/src/hooks/tests/useIndexOfLastVisibleChild.test.jsx +++ b/src/hooks/tests/useIndexOfLastVisibleChild.test.tsx @@ -12,7 +12,7 @@ window.ResizeObserver = window.ResizeObserver })); function TestComponent() { - const [containerElementRef, setContainerElementRef] = React.useState(null); + const [containerElementRef, setContainerElementRef] = React.useState(null); const overflowElementRef = React.useRef(null); const indexOfLastVisibleChild = useIndexOfLastVisibleChild(containerElementRef, overflowElementRef.current); diff --git a/src/hooks/tests/useToggle.test.jsx b/src/hooks/tests/useToggle.test.tsx similarity index 96% rename from src/hooks/tests/useToggle.test.jsx rename to src/hooks/tests/useToggle.test.tsx index c1fd08fdc5..0c457617f1 100644 --- a/src/hooks/tests/useToggle.test.jsx +++ b/src/hooks/tests/useToggle.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useToggle } from '../..'; +import { ToggleHandlers } from '../useToggle'; const TOGGLE_IS_ON = 'on'; const TOGGLE_IS_OFF = 'off'; @@ -19,7 +20,7 @@ const resetHandlerMocks = () => { }; // eslint-disable-next-line react/prop-types -function FakeComponent({ defaultIsOn, handlers }) { +function FakeComponent({ defaultIsOn, handlers }: { defaultIsOn: boolean, handlers: ToggleHandlers }) { const [isOn, setOn, setOff, toggle] = useToggle(defaultIsOn, handlers); return ( diff --git a/src/hooks/tests/useWindowSize.test.jsx b/src/hooks/tests/useWindowSize.test.tsx similarity index 100% rename from src/hooks/tests/useWindowSize.test.jsx rename to src/hooks/tests/useWindowSize.test.tsx diff --git a/src/hooks/useArrowKeyNavigation.jsx b/src/hooks/useArrowKeyNavigation.tsx similarity index 68% rename from src/hooks/useArrowKeyNavigation.jsx rename to src/hooks/useArrowKeyNavigation.tsx index 69b3bb6cbc..862d3f8d14 100644 --- a/src/hooks/useArrowKeyNavigation.jsx +++ b/src/hooks/useArrowKeyNavigation.tsx @@ -4,13 +4,21 @@ import { useRef, useEffect } from 'react'; * A React hook to enable arrow key navigation on a component. */ -function handleEnter({ event, currentIndex, activeElement }) { +function handleEnter( + { event, currentIndex, activeElement }: { event: KeyboardEvent, currentIndex: number, activeElement: HTMLElement }, +) { if (currentIndex === -1) { return; } activeElement.click(); event.preventDefault(); } -function handleArrowKey({ event, currentIndex, availableElements }) { +function handleArrowKey( + { event, currentIndex, availableElements }: { + event: KeyboardEvent, + currentIndex: number, + availableElements: NodeListOf, + }, +) { // If the focus isn't in the container, focus on the first thing if (currentIndex === -1) { availableElements[0].focus(); } @@ -32,8 +40,7 @@ function handleArrowKey({ event, currentIndex, availableElements }) { [nextElement] = availableElements; } - // eslint-disable-next-line no-unused-expressions - nextElement && nextElement.focus(); + nextElement?.focus(); event.preventDefault(); } @@ -45,7 +52,7 @@ function handleEvents({ ignoredKeys = [], parentNode, selectors = 'a,button,input', -}) { +}: { event: KeyboardEvent, ignoredKeys?: string[], parentNode: HTMLElement | undefined, selectors?: string }) { if (!parentNode) { return; } const { key } = event; @@ -61,7 +68,7 @@ function handleEvents({ if (!parentNode.contains(activeElement)) { return; } // Get the list of elements we're allowed to scroll through - const availableElements = parentNode.querySelectorAll(selectors); + const availableElements = parentNode.querySelectorAll(selectors); // No elements are available to loop through. if (!availableElements.length) { return; } @@ -71,18 +78,24 @@ function handleEvents({ (availableElement) => availableElement === activeElement, ); - if (key === 'Enter') { - handleEnter({ event, currentIndex, activeElement }); + if (key === 'Enter' && activeElement) { + handleEnter({ event, currentIndex, activeElement: activeElement as HTMLElement }); } handleArrowKey({ event, currentIndex, availableElements }); } -export default function useArrowKeyNavigation(props) { - const { selectors, ignoredKeys } = props || {}; - const parentNode = useRef(); +export interface ArrowKeyNavProps { + /** e.g. 'a,button,input' */ + selectors?: string; + ignoredKeys?: string[]; +} + +export default function useArrowKeyNavigation(props: ArrowKeyNavProps = {}) { + const { selectors, ignoredKeys } = props; + const parentNode = useRef(); useEffect(() => { - const eventHandler = (event) => { + const eventHandler = (event: KeyboardEvent) => { handleEvents({ event, ignoredKeys, parentNode: parentNode.current, selectors, }); diff --git a/src/hooks/useIndexOfLastVisibleChild.jsx b/src/hooks/useIndexOfLastVisibleChild.tsx similarity index 70% rename from src/hooks/useIndexOfLastVisibleChild.jsx rename to src/hooks/useIndexOfLastVisibleChild.tsx index 786375cdbf..0428ef97b5 100644 --- a/src/hooks/useIndexOfLastVisibleChild.jsx +++ b/src/hooks/useIndexOfLastVisibleChild.tsx @@ -5,25 +5,32 @@ import { useLayoutEffect, useState } from 'react'; * that fits within its bounding rectangle. This is done by summing the widths * of the children until they exceed the width of the container. * - * @param {Element} containerElementRef - container element - * @param {Element} overflowElementRef - overflow element - * * The hook returns the index of the last visible child. + * + * @param containerElementRef - container element + * @param overflowElementRef - overflow element */ -const useIndexOfLastVisibleChild = (containerElementRef, overflowElementRef) => { +const useIndexOfLastVisibleChild = ( + containerElementRef: Element | null, + overflowElementRef: Element | null, +): number => { const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1); useLayoutEffect(() => { + if (!containerElementRef) { + return undefined; + } + function updateLastVisibleChildIndex() { // Get array of child nodes from NodeList form - const childNodesArr = Array.prototype.slice.call(containerElementRef.children); + const childNodesArr = Array.prototype.slice.call(containerElementRef!.children); const { nextIndexOfLastVisibleChild } = childNodesArr // filter out the overflow element .filter(childNode => childNode !== overflowElementRef) // sum the widths to find the last visible element's index .reduce((acc, childNode, index) => { acc.sumWidth += childNode.getBoundingClientRect().width; - if (acc.sumWidth <= containerElementRef.getBoundingClientRect().width) { + if (acc.sumWidth <= containerElementRef!.getBoundingClientRect().width) { acc.nextIndexOfLastVisibleChild = index; } return acc; @@ -32,23 +39,18 @@ const useIndexOfLastVisibleChild = (containerElementRef, overflowElementRef) => // sometimes we'll show a dropdown with one item in it when it would fit, // but allowing this case dramatically simplifies the calculations we need // to do above. - sumWidth: overflowElementRef ? overflowElementRef.getBoundingClientRect().width : 0, + sumWidth: overflowElementRef?.getBoundingClientRect().width ?? 0, nextIndexOfLastVisibleChild: -1, }); setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild); } - if (containerElementRef) { - updateLastVisibleChildIndex(); - - const resizeObserver = new ResizeObserver(() => updateLastVisibleChildIndex()); - resizeObserver.observe(containerElementRef); - - return () => resizeObserver.disconnect(); - } + updateLastVisibleChildIndex(); - return undefined; + const resizeObserver = new ResizeObserver(() => updateLastVisibleChildIndex()); + resizeObserver.observe(containerElementRef); + return () => resizeObserver.disconnect(); }, [containerElementRef, overflowElementRef]); return indexOfLastVisibleChild; diff --git a/src/hooks/useIsVisible.jsx b/src/hooks/useIsVisible.tsx similarity index 74% rename from src/hooks/useIsVisible.jsx rename to src/hooks/useIsVisible.tsx index c9f661af86..0ac26a9f86 100644 --- a/src/hooks/useIsVisible.jsx +++ b/src/hooks/useIsVisible.tsx @@ -1,7 +1,10 @@ -import { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; -const useIsVisible = (defaultIsVisible = true) => { - const sentinelRef = useRef(); +const useIsVisible = (defaultIsVisible = true): [ + isVisible: boolean, + sentinelRef: React.MutableRefObject, +] => { + const sentinelRef = useRef(null); const [isVisible, setIsVisible] = useState(defaultIsVisible); useEffect(() => { diff --git a/src/hooks/useToggle.jsx b/src/hooks/useToggle.jsx deleted file mode 100644 index 731d50e07d..0000000000 --- a/src/hooks/useToggle.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState, useCallback } from 'react'; - -export default function useToggle(defaultIsOn, handlers = {}) { - const { handleToggleOn, handleToggleOff, handleToggle } = handlers; - const [isOn, setIsOn] = useState(defaultIsOn || false); - - const setOn = useCallback(() => { - setIsOn(true); - // istanbul ignore else - if (handleToggleOn) { - handleToggleOn(); - } - // istanbul ignore else - if (handleToggle) { - handleToggle(true); - } - }, [handleToggleOn, handleToggle]); - - const setOff = useCallback(() => { - setIsOn(false); - // istanbul ignore else - if (handleToggleOff) { - handleToggleOff(); - } - // istanbul ignore else - if (handleToggle) { - handleToggle(false); - } - }, [handleToggleOff, handleToggle]); - - const toggle = useCallback(() => { - const doToggle = isOn ? setOff : setOn; - doToggle(); - }, [isOn, setOn, setOff]); - - return [isOn, setOn, setOff, toggle]; -} diff --git a/src/hooks/useToggle.tsx b/src/hooks/useToggle.tsx new file mode 100644 index 0000000000..20614dcf44 --- /dev/null +++ b/src/hooks/useToggle.tsx @@ -0,0 +1,38 @@ +import { useState, useCallback } from 'react'; + +export type Toggler = [ + isOn: boolean, + setOn: () => void, + setOff: () => void, + toggle: () => void, +]; + +export interface ToggleHandlers { + handleToggleOn?: () => void; + handleToggleOff?: () => void; + handleToggle?: (newStatus: boolean) => void; +} + +export default function useToggle(defaultIsOn = false, handlers: ToggleHandlers = {}): Toggler { + const { handleToggleOn, handleToggleOff, handleToggle } = handlers; + const [isOn, setIsOn] = useState(defaultIsOn); + + const setOn = useCallback(() => { + setIsOn(true); + handleToggleOn?.(); + handleToggle?.(true); + }, [handleToggleOn, handleToggle]); + + const setOff = useCallback(() => { + setIsOn(false); + handleToggleOff?.(); + handleToggle?.(false); + }, [handleToggleOff, handleToggle]); + + const toggle = useCallback(() => { + const doToggle = isOn ? setOff : setOn; + doToggle(); + }, [isOn, setOn, setOff]); + + return [isOn, setOn, setOff, toggle]; +} diff --git a/src/hooks/useWindowSize.jsx b/src/hooks/useWindowSize.tsx similarity index 81% rename from src/hooks/useWindowSize.jsx rename to src/hooks/useWindowSize.tsx index 68229c7b01..6053563e0d 100644 --- a/src/hooks/useWindowSize.jsx +++ b/src/hooks/useWindowSize.tsx @@ -1,9 +1,14 @@ import { useState, useLayoutEffect } from 'react'; -function useWindowSize() { +export interface WindowSizeData { + width: number | undefined; + height: number | undefined; +} + +function useWindowSize(): WindowSizeData { // Initialize state with undefined width/height so server and client renders match // Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/ - const [windowSize, setWindowSize] = useState({ + const [windowSize, setWindowSize] = useState({ width: undefined, height: undefined, }); diff --git a/src/index.d.ts b/src/index.d.ts index a680a1ac86..0e8ce5d401 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -13,6 +13,11 @@ export { default as Icon } from './Icon'; export { default as IconButton, IconButtonWithTooltip } from './IconButton'; export { default as Overlay, OverlayTrigger } from './Overlay'; export { default as Tooltip } from './Tooltip'; +export { default as useWindowSize, type WindowSizeData } from './hooks/useWindowSize'; +export { default as useToggle, type Toggler, type ToggleHandlers } from './hooks/useToggle'; +export { default as useArrowKeyNavigation, type ArrowKeyNavProps } from './hooks/useArrowKeyNavigation'; +export { default as useIndexOfLastVisibleChild } from './hooks/useIndexOfLastVisibleChild'; +export { default as useIsVisible } from './hooks/useIsVisible'; // // // // // // // // // // // // // // // // // // // // // // // // // // // // Things that don't have types @@ -173,11 +178,6 @@ export const Sticky: any; // from './Sticky'; export const SelectableBox: any; // from './SelectableBox'; export const breakpoints: any; // from './utils/breakpoints'; export const Variant: any; // from './utils/constants'; -export const useWindowSize: any; // from './hooks/useWindowSize'; -export const useToggle: any; // from './hooks/useToggle'; -export const useArrowKeyNavigation: any; // from './hooks/useArrowKeyNavigation'; -export const useIndexOfLastVisibleChild: any; // from './hooks/useIndexOfLastVisibleChild'; -export const useIsVisible: any; // from './hooks/useIsVisible'; export const OverflowScrollContext: any, OverflowScroll: any, diff --git a/src/index.js b/src/index.js index 258b03af3f..d545c54171 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,11 @@ export { default as Icon } from './Icon'; export { default as IconButton, IconButtonWithTooltip } from './IconButton'; export { default as Overlay, OverlayTrigger } from './Overlay'; export { default as Tooltip } from './Tooltip'; +export { default as useWindowSize } from './hooks/useWindowSize'; +export { default as useToggle } from './hooks/useToggle'; +export { default as useArrowKeyNavigation } from './hooks/useArrowKeyNavigation'; +export { default as useIndexOfLastVisibleChild } from './hooks/useIndexOfLastVisibleChild'; +export { default as useIsVisible } from './hooks/useIsVisible'; // // // // // // // // // // // // // // // // // // // // // // // // // // // // Things that don't have types @@ -173,11 +178,6 @@ export { default as Sticky } from './Sticky'; export { default as SelectableBox } from './SelectableBox'; export { default as breakpoints } from './utils/breakpoints'; export { default as Variant } from './utils/constants'; -export { default as useWindowSize } from './hooks/useWindowSize'; -export { default as useToggle } from './hooks/useToggle'; -export { default as useArrowKeyNavigation } from './hooks/useArrowKeyNavigation'; -export { default as useIndexOfLastVisibleChild } from './hooks/useIndexOfLastVisibleChild'; -export { default as useIsVisible } from './hooks/useIsVisible'; export { OverflowScrollContext, OverflowScroll, diff --git a/www/src/components/header/Navbar.tsx b/www/src/components/header/Navbar.tsx index ffef619923..1652cb843a 100644 --- a/www/src/components/header/Navbar.tsx +++ b/www/src/components/header/Navbar.tsx @@ -15,7 +15,7 @@ import Search from '../Search'; export interface INavbar { siteTitle: string, - onMenuClick: () => boolean, + onMenuClick: () => void, setTarget: React.Dispatch>, onSettingsClick?: () => void, menuIsOpen?: boolean,