diff --git a/packages/@adobe/spectrum-css-temp/components/tray/index.css b/packages/@adobe/spectrum-css-temp/components/tray/index.css index b82f9ae6250..30f025ac6d2 100644 --- a/packages/@adobe/spectrum-css-temp/components/tray/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tray/index.css @@ -70,7 +70,7 @@ border-radius: var(--spectrum-tray-full-width-border-radius) var(--spectrum-tray-full-width-border-radius) var(--spectrum-tray-border-radius) var(--spectrum-tray-border-radius); /* Start offset by the animation distance */ - transform: translateY(100%); + transform: translateY(calc(100% - 100vh)); /* Exit animations */ transition: opacity var(--spectrum-dialog-exit-animation-duration) cubic-bezier(0.5, 0, 1, 1) var(--spectrum-dialog-exit-animation-delay), diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 6c7deae42ba..1b42aea1daf 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -139,19 +139,22 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta } // 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 && state.selectionManager.isLink(state.selectionManager.focusedKey)) { - let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); - if (e.key === 'Enter' && item instanceof HTMLAnchorElement) { - let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); - if (collectionItem) { + if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) { + let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); + if (collectionItem?.props.href) { + let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); + if (e.key === 'Enter' && item instanceof HTMLAnchorElement) { router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); } + state.close(); + break; + } else if (collectionItem?.props.onAction) { + collectionItem.props.onAction(); + state.close(); + break; } - - state.close(); - } else { - state.commit(); } + state.commit(); break; case 'Escape': if ( diff --git a/packages/@react-spectrum/overlays/src/Underlay.tsx b/packages/@react-spectrum/overlays/src/Underlay.tsx index 646eff6d7a0..cd732301151 100644 --- a/packages/@react-spectrum/overlays/src/Underlay.tsx +++ b/packages/@react-spectrum/overlays/src/Underlay.tsx @@ -25,7 +25,7 @@ export function Underlay({isOpen, isTransparent, ...otherProps}: UnderlayProps): data-testid="underlay" {...otherProps} // Cover the entire document so iOS 26 Safari doesn't clip the underlay to the inner viewport. - style={{height: isOpen && typeof document !== 'undefined' ? document.body.clientHeight : undefined}} + style={{height: typeof document !== 'undefined' ? document.body.getBoundingClientRect().height : undefined}} className={classNames(underlayStyles, 'spectrum-Underlay', { 'is-open': isOpen, 'spectrum-Underlay--transparent': isTransparent diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index de788b6f530..a8b30d75827 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -399,7 +399,7 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode { } }] ]}> - {!isLink && } + {!isLink && !props.onAction && } {typeof children === 'string' ? {children} : children} diff --git a/packages/@react-spectrum/s2/src/SegmentedControl.tsx b/packages/@react-spectrum/s2/src/SegmentedControl.tsx index 0dafd0cc08d..8009d75134b 100644 --- a/packages/@react-spectrum/s2/src/SegmentedControl.tsx +++ b/packages/@react-spectrum/s2/src/SegmentedControl.tsx @@ -110,6 +110,7 @@ const slider = style<{isDisabled: boolean}>({ isDisabled: 'GrayText' } }, + top: 0, left: 0, width: 'full', height: 'full', diff --git a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx index 08e596507b1..d21a1d3bcf2 100644 --- a/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/ComboBox.stories.tsx @@ -16,7 +16,7 @@ import {ComboBoxProps} from 'react-aria-components'; import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg'; import DeviceTabletIcon from '../s2wf-icons/S2_Icon_DeviceTablet_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; -import {ReactElement} from 'react'; +import {ReactElement, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import {useAsyncList} from 'react-stately'; @@ -307,3 +307,26 @@ export const EmptyCombobox: Story = { } } }; + +export function WithCreateOption() { + let [inputValue, setInputValue] = useState(''); + + return ( + + {inputValue.length > 0 && ( + alert('hi')}> + {`Create "${inputValue}"`} + + )} + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + ); +} diff --git a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx index 14b8a028341..eb2b75a46c0 100644 --- a/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx +++ b/packages/dev/docs/pages/react-aria/home/ExampleApp.tsx @@ -510,30 +510,30 @@ function PlantModal(props: ModalOverlayProps) { ${isExiting ? 'animate-out fade-out duration-200 ease-in' : ''} `}> {({isEntering, isExiting}) => (<> - {!isResized && - - } {/* Inner position: sticky div sized to the visual viewport height so the modal appears in view. Note that position: fixed will not work here because this is positioned relative to the containing block, which is the ModalOverlay in this case due to backdrop-blur. */}
+ {!isResized && + + } ` to perform a custom action when the item is selected. This example adds a "Create" action for the current input value. + +```tsx render +"use client"; +import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; +import {useState} from 'react'; + +function Example() { + let [inputValue, setInputValue] = useState(''); + + return ( + + {/*- begin highlight -*/} + {inputValue.length > 0 && ( + alert('Creating ' + inputValue)}> + {`Create "${inputValue}"`} + + )} + {/*- end highlight -*/} + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + ); +} +``` + ## Forms Use the `name` prop to submit the `id` of the selected item to the server. Set the `isRequired` prop to validate that the user selects a value, or implement custom client or server-side validation. See the Forms guide to learn more. diff --git a/packages/dev/s2-docs/pages/s2/ComboBox.mdx b/packages/dev/s2-docs/pages/s2/ComboBox.mdx index 702bd558c6a..d290d5bae4e 100644 --- a/packages/dev/s2-docs/pages/s2/ComboBox.mdx +++ b/packages/dev/s2-docs/pages/s2/ComboBox.mdx @@ -164,6 +164,41 @@ function Example() { } ``` +### Actions + +Use the `onAction` prop on a `` to perform a custom action when the item is selected. This example adds a "Create" action for the current input value. + +```tsx render +"use client"; +import {ComboBox, ComboBoxItem} from '@react-spectrum/s2'; +import {useState} from 'react'; + +function Example() { + let [inputValue, setInputValue] = useState(''); + + return ( + + {/*- begin highlight -*/} + {inputValue.length > 0 && ( + alert('Creating ' + inputValue)}> + {`Create "${inputValue}"`} + + )} + {/*- end highlight -*/} + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + ); +} +``` + ### Links Use the `href` prop on a `` to create a link. See the **client side routing guide** to learn how to integrate with your framework. Link items in a `ComboBox` are not selectable. diff --git a/packages/react-aria-components/docs/Disclosure.mdx b/packages/react-aria-components/docs/Disclosure.mdx index 49b30fd02f2..44c72b655db 100644 --- a/packages/react-aria-components/docs/Disclosure.mdx +++ b/packages/react-aria-components/docs/Disclosure.mdx @@ -86,7 +86,7 @@ import {ChevronRight} from 'lucide-react'; } .react-aria-Heading { - margin-bottom: 0; + margin: 0; } &[data-expanded] .react-aria-Button[slot=trigger] svg { @@ -211,7 +211,7 @@ In some use cases, you may want to add an interactive element, like a button, ad ```tsx example -
+
+
+ + + {inputValue.length > 0 && ( + alert('hi')}> + {`Create "${inputValue}"`} + + )} + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + + + ); +} diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index d48141179d5..e9b1971a5da 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -13,7 +13,7 @@ import {act} from '@testing-library/react'; import {Button, ComboBox, ComboBoxContext, FieldError, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, ListLayout, Popover, Text, Virtualizer} from '../'; import {fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; -import React from 'react'; +import React, {useState} from 'react'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -408,4 +408,57 @@ describe('ComboBox', () => { expect(comboboxTester.listbox).toBeTruthy(); expect(options[0]).toHaveTextContent('No results'); }); + + it('should support onAction', async () => { + let onAction = jest.fn(); + function WithCreateOption() { + let [inputValue, setInputValue] = useState(''); + + return ( + + +
+ + +
+ + + {inputValue.length > 0 && ( + + {`Create "${inputValue}"`} + + )} + Aardvark + Cat + Dog + Kangaroo + Panda + Snake + + +
+ ); + } + + let tree = render(); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container}); + act(() => { + comboboxTester.combobox.focus(); + }); + + await user.keyboard('L'); + + let options = comboboxTester.options(); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Create "L"'); + + await user.keyboard('{ArrowDown}{Enter}'); + expect(onAction).toHaveBeenCalledTimes(1); + expect(comboboxTester.combobox).toHaveValue(''); + }); });