diff --git a/eslint.config.mjs b/eslint.config.mjs index ce69f768024..243ac9a43fc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -235,7 +235,7 @@ export default [{ 'react-hooks/component-hook-factories': ERROR, 'react-hooks/gating': ERROR, 'react-hooks/globals': ERROR, - // 'react-hooks/immutability': ERROR, + 'react-hooks/immutability': ERROR, // 'react-hooks/preserve-manual-memoization': ERROR, // No idea how to turn this one on yet 'react-hooks/purity': ERROR, // 'react-hooks/refs': ERROR, // can't turn on until https://github.com/facebook/react/issues/34775 is fixed @@ -250,7 +250,6 @@ export default [{ "rsp-rules/sort-imports": [ERROR], "rulesdir/imports": [ERROR], "rulesdir/useLayoutEffectRule": [ERROR], - "rulesdir/pure-render": [ERROR], "jsx-a11y/accessible-emoji": ERROR, "jsx-a11y/alt-text": ERROR, "jsx-a11y/anchor-has-content": ERROR, diff --git a/packages/@react-aria/breadcrumbs/src/useBreadcrumbItem.ts b/packages/@react-aria/breadcrumbs/src/useBreadcrumbItem.ts index fc791dd1dd3..b7c44318eb9 100644 --- a/packages/@react-aria/breadcrumbs/src/useBreadcrumbItem.ts +++ b/packages/@react-aria/breadcrumbs/src/useBreadcrumbItem.ts @@ -32,7 +32,8 @@ export function useBreadcrumbItem(props: AriaBreadcrumbItemProps, ref: RefObject ...otherProps } = props; - let {linkProps} = useLink({isDisabled: isDisabled || isCurrent, elementType, ...otherProps}, ref); + let {linkProps: linkBaseLinkProps} = useLink({isDisabled: isDisabled || isCurrent, elementType, ...otherProps}, ref); + let linkProps = {...linkBaseLinkProps}; let isHeading = /^h[1-6]$/.test(elementType); let itemProps: DOMAttributes = {}; diff --git a/packages/@react-aria/button/src/useButton.ts b/packages/@react-aria/button/src/useButton.ts index 163b00c05e6..d990e9fc3e9 100644 --- a/packages/@react-aria/button/src/useButton.ts +++ b/packages/@react-aria/button/src/useButton.ts @@ -101,7 +101,8 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< ref }); - let {focusableProps} = useFocusable(props, ref); + let {focusableProps: focusableBaseFocusableProps} = useFocusable(props, ref); + let focusableProps = {...focusableBaseFocusableProps}; if (allowFocusWhenDisabled) { focusableProps.tabIndex = isDisabled ? -1 : focusableProps.tabIndex; } diff --git a/packages/@react-aria/button/src/useToggleButtonGroup.ts b/packages/@react-aria/button/src/useToggleButtonGroup.ts index 3115b1ade3f..3158322dc67 100644 --- a/packages/@react-aria/button/src/useToggleButtonGroup.ts +++ b/packages/@react-aria/button/src/useToggleButtonGroup.ts @@ -77,11 +77,12 @@ export function useToggleButtonGroupItem(props: AriaToggleButtonGroupItemOptions } }; - let {isPressed, isSelected, isDisabled, buttonProps} = useToggleButton({ + let {isPressed, isSelected, isDisabled, buttonProps: toggleButtonProps} = useToggleButton({ ...props, id: undefined, isDisabled: props.isDisabled || state.isDisabled }, toggleState, ref); + let buttonProps = {...toggleButtonProps}; if (state.selectionMode === 'single') { buttonProps.role = 'radio'; buttonProps['aria-checked'] = toggleState.isSelected; diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index 87695d4268d..110c7ee4592 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -22,7 +22,8 @@ import {useRef} from 'react'; * A range calendar displays one or more date grids and allows users to select a contiguous range of dates. */ export function useRangeCalendar(props: AriaRangeCalendarProps, state: RangeCalendarState, ref: RefObject): CalendarAria { - let res = useCalendarBase(props, state); + let {calendarProps: calendarBaseCalendarProps, ...res} = useCalendarBase(props, state); + let calendarProps = {...calendarBaseCalendarProps}; // We need to ignore virtual pointer events from VoiceOver due to these bugs. // https://bugs.webkit.org/show_bug.cgi?id=222627 @@ -62,7 +63,7 @@ export function useRangeCalendar(props: AriaRangeCalendarPr useEvent(windowRef, 'pointerup', endDragging); // Also stop range selection on blur, e.g. tabbing away from the calendar. - res.calendarProps.onBlur = e => { + calendarProps.onBlur = e => { if (!ref.current) { return; } @@ -78,5 +79,5 @@ export function useRangeCalendar(props: AriaRangeCalendarPr } }, {passive: false, capture: true}); - return res; + return {...res, calendarProps}; } diff --git a/packages/@react-aria/collections/src/CollectionBuilder.tsx b/packages/@react-aria/collections/src/CollectionBuilder.tsx index e7585b8ab81..3229ba3245e 100644 --- a/packages/@react-aria/collections/src/CollectionBuilder.tsx +++ b/packages/@react-aria/collections/src/CollectionBuilder.tsx @@ -47,9 +47,16 @@ export function CollectionBuilder>(props: Colle // Otherwise, render a hidden copy of the children so that we can build the collection before constructing the state. // This should always come before the real DOM content so we have built the collection by the time it renders during SSR. + return ( + + {props.children} + + ); +} - // This is fine. CollectionDocumentContext never changes after mounting. - // eslint-disable-next-line react-hooks/rules-of-hooks +function CollectionBuilderInner(props) { + // Otherwise, render a hidden copy of the children so that we can build the collection before constructing the state. + // This should always come before the real DOM content so we have built the collection by the time it renders during SSR. let {collection, document} = useCollectionDocument(props.createCollection); return ( <> @@ -80,7 +87,6 @@ function useSyncExternalStoreFallback(subscribe: (onStoreChange: () => void) // This is read immediately inside the wrapper, which also runs during render. // We just need a ref to avoid invalidating the callback itself, which // would cause React to re-run the callback more than necessary. - // eslint-disable-next-line rulesdir/pure-render isSSRRef.current = isSSR; let getSnapshotWrapper = useCallback(() => { @@ -109,7 +115,7 @@ function useCollectionDocument>(cr return collection; }, [document]); let getServerSnapshot = useCallback(() => { - document.isSSR = true; + document.setSSR(true); return document.getCollection(); }, [document]); let collection = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); @@ -132,8 +138,9 @@ function createCollectionNodeClass(type: string): CollectionNodeClass { function useSSRCollectionNode(CollectionNodeClass: CollectionNodeClass | string, props: object, ref: ForwardedRef, rendered?: any, children?: ReactNode, render?: (node: Node) => ReactElement) { // To prevent breaking change, if CollectionNodeClass is a string, create a CollectionNodeClass using the string as the type - if (typeof CollectionNodeClass === 'string') { - CollectionNodeClass = createCollectionNodeClass(CollectionNodeClass); + let CollectionNodeClassLocal = CollectionNodeClass; + if (typeof CollectionNodeClassLocal === 'string') { + CollectionNodeClassLocal = createCollectionNodeClass(CollectionNodeClassLocal); } // During SSR, portals are not supported, so the collection children will be wrapped in an SSRContext. @@ -142,15 +149,15 @@ function useSSRCollectionNode(CollectionNodeClass: Collection // collection by the time we need to use the collection to render to the real DOM. // After hydration, we switch to client rendering using the portal. let itemRef = useCallback((element: ElementNode | null) => { - element?.setProps(props, ref, CollectionNodeClass, rendered, render); - }, [props, ref, rendered, render, CollectionNodeClass]); + element?.setProps(props, ref, CollectionNodeClassLocal, rendered, render); + }, [props, ref, rendered, render, CollectionNodeClassLocal]); let parentNode = useContext(SSRContext); if (parentNode) { // Guard against double rendering in strict mode. let element = parentNode.ownerDocument.nodesByProps.get(props); if (!element) { - element = parentNode.ownerDocument.createElement(CollectionNodeClass.type); - element.setProps(props, ref, CollectionNodeClass, rendered, render); + element = parentNode.ownerDocument.createElement(CollectionNodeClassLocal.type); + element.setProps(props, ref, CollectionNodeClassLocal, rendered, render); parentNode.appendChild(element); parentNode.ownerDocument.updateCollection(); parentNode.ownerDocument.nodesByProps.set(props, element); @@ -162,7 +169,7 @@ function useSSRCollectionNode(CollectionNodeClass: Collection } // @ts-ignore - return {children}; + return {children}; } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts index dd1dbeb4f36..5b4925b3acf 100644 --- a/packages/@react-aria/collections/src/Document.ts +++ b/packages/@react-aria/collections/src/Document.ts @@ -431,6 +431,10 @@ export class Document = BaseCollection> extend return true; } + setSSR(value: boolean): void { + this.isSSR = value; + } + createElement(type: string): ElementNode { return new ElementNode(type, this); } diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts index 13d56119eef..66fca3cb2bd 100644 --- a/packages/@react-aria/color/src/useColorArea.ts +++ b/packages/@react-aria/color/src/useColorArea.ts @@ -63,7 +63,12 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState) let {direction, locale} = useLocale(); - let [focusedInput, setFocusedInput] = useState<'x' | 'y' | null>(null); + let [focusedInput, _setFocusedInput] = useState<'x' | 'y' | null>(null); + let focusedInputRef = useRef<'x' | 'y' | null>(focusedInput); + let setFocusedInput = useCallback((newFocusedInput: 'x' | 'y' | null) => { + focusedInputRef.current = newFocusedInput; + _setFocusedInput(newFocusedInput); + }, [_setFocusedInput]); let focusInput = useCallback((inputRef:RefObject = inputXRef) => { if (inputRef.current) { focusWithoutScrolling(inputRef.current); @@ -157,8 +162,8 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState) } setValueChangedViaKeyboard(valueChanged); // set the focused input based on which axis has the greater delta - focusedInput = valueChanged && Math.abs(deltaY) > Math.abs(deltaX) ? 'y' : 'x'; - setFocusedInput(focusedInput); + let newFocusedInput = valueChanged && Math.abs(deltaY) > Math.abs(deltaX) ? 'y' as const : 'x' as const; + setFocusedInput(newFocusedInput); } else { currentPosition.current.x += (direction === 'rtl' ? -1 : 1) * deltaX / width ; currentPosition.current.y += deltaY / height; @@ -168,7 +173,7 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState) onMoveEnd() { isOnColorArea.current = false; state.setDragging(false); - let input = focusedInput === 'x' ? inputXRef : inputYRef; + let input = focusedInputRef.current === 'x' ? inputXRef : inputYRef; focusInput(input); } }; diff --git a/packages/@react-aria/color/src/useColorSlider.ts b/packages/@react-aria/color/src/useColorSlider.ts index 804914a983f..c5a19ee10db 100644 --- a/packages/@react-aria/color/src/useColorSlider.ts +++ b/packages/@react-aria/color/src/useColorSlider.ts @@ -55,7 +55,7 @@ export function useColorSlider(props: AriaColorSliderOptions, state: ColorSlider // @ts-ignore - ignore unused incompatible props let {groupProps, trackProps, labelProps, outputProps} = useSlider({...props, 'aria-label': ariaLabel}, state, trackRef); - let {inputProps, thumbProps} = useSliderThumb({ + let {inputProps: sliderInputProps, thumbProps} = useSliderThumb({ index: 0, orientation, isDisabled: props.isDisabled, @@ -64,6 +64,7 @@ export function useColorSlider(props: AriaColorSliderOptions, state: ColorSlider trackRef, inputRef }, state); + let inputProps = {...sliderInputProps}; let value = state.getDisplayColor(); let generateBackground = () => { diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 5a04a465421..a448d7b338e 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -207,7 +207,8 @@ export interface AriaTimeFieldOptions extends AriaTimeField * Each part of a time value is displayed in an individually editable segment. */ export function useTimeField(props: AriaTimeFieldOptions, state: TimeFieldState, ref: RefObject): DateFieldAria { - let res = useDateField(props, state, ref); - res.inputProps.value = state.timeValue?.toString() || ''; - return res; + let {inputProps: dateFieldInputProps, ...res} = useDateField(props, state, ref); + let inputProps = {...dateFieldInputProps}; + inputProps.value = state.timeValue?.toString() || ''; + return {...res, inputProps}; } diff --git a/packages/@react-aria/dnd/src/useDraggableItem.ts b/packages/@react-aria/dnd/src/useDraggableItem.ts index 6d5781ddeaa..f48af63def9 100644 --- a/packages/@react-aria/dnd/src/useDraggableItem.ts +++ b/packages/@react-aria/dnd/src/useDraggableItem.ts @@ -66,7 +66,7 @@ const MESSAGES = { export function useDraggableItem(props: DraggableItemProps, state: DraggableCollectionState): DraggableItemResult { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd'); let isDisabled = state.isDisabled || state.selectionManager.isDisabled(props.key); - let {dragProps, dragButtonProps} = useDrag({ + let {dragProps: draggableDragProps, dragButtonProps} = useDrag({ getItems() { return state.getItems(props.key); }, @@ -88,6 +88,7 @@ export function useDraggableItem(props: DraggableItemProps, state: DraggableColl clearGlobalDnDState(); } }); + let dragProps = {...draggableDragProps}; let item = state.collection.getItem(props.key); let numKeysForDrag = state.getKeysForDrag(props.key).size; diff --git a/packages/@react-aria/form/src/useFormValidation.ts b/packages/@react-aria/form/src/useFormValidation.ts index b4699bf8046..b5663f531e5 100644 --- a/packages/@react-aria/form/src/useFormValidation.ts +++ b/packages/@react-aria/form/src/useFormValidation.ts @@ -11,10 +11,10 @@ */ import {FormValidationState} from '@react-stately/form'; +import {getOwnerDocument, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; import {RefObject, Validation, ValidationResult} from '@react-types/shared'; import {setInteractionModality} from '@react-aria/interactions'; import {useEffect, useRef} from 'react'; -import {useEffectEvent, useLayoutEffect} from '@react-aria/utils'; type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; @@ -84,7 +84,15 @@ export function useFormValidation(props: FormValidationProps, state: FormV return; } - let form = input.form; + // Uses closest and querySelector instead of just the form property to work around a React compiler bug. + // https://github.com/facebook/react/issues/34891 + let form = input.closest('form'); + if (!form) { + let formId = input.getAttribute('form'); + if (formId) { + form = getOwnerDocument(input).querySelector(`#${formId}`) as HTMLFormElement | null; + } + } let reset = form?.reset; if (form) { diff --git a/packages/@react-aria/i18n/src/useDateFormatter.ts b/packages/@react-aria/i18n/src/useDateFormatter.ts index 482ea626dbc..a54b58bc20b 100644 --- a/packages/@react-aria/i18n/src/useDateFormatter.ts +++ b/packages/@react-aria/i18n/src/useDateFormatter.ts @@ -26,9 +26,9 @@ export interface DateFormatterOptions extends Intl.DateTimeFormatOptions { */ export function useDateFormatter(options?: DateFormatterOptions): DateFormatter { // Reuse last options object if it is shallowly equal, which allows the useMemo result to also be reused. - options = useDeepMemo(options ?? {}, isEqual); + let memoizedOptions = useDeepMemo(options ?? {}, isEqual); let {locale} = useLocale(); - return useMemo(() => new DateFormatter(locale, options), [locale, options]); + return useMemo(() => new DateFormatter(locale, memoizedOptions), [locale, memoizedOptions]); } function isEqual(a: DateFormatterOptions, b: DateFormatterOptions) { diff --git a/packages/@react-aria/interactions/src/PressResponder.tsx b/packages/@react-aria/interactions/src/PressResponder.tsx index de0a7c78e66..6578bbd3d29 100644 --- a/packages/@react-aria/interactions/src/PressResponder.tsx +++ b/packages/@react-aria/interactions/src/PressResponder.tsx @@ -37,7 +37,7 @@ React.forwardRef(({children, ...props}: PressResponderProps, ref: ForwardedRef { if (!isRegistered.current) { diff --git a/packages/@react-aria/interactions/src/useFocusable.tsx b/packages/@react-aria/interactions/src/useFocusable.tsx index ac9f81251c6..74ef7c26e22 100644 --- a/packages/@react-aria/interactions/src/useFocusable.tsx +++ b/packages/@react-aria/interactions/src/useFocusable.tsx @@ -37,7 +37,7 @@ export let FocusableContext: React.Context = React function useFocusableContext(ref: RefObject): FocusableContextValue { let context = useContext(FocusableContext) || {}; - useSyncRef(context, ref); + useSyncRef(context.ref, ref); // eslint-disable-next-line let {ref: _, ...otherProps} = context; diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 6dc4fd7f757..331bbc434e3 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -102,7 +102,7 @@ function usePressResponderContext(props: PressHookProps): PressHookProps { props = mergeProps(contextProps, props) as PressHookProps; register(); } - useSyncRef(context, props.ref); + useSyncRef(context.ref, props.ref); return props; } diff --git a/packages/@react-aria/menu/src/useMenuTrigger.ts b/packages/@react-aria/menu/src/useMenuTrigger.ts index 2d2227276a3..b4684b88deb 100644 --- a/packages/@react-aria/menu/src/useMenuTrigger.ts +++ b/packages/@react-aria/menu/src/useMenuTrigger.ts @@ -53,7 +53,8 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge } = props; let menuTriggerId = useId(); - let {triggerProps, overlayProps} = useOverlayTrigger({type}, state, ref); + let {triggerProps: overlayTriggerProps, overlayProps} = useOverlayTrigger({type}, state, ref); + let triggerProps = {...overlayTriggerProps}; let onKeyDown = (e) => { if (isDisabled) { diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index daebc1d3910..bac3640ae0d 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -99,7 +99,7 @@ export function useSelect(props: AriaSele if (state.selectionManager.selectionMode === 'multiple') { return; } - + switch (e.key) { case 'ArrowLeft': { // prevent scrolling containers @@ -124,13 +124,14 @@ export function useSelect(props: AriaSele } }; - let {typeSelectProps} = useTypeSelect({ + let {typeSelectProps: selectTypeSelectProps} = useTypeSelect({ keyboardDelegate: delegate, selectionManager: state.selectionManager, onTypeSelect(key) { state.setSelectedKey(key); } }); + let typeSelectProps = {...selectTypeSelectProps}; let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ diff --git a/packages/@react-aria/slider/test/useSlider.test.js b/packages/@react-aria/slider/test/useSlider.test.js index 7bcaed97e0d..850e97c897e 100644 --- a/packages/@react-aria/slider/test/useSlider.test.js +++ b/packages/@react-aria/slider/test/useSlider.test.js @@ -64,7 +64,9 @@ describe('useSlider', () => { function Example(props) { let trackRef = useRef(null); let state = useSliderState({...props, numberFormatter}); - stateRef.current = state; + React.useEffect(() => { + stateRef.current = state; + }, [state]); let {trackProps} = useSlider(props, state, trackRef); return
; } @@ -182,7 +184,9 @@ describe('useSlider', () => { function Example(props) { let trackRef = useRef(null); let state = useSliderState({...props, numberFormatter}); - stateRef.current = state; + React.useEffect(() => { + stateRef.current = state; + }, [state]); let {trackProps} = useSlider(props, state, trackRef); return
; } diff --git a/packages/@react-aria/slider/test/useSliderThumb.test.js b/packages/@react-aria/slider/test/useSliderThumb.test.js index eefe094d5b6..cfb7fed181a 100644 --- a/packages/@react-aria/slider/test/useSliderThumb.test.js +++ b/packages/@react-aria/slider/test/useSliderThumb.test.js @@ -113,7 +113,9 @@ describe('useSliderThumb', () => { let input0Ref = useRef(null); let input1Ref = useRef(null); let state = useSliderState({...props, numberFormatter}); - stateRef.current = state; + React.useEffect(() => { + stateRef.current = state; + }, [state]); let {trackProps, thumbProps: commonThumbProps} = useSlider(props, state, trackRef); let {inputProps: input0Props, thumbProps: thumb0Props} = useSliderThumb({ ...commonThumbProps, @@ -273,7 +275,9 @@ describe('useSliderThumb', () => { let trackRef = useRef(null); let inputRef = useRef(null); let state = useSliderState({...props, numberFormatter}); - stateRef.current = state; + React.useEffect(() => { + stateRef.current = state; + }, [state]); let {trackProps} = useSlider(props, state, trackRef); let {inputProps, thumbProps} = useSliderThumb({ ...props, diff --git a/packages/@react-aria/spinbutton/src/useSpinButton.ts b/packages/@react-aria/spinbutton/src/useSpinButton.ts index de772fa5106..c1a4e9f5888 100644 --- a/packages/@react-aria/spinbutton/src/useSpinButton.ts +++ b/packages/@react-aria/spinbutton/src/useSpinButton.ts @@ -161,6 +161,8 @@ export function useSpinButton( _async.current = window.setTimeout( () => { if ((maxValue === undefined || isNaN(maxValue)) || (value === undefined || isNaN(value)) || value < maxValue) { + // https://github.com/facebook/react/issues/34888 + // eslint-disable-next-line react-hooks/immutability onIncrementPressStart(60); } }, @@ -178,6 +180,8 @@ export function useSpinButton( _async.current = window.setTimeout( () => { if ((minValue === undefined || isNaN(minValue)) || (value === undefined || isNaN(value)) || value > minValue) { + // https://github.com/facebook/react/issues/34888 + // eslint-disable-next-line react-hooks/immutability onDecrementPressStart(60); } }, diff --git a/packages/@react-aria/ssr/src/SSRContext.tsx b/packages/@react-aria/ssr/src/SSRContext.tsx new file mode 100644 index 00000000000..3f67b680bfa --- /dev/null +++ b/packages/@react-aria/ssr/src/SSRContext.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2020 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. + */ +import React from 'react'; + + +// To support SSR, the auto incrementing id counter is stored in a context. This allows +// it to be reset on every request to ensure the client and server are consistent. +// There is also a prefix string that is used to support async loading components +// Each async boundary must be wrapped in an SSR provider, which appends to the prefix +// and resets the current id counter. This ensures that async loaded components have +// consistent ids regardless of the loading order. +export interface SSRContextValue { + prefix: string, + current: number +} + +// Default context value to use in case there is no SSRProvider. This is fine for +// client-only apps. In order to support multiple copies of React Aria potentially +// being on the page at once, the prefix is set to a random number. SSRProvider +// will reset this to zero for consistency between server and client, so in the +// SSR case multiple copies of React Aria is not supported. +export const defaultContext: SSRContextValue = { + prefix: String(Math.round(Math.random() * 10000000000)), + current: 0 +}; + +export const SSRContext = React.createContext(defaultContext); diff --git a/packages/@react-aria/ssr/src/SSRProvider.tsx b/packages/@react-aria/ssr/src/SSRProvider.tsx index 83e07aa46e6..b8f5606397e 100644 --- a/packages/@react-aria/ssr/src/SSRProvider.tsx +++ b/packages/@react-aria/ssr/src/SSRProvider.tsx @@ -10,33 +10,14 @@ * governing permissions and limitations under the License. */ +import {defaultContext, SSRContext, SSRContextValue} from './SSRContext'; // We must avoid a circular dependency with @react-aria/utils, and this useLayoutEffect is // guarded by a check that it only runs on the client side. // eslint-disable-next-line rulesdir/useLayoutEffectRule -import React, {JSX, ReactNode, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react'; - -// To support SSR, the auto incrementing id counter is stored in a context. This allows -// it to be reset on every request to ensure the client and server are consistent. -// There is also a prefix string that is used to support async loading components -// Each async boundary must be wrapped in an SSR provider, which appends to the prefix -// and resets the current id counter. This ensures that async loaded components have -// consistent ids regardless of the loading order. -interface SSRContextValue { - prefix: string, - current: number -} +import React, {JSX, ReactNode, useContext, useLayoutEffect, useMemo, useState} from 'react'; +import {useCounter} from './useCounter'; + -// Default context value to use in case there is no SSRProvider. This is fine for -// client-only apps. In order to support multiple copies of React Aria potentially -// being on the page at once, the prefix is set to a random number. SSRProvider -// will reset this to zero for consistency between server and client, so in the -// SSR case multiple copies of React Aria is not supported. -const defaultContext: SSRContextValue = { - prefix: String(Math.round(Math.random() * 10000000000)), - current: 0 -}; - -const SSRContext = React.createContext(defaultContext); const IsSSRContext = React.createContext(false); export interface SSRProviderProps { @@ -99,49 +80,6 @@ let canUseDOM = Boolean( window.document.createElement ); -let componentIds = new WeakMap(); - -function useCounter(isDisabled = false) { - let ctx = useContext(SSRContext); - let ref = useRef(null); - // eslint-disable-next-line rulesdir/pure-render - if (ref.current === null && !isDisabled) { - // In strict mode, React renders components twice, and the ref will be reset to null on the second render. - // This means our id counter will be incremented twice instead of once. This is a problem because on the - // server, components are only rendered once and so ids generated on the server won't match the client. - // In React 18, useId was introduced to solve this, but it is not available in older versions. So to solve this - // we need to use some React internals to access the underlying Fiber instance, which is stable between renders. - // This is exposed as ReactCurrentOwner in development, which is all we need since StrictMode only runs in development. - // To ensure that we only increment the global counter once, we store the starting id for this component in - // a weak map associated with the Fiber. On the second render, we reset the global counter to this value. - // Since React runs the second render immediately after the first, this is safe. - // @ts-ignore - let currentOwner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?.ReactCurrentOwner?.current; - if (currentOwner) { - let prevComponentValue = componentIds.get(currentOwner); - if (prevComponentValue == null) { - // On the first render, and first call to useId, store the id and state in our weak map. - componentIds.set(currentOwner, { - id: ctx.current, - state: currentOwner.memoizedState - }); - } else if (currentOwner.memoizedState !== prevComponentValue.state) { - // On the second render, the memoizedState gets reset by React. - // Reset the counter, and remove from the weak map so we don't - // do this for subsequent useId calls. - ctx.current = prevComponentValue.id; - componentIds.delete(currentOwner); - } - } - - // eslint-disable-next-line rulesdir/pure-render - ref.current = ++ctx.current; - } - - // eslint-disable-next-line rulesdir/pure-render - return ref.current; -} - function useLegacySSRSafeId(defaultId?: string): string { let ctx = useContext(SSRContext); @@ -187,7 +125,7 @@ function subscribe(onStoreChange: () => void): () => void { * until after hydration. */ export function useIsSSR(): boolean { - // In React 18, we can use useSyncExternalStore to detect if we're server rendering or hydrating. + // In React 18+, we can use useSyncExternalStore to detect if we're server rendering or hydrating. if (typeof React['useSyncExternalStore'] === 'function') { return React['useSyncExternalStore'](subscribe, getSnapshot, getServerSnapshot); } diff --git a/packages/@react-aria/ssr/src/useCounter.ts b/packages/@react-aria/ssr/src/useCounter.ts new file mode 100644 index 00000000000..39b33c6bcad --- /dev/null +++ b/packages/@react-aria/ssr/src/useCounter.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 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. + */ +import React, {useContext, useRef} from 'react'; +import {SSRContext} from './SSRContext'; + +// Moved this to a separate file to avoid the compiler bailing on the entire file, this is only used in React 16 and 17 + +let componentIds = new WeakMap(); + +export function useCounter(isDisabled = false) { + let ctx = useContext(SSRContext); + let ref = useRef(null); + if (ref.current === null && !isDisabled) { + // In strict mode, React renders components twice, and the ref will be reset to null on the second render. + // This means our id counter will be incremented twice instead of once. This is a problem because on the + // server, components are only rendered once and so ids generated on the server won't match the client. + // In React 18, useId was introduced to solve this, but it is not available in older versions. So to solve this + // we need to use some React internals to access the underlying Fiber instance, which is stable between renders. + // This is exposed as ReactCurrentOwner in development, which is all we need since StrictMode only runs in development. + // To ensure that we only increment the global counter once, we store the starting id for this component in + // a weak map associated with the Fiber. On the second render, we reset the global counter to this value. + // Since React runs the second render immediately after the first, this is safe. + // @ts-ignore + let currentOwner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?.ReactCurrentOwner?.current; + if (currentOwner) { + let prevComponentValue = componentIds.get(currentOwner); + if (prevComponentValue == null) { + // On the first render, and first call to useId, store the id and state in our weak map. + componentIds.set(currentOwner, { + id: ctx.current, + state: currentOwner.memoizedState + }); + } else if (currentOwner.memoizedState !== prevComponentValue.state) { + // On the second render, the memoizedState gets reset by React. + // Reset the counter, and remove from the weak map so we don't + // do this for subsequent useId calls. + // eslint-disable-next-line react-hooks/immutability + ctx.current = prevComponentValue.id; + componentIds.delete(currentOwner); + } + } + + // eslint-disable-next-line react-hooks/immutability + ref.current = ++ctx.current; + } + + return ref.current; +} diff --git a/packages/@react-aria/table/src/useTable.ts b/packages/@react-aria/table/src/useTable.ts index 3ed31be82a7..af48c112432 100644 --- a/packages/@react-aria/table/src/useTable.ts +++ b/packages/@react-aria/table/src/useTable.ts @@ -78,12 +78,14 @@ export function useTable(props: AriaTableProps, state: TableState | TreeGr let id = useId(props.id); gridIds.set(state, id); - let {gridProps} = useGrid({ + let {gridProps: gridPropsBase} = useGrid({ ...props, id, keyboardDelegate: delegate }, state, ref); + let gridProps = {...gridPropsBase}; + // Override to include header rows if (isVirtualized) { gridProps['aria-rowcount'] = state.collection.size + state.collection.headerRows.length; @@ -98,8 +100,7 @@ export function useTable(props: AriaTableProps, state: TableState | TreeGr let sortDescription = useMemo(() => { let columnName = state.collection.columns.find(c => c.key === column)?.textValue ?? ''; return sortDirection && column ? stringFormatter.format(`${sortDirection}Sort`, {columnName}) : undefined; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sortDirection, column, state.collection.columns]); + }, [sortDirection, column, state.collection.columns, stringFormatter]); let descriptionProps = useDescription(sortDescription); diff --git a/packages/@react-aria/table/src/useTableCell.ts b/packages/@react-aria/table/src/useTableCell.ts index 1016cc13297..1159206867b 100644 --- a/packages/@react-aria/table/src/useTableCell.ts +++ b/packages/@react-aria/table/src/useTableCell.ts @@ -45,7 +45,8 @@ export interface TableCellAria { * @param ref - The ref attached to the cell element. */ export function useTableCell(props: AriaTableCellProps, state: TableState, ref: RefObject): TableCellAria { - let {gridCellProps, isPressed} = useGridCell(props, state, ref); + let {gridCellProps: tableGridCellProps, isPressed} = useGridCell(props, state, ref); + let gridCellProps = {...tableGridCellProps}; let columnKey = props.node.column?.key; if (columnKey != null && state.collection.rowHeaderColumnKeys.has(columnKey)) { gridCellProps.role = 'rowheader'; diff --git a/packages/@react-aria/table/src/useTableRow.ts b/packages/@react-aria/table/src/useTableRow.ts index 41556dd6e10..cd72e01c015 100644 --- a/packages/@react-aria/table/src/useTableRow.ts +++ b/packages/@react-aria/table/src/useTableRow.ts @@ -40,7 +40,8 @@ const EXPANSION_KEYS = { */ export function useTableRow(props: GridRowProps, state: TableState | TreeGridState, ref: RefObject): GridRowAria { let {node, isVirtualized} = props; - let {rowProps, ...states} = useGridRow, TableState>(props, state, ref); + let {rowProps: gridRowProps, ...states} = useGridRow, TableState>(props, state, ref); + let rowProps = {...gridRowProps}; let {direction} = useLocale(); if (isVirtualized && !(tableNestedRows() && 'expandedKeys' in state)) { diff --git a/packages/@react-aria/toast/src/useToastRegion.ts b/packages/@react-aria/toast/src/useToastRegion.ts index 1c6c3dbaf84..b2457321e4f 100644 --- a/packages/@react-aria/toast/src/useToastRegion.ts +++ b/packages/@react-aria/toast/src/useToastRegion.ts @@ -65,6 +65,20 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState } }); + let lastFocused = useRef(null); + let {focusWithinProps} = useFocusWithin({ + onFocusWithin: (e) => { + isFocused.current = true; + lastFocused.current = e.relatedTarget as FocusableElement; + updateTimers(); + }, + onBlurWithin: () => { + isFocused.current = false; + lastFocused.current = null; + updateTimers(); + } + }); + // Manage focus within the toast region. // If a focused containing toast is removed, move focus to the next toast, or the previous toast if there is no next toast. let toasts = useRef([]); @@ -135,20 +149,6 @@ export function useToastRegion(props: AriaToastRegionProps, state: ToastState prevVisibleToasts.current = state.visibleToasts; }, [state.visibleToasts, ref]); - let lastFocused = useRef(null); - let {focusWithinProps} = useFocusWithin({ - onFocusWithin: (e) => { - isFocused.current = true; - lastFocused.current = e.relatedTarget as FocusableElement; - updateTimers(); - }, - onBlurWithin: () => { - isFocused.current = false; - lastFocused.current = null; - updateTimers(); - } - }); - // When the number of visible toasts becomes 0 or the region unmounts, // restore focus to the last element that had focus before the user moved focus // into the region. FocusScope restore focus doesn't update whenever the focus diff --git a/packages/@react-aria/toolbar/src/useToolbar.ts b/packages/@react-aria/toolbar/src/useToolbar.ts index b94bb988c57..a5f0af6a77d 100644 --- a/packages/@react-aria/toolbar/src/useToolbar.ts +++ b/packages/@react-aria/toolbar/src/useToolbar.ts @@ -44,12 +44,10 @@ export function useToolbar(props: AriaToolbarProps, ref: RefObject { setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]'))); - }); + }, [ref]); const {direction} = useLocale(); const shouldReverse = direction === 'rtl' && orientation === 'horizontal'; let focusManager = createFocusManager(ref); diff --git a/packages/@react-aria/tree/src/useTree.ts b/packages/@react-aria/tree/src/useTree.ts index 8936e026757..c015fbcff67 100644 --- a/packages/@react-aria/tree/src/useTree.ts +++ b/packages/@react-aria/tree/src/useTree.ts @@ -42,7 +42,8 @@ export interface TreeAria { * @param ref - The ref attached to the treegrid element. */ export function useTree(props: AriaTreeOptions, state: TreeState, ref: RefObject): TreeAria { - let {gridProps} = useGridList(props, state, ref); + let {gridProps: gridListGridProps} = useGridList(props, state, ref); + let gridProps = {...gridListGridProps}; gridProps.role = 'treegrid'; return { diff --git a/packages/@react-aria/utils/src/useDeepMemo.ts b/packages/@react-aria/utils/src/useDeepMemo.ts index d0f7e144579..2542f2e7b76 100644 --- a/packages/@react-aria/utils/src/useDeepMemo.ts +++ b/packages/@react-aria/utils/src/useDeepMemo.ts @@ -10,8 +10,6 @@ * governing permissions and limitations under the License. */ -/* eslint-disable rulesdir/pure-render */ - import {useRef} from 'react'; export function useDeepMemo(value: T, isEqual: (a: T, b: T) => boolean): T { diff --git a/packages/@react-aria/utils/src/useDrag1D.ts b/packages/@react-aria/utils/src/useDrag1D.ts index e907128c9b3..618cdf0c61e 100644 --- a/packages/@react-aria/utils/src/useDrag1D.ts +++ b/packages/@react-aria/utils/src/useDrag1D.ts @@ -10,8 +10,6 @@ * governing permissions and limitations under the License. */ - /* eslint-disable rulesdir/pure-render */ - import {getOffset} from './getOffset'; import {Orientation} from '@react-types/shared'; import React, {HTMLAttributes, MutableRefObject, useRef} from 'react'; diff --git a/packages/@react-aria/utils/src/useSyncRef.ts b/packages/@react-aria/utils/src/useSyncRef.ts index 5bbc2389325..38866573e0f 100644 --- a/packages/@react-aria/utils/src/useSyncRef.ts +++ b/packages/@react-aria/utils/src/useSyncRef.ts @@ -10,22 +10,17 @@ * governing permissions and limitations under the License. */ -import {MutableRefObject} from 'react'; import {RefObject} from '@react-types/shared'; import {useLayoutEffect} from './'; -interface ContextValue { - ref?: MutableRefObject -} - // Syncs ref from context with ref passed to hook -export function useSyncRef(context?: ContextValue | null, ref?: RefObject): void { +export function useSyncRef(contextRef?: RefObject | null, ref?: RefObject): void { useLayoutEffect(() => { - if (context && context.ref && ref) { - context.ref.current = ref.current; + if (contextRef && ref) { + contextRef.current = ref.current; return () => { - if (context.ref) { - context.ref.current = null; + if (contextRef) { + contextRef.current = null; } }; } diff --git a/packages/@react-aria/visually-hidden/src/VisuallyHidden.tsx b/packages/@react-aria/visually-hidden/src/VisuallyHidden.tsx index 35fd53580ec..a5fd5c8dd36 100644 --- a/packages/@react-aria/visually-hidden/src/VisuallyHidden.tsx +++ b/packages/@react-aria/visually-hidden/src/VisuallyHidden.tsx @@ -71,8 +71,7 @@ export function useVisuallyHidden(props: VisuallyHiddenProps = {}): VisuallyHidd } else { return styles; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isFocused]); + }, [isFocused, style]); return { visuallyHiddenProps: { diff --git a/packages/@react-spectrum/actiongroup/src/ActionGroup.tsx b/packages/@react-spectrum/actiongroup/src/ActionGroup.tsx index 865a651eaba..9bf9d5ba3d7 100644 --- a/packages/@react-spectrum/actiongroup/src/ActionGroup.tsx +++ b/packages/@react-spectrum/actiongroup/src/ActionGroup.tsx @@ -377,9 +377,10 @@ function ActionGroupMenu({state, isDisabled, isEmphasized, staticColor, items // Use the key of the first item within the menu as the key of the button. // The key must actually exist in the collection for focus to work correctly. let key = items[0].key; - let {buttonProps} = useActionGroupItem({key}, state); + let {buttonProps: actionGroupItemButtonProps} = useActionGroupItem({key}, state); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/actiongroup'); + let buttonProps = {...actionGroupItemButtonProps}; // The menu button shouldn't act like an actual action group item. delete buttonProps.onPress; delete buttonProps.role; diff --git a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx index 242a1e0c91b..93fffd12c99 100644 --- a/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx +++ b/packages/@react-spectrum/autocomplete/src/MobileSearchAutocomplete.tsx @@ -53,7 +53,7 @@ import {useProviderProps} from '@react-spectrum/provider'; import {useSearchAutocomplete} from '@react-aria/autocomplete'; function ForwardMobileSearchAutocomplete(props: SpectrumSearchAutocompleteProps, ref: FocusableRef) { - props = useProviderProps(props); + let allProps = useProviderProps(props); let { isQuiet, @@ -64,11 +64,11 @@ function ForwardMobileSearchAutocomplete(props: SpectrumSearch name, isReadOnly, onSubmit = () => {} - } = props; + } = allProps; let {contains} = useFilter({sensitivity: 'base'}); let state = useComboBoxState({ - ...props, + ...allProps, defaultFilter: contains, allowsEmptyCollection: true, // Needs to be false here otherwise we double up on commitSelection/commitCustomValue calls when @@ -88,25 +88,28 @@ function ForwardMobileSearchAutocomplete(props: SpectrumSearch let inputRef = useRef(null); useFormValidation({ - ...props, + ...allProps, focus: () => buttonRef.current?.focus() }, state, inputRef); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let validationState = props.validationState || (isInvalid ? 'invalid' : undefined); - let errorMessage = props.errorMessage ?? validationErrors.join(' '); + let validationState = allProps.validationState || (isInvalid ? 'invalid' : undefined); + let errorMessage = allProps.errorMessage ?? validationErrors.join(' '); - let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ - ...props, + let {labelProps: fieldLabelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + ...allProps, labelElementType: 'span', isInvalid, errorMessage }); - // Focus the button and show focus ring when clicking on the label - labelProps.onClick = () => { - if (!props.isDisabled && buttonRef.current) { - buttonRef.current.focus(); - setInteractionModality('keyboard'); + let labelProps = { + ...fieldLabelProps, + // Focus the button and show focus ring when clicking on the label + onClick: () => { + if (!allProps.isDisabled && buttonRef.current) { + buttonRef.current.focus(); + setInteractionModality('keyboard'); + } } }; @@ -131,7 +134,7 @@ function ForwardMobileSearchAutocomplete(props: SpectrumSearch return ( <> (props: SpectrumSearch inputValue={state.inputValue} clearInput={() => state.setInputValue('')} onPress={() => !isReadOnly && state.open(null, 'manual')}> - {state.inputValue || props.placeholder || ''} + {state.inputValue || allProps.placeholder || ''} @@ -435,9 +438,10 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { // VoiceOver on iOS reads "double tap to collapse" when focused on the input rather than // "double tap to edit text", as with a textbox or searchbox. We'd like double tapping to // open the virtual keyboard rather than closing the tray. - inputProps.role = 'searchbox'; - inputProps['aria-haspopup'] = 'listbox'; - delete inputProps.onTouchEnd; + let newInputProps = {...inputProps}; + newInputProps.role = 'searchbox'; + newInputProps['aria-haspopup'] = 'listbox'; + delete newInputProps.onTouchEnd; let clearButton = ( (props: SearchAutocompleteTrayProps) { } }, [inputRef, popoverRef, isTouchDown]); - let inputValue = inputProps.value; + let inputValue = newInputProps.value; let lastInputValue = useRef(inputValue); useEffect(() => { if (loadingState === 'filtering' && !showLoading) { @@ -510,6 +514,8 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { } } else if (loadingState !== 'filtering') { // If loading is no longer happening, clear any timers and hide the loading circle + // ignoring error because this is an old and dead component + // eslint-disable-next-line react-hooks/set-state-in-effect setShowLoading(false); if (timeout.current !== null) { clearTimeout(timeout.current); @@ -531,8 +537,8 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { onSubmit(inputValue == null ? null : inputValue.toString(), null); } } else { - if (inputProps.onKeyDown) { - inputProps.onKeyDown(e); + if (newInputProps.onKeyDown) { + newInputProps.onKeyDown(e); } } }; @@ -562,7 +568,7 @@ function SearchAutocompleteTray(props: SearchAutocompleteTrayProps) { (props: SpectrumSearchAutocompleteProps, ref: FocusableRef) { - props = useProviderProps(props); - props = useFormProps(props); + let propsWithProvider = useProviderProps(props); + let allProps = useFormProps(propsWithProvider); let hasWarned = useRef(false); useEffect(() => { - if (props.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { + if (allProps.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { console.warn('Placeholders are deprecated due to accessibility issues. Please use help text instead.'); hasWarned.current = true; } - }, [props.placeholder]); + }, [allProps.placeholder]); let isMobile = useIsMobileDevice(); if (isMobile) { // menuTrigger=focus/manual don't apply to mobile searchwithin - return ; + return ; } else { - return ; + return ; } } diff --git a/packages/@react-spectrum/buttongroup/src/ButtonGroup.tsx b/packages/@react-spectrum/buttongroup/src/ButtonGroup.tsx index f01e328bb8f..d0bcf79dc4d 100644 --- a/packages/@react-spectrum/buttongroup/src/ButtonGroup.tsx +++ b/packages/@react-spectrum/buttongroup/src/ButtonGroup.tsx @@ -67,14 +67,13 @@ export const ButtonGroup = React.forwardRef(function ButtonGroup(props: Spectrum yield computeHasOverflow(); }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [domRef, orientation, scale, setHasOverflow, children]); + }, [domRef, orientation, setHasOverflow]); // There are two main reasons we need to remeasure: // 1. Internal changes: Check for initial overflow or when orientation/scale/children change (from checkForOverflow dep array) useLayoutEffect(() => { checkForOverflow(); - }, [checkForOverflow]); + }, [checkForOverflow, children, scale]); // 2. External changes: buttongroup won't change size due to any parents changing size, so listen to its container for size changes to figure out if we should remeasure let parent = useRef(undefined); @@ -82,8 +81,7 @@ export const ButtonGroup = React.forwardRef(function ButtonGroup(props: Spectrum if (domRef.current) { parent.current = domRef.current.parentElement as HTMLElement; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [domRef.current]); + }, [domRef]); useResizeObserver({ref: parent, onResize: checkForOverflow}); return ( diff --git a/packages/@react-spectrum/buttongroup/test/ButtonGroup.test.js b/packages/@react-spectrum/buttongroup/test/ButtonGroup.test.js index 43131e96193..4c9e01e0363 100644 --- a/packages/@react-spectrum/buttongroup/test/ButtonGroup.test.js +++ b/packages/@react-spectrum/buttongroup/test/ButtonGroup.test.js @@ -158,15 +158,15 @@ function ButtonGroupWithRefs(props) { let button1 = useRef(); let button2 = useRef(); let button3 = useRef(); + let {setUp} = props; useEffect(() => { - props.setUp({ + setUp({ buttonGroup: buttonGroup.current.UNSAFE_getDOMNode(), button1: button1.current.UNSAFE_getDOMNode(), button2: button2.current.UNSAFE_getDOMNode(), button3: button3.current.UNSAFE_getDOMNode() }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [setUp]); return ( diff --git a/packages/@react-spectrum/card/src/CardView.tsx b/packages/@react-spectrum/card/src/CardView.tsx index e060a2e84a4..07e47c00efc 100644 --- a/packages/@react-spectrum/card/src/CardView.tsx +++ b/packages/@react-spectrum/card/src/CardView.tsx @@ -80,7 +80,9 @@ export const CardView = React.forwardRef(function CardView(pro focusMode: 'cell' }); + // eslint-disable-next-line react-hooks/immutability cardViewLayout.collection = gridCollection; + // eslint-disable-next-line react-hooks/immutability cardViewLayout.disabledKeys = state.disabledKeys; let {gridProps} = useGrid({ @@ -230,15 +232,16 @@ function InternalCard(props) { cardOrientation = 'vertical'; } + let newGridCellProps = {...gridCellProps}; // We don't want to focus the checkbox (or any other focusable elements) within the Card // when pressing the arrow keys so we delete the key down handler here. Arrow key navigation between // the cards in the CardView is handled by useGrid => useSelectableCollection instead. - delete gridCellProps.onKeyDownCapture; + delete newGridCellProps.onKeyDownCapture; return (
) { + let groupState = useContext(CheckboxGroupContext); + if (groupState) { + return ; + } + return ; +}); + +let CheckboxInGroup = forwardRef(function CheckboxInGroup(props: SpectrumCheckboxProps, ref: FocusableRef) { let originalProps = props; let inputRef = useRef(null); let domRef = useFocusableRef(ref, inputRef); + let groupState = useContext(CheckboxGroupContext); [props, domRef] = useContextProps(props, domRef, CheckboxContext); props = useProviderProps(props); @@ -46,27 +55,18 @@ export const Checkbox = forwardRef(function Checkbox(props: SpectrumCheckboxProp ...otherProps } = props; let {styleProps} = useStyleProps(otherProps); - - // Swap hooks depending on whether this checkbox is inside a CheckboxGroup. - // This is a bit unorthodox. Typically, hooks cannot be called in a conditional, - // but since the checkbox won't move in and out of a group, it should be safe. - let groupState = useContext(CheckboxGroupContext); - let {inputProps, isInvalid, isDisabled} = groupState - // eslint-disable-next-line react-hooks/rules-of-hooks - ? useCheckboxGroupItem({ - ...props, - // Value is optional for standalone checkboxes, but required for CheckboxGroup items; - // it's passed explicitly here to avoid typescript error (requires ignore). - // @ts-ignore - value: props.value, - // Only pass isRequired and validationState to react-aria if they came from - // the props for this individual checkbox, and not from the group via context. - isRequired: originalProps.isRequired, - validationState: originalProps.validationState, - isInvalid: originalProps.isInvalid - }, groupState, inputRef) - // eslint-disable-next-line react-hooks/rules-of-hooks - : useCheckbox(props, useToggleState(props), inputRef); + let {inputProps, isInvalid, isDisabled} = useCheckboxGroupItem({ + ...props, + // Value is optional for standalone checkboxes, but required for CheckboxGroup items; + // it's passed explicitly here to avoid typescript error (requires ignore). + // @ts-ignore + value: props.value, + // Only pass isRequired and validationState to react-aria if they came from + // the props for this individual checkbox, and not from the group via context. + isRequired: originalProps.isRequired, + validationState: originalProps.validationState, + isInvalid: originalProps.isInvalid + }, groupState, inputRef); let {hoverProps, isHovered} = useHover({isDisabled}); @@ -74,7 +74,7 @@ export const Checkbox = forwardRef(function Checkbox(props: SpectrumCheckboxProp ? : ; - if (groupState && process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== 'production') { for (let key of ['isSelected', 'defaultSelected', 'isEmphasized']) { if (originalProps[key] != null) { console.warn(`${key} is unsupported on individual elements within a . Please apply these props to the group instead.`); @@ -120,3 +120,63 @@ export const Checkbox = forwardRef(function Checkbox(props: SpectrumCheckboxProp ); }); + +let CheckboxStandalone = forwardRef(function CheckboxStandalone(props: SpectrumCheckboxProps, ref: FocusableRef) { + let inputRef = useRef(null); + let domRef = useFocusableRef(ref, inputRef); + + [props, domRef] = useContextProps(props, domRef, CheckboxContext); + props = useProviderProps(props); + props = useFormProps(props); + let { + isIndeterminate = false, + isEmphasized = false, + autoFocus, + children, + ...otherProps + } = props; + let {styleProps} = useStyleProps(otherProps); + + let {inputProps, isInvalid, isDisabled} = useCheckbox(props, useToggleState(props), inputRef); + + let {hoverProps, isHovered} = useHover({isDisabled}); + + let markIcon = isIndeterminate + ? + : ; + + return ( + + ); +}); diff --git a/packages/@react-spectrum/color/src/ColorField.tsx b/packages/@react-spectrum/color/src/ColorField.tsx index 9cbdfba2618..4ea00a4fe94 100644 --- a/packages/@react-spectrum/color/src/ColorField.tsx +++ b/packages/@react-spectrum/color/src/ColorField.tsx @@ -27,22 +27,22 @@ import {useProviderProps} from '@react-spectrum/provider'; * A color field allows users to edit a hex color or individual color channel value. */ export const ColorField = React.forwardRef(function ColorField(props: SpectrumColorFieldProps, ref: Ref) { - props = useProviderProps(props); - props = useFormProps(props); - [props] = useContextProps(props, null, ColorFieldContext); - + let propsWithProvider = useProviderProps(props); + let propsWithForm = useFormProps(propsWithProvider); + let [allProps] = useContextProps(propsWithForm, null, ColorFieldContext); + let hasWarned = useRef(false); useEffect(() => { - if (props.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { + if (allProps.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { console.warn('Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/ColorField.html#help-text'); hasWarned.current = true; } - }, [props.placeholder]); + }, [allProps.placeholder]); - if (props.channel) { - return ; + if (allProps.channel) { + return ; } else { - return ; + return ; } }); diff --git a/packages/@react-spectrum/combobox/src/ComboBox.tsx b/packages/@react-spectrum/combobox/src/ComboBox.tsx index 70e78af304a..460833c6d30 100644 --- a/packages/@react-spectrum/combobox/src/ComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/ComboBox.tsx @@ -57,23 +57,23 @@ import {useProvider, useProviderProps} from '@react-spectrum/provider'; * ComboBoxes combine a text entry with a picker menu, allowing users to filter longer lists to only the selections matching a query. */ export const ComboBox = React.forwardRef(function ComboBox(props: SpectrumComboBoxProps, ref: FocusableRef) { - props = useProviderProps(props); - props = useFormProps(props); + let propsWithProvider = useProviderProps(props); + let allProps = useFormProps(propsWithProvider); let hasWarned = useRef(false); useEffect(() => { - if (props.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { + if (allProps.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { console.warn('Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/ComboBox.html#help-text'); hasWarned.current = true; } - }, [props.placeholder]); + }, [allProps.placeholder]); let isMobile = useIsMobileDevice(); if (isMobile) { // menuTrigger=focus/manual don't apply to mobile combobox - return ; + return ; } else { - return ; + return ; } }) as (props: SpectrumComboBoxProps & {ref?: FocusableRef}) => ReactElement; diff --git a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx index 5954ab301a3..a2c953e0295 100644 --- a/packages/@react-spectrum/combobox/src/MobileComboBox.tsx +++ b/packages/@react-spectrum/combobox/src/MobileComboBox.tsx @@ -46,7 +46,7 @@ import {useFormValidation} from '@react-aria/form'; import {useProviderProps} from '@react-spectrum/provider'; export const MobileComboBox = React.forwardRef(function MobileComboBox(props: SpectrumComboBoxProps, ref: FocusableRef) { - props = useProviderProps(props); + let allProps = useProviderProps(props); let { isQuiet, @@ -57,14 +57,14 @@ export const MobileComboBox = React.forwardRef(function MobileComboBox(props: Sp name, formValue = 'text', allowsCustomValue - } = props; + } = allProps; if (allowsCustomValue) { formValue = 'text'; } let {contains} = useFilter({sensitivity: 'base'}); let state = useComboBoxState({ - ...props, + ...allProps, defaultFilter: contains, allowsEmptyCollection: true, // Needs to be false here otherwise we double up on commitSelection/commitCustomValue calls when @@ -79,25 +79,28 @@ export const MobileComboBox = React.forwardRef(function MobileComboBox(props: Sp let inputRef = useRef(null); useFormValidation({ - ...props, + ...allProps, focus: () => buttonRef.current?.focus() }, state, inputRef); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let validationState = props.validationState || (isInvalid ? 'invalid' : undefined); - let errorMessage = props.errorMessage ?? validationErrors.join(' '); + let validationState = allProps.validationState || (isInvalid ? 'invalid' : undefined); + let errorMessage = allProps.errorMessage ?? validationErrors.join(' '); - let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ - ...props, + let {labelProps: fieldLabelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + ...allProps, labelElementType: 'span', isInvalid, errorMessage }); - // Focus the button and show focus ring when clicking on the label - labelProps.onClick = () => { - if (!props.isDisabled) { - buttonRef.current?.focus(); - setInteractionModality('keyboard'); + let labelProps = { + ...fieldLabelProps, + onClick: () => { + // Focus the button and show focus ring when clicking on the label + if (!allProps.isDisabled) { + buttonRef.current?.focus(); + setInteractionModality('keyboard'); + } } }; @@ -126,7 +129,7 @@ export const MobileComboBox = React.forwardRef(function MobileComboBox(props: Sp return ( <> !isReadOnly && state.open(null, 'manual')}> - {state.inputValue || props.placeholder || ''} + {state.inputValue || allProps.placeholder || ''} @@ -385,10 +388,11 @@ function ComboBoxTray(props: ComboBoxTrayProps) { // "double tap to edit text", as with a textbox or searchbox. We'd like double tapping to // open the virtual keyboard rather than closing the tray. // Unlike "combobox", "aria-expanded" is not a valid attribute on "searchbox". - inputProps.role = 'searchbox'; - inputProps['aria-haspopup'] = 'listbox'; - delete inputProps['aria-expanded']; - delete inputProps.onTouchEnd; + let newInputProps = {...inputProps}; + newInputProps.role = 'searchbox'; + newInputProps['aria-haspopup'] = 'listbox'; + delete newInputProps['aria-expanded']; + delete newInputProps.onTouchEnd; let clearButton = ( { if (loadingState === 'filtering' && !showLoading) { @@ -477,7 +483,7 @@ function ComboBoxTray(props: ComboBoxTrayProps) { if (e.key === 'Enter' && state.selectionManager.focusedKey == null) { popoverRef.current?.focus(); } else { - inputProps.onKeyDown?.(e); + newInputProps.onKeyDown?.(e); } }; @@ -496,7 +502,7 @@ function ComboBoxTray(props: ComboBoxTrayProps) { (props: SpectrumDateRangePickerProps, ref: FocusableRef) { - props = useProviderProps(props); - props = useFormProps(props); + let propsWithProvider = useProviderProps(props); + let allProps = useFormProps(propsWithProvider); let { isQuiet, isDisabled, autoFocus, placeholderValue, maxVisibleMonths = 1 - } = props; + } = allProps; let {hoverProps, isHovered} = useHover({isDisabled}); let targetRef = useRef(null); let state = useDateRangePickerState({ - ...props, + ...allProps, shouldCloseOnSelect: () => !state.hasTime }); let {labelProps, groupProps, buttonProps, dialogProps, startFieldProps, endFieldProps, descriptionProps, errorMessageProps, calendarProps, isInvalid, validationErrors, validationDetails} = useDateRangePicker(props, state, targetRef); @@ -100,15 +100,16 @@ export const DateRangePicker = React.forwardRef(function DateRangePicker + shouldFlip={allProps.shouldFlip}> {showTimeField && @@ -199,8 +200,8 @@ export const DateRangePicker = React.forwardRef(function DateRangePicker } diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js index cf027ab73ad..7ba9f608861 100644 --- a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js @@ -626,9 +626,9 @@ describe('DateRangePicker', function () { } else { let localTime = today(getLocalTimeZone()); expect(onChange).toHaveBeenCalledTimes(1); - + expectPlaceholder(startDate, `${localTime.month}/1/${localTime.year}, 12:00 AM`); - + expectPlaceholder(endDate, `${localTime.month}/2/${localTime.year}, 12:00 AM`); } @@ -1528,7 +1528,7 @@ describe('DateRangePicker', function () { it('resets to defaultValue when submitting form action', async () => { function Test() { const [value, formAction] = React.useActionState(() => ({start: new CalendarDate(2025, 2, 3), end: new CalendarDate(2025, 4, 8)}), {start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2022, 4, 8)}); - + return (
@@ -1536,13 +1536,13 @@ describe('DateRangePicker', function () { ); } - + let {getByTestId} = render(); let start = document.querySelector('input[name=start]'); let end = document.querySelector('input[name=end]'); expect(start).toHaveValue('2020-02-03'); expect(end).toHaveValue('2022-04-08'); - + let button = getByTestId('submit'); await user.click(button); expect(start).toHaveValue('2025-02-03'); diff --git a/packages/@react-spectrum/dialog/src/Dialog.tsx b/packages/@react-spectrum/dialog/src/Dialog.tsx index 70adf576081..afeff9002fa 100644 --- a/packages/@react-spectrum/dialog/src/Dialog.tsx +++ b/packages/@react-spectrum/dialog/src/Dialog.tsx @@ -82,8 +82,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp content: {UNSAFE_className: styles['spectrum-Dialog-content']}, footer: {UNSAFE_className: styles['spectrum-Dialog-footer']}, buttonGroup: {UNSAFE_className: classNames(styles, 'spectrum-Dialog-buttonGroup', {'spectrum-Dialog-buttonGroup--noFooter': !hasFooter}), align: 'end'} - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [hasFooter, hasHeader, titleProps]); + }), [hasFooter, hasHeader, titleProps, hasTypeIcon, hasHeading]); return (
isExiting.current = true; let onExited = () => isExiting.current = false; - + useEffect(() => { return () => { if ((wasOpen.current || isExiting.current) && type !== 'popover' && type !== 'tray' && process.env.NODE_ENV !== 'production') { console.warn('A DialogTrigger unmounted while open. This is likely due to being placed within a trigger that unmounts or inside a conditional. Consider using a DialogContainer instead.'); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [type]); if (type === 'popover') { return ( diff --git a/packages/@react-spectrum/layout/src/Grid.tsx b/packages/@react-spectrum/layout/src/Grid.tsx index b09a89705a4..fdbabb0eb7f 100644 --- a/packages/@react-spectrum/layout/src/Grid.tsx +++ b/packages/@react-spectrum/layout/src/Grid.tsx @@ -50,13 +50,14 @@ export const Grid = forwardRef(function Grid(props: GridProps, ref: DOMRef +
{children}
); diff --git a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx index b51d3580b4d..6bfece2a931 100644 --- a/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx +++ b/packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx @@ -104,9 +104,10 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem }, 220); // Matches transition duration }; + let newSubmenuTriggerProps = {...submenuTriggerProps}; if (isMobile) { - delete submenuTriggerProps.onBlur; - delete submenuTriggerProps.onHoverChange; + delete newSubmenuTriggerProps.onBlur; + delete newSubmenuTriggerProps.onHoverChange; if (trayContainerRef.current && submenuTriggerState.isOpen) { let subDialogKeyDown: KeyboardEventHandler = (e) => { switch (e.key) { @@ -160,7 +161,7 @@ function ContextualHelpTrigger(props: InternalMenuDialogTriggerProps): ReactElem return ( <> - {trigger} + {trigger} {submenuTriggerState.isOpen && overlay} diff --git a/packages/@react-spectrum/menu/src/Menu.tsx b/packages/@react-spectrum/menu/src/Menu.tsx index 9fbb712d51b..804f5597fce 100644 --- a/packages/@react-spectrum/menu/src/Menu.tsx +++ b/packages/@react-spectrum/menu/src/Menu.tsx @@ -52,7 +52,7 @@ export const Menu = React.forwardRef(function Menu(props: Spec let submenuRef = useRef(null); let {menuProps} = useMenu(completeProps, state, domRef); let {styleProps} = useStyleProps(completeProps); - useSyncRef(contextProps, domRef); + useSyncRef(contextProps.ref, domRef); let [leftOffset, setLeftOffset] = useState({left: 0}); let prevPopoverContainer = useRef(null); diff --git a/packages/@react-spectrum/menu/src/MenuItem.tsx b/packages/@react-spectrum/menu/src/MenuItem.tsx index 519e514787d..118a969548c 100644 --- a/packages/@react-spectrum/menu/src/MenuItem.tsx +++ b/packages/@react-spectrum/menu/src/MenuItem.tsx @@ -92,9 +92,10 @@ export function MenuItem(props: MenuItemProps): JSX.Element { ); let endId = useSlotId(); let endProps: DOMAttributes = {}; + let newMenuItemProps = {...menuItemProps}; if (endId) { endProps.id = endId; - menuItemProps['aria-describedby'] = [menuItemProps['aria-describedby'], endId].filter(Boolean).join(' '); + newMenuItemProps['aria-describedby'] = [menuItemProps['aria-describedby'], endId].filter(Boolean).join(' '); } let contents = typeof rendered === 'string' @@ -104,7 +105,7 @@ export function MenuItem(props: MenuItemProps): JSX.Element { return ( ) { +export const RangeSlider = /*#__PURE__*/ forwardRef(function RangeSlider(props: RangeSliderProps, outerRef: FocusableRef) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); - [props, ref] = useSpectrumContextProps(props, ref, RangeSliderContext); + let [propsWithContext, ref] = useSpectrumContextProps(props, outerRef, RangeSliderContext); let formContext = useContext(FormContext); - props = useFormProps(props); + let allProps = useFormProps(propsWithContext); let { labelPosition = 'top', size = 'M', isEmphasized, trackStyle = 'thin', thumbStyle = 'default' - } = props; + } = allProps; let lowerThumbRef = useRef(null); let upperThumbRef = useRef(null); let inputRef = useRef(null); // TODO: need to pass inputRef to SliderThumb when we release the next version of RAC 1.3.0 @@ -68,20 +68,20 @@ export const RangeSlider = /*#__PURE__*/ forwardRef(function RangeSlider(props: let {direction} = useLocale(); let cssDirection = direction === 'rtl' ? 'right' : 'left'; let defaultThumbValues: number[] | undefined = undefined; - if (props.defaultValue != null) { - defaultThumbValues = [props.defaultValue.start, props.defaultValue.end]; - } else if (props.value == null) { + if (allProps.defaultValue != null) { + defaultThumbValues = [allProps.defaultValue.start, allProps.defaultValue.end]; + } else if (allProps.value == null) { // make sure that useSliderState knows we have two handles - defaultThumbValues = [props.minValue ?? 0, props.maxValue ?? 100]; + defaultThumbValues = [allProps.minValue ?? 0, allProps.maxValue ?? 100]; } return ( props.onChange?.({start: v[0], end: v[1]})} - onChangeEnd={v => props.onChangeEnd?.({start: v[0], end: v[1]})} + onChange={v => allProps.onChange?.({start: v[0], end: v[1]})} + onChangeEnd={v => allProps.onChangeEnd?.({start: v[0], end: v[1]})} sliderRef={domRef}> @@ -97,8 +97,8 @@ export const RangeSlider = /*#__PURE__*/ forwardRef(function RangeSlider(props: pressScale(lowerThumbRef, { @@ -119,8 +119,8 @@ export const RangeSlider = /*#__PURE__*/ forwardRef(function RangeSlider(props: pressScale(upperThumbRef, { diff --git a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx index e102ac19515..405c33406e4 100644 --- a/packages/@react-spectrum/s2/test/EditableTableView.test.tsx +++ b/packages/@react-spectrum/s2/test/EditableTableView.test.tsx @@ -130,11 +130,11 @@ describe('TableView', () => { let [editableItems, setEditableItems] = useState(defaultItems); let intermediateValue = useRef(null); + let currentRequests = useRef}>>(new Map()); let saveItem = useCallback((id: Key, columnId: Key) => { setEditableItems(prev => prev.map(i => i.id === id ? {...i, isSaving: {...i.isSaving, [columnId]: false}} : i)); currentRequests.current.delete(id); }, []); - let currentRequests = useRef}>>(new Map()); let onChange = useCallback((id: Key, columnId: Key) => { let value = intermediateValue.current; if (value === null) { diff --git a/packages/@react-spectrum/slider/src/Slider.tsx b/packages/@react-spectrum/slider/src/Slider.tsx index fa531df2a6b..f8086797121 100644 --- a/packages/@react-spectrum/slider/src/Slider.tsx +++ b/packages/@react-spectrum/slider/src/Slider.tsx @@ -54,7 +54,7 @@ export const Slider = React.forwardRef(function Slider(props: SpectrumSliderProp {'--spectrum-slider-track-gradient': trackGradient && `linear-gradient(to ${direction === 'ltr' ? 'right' : 'left'}, ${trackGradient.join(', ')})`} }> {({trackRef, inputRef, state}: SliderBaseChildArguments) => { - fillOffset = fillOffset != null ? clamp(fillOffset, state.getThumbMinValue(0), state.getThumbMaxValue(0)) : fillOffset; + let newFillOffset = fillOffset != null ? clamp(fillOffset, state.getThumbMinValue(0), state.getThumbMaxValue(0)) : fillOffset; let cssDirection = direction === 'rtl' ? 'right' : 'left'; let lowerTrack = ( @@ -84,10 +84,10 @@ export const Slider = React.forwardRef(function Slider(props: SpectrumSliderProp ); let filledTrack: ReactNode = null; - if (isFilled && fillOffset != null) { - let width = state.getThumbPercent(0) - state.getValuePercent(fillOffset); + if (isFilled && newFillOffset != null) { + let width = state.getThumbPercent(0) - state.getValuePercent(newFillOffset); let isRightOfOffset = width > 0; - let offset = isRightOfOffset ? state.getValuePercent(fillOffset) : state.getThumbPercent(0); + let offset = isRightOfOffset ? state.getValuePercent(newFillOffset) : state.getThumbPercent(0); filledTrack = (
-
+
{props.children}
diff --git a/packages/@react-spectrum/textfield/src/TextArea.tsx b/packages/@react-spectrum/textfield/src/TextArea.tsx index 2424823560b..0b625e899c0 100644 --- a/packages/@react-spectrum/textfield/src/TextArea.tsx +++ b/packages/@react-spectrum/textfield/src/TextArea.tsx @@ -25,8 +25,8 @@ import {useTextField} from '@react-aria/textfield'; * are available to text fields. */ export const TextArea = React.forwardRef(function TextArea(props: SpectrumTextAreaProps, ref: Ref>) { - props = useProviderProps(props); - props = useFormProps(props); + let propsWithProvider = useProviderProps(props); + let allProps = useFormProps(propsWithProvider); let { isDisabled = false, isQuiet = false, @@ -34,16 +34,16 @@ export const TextArea = React.forwardRef(function TextArea(props: SpectrumTextAr isRequired = false, onChange, ...otherProps - } = props; + } = allProps; // not in stately because this is so we know when to re-measure, which is a spectrum design - let [inputValue, setInputValue] = useControlledState(props.value, props.defaultValue ?? '', () => {}); + let [inputValue, setInputValue] = useControlledState(allProps.value, allProps.defaultValue ?? '', () => {}); let inputRef = useRef(null); let onHeightChange = useCallback(() => { // Quiet textareas always grow based on their text content. // Standard textareas also grow by default, unless an explicit height is set. - if ((isQuiet || !props.height) && inputRef.current) { + if ((isQuiet || !allProps.height) && inputRef.current) { let input = inputRef.current; let prevAlignment = input.style.alignSelf; let prevOverflow = input.style.overflow; @@ -61,7 +61,7 @@ export const TextArea = React.forwardRef(function TextArea(props: SpectrumTextAr input.style.overflow = prevOverflow; input.style.alignSelf = prevAlignment; } - }, [isQuiet, inputRef, props.height]); + }, [isQuiet, inputRef, allProps.height]); useLayoutEffect(() => { if (inputRef.current) { @@ -71,14 +71,14 @@ export const TextArea = React.forwardRef(function TextArea(props: SpectrumTextAr let hasWarned = useRef(false); useEffect(() => { - if (props.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { + if (allProps.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { console.warn('Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/TextArea.html#help-text'); hasWarned.current = true; } - }, [props.placeholder]); + }, [allProps.placeholder]); let result = useTextField({ - ...props, + ...allProps, onChange: chain(onChange, setInputValue), inputElementType: 'textarea' }, inputRef); diff --git a/packages/@react-spectrum/textfield/src/TextField.tsx b/packages/@react-spectrum/textfield/src/TextField.tsx index 4381c066d60..3aa2cdb95a9 100644 --- a/packages/@react-spectrum/textfield/src/TextField.tsx +++ b/packages/@react-spectrum/textfield/src/TextField.tsx @@ -23,23 +23,23 @@ import {useTextField} from '@react-aria/textfield'; * communicate the entry requirements. */ export const TextField = forwardRef(function TextField(props: SpectrumTextFieldProps, ref: Ref) { - props = useProviderProps(props); - props = useFormProps(props); + let propsWithProvider = useProviderProps(props); + let allProps = useFormProps(propsWithProvider); let inputRef = useRef(null); - let result = useTextField(props, inputRef); + let result = useTextField(allProps, inputRef); let hasWarned = useRef(false); useEffect(() => { - if (props.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { + if (allProps.placeholder && !hasWarned.current && process.env.NODE_ENV !== 'production') { console.warn('Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/TextField.html#help-text'); hasWarned.current = true; } - }, [props.placeholder]); + }, [allProps.placeholder]); return ( diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 2bf28053575..b505080cbbd 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -16,7 +16,7 @@ import {DatePickerProps, DateValue, Granularity, MappedDateValue} from '@react-t import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getPlaceholder} from './placeholders'; import {useControlledState} from '@react-stately/utils'; -import {useEffect, useMemo, useRef, useState} from 'react'; +import {useMemo, useRef, useState} from 'react'; import {ValidationState} from '@react-types/shared'; export type SegmentType = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName'; @@ -224,38 +224,34 @@ export function useDateFieldState(props: DateFi let clearedSegment = useRef(null); // Reset placeholder when calendar changes - let lastCalendar = useRef(calendar); - useEffect(() => { - if (!isEqualCalendar(calendar, lastCalendar.current)) { - lastCalendar.current = calendar; - setPlaceholderDate(placeholder => - Object.keys(validSegments).length > 0 - ? toCalendar(placeholder, calendar) - : createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) - ); - } - }, [calendar, granularity, validSegments, defaultTimeZone, props.placeholderValue]); + let [lastCalendar, setLastCalendar] = useState(calendar); + if (!isEqualCalendar(calendar, lastCalendar)) { + setLastCalendar(calendar); + setPlaceholderDate(placeholder => + Object.keys(validSegments).length > 0 + ? toCalendar(placeholder, calendar) + : createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) + ); + } // If there is a value prop, and some segments were previously placeholders, mark them all as valid. if (value && Object.keys(validSegments).length < Object.keys(allSegments).length) { - validSegments = {...allSegments}; - setValidSegments(validSegments); + setValidSegments({...allSegments}); } // If the value is set to null and all segments are valid, reset the placeholder. if (value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { - validSegments = {}; - setValidSegments(validSegments); + setValidSegments({}); setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); } // If all segments are valid, use the date from state, otherwise use the placeholder date. let displayValue = calendarValue && Object.keys(validSegments).length >= Object.keys(allSegments).length ? calendarValue : placeholderDate; - let setValue = (newValue: DateValue) => { + let setValue = (newValue: DateValue, newValidSegments: Partial = validSegments) => { if (props.isDisabled || props.isReadOnly) { return; } - let validKeys = Object.keys(validSegments); + let validKeys = Object.keys(newValidSegments); let allKeys = Object.keys(allSegments); // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared @@ -266,13 +262,12 @@ export function useDateFieldState(props: DateFi } else if ( (validKeys.length === 0 && clearedSegment.current == null) || validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod') + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !newValidSegments.dayPeriod && clearedSegment.current !== 'dayPeriod') ) { // If the field was empty (no valid segments) or all segments are completed, commit the new value. // When committing from an empty state, mark every segment as valid so value is committed. if (validKeys.length === 0) { - validSegments = {...allSegments}; - setValidSegments(validSegments); + setValidSegments({...allSegments}); } // The display calendar should not have any effect on the emitted value. @@ -293,28 +288,27 @@ export function useDateFieldState(props: DateFi // When the era field appears, mark it valid if the year field is already valid. // If the era field disappears, remove it from the valid segments. if (allSegments.era && validSegments.year && !validSegments.era) { - validSegments.era = true; - setValidSegments({...validSegments}); + setValidSegments({...validSegments, era: true}); } else if (!allSegments.era && validSegments.era) { - delete validSegments.era; - setValidSegments({...validSegments}); + setValidSegments({...validSegments, era: undefined}); } let markValid = (part: Intl.DateTimeFormatPartTypes) => { - validSegments[part] = true; + let newValidSegments = {...validSegments, [part]: true}; if (part === 'year' && allSegments.era) { - validSegments.era = true; + newValidSegments.era = true; } - setValidSegments({...validSegments}); + setValidSegments(newValidSegments); + return newValidSegments; }; let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { if (!validSegments[type]) { - markValid(type); - let validKeys = Object.keys(validSegments); + let newValidSegments = markValid(type); + let validKeys = Object.keys(newValidSegments); let allKeys = Object.keys(allSegments); - if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { - setValue(displayValue); + if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !newValidSegments.dayPeriod)) { + setValue(displayValue, newValidSegments); } } else { setValue(addSegment(displayValue, type, amount, resolvedOptions)); @@ -367,27 +361,26 @@ export function useDateFieldState(props: DateFi adjustSegment(part, -(PAGE_STEP[part] || 1)); }, setSegment(part, v: string | number) { - markValid(part); - setValue(setSegment(displayValue, part, v, resolvedOptions)); + let newValidSegments = markValid(part); + setValue(setSegment(displayValue, part, v, resolvedOptions), newValidSegments); }, confirmPlaceholder() { if (props.isDisabled || props.isReadOnly) { return; } - // Confirm the placeholder if only the day period is not filled in. let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); if (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod) { - validSegments = {...allSegments}; - setValidSegments(validSegments); + setValidSegments({...allSegments}); setValue(displayValue.copy()); } }, clearSegment(part) { - delete validSegments[part]; + let newValidSegments = {...validSegments}; + delete newValidSegments[part]; clearedSegment.current = part; - setValidSegments({...validSegments}); + setValidSegments(newValidSegments); let placeholder = createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone); let value = displayValue; @@ -401,14 +394,14 @@ export function useDateFieldState(props: DateFi } else if (!isPM && shouldBePM) { value = displayValue.set({hour: displayValue.hour + 12}); } - } else if (part === 'hour' && 'hour' in displayValue && displayValue.hour >= 12 && validSegments.dayPeriod) { + } else if (part === 'hour' && 'hour' in displayValue && displayValue.hour >= 12 && newValidSegments.dayPeriod) { value = displayValue.set({hour: placeholder['hour'] + 12}); } else if (part in displayValue) { value = displayValue.set({[part]: placeholder[part]}); } setDate(null); - setValue(value); + setValue(value, newValidSegments); }, formatValue(fieldOptions: FieldOptions) { if (!calendarValue) { diff --git a/packages/@react-stately/datepicker/src/useDateRangePickerState.ts b/packages/@react-stately/datepicker/src/useDateRangePickerState.ts index 18b1231491d..3bdf3382782 100644 --- a/packages/@react-stately/datepicker/src/useDateRangePickerState.ts +++ b/packages/@react-stately/datepicker/src/useDateRangePickerState.ts @@ -97,6 +97,8 @@ export function useDateRangePickerState(props: let value = controlledValue || placeholderValue; let setValue = (newValue: RangeValue | null) => { + // TODO: I don't know how to fix this one + // eslint-disable-next-line react-hooks/immutability value = newValue || {start: null, end: null}; setPlaceholderValue(value); if (isCompleteRange(value)) { @@ -203,6 +205,7 @@ export function useDateRangePickerState(props: granularity, hasTime, setDate(part, date) { + // Both the start and end datefields update on form reset back to back, so this is a problem due to not supporting setState callbacks if (part === 'start') { setDateRange({start: date, end: dateRange?.end ?? null}); } else { diff --git a/packages/dev/docs/pages/react-aria/home/Styles.tsx b/packages/dev/docs/pages/react-aria/home/Styles.tsx index a6b65c5768d..a719268cc8a 100644 --- a/packages/dev/docs/pages/react-aria/home/Styles.tsx +++ b/packages/dev/docs/pages/react-aria/home/Styles.tsx @@ -187,12 +187,6 @@ function AnimatedTabs({tabs}: {tabs: TabOptions[]}) { let x = useTransform(scrollXProgress, (x) => transform(x, 'offsetLeft')); let width = useTransform(scrollXProgress, (x) => transform(x, 'offsetWidth')); - // When the user scrolls, update the selected key - // so that the correct tab panel becomes interactive. - useMotionValueEvent(scrollXProgress, 'change', (x) => { - if (animationRef.current || !tabElements.length) {return;} - setSelectedKey(tabs[getIndex(x)].id); - }); // When the user clicks on a tab perform an animation of // the scroll position to the newly selected tab panel. @@ -238,6 +232,13 @@ function AnimatedTabs({tabs}: {tabs: TabOptions[]}) { ); }; + // When the user scrolls, update the selected key + // so that the correct tab panel becomes interactive. + useMotionValueEvent(scrollXProgress, 'change', (x) => { + if (animationRef.current || !tabElements.length) {return;} + setSelectedKey(tabs[getIndex(x)].id); + }); + // Scroll selected tab into view. let tabListScrollRef = useRef(null); useEffect(() => { diff --git a/packages/react-aria-components/src/Dialog.tsx b/packages/react-aria-components/src/Dialog.tsx index 5fc9a33cbe6..75f8fed2e01 100644 --- a/packages/react-aria-components/src/Dialog.tsx +++ b/packages/react-aria-components/src/Dialog.tsx @@ -70,23 +70,24 @@ export function DialogTrigger(props: DialogTriggerProps): JSX.Element { // This is done in RAC instead of hooks because otherwise we cannot distinguish // between context and props. Normally aria-labelledby overrides the title // but when sent by context we want the title to win. - triggerProps.id = useId(); - overlayProps['aria-labelledby'] = triggerProps.id; + let id = useId(); + let newTriggerProps = {...triggerProps, id}; + let newOverlayProps = {...overlayProps, 'aria-labelledby': id}; return ( - + {props.children} diff --git a/packages/react-aria-components/src/DropZone.tsx b/packages/react-aria-components/src/DropZone.tsx index 1138dd248c0..456f74a1d79 100644 --- a/packages/react-aria-components/src/DropZone.tsx +++ b/packages/react-aria-components/src/DropZone.tsx @@ -68,27 +68,27 @@ export const DropZoneContext = createContext) { +export const DropZone = forwardRef(function DropZone(props: DropZoneProps, outerRef: ForwardedRef) { let {isDisabled = false} = props; - [props, ref] = useContextProps(props, ref, DropZoneContext); + let [allProps, ref] = useContextProps(props, outerRef, DropZoneContext); let dropzoneRef = useObjectRef(ref); let buttonRef = useRef(null); - let {dropProps, dropButtonProps, isDropTarget} = useDrop({...props, ref: buttonRef, hasDropButton: true}); + let {dropProps, dropButtonProps, isDropTarget} = useDrop({...allProps, ref: buttonRef, hasDropButton: true}); let {buttonProps} = useButton(dropButtonProps || {}, buttonRef); - let {hoverProps, isHovered} = useHover(props); + let {hoverProps, isHovered} = useHover(allProps); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); let textId = useSlotId(); - let ariaLabel = props['aria-label'] || stringFormatter.format('dropzoneLabel'); - let messageId = props['aria-labelledby']; + let ariaLabel = allProps['aria-label'] || stringFormatter.format('dropzoneLabel'); + let messageId = allProps['aria-labelledby']; let ariaLabelledby = [textId, messageId].filter(Boolean).join(' '); let labelProps = useLabels({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby}); let {clipboardProps} = useClipboard({ isDisabled, - onPaste: (items) => props.onDrop?.({ + onPaste: (items) => allProps.onDrop?.({ type: 'drop', items, x: 0, @@ -98,11 +98,11 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: }); let renderProps = useRenderProps({ - ...props, + ...allProps, values: {isHovered, isFocused, isFocusVisible, isDropTarget, isDisabled}, defaultClassName: 'react-aria-DropZone' }); - let DOMProps = filterDOMProps(props, {global: true}); + let DOMProps = filterDOMProps(allProps, {global: true}); delete DOMProps.id; return ( @@ -113,7 +113,7 @@ export const DropZone = forwardRef(function DropZone(props: DropZoneProps, ref: {/* eslint-disable-next-line */}
{ let target = e.target as HTMLElement | null; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 090d188c849..c32908d4dc2 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -350,13 +350,13 @@ export interface TableProps extends Omit, 'children'>, Sty * A table displays data in rows and columns and enables a user to navigate its contents via directional navigation keys, * and optionally supports row selection and sorting. */ -export const Table = forwardRef(function Table(props: TableProps, ref: ForwardedRef) { - [props, ref] = useContextProps(props, ref, TableContext); +export const Table = forwardRef(function Table(props: TableProps, outerRef: ForwardedRef) { + let [allProps, ref] = useContextProps(props, outerRef, TableContext); // Separate selection state so we have access to it from collection components via useTableOptions. - let selectionState = useMultipleSelectionState(props); + let selectionState = useMultipleSelectionState(allProps); let {selectionBehavior, selectionMode, disallowEmptySelection} = selectionState; - let hasDragHooks = !!props.dragAndDropHooks?.useDraggableCollectionState; + let hasDragHooks = !!allProps.dragAndDropHooks?.useDraggableCollectionState; let ctx = useMemo(() => ({ selectionBehavior: selectionMode === 'none' ? null : selectionBehavior, selectionMode, @@ -366,13 +366,13 @@ export const Table = forwardRef(function Table(props: TableProps, ref: Forwarded let content = ( - + ); return ( new TableCollection()}> - {collection => } + {collection => } ); });