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 => ( diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 6537b3cc437..d787f21b6a0 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -38,18 +38,16 @@ 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'; -// @ts-ignore -import {ListState} from 'react-stately/useListState'; import {ProgressCircle} from './ProgressCircle'; import {Text, TextContext} from './Content'; 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 +705,6 @@ function ListSelectionCheckbox({isDisabled}: {isDisabled: boolean}) { ); } -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 547cfc6b917..54b43218d03 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -33,7 +33,6 @@ import { TableHeaderProps as RACTableHeaderProps, TableProps as RACTableProps, ResizableTableContainer, - RowRenderProps, TableBodyRenderProps, TableLayout, TableLoadMoreItem, @@ -59,6 +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, 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'; @@ -121,7 +120,12 @@ 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, + /** + * How selection should be displayed. + * @default 'checkbox' + */ + selectionStyle?: 'checkbox' | 'highlight' } // TODO: Note that loadMore and loadingState are now on the Table instead of on the TableBody @@ -130,7 +134,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, @@ -300,6 +304,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re onAction, onLoadMore, selectionMode = 'none', + selectionStyle = 'checkbox', ...otherProps } = props; @@ -325,8 +330,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'; @@ -371,7 +377,7 @@ export const TableView = forwardRef(function TableView(props: TableViewProps, re isCheckboxSelection, isQuiet })} - selectionBehavior="toggle" + selectionBehavior={selectionStyle === 'highlight' ? 'replace' : 'toggle'} selectionMode={selectionMode} onRowAction={onAction} {...otherProps} @@ -481,13 +487,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({ @@ -552,20 +563,19 @@ 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 (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 ? ( @@ -903,7 +913,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 ( @@ -912,7 +922,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 ( @@ -992,17 +1002,10 @@ const cell = style } {children} - {isFocusVisible && } + {isFocusVisible && } )} @@ -1168,7 +1171,12 @@ const editableCell = style {children} - {isFocusVisible && } + {isFocusVisible && } ({ +const row = style({ height: 'full', position: 'relative', boxSizing: 'border-box', - backgroundColor: '--rowBackgroundColor', + backgroundColor: { + default: '--rowBackgroundColor' + }, '--rowBackgroundColor': { type: 'backgroundColor', value: rowBackgroundColor @@ -1519,8 +1543,16 @@ const row = style({ '--rowFocusIndicatorColor': { type: 'outlineColor', value: { - default: 'focus-ring', - forcedColors: 'Highlight' + selectionStyle: { + checkbox: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + highlight: { + default: 'focus-ring', + forcedColors: 'ButtonBorder' + } + } } }, // TODO: outline here is to emulate v3 forcedColors experience but runs into the same problem where the sticky column covers the outline @@ -1549,17 +1581,89 @@ const row = style({ // }, outlineStyle: 'none', borderTopWidth: 0, - borderBottomWidth: 1, + borderBottomWidth: { + selectionStyle: { + highlight: 0, + checkbox: 1 + } + }, borderStartWidth: 0, borderEndWidth: 0, borderStyle: 'solid', borderColor: { - default: 'gray-300', - forcedColors: 'ButtonBorder' + selectionStyle: { + highlight: 'transparent', + checkbox: 'gray-300' + } + }, + '--borderColorGray': { + type: 'borderColor', + value: { + default: 'gray-300', + forcedColors: 'ButtonBorder' + } + }, + '--borderColorBlue': { + type: 'borderColor', + value: { + default: 'blue-900', + forcedColors: 'ButtonBorder' + } }, + // In order to prevent layout shifts, we use box shadows to render the borders since we can't add an absolute position div (it messes up the cell count due to the way Table collections are built) + // In highlight mode, selected groups also have gray borders between the items in addition to having a blue outer border + // Having a border have two colors is possible, the issue is that the browser will render a diagonal line where the two borders meet + // Using box shadows gives us a bit more control on how the border colors appear + 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 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))' + } + }, + isolation: 'isolate', + zIndex: 3, forcedColorAdjust: 'none' }); +// Unlike the other rows, the first row needs to render a border on top when it is selected in highlight mode +// Unfortunately, we can't add a position: absolute div to 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: ""; + width: 100%; + height: 100%; + top: 0; + inset-inline-start: 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', @@ -1574,7 +1678,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 ( @@ -1585,8 +1689,13 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row row({ ...renderProps, - ...tableVisualOptions - }) + (renderProps.isFocusVisible ? ' ' + css('&: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), + isFirstItem: isFirstItem(id, renderProps.state) + }) + (renderProps.isFocusVisible ? ' ' + css('&: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. diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 76d886d4654..c94205f9afb 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -23,7 +23,7 @@ import {DOMRef, forwardRefType, GlobalDOMAttributes, Key, LoadingState} from '@r import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'}; import {IconContext} from './Icon'; import intlMessages from '../intl/*.json'; -import {isFirstItem, isPrevSelected} from './ListView'; +import {isFirstItem, isPrevSelected, useScale} from './utils'; import {ListLayout} from 'react-stately/private/layout/ListLayout'; import {ProgressCircle} from './ProgressCircle'; import {Provider, useContextProps} from 'react-aria-components/utils'; @@ -45,7 +45,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 {Virtualizer} from 'react-aria-components/Virtualizer'; interface S2TreeProps { diff --git a/packages/@react-spectrum/s2/src/utils.ts b/packages/@react-spectrum/s2/src/utils.ts index d2957cb9ae5..aa59fafe392 100644 --- a/packages/@react-spectrum/s2/src/utils.ts +++ b/packages/@react-spectrum/s2/src/utils.ts @@ -10,6 +10,10 @@ * governing permissions and limitations under the License. */ +import {Key} from '@react-types/shared'; +import {ListState} from 'react-stately/useListState'; +import {TableState} from 'react-stately/useTableState'; +import {TreeState} from 'react-stately/useTreeState'; import {useMediaQuery} from './useMediaQuery'; export type Scale = 'large' | 'medium'; @@ -26,3 +30,32 @@ export function useScale(): Scale { return 'medium'; } + +export function isNextSelected(id: Key | undefined, state: ListState | 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; +} diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index d64c99799a0..189236c190c 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/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx index d8ffc282050..98209dc4761 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'}; @@ -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 @@ -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'; diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index e8b352c5871..888cadb836a 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -1297,7 +1297,11 @@ export interface RowRenderProps extends ItemRenderProps { * What level the row has within the table. * @selector [data-level] */ - level: number + level: number, + /** + * State of the table. + */ + state: TableState } export interface RowProps extends StyleRenderProps, LinkDOMProps, HoverEvents, PressEvents, Omit, 'onClick'> { @@ -1427,6 +1431,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( }, values: { ...states, + state, isHovered, isFocused, isFocusVisible,