diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index d9306e539e4..44cad4ad784 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -73,7 +73,6 @@ import {useControlledState} from 'react-stately/useControlledState'; import {useEffectEvent} from 'react-aria/private/utils/useEffectEvent'; import {useFocusableRef} from './useDOMRef'; import {useFocusWithin} from 'react-aria/useFocusWithin'; -import {useKeyboard} from 'react-aria/useKeyboard'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useVoiceInput, VoiceInputErrorCode} from './useVoiceInput'; @@ -354,9 +353,8 @@ export function PromptTokenField(props: PromptTokenFieldProps) { pixelLoader, placeholder, menuWidth, - onKeyDown: onKeyDownProp + onKeyDown } = props; - let {keyboardProps} = useKeyboard({onKeyDown: onKeyDownProp}); let { prompt, setPrompt, @@ -453,6 +451,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { setFocused(false); } }} + onKeyDown={onKeyDown} onPaste={ acceptedAttachmentTypes ? e => { @@ -476,7 +475,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) { : undefined }> diff --git a/packages/@react-spectrum/ai/test/PromptField.test.tsx b/packages/@react-spectrum/ai/test/PromptField.test.tsx new file mode 100644 index 00000000000..84cbabd317e --- /dev/null +++ b/packages/@react-spectrum/ai/test/PromptField.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2026 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 {PromptField, PromptTokenField} from '../src/PromptField'; +import React from 'react'; +import {render} from '@react-spectrum/test-utils-internal'; +import userEvent from '@testing-library/user-event'; + +const describeOrSkip = parseInt(React.version, 10) < 19 ? describe.skip : describe; +describeOrSkip('PromptField', () => { + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null}); + }); + + it('fires onKeyDown when a key is pressed in the token field', async () => { + let onKeyDown = jest.fn(); + let {getByRole} = render( + + + + ); + + let input = getByRole('textbox'); + await user.click(input); + await user.keyboard('a'); + + expect(onKeyDown).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-aria-components/src/Text.tsx b/packages/react-aria-components/src/Text.tsx index 7df7ba07873..5f4eaa170d3 100644 --- a/packages/react-aria-components/src/Text.tsx +++ b/packages/react-aria-components/src/Text.tsx @@ -9,9 +9,9 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ - import {ContextValue, dom, DOMRenderProps, useContextProps} from './utils'; -import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes} from 'react'; +import {createHideableComponent} from 'react-aria/private/collections/Hidden'; +import React, {createContext, ForwardedRef, HTMLAttributes} from 'react'; export interface TextProps extends HTMLAttributes, DOMRenderProps { elementType?: string; @@ -19,8 +19,12 @@ export interface TextProps extends HTMLAttributes, DOMRenderProps>({}); -export const Text = forwardRef(function Text(props: TextProps, ref: ForwardedRef) { +export const Text = /*#__PURE__*/ createHideableComponent(function Text( + props: TextProps, + ref: ForwardedRef +) { [props, ref] = useContextProps(props, ref, TextContext); + let {elementType = 'span', ...domProps} = props; let ElementType = dom[elementType]; // @ts-ignore diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index dfa993ecef8..4fe042f3c76 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -13,6 +13,7 @@ import {act} from '@testing-library/react'; import {Button} from '../src/Button'; import {ComboBox, ComboBoxContext, ComboBoxValue} from '../src/ComboBox'; +import {Dialog} from '../src/Dialog'; import {FieldError} from '../src/FieldError'; import {fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Form} from '../src/Form'; @@ -1062,4 +1063,20 @@ describe('ComboBox', () => { rerender(); expect(input.closest('.react-aria-ComboBox')).toHaveAttribute('data-readonly'); }); + + it('should not throw when rendered inside a Dialog with a Text errorMessage slot', () => { + render( + + + + ); + }); + + it('should not throw when rendered inside an alertdialog with a Text errorMessage slot', () => { + render( + + + + ); + }); }); diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 44f81b57a20..08bcbba6a48 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -12,6 +12,7 @@ import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Button} from '../src/Button'; +import {Dialog} from '../src/Dialog'; import {FieldError} from '../src/FieldError'; import {Form} from '../src/Form'; import {Label} from '../src/Label'; @@ -914,4 +915,20 @@ describe('Select', () => { let selectValue = getByTestId('select-value'); expect(selectValue).toHaveTextContent('select value: 1'); }); + + it('should not throw when rendered inside a Dialog with a Text errorMessage slot', () => { + render( + + + + ); + }); + + it('should not throw when rendered inside an alertdialog with a Text errorMessage slot', () => { + render( + + + + ); + }); }); diff --git a/packages/react-aria/src/overlays/usePreventScroll.ts b/packages/react-aria/src/overlays/usePreventScroll.ts index 7cde4d0ff2e..185c47e985a 100644 --- a/packages/react-aria/src/overlays/usePreventScroll.ts +++ b/packages/react-aria/src/overlays/usePreventScroll.ts @@ -10,13 +10,14 @@ * governing permissions and limitations under the License. */ +import {addEvent} from '../utils/domHelpers'; import {chain} from '../utils/chain'; - import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getNonce} from '../utils/getNonce'; import {getScrollParent} from '../utils/getScrollParent'; import {isIOS, isWebKit} from '../utils/platform'; import {isScrollable} from '../utils/isScrollable'; +import {setStyle} from '../utils/domHelpers'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {willOpenKeyboard} from '../utils/keyboard'; @@ -70,8 +71,8 @@ function preventScrollStandard() { scrollbarWidth > 0 && // Use scrollbar-gutter when supported because it also works for fixed positioned elements. ('scrollbarGutter' in document.documentElement.style - ? setStyle(document.documentElement, 'scrollbarGutter', 'stable') - : setStyle(document.documentElement, 'paddingRight', `${scrollbarWidth}px`)), + ? setStyle(document.documentElement, 'scrollbar-gutter', 'stable') + : setStyle(document.documentElement, 'padding-right', `${scrollbarWidth}px`)), setStyle(document.documentElement, 'overflow', 'hidden') ); } @@ -194,18 +195,22 @@ function preventScrollMobileWebKit() { // Override programmatic focus to scroll into view without scrolling the whole page. let focus = HTMLElement.prototype.focus; - HTMLElement.prototype.focus = function (opts) { - // Track whether the keyboard was already visible before. - let activeElement = getActiveElement(); - let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); - - // Focus the element without scrolling the page. - focus.call(this, {...opts, preventScroll: true}); - - if (!opts || !opts.preventScroll) { - scrollIntoViewWhenReady(this, wasKeyboardVisible); + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: function (opts?: FocusOptions) { + // Track whether the keyboard was already visible before. + let activeElement = getActiveElement(); + let wasKeyboardVisible = activeElement != null && willOpenKeyboard(activeElement); + + // Focus the element without scrolling the page. + focus.call(this, {...opts, preventScroll: true}); + + if (!opts || !opts.preventScroll) { + scrollIntoViewWhenReady(this, wasKeyboardVisible); + } } - }; + }); let removeEvents = chain( addEvent(document, 'touchstart', onTouchStart, {passive: false, capture: true}), @@ -217,33 +222,11 @@ function preventScrollMobileWebKit() { restoreOverflow(); removeEvents(); style.remove(); - HTMLElement.prototype.focus = focus; - }; -} - -// Sets a CSS property on an element, and returns a function to revert it to the previous value. -function setStyle(element: HTMLElement, style: string, value: string) { - let cur = element.style[style]; - element.style[style] = value; - - return () => { - element.style[style] = cur; - }; -} - -// Adds an event listener to an element, and returns a function to remove it. -function addEvent( - target: Document | Window, - event: K, - handler: (this: Document | Window, ev: GlobalEventHandlersEventMap[K]) => any, - options?: boolean | AddEventListenerOptions -) { - // internal function, so it's ok to ignore the difficult to fix type error - // @ts-ignore - target.addEventListener(event, handler, options); - return () => { - // @ts-ignore - target.removeEventListener(event, handler, options); + Reflect.defineProperty(HTMLElement.prototype, 'focus', { + configurable: true, + writable: true, + value: focus + }); }; } diff --git a/packages/react-aria/src/utils/domHelpers.ts b/packages/react-aria/src/utils/domHelpers.ts index 957e43fe983..a1f7f8d7701 100644 --- a/packages/react-aria/src/utils/domHelpers.ts +++ b/packages/react-aria/src/utils/domHelpers.ts @@ -91,3 +91,41 @@ export function addEvent void { + if (target == null) { + return () => {}; + } + + let restore = new Array(); + let styleTargets = Array.isArray(target) ? target : [target]; + + for (let styleTarget of styleTargets) { + let initialValue = styleTarget.style.getPropertyValue(property); + let initialPriority = styleTarget.style.getPropertyPriority(property); + + styleTarget.style.setProperty(property, value, priority); + + restore.unshift(() => { + if (initialValue) { + styleTarget.style.setProperty(property, initialValue, initialPriority); + } else { + styleTarget.style.removeProperty(property); + } + }); + } + + return () => { + for (let cleanup of restore) { + cleanup(); + } + }; +} diff --git a/packages/react-aria/src/utils/useEvent.ts b/packages/react-aria/src/utils/useEvent.ts index 1dd35499847..9ebfe86f74b 100644 --- a/packages/react-aria/src/utils/useEvent.ts +++ b/packages/react-aria/src/utils/useEvent.ts @@ -10,28 +10,25 @@ * governing permissions and limitations under the License. */ -import {RefObject} from '@react-types/shared'; +import {addEvent} from './domHelpers'; +import {EventMapType, RefObject} from '@react-types/shared'; import {useEffect} from 'react'; import {useEffectEvent} from './useEffectEvent'; -export function useEvent( - ref: RefObject, - event: K | (string & {}), - handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any, +export function useEvent>( + ref: RefObject, + event: Extract | (string & {}), + listener?: (this: T, ev: EventMapType>[K]) => any, options?: boolean | AddEventListenerOptions ): void { - let handleEvent = useEffectEvent(handler); - let isDisabled = handler == null; + let handleEvent = useEffectEvent(listener); + let isDisabled = listener == null; useEffect(() => { - if (isDisabled || !ref.current) { + if (isDisabled || ref.current == null) { return; } - let element = ref.current; - element.addEventListener(event, handleEvent as EventListener, options); - return () => { - element.removeEventListener(event, handleEvent as EventListener, options); - }; + return addEvent(ref.current, event, handleEvent, options); }, [ref, event, options, isDisabled]); } diff --git a/packages/react-aria/test/utils/domHelpers.test.js b/packages/react-aria/test/utils/domHelpers.test.js index ec13d78b1af..4167277aae4 100644 --- a/packages/react-aria/test/utils/domHelpers.test.js +++ b/packages/react-aria/test/utils/domHelpers.test.js @@ -13,7 +13,7 @@ import {act} from 'react-dom/test-utils'; import {enableShadowDOM} from 'react-stately/private/flags/flags'; import {getActiveElement} from '../../src/utils/shadowdom/DOMFunctions'; -import {getOwnerDocument, getOwnerWindow} from '../../src/utils/domHelpers'; +import {getOwnerDocument, getOwnerWindow, setStyle} from '../../src/utils/domHelpers'; describe('getOwnerDocument', () => { beforeAll(() => { @@ -191,3 +191,59 @@ describe('getActiveElement', () => { iframe.remove(); }); }); + +describe('setStyle', () => { + it('returns a no-op cleanup and does not throw when the target is null', () => { + const cleanup = setStyle(null, 'opacity', '0'); + expect(cleanup).toBeInstanceOf(Function); + expect(() => cleanup()).not.toThrow(); + }); + + it('sets a CSS property on a single element and removes the property on cleanup when there was no initial value', () => { + const el = document.createElement('div'); + const cleanup = setStyle(el, 'opacity', '0'); + expect(el.style.getPropertyValue('opacity')).toBe('0'); + + cleanup(); + expect(el.style.getPropertyValue('opacity')).toBe(''); + expect(el.getAttribute('style')).toBeFalsy(); + }); + + it('restores the previous value on cleanup when one existed', () => { + const el = document.createElement('div'); + el.style.setProperty('opacity', '0.5'); + + const cleanup = setStyle(el, 'opacity', '0'); + expect(el.style.getPropertyValue('opacity')).toBe('0'); + + cleanup(); + expect(el.style.getPropertyValue('opacity')).toBe('0.5'); + }); + + it('applies the given priority and restores the previous priority on cleanup', () => { + const el = document.createElement('div'); + el.style.setProperty('color', 'red', 'important'); + + const cleanup = setStyle(el, 'color', 'blue', 'important'); + expect(el.style.getPropertyValue('color')).toBe('blue'); + expect(el.style.getPropertyPriority('color')).toBe('important'); + + cleanup(); + expect(el.style.getPropertyValue('color')).toBe('red'); + expect(el.style.getPropertyPriority('color')).toBe('important'); + }); + + it('ets the property on every element in an array and restores on cleanup, preserving each prior value', () => { + const withPrior = document.createElement('div'); + withPrior.style.setProperty('display', 'flex'); + const withoutPrior = document.createElement('div'); + + const cleanup = setStyle([withPrior, withoutPrior], 'display', 'none'); + expect(withPrior.style.getPropertyValue('display')).toBe('none'); + expect(withoutPrior.style.getPropertyValue('display')).toBe('none'); + + cleanup(); + expect(withPrior.style.getPropertyValue('display')).toBe('flex'); + expect(withoutPrior.style.getPropertyValue('display')).toBe(''); + }); +}); diff --git a/packages/react-aria/test/utils/useEvent.test.tsx b/packages/react-aria/test/utils/useEvent.test.tsx new file mode 100644 index 00000000000..eb8900b7761 --- /dev/null +++ b/packages/react-aria/test/utils/useEvent.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright 2026 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 {act, render} from '@react-spectrum/test-utils-internal'; +import React, {RefObject, useRef} from 'react'; +import {useEvent} from '../../src/utils/useEvent'; + +interface ExampleProps { + event: string; + listener?: (e: Event) => void; + options?: boolean | AddEventListenerOptions; +} + +function Example({event, listener, options}: ExampleProps) { + let ref = useRef(null); + useEvent(ref as RefObject, event, listener, options); + return
; +} + +describe('useEvent', () => { + it('subscribes on mount and unsubscribes on unmount', () => { + let listener = jest.fn(); + let {getByTestId, unmount} = render(); + let target = getByTestId('target'); + + act(() => { + target.dispatchEvent(new Event('customevent')); + }); + expect(listener).toHaveBeenCalledTimes(1); + + unmount(); + act(() => { + target.dispatchEvent(new Event('customevent')); + }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('removes the listener when it becomes undefined', () => { + let listener = jest.fn(); + let {getByTestId, rerender} = render(); + let target = getByTestId('target'); + + rerender(); + act(() => { + target.dispatchEvent(new Event('customevent')); + }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('re-subscribes when the event name changes', () => { + let listener = jest.fn(); + let {getByTestId, rerender} = render(); + let target = getByTestId('target'); + + // Change the event name we're listening for to "second" and re-render the component + rerender(); + + act(() => { + target.dispatchEvent(new Event('first')); + }); + expect(listener).not.toHaveBeenCalled(); + + act(() => { + target.dispatchEvent(new Event('second')); + }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('forwards listener options such as once', () => { + let listener = jest.fn(); + let {getByTestId} = render( + + ); + let target = getByTestId('target'); + + act(() => { + target.dispatchEvent(new Event('customevent')); + }); + act(() => { + target.dispatchEvent(new Event('customevent')); + }); + + expect(listener).toHaveBeenCalledTimes(1); + }); +});