From 8f8557a01377eb10c4998cff74223fccb63db23f Mon Sep 17 00:00:00 2001 From: Andrey Goder Date: Tue, 13 Oct 2026 17:28:02 -0700 Subject: [PATCH 1/2] Fix sortable table header alignment --- src/components/Table/Table.browser.test.tsx | 128 ++++++++++++++++++ src/components/Table/Table.stories.tsx | 44 ++++++ .../plugins/sortable/useTableSortable.tsx | 21 ++- 3 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 src/components/Table/Table.browser.test.tsx diff --git a/src/components/Table/Table.browser.test.tsx b/src/components/Table/Table.browser.test.tsx new file mode 100644 index 00000000..5a53f124 --- /dev/null +++ b/src/components/Table/Table.browser.test.tsx @@ -0,0 +1,128 @@ +import {render, screen, within} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; +import {Table} from 'components/Table/Table'; +import { + useTableSortable, + type TableSortState, +} from 'components/Table/plugins/sortable'; +import type {TableColumn, TableColumnAlign} from 'components/Table/types'; + +type SortState = 'unsorted' | 'ascending' | 'descending' | 'multi-sort'; + +interface Row extends Record { + amount: number; + name: string; +} + +function SortableTable({ + align, + direction, + state, +}: { + align: TableColumnAlign | undefined; + direction: 'ltr' | 'rtl'; + state: SortState; +}): React.JSX.Element { + const columns: TableColumn[] = [ + {align, header: 'Amount', key: 'amount', sortable: true}, + {header: 'Name', key: 'name', sortable: true}, + ]; + const sort: TableSortState = + state === 'unsorted' + ? [] + : state === 'multi-sort' + ? [ + {direction: 'ascending', sortKey: 'amount'}, + {direction: 'descending', sortKey: 'name'}, + ] + : [{direction: state, sortKey: 'amount'}]; + const sortPlugin = useTableSortable({ + isMultiSortEnabled: true, + onSortChange: () => {}, + sort, + }); + + return ( +
+ + + ); +} + +describe.each(['ltr', 'rtl'] as const)( + 'sortable header alignment in %s', + direction => { + describe.each([undefined, 'start', 'center', 'end'] as const)( + 'align=%s', + align => { + it.each(['unsorted', 'ascending', 'descending', 'multi-sort'] as const)( + 'aligns the label, icon and rank as a group when %s', + state => { + render( + , + ); + + const button = screen.getByRole('button', { + name: /^Sort by Amount/, + }); + const label = within(button) + .getByText('Amount') + .getBoundingClientRect(); + const icon = within(button).getByTestId('table-sort-icon-amount'); + const iconBox = icon.getBoundingClientRect(); + const boxes = [label, iconBox]; + if (state === 'multi-sort') { + boxes.push(within(button).getByText('1').getBoundingClientRect()); + } + const groupLeft = Math.min(...boxes.map(box => box.left)); + const groupRight = Math.max(...boxes.map(box => box.right)); + const buttonBox = button.getBoundingClientRect(); + + // Leave enough free space for a misplaced group to be detectable. + expect(buttonBox.width - (groupRight - groupLeft)).toBeGreaterThan( + 100, + ); + if (align === 'center') { + expect( + Math.abs( + (groupLeft + groupRight) / 2 - + (buttonBox.left + buttonBox.right) / 2, + ), + ).toBeLessThan(1); + } else { + const isLeftAligned = + (align !== 'end' && direction === 'ltr') || + (align === 'end' && direction === 'rtl'); + expect( + Math.abs( + isLeftAligned + ? groupLeft - buttonBox.left + : groupRight - buttonBox.right, + ), + ).toBeLessThan(1); + } + + // The icon follows the label in the logical inline direction. + if (direction === 'ltr') { + expect(iconBox.left).toBeGreaterThan(label.right); + } else { + expect(iconBox.right).toBeLessThan(label.left); + } + expect(icon).toHaveAttribute( + 'data-table-sort-icon-state', + state === 'unsorted' ? 'inactive' : 'active', + ); + }, + ); + }, + ); + }, +); diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx index 62e758fb..3a7bf270 100644 --- a/src/components/Table/Table.stories.tsx +++ b/src/components/Table/Table.stories.tsx @@ -502,6 +502,40 @@ function SortableStory() { ); } +const alignedSortableColumns: TableColumn[] = [ + {align: 'start', header: 'Task (start)', key: 'task', sortable: true}, + { + align: 'center', + header: 'Progress (center)', + key: 'progress', + sortable: true, + }, + {align: 'end', header: 'Budget (end)', key: 'budget', sortable: true}, +]; + +function SortableAlignmentStory({ + direction, +}: { + direction: 'ltr' | 'rtl'; +}): React.JSX.Element { + const sortable = useTableSortableState({ + data, + defaultSort: [{direction: 'descending', sortKey: 'budget'}], + isMultiSortEnabled: true, + }); + const sortPlugin = useTableSortable(sortable.sortConfig); + return ( +
+
+ + ); +} + function SelectionStory() { const [selectedKeys, setSelectedKeys] = useState( () => new Set(['design']), @@ -1088,6 +1122,16 @@ export const Sortable: Story = { render: () => , }; +export const SortableAlignment: Story = { + name: 'Sortable / Alignment', + render: () => , +}; + +export const SortableAlignmentRTL: Story = { + name: 'Sortable / Alignment RTL', + render: () => , +}; + export const Selection: Story = { render: () => , }; diff --git a/src/components/Table/plugins/sortable/useTableSortable.tsx b/src/components/Table/plugins/sortable/useTableSortable.tsx index 62435431..335e75d1 100644 --- a/src/components/Table/plugins/sortable/useTableSortable.tsx +++ b/src/components/Table/plugins/sortable/useTableSortable.tsx @@ -8,7 +8,7 @@ import type { TableColumn, TablePlugin, } from 'components/Table/types'; -import {css} from 'styled-system/css'; +import {css, cva} from 'styled-system/css'; export type TableSortDirection = 'ascending' | 'descending'; @@ -27,8 +27,8 @@ export interface UseTableSortableConfig { sort: TableSortState; } -const styles = { - button: css({ +const sortHeaderButtonRecipe = cva({ + base: { display: 'flex', alignItems: 'center', gap: '1', @@ -53,7 +53,18 @@ const styles = { { opacity: 1, }, - }), + }, + variants: { + align: { + start: {justifyContent: 'start'}, + center: {justifyContent: 'center'}, + end: {justifyContent: 'end'}, + }, + }, + defaultVariants: {align: 'start'}, +}); + +const styles = { icon: css({ display: 'inline-flex', flexShrink: 0, @@ -182,7 +193,7 @@ function SortHeaderButton>({ return (