Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions packages/@react-spectrum/ai/src/PromptField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -354,9 +353,8 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
pixelLoader,
placeholder,
menuWidth,
onKeyDown: onKeyDownProp
onKeyDown
} = props;
let {keyboardProps} = useKeyboard({onKeyDown: onKeyDownProp});
let {
prompt,
setPrompt,
Expand Down Expand Up @@ -453,6 +451,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
setFocused(false);
}
}}
onKeyDown={onKeyDown}
onPaste={
acceptedAttachmentTypes
? e => {
Expand All @@ -476,7 +475,6 @@ export function PromptTokenField(props: PromptTokenFieldProps) {
: undefined
}>
<TokenInput
{...keyboardProps}
data-placeholder={placeholder || stringFormatter.format('promptfield.placeholder')}
ref={inputRef}
className={renderProps =>
Expand Down
40 changes: 40 additions & 0 deletions packages/@react-spectrum/ai/test/PromptField.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PromptField>
<PromptTokenField onKeyDown={onKeyDown} />
</PromptField>
);

let input = getByRole('textbox');
await user.click(input);
await user.keyboard('a');

expect(onKeyDown).toHaveBeenCalled();
});
});
10 changes: 7 additions & 3 deletions packages/react-aria-components/src/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@
* 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<HTMLElement>, DOMRenderProps<any, any> {
elementType?: string;
}

export const TextContext = createContext<ContextValue<TextProps, HTMLElement>>({});

export const Text = forwardRef(function Text(props: TextProps, ref: ForwardedRef<HTMLElement>) {
export const Text = /*#__PURE__*/ createHideableComponent(function Text(
props: TextProps,
ref: ForwardedRef<HTMLElement>
) {
[props, ref] = useContextProps(props, ref, TextContext);

let {elementType = 'span', ...domProps} = props;
let ElementType = dom[elementType];
// @ts-ignore
Expand Down
17 changes: 17 additions & 0 deletions packages/react-aria-components/test/ComboBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1062,4 +1063,20 @@ describe('ComboBox', () => {
rerender(<TestComboBox isReadOnly />);
expect(input.closest('.react-aria-ComboBox')).toHaveAttribute('data-readonly');
});

it('should not throw when rendered inside a Dialog with a Text errorMessage slot', () => {
render(
<Dialog aria-label="Dialog">
<TestComboBox isInvalid />
</Dialog>
);
});

it('should not throw when rendered inside an alertdialog with a Text errorMessage slot', () => {
render(
<Dialog role="alertdialog" aria-label="Dialog">
<TestComboBox isInvalid />
</Dialog>
);
});
});
17 changes: 17 additions & 0 deletions packages/react-aria-components/test/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<Dialog aria-label="Dialog">
<TestSelect isInvalid />
</Dialog>
);
});

it('should not throw when rendered inside an alertdialog with a Text errorMessage slot', () => {
render(
<Dialog role="alertdialog" aria-label="Dialog">
<TestSelect isInvalid />
</Dialog>
);
});
});
65 changes: 24 additions & 41 deletions packages/react-aria/src/overlays/usePreventScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')
);
}
Expand Down Expand Up @@ -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}),
Expand All @@ -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<K extends keyof GlobalEventHandlersEventMap>(
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
});
};
}

Expand Down
38 changes: 38 additions & 0 deletions packages/react-aria/src/utils/domHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,41 @@ export function addEvent<T extends EventTarget, K extends keyof EventMapType<Exc
}
};
}

/**
* Sets a CSS property on an element and returns a cleanup function.
*/
export function setStyle(
target: HTMLElement | HTMLElement[] | null,
property: string,
value: string,
priority?: string
): () => void {
if (target == null) {
return () => {};
}

let restore = new Array<Function>();
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();
}
};
}
23 changes: 10 additions & 13 deletions packages/react-aria/src/utils/useEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<K extends keyof GlobalEventHandlersEventMap>(
ref: RefObject<EventTarget | null>,
event: K | (string & {}),
handler?: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,
export function useEvent<T extends EventTarget, K extends keyof EventMapType<T>>(
ref: RefObject<T | null>,
event: Extract<K, string> | (string & {}),
listener?: (this: T, ev: EventMapType<Exclude<T, null>>[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]);
}
Loading
Loading