Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions pages/table/sticky-columns-min-scrollable-width.page.tsx
Original file line number Diff line number Diff line change
@@ -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<Instance>[] = [
{ 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<Instance>['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 (
<ScreenshotArea>
<h1>Sticky columns – minScrollableWidth</h1>
<SpaceBetween size="l">
<SpaceBetween direction="horizontal" size="m">
<FormField label="Sticky columns first">
<Select
selectedOption={
stickyColumnsOptions.find(o => o.value === urlParams.stickyColumnsFirst) ?? stickyColumnsOptions[1]
}
options={stickyColumnsOptions}
onChange={event => setUrlParams({ stickyColumnsFirst: event.detail.selectedOption.value })}
/>
</FormField>

<FormField label="minScrollableWidth">
<Select
selectedOption={
minScrollableWidthOptions.find(o => o.value === urlParams.minScrollableWidth) ??
minScrollableWidthOptions[0]
}
options={minScrollableWidthOptions}
onChange={event => setUrlParams({ minScrollableWidth: event.detail.selectedOption.value })}
/>
</FormField>

<FormField label="Filter (keeps focus behind sticky column)">
<Input value={inputValue} onChange={event => setInputValue(event.detail.value)} />
</FormField>
</SpaceBetween>

{/* Constrain the table so it becomes horizontally scrollable within a narrow viewport. */}
<div style={{ maxWidth: 600 }}>
<Table
{...collectionProps}
data-test-id="min-scrollable-width-table"
stickyColumns={{ first: stickyColumnsFirst, minScrollableWidth }}
columnDefinitions={COLUMN_DEFINITIONS}
items={items}
ariaLabels={ariaLabels}
header={<Header>Narrow table with configurable minScrollableWidth</Header>}
/>
</div>
</SpaceBetween>
</ScreenshotArea>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
},
Expand Down
10 changes: 10 additions & 0 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export interface TableProps<T = any> 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.
Expand Down Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/table/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
3 changes: 3 additions & 0 deletions src/table/sticky-columns/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions src/table/sticky-columns/use-sticky-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function useStickyColumns({
visibleColumns,
stickyColumnsFirst,
stickyColumnsLast,
minScrollableWidth,
}: StickyColumnsProps): StickyColumnsModel {
const store = useMemo(() => new StickyColumnsStore(), []);
const wrapperRef = useRef<HTMLElement>(null) as React.MutableRefObject<null | HTMLElement>;
Expand All @@ -54,6 +55,7 @@ export function useStickyColumns({
visibleColumns,
stickyColumnsFirst,
stickyColumnsLast,
minScrollableWidth,
});
}
});
Expand All @@ -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(() => {
Expand Down Expand Up @@ -258,6 +261,7 @@ interface UpdateCellStylesProps {
visibleColumns: readonly PropertyKey[];
stickyColumnsFirst: number;
stickyColumnsLast: number;
minScrollableWidth?: number;
}

class StickyColumnsStore extends AsyncStore<StickyColumnsState> {
Expand Down Expand Up @@ -363,8 +367,9 @@ class StickyColumnsStore extends AsyncStore<StickyColumnsState> {
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;
}
Expand Down
Loading