diff --git a/eslint.config.mjs b/eslint.config.mjs index febc4cebfdd..c3593ff37f4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -60,7 +60,8 @@ export default [{ "packages/dev/storybook-builder-parcel/*", "packages/dev/storybook-react-parcel/*", "packages/dev/s2-docs/pages/**", - "packages/dev/mcp/*/dist" + "packages/dev/mcp/*/dist", + "packages/dev/codemods/src/s1-to-s2/__testfixtures__/cli/**" ], }, ...compat.extends("eslint:recommended"), { plugins: { @@ -534,4 +535,4 @@ export default [{ ...globals.browser } } -}]; \ No newline at end of file +}]; diff --git a/packages/@internationalized/number/src/NumberParser.ts b/packages/@internationalized/number/src/NumberParser.ts index ae6448f027d..dfeb7c6c24f 100644 --- a/packages/@internationalized/number/src/NumberParser.ts +++ b/packages/@internationalized/number/src/NumberParser.ts @@ -19,7 +19,9 @@ interface Symbols { group?: string, literals: RegExp, numeral: RegExp, - index: (v: string) => string + numerals: string[], + index: (v: string) => string, + noNumeralUnits: Array<{unit: string, value: number}> } const CURRENCY_SIGN_REGEX = new RegExp('^.*\\(.*\\).*$'); @@ -130,13 +132,17 @@ class NumberParserImpl { } parse(value: string) { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; // to parse the number, we need to remove anything that isn't actually part of the number, for example we want '-10.40' not '-10.40 USD' let fullySanitizedValue = this.sanitize(value); - if (this.symbols.group) { - // Remove group characters, and replace decimal points and numerals with ASCII values. - fullySanitizedValue = replaceAll(fullySanitizedValue, this.symbols.group, ''); + // Return NaN if there is a group symbol but useGrouping is false + if (!isGroupSymbolAllowed && this.symbols.group && fullySanitizedValue.includes(this.symbols.group)) { + return NaN; + } else if (this.symbols.group) { + fullySanitizedValue = fullySanitizedValue.replaceAll(this.symbols.group!, ''); } + if (this.symbols.decimal) { fullySanitizedValue = fullySanitizedValue.replace(this.symbols.decimal!, '.'); } @@ -189,12 +195,17 @@ class NumberParserImpl { if (this.options.currencySign === 'accounting' && CURRENCY_SIGN_REGEX.test(value)) { newValue = -1 * newValue; } - return newValue; } sanitize(value: string) { - // Remove literals and whitespace, which are allowed anywhere in the string + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; + // If the value is only a unit and it matches one of the formatted numbers where the value is part of the unit and doesn't have any numerals, then + // return the known value for that case. + if (this.symbols.noNumeralUnits.length > 0 && this.symbols.noNumeralUnits.find(obj => obj.unit === value)) { + return this.symbols.noNumeralUnits.find(obj => obj.unit === value)!.value.toString(); + } + value = value.replace(this.symbols.literals, ''); // Replace the ASCII minus sign with the minus sign used in the current locale @@ -207,23 +218,23 @@ class NumberParserImpl { // instead they use the , (44) character or apparently the (1548) character. if (this.options.numberingSystem === 'arab') { if (this.symbols.decimal) { - value = value.replace(',', this.symbols.decimal); - value = value.replace(String.fromCharCode(1548), this.symbols.decimal); + value = replaceAll(value, ',', this.symbols.decimal); + value = replaceAll(value, String.fromCharCode(1548), this.symbols.decimal); } - if (this.symbols.group) { + if (this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, '.', this.symbols.group); } } // In some locale styles, such as swiss currency, the group character can be a special single quote // that keyboards don't typically have. This expands the character to include the easier to type single quote. - if (this.symbols.group === '’' && value.includes("'")) { + if (this.symbols.group === '’' && value.includes("'") && isGroupSymbolAllowed) { value = replaceAll(value, "'", this.symbols.group); } // fr-FR group character is narrow non-breaking space, char code 8239 (U+202F), but that's not a key on the french keyboard, // so allow space and non-breaking space as a group char as well - if (this.options.locale === 'fr-FR' && this.symbols.group) { + if (this.options.locale === 'fr-FR' && this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, ' ', this.symbols.group); value = replaceAll(value, /\u00A0/g, this.symbols.group); } @@ -232,6 +243,7 @@ class NumberParserImpl { } isValidPartialNumber(value: string, minValue: number = -Infinity, maxValue: number = Infinity): boolean { + let isGroupSymbolAllowed = this.formatter.resolvedOptions().useGrouping; value = this.sanitize(value); // Remove minus or plus sign, which must be at the start of the string. @@ -241,18 +253,13 @@ class NumberParserImpl { value = value.slice(this.symbols.plusSign.length); } - // Numbers cannot start with a group separator - if (this.symbols.group && value.startsWith(this.symbols.group)) { - return false; - } - // Numbers that can't have any decimal values fail if a decimal character is typed if (this.symbols.decimal && value.indexOf(this.symbols.decimal) > -1 && this.options.maximumFractionDigits === 0) { return false; } // Remove numerals, groups, and decimals - if (this.symbols.group) { + if (this.symbols.group && isGroupSymbolAllowed) { value = replaceAll(value, this.symbols.group, ''); } value = value.replace(this.symbols.numeral, ''); @@ -282,12 +289,21 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I maximumSignificantDigits: 21, roundingIncrement: 1, roundingPriority: 'auto', - roundingMode: 'halfExpand' + roundingMode: 'halfExpand', + useGrouping: true }); // Note: some locale's don't add a group symbol until there is a ten thousands place let allParts = symbolFormatter.formatToParts(-10000.111); let posAllParts = symbolFormatter.formatToParts(10000.111); let pluralParts = pluralNumbers.map(n => symbolFormatter.formatToParts(n)); + // if the plural parts include a unit but no integer or fraction, then we need to add the unit to the special set + let noNumeralUnits = pluralParts.map((p, i) => { + let unit = p.find(p => p.type === 'unit'); + if (unit && !p.some(p => p.type === 'integer' || p.type === 'fraction')) { + return {unit: unit.value, value: pluralNumbers[i]}; + } + return null; + }).filter(p => !!p); let minusSign = allParts.find(p => p.type === 'minusSign')?.value ?? '-'; let plusSign = posAllParts.find(p => p.type === 'plusSign')?.value; @@ -311,9 +327,10 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I let pluralPartsLiterals = pluralParts.flatMap(p => p.filter(p => !nonLiteralParts.has(p.type)).map(p => escapeRegex(p.value))); let sortedLiterals = [...new Set([...allPartsLiterals, ...pluralPartsLiterals])].sort((a, b) => b.length - a.length); + // Match both whitespace and formatting characters let literals = sortedLiterals.length === 0 ? - new RegExp('[\\p{White_Space}]', 'gu') : - new RegExp(`${sortedLiterals.join('|')}|[\\p{White_Space}]`, 'gu'); + new RegExp('\\p{White_Space}|\\p{Cf}', 'gu') : + new RegExp(`${sortedLiterals.join('|')}|\\p{White_Space}|\\p{Cf}`, 'gu'); // These are for replacing non-latn characters with the latn equivalent let numerals = [...new Intl.NumberFormat(intlOptions.locale, {useGrouping: false}).format(9876543210)].reverse(); @@ -321,7 +338,7 @@ function getSymbols(locale: string, formatter: Intl.NumberFormat, intlOptions: I let numeral = new RegExp(`[${numerals.join('')}]`, 'g'); let index = d => String(indexes.get(d)); - return {minusSign, plusSign, decimal, group, literals, numeral, index}; + return {minusSign, plusSign, decimal, group, literals, numeral, numerals, index, noNumeralUnits}; } function replaceAll(str: string, find: string | RegExp, replace: string) { diff --git a/packages/@internationalized/number/test/NumberParser.test.js b/packages/@internationalized/number/test/NumberParser.test.js index a9266d997cf..19222fe946a 100644 --- a/packages/@internationalized/number/test/NumberParser.test.js +++ b/packages/@internationalized/number/test/NumberParser.test.js @@ -56,6 +56,11 @@ describe('NumberParser', function () { expect(new NumberParser('en-US', {style: 'decimal'}).parse('1abc')).toBe(NaN); }); + it('should return NaN for invalid grouping', function () { + expect(new NumberParser('en-US', {useGrouping: false}).parse('1234,7')).toBeNaN(); + expect(new NumberParser('de-DE', {useGrouping: false}).parse('1234.7')).toBeNaN(); + }); + describe('currency', function () { it('should parse without the currency symbol', function () { expect(new NumberParser('en-US', {currency: 'USD', style: 'currency'}).parse('10.50')).toBe(10.5); @@ -194,8 +199,13 @@ describe('NumberParser', function () { expect(new NumberParser('de-CH', {style: 'currency', currency: 'CHF'}).parse("CHF 1'000.00")).toBe(1000); }); + it('should parse arabic singular and dual counts', () => { + expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يومان')).toBe(2); + expect(new NumberParser('ar-AE', {style: 'unit', unit: 'day', unitDisplay: 'long'}).parse('يوم')).toBe(1); + }); + describe('round trips', function () { - fc.configureGlobal({numRuns: 200}); + fc.configureGlobal({numRuns: 2000}); // Locales have to include: 'de-DE', 'ar-EG', 'fr-FR' and possibly others // But for the moment they are not properly supported const localesArb = fc.constantFrom(...locales); @@ -301,6 +311,78 @@ describe('NumberParser', function () { const formattedOnce = formatter.format(1); expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); }); + it('should handle small numbers', () => { + let locale = 'ar-AE'; + let options = { + style: 'decimal', + minimumIntegerDigits: 4, + maximumSignificantDigits: 1 + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle currency small numbers', () => { + let locale = 'ar-AE-u-nu-latn'; + let options = { + style: 'currency', + currency: 'USD' + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle hanidec small numbers', () => { + let locale = 'ar-AE-u-nu-hanidec'; + let options = { + style: 'decimal' + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle beng with minimum integer digits', () => { + let locale = 'ar-AE-u-nu-beng'; + let options = { + style: 'decimal', + minimumIntegerDigits: 4, + maximumFractionDigits: 0 + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle percent with minimum integer digits', () => { + let locale = 'ar-AE-u-nu-latn'; + let options = { + style: 'percent', + minimumIntegerDigits: 4, + minimumFractionDigits: 9, + maximumSignificantDigits: 1, + maximumFractionDigits: undefined + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(0.0095); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); + it('should handle non-grouping in russian locale', () => { + let locale = 'ru-RU'; + let options = { + style: 'percent', + useGrouping: false, + minimumFractionDigits: undefined, + maximumFractionDigits: undefined + }; + const formatter = new Intl.NumberFormat(locale, options); + const parser = new NumberParser(locale, options); + const formattedOnce = formatter.format(2.220446049250313e-16); + expect(formatter.format(parser.parse(formattedOnce))).toBe(formattedOnce); + }); }); }); @@ -327,14 +409,21 @@ describe('NumberParser', function () { }); it('should support group characters', function () { - expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true); // en-US-u-nu-arab uses commas as the decimal point character - expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(false); // latin numerals cannot follow arab decimal point + // starting with arabic decimal point + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',')).toBe(true); + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber(',000')).toBe(true); + expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('000,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1,000,000')).toBe(true); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('-1,000,000')).toBe(true); }); + it('should return false for invalid grouping', function () { + expect(new NumberParser('en-US', {useGrouping: false}).isValidPartialNumber('1234,7')).toBe(false); + expect(new NumberParser('de-DE', {useGrouping: false}).isValidPartialNumber('1234.7')).toBe(false); + }); + it('should reject random characters', function () { expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('g')).toBe(false); expect(new NumberParser('en-US', {style: 'decimal'}).isValidPartialNumber('1abc')).toBe(false); diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index df4a19ce040..93d7ca15b43 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -156,7 +156,9 @@ export function useComboBox(props: AriaCo break; } } - state.commit(); + if (e.key === 'Enter' || state.isOpen) { + state.commit(); + } break; case 'Escape': if ( diff --git a/packages/@react-aria/combobox/test/useComboBox.test.js b/packages/@react-aria/combobox/test/useComboBox.test.js index 2d2508a1848..4f5a0fa15c6 100644 --- a/packages/@react-aria/combobox/test/useComboBox.test.js +++ b/packages/@react-aria/combobox/test/useComboBox.test.js @@ -119,6 +119,23 @@ describe('useComboBox', function () { expect(preventDefault).toHaveBeenCalledTimes(1); }); + it('should only call commit on Tab when the menu is open', function () { + let commitSpy = jest.fn(); + let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props}); + let closedState = {...state.current, isOpen: false, commit: commitSpy}; + let {result: closedResult} = renderHook((props) => useComboBox(props, closedState), {initialProps: props}); + act(() => { + closedResult.current.inputProps.onKeyDown(event({key: 'Tab'})); + }); + expect(commitSpy).toHaveBeenCalledTimes(0); + let openState = {...state.current, isOpen: true, commit: commitSpy}; + let {result: openResult} = renderHook((props) => useComboBox(props, openState), {initialProps: props}); + act(() => { + openResult.current.inputProps.onKeyDown(event({key: 'Tab'})); + }); + expect(commitSpy).toHaveBeenCalledTimes(1); + }); + it('calls open and toggle with the expected parameters when arrow down/up/trigger button is pressed', function () { let {result: state} = renderHook((props) => useComboBoxState(props), {initialProps: props}); state.current.open = openSpy; diff --git a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx index eff3a097032..1451c1f558a 100644 --- a/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx +++ b/packages/@react-aria/dnd/stories/VirtualizedListBox.tsx @@ -174,6 +174,7 @@ React.forwardRef(function (props: any, ref) { , contain?: bo e.preventDefault(); if (nextElement) { focusElement(nextElement, true); + if (nextElement instanceof getOwnerWindow(nextElement).HTMLInputElement) { + nextElement.select(); + } } }; diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index 38648c12635..31644123abf 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -354,6 +354,27 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(input2); }); + + it('should select all text in input when tabbing', async function () { + let {getByTestId} = render( + + + + + + ); + + let input1 = getByTestId('input1'); + let input2 = getByTestId('input2'); + + act(() => {input1.focus();}); + expect(document.activeElement).toBe(input1); + + await user.tab(); + expect(document.activeElement).toBe(input2); + await user.keyboard('{Delete}'); + expect(input2.value).toBe(''); + }); }); describe('focus restoration', function () { diff --git a/packages/@react-aria/interactions/src/useMove.ts b/packages/@react-aria/interactions/src/useMove.ts index c8158b4f9c3..310c93f223e 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, {useCallback, useMemo, useRef, useState} from 'react'; -import {useEffectEvent, useGlobalListeners, useLayoutEffect} from '@react-aria/utils'; +import React, {useCallback, useMemo, useRef} from 'react'; +import {useEffectEvent, useGlobalListeners} from '@react-aria/utils'; export interface MoveResult { /** Props to spread on the target element. */ @@ -87,66 +87,49 @@ export function useMove(props: MoveEvents): MoveResult { }, [onMoveEnd, state]); let endEvent = useEffectEvent(end); - 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 moveProps = useMemo(() => { + let moveProps: DOMAttributes = {}; - // 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}; - } - }; + let start = () => { + disableTextSelection(); + state.current.didMove = false; + }; - 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') { + if (typeof PointerEvent === 'undefined' && process.env.NODE_ENV === 'test') { let onMouseMove = (e: MouseEvent) => { if (e.button === 0) { + // Should be safe to use the useEffectEvent because these are equivalent https://github.com/reactjs/react.dev/issues/8075#issuecomment-3400179389 + // However, the compiler is not smart enough to know that. As such, this whole file must be manually optimised as the compiler will bail. + // + // eslint-disable-next-line react-hooks/rules-of-hooks 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) { + // eslint-disable-next-line react-hooks/rules-of-hooks endEvent(e, 'mouse'); removeGlobalListener(window, 'mousemove', onMouseMove, false); removeGlobalListener(window, 'mouseup', onMouseUp, false); - setPointerDown(null); } }; - addGlobalListener(window, 'mousemove', onMouseMove, false); - addGlobalListener(window, 'mouseup', onMouseUp, false); - return () => { - removeGlobalListener(window, 'mousemove', onMouseMove, false); - removeGlobalListener(window, 'mouseup', onMouseUp, false); + 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); + } }; - } 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]; + // eslint-disable-next-line react-hooks/rules-of-hooks moveEvent(e, 'touch', pageX - (state.current.lastPosition?.pageX ?? 0), pageY - (state.current.lastPosition?.pageY ?? 0)); state.current.lastPosition = {pageX, pageY}; } @@ -154,41 +137,12 @@ export function useMove(props: MoveEvents): MoveResult { let onTouchEnd = (e: TouchEvent) => { let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id); if (touch >= 0) { + // eslint-disable-next-line react-hooks/rules-of-hooks 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) => { @@ -202,9 +156,36 @@ export function useMove(props: MoveEvents): MoveResult { e.preventDefault(); state.current.lastPosition = {pageX, pageY}; state.current.id = identifier; - setPointerDown('touch'); + addGlobalListener(window, 'touchmove', onTouchMove, false); + addGlobalListener(window, 'touchend', onTouchEnd, false); + addGlobalListener(window, 'touchcancel', onTouchEnd, false); }; } 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 + // eslint-disable-next-line react-hooks/rules-of-hooks + 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}; + } + }; + + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.current.id) { + let pointerType = (e.pointerType || 'mouse') as PointerType; + // eslint-disable-next-line react-hooks/rules-of-hooks + endEvent(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(); @@ -212,15 +193,19 @@ export function useMove(props: MoveEvents): MoveResult { e.preventDefault(); state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY}; state.current.id = e.pointerId; - setPointerDown('pointer'); + addGlobalListener(window, 'pointermove', onPointerMove, false); + addGlobalListener(window, 'pointerup', onPointerUp, false); + addGlobalListener(window, 'pointercancel', onPointerUp, false); } }; } let triggerKeyboardMove = (e: EventBase, deltaX: number, deltaY: number) => { start(); - move(e, 'keyboard', deltaX, deltaY); - end(e, 'keyboard'); + // eslint-disable-next-line react-hooks/rules-of-hooks + moveEvent(e, 'keyboard', deltaX, deltaY); + // eslint-disable-next-line react-hooks/rules-of-hooks + endEvent(e, 'keyboard'); }; moveProps.onKeyDown = (e) => { @@ -253,7 +238,7 @@ export function useMove(props: MoveEvents): MoveResult { }; return moveProps; - }, [state, move, end]); + }, [addGlobalListener, removeGlobalListener, state]); return {moveProps}; } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 46630ea9389..b24a383df12 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -30,7 +30,6 @@ import { openLink, useEffectEvent, useGlobalListeners, - useLayoutEffect, useSyncRef } from '@react-aria/utils'; import {createSyntheticEvent, preventFocus, setEventTarget} from './utils'; @@ -202,7 +201,7 @@ export function usePress(props: PressHookProps): PressResult { disposables: [] }); - let {addGlobalListener, removeAllGlobalListeners, removeGlobalListener} = useGlobalListeners(); + let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); let triggerPressStart = useCallback((originalEvent: EventBase, pointerType: PointerType) => { let state = ref.current; @@ -286,7 +285,6 @@ export function usePress(props: PressHookProps): PressResult { triggerPressEnd(createEvent(state.target, e), state.pointerType, false); } state.isPressed = false; - setIsPointerPressed(null); state.isOverTarget = false; state.activePointerId = null; state.pointerType = null; @@ -332,165 +330,6 @@ export function usePress(props: PressHookProps): PressResult { 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) as Element, e.key)) { - e.preventDefault(); - } - - let target = getEventTarget(e) as Element; - let wasPressed = nodeContains(state.target, target); - 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: KeyboardEvent) => { - if (originalTarget && isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e) as Element) && 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) as Element) && 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. - if (e.currentTarget) { - addGlobalListener(e.currentTarget, '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 && nodeContains(state.target, 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) as Element, 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; @@ -508,11 +347,23 @@ 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) as Element) && state.target) { + // eslint-disable-next-line react-hooks/rules-of-hooks + triggerPressUpEvent(createEvent(state.target, e), 'keyboard'); + } + }; + + addGlobalListener(getOwnerDocument(e.currentTarget), 'keyup', chain(pressUp, onKeyUp), true); + if (shouldStopPropagation) { e.stopPropagation(); } @@ -546,18 +397,23 @@ export function usePress(props: PressHookProps): PressResult { // trigger as if it were a keyboard click. if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) { let stopPressStart = triggerPressStart(e, 'virtual'); - let stopPressUp = triggerPressUp(e, 'virtual'); - let stopPressEnd = triggerPressEnd(e, 'virtual'); + // eslint-disable-next-line react-hooks/rules-of-hooks + let stopPressUp = triggerPressUpEvent(e, 'virtual'); + // eslint-disable-next-line react-hooks/rules-of-hooks + let stopPressEnd = triggerPressEndEvent(e, 'virtual'); triggerClick(e); shouldStopPropagation = stopPressStart && stopPressUp && stopPressEnd; } else if (state.isPressed && state.pointerType !== 'keyboard') { let pointerType = state.pointerType || (e.nativeEvent as PointerEvent).pointerType as PointerType || 'virtual'; - let stopPressUp = triggerPressUp(createEvent(e.currentTarget, e), pointerType); - let stopPressEnd = triggerPressEnd(createEvent(e.currentTarget, e), pointerType, true); + // eslint-disable-next-line react-hooks/rules-of-hooks + let stopPressUp = triggerPressUpEvent(createEvent(e.currentTarget, e), pointerType); + // eslint-disable-next-line react-hooks/rules-of-hooks + let stopPressEnd = triggerPressEndEvent(createEvent(e.currentTarget, e), pointerType, true); shouldStopPropagation = stopPressUp && stopPressEnd; state.isOverTarget = false; triggerClick(e); - cancel(e); + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); } state.ignoreEmulatedMouseEvents = false; @@ -568,6 +424,45 @@ export function usePress(props: PressHookProps): PressResult { } }; + let onKeyUp = (e: KeyboardEvent) => { + if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) { + if (shouldPreventDefaultKeyboard(getEventTarget(e) as Element, e.key)) { + e.preventDefault(); + } + + let target = getEventTarget(e); + let wasPressed = nodeContains(state.target, target as Element); + // eslint-disable-next-line react-hooks/rules-of-hooks + triggerPressEndEvent(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 as Element) && !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. @@ -589,7 +484,6 @@ 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; @@ -612,6 +506,8 @@ export function usePress(props: PressHookProps): PressResult { (target as Element).releasePointerCapture(e.pointerId); } } + addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false); + addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false); } if (shouldStopPropagation) { @@ -644,7 +540,8 @@ export function usePress(props: PressHookProps): PressResult { // Only handle left clicks. If isPressed is true, delay until onClick. if (e.button === 0 && !state.isPressed) { - triggerPressUp(e, state.pointerType || e.pointerType); + // eslint-disable-next-line react-hooks/rules-of-hooks + triggerPressUpEvent(e, state.pointerType || e.pointerType); } }; @@ -658,11 +555,55 @@ export function usePress(props: PressHookProps): PressResult { pressProps.onPointerLeave = (e) => { if (e.pointerId === state.activePointerId && state.target && state.isOverTarget && state.pointerType != null) { state.isOverTarget = false; - triggerPressEnd(createEvent(state.target, e), state.pointerType, false); + // eslint-disable-next-line react-hooks/rules-of-hooks + triggerPressEndEvent(createEvent(state.target, e), state.pointerType, false); cancelOnPointerExit(e); } }; + let onPointerUp = (e: PointerEvent) => { + if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) { + if (nodeContains(state.target, getEventTarget(e) as Element) && 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) { + // eslint-disable-next-line react-hooks/rules-of-hooks + 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 { + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); + } + + // Ignore subsequent onPointerLeave event before onClick on touch devices. + state.isOverTarget = false; + } + }; + + let onPointerCancel = (e: PointerEvent) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); + }; pressProps.onDragStart = (e) => { if (!nodeContains(e.currentTarget, getEventTarget(e))) { @@ -670,7 +611,8 @@ export function usePress(props: PressHookProps): PressResult { } // Safari does not call onPointerCancel when a drag starts, whereas Chrome and Firefox do. - cancel(e); + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); }; } else if (process.env.NODE_ENV === 'test') { // NOTE: this fallback branch is entirely used by unit tests. @@ -688,7 +630,6 @@ 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'; @@ -705,6 +646,7 @@ export function usePress(props: PressHookProps): PressResult { state.disposables.push(dispose); } } + addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false); }; pressProps.onMouseEnter = (e) => { @@ -731,7 +673,8 @@ export function usePress(props: PressHookProps): PressResult { let shouldStopPropagation = true; if (state.isPressed && !state.ignoreEmulatedMouseEvents && state.pointerType != null) { state.isOverTarget = false; - shouldStopPropagation = triggerPressEnd(e, state.pointerType, false); + // eslint-disable-next-line react-hooks/rules-of-hooks + shouldStopPropagation = triggerPressEndEvent(e, state.pointerType, false); cancelOnPointerExit(e); } @@ -746,8 +689,31 @@ export function usePress(props: PressHookProps): PressResult { } if (!state.ignoreEmulatedMouseEvents && e.button === 0 && !state.isPressed) { - triggerPressUp(e, state.pointerType || 'mouse'); + // eslint-disable-next-line react-hooks/rules-of-hooks + triggerPressUpEvent(e, state.pointerType || 'mouse'); + } + }; + + let onMouseUp = (e: MouseEvent) => { + // Only handle left clicks + if (e.button !== 0) { + return; + } + + if (state.ignoreEmulatedMouseEvents) { + state.ignoreEmulatedMouseEvents = false; + return; + } + + if (state.target && nodeContains(state.target, getEventTarget(e) 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 { + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); } + + state.isOverTarget = false; }; pressProps.onTouchStart = (e) => { @@ -763,7 +729,6 @@ export function usePress(props: PressHookProps): PressResult { state.ignoreEmulatedMouseEvents = true; state.isOverTarget = true; state.isPressed = true; - setIsPointerPressed('touch'); state.target = e.currentTarget; state.pointerType = 'touch'; @@ -775,6 +740,7 @@ export function usePress(props: PressHookProps): PressResult { if (shouldStopPropagation) { e.stopPropagation(); } + addGlobalListener(getOwnerWindow(e.currentTarget), 'scroll', onScroll, true); }; pressProps.onTouchMove = (e) => { @@ -796,7 +762,8 @@ export function usePress(props: PressHookProps): PressResult { } } else if (state.isOverTarget && state.pointerType != null) { state.isOverTarget = false; - shouldStopPropagation = triggerPressEnd(createTouchEvent(state.target!, e), state.pointerType, false); + // eslint-disable-next-line react-hooks/rules-of-hooks + shouldStopPropagation = triggerPressEndEvent(createTouchEvent(state.target!, e), state.pointerType, false); cancelOnPointerExit(createTouchEvent(state.target!, e)); } @@ -818,11 +785,14 @@ export function usePress(props: PressHookProps): PressResult { let touch = getTouchById(e.nativeEvent, state.activePointerId); let shouldStopPropagation = true; if (touch && isOverTarget(touch, e.currentTarget) && state.pointerType != null) { - triggerPressUp(createTouchEvent(state.target!, e), state.pointerType); - shouldStopPropagation = triggerPressEnd(createTouchEvent(state.target!, e), state.pointerType); + // eslint-disable-next-line react-hooks/rules-of-hooks + triggerPressUpEvent(createTouchEvent(state.target!, e), state.pointerType); + // eslint-disable-next-line react-hooks/rules-of-hooks + shouldStopPropagation = triggerPressEndEvent(createTouchEvent(state.target!, e), state.pointerType); triggerSyntheticClick(e.nativeEvent, state.target!); } else if (state.isOverTarget && state.pointerType != null) { - shouldStopPropagation = triggerPressEnd(createTouchEvent(state.target!, e), state.pointerType, false); + // eslint-disable-next-line react-hooks/rules-of-hooks + shouldStopPropagation = triggerPressEndEvent(createTouchEvent(state.target!, e), state.pointerType, false); } if (shouldStopPropagation) { @@ -830,7 +800,6 @@ export function usePress(props: PressHookProps): PressResult { } state.isPressed = false; - setIsPointerPressed(null); state.activePointerId = null; state.isOverTarget = false; state.ignoreEmulatedMouseEvents = true; @@ -847,7 +816,21 @@ export function usePress(props: PressHookProps): PressResult { e.stopPropagation(); if (state.isPressed) { - cancel(createTouchEvent(state.target!, e)); + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(createTouchEvent(state.target!, e)); + } + }; + + let onScroll = (e: Event) => { + if (state.isPressed && nodeContains(getEventTarget(e) as Element, state.target)) { + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent({ + currentTarget: state.target, + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false + }); } }; @@ -856,21 +839,20 @@ export function usePress(props: PressHookProps): PressResult { return; } - cancel(e); + // eslint-disable-next-line react-hooks/rules-of-hooks + cancelEvent(e); }; } return pressProps; }, [ + addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress, - cancel, cancelOnPointerExit, - triggerPressEnd, triggerPressStart, - triggerPressUp, triggerClick, triggerSyntheticClick ]); diff --git a/packages/@react-aria/listbox/src/useListBox.ts b/packages/@react-aria/listbox/src/useListBox.ts index c5ac0984bbf..4fdbdffbceb 100644 --- a/packages/@react-aria/listbox/src/useListBox.ts +++ b/packages/@react-aria/listbox/src/useListBox.ts @@ -11,7 +11,7 @@ */ import {AriaListBoxProps} from '@react-types/listbox'; -import {DOMAttributes, KeyboardDelegate, LayoutDelegate, RefObject} from '@react-types/shared'; +import {DOMAttributes, KeyboardDelegate, LayoutDelegate, Orientation, RefObject} from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {listData} from './utils'; import {ListState} from '@react-stately/list'; @@ -55,7 +55,13 @@ export interface AriaListBoxOptions extends Omit, 'childr * - 'override': links override all other interactions (link items are not selectable). * @default 'override' */ - linkBehavior?: 'action' | 'selection' | 'override' + linkBehavior?: 'action' | 'selection' | 'override', + + /** + * The primary orientation of the items. Usually this is the direction that the collection scrolls. + * @default 'vertical' + */ + orientation?: Orientation } /** @@ -68,6 +74,7 @@ export function useListBox(props: AriaListBoxOptions, state: ListState, let domProps = filterDOMProps(props, {labelable: true}); // Use props instead of state here. We don't want this to change due to long press. let selectionBehavior = props.selectionBehavior || 'toggle'; + let orientation = props.orientation || 'vertical'; let linkBehavior = props.linkBehavior || (selectionBehavior === 'replace' ? 'action' : 'override'); if (selectionBehavior === 'toggle' && linkBehavior === 'action') { // linkBehavior="action" does not work with selectionBehavior="toggle" because there is no way @@ -119,6 +126,7 @@ export function useListBox(props: AriaListBoxOptions, state: ListState, 'aria-multiselectable': 'true' } : {}, { role: 'listbox', + 'aria-orientation': orientation, ...mergeProps(fieldProps, listProps) }) }; diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 06e5d42e526..427017983c2 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -13,7 +13,7 @@ import {announce} from '@react-aria/live-announcer'; import {AriaButtonProps} from '@react-types/button'; import {AriaNumberFieldProps} from '@react-types/numberfield'; -import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId} from '@react-aria/utils'; +import {chain, filterDOMProps, getActiveElement, getEventTarget, isAndroid, isIOS, isIPhone, mergeProps, useFormReset, useId, useLayoutEffect} from '@react-aria/utils'; import { type ClipboardEvent, type ClipboardEventHandler, @@ -262,6 +262,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt }, state, inputRef); useFormReset(inputRef, state.defaultNumberValue, state.setNumberValue); + useNativeValidation(state, props.validationBehavior, props.commitBehavior, inputRef, state.minValue, state.maxValue, props.step, state.numberValue); let inputProps: InputHTMLAttributes = mergeProps( spinButtonProps, @@ -363,3 +364,69 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt validationDetails }; } + +let numberInput: HTMLInputElement | null = null; + +function useNativeValidation( + state: NumberFieldState, + validationBehavior: 'native' | 'aria' | undefined, + commitBehavior: 'snap' | 'validate' | undefined, + inputRef: RefObject, + min: number | undefined, + max: number | undefined, + step: number | undefined, + value: number | undefined +) { + useLayoutEffect(() => { + let input = inputRef.current; + if (commitBehavior !== 'validate' || state.realtimeValidation.isInvalid || !input || input.disabled) { + return; + } + + // Create a native number input and use it to implement validation of min/max/step. + // This lets us get the native validation message provided by the browser instead of needing our own translations. + if (!numberInput && typeof document !== 'undefined') { + numberInput = document.createElement('input'); + numberInput.type = 'number'; + } + + if (!numberInput) { + // For TypeScript. + return; + } + + numberInput.min = min != null && !isNaN(min) ? String(min) : ''; + numberInput.max = max != null && !isNaN(max) ? String(max) : ''; + numberInput.step = step != null && !isNaN(step) ? String(step) : ''; + numberInput.value = value != null && !isNaN(value) ? String(value) : ''; + + // Merge validity with the visible text input (for other validations like required). + let valid = input.validity.valid && numberInput.validity.valid; + let validationMessage = input.validationMessage || numberInput.validationMessage; + let validity = { + isInvalid: !valid, + validationErrors: validationMessage ? [validationMessage] : [], + validationDetails: { + badInput: input.validity.badInput, + customError: input.validity.customError, + patternMismatch: input.validity.patternMismatch, + rangeOverflow: numberInput.validity.rangeOverflow, + rangeUnderflow: numberInput.validity.rangeUnderflow, + stepMismatch: numberInput.validity.stepMismatch, + tooLong: input.validity.tooLong, + tooShort: input.validity.tooShort, + typeMismatch: input.validity.typeMismatch, + valueMissing: input.validity.valueMissing, + valid + } + }; + + state.updateValidation(validity); + + // Block form submission if validation behavior is native. + // This won't overwrite any user-defined validation message because we checked realtimeValidation above. + if (validationBehavior === 'native' && !numberInput.validity.valid) { + input.setCustomValidity(numberInput.validationMessage); + } + }); +} diff --git a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts index 21239d23dad..eb223deae50 100644 --- a/packages/@react-aria/selection/src/ListKeyboardDelegate.ts +++ b/packages/@react-aria/selection/src/ListKeyboardDelegate.ts @@ -248,7 +248,7 @@ export class ListKeyboardDelegate implements KeyboardDelegate { let nextKey: Key | null = key; if (this.orientation === 'horizontal') { - let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.y - itemRect.width + this.layoutDelegate.getVisibleRect().width); + let pageX = Math.min(this.layoutDelegate.getContentSize().width, itemRect.x - itemRect.width + this.layoutDelegate.getVisibleRect().width); while (itemRect && itemRect.x < pageX && nextKey != null) { nextKey = this.getKeyBelow(nextKey); diff --git a/packages/@react-aria/selection/src/useSelectableList.ts b/packages/@react-aria/selection/src/useSelectableList.ts index 98072b7c3ee..0e319623423 100644 --- a/packages/@react-aria/selection/src/useSelectableList.ts +++ b/packages/@react-aria/selection/src/useSelectableList.ts @@ -11,7 +11,7 @@ */ import {AriaSelectableCollectionOptions, useSelectableCollection} from './useSelectableCollection'; -import {Collection, DOMAttributes, Key, KeyboardDelegate, LayoutDelegate, Node} from '@react-types/shared'; +import {Collection, DOMAttributes, Key, KeyboardDelegate, LayoutDelegate, Node, Orientation} from '@react-types/shared'; import {ListKeyboardDelegate} from './ListKeyboardDelegate'; import {useCollator} from '@react-aria/i18n'; import {useMemo} from 'react'; @@ -34,7 +34,12 @@ export interface AriaSelectableListOptions extends Omit + disabledKeys: Set, + /** + * The primary orientation of the items. Usually this is the direction that the collection scrolls. + * @default 'vertical' + */ + orientation?: Orientation } export interface SelectableListAria { @@ -54,7 +59,8 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL disabledKeys, ref, keyboardDelegate, - layoutDelegate + layoutDelegate, + orientation } = props; // By default, a KeyboardDelegate is provided which uses the DOM to query layout information (e.g. for page up/page down). @@ -68,9 +74,10 @@ export function useSelectableList(props: AriaSelectableListOptions): SelectableL disabledBehavior, ref, collator, - layoutDelegate + layoutDelegate, + orientation }) - ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, ref, collator, disabledBehavior]); + ), [keyboardDelegate, layoutDelegate, collection, disabledKeys, ref, collator, disabledBehavior, orientation]); let {collectionProps} = useSelectableCollection({ ...props, diff --git a/packages/@react-aria/utils/src/scrollIntoView.ts b/packages/@react-aria/utils/src/scrollIntoView.ts index 2d69a34bc01..149b255a11a 100644 --- a/packages/@react-aria/utils/src/scrollIntoView.ts +++ b/packages/@react-aria/utils/src/scrollIntoView.ts @@ -11,7 +11,7 @@ */ import {getScrollParents} from './getScrollParents'; -import {isChrome, isIOS} from './platform'; +import {isIOS} from './platform'; interface ScrollIntoViewOpts { /** The position to align items along the block axis in. */ @@ -133,9 +133,7 @@ export function scrollIntoViewport(targetElement: Element | null, opts: ScrollIn if (targetElement && targetElement.isConnected) { let root = document.scrollingElement || document.documentElement; let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden'; - // If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view - // Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749 - if (!isScrollPrevented && !isChrome()) { + if (!isScrollPrevented) { let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect(); // use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus() diff --git a/packages/@react-aria/utils/src/useFormReset.ts b/packages/@react-aria/utils/src/useFormReset.ts index c37eb67cc50..5051312974a 100644 --- a/packages/@react-aria/utils/src/useFormReset.ts +++ b/packages/@react-aria/utils/src/useFormReset.ts @@ -19,8 +19,9 @@ export function useFormReset( initialValue: T, onReset: (value: T) => void ): void { - let handleReset = useEffectEvent(() => { - if (onReset) { + + let handleReset = useEffectEvent((e: Event) => { + if (onReset && !e.defaultPrevented) { onReset(initialValue); } }); diff --git a/packages/@react-aria/utils/test/useFormReset.test.tsx b/packages/@react-aria/utils/test/useFormReset.test.tsx new file mode 100644 index 00000000000..b0be933a3f3 --- /dev/null +++ b/packages/@react-aria/utils/test/useFormReset.test.tsx @@ -0,0 +1,137 @@ +/* + * 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. + */ + +import {fireEvent, render} from '@react-spectrum/test-utils-internal'; +import React, {useRef} from 'react'; +import {useFormReset} from '../'; + +describe('useFormReset', () => { + it('should call onReset on reset', () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( +
+ + +
+ ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).toHaveBeenCalled(); + }); + + it('should call onReset on reset even if event is stopped', () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( + e.stopPropagation()}> + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).toHaveBeenCalled(); + }); + + it('should call every onReset on reset', () => { + const onReset1 = jest.fn(); + const onReset2 = jest.fn(); + const Form = () => { + const ref1 = useRef(null); + useFormReset(ref1, '', onReset1); + const ref2 = useRef(null); + useFormReset(ref2, '', onReset2); + return ( + + + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset1).toHaveBeenCalled(); + expect(onReset2).toHaveBeenCalled(); + }); + + it.skip('should not call onReset if reset is cancelled', async () => { + // Simpler case at the moment, but you have to setup a capture listener to prevent the default behavior. + // Matching native behavior is too much of a change until someone asks for it. + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( + e.preventDefault()}> + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).not.toHaveBeenCalled(); + }); + + it('should not call onReset if reset is cancelled in capture phase', async () => { + const onReset = jest.fn(); + const Form = () => { + const ref = useRef(null); + useFormReset(ref, '', onReset); + return ( + e.preventDefault()}> + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset).not.toHaveBeenCalled(); + }); + + it('should not call any onReset if reset is cancelled', () => { + const onReset1 = jest.fn(); + const onReset2 = jest.fn(); + const Form = () => { + const ref1 = useRef(null); + useFormReset(ref1, '', onReset1); + const ref2 = useRef(null); + useFormReset(ref2, '', onReset2); + return ( + e.preventDefault()}> + + + + + ); + }; + const {getByRole} = render(
); + const button = getByRole('button'); + fireEvent.click(button); + expect(onReset1).not.toHaveBeenCalled(); + expect(onReset2).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index ba6c460104f..452536a730e 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -12,8 +12,9 @@ // @ts-ignore import {flushSync} from 'react-dom'; -import {getEventTarget, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {getEventTarget, nodeContains, useEffectEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {getScrollLeft} from './utils'; +import {Point, Rect, Size} from '@react-stately/virtualizer'; import React, { CSSProperties, ForwardedRef, @@ -25,17 +26,17 @@ import React, { useRef, useState } from 'react'; -import {Rect, Size} from '@react-stately/virtualizer'; import {useLocale} from '@react-aria/i18n'; -interface ScrollViewProps extends HTMLAttributes { +interface ScrollViewProps extends Omit, 'onScroll'> { contentSize: Size, onVisibleRectChange: (rect: Rect) => void, children?: ReactNode, innerStyle?: CSSProperties, onScrollStart?: () => void, onScrollEnd?: () => void, - scrollDirection?: 'horizontal' | 'vertical' | 'both' + scrollDirection?: 'horizontal' | 'vertical' | 'both', + onScroll?: (e: Event) => void } function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { @@ -70,39 +71,76 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject | null, - width: 0, - height: 0, isScrolling: false }).current; let {direction} = useLocale(); + let updateVisibleRect = useCallback(() => { + // Intersect the window viewport with the scroll view itself to find the actual visible rectangle. + // This allows virtualized components to have unbounded height but still virtualize when scrolled with the page. + // While there may be other scrollable elements between the and the scroll view, we do not take + // their sizes into account for performance reasons. Their scroll positions are accounted for in viewportOffset + // though (due to getBoundingClientRect). This may result in more rows than absolutely necessary being rendered, + // but no more than the entire height of the viewport which is good enough for virtualization use cases. + let visibleRect = new Rect( + state.viewportOffset.x + state.scrollPosition.x, + state.viewportOffset.y + state.scrollPosition.y, + Math.max(0, Math.min(state.size.width - state.viewportOffset.x, state.viewportSize.width)), + Math.max(0, Math.min(state.size.height - state.viewportOffset.y, state.viewportSize.height)) + ); + onVisibleRectChange(visibleRect); + }, [state, onVisibleRectChange]); + let [isScrolling, setScrolling] = useState(false); - let onScroll = useCallback((e) => { - if (getEventTarget(e) !== e.currentTarget) { + let onScroll = useCallback((e: Event) => { + let target = getEventTarget(e) as Element; + if (!nodeContains(target, ref.current!)) { return; } - if (props.onScroll) { - props.onScroll(e); + if (onScrollProp && target === ref.current) { + onScrollProp(e); } - flushSync(() => { - let scrollTop = e.currentTarget.scrollTop; - let scrollLeft = getScrollLeft(e.currentTarget, direction); + if (target !== ref.current) { + // An ancestor element or the window was scrolled. Update the position of the scroll view relative to the viewport. + let boundingRect = ref.current!.getBoundingClientRect(); + let x = boundingRect.x < 0 ? -boundingRect.x : 0; + let y = boundingRect.y < 0 ? -boundingRect.y : 0; + if (x === state.viewportOffset.x && y === state.viewportOffset.y) { + return; + } + state.viewportOffset = new Point(x, y); + } else { + // The scroll view itself was scrolled. Update the local scroll position. // Prevent rubber band scrolling from shaking when scrolling out of bounds - state.scrollTop = Math.max(0, Math.min(scrollTop, contentSize.height - state.height)); - state.scrollLeft = Math.max(0, Math.min(scrollLeft, contentSize.width - state.width)); - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); + let scrollTop = target.scrollTop; + let scrollLeft = getScrollLeft(target, direction); + state.scrollPosition = new Point( + Math.max(0, Math.min(scrollLeft, contentSize.width - state.size.width)), + Math.max(0, Math.min(scrollTop, contentSize.height - state.size.height)) + ); + } + + flushSync(() => { + updateVisibleRect(); if (!state.isScrolling) { state.isScrolling = true; @@ -138,10 +176,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { + document.addEventListener('scroll', onScroll, true); + return () => document.removeEventListener('scroll', onScroll, true); + }, [onScroll]); useEffect(() => { return () => { @@ -175,11 +216,18 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, w, h)); + updateVisibleRect(); }); // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as @@ -188,18 +236,30 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); + updateVisibleRect(); }); } } isUpdatingSize.current = false; - }, [ref, state, onVisibleRectChange]); + }, [ref, state, updateVisibleRect]); let updateSizeEvent = useEffectEvent(updateSize); + // Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle. + useLayoutEffect(() => { + // Initialize viewportRect before updating size for the first time. + state.viewportSize = new Size(window.innerWidth, window.innerHeight); + + let onWindowResize = () => { + updateSizeEvent(flushSync); + }; + + window.addEventListener('resize', onWindowResize); + return () => window.removeEventListener('resize', onWindowResize); + }, [state]); + // Update visible rect when the content size changes, in case scrollbars need to appear or disappear. let lastContentSize = useRef(null); let [update, setUpdate] = useState({}); @@ -250,7 +310,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject