diff --git a/UNRELEASED.md b/UNRELEASED.md index 0c8aa79085c..d5ce9938d8d 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -9,11 +9,15 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Added `id` prop to `Layout` and `Heading` for hash linking ([#4307](https://github.com/Shopify/polaris-react/pull/4307)) - Added `external` prop to `Navigation.Item` component ([#4310](https://github.com/Shopify/polaris-react/pull/4310)) - Added `ariaLabelledBy` props to `Navigation` component to allow a hidden label for accessibility ([#4343](https://github.com/Shopify/polaris-react/pull/4343)) +- Add `lastColumnSticky` prop to `IndexTable` to create a sticky last cell and optional sticky last heading on viewports larger than small ([#4150](https://github.com/Shopify/polaris-react/pull/4150)) ### Bug fixes - Fixed a bug in `Banner` where loading state wasn't getting passed to `primaryAction` ([#4338](https://github.com/Shopify/polaris-react/pull/4338)) - Fixed a bug `TextField` where Safari would render the incorrect text color ([#4344](https://github.com/Shopify/polaris-react/pull/4344)) +- Fix bug in Safari where `Button` text is gray instead of white after changing state from disabled to enabled ([#4270](https://github.com/Shopify/polaris-react/pull/4270)) +- Bring back borders on the `IndexTable` sticky cells ([#4150](https://github.com/Shopify/polaris-react/pull/4150)) +- Adjust `IndexTable` sticky z-index to avoid collisions with focused `TextField` ([#4150](https://github.com/Shopify/polaris-react/pull/4150)) ### Documentation diff --git a/src/components/IndexTable/IndexTable.scss b/src/components/IndexTable/IndexTable.scss index ca4b7dbee74..cf1800a0264 100644 --- a/src/components/IndexTable/IndexTable.scss +++ b/src/components/IndexTable/IndexTable.scss @@ -2,7 +2,7 @@ $index-table-stacking-order: ( cell: 1, - sticky-cell: 30, + sticky-cell: 31, scroll-bar: 35, bulk-actions: 36, loading-panel: 37, @@ -93,17 +93,14 @@ $loading-panel-height: rem(53px); .TableCell-first, .TableHeading-first { - box-shadow: rem(1px) rem(-1px) 0 0 var(--p-divider); - @include breakpoint-after($breakpoint-small) { - box-shadow: 0 rem(-1px) 0 0 var(--p-divider); - } + filter: drop-shadow(rem(1px) 0 0 var(--p-divider)); } // stylelint-disable-next-line selector-max-class, selector-max-combinators .TableCell-first + .TableCell, .TableHeading-second { @include breakpoint-after($breakpoint-small) { - box-shadow: rem(1px) rem(-1px) 0 0 var(--p-divider); + filter: drop-shadow(rem(1px) 0 0 var(--p-divider)); } } } @@ -111,6 +108,7 @@ $loading-panel-height: rem(53px); .TableRow { background-color: var(--p-surface); cursor: pointer; + filter: drop-shadow(0 rem(-1px) 0 var(--p-divider)); &.statusSuccess { // stylelint-disable-next-line selector-max-combinators, selector-max-class, selector-max-specificity @@ -187,17 +185,11 @@ $loading-panel-height: rem(53px); } } -.TableHeading-last { - position: sticky; - right: 0; -} - .TableCell { z-index: z-index(cell, $index-table-stacking-order); text-align: left; padding: spacing(tight) spacing(); white-space: nowrap; - box-shadow: 0 rem(-1px) 0 0 var(--p-divider); } .TableCell-flush { @@ -222,6 +214,61 @@ $loading-panel-height: rem(53px); } } +.Table-sticky-scrolling { + .TableCell:last-child, + .TableHeading-last { + @include breakpoint-after($breakpoint-small) { + filter: drop-shadow(rem(-1px) 0 0 var(--p-divider)); + } + } +} + +.Table-sticky-last { + .TableCell:last-child { + @include breakpoint-after($breakpoint-small) { + position: sticky; + right: 0; + background-color: var(--p-surface); + z-index: z-index(sticky-cell, $index-table-stacking-order); + } + } + + .TableHeading-last { + @include breakpoint-after($breakpoint-small) { + position: sticky; + right: 0; + background-color: var(--p-surface); + z-index: auto; + } + } + + // stylelint-disable selector-max-class, selector-max-combinators, selector-max-specificity + .statusSuccess { + .TableCell:last-child { + background-color: var(--p-surface-primary-selected); + } + } + + .statusSubdued { + .TableCell:last-child { + background-color: var(--p-surface-subdued); + } + } + + .TableRow-hovered { + .TableCell:last-child { + background-color: var(--p-surface-selected-hovered); + } + } + + .TableRow-selected { + .TableCell:last-child { + background-color: var(--p-surface-selected); + } + } + // stylelint-enable selector-max-class, selector-max-combinators, selector-max-specificity +} + .StickyTable { position: relative; top: 0; @@ -372,6 +419,7 @@ $scroll-bar-border-radius: rem(4px); transition: transform easing() duration(); display: flex; border-top: border('divider'); + filter: none; } [data-selectmode='true'] { @@ -395,7 +443,6 @@ $scroll-bar-border-radius: rem(4px); min-height: 5.6rem; padding: 1rem spacing(); background-color: var(--p-surface); - box-shadow: shadow(); } .StickyTable-condensed { diff --git a/src/components/IndexTable/IndexTable.tsx b/src/components/IndexTable/IndexTable.tsx index 7f7565d592c..e38b2e98c26 100644 --- a/src/components/IndexTable/IndexTable.tsx +++ b/src/components/IndexTable/IndexTable.tsx @@ -45,6 +45,7 @@ export interface IndexTableBaseProps { children?: React.ReactNode; emptyState?: React.ReactNode; sort?: React.ReactNode; + lastColumnSticky?: boolean; } export interface TableHeadingRect { @@ -64,6 +65,7 @@ function IndexTableBase({ children, emptyState, sort, + lastColumnSticky = false, }: IndexTableBaseProps) { const { loading, @@ -85,7 +87,6 @@ function IndexTableBase({ toggle: toggleHasMoreLeftColumns, } = useToggle(false); - const onboardingScrollButtons = useRef(false); const tablePosition = useRef({top: 0, left: 0}); const tableHeadingRects = useRef([]); @@ -204,6 +205,27 @@ function IndexTableBase({ [resizeTableScrollBar], ); + const [canScrollRight, setCanScrollRight] = useState(true); + + const handleCanScrollRight = useCallback(() => { + if ( + !lastColumnSticky || + !tableElement.current || + !scrollableContainerElement.current + ) { + return; + } + + const tableRect = tableElement.current.getBoundingClientRect(); + const scrollableRect = scrollableContainerElement.current.getBoundingClientRect(); + + setCanScrollRight(tableRect.width > scrollableRect.width); + }, [lastColumnSticky]); + + useEffect(() => { + handleCanScrollRight(); + }, [handleCanScrollRight]); + const handleResize = useCallback(() => { // hide the scrollbar when resizing scrollBarElement.current?.style.setProperty( @@ -213,7 +235,8 @@ function IndexTableBase({ resizeTableHeadings(); debounceResizeTableScrollbar(); - }, [debounceResizeTableScrollbar, resizeTableHeadings]); + handleCanScrollRight(); + }, [debounceResizeTableScrollbar, resizeTableHeadings, handleCanScrollRight]); const handleScrollContainerScroll = useCallback( (canScrollLeft, canScrollRight) => { @@ -240,9 +263,7 @@ function IndexTableBase({ toggleHasMoreLeftColumns(); } - if (!canScrollRight) { - onboardingScrollButtons.current = false; - } + setCanScrollRight(canScrollRight); }, [hasMoreLeftColumns, toggleHasMoreLeftColumns], ); @@ -500,6 +521,8 @@ function IndexTableBase({ hasMoreLeftColumns && styles['Table-scrolling'], selectMode && styles.disableTextSelection, selectMode && shouldShowBulkActions && styles.selectMode, + lastColumnSticky && styles['Table-sticky-last'], + lastColumnSticky && canScrollRight && styles['Table-sticky-scrolling'], ); const emptyStateMarkup = emptyState ? ( @@ -566,9 +589,11 @@ function IndexTableBase({ function renderHeading(heading: IndexTableHeading, index: number) { const isSecond = index === 0; + const isLast = index === headings.length - 1; const headingContentClassName = classNames( styles.TableHeading, isSecond && styles['TableHeading-second'], + isLast && !heading.hidden && styles['TableHeading-last'], ); const stickyPositioningStyle = diff --git a/src/components/IndexTable/README.md b/src/components/IndexTable/README.md index 7c69e72bb47..f75e7a0ecc0 100644 --- a/src/components/IndexTable/README.md +++ b/src/components/IndexTable/README.md @@ -710,7 +710,7 @@ function IndexTableWithFilteringExample() { An index table with rows differentiated by status. ```jsx -function SimpleIndexTableExample() { +function IndexTableWithRowStatusExample() { const customers = [ { id: '3411', @@ -784,6 +784,83 @@ function SimpleIndexTableExample() { } ``` +### Index table with sticky last column + +An index table with a sticky last column that stays visible on scroll. The last heading will also be sticky if not hidden. + +```jsx +function StickyLastCellIndexTableExample() { + const customers = [ + { + id: '3411', + url: 'customers/341', + name: 'Mae Jemison', + location: 'Decatur, USA', + orders: 20, + amountSpent: '$2,400', + }, + { + id: '2561', + url: 'customers/256', + 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} + + + ); +} +``` + ### IndexTable with all of its elements Use as a broad example that includes most of the elements and props available to index table. @@ -938,11 +1015,12 @@ function IndexTableWithAllElementsExample() { hasMoreItems bulkActions={bulkActions} promotedBulkActions={promotedBulkActions} + lastColumnSticky headings={[ {title: 'Name'}, {title: 'Location'}, {title: 'Order count'}, - {title: 'Amount spent'}, + {title: 'Amount spent', hidden: false}, ]} > {rowMarkup} diff --git a/src/components/IndexTable/tests/IndexTable.test.tsx b/src/components/IndexTable/tests/IndexTable.test.tsx index 62a8147725e..3e1c5f6188a 100644 --- a/src/components/IndexTable/tests/IndexTable.test.tsx +++ b/src/components/IndexTable/tests/IndexTable.test.tsx @@ -148,6 +148,18 @@ describe('', () => { }); }); + it('applies sticky last column styles when `lastColumnSticky` prop is true', () => { + const index = mountWithApp( + + {mockTableItems.map(mockRenderRow)} + , + ); + + expect(index).toContainReactComponent('table', { + className: 'Table Table-sticky-last', + }); + }); + describe('ScrollContainer', () => { it('updates sticky header scroll left on scoll', () => { const updatedScrollLeft = 25; @@ -172,7 +184,7 @@ describe('', () => { expect(stickyHeaderElementScrollLeft).toBe(updatedScrollLeft); }); - it('updates stickty table column header styles when scrolling right & hasMoreLeftColumns is false', () => { + it('updates sticky table column header styles when scrolling right & hasMoreLeftColumns is false', () => { const index = mountWithApp( {mockTableItems.map(mockRenderRow)} @@ -187,6 +199,21 @@ describe('', () => { 'StickyTableColumnHeader StickyTableColumnHeader-isScrolling', }); }); + + it('updates sticky last column styles when scrolled right', () => { + const index = mountWithApp( + + {mockTableItems.map(mockRenderRow)} + , + ); + + const scrollContainer = index.find(ScrollContainer); + scrollContainer!.trigger('onScroll', true, false); + + expect(index).toContainReactComponent('table', { + className: 'Table Table-scrolling Table-sticky-last', + }); + }); }); describe('resize', function () { @@ -276,6 +303,57 @@ describe('', () => { expect(index).toContainReactComponent(VisuallyHidden, {children: title}); }); + + it('renders a sticky last heading if `lastColumnSticky` prop is true and last heading is not hidden', () => { + const title = 'Heading two'; + const headings: IndexTableProps['headings'] = [ + {title: 'Heading one'}, + {title, hidden: false}, + ]; + const index = mountWithApp( + + {mockTableItems.map(mockRenderRow)} + , + ); + + expect(index).toContainReactComponent('table', { + className: 'Table Table-sticky-last', + }); + expect(index).toContainReactComponent('th', { + children: title, + className: 'TableHeading TableHeading-last', + }); + }); + + it('does not render a sticky last heading if `lastColumnSticky` prop is true and last heading is hidden', () => { + const title = 'Heading two'; + const headings: IndexTableProps['headings'] = [ + {title: 'Heading one'}, + {title, hidden: true}, + ]; + const index = mountWithApp( + + {mockTableItems.map(mockRenderRow)} + , + ); + + expect(index).toContainReactComponent('table', { + className: 'Table Table-sticky-last', + }); + expect(index).toContainReactComponent(VisuallyHidden, { + children: title, + }); + }); }); describe('BulkActions', () => {