From 832519ee46cca32cba1ef53d7f8c2d1eaa90e6fd Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:48:11 -0700 Subject: [PATCH 1/4] chore: followup on MessageSource/MessageSuggestion from testing (#10178) * chore: followup on MessageSource/MessageSuggestion from testing * fix formatting * add hover state to ResponseStatus * update labeler * update labeler --- .github/labeler.yml | 2 +- packages/@react-spectrum/ai/src/MessageSource.tsx | 4 ++-- .../@react-spectrum/ai/src/MessageSuggestion.tsx | 14 ++++++++------ packages/@react-spectrum/ai/src/ResponseStatus.tsx | 12 ++++++++++-- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 49df0a71d12..c3350301447 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -20,7 +20,7 @@ v3: - all: - changed-files: - all-globs-to-any-file: ['**/@react-spectrum/**', '!**/*.stories.*', '!**/*.test.*'] - - all-globs-to-all-files: '!**/@react-spectrum/s2/**' + - all-globs-to-all-files: ['!**/@react-spectrum/s2/**', '!**/@react-spectrum/ai/**'] Accordion: - changed-files: diff --git a/packages/@react-spectrum/ai/src/MessageSource.tsx b/packages/@react-spectrum/ai/src/MessageSource.tsx index c6167945dce..687557b2a7e 100644 --- a/packages/@react-spectrum/ai/src/MessageSource.tsx +++ b/packages/@react-spectrum/ai/src/MessageSource.tsx @@ -163,7 +163,7 @@ export const SourceListItem = (forwardRef as forwardRefType)(function SourceList return (
  • - + linkStyles({size, ...renderProps})}> {children} @@ -244,7 +244,7 @@ export const NumberBadge = forwardRef(function NumberBadge( props: NumberBadgeProps, ref: DOMRef ) { - let {size = 'S', value, ...otherProps} = props; + let {size = 'M', value, ...otherProps} = props; let domRef = useDOMRef(ref); let {locale} = useLocale(); let formattedValue = ''; diff --git a/packages/@react-spectrum/ai/src/MessageSuggestion.tsx b/packages/@react-spectrum/ai/src/MessageSuggestion.tsx index 1a8947c42c3..c1daf4303b1 100644 --- a/packages/@react-spectrum/ai/src/MessageSuggestion.tsx +++ b/packages/@react-spectrum/ai/src/MessageSuggestion.tsx @@ -12,9 +12,14 @@ import {AriaLabelingProps, DOMProps, DOMRef} from '@react-types/shared'; import ArrowCurved from '@react-spectrum/s2/icons/ArrowCurved'; +import { + baseColor, + centerPadding, + focusRing, + style +} from '@react-spectrum/s2/style' with {type: 'macro'}; import {ButtonProps, Button as RACButton} from 'react-aria-components/Button'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; -import {centerPadding, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {createContext, forwardRef, ReactNode, useContext} from 'react'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {IconContext} from '@react-spectrum/s2/Icon'; @@ -79,14 +84,11 @@ const suggestionStyles = style<{ XL: 'body-xl' } }, - backgroundColor: { - default: 'gray-100', - isHovered: 'gray-200', - isPressed: 'gray-300' - }, + backgroundColor: baseColor('gray-100'), color: 'neutral', borderStyle: 'none', disableTapHighlight: true, + transition: 'default', ...focusRing() }); diff --git a/packages/@react-spectrum/ai/src/ResponseStatus.tsx b/packages/@react-spectrum/ai/src/ResponseStatus.tsx index 1fd3b2ba6d5..13bcde2b384 100644 --- a/packages/@react-spectrum/ai/src/ResponseStatus.tsx +++ b/packages/@react-spectrum/ai/src/ResponseStatus.tsx @@ -15,6 +15,7 @@ import { baseColor, focusRing, iconStyle, + lightDark, space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -151,6 +152,7 @@ const buttonStyles = style({ }, color: { default: baseColor('neutral'), + isLoading: 'neutral', forcedColors: 'ButtonText', isDisabled: { default: 'disabled', @@ -195,7 +197,13 @@ const buttonStyles = style({ } }, width: 'full', - backgroundColor: 'transparent', + backgroundColor: { + default: 'transparent', + isFocusVisible: lightDark('transparent-black-100', 'transparent-white-100'), + isHovered: lightDark('transparent-black-100', 'transparent-white-100'), + isPressed: lightDark('transparent-black-300', 'transparent-white-300'), + isLoading: 'transparent' + }, transition: 'default', borderWidth: 0, borderRadius: 'default', @@ -256,7 +264,7 @@ export const ResponseStatusTitle = forwardRef(function ResponseStatusTitle( return ( + + + + + ); + + let wrapper = container.querySelector('[data-testid="wrapper"]') as HTMLElement; + let buttonGroup = container.querySelector('[data-testid="button-group"]') as HTMLElement; + + // Initially wide — buttons should be laid out horizontally + expect(getComputedStyle(buttonGroup).flexDirection).toBe('row'); + + // Shrink the container below the combined button widths to trigger overflow detection + wrapper.style.width = '50px'; + + // Wait for ResizeObserver and React re-render to settle + await expect.poll(() => getComputedStyle(buttonGroup).flexDirection).toBe('column'); + + // Restore the container width — should return to horizontal + wrapper.style.width = '500px'; + await expect.poll(() => getComputedStyle(buttonGroup).flexDirection).toBe('row'); + }); +}); From 04a9b04f2db7b124a4148564324ac93bcaf52fc7 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 12 Jun 2026 15:31:52 -0700 Subject: [PATCH 3/4] Revert "feat: Keyboard shortcut handler (#9929)" (#10197) This reverts commit 9e1b07014067f824a50cae586a4532a9d0b9f841. --- .../src/actionbar/ActionBar.tsx | 5 +- packages/@react-spectrum/s2/src/ActionBar.tsx | 7 +- .../test/ListBox.test.js | 37 -- .../src/actiongroup/useActionGroup.ts | 40 +- .../src/calendar/useCalendarGrid.ts | 84 ++-- packages/react-aria/src/color/useColorArea.ts | 88 ++-- .../react-aria/src/color/useColorWheel.ts | 30 +- .../react-aria/src/combobox/useComboBox.ts | 77 ++- .../react-aria/src/datepicker/useDateField.ts | 13 +- .../src/datepicker/useDatePickerGroup.ts | 64 ++- .../src/datepicker/useDateSegment.ts | 39 +- .../src/interactions/createEventHandler.ts | 8 +- .../createKeyboardShortcutHandler.ts | 217 --------- .../src/interactions/useKeyboard.ts | 59 +-- packages/react-aria/src/menu/useMenuItem.ts | 58 ++- .../react-aria/src/menu/useMenuTrigger.ts | 90 ++-- .../react-aria/src/menu/useSubmenuTrigger.ts | 91 ++-- .../src/numberfield/useNumberField.ts | 29 +- .../react-aria/src/overlays/useOverlay.ts | 19 +- .../react-aria/src/radio/useRadioGroup.ts | 57 +-- .../src/searchfield/useSearchField.ts | 63 +-- packages/react-aria/src/select/useSelect.ts | 39 +- .../src/selection/useSelectableCollection.ts | 457 ++++++++---------- .../react-aria/src/slider/useSliderThumb.ts | 60 +-- .../src/spinbutton/useSpinButton.ts | 87 ++-- .../src/steplist/useStepListItem.ts | 12 + .../src/table/useTableColumnResize.ts | 31 +- packages/react-aria/src/tag/useTag.ts | 33 +- .../useSearchAutocomplete.test.js | 4 +- .../test/combobox/useComboBox.test.js | 48 +- .../test/interactions/useKeyboard.test.js | 278 ----------- .../test/searchfield/useSearchField.test.js | 7 +- 32 files changed, 823 insertions(+), 1408 deletions(-) delete mode 100644 packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts diff --git a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx index c59b9c93a41..016a8c620ec 100644 --- a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx +++ b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx @@ -110,8 +110,9 @@ function ActionBarInner(props: ActionBarInnerProps, ref: Ref { + onKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); onClearSelection(); } } diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index e7b65502b36..323cab27972 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -167,9 +167,12 @@ const ActionBarInner = forwardRef(function ActionBarInner( }); let {keyboardProps} = useKeyboard({ - shortcuts: { - Escape: () => { + onKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); onClearSelection?.(); + } else { + e.continuePropagation(); } } }); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index f851badd821..38a125105df 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -2396,40 +2396,3 @@ describe('ListBox', () => { }); } }); - -describe('keyboard modifier keys', () => { - let user; - let platformMock; - beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); - }); - // selectionMode: 'none', 'single', 'multiple' - // selectionBehavior: 'toggle', 'replace' - // platform: 'mac', 'windows' - - // modifier key: 'alt', 'ctrl', 'meta', 'shift' - // key: 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'home', 'end', 'page-up', 'page-down', 'enter', 'space', 'tab' - // expected behavior: 'navigate', 'select', 'toggle', 'replace' - describe('mac', () => { - beforeAll(() => { - platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); - }); - afterAll(() => { - platformMock.mockRestore(); - }); - it('should not navigate when using unsupported modifier keys', async () => { - let {getByRole} = renderListbox({selectionMode: 'none'}); - await user.tab(); - let listbox = getByRole('listbox'); - let options = within(listbox).getAllByRole('option'); - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(options[1]); - await user.keyboard('{Meta>}{ArrowDown}{/Meta}'); - expect(document.activeElement).toBe(options[1]); - await user.keyboard('{Meta>}{ArrowUp}{/Meta}'); - expect(document.activeElement).toBe(options[1]); - await user.keyboard('{Control>}{Home}{/Control}'); - expect(document.activeElement).toBe(options[1]); - }); - }); -}); diff --git a/packages/react-aria/src/actiongroup/useActionGroup.ts b/packages/react-aria/src/actiongroup/useActionGroup.ts index cf2dbe38864..15985ff3c46 100644 --- a/packages/react-aria/src/actiongroup/useActionGroup.ts +++ b/packages/react-aria/src/actiongroup/useActionGroup.ts @@ -24,11 +24,11 @@ import { } from '@react-types/shared'; import {createFocusManager} from '../focus/FocusScope'; import {filterDOMProps} from '../utils/filterDOMProps'; +import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {KeyboardEventHandler, useState} from 'react'; import {ListState} from 'react-stately/useListState'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; -import {useState} from 'react'; const BUTTON_GROUP_ROLES = { none: 'toolbar', @@ -91,30 +91,34 @@ export function useActionGroup( let {direction} = useLocale(); let focusManager = createFocusManager(ref); let flipDirection = direction === 'rtl' && orientation === 'horizontal'; - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowRight: () => { - if (flipDirection) { + let onKeyDown: KeyboardEventHandler = e => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return; + } + + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'ArrowRight' && flipDirection) { focusManager.focusPrevious({wrap: true}); } else { focusManager.focusNext({wrap: true}); } - }, - ArrowDown: () => { - focusManager.focusNext({wrap: true}); - }, - ArrowLeft: () => { - if (flipDirection) { + break; + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'ArrowLeft' && flipDirection) { focusManager.focusNext({wrap: true}); } else { focusManager.focusPrevious({wrap: true}); } - }, - ArrowUp: () => { - focusManager.focusPrevious({wrap: true}); - } + break; } - }); + }; let role: string | undefined = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode]; if (isInToolbar && role === 'toolbar') { @@ -126,7 +130,7 @@ export function useActionGroup( role, 'aria-orientation': role === 'toolbar' ? orientation : undefined, 'aria-disabled': isDisabled, - ...keyboardProps + onKeyDown } }; } diff --git a/packages/react-aria/src/calendar/useCalendarGrid.ts b/packages/react-aria/src/calendar/useCalendarGrid.ts index 1a1efb20057..d610ab780ab 100644 --- a/packages/react-aria/src/calendar/useCalendarGrid.ts +++ b/packages/react-aria/src/calendar/useCalendarGrid.ts @@ -14,13 +14,12 @@ import {CalendarDate, startOfWeek, today} from '@internationalized/date'; import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; import {DOMAttributes} from '@react-types/shared'; import {hookData, useVisibleRangeDescription} from './utils'; +import {KeyboardEvent, useMemo} from 'react'; import {mergeProps} from '../utils/mergeProps'; import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; import {useDateFormatter} from '../i18n/useDateFormatter'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLocale} from '../i18n/I18nProvider'; -import {useMemo} from 'react'; export interface AriaCalendarGridProps { /** @@ -79,61 +78,70 @@ export function useCalendarGrid( let {direction} = useLocale(); - let {keyboardProps} = useKeyboard({ - shortcuts: { - Enter: () => { + let onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + case ' ': + e.preventDefault(); state.selectFocusedDate(); - }, - ' ': () => { - state.selectFocusedDate(); - }, - PageUp: () => { - state.focusPreviousSection(); - }, - 'Shift+PageUp': () => { - state.focusPreviousSection(true); - }, - PageDown: () => { - state.focusNextSection(); - }, - 'Shift+PageDown': () => { - state.focusNextSection(true); - }, - End: () => { + break; + case 'PageUp': + e.preventDefault(); + e.stopPropagation(); + state.focusPreviousSection(e.shiftKey); + break; + case 'PageDown': + e.preventDefault(); + e.stopPropagation(); + state.focusNextSection(e.shiftKey); + break; + case 'End': + e.preventDefault(); + e.stopPropagation(); state.focusSectionEnd(); - }, - Home: () => { + break; + case 'Home': + e.preventDefault(); + e.stopPropagation(); state.focusSectionStart(); - }, - ArrowLeft: () => { + break; + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { state.focusNextDay(); } else { state.focusPreviousDay(); } - }, - ArrowUp: () => { + break; + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); state.focusPreviousRow(); - }, - ArrowRight: () => { + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { state.focusPreviousDay(); } else { state.focusNextDay(); } - }, - ArrowDown: () => { + break; + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); state.focusNextRow(); - }, - Escape: () => { + break; + case 'Escape': // Cancel the selection. if ('setAnchorDate' in state) { + e.preventDefault(); state.setAnchorDate(null); } - return false; // TODO: is this really correct? or should it return true when we cancel and only propagate if there's nothing to do - } + break; } - }); + }; let visibleRangeDescription = useVisibleRangeDescription( startDate, @@ -174,7 +182,7 @@ export function useCalendarGrid( 'aria-disabled': state.isDisabled || undefined, 'aria-multiselectable': 'highlightedRange' in state || state.selectionMode === 'multiple' || undefined, - ...keyboardProps, + onKeyDown, onFocus: () => state.setFocused(true), onBlur: () => state.setFocused(false) }), diff --git a/packages/react-aria/src/color/useColorArea.ts b/packages/react-aria/src/color/useColorArea.ts index c7a293abf70..8351e622c25 100644 --- a/packages/react-aria/src/color/useColorArea.ts +++ b/packages/react-aria/src/color/useColorArea.ts @@ -111,56 +111,46 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState) let currentPosition = useRef<{x: number; y: number} | null>(null); - let keyboardUpdate = (cb, inputRef: RefObject, input: 'x' | 'y') => { - state.setDragging(true); - setValueChangedViaKeyboard(true); - cb(); - state.setDragging(false); - focusInput(inputRef); - setFocusedInput(input); - }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - PageUp: () => { - return keyboardUpdate( - () => { - state.incrementY(state.yChannelPageStep); - }, - inputYRef, - 'y' - ); - }, - PageDown: () => { - return keyboardUpdate( - () => { - state.decrementY(state.yChannelPageStep); - }, - inputYRef, - 'y' - ); - }, - Home: () => { - return keyboardUpdate( - () => { - direction === 'rtl' - ? state.incrementX(state.xChannelPageStep) - : state.decrementX(state.xChannelPageStep); - }, - inputXRef, - 'x' - ); - }, - End: () => { - return keyboardUpdate( - () => { - direction === 'rtl' - ? state.decrementX(state.xChannelPageStep) - : state.incrementX(state.xChannelPageStep); - }, - inputXRef, - 'x' - ); + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + state.setDragging(true); + setValueChangedViaKeyboard(true); + let dir; + switch (e.key) { + case 'PageUp': + state.incrementY(state.yChannelPageStep); + dir = 'y'; + break; + case 'PageDown': + state.decrementY(state.yChannelPageStep); + dir = 'y'; + break; + case 'Home': + direction === 'rtl' + ? state.incrementX(state.xChannelPageStep) + : state.decrementX(state.xChannelPageStep); + dir = 'x'; + break; + case 'End': + direction === 'rtl' + ? state.decrementX(state.xChannelPageStep) + : state.incrementX(state.xChannelPageStep); + dir = 'x'; + break; + } + state.setDragging(false); + if (dir) { + let input = dir === 'x' ? inputXRef : inputYRef; + focusInput(input); + setFocusedInput(dir); } } }); diff --git a/packages/react-aria/src/color/useColorWheel.ts b/packages/react-aria/src/color/useColorWheel.ts index cd313393755..5a1edc2ca59 100644 --- a/packages/react-aria/src/color/useColorWheel.ts +++ b/packages/react-aria/src/color/useColorWheel.ts @@ -75,17 +75,27 @@ export function useColorWheel( let currentPosition = useRef<{x: number; y: number} | null>(null); let {keyboardProps} = useKeyboard({ - shortcuts: { - PageUp: () => { - state.setDragging(true); - state.increment(state.pageStep); - state.setDragging(false); - }, - PageDown: () => { - state.setDragging(true); - state.decrement(state.pageStep); - state.setDragging(false); + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + state.setDragging(true); + switch (e.key) { + case 'PageUp': + e.preventDefault(); + state.increment(state.pageStep); + break; + case 'PageDown': + e.preventDefault(); + state.decrement(state.pageStep); + break; } + state.setDragging(false); } }); diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index 42fe109769c..7763da45591 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -15,6 +15,7 @@ import {AriaButtonProps} from '../button/useButton'; import {ariaHideOutside} from '../overlays/ariaHideOutside'; import { AriaLabelingProps, + BaseEvent, DOMAttributes, DOMProps, InputDOMProps, @@ -32,6 +33,7 @@ import {dispatchVirtualFocus} from '../focus/virtualFocus'; import { FocusEvent, InputHTMLAttributes, + KeyboardEvent, TouchEvent, useEffect, useMemo, @@ -51,7 +53,6 @@ import {privateValidationStateProp} from 'react-stately/private/form/useFormVali import {useEvent} from '../utils/useEvent'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useMenuTrigger} from '../menu/useMenuTrigger'; @@ -175,12 +176,19 @@ export function useComboBox( let router = useRouter(); - // for textfield specific operations - let {keyboardProps} = useKeyboard({ - shortcuts: { - Enter: e => { - // Prevent default form submission if menu is open since we may be selecting a option - let shouldPreventDefault = state.isOpen; + // For textfield specific keydown operations + let onKeyDown = (e: BaseEvent>) => { + if (e.nativeEvent.isComposing) { + return; + } + switch (e.key) { + case 'Enter': + case 'Tab': + // Prevent form submission if menu is open since we may be selecting a option + if (state.isOpen && e.key === 'Enter') { + e.preventDefault(); + } + // If the focused item is a link, trigger opening it. Items that are links are not selectable. if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) { let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); @@ -188,7 +196,7 @@ export function useComboBox( let item = listBoxRef.current.querySelector( `[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]` ); - if (item instanceof HTMLAnchorElement) { + if (e.key === 'Enter' && item instanceof HTMLAnchorElement) { router.open( item, e, @@ -197,56 +205,35 @@ export function useComboBox( ); } state.close(); - return {shouldPreventDefault}; + break; } else if (collectionItem?.props.onAction) { collectionItem.props.onAction(); state.close(); - return {shouldPreventDefault}; + break; } } - state.commit(); - return {shouldPreventDefault}; - }, - Tab: () => { - // If the focused item is a link, trigger opening it. Items that are links are not selectable. - if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) { - let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); - if (collectionItem?.props.href) { - state.close(); - return {shouldPreventDefault: false}; - } else if (collectionItem?.props.onAction) { - collectionItem.props.onAction(); - state.close(); - return {shouldPreventDefault: false}; - } - } - if (state.isOpen) { + if (e.key === 'Enter' || state.isOpen) { state.commit(); } - return {shouldPreventDefault: false}; - }, - Escape: () => { - let shouldContinuePropagation = false; + break; + case 'Escape': if (!state.selectionManager.isEmpty || state.inputValue === '' || props.allowsCustomValue) { - shouldContinuePropagation = true; + e.continuePropagation(); } state.revert(); - return {shouldContinuePropagation}; - }, - ArrowDown: () => { + break; + case 'ArrowDown': state.open('first', 'manual'); - }, - ArrowUp: () => { + break; + case 'ArrowUp': state.open('last', 'manual'); - }, - ArrowLeft: () => { - state.selectionManager.setFocusedKey(null); - }, - ArrowRight: () => { + break; + case 'ArrowLeft': + case 'ArrowRight': state.selectionManager.setFocusedKey(null); - } + break; } - }); + }; let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; @@ -290,7 +277,7 @@ export function useComboBox( : props.isRequired, onChange: state.setInputValue, onKeyDown: !isReadOnly - ? chain(state.isOpen && collectionProps.onKeyDown, keyboardProps.onKeyDown, props.onKeyDown) + ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, onBlur, value: state.inputValue, diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 5383ee5aa7e..cf2d5475dc9 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -16,6 +16,7 @@ import { DOMProps, GroupDOMAttributes, InputDOMProps, + KeyboardEvent, RefObject, ValidationResult } from '@react-types/shared'; @@ -213,8 +214,16 @@ export function useDateField( } }, fieldProps: mergeProps(domProps, fieldDOMProps, groupProps, focusWithinProps, { - onKeyDown: props.onKeyDown, - onKeyUp: props.onKeyUp, + onKeyDown(e: KeyboardEvent) { + if (props.onKeyDown) { + props.onKeyDown(e); + } + }, + onKeyUp(e: KeyboardEvent) { + if (props.onKeyUp) { + props.onKeyUp(e); + } + }, style: { unicodeBidi: 'isolate' } diff --git a/packages/react-aria/src/datepicker/useDatePickerGroup.ts b/packages/react-aria/src/datepicker/useDatePickerGroup.ts index 8d353286e20..3bf75fbe3df 100644 --- a/packages/react-aria/src/datepicker/useDatePickerGroup.ts +++ b/packages/react-aria/src/datepicker/useDatePickerGroup.ts @@ -2,10 +2,9 @@ import {createFocusManager, getFocusableTreeWalker} from '../focus/FocusScope'; import {DateFieldState} from 'react-stately/useDateFieldState'; import {DatePickerState} from 'react-stately/useDatePickerState'; import {DateRangePickerState} from 'react-stately/useDateRangePickerState'; -import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; +import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared'; +import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {mergeProps} from '../utils/mergeProps'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocale} from '../i18n/I18nProvider'; import {useMemo} from 'react'; import {usePress} from '../interactions/usePress'; @@ -18,24 +17,26 @@ export function useDatePickerGroup( let {direction} = useLocale(); let focusManager = useMemo(() => createFocusManager(ref), [ref]); - let {keyboardProps} = useKeyboard({ - shortcuts: { - 'Alt+ArrowDown': () => { - if ('setOpen' in state) { - state.setOpen(true); - } - return false; - }, - 'Alt+ArrowUp': () => { - if ('setOpen' in state) { - state.setOpen(true); - } - return false; - }, - ArrowLeft: e => { - if (disableArrowNavigation) { - return false; - } + // Open the popover on alt + arrow down + let onKeyDown = (e: KeyboardEvent) => { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + return; + } + + if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && 'setOpen' in state) { + e.preventDefault(); + e.stopPropagation(); + state.setOpen(true); + } + + if (disableArrowNavigation) { + return; + } + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { let target = getEventTarget(e) as FocusableElement; @@ -43,19 +44,15 @@ export function useDatePickerGroup( if (prev) { prev.focus(); - return; } } } else { focusManager.focusPrevious(); - return; - } - return false; - }, - ArrowRight: e => { - if (disableArrowNavigation) { - return false; } + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { let target = getEventTarget(e) as FocusableElement; @@ -63,17 +60,14 @@ export function useDatePickerGroup( if (next) { next.focus(); - return; } } } else { focusManager.focusNext(); - return; } - return false; - } + break; } - }); + }; // Focus the first placeholder segment from the end on mouse down/touch up in the field. let focusLast = () => { @@ -129,7 +123,7 @@ export function useDatePickerGroup( } }); - return mergeProps(pressProps, keyboardProps); + return mergeProps(pressProps, {onKeyDown}); } function findNextSegment(group: Element, fromX: number, direction: number) { diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts index 9119e2b7d76..0ecc714760f 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -15,7 +15,7 @@ import {DateFieldState, DateSegment} from 'react-stately/useDateFieldState'; import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getScrollParent} from '../utils/getScrollParent'; import {hookData} from './useDateField'; -import {isIOS} from '../utils/platform'; +import {isIOS, isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; @@ -26,7 +26,6 @@ import {useDisplayNames} from './useDisplayNames'; import {useEvent} from '../utils/useEvent'; import {useFilter} from '../i18n/useFilter'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; @@ -126,20 +125,28 @@ export function useDateSegment( } }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - Backspace: () => { - backspace(); - }, - Delete: () => { + let onKeyDown = e => { + // Firefox does not fire selectstart for Ctrl/Cmd + A + // https://bugzilla.mozilla.org/show_bug.cgi?id=1742153 + if (e.key === 'a' && (isMac() ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + } + + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) { + return; + } + + switch (e.key) { + case 'Backspace': + case 'Delete': { + // Safari on iOS does not fire beforeinput for the backspace key because the cursor is at the start. + e.preventDefault(); + e.stopPropagation(); backspace(); - }, - 'Mod+a': () => { - // Firefox does not fire selectstart for Ctrl/Cmd + A - // https://bugzilla.mozilla.org/show_bug.cgi?id=1742153 + break; } } - }); + }; // Safari dayPeriod option doesn't work... let {startsWith} = useFilter({sensitivity: 'base'}); @@ -387,14 +394,13 @@ export function useDateSegment( segmentProps: mergeProps(spinButtonProps, labelProps, { id, ...touchPropOverrides, - ...keyboardProps, 'aria-invalid': state.isInvalid ? 'true' : undefined, 'aria-describedby': ariaDescribedBy, 'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined, 'data-placeholder': segment.isPlaceholder || undefined, contentEditable: isEditable, suppressContentEditableWarning: isEditable, - spellCheck: isEditable ? ('false' as const) : undefined, + spellCheck: isEditable ? 'false' : undefined, autoCorrect: isEditable ? 'off' : undefined, // Capitalization was changed in React 17... [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable @@ -403,8 +409,9 @@ export function useDateSegment( inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined - : ('numeric' as const), + : 'numeric', tabIndex: state.isDisabled ? undefined : 0, + onKeyDown, onFocus, style: segmentStyle, // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment. diff --git a/packages/react-aria/src/interactions/createEventHandler.ts b/packages/react-aria/src/interactions/createEventHandler.ts index 0c9d6900f9d..88831ab4d9f 100644 --- a/packages/react-aria/src/interactions/createEventHandler.ts +++ b/packages/react-aria/src/interactions/createEventHandler.ts @@ -24,8 +24,8 @@ export function createEventHandler( return undefined; } + let shouldStopPropagation = true; return (e: T) => { - let shouldStopPropagation = true; let event: BaseEvent = { ...e, preventDefault() { @@ -53,11 +53,7 @@ export function createEventHandler( handler(event); - // nested createEventHandler calls may already have stopped propagation - if ( - shouldStopPropagation && - !(typeof e.isPropagationStopped === 'function' && e.isPropagationStopped()) - ) { + if (shouldStopPropagation) { e.stopPropagation(); } }; diff --git a/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts b/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts deleted file mode 100644 index 071bfe3ec64..00000000000 --- a/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * 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 {isMac} from '../utils/platform'; -import {KeyboardEvent} from '@react-types/shared'; - -export type KeyboardShortcutAction = ( - e: KeyboardEvent -) => - | void - | boolean - | Partial<{shouldContinuePropagation?: boolean; shouldPreventDefault?: boolean}>; - -/** Maps shortcut strings (e.g. `"Mod+s"`, `"Control+Shift+a"`) to handlers. */ -export type KeyboardShortcutBindings = Record; - -/** Modifier names in shortcut strings (case-insensitive). Order in the string does not matter. */ -const MODIFIER_NAMES = new Set([ - 'shift', - 'alt', - 'control', - 'meta', - 'mod' // OS dependent - Cmd on Mac, Control on Windows/Linux -]); - -/** Canonical modifier order for stable keys (sorted, fixed order). */ -const CANONICAL_MODIFIER_ORDER = ['Alt', 'Control', 'Meta', 'Shift'] as const; - -export interface ParsedKeyboardShortcut { - shift: boolean; - alt: boolean; - ctrl: boolean; - meta: boolean; - /** - * Platform primary: Cmd on Mac, Control on Windows/Linux — expands to Meta or Control in - * canonical form. - */ - mod: boolean; - key: string; -} - -/** - * Builds the set of canonical modifier tokens for a binding. - * `Mod` contributes Meta (Mac) or Ctrl (non-Mac); explicit Ctrl/Meta add those keys too. - */ -export function modifierSetFromParsed(parsed: ParsedKeyboardShortcut): Set { - let set = new Set(); - if (parsed.alt) { - set.add('Alt'); - } - if (parsed.shift) { - set.add('Shift'); - } - if (parsed.ctrl) { - set.add('Control'); - } - if (parsed.meta) { - set.add('Meta'); - } - if (parsed.mod) { - set.add(isMac() ? 'Meta' : 'Control'); - } - return set; -} - -/** Modifier set from a keydown event (native flags only). */ -export function modifierSetFromEvent(e: KeyboardEvent): Set { - let set = new Set(); - if (e.altKey) { - set.add('Alt'); - } - if (e.ctrlKey) { - set.add('Control'); - } - if (e.metaKey) { - set.add('Meta'); - } - if (e.shiftKey) { - set.add('Shift'); - } - return set; -} - -function sortedModifierTokens(set: Set): string[] { - return CANONICAL_MODIFIER_ORDER.filter(name => set.has(name)); -} - -/** - * Parses a shortcut like `"Mod+Shift+z"`, `"Ctrl+Alt+Enter"`, or `"Escape"`. - * Modifiers are case-insensitive; order does not matter. `control` is an alias for `ctrl`. - */ -export function parseKeyboardShortcut(spec: string): ParsedKeyboardShortcut { - let parts = spec.split('+').reduce( - (prev, part) => { - let lower = part.toLowerCase(); - if (MODIFIER_NAMES.has(lower)) { - if (lower === 'shift') { - prev.shift = true; - } else if (lower === 'alt') { - prev.alt = true; - } else if (lower === 'control') { - prev.ctrl = true; - } else if (lower === 'meta') { - prev.meta = true; - } else if (lower === 'mod') { - prev.mod = true; - } - } else { - prev.key = part; - } - return prev; - }, - {shift: false, alt: false, ctrl: false, meta: false, mod: false, key: ''} - ); - if (parts.key === '') { - throw new Error( - `Invalid keyboard shortcut: "${spec}". Must include exactly one non-modifier key (e.g. "a", "Enter", "ArrowDown"). Combine any of Shift, Alt, Ctrl, Meta, and Mod.` - ); - } - return parts; -} - -function normalizeEventKey(key: string): string { - return key.toLowerCase(); -} - -/** Short aliases for common keys (shortcut side, before match). */ -const KEY_ALIASES: Record = { - space: ' ', - esc: 'escape', - del: 'delete', - ins: 'insert', - left: 'arrowleft', - right: 'arrowright', - up: 'arrowup', - down: 'arrowdown', - pageup: 'pageup', - pagedown: 'pagedown' -}; - -/** Canonical key segment (lowercase); aliases like `down` → `arrowdown`. */ -function canonicalKeyFromSpecKey(specKey: string): string { - let k = normalizeEventKey(specKey); - let aliased = KEY_ALIASES[k]; - return aliased != null ? aliased : k; -} - -/** Canonical shortcut string for a binding (modifiers sorted: Alt, Ctrl, Meta, Shift, then key). */ -export function canonicalKeyboardShortcut(parsed: ParsedKeyboardShortcut): string { - let mods = sortedModifierTokens(modifierSetFromParsed(parsed)); - let key = canonicalKeyFromSpecKey(parsed.key); - return mods.length > 0 ? `${mods.join('+')}+${key}` : key; -} - -/** Canonical shortcut string for a keydown event. */ -export function keyboardEventToCanonicalShortcut(e: KeyboardEvent): string { - let mods = sortedModifierTokens(modifierSetFromEvent(e)); - let key = normalizeEventKey(e.key); - let prefix = mods.length > 0 ? `${mods.join('+')}+` : ''; - return prefix + key; -} - -/** - * Returns a keydown handler that runs the action only for an exact modifier+key match. - * Modifier order in the string does not matter (`Shift+Mod+a` ≡ `Mod+Shift+a`). - * Any combination of **Shift**, **Alt**, **Ctrl**, **Meta**, and **Mod** is allowed; **Mod** means - * Cmd on Apple platforms and Ctrl on Windows/Linux (same as before). **control** aliases **ctrl**. - * - * Duplicate bindings that normalize to the same shortcut: later object entries win. - * - * @example - * ```tsx - * let onKeyDown = createKeyboardShortcutHandler({ - * 'Mod+s': e => { - * e.preventDefault(); - * save(); - * }, - * 'Ctrl+Shift+k': () => palette(), - * 'Meta+Alt+ArrowLeft': () => back() - * }); - * ```; - */ -export function createKeyboardShortcutHandler( - bindings: KeyboardShortcutBindings -): (e: KeyboardEvent) => void { - let map = new Map(); - for (let [spec, action] of Object.entries(bindings)) { - let parsed = parseKeyboardShortcut(spec); - map.set(canonicalKeyboardShortcut(parsed), action); - } - - return (e: KeyboardEvent) => { - let canonical = keyboardEventToCanonicalShortcut(e); - let action = map.get(canonical); - let result = action?.(e); - if (result === undefined && action !== undefined) { - result = {shouldContinuePropagation: false, shouldPreventDefault: true}; - } else if (typeof result === 'boolean') { - result = {shouldContinuePropagation: !result, shouldPreventDefault: result}; - } - if (result?.shouldPreventDefault) { - e.preventDefault(); - } - if (!action || result?.shouldContinuePropagation) { - e.continuePropagation(); - } - }; -} diff --git a/packages/react-aria/src/interactions/useKeyboard.ts b/packages/react-aria/src/interactions/useKeyboard.ts index 9264a58ac22..f7f99ca827d 100644 --- a/packages/react-aria/src/interactions/useKeyboard.ts +++ b/packages/react-aria/src/interactions/useKeyboard.ts @@ -11,21 +11,11 @@ */ import {createEventHandler} from './createEventHandler'; -import { - createKeyboardShortcutHandler, - KeyboardShortcutBindings -} from './createKeyboardShortcutHandler'; import {DOMAttributes, KeyboardEvents} from '@react-types/shared'; -import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; -import {KeyboardEvent as ReactKeyboardEvent} from 'react'; export interface KeyboardProps extends KeyboardEvents { /** Whether the keyboard events should be disabled. */ isDisabled?: boolean; - /** Keyboard shortcuts to handle. */ - shortcuts?: KeyboardShortcutBindings; - allowRepeats?: boolean; - allowComposing?: boolean; } export interface KeyboardResult { @@ -37,57 +27,12 @@ export interface KeyboardResult { * Handles keyboard interactions for a focusable element. */ export function useKeyboard(props: KeyboardProps): KeyboardResult { - let {shortcuts, allowRepeats = false, allowComposing = false} = props; - let onKeyDown; - let onKeyUp; - if (shortcuts) { - let shortcutHandler = createKeyboardShortcutHandler(shortcuts); - onKeyDown = createEventHandler>(e => { - // If keyboard event didn't originate from a child of the current target, - // then it's a React event coming through a portal. We should ignore it. - if (!nodeContains(e.currentTarget, getEventTarget(e))) { - e.continuePropagation(); - return; - } - if ( - (e.nativeEvent?.repeat && !allowRepeats) || - (e.nativeEvent?.isComposing && !allowComposing) - ) { - e.continuePropagation(); - return; - } - - shortcutHandler(e); - props.onKeyDown?.(e); - }); - onKeyUp = createEventHandler>(e => { - // If keyboard event didn't originate from a child of the current target, - // then it's a React event coming through a portal. We should ignore it. - if (!nodeContains(e.currentTarget, getEventTarget(e))) { - e.continuePropagation(); - return; - } - if ( - (e.nativeEvent?.repeat && !allowRepeats) || - (e.nativeEvent?.isComposing && !allowComposing) - ) { - e.continuePropagation(); - return; - } - // implement shortcut handler on keyup, what should the map be called? or should it be another syntax on shortcuts? - e.continuePropagation(); - props.onKeyUp?.(e); - }); - } else { - onKeyDown = createEventHandler(props.onKeyDown); - onKeyUp = createEventHandler(props.onKeyUp); - } return { keyboardProps: props.isDisabled ? {} : { - onKeyDown, - onKeyUp + onKeyDown: createEventHandler(props.onKeyDown), + onKeyUp: createEventHandler(props.onKeyUp) } }; } diff --git a/packages/react-aria/src/menu/useMenuItem.ts b/packages/react-aria/src/menu/useMenuItem.ts index 8dc2f764860..dbea1ac2b12 100644 --- a/packages/react-aria/src/menu/useMenuItem.ts +++ b/packages/react-aria/src/menu/useMenuItem.ts @@ -314,34 +314,44 @@ export function useMenuItem( }); let {keyboardProps} = useKeyboard({ - shortcuts: { - ' ': e => { - interaction.current = {pointerType: 'keyboard', key: ' '}; - (getEventTarget(e) as HTMLElement).click(); - - // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus - // to the newly opened submenu's first item. - setInteractionModality('keyboard'); - }, - Enter: e => { - interaction.current = {pointerType: 'keyboard', key: 'Enter'}; - let target = getEventTarget(e) as HTMLElement; - - // Trigger click unless this is a link. Links with real DOM focus activate on Enter natively. - // With virtual focus (e.g. Autocomplete) focus stays on the input and useAutocomplete dispatches - // keydown here then follows with a synthetic click only if dispatchEvent was not canceled—so - // links must not preventDefault on that keydown. - if (target.tagName !== 'A') { - target.click(); + onKeyDown: e => { + // Ignore repeating events, which may have started on the menu trigger before moving + // focus to the menu item. We want to wait for a second complete key press sequence. + if (e.repeat) { + e.continuePropagation(); + return; + } + + switch (e.key) { + case ' ': + interaction.current = {pointerType: 'keyboard', key: ' '}; + (getEventTarget(e) as HTMLElement).click(); + + // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus + // to the newly opened submenu's first item. setInteractionModality('keyboard'); - return; - } + break; + case 'Enter': + interaction.current = {pointerType: 'keyboard', key: 'Enter'}; - setInteractionModality('keyboard'); - return {shouldPreventDefault: false, shouldContinuePropagation: false}; + // Trigger click unless this is a link. Links trigger click natively. + if ((getEventTarget(e) as HTMLElement).tagName !== 'A') { + (getEventTarget(e) as HTMLElement).click(); + } + + // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus + // to the newly opened submenu's first item. + setInteractionModality('keyboard'); + break; + default: + if (!isTrigger) { + e.continuePropagation(); + } + + onKeyDown?.(e); + break; } }, - onKeyDown, onKeyUp }); diff --git a/packages/react-aria/src/menu/useMenuTrigger.ts b/packages/react-aria/src/menu/useMenuTrigger.ts index 61605da99ec..2cda3915319 100644 --- a/packages/react-aria/src/menu/useMenuTrigger.ts +++ b/packages/react-aria/src/menu/useMenuTrigger.ts @@ -12,13 +12,12 @@ import {AriaButtonProps} from '../button/useButton'; import {AriaMenuOptions} from './useMenu'; -import {FocusableElement, FocusStrategy, KeyboardEvent, RefObject} from '@react-types/shared'; +import {FocusableElement, RefObject} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import intlMessages from '../../intl/menu/*.json'; import {MenuTriggerState, MenuTriggerType} from 'react-stately/useMenuTriggerState'; import {PressProps} from '../interactions/usePress'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useLongPress} from '../interactions/useLongPress'; import {useOverlayTrigger} from '../overlays/useOverlayTrigger'; @@ -57,53 +56,50 @@ export function useMenuTrigger( let menuTriggerId = useId(); let {triggerProps, overlayProps} = useOverlayTrigger({type}, state, ref); - let open = ( - shouldOpen: boolean, - e: KeyboardEvent, - focusStrategy: FocusStrategy = 'first' - ): boolean | void => { - if (!shouldOpen || e.isDefaultPrevented()) { - return false; + let onKeyDown = e => { + if (isDisabled) { + return; + } + + if (trigger === 'longPress' && !e.altKey) { + return; } - state.toggle(focusStrategy); - }; - // React puts listeners on the same root, so even if propagation was stopped, immediate propagation is still possible. - // useTypeSelect will handle the spacebar first if it's running, so we don't want to open if it's handled it already. - // We use isDefaultPrevented() instead of isPropagationStopped() because createEventHandler stops propagation by default. - // And default prevented means that the event was handled by something else (typeahead), so we don't want to open the menu. - let {keyboardProps} = useKeyboard({ - isDisabled, - shortcuts: { - Enter: e => { - return open(trigger !== 'longPress', e, 'first'); - }, - ' ': e => { - return open(trigger !== 'longPress', e, 'first'); - }, - ArrowDown: e => { - return open(trigger !== 'longPress', e, 'first'); - }, - ArrowUp: e => { - return open(trigger !== 'longPress', e, 'last'); - }, - 'Alt+Enter': e => { - return open(trigger === 'longPress', e, 'first'); - }, - 'Alt+ ': e => { - return open(trigger === 'longPress', e, 'first'); - }, - // Alt+Arrow* must open for both trigger modes: for `press` it matches the same `e.key` cases as - // plain Arrow*; for `longPress`, plain arrows are ignored elsewhere and Alt+Arrow is the opener - // (see legacy `if (trigger === 'longPress' && !e.altKey) return` before the ArrowDown/Up switch). - 'Alt+ArrowDown': e => { - return open(true, e, 'first'); - }, - 'Alt+ArrowUp': e => { - return open(true, e, 'last'); + if (ref && ref.current) { + switch (e.key) { + case 'Enter': + case ' ': + // React puts listeners on the same root, so even if propagation was stopped, immediate propagation is still possible. + // useTypeSelect will handle the spacebar first if it's running, so we don't want to open if it's handled it already. + // We use isDefaultPrevented() instead of isPropagationStopped() because createEventHandler stops propagation by default. + // And default prevented means that the event was handled by something else (typeahead), so we don't want to open the menu. + if (trigger === 'longPress' || e.isDefaultPrevented()) { + return; + } + // fallthrough + case 'ArrowDown': + // Stop propagation, unless it would already be handled by useKeyboard. + if (!('continuePropagation' in e)) { + e.stopPropagation(); + } + e.preventDefault(); + state.toggle('first'); + break; + case 'ArrowUp': + if (!('continuePropagation' in e)) { + e.stopPropagation(); + } + e.preventDefault(); + state.toggle('last'); + break; + default: + // Allow other keys. + if ('continuePropagation' in e) { + e.continuePropagation(); + } } } - }); + }; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/menu'); let {longPressProps} = useLongPress({ @@ -148,8 +144,8 @@ export function useMenuTrigger( menuTriggerProps: { ...triggerProps, ...(trigger === 'press' ? pressProps : longPressProps), - ...keyboardProps, - id: menuTriggerId + id: menuTriggerId, + onKeyDown }, menuProps: { ...overlayProps, diff --git a/packages/react-aria/src/menu/useSubmenuTrigger.ts b/packages/react-aria/src/menu/useSubmenuTrigger.ts index 9adaa754e83..21d6ffaf6ca 100644 --- a/packages/react-aria/src/menu/useSubmenuTrigger.ts +++ b/packages/react-aria/src/menu/useSubmenuTrigger.ts @@ -13,7 +13,14 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps} from '../overlays/usePopover'; -import {FocusableElement, FocusStrategy, Node, PressEvent, RefObject} from '@react-types/shared'; +import { + FocusableElement, + FocusStrategy, + KeyboardEvent, + Node, + PressEvent, + RefObject +} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import { getActiveElement, @@ -26,7 +33,6 @@ import type {SubmenuTriggerState} from 'react-stately/useMenuTriggerState'; import {useCallback, useRef} from 'react'; import {useEvent} from '../utils/useEvent'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; import {useSafelyMouseToSubmenu} from './useSafelyMouseToSubmenu'; @@ -128,51 +134,46 @@ export function useSubmenuTrigger( }; }, [cancelOpenTimeout]); - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowLeft: e => { - // If focus is not within the menu, assume virtual focus is being used. - // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!isFocusWithin(e.currentTarget)) { - return false; - } + let submenuKeyDown = (e: KeyboardEvent) => { + // If focus is not within the menu, assume virtual focus is being used. + // This means some other input element is also within the popover, so we shouldn't close the menu. + if (!isFocusWithin(e.currentTarget)) { + return; + } + + switch (e.key) { + case 'ArrowLeft': if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + e.preventDefault(); + e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } - return; - } - return false; - }, - ArrowRight: e => { - if (!isFocusWithin(e.currentTarget)) { - return false; } + break; + case 'ArrowRight': if (direction === 'rtl' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + e.preventDefault(); + e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } - return; - } - return false; - }, - Escape: e => { - if (!isFocusWithin(e.currentTarget)) { - return false; } + break; + case 'Escape': + // TODO: can remove this when we fix collection event leaks if (nodeContains(submenuRef.current, getEventTarget(e) as Element)) { + e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } - return; } - return false; - } + break; } - }); + }; let submenuProps = { id: overlayId, @@ -181,15 +182,16 @@ export function useSubmenuTrigger( ...(type === 'menu' && { onClose: state.closeAll, autoFocus: state.focusStrategy ?? undefined, - ...keyboardProps + onKeyDown: submenuKeyDown }) }; - let {keyboardProps: submenuTriggerKeyboardProps} = useKeyboard({ - shortcuts: { - ArrowRight: () => { + let submenuTriggerKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowRight': if (!isDisabled) { if (direction === 'ltr') { + e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } @@ -197,19 +199,18 @@ export function useSubmenuTrigger( if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) { focusWithoutScrolling(submenuRef.current); } - return; } else if (state.isOpen) { onSubmenuClose(); - return; } else { - return false; + e.continuePropagation(); } } - return false; - }, - ArrowLeft: () => { + + break; + case 'ArrowLeft': if (!isDisabled) { if (direction === 'rtl') { + e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } @@ -217,18 +218,18 @@ export function useSubmenuTrigger( if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) { focusWithoutScrolling(submenuRef.current); } - return; } else if (state.isOpen) { onSubmenuClose(); - return; } else { - return false; + e.continuePropagation(); } } - return false; - } + break; + default: + e.continuePropagation(); + break; } - }); + }; let onPressStart = (e: PressEvent) => { if (!isDisabled && (e.pointerType === 'virtual' || e.pointerType === 'keyboard')) { @@ -288,7 +289,6 @@ export function useSubmenuTrigger( return { submenuTriggerProps: { - ...(submenuTriggerKeyboardProps as any), // TODO: fix this id: submenuTriggerId, 'aria-controls': state.isOpen ? overlayId : undefined, 'aria-haspopup': !isDisabled ? type : undefined, @@ -296,6 +296,7 @@ export function useSubmenuTrigger( onPressStart, onPress, onHoverChange, + onKeyDown: submenuTriggerKeyDown, isOpen: state.isOpen }, submenuProps, diff --git a/packages/react-aria/src/numberfield/useNumberField.ts b/packages/react-aria/src/numberfield/useNumberField.ts index f6360aa4b2f..aebb6446e4c 100644 --- a/packages/react-aria/src/numberfield/useNumberField.ts +++ b/packages/react-aria/src/numberfield/useNumberField.ts @@ -21,6 +21,7 @@ import { TextInputDOMProps, ValidationResult } from '@react-types/shared'; +import {chain} from '../utils/chain'; import { type ClipboardEvent, type ClipboardEventHandler, @@ -44,7 +45,6 @@ import {useFocusWithin} from '../interactions/useFocusWithin'; import {useFormattedTextField} from '../textfield/useFormattedTextField'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useNumberFormatter} from '../i18n/useNumberFormatter'; @@ -255,24 +255,28 @@ export function useNumberField( }; let domProps = filterDOMProps(props); - let {keyboardProps} = useKeyboard({ - isDisabled: isDisabled || isReadOnly, - shortcuts: { - Enter: () => { + let onKeyDownEnter = useCallback( + e => { + if (e.nativeEvent.isComposing) { + return; + } + + if (e.key === 'Enter') { flushSync(() => { commit(); }); commitValidation(); + } else { + e.continuePropagation(); } }, - onKeyDown, - onKeyUp - }); + [commit, commitValidation] + ); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let { labelProps, - inputProps: textFieldPropsFromHook, + inputProps: textFieldProps, descriptionProps, errorMessageProps } = useFormattedTextField( @@ -301,6 +305,8 @@ export function useNumberField( onBlur, onFocus, onFocusChange, + onKeyDown: useMemo(() => chain(onKeyDownEnter, onKeyDown), [onKeyDownEnter, onKeyDown]), + onKeyUp, onPaste, description, errorMessage @@ -309,11 +315,6 @@ export function useNumberField( inputRef ); - // Merge outside useFormattedTextField so useKeyboard's createEventHandler is not nested inside - // useTextField/useFocusable's createEventHandler (avoids redundant stopPropagation on RS events). - // Shortcuts run first (mergeProps chains the second argument after the first). - let textFieldProps = mergeProps(keyboardProps, textFieldPropsFromHook); - useFormReset(inputRef, state.defaultNumberValue, state.setNumberValue); useNativeValidation( state, diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 1f2f476f4f8..1e844bf6427 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -16,7 +16,6 @@ import {isElementInChildOfActiveScope} from '../focus/FocusScope'; import {useEffect, useRef} from 'react'; import {useFocusWithin} from '../interactions/useFocusWithin'; import {useInteractOutside} from '../interactions/useInteractOutside'; -import {useKeyboard} from '../interactions/useKeyboard'; export interface AriaOverlayProps { /** Whether the overlay is currently open. */ @@ -126,17 +125,13 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!isKeyboardDismissDisabled) { - onHide(); - return; - } - return false; - } + let onKeyDown = e => { + if (e.key === 'Escape' && !isKeyboardDismissDisabled && !e.nativeEvent.isComposing) { + e.stopPropagation(); + e.preventDefault(); + onHide(); } - }); + }; // Handle clicking outside the overlay to close it useInteractOutside({ @@ -172,7 +167,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { + let nextDir; + switch (e.key) { + case 'ArrowRight': + if (direction === 'rtl' && orientation !== 'vertical') { + nextDir = 'prev'; + } else { + nextDir = 'next'; + } + break; + case 'ArrowLeft': + if (direction === 'rtl' && orientation !== 'vertical') { + nextDir = 'next'; + } else { + nextDir = 'prev'; + } + break; + case 'ArrowDown': + nextDir = 'next'; + break; + case 'ArrowUp': + nextDir = 'prev'; + break; + default: + return; + } + e.preventDefault(); let walker = getFocusableTreeWalker(e.currentTarget, { from: getEventTarget(e) as Element, accept: node => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio' @@ -109,36 +134,12 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState nextElem = walker.lastChild(); } } - if (nextElem) { // Call focus on nextElem so that keyboard navigation scrolls the radio into view nextElem.focus(); state.setSelectedValue(nextElem.value); - return true; } - return false; - } - - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowRight: e => { - let nextDir: 'next' | 'prev' = - direction === 'rtl' && orientation !== 'vertical' ? 'prev' : 'next'; - return getNextElement(nextDir, e); - }, - ArrowLeft: e => { - let nextDir: 'next' | 'prev' = - direction === 'rtl' && orientation !== 'vertical' ? 'next' : 'prev'; - return getNextElement(nextDir, e); - }, - ArrowDown: e => { - return getNextElement('next', e); - }, - ArrowUp: e => { - return getNextElement('prev', e); - } - } - }); + }; let groupName = useId(name); radioGroupData.set(state, { @@ -153,7 +154,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState radioGroupProps: mergeProps(domProps, { // https://www.w3.org/TR/wai-aria-1.2/#radiogroup role: 'radiogroup', - ...keyboardProps, + onKeyDown, 'aria-invalid': state.isInvalid || undefined, 'aria-errormessage': props['aria-errormessage'], 'aria-readonly': isReadOnly || undefined, diff --git a/packages/react-aria/src/searchfield/useSearchField.ts b/packages/react-aria/src/searchfield/useSearchField.ts index 6278919130f..bde44998ae8 100644 --- a/packages/react-aria/src/searchfield/useSearchField.ts +++ b/packages/react-aria/src/searchfield/useSearchField.ts @@ -12,13 +12,11 @@ import {AriaButtonProps} from '../button/useButton'; import {AriaTextFieldProps, useTextField} from '../textfield/useTextField'; +import {chain} from '../utils/chain'; import {DOMAttributes, RefObject, ValidationResult} from '@react-types/shared'; import {InputHTMLAttributes, LabelHTMLAttributes} from 'react'; import intlMessages from '../../intl/searchfield/*.json'; -// @ts-ignore -import {mergeProps} from '../utils/mergeProps'; import {SearchFieldProps, SearchFieldState} from 'react-stately/useSearchFieldState'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; export interface AriaSearchFieldProps extends SearchFieldProps, Omit { @@ -65,29 +63,38 @@ export function useSearchField( let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/searchfield'); let {isDisabled, isReadOnly, onSubmit, onClear, type = 'search'} = props; - let {keyboardProps} = useKeyboard({ - isDisabled: isDisabled || isReadOnly, - shortcuts: { - Enter: () => { - if (onSubmit) { - // for backward compatibility; - // otherwise, "Enter" on an input would trigger a form submit, the default browser behavior - onSubmit(state.value); - return; - } - return false; - }, - Escape: () => { - // Also check the inputRef value for the case where the value was set directly on the input element instead of going through - // the hook - if (state.value === '' && (!inputRef.current || inputRef.current.value === '')) { - return false; - } + let onKeyDown = e => { + const key = e.key; + + if (key === 'Enter' && (isDisabled || isReadOnly)) { + e.preventDefault(); + } + + if (isDisabled || isReadOnly) { + return; + } + + // for backward compatibility; + // otherwise, "Enter" on an input would trigger a form submit, the default browser behavior + if (key === 'Enter' && onSubmit) { + e.preventDefault(); + onSubmit(state.value); + } + + if (key === 'Escape') { + // Also check the inputRef value for the case where the value was set directly on the input element instead of going through + // the hook + if (state.value === '' && (!inputRef.current || inputRef.current.value === '')) { + e.continuePropagation(); + } else { + e.preventDefault(); state.setValue(''); - onClear?.(); + if (onClear) { + onClear(); + } } } - }); + }; let onClearButtonClick = () => { state.setValue(''); @@ -108,8 +115,7 @@ export function useSearchField( ...props, value: state.value, onChange: state.setValue, - onKeyDown: props.onKeyDown, - onKeyUp: props.onKeyUp, + onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, type }, inputRef @@ -117,14 +123,11 @@ export function useSearchField( return { labelProps, - // An edge case, in Autocomplete, if the keyboard hanlders are not in this order, then - // Escape runs autocomplete/listbox first, then the search-field shortcut returns false and - // continues propagation, leaking Escape to a parent Dialog. - inputProps: mergeProps(keyboardProps, { + inputProps: { ...inputProps, // already handled by useSearchFieldState defaultValue: undefined - }), + }, clearButtonProps: { 'aria-label': stringFormatter.format('Clear search'), excludeFromTabOrder: true, diff --git a/packages/react-aria/src/select/useSelect.ts b/packages/react-aria/src/select/useSelect.ts index 7e54b43ae4e..470a72555d3 100644 --- a/packages/react-aria/src/select/useSelect.ts +++ b/packages/react-aria/src/select/useSelect.ts @@ -34,7 +34,6 @@ import {setInteractionModality} from '../interactions/useFocusVisible'; import {useCollator} from '../i18n/useCollator'; import {useField} from '../label/useField'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useMenuTrigger} from '../menu/useMenuTrigger'; import {useTypeSelect} from '../selection/useTypeSelect'; @@ -137,12 +136,16 @@ export function useSelect( ref ); - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowLeft: () => { - if (state.selectionManager.selectionMode === 'multiple') { - return false; - } + let onKeyDown = (e: KeyboardEvent) => { + if (state.selectionManager.selectionMode === 'multiple') { + return; + } + + switch (e.key) { + case 'ArrowLeft': { + // prevent scrolling containers + e.preventDefault(); + let key = state.selectedKey != null ? delegate.getKeyAbove?.(state.selectedKey) @@ -150,11 +153,12 @@ export function useSelect( if (key != null) { state.setSelectedKey(key); } - }, - ArrowRight: () => { - if (state.selectionManager.selectionMode === 'multiple') { - return false; - } + break; + } + case 'ArrowRight': { + // prevent scrolling containers + e.preventDefault(); + let key = state.selectedKey != null ? delegate.getKeyBelow?.(state.selectedKey) @@ -162,11 +166,10 @@ export function useSelect( if (key != null) { state.setSelectedKey(key); } + break; } - }, - onKeyDown: props.onKeyDown, - onKeyUp: props.onKeyUp - }); + } + }; let {typeSelectProps} = useTypeSelect({ keyboardDelegate: delegate, @@ -218,8 +221,8 @@ export function useSelect( triggerProps: mergeProps(domProps, { ...triggerProps, isDisabled, - onKeyDown: chain(triggerProps.onKeyDown, keyboardProps.onKeyDown), - onKeyUp: keyboardProps.onKeyUp, + onKeyDown: chain(triggerProps.onKeyDown, onKeyDown, props.onKeyDown), + onKeyUp: props.onKeyUp, 'aria-labelledby': [ valueId, triggerProps['aria-labelledby'], diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index b06482a1086..a834398112d 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -19,11 +19,10 @@ import { FocusStrategy, Key, KeyboardDelegate, - KeyboardEvent, RefObject } from '@react-types/shared'; import {flushSync} from 'react-dom'; -import {FocusEvent, useEffect, useRef} from 'react'; +import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; import {focusSafely} from '../interactions/focusSafely'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import { @@ -36,13 +35,11 @@ import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getInteractionModality} from '../interactions/useFocusVisible'; import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils'; import {isCtrlKeyPressed} from '../utils/keyboard'; -import {isMac} from '../utils/platform'; import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; import {scrollIntoView, scrollIntoViewport} from '../utils/scrollIntoView'; import {useEvent} from '../utils/useEvent'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocale} from '../i18n/I18nProvider'; import {useRouter} from '../utils/openLink'; import {useTypeSelect} from './useTypeSelect'; @@ -170,266 +167,234 @@ export function useSelectableCollection( let {direction} = useLocale(); let router = useRouter(); - const navigateToKey = ( - e: KeyboardEvent, - key: Key | undefined, - childFocus?: FocusStrategy - ): boolean | void => { - if (key != null) { - if ( - manager.isLink(key) && - linkBehavior === 'selection' && - selectOnFocus && - !isNonContiguousSelectionModifier(e) - ) { - // Set focused key and re-render synchronously to bring item into view if needed. - flushSync(() => { - manager.setFocusedKey(key, childFocus); - }); - - let item = getItemElement(ref, key); - let itemProps = manager.getItemProps(key); - if (item) { - router.open(item, e, itemProps.href, itemProps.routerOptions); - return; - } - - return false; - } - - manager.setFocusedKey(key, childFocus); - - if (manager.isLink(key) && linkBehavior === 'override') { - return false; - } - - if (e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(key); - return; - } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { - manager.replaceSelection(key); - return; - } + let onKeyDown = (e: KeyboardEvent) => { + // Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes + if (e.altKey && e.key === 'Tab') { + e.preventDefault(); } - return false; - }; - let arrowDown = (e: KeyboardEvent) => { - if (delegate.getKeyBelow) { - let nextKey = - manager.focusedKey != null - ? delegate.getKeyBelow?.(manager.focusedKey) - : delegate.getFirstKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getFirstKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } + // Keyboard events bubble through portals. Don't handle keyboard events + // for elements outside the collection (e.g. menus). + if (!ref.current || !nodeContains(ref.current, getEventTarget(e) as Element)) { + return; } - return false; - }; - let arrowUp = (e: KeyboardEvent) => { - if (delegate.getKeyAbove) { - let nextKey = - manager.focusedKey != null - ? delegate.getKeyAbove?.(manager.focusedKey) - : delegate.getLastKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getLastKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } - } - return false; - }; + const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => { + if (key != null) { + if ( + manager.isLink(key) && + linkBehavior === 'selection' && + selectOnFocus && + !isNonContiguousSelectionModifier(e) + ) { + // Set focused key and re-render synchronously to bring item into view if needed. + flushSync(() => { + manager.setFocusedKey(key, childFocus); + }); + + let item = getItemElement(ref, key); + let itemProps = manager.getItemProps(key); + if (item) { + router.open(item, e, itemProps.href, itemProps.routerOptions); + } - let home = (e: KeyboardEvent) => { - if (delegate.getFirstKey) { - if (manager.focusedKey === null && e.shiftKey) { - return false; - } - // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) - let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); - manager.setFocusedKey(firstKey); - if (firstKey != null) { - if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(firstKey); - return; - } else if (selectOnFocus) { - manager.replaceSelection(firstKey); return; } - } - } - return false; - }; - let arrowLeft = (e: KeyboardEvent) => { - if (delegate.getKeyLeftOf) { - let nextKey: Key | undefined | null = - manager.focusedKey != null - ? delegate.getKeyLeftOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = - direction === 'rtl' - ? delegate.getFirstKey?.(manager.focusedKey) - : delegate.getLastKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey, direction === 'rtl' ? 'first' : 'last'); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } - } - return false; - }; + manager.setFocusedKey(key, childFocus); - let arrowRight = (e: KeyboardEvent) => { - if (delegate.getKeyRightOf) { - let nextKey: Key | undefined | null = - manager.focusedKey != null - ? delegate.getKeyRightOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = - direction === 'rtl' - ? delegate.getLastKey?.(manager.focusedKey) - : delegate.getFirstKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey, direction === 'rtl' ? 'last' : 'first'); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } - } - return false; - }; - - let end = (e: KeyboardEvent) => { - if (delegate.getLastKey) { - if (manager.focusedKey === null && e.shiftKey) { - return false; - } - let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); - manager.setFocusedKey(lastKey); - if (lastKey != null) { - if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(lastKey); - return; - } else if (selectOnFocus) { - manager.replaceSelection(lastKey); + if (manager.isLink(key) && linkBehavior === 'override') { return; } - } - } - return false; - }; - let pageDown = (e: KeyboardEvent) => { - if (delegate.getKeyPageBelow && manager.focusedKey != null) { - let nextKey = delegate.getKeyPageBelow(manager.focusedKey); - if (nextKey != null) { - return navigateToKey(e, nextKey); + if (e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(key); + } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { + manager.replaceSelection(key); + } } - } - return false; - }; + }; - let pageUp = (e: KeyboardEvent) => { - if (delegate.getKeyPageAbove && manager.focusedKey != null) { - let nextKey = delegate.getKeyPageAbove(manager.focusedKey); - if (nextKey != null) { - return navigateToKey(e, nextKey); + switch (e.key) { + case 'ArrowDown': { + if (delegate.getKeyBelow) { + let nextKey = + manager.focusedKey != null + ? delegate.getKeyBelow?.(manager.focusedKey) + : delegate.getFirstKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = delegate.getFirstKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + } + case 'ArrowUp': { + if (delegate.getKeyAbove) { + let nextKey = + manager.focusedKey != null + ? delegate.getKeyAbove?.(manager.focusedKey) + : delegate.getLastKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = delegate.getLastKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + } + case 'ArrowLeft': { + if (delegate.getKeyLeftOf) { + let nextKey: Key | undefined | null = + manager.focusedKey != null + ? delegate.getKeyLeftOf?.(manager.focusedKey) + : delegate.getFirstKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = + direction === 'rtl' + ? delegate.getFirstKey?.(manager.focusedKey) + : delegate.getLastKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); + } + } + break; + } + case 'ArrowRight': { + if (delegate.getKeyRightOf) { + let nextKey: Key | undefined | null = + manager.focusedKey != null + ? delegate.getKeyRightOf?.(manager.focusedKey) + : delegate.getFirstKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = + direction === 'rtl' + ? delegate.getLastKey?.(manager.focusedKey) + : delegate.getFirstKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); + } + } + break; } - } - return false; - }; - - let aHandler = () => { - if (manager.selectionMode === 'multiple' && disallowSelectAll !== true) { - manager.selectAll(); - return; - } - return false; - }; - - let escape = () => { - if ( - escapeKeyBehavior === 'clearSelection' && - !disallowEmptySelection && - manager.selectedKeys.size !== 0 - ) { - manager.clearSelection(); - return; - } - return false; - }; - - let tab = () => { - if (!allowsTabNavigation && ref.current) { - // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). - // However, collections should be treated as a single tab stop, with arrow key navigation internally. - // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. - // Instead, we handle the Tab key, and move focus manually to the first/last tabbable element - // in the collection, so that the browser default behavior will apply starting from that element - // rather than the currently focused one. - - let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); - let next: FocusableElement | undefined = undefined; - let last: FocusableElement; - do { - last = walker.lastChild() as FocusableElement; - if (last) { - next = last; + case 'Home': + if (delegate.getFirstKey) { + if (manager.focusedKey === null && e.shiftKey) { + return; + } + // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) + e.preventDefault(); + let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); + manager.setFocusedKey(firstKey); + if (firstKey != null) { + if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(firstKey); + } else if (selectOnFocus) { + manager.replaceSelection(firstKey); + } + } + } + break; + case 'End': + if (delegate.getLastKey) { + if (manager.focusedKey === null && e.shiftKey) { + return; + } + e.preventDefault(); + let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); + manager.setFocusedKey(lastKey); + if (lastKey != null) { + if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(lastKey); + } else if (selectOnFocus) { + manager.replaceSelection(lastKey); + } + } + } + break; + case 'PageDown': + if (delegate.getKeyPageBelow && manager.focusedKey != null) { + let nextKey = delegate.getKeyPageBelow(manager.focusedKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + case 'PageUp': + if (delegate.getKeyPageAbove && manager.focusedKey != null) { + let nextKey = delegate.getKeyPageAbove(manager.focusedKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + case 'a': + if ( + isCtrlKeyPressed(e) && + manager.selectionMode === 'multiple' && + disallowSelectAll !== true + ) { + e.preventDefault(); + manager.selectAll(); + } + break; + case 'Escape': + if ( + escapeKeyBehavior === 'clearSelection' && + !disallowEmptySelection && + manager.selectedKeys.size !== 0 + ) { + e.stopPropagation(); + e.preventDefault(); + manager.clearSelection(); + } + break; + case 'Tab': { + if (!allowsTabNavigation) { + // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). + // However, collections should be treated as a single tab stop, with arrow key navigation internally. + // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. + // Instead, we handle the Tab key, and move focus manually to the first/last tabbable element + // in the collection, so that the browser default behavior will apply starting from that element + // rather than the currently focused one. + if (e.shiftKey) { + ref.current.focus(); + } else { + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + let next: FocusableElement | undefined = undefined; + let last: FocusableElement; + do { + last = walker.lastChild() as FocusableElement; + // oxlint-disable-next-line max-depth + if (last) { + next = last; + } + } while (last); + + // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to + // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between + // focusing the containing cell and back to the non-tabbable child element + let activeElement = getActiveElement(); + if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) { + focusWithoutScrolling(next); + } + } + break; } - } while (last); - - // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to - // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between - // focusing the containing cell and back to the non-tabbable child element - let activeElement = getActiveElement(); - if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) { - focusWithoutScrolling(next); } } - return {shouldContinuePropagation: true, shouldPreventDefault: false}; - }; - - let shiftTab = () => { - if (!allowsTabNavigation && ref.current) { - ref.current.focus(); - } - return {shouldContinuePropagation: true, shouldPreventDefault: false}; - }; - - let withShiftSel = (key, callback) => { - return { - [isMac() ? key + '+Shift+Alt' : key + '+Shift+Control']: callback, - [key + '+Shift']: callback, - [isMac() ? key + '+Alt' : key + '+Control']: callback, - [key]: callback - }; }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - ...withShiftSel('ArrowDown', arrowDown), - ...withShiftSel('ArrowUp', arrowUp), - ...withShiftSel('ArrowLeft', arrowLeft), - ...withShiftSel('ArrowRight', arrowRight), - ...withShiftSel('Home', home), - ...withShiftSel('End', end), - ...withShiftSel('PageDown', pageDown), - ...withShiftSel('PageUp', pageUp), - [isMac() ? 'a+Alt' : 'a+Control']: aHandler, - Escape: escape, - Tab: tab, - 'Tab+Shift': shiftTab - } - }); // Store the scroll position so we can restore it later. /// TODO: should this happen all the time?? @@ -695,7 +660,7 @@ export function useSelectableCollection( }); let handlers = { - ...keyboardProps, + onKeyDown, onFocus, onBlur, onMouseDown(e) { diff --git a/packages/react-aria/src/slider/useSliderThumb.ts b/packages/react-aria/src/slider/useSliderThumb.ts index a077d07baab..efaaa0b50f8 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -142,35 +142,41 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState) let reverseX = direction === 'rtl'; let currentPosition = useRef(null); - let keyboardUpdate = cb => { - // remember to set this so that onChangeEnd is fired - state.setThumbDragging(index, true); - cb(); - state.setThumbDragging(index, false); - }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - PageUp: () => { - return keyboardUpdate(() => { - state.incrementThumb(index, state.pageSize); - }); - }, - PageDown: () => { - return keyboardUpdate(() => { - state.decrementThumb(index, state.pageSize); - }); - }, - Home: () => { - return keyboardUpdate(() => { - state.setThumbValue(index, state.getThumbMinValue(index)); - }); - }, - End: () => { - return keyboardUpdate(() => { - state.setThumbValue(index, state.getThumbMaxValue(index)); - }); + onKeyDown(e) { + let { + getThumbMaxValue, + getThumbMinValue, + decrementThumb, + incrementThumb, + setThumbValue, + setThumbDragging, + pageSize + } = state; + // these are the cases that useMove or useSlider don't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, stopPropagation to prevent useSlider from handling the event as well. + e.preventDefault(); + // remember to set this so that onChangeEnd is fired + setThumbDragging(index, true); + switch (e.key) { + case 'PageUp': + incrementThumb(index, pageSize); + break; + case 'PageDown': + decrementThumb(index, pageSize); + break; + case 'Home': + setThumbValue(index, getThumbMinValue(index)); + break; + case 'End': + setThumbValue(index, getThumbMaxValue(index)); + break; } + setThumbDragging(index, false); } }); diff --git a/packages/react-aria/src/spinbutton/useSpinButton.ts b/packages/react-aria/src/spinbutton/useSpinButton.ts index 171419b46ac..39e214ef995 100644 --- a/packages/react-aria/src/spinbutton/useSpinButton.ts +++ b/packages/react-aria/src/spinbutton/useSpinButton.ts @@ -18,7 +18,6 @@ import intlMessages from '../../intl/spinbutton/*.json'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useEffectEvent} from '../utils/useEffectEvent'; import {useGlobalListeners} from '../utils/useGlobalListeners'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; const noop = () => {}; @@ -72,61 +71,61 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { return () => clearAsyncEvent(); }, []); - let {keyboardProps} = useKeyboard({ - isDisabled: isDisabled || isReadOnly, - shortcuts: { - PageUp: () => { + let onKeyDown = e => { + if ( + e.ctrlKey || + e.metaKey || + e.shiftKey || + e.altKey || + isReadOnly || + e.nativeEvent.isComposing + ) { + return; + } + + switch (e.key) { + case 'PageUp': if (onIncrementPage) { - onIncrementPage(); - return; + e.preventDefault(); + onIncrementPage?.(); + break; } + // fallthrough! + case 'ArrowUp': + case 'Up': if (onIncrement) { - onIncrement(); - return; - } - return false; - }, - ArrowUp: () => { - if (onIncrement) { - onIncrement(); - return; + e.preventDefault(); + onIncrement?.(); } - return false; - }, - PageDown: () => { + break; + case 'PageDown': if (onDecrementPage) { - onDecrementPage(); - return; + e.preventDefault(); + onDecrementPage?.(); + break; } + // fallthrough + case 'ArrowDown': + case 'Down': if (onDecrement) { - onDecrement(); - return; - } - return false; - }, - ArrowDown: () => { - if (onDecrement) { - onDecrement(); - return; + e.preventDefault(); + onDecrement?.(); } - return false; - }, - Home: () => { + break; + case 'Home': if (onDecrementToMin) { - onDecrementToMin(); - return; + e.preventDefault(); + onDecrementToMin?.(); } - return false; - }, - End: () => { + break; + case 'End': if (onIncrementToMax) { - onIncrementToMax(); - return; + e.preventDefault(); + onIncrementToMax?.(); } - return false; - } + break; } - }); + }; let isFocused = useRef(false); let onFocus = () => { @@ -245,7 +244,7 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { 'aria-disabled': isDisabled || undefined, 'aria-readonly': isReadOnly || undefined, 'aria-required': isRequired || undefined, - ...keyboardProps, + onKeyDown, onFocus, onBlur }, diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index 802fa7ff89a..ede27181d74 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -47,9 +47,21 @@ export function useStepListItem( const isSelected = selectedKey === key; + let onKeyDown = event => { + const {key: eventKey} = event; + + if (eventKey === 'ArrowDown' || eventKey === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + } + + itemProps.onKeyDown?.(event); + }; + return { stepProps: { ...itemProps, + onKeyDown, role: 'link', 'aria-current': isSelected ? 'step' : undefined, 'aria-disabled': isDisabled ? true : undefined, diff --git a/packages/react-aria/src/table/useTableColumnResize.ts b/packages/react-aria/src/table/useTableColumnResize.ts index 5dd4c15d431..dc0b1f02f5d 100644 --- a/packages/react-aria/src/table/useTableColumnResize.ts +++ b/packages/react-aria/src/table/useTableColumnResize.ts @@ -141,31 +141,20 @@ export function useTableColumnResize( [state, triggerRef, onResizeEnd] ); - let endResizeEvent = () => { - if (editModeEnabled) { - endResize(item); - return; - } - return false; - }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - Escape: () => { - return endResizeEvent(); - }, - Enter: () => { - if (editModeEnabled) { + onKeyDown: e => { + if (editModeEnabled) { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { + e.preventDefault(); endResize(item); - } else { + } + } else { + // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there + e.continuePropagation(); + + if (e.key === 'Enter') { startResize(item); } - }, - ' ': () => { - return endResizeEvent(); - }, - Tab: () => { - return endResizeEvent(); } } }); diff --git a/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index 84479e03603..c5b4ea5df25 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -15,6 +15,7 @@ import {DOMAttributes, FocusableElement, Node, RefObject} from '@react-types/sha import {filterDOMProps} from '../utils/filterDOMProps'; import {hookData} from './useTagGroup'; import intlMessages from '../../intl/tag/*.json'; +import {KeyboardEvent} from 'react'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {SelectableItemStates} from '../selection/useSelectableItem'; @@ -23,7 +24,6 @@ import {useFocusable} from '../interactions/useFocusable'; import {useGridListItem} from '../gridlist/useGridListItem'; import {useId} from '../utils/useId'; import {useInteractionModality} from '../interactions/useFocusVisible'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useSyntheticLinkProps} from '../utils/openLink'; @@ -72,25 +72,20 @@ export function useTag( let {descriptionProps: _, ...stateWithoutDescription} = states; let isDisabled = state.disabledKeys.has(item.key) || item.props.isDisabled; - let {keyboardProps} = useKeyboard({ - isDisabled, - shortcuts: { - Delete: () => { - if (state.selectionManager.isSelected(item.key)) { - onRemove?.(new Set(state.selectionManager.selectedKeys)); - } else { - onRemove?.(new Set([item.key])); - } - }, - Backspace: () => { - if (state.selectionManager.isSelected(item.key)) { - onRemove?.(new Set(state.selectionManager.selectedKeys)); - } else { - onRemove?.(new Set([item.key])); - } + let onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (isDisabled) { + return; + } + + e.preventDefault(); + if (state.selectionManager.isSelected(item.key)) { + onRemove?.(new Set(state.selectionManager.selectedKeys)); + } else { + onRemove?.(new Set([item.key])); } } - }); + }; let modality = useInteractionModality(); if (modality === 'virtual' && typeof window !== 'undefined' && 'ontouchstart' in window) { @@ -129,7 +124,7 @@ export function useTag( }, rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, { tabIndex, - ...(onRemove ? keyboardProps : {}), + onKeyDown: onRemove ? onKeyDown : undefined, 'aria-describedby': descProps['aria-describedby'] }), gridCellProps: mergeProps(gridCellProps, { diff --git a/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js b/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js index 23ac503b8a3..d732f5266e1 100644 --- a/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js +++ b/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js @@ -25,9 +25,7 @@ describe('useSearchAutocomplete', function () { isComposing: false }, preventDefault, - stopPropagation, - target: {}, - currentTarget: {contains: () => true} + stopPropagation }); let defaultProps = { diff --git a/packages/react-aria/test/combobox/useComboBox.test.js b/packages/react-aria/test/combobox/useComboBox.test.js index 62d2511adb4..389c7ec3e64 100644 --- a/packages/react-aria/test/combobox/useComboBox.test.js +++ b/packages/react-aria/test/combobox/useComboBox.test.js @@ -117,9 +117,7 @@ describe('useComboBox', function () { let {inputProps} = result.current; act(() => { - inputProps.onKeyDown( - event({key: 'Enter', target: {}, currentTarget: {contains: () => true}}) - ); + inputProps.onKeyDown(event({key: 'Enter'})); }); expect(preventDefault).toHaveBeenCalledTimes(1); @@ -151,9 +149,7 @@ describe('useComboBox', function () { initialProps: props }); act(() => { - openResult.current.inputProps.onKeyDown( - event({key: 'Tab', target: {}, currentTarget: {contains: () => true}}) - ); + openResult.current.inputProps.onKeyDown(event({key: 'Tab'})); }); expect(commitSpy).toHaveBeenCalledTimes(1); }); @@ -165,15 +161,11 @@ describe('useComboBox', function () { let {result} = renderHook(props => useComboBox(props, state.current), {initialProps: props}); let {inputProps, buttonProps} = result.current; - inputProps.onKeyDown( - event({key: 'ArrowDown', target: {}, currentTarget: {contains: () => true}}) - ); + inputProps.onKeyDown(event({key: 'ArrowDown'})); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenLastCalledWith('first', 'manual'); expect(toggleSpy).toHaveBeenCalledTimes(0); - inputProps.onKeyDown( - event({key: 'ArrowUp', target: {}, currentTarget: {contains: () => true}}) - ); + inputProps.onKeyDown(event({key: 'ArrowUp'})); expect(openSpy).toHaveBeenCalledTimes(2); expect(openSpy).toHaveBeenLastCalledWith('last', 'manual'); expect(toggleSpy).toHaveBeenCalledTimes(0); @@ -198,4 +190,36 @@ describe('useComboBox', function () { expect(onBlurMock).toHaveBeenCalledTimes(1); }); + + it.each` + Name | componentProps + ${'disabled'} | ${{isDisabled: true}} + ${'readonly'} | ${{isReadOnly: true}} + `( + "press and keyboard events on the button doesn't toggle the menu if $Name", + function ({componentProps}) { + let additionalProps = { + ...props, + ...componentProps + }; + + let {result: state} = renderHook(props => useComboBoxState(props), { + initialProps: additionalProps + }); + state.current.open = openSpy; + state.current.toggle = toggleSpy; + + let {result} = renderHook(props => useComboBox(props, state.current), { + initialProps: additionalProps + }); + let {buttonProps} = result.current; + buttonProps.onKeyDown(event({key: 'ArrowDown'})); + expect(openSpy).toHaveBeenCalledTimes(0); + expect(toggleSpy).toHaveBeenCalledTimes(0); + buttonProps.onKeyDown(event({key: 'ArrowUp'})); + expect(openSpy).toHaveBeenCalledTimes(0); + expect(toggleSpy).toHaveBeenCalledTimes(0); + expect(buttonProps.isDisabled).toBeTruthy(); + } + ); }); diff --git a/packages/react-aria/test/interactions/useKeyboard.test.js b/packages/react-aria/test/interactions/useKeyboard.test.js index 901b8bb74ab..60a0d405772 100644 --- a/packages/react-aria/test/interactions/useKeyboard.test.js +++ b/packages/react-aria/test/interactions/useKeyboard.test.js @@ -10,8 +10,6 @@ * governing permissions and limitations under the License. */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ - import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import {useKeyboard} from '../../src/interactions/useKeyboard'; @@ -99,280 +97,4 @@ describe('useKeyboard', function () { expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); }); - - describe('shortcuts', () => { - let platformMock; - let user; - beforeEach(() => { - user = userEvent.setup({delay: null, pointerMap}); - }); - afterEach(() => { - platformMock?.mockRestore(); - }); - let ExampleButton = props => { - let {keyboardProps} = useKeyboard(props); - return ; - }; - describe('Mac (Mod = Meta)', () => { - beforeEach(() => { - platformMock = jest - .spyOn(navigator, 'platform', 'get') - .mockImplementation(() => 'MacIntel'); - }); - - it('matches Mod+key with metaKey', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
    - -
    - ); - - await user.tab(); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}s{/Meta}'); - expect(save).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); // Meta keydown should be only one - expect(onWrapperKeyUp).toHaveBeenCalledTimes(2); // both s keyup and meta keyup - - save.mockClear(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - // None of the below should trigger the preventDefault and stopPropagation because - // we are not handling the event. - await user.keyboard('{Control>}s{/Control}'); - expect(save).not.toHaveBeenCalled(); - - await user.keyboard('s'); - expect(save).not.toHaveBeenCalled(); - - expect(onWrapperKeyDown).toHaveBeenCalledTimes(3); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - - it('plain key ignores meta', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
    - -
    - ); - - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}s{/Meta}'); - expect(save).not.toHaveBeenCalled(); - - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(2); - }); - - it('Control+Shift distinct from Mod+Shift', async () => { - let modShift = jest.fn(); - let ctrlShift = jest.fn(); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - render( -
    - -
    - ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}{Shift>}a{/Shift}{/Meta}'); - expect(modShift).toHaveBeenCalledTimes(1); - expect(ctrlShift).not.toHaveBeenCalled(); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - modShift.mockClear(); - ctrlShift.mockClear(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Control>}{Shift>}a{/Shift}{/Control}'); - expect(modShift).not.toHaveBeenCalled(); - expect(ctrlShift).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - - it('Meta+Control+Alt combination', async () => { - let fn = jest.fn(); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - render( -
    - -
    - ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}{Control>}{Alt>}z{/Alt}{/Control}{/Meta}'); - expect(fn).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(3); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(4); - }); - - it('Shift+Alt and key aliases', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
    - -
    - ); - - await user.tab(); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Shift>}{Alt>}{ArrowDown}{/Alt}{/Shift}'); - expect(save).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - - it('Mod+Shift+a matches only that binding, not Mod+a', async () => { - let modA = jest.fn(() => true); - let modShiftA = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
    - -
    - ); - - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Shift>}{Meta>}a{/Meta}{/Shift}'); - expect(modShiftA).toHaveBeenCalled(); - expect(modA).not.toHaveBeenCalled(); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - modShiftA.mockClear(); - modA.mockClear(); - - await user.keyboard('{Meta>}a{/Meta}'); - expect(modA).toHaveBeenCalled(); - expect(modShiftA).not.toHaveBeenCalled(); - }); - - it('passes event to handler', async () => { - let fn = jest.fn(e => { - expect(e.key).toBe('Escape'); - }); - render(); - await user.tab(); - await user.keyboard('{Escape}'); - }); - - it('continues propagation if the function did not handle the event', async () => { - let fn = jest.fn(e => { - return false; - }); - let onWrapperKeyDown = jest.fn(e => { - expect(e.isDefaultPrevented()).toBe(false); - }); - let onWrapperKeyUp = jest.fn(); - render( -
    - -
    - ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - await user.keyboard('{Escape}'); - expect(fn).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - }); - - it('prevent default and stop propagation can both be finely controlled', async () => { - let fn = jest.fn(e => { - return {shouldPreventDefault: false, shouldContinuePropagation: true}; - }); - let onWrapperKeyDown = jest.fn(e => { - expect(e.isDefaultPrevented()).toBe(false); - }); - let onWrapperKeyUp = jest.fn(); - render( -
    - -
    - ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - await user.keyboard('{Escape}'); - expect(fn).toHaveBeenCalledTimes(1); - }); - }); - - describe('Windows (Mod = Ctrl)', () => { - beforeEach(() => { - platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Win32'); - }); - - it('matches Mod+key with ctrlKey', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
    - -
    - ); - - await user.tab(); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Control>}s{/Control}'); - expect(save).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); // Meta keydown should be only one - expect(onWrapperKeyUp).toHaveBeenCalledTimes(2); // both s keyup and meta keyup - - save.mockClear(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - // None of the below should trigger the preventDefault and stopPropagation because - // we are not handling the event. - await user.keyboard('{Meta>}s{/Meta}'); - expect(save).not.toHaveBeenCalled(); - - await user.keyboard('s'); - expect(save).not.toHaveBeenCalled(); - - expect(onWrapperKeyDown).toHaveBeenCalledTimes(3); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - }); - }); }); diff --git a/packages/react-aria/test/searchfield/useSearchField.test.js b/packages/react-aria/test/searchfield/useSearchField.test.js index 3422c75175b..604711162f1 100644 --- a/packages/react-aria/test/searchfield/useSearchField.test.js +++ b/packages/react-aria/test/searchfield/useSearchField.test.js @@ -61,13 +61,8 @@ describe('useSearchField hook', () => { let onKeyDown = jest.fn(); let event = key => ({ key, - nativeEvent: { - isComposing: false - }, preventDefault, - stopPropagation, - target: {}, - currentTarget: {contains: () => true} + stopPropagation }); afterEach(() => { From 57401289baf80a273c444a8201f96e2f88bf3ec6 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 12 Jun 2026 17:11:44 -0700 Subject: [PATCH 4/4] feat: PromptField (#10191) * Initial TokenField implementation * tokenize with regex * rich copy paste * undo/redo * refactor undo/redo to enable external state updates * Support autocomplete when the collection is not always mounted * Add tests for undo/redo * support positioning popover inline with cursor * Add states and handle some browser differences * setup tests * Add more story examples * PromptField * lint * update browser test scripts * implement plus menu * Wrap tokens in zero width spaces so cursor is placed correctly cross browser * migrate to useKeyboard * fix triple click * Fix cursor position with trailing newlines * skip flaky test for now * run browser tests in ci * run in docker * set the working directory * use absolute path? * Pass value to menu onAction * Support checking multiple drag types at once * refactor PromptField API * fix browser test * Move TokenField into AI package temporarily * revert running browser tests in CI for now * cleanup * Fix locale * support async completions * example of attachment upload states * Add upload progress directly into Attachment component * review comments * don't depend on useKeyboard shortcuts * typescript --- .storybook/.parcelrc | 2 + package.json | 3 +- .../src/actiongroup/ActionGroup.tsx | 2 +- .../react-spectrum/src/card/BaseLayout.tsx | 4 +- .../react-spectrum/test/menu/Menu.test.js | 12 +- .../test/menu/SubMenuTrigger.test.tsx | 4 +- .../@react-spectrum/ai/src/AttachmentList.tsx | 70 +- .../@react-spectrum/ai/src/PromptField.tsx | 604 +++++++++ .../@react-spectrum/ai/src/TokenField.tsx | 687 ++++++++++ .../ai/src/TokenSegmentList.ts | 451 +++++++ .../ai/stories/HorizontalCard.stories.tsx | 23 +- .../ai/stories/PromptField.stories.tsx | 315 +++++ .../ai/stories/Thread.stories.tsx | 312 +---- .../ai/stories/TokenField.stories.tsx | 463 +++++++ .../ai/stories/styles.global.css | 44 + .../ai/test/Thread.browser.test.tsx | 5 +- .../ai/test/TokenField.browser.test.tsx | 606 +++++++++ .../ai/test/TokenSegmentList.test.ts | 1159 +++++++++++++++++ .../ai/test/utils/tokenFieldBrowserUtils.tsx | 203 +++ packages/@react-spectrum/s2/src/Menu.tsx | 8 +- packages/@react-spectrum/s2/src/Popover.tsx | 3 +- .../s2/test/Menu.browser.test.tsx | 2 +- packages/@react-types/shared/src/dnd.d.ts | 3 +- .../react-aria-components/example/index.css | 2 +- .../test/AriaAutocomplete.test-util.tsx | 4 +- .../test/Autocomplete.test.tsx | 69 - .../react-aria-components/test/Menu.test.tsx | 8 +- .../src/autocomplete/useAutocomplete.ts | 40 +- .../react-aria/src/combobox/useComboBox.ts | 1 + packages/react-aria/src/dnd/utils.ts | 24 +- .../react-aria/src/form/useFormValidation.ts | 7 +- packages/react-aria/src/menu/useMenu.ts | 2 +- packages/react-aria/src/menu/useMenuItem.ts | 2 +- packages/react-aria/src/menu/utils.ts | 2 +- .../src/overlays/calculatePosition.ts | 27 +- .../src/overlays/useOverlayPosition.ts | 14 +- packages/react-aria/src/select/useSelect.ts | 1 + .../src/selection/useSelectableCollection.ts | 9 +- .../src/tabs/TabsKeyboardDelegate.ts | 4 +- vitest.browser.config.ts | 56 +- 40 files changed, 4840 insertions(+), 417 deletions(-) create mode 100644 packages/@react-spectrum/ai/src/PromptField.tsx create mode 100644 packages/@react-spectrum/ai/src/TokenField.tsx create mode 100644 packages/@react-spectrum/ai/src/TokenSegmentList.ts create mode 100644 packages/@react-spectrum/ai/stories/PromptField.stories.tsx create mode 100644 packages/@react-spectrum/ai/stories/TokenField.stories.tsx create mode 100644 packages/@react-spectrum/ai/stories/styles.global.css create mode 100644 packages/@react-spectrum/ai/test/TokenField.browser.test.tsx create mode 100644 packages/@react-spectrum/ai/test/TokenSegmentList.test.ts create mode 100644 packages/@react-spectrum/ai/test/utils/tokenFieldBrowserUtils.tsx diff --git a/.storybook/.parcelrc b/.storybook/.parcelrc index f725210d16d..83fa6373975 100644 --- a/.storybook/.parcelrc +++ b/.storybook/.parcelrc @@ -3,6 +3,8 @@ "resolvers": ["@parcel/resolver-glob", "..."], "transformers": { "packages/**/intl/**/*.json": ["parcel-transformer-intl"], + "starters/**/*.css": ["@parcel/transformer-css"], + "packages/react-aria-components/stories/styles.global.css": ["@parcel/transformer-css"], "raw:*": ["@parcel/transformer-raw"] } } diff --git a/package.json b/package.json index 63938ee5375..792f717e23a 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "start:mcp": "yarn workspace @react-spectrum/s2-docs generate:md && yarn build:mcp && node packages/dev/mcp/s2/dist/index.js && node packages/dev/mcp/react-aria/dist/index.js", "test:mcp": "yarn build:s2-docs && yarn build:mcp && node packages/dev/mcp/scripts/smoke-list-pages.mjs", "test": "cross-env STRICT_MODE=1 VIRT_ON=1 yarn jest", - "test:browser": " yarn playwright install && vitest --config=vitest.browser.config.ts", + "test:browser": "yarn playwright install && vitest run --config=vitest.browser.config.ts", + "test:browser:watch": "yarn playwright install && vitest --config=vitest.browser.config.ts", "test:lint": "ls packages/**/*.test-lint.js | xargs -n 1 node", "test-loose": "cross-env VIRT_ON=1 yarn jest", "test-storybook": "test-storybook --url http://localhost:9003 --browsers chromium --no-cache", diff --git a/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx b/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx index c7eb46dd757..f5c9a9755c4 100644 --- a/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx +++ b/packages/@adobe/react-spectrum/src/actiongroup/ActionGroup.tsx @@ -286,7 +286,7 @@ export const ActionGroup = forwardRef(function ActionGroup( onAction?.(key)} isDisabled={isDisabled} isEmphasized={isEmphasized} staticColor={staticColor} diff --git a/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx b/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx index 0b9a213e77a..6725bd065b5 100644 --- a/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx +++ b/packages/@adobe/react-spectrum/src/card/BaseLayout.tsx @@ -221,12 +221,12 @@ export class BaseLayout getFirstKey(): Node | undefined { let firstRow = this.collection.getItem(this.collection.getFirstKey()); - return getFirstItem(getChildNodes(firstRow, this.collection))?.key; + return firstRow ? getFirstItem(getChildNodes(firstRow, this.collection))?.key : null; } getLastKey(): Node | undefined { let lastRow = this.collection.getItem(this.collection.getLastKey()); - return getFirstItem(getChildNodes(lastRow, this.collection))?.key; + return lastRow ? getFirstItem(getChildNodes(lastRow, this.collection))?.key : null; } // TODO: pretty unwieldy because it needs to bounce back and forth between the parent key and the child key diff --git a/packages/@adobe/react-spectrum/test/menu/Menu.test.js b/packages/@adobe/react-spectrum/test/menu/Menu.test.js index 3cf74d2321f..9fd89ef7613 100644 --- a/packages/@adobe/react-spectrum/test/menu/Menu.test.js +++ b/packages/@adobe/react-spectrum/test/menu/Menu.test.js @@ -659,15 +659,15 @@ describe('Menu', function () { ]; await user.click(item1); - expect(onAction).toHaveBeenCalledWith('One'); + expect(onAction).toHaveBeenCalledWith('One', null); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item2); - expect(onAction).toHaveBeenCalledWith('Two'); + expect(onAction).toHaveBeenCalledWith('Two', null); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item3); - expect(onAction).toHaveBeenCalledWith('Three'); + expect(onAction).toHaveBeenCalledWith('Three', null); expect(onSelectionChange).toHaveBeenCalledTimes(0); }); @@ -701,15 +701,15 @@ describe('Menu', function () { ]; await user.click(item1); - expect(onAction).toHaveBeenCalledWith('One'); + expect(onAction).toHaveBeenCalledWith('One', {name: 'One'}); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item2); - expect(onAction).toHaveBeenCalledWith('Two'); + expect(onAction).toHaveBeenCalledWith('Two', {name: 'Two'}); expect(onSelectionChange).toHaveBeenCalledTimes(0); await user.click(item3); - expect(onAction).toHaveBeenCalledWith('Three'); + expect(onAction).toHaveBeenCalledWith('Three', {name: 'Three'}); expect(onSelectionChange).toHaveBeenCalledTimes(0); }); diff --git a/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx b/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx index 03468f2c4b9..c9af0ac3780 100644 --- a/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx +++ b/packages/@adobe/react-spectrum/test/menu/SubMenuTrigger.test.tsx @@ -656,7 +656,7 @@ describe('Submenu', function () { expect(onAction).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(submenuOnAction).toHaveBeenCalledTimes(1); - expect(submenuOnAction).toHaveBeenLastCalledWith('Lvl 2 Item 2'); + expect(submenuOnAction).toHaveBeenLastCalledWith('Lvl 2 Item 2', null); expect(submenuOnClose).toHaveBeenCalledTimes(1); expect(onOpenChange).toHaveBeenCalledTimes(4); expect(onOpenChange).toHaveBeenLastCalledWith(false); @@ -725,7 +725,7 @@ describe('Submenu', function () { menus = tree.queryAllByRole('menu'); expect(menus).toHaveLength(0); expect(onAction).toHaveBeenCalledTimes(1); - expect(onAction).toHaveBeenLastCalledWith('Lvl 1 Item 1'); + expect(onAction).toHaveBeenLastCalledWith('Lvl 1 Item 1', null); }); it('supports selectionMode and onSelectionChange on submenus', async function () { diff --git a/packages/@react-spectrum/ai/src/AttachmentList.tsx b/packages/@react-spectrum/ai/src/AttachmentList.tsx index 2d864dce7df..e5b8b93e847 100644 --- a/packages/@react-spectrum/ai/src/AttachmentList.tsx +++ b/packages/@react-spectrum/ai/src/AttachmentList.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMRef} from '@react-types/shared'; +import {AriaLabelingProps, DOMRef, forwardRefType} from '@react-types/shared'; import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {BasicHorizontalCard} from './HorizontalCard'; import {Button} from 'react-aria-components/Button'; @@ -18,9 +18,12 @@ import {CardProps} from '@react-spectrum/s2/Card'; import Close from '@react-spectrum/s2/icons/Close'; import {forwardRef, useRef} from 'react'; import {iconStyle} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ImageContext} from '@react-spectrum/s2/Image'; import {mergeStyles} from '@react-spectrum/s2/mergeStyles'; import {pressScale} from '@react-spectrum/s2/pressScale'; -import {Tag, TagGroup, TagList} from 'react-aria-components/TagGroup'; +import {ProgressCircle} from '@react-spectrum/s2/ProgressCircle'; +import {StyleProps, TagProps} from '@react-spectrum/s2'; +import {Tag, TagGroup, TagGroupProps, TagList, TagListProps} from 'react-aria-components/TagGroup'; import {useDOMRef} from './useDOMRef'; const controlSizeM = { @@ -103,14 +106,22 @@ const CloseButton = function CloseButton(props) { ); }; -export const AttachmentList = forwardRef(function AttachmentList( - props: any, +export interface AttachmentListProps + extends + Omit, + StyleProps, + Pick, 'items' | 'children' | 'dependencies'> {} + +export const AttachmentList = (forwardRef as forwardRefType)(function AttachmentList( + props: AttachmentListProps, ref: DOMRef ) { let domRef = useDOMRef(ref); return ( { + uploadProgress?: number; +} + export const Attachment = forwardRef(function Attachment( - props: CardProps & AriaLabelingProps, + props: AttachmentProps, ref: DOMRef ) { let { + id, textValue, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, @@ -139,6 +156,7 @@ export const Attachment = forwardRef(function Attachment( let domRef = useDOMRef(ref); return ( - {props.children} + + {props.uploadProgress != null && props.uploadProgress < 100 && ( +
    + +
    + )} + {/* Reduce opacity of the thumbnail if upload is in progress */} + + {ctx => ( + + {typeof props.children === 'function' + ? props.children({size: otherProps.size || 'M'}) + : props.children} + + )} + +
    {/** Definitely not a close button, though looks like one. */}
    void; + isGenerating?: boolean; + onStop?: () => void; + onAddAttachments?: (attachments: Attachment[]) => void; + onRemoveAttachments?: (attachments: Attachment[]) => void; + styles?: StyleString; +} + +interface PromptFieldState { + attachments: Attachment[]; + setAttachments: React.Dispatch>; + acceptedAttachmentTypes?: string[]; + prompt: TokenSegmentList; + setPrompt: React.Dispatch>; + inputRef: React.RefObject; + onSubmit?: () => void; + onStop?: () => void; + isGenerating: boolean; + onAddAttachments?: (attachments: Attachment[]) => void; + onRemoveAttachments?: (attachments: Attachment[]) => void; +} + +// TODO: make this customizable +const tokenRegex = /(?<=\s|^)(https?:\/\/)?(www\.)?([^/\s]+\.[a-z]{2,}(\/\S+)?)(?=\s)/g; +class AutoLinkingSegmentList extends TokenSegmentList { + tokenize(text: string): TokenFieldSegment[] { + if (text.length === 0) { + return [{type: 'text', text}]; + } + + tokenRegex.lastIndex = 0; + + let match: RegExpExecArray | null = null; + let start = 0; + let segments: TokenFieldSegment[] = []; + while ((match = tokenRegex.exec(text))) { + if (match.index > start) { + segments.push({type: 'text', text: text.slice(start, match.index)}); + } + segments.push({type: 'token', text: match[3], value: {type: 'url', url: match[0]}}); + start = match.index + match[0].length; + } + + if (start < text.length) { + segments.push({type: 'text', text: text.slice(start)}); + } + + return segments; + } +} + +const PromptFieldContext = createContext({ + attachments: [], + setAttachments: () => {}, + prompt: new AutoLinkingSegmentList([]), + setPrompt: () => {}, + inputRef: createRef(), + isGenerating: false +}); + +function matchMimeType(mimeType: string, acceptedMimeTypes: string[]): boolean { + return acceptedMimeTypes.some(type => { + if (type === '*/*') { + return true; + } + if (type.endsWith('/*')) { + return mimeType.startsWith(type.slice(0, -2)); + } + return mimeType === type; + }); +} + +export function PromptField(props: PromptFieldProps) { + let { + children, + acceptedAttachmentTypes, + isGenerating, + onStop, + UNSAFE_className = '', + UNSAFE_style, + styles, + onAddAttachments, + onRemoveAttachments + } = props; + let [prompt, setPrompt] = useState(new AutoLinkingSegmentList([])); + let [attachments, setAttachments] = useState([]); + + // Not using RAC DropZone because it adds its own focusable button, + // and we want to avoid an extra tab. We support pasting files directly into the input. + let inputRef = useRef(null); + let {dropProps, isDropTarget} = useDrop({ + ref: inputRef, + hasDropButton: true, + isDisabled: !acceptedAttachmentTypes, + getDropOperation(types) { + return acceptedAttachmentTypes && types.has(acceptedAttachmentTypes) ? 'copy' : 'cancel'; + }, + async onDrop(e) { + let files = await Promise.all( + e.items + .filter(isFileDropItem) + .filter(item => matchMimeType(item.type, acceptedAttachmentTypes!)) + .map(async item => ({ + id: crypto.randomUUID(), + file: await item.getFile(), + image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '' + })) + ); + onAddAttachments?.(files); + setAttachments(attachments => [...attachments, ...files]); + } + }); + + let onSubmit = () => { + if (prompt.segments.length === 0) { + return; + } + + props.onSubmit?.(prompt, attachments); + setPrompt(new AutoLinkingSegmentList([])); + setAttachments([]); + inputRef.current?.focus(); + }; + + return ( + +
    + + UNSAFE_className + + mergeStyles( + style({ + display: 'flex', + flexDirection: 'column', + gap: 16, + padding: 16, + boxShadow: 'emphasized', + backgroundColor: { + default: 'elevated', + isDropTarget: 'blue-200' + }, + borderRadius: 'lg', + borderWidth: 2, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isDropTarget: 'blue-800' + }, + cursor: 'text' + })({...renderProps, isDropTarget}), + styles + ) + } + onPointerDown={e => { + // If not clicking on something focusable within the prompt field, focus the input. + let target = getEventTarget(e) as Element | null; + while (target && target !== e.currentTarget && !isFocusable(target)) { + target = target.parentElement; + } + + if (target === e.currentTarget) { + e.preventDefault(); + inputRef.current?.focus(); + } + }}> + {children} + +

    + Responses are generated using AI, and may be inaccurate. Check before using.{' '} + + AI User Guidelines + +

    +
    +
    + ); +} + +interface PromptFieldAttachmentListProps extends AttachmentListProps { + children?: (attachment: Attachment) => React.ReactNode; +} + +export function PromptFieldAttachmentList(props: PromptFieldAttachmentListProps) { + let {children} = props; + let {attachments, setAttachments, onRemoveAttachments, inputRef} = useContext(PromptFieldContext); + if (attachments.length === 0) { + return null; + } + + return ( + { + let removedAttachments = attachments.filter(attachment => keys.has(attachment.id)); + onRemoveAttachments?.(removedAttachments); + setAttachments(attachments => attachments.filter(attachment => !keys.has(attachment.id))); + if (removedAttachments.length === attachments.length) { + inputRef.current?.focus(); + } + }} + items={attachments}> + {children || + (attachment => ( + + {attachment.image && } + + ))} + + ); +} + +interface PromptTokenFieldProps { + completionTrigger?: RegExp; + renderCompletions?: ( + filterValue: string + ) => React.ReactNode[] | null | Promise; + children?: (segment: TokenSegment) => React.ReactElement; +} + +export function PromptTokenField(props: PromptTokenFieldProps) { + let {completionTrigger, renderCompletions, children} = props; + let { + prompt, + setPrompt, + acceptedAttachmentTypes, + setAttachments, + onAddAttachments, + inputRef, + onSubmit + } = useContext(PromptFieldContext); + let [isFocused, setFocused] = useState(false); + + let [filterAnchor, filterValue] = useMemo(() => { + if (completionTrigger) { + let filterAnchor = prompt.findText( + prompt.caretPosition, + Direction.Backward, + completionTrigger + ); + if (filterAnchor != null) { + let filterValue = prompt.slice(filterAnchor, prompt.caretPosition).toString(); + return [filterAnchor, filterValue]; + } + } + return [null, null]; + }, [completionTrigger, prompt]); + + let items = useMemo(() => { + return filterValue != null ? renderCompletions?.(filterValue) : null; + }, [filterValue, renderCompletions]); + + return ( + + { + if (e.isTrusted) { + setFocused(true); + } + }} + onBlur={e => { + if (e.isTrusted) { + setFocused(false); + } + }} + onPaste={ + acceptedAttachmentTypes + ? e => { + let clipboardData = e.clipboardData as DataTransfer; + let attachments: Attachment[] = []; + for (let item of clipboardData.items) { + if (matchMimeType(item.type, acceptedAttachmentTypes)) { + let file = item.getAsFile()!; + attachments.push({ + id: crypto.randomUUID(), + file, + image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' + }); + } + } + if (attachments.length > 0) { + onAddAttachments?.(attachments); + setAttachments(prev => [...prev, ...attachments]); + } + } + : undefined + } + onSubmit={onSubmit} + className={renderProps => + css('&:empty::before { content: attr(data-placeholder); }') + + style({ + font: 'body', + color: { + default: baseColor('neutral'), + ':empty': { + default: 'gray-600', + forcedColors: 'GrayText' + } + }, + width: 'full', + outlineStyle: 'none', + cursor: 'text' + })(renderProps) + }> + {children || (segment => {segment.text})} + + + + ); +} + +interface PromptTokenFieldPopoverProps extends PopoverProps { + filterAnchor?: Position | null; + items?: React.ReactNode[] | null | Promise; + isFocused?: boolean; +} + +function PromptTokenFieldPopover(props: PromptTokenFieldPopoverProps) { + let {filterAnchor, items, isFocused} = props; + let {inputRef, setPrompt} = useContext(PromptFieldContext); + + let resolvedItems = items instanceof Promise ? use(items) : items; + let isOpen = + isFocused && filterAnchor != null && resolvedItems != null && resolvedItems.length > 0; + + // Cache items so that popover content doesn't flicker to empty while animating out + let [menuItems, setMenuItems] = useState(resolvedItems); + if (resolvedItems !== menuItems && resolvedItems != null && resolvedItems.length > 0) { + setMenuItems(resolvedItems); + } + + let onAction = (item: any) => { + setPrompt(value => + value.replaceRangeWithSegments( + filterAnchor!, + value.caretPosition, + [ + { + type: 'token', + text: 'command' in item ? item.command : item.title, + value: item + }, + {type: 'text', text: ' '} + ], + false // Don't coalesce in undo/redo history. + ) + ); + }; + + return ( + { + return positionToDOMRange(target, filterAnchor!).getBoundingClientRect(); + }}> + onAction(value)}>{menuItems} + + ); +} + +export interface PromptTokenProps extends Omit { + children: React.ReactNode; +} + +export function PromptToken(props: PromptTokenProps) { + return ( + + {icon}}}> + {props.children} + + + ); +} + +interface PromptFieldToolbarProps { + children: React.ReactNode; +} + +export function PromptFieldToolbar(props: PromptFieldToolbarProps) { + let {children} = props; + return ( +
    + {children} +
    + ); +} + +export function PromptFieldSubmitButton() { + let {prompt, isGenerating, onSubmit, onStop} = useContext(PromptFieldContext); + return ( + + ); +} + +interface InsertMenuItemProps { + children: React.ReactNode; +} + +export function InsertMenuButton(props: InsertMenuItemProps) { + let {children} = props; + return ( + + + + + {children} + + ); +} + +export function AttachFileMenuItem() { + let {acceptedAttachmentTypes, setAttachments, onAddAttachments} = useContext(PromptFieldContext); + return ( + { + let input = document.createElement('input'); + input.type = 'file'; + if (acceptedAttachmentTypes) { + input.accept = acceptedAttachmentTypes.join(','); + } + input.multiple = true; + input.onchange = e => { + let files = (e.currentTarget as HTMLInputElement).files; + if (files && acceptedAttachmentTypes) { + let attachments = Array.from(files) + .filter(file => matchMimeType(file.type, acceptedAttachmentTypes)) + .map(file => ({ + id: crypto.randomUUID(), + file, + image: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' + })); + if (attachments.length > 0) { + onAddAttachments?.(attachments); + setAttachments(prev => [...prev, ...attachments]); + } + } + }; + input.click(); + }}> + + Attach a file + + ); +} + +export function InsertTokenMenuItem(props: MenuItemProps) { + let {setPrompt, inputRef} = useContext(PromptFieldContext); + let onAction = (item: any) => { + setPrompt(value => + value.replaceRangeWithSegments( + value.caretPosition, + value.caretPosition, + [ + { + type: 'token', + text: 'command' in item ? item.command : item.title, + value: item + }, + {type: 'text', text: ' '} + ], + false // Don't coalesce in undo/redo history. + ) + ); + + // Wait for popover animation + setTimeout(() => { + inputRef.current?.focus(); + }, 400); + }; + + return onAction(props.value)} />; +} diff --git a/packages/@react-spectrum/ai/src/TokenField.tsx b/packages/@react-spectrum/ai/src/TokenField.tsx new file mode 100644 index 00000000000..df659987a46 --- /dev/null +++ b/packages/@react-spectrum/ai/src/TokenField.tsx @@ -0,0 +1,687 @@ +/* + * 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 {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; +import { + Direction, + Position, + TokenFieldSegment, + TokenSegment, + TokenSegmentList +} from './TokenSegmentList'; +import {FieldInputContext} from 'react-aria-components/Autocomplete'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {FocusableProps} from '@react-types/shared'; +import {isMac} from 'react-aria/private/utils/platform'; +import {mergeProps} from 'react-aria/mergeProps'; +import {mergeRefs} from 'react-aria/mergeRefs'; +import React, {ForwardedRef, forwardRef, HTMLAttributes, useMemo, useRef, useState} from 'react'; +import {RenderProps, StyleRenderProps, useRenderProps} from 'react-aria-components/useRenderProps'; +import {SlotProps, useSlottedContext} from 'react-aria-components/slots'; +import {useControlledState} from 'react-stately/useControlledState'; +import {useEvent} from 'react-aria/private/utils/useEvent'; +import {useFocusable} from 'react-aria/useFocusable'; +import {useFocusRing} from 'react-aria/useFocusRing'; +import {useKeyboard} from 'react-aria/useKeyboard'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLocale} from 'react-aria/I18nProvider'; +import {useObjectRef} from 'react-aria/useObjectRef'; + +export type {TokenFieldSegment}; + +interface TokenFieldRenderProps { + isReadOnly: boolean; + isDisabled: boolean; + isFocused: boolean; + isFocusVisible: boolean; +} + +export interface TokenFieldProps + extends StyleRenderProps, SlotProps, FocusableProps { + value?: TokenSegmentList; + defaultValue?: TokenSegmentList; + onChange?: (value: TokenSegmentList) => void; + children: (segment: TokenSegment) => React.ReactElement; + multiline?: boolean; + isReadOnly?: boolean; + isDisabled?: boolean; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-describedby'?: string; + onPaste?: (e: ClipboardEvent) => void; + onSubmit?: () => void; +} + +export const CLIPBOARD_MIME_TYPE = 'application/vnd.react-aria.tokens+json'; + +export const TokenField = forwardRef(function TokenField( + props: TokenFieldProps, + forwardedRef: ForwardedRef +) { + let { + value: valueProp, + defaultValue: defaultValueProp = new TokenSegmentList([]), + onChange, + children, + multiline = false, + isReadOnly = false, + isDisabled = false, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy + } = props; + + let fieldCtx = useSlottedContext(FieldInputContext, props.slot); + let { + value: _autocompleteValue, + onChange: onAutocompleteChange, + ref: autocompleteRef, + ...autocompleteProps + } = fieldCtx ?? {}; + + let ref = useObjectRef(forwardedRef); + let [state, setState] = useControlledState(valueProp, defaultValueProp, onChange); + let {locale} = useLocale(); + let graphemeSegmenter = useMemo( + () => new Intl.Segmenter(locale, {granularity: 'grapheme'}), + [locale] + ); + let wordSegmenter = useMemo(() => new Intl.Segmenter(locale, {granularity: 'word'}), [locale]); + + let dropPosition = useRef(null); + let transferredData = useRef(null); + + let apply = (fn: (value: TokenSegmentList) => TokenSegmentList) => { + setState(value => { + let newValue = fn(value); + onAutocompleteChange?.(newValue.toString()); + return newValue; + }); + }; + + let caretPosition = useRef(null); + useLayoutEffect(() => { + if (ref.current && state.caretPosition && state.caretPosition !== caretPosition.current) { + setCursor(ref.current, state.caretPosition); + caretPosition.current = state.caretPosition; + } + }); + + // Handle text editing commands and prevent browser default behavior. + useEvent(ref, 'beforeinput', e => { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + let range = selection.getRangeAt(0); + let [start, end] = rangeToPositions(ref.current!, range); + + // https://www.w3.org/TR/input-events-2/#interface-InputEvent-Attributes + switch (e.inputType) { + case 'insertText': + case 'insertReplacementText': + case 'insertFromPaste': + case 'insertFromYank': + case 'insertFromDrop': { + let data: TokenFieldSegment[] = [{type: 'text', text: e.data ?? ''}]; + if (transferredData.current) { + data = transferredData.current; + transferredData.current = null; + } else if (e.dataTransfer) { + if (e.dataTransfer.types.includes(CLIPBOARD_MIME_TYPE)) { + data = JSON.parse(e.dataTransfer.getData(CLIPBOARD_MIME_TYPE)); + } else if (e.dataTransfer.types.includes('text/plain')) { + data[0].text = e.dataTransfer.getData('text/plain'); + } + } + + if (e.inputType === 'insertFromDrop' && dropPosition.current) { + start = end = dropPosition.current; + dropPosition.current = null; + } + + apply(tokens => + tokens.replaceRangeWithSegments( + start, + end, + data, + e.inputType === 'insertText' // Don't coalesce paste/drop events with other edits. + ) + ); + break; + } + case 'insertParagraph': { + if (props.onSubmit) { + props.onSubmit(); + break; + } + // fallthrough + } + case 'insertLineBreak': { + if (multiline) { + apply(tokens => tokens.replaceRange(start, end, '\n')); + } + break; + } + case 'deleteContentBackward': + case 'deleteContentForward': + case 'deleteWordBackward': + case 'deleteWordForward': + case 'deleteHardLineForward': + case 'deleteHardLineBackward': + case 'deleteSoftLineForward': + case 'deleteSoftLineBackward': + case 'deleteContent': + case 'deleteByCut': { + if (!range.collapsed) { + apply(tokens => tokens.replaceRange(start, end, '')); + break; + } + + switch (e.inputType) { + case 'deleteContentBackward': { + apply(tokens => tokens.delete(start, graphemeSegmenter, Direction.Backward)); + break; + } + case 'deleteContentForward': + apply(tokens => tokens.delete(start, graphemeSegmenter, Direction.Forward)); + break; + case 'deleteWordBackward': { + apply(tokens => tokens.delete(start, wordSegmenter, Direction.Backward)); + break; + } + case 'deleteWordForward': + apply(tokens => tokens.delete(start, wordSegmenter, Direction.Forward)); + break; + case 'deleteHardLineForward': + case 'deleteSoftLineForward': // TODO: this usually deletes to the nearest *visual* line break rather than a hard break + apply(tokens => tokens.deleteLine(start, Direction.Forward)); + break; + case 'deleteHardLineBackward': + case 'deleteSoftLineBackward': + apply(tokens => tokens.deleteLine(start, Direction.Backward)); + break; + } + break; + } + case 'deleteByDrag': { + apply(tokens => { + let endOffset = + start.index === end.index ? end.offset : tokens.segments[start.index].text.length; + let change = tokens.replaceRange(start, end, ''); + if ( + dropPosition.current && + dropPosition.current.index === start.index && + dropPosition.current.offset >= start.offset + ) { + dropPosition.current.offset -= endOffset - start.offset; + } + + return change; + }); + break; + } + } + + e.preventDefault(); + }); + + // Composition events are not cancelable, so we need to store the start position and update the value in the compositionend event. + let compositionStart = useRef<[Position, Position] | null>(null); + useEvent(ref, 'compositionstart', () => { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return; + } + let range = selection.getRangeAt(0); + compositionStart.current = rangeToPositions(ref.current!, range); + }); + + useEvent(ref, 'compositionend', e => { + let range = compositionStart.current; + if (range) { + apply(tokens => tokens.replaceRange(range[0], range[1], e.data)); + } + }); + + let writeClipboardData = (e: ClipboardEvent | DragEvent) => { + if ('clipboardData' in e) { + e.preventDefault(); + } + let selection = getSelection(ref.current!); + if (!selection) { + return; + } + let [start, end] = selection; + let slice = state.slice(start, end); + let dataTransfer = 'clipboardData' in e ? e.clipboardData : e.dataTransfer; + dataTransfer?.setData(CLIPBOARD_MIME_TYPE, JSON.stringify(slice.segments)); + dataTransfer?.setData('text/plain', slice.toString()); + + if (e.type === 'cut') { + apply(tokens => tokens.replaceRange(start, end, '', false)); + } + }; + + useEvent(ref, 'copy', writeClipboardData); + useEvent(ref, 'cut', writeClipboardData); + useEvent(ref, 'dragstart', writeClipboardData); + useEvent(ref, 'paste', e => { + // Safari doesn't pass the custom clipboard data type to beforeinput dataTransfer so we handle it here. + if (e.clipboardData && e.clipboardData.types.includes(CLIPBOARD_MIME_TYPE)) { + transferredData.current = JSON.parse(e.clipboardData.getData(CLIPBOARD_MIME_TYPE)); + } + }); + + // Store the cursor position on drop so we know where to insert when the insertFromDrop event occurs. + useEvent(ref, 'drop', e => { + if (typeof document.caretPositionFromPoint === 'function') { + let pos = document.caretPositionFromPoint(e.clientX, e.clientY); + if (pos) { + dropPosition.current = getPosition(ref.current!, pos.offsetNode, pos.offset); + } + } else if (typeof document.caretRangeFromPoint === 'function') { + let range = document.caretRangeFromPoint(e.clientX, e.clientY); + if (range) { + dropPosition.current = getPosition(ref.current!, range.startContainer, range.startOffset); + } + } + + if (e.dataTransfer && e.dataTransfer.types.includes(CLIPBOARD_MIME_TYPE)) { + transferredData.current = JSON.parse(e.dataTransfer.getData(CLIPBOARD_MIME_TYPE)); + } + }); + + useSelectionChange(ref, () => { + state.endCoalescing(); + + // When the cursor moves next to a token, announce it. + // Otherwise the screen reader will only announce the first/last character. + if (window.getSelection()?.isCollapsed) { + let [start, end] = getSelection(ref.current!)!; + if (start.index === end.index && start.offset === 0) { + let segment = state.segments[start.index]; + if (segment?.type === 'token') { + announce(segment.text, 'assertive'); + } + } else if (start.offset === state.segments[start.index].text.length) { + let segment = state.segments[start.index + 1]; + if (segment?.type === 'token') { + announce(segment.text, 'assertive'); + } + } + + // Update the caret position in the value. + setState(value => value.withCaretPosition(end)); + } + }); + + // Override the default triple click behavior to ensure that tokens get selected. + // Some browsers only select the text between tokens instead of the entire line. + useEvent(ref, 'mousedown', e => { + if (e.detail === 3) { + let selection = getSelection(ref.current!); + if (!selection) { + return; + } + + let start = state.findLineBoundary(selection[0], Direction.Backward); + let end = state.findLineBoundary(selection[1], Direction.Forward); + if (start && end) { + e.preventDefault(); + setSelection(ref.current!, start, end, true); + } + } + }); + + // Firefox does not allow placing the cursor between adjacent tokens, so navigate manually. + let moveSelection = (segmenter: Intl.Segmenter, direction: Direction, extend = false) => { + let selection = getSelection(ref.current!); + if (!selection) { + return false; + } + let originalPos = direction === Direction.Backward ? selection[0] : selection[1]; + let pos = + extend || isCollapsed(selection[0], selection[1]) + ? state.findBoundaryWithSegmenter(originalPos, segmenter, direction) + : originalPos; + if (pos) { + let [start, end] = + direction === Direction.Backward + ? [pos, extend ? selection[1] : pos] + : [extend ? selection[0] : pos, pos]; + setSelection(ref.current!, start, end, true); + return true; + } + return false; + }; + + let mod = isMac() ? 'Meta' : 'Control'; + let wordModKey = isMac() ? 'Alt' : 'Control'; + let shortcuts: Record boolean | void> = { + [`${mod}+z`]: () => { + apply(state => state.undo()); + }, + [isMac() ? 'Shift+Meta+z' : 'Control+y']: () => { + apply(state => state.redo()); + }, + ArrowLeft: () => { + return moveSelection(graphemeSegmenter, Direction.Backward); + }, + [`${wordModKey}+ArrowLeft`]: () => { + return moveSelection(wordSegmenter, Direction.Backward); + }, + 'Shift+ArrowLeft': () => { + return moveSelection(graphemeSegmenter, Direction.Backward, true); + }, + [`Shift+${wordModKey}+ArrowLeft`]: () => { + return moveSelection(wordSegmenter, Direction.Backward, true); + }, + ArrowRight: () => { + return moveSelection(graphemeSegmenter, Direction.Forward); + }, + [`${wordModKey}+ArrowRight`]: () => { + return moveSelection(wordSegmenter, Direction.Forward); + }, + 'Shift+ArrowRight': () => { + return moveSelection(graphemeSegmenter, Direction.Forward, true); + }, + [`Shift+${wordModKey}+ArrowRight`]: () => { + return moveSelection(wordSegmenter, Direction.Forward, true); + }, + Home: () => { + // Browsers do not behave consistently when there are tokens. + let selection = getSelection(ref.current!); + if (!selection) { + return false; + } + let boundary = state.findLineBoundary(selection[0], Direction.Backward); + if (boundary) { + setCursor(ref.current!, boundary, true); + return true; + } + return false; + }, + End: () => { + let selection = getSelection(ref.current!); + if (!selection) { + return false; + } + let boundary = state.findLineBoundary(selection[1], Direction.Forward); + if (boundary) { + setCursor(ref.current!, boundary, true); + return true; + } + return false; + } + }; + + let {keyboardProps} = useKeyboard({ + onKeyDown: e => { + // mini version of useKeyboard shortcuts until it is merged. + let modifiers = ['Shift', 'Control', 'Alt', 'Meta'] satisfies React.ModifierKey[]; + let modifierKeys = modifiers.filter(modifier => e.getModifierState(modifier)); + let keys = modifierKeys.length > 0 ? `${modifierKeys.join('+')}+${e.key}` : e.key; + let handler = shortcuts[keys]; + if (handler) { + let result = handler(); + if (result === true || result === undefined) { + e.preventDefault(); + return; + } + } + + e.continuePropagation(); + } + }); + + let {isFocused, isFocusVisible, focusProps} = useFocusRing(); + let {focusableProps} = useFocusable(props, ref); + + let renderProps = useRenderProps({ + ...props, + children: undefined, + defaultClassName: 'react-aria-TokenField', + values: { + isFocused, + isFocusVisible, + isDisabled, + isReadOnly + } + }); + + let DOMProps = filterDOMProps(props, {global: true}); + + return ( +
    , + keyboardProps, + {onPaste: props.onPaste} + )} + ref={mergeRefs(ref, autocompleteRef as any)} + role="textbox" + contentEditable="true" + suppressContentEditableWarning + aria-multiline={multiline} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} + aria-describedby={ariaDescribedBy} + data-focused={isFocused || undefined} + data-focus-visible={isFocusVisible || undefined} + data-disabled={isDisabled || undefined} + data-readonly={isReadOnly || undefined} + style={{...renderProps.style, whiteSpace: 'pre-wrap'}}> + {state.segments.map((v, i) => { + switch (v.type) { + case 'token': { + let token = children(v); + return ( + // Wrap tokens in zero-width spaces so the cursor is placed correctly. + + {'\u200b'} + {token} + {'\u200b'} + + ); + } + case 'text': + return v.text; + } + })} + {/* Force cursor to the next line if the last segment ends with a newline. */} + {state.segments.at(-1)?.text.endsWith('\n') &&
    } +
    + ); +}); + +interface TokenRenderProps { + isSelected: boolean; + isDisabled: boolean; +} + +export interface TokenProps extends RenderProps {} + +export const Token = forwardRef(function Token( + props: TokenProps, + ref: ForwardedRef +) { + let objectRef = useObjectRef(ref); + let [isSelected, setSelected] = useState(false); + + useEvent(useRef(document), 'selectionchange', () => { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !objectRef.current) { + return; + } + + let range = selection.getRangeAt(0); + if (!range.collapsed && range.intersectsNode(objectRef.current)) { + setSelected(true); + } else { + setSelected(false); + } + }); + + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-Token', + values: { + isSelected, + isDisabled: false + } + }); + + return ( + + {renderProps.children} + + ); +}); + +function indexOfNode(node: Node) { + let index = 0; + let n: Node | null = node; + + while ((n = n.previousSibling)) { + index++; + } + return index; +} + +export function getSelection(container: Element): [Position, Position] | null { + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + return null; + } + let range = selection.getRangeAt(0); + return rangeToPositions(container, range); +} + +export function rangeToPositions( + container: Element, + range: Range | StaticRange +): [Position, Position] { + let start = getPosition(container, range.startContainer, range.startOffset); + let end = getPosition(container, range.endContainer, range.endOffset); + return [start, end]; +} + +function getPosition(container: Element, node: Node, offset: number): Position { + if (node === container) { + return {index: offset, offset: 0}; + } + + let originalNode = node; + while (node.parentNode !== container) { + node = node.parentNode!; + } + + let index = indexOfNode(node); + if (node.nodeType === Node.ELEMENT_NODE) { + let tokenNode = node.childNodes[1]; + if (originalNode === tokenNode) { + // Cursor is inside the token. + offset = offset > 0 ? (tokenNode?.textContent?.length ?? 0) : 0; + } else if (originalNode === node) { + // Cursor is inside the wrapper element. + offset = offset <= 1 ? 0 : (tokenNode?.textContent?.length ?? 0); + } else { + // Cursor is on one of the zero width spaces. + offset = + originalNode === tokenNode.previousSibling ? 0 : (tokenNode?.textContent?.length ?? 0); + } + return {index, offset}; + } + return {index, offset}; +} + +let isProgrammaticSelectionChange = false; + +// TODO: do we want to export these? +export function setCursor(root: Element, pos: Position, fireEvent = false) { + setSelection(root, pos, pos, fireEvent); +} + +export function setSelection(root: Element, start: Position, end: Position, fireEvent = false) { + let selection = window.getSelection(); + if (selection) { + let range = createDOMRange(root, start, end); + isProgrammaticSelectionChange = !fireEvent; + selection.removeAllRanges(); + selection.addRange(range); + } +} + +export function positionToDOMRange(root: Element, pos: Position): Range { + return createDOMRange(root, pos, pos); +} + +function createDOMRange(root: Element, start: Position, end: Position): Range { + let range = document.createRange(); + let child = root.childNodes[start.index]; + if (!child) { + range.setStart(root, Math.min(root.childNodes.length, start.index)); + } else if (child.nodeType === Node.ELEMENT_NODE) { + // Place the cursor in one of the zero width space nodes. + range.setStart(child, start.offset > 0 ? 2 : 0); + } else { + // Place the cursor in the text node. + range.setStart(child, start.offset); + } + child = root.childNodes[end.index]; + if (!child) { + range.setEnd(root, Math.min(root.childNodes.length, end.index)); + } else if (child.nodeType === Node.ELEMENT_NODE) { + range.setEnd(child, end.offset > 0 ? 2 : 0); + } else { + range.setEnd(child, end.offset); + } + return range; +} + +function useSelectionChange(ref: React.RefObject, handler: () => void) { + useEvent(useRef(document), 'selectionchange', () => { + if (isProgrammaticSelectionChange) { + isProgrammaticSelectionChange = false; + return; + } + + let selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !ref.current) { + return; + } + + let range = selection.getRangeAt(0); + if (range.intersectsNode(ref.current)) { + handler(); + } + }); +} + +function isCollapsed(pos1: Position, pos2: Position) { + return pos1.index === pos2.index && pos1.offset === pos2.offset; +} diff --git a/packages/@react-spectrum/ai/src/TokenSegmentList.ts b/packages/@react-spectrum/ai/src/TokenSegmentList.ts new file mode 100644 index 00000000000..5b8518dcd2c --- /dev/null +++ b/packages/@react-spectrum/ai/src/TokenSegmentList.ts @@ -0,0 +1,451 @@ +/* + * 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. + */ + +export type TokenFieldSegment = TextSegment | TokenSegment; + +export interface TextSegment { + type: 'text'; + text: string; +} + +export interface TokenSegment { + type: 'token'; + text: string; + /** An arbitrary value associated with the token. */ + value?: any; +} + +export interface Position { + /** Index of the segment in the list. */ + index: number; + /** Text offset within the segment in UTF-16 code units. */ + offset: number; +} + +export enum Direction { + Forward = 1, + Backward = -1 +} + +export interface TokenSegmentListOptions { + caretPosition?: Position | null; +} + +/** + * A list of segments containing editable text and non-editable tokens. + */ +export class TokenSegmentList { + readonly segments: readonly TokenFieldSegment[]; + caretPosition: Position = {index: 0, offset: 0}; + // Linked list representing the undo/redo history. + private previous: TokenSegmentList | null = null; + private next: TokenSegmentList | null = null; + private isCoalescing = true; + + constructor(tokens: readonly TokenFieldSegment[], options?: TokenSegmentListOptions) { + this.segments = tokens; + this.caretPosition = options?.caretPosition ?? {index: 0, offset: 0}; + } + + protected createSegmentList(segments: readonly TokenFieldSegment[]): TokenSegmentList { + const Constructor = this.constructor as new ( + segments: readonly TokenFieldSegment[], + options?: TokenSegmentListOptions + ) => TokenSegmentList; + return new Constructor(segments); + } + + withCaretPosition(caretPosition: Position): TokenSegmentList { + if ( + this.caretPosition.index === caretPosition.index && + this.caretPosition.offset === caretPosition.offset + ) { + return this; + } + + let result = this.createSegmentList(this.segments); + result.caretPosition = caretPosition; + result.previous = this.previous; + result.next = this.next; + result.isCoalescing = this.isCoalescing; + return result; + } + + private splitSegment( + segment: TokenFieldSegment | undefined, + offset: number + ): [TokenFieldSegment | null, TokenFieldSegment | null] { + if (!segment) { + let empty = this.createTextSegment(''); + return [offset > 0 ? empty : null, offset > 0 ? null : empty]; + } + + if (segment.type === 'token') { + return [offset > 0 ? {...segment} : null, offset > 0 ? null : {...segment}]; + } + + return [ + offset > 0 ? {type: 'text', text: segment.text.slice(0, offset)} : null, + offset < segment.text.length ? this.createTextSegment(segment.text.slice(offset)) : null + ]; + } + + private createTextSegment(text: string): TextSegment { + return {type: 'text', text}; + } + + protected tokenize(text: string): TokenFieldSegment[] { + return [this.createTextSegment(text)]; + } + + private clampPosition(position: Position): Position { + if (this.segments.length > 0 && position.index >= this.segments.length) { + return { + index: this.segments.length - 1, + offset: this.segments[this.segments.length - 1].text.length + }; + } + + if (position.index < 0) { + return {index: 0, offset: 0}; + } + return position; + } + + /** Replace the text between two positions with new text. */ + replaceRange(start: Position, end: Position, text: string, coalesce = true): TokenSegmentList { + return this.replaceRangeWithSegments( + start, + end, + text.length > 0 ? [this.createTextSegment(text)] : [], + coalesce + ); + } + + /** Replace the text between two positions with new segments. */ + replaceRangeWithSegments( + start: Position, + end: Position, + insert: TokenFieldSegment[], + coalesce = true + ): TokenSegmentList { + start = this.clampPosition(start); + end = this.clampPosition(end); + let startSegment = this.segments[start.index]; + let endSegment = this.segments[end.index]; + let [startSplit] = this.splitSegment(startSegment, start.offset); + let [, endSplit] = this.splitSegment(endSegment, end.offset); + + let newSegments = this.segments.slice(0, start.index); + if (startSplit) { + appendSegments(newSegments, [startSplit]); + } + + if (insert.length) { + appendSegments(newSegments, insert); + } + + let lastSegment = newSegments[newSegments.length - 1]; + let lastIsText = lastSegment && lastSegment.type === 'text'; + let caret = { + index: lastIsText ? newSegments.length - 1 : newSegments.length, + offset: lastIsText ? lastSegment.text.length : 0 + }; + + if (endSplit) { + appendSegments(newSegments, [endSplit]); + } + + appendSegments(newSegments, this.segments.slice(end.index + 1)); + + if (insert.length > 0) { + let i = caret.index; + let seg = newSegments[i]; + if (seg?.type === 'text') { + let window = seg.text.slice(0, caret.offset); + let suffix = seg.text.slice(caret.offset); + let tokenized = this.tokenize(window); + caret.index += tokenized.length - 1; + caret.offset = tokenized[tokenized.length - 1].text.length; + if (suffix.length > 0) { + appendSegments(tokenized, [this.createTextSegment(suffix)]); + } + newSegments.splice(i, 1, ...tokenized); + } + } + + let segments = this.createSegmentList(newSegments); + segments.caretPosition = caret; + segments.isCoalescing = coalesce; + if (this.isCoalescing && coalesce && this.previous) { + segments.previous = this.previous; + segments.previous.next = segments; + } else { + segments.previous = this; + this.caretPosition = end; + this.next = segments; + } + return segments; + } + + /** Find the boundary before or after a position using an Intl.Segmenter. */ + findBoundaryWithSegmenter( + position: Position, + segmenter: Intl.Segmenter, + direction: Direction + ): Position | null { + position = this.clampPosition(position); + for (let i = position.index; i >= 0 && i < this.segments.length; i += direction) { + let segment = this.segments[i]; + switch (segment.type) { + case 'token': + if ( + i !== position.index || + (direction === Direction.Backward ? position.offset > 0 : position.offset === 0) + ) { + let index = i + direction; + return { + index: index >= 0 ? index : 0, + offset: + direction === Direction.Backward && index >= 0 + ? this.segments[index].text.length + : 0 + }; + } + continue; + case 'text': { + let offset = direction === Direction.Backward ? segment.text.length : 0; + if (i === position.index) { + offset = position.offset; + } + if (direction === Direction.Backward) { + offset--; + } + if (offset < 0 || offset >= segment.text.length) { + continue; + } + + let part = segmenter.segment(segment.text).containing(offset); + while (part && part.isWordLike === false) { + offset += direction; + part = segmenter.segment(segment.text).containing(offset); + } + + if (part) { + return { + index: i, + offset: + direction === Direction.Backward ? part.index : part.index + part.segment.length + }; + } + continue; + } + } + } + + return null; + } + + /** Find a line boundary before or after a position. */ + findLineBoundary(position: Position, direction: Direction): Position | null { + let res = this.findText(position, direction, '\n'); + if (res) { + return res; + } + return direction === Direction.Backward + ? {index: 0, offset: 0} + : { + index: this.segments.length - 1, + offset: this.segments[this.segments.length - 1].text.length + }; + } + + /** Find a string or regular expression match before or after a position. */ + findText(position: Position, direction: Direction, search: string | RegExp): Position | null { + if (this.segments.length === 0) { + return null; + } + + for (let i = position.index; i >= 0 && i < this.segments.length; i += direction) { + let segment = this.segments[i]; + if (segment.type !== 'text') { + continue; + } + let offset = findInText( + segment.text, + search, + direction, + i === position.index ? position.offset : undefined + ); + if (offset >= 0) { + return { + index: i, + offset: offset + }; + } + } + + return null; + } + + /** Delete text at a position using a segmenter. */ + delete( + position: Position, + segmenter: Intl.Segmenter, + direction: Direction, + coalesce = true + ): TokenSegmentList { + let boundary = this.findBoundaryWithSegmenter(position, segmenter, direction); + if (boundary) { + return this.replaceRange( + direction === Direction.Backward ? boundary : position, + direction === Direction.Backward ? position : boundary, + '', + coalesce + ); + } + + this.caretPosition = position; + return this; + } + + /** Delete text to the next or previous line break. */ + deleteLine(position: Position, direction: Direction, coalesce = true): TokenSegmentList { + if (this.segments.length === 0) { + return this; + } + + let boundary = this.findLineBoundary(position, direction); + if (boundary) { + return this.replaceRange( + direction === Direction.Backward ? boundary : position, + direction === Direction.Backward ? position : boundary, + '', + coalesce + ); + } + + return this; + } + + /** Converts the text at a position into a token. */ + insertToken(position: Position): TokenSegmentList { + let segment = this.segments[position.index]; + if (segment && segment.type === 'text') { + return this.replaceRangeWithSegments( + {index: position.index, offset: 0}, + {index: position.index, offset: segment.text.length}, + [{type: 'token', text: segment.text}], + false + ); + } + + this.caretPosition = position; + return this; + } + + /** Create a new list containing a subset of the segments. */ + slice(start: Position, end: Position): TokenSegmentList { + start = this.clampPosition(start); + end = this.clampPosition(end); + if (start.index === end.index && start.offset === end.offset) { + return this.createSegmentList([]); + } + if (start.index === end.index) { + let segment = this.segments[start.index]; + if (segment.type === 'text') { + return this.createSegmentList([ + {type: 'text', text: segment.text.slice(start.offset, end.offset)} + ]); + } + return this.createSegmentList([segment]); + } + let startSegment = this.segments[start.index]; + let endSegment = this.segments[end.index]; + let [, startSplit] = this.splitSegment(startSegment, start.offset); + let [endSplit] = this.splitSegment(endSegment, end.offset); + + let result: TokenFieldSegment[] = []; + if (startSplit) { + result.push(startSplit); + } + result.push(...this.segments.slice(start.index + 1, end.index)); + if (endSplit) { + result.push(endSplit); + } + return this.createSegmentList(result); + } + + toString(): string { + return this.segments.map(seg => seg.text).join(''); + } + + undo(): TokenSegmentList { + return this.previous ?? this; + } + + redo(): TokenSegmentList { + return this.next ?? this; + } + + endCoalescing(): void { + this.isCoalescing = false; + } +} + +function findInText( + text: string, + search: string | RegExp, + direction: Direction, + fromOffset?: number +): number { + if (typeof search === 'string') { + if (direction === Direction.Backward) { + return text.lastIndexOf(search, fromOffset !== undefined ? fromOffset - 1 : text.length - 1); + } + return text.indexOf(search, fromOffset ?? 0); + } + + if (direction === Direction.Forward) { + let start = fromOffset ?? 0; + let index = text.slice(start).search(search); + return index >= 0 ? start + index : -1; + } + + let limit = fromOffset !== undefined ? fromOffset : text.length; + if (limit < 0) { + return -1; + } + + let re = search.flags.includes('g') ? search : new RegExp(search.source, search.flags + 'g'); + let matches = Array.from(text.slice(0, limit).matchAll(re)); + return matches.at(-1)?.index ?? -1; +} + +function appendSegments( + segments: TokenFieldSegment[], + insert: TokenFieldSegment[] +): TokenFieldSegment[] { + for (let segment of insert) { + if (segment.type === 'text' && segment.text.length === 0) { + continue; + } + + let last = segments[segments.length - 1]; + if (last && last.type === 'text' && segment.type === 'text') { + segments[segments.length - 1] = {type: 'text', text: last.text + segment.text}; + } else { + segments.push(segment); + } + } + return segments; +} diff --git a/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx b/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx index 50a5e109fa8..52ba672e8b8 100644 --- a/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx +++ b/packages/@react-spectrum/ai/stories/HorizontalCard.stories.tsx @@ -201,28 +201,28 @@ export const Basic: BasicStory = { } }; -export const AIAttachmentList: Story = { +export const AIAttachmentList = { render: args => ( - - + + - + - + - + - ) + ), + argTypes: { + uploadProgress: { + control: { + type: 'range', + min: 0, + max: 100 + } + } + } }; diff --git a/packages/@react-spectrum/ai/stories/PromptField.stories.tsx b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx new file mode 100644 index 00000000000..1724f92c2cf --- /dev/null +++ b/packages/@react-spectrum/ai/stories/PromptField.stories.tsx @@ -0,0 +1,315 @@ +/* + * 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 { + AttachFileMenuItem, + InsertMenuButton, + InsertTokenMenuItem, + PromptField, + PromptFieldAttachmentList, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptToken, + PromptTokenField +} from '../src/PromptField'; +import {Attachment} from '../src/AttachmentList'; +import Brand from '@react-spectrum/s2/icons/Brand'; +import { + Collection, + Header, + Heading, + Menu, + MenuItem, + MenuSection, + SubmenuTrigger, + Text +} from '@react-spectrum/s2/Menu'; +import Data from '@react-spectrum/s2/icons/Data'; +import {iconStyle, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Image} from '@react-spectrum/s2/Image'; +import LinkIcon from '@react-spectrum/s2/icons/Link'; +import type {Meta} from '@storybook/react'; +import Plugin from '@react-spectrum/s2/icons/Plugin'; +import Prompt from '@react-spectrum/s2/icons/Prompt'; +import SocialNetwork from '@react-spectrum/s2/icons/SocialNetwork'; +import UserGroup from '@react-spectrum/s2/icons/UserGroup'; +import {useState} from 'react'; + +const meta: Meta = { + component: PromptField, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + title: 'AI/PromptField', + decorators: [ + Story => ( +
    + +
    + ) + ] +}; + +export default meta; + +const slashCommands = [ + { + command: '/audience-explainer', + type: 'skill', + description: 'Explain an AEP audience in english' + }, + {command: '/clear', type: 'command', description: 'Clear the context'}, + {command: '/compact', type: 'command', description: 'Summarize conversation history'}, + {command: '/dataset-usage', type: 'skill', description: 'Explain how to use a dataset'}, + {command: '/plan', type: 'command', description: 'Create a plan before executing'}, + {command: '/visual-artifact', type: 'skill', description: 'Generate a chart or graph'} +]; + +const icons = { + command: , + skill: , + audience: , + campaign: , + journey: , + url: +} as const; + +const objects = [ + { + section: 'Audiences', + items: [ + {type: 'audience', title: 'New Customers'}, + {type: 'audience', title: 'Returning Customers'}, + {type: 'audience', title: 'Loyal Customers'}, + {type: 'audience', title: 'High-Value Customers'}, + {type: 'audience', title: 'Low-Value Customers'} + ] + }, + { + section: 'Campaigns', + items: [ + {type: 'campaign', title: 'Spring Launch 2026'}, + {type: 'campaign', title: 'Holiday Cheer'}, + {type: 'campaign', title: 'Back to School'}, + {type: 'campaign', title: 'Summer Adventure'}, + {type: 'campaign', title: 'Tech Trends Expo'} + ] + }, + { + section: 'Journeys', + items: [ + {type: 'journey', title: 'Welcome Flow'}, + {type: 'journey', title: 'Abandoned Cart Recovery'}, + {type: 'journey', title: 'Post-Purchase Follow-up'}, + {type: 'journey', title: 'Re-engagement Campaign'}, + {type: 'journey', title: 'Birthday Surprise Journey'} + ] + } +]; + +function renderCompletions(filterValue: string) { + if (filterValue.startsWith('/')) { + return slashCommands + .filter(item => item.command.includes(filterValue.slice(1))) + .map(item => ( + + {item.type === 'skill' ? : } + {item.command} + {item.description} + + )); + } else if (filterValue.startsWith('@')) { + return objects + .map(section => { + let matchingItems = section.items + .filter(item => item.title.toLowerCase().includes(filterValue.slice(1).toLowerCase())) + .map(item => ( + + {item.title} + + )); + + if (matchingItems.length > 0) { + return ( + +
    + {section.section} +
    + {matchingItems} +
    + ); + } else { + return null; + } + }) + .filter(v => v != null); + } + return null; +} + +interface UploadState { + status: 'uploading' | 'completed'; + progress?: number; +} + +export const Everything = () => { + let [attachmentState, setAttachmentState] = useState>(new Map()); + + let mockUpload = async (id: string) => { + await new Promise(resolve => setTimeout(resolve, Math.random() * 30)); + setAttachmentState(prev => { + let item = prev.get(id); + if (!item || item.status === 'completed') { + return prev; + } + let newState = new Map(prev); + let progress = (item.progress ?? 0) + 1; + if (progress >= 100) { + newState.set(id, {status: 'completed'}); + } else { + newState.set(id, {status: 'uploading', progress}); + mockUpload(id); + } + return newState; + }); + }; + + return ( + { + setAttachmentState(prev => { + let newState = new Map(prev); + attachments.forEach(attachment => { + newState.set(attachment.id, {status: 'uploading', progress: 0}); + mockUpload(attachment.id); + }); + return newState; + }); + }} + onRemoveAttachments={attachments => { + setAttachmentState(prev => { + let newState = new Map(prev); + attachments.forEach(attachment => { + newState.delete(attachment.id); + }); + return newState; + }); + }}> + + {attachment => { + let state = attachmentState.get(attachment.id); + return ( + + {/* TODO: what about non-image attachments? */} + {attachment.image && } + + ); + }} + + + {segment => ( + + {icons[segment.value?.type]} + {segment.text} + + )} + + + + + + + + Commands + + item.type === 'command')}> + {item => ( + + {item.command} + {item.description} + + )} + + + + + + Skills + + item.type === 'skill')}> + {item => ( + + {item.command} + {item.description} + + )} + + + + + + Reference an object + + + {item => ( + +
    + {item.section} +
    + + {item => ( + {item.title} + )} + +
    + )} +
    +
    +
    + +
    +
    + ); +}; + +export const Basic = () => ( + +
    + + +
    +
    +); + +export const AsyncCompletions = () => ( + +
    + { + await new Promise(resolve => setTimeout(resolve, 500)); + return renderCompletions(filterValue); + }}> + {segment => ( + + {icons[segment.value?.type]} + {segment.text} + + )} + + +
    +
    +); diff --git a/packages/@react-spectrum/ai/stories/Thread.stories.tsx b/packages/@react-spectrum/ai/stories/Thread.stories.tsx index 3bdd7438078..38fbb6669a5 100644 --- a/packages/@react-spectrum/ai/stories/Thread.stories.tsx +++ b/packages/@react-spectrum/ai/stories/Thread.stories.tsx @@ -12,39 +12,24 @@ import {ActionButton} from '@react-spectrum/s2/ActionButton'; import {ActionMenu} from '@react-spectrum/s2/ActionMenu'; -import {AssetCard, Card, CardPreview} from '@react-spectrum/s2/Card'; -import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; -import {Button} from '@react-spectrum/s2/Button'; -import { - ButtonContext, - GridList, - Group, - isFileDropItem, - Label, - Tag, - TagGroup, - TagList, - TextArea, - TextField, - useDrop -} from 'react-aria-components'; +import {AssetCard, CardPreview} from '@react-spectrum/s2/Card'; import ChevronDown from '@react-spectrum/s2/icons/ChevronDown'; -import {CloseButton} from '@react-spectrum/s2/CloseButton'; import {Content} from '@react-spectrum/s2/Content'; +import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {GridList} from 'react-aria-components'; import {Image} from '@react-spectrum/s2/Image'; -import {Link} from '@react-spectrum/s2/Link'; import {ListLayout} from 'react-stately/useVirtualizerState'; import {MenuItem} from '@react-spectrum/s2/Menu'; import {MessageFeedback} from '../src/MessageFeedback'; import {MessageSource, SourceList, SourceListItem} from '../src/MessageSource'; import {MessageSuggestion, MessageSuggestionList} from '../src/MessageSuggestion'; import type {Meta} from '@storybook/react'; -import Plus from '@react-spectrum/s2/icons/Add'; +import {PromptField, PromptFieldSubmitButton, PromptTokenField} from '../src/PromptField'; import {ReactNode, useRef, useState} from 'react'; import {ResponseStatus, ResponseStatusPanel, ResponseStatusTitle} from '../src/ResponseStatus'; -import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import {Text} from '@react-spectrum/s2/Text'; import {Thread, ThreadItem, ThreadList, ThreadScrollButton} from '../src/Thread'; +import type {TokenSegmentList} from '../src/TokenSegmentList'; import {UserMessage} from '../src/UserMessage'; import {Virtualizer} from 'react-aria-components/Virtualizer'; @@ -157,18 +142,16 @@ export function StreamingThread() { initialResponses as StreamingMessage[] ); let nextId = useRef(initialResponses.length); - let lastMessage = messages.at(-1); - let isDisabled = - (lastMessage?.type === 'status' && lastMessage.isStreaming) || - (lastMessage?.type === 'system' && lastMessage.isStreaming); - - function handleSend(text: string) { - if (!text.trim()) { - return; - } + let [isGenerating, setGenerating] = useState(false); + let timeouts = useRef([]); + function handleSend(prompt: TokenSegmentList) { + setGenerating(true); // user message added first so its announcement plays before - setMessages(prev => [...prev, {id: nextId.current++, type: 'user', content: text}]); + setMessages(prev => [ + ...prev, + {id: nextId.current++, type: 'user', content: prompt.toString()} + ]); function addTool(label: string, replaceStatus = false) { setMessages(prev => @@ -231,13 +214,19 @@ export function StreamingThread() { }); } + let addTimeout = (callback: () => void, delay: number) => { + let timeout = setTimeout(callback, delay); + timeouts.current.push(timeout); + return timeout; + }; + // TODO: these durations are quite generous in order to accomodate for announcements, but realistically it might be // faster and thus the announcements will get cut off even with polite... // first batch, does tool calls with text response let timestamp = 0; let toolCallDuration = 4000; // Status added after short delay so user message announcement plays first - setTimeout( + addTimeout( () => { setMessages(prev => [ ...prev, @@ -252,25 +241,25 @@ export function StreamingThread() { }, (timestamp += 1000) ); - setTimeout(() => addTool('Thinking', true), (timestamp += 1000)); - setTimeout( + addTimeout(() => addTool('Thinking', true), (timestamp += 1000)); + addTimeout( () => completeTool( 'Reviewed conversation context and identified the user is searching for Hilton brand assets.' ), (timestamp += toolCallDuration) ); - setTimeout(() => addTool('Loading tool'), (timestamp += 1000)); - setTimeout( + addTimeout(() => addTool('Loading tool'), (timestamp += 1000)); + addTimeout( () => completeTool('Asset search tool loaded with access to the Hilton brand library.'), (timestamp += toolCallDuration) ); - setTimeout(() => addTool('Searching'), (timestamp += 1000)); - setTimeout( + addTimeout(() => addTool('Searching'), (timestamp += 1000)); + addTimeout( () => completeTool('Found 15 assets matching the brand criteria across 3 campaigns.'), (timestamp += toolCallDuration) ); - setTimeout( + addTimeout( () => streamText( 'I found some relevant assets that match your request. Let me pull up the details.' @@ -279,21 +268,21 @@ export function StreamingThread() { ); // then does searching, streaming more text, returning a card and sources - setTimeout(() => addTool('Searching'), (timestamp += 4000)); - setTimeout( + addTimeout(() => addTool('Searching'), (timestamp += 4000)); + addTimeout( () => completeTool('Identified additional brand materials related to the presentation context.'), (timestamp += toolCallDuration) ); - setTimeout(() => addTool('Querying database'), (timestamp += 1000)); - setTimeout( + addTimeout(() => addTool('Querying database'), (timestamp += 1000)); + addTimeout( () => completeTool( 'Retrieved asset records including metadata, previews, and usage rights for 12 items.' ), (timestamp += toolCallDuration) ); - setTimeout( + addTimeout( () => setMessages(prev => [ ...prev, @@ -307,7 +296,7 @@ export function StreamingThread() { ]), (timestamp += 500) ); - setTimeout( + addTimeout( () => setMessages(prev => [ ...prev.slice(0, -1), @@ -322,7 +311,7 @@ export function StreamingThread() { ]), (timestamp += 2000) ); - setTimeout( + addTimeout( () => streamText( 'Based on the assets you shared, I recommend focusing on the narrative arc first, then ' + @@ -334,10 +323,10 @@ export function StreamingThread() { ); let streamEndTimestamp = timestamp + 8000; - setTimeout(() => { + addTimeout(() => { setMessages(prev => [...prev, {id: nextId.current++, type: 'card', ...MOCK_CARD}]); }, streamEndTimestamp); - setTimeout(() => { + addTimeout(() => { setMessages(prev => [ ...prev, { @@ -347,6 +336,7 @@ export function StreamingThread() { suggestions: MOCK_SUGGESTIONS } ]); + setGenerating(false); }, streamEndTimestamp + 1000); } @@ -478,7 +468,19 @@ export function StreamingThread() { }}
    - + { + setGenerating(false); + timeouts.current.forEach(clearTimeout); + timeouts.current = []; + }}> +
    + + +
    +
    ); @@ -490,13 +492,10 @@ export function VirtualizedThread() { let nextId = useRef(initialResponses.length); let lastMessage = messages.at(-1); let isPending = lastMessage?.type === 'status' && lastMessage.status === 'pending'; - function handleSend(text: string) { - if (!text.trim()) { - return; - } + function handleSend(prompt: TokenSegmentList) { setMessages(prev => [ ...prev, - {id: nextId.current++, type: 'user', content: text}, + {id: nextId.current++, type: 'user', content: prompt.toString()}, {id: nextId.current++, type: 'status', status: 'pending'} ]); setTimeout(() => { @@ -564,213 +563,16 @@ export function VirtualizedThread() { }} - - - ); -} - -// TODO: all of the below was copied from rsp-prototypes, just filler for now -// some modifications for streaming and what not -function PromptField({ - onSend, - isDisabled -}: { - onSend?: (text: string) => void; - isDisabled?: boolean; -}) { - let [text, setText] = useState(''); - let [attachments, setAttachments] = useState([ - { - image: 'https://react-spectrum.adobe.com/preview.c3b340d3.png', - title: 'Hilton assets', - description: '2026' - } - ]); - - // Not using RAC DropZone because it adds its own focusable button, - // and we want to avoid an extra tab stop by attaching to the input. - // TODO: support clipboard too (without messing up pasting text) - let inputRef = useRef(null); - let {dropProps, dropButtonProps, isDropTarget} = useDrop({ - ref: inputRef, - hasDropButton: true, - async onDrop(e) { - let files = await Promise.all( - e.items.filter(isFileDropItem).map(async item => ({ - image: item.type.startsWith('image/') ? URL.createObjectURL(await item.getFile()) : '', - title: item.name, - description: item.type - })) - ); - setAttachments(attachments => [...attachments, ...files]); - } - }); - - return ( -
    - - style({ - ...focusRing(), - padding: 16, - boxShadow: 'emphasized', - backgroundColor: { - default: 'elevated', - isDropTarget: 'blue-200' - }, - borderRadius: 'lg', - borderWidth: 2, - borderStyle: 'solid', - borderColor: { - default: 'transparent', - isFocusWithin: 'gray-900', - isDropTarget: 'blue-800' - } - })({...renderProps, isDropTarget}) - }> - - {attachments.map((attachment, i) => ( - { - setAttachments(attachments.slice(0, i).concat(attachments.slice(i + 1))); - }} - /> - ))} - - setText(value)} slot="prompt"> - -