diff --git a/.parcelrc b/.parcelrc index 025b56a2a6d..a265e9a1541 100644 --- a/.parcelrc +++ b/.parcelrc @@ -11,6 +11,7 @@ "*.{md,mdx}": ["parcel-transformer-mdx-docs"], "*.svg": ["@parcel/transformer-svg-react"], "packages/@react-aria/example-theme/**/*.css": ["@parcel/transformer-css"], + "starters/docs/src/*.css": ["@parcel/transformer-css"], "*.css": ["...", "parcel-transformer-css-env"], "*.{js,mjs,jsm,jsx,es6,cjs,ts,tsx}": [ "@parcel/transformer-js", diff --git a/eslint.config.mjs b/eslint.config.mjs index 92f49f59acc..c1ac2344dd9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,6 @@ import reactHooks from "eslint-plugin-react-hooks"; import jest from "eslint-plugin-jest"; import monorepo from "@jdb8/eslint-plugin-monorepo"; import * as rspRules from "eslint-plugin-rsp-rules"; -import { fixupPluginRules } from "@eslint/compat"; import globals from "globals"; import babelParser from "@babel/eslint-parser"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; @@ -67,7 +66,7 @@ export default [{ react, rulesdir, "jsx-a11y": jsxA11Y, - "react-hooks": fixupPluginRules(reactHooks), + "react-hooks": reactHooks, jest, monorepo, "rsp-rules": rspRules, @@ -225,8 +224,28 @@ export default [{ "react/jsx-boolean-value": ERROR, "react/jsx-first-prop-new-line": [ERROR, "multiline"], "react/self-closing-comp": ERROR, + + // Core hooks rules "react-hooks/rules-of-hooks": ERROR, // https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md "react-hooks/exhaustive-deps": WARN, + + // React Compiler rules + 'react-hooks/config': ERROR, + 'react-hooks/error-boundaries': ERROR, + 'react-hooks/component-hook-factories': ERROR, + 'react-hooks/gating': ERROR, + // 'react-hooks/globals': ERROR, + // 'react-hooks/immutability': ERROR, + // 'react-hooks/preserve-manual-memoization': ERROR, + // 'react-hooks/purity': ERROR, + // 'react-hooks/refs': ERROR, + // 'react-hooks/set-state-in-effect': ERROR, + 'react-hooks/set-state-in-render': ERROR, + // 'react-hooks/static-components': ERROR, + 'react-hooks/unsupported-syntax': WARN, + 'react-hooks/use-memo': ERROR, + 'react-hooks/incompatible-library': WARN, + "rsp-rules/no-react-key": [ERROR], "rsp-rules/sort-imports": [ERROR], "rulesdir/imports": [ERROR], @@ -332,7 +351,7 @@ export default [{ react, rulesdir, "jsx-a11y": jsxA11Y, - "react-hooks": fixupPluginRules(reactHooks), + "react-hooks": reactHooks, jest, "@typescript-eslint": typescriptEslint, monorepo, diff --git a/lib/svg.d.ts b/lib/svg.d.ts index 6d7c0a20c88..9569e1964b5 100644 --- a/lib/svg.d.ts +++ b/lib/svg.d.ts @@ -15,6 +15,11 @@ declare module 'bundle-text:*' { export default content; } +declare module 'url:*' { + const content: string; + export default content; +} + declare module '*.svg' { import {FunctionComponent, SVGProps} from 'react'; const content: FunctionComponent> ; diff --git a/package.json b/package.json index 6fde5bad643..cd51b2ecd1c 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "eslint-plugin-jsdoc": "^50.4.1", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.1", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-rulesdir": "^0.2.2", "fast-check": "^2.19.0", "fast-glob": "^3.1.0", diff --git a/packages/@react-aria/actiongroup/src/useActionGroupItem.ts b/packages/@react-aria/actiongroup/src/useActionGroupItem.ts index 9f33010a1eb..5cb90c01764 100644 --- a/packages/@react-aria/actiongroup/src/useActionGroupItem.ts +++ b/packages/@react-aria/actiongroup/src/useActionGroupItem.ts @@ -54,7 +54,7 @@ export function useActionGroupItem(props: AriaActionGroupItemProps, state: Li return () => { onRemovedWithFocus(); }; - }, [onRemovedWithFocus]); + }, []); return { buttonProps: mergeProps(buttonProps, { diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 2110caebeef..b9c1e6420ee 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, BaseEvent, DOMProps, FocusableElement, FocusEvents, KeyboardEvents, Node, RefObject, ValueBase} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isAndroid, isCtrlKeyPressed, isIOS, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useLayoutEffect, useObjectRef} from '@react-aria/utils'; import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore @@ -92,7 +92,6 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); let queuedActiveDescendant = useRef(null); - let lastCollectionNode = useRef(null); // For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually // moving focus back to the subtriggers @@ -106,7 +105,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut return () => clearTimeout(timeout.current); }, []); - let updateActiveDescendant = useEffectEvent((e: Event) => { + let updateActiveDescendantEvent = useEffectEvent((e: Event) => { // Ensure input is focused if the user clicks on the collection directly. if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) { inputRef.current.focus(); @@ -140,32 +139,36 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut delayNextActiveDescendant.current = false; }); - let callbackRef = useCallback((collectionNode) => { - if (collectionNode != null) { - // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement - // of the letter you just typed. If we recieve another focus event then we clear the queued update - // We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles - // React 19's extra call of the callback ref in strict mode - lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant); - lastCollectionNode.current = collectionNode; - collectionNode.addEventListener('focusin', updateActiveDescendant); + let [collectionNode, setCollectionNode] = useState(null); + let callbackRef = useCallback((node) => { + setCollectionNode(node); + if (node != null) { // If useSelectableCollection isn't passed shouldUseVirtualFocus even when useAutocomplete provides it // that means the collection doesn't support it (e.g. Table). If that is the case, we need to disable it here regardless // of what the user's provided so that the input doesn't recieve the onKeyDown and autocomplete props. - if (collectionNode.getAttribute('tabindex') != null) { + if (node.getAttribute('tabindex') != null) { setShouldUseVirtualFocus(false); } setHasCollection(true); } else { - lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant); setHasCollection(false); } - }, [updateActiveDescendant]); + }, []); + useLayoutEffect(() => { + if (collectionNode != null) { + // When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement + // of the letter you just typed. If we recieve another focus event then we clear the queued update + collectionNode.addEventListener('focusin', updateActiveDescendantEvent); + } + return () => { + collectionNode?.removeEventListener('focusin', updateActiveDescendantEvent); + }; + }, [collectionNode]); // Make sure to memo so that React doesn't keep registering a new event listeners on every rerender of the wrapped collection let mergedCollectionRef = useObjectRef(useMemo(() => mergeRefs(collectionRef, callbackRef), [collectionRef, callbackRef])); - let focusFirstItem = useEffectEvent(() => { + let focusFirstItem = useCallback(() => { delayNextActiveDescendant.current = true; collectionRef.current?.dispatchEvent( new CustomEvent(FOCUS_EVENT, { @@ -176,9 +179,9 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut } }) ); - }); + }, [collectionRef]); - let clearVirtualFocus = useEffectEvent((clearFocusKey?: boolean) => { + let clearVirtualFocus = useCallback((clearFocusKey?: boolean) => { moveVirtualFocus(getActiveElement()); queuedActiveDescendant.current = null; state.setFocusedNodeId(null); @@ -192,7 +195,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut clearTimeout(timeout.current); delayNextActiveDescendant.current = false; collectionRef.current?.dispatchEvent(clearFocusEvent); - }); + }, [collectionRef, state]); let lastInputType = useRef(''); useEvent(inputRef, 'input', e => { @@ -346,7 +349,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut return () => { document.removeEventListener('keyup', onKeyUpCapture, true); }; - }, [onKeyUpCapture]); + }, []); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/autocomplete'); let collectionProps = useLabels({ diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index 7726c2c6168..8c8a7bd12d3 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -221,7 +221,7 @@ const CollectionContext = createContext | null>(n export function Collection(props: CollectionProps): JSX.Element { let ctx = useContext(CollectionContext)!; let dependencies = (ctx?.dependencies || []).concat(props.dependencies); - let idScope = props.idScope || ctx?.idScope; + let idScope = props.idScope ?? ctx?.idScope; let children = useCollectionChildren({ ...props, idScope, diff --git a/packages/@react-aria/collections/src/useCachedChildren.ts b/packages/@react-aria/collections/src/useCachedChildren.ts index 1ce6c9143be..0e0d6487af5 100644 --- a/packages/@react-aria/collections/src/useCachedChildren.ts +++ b/packages/@react-aria/collections/src/useCachedChildren.ts @@ -50,7 +50,7 @@ export function useCachedChildren(props: CachedChildrenOptions throw new Error('Could not determine key for item'); } - if (idScope) { + if (idScope != null) { key = idScope + ':' + key; } // Note: only works if wrapped Item passes through id... diff --git a/packages/@react-aria/dnd/src/useClipboard.ts b/packages/@react-aria/dnd/src/useClipboard.ts index 53f23ee148b..fb883fa598e 100644 --- a/packages/@react-aria/dnd/src/useClipboard.ts +++ b/packages/@react-aria/dnd/src/useClipboard.ts @@ -143,7 +143,7 @@ export function useClipboard(options: ClipboardProps): ClipboardResult { addGlobalEventListener('beforepaste', onBeforePaste), addGlobalEventListener('paste', onPaste) ); - }, [isDisabled, onBeforeCopy, onCopy, onBeforeCut, onCut, onBeforePaste, onPaste]); + }, [isDisabled]); return { clipboardProps: focusProps diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index 09985b2d7a5..31b2204546b 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -339,7 +339,7 @@ export function useDrop(options: DropOptions): DropResult { onDrop: onKeyboardDrop, onDropActivate }); - }, [isDisabled, ref, getDropOperationKeyboard, onDropEnter, onDropExit, onKeyboardDrop, onDropActivate]); + }, [isDisabled, ref]); let {dropProps} = useVirtualDrop(); if (isDisabled) { diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index 034814fd6f2..b4699bf8046 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -112,7 +112,7 @@ export function useFormValidation(props: FormValidationProps, state: FormV form.reset = reset; } }; - }, [ref, onInvalid, onChange, onReset, validationBehavior]); + }, [ref, validationBehavior]); } function getValidity(input: ValidatableElement) { diff --git a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts index c3d84564c32..5d129aedba9 100644 --- a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts +++ b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts @@ -15,9 +15,9 @@ import {Collection, Key, Node, Selection} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; import {SelectionManager} from '@react-stately/selection'; -import {useEffectEvent, useUpdateEffect} from '@react-aria/utils'; +import {useCallback, useRef} from 'react'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useRef} from 'react'; +import {useUpdateEffect} from '@react-aria/utils'; export interface GridSelectionAnnouncementProps { /** @@ -46,7 +46,7 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement // We do this using an ARIA live region. let selection = state.selectionManager.rawSelection; let lastSelection = useRef(selection); - let announceSelectionChange = useEffectEvent(() => { + let announceSelectionChange = useCallback(() => { if (!state.selectionManager.isFocused || selection === lastSelection.current) { lastSelection.current = selection; @@ -101,8 +101,18 @@ export function useGridSelectionAnnouncement(props: GridSelectionAnnouncement } lastSelection.current = selection; - }); - + }, [ + selection, + state.selectionManager.selectedKeys, + state.selectionManager.isFocused, + state.selectionManager.selectionBehavior, + state.selectionManager.selectionMode, + state.collection, + getRowText, + stringFormatter + ]); + + // useUpdateEffect will handle using useEffectEvent, no need to stabilize anything on this end useUpdateEffect(() => { if (state.selectionManager.isFocused) { announceSelectionChange(); diff --git a/packages/@react-aria/interactions/src/useInteractOutside.ts b/packages/@react-aria/interactions/src/useInteractOutside.ts index 94c1e65a46c..9f413630ca3 100644 --- a/packages/@react-aria/interactions/src/useInteractOutside.ts +++ b/packages/@react-aria/interactions/src/useInteractOutside.ts @@ -111,7 +111,7 @@ export function useInteractOutside(props: InteractOutsideProps): void { documentObject.removeEventListener('touchend', onTouchEnd, true); }; } - }, [ref, isDisabled, onPointerDown, triggerInteractOutside]); + }, [ref, isDisabled]); } function isValidEvent(event, ref) { diff --git a/packages/@react-aria/interactions/src/useMove.ts b/packages/@react-aria/interactions/src/useMove.ts index f7f32acfdbb..c8158b4f9c3 100644 --- a/packages/@react-aria/interactions/src/useMove.ts +++ b/packages/@react-aria/interactions/src/useMove.ts @@ -12,8 +12,8 @@ import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, MoveEvents, PointerType} from '@react-types/shared'; -import React, {useMemo, useRef} from 'react'; -import {useEffectEvent, useGlobalListeners} from '@react-aria/utils'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {useEffectEvent, useGlobalListeners, useLayoutEffect} from '@react-aria/utils'; export interface MoveResult { /** Props to spread on the target element. */ @@ -43,7 +43,7 @@ export function useMove(props: MoveEvents): MoveResult { let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); - let move = useEffectEvent((originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => { + let move = useCallback((originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => { if (deltaX === 0 && deltaY === 0) { return; } @@ -69,9 +69,10 @@ export function useMove(props: MoveEvents): MoveResult { ctrlKey: originalEvent.ctrlKey, altKey: originalEvent.altKey }); - }); + }, [onMoveStart, onMove, state]); + let moveEvent = useEffectEvent(move); - let end = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let end = useCallback((originalEvent: EventBase, pointerType: PointerType) => { restoreTextSelection(); if (state.current.didMove) { onMoveEnd?.({ @@ -83,57 +84,111 @@ export function useMove(props: MoveEvents): MoveResult { altKey: originalEvent.altKey }); } - }); + }, [onMoveEnd, state]); + let endEvent = useEffectEvent(end); - let moveProps = useMemo(() => { - let moveProps: DOMAttributes = {}; + let [pointerDown, setPointerDown] = useState<'pointer' | 'mouse' | 'touch' | null>(null); + useLayoutEffect(() => { + if (pointerDown === 'pointer') { + let onPointerMove = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + let pointerType = (e.pointerType || 'mouse') as PointerType; - let start = () => { - disableTextSelection(); - state.current.didMove = false; - }; + // Problems with PointerEvent#movementX/movementY: + // 1. it is always 0 on macOS Safari. + // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS + moveEvent(e, pointerType, e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + } + }; - if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') { + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + let pointerType = (e.pointerType || 'mouse') as PointerType; + endEvent(e, pointerType); + state.current.id = null; + removeGlobalListener(window, 'pointermove', onPointerMove, false); + removeGlobalListener(window, 'pointerup', onPointerUp, false); + removeGlobalListener(window, 'pointercancel', onPointerUp, false); + setPointerDown(null); + } + }; + addGlobalListener(window, 'pointermove', onPointerMove, false); + addGlobalListener(window, 'pointerup', onPointerUp, false); + addGlobalListener(window, 'pointercancel', onPointerUp, false); + return () => { + removeGlobalListener(window, 'pointermove', onPointerMove, false); + removeGlobalListener(window, 'pointerup', onPointerUp, false); + removeGlobalListener(window, 'pointercancel', onPointerUp, false); + }; + } else if (pointerDown === 'mouse' && process.env.NODE_ENV === 'test') { let onMouseMove = (e: MouseEvent) => { if (e.button === 0) { - move(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); + moveEvent(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; } }; let onMouseUp = (e: MouseEvent) => { if (e.button === 0) { - end(e, 'mouse'); + endEvent(e, 'mouse'); removeGlobalListener(window, 'mousemove', onMouseMove, false); removeGlobalListener(window, 'mouseup', onMouseUp, false); + setPointerDown(null); } }; - moveProps.onMouseDown = (e: React.MouseEvent) => { - if (e.button === 0) { - start(); - e.stopPropagation(); - e.preventDefault(); - state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; - addGlobalListener(window, 'mousemove', onMouseMove, false); - addGlobalListener(window, 'mouseup', onMouseUp, false); - } + addGlobalListener(window, 'mousemove', onMouseMove, false); + addGlobalListener(window, 'mouseup', onMouseUp, false); + return () => { + removeGlobalListener(window, 'mousemove', onMouseMove, false); + removeGlobalListener(window, 'mouseup', onMouseUp, false); }; - + } else if (pointerDown === 'touch' && process.env.NODE_ENV === 'test') { let onTouchMove = (e: TouchEvent) => { let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); if (touch >= 0) { let {pageX, pageY} = e.changedTouches[touch]; - move(e, 'touch', pageX - (state.current.lastPosition?.pageX ?? 0), pageY - (state.current.lastPosition?.pageY ?? 0)); + moveEvent(e, 'touch', pageX - (state.current.lastPosition?.pageX ?? 0), pageY - (state.current.lastPosition?.pageY ?? 0)); state.current.lastPosition = {pageX, pageY}; } }; let onTouchEnd = (e: TouchEvent) => { let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); if (touch >= 0) { - end(e, 'touch'); + endEvent(e, 'touch'); state.current.id = null; removeGlobalListener(window, 'touchmove', onTouchMove); removeGlobalListener(window, 'touchend', onTouchEnd); removeGlobalListener(window, 'touchcancel', onTouchEnd); + setPointerDown(null); + } + }; + addGlobalListener(window, 'touchmove', onTouchMove, false); + addGlobalListener(window, 'touchend', onTouchEnd, false); + addGlobalListener(window, 'touchcancel', onTouchEnd, false); + return () => { + removeGlobalListener(window, 'touchmove', onTouchMove, false); + removeGlobalListener(window, 'touchend', onTouchEnd, false); + removeGlobalListener(window, 'touchcancel', onTouchEnd, false); + }; + } + }, [pointerDown, addGlobalListener, removeGlobalListener]); + + let moveProps = useMemo(() => { + let moveProps: DOMAttributes = {}; + + let start = () => { + disableTextSelection(); + state.current.didMove = false; + }; + + if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') { + moveProps.onMouseDown = (e: React.MouseEvent) => { + if (e.button === 0) { + start(); + e.stopPropagation(); + e.preventDefault(); + state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; + setPointerDown('mouse'); } }; moveProps.onTouchStart = (e: React.TouchEvent) => { @@ -147,34 +202,9 @@ export function useMove(props: MoveEvents): MoveResult { e.preventDefault(); state.current.lastPosition = {pageX, pageY}; state.current.id = identifier; - addGlobalListener(window, 'touchmove', onTouchMove, false); - addGlobalListener(window, 'touchend', onTouchEnd, false); - addGlobalListener(window, 'touchcancel', onTouchEnd, false); + setPointerDown('touch'); }; } else { - let onPointerMove = (e: PointerEvent) => { - if (e.pointerId === state.current.id) { - let pointerType = (e.pointerType || 'mouse') as PointerType; - - // Problems with PointerEvent#movementX/movementY: - // 1. it is always 0 on macOS Safari. - // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS - move(e, pointerType, e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0)); - state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; - } - }; - - let onPointerUp = (e: PointerEvent) => { - if (e.pointerId === state.current.id) { - let pointerType = (e.pointerType || 'mouse') as PointerType; - end(e, pointerType); - state.current.id = null; - removeGlobalListener(window, 'pointermove', onPointerMove, false); - removeGlobalListener(window, 'pointerup', onPointerUp, false); - removeGlobalListener(window, 'pointercancel', onPointerUp, false); - } - }; - moveProps.onPointerDown = (e: React.PointerEvent) => { if (e.button === 0 && state.current.id == null) { start(); @@ -182,9 +212,7 @@ export function useMove(props: MoveEvents): MoveResult { e.preventDefault(); state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; state.current.id = e.pointerId; - addGlobalListener(window, 'pointermove', onPointerMove, false); - addGlobalListener(window, 'pointerup', onPointerUp, false); - addGlobalListener(window, 'pointercancel', onPointerUp, false); + setPointerDown('pointer'); } }; } @@ -225,7 +253,7 @@ export function useMove(props: MoveEvents): MoveResult { }; return moveProps; - }, [state, addGlobalListener, removeGlobalListener, move, end]); + }, [state, move, end]); return {moveProps}; } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 42576b36095..6dc4fd7f757 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -29,6 +29,7 @@ import { openLink, useEffectEvent, useGlobalListeners, + useLayoutEffect, useSyncRef } from '@react-aria/utils'; import {createSyntheticEvent, preventFocus, setEventTarget} from './utils'; @@ -36,7 +37,7 @@ import {disableTextSelection, restoreTextSelection} from './textSelection'; import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {PressResponderContext} from './context'; -import {MouseEvent as RMouseEvent, TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {MouseEvent as RMouseEvent, TouchEvent as RTouchEvent, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; export interface PressProps extends PressEvents { /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */ @@ -195,9 +196,9 @@ export function usePress(props: PressHookProps): PressResult { disposables: [] }); - let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); + let {addGlobalListener, removeAllGlobalListeners, removeGlobalListener} = useGlobalListeners(); - let triggerPressStart = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let triggerPressStart = useCallback((originalEvent: EventBase, pointerType: PointerType) => { let state = ref.current; if (isDisabled || state.didFirePressStart) { return false; @@ -219,9 +220,9 @@ export function usePress(props: PressHookProps): PressResult { state.didFirePressStart = true; setPressed(true); return shouldStopPropagation; - }); + }, [isDisabled, onPressStart, onPressChange]); - let triggerPressEnd = useEffectEvent((originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => { + let triggerPressEnd = useCallback((originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => { let state = ref.current; if (!state.didFirePressStart) { return false; @@ -251,9 +252,10 @@ export function usePress(props: PressHookProps): PressResult { state.isTriggeringEvent = false; return shouldStopPropagation; - }); + }, [isDisabled, onPressEnd, onPressChange, onPress]); + let triggerPressEndEvent = useEffectEvent(triggerPressEnd); - let triggerPressUp = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => { + let triggerPressUp = useCallback((originalEvent: EventBase, pointerType: PointerType) => { let state = ref.current; if (isDisabled) { return false; @@ -268,15 +270,17 @@ export function usePress(props: PressHookProps): PressResult { } return true; - }); + }, [isDisabled, onPressUp]); + let triggerPressUpEvent = useEffectEvent(triggerPressUp); - let cancel = useEffectEvent((e: EventBase) => { + let cancel = useCallback((e: EventBase) => { let state = ref.current; if (state.isPressed && state.target) { if (state.didFirePressStart && state.pointerType != null) { triggerPressEnd(createEvent(state.target, e), state.pointerType, false); } state.isPressed = false; + setIsPointerPressed(null); state.isOverTarget = false; state.activePointerId = null; state.pointerType = null; @@ -289,23 +293,24 @@ export function usePress(props: PressHookProps): PressResult { } state.disposables = []; } - }); + }, [allowTextSelectionOnPress, removeAllGlobalListeners, triggerPressEnd]); + let cancelEvent = useEffectEvent(cancel); - let cancelOnPointerExit = useEffectEvent((e: EventBase) => { + let cancelOnPointerExit = useCallback((e: EventBase) => { if (shouldCancelOnPointerExit) { cancel(e); } - }); + }, [shouldCancelOnPointerExit, cancel]); - let triggerClick = useEffectEvent((e: RMouseEvent) => { + let triggerClick = useCallback((e: RMouseEvent) => { if (isDisabled) { return; } onClick?.(e); - }); + }, [isDisabled, onClick]); - let triggerSyntheticClick = useEffectEvent((e: KeyboardEvent | TouchEvent, target: FocusableElement) => { + let triggerSyntheticClick = useCallback((e: KeyboardEvent | TouchEvent, target: FocusableElement) => { if (isDisabled) { return; } @@ -320,7 +325,164 @@ export function usePress(props: PressHookProps): PressResult { setEventTarget(event, target); onClick(createSyntheticEvent(event)); } - }); + }, [isDisabled, onClick]); + let triggerSyntheticClickEvent = useEffectEvent(triggerSyntheticClick); + + let [isElemKeyPressed, setIsElemKeyPressed] = useState(false); + useLayoutEffect(() => { + let state = ref.current; + if (isElemKeyPressed) { + let onKeyUp = (e: KeyboardEvent) => { + if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { + if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { + e.preventDefault(); + } + + let target = getEventTarget(e); + let wasPressed = nodeContains(state.target, getEventTarget(e)); + triggerPressEndEvent(createEvent(state.target, e), 'keyboard', wasPressed); + if (wasPressed) { + triggerSyntheticClickEvent(e, state.target); + } + removeAllGlobalListeners(); + + // If a link was triggered with a key other than Enter, open the URL ourselves. + // This means the link has a role override, and the default browser behavior + // only applies when using the Enter key. + if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { + // Store a hidden property on the event so we only trigger link click once, + // even if there are multiple usePress instances attached to the element. + e[LINK_CLICKED] = true; + openLink(state.target, e, false); + } + + state.isPressed = false; + setIsElemKeyPressed(false); + state.metaKeyEvents?.delete(e.key); + } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { + // If we recorded keydown events that occurred while the Meta key was pressed, + // and those haven't received keyup events already, fire keyup events ourselves. + // See comment above for more info about the macOS bug causing this. + let events = state.metaKeyEvents; + state.metaKeyEvents = undefined; + for (let event of events.values()) { + state.target?.dispatchEvent(new KeyboardEvent('keyup', event)); + } + } + }; + // Focus may move before the key up event, so register the event on the document + // instead of the same element where the key down event occurred. Make it capturing so that it will trigger + // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element. + let originalTarget = state.target; + let pressUp = (e) => { + if (originalTarget && isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { + triggerPressUpEvent(createEvent(state.target, e), 'keyboard'); + } + }; + let listener = chain(pressUp, onKeyUp); + addGlobalListener(getOwnerDocument(state.target), 'keyup', listener, true); + return () => { + removeGlobalListener(getOwnerDocument(state.target), 'keyup', listener, true); + }; + } + }, [isElemKeyPressed, addGlobalListener, removeAllGlobalListeners, removeGlobalListener]); + + let [isPointerPressed, setIsPointerPressed] = useState<'pointer' | 'mouse' | 'touch' | null>(null); + useLayoutEffect(() => { + let state = ref.current; + if (isPointerPressed === 'pointer') { + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { + if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { + // Wait for onClick to fire onPress. This avoids browser issues when the DOM + // is mutated between onPointerUp and onClick, and is more compatible with third party libraries. + // https://github.com/adobe/react-spectrum/issues/1513 + // https://issues.chromium.org/issues/40732224 + // However, iOS and Android do not focus or fire onClick after a long press. + // We work around this by triggering a click ourselves after a timeout. + // This timeout is canceled during the click event in case the real one fires first. + // The timeout must be at least 32ms, because Safari on iOS delays the click event on + // non-form elements without certain ARIA roles (for hover emulation). + // https://github.com/WebKit/WebKit/blob/dccfae42bb29bd4bdef052e469f604a9387241c0/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L875-L892 + let clicked = false; + let timeout = setTimeout(() => { + if (state.isPressed && state.target instanceof HTMLElement) { + if (clicked) { + cancelEvent(e); + } else { + focusWithoutScrolling(state.target); + state.target.click(); + } + } + }, 80); + // Use a capturing listener to track if a click occurred. + // If stopPropagation is called it may never reach our handler. + addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true); + state.disposables.push(() => clearTimeout(timeout)); + } else { + cancelEvent(e); + } + + // Ignore subsequent onPointerLeave event before onClick on touch devices. + state.isOverTarget = false; + } + }; + + let onPointerCancel = (e: PointerEvent) => { + cancelEvent(e); + }; + + addGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp, false); + addGlobalListener(getOwnerDocument(state.target), 'pointercancel', onPointerCancel, false); + return () => { + removeGlobalListener(getOwnerDocument(state.target), 'pointerup', onPointerUp, false); + removeGlobalListener(getOwnerDocument(state.target), 'pointercancel', onPointerCancel, false); + }; + } else if (isPointerPressed === 'mouse' && process.env.NODE_ENV === 'test') { + let onMouseUp = (e: MouseEvent) => { + // Only handle left clicks + if (e.button !== 0) { + return; + } + + if (state.ignoreEmulatedMouseEvents) { + state.ignoreEmulatedMouseEvents = false; + return; + } + + if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { + // Wait for onClick to fire onPress. This avoids browser issues when the DOM + // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. + } else { + cancelEvent(e); + } + + state.isOverTarget = false; + }; + + addGlobalListener(getOwnerDocument(state.target), 'mouseup', onMouseUp, false); + return () => { + removeGlobalListener(getOwnerDocument(state.target), 'mouseup', onMouseUp, false); + }; + } else if (isPointerPressed === 'touch' && process.env.NODE_ENV === 'test') { + let onScroll = (e: Event) => { + if (state.isPressed && nodeContains(getEventTarget(e), state.target)) { + cancelEvent({ + currentTarget: state.target, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false + }); + } + }; + + addGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll, true); + return () => { + removeGlobalListener(getOwnerWindow(state.target), 'scroll', onScroll, true); + }; + } + }, [isPointerPressed, addGlobalListener, removeGlobalListener]); let pressProps = useMemo(() => { let state = ref.current; @@ -338,20 +500,9 @@ export function usePress(props: PressHookProps): PressResult { if (!state.isPressed && !e.repeat) { state.target = e.currentTarget; state.isPressed = true; + setIsElemKeyPressed(true); state.pointerType = 'keyboard'; shouldStopPropagation = triggerPressStart(e, 'keyboard'); - - // Focus may move before the key up event, so register the event on the document - // instead of the same element where the key down event occurred. Make it capturing so that it will trigger - // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element. - let originalTarget = e.currentTarget; - let pressUp = (e) => { - if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) { - triggerPressUp(createEvent(state.target, e), 'keyboard'); - } - }; - - addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); } if (shouldStopPropagation) { @@ -409,44 +560,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onKeyUp = (e: KeyboardEvent) => { - if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { - if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) { - e.preventDefault(); - } - - let target = getEventTarget(e); - let wasPressed = nodeContains(state.target, getEventTarget(e)); - triggerPressEnd(createEvent(state.target, e), 'keyboard', wasPressed); - if (wasPressed) { - triggerSyntheticClick(e, state.target); - } - removeAllGlobalListeners(); - - // If a link was triggered with a key other than Enter, open the URL ourselves. - // This means the link has a role override, and the default browser behavior - // only applies when using the Enter key. - if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) { - // Store a hidden property on the event so we only trigger link click once, - // even if there are multiple usePress instances attached to the element. - e[LINK_CLICKED] = true; - openLink(state.target, e, false); - } - - state.isPressed = false; - state.metaKeyEvents?.delete(e.key); - } else if (e.key === 'Meta' && state.metaKeyEvents?.size) { - // If we recorded keydown events that occurred while the Meta key was pressed, - // and those haven't received keyup events already, fire keyup events ourselves. - // See comment above for more info about the macOS bug causing this. - let events = state.metaKeyEvents; - state.metaKeyEvents = undefined; - for (let event of events.values()) { - state.target?.dispatchEvent(new KeyboardEvent('keyup', event)); - } - } - }; - if (typeof PointerEvent !== 'undefined') { pressProps.onPointerDown = (e) => { // Only handle left clicks, and ignore events that bubbled through portals. @@ -468,6 +581,7 @@ export function usePress(props: PressHookProps): PressResult { let shouldStopPropagation = true; if (!state.isPressed) { state.isPressed = true; + setIsPointerPressed('pointer'); state.isOverTarget = true; state.activePointerId = e.pointerId; state.target = e.currentTarget as FocusableElement; @@ -484,9 +598,6 @@ export function usePress(props: PressHookProps): PressResult { if ('releasePointerCapture' in target) { target.releasePointerCapture(e.pointerId); } - - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false); - addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false); } if (shouldStopPropagation) { @@ -538,46 +649,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onPointerUp = (e: PointerEvent) => { - if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { - if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) { - // Wait for onClick to fire onPress. This avoids browser issues when the DOM - // is mutated between onPointerUp and onClick, and is more compatible with third party libraries. - // https://github.com/adobe/react-spectrum/issues/1513 - // https://issues.chromium.org/issues/40732224 - // However, iOS and Android do not focus or fire onClick after a long press. - // We work around this by triggering a click ourselves after a timeout. - // This timeout is canceled during the click event in case the real one fires first. - // The timeout must be at least 32ms, because Safari on iOS delays the click event on - // non-form elements without certain ARIA roles (for hover emulation). - // https://github.com/WebKit/WebKit/blob/dccfae42bb29bd4bdef052e469f604a9387241c0/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L875-L892 - let clicked = false; - let timeout = setTimeout(() => { - if (state.isPressed && state.target instanceof HTMLElement) { - if (clicked) { - cancel(e); - } else { - focusWithoutScrolling(state.target); - state.target.click(); - } - } - }, 80); - // Use a capturing listener to track if a click occurred. - // If stopPropagation is called it may never reach our handler. - addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true); - state.disposables.push(() => clearTimeout(timeout)); - } else { - cancel(e); - } - - // Ignore subsequent onPointerLeave event before onClick on touch devices. - state.isOverTarget = false; - } - }; - - let onPointerCancel = (e: PointerEvent) => { - cancel(e); - }; pressProps.onDragStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { @@ -603,6 +674,7 @@ export function usePress(props: PressHookProps): PressResult { } state.isPressed = true; + setIsPointerPressed('mouse'); state.isOverTarget = true; state.target = e.currentTarget; state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse'; @@ -619,8 +691,6 @@ export function usePress(props: PressHookProps): PressResult { state.disposables.push(dispose); } } - - addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false); }; pressProps.onMouseEnter = (e) => { @@ -666,27 +736,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onMouseUp = (e: MouseEvent) => { - // Only handle left clicks - if (e.button !== 0) { - return; - } - - if (state.ignoreEmulatedMouseEvents) { - state.ignoreEmulatedMouseEvents = false; - return; - } - - if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) { - // Wait for onClick to fire onPress. This avoids browser issues when the DOM - // is mutated between onMouseUp and onClick, and is more compatible with third party libraries. - } else { - cancel(e); - } - - state.isOverTarget = false; - }; - pressProps.onTouchStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; @@ -700,6 +749,7 @@ export function usePress(props: PressHookProps): PressResult { state.ignoreEmulatedMouseEvents = true; state.isOverTarget = true; state.isPressed = true; + setIsPointerPressed('touch'); state.target = e.currentTarget; state.pointerType = 'touch'; @@ -711,8 +761,6 @@ export function usePress(props: PressHookProps): PressResult { if (shouldStopPropagation) { e.stopPropagation(); } - - addGlobalListener(getOwnerWindow(e.currentTarget), 'scroll', onScroll, true); }; pressProps.onTouchMove = (e) => { @@ -768,6 +816,7 @@ export function usePress(props: PressHookProps): PressResult { } state.isPressed = false; + setIsPointerPressed(null); state.activePointerId = null; state.isOverTarget = false; state.ignoreEmulatedMouseEvents = true; @@ -788,18 +837,6 @@ export function usePress(props: PressHookProps): PressResult { } }; - let onScroll = (e: Event) => { - if (state.isPressed && nodeContains(getEventTarget(e), state.target)) { - cancel({ - currentTarget: state.target, - shiftKey: false, - ctrlKey: false, - metaKey: false, - altKey: false - }); - } - }; - pressProps.onDragStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) { return; @@ -811,7 +848,6 @@ export function usePress(props: PressHookProps): PressResult { return pressProps; }, [ - addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index f31da638bc9..46321a1d4a8 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -11,7 +11,7 @@ */ import {FocusableElement} from '@react-types/shared'; -import {focusWithoutScrolling, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; // Turn a native event into a React synthetic event. @@ -48,10 +48,6 @@ export function useSyntheticBlurEvent(onBlur: }; }, []); - let dispatchBlur = useEffectEvent((e: ReactFocusEvent) => { - onBlur?.(e); - }); - // This function is called during a React onFocus event. return useCallback((e: ReactFocusEvent) => { // React does not fire onBlur when an element is disabled. https://github.com/facebook/react/issues/9142 @@ -73,7 +69,7 @@ export function useSyntheticBlurEvent(onBlur: if (target.disabled) { // For backward compatibility, dispatch a (fake) React synthetic event. let event = createSyntheticEvent>(e); - dispatchBlur(event); + onBlur?.(event); } // We no longer need the MutationObserver once the target is blurred. @@ -96,7 +92,7 @@ export function useSyntheticBlurEvent(onBlur: stateRef.current.observer.observe(target, {attributes: true, attributeFilter: ['disabled']}); } - }, [dispatchBlur]); + }, [onBlur]); } export let ignoreFocusEvent = false; diff --git a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts index 1a43971bbe4..f36eb66261e 100644 --- a/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts +++ b/packages/@react-aria/menu/src/useSafelyMouseToSubmenu.ts @@ -174,5 +174,5 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v movementsTowardsSubmenuCount.current = ALLOWED_INVALID_MOVEMENTS; }; - }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, onPointerDown, submenuRef]); + }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, submenuRef]); } diff --git a/packages/@react-aria/menu/src/useSubmenuTrigger.ts b/packages/@react-aria/menu/src/useSubmenuTrigger.ts index 590ce43a213..eed89771089 100644 --- a/packages/@react-aria/menu/src/useSubmenuTrigger.ts +++ b/packages/@react-aria/menu/src/useSubmenuTrigger.ts @@ -14,7 +14,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays'; import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, useEffectEvent, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, useEvent, useId, useLayoutEffect} from '@react-aria/utils'; import type {SubmenuTriggerState} from '@react-stately/menu'; import {useCallback, useRef} from 'react'; import {useLocale} from '@react-aria/i18n'; @@ -81,15 +81,15 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm } }, [openTimeout]); - let onSubmenuOpen = useEffectEvent((focusStrategy?: FocusStrategy) => { + let onSubmenuOpen = useCallback((focusStrategy?: FocusStrategy) => { cancelOpenTimeout(); state.open(focusStrategy); - }); + }, [state, cancelOpenTimeout]); - let onSubmenuClose = useEffectEvent(() => { + let onSubmenuClose = useCallback(() => { cancelOpenTimeout(); state.close(); - }); + }, [state, cancelOpenTimeout]); useLayoutEffect(() => { return () => { diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 4259dc51132..f44cb0a123d 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, isTabbable, mergeProps, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -416,49 +416,42 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } }); - let updateActiveDescendant = useEffectEvent(() => { - let keyToFocus = delegate.getFirstKey?.() ?? null; - - // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to - // the original active element (e.g. the autocomplete input) - if (keyToFocus == null) { - let previousActiveElement = getActiveElement(); - moveVirtualFocus(ref.current); - dispatchVirtualFocus(previousActiveElement!, null); - - // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. - // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. - if (manager.collection.size > 0) { + // update active descendant + useUpdateLayoutEffect(() => { + if (shouldVirtualFocusFirst.current) { + let keyToFocus = delegate.getFirstKey?.() ?? null; + + // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to + // the original active element (e.g. the autocomplete input) + if (keyToFocus == null) { + let previousActiveElement = getActiveElement(); + moveVirtualFocus(ref.current); + dispatchVirtualFocus(previousActiveElement!, null); + + // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. + // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. + if (manager.collection.size > 0) { + shouldVirtualFocusFirst.current = false; + } + } else { + manager.setFocusedKey(keyToFocus); + // Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key + // If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key + // after the collection updates after load shouldVirtualFocusFirst.current = false; } - } else { - manager.setFocusedKey(keyToFocus); - // Only set shouldVirtualFocusFirst to false if we've successfully set the first key as the focused key - // If there wasn't a key to focus, we might be in a temporary loading state so we'll want to still focus the first key - // after the collection updates after load - shouldVirtualFocusFirst.current = false; } - }); + }, [manager.collection]); + // reset focus first flag useUpdateLayoutEffect(() => { - if (shouldVirtualFocusFirst.current) { - updateActiveDescendant(); - } - - }, [manager.collection, updateActiveDescendant]); - - let resetFocusFirstFlag = useEffectEvent(() => { // If user causes the focused key to change in any other way, clear shouldVirtualFocusFirst so we don't // accidentally move focus from under them. Skip this if the collection was empty because we might be in a load // state and will still want to focus the first item after load if (manager.collection.size > 0) { shouldVirtualFocusFirst.current = false; } - }); - - useUpdateLayoutEffect(() => { - resetFocusFirstFlag(); - }, [manager.focusedKey, resetFocusFirstFlag]); + }, [manager.focusedKey]); useEvent(ref, CLEAR_FOCUS_EVENT, !shouldUseVirtualFocus ? undefined : (e: any) => { e.stopPropagation(); diff --git a/packages/@react-aria/spinbutton/src/useSpinButton.ts b/packages/@react-aria/spinbutton/src/useSpinButton.ts index dada685076b..de772fa5106 100644 --- a/packages/@react-aria/spinbutton/src/useSpinButton.ts +++ b/packages/@react-aria/spinbutton/src/useSpinButton.ts @@ -15,7 +15,7 @@ import {AriaButtonProps} from '@react-types/button'; import {DOMAttributes, InputBase, RangeInputBase, Validation, ValueBase} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {useCallback, useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {useEffectEvent, useGlobalListeners} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -64,7 +64,6 @@ export function useSpinButton( isSpinning.current = false; }; - useEffect(() => { return () => clearAsync(); }, []); @@ -199,6 +198,24 @@ export function useSpinButton( // an increment or decrement. let isUp = useRef(false); + let [isIncrementPressed, setIsIncrementPressed] = useState<'touch' | 'mouse' | null>(null); + useEffect(() => { + if (isIncrementPressed === 'touch') { + onIncrementPressStart(60); + } else if (isIncrementPressed) { + onIncrementPressStart(400); + } + }, [isIncrementPressed]); + + let [isDecrementPressed, setIsDecrementPressed] = useState<'touch' | 'mouse' | null>(null); + useEffect(() => { + if (isDecrementPressed === 'touch') { + onDecrementPressStart(60); + } else if (isDecrementPressed) { + onDecrementPressStart(400); + } + }, [isDecrementPressed]); + return { spinButtonProps: { role: 'spinbutton', @@ -216,19 +233,19 @@ export function useSpinButton( incrementButtonProps: { onPressStart: (e) => { if (e.pointerType !== 'touch') { - onIncrementPressStart(400); + setIsIncrementPressed('mouse'); } else { if (_async.current) { clearAsync(); } - // For touch users, don't trigger an increment on press start, we'll wait for the press end to trigger it if - // the control isn't spinning. - _async.current = window.setTimeout(() => { - onIncrementPressStart(60); - }, 600); addGlobalListener(window, 'touchmove', onTouchMove, {capture: true}); isUp.current = false; + // For touch users, don't trigger a decrement on press start, we'll wait for the press end to trigger it if + // the control isn't spinning. + _async.current = window.setTimeout(() => { + setIsIncrementPressed('touch'); + }, 600); } addGlobalListener(window, 'contextmenu', cancelContextMenu); }, @@ -239,6 +256,7 @@ export function useSpinButton( prevTouchPosition.current = null; clearAsync(); removeAllGlobalListeners(); + setIsIncrementPressed(null); }, onPressEnd: (e) => { if (e.pointerType === 'touch') { @@ -247,6 +265,7 @@ export function useSpinButton( } } isUp.current = false; + setIsIncrementPressed(null); }, onFocus, onBlur @@ -254,19 +273,19 @@ export function useSpinButton( decrementButtonProps: { onPressStart: (e) => { if (e.pointerType !== 'touch') { - onDecrementPressStart(400); + setIsDecrementPressed('mouse'); } else { if (_async.current) { clearAsync(); } + + addGlobalListener(window, 'touchmove', onTouchMove, {capture: true}); + isUp.current = false; // For touch users, don't trigger a decrement on press start, we'll wait for the press end to trigger it if // the control isn't spinning. _async.current = window.setTimeout(() => { - onDecrementPressStart(60); + setIsDecrementPressed('touch'); }, 600); - - addGlobalListener(window, 'touchmove', onTouchMove, {capture: true}); - isUp.current = false; } }, onPressUp: (e) => { @@ -276,6 +295,7 @@ export function useSpinButton( prevTouchPosition.current = null; clearAsync(); removeAllGlobalListeners(); + setIsDecrementPressed(null); }, onPressEnd: (e) => { if (e.pointerType === 'touch') { @@ -284,6 +304,7 @@ export function useSpinButton( } } isUp.current = false; + setIsDecrementPressed(null); }, onFocus, onBlur diff --git a/packages/@react-aria/table/src/useTableColumnResize.ts b/packages/@react-aria/table/src/useTableColumnResize.ts index 0a8c956ab13..7d02274c946 100644 --- a/packages/@react-aria/table/src/useTableColumnResize.ts +++ b/packages/@react-aria/table/src/useTableColumnResize.ts @@ -70,25 +70,8 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let editModeEnabled = state.tableState.isKeyboardNavigationDisabled; let {direction} = useLocale(); - let {keyboardProps} = useKeyboard({ - onKeyDown: (e) => { - if (editModeEnabled) { - if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { - e.preventDefault(); - endResize(item); - } - } else { - // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there - e.continuePropagation(); - if (e.key === 'Enter') { - startResize(item); - } - } - } - }); - - let startResize = useEffectEvent((item) => { + let startResize = useCallback((item) => { if (!isResizingRef.current) { lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); state.startResize(item.key); @@ -96,15 +79,15 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st onResizeStart?.(lastSize.current); } isResizingRef.current = true; - }); + }, [state, onResizeStart]); - let resize = useEffectEvent((item, newWidth) => { + let resize = useCallback((item, newWidth) => { let sizes = state.updateResizedColumns(item.key, newWidth); onResize?.(sizes); lastSize.current = sizes; - }); + }, [state, onResize]); - let endResize = useEffectEvent((item) => { + let endResize = useCallback((item) => { if (isResizingRef.current) { if (lastSize.current == null) { lastSize.current = state.updateResizedColumns(item.key, state.getColumnWidth(item.key)); @@ -121,6 +104,24 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st } } lastSize.current = null; + }, [state, triggerRef, onResizeEnd]); + + let {keyboardProps} = useKeyboard({ + onKeyDown: (e) => { + if (editModeEnabled) { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { + e.preventDefault(); + endResize(item); + } + } else { + // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there + e.continuePropagation(); + + if (e.key === 'Enter') { + startResize(item); + } + } + } }); const columnResizeWidthRef = useRef(0); @@ -194,10 +195,11 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st let resizingColumn = state.resizingColumn; let prevResizingColumn = useRef(null); + let startResizeEvent = useEffectEvent(startResize); useEffect(() => { if (prevResizingColumn.current !== resizingColumn && resizingColumn != null && resizingColumn === item.key) { wasFocusedOnResizeStart.current = document.activeElement === ref.current; - startResize(item); + startResizeEvent(item); // Delay focusing input until Android Chrome's delayed click after touchend happens: https://bugs.chromium.org/p/chromium/issues/detail?id=1150073 let timeout = setTimeout(() => focusInput(), 0); // VoiceOver on iOS has problems focusing the input from a menu. @@ -208,7 +210,7 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st }; } prevResizingColumn.current = resizingColumn; - }, [resizingColumn, item, focusInput, ref, startResize]); + }, [resizingColumn, item, focusInput, ref]); let onChange = (e: ChangeEvent) => { let currentWidth = state.getColumnWidth(item.key); diff --git a/packages/@react-aria/textfield/src/useFormattedTextField.ts b/packages/@react-aria/textfield/src/useFormattedTextField.ts index 878deb9c841..e0d866fd016 100644 --- a/packages/@react-aria/textfield/src/useFormattedTextField.ts +++ b/packages/@react-aria/textfield/src/useFormattedTextField.ts @@ -104,7 +104,7 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte return () => { input.removeEventListener('beforeinput', onBeforeInputFallback, false); }; - }, [inputRef, onBeforeInputFallback]); + }, [inputRef]); let onBeforeInput = !supportsNativeBeforeInputEvent() ? e => { diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 04d6a8dceed..1c6c3dbaf84 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -11,12 +11,12 @@ */ import {AriaLabelingProps, DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {focusWithoutScrolling, mergeProps, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; +import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils'; import {getInteractionModality, useFocusWithin, useHover} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ToastState} from '@react-stately/toast'; -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; import {useLandmark} from '@react-aria/landmark'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; @@ -46,13 +46,13 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState let isHovered = useRef(false); let isFocused = useRef(false); - let updateTimers = useEffectEvent(() => { + let updateTimers = useCallback(() => { if (isHovered.current || isFocused.current) { state.pauseAll(); } else { state.resumeAll(); } - }); + }, [state]); let {hoverProps} = useHover({ onHoverStart: () => { @@ -133,7 +133,7 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState } prevVisibleToasts.current = state.visibleToasts; - }, [state.visibleToasts, ref, updateTimers]); + }, [state.visibleToasts, ref]); let lastFocused = useRef(null); let {focusWithinProps} = useFocusWithin({ diff --git a/packages/@react-aria/utils/src/useEvent.ts b/packages/@react-aria/utils/src/useEvent.ts index a56710b906c..1dd35499847 100644 --- a/packages/@react-aria/utils/src/useEvent.ts +++ b/packages/@react-aria/utils/src/useEvent.ts @@ -33,5 +33,5 @@ export function useEvent( return () => { element.removeEventListener(event, handleEvent as EventListener, options); }; - }, [ref, event, options, isDisabled, handleEvent]); + }, [ref, event, options, isDisabled]); } diff --git a/packages/@react-aria/utils/src/useFormReset.ts b/packages/@react-aria/utils/src/useFormReset.ts index 749c5e38788..c37eb67cc50 100644 --- a/packages/@react-aria/utils/src/useFormReset.ts +++ b/packages/@react-aria/utils/src/useFormReset.ts @@ -32,5 +32,5 @@ export function useFormReset( return () => { form?.removeEventListener('reset', handleReset); }; - }, [ref, handleReset]); + }, [ref]); } diff --git a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts index 2d02f2ca101..457124261b4 100644 --- a/packages/@react-aria/utils/src/useLoadMoreSentinel.ts +++ b/packages/@react-aria/utils/src/useLoadMoreSentinel.ts @@ -59,5 +59,5 @@ export function useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject sentinelObserver.current.disconnect(); } }; - }, [collection, triggerLoadMore, ref, scrollOffset]); + }, [collection, ref, scrollOffset]); } diff --git a/packages/@react-aria/utils/src/useResizeObserver.ts b/packages/@react-aria/utils/src/useResizeObserver.ts index 32d9e8a4e4b..ab0a1d5ae3c 100644 --- a/packages/@react-aria/utils/src/useResizeObserver.ts +++ b/packages/@react-aria/utils/src/useResizeObserver.ts @@ -1,6 +1,7 @@ import {RefObject} from '@react-types/shared'; import {useEffect} from 'react'; +import {useEffectEvent} from './useEffectEvent'; function hasResizeObserver() { return typeof window.ResizeObserver !== 'undefined'; @@ -13,7 +14,10 @@ type useResizeObserverOptionsType = { } export function useResizeObserver(options: useResizeObserverOptionsType): void { + // Only call onResize from inside the effect, otherwise we'll void our assumption that + // useEffectEvents are safe to pass in. const {ref, box, onResize} = options; + let onResizeEvent = useEffectEvent(onResize); useEffect(() => { let element = ref?.current; @@ -22,9 +26,9 @@ export function useResizeObserver(options: useResizeObserverO } if (!hasResizeObserver()) { - window.addEventListener('resize', onResize, false); + window.addEventListener('resize', onResizeEvent, false); return () => { - window.removeEventListener('resize', onResize, false); + window.removeEventListener('resize', onResizeEvent, false); }; } else { @@ -33,7 +37,7 @@ export function useResizeObserver(options: useResizeObserverO return; } - onResize(); + onResizeEvent(); }); resizeObserverInstance.observe(element, {box}); @@ -44,5 +48,5 @@ export function useResizeObserver(options: useResizeObserverO }; } - }, [onResize, ref, box]); + }, [ref, box]); } diff --git a/packages/@react-aria/utils/src/useUpdateEffect.ts b/packages/@react-aria/utils/src/useUpdateEffect.ts index d486e61baa6..e1aff96e964 100644 --- a/packages/@react-aria/utils/src/useUpdateEffect.ts +++ b/packages/@react-aria/utils/src/useUpdateEffect.ts @@ -11,11 +11,13 @@ */ import {EffectCallback, useEffect, useRef} from 'react'; +import {useEffectEvent} from './useEffectEvent'; // Like useEffect, but only called for updates after the initial render. -export function useUpdateEffect(effect: EffectCallback, dependencies: any[]): void { +export function useUpdateEffect(cb: EffectCallback, dependencies: any[]): void { const isInitialMount = useRef(true); const lastDeps = useRef(null); + let cbEvent = useEffectEvent(cb); useEffect(() => { isInitialMount.current = true; @@ -29,9 +31,9 @@ export function useUpdateEffect(effect: EffectCallback, dependencies: any[]): vo if (isInitialMount.current) { isInitialMount.current = false; } else if (!prevDeps || dependencies.some((dep, i) => !Object.is(dep, prevDeps[i]))) { - effect(); + cbEvent(); } lastDeps.current = dependencies; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, dependencies); } diff --git a/packages/@react-aria/utils/src/useValueEffect.ts b/packages/@react-aria/utils/src/useValueEffect.ts index a8c90397aea..f22ee14fb05 100644 --- a/packages/@react-aria/utils/src/useValueEffect.ts +++ b/packages/@react-aria/utils/src/useValueEffect.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ -import {Dispatch, MutableRefObject, useRef, useState} from 'react'; -import {useEffectEvent, useLayoutEffect} from './'; +import {Dispatch, RefObject, useCallback, useRef, useState} from 'react'; +import {useLayoutEffect} from './'; type SetValueAction = (prev: S) => Generator; @@ -21,11 +21,14 @@ type SetValueAction = (prev: S) => Generator; // written linearly. export function useValueEffect(defaultValue: S | (() => S)): [S, Dispatch>] { let [value, setValue] = useState(defaultValue); - let effect: MutableRefObject | null> = useRef | null>(null); + // Keep an up to date copy of value in a ref so we can access the current value in the generator. + // This allows us to maintain a stable queue function. + let currValue = useRef(value); + let effect: RefObject | null> = useRef | null>(null); // Store the function in a ref so we can always access the current version // which has the proper `value` in scope. - let nextRef = useEffectEvent(() => { + let nextRef = useRef(() => { if (!effect.current) { return; } @@ -41,24 +44,25 @@ export function useValueEffect(defaultValue: S | (() => S)): [S, Dispatch { + currValue.current = value; // If there is an effect currently running, continue to the next yield. if (effect.current) { - nextRef(); + nextRef.current(); } }); - let queue = useEffectEvent(fn => { - effect.current = fn(value); - nextRef(); - }); + let queue = useCallback(fn => { + effect.current = fn(currValue.current); + nextRef.current(); + }, [nextRef]); return [value, queue]; } diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index 7c4988a5b3e..b57efc4f19e 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -157,7 +157,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { + let updateSize = useCallback((flush: typeof flushSync) => { let dom = ref.current; if (!dom || isUpdatingSize.current) { return; @@ -197,11 +197,14 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject(null); let [update, setUpdate] = useState({}); + // We only contain a call to setState in here for testing environments. + // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) { // React doesn't allow flushSync inside effects, so queue a microtask. @@ -218,7 +221,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject updateSize(flushSync)); + queueMicrotask(() => updateSizeEvent(flushSync)); } } @@ -227,7 +230,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - updateSize(fn => fn()); + updateSizeEvent(fn => fn()); }, [update]); let onResize = useCallback(() => { diff --git a/packages/@react-spectrum/s2/src/CardView.tsx b/packages/@react-spectrum/s2/src/CardView.tsx index e589a1cbd4a..2023a8c0d95 100644 --- a/packages/@react-spectrum/s2/src/CardView.tsx +++ b/packages/@react-spectrum/s2/src/CardView.tsx @@ -24,7 +24,7 @@ import { WaterfallLayout } from 'react-aria-components'; import {CardContext, InternalCardViewContext} from './Card'; -import {createContext, forwardRef, ReactElement, useMemo, useRef, useState} from 'react'; +import {createContext, forwardRef, ReactElement, useCallback, useMemo, useRef, useState} from 'react'; import {DOMRef, DOMRefValue, forwardRefType, GlobalDOMAttributes, Key, LoadingState} from '@react-types/shared'; import {focusRing, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; @@ -218,7 +218,7 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca // This calculates the maximum t-shirt size where at least two columns fit in the available width. let [maxSizeIndex, setMaxSizeIndex] = useState(SIZES.length - 1); - let updateSize = useEffectEvent(() => { + let updateSize = useCallback(() => { let w = scrollRef.current?.clientWidth ?? 0; let i = SIZES.length - 1; while (i > 0) { @@ -229,7 +229,8 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca i--; } setMaxSizeIndex(i); - }); + }, [scrollRef, density]); + let updateSizeEvent = useEffectEvent(updateSize); useResizeObserver({ ref: scrollRef, @@ -238,8 +239,8 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca }); useLayoutEffect(() => { - updateSize(); - }, [updateSize]); + updateSizeEvent(); + }, []); // The actual rendered t-shirt size is the minimum between the size prop and the maximum possible size. let size = SIZES[Math.min(maxSizeIndex, SIZES.indexOf(sizeProp))]; diff --git a/packages/@react-spectrum/s2/src/CoachMark.tsx b/packages/@react-spectrum/s2/src/CoachMark.tsx index a4d6cc0a70d..41229c198d1 100644 --- a/packages/@react-spectrum/s2/src/CoachMark.tsx +++ b/packages/@react-spectrum/s2/src/CoachMark.tsx @@ -504,6 +504,7 @@ export const CoachMarkIndicator = /*#__PURE__*/ (forwardRef as forwardRefType)(f objRef.current.style.minWidth = childMinWidth; objRef.current.style.minHeight = childMinHeight; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [children]); return ( diff --git a/packages/@react-spectrum/s2/src/Skeleton.tsx b/packages/@react-spectrum/s2/src/Skeleton.tsx index 1fb2bdb185b..b8ebd13cba4 100644 --- a/packages/@react-spectrum/s2/src/Skeleton.tsx +++ b/packages/@react-spectrum/s2/src/Skeleton.tsx @@ -41,7 +41,7 @@ export function useLoadingAnimation(isAnimating: boolean): (element: HTMLElement animationRef.current.cancel(); animationRef.current = null; } - }, [isAnimating]); + }, [isAnimating, reduceMotion]); } export type SkeletonElement = ReactElement<{ diff --git a/packages/@react-spectrum/s2/src/Tabs.tsx b/packages/@react-spectrum/s2/src/Tabs.tsx index 329a6284daa..e3af2c8fb9b 100644 --- a/packages/@react-spectrum/s2/src/Tabs.tsx +++ b/packages/@react-spectrum/s2/src/Tabs.tsx @@ -216,7 +216,7 @@ export function TabList(props: TabListProps): ReactNode | n if (showTabs) { return ; } - + return (
{listRef &&
@@ -628,7 +628,8 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect let children = useMemo(() => [...collection], [collection]); let listRef = useRef(null); - let updateOverflow = useEffectEvent(() => { + + let updateOverflow = () => { if (orientation === 'vertical' || !listRef.current || !containerRef?.current) { return; } @@ -642,29 +643,30 @@ let CollapsingTabs = ({collection, containerRef, ...props}: {collection: Collect } else { setShowItems?.(lastTabRect.left >= containerRect.left); } - }); + }; + + let updateOverflowEffect = useEffectEvent(updateOverflow); useResizeObserver({ref: containerRef, onResize: updateOverflow}); useLayoutEffect(() => { if (collection.size > 0) { - queueMicrotask(updateOverflow); + queueMicrotask(updateOverflowEffect); } - }, [collection.size, updateOverflow]); + }, [collection.size]); // start with null so that the first render won't have a flicker let prevOrientation = useRef(null); useLayoutEffect(() => { if (collection.size > 0 && prevOrientation.current !== orientation) { - updateOverflow(); + updateOverflowEffect(); } prevOrientation.current = orientation; - }, [collection.size, updateOverflow, orientation]); + }, [collection.size, orientation]); useEffect(() => { // Recalculate visible tags when fonts are loaded. - document.fonts?.ready.then(() => updateOverflow()); - // eslint-disable-next-line react-hooks/exhaustive-deps + document.fonts?.ready.then(() => updateOverflowEffect()); }, []); let menuId = useId(); diff --git a/packages/@react-spectrum/s2/src/TagGroup.tsx b/packages/@react-spectrum/s2/src/TagGroup.tsx index ce68e04deda..8a67d056c8c 100644 --- a/packages/@react-spectrum/s2/src/TagGroup.tsx +++ b/packages/@react-spectrum/s2/src/TagGroup.tsx @@ -147,7 +147,7 @@ function TagGroupInner({ [collection, tagState.visibleTagCount, isCollapsed] ); - let updateVisibleTagCount = useEffectEvent(() => { + let updateVisibleTagCount = () => { if (maxRows == null) { setTagState({visibleTagCount: collection.size, showCollapseButton: false}); } @@ -217,20 +217,21 @@ function TagGroupInner({ setTagState(result); }); } - }); + }; + + let updateVisibleTagCountEffect = useEffectEvent(updateVisibleTagCount); useResizeObserver({ref: maxRows != null ? containerRef : undefined, onResize: updateVisibleTagCount}); useLayoutEffect(() => { if (collection.size > 0 && (maxRows != null && maxRows > 0)) { - queueMicrotask(updateVisibleTagCount); + queueMicrotask(updateVisibleTagCountEffect); } - }, [collection.size, updateVisibleTagCount, maxRows]); + }, [collection.size, maxRows]); useEffect(() => { // Recalculate visible tags when fonts are loaded. - document.fonts?.ready.then(() => updateVisibleTagCount()); - // eslint-disable-next-line react-hooks/exhaustive-deps + document.fonts?.ready.then(() => updateVisibleTagCountEffect()); }, []); let handlePressCollapse = () => { diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index 27558e39fb2..c01caba0f87 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -1253,6 +1253,7 @@ function useMountEffect(fn: () => void, deps: Array): void { } else { mounted.current = true; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); } @@ -1631,12 +1632,12 @@ function TableWithBreadcrumbs(props) { {key: 'd', name: 'File D', value: '10 MB', parent: 'a'} ]; - const [loadingState, setLoadingState] = useState('idle' as 'idle'); + const [loadingState, setLoadingState] = useState('idle' as const); const [selection, setSelection] = useState<'all' | Iterable>(new Set([])); const [items, setItems] = useState(() => fs.filter(item => !item.parent)); const changeFolder = (folder) => { setItems([]); - setLoadingState('loading' as 'loading'); + setLoadingState('loading' as const); // mimic loading behavior setTimeout(() => { @@ -2175,7 +2176,7 @@ function LoadingTable(): JSX.Element { setItems([]); setLoadingState('loading'); setTimeout(() => { - setItems(items.length > 1 ? [...items.slice(0, 1)] : []); + setItems(items.length > 1 ? items.slice(0, 1) : []); setLoadingState('idle'); }, 1000); }; diff --git a/packages/dev/docs/pages/react-aria/home.global.css b/packages/dev/docs/pages/react-aria/home.global.css index a8c59e43e14..dd21cda2877 100644 --- a/packages/dev/docs/pages/react-aria/home.global.css +++ b/packages/dev/docs/pages/react-aria/home.global.css @@ -9,16 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -@import 'tailwindcss' source(none); +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities) source(none); @source "*.mdx"; @source "home/*.tsx"; @source "../../../../../starters/tailwind/**/*.{ts,tsx}"; @plugin "tailwindcss-react-aria-components"; @plugin "tailwindcss-animate"; - -@utility focus-ring { - @apply outline outline-0 outline-blue-600 dark:outline-blue-500 focus-visible:outline-2; -} +@import "./home/home.css"; @font-face { font-family: "adobe-clean"; @@ -54,9 +52,7 @@ body { font-family: "adobe-clean", system-ui, sans-serif; - background: var(--page-bg); - color-scheme: dark light; - @apply [--page-bg:var(--color-zinc-50)] dark:[--page-bg:var(--color-zinc-900)]; + margin: 0; } @media (min-resolution: 200dpi) { @@ -72,451 +68,3 @@ body { * { -webkit-tap-highlight-color: transparent; } - -.header-background { - @apply [--l:91%] [--c:0.11] dark:[--l:32%] dark:[--c:0.13]; - background: linear-gradient(to bottom, transparent 0% 80%, var(--page-bg)), - radial-gradient(circle at 50% 60%, transparent 0% 50%, var(--page-bg)), - conic-gradient(from 30deg at 50% 60%, - oklch(var(--l) var(--c) 220), - oklch(min(var(--l), 86%) var(--c) 260), - oklch(var(--l) var(--c) 300), - oklch(var(--l) var(--c) 340), - oklch(var(--l) var(--c) 0), - oklch(var(--l) var(--c) 40), - oklch(var(--l) var(--c) 60), - oklch(max(var(--l), 44%) var(--c) 100), - oklch(max(var(--l), 38%) var(--c) 140), - oklch(var(--l) var(--c) 180), - oklch(var(--l) var(--c) 220) - ); - background-repeat: no-repeat; -} - -main > section > h2 { - @apply text-4xl md:text-5xl font-semibold mb-4 pb-2 text-transparent; - - + p { - @apply text-lg md:text-2xl max-w-5xl text-slate-700 dark:text-slate-400; - text-wrap: pretty; - } -} - -.no-scrollbar::-webkit-scrollbar { - display: none; -} - -.no-scrollbar { - scrollbar-width: none; -} - -:root { - --hljs-color: theme(colors.gray.800); - --hljs-background: transparent; - --hljs-keyword-color: theme(colors.fuchsia.700); - --hljs-section-color: theme(colors.red.600); - --hljs-string-color: theme(colors.green.700); - --hljs-literal-color: theme(colors.purple.700); - --hljs-attribute-color: theme(colors.indigo.700); - --hljs-class-color: theme(colors.cyan.600); - --hljs-function-color: theme(colors.blue.600); - --hljs-variable-color: theme(colors.purple.700); - --hljs-title-color: theme(colors.indigo.700); - --hljs-comment-color: theme(colors.gray.700); - --mark-background: theme(colors.blue.400/10%); - --mark-border: theme(colors.blue.500); -} - -@media (prefers-color-scheme: dark) { - :root { - --hljs-color: theme(colors.gray.300); - --hljs-keyword-color: theme(colors.fuchsia.300); - --hljs-section-color: theme(colors.red.400); - --hljs-string-color: theme(colors.green.400); - --hljs-literal-color: theme(colors.purple.400); - --hljs-attribute-color: theme(colors.indigo.400); - --hljs-class-color: theme(colors.cyan.400); - --hljs-function-color: theme(colors.blue.400); - --hljs-variable-color: theme(colors.purple.400); - --hljs-title-color: theme(colors.indigo.400); - --hljs-comment-color: theme(colors.gray.400); - --mark-border: theme(colors.blue.400); - } -} - -pre .source { - @apply p-5 text-xs sm:text-sm min-h-[250px] h-full min-w-fit; - font-family: source-code-pro, 'Source Code Pro', Monaco, monospace; -} - -pre.large { - @apply hidden lg:block; -} - -pre.medium { - @apply hidden sm:block lg:hidden; -} - -pre.small { - @apply sm:hidden -} - -.card-shadow { - box-shadow: 0 0 2px rgb(0 0 0 / 12%), 0 3px 6px rgb(0 0 0 / 4%), 0 4px 8px 0 rgba(0 0 0 / 8%); - outline: 1px solid transparent; /* WHCM */ - @apply dark:border dark:border-zinc-200/10 dark:bg-clip-padding; -} - -.card-shadow-hover:hover { - box-shadow: 0 0 2px rgb(0 0 0 / 18%), 0 3px 8px rgb(0 0 0 / 6%), 0 4px 16px 0 rgba(0 0 0 / 10%); -} - -.card-shadow-hover:focus-visible { - @apply focus-ring; -} - -.edge-mask { - mask-image: linear-gradient(to right, transparent, white 8px calc(100% - 8px), transparent); -} - -@keyframes touch-animation { - 0% { - opacity: var(--hover-opacity); - transform: translate(10px, 135px); - } - - 15%, 16% { - opacity: var(--hover-opacity); - transform: translate(7px, 0); - } - - 17.2%, 19% { - opacity: var(--pressed-opacity); - transform: translate(7px, 0); - } - - 25%, 27% { - opacity: var(--pressed-opacity); - transform: translate(7px, 48px); - } - - 35%, 36% { - opacity: var(--pressed-opacity); - transform: translate(7px, 7px); - } - - 37.2% { - opacity: var(--hover-opacity); - transform: translate(7px, 7px); - } - - 50%, 55% { - opacity: var(--hover-opacity); - transform: translate(4px, 52px); - } - - 65%, 66% { - opacity: var(--hover-opacity); - transform: translate(7px, 0); - } - - 67.2%, 69% { - opacity: var(--pressed-opacity); - transform: translate(7px, 0); - } - - 75%, 77% { - opacity: var(--pressed-opacity); - transform: translate(7px, 48px); - } - - 85%, 86% { - opacity: var(--pressed-opacity); - transform: translate(7px, 7px); - } - - 87.2% { - opacity: var(--hover-opacity); - transform: translate(7px, 7px); - } - - 100% { - opacity: var(--hover-opacity); - transform: translate(10px, 135px); - } -} - -@keyframes switch-animation { - 0%, 16% { - margin-left: --spacing(6); - width: --spacing(8); - } - - 18.5%, 22% { - margin-left: --spacing(4); - width: --spacing(10); - } - - 25%, 30% { - margin-left: --spacing(6); - width: --spacing(8); - } - - 33%, 36.5% { - margin-left: --spacing(4); - width: --spacing(10); - } - - 38.5%, 66% { - margin-left: 0; - width: --spacing(8); - } - - 68.5%, 72% { - margin-left: 0; - width: --spacing(10); - } - - 75%, 80% { - margin-left: 0; - width: --spacing(8); - } - - 83%, 86.5% { - margin-left: 0; - width: --spacing(10); - } - - 88.5%, 100% { - margin-left: --spacing(6); - width: --spacing(8); - } -} - -@keyframes switch-background-animation { - 0%, 36.5% { - background: var(--bg-selected); - } - - 38.5%, 86.5% { - background: var(--bg); - } - - 88.5%, 100% { - background: var(--bg-selected); - } -} - -.iphone-frame { - background-image: url(../assets/iphone-frame.webp); - background-size: contain; -} - -.iphone-mask { - mask-image: url(../assets/iphone-mask.webp), linear-gradient(#fff 0 0); - mask-size: contain; - mask-composite: exclude; -} - -@keyframes cross-fade { - 0%, 40% { - opacity: var(--fade-from, 0); - } - - 50%, 90% { - opacity: var(--fade-to, 1); - } - - 100% { - opacity: var(--fade-from, 0); - } -} - -@keyframes highlight { - 0%, 30% { - opacity: 0; - } - - 50% { - opacity: 1; - } - - 70%, 100% { - opacity: 0; - } -} - -.cross-fade { - animation: cross-fade 5s infinite; - mix-blend-mode: plus-lighter; -} - -.highlight-tags { - .tag:nth-child(1 of .tag), - .tag:nth-child(n+4 of .tag):nth-child(-n+8 of .tag), - .tag:nth-last-child(1 of .tag) { - position: relative; - &::after { - content: ''; - position: absolute; - left: 0; - right: 0; - height: 0.99lh; - opacity: 0; - @apply rounded-sm bg-red-600/[15%] dark:bg-red-600/20; - animation: highlight 5s infinite var(--delay, 0s); - } - } -} - -.code-mask { - mask: linear-gradient(to bottom, white 0% 70%, transparent); -} - -.cyan-gradient-background { - --a: oklch(90% 0.05 200); - --a-shape: ellipse 30% 23% at 30% 56%; - --b: oklch(94% 0.09 175); - --b-shape: ellipse 30% 30% at 71% 42%; - --c: oklch(96% 0.06 218); - --c-shape: ellipse 40% 25% at 50% 72%; - background: radial-gradient(var(--a-shape), var(--a), transparent), - radial-gradient(var(--b-shape), var(--b), transparent), - radial-gradient(var(--c-shape), var(--c), transparent); -} - -@media (width < 768px) { - .cyan-gradient-background { - --a-shape: circle 250px at 26% 65%; - --b-shape: circle 250px at 73% 64%; - --c-shape: circle 250px at 50% 70%; - } -} - -@media (prefers-color-scheme: dark) { - .cyan-gradient-background { - --a: oklch(25% 0.1 200); - --b: oklch(27% 0.1 175); - --c: oklch(26% 0.08 218); - } -} - -.blue-gradient-background { - --a: oklch(94% 0.08 250); - --b: oklch(94% 0.12 275); - --c: oklch(91% 0.15 290); - background: radial-gradient(circle farthest-side at 28% 54%, var(--a), transparent 36%), - radial-gradient(circle farthest-side at 65% 45%, var(--b), transparent 50%), - radial-gradient(circle farthest-side at 60% 65%, var(--c), transparent 50%); -} - -@media (prefers-color-scheme: dark) { - .blue-gradient-background { - --a: oklch(25% 0.08 250); - --b: oklch(25% 0.07 275); - --c: oklch(28% 0.1 280); - } -} - -.orange-gradient-background { - --color: oklch(96% 0.1 75); - --shape: ellipse 50% 35% at 50% 58%; - background: radial-gradient(var(--shape), var(--color), transparent); -} - -@media (width < 1080px) { - .orange-gradient-background { - --shape: ellipse 60% 40% at 50% 63%; - } -} - -@media (prefers-color-scheme: dark) { - .orange-gradient-background { - --color: oklch(27% 0.09 71); - } -} - -.red-gradient-background { - --a: oklch(96% 0.1 350); - --a-shape: ellipse 30% 30% at 29% 54%; - --b: oklch(96% 0.11 6); - --b-shape: ellipse 35% 30% at 66% 50%; - --c: oklch(96% 0.15 20); - --c-shape: ellipse 40% 25% at 50% 70%; - background: radial-gradient(var(--a-shape), var(--a), transparent), - radial-gradient(var(--b-shape), var(--b), transparent), - radial-gradient(var(--c-shape), var(--c), transparent); -} - -@media (width < 768px) { - .red-gradient-background { - --a-shape: circle 300px at 20% 72%; - --b-shape: circle 300px at 50% 72%; - --c-shape: circle 300px at 80% 72%; - } -} - -@media (prefers-color-scheme: dark) { - .red-gradient-background { - --a: oklch(27% 0.11 350); - --b: oklch(26% 0.11 6); - --c: oklch(26% 0.1 20); - } -} - -.pink-gradient-background { - --a: oklch(92% 0.11 300); - --a-shape: ellipse 30% 30% at 29% 57%; - --b: oklch(96% 0.11 320); - --b-shape: ellipse 35% 30% at 66% 47%; - --c: oklch(96% 0.14 340); - --c-shape: ellipse 40% 25% at 50% 70%; - background: radial-gradient(var(--a-shape), var(--a), transparent), - radial-gradient(var(--b-shape), var(--b), transparent), - radial-gradient(var(--c-shape), var(--c), transparent); -} - -@media (width < 768px) { - .pink-gradient-background { - --a-shape: circle 400px at 50% 40%; - --b-shape: circle 400px at 50% 60%; - --c-shape: circle 400px at 50% 80%; - } -} - -@media (prefers-color-scheme: dark) { - .pink-gradient-background { - --a: oklch(30% 0.11 300); - --b: oklch(30% 0.11 320); - --c: oklch(27% 0.1 340); - } -} - - -.green-gradient-background { - --a: oklch(96% 0.1 120); - --a-shape: ellipse 30% 30% at 29% 54%; - --b: oklch(91% 0.12 140); - --b-shape: ellipse 35% 30% at 66% 41%; - --c: oklch(94% 0.06 150); - --c-shape: ellipse 40% 25% at 50% 74%; - background: radial-gradient(var(--a-shape), var(--a), transparent), - radial-gradient(var(--b-shape), var(--b), transparent), - radial-gradient(var(--c-shape), var(--c), transparent); -} - -@media (width < 768px) { - .green-gradient-background { - --a-shape: circle 400px at 20% 63%; - --b-shape: circle 400px at 50% 63%; - --c-shape: circle 400px at 80% 63%; - } -} - -@media (prefers-color-scheme: dark) { - .green-gradient-background { - --a: oklch(28% 0.1 130); - --b: oklch(28% 0.12 145); - --c: oklch(26% 0.06 150); - } -} diff --git a/packages/dev/docs/pages/react-aria/home/A11y.tsx b/packages/dev/docs/pages/react-aria/home/A11y.tsx index d6ff83831eb..3d91d5648de 100644 --- a/packages/dev/docs/pages/react-aria/home/A11y.tsx +++ b/packages/dev/docs/pages/react-aria/home/A11y.tsx @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {animate, useIntersectionObserver} from './utils'; import {Button} from 'tailwind-starter/Button'; import {ChevronDown, WifiIcon} from 'lucide-react'; @@ -196,7 +197,7 @@ export function A11y(): ReactNode { {fingerPos && createPortal(, document.body)} {cursorRect && createPortal((
- diff --git a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx index f835f94e8e1..99dd00be8af 100644 --- a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx +++ b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx @@ -9,13 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {AlertDialog} from 'tailwind-starter/AlertDialog'; import {Arrow} from './components'; import {Button} from 'tailwind-starter/Button'; -import {Cell, Column, Row, TableHeader} from 'tailwind-starter/Table'; +import {Cell, Column, Row, Table, TableHeader} from 'tailwind-starter/Table'; import {Checkbox} from 'tailwind-starter/Checkbox'; import {CloudSun, Dessert, Droplet, Droplets, FilterIcon, Mail, MoreHorizontal, PencilIcon, PlusIcon, RefreshCw, ShareIcon, SlidersIcon, StarIcon, Sun, SunDim, TrashIcon, Twitter} from 'lucide-react'; -import {ColumnProps, Dialog, DialogTrigger, DropZone, Form, Heading, isFileDropItem, Key, ModalOverlay, ModalOverlayProps, Modal as RACModal, ResizableTableContainer, Selection, SortDescriptor, Table, TableBody, Text, ToggleButton, ToggleButtonProps, TooltipTrigger} from 'react-aria-components'; +import {ColumnProps, Dialog, DialogTrigger, DropZone, Form, Heading, isFileDropItem, Key, ModalOverlay, ModalOverlayProps, Modal as RACModal, Selection, SortDescriptor, TableBody, Text, ToggleButton, ToggleButtonProps, TooltipTrigger} from 'react-aria-components'; import {ComboBox, ComboBoxItem} from 'tailwind-starter/ComboBox'; import {DatePicker} from 'tailwind-starter/DatePicker'; import {focusRing} from 'tailwind-starter/utils'; @@ -41,10 +42,10 @@ const allColumns: ColumnProps[] = [ {id: 'cycle', children: 'Cycle', defaultWidth: 120, allowsSorting: true}, {id: 'sunlight', children: 'Sunlight', defaultWidth: 120, allowsSorting: true}, {id: 'watering', children: 'Watering', defaultWidth: 120, allowsSorting: true}, - {id: 'actions', children: Actions, width: 64, minWidth: 64} + {id: 'actions', children: Actions, width: 48, minWidth: 48} ]; -let hideOnScroll = document.getElementById('hideOnScroll'); +let hideOnScroll = typeof document !== 'undefined' ? document.getElementById('hideOnScroll') : null; export function ExampleApp(): React.ReactNode { let [sortDescriptor, setSortDescriptor] = useState({ @@ -171,20 +172,20 @@ export function ExampleApp(): React.ReactNode { let isSmall = useMediaQuery('(max-width: 640px)'); return ( -
+
Filters - - Filters + + Filters {filters > 0 && }
Favorite @@ -209,7 +210,7 @@ export function ExampleApp(): React.ReactNode { Columns @@ -222,7 +223,7 @@ export function ExampleApp(): React.ReactNode { @@ -260,8 +261,8 @@ export function ExampleApp(): React.ReactNode { )} } - {!isSmall && - + {!isSmall && +
{column => } @@ -280,7 +281,7 @@ export function ExampleApp(): React.ReactNode { return (
- + {item.common_name} {item.scientific_name}
@@ -296,7 +297,7 @@ export function ExampleApp(): React.ReactNode { return ( - onAction(item, action)}> @@ -325,7 +326,7 @@ export function ExampleApp(): React.ReactNode { )}
-
} + } Are you sure you want to delete "{actionItem?.common_name}"? @@ -423,7 +424,7 @@ function PlantDialog({item, onSave}: {item?: Plant | null, onSave: (item: Plant) setDroppedImage(URL.createObjectURL(await item.getFile())); } }} - className="w-24 sm:w-32 p-2 flex items-center justify-center shrink-0 border-2 border-gray-400 border-dashed rounded-xl text-gray-500 dark:text-gray-300 focus-visible:border-blue-600 forced-colors:focus-visible:border-[Highlight] focus-visible:border-solid drop-target:border-blue-600 forced-colors:drop-target:border-[Highlight] drop-target:border-solid drop-target:bg-blue-200 dark:drop-target:bg-blue-800/60 drop-target:text-blue-600 dark:drop-target:text-blue-300"> + className="w-24 sm:w-32 p-2 box-border flex items-center justify-center shrink-0 border-2 border-gray-400 border-dashed rounded-xl text-gray-500 dark:text-gray-300 focus-visible:border-blue-600 forced-colors:focus-visible:border-[Highlight] focus-visible:border-solid drop-target:border-blue-600 forced-colors:drop-target:border-[Highlight] drop-target:border-solid drop-target:bg-blue-200 dark:drop-target:bg-blue-800/60 drop-target:text-blue-600 dark:drop-target:text-blue-300"> {droppedImage ? : Drop or paste image here @@ -515,7 +516,7 @@ function PlantModal(props: ModalOverlayProps) { Note that position: fixed will not work here because this is positioned relative to the containing block, which is the ModalOverlay in this case due to backdrop-blur. */} -
+
{!isResized &&
` - w-full max-w-md max-h-full overflow-auto rounded-2xl bg-white dark:bg-zinc-800/70 dark:backdrop-blur-2xl dark:backdrop-saturate-200 forced-colors:!bg-[Canvas] p-6 text-left align-middle shadow-2xl bg-clip-padding border border-black/10 dark:border-white/10 + w-full max-w-md max-h-full overflow-auto rounded-2xl bg-white dark:bg-zinc-800/70 dark:backdrop-blur-2xl dark:backdrop-saturate-200 forced-colors:!bg-[Canvas] box-border p-6 border-box text-left align-middle shadow-2xl bg-clip-padding border border-black/10 dark:border-white/10 ${isEntering ? 'animate-in zoom-in-105 ease-out duration-200' : ''} ${isExiting ? 'animate-out zoom-out-95 ease-in duration-200' : ''} `} /> @@ -550,7 +551,7 @@ function PlantModal(props: ModalOverlayProps) { const favoriteButtonStyles = tv({ extend: focusRing, - base: 'group cursor-default align-middle rounded-sm', + base: 'group cursor-default align-middle rounded-sm border-0 bg-transparent p-0', variants: { isSelected: { false: 'text-gray-500 dark:text-zinc-400 pressed:text-gray-600 dark:pressed:text-zinc-300', @@ -562,7 +563,7 @@ const favoriteButtonStyles = tv({ function FavoriteButton(props: ToggleButtonProps) { return ( - + ); } diff --git a/packages/dev/docs/pages/react-aria/home/FocusExample.tsx b/packages/dev/docs/pages/react-aria/home/FocusExample.tsx new file mode 100644 index 00000000000..1b5020db603 --- /dev/null +++ b/packages/dev/docs/pages/react-aria/home/FocusExample.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +'use client'; +import {Button} from 'tailwind-starter/Button'; +import {CircleUser} from 'lucide-react'; +import {Dialog, DialogTrigger, Heading} from 'react-aria-components'; +import {Popover} from 'tailwind-starter/Popover'; +import React from 'react'; +import {TextField} from 'tailwind-starter/TextField'; + +export function FocusExample() { + return ( +
+ + + + + Your Account + + + + + +
+ ); +} diff --git a/packages/dev/docs/pages/react-aria/home/I18n.tsx b/packages/dev/docs/pages/react-aria/home/I18n.tsx index 1df0a8bf8c7..4452ac28334 100644 --- a/packages/dev/docs/pages/react-aria/home/I18n.tsx +++ b/packages/dev/docs/pages/react-aria/home/I18n.tsx @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {Calendar} from 'tailwind-starter/Calendar'; import {DateField} from 'tailwind-starter/DateField'; import {DateValue, I18nProvider, useLocale} from 'react-aria-components'; diff --git a/packages/dev/docs/pages/react-aria/home/KanbanExample.tsx b/packages/dev/docs/pages/react-aria/home/KanbanExample.tsx index 5b50642928b..fa5bf872ed1 100644 --- a/packages/dev/docs/pages/react-aria/home/KanbanExample.tsx +++ b/packages/dev/docs/pages/react-aria/home/KanbanExample.tsx @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {Button, DropIndicator, GridList, GridListItem, isTextDropItem, Text, useDragAndDrop} from 'react-aria-components'; import {ListData, useListData} from 'react-stately'; import React, {ReactNode} from 'react'; @@ -112,7 +113,7 @@ export function KanbanBoard(): ReactNode { }); return ( -
+
@@ -141,7 +142,7 @@ function Column({list, status, itemClassName}: ColumnProps) { renderDropIndicator(target) { return ( - + @@ -194,7 +195,7 @@ function Column({list, status, itemClassName}: ColumnProps) { return (
-

{status}

+

{status}

{items.length} {items.length === 1 ? 'task' : 'tasks'} 'No tasks.'} - className="h-[320px] p-2 md:p-4 overflow-y-auto overflow-x-hidden relative outline outline-0 bg-white/70 dark:bg-zinc-900/60 backdrop-blur-sm border border-black/10 dark:border-white/10 bg-clip-padding text-gray-700 dark:text-zinc-400 flex flex-col gap-3 rounded-xl shadow-xl drop-target:bg-blue-200 dark:drop-target:bg-blue-800/60 drop-target:outline-2 outline-blue-500 forced-colors:outline-[Highlight] -outline-offset-2 empty:items-center empty:justify-center"> + className="h-[320px] box-border p-2 md:p-4 overflow-y-auto overflow-x-hidden relative outline outline-0 bg-white/70 dark:bg-zinc-900/60 backdrop-blur-sm border border-black/10 dark:border-white/10 bg-clip-padding text-gray-700 dark:text-zinc-400 flex flex-col gap-3 rounded-xl shadow-xl drop-target:bg-blue-200 dark:drop-target:bg-blue-800/60 drop-target:outline-2 outline-blue-500 forced-colors:outline-[Highlight] -outline-offset-2 empty:items-center empty:justify-center"> {item => } @@ -218,7 +219,7 @@ interface CardProps { function Card({id, item, className}: CardProps) { return ( - + {item.title} {item.id} {item.description} diff --git a/packages/dev/docs/pages/react-aria/home/ListBoxExample.tsx b/packages/dev/docs/pages/react-aria/home/ListBoxExample.tsx index fdec055eb38..9a9784807f4 100644 --- a/packages/dev/docs/pages/react-aria/home/ListBoxExample.tsx +++ b/packages/dev/docs/pages/react-aria/home/ListBoxExample.tsx @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {animate, useIntersectionObserver} from './utils'; import {ListBoxItem as AriaListBoxItem, ListBoxItemProps as AriaListBoxItemProps, Key, ListBox, Selection} from 'react-aria-components'; import {itemStyles} from 'tailwind-starter/ListBox'; diff --git a/packages/dev/docs/pages/react-aria/home/MouseAnimation.tsx b/packages/dev/docs/pages/react-aria/home/MouseAnimation.tsx index 0c6873a3719..3810e59ae75 100644 --- a/packages/dev/docs/pages/react-aria/home/MouseAnimation.tsx +++ b/packages/dev/docs/pages/react-aria/home/MouseAnimation.tsx @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {animate, useIntersectionObserver} from './utils'; import {Button} from 'tailwind-starter/Button'; import {ButtonContext, Key, TooltipTrigger} from 'react-aria-components'; diff --git a/packages/dev/docs/pages/react-aria/home/PaginatedCarousel.tsx b/packages/dev/docs/pages/react-aria/home/PaginatedCarousel.tsx new file mode 100644 index 00000000000..5534bb183cd --- /dev/null +++ b/packages/dev/docs/pages/react-aria/home/PaginatedCarousel.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +'use client'; + +import {Pagination} from './Pagination'; +import React, {useRef} from 'react'; + +export function PaginatedCarousel({children, className, paginationClassName}) { + let carouselRef = useRef(null); + + return ( + <> +
+ {children} +
+ + + ); +} + diff --git a/packages/dev/docs/pages/react-aria/home/Pagination.tsx b/packages/dev/docs/pages/react-aria/home/Pagination.tsx index 5620d06d0a7..0cd64a811cd 100644 --- a/packages/dev/docs/pages/react-aria/home/Pagination.tsx +++ b/packages/dev/docs/pages/react-aria/home/Pagination.tsx @@ -9,12 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {Button} from 'tailwind-starter/Button'; import {ChevronLeft, ChevronRight} from 'lucide-react'; -import React, {ReactNode, useEffect, useState} from 'react'; +import React, {ReactNode, RefObject, useEffect, useState} from 'react'; -export function Pagination({carousel, className}: {carousel: HTMLElement, className?: string}): ReactNode { +export function Pagination({carouselRef, className}: {carouselRef: RefObject, className?: string}): ReactNode { let scroll = (dir: number) => { + let carousel = carouselRef.current!; let style = window.getComputedStyle(carousel); carousel.scrollBy({ left: dir * (carousel.clientWidth - parseInt(style.paddingLeft, 10) - parseInt(style.paddingRight, 10) + parseInt(style.columnGap, 10)), @@ -26,6 +28,7 @@ export function Pagination({carousel, className}: {carousel: HTMLElement, classN let [isNextDisabled, setNextDisabled] = useState(false); useEffect(() => { + let carousel = carouselRef.current!; let update = () => { setPrevDisabled(carousel.scrollLeft <= 0); setNextDisabled(carousel.scrollLeft >= carousel.scrollWidth - carousel.clientWidth); @@ -40,7 +43,7 @@ export function Pagination({carousel, className}: {carousel: HTMLElement, classN carousel.addEventListener('scroll', update); return () => carousel.removeEventListener('scroll', update); } - }, [carousel]); + }, [carouselRef]); return (
diff --git a/packages/dev/docs/pages/react-aria/home/Styles.tsx b/packages/dev/docs/pages/react-aria/home/Styles.tsx index 9ca4af33d0f..a6b65c5768d 100644 --- a/packages/dev/docs/pages/react-aria/home/Styles.tsx +++ b/packages/dev/docs/pages/react-aria/home/Styles.tsx @@ -9,10 +9,11 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {AddressBar, FileTab, Scrollable, Window} from './components'; import {animate, AnimationPlaybackControls, motion, useMotionValueEvent, useReducedMotion, useScroll, useTransform} from 'motion/react'; +import {Button} from 'vanilla-starter/Button'; import { - Button, Collection, Key, Tab, @@ -21,11 +22,12 @@ import { Tabs } from 'react-aria-components'; import {ComboBox, ComboBoxItem} from 'tailwind-starter/ComboBox'; -import {DatePicker} from 'tailwind-starter/DatePicker'; +import {DatePicker} from 'vanilla-starter/DatePicker'; import React, {ReactNode, useCallback, useEffect, useRef, useState} from 'react'; -import {Slider} from 'tailwind-starter/Slider'; +import {Slider} from 'vanilla-starter/Slider'; -export function Styles(): ReactNode { +export function Styles({children}): ReactNode { + let [styling, css, tailwind, styledComponents, panda] = children; return (
-
+ {styling}
DatePicker.css
-
+ {css}
https://your-app.com}> -
+
@@ -69,11 +71,11 @@ export function Styles(): ReactNode { content:
ComboBox.tsx}> -
+ {tailwind} https://your-app.com}> -
+
{item => ( @@ -92,11 +94,11 @@ export function Styles(): ReactNode { content:
Slider.tsx} className="bg-gray-50 dark:bg-zinc-800/80 backdrop-saturate-200"> -
+ {styledComponents} https://your-app.com}> -
+
@@ -108,12 +110,12 @@ export function Styles(): ReactNode { content:
Button.tsx} className="bg-gray-50 dark:bg-zinc-800/80 dark:backdrop-saturate-200"> -
+ {panda} https://your-app.com}> -
-
diff --git a/packages/dev/docs/pages/react-aria/home/SwitchAnimation.tsx b/packages/dev/docs/pages/react-aria/home/SwitchAnimation.tsx index 350cc4bbb46..ab7d9c49f18 100644 --- a/packages/dev/docs/pages/react-aria/home/SwitchAnimation.tsx +++ b/packages/dev/docs/pages/react-aria/home/SwitchAnimation.tsx @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +'use client'; import {animationQueue, useIntersectionObserver} from './utils'; import {Finger} from './components'; import {flushSync} from 'react-dom'; @@ -56,8 +57,8 @@ export function SwitchAnimation(): ReactNode { isSelected={isSelected} onChange={isAnimating ? undefined : setSelected} className="group inline-flex touch-none"> - - + + diff --git a/packages/dev/docs/pages/react-aria/home/components.tsx b/packages/dev/docs/pages/react-aria/home/components.tsx index 873a99987ec..13329811e3c 100644 --- a/packages/dev/docs/pages/react-aria/home/components.tsx +++ b/packages/dev/docs/pages/react-aria/home/components.tsx @@ -18,9 +18,9 @@ export function Window({children, className = '', isBackground = false, toolbar}
{toolbar}
-
-
-
+
+
+
{children} @@ -44,15 +44,15 @@ export function Card({className, ...otherProps}: { [x: string]: any, className: any }): ReactNode { - return
; + return
; } export function CardTitle({children}: {children: ReactNode}): ReactNode { - return

{children}

; + return

{children}

; } export function CardDescription({children}: {children: ReactNode}): ReactNode { - return

{children}

; + return

{children}

; } interface ArrowProps { @@ -86,7 +86,7 @@ React.forwardRef((props: HTMLAttributes, ref: ForwardedRefLearn more; + return Learn more; } export function Scrollable({children, className = ''}: {children: ReactNode, className?: string}): ReactNode { diff --git a/packages/dev/docs/pages/react-aria/home/home.css b/packages/dev/docs/pages/react-aria/home/home.css new file mode 100644 index 00000000000..8be86213c8a --- /dev/null +++ b/packages/dev/docs/pages/react-aria/home/home.css @@ -0,0 +1,473 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +@utility focus-ring { + @apply outline outline-0 outline-blue-600 dark:outline-blue-500 focus-visible:outline-2; +} + +body { + background: var(--page-bg); + color-scheme: dark light; + @apply [--page-bg:var(--color-zinc-50)] dark:[--page-bg:var(--color-zinc-900)]; +} + +.header-background { + @apply [--l:91%] [--c:0.11] dark:[--l:32%] dark:[--c:0.13]; + background: linear-gradient(to bottom, transparent 0% 80%, var(--page-bg)), + radial-gradient(circle at 50% 60%, transparent 0% 50%, var(--page-bg)), + conic-gradient(from 30deg at 50% 60%, + oklch(var(--l) var(--c) 220), + oklch(min(var(--l), 86%) var(--c) 260), + oklch(var(--l) var(--c) 300), + oklch(var(--l) var(--c) 340), + oklch(var(--l) var(--c) 0), + oklch(var(--l) var(--c) 40), + oklch(var(--l) var(--c) 60), + oklch(max(var(--l), 44%) var(--c) 100), + oklch(max(var(--l), 38%) var(--c) 140), + oklch(var(--l) var(--c) 180), + oklch(var(--l) var(--c) 220) + ); + background-repeat: no-repeat; +} + +main > section > h2 { + @apply text-4xl md:text-5xl font-semibold m-0 mb-4 pb-2 text-transparent; + + + p { + @apply text-lg md:text-2xl max-w-5xl text-slate-700 dark:text-slate-400; + text-wrap: pretty; + } +} + +pre { + margin: 0; +} + +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +.no-scrollbar { + scrollbar-width: none; +} + +:root { + --hljs-color: theme(colors.gray.800); + --hljs-background: transparent; + --hljs-keyword-color: theme(colors.fuchsia.700); + --hljs-section-color: theme(colors.red.600); + --hljs-string-color: theme(colors.green.700); + --hljs-literal-color: theme(colors.purple.700); + --hljs-attribute-color: theme(colors.indigo.700); + --hljs-class-color: theme(colors.cyan.600); + --hljs-function-color: theme(colors.blue.600); + --hljs-variable-color: theme(colors.purple.700); + --hljs-title-color: theme(colors.indigo.700); + --hljs-comment-color: theme(colors.gray.700); + --mark-background: theme(colors.blue.400/10%); + --mark-border: theme(colors.blue.500); +} + +@media (prefers-color-scheme: dark) { + :root { + --hljs-color: theme(colors.gray.300); + --hljs-keyword-color: theme(colors.fuchsia.300); + --hljs-section-color: theme(colors.red.400); + --hljs-string-color: theme(colors.green.400); + --hljs-literal-color: theme(colors.purple.400); + --hljs-attribute-color: theme(colors.indigo.400); + --hljs-class-color: theme(colors.cyan.400); + --hljs-function-color: theme(colors.blue.400); + --hljs-variable-color: theme(colors.purple.400); + --hljs-title-color: theme(colors.indigo.400); + --hljs-comment-color: theme(colors.gray.400); + --mark-border: theme(colors.blue.400); + } +} + +pre .source { + @apply p-5 text-xs sm:text-sm min-h-[250px] h-full min-w-fit; + font-family: source-code-pro, 'Source Code Pro', Monaco, monospace; +} + +pre.large { + @apply hidden lg:block; +} + +pre.medium { + @apply hidden sm:block lg:hidden; +} + +pre.small { + @apply sm:hidden +} + +.card-shadow { + box-shadow: 0 0 2px rgb(0 0 0 / 12%), 0 3px 6px rgb(0 0 0 / 4%), 0 4px 8px 0 rgba(0 0 0 / 8%); + outline: 1px solid transparent; /* WHCM */ + @apply dark:border dark:border-zinc-200/10 dark:bg-clip-padding; +} + +.card-shadow-hover:hover { + box-shadow: 0 0 2px rgb(0 0 0 / 18%), 0 3px 8px rgb(0 0 0 / 6%), 0 4px 16px 0 rgba(0 0 0 / 10%); +} + +.card-shadow-hover:focus-visible { + @apply focus-ring; +} + +.edge-mask { + mask-image: linear-gradient(to right, transparent, white 8px calc(100% - 8px), transparent); +} + +@keyframes touch-animation { + 0% { + opacity: var(--hover-opacity); + transform: translate(10px, 135px); + } + + 15%, 16% { + opacity: var(--hover-opacity); + transform: translate(7px, 0); + } + + 17.2%, 19% { + opacity: var(--pressed-opacity); + transform: translate(7px, 0); + } + + 25%, 27% { + opacity: var(--pressed-opacity); + transform: translate(7px, 48px); + } + + 35%, 36% { + opacity: var(--pressed-opacity); + transform: translate(7px, 7px); + } + + 37.2% { + opacity: var(--hover-opacity); + transform: translate(7px, 7px); + } + + 50%, 55% { + opacity: var(--hover-opacity); + transform: translate(4px, 52px); + } + + 65%, 66% { + opacity: var(--hover-opacity); + transform: translate(7px, 0); + } + + 67.2%, 69% { + opacity: var(--pressed-opacity); + transform: translate(7px, 0); + } + + 75%, 77% { + opacity: var(--pressed-opacity); + transform: translate(7px, 48px); + } + + 85%, 86% { + opacity: var(--pressed-opacity); + transform: translate(7px, 7px); + } + + 87.2% { + opacity: var(--hover-opacity); + transform: translate(7px, 7px); + } + + 100% { + opacity: var(--hover-opacity); + transform: translate(10px, 135px); + } +} + +@keyframes switch-animation { + 0%, 16% { + margin-left: --spacing(6); + width: --spacing(8); + } + + 18.5%, 22% { + margin-left: --spacing(4); + width: --spacing(10); + } + + 25%, 30% { + margin-left: --spacing(6); + width: --spacing(8); + } + + 33%, 36.5% { + margin-left: --spacing(4); + width: --spacing(10); + } + + 38.5%, 66% { + margin-left: 0; + width: --spacing(8); + } + + 68.5%, 72% { + margin-left: 0; + width: --spacing(10); + } + + 75%, 80% { + margin-left: 0; + width: --spacing(8); + } + + 83%, 86.5% { + margin-left: 0; + width: --spacing(10); + } + + 88.5%, 100% { + margin-left: --spacing(6); + width: --spacing(8); + } +} + +@keyframes switch-background-animation { + 0%, 36.5% { + background: var(--bg-selected); + } + + 38.5%, 86.5% { + background: var(--bg); + } + + 88.5%, 100% { + background: var(--bg-selected); + } +} + +.iphone-frame { + background-image: url(../../assets/iphone-frame.webp); + background-size: contain; +} + +.iphone-mask { + mask-image: url(../../assets/iphone-mask.webp), linear-gradient(#fff 0 0); + mask-size: contain; + mask-composite: exclude; +} + +@keyframes cross-fade { + 0%, 40% { + opacity: var(--fade-from, 0); + } + + 50%, 90% { + opacity: var(--fade-to, 1); + } + + 100% { + opacity: var(--fade-from, 0); + } +} + +@keyframes highlight { + 0%, 30% { + opacity: 0; + } + + 50% { + opacity: 1; + } + + 70%, 100% { + opacity: 0; + } +} + +.cross-fade { + animation: cross-fade 5s infinite; + mix-blend-mode: plus-lighter; +} + +.highlight-tags { + .tag:nth-child(1 of .tag), + .tag:nth-child(n+4 of .tag):nth-child(-n+8 of .tag), + .tag:nth-last-child(1 of .tag) { + position: relative; + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 0.99lh; + opacity: 0; + @apply rounded-sm bg-red-600/[15%] dark:bg-red-600/20; + animation: highlight 5s infinite var(--delay, 0s); + } + } +} + +.code-mask { + mask: linear-gradient(to bottom, white 0% 70%, transparent); +} + +.cyan-gradient-background { + --a: oklch(90% 0.05 200); + --a-shape: ellipse 30% 23% at 30% 56%; + --b: oklch(94% 0.09 175); + --b-shape: ellipse 30% 30% at 71% 42%; + --c: oklch(96% 0.06 218); + --c-shape: ellipse 40% 25% at 50% 72%; + background: radial-gradient(var(--a-shape), var(--a), transparent), + radial-gradient(var(--b-shape), var(--b), transparent), + radial-gradient(var(--c-shape), var(--c), transparent); +} + +@media (width < 768px) { + .cyan-gradient-background { + --a-shape: circle 250px at 26% 65%; + --b-shape: circle 250px at 73% 64%; + --c-shape: circle 250px at 50% 70%; + } +} + +@media (prefers-color-scheme: dark) { + .cyan-gradient-background { + --a: oklch(25% 0.1 200); + --b: oklch(27% 0.1 175); + --c: oklch(26% 0.08 218); + } +} + +.blue-gradient-background { + --a: oklch(94% 0.08 250); + --b: oklch(94% 0.12 275); + --c: oklch(91% 0.15 290); + background: radial-gradient(circle farthest-side at 28% 54%, var(--a), transparent 36%), + radial-gradient(circle farthest-side at 65% 45%, var(--b), transparent 50%), + radial-gradient(circle farthest-side at 60% 65%, var(--c), transparent 50%); +} + +@media (prefers-color-scheme: dark) { + .blue-gradient-background { + --a: oklch(25% 0.08 250); + --b: oklch(25% 0.07 275); + --c: oklch(28% 0.1 280); + } +} + +.orange-gradient-background { + --color: oklch(96% 0.1 75); + --shape: ellipse 50% 35% at 50% 58%; + background: radial-gradient(var(--shape), var(--color), transparent); +} + +@media (width < 1080px) { + .orange-gradient-background { + --shape: ellipse 60% 40% at 50% 63%; + } +} + +@media (prefers-color-scheme: dark) { + .orange-gradient-background { + --color: oklch(27% 0.09 71); + } +} + +.red-gradient-background { + --a: oklch(96% 0.1 350); + --a-shape: ellipse 30% 30% at 29% 54%; + --b: oklch(96% 0.11 6); + --b-shape: ellipse 35% 30% at 66% 50%; + --c: oklch(96% 0.15 20); + --c-shape: ellipse 40% 25% at 50% 70%; + background: radial-gradient(var(--a-shape), var(--a), transparent), + radial-gradient(var(--b-shape), var(--b), transparent), + radial-gradient(var(--c-shape), var(--c), transparent); +} + +@media (width < 768px) { + .red-gradient-background { + --a-shape: circle 300px at 20% 72%; + --b-shape: circle 300px at 50% 72%; + --c-shape: circle 300px at 80% 72%; + } +} + +@media (prefers-color-scheme: dark) { + .red-gradient-background { + --a: oklch(27% 0.11 350); + --b: oklch(26% 0.11 6); + --c: oklch(26% 0.1 20); + } +} + +.pink-gradient-background { + --a: oklch(92% 0.11 300); + --a-shape: ellipse 30% 30% at 29% 57%; + --b: oklch(96% 0.11 320); + --b-shape: ellipse 35% 30% at 66% 47%; + --c: oklch(96% 0.14 340); + --c-shape: ellipse 40% 25% at 50% 70%; + background: radial-gradient(var(--a-shape), var(--a), transparent), + radial-gradient(var(--b-shape), var(--b), transparent), + radial-gradient(var(--c-shape), var(--c), transparent); +} + +@media (width < 768px) { + .pink-gradient-background { + --a-shape: circle 400px at 50% 40%; + --b-shape: circle 400px at 50% 60%; + --c-shape: circle 400px at 50% 80%; + } +} + +@media (prefers-color-scheme: dark) { + .pink-gradient-background { + --a: oklch(30% 0.11 300); + --b: oklch(30% 0.11 320); + --c: oklch(27% 0.1 340); + } +} + + +.green-gradient-background { + --a: oklch(96% 0.1 120); + --a-shape: ellipse 30% 30% at 29% 54%; + --b: oklch(91% 0.12 140); + --b-shape: ellipse 35% 30% at 66% 41%; + --c: oklch(94% 0.06 150); + --c-shape: ellipse 40% 25% at 50% 74%; + background: radial-gradient(var(--a-shape), var(--a), transparent), + radial-gradient(var(--b-shape), var(--b), transparent), + radial-gradient(var(--c-shape), var(--c), transparent); +} + +@media (width < 768px) { + .green-gradient-background { + --a-shape: circle 400px at 20% 63%; + --b-shape: circle 400px at 50% 63%; + --c-shape: circle 400px at 80% 63%; + } +} + +@media (prefers-color-scheme: dark) { + .green-gradient-background { + --a: oklch(28% 0.1 130); + --b: oklch(28% 0.12 145); + --c: oklch(26% 0.06 150); + } +} diff --git a/packages/dev/docs/pages/react-aria/home/plants.ts b/packages/dev/docs/pages/react-aria/home/plants.ts index 65be38efb0c..36d1c7217d4 100644 --- a/packages/dev/docs/pages/react-aria/home/plants.ts +++ b/packages/dev/docs/pages/react-aria/home/plants.ts @@ -9,6 +9,26 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import agapanthus from 'url:./plants/agapanthus.jpg?as=webp'; +import aloe from 'url:./plants/aloe.jpg?as=webp'; +import dracaena from 'url:./plants/dracaena.jpg?as=webp'; +import fern from 'url:./plants/fern.jpg?as=webp'; +import fig from 'url:./plants/fig.jpg?as=webp'; +import gardenia from 'url:./plants/gardenia.jpg?as=webp'; +import ivy from 'url:./plants/ivy.jpg?as=webp'; +import jacaranda from 'url:./plants/jacaranda.jpg?as=webp'; +import maidenhair from 'url:./plants/maidenhair.jpg?as=webp'; +import money from 'url:./plants/money.jpg?as=webp'; +import monstera from 'url:./plants/monstera.jpg?as=webp'; +import morning from 'url:./plants/morning.jpg?as=webp'; +import nasturtium from 'url:./plants/nasturtium.jpg?as=webp'; +import oleander from 'url:./plants/oleander.jpg?as=webp'; +import poplar from 'url:./plants/poplar.jpg?as=webp'; +import spider from 'url:./plants/spider.jpg?as=webp'; +import star from 'url:./plants/star.jpg?as=webp'; +import tree_fern from 'url:./plants/tree_fern.jpg?as=webp'; +import xmas from 'url:./plants/xmas.jpg?as=webp'; +import zz from 'url:./plants/zz.jpg?as=webp'; export interface Plant { id: number, @@ -32,7 +52,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/aloe.jpg?as=webp', import.meta.url).toString() + thumbnail: aloe } }, { @@ -43,7 +63,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/jacaranda.jpg?as=webp', import.meta.url).toString() + thumbnail: jacaranda } }, { @@ -54,7 +74,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/oleander.jpg?as=webp', import.meta.url).toString() + thumbnail: oleander } }, { @@ -65,7 +85,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/poplar.jpg?as=webp', import.meta.url).toString() + thumbnail: poplar } }, { @@ -76,7 +96,7 @@ export default [ sunlight: ['part sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/zz.jpg?as=webp', import.meta.url).toString() + thumbnail: zz } }, { @@ -87,7 +107,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/morning.jpg?as=webp', import.meta.url).toString() + thumbnail: morning } }, { @@ -98,7 +118,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/xmas.jpg?as=webp', import.meta.url).toString() + thumbnail: xmas } }, { @@ -109,7 +129,7 @@ export default [ sunlight: ['part sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/gardenia.jpg?as=webp', import.meta.url).toString() + thumbnail: gardenia } }, { @@ -120,7 +140,7 @@ export default [ sunlight: ['part sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/spider.jpg?as=webp', import.meta.url).toString() + thumbnail: spider } }, { @@ -131,7 +151,7 @@ export default [ sunlight: ['part sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/money.jpg?as=webp', import.meta.url).toString() + thumbnail: money } }, { @@ -142,7 +162,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/fig.jpg?as=webp', import.meta.url).toString() + thumbnail: fig } }, { @@ -153,7 +173,7 @@ export default [ sunlight: ['part shade'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/fern.jpg?as=webp', import.meta.url).toString() + thumbnail: fern } }, { @@ -164,7 +184,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/star.jpg?as=webp', import.meta.url).toString() + thumbnail: star } }, { @@ -175,7 +195,7 @@ export default [ sunlight: ['part shade'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/monstera.jpg?as=webp', import.meta.url).toString() + thumbnail: monstera } }, { @@ -186,7 +206,7 @@ export default [ sunlight: ['full sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/agapanthus.jpg?as=webp', import.meta.url).toString() + thumbnail: agapanthus } }, { @@ -197,7 +217,7 @@ export default [ sunlight: ['part sun'], cycle: 'Annual', default_image: { - thumbnail: new URL('plants/tree_fern.jpg?as=webp', import.meta.url).toString() + thumbnail: tree_fern } }, { @@ -208,7 +228,7 @@ export default [ sunlight: ['part sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/dracaena.jpg?as=webp', import.meta.url).toString() + thumbnail: dracaena } }, { @@ -219,7 +239,7 @@ export default [ sunlight: ['part shade'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/maidenhair.jpg?as=webp', import.meta.url).toString() + thumbnail: maidenhair } }, { @@ -230,7 +250,7 @@ export default [ sunlight: ['part sun'], cycle: 'Perennial', default_image: { - thumbnail: new URL('plants/ivy.jpg?as=webp', import.meta.url).toString() + thumbnail: ivy } }, { @@ -241,7 +261,7 @@ export default [ sunlight: ['full sun'], cycle: 'Annual', default_image: { - thumbnail: new URL('plants/nasturtium.jpg?as=webp', import.meta.url).toString() + thumbnail: nasturtium } } ] as Plant[]; diff --git a/packages/dev/docs/pages/react-aria/index.mdx b/packages/dev/docs/pages/react-aria/index.mdx index bfe5abab450..3d09fc4f40f 100644 --- a/packages/dev/docs/pages/react-aria/index.mdx +++ b/packages/dev/docs/pages/react-aria/index.mdx @@ -24,28 +24,28 @@ description: Craft world-class accessible components with custom styles. image: ../assets/ReactAriaOpenGraph.webp --- -
+
-

Craft world-class accessible components with custom styles.

-

Over 50 components with built-in behavior, adaptive interactions, top-tier accessibility, and internationalization out of the box, ready for your styles.

+

Craft world-class accessible components with custom styles.

+

Over 50 components with built-in behavior, adaptive interactions, top-tier accessibility, and internationalization out of the box, ready for your styles.

@@ -112,7 +112,7 @@ ReactDOM.createRoot(document.getElementById('example-app')).render(

Bring your own styles.

-

React Aria is style-free out of the box, allowing you to build custom designs to fit your application or design system using any styling and animation solution. Each component is broken down into individual parts with built-in states, render props, and slots that make styling a breeze.

+

React Aria is style-free out of the box, allowing you to build custom designs to fit your application or design system using any styling and animation solution. Each component is broken down into individual parts with built-in states, render props, and slots that make styling a breeze.

Advanced features for ambitious apps.

-

Make your web app feel native with rich interactions that adapt to the device, platform, and user. React Aria supports advanced features like accessible drag and drop, keyboard multi-selection, built-in form validation, table column resizing, and a ton more.

+

Make your web app feel native with rich interactions that adapt to the device, platform, and user. React Aria supports advanced features like accessible drag and drop, keyboard multi-selection, built-in form validation, table column resizing, and a ton more.

```tsx snippet @@ -294,7 +300,7 @@ import {KanbanBoard} from './home/KanbanExample';

High quality interactions on all devices.

-

React Aria ensures a great experience for users, no matter their device. All components are optimized for mouse, touch, keyboard, and screen reader interactions, with a meticulous attention to detail that makes your app feel responsive and natural on every platform.

+

React Aria ensures a great experience for users, no matter their device. All components are optimized for mouse, touch, keyboard, and screen reader interactions, with a meticulous attention to detail that makes your app feel responsive and natural on every platform.

Accessibility that's truly first-class.

-

React Aria is designed with accessibility as a top priority, and battle tested in production applications. All components are built to work across a wide variety of devices and assistive technologies to ensure the best experience possible for all users.

+

React Aria is designed with accessibility as a top priority, and battle tested in production applications. All components are built to work across a wide variety of devices and assistive technologies to ensure the best experience possible for all users.

-
+
```tsx snippet import {A11y} from './home/A11y'; @@ -414,7 +420,7 @@ import {A11y} from './home/A11y';

Ready for an international audience.

-

React Aria is engineered for internationalization out of the box, including translations in over 30 languages, localized date and number formatting and parsing, support for 13 calendar systems, 5 numbering systems, right-to-left layout, and more.

+

React Aria is engineered for internationalization out of the box, including translations in over 30 languages, localized date and number formatting and parsing, support for 13 calendar systems, 5 numbering systems, right-to-left layout, and more.

```tsx snippet @@ -427,7 +433,7 @@ import {I18n} from './home/I18n';

Customizable to the max.

-

React Aria offers a flexible and scalable API that lets you dive as deep into the details as you like. Start with high-level components with a built-in DOM structure and simple styling API, compose custom patterns with contexts, and for the ultimate control, drop down to the low-level Hook-based API. Mix and match as needed.

+

React Aria offers a flexible and scalable API that lets you dive as deep into the details as you like. Start with high-level components with a built-in DOM structure and simple styling API, compose custom patterns with contexts, and for the ultimate control, drop down to the low-level Hook-based API. Mix and match as needed.

@@ -623,18 +629,18 @@ import {Pagination} from './home/Pagination';

Ready to get started?

@@ -642,13 +648,13 @@ import {Pagination} from './home/Pagination'; -