Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(table): S2 tableview custom column menu #7617

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 deletions packages/@react-spectrum/s2/src/TableView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {IconContext} from './Icon';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {LayoutNode} from '@react-stately/layout';
import {Menu, MenuItem, MenuTrigger} from './Menu';
import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
import {mergeStyles} from '../style/runtime';
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
import {ProgressCircle} from './ProgressCircle';
Expand Down Expand Up @@ -513,7 +513,8 @@ export interface ColumnProps extends RACColumnProps {
*/
align?: 'start' | 'center' | 'end',
/** The content to render as the column header. */
children: ReactNode
children: ReactNode,
menu?: ReactNode
}

/**
Expand All @@ -526,6 +527,7 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef
let domRef = useDOMRef(ref);
let isColumnResizable = allowsResizing;


return (
<RACColumn {...props} ref={domRef} style={{borderInlineEndColor: 'transparent'}} className={renderProps => columnStyles({...renderProps, isColumnResizable, align, isQuiet})}>
{({allowsSorting, sortDirection, isFocusVisible, sort, startResize, isHovered}) => (
Expand All @@ -534,9 +536,9 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef
(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 && <CellFocusRing />}
{isColumnResizable ?
{isColumnResizable || !!props.menu ?
(
<ResizableColumnContents allowsSorting={allowsSorting} sortDirection={sortDirection} sort={sort} startResize={startResize} isHovered={isHeaderRowHovered || isHovered} align={align}>
<ResizableColumnContents isColumnResizable={isColumnResizable} menu={props.menu} allowsSorting={allowsSorting} sortDirection={sortDirection} sort={sort} startResize={startResize} isHovered={isHeaderRowHovered || isHovered} align={align}>
{children}
</ResizableColumnContents>
) : (
Expand Down Expand Up @@ -709,10 +711,13 @@ const nubbin = style({
}
});

interface ResizableColumnContentProps extends Pick<ColumnRenderProps, 'allowsSorting' | 'sort' | 'sortDirection' | 'startResize' | 'isHovered'>, Pick<ColumnProps, 'align' | 'children'> {}
interface ResizableColumnContentProps extends Pick<ColumnRenderProps, 'allowsSorting' | 'sort' | 'sortDirection' | 'startResize' | 'isHovered'>, Pick<ColumnProps, 'align' | 'children'> {
isColumnResizable?: boolean,
menu?: ReactNode
}

function ResizableColumnContents(props: ResizableColumnContentProps) {
let {allowsSorting, sortDirection, sort, startResize, children, isHovered, align} = props;
let {allowsSorting, sortDirection, sort, startResize, children, isHovered, align, isColumnResizable, menu} = props;
let {setIsInResizeMode, isInResizeMode} = useContext(InternalTableContext);
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
const onMenuSelect = (key) => {
Expand All @@ -731,12 +736,13 @@ function ResizableColumnContents(props: ResizableColumnContentProps) {
};

let items = useMemo(() => {
let options = [
{
let options: Array<{label: string, id: string}> = [];
if (isColumnResizable) {
options = [{
label: stringFormatter.format('table.resizeColumn'),
id: 'resize'
}
];
}];
}
if (allowsSorting) {
options = [
{
Expand All @@ -752,7 +758,7 @@ function ResizableColumnContents(props: ResizableColumnContentProps) {
}
return options;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowsSorting]);
}, [allowsSorting, isColumnResizable]);

let buttonAlignment = 'start';
let menuAlign = 'start' as 'start' | 'end';
Expand Down Expand Up @@ -784,20 +790,27 @@ function ResizableColumnContents(props: ResizableColumnContentProps) {
</div>
<Chevron size="M" className={chevronIcon} />
</Button>
<Menu onAction={onMenuSelect} items={items} styles={style({minWidth: 128})}>
{(item) => <MenuItem>{item?.label}</MenuItem>}
<Menu onAction={onMenuSelect} styles={style({minWidth: 128})}>
<MenuSection>
<Collection items={items}>
{(item) => <MenuItem>{item?.label}</MenuItem>}
</Collection>
</MenuSection>
{menu}
</Menu>
</MenuTrigger>
<div data-react-aria-prevent-focus="true">
<ColumnResizer data-react-aria-prevent-focus="true" className={({resizableDirection, isResizing}) => resizerHandleContainer({resizableDirection, isResizing, isHovered: isInResizeMode || isHovered})}>
{({isFocusVisible, isResizing}) => (
<>
<ResizerIndicator isInResizeMode={isInResizeMode} isFocusVisible={isFocusVisible} isHovered={isHovered} isResizing={isResizing} />
{(isFocusVisible || isInResizeMode) && isResizing && <div className={nubbin}><Nubbin /></div>}
</>
)}
</ColumnResizer>
</div>
{isColumnResizable && (
<div data-react-aria-prevent-focus="true">
<ColumnResizer data-react-aria-prevent-focus="true" className={({resizableDirection, isResizing}) => resizerHandleContainer({resizableDirection, isResizing, isHovered: isInResizeMode || isHovered})}>
{({isFocusVisible, isResizing}) => (
<>
<ResizerIndicator isInResizeMode={isInResizeMode} isFocusVisible={isFocusVisible} isHovered={isHovered} isResizing={isResizing} />
{(isFocusVisible || isInResizeMode) && isResizing && <div className={nubbin}><Nubbin /></div>}
</>
)}
</ColumnResizer>
</div>
)}
</>
);
}
Expand Down
113 changes: 111 additions & 2 deletions packages/@react-spectrum/s2/stories/TableView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
*/

import {action} from '@storybook/addon-actions';
import {ActionButton, Cell, Column, Content, Heading, IllustratedMessage, Link, Row, TableBody, TableHeader, TableView} from '../src';
import {ActionButton, Cell, Column, Content, Heading, IllustratedMessage, Link, MenuItem, MenuSection, Row, TableBody, TableHeader, TableView, Text} from '../src';
import {categorizeArgTypes} from './utils';
import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg';
import FolderOpen from '../spectrum-illustrations/linear/FolderOpen';
import type {Meta} from '@storybook/react';
import {SortDescriptor} from 'react-aria-components';
Expand Down Expand Up @@ -151,6 +152,94 @@ const DynamicTable = (args: any) => (
</TableView>
);


const DynamicTableWithCustomMenus = (args: any) => (
<TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
<TableHeader columns={columns}>
{(column) => (
<Column
width={150}
minWidth={150}
isRowHeader={column.isRowHeader}
menu={
<>
<MenuSection>
<MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>
</MenuSection>
Comment on lines +166 to +168
Copy link
Member

@ktabors ktabors Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line as the first MenuItem from a MenuSection when it's the first section and there is no Header looks weird. I checked our Menu stories and they all have Headers. Is this because a MenuSection is wrapping the Collection where the MenuItems are inserted into the TableHeader's column cell?
image

<MenuSection>
<MenuItem onAction={action('hide column')}><Text slot="label">Hide column</Text></MenuItem>
<MenuItem onAction={action('manage columns')}><Text slot="label">Manage columns</Text></MenuItem>
</MenuSection>
</>
}>{column.name}</Column>
)}
</TableHeader>
<TableBody items={items}>
{item => (
<Row id={item.id} columns={columns}>
{(column) => {
return <Cell>{item[column.id]}</Cell>;
}}
</Row>
)}
</TableBody>
</TableView>
);

let sortItems = items;
const DynamicSortableTableWithCustomMenus = (args: any) => {
let [items, setItems] = useState(sortItems);
let [sortDescriptor, setSortDescriptor] = useState({});
let onSortChange = (sortDescriptor: SortDescriptor) => {
let {direction = 'ascending', column = 'name'} = sortDescriptor;

let sorted = items.slice().sort((a, b) => {
let cmp = a[column] < b[column] ? -1 : 1;
if (direction === 'descending') {
cmp *= -1;
}
return cmp;
});

setItems(sorted);
setSortDescriptor(sortDescriptor);
};

return (
<TableView aria-label="Dynamic table" {...args} sortDescriptor={sortDescriptor} onSortChange={onSortChange} styles={style({width: 320, height: 208})}>
<TableHeader columns={columns}>
{(column) => (
<Column
allowsSorting
width={150}
minWidth={150}
isRowHeader={column.isRowHeader}
menu={
<>
<MenuSection>
<MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>
</MenuSection>
<MenuSection>
<MenuItem onAction={action('hide column')}><Text slot="label">Hide column</Text></MenuItem>
<MenuItem onAction={action('manage columns')}><Text slot="label">Manage columns</Text></MenuItem>
</MenuSection>
</>
}>{column.name}</Column>
)}
</TableHeader>
<TableBody items={items}>
{item => (
<Row id={item.id} columns={columns}>
{(column) => {
return <Cell>{item[column.id]}</Cell>;
}}
</Row>
)}
</TableBody>
</TableView>
);
};

export const Dynamic = {
render: DynamicTable,
args: {
Expand All @@ -159,6 +248,22 @@ export const Dynamic = {
}
};

export const DynamicCustomMenus = {
render: DynamicTableWithCustomMenus,
args: {
...Example.args,
disabledKeys: ['Foo 5']
}
};

export const DynamicSortableCustomMenus = {
render: DynamicSortableTableWithCustomMenus,
args: {
...Example.args,
disabledKeys: ['Foo 5']
}
};

function renderEmptyState() {
return (
<IllustratedMessage>
Expand Down Expand Up @@ -471,7 +576,11 @@ const SortableResizableTable = (args: any) => {
<TableView aria-label="sortable table" {...args} sortDescriptor={isSortable ? sortDescriptor : null} onSortChange={isSortable ? onSortChange : null} styles={style({width: 384, height: 320})}>
<TableHeader columns={args.columns}>
{(column: any) => (
<Column isRowHeader={column.isRowHeader} allowsSorting={column.isSortable} allowsResizing={column.allowsResizing} align={column.align}>{column.name}</Column>
<Column
isRowHeader={column.isRowHeader}
allowsSorting={column.isSortable}
allowsResizing={column.allowsResizing}
align={column.align}>{column.name}</Column>
)}
</TableHeader>
<TableBody items={items}>
Expand Down
Loading