From e49ba799ec0ff9a9f8cf1a97c2f61bab6fc27a7d Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 13:34:47 +0000 Subject: [PATCH] feat: add minScrollableWidth option to Table stickyColumns The stickyColumns feature silently deactivates when the remaining scrollable space drops below a hard-coded 148px threshold, with no way for consumers to influence it. This adds an opt-in stickyColumns.minScrollableWidth to override that minimum, allowing the first (or last) column(s) to stay pinned on narrower tables (e.g. set to 0) or to deactivate sooner. The option is threaded through useStickyColumns into the enablement check and defaults to the existing MINIMUM_SCROLLABLE_SPACE, so behavior is unchanged when the property is omitted (backward compatible). Adds unit tests and a dev page. --- ...icky-columns-min-scrollable-width.page.tsx | 106 ++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 6 + src/table/interfaces.tsx | 10 ++ src/table/internal.tsx | 1 + .../__tests__/use-sticky-columns.test.tsx | 51 +++++++++ src/table/sticky-columns/interfaces.ts | 3 + .../sticky-columns/use-sticky-columns.ts | 9 +- 7 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 pages/table/sticky-columns-min-scrollable-width.page.tsx 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; }