From 3b4b145ee6545175e3198249d65543d7d152ba44 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:00:39 -0700 Subject: [PATCH 01/11] add selectionStyle prop --- packages/@react-spectrum/s2/src/ListView.tsx | 4 ++-- packages/@react-spectrum/s2/src/TableView.tsx | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index b749a5a4404..84b7e44a454 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -669,14 +669,14 @@ function ListSelectionCheckbox({isDisabled}: {isDisabled: boolean}) { ); } -function isNextSelected(id: Key | undefined, state: ListState) { +export function isNextSelected(id: Key | undefined, state: ListState) { if (id == null || !state) { return false; } let keyAfter = state.collection.getKeyAfter(id); return keyAfter != null && state.selectionManager.isSelected(keyAfter); } -function isPrevSelected(id: Key | undefined, state: ListState) { +export function isPrevSelected(id: Key | undefined, state: ListState) { if (id == null || !state) { return false; } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 94939aa6741..69ea02bde4c 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -66,6 +66,7 @@ import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {isNextSelected, isPrevSelected} from './ListView'; import {LayoutNode} from '@react-stately/layout'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; @@ -118,7 +119,8 @@ interface S2TableProps { /** Handler that is called when more items should be loaded, e.g. while scrolling near the bottom. */ onLoadMore?: () => any, /** Provides the ActionBar to display when rows are selected in the TableView. */ - renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement + renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement, + selectionStyle?: 'checkbox' | 'highlight' } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody @@ -127,7 +129,7 @@ export interface TableViewProps extends Omit, setIsInResizeMode?:(val: boolean) => void, isInResizeMode?: boolean, selectionMode?: 'none' | 'single' | 'multiple'}>({}); +let InternalTableContext = createContext, setIsInResizeMode?:(val: boolean) => void, isInResizeMode?: boolean, selectionMode?: 'none' | 'single' | 'multiple', selectionStyle?: 'checkbox' | 'highlight'}>({}); const tableWrapper = style({ minHeight: 0, @@ -297,6 +299,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onAction, onLoadMore, selectionMode = 'none', + selectionStyle = 'checkbox', ...otherProps } = props; @@ -322,8 +325,9 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onLoadMore, isInResizeMode, setIsInResizeMode, - selectionMode - }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionMode]); + selectionMode, + selectionStyle + }), [isQuiet, density, overflowMode, loadingState, onLoadMore, isInResizeMode, setIsInResizeMode, selectionMode, selectionStyle]); let scrollRef = useRef(null); let isCheckboxSelection = selectionMode === 'multiple' || selectionMode === 'single'; From 9adcfb266a200997afa21470962114805690d26c Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:06:01 -0700 Subject: [PATCH 02/11] current state of highlight which sorta works but idk if i like the implementation --- packages/@react-spectrum/s2/src/ListView.tsx | 2 +- packages/@react-spectrum/s2/src/TableView.tsx | 183 ++++++++++++++---- .../s2/stories/TableView.stories.tsx | 13 ++ packages/react-aria-components/src/Table.tsx | 4 +- 4 files changed, 165 insertions(+), 37 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 84b7e44a454..ef2eaa3f65c 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -684,7 +684,7 @@ export function isPrevSelected(id: Key | undefined, state: ListState) { return keyBefore != null && state.selectionManager.isSelected(keyBefore); } -function isFirstItem(id: Key | undefined, state: ListState) { +export function isFirstItem(id: Key | undefined, state: ListState) { if (id == null || !state) { return false; } diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 69ea02bde4c..1cb480c15fe 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -43,7 +43,6 @@ import { TableProps as RACTableProps, Rect, ResizableTableContainer, - RowRenderProps, TableBodyRenderProps, TableLayout, TableLoadMoreItem, @@ -66,7 +65,7 @@ import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isNextSelected, isPrevSelected} from './ListView'; +import {isFirstItem, isNextSelected, isPrevSelected} from './ListView'; import {LayoutNode} from '@react-stately/layout'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; @@ -485,7 +484,7 @@ const cellFocus = { } as const; function CellFocusRing() { - return
; + return
; } const columnStyles = style({ @@ -550,14 +549,14 @@ export interface ColumnProps extends Omit`. */ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef) { - let {isQuiet} = useContext(InternalTableContext); + let {isQuiet, selectionStyle} = useContext(InternalTableContext); let {allowsResizing, children, align = 'start'} = props; let domRef = useDOMRef(ref); let isMenu = allowsResizing || !!props.menuItems; return ( - columnStyles({...renderProps, isMenu, align, isQuiet})}> + columnStyles({...renderProps, isMenu, align, isQuiet, selectionStyle})}> {({allowsSorting, sortDirection, isFocusVisible, sort, startResize}) => ( <> {/* Note this is mainly for column's without a dropdown menu. If there is a dropdown menu, the button is styled to have a focus ring for simplicity @@ -901,7 +900,7 @@ export interface TableHeaderProps extends Omit, 'style export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableHeader({columns, dependencies, children}: TableHeaderProps, ref: DOMRef) { let scale = useScale(); let {selectionBehavior, selectionMode} = useTableOptions(); - let {isQuiet} = useContext(InternalTableContext); + let {isQuiet, selectionStyle} = useContext(InternalTableContext); let domRef = useDOMRef(ref); return ( @@ -910,7 +909,7 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref={domRef} className={tableHeader}> {/* Add extra columns for selection. */} - {selectionBehavior === 'toggle' && ( + {selectionBehavior === 'toggle' && selectionStyle === 'checkbox' && ( // Also isSticky prop is applied just for the layout, will decide what the RAC api should be later // @ts-ignore @@ -990,20 +989,38 @@ const cell = style({ +const row = style({ height: 'full', position: 'relative', boxSizing: 'border-box', - backgroundColor: '--rowBackgroundColor', + backgroundColor: { + default: '--rowBackgroundColor', + selectionStyle: { + highlight: { + default: '--rowBackgroundColor', + isSelected: { + default: colorMix('gray-25', 'blue-900', 10), + isHovered: colorMix('gray-25', 'blue-900', 15), + isPressed: colorMix('gray-25', 'blue-900', 15) + } + } + } + }, '--rowBackgroundColor': { type: 'backgroundColor', value: rowBackgroundColor @@ -1546,18 +1576,95 @@ const row = style({ // } // }, outlineStyle: 'none', - borderTopWidth: 0, - borderBottomWidth: 1, + // kinda unfortunate but the border is really only needed for the first item case. the issue is related to the divider + // essentially, the gray box shadow from the divider would appear on top of the blue box shadow but only for the first item + // in order for it to be on the bottom, i used border...couldn't figure out why this was only happening with box shadow + borderTopWidth: { + default: 0, + // selectionStyle: { + // highlight: { + // default: 0, + // isFirstItem: 1 + // } + // } + }, + borderBottomWidth: { + selectionStyle: { + highlight: 0, + checkbox: 1 + } + }, borderStartWidth: 0, borderEndWidth: 0, borderStyle: 'solid', borderColor: { - default: 'gray-300', - forcedColors: 'ButtonBorder' + selectionStyle: { + highlight: { + default: 'transparent', + // isFirstItem: { + // default: 'transparent', + // isSelected: 'blue-900' + // } + }, + checkbox: 'gray-300' + } + }, + '--borderColorGray': { + type: 'borderColor', + value: 'gray-300' + }, + '--borderColorBlue': { + type: 'borderColor', + value: 'blue-900' + }, + // to avoid affecting the layout, use box shadow instead + // also selected groups still have gray borders in between the items, hard to have two colors with borders because you'll get a diagonal line where the two borders meet + // have more control over how the borders are rendered using box shadow instead + // couldn't add an absolute positioned div because it would mess up the cell count + boxShadow: { + selectionStyle: { + highlight: { + default: '[inset 0px -1px 0px var(--borderColorGray)]', + isNextSelected: '[inset 0px -1px 0px var(--borderColorBlue)]', + isSelected: { + default: '[inset 0px -1px 0px var(--borderColorBlue), inset 1px 0px 0px var(--borderColorBlue), inset -1px 0px var(--borderColorBlue)]', + isNextSelected: '[inset 1px 0px 0px var(--borderColorBlue), inset -1px 0px var(--borderColorBlue), inset 0px -1px 0px var(--borderColorGray)]', + isFirstItem: { + default: '[inset 0px -1px 0px var(--borderColorBlue), inset 1px 0px 0px var(--borderColorBlue), inset -1px 0px var(--borderColorBlue)]', + isNextSelected: '[inset 1px 0px 0px var(--borderColorBlue), inset -1px 0px 0px var(--borderColorBlue), inset 0px -1px 0px var(--borderColorGray)]' + } + } + } + } + }, + '--focusIndicatorHeight': { + type: 'top', + value: { + default: 'calc(self(height) - 1px)' + } }, forcedColorAdjust: 'none' }); +const border = raw( + `&:after { + content: ""; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: 3; + position: absolute; + box-sizing: border-box; + border-top-width: 1px; + border-bottom-width: 0px; + border-inline-start-width: 0px; + border-inline-end-width: 0px; + border-style: solid; + border-color: var(--borderColorBlue); + ` +); + const selectionCheckbox = style({ visibility: { default: 'visible', @@ -1572,7 +1679,7 @@ export interface RowProps extends Pick, 'id' | 'columns' | 'is */ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row({id, columns, children, dependencies = [], ...otherProps}: RowProps, ref: DOMRef) { let {selectionBehavior, selectionMode} = useTableOptions(); - let tableVisualOptions = useContext(InternalTableContext); + let {selectionStyle, ...tableVisualOptions} = useContext(InternalTableContext); let domRef = useDOMRef(ref); return ( @@ -1583,17 +1690,23 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row row({ ...renderProps, - ...tableVisualOptions - }) + (renderProps.isFocusVisible ? ' ' + raw('&:before { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: 100%; margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)') : '')} + ...tableVisualOptions, + selectionStyle, + isNextSelected: isNextSelected(id, renderProps.state), + isPrevSelected: isPrevSelected(id, renderProps.state), + isFirstItem: isFirstItem(id, renderProps.state) + }) + (renderProps.isFocusVisible ? ' ' + raw('&:before { content: ""; display: inline-block; position: sticky; inset-inline-start: 0; width: 3px; height: var(--focusIndicatorHeight); margin-inline-end: -3px; margin-block-end: 1px; z-index: 3; background-color: var(--rowFocusIndicatorColor)') : '') + + (isFirstItem(id, renderProps.state) && renderProps.isSelected && selectionStyle === 'highlight' ? ' ' + border : '') + } {...otherProps}> - {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( - // Not sure what we want to do with this className, in Cell it currently overrides the className that would have been applied. - // The `spread` otherProps must be after className in Cell. - // @ts-ignore - - - - )} + {selectionMode !== 'none' && selectionBehavior === 'toggle' && selectionStyle === 'checkbox' && ( + // Not sure what we want to do with this className, in Cell it currently overrides the className that would have been applied. + // The `spread` otherProps must be after className in Cell. + // @ts-ignore + + + + )} {children} diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 1bee8605955..a7edd6d87bc 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -129,6 +129,19 @@ export const Example: StoryObj = { } }; +export const Highlight: StoryObj = { + render: StaticTable, + args: { + selectionMode: 'multiple', + selectionStyle: 'highlight', + onResize: undefined, + onResizeStart: undefined, + onResizeEnd: undefined, + onLoadMore: undefined + } +}; + + export const DisabledRows: StoryObj = { ...Example, args: { diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 3e68b7bc624..abc36ffad79 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1247,7 +1247,8 @@ export interface RowRenderProps extends ItemRenderProps { * What level the row has within the table. * @selector [data-level] */ - level: number + level: number, + state: TableState } export interface RowProps extends StyleRenderProps, LinkDOMProps, HoverEvents, PressEvents, Omit, 'onClick'> { @@ -1377,6 +1378,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( }, values: { ...states, + state, isHovered, isFocused, isFocusVisible, From 6a79fa6df7170a5570b721dfcf6f38bdebdfc76e Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:22:35 -0700 Subject: [PATCH 03/11] fix lint --- packages/@react-spectrum/s2/src/TableView.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 1cb480c15fe..b01fc9354be 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -65,7 +65,7 @@ import {GridNode} from '@react-types/grid'; import {IconContext} from './Icon'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isFirstItem, isNextSelected, isPrevSelected} from './ListView'; +import {isFirstItem, isNextSelected} from './ListView'; import {LayoutNode} from '@react-stately/layout'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg'; @@ -371,7 +371,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re isCheckboxSelection, isQuiet })} - selectionBehavior="toggle" + selectionBehavior={selectionStyle === 'highlight' ? 'replace' : 'toggle'} selectionMode={selectionMode} onRowAction={onAction} {...otherProps} @@ -1003,7 +1003,7 @@ const cell = style - {selectionMode !== 'none' && selectionBehavior === 'toggle' && selectionStyle === 'checkbox' && ( + {selectionMode !== 'none' && selectionBehavior === 'toggle' && ( // Not sure what we want to do with this className, in Cell it currently overrides the className that would have been applied. // The `spread` otherProps must be after className in Cell. // @ts-ignore From e59fe39ea829241dabae0ca14b73dc99fa9c0be5 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:17:52 -0700 Subject: [PATCH 04/11] code cleanup + comments --- packages/@react-spectrum/s2/src/ListView.tsx | 30 +--------- packages/@react-spectrum/s2/src/TableView.tsx | 55 ++++--------------- packages/@react-spectrum/s2/src/TreeView.tsx | 3 +- packages/@react-spectrum/s2/src/utils.ts | 33 +++++++++++ 4 files changed, 47 insertions(+), 74 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index b43987f513f..8a2c3933704 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -38,6 +38,7 @@ import { import {IconContext} from './Icon'; import {ImageContext} from './Image'; import intlMessages from '../intl/*.json'; +import {isFirstItem, isLastItem, isNextSelected, isPrevSelected, useScale} from './utils'; import {Key} from '@react-types/shared'; import LinkOutIcon from '../ui-icons/LinkOut'; import {ListLayout} from 'react-stately/private/layout/ListLayout'; @@ -49,7 +50,6 @@ import {useActionBarContainer} from './ActionBar'; import {useDOMRef} from './useDOMRef'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; -import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; import {Virtualizer} from 'react-aria-components/Virtualizer'; @@ -707,34 +707,6 @@ function ListSelectionCheckbox({isDisabled}: {isDisabled: boolean}) { ); } -export function isNextSelected(id: Key | undefined, state: ListState) { - if (id == null || !state) { - return false; - } - let keyAfter = state.collection.getKeyAfter(id); - return keyAfter != null && state.selectionManager.isSelected(keyAfter); -} -export function isPrevSelected(id: Key | undefined, state: ListState) { - if (id == null || !state) { - return false; - } - let keyBefore = state.collection.getKeyBefore(id); - return keyBefore != null && state.selectionManager.isSelected(keyBefore); -} - -export function isFirstItem(id: Key | undefined, state: ListState) { - if (id == null || !state) { - return false; - } - return state.collection.getFirstKey() === id; -} -function isLastItem(id: Key | undefined, state: ListState) { - if (id == null || !state) { - return false; - } - return state.collection.getLastKey() === id; -} - export function ListViewItem(props: ListViewItemProps): ReactNode { let ref = useRef(null); let {hasChildItems, ...otherProps} = props; diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 07159fbe7af..3fb2c5b7ba8 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -58,7 +58,7 @@ import {getOwnerDocument} from 'react-aria/private/utils/domHelpers'; import {GridNode} from 'react-stately/private/grid/GridCollection'; import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; -import {isFirstItem, isNextSelected} from './ListView'; +import {isFirstItem, isNextSelected, useScale} from './utils'; import {Key} from '@react-types/shared'; import {LayoutNode} from 'react-stately/private/layout/ListLayout'; import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu'; @@ -80,7 +80,6 @@ import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useMediaQuery} from './useMediaQuery'; import {useObjectRef} from 'react-aria/useObjectRef'; -import {useScale} from './utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; import {Virtualizer} from 'react-aria-components/Virtualizer'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; @@ -995,38 +994,13 @@ const cell = style | TableState | TreeState) { + if (id == null || !state) { + return false; + } + let keyAfter = state.collection.getKeyAfter(id); + return keyAfter != null && state.selectionManager.isSelected(keyAfter); +} + +export function isPrevSelected(id: Key | undefined, state:ListState | TableState | TreeState) { + if (id == null || !state) { + return false; + } + let keyBefore = state.collection.getKeyBefore(id); + return keyBefore != null && state.selectionManager.isSelected(keyBefore); +} + +export function isFirstItem(id: Key | undefined, state: ListState | TableState | TreeState) { + if (id == null || !state) { + return false; + } + return state.collection.getFirstKey() === id; +} +export function isLastItem(id: Key | undefined, state: ListState | TableState | TreeState) { + if (id == null || !state) { + return false; + } + return state.collection.getLastKey() === id; +} From 0cb910e1a10bb502f632bfe48d92d31c582380df Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:21:34 -0700 Subject: [PATCH 05/11] fix lint --- packages/@react-spectrum/s2/src/ListView.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 8a2c3933704..d787f21b6a0 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -42,8 +42,6 @@ import {isFirstItem, isLastItem, isNextSelected, isPrevSelected, useScale} from import {Key} from '@react-types/shared'; import LinkOutIcon from '../ui-icons/LinkOut'; import {ListLayout} from 'react-stately/private/layout/ListLayout'; -// @ts-ignore -import {ListState} from 'react-stately/useListState'; import {ProgressCircle} from './ProgressCircle'; import {Text, TextContext} from './Content'; import {useActionBarContainer} from './ActionBar'; From 33cbc66ce9f949d877ec3258959d07b36b107a00 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:01:58 -0700 Subject: [PATCH 06/11] add highlight selection to the docs --- packages/dev/s2-docs/pages/s2/TableView.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index d8ffc282050..fa74a20d996 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -14,7 +14,7 @@ export const description = 'Displays data in rows and columns, with row selectio {docs.exports.TableView.description} -```tsx render docs={docs.exports.TableView} links={docs.links} props={['selectionMode', 'overflowMode', 'density', 'isQuiet', 'disabledBehavior']} initialProps={{'aria-label': 'Files', selectionMode: 'multiple', 'treeColumn': 'name', disabledBehavior: 'selection'}} type="s2" +```tsx render docs={docs.exports.TableView} links={docs.links} props={['selectionMode', 'overflowMode', 'density', 'isQuiet', 'disabledBehavior', 'selectionStyle']} initialProps={{'aria-label': 'Files', selectionMode: 'multiple', 'treeColumn': 'name', disabledBehavior: 'selection', selectionStyle: 'checkbox'}} type="s2" "use client"; import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -293,7 +293,7 @@ function AsyncSortTable() { Use the `href` prop on a Row to create a link. See the [getting started guide](getting-started) to learn how to integrate with your framework. Link interactions vary depending on the selection behavior. See the [selection guide](selection) for more details. -```tsx render docs={docs.exports.TableView} links={docs.links} props={['selectionMode']} initialProps={{'aria-label': 'Bookmarks', selectionMode: 'multiple'}} wide type="s2" +```tsx render docs={docs.exports.TableView} links={docs.links} props={['selectionMode', 'selectionStyle']} initialProps={{'aria-label': 'Bookmarks', selectionMode: 'multiple', selectionStyle: 'checkbox'}} wide type="s2" "use client"; import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -635,7 +635,7 @@ export default function EditableTable(props) { Use `selectionMode` to enable single or multiple selection, and `selectedKeys` (matching each row's `id`) to control the selected rows. Return an [ActionBar](ActionBar) from `renderActionBar` to handle bulk actions, and use `onAction` for row navigation. Disable rows with `isDisabled`. See the [selection guide](selection) for details. -```tsx render docs={docs.exports.TableView} links={docs.links} props={['selectionMode', 'disallowEmptySelection']} initialProps={{selectionMode: 'multiple'}} wide type="s2" +```tsx render docs={docs.exports.TableView} links={docs.links} props={['selectionMode', 'disallowEmptySelection', 'selectionStyle']} initialProps={{selectionMode: 'multiple', selectionStyle: 'checkbox'}} wide type="s2" "use client"; import {TableView, TableHeader, Column, TableBody, Row, Cell, ActionBar, ActionButton, Text, type Selection} from '@react-spectrum/s2'; import Edit from '@react-spectrum/s2/icons/Edit'; From 8646952b67614f6fbfc17d1f18e29431baeee2c7 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:02:19 -0700 Subject: [PATCH 07/11] add jsdoc comment --- packages/react-aria-components/src/Table.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index 2fad401fa65..888cadb836a 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1298,6 +1298,9 @@ export interface RowRenderProps extends ItemRenderProps { * @selector [data-level] */ level: number, + /** + * State of the table. + */ state: TableState } From 042ce49c21a1df11a91317c5787efc0f23e8fa42 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:08:31 -0700 Subject: [PATCH 08/11] update hcm --- packages/@react-spectrum/s2/src/TableView.tsx | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index 3fb2c5b7ba8..cb2e6b374de 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -483,13 +483,18 @@ const cellFocus = { outlineWidth: 2, outlineColor: { default: 'focus-ring', - forcedColors: 'Highlight' + forcedColors: { + default: 'Highlight', + selectionStyle: { + highlight: 'ButtonBorder' + } + } }, borderRadius: '[6px]' } as const; -function CellFocusRing() { - return
; +function CellFocusRing({selectionStyle} : {selectionStyle?: 'checkbox' | 'highlight'}) { + return
; } const columnStyles = style({ @@ -559,7 +564,6 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef let domRef = useDOMRef(ref); let isMenu = allowsResizing || !!props.menuItems; - return ( columnStyles({...renderProps, isMenu, align, isQuiet, selectionStyle})}> {({allowsSorting, sortDirection, isFocusVisible, sort, startResize}) => ( @@ -567,7 +571,7 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef {/* Note this is mainly for column's without a dropdown menu. If there is a dropdown menu, the button is styled to have a focus ring for simplicity (no need to juggle showing this focus ring if focus is on the menu button and not if it is on the resizer) */} {/* Separate absolutely positioned element because appyling the ring on the column directly via outline means the ring's required borderRadius will cause the bottom gray border to curve as well */} - {isFocusVisible && } + {isFocusVisible && } {isMenu ? ( @@ -1083,7 +1087,7 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef } {children} - {isFocusVisible && } + {isFocusVisible && } )} @@ -1164,7 +1168,12 @@ const editableCell = style {children} - {isFocusVisible && } + {isFocusVisible && } because it messes up the cell count +// As a result, we rely on adding a css pseudo element when the row is selected + first item + highlight selection const border = css( `&:after { content: ""; From a31ba79d55201c23cc360690f4d330cdbfd65154 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:23:03 -0700 Subject: [PATCH 09/11] add chromatic stories --- .../s2/chromatic/TableView.stories.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx b/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx index 23057e6083b..09a46467371 100644 --- a/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/chromatic/TableView.stories.tsx @@ -33,7 +33,7 @@ const meta: Meta = { export default meta; const StaticTable = (args: TableViewProps): ReactElement => ( - + Name Type @@ -106,6 +106,34 @@ let items = [ {id: 10, foo: 'Foo 10', bar: 'Bar 10', baz: 'Baz 10', yah: 'Yah long long long 10'} ]; +export const CheckboxSelection: StoryObj = { + render: StaticTable, + args: { + selectionMode: 'multiple', + selectionStyle: 'checkbox', + selectedKeys: ['1', '2'], + styles: style({width: 500}), + onResize: undefined, + onResizeStart: undefined, + onResizeEnd: undefined, + onLoadMore: undefined + } +}; + +export const HighlightSelection: StoryObj = { + ...Example, + args: { + selectionMode: 'multiple', + selectionStyle: 'highlight', + selectedKeys: ['1', '2'], + styles: style({width: 500}), + onResize: undefined, + onResizeStart: undefined, + onResizeEnd: undefined, + onLoadMore: undefined + } +}; + const DynamicTable = (args: TableViewProps): ReactElement => ( From d7be568f1d76057fe55753417e56862b5a26a5b2 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:28:51 -0700 Subject: [PATCH 10/11] add ids to table with links to fix styles --- packages/dev/s2-docs/pages/s2/TableView.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index fa74a20d996..98209dc4761 100644 --- a/packages/dev/s2-docs/pages/s2/TableView.mdx +++ b/packages/dev/s2-docs/pages/s2/TableView.mdx @@ -308,18 +308,18 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; {/*- begin highlight -*/} - + {/*- end highlight -*/} Adobe https://adobe.com/ January 28, 2023 - + Google https://google.com/ April 5, 2023 - + New York Times https://nytimes.com/ July 12, 2023 From 8a2ea101b704c41e17bb1d9900171b91c80aa327 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:29:35 -0700 Subject: [PATCH 11/11] minor cleanup --- packages/@react-spectrum/s2/src/TableView.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index cb2e6b374de..54b43218d03 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -121,6 +121,10 @@ interface S2TableProps { onLoadMore?: () => any, /** Provides the ActionBar to display when rows are selected in the TableView. */ renderActionBar?: (selectedKeys: 'all' | Set) => ReactElement, + /** + * How selection should be displayed. + * @default 'checkbox' + */ selectionStyle?: 'checkbox' | 'highlight' } @@ -1064,7 +1068,6 @@ export const Cell = forwardRef(function Cell(props: CellProps, ref: DOMRef