From 65e58b56c68503709a001fb825af9f123de0af4d Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Mon, 1 Dec 2025 10:56:32 -0800 Subject: [PATCH 1/4] chore: Update S2 TreeView and TableView to be more inline with new designs (#9249) * update treeview not to have emphasizied/detached removing for now, to be readded when designs settle * update table colors * make consistent with tree and future design * fix lint --- .../s2/chromatic/TreeView.stories.tsx | 37 ------- packages/@react-spectrum/s2/src/TableView.tsx | 8 +- packages/@react-spectrum/s2/src/TreeView.tsx | 97 ++++--------------- packages/dev/s2-docs/pages/s2/TreeView.mdx | 2 +- 4 files changed, 26 insertions(+), 118 deletions(-) diff --git a/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx b/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx index d297c5e911c..7e4aaed9e7a 100644 --- a/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/TreeView.stories.tsx @@ -152,43 +152,6 @@ export const TreeSelection: StoryObj = { } }; -export const TreeIsDetached: StoryObj = { - ...TreeStatic, - args: { - isDetached: true, - selectionMode: 'multiple', - defaultSelectedKeys: ['projects-2', 'projects-3'] - } -}; - -export const TreeIsEmphasized: StoryObj = { - ...TreeStatic, - args: { - isEmphasized: true, - selectionMode: 'multiple', - defaultSelectedKeys: ['projects-2', 'projects-3'] - } -}; - -export const TreeIsDetachedIsEmphasized: StoryObj = { - ...TreeStatic, - args: { - isDetached: true, - isEmphasized: true, - selectionMode: 'multiple', - defaultSelectedKeys: ['projects-2', 'projects-3'] - } -}; - -export const TreeIsDetachedMobile: StoryObj = { - ...TreeStatic, - args: { - isDetached: true, - selectionMode: 'multiple', - defaultSelectedKeys: ['projects-2', 'projects-3'] - } -}; - interface TreeViewItemType { id: string, name: string, diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b7af9870fc1..4d4e9fd494e 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -908,7 +908,7 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function } {selectionMode === 'multiple' && - + } )} @@ -1362,8 +1362,8 @@ function EditableCellInner(props: EditableCellProps & {isFocusVisible: boolean, }; // Use color-mix instead of transparency so sticky cells work correctly. -const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10)); -const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15)); +const selectedBackground = colorMix('gray-25', 'gray-900', 7); +const selectedActiveBackground = colorMix('gray-25', 'gray-900', 10); const rowBackgroundColor = { default: { default: 'gray-25', @@ -1462,7 +1462,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row - + )} diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 5d1f0c26e5d..b2801ba08c1 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -31,7 +31,7 @@ import { import {centerBaseline} from './CenterBaseline'; import {Checkbox} from './Checkbox'; import Chevron from '../ui-icons/Chevron'; -import {colorMix, focusRing, fontRelative, lightDark, style} from '../style' with {type: 'macro'}; +import {colorMix, focusRing, fontRelative, style} from '../style' with {type: 'macro'}; import {DOMRef, forwardRefType, GlobalDOMAttributes, Key, LoadingState} from '@react-types/shared'; import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {IconContext} from './Icon'; @@ -39,21 +39,15 @@ import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; import {ProgressCircle} from './ProgressCircle'; import {raw} from '../style/style-macro' with {type: 'macro'}; -import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react'; +import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useRef} from 'react'; import {Text, TextContext} from './Content'; import {useDOMRef} from '@react-spectrum/utils'; import {useLocale, useLocalizedStringFormatter} from 'react-aria'; import {useScale} from './utils'; interface S2TreeProps { - // Only detatched is supported right now with the current styles from Spectrum - // See https://github.com/adobe/react-spectrum/pull/7343 for what remaining combinations are left - /** Whether the tree should be displayed with a [detached style](https://spectrum.adobe.com/page/tree-view/#Detached). */ - isDetached?: boolean, /** Handler that is called when a user performs an action on a row. */ - onAction?: (key: Key) => void, - /** Whether the tree should be displayed with a [emphasized style](https://spectrum.adobe.com/page/tree-view/#Emphasis). */ - isEmphasized?: boolean + onAction?: (key: Key) => void } export interface TreeViewProps extends Omit, 'style' | 'className' | 'onRowAction' | 'selectionBehavior' | 'onScroll' | 'onCellAction' | 'dragAndDropHooks' | keyof GlobalDOMAttributes>, UnsafeStyles, S2TreeProps { @@ -77,8 +71,6 @@ interface TreeRendererContextValue { const TreeRendererContext = createContext({}); -let InternalTreeContext = createContext<{isDetached?: boolean, isEmphasized?: boolean}>({}); - // TODO: the below is needed so the borders of the top and bottom row isn't cut off if the TreeView is wrapped within a container by always reserving the 2px needed for the // keyboard focus ring. Perhaps find a different way of rendering the outlines since the top of the item doesn't // scroll into view due to how the ring is offset. Alternatively, have the tree render the top/bottom outline like it does in Listview @@ -108,7 +100,7 @@ const tree = style({ * A tree view provides users with a way to navigate nested hierarchical information. */ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function TreeView(props: TreeViewProps, ref: DOMRef) { - let {children, isDetached, isEmphasized, UNSAFE_className, UNSAFE_style} = props; + let {children, UNSAFE_className, UNSAFE_style} = props; let scale = useScale(); let renderer; @@ -122,28 +114,22 @@ export const TreeView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Tr - - (UNSAFE_className ?? '') + tree({isDetached, ...renderProps}, props.styles)} - selectionBehavior="toggle" - ref={domRef}> - {props.children} - - + (UNSAFE_className ?? '') + tree({...renderProps}, props.styles)} + selectionBehavior="toggle" + ref={domRef}> + {props.children} + ); }); -const selectedBackground = lightDark(colorMix('gray-25', 'informative-900', 10), colorMix('gray-25', 'informative-700', 10)); -const selectedActiveBackground = lightDark(colorMix('gray-25', 'informative-900', 15), colorMix('gray-25', 'informative-700', 15)); - const rowBackgroundColor = { default: '--s2-container-bg', isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 7), @@ -151,19 +137,9 @@ const rowBackgroundColor = { isPressed: colorMix('gray-25', 'gray-900', 10), isSelected: { default: colorMix('gray-25', 'gray-900', 7), - isEmphasized: selectedBackground, - isFocusVisibleWithin: { - default: colorMix('gray-25', 'gray-900', 10), - isEmphasized: selectedActiveBackground - }, - isHovered: { - default: colorMix('gray-25', 'gray-900', 10), - isEmphasized: selectedActiveBackground - }, - isPressed: { - default: colorMix('gray-25', 'gray-900', 10), - isEmphasized: selectedActiveBackground - } + isFocusVisibleWithin: colorMix('gray-25', 'gray-900', 10), + isHovered: colorMix('gray-25', 'gray-900', 10), + isPressed: colorMix('gray-25', 'gray-900', 10) }, forcedColors: { default: 'Background' @@ -230,21 +206,6 @@ const treeCellGrid = style({ default: 'focus-ring', forcedColors: 'Highlight' } - }, - borderColor: { - isDetached: { - default: 'transparent', - isSelected: '--rowSelectedBorderColor' - } - }, - borderWidth: { - isDetached: 1 - }, - borderRadius: { - isDetached: 'default' - }, - borderStyle: { - isDetached: 'solid' } }); @@ -282,17 +243,6 @@ const treeActionMenu = style({ gridArea: 'actionmenu' }); -const cellFocus = { - outlineStyle: { - default: 'none', - isFocusVisible: 'solid' - }, - outlineOffset: -2, - outlineWidth: 2, - outlineColor: 'focus-ring', - borderRadius: '[6px]' -} as const; - const treeRowFocusIndicator = raw(` &:before { content: ""; @@ -312,15 +262,14 @@ export const TreeViewItem = (props: TreeViewItemProps): ReactNode => { let { href } = props; - let {isDetached, isEmphasized} = useContext(InternalTreeContext); return ( treeRow({ ...renderProps, - isLink: !!href, isEmphasized - }) + (renderProps.isFocusVisible && !isDetached ? ' ' + treeRowFocusIndicator : '')} /> + isLink: !!href + }) + (renderProps.isFocusVisible ? ' ' + treeRowFocusIndicator : '')} /> ); }; @@ -333,12 +282,11 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode let { children } = props; - let {isDetached, isEmphasized} = useContext(InternalTreeContext); let scale = useScale(); return ( - {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isFocusVisible, isSelected, id, state}) => { + {({isExpanded, hasChildItems, selectionMode, selectionBehavior, isDisabled, isSelected, id, state}) => { let isNextSelected = false; let isNextFocused = false; let keyAfter = state.collection.getKeyAfter(id); @@ -347,13 +295,11 @@ export const TreeViewItemContent = (props: TreeViewItemContentProps): ReactNode } let isFirst = state.collection.getFirstKey() === id; return ( -
+
{selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // TODO: add transition?
- +
)}
{typeof children === 'string' ? {children} : children} - {isFocusVisible && isDetached &&
}
); }} diff --git a/packages/dev/s2-docs/pages/s2/TreeView.mdx b/packages/dev/s2-docs/pages/s2/TreeView.mdx index 7add17b8b7d..7a454b31239 100644 --- a/packages/dev/s2-docs/pages/s2/TreeView.mdx +++ b/packages/dev/s2-docs/pages/s2/TreeView.mdx @@ -14,7 +14,7 @@ export const description = 'Displays hierarchical data with selection and collap {docs.exports.TreeView.description} -```tsx render docs={docs.exports.TreeView} links={docs.links} props={['selectionMode', 'isDetached', 'isEmphasized']} initialProps={{selectionMode: 'multiple'}} type="s2" +```tsx render docs={docs.exports.TreeView} links={docs.links} props={['selectionMode']} initialProps={{selectionMode: 'multiple'}} type="s2" import {TreeView, TreeViewItem, TreeViewItemContent} from '@react-spectrum/s2'; Date: Mon, 1 Dec 2025 10:58:07 -0800 Subject: [PATCH 2/4] docs: Toast/TabsPicker docs fixes, prevent Autocomplete input focus on mobile (#9243) * update Toast examples to work when user is on tailwind * hide asterix from screenreaders if required is applied to the field already still needed for cases like Picker since those dont support aria-invalid since they are buttons * add default class name to tab picker so it doesnt pick up react-aria-select styles * only focus autocomplete input field if click or keyboard --------- Co-authored-by: Devon Govett --- .../autocomplete/src/useAutocomplete.ts | 17 +- packages/@react-spectrum/s2/src/ComboBox.tsx | 1 - packages/@react-spectrum/s2/src/Field.tsx | 3 +- .../dev/s2-docs/pages/react-aria/Toast.mdx | 223 ++++++++++++------ .../test/Autocomplete.test.tsx | 35 ++- starters/docs/src/Toast.css | 1 + starters/tailwind/src/Toast.tsx | 6 +- 7 files changed, 208 insertions(+), 78 deletions(-) diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index b9c1e6420ee..3abaafd6413 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -92,6 +92,7 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut let timeout = useRef | undefined>(undefined); let delayNextActiveDescendant = useRef(false); let queuedActiveDescendant = useRef(null); + let lastPointerType = useRef(null); // For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually // moving focus back to the subtriggers @@ -105,9 +106,23 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Aut return () => clearTimeout(timeout.current); }, []); + useEffect(() => { + let handlePointerDown = (e: PointerEvent) => { + lastPointerType.current = e.pointerType; + }; + + if (typeof PointerEvent !== 'undefined') { + document.addEventListener('pointerdown', handlePointerDown, true); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + }; + } + }, []); + let updateActiveDescendantEvent = useEffectEvent((e: Event) => { // Ensure input is focused if the user clicks on the collection directly. - if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) { + // don't trigger on touch so that mobile keyboard doesnt appear when tapping on options + if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current && lastPointerType.current !== 'touch') { inputRef.current.focus(); } diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index 18a4c541182..9f5ea1cfb9d 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -601,7 +601,6 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps + aria-label={includeNecessityIndicatorInAccessibilityName ? stringFormatter.format('label.(required)') : undefined} + aria-hidden={!includeNecessityIndicatorInAccessibilityName} /> } {necessityIndicator === 'label' && /* The necessity label is hidden to screen readers if the field is required because diff --git a/packages/dev/s2-docs/pages/react-aria/Toast.mdx b/packages/dev/s2-docs/pages/react-aria/Toast.mdx index 6cc74517677..41f8bf88aa2 100644 --- a/packages/dev/s2-docs/pages/react-aria/Toast.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Toast.mdx @@ -33,7 +33,7 @@ export const description = 'Displays brief, temporary notifications of actions, }, props.timeout ? {timeout: props.timeout} : undefined )}> - Upload files + Show Toast
); @@ -56,7 +56,7 @@ export const description = 'Displays brief, temporary notifications of actions, }, props.timeout ? {timeout: props.timeout} : undefined )}> - Upload files + Show Toast
); @@ -71,26 +71,50 @@ export const description = 'Displays brief, temporary notifications of actions, Use the `"title"` and `"description"` slots within `` to provide structured content for the toast. The title is required, and description is optional. -```tsx render hideImports -"use client"; -import {queue} from 'vanilla-starter/Toast'; -import {Button} from 'vanilla-starter/Button'; - -function Example() { - return ( - - ); -} -``` + + ```tsx render hideImports type="vanilla" + "use client"; + import {queue} from 'vanilla-starter/Toast'; + import {Button} from 'vanilla-starter/Button'; + + function Example() { + return ( + + ); + } + ``` + + ```tsx render hideImports type="tailwind" + "use client"; + import {queue} from 'tailwind-starter/Toast'; + import {Button} from 'tailwind-starter/Button'; + + function Example() { + return ( + + ); + } + ``` + + ### Close button @@ -105,26 +129,50 @@ Include a ` - ); -} -``` + + ```tsx render hideImports type="vanilla" + "use client"; + import {queue} from 'vanilla-starter/Toast'; + import {Button} from 'vanilla-starter/Button'; + + function Example() { + return ( + + ); + } + ``` + + ```tsx render hideImports type="tailwind" + "use client"; + import {queue} from 'tailwind-starter/Toast'; + import {Button} from 'tailwind-starter/Button'; + + function Example() { + return ( + + ); + } + ``` + + Accessibility @@ -135,35 +183,68 @@ function Example() { Toasts can be programmatically dismissed using the key returned from `queue.add()`. This is useful when a toast becomes irrelevant before the user manually closes it. -```tsx render hideImports -"use client"; -import {queue} from 'vanilla-starter/Toast'; -import {Button} from 'vanilla-starter/Button'; -import {useState} from 'react'; - -function Example() { - let [toastKey, setToastKey] = useState(null); - - return ( - - ); -} -``` + + ```tsx render hideImports type="vanilla" + "use client"; + import {queue} from 'vanilla-starter/Toast'; + import {Button} from 'vanilla-starter/Button'; + import {useState} from 'react'; + + function Example() { + let [toastKey, setToastKey] = useState(null); + + return ( + + ); + } + ``` + + ```tsx render hideImports type="tailwind" + "use client"; + import {queue} from 'tailwind-starter/Toast'; + import {Button} from 'tailwind-starter/Button'; + import {useState} from 'react'; + + function Example() { + let [toastKey, setToastKey] = useState(null); + + return ( + + ); + } + ``` + + ## Accessibility diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index 8bcd266cb6c..c01bd62b6b2 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {AriaAutocompleteTests} from './AriaAutocomplete.test-util'; import {Autocomplete, Breadcrumb, Breadcrumbs, Button, Cell, Collection, Column, Dialog, DialogTrigger, GridList, GridListItem, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, Menu, MenuItem, MenuSection, Popover, Row, SearchField, Select, SelectValue, Separator, SubmenuTrigger, Tab, Table, TableBody, TableHeader, TabList, TabPanel, Tabs, Tag, TagGroup, TagList, Text, TextField, Tree, TreeItem, TreeItemContent} from '..'; import React, {ReactNode, useState} from 'react'; @@ -382,6 +382,7 @@ let CustomFiltering = ({autocompleteProps = {}, inputProps = {}, children}: {aut describe('Autocomplete', () => { let user; + installPointerEvent(); beforeAll(() => { user = userEvent.setup({delay: null, pointerMap}); jest.useFakeTimers(); @@ -626,6 +627,38 @@ describe('Autocomplete', () => { expect(foo).not.toHaveAttribute('data-focus-visible'); }); + it('should not move focus to the input field if tapping on a menu item via touch', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + let foo = options[0]; + + await user.pointer({target: foo, keys: '[TouchA]'}); + expect(document.activeElement).not.toBe(input); + }); + + it('should move focus to the input field if clicking on a menu item via mouse', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + let foo = options[0]; + + await user.click(foo); + expect(document.activeElement).toBe(input); + }); + it('should work inside a Select', async function () { let {getByRole} = render(