Skip to content

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

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

Merged
merged 13 commits into from
Apr 12, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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={
Copy link
Member

Choose a reason for hiding this comment

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

Bit strange that this doesn't actually accept a <Menu>. We should discuss options for the API here.

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah, that's another option to handle it
was trying to answer #7617 (comment) as well

if it accepted a Menu, I guess we could do a custom renderer to inject our three menu items, still not a good way to ensure groups that make sense though

maybe people have to provide a menu with specific ids on certain children if they want resize/sort?

open to other ideas, not sold on what I currently have

Copy link
Member Author

Choose a reason for hiding this comment

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

Marked current api as UNSTABLE, also changed to menuItems

Other API considerations have been

           // use a MenuTrigger around Column, but how to inject extra children??? also, prevent context leak into Resizer
            menuItems={[ // seems silly, may as well use jsx
              {id: 'filter', label: 'Filter', icon: <Filter />},
              {id: 'hide column', label: 'Hide column'},
              {id: 'manage columns', label: 'Manage columns'}
            ]}

            menu={(extraChildren) => {
              return (
                // too much control? what if our children are omitted
                <Menu>
                  {extraChildren}
                  <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>
                </Menu>
              );
            }}

            menu={
              // how to inject extra children? S2 context to inject them? create a new component, ColumnMenu?
              <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>
              </Menu>
            }

            menuItems={
              // current implementation
              <>
                <MenuSection>
                  <MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>

Copy link
Member Author

Choose a reason for hiding this comment

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

Design is fine (for now) if our default items are in their own section at the top. We've decided that without needing to interleave/change order of menuitems, it's better to not give an API which allows people to omit our default options as this could be a problem for accessibility.

<>
<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>
);

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']
}
};

Copy link
Member

Choose a reason for hiding this comment

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

We should look at potentially combining some of the stories since these are also our docs

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