diff --git a/.changeset/lovely-rings-enjoy.md b/.changeset/lovely-rings-enjoy.md new file mode 100644 index 00000000000..dbd8b188c58 --- /dev/null +++ b/.changeset/lovely-rings-enjoy.md @@ -0,0 +1,17 @@ +--- +'@shopify/polaris': minor +'polaris.shopify.com': patch +--- + +IndexTable subheader support updates: +- `IndexTable.Row` + - Added the `header` prop to apply subheader styles + - Added `indeterminate` value to `selected` prop + - Added `selectionRange` prop to specify which rows in the range are selected when the row is selected + - Added `rowType` prop to indicate the relationship or role of the row's contents (`data` or `subheader`) +- `IndexTable.Cell` + - Added the `header` prop to apply subheader styles + - Added `as` prop to set as a `th` if it is serving as a subheading + - Added `colSpan` prop to specify the number of the columns that the cell element should extend + - Added `scope` prop to indicate which cells the `th` element relates +- See the [With subheaders](https://polaris.shopify.com/components/tables/index-table) example on polaris.shopify.com for how to properly configure. diff --git a/polaris-react/src/components/IndexTable/IndexTable.scss b/polaris-react/src/components/IndexTable/IndexTable.scss index 55877f55b9a..e7285829652 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.scss +++ b/polaris-react/src/components/IndexTable/IndexTable.scss @@ -118,11 +118,14 @@ $loading-panel-height: 53px; } // This is to bust specificity with .Table-scrolling and TableCell:first-child or TableCell:first-child + TableCell bg color above. - #{$se23} & .TableRow-subdued { - // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-compound-selectors -- se23 overrides - .TableCell-first, - .TableCell-first + .TableCell { - background-color: var(--p-color-bg-subdued); + #{$se23} & { + // stylelint-disable-next-line selector-max-combinators -- se23 overrides + .TableRow-subdued { + // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-compound-selectors -- se23 overrides + .TableCell-first, + .TableCell-first + .TableCell { + background-color: var(--p-color-bg-subdued); + } } } @@ -220,19 +223,65 @@ $loading-panel-height: 53px; .TableCell-first, .TableCell-first + .TableCell { background-color: var(--p-color-bg-subdued); + #{$se23} & { color: var(--p-color-text-subdued); } } } - &.TableRow-hovered { + &.TableRow-subheader { + cursor: default; + // stylelint-disable-next-line selector-max-combinators, selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + color: var(--p-color-text-subdued); + font-weight: var(--p-font-weight-medium); + font-size: var(--p-font-size-75); + background-color: var(--p-color-bg-subdued); + border-top: var(--p-border-width-1) solid var(--p-color-border); + border-bottom: var(--p-border-width-1) solid var(--p-color-border); + } + + #{$se23} & { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-compound-selectors -- generated by polaris-migrator DO NOT COPY */ + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + border-color: var(--p-color-border); + } + } + } + + &.TableRow-hovered:not(.TableRow-disabled) { // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY &, .TableCell-first, .TableCell-first + .TableCell { background-color: var(--p-color-bg-hover); } + + // stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY + &.TableRow-subheader { + // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + + #{$se23} & { + background-color: var(--p-color-bg-subdued); + } + } + } } &.TableRow-selected { @@ -248,11 +297,27 @@ $loading-panel-height: 53px; background-color: var(--p-color-bg-primary-subdued-selected); } } + + /* stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY */ + &.TableRow-subheader { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + + #{$se23} & { + background-color: var(--p-color-bg-subdued); + } + } + } } // stylelint-disable-next-line no-duplicate-selectors -- se23 temporary styles #{$se23} & { - &.TableRow-hovered { + &.TableRow-hovered:not(.TableRow-disabled) { // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-compound-selectors -- se23 temporary styles &, .TableCell-first, @@ -299,7 +364,7 @@ $loading-panel-height: 53px; } } - // stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY + /* stylelint-disable-next-line selector-max-class -- generated by polaris-migrator DO NOT COPY */ &.TableRow-hovered.TableRow-selected { // stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY &, @@ -313,6 +378,18 @@ $loading-panel-height: 53px; background-color: var(--p-color-bg-primary-subdued-hover); } } + + /* stylelint-disable-next-line selector-max-class, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ + &.TableRow-subheader { + /* stylelint-disable-next-line selector-max-class, selector-max-combinators, selector-max-specificity -- generated by polaris-migrator DO NOT COPY */ + &, + .TableCell:first-child, + .TableCell-first, + .TableCell-first + .TableCell, + .TableCell:last-child { + background-color: var(--p-color-bg-subdued); + } + } } } @@ -757,7 +834,17 @@ $loading-panel-height: 53px; } } } + #{$se23} & { + /* stylelint-disable-next-line selector-max-combinators -- generated by polaris-migrator DO NOT COPY */ + .TableCell, + .TableHeading { + /* stylelint-disable-next-line selector-max-combinators -- generated by polaris-migrator DO NOT COPY */ + &:first-child { + padding-left: var(--p-space-3); + } + } + // This is to bust specificity with Table-unselectable and TableCel:first-child bg color above. // stylelint-disable-next-line selector-max-combinators -- se23 override .TableRow-subdued:not(.TableRow-hovered) { @@ -838,6 +925,7 @@ $loading-panel-height: 53px; .statusSubdued { .TableCell:last-child { background-color: var(--p-color-bg-subdued); + #{$se23} & { color: var(--p-color-text-subdued); } @@ -860,11 +948,11 @@ $loading-panel-height: 53px; } } - #{$se23} & .TableRow-selected.TableRow-subdued { - color: var(--p-color-text-subdued); - } - #{$se23} & { + .TableRow-selected.TableRow-subdued { + color: var(--p-color-text-subdued); + } + .TableRow-hovered { // stylelint-disable-next-line selector-max-compound-selectors -- se23 temporary styles .TableCell:last-child { diff --git a/polaris-react/src/components/IndexTable/IndexTable.stories.tsx b/polaris-react/src/components/IndexTable/IndexTable.stories.tsx index e170da57088..e0807467670 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.stories.tsx +++ b/polaris-react/src/components/IndexTable/IndexTable.stories.tsx @@ -1,19 +1,26 @@ -import React, {useCallback, useState} from 'react'; +import React, {Fragment, useCallback, useState} from 'react'; import type {ComponentMeta} from '@storybook/react'; -import type {IndexFiltersProps} from '@shopify/polaris'; +import type { + IndexFiltersProps, + IndexTableProps, + IndexTableRowProps, +} from '@shopify/polaris'; import { + Icon, + HorizontalStack, Button, LegacyCard, EmptySearchResult, IndexFilters, useSetIndexFiltersMode, - IndexTable, Link, TextField, Text, useIndexResourceState, } from '@shopify/polaris'; +import {IndexTable} from './IndexTable'; + export default { component: IndexTable, } as ComponentMeta; @@ -1400,7 +1407,7 @@ export function WithFiltering() { (value) => setTaggedWith(value), [], ); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleClearAll = useCallback(() => { handleTaggedWithRemove(); @@ -1435,7 +1442,7 @@ export function WithFiltering() { ] : []; - const sortOptions = [ + const sortOptions: IndexFiltersProps['sortOptions'] = [ {label: 'Date', value: 'today asc', directionLabel: 'Ascending'}, {label: 'Date', value: 'today desc', directionLabel: 'Descending'}, ]; @@ -1613,7 +1620,7 @@ export function WithRowStatus() { key={id} selected={selectedResources.includes(id)} position={index} - status={status} + status={status as IndexTableRowProps['status']} > @@ -1959,7 +1966,7 @@ export function WithClickableButtonColumn() { position={index} > - + {location} @@ -2171,7 +2178,7 @@ export function WithAllOfItsElements() { (value) => setTaggedWith(value), [], ); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleClearAll = useCallback(() => { handleTaggedWithRemove(); @@ -2227,7 +2234,7 @@ export function WithAllOfItsElements() { ] : []; - const sortOptions = [ + const sortOptions: IndexFiltersProps['sortOptions'] = [ {label: 'Date', value: 'today asc', directionLabel: 'Ascending'}, {label: 'Date', value: 'today desc', directionLabel: 'Descending'}, ]; @@ -2359,7 +2366,8 @@ export function WithAllOfItsElements() { export function WithSortableHeadings() { const [sortIndex, setSortIndex] = useState(0); - const [sortDirection, setSortDirection] = useState('descending'); + const [sortDirection, setSortDirection] = + useState('descending'); const sortToggleLabels = { 0: {ascending: 'A-Z', descending: 'Z-A'}, @@ -2544,7 +2552,8 @@ export function WithSortableHeadings() { export function WithSortableCustomHeadings() { const [sortIndex, setSortIndex] = useState(0); - const [sortDirection, setSortDirection] = useState('descending'); + const [sortDirection, setSortDirection] = + useState('descending'); const sortToggleLabels = { 0: {ascending: 'A-Z', descending: 'Z-A'}, @@ -2849,6 +2858,116 @@ export function WithCustomTooltips() { ); } +export function WithHeadingTooltips() { + const customers = [ + { + id: '3410', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$2,400', + }, + { + id: '3411', + url: '#', + name: 'Joe Jemison', + location: 'Sydney, AU', + orders: 20, + amountSpent: '$1,400', + }, + { + id: '3412', + url: '#', + name: 'Sam Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$400', + }, + { + id: '3413', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$4,300', + }, + { + id: '2563', + url: '#', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + orders: 30, + amountSpent: '$140', + }, + ]; + const resourceName = { + singular: 'customer', + plural: 'customers', + }; + + const {selectedResources, allResourcesSelected, handleSelectionChange} = + useIndexResourceState(customers); + + const rowMarkup = customers.map( + ({id, name, location, orders, amountSpent}, index) => ( + + + + {name} + + + {location} + + + {orders} + + + + + {amountSpent} + + + + ), + ); + + return ( + + + {rowMarkup} + + + ); +} + export function WithZebraStriping() { const customers = [ { @@ -3106,7 +3225,7 @@ export function WithZebraStripingAndRowStatus() { key={id} selected={selectedResources.includes(id)} position={index} - status={status} + status={status as IndexTableRowProps['status']} > @@ -3408,7 +3527,7 @@ export function SmallScreenWithAllOfItsElements() { (value) => setTaggedWith(value), [], ); - const handleTaggedWithRemove = useCallback(() => setTaggedWith(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleClearAll = useCallback(() => { handleTaggedWithRemove(); @@ -3464,7 +3583,7 @@ export function SmallScreenWithAllOfItsElements() { ] : []; - const sortOptions = [ + const sortOptions: IndexFiltersProps['sortOptions'] = [ {label: 'Date', value: 'today asc', directionLabel: 'Ascending'}, {label: 'Date', value: 'today desc', directionLabel: 'Descending'}, ]; @@ -3591,109 +3710,208 @@ export function SmallScreenWithAllOfItsElements() { } } -export function WithHeadingTooltips() { - const customers = [ +export function WithSubHeaders() { + const rows = [ { - id: '3410', + id: '3411', url: '#', name: 'Mae Jemison', location: 'Decatur, USA', - orders: 20, + orders: 11, amountSpent: '$2,400', + lastOrderDate: 'May 31, 2023', }, { - id: '3411', + id: '2562', url: '#', - name: 'Joe Jemison', - location: 'Sydney, AU', - orders: 20, - amountSpent: '$1,400', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + orders: 30, + amountSpent: '$975', + lastOrderDate: 'May 31, 2023', }, { - id: '3412', + id: '4102', url: '#', - name: 'Sam Jemison', - location: 'Decatur, USA', - orders: 20, - amountSpent: '$400', + name: 'Colm Dillane', + location: 'New York, USA', + orders: 27, + amountSpent: '$2885', + lastOrderDate: 'May 31, 2023', }, { - id: '3413', + id: '2564', url: '#', - name: 'Mae Jemison', - location: 'Decatur, USA', - orders: 20, - amountSpent: '$4,300', + name: 'Al Chemist', + location: 'New York, USA', + orders: 19, + amountSpent: '$1,209', + lastOrderDate: 'April 4, 2023', + disabled: true, }, { id: '2563', url: '#', - name: 'Ellen Ochoa', - location: 'Los Angeles, USA', - orders: 30, - amountSpent: '$140', + name: 'Larry June', + location: 'San Francisco, USA', + orders: 22, + amountSpent: '$1,400', + lastOrderDate: 'March 19, 2023', }, ]; + + const columnHeadings = [ + {title: 'Name', id: 'column-header--name'}, + {title: 'Location', id: 'column-header--location'}, + { + alignment: 'end', + id: 'column-header--order-count', + title: 'Order count', + }, + { + alignment: 'end', + hidden: false, + id: 'column-header--amount-spent', + title: 'Amount spent', + }, + ]; + + const groupRowsBy = (groupKey: string, resolveId: (groupVal) => string) => { + let position = -1; + const groups = rows.reduce((groups, customer) => { + const groupVal = customer[groupKey]; + if (!groups[groupVal]) { + position += 1; + + groups[groupVal] = { + position, + customers: [], + id: resolveId(groupVal), + }; + } + + groups[groupVal].customers.push({ + ...customer, + position: position + 1, + }); + + position += 1; + return groups; + }, {}); + + return groups; + }; + const resourceName = { singular: 'customer', plural: 'customers', }; const {selectedResources, allResourcesSelected, handleSelectionChange} = - useIndexResourceState(customers); + useIndexResourceState(rows, {resourceFilter: ({disabled}) => !disabled}); - const rowMarkup = customers.map( - ({id, name, location, orders, amountSpent}, index) => ( - - - - {name} - - - {location} - - - {orders} - - - - - {amountSpent} - - - - ), + const orders = groupRowsBy( + 'lastOrderDate', + (date) => `last-order-date--${date.replace(',', '').split(' ').join('-')}`, ); + const rowMarkup = Object.keys(orders).map((orderDate, index) => { + const {customers, position, id: subheaderId} = orders[orderDate]; + let selected: IndexTableRowProps['selected'] = false; + + const someCustomersSelected = customers.some(({id}) => + selectedResources.includes(id), + ); + + const allCustomersSelected = customers.every(({id}) => + selectedResources.includes(id), + ); + + if (allCustomersSelected) { + selected = true; + } else if (someCustomersSelected) { + selected = 'indeterminate'; + } + + const selectableRows = rows.filter(({disabled}) => !disabled); + const rowRange: IndexTableRowProps['subHeaderRange'] = [ + selectableRows.findIndex((row) => row.id === customers[0].id), + selectableRows.findIndex( + (row) => row.id === customers[customers.length - 1].id, + ), + ]; + + const disabled = customers.every(({disabled}) => disabled); + + return ( + + + + {`Last order placed: ${orderDate}`} + + + + + + {customers.map( + ( + {id, name, location, orders, amountSpent, position, disabled}, + rowIndex, + ) => { + return ( + + + + {name} + + + {location} + + + {orders} + + + + + {amountSpent} + + + + ); + }, + )} + + ); + }); + return ( {rowMarkup} diff --git a/polaris-react/src/components/IndexTable/IndexTable.tsx b/polaris-react/src/components/IndexTable/IndexTable.tsx index a414023a328..e388b2d1897 100644 --- a/polaris-react/src/components/IndexTable/IndexTable.tsx +++ b/polaris-react/src/components/IndexTable/IndexTable.tsx @@ -45,6 +45,7 @@ import {ScrollContainer, Cell, Row} from './components'; import styles from './IndexTable.scss'; interface IndexTableHeadingBase { + id?: string; /** * Adjust horizontal alignment of header content. * @default 'start' @@ -65,6 +66,7 @@ interface IndexTableHeadingBase { interface IndexTableHeadingTitleString extends IndexTableHeadingBase { title: string; + id?: string; } interface IndexTableHeadingTitleNode extends IndexTableHeadingBase { @@ -841,6 +843,7 @@ function IndexTableBase({ const headingContent = ( { }; function getHeadingKey(heading: IndexTableHeading): string { - if ('id' in heading && heading.id) { + if (heading.id) { return heading.id; - } - - if (typeof heading.title === 'string') { + } else if (typeof heading.title === 'string') { return heading.title; } diff --git a/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx b/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx index 0ef0604da6d..b181e700fc7 100644 --- a/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx +++ b/polaris-react/src/components/IndexTable/components/Cell/Cell.tsx @@ -5,21 +5,47 @@ import {classNames} from '../../../../utilities/css'; import styles from '../../IndexTable.scss'; export interface CellProps { + /** The table cell element to render. Render the cell as a `th` if it serves as a subheading + * @default 'td' + */ + as?: 'th' | 'td'; + /** The unique ID to set on the cell element */ + id?: string; + /** The cell contents */ children?: ReactNode; + /** Custom class name to apply to the cell element */ className?: string; + /** Whether the cell padding should be removed + * @default false + */ flush?: boolean; + /** For subheader cells -- The number of the columns that the cell element should extend to */ + colSpan?: HTMLTableCellElement['colSpan']; + /** For subheader cells -- Indicates the cells that the `th` element relates to */ + scope?: HTMLTableCellElement['scope']; + /** A space-separated list of the `th` cell IDs that describe or apply to it. Use for cells within a row that relate to a subheader cell in addition to their column header. */ + headers?: HTMLTableCellElement['headers']; } export const Cell = memo(function Cell({ children, - className, + className: customClassName, flush, + colSpan, + headers, + scope, + as = 'td', + id, }: CellProps) { - const cellClassName = classNames( - className, + const className = classNames( + customClassName, styles.TableCell, flush && styles['TableCell-flush'], ); - return {children}; + return React.createElement( + as, + {id, colSpan, headers, scope, className}, + children, + ); }); diff --git a/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx b/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx index c9afc3862ad..b3ef040488b 100644 --- a/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx +++ b/polaris-react/src/components/IndexTable/components/Cell/tests/Cell.test.tsx @@ -11,6 +11,38 @@ describe('', () => { expect(cell).toContainReactComponent('td'); }); + it('renders a th element if set on `as` prop', () => { + const cell = mountWithTable(); + + expect(cell).toContainReactComponent('th'); + }); + + it('renders a td element if `as` prop is not set', () => { + const cell = mountWithTable(); + + expect(cell).toContainReactComponent('td'); + }); + + it('forwards the `colSpan` prop', () => { + const cell = mountWithTable(); + + expect(cell.find('td')).toHaveReactProps({colSpan: 3}); + }); + + it('forwards the `scope` prop', () => { + const cell = mountWithTable(); + + expect(cell.find('td')).toHaveReactProps({scope: 'colgroup'}); + }); + + it('forwards the `headers` prop', () => { + const cell = mountWithTable(); + + expect(cell.find('td')).toHaveReactProps({ + headers: 'last-order-date name', + }); + }); + it('applies flushed styles when flush prop is true', () => { const cell = mountWithTable(); diff --git a/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx b/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx index dff6974846e..103e8bf1eed 100644 --- a/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx +++ b/polaris-react/src/components/IndexTable/components/Checkbox/Checkbox.tsx @@ -12,12 +12,23 @@ import sharedStyles from '../../IndexTable.scss'; import styles from './Checkbox.scss'; -export const Checkbox = memo(function Checkbox() { +interface CheckboxProps { + accessibilityLabel?: string; +} + +export const Checkbox = memo(function Checkbox({ + accessibilityLabel, +}: CheckboxProps) { const i18n = useI18n(); const {resourceName} = useIndexValue(); const {itemId, selected, disabled, onInteraction} = useContext(RowContext); const wrapperClassName = classNames(styles.Wrapper); + const label = accessibilityLabel + ? accessibilityLabel + : i18n.translate('Polaris.IndexTable.selectItem', { + resourceName: resourceName.singular, + }); return ( @@ -28,10 +39,8 @@ export const Checkbox = memo(function Checkbox() { onKeyUp={noop} > ', () => { const id = 'id'; const checkbox = mountWithTable(, {rowProps: {id}}); - expect(checkbox).toContainReactComponent(PolarisCheckbox, {id}); + expect(checkbox).toContainReactComponent(PolarisCheckbox, { + id: `Select-${id}`, + }); }); it('renders a Checkbox with a label', () => { @@ -55,6 +57,21 @@ describe('', () => { }); }); + it('renders a Checkbox with a custom label', () => { + const resourceName = {singular: 'Singular', plural: 'Plural'}; + const accessibilityLabel = `Select ${resourceName.singular} who ordered yesterday`; + const checkbox = mountWithTable( + , + { + indexProps: {resourceName}, + }, + ); + + expect(checkbox).toContainReactComponent(PolarisCheckbox, { + label: accessibilityLabel, + }); + }); + it('renders a Checkbox with a label hidden set to true', () => { const checkbox = mountWithTable(); diff --git a/polaris-react/src/components/IndexTable/components/Row/Row.tsx b/polaris-react/src/components/IndexTable/components/Row/Row.tsx index 2a28f9120fc..2b94d6aafc8 100644 --- a/polaris-react/src/components/IndexTable/components/Row/Row.tsx +++ b/polaris-react/src/components/IndexTable/components/Row/Row.tsx @@ -9,20 +9,41 @@ import { import {Checkbox} from '../Checkbox'; import {classNames, variationName} from '../../../../utilities/css'; import {RowContext, RowHoveredContext} from '../../../../utilities/index-table'; +import type {Range} from '../../../../utilities/index-provider/types'; import styles from '../../IndexTable.scss'; +type RowType = 'data' | 'subheader'; type RowStatus = 'success' | 'subdued' | 'critical'; type TableRowElementType = HTMLTableRowElement & HTMLLIElement; export interface RowProps { + /** Table header or data cells */ children: React.ReactNode; + /** A unique identifier for the row */ id: string; - selected?: boolean; + /** Whether the row is selected */ + selected?: boolean | 'indeterminate'; + /** The zero-indexed position of the row. Used for Shift key multi-selection */ position: number; + /** Whether the row should be subdued */ subdued?: boolean; + /** Whether the row should have a status */ status?: RowStatus; + /** Whether the row should be disabled */ disabled?: boolean; + /** A tuple array with the first and last index of the range of other rows that this row describes. All rows in the range are selected when the selection range row is selected. */ + selectionRange?: Range; + /** + * Indicates the relationship or role of the row's contents. A "subheader" row displays the same as the table header. + * @default 'data' */ + rowType?: RowType; + /** Label set on the row's checkbox + * @default "Select {resourceName}" + */ + accessibilityLabel?: string; + /** Callback fired when the row is clicked and contains a data-primary-link */ onNavigation?(id: string): void; + /** Callback fired when the row is clicked. Overrides the default click behaviour. */ onClick?(): void; } @@ -34,6 +55,9 @@ export const Row = memo(function Row({ subdued, status, disabled, + selectionRange, + rowType = 'data', + accessibilityLabel, onNavigation, onClick, }: RowProps) { @@ -48,15 +72,20 @@ export const Row = memo(function Row({ const handleInteraction = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { event.stopPropagation(); + let selectionType = SelectionType.Single; if (('key' in event && event.key !== ' ') || !onSelectionChange) return; - const selectionType = event.nativeEvent.shiftKey - ? SelectionType.Multi - : SelectionType.Single; - onSelectionChange(selectionType, !selected, id, position); + if (event.nativeEvent.shiftKey) { + selectionType = SelectionType.Multi; + } else if (selectionRange) { + selectionType = SelectionType.Range; + } + + const selection: string | Range = selectionRange ?? id; + onSelectionChange(selectionType, !selected, selection, position); }, - [id, onSelectionChange, position, selected], + [id, onSelectionChange, selected, selectionRange, position], ); const contextValue = useMemo( @@ -86,6 +115,7 @@ export const Row = memo(function Row({ const rowClassName = classNames( styles.TableRow, + rowType === 'subheader' && styles['TableRow-subheader'], selectable && condensed && styles.condensedRow, selected && styles['TableRow-selected'], subdued && styles['TableRow-subdued'], @@ -101,6 +131,8 @@ export const Row = memo(function Row({ if ((!disabled && selectable) || primaryLinkElement.current) { handleRowClick = (event: React.MouseEvent) => { + if (rowType === 'subheader') return; + if (!tableRowRef.current || isNavigating.current) { return; } @@ -140,14 +172,16 @@ export const Row = memo(function Row({ } const RowWrapper = condensed ? 'li' : 'tr'; - - const checkboxMarkup = selectable ? : null; + const checkboxMarkup = selectable ? ( + + ) : null; return ( ', () => { expect(row).toContainReactComponent(RowHoveredContext.Provider); }); + it('applies the styles.TableRow class to the table row element', () => { + const row = mountWithTable( + + + , + ); + + expect(row.find(Row)?.find('tr')?.prop('className')).toContain( + styles.TableRow, + ); + }); + + describe('rowType', () => { + describe('when a `rowType` of `subheader` is set', () => { + it('applies the .TableRow-subheader class to the table row element', () => { + const row = mountWithTable( + + + , + ); + + expect(row.find(Row)?.find('tr')?.prop('className')).toContain( + styles['TableRow-subheader'], + ); + }); + + it('calls onSelectionChange with the `selectionRange` when present and the row checkbox cell is clicked', () => { + const onSelectionChangeSpy = jest.fn(); + const range: Range = [0, 1]; + const row = mountWithTable( + + + Child without data-primary-link + + , + { + indexTableProps: { + itemCount: 50, + selectedItemsCount: 0, + onSelectionChange: onSelectionChangeSpy, + }, + }, + ); + + row.find('div', {className: 'Wrapper'})!.triggerKeypath('onClick', { + stopPropagation: noop, + key: ' ', + nativeEvent: {}, + }); + + expect(onSelectionChangeSpy).toHaveBeenCalledTimes(1); + expect(onSelectionChangeSpy).toHaveBeenCalledWith( + SelectionType.Range, + true, + range, + ); + }); + + it('does not call onSelectionChange with the `selectionRange` when present and the row is clicked', () => { + const onSelectionChangeSpy = jest.fn(); + const range: Range = [0, 1]; + const row = mountWithTable( + + + Child without data-primary-link + + , + { + indexTableProps: { + itemCount: 50, + selectedItemsCount: 0, + onSelectionChange: onSelectionChangeSpy, + }, + }, + ); + + triggerOnClick(row, 1, defaultEvent); + + expect(onSelectionChangeSpy).not.toHaveBeenCalled(); + }); + }); + }); + + it('allows the checkbox to be indeterminate', () => { + const row = mountWithTable( + + + , + ); + + expect(row.find(Row)?.find(PolarisCheckbox)?.prop('checked')).toBe( + 'indeterminate', + ); + }); + it(`dispatches a mouse event when the row is clicked and selectMode is false`, () => { const spy = jest.fn(); const row = mountWithTable( diff --git a/polaris-react/src/index.ts b/polaris-react/src/index.ts index 0fdb30948db..c687178a38a 100644 --- a/polaris-react/src/index.ts +++ b/polaris-react/src/index.ts @@ -199,7 +199,10 @@ export type { } from './components/IndexFilters'; export {IndexTable} from './components/IndexTable'; -export type {IndexTableProps} from './components/IndexTable'; +export type { + IndexTableProps, + RowProps as IndexTableRowProps, +} from './components/IndexTable'; export {Indicator} from './components/Indicator'; export type {IndicatorProps} from './components/Indicator'; diff --git a/polaris-react/src/utilities/index-provider/hooks.ts b/polaris-react/src/utilities/index-provider/hooks.ts index 5ae3d9d0f01..90d546b25ea 100644 --- a/polaris-react/src/utilities/index-provider/hooks.ts +++ b/polaris-react/src/utilities/index-provider/hooks.ts @@ -179,6 +179,8 @@ export function useHandleBulkSelection({ selectionType === SelectionType.All ) { onSelectionChange(selectionType, toggleType); + } else if (selectionType === SelectionType.Range) { + onSelectionChange(SelectionType.Range, toggleType, selection); } }, [onSelectionChange], diff --git a/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx b/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx index de56b51a30c..d7958a0c190 100644 --- a/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx +++ b/polaris-react/src/utilities/index-provider/tests/hooks.test.tsx @@ -215,7 +215,7 @@ describe('useHandleBulkSelection', () => { return ; } - it('selects ranges', () => { + it('selects ranges with shift key selection', () => { const onSelectionChangeSpy = jest.fn(); const mockComponent = mount( , @@ -246,4 +246,21 @@ describe('useHandleBulkSelection', () => { [3, 4], ); }); + + it('selects ranges with subheader selection', () => { + const onSelectionChangeSpy = jest.fn(); + const mockComponent = mount( + , + ); + + const typedChild = mockComponent.find(TypedChild)!; + + typedChild.trigger('onSelectionChange', SelectionType.Range, true, [1, 3]); + + expect(onSelectionChangeSpy).toHaveBeenLastCalledWith( + SelectionType.Range, + true, + [1, 3], + ); + }); }); diff --git a/polaris-react/src/utilities/index-provider/types.ts b/polaris-react/src/utilities/index-provider/types.ts index 43a9087cb46..093d847ed51 100644 --- a/polaris-react/src/utilities/index-provider/types.ts +++ b/polaris-react/src/utilities/index-provider/types.ts @@ -7,6 +7,7 @@ export enum SelectionType { Page = 'page', Multi = 'multi', Single = 'single', + Range = 'range', } export type Range = [number, number]; @@ -27,6 +28,7 @@ export interface IndexProviderProps { selectionType: SelectionType, toggleType: boolean, selection?: string | Range, + position?: number, ): void; } diff --git a/polaris-react/src/utilities/index-table/context.ts b/polaris-react/src/utilities/index-table/context.ts index 425ae65f874..0bb4515dd1e 100644 --- a/polaris-react/src/utilities/index-table/context.ts +++ b/polaris-react/src/utilities/index-table/context.ts @@ -2,7 +2,7 @@ import {createContext} from 'react'; interface RowContextType { itemId?: string; - selected?: boolean; + selected?: boolean | 'indeterminate'; disabled?: boolean; position?: number; onInteraction?: (event: React.MouseEvent | React.KeyboardEvent) => void; diff --git a/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx b/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx index ccbc04c489c..fd25d7847c1 100644 --- a/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx +++ b/polaris-react/src/utilities/tests/use-index-resource-state.test.tsx @@ -496,6 +496,92 @@ describe('useIndexResourceState', () => { }); }); }); + + describe('SelectionType.Range', () => { + describe('with a custom resource filter', () => { + it('only selects resources that match the filter', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const customResoureFilter = (item: typeof resources[0]) => { + return item.id !== idOne; + }; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, true, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [idTwo, idThree], + }); + }); + }); + + it('selects all resources within range when none are selected', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, true, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [idOne, idTwo, idThree], + }); + }); + + it('selects all resources within range when some are selected', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const options = {selectedResources: [idOne]}; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, true, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [idOne, idTwo, idThree], + }); + }); + + it('deselects all resources within range when all are selected', () => { + const idOne = '1'; + const idTwo = '2'; + const idThree = '3'; + const resources = [{id: idOne}, {id: idTwo}, {id: idThree}]; + const mockComponent = mountWithApp( + , + ); + + mockComponent + .find(TypedChild)! + .trigger('onClick', SelectionType.Range, false, [0, 2]); + + expect(mockComponent).toContainReactComponent(TypedChild, { + selectedResources: [], + }); + }); + }); }); describe('clearSelection', () => { diff --git a/polaris-react/src/utilities/use-index-resource-state.ts b/polaris-react/src/utilities/use-index-resource-state.ts index 361fbffec30..2c3eb1463b6 100644 --- a/polaris-react/src/utilities/use-index-resource-state.ts +++ b/polaris-react/src/utilities/use-index-resource-state.ts @@ -5,6 +5,7 @@ export enum SelectionType { Page = 'page', Multi = 'multi', Single = 'single', + Range = 'range', } type Range = [number, number]; type ResourceIDResolver = ( @@ -32,7 +33,7 @@ export function useIndexResourceState( selectedResources?: string[]; allResourcesSelected?: boolean; resourceIDResolver?: ResourceIDResolver; - resourceFilter?: (value: T) => boolean; + resourceFilter?: (value: T, index: number) => boolean; } = { selectedResources: [], allResourcesSelected: false, @@ -85,18 +86,22 @@ export function useIndexResourceState( break; case SelectionType.Multi: if (!selection) break; - setSelectedResources((newSelectedResources) => { + setSelectedResources((currentSelectedResources) => { const ids: string[] = []; const filteredResources = resourceFilter ? resources.filter(resourceFilter) : resources; - for (let i = selection[0] as number; i <= selection[1]; i++) { + for ( + let i = selection[0] as number; + i <= (selection[1] as number); + i++ + ) { if (filteredResources.includes(resources[i])) { const id = resourceIDResolver(resources[i]); if ( - (isSelecting && !newSelectedResources.includes(id)) || - (!isSelecting && newSelectedResources.includes(id)) + (isSelecting && !currentSelectedResources.includes(id)) || + (!isSelecting && currentSelectedResources.includes(id)) ) { ids.push(id); } @@ -104,8 +109,49 @@ export function useIndexResourceState( } return isSelecting - ? [...newSelectedResources, ...ids] - : newSelectedResources.filter((id) => !ids.includes(id)); + ? [...currentSelectedResources, ...ids] + : currentSelectedResources.filter((id) => !ids.includes(id)); + }); + + break; + case SelectionType.Range: + if (!selection) break; + + setSelectedResources((currentSelectedResources) => { + const filteredResources = resourceFilter + ? resources.filter(resourceFilter) + : resources; + + const resourceIds = filteredResources.map(resourceIDResolver); + + const selectedIds = resourceIds.slice( + Number(selection[0]), + Number(selection[1]) + 1, + ); + + const isIndeterminate = selectedIds.some((id) => { + return selectedResources.includes(id); + }); + + const isChecked = selectedIds.every((id) => { + return selectedResources.includes(id); + }); + + const isSelectingAllInRange = + !isChecked && (isSelecting || isIndeterminate); + + const nextSelectedResources = isSelectingAllInRange + ? [ + ...new Set([ + ...currentSelectedResources, + ...selectedIds, + ]).values(), + ] + : currentSelectedResources.filter( + (id) => !selectedIds.includes(id), + ); + + return nextSelectedResources; }); break; } @@ -113,7 +159,7 @@ export function useIndexResourceState( [ allResourcesSelected, resourceFilter, - selectedResources.length, + selectedResources, resources, resourceIDResolver, ], diff --git a/polaris.shopify.com/content/components/lists/resource-list.md b/polaris.shopify.com/content/components/lists/resource-list.md index 8c74e9bf3c1..feea48ffa05 100644 --- a/polaris.shopify.com/content/components/lists/resource-list.md +++ b/polaris.shopify.com/content/components/lists/resource-list.md @@ -181,5 +181,5 @@ Resource lists should: ## Related components -- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/data-table) +- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/tables/data-table) - To display a simple list of related content, [use the list component](https://polaris.shopify.com/components/lists/list) diff --git a/polaris.shopify.com/content/components/tables/index-table.md b/polaris.shopify.com/content/components/tables/index-table.md index 7a948d317ab..005170fc8cf 100644 --- a/polaris.shopify.com/content/components/tables/index-table.md +++ b/polaris.shopify.com/content/components/tables/index-table.md @@ -70,6 +70,9 @@ examples: - fileName: index-table-without-checkboxes.tsx title: Without checkboxes description: An index table without checkboxes and bulk actions. + - fileName: index-table-with-subheaders.tsx + title: With subheaders + description: An index table with multiple table headers. Use to present merchants with resources grouped by a relevant data value to enable faster bulk selection. --- Index tables can also: @@ -86,19 +89,19 @@ Index tables can also: Using an index table in a project involves combining the following components and subcomponents: - IndexTable -- [IndexTableRow](#index-table-row) -- [IndexTableCell](#index-table-cell) +- [IndexTable.Row](#index-table-row) +- [IndexTable.Cell](#index-table-cell) - [Filters](/components/selection-and-input/filters) (optional) - [IndexFilters](/components/selection-and-input/index-filters) (optional) -- Pagination component (optional) +- [Pagination](/components/navigation/pagination) (optional) -The index table component provides the UI elements for list sorting, filtering, and pagination, but doesn’t provide the logic for these operations. When a sort option is changed, filter added, or second page requested, you’ll need to handle that event (including any network requests) and then update the component with new props. +The index table component provides the UI elements for list selection, sorting, filtering, and pagination, but doesn’t provide the logic for these operations. When a sort option is changed, filter added, or second page requested, you’ll need to handle that event (including any network requests) and then update the component with new props. --- ## Purpose -Shopify is organized around objects that represent merchants businesses, like customers, products, and orders. Each individual order, for example, is given a dedicated page that can be linked to. In Shopify, we call these types of objects _resources_, and we call the object’s dedicated page its _details page_. +Shopify is organized around objects that represent merchants' businesses, like customers, products, and orders. Each individual order, for example, is given a dedicated page that can be linked to. In Shopify, we call these types of objects _resources_, and we call the object’s dedicated page its _details page_. ### Problem @@ -174,37 +177,71 @@ Index tables should: --- -## IndexTableRow +## IndexTable.Row -An `IndexTableRow` is used to render a row representing an item within an `IndexTable` +An `IndexTable.Row` is used to render a row representing an item within an `IndexTable` -### IndexTableRow properties +### IndexTable.Row properties -| Prop | Type | Description | -| -------- | ---------- | ---------------------------------------------------------------- | -| id | string | A unique identifier for the row | -| selected | boolean | A boolean property indicating whether the row is selected | -| position | number | The index position of the row | -| subdued | boolean | A boolean property indicating whether the row should be subdued | -| status | RowStatus | A property indicating whether the row should have a status | -| disabled | boolean | A boolean property indicating whether the row should be disabled | -| onClick | () => void | A function which overrides the default click behaviour | +| Prop | Type | Description | +| ------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| children | ReactNode | Table header or data cells | +| id | string | A unique identifier for the row | +| selected? | boolean | "indeterminate" | A boolean property indicating whether the row or it's related rows are selected | +| position | number | The zero-indexed position of the row. Used for Shift key multi-selection as well as selection of a range of rows when a `selectionRange` is set. | +| subdued? | boolean | Whether the row should be subdued | +| status? | "success" | "subdued" | "critical" | Whether the row should have a status | +| disabled? | boolean | Whether the row should be disabled | +| selectionRange? | [number, number] | A tuple array with the first and last index of the range of other rows that the row describes. All non-disabled rows in the range are selected when the row with a selection range set is selected. | +| rowType? | "data" | "subheader" | Indicates the relationship or role of the row's contents. A `rowType` of "subheader" looks and behaves the same as the table header. Defaults to "data". | +| accessibilityLabel? | string | Label set on the row's checkbox. Defaults to "Select {resourceName}" | +| onClick? | () => void | Callback fired when the row is clicked. Overrides the default click behaviour. | +| onNavigation? | (id: string) => void | Callback fired when the row is clicked and contains an anchor element with the `data-primary-link` property set | -## IndexTableCell +## IndexTable.Cell -An `IndexTableCell` is used to render a single cell within an `IndexTableRow` +An `IndexTable.Cell` is used to render a single cell within an `IndexTable.Row` -### IndexTableCell properties +### IndexTable.Cell properties -| Prop | Type | Description | -| --------- | ------- | -------------------------------------------------------------------------------- | -| flush | boolean | A boolean property indicating whether the cell should remove the default padding | -| className | string | Adds a class to the cell, used for setting widths of a cell | +| Prop | Type | Description | +| ---------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| as? | 'th' | 'td' | The table cell element to render. Render the cell as a `th` if it serves as a subheading. Defaults to `td`. | +| id? | string | The unique ID to set on the cell element | +| children? | ReactNode | The cell contents | +| className? | string | Adds a class to the cell. Use to set a custom cell width. | +| flush? | boolean | Whether the cell padding should be removed. Defaults to false. | +| colSpan? | [HTMLTableCellElement['colSpan']](https://www.w3schools.com/tags/att_colspan.asp) | For subheader cells -- The number of the columns that the cell element should extend to within the row. | +| scope? | [HTMLTableCellElement['scope']](https://www.w3schools.com/tags/att_scope.asp) | For subheader cells -- Indicates the cells that the `th` element relates to | +| headers? | [HTMLTableCellElement['headers']](https://www.w3schools.com/tags/att_headers.asp) | A space-separated list of the `th` cell IDs that describe or apply to it. Use for cells within a row that relate to a subheader cell in addition to their column header. | --- ## Related components -- To create an actionable list of related items that link to details pages, such as a list of customers, use the [resource list component](https://polaris.shopify.com/components/resource-list) -- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/data-table) +- To create an actionable list of related items that link to details pages, such as a list of customers, use the [resource list component](https://polaris.shopify.com/components/lists/resource-list) +- To present structured data for comparison and analysis, like when helping merchants to gain insights or review analytics, use the [data table component](https://polaris.shopify.com/components/tables/data-table) - To display a simple list of related content, [use the list component](https://polaris.shopify.com/components/lists/list) + +--- + +## Accessibility + +### Structure + +The `IndexTable` is an actionable, filterable, and sortable table widget that supports row selection with [subheaders](https://www.w3.org/WAI/tutorials/tables/multi-level/). To ensure that the power of this table is accessible to all merchants when implementing `IndexTable.Row` subheaders, set the following props on `IndexTable.Cell` that are appropriate for the enhancement you are implementing. + +Merchants can select a group of rows at once by clicking or Space keypressing a subheader row's checkbox. To indicate that an `IndexTable.Row` serves as a subheader for 1 or more rows below it, set the: + +- Zero-indexed table `position` of the first and last `IndexTable.Row` described by the subheader `IndexTable.Row` as a tuple array on its `subHeaderRange` prop +- Unique `id` on the `IndexTable.Cell` that contains the subheader content +- Element tag to `"th"` on the `as` prop of the subheader `IndexTable.Cell` +- Subheader `IndexTable.Cell` `scope` prop to `"colgroup"` + +To associate the subheader `IndexTable.Row` with each `IndexTable.Cell` that it describes, set the: + +- Unique `id` provided to the subheader `IndexTable.Cell` on the `headers` prop of each related `IndexTable.Cell` (contained by an `IndexTable.Row` that's position is within the `subHeaderRange`) as well as the unique `id` of its corresponding column heading that you provided to the `IndexTable` `headings` prop + +### Keyboard support + +`IndexTable` also supports multi-selection of a range of rows by keypressing the Shift key. To select a range, press and hold the Shift key while you click or keypress the Space key on a row checkbox and then do the same on another row's checkbox. All selectable rows between the selected checkboxes will also be selected. diff --git a/polaris.shopify.com/pages/examples/index-table-with-subheaders.tsx b/polaris.shopify.com/pages/examples/index-table-with-subheaders.tsx new file mode 100644 index 00000000000..bc5c3e571d7 --- /dev/null +++ b/polaris.shopify.com/pages/examples/index-table-with-subheaders.tsx @@ -0,0 +1,242 @@ +import { + LegacyCard, + Text, + useIndexResourceState, + IndexTable, +} from '@shopify/polaris'; +import type {IndexTableRowProps, IndexTableProps} from '@shopify/polaris'; +import React, {Fragment} from 'react'; +import {withPolarisExample} from '../../src/components/PolarisExampleWrapper'; + +export function WithSubHeadersExample() { + interface Customer { + id: string; + url: string; + name: string; + location: string; + orders: number; + amountSpent: string; + lastOrderDate: string; + disabled?: boolean; + } + + interface CustomerRow extends Customer { + position: number; + } + + interface CustomerGroup { + id: string; + position: number; + customers: CustomerRow[]; + } + + interface Groups { + [key: string]: CustomerGroup; + } + + const rows = [ + { + id: '3411', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 11, + amountSpent: '$2,400', + lastOrderDate: 'May 31, 2023', + }, + { + id: '2562', + url: '#', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + orders: 30, + amountSpent: '$975', + lastOrderDate: 'May 31, 2023', + }, + { + id: '4102', + url: '#', + name: 'Colm Dillane', + location: 'New York, USA', + orders: 27, + amountSpent: '$2885', + lastOrderDate: 'May 31, 2023', + }, + { + id: '2564', + url: '#', + name: 'Al Chemist', + location: 'New York, USA', + orders: 19, + amountSpent: '$1,209', + lastOrderDate: 'April 4, 2023', + disabled: true, + }, + { + id: '2563', + url: '#', + name: 'Larry June', + location: 'San Francisco, USA', + orders: 22, + amountSpent: '$1,400', + lastOrderDate: 'March 19, 2023', + }, + ]; + + const columnHeadings = [ + {title: 'Name', id: 'name'}, + {title: 'Location', id: 'location'}, + { + alignment: 'end', + id: 'order-count', + title: 'Order count', + }, + { + alignment: 'end', + hidden: false, + id: 'amount-spent', + title: 'Amount spent', + }, + ]; + + const groupRowsByLastOrderDate = () => { + let position = -1; + const groups: Groups = (rows as Customer[]).reduce( + (groups: Groups, customer: Customer) => { + const {lastOrderDate} = customer; + if (!groups[lastOrderDate]) { + position += 1; + + groups[lastOrderDate] = { + position, + customers: [], + id: `order-${lastOrderDate.split(' ').join('-')}`, + }; + } + + groups[lastOrderDate].customers.push({ + ...customer, + position: position + 1, + }); + + position += 1; + return groups; + }, + {}, + ); + + return groups; + }; + + const resourceName = { + singular: 'customer', + plural: 'customers', + }; + + const {selectedResources, allResourcesSelected, handleSelectionChange} = + useIndexResourceState(rows, {resourceFilter: ({disabled}) => !disabled}); + + const orders = groupRowsByLastOrderDate(); + + const rowMarkup = Object.keys(orders).map((orderDate, index) => { + const {customers, position, id: subheaderId} = orders[orderDate]; + let selected: IndexTableRowProps['selected'] = false; + + const someCustomersSelected = customers.some(({id}) => + selectedResources.includes(id), + ); + + const allCustomersSelected = customers.every(({id}) => + selectedResources.includes(id), + ); + + if (allCustomersSelected) { + selected = true; + } else if (someCustomersSelected) { + selected = 'indeterminate'; + } + + const selectableRows = rows.filter(({disabled}) => !disabled); + const childRowRange: IndexTableRowProps['selectionRange'] = [ + selectableRows.findIndex((row) => row.id === customers[0].id), + selectableRows.findIndex( + (row) => row.id === customers[customers.length - 1].id, + ), + ]; + + return ( + + + + {`Last order placed: ${orderDate}`} + + + {customers.map( + ( + {id, name, location, orders, amountSpent, position, disabled}, + rowIndex, + ) => { + return ( + + + + {name} + + + {location} + + + {orders} + + + + + {amountSpent} + + + + ); + }, + )} + + ); + }); + + return ( + + + {rowMarkup} + + + ); +} + +export default withPolarisExample(WithSubHeadersExample);