diff --git a/pages/table/sticky-columns-min-scrollable-width.page.tsx b/pages/table/sticky-columns-min-scrollable-width.page.tsx new file mode 100644 index 0000000000..23271e8e49 --- /dev/null +++ b/pages/table/sticky-columns-min-scrollable-width.page.tsx @@ -0,0 +1,106 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { FormField, Select } from '~components'; +import Header from '~components/header'; +import Input from '~components/input'; +import SpaceBetween from '~components/space-between'; +import Table, { TableProps } from '~components/table'; + +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; +import { generateItems, Instance } from './generate-data'; + +type DemoContext = React.Context< + AppContextType<{ + stickyColumnsFirst: string; + minScrollableWidth: string; + }> +>; + +const tableItems: Instance[] = generateItems(10); + +const COLUMN_DEFINITIONS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', minWidth: 180, cell: item => item.id }, + { id: 'state', header: 'State', minWidth: 180, cell: item => item.state }, + { id: 'type', header: 'Type', minWidth: 180, cell: item => item.type }, + { id: 'imageId', header: 'Image ID', minWidth: 180, cell: item => item.imageId }, + { id: 'dnsName', header: 'DNS name', minWidth: 220, cell: item => item.dnsName || '-' }, +]; + +const ariaLabels: TableProps['ariaLabels'] = { + tableLabel: 'Min scrollable width demo table', +}; + +// A narrow wrapper where the default 148px minimum scrollable space would deactivate +// sticky columns. Lowering `minScrollableWidth` keeps the first column pinned. +const stickyColumnsOptions = [{ value: '0' }, { value: '1' }, { value: '2' }]; +const minScrollableWidthOptions = [ + { value: 'default', label: 'default (148)' }, + { value: '0', label: '0' }, + { value: '40', label: '40' }, + { value: '148', label: '148' }, + { value: '300', label: '300' }, +]; + +export default () => { + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + const [inputValue, setInputValue] = useState(''); + const { items, collectionProps } = useCollection(tableItems, { pagination: {}, sorting: {} }); + + const stickyColumnsFirst = parseInt(urlParams.stickyColumnsFirst || '1'); + const minScrollableWidth = + urlParams.minScrollableWidth && urlParams.minScrollableWidth !== 'default' + ? parseInt(urlParams.minScrollableWidth) + : undefined; + + return ( + +

Sticky columns – minScrollableWidth

+ + + + o.value === urlParams.minScrollableWidth) ?? + minScrollableWidthOptions[0] + } + options={minScrollableWidthOptions} + onChange={event => setUrlParams({ minScrollableWidth: event.detail.selectedOption.value })} + /> + + + + setInputValue(event.detail.value)} /> + + + + {/* Constrain the table so it becomes horizontally scrollable within a narrow viewport. */} +
+ Narrow table with configurable minScrollableWidth} + /> + + + + ); +}; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..6972289700 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29035,6 +29035,7 @@ to prevent the user from sorting before items are fully loaded.", "description": "Specifies the number of first and/or last columns that should be sticky. If the available scrollable space is less than a certain threshold, the feature is deactivated. +Use \`minScrollableWidth\` to override that threshold. Use it in conjunction with the sticky columns preference of the [collection preferences](/components/collection-preferences/) component.", @@ -29051,6 +29052,11 @@ Use it in conjunction with the sticky columns preference of the "optional": true, "type": "number", }, + { + "name": "minScrollableWidth", + "optional": true, + "type": "number", + }, ], "type": "object", }, diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..ec26c7e5cd 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -248,6 +248,7 @@ export interface TableProps extends BaseComponentProps { * Specifies the number of first and/or last columns that should be sticky. * * If the available scrollable space is less than a certain threshold, the feature is deactivated. + * Use `minScrollableWidth` to override that threshold. * * Use it in conjunction with the sticky columns preference of the * [collection preferences](/components/collection-preferences/) component. @@ -556,6 +557,15 @@ export namespace TableProps { export interface StickyColumns { first?: number; last?: number; + /** + * Overrides the minimum scrollable width, in pixels, that must remain available next to the + * sticky columns for the feature to stay active. When the table's remaining scrollable space + * drops below this value, sticky columns are deactivated to preserve horizontal scrolling. + * + * Defaults to `148`. Lower it (for example to `0`) to keep the first column(s) pinned on + * narrower tables, or raise it to deactivate sticky columns sooner. + */ + minScrollableWidth?: number; } export type VerticalAlign = 'middle' | 'top'; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..f14d60ac7b 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -392,6 +392,7 @@ const InternalTable = React.forwardRef( visibleColumns: visibleColumnIdsWithSelection, stickyColumnsFirst: (stickyColumns?.first ?? 0) + (stickyColumns?.first && hasSelection ? 1 : 0), stickyColumnsLast: stickyColumns?.last || 0, + minScrollableWidth: stickyColumns?.minScrollableWidth, }); const hasStickyColumns = !!((stickyColumns?.first ?? 0) + (stickyColumns?.last ?? 0) > 0); diff --git a/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx b/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx index a574e32708..7bd5069e75 100644 --- a/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx +++ b/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx @@ -153,6 +153,57 @@ test('generates empty sticky cell state if not enough scrollable space', () => { }); }); +test('minScrollableWidth=0 keeps sticky columns active when default threshold would deactivate them', () => { + // wrapper=300, table=500 (scrollable). First sticky column is 200px wide. + // With the default 148px minimum, 200 + 148 = 348 > 300 -> feature is deactivated (see test above). + // Overriding minScrollableWidth to 0 makes 200 + 0 = 200 < 300 -> feature stays active. + const { result, rerender } = renderHook(() => + useStickyColumns({ visibleColumns: [1, 2, 3], stickyColumnsFirst: 1, stickyColumnsLast: 0, minScrollableWidth: 0 }) + ); + createMockTable(result.current, 300, 500, 200, 300, 100); + + // Wait for effect + rerender({}); + + expect(result.current.store.get()).toEqual({ + cellState: new Map([ + [ + 1, + { + lastInsetInlineStart: false, + lastInsetInlineEnd: false, + padInlineStart: false, + offset: { insetInlineStart: 0 }, + }, + ], + ]), + wrapperState: { scrollPaddingInlineStart: 200, scrollPaddingInlineEnd: 0 }, + }); +}); + +test('a large minScrollableWidth deactivates sticky columns even when the default threshold would allow them', () => { + // wrapper=300, table=500 (scrollable). First sticky column is only 50px wide. + // With the default 148px minimum, 50 + 148 = 198 < 300 -> feature would be active. + // Raising minScrollableWidth to 300 makes 50 + 300 = 350 > 300 -> feature is deactivated. + const { result, rerender } = renderHook(() => + useStickyColumns({ + visibleColumns: [1, 2, 3], + stickyColumnsFirst: 1, + stickyColumnsLast: 0, + minScrollableWidth: 300, + }) + ); + createMockTable(result.current, 300, 500, 50, 300, 150); + + // Wait for effect + rerender({}); + + expect(result.current.store.get()).toEqual({ + cellState: new Map(), + wrapperState: { scrollPaddingInlineStart: 50, scrollPaddingInlineEnd: 0 }, + }); +}); + test('generates non-empty styles for sticky cells', () => { const { result, rerender } = renderHook(() => useStickyColumns({ visibleColumns: [1, 2, 3], stickyColumnsFirst: 0, stickyColumnsLast: 1 }) diff --git a/src/table/sticky-columns/interfaces.ts b/src/table/sticky-columns/interfaces.ts index 3bfba1ac89..dc530ad0c5 100644 --- a/src/table/sticky-columns/interfaces.ts +++ b/src/table/sticky-columns/interfaces.ts @@ -5,6 +5,9 @@ export interface StickyColumnsProps { visibleColumns: readonly PropertyKey[]; stickyColumnsFirst: number; stickyColumnsLast: number; + // Minimum scrollable width (px) that must remain besides the sticky columns for the feature + // to stay active. Defaults to MINIMUM_SCROLLABLE_SPACE when not provided. + minScrollableWidth?: number; } export interface StickyColumnsState { diff --git a/src/table/sticky-columns/use-sticky-columns.ts b/src/table/sticky-columns/use-sticky-columns.ts index 7513173aeb..611b46468c 100644 --- a/src/table/sticky-columns/use-sticky-columns.ts +++ b/src/table/sticky-columns/use-sticky-columns.ts @@ -37,6 +37,7 @@ export function useStickyColumns({ visibleColumns, stickyColumnsFirst, stickyColumnsLast, + minScrollableWidth, }: StickyColumnsProps): StickyColumnsModel { const store = useMemo(() => new StickyColumnsStore(), []); const wrapperRef = useRef(null) as React.MutableRefObject; @@ -54,6 +55,7 @@ export function useStickyColumns({ visibleColumns, stickyColumnsFirst, stickyColumnsLast, + minScrollableWidth, }); } }); @@ -71,9 +73,10 @@ export function useStickyColumns({ visibleColumns, stickyColumnsFirst, stickyColumnsLast, + minScrollableWidth, }); } - }, [store, stickyColumnsFirst, stickyColumnsLast, visibleColumns]); + }, [store, stickyColumnsFirst, stickyColumnsLast, visibleColumns, minScrollableWidth]); // Update wrapper styles imperatively to avoid unnecessary re-renders. useEffect(() => { @@ -258,6 +261,7 @@ interface UpdateCellStylesProps { visibleColumns: readonly PropertyKey[]; stickyColumnsFirst: number; stickyColumnsLast: number; + minScrollableWidth?: number; } class StickyColumnsStore extends AsyncStore { @@ -363,8 +367,9 @@ class StickyColumnsStore extends AsyncStore { const totalStickySpace = this.cellOffsets.stickyWidthInlineStart + this.cellOffsets.stickyWidthInlineEnd; const tablePaddingLeft = parseFloat(getComputedStyle(props.table).paddingLeft) || 0; const tablePaddingRight = parseFloat(getComputedStyle(props.table).paddingRight) || 0; + const minScrollableWidth = props.minScrollableWidth ?? MINIMUM_SCROLLABLE_SPACE; const hasEnoughScrollableSpace = - totalStickySpace + MINIMUM_SCROLLABLE_SPACE + tablePaddingLeft + tablePaddingRight < wrapperWidth; + totalStickySpace + minScrollableWidth + tablePaddingLeft + tablePaddingRight < wrapperWidth; if (!hasEnoughScrollableSpace) { return false; }