diff --git a/package.json b/package.json index da71a0383..edae16070 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/ui", - "version": "2.6.4", + "version": "2.6.4-canary.2", "keywords": [ "sanity", "ui", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e1d68993..1dcd6f782 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,7 +65,7 @@ importers: version: 5.0.0(semantic-release@24.0.0(typescript@5.5.3)) '@sanity/ui-workshop': specifier: ^2.0.15 - version: 2.0.15(@sanity/icons@3.3.0(react@18.3.1))(@sanity/ui@2.6.3(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3) + version: 2.0.15(@sanity/icons@3.3.0(react@18.3.1))(@sanity/ui@2.6.4-canary.2(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3) '@storybook/addon-a11y': specifier: ^8.2.1 version: 8.2.1(storybook@8.2.1(@babel/preset-env@7.24.7(@babel/core@7.24.7))) @@ -1861,14 +1861,14 @@ packages: react-dom: ^18 styled-components: ^5.2 || ^6 - '@sanity/ui@2.6.3': - resolution: {integrity: sha512-qyNaIlfRvLQ21Z4+PP61qZ/Rjbk2UqqC/YTS9AmeRvetL9WEuUYFbMrprdKzAAUcWOqabBZVLjrU9F01nDEtmw==} + '@sanity/ui@2.6.4-canary.2': + resolution: {integrity: sha512-dw8x8xxZEwAT2FjzaqIGJG/+2Qb6tmq0Ax/uUK2UHPQUDTlB6fg1otTczti2UI5sgKQdht5iMQCKOcHxWeIbFA==} engines: {node: '>=14.0.0'} peerDependencies: - react: ^18 - react-dom: ^18 - react-is: ^18 - styled-components: ^5.2 || ^6 + react: '*' + react-dom: '*' + react-is: '*' + styled-components: npm:speedy-styled-components@6.1.12-canary.0 '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -9676,10 +9676,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@sanity/ui-workshop@2.0.15(@sanity/icons@3.3.0(react@18.3.1))(@sanity/ui@2.6.3(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3)': + '@sanity/ui-workshop@2.0.15(@sanity/icons@3.3.0(react@18.3.1))(@sanity/ui@2.6.4-canary.2(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3)': dependencies: '@sanity/icons': 3.3.0(react@18.3.1) - '@sanity/ui': 2.6.3(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@sanity/ui': 2.6.4-canary.2(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@vitejs/plugin-react': 4.3.0(vite@5.3.3(@types/node@20.12.7)(terser@5.30.3)) axe-core: 4.9.1 cac: 6.7.14 @@ -9709,7 +9709,7 @@ snapshots: - supports-color - terser - '@sanity/ui@2.6.3(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + '@sanity/ui@2.6.4-canary.2(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': dependencies: '@floating-ui/react-dom': 2.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sanity/color': 3.0.6 @@ -9721,6 +9721,7 @@ snapshots: react-is: 18.3.1 react-refractor: 2.2.0(react@18.3.1) styled-components: 6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + use-effect-event: 1.0.2(react@18.3.1) '@sec-ant/readable-stream@0.4.1': {} diff --git a/src/core/components/breadcrumbs/breadcrumbs.tsx b/src/core/components/breadcrumbs/breadcrumbs.tsx index 53e83fc81..c8fba2c07 100644 --- a/src/core/components/breadcrumbs/breadcrumbs.tsx +++ b/src/core/components/breadcrumbs/breadcrumbs.tsx @@ -1,4 +1,13 @@ -import {Children, forwardRef, Fragment, isValidElement, useCallback, useMemo, useState} from 'react' +import { + Children, + forwardRef, + Fragment, + isValidElement, + useCallback, + useMemo, + useRef, + useState, +} from 'react' import {useArrayProp, useClickOutside} from '../../hooks' import {Box, Popover, Stack, Text} from '../../primitives' import {ExpandButton, Root} from './breadcrumbs.styles' @@ -22,13 +31,13 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs( const {children, maxLength, separator, space: spaceRaw = 2, ...restProps} = props const space = useArrayProp(spaceRaw) const [open, setOpen] = useState(false) - const [expandElement, setExpandElement] = useState(null) - const [popoverElement, setPopoverElement] = useState(null) + const expandElementRef = useRef(null) + const popoverElementRef = useRef(null) const collapse = useCallback(() => setOpen(false), []) const expand = useCallback(() => setOpen(true), []) - useClickOutside(collapse, [expandElement, popoverElement]) + useClickOutside(collapse, () => [expandElementRef.current, popoverElementRef.current]) const rawItems = useMemo(() => Children.toArray(children).filter(isValidElement), [children]) @@ -52,14 +61,14 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs( open={open} placement="top" portal - ref={setPopoverElement} + ref={popoverElementRef} > diff --git a/src/core/components/dialog/dialog.tsx b/src/core/components/dialog/dialog.tsx index 2e04ed609..5ac4084cc 100644 --- a/src/core/components/dialog/dialog.tsx +++ b/src/core/components/dialog/dialog.tsx @@ -1,6 +1,6 @@ import {CloseIcon} from '@sanity/icons' import {ThemeColorSchemeKey} from '@sanity/ui/theme' -import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react' +import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef} from 'react' import {styled} from 'styled-components' import { containsOrEqualsElement, @@ -170,7 +170,6 @@ const DialogCard = forwardRef(function DialogCard( const shadow = useArrayProp(shadowProp) const width = useArrayProp(widthProp) const ref = useRef(null) - const [rootElement, setRootElement] = useState(null) const contentRef = useRef(null) const layer = useLayer() const {isTopLayer} = layer @@ -216,32 +215,24 @@ const DialogCard = forwardRef(function DialogCard( ) useClickOutside( - useCallback( - (event: MouseEvent) => { - if (!isTopLayer || !onClickOutside) return + (event: MouseEvent | TouchEvent) => { + if (!isTopLayer || !onClickOutside) return - const target = event.target as Node | null + const target = event.target as Node | null - if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) { - // Ignore clicks outside of the scope - return - } + if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) { + // Ignore clicks outside of the scope + return + } - onClickOutside() - }, - [boundaryElement, isTopLayer, onClickOutside, portalElement], - ), - [rootElement], + onClickOutside() + }, + () => [ref.current], ) - const setRef = useCallback((el: HTMLDivElement | null) => { - setRootElement(el) - ref.current = el - }, []) - return ( - + {showHeader && ( diff --git a/src/core/components/menu/menu.tsx b/src/core/components/menu/menu.tsx index b84a0a373..61949cfaa 100644 --- a/src/core/components/menu/menu.tsx +++ b/src/core/components/menu/menu.tsx @@ -19,7 +19,7 @@ export interface MenuProps extends ResponsivePaddingProps { * @deprecated Use `shouldFocus="last"` instead. */ focusLast?: boolean - onClickOutside?: (event: MouseEvent) => void + onClickOutside?: (event: MouseEvent | TouchEvent) => void onEscape?: () => void onItemClick?: () => void onItemSelect?: (index: number) => void @@ -75,16 +75,18 @@ export const Menu = forwardRef(function Menu( handleItemMouseLeave, handleKeyDown, mount, - rootElement, - setRootElement, - } = useMenuController({onKeyDown, originElement, shouldFocus}) + } = useMenuController({onKeyDown, originElement, shouldFocus, rootElementRef: ref}) const handleRefChange = useCallback( (el: HTMLDivElement | null) => { - setRootElement(el) ref.current = el + + // Register root element (for nested menus) + if (ref.current && registerElement) { + registerElement(ref.current) + } }, - [setRootElement], + [registerElement], ) // Trigger `onItemSelect` when active index changes @@ -94,11 +96,8 @@ export const Menu = forwardRef(function Menu( // Close menu when clicking outside useClickOutside( - useCallback( - (event) => isTopLayer && onClickOutside && onClickOutside(event), - [isTopLayer, onClickOutside], - ), - [rootElement], + (event) => isTopLayer && onClickOutside && onClickOutside(event), + () => [ref.current], ) // Close menu when pressing Escape @@ -116,13 +115,7 @@ export const Menu = forwardRef(function Menu( ), ) - // Register root element (for nested menus) - useEffect(() => { - if (!rootElement || !registerElement) return - - return registerElement(rootElement) - }, [registerElement, rootElement]) - + // @TODO split out into separate contexts const value: MenuContextValue = useMemo( () => ({ version: 0.0, diff --git a/src/core/components/menu/menuButton.tsx b/src/core/components/menu/menuButton.tsx index 55fb25ca8..adf8cf280 100644 --- a/src/core/components/menu/menuButton.tsx +++ b/src/core/components/menu/menuButton.tsx @@ -140,7 +140,7 @@ export const MenuButton = forwardRef(function MenuButton( }, []) const handleMenuClickOutside = useCallback( - (event: MouseEvent) => { + (event: MouseEvent | TouchEvent) => { const target = event.target if (!(target instanceof Node)) { diff --git a/src/core/components/menu/menuContext.ts b/src/core/components/menu/menuContext.ts index fcf11d9a1..b36b0458b 100644 --- a/src/core/components/menu/menuContext.ts +++ b/src/core/components/menu/menuContext.ts @@ -5,7 +5,7 @@ export interface MenuContextValue { activeElement: HTMLElement | null activeIndex: number mount: (element: HTMLElement | null, selected?: boolean) => () => void - onClickOutside?: (event: MouseEvent) => void + onClickOutside?: (event: MouseEvent | TouchEvent) => void onEscape?: () => void onItemClick?: () => void onItemMouseEnter?: (event: React.MouseEvent) => void diff --git a/src/core/components/menu/useMenuController.ts b/src/core/components/menu/useMenuController.ts index e227291f7..0501d43f3 100644 --- a/src/core/components/menu/useMenuController.ts +++ b/src/core/components/menu/useMenuController.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useRef, useState} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {_getFocusableElements, _sortElements} from './helpers' /** @@ -11,8 +11,6 @@ export interface MenuController { handleItemMouseLeave: () => void handleKeyDown: (event: React.KeyboardEvent) => void mount: (element: HTMLElement | null, selected?: boolean) => () => void - rootElement: HTMLDivElement | null - setRootElement: (el: HTMLDivElement | null) => void } /** @@ -24,14 +22,14 @@ export function useMenuController(props: { onKeyDown?: React.KeyboardEventHandler originElement?: HTMLElement | null shouldFocus: 'first' | 'last' | null + rootElementRef: React.MutableRefObject }): MenuController { - const {onKeyDown, originElement, shouldFocus} = props + const {onKeyDown, originElement, shouldFocus, rootElementRef} = props const elementsRef = useRef([]) - const [rootElement, setRootElement] = useState(null) const [activeIndex, _setActiveIndex] = useState(-1) const activeIndexRef = useRef(activeIndex) - const activeElement = elementsRef.current[activeIndex] || null - const mounted = Boolean(rootElement) + const activeElement = useMemo(() => elementsRef.current[activeIndex] || null, [activeIndex]) + const mounted = Boolean(rootElementRef.current) const setActiveIndex = useCallback((nextActiveIndex: number) => { _setActiveIndex(nextActiveIndex) @@ -44,7 +42,7 @@ export function useMenuController(props: { if (elementsRef.current.indexOf(element) === -1) { elementsRef.current.push(element) - _sortElements(rootElement, elementsRef.current) + _sortElements(rootElementRef.current, elementsRef.current) } if (selected) { @@ -61,7 +59,7 @@ export function useMenuController(props: { } } }, - [rootElement, setActiveIndex], + [rootElementRef, setActiveIndex], ) const handleKeyDown = useCallback( @@ -179,17 +177,15 @@ export function useMenuController(props: { // which would be incorrect when the user hovers over a gap // between two menu items or a menu divider. setActiveIndex(-2) - rootElement?.focus() - }, [setActiveIndex, rootElement]) + rootElementRef.current?.focus() + }, [rootElementRef, setActiveIndex]) // Set focus on the currently active element useEffect(() => { if (!mounted) return const rafId = window.requestAnimationFrame(() => { - const _activeIndex = activeIndexRef.current - - if (_activeIndex === -1) { + if (activeIndex === -1) { if (shouldFocus === 'first') { const focusableElements = _getFocusableElements(elementsRef.current) const el = focusableElements[0] @@ -217,7 +213,7 @@ export function useMenuController(props: { return } - const element = elementsRef.current[_activeIndex] || null + const element = elementsRef.current[activeIndex] || null element?.focus() }) @@ -234,7 +230,5 @@ export function useMenuController(props: { handleItemMouseLeave, handleKeyDown, mount, - rootElement, - setRootElement, } } diff --git a/src/core/components/toast/styles.ts b/src/core/components/toast/styles.ts index 55b7bc9f2..50df44be3 100644 --- a/src/core/components/toast/styles.ts +++ b/src/core/components/toast/styles.ts @@ -14,11 +14,12 @@ const loadingAnimation = keyframes` } 100% { width: 100%; - } + } ` const LOADING_BAR_HEIGHT = 2 +// @TODO get rid of $duration modifier, set data attribute instead and use stable selector export function rootStyles( props: {$duration?: number; tone: ThemeColorStateToneKey} & ThemeProps, ): ReturnType { diff --git a/src/core/components/toast/useToast.ts b/src/core/components/toast/useToast.ts index 2398c7ef6..3e1b2a1fc 100644 --- a/src/core/components/toast/useToast.ts +++ b/src/core/components/toast/useToast.ts @@ -13,6 +13,7 @@ export function useToast(): ToastContextValue { throw new Error('useToast(): missing context value') } + // @TODO context and hooks doesn't really work like this, there will never be a mismatch between the provider and the consumer, we can remove these version specifiers // NOTE: This check is for future-compatiblity // - If the value is not an object, it’s not compatible with the current version // - If the value is an object, but doesn’t have `version: 0.0`, it’s not compatible with the current version diff --git a/src/core/components/tree/tree.tsx b/src/core/components/tree/tree.tsx index dd9d1b7f6..f49515354 100644 --- a/src/core/components/tree/tree.tsx +++ b/src/core/components/tree/tree.tsx @@ -84,6 +84,7 @@ export const Tree = memo( }) }, []) + // @TODO split out into separate contexts const contextValue: TreeContextValue = useMemo( () => ({ version: 0.0, diff --git a/src/core/components/tree/treeItem.tsx b/src/core/components/tree/treeItem.tsx index 5c92d8f34..c0b485037 100644 --- a/src/core/components/tree/treeItem.tsx +++ b/src/core/components/tree/treeItem.tsx @@ -76,6 +76,7 @@ export const TreeItem = memo(function TreeItem( const focused = tree.focusedElement === rootRef.current const expanded = itemState?.expanded === undefined ? expandedProp : itemState?.expanded || false const tabIndex = tree.focusedElement && tree.focusedElement === rootRef.current ? 0 : -1 + // @TODO split out into separate contexts const contextValue = useMemo( () => ({...tree, level: tree.level + 1, path: itemPath}), [itemPath, tree], diff --git a/src/core/helpers/focus.ts b/src/core/helpers/focus.ts index 2e331ddf0..ea4b6995b 100644 --- a/src/core/helpers/focus.ts +++ b/src/core/helpers/focus.ts @@ -15,6 +15,7 @@ import { * @internal */ export function _hasFocus(element: HTMLElement): boolean { + // @TODO verify this is not called during render return Boolean(document.activeElement) && element.contains(document.activeElement) } diff --git a/src/core/helpers/scroll.ts b/src/core/helpers/scroll.ts index bff6de6e9..d850e51be 100644 --- a/src/core/helpers/scroll.ts +++ b/src/core/helpers/scroll.ts @@ -4,6 +4,7 @@ export function _isScrollable(el: Node): boolean { if (!(el instanceof Element)) return false + // @TODO check if this is called during render const style = window.getComputedStyle(el) return ( diff --git a/src/core/hooks/_internal/index.ts b/src/core/hooks/_internal/index.ts deleted file mode 100644 index 89652a849..000000000 --- a/src/core/hooks/_internal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useUnique' diff --git a/src/core/hooks/_internal/useUnique.ts b/src/core/hooks/_internal/useUnique.ts deleted file mode 100644 index d7a14e372..000000000 --- a/src/core/hooks/_internal/useUnique.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {useRef} from 'react' - -/** - * This is a React hook to make sure that a record identity is the same on every render. Uses strict - * equality comparison (eg by identity), and only goes one level deep. - * - * @internal - */ -type Comparable = Record | undefined | null - -export function useUnique(value: ValueType): ValueType { - const valueRef = useRef(value) - - if (!_isEqual(valueRef.current, value)) { - valueRef.current = value - } - - return valueRef.current -} - -function _isEqual(objA: Comparable, objB: Comparable): boolean { - if (!objA || !objB) { - return objA === objB - } - - const keysA = Object.keys(objA) - const keysB = Object.keys(objB) - - if (keysA.length !== keysB.length) { - return false - } - - return keysA.every((key) => objA[key] === objB[key]) -} diff --git a/src/core/hooks/index.ts b/src/core/hooks/index.ts index 5599c3213..df1576c1b 100644 --- a/src/core/hooks/index.ts +++ b/src/core/hooks/index.ts @@ -1,10 +1,11 @@ export * from './useArrayProp' export * from './useClickOutside' +export * from './useCustomValidity' export * from './useElementRect' export * from './useElementSize' +export * from './useForwardedRef' export * from './useGlobalKeyDown' +export * from './useMatchMedia' export * from './useMediaIndex' export * from './usePrefersDark' export * from './usePrefersReducedMotion' -export * from './useForwardedRef' -export * from './useCustomValidity' diff --git a/src/core/hooks/useClickOutside.ts b/src/core/hooks/useClickOutside.ts index 92a46f466..439fee3e0 100644 --- a/src/core/hooks/useClickOutside.ts +++ b/src/core/hooks/useClickOutside.ts @@ -1,99 +1,65 @@ -import {useEffect, useRef, useState} from 'react' +import {useEffect} from 'react' +import {useEffectEvent} from 'use-effect-event' import {EMPTY_ARRAY} from '../constants' /** * @public */ -export type ClickOutsideListener = (event: MouseEvent) => void - -function _getElements( - element: HTMLElement | null, - elementsArg: Array, -): HTMLElement[] { - const ret = [element] - - for (const el of elementsArg) { - if (Array.isArray(el)) { - ret.push(...el) - } else { - ret.push(el) - } - } - - return ret.filter(Boolean) as HTMLElement[] -} +export type ClickOutsideListener = (event: MouseEvent | TouchEvent) => void /** + * Use the callback version of `elementsArg` if you're using `useRef` to handle elements: + * ```tsx + * useClickOutside( + * () => {}, + * () => [ref.current], + * ) + * * @public */ export function useClickOutside( listener: ClickOutsideListener, - elementsArg: Array = EMPTY_ARRAY, + elementsArg: + | Array + | (() => Array) = EMPTY_ARRAY, boundaryElement?: HTMLElement | null, -): (el: HTMLElement | null) => void { - const [element, setElement] = useState(null) - const [elements, setElements] = useState(() => _getElements(element, elementsArg)) - const elementsRef = useRef(elements) - - useEffect(() => { - const prevElements = elementsRef.current - const nextElements = _getElements(element, elementsArg) - - if (prevElements.length !== nextElements.length) { - setElements(nextElements) - elementsRef.current = nextElements - +): void { + /** + * The `useEffectEvent` hook allow us to always see the latest value of `listener`, `elementsArg` and `boundaryElement` without needing to + * juggle `useState`, `useRef` and `useState` to make sure the `mousedown` event listener isn't constantly being added and removed. + */ + const eventHandler = useEffectEvent((evt: MouseEvent | TouchEvent) => { + const target = evt.target + + if (!(target instanceof Node)) { return } - for (const el of prevElements) { - if (!nextElements.includes(el)) { - setElements(nextElements) - elementsRef.current = nextElements - - return - } - } - - for (const el of nextElements) { - if (!prevElements.includes(el)) { - setElements(nextElements) - elementsRef.current = nextElements - - return - } + if (boundaryElement && !boundaryElement.contains(target)) { + return } - }, [element, elementsArg]) - useEffect(() => { - if (!listener) return undefined - - const handleWindowMouseDown = (evt: MouseEvent) => { - const target = evt.target + const resolvedElements = Array.isArray(elementsArg) ? elementsArg : elementsArg() + const elements = resolvedElements.flat() - if (!(target instanceof Node)) { - return - } + for (const el of elements) { + if (!el) continue - if (boundaryElement && !boundaryElement.contains(target)) { + if (target === el || el.contains(target)) { return } - - for (const el of elements) { - if (target === el || el.contains(target)) { - return - } - } - - listener(evt) } - window.addEventListener('mousedown', handleWindowMouseDown) + listener(evt) + }) + + useEffect(() => { + document.addEventListener('mousedown', eventHandler) + document.addEventListener('touchstart', eventHandler) return () => { - window.removeEventListener('mousedown', handleWindowMouseDown) + document.removeEventListener('mousedown', eventHandler) + document.removeEventListener('touchstart', eventHandler) } - }, [boundaryElement, listener, elements]) - - return setElement + }, [eventHandler]) } diff --git a/src/core/hooks/useElementSize.ts b/src/core/hooks/useElementSize.ts index 51adef0d5..e5f58f11b 100644 --- a/src/core/hooks/useElementSize.ts +++ b/src/core/hooks/useElementSize.ts @@ -6,6 +6,7 @@ import {ElementSize, _elementSizeObserver} from '../observers/elementSizeObserve * @beta */ export function useElementSize(element: HTMLElement | null): ElementSize | null { + // @TODO we can probably use something in framer-motion or @floating-ui instead of rolling our own const [size, setSize] = useState(null) useEffect(() => { diff --git a/src/core/hooks/useMatchMedia.ts b/src/core/hooks/useMatchMedia.ts new file mode 100644 index 000000000..c155698ef --- /dev/null +++ b/src/core/hooks/useMatchMedia.ts @@ -0,0 +1,46 @@ +import {useDebugValue, useMemo, useSyncExternalStore} from 'react' + +/** + * Efficiently subscribes to `window.matchMedia` queries + * + * @param getServerSnapshot - Only called during server-side rendering, and hydration if using hydrateRoot. Required if the hook is called during SSR (https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering) + * + * @public + */ +export function useMatchMedia( + mediaQueryString: `(${string})`, + getServerSnapshot?: () => boolean, +): boolean { + const {subscribe, getSnapshot} = useMemo(() => { + /** + * `subscribe` and `getSnapshot` are only called on the client and both need access to the same `matchMedia` instance + * we don't want to eagerly instantiate it to ensure it's only created when actually used + */ + let MEDIA_QUERY_CACHE: MediaQueryList | undefined + + const getMatchMedia = (): MediaQueryList => { + if (!MEDIA_QUERY_CACHE) { + // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the + // the `window` global is available and we're in a browser environment + MEDIA_QUERY_CACHE = window.matchMedia(mediaQueryString) + } + + return MEDIA_QUERY_CACHE + } + + return { + subscribe: (onStoreChange: () => void): (() => void) => { + const matchMedia = getMatchMedia() + + matchMedia.addEventListener('change', onStoreChange) + + return () => matchMedia.removeEventListener('change', onStoreChange) + }, + getSnapshot: () => getMatchMedia().matches, + } + }, [mediaQueryString]) + + useDebugValue(mediaQueryString) + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} diff --git a/src/core/hooks/useMediaIndex/useMediaIndex.ts b/src/core/hooks/useMediaIndex/useMediaIndex.ts index 8524630f5..b2bfc94f7 100644 --- a/src/core/hooks/useMediaIndex/useMediaIndex.ts +++ b/src/core/hooks/useMediaIndex/useMediaIndex.ts @@ -1,4 +1,4 @@ -import {useSyncExternalStore} from 'react' +import {useMemo, useSyncExternalStore} from 'react' import {useTheme_v2} from '../../theme' /** @@ -9,8 +9,6 @@ export interface _MediaStore { getSnapshot: () => number } -const MEDIA_STORE_CACHE = new WeakMap() - type MediaQueryMinWidth = `(min-width: ${number}px)` type MediaQueryMaxWidth = `(max-width: ${number}px)` type MediaQueryMinMaxWidth = `${MediaQueryMinWidth} and ${MediaQueryMaxWidth}` @@ -97,13 +95,7 @@ function getServerSnapshot() { */ export function useMediaIndex(): number { const {media} = useTheme_v2() - - let store = MEDIA_STORE_CACHE.get(media) - - if (!store) { - store = _createMediaStore(media) - MEDIA_STORE_CACHE.set(media, store) - } + const store = useMemo(() => _createMediaStore(media), [media]) return useSyncExternalStore(store.subscribe, store.getSnapshot, getServerSnapshot) } diff --git a/src/core/hooks/usePrefersDark.ts b/src/core/hooks/usePrefersDark.ts index bd03ab133..1f18c5ba0 100644 --- a/src/core/hooks/usePrefersDark.ts +++ b/src/core/hooks/usePrefersDark.ts @@ -1,61 +1,16 @@ -import {useSyncExternalStore} from 'react' - -let MEDIA_QUERY_CACHE: MediaQueryList | undefined - -/** - * Lazy init the matchMedia instance - */ -function getMatchMedia(): MediaQueryList { - if (!MEDIA_QUERY_CACHE) { - // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the - // the `window` global is available and we're in a browser environment - MEDIA_QUERY_CACHE = window.matchMedia('(prefers-color-scheme: dark)') - } - - return MEDIA_QUERY_CACHE -} - -/** - * As the query is the same for all instances of this hook, we can cache the matchMedia instance - * and have cheap `change` event listeners, while getSnapshot always reads from the same - * matchMedia instance and we don't get any tearing. - * Tearing in this context means the bad edge case in React concurrent render mdoe - * where you sometimes would end up with some components doing render while seeing `usePrefersDark() === true` while others would see `usePrefersDark() === false` - * during the same render. - * By using `useSyncExternalStore` every component only sees the same value during the same render, and always re-render when it changes no matter - * what React.memo boundaries there might be between the layers.. - */ -function subscribe(onStoreChange: () => void): () => void { - const matchMedia = getMatchMedia() - - matchMedia.addEventListener('change', onStoreChange) - - return () => matchMedia.removeEventListener('change', onStoreChange) -} - -/** - * Only called client-side, when using createRoot, or after hydration is complete when using hydrateRoot. - * It's important that this function does not create new objects or arrays when called: - * https://beta.reactjs.org/apis/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached - */ -function getSnapshot() { - return getMatchMedia().matches -} +import {useMatchMedia} from './useMatchMedia' /** - * Only called during server-side rendering, and hydration if using hydrateRoot - * Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query - * and we assume `(prefers-color-scheme: light)` since it's the most common scheme + * Returns true if a dark color scheme is preferred, false if a light color scheme is preferred or the preference is not known. + * + * @param getServerSnapshot - Only called during server-side rendering, and hydration if using hydrateRoot. Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query and we assume `(prefers-color-scheme: light)` since it's the most common scheme (https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering) + * + * If you persist the detected preference in a cookie or a header then you may implement your own server snapshot to read it. + * Chrome supports reading the `prefers-color-scheme` media query from a header if the server response: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Color-Scheme + * @example https://gist.github.com/stipsan/13c0cccf8dfc34f4b44bb1b984baf7df * - * @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering - */ -function getServerSnapshot() { - return false -} - -/** * @public */ -export function usePrefersDark(): boolean { - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +export function usePrefersDark(getServerSnapshot = () => false): boolean { + return useMatchMedia('(prefers-color-scheme: dark)', getServerSnapshot) } diff --git a/src/core/hooks/usePrefersReducedMotion.ts b/src/core/hooks/usePrefersReducedMotion.ts index 7fd872d61..f0ae0c351 100644 --- a/src/core/hooks/usePrefersReducedMotion.ts +++ b/src/core/hooks/usePrefersReducedMotion.ts @@ -1,62 +1,16 @@ -import {useSyncExternalStore} from 'react' - -let MEDIA_QUERY_CACHE: MediaQueryList | undefined - -/** - * Lazy init the matchMedia instance - */ -function getMatchMedia(): MediaQueryList { - if (!MEDIA_QUERY_CACHE) { - // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the - // the `window` global is available and we're in a browser environment - MEDIA_QUERY_CACHE = window.matchMedia('(prefers-reduced-motion: reduce)') - } - - return MEDIA_QUERY_CACHE -} - -/** - * As the query is the same for all instances of this hook, we can cache the matchMedia instance - * and have cheap `change` event listeners, while getSnapshot always reads from the same - * matchMedia instance and we don't get any tearing. - * Tearing in this context means the bad edge case in React concurrent render mdoe - * where you sometimes would end up with some components doing render while seeing `usePrefersDark() === true` while others would see `usePrefersDark() === false` - * during the same render. - * By using `useSyncExternalStore` every component only sees the same value during the same render, and always re-render when it changes no matter - * what React.memo boundaries there might be between the layers.. - */ -function subscribe(onStoreChange: () => void): () => void { - const matchMedia = getMatchMedia() - - matchMedia.addEventListener('change', onStoreChange) - - return () => matchMedia.removeEventListener('change', onStoreChange) -} - -/** - * Only called client-side, when using createRoot, or after hydration is complete when using hydrateRoot. - * It's important that this function does not create new objects or arrays when called: - * https://beta.reactjs.org/apis/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached - */ -function getSnapshot() { - return getMatchMedia().matches -} - -/** - * Only called during server-side rendering, and hydration if using hydrateRoot - * Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query - * and we assume `(prefers-reduced-motion: no-preference)` since it's the most common scheme - * - * @link https://beta.reactjs.org/apis/react/useSyncExternalStore#adding-support-for-server-rendering - */ -function getServerSnapshot() { - return false -} +import {useMatchMedia} from './useMatchMedia' /** * Returns true if motion should be reduced + * + * @param getServerSnapshot - Only called during server-side rendering, and hydration if using hydrateRoot. Since the server environment doesn't have access to the DOM, we can't determine the current value of the media query and we assume `(prefers-reduced-motion: no-preference)` since it's the most common scheme (https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering) + * + * If you persist the detected preference in a cookie or a header then you may implement your own server snapshot to read it. + * Chrome supports reading the `prefers-reduced-motion` media query from a header if the server response: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-Prefers-Reduced-Motion + * @example https://gist.github.com/stipsan/0c0f839a27842249cada893e9fb7767b + * * @public */ -export function usePrefersReducedMotion(): boolean { - return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +export function usePrefersReducedMotion(getServerSnapshot = () => false): boolean { + return useMatchMedia('(prefers-reduced-motion: reduce)', getServerSnapshot) } diff --git a/src/core/primitives/popover/__workshop__/AlignedStory.tsx b/src/core/primitives/popover/__workshop__/AlignedStory.tsx index 53e011d49..c78840e15 100644 --- a/src/core/primitives/popover/__workshop__/AlignedStory.tsx +++ b/src/core/primitives/popover/__workshop__/AlignedStory.tsx @@ -1,7 +1,7 @@ import {EllipsisVerticalIcon} from '@sanity/icons' import {Button, Card, Flex, Popover, Text, useClickOutside} from '@sanity/ui' import {useBoolean, useSelect} from '@sanity/ui-workshop' -import {useCallback, useState} from 'react' +import {useCallback, useRef, useState} from 'react' import { WORKSHOP_FLEX_ALIGN_OPTIONS, WORKSHOP_FLEX_JUSTIFY_OPTIONS, @@ -20,8 +20,8 @@ export default function AlignedStory() { const [open, setOpen] = useState(false) const [boundaryElement, setBoundaryElement] = useState(null) - const [buttonElement, setButtonElement] = useState(null) - const [popoverElement, setPopoverElement] = useState(null) + const buttonElementRef = useRef(null) + const popoverElementRef = useRef(null) const content = ( @@ -39,9 +39,11 @@ export default function AlignedStory() { ) const handleToggleOpen = useCallback(() => setOpen((v) => !v), []) - const handleClose = useCallback(() => setOpen(false), []) - useClickOutside(handleClose, [buttonElement, popoverElement]) + useClickOutside( + () => setOpen(false), + () => [buttonElementRef.current, popoverElementRef.current], + ) return ( @@ -56,14 +58,14 @@ export default function AlignedStory() { padding={3} portal={portal} placement={placement} - ref={setPopoverElement} + ref={popoverElementRef} width={width} >