Skip to content

Commit 23faef4

Browse files
feat(table): S2 tableview custom column menu (#7617)
* S2 TableView custom column menus * Fix lint * Update TableView.tsx * fix styles and empty section * fix lint * chnage prop name, description, internal component name * Add better string * add unstable prefix * fix storybook --------- Co-authored-by: Kyle Taborski <[email protected]>
1 parent c73bebd commit 23faef4

File tree

3 files changed

+156
-34
lines changed

3 files changed

+156
-34
lines changed

packages/@react-spectrum/s2/intl/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"table.sortAscending": "Sort Ascending",
2828
"table.sortDescending": "Sort Descending",
2929
"table.resizeColumn": "Resize column",
30+
"table.standardColumnMenu": "Default column actions",
3031
"tag.showAllButtonLabel": "Show all ({tagCount, number})",
3132
"tag.hideButtonLabel": "Show less",
3233
"tag.actions": "Actions",

packages/@react-spectrum/s2/src/TableView.tsx

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {IconContext} from './Icon';
5454
// @ts-ignore
5555
import intlMessages from '../intl/*.json';
5656
import {LayoutNode} from '@react-stately/layout';
57-
import {Menu, MenuItem, MenuTrigger} from './Menu';
57+
import {Menu, MenuItem, MenuSection, MenuTrigger} from './Menu';
5858
import {mergeStyles} from '../style/runtime';
5959
import Nubbin from '../ui-icons/S2_MoveHorizontalTableWidget.svg';
6060
import {ProgressCircle} from './ProgressCircle';
@@ -466,7 +466,7 @@ const columnStyles = style({
466466
},
467467
paddingX: {
468468
default: 16,
469-
isColumnResizable: 0
469+
isMenu: 0
470470
},
471471
textAlign: {
472472
align: {
@@ -493,7 +493,7 @@ const columnStyles = style({
493493
borderStartWidth: 0,
494494
borderEndWidth: {
495495
default: 0,
496-
isColumnResizable: 1
496+
isMenu: 1
497497
},
498498
borderStyle: 'solid',
499499
forcedColorAdjust: 'none'
@@ -510,7 +510,9 @@ export interface ColumnProps extends RACColumnProps {
510510
*/
511511
align?: 'start' | 'center' | 'end',
512512
/** The content to render as the column header. */
513-
children: ReactNode
513+
children: ReactNode,
514+
/** Menu fragment to be rendered inside the column header's menu. */
515+
UNSTABLE_menuItems?: ReactNode
514516
}
515517

516518
/**
@@ -520,21 +522,22 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef
520522
let {isQuiet} = useContext(InternalTableContext);
521523
let {allowsResizing, children, align = 'start'} = props;
522524
let domRef = useDOMRef(ref);
523-
let isColumnResizable = allowsResizing;
525+
let isMenu = allowsResizing || !!props.UNSTABLE_menuItems;
526+
524527

525528
return (
526-
<RACColumn {...props} ref={domRef} style={{borderInlineEndColor: 'transparent'}} className={renderProps => columnStyles({...renderProps, isColumnResizable, align, isQuiet})}>
529+
<RACColumn {...props} ref={domRef} style={{borderInlineEndColor: 'transparent'}} className={renderProps => columnStyles({...renderProps, isMenu, align, isQuiet})}>
527530
{({allowsSorting, sortDirection, isFocusVisible, sort, startResize}) => (
528531
<>
529532
{/* 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
530533
(no need to juggle showing this focus ring if focus is on the menu button and not if it is on the resizer) */}
531534
{/* 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 */}
532535
{isFocusVisible && <CellFocusRing />}
533-
{isColumnResizable ?
536+
{isMenu ?
534537
(
535-
<ResizableColumnContents allowsSorting={allowsSorting} sortDirection={sortDirection} sort={sort} startResize={startResize} align={align}>
538+
<ColumnWithMenu isColumnResizable={allowsResizing} menuItems={props.UNSTABLE_menuItems} allowsSorting={allowsSorting} sortDirection={sortDirection} sort={sort} startResize={startResize} align={align}>
536539
{children}
537-
</ResizableColumnContents>
540+
</ColumnWithMenu>
538541
) : (
539542
<ColumnContents allowsSorting={allowsSorting} sortDirection={sortDirection}>
540543
{children}
@@ -704,10 +707,13 @@ const nubbin = style({
704707
}
705708
});
706709

707-
interface ResizableColumnContentProps extends Pick<ColumnRenderProps, 'allowsSorting' | 'sort' | 'sortDirection' | 'startResize'>, Pick<ColumnProps, 'align' | 'children'> {}
710+
interface ColumnWithMenuProps extends Pick<ColumnRenderProps, 'allowsSorting' | 'sort' | 'sortDirection' | 'startResize'>, Pick<ColumnProps, 'align' | 'children'> {
711+
isColumnResizable?: boolean,
712+
menuItems?: ReactNode
713+
}
708714

709-
function ResizableColumnContents(props: ResizableColumnContentProps) {
710-
let {allowsSorting, sortDirection, sort, startResize, children, align} = props;
715+
function ColumnWithMenu(props: ColumnWithMenuProps) {
716+
let {allowsSorting, sortDirection, sort, startResize, children, align, isColumnResizable, menuItems} = props;
711717
let {setIsInResizeMode, isInResizeMode} = useContext(InternalTableContext);
712718
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
713719
const onMenuSelect = (key) => {
@@ -726,12 +732,13 @@ function ResizableColumnContents(props: ResizableColumnContentProps) {
726732
};
727733

728734
let items = useMemo(() => {
729-
let options = [
730-
{
735+
let options: Array<{label: string, id: string}> = [];
736+
if (isColumnResizable) {
737+
options = [{
731738
label: stringFormatter.format('table.resizeColumn'),
732739
id: 'resize'
733-
}
734-
];
740+
}];
741+
}
735742
if (allowsSorting) {
736743
options = [
737744
{
@@ -747,7 +754,7 @@ function ResizableColumnContents(props: ResizableColumnContentProps) {
747754
}
748755
return options;
749756
// eslint-disable-next-line react-hooks/exhaustive-deps
750-
}, [allowsSorting]);
757+
}, [allowsSorting, isColumnResizable]);
751758

752759
let buttonAlignment = 'start';
753760
let menuAlign = 'start' as 'start' | 'end';
@@ -779,20 +786,29 @@ function ResizableColumnContents(props: ResizableColumnContentProps) {
779786
</div>
780787
<Chevron size="M" className={chevronIcon} />
781788
</Button>
782-
<Menu onAction={onMenuSelect} items={items} styles={style({minWidth: 128})}>
783-
{(item) => <MenuItem>{item?.label}</MenuItem>}
789+
<Menu onAction={onMenuSelect} styles={style({minWidth: 128})}>
790+
{items.length > 0 && (
791+
<MenuSection aria-label={stringFormatter.format('table.standardColumnMenu')}>
792+
<Collection items={items}>
793+
{(item) => <MenuItem>{item?.label}</MenuItem>}
794+
</Collection>
795+
</MenuSection>
796+
)}
797+
{menuItems}
784798
</Menu>
785799
</MenuTrigger>
786-
<div data-react-aria-prevent-focus="true">
787-
<ColumnResizer data-react-aria-prevent-focus="true" className={({resizableDirection, isResizing}) => resizerHandleContainer({resizableDirection, isResizing, isInResizeMode})}>
788-
{({isFocusVisible, isResizing}) => (
789-
<>
790-
<ResizerIndicator isInResizeMode={isInResizeMode} isFocusVisible={isFocusVisible} isResizing={isResizing} />
791-
{(isFocusVisible || isInResizeMode) && isResizing && <div className={nubbin}><Nubbin /></div>}
792-
</>
793-
)}
794-
</ColumnResizer>
795-
</div>
800+
{isColumnResizable && (
801+
<div data-react-aria-prevent-focus="true">
802+
<ColumnResizer data-react-aria-prevent-focus="true" className={({resizableDirection, isResizing}) => resizerHandleContainer({resizableDirection, isResizing, isInResizeMode})}>
803+
{({isFocusVisible, isResizing}) => (
804+
<>
805+
<ResizerIndicator isInResizeMode={isInResizeMode} isFocusVisible={isFocusVisible} isResizing={isResizing} />
806+
{(isFocusVisible || isInResizeMode) && isResizing && <div className={nubbin}><Nubbin /></div>}
807+
</>
808+
)}
809+
</ColumnResizer>
810+
</div>
811+
)}
796812
</>
797813
);
798814
}

packages/@react-spectrum/s2/stories/TableView.stories.tsx

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
*/
1212

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

156+
157+
const DynamicTableWithCustomMenus = (args: any) => (
158+
<TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
159+
<TableHeader columns={columns}>
160+
{(column) => (
161+
<Column
162+
width={150}
163+
minWidth={150}
164+
isRowHeader={column.isRowHeader}
165+
UNSTABLE_menuItems={
166+
<>
167+
<MenuSection>
168+
<MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>
169+
</MenuSection>
170+
<MenuSection>
171+
<MenuItem onAction={action('hide column')}><Text slot="label">Hide column</Text></MenuItem>
172+
<MenuItem onAction={action('manage columns')}><Text slot="label">Manage columns</Text></MenuItem>
173+
</MenuSection>
174+
</>
175+
}>{column.name}</Column>
176+
)}
177+
</TableHeader>
178+
<TableBody items={items}>
179+
{item => (
180+
<Row id={item.id} columns={columns}>
181+
{(column) => {
182+
return <Cell>{item[column.id]}</Cell>;
183+
}}
184+
</Row>
185+
)}
186+
</TableBody>
187+
</TableView>
188+
);
189+
190+
let sortItems = items;
191+
const DynamicSortableTableWithCustomMenus = (args: any) => {
192+
let [items, setItems] = useState(sortItems);
193+
let [sortDescriptor, setSortDescriptor] = useState({});
194+
let onSortChange = (sortDescriptor: SortDescriptor) => {
195+
let {direction = 'ascending', column = 'name'} = sortDescriptor;
196+
197+
let sorted = items.slice().sort((a, b) => {
198+
let cmp = a[column] < b[column] ? -1 : 1;
199+
if (direction === 'descending') {
200+
cmp *= -1;
201+
}
202+
return cmp;
203+
});
204+
205+
setItems(sorted);
206+
setSortDescriptor(sortDescriptor);
207+
};
208+
209+
return (
210+
<TableView aria-label="Dynamic table" {...args} sortDescriptor={sortDescriptor} onSortChange={onSortChange} styles={style({width: 320, height: 208})}>
211+
<TableHeader columns={columns}>
212+
{(column) => (
213+
<Column
214+
allowsSorting
215+
width={150}
216+
minWidth={150}
217+
isRowHeader={column.isRowHeader}
218+
UNSTABLE_menuItems={
219+
<>
220+
<MenuSection>
221+
<MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>
222+
</MenuSection>
223+
<MenuSection>
224+
<MenuItem onAction={action('hide column')}><Text slot="label">Hide column</Text></MenuItem>
225+
<MenuItem onAction={action('manage columns')}><Text slot="label">Manage columns</Text></MenuItem>
226+
</MenuSection>
227+
</>
228+
}>{column.name}</Column>
229+
)}
230+
</TableHeader>
231+
<TableBody items={items}>
232+
{item => (
233+
<Row id={item.id} columns={columns}>
234+
{(column) => {
235+
return <Cell>{item[column.id]}</Cell>;
236+
}}
237+
</Row>
238+
)}
239+
</TableBody>
240+
</TableView>
241+
);
242+
};
243+
155244
export const Dynamic = {
156245
render: DynamicTable,
157246
args: {
@@ -160,6 +249,22 @@ export const Dynamic = {
160249
}
161250
};
162251

252+
export const DynamicCustomMenus = {
253+
render: DynamicTableWithCustomMenus,
254+
args: {
255+
...Example.args,
256+
disabledKeys: ['Foo 5']
257+
}
258+
};
259+
260+
export const DynamicSortableCustomMenus = {
261+
render: DynamicSortableTableWithCustomMenus,
262+
args: {
263+
...Example.args,
264+
disabledKeys: ['Foo 5']
265+
}
266+
};
267+
163268
function renderEmptyState() {
164269
return (
165270
<IllustratedMessage>
@@ -444,9 +549,9 @@ let resizeColumn = [
444549
];
445550

446551
let sortResizeColumns = [
447-
{name: 'Name', id: 'name', isRowHeader: true, allowsResizing: true, showDivider: true, isSortable: true},
448-
{name: 'Height', id: 'height', isSortable: true},
449-
{name: 'Weight', id: 'weight', allowsResizing: true, isSortable: true}
552+
{name: 'Name', id: 'name', isRowHeader: true, allowsResizing: true, showDivider: true, allowsSorting: true},
553+
{name: 'Height', id: 'height', allowsSorting: true},
554+
{name: 'Weight', id: 'weight', allowsResizing: true, allowsSorting: true}
450555
];
451556

452557
const SortableResizableTable = (args: any) => {
@@ -472,7 +577,7 @@ const SortableResizableTable = (args: any) => {
472577
<TableView aria-label="sortable table" {...args} sortDescriptor={isSortable ? sortDescriptor : null} onSortChange={isSortable ? onSortChange : null} styles={style({width: 384, height: 320})}>
473578
<TableHeader columns={args.columns}>
474579
{(column: any) => (
475-
<Column isRowHeader={column.isRowHeader} allowsSorting={column.isSortable} allowsResizing={column.allowsResizing} align={column.align}>{column.name}</Column>
580+
<Column {...column}>{column.name}</Column>
476581
)}
477582
</TableHeader>
478583
<TableBody items={items}>

0 commit comments

Comments
 (0)