From 530a0b3e24c2565c14ea19d19fa0f6fd7583e0ac Mon Sep 17 00:00:00 2001 From: Humberto Virtudes Date: Fri, 10 Jul 2026 21:12:38 +0800 Subject: [PATCH 1/5] feat(core): add useTableRowStatus plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepends a narrow column that renders a full-height colored bar on each row's leading edge — a compact per-row status signal (error, warning, unread, etc.) without a dedicated status column. - getStatus(item) maps a row to {color, label?} (any CSS color/token + optional accessible label), or null for no indicator. - Storybook stories (token colors + raw colors), docsite hook doc + example block, unit tests, and a Table Lab toggle. --- .changeset/table-row-status.md | 13 ++ .../app/(sandbox)/pages/table-lab/page.tsx | 26 +++- .../stories/TableRowStatus.stories.tsx | 101 +++++++++++++++ .../Table/TableRowStatusTable.doc.mjs | 14 +++ .../components/Table/TableRowStatusTable.tsx | 59 +++++++++ packages/core/src/Table/index.ts | 5 + .../core/src/Table/plugins/rowStatus/index.ts | 7 ++ .../rowStatus/useTableRowStatus.test.tsx | 71 +++++++++++ .../plugins/rowStatus/useTableRowStatus.tsx | 116 ++++++++++++++++++ .../core/src/Table/useTableRowStatus.doc.mjs | 30 +++++ 10 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 .changeset/table-row-status.md create mode 100644 apps/storybook/stories/TableRowStatus.stories.tsx create mode 100644 packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs create mode 100644 packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx create mode 100644 packages/core/src/Table/plugins/rowStatus/index.ts create mode 100644 packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx create mode 100644 packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx create mode 100644 packages/core/src/Table/useTableRowStatus.doc.mjs diff --git a/.changeset/table-row-status.md b/.changeset/table-row-status.md new file mode 100644 index 000000000000..bdff4dc6d0ce --- /dev/null +++ b/.changeset/table-row-status.md @@ -0,0 +1,13 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Add `useTableRowStatus` — a plugin that prepends a narrow column +rendering a full-height colored bar on each row's leading edge. + +- Compact per-row status signal (error, warning, unread, etc.) without a + dedicated status column. +- `getStatus(item)` maps a row to `{color, label?}` (any CSS color/token + + optional accessible label), or `null` for no indicator. + +@humbertovirtudes diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx index da943088c3d4..c384ee9575f6 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx @@ -49,6 +49,7 @@ import { useTableRowExpansionState, useTableGroupedRows, useTableRowIndex, + useTableRowStatus, } from '@astryxdesign/core/Table'; import type {TablePlugin, TableSortState} from '@astryxdesign/core/Table'; @@ -72,7 +73,8 @@ type PluginId = | 'stickyColumns' | 'rowExpansion' | 'groupedRows' - | 'rowIndex'; + | 'rowIndex' + | 'rowStatus'; interface PluginMeta { id: PluginId; @@ -117,6 +119,11 @@ const PLUGIN_REGISTRY: PluginMeta[] = [ label: 'Row Index', description: 'Prepend a monospaced row-number column', }, + { + id: 'rowStatus', + label: 'Row Status', + description: 'Leading-edge color bar (by member status)', + }, ]; // ============================================================================= @@ -244,6 +251,19 @@ function useLabPlugins({ getRowKey: item => item.id, }); + // --- row status --- + const rowStatusPlugin = useTableRowStatus({ + getStatus: item => { + if (item.status === 'Active') { + return {color: 'var(--color-icon-green)', label: 'Active'}; + } + if (item.status === 'Away') { + return {color: 'var(--color-icon-orange)', label: 'Away'}; + } + return null; // Offline → no bar + }, + }); + // Assemble enabled plugins in a stable order. const plugins: Record> = {}; if (enabled.groupedRows) { @@ -252,6 +272,9 @@ function useLabPlugins({ if (enabled.rowIndex) { plugins.rowIndex = rowIndexPlugin; } + if (enabled.rowStatus) { + plugins.rowStatus = rowStatusPlugin; + } if (enabled.sortable) { plugins.sort = sortPlugin; } @@ -431,6 +454,7 @@ export default function TableLabPage() { rowExpansion: false, groupedRows: false, rowIndex: false, + rowStatus: false, }); const baseData = useMemo(() => generateRows(rowCount), [rowCount]); diff --git a/apps/storybook/stories/TableRowStatus.stories.tsx b/apps/storybook/stories/TableRowStatus.stories.tsx new file mode 100644 index 000000000000..2b925f3d2a77 --- /dev/null +++ b/apps/storybook/stories/TableRowStatus.stories.tsx @@ -0,0 +1,101 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import type {Meta, StoryObj} from '@storybook/react'; +import { + Table, + useTableRowStatus, + proportional, + pixel, +} from '@astryxdesign/core/Table'; +import type {TableColumn, TableRowStatus} from '@astryxdesign/core/Table'; + +// ============================================================================= +// Sample Data +// ============================================================================= + +interface Job extends Record { + id: string; + name: string; + owner: string; + state: 'failed' | 'running' | 'queued' | 'succeeded'; +} + +const jobs: Job[] = [ + {id: 'j1', name: 'build-core', owner: 'Ava', state: 'failed'}, + {id: 'j2', name: 'lint', owner: 'Liam', state: 'running'}, + {id: 'j3', name: 'unit-tests', owner: 'Zoe', state: 'succeeded'}, + {id: 'j4', name: 'docsite-deploy', owner: 'Max', state: 'queued'}, + {id: 'j5', name: 'smoke-test', owner: 'Mia', state: 'succeeded'}, +]; + +const columns: TableColumn[] = [ + {key: 'name', header: 'Job', width: proportional(2)}, + {key: 'owner', header: 'Owner', width: pixel(120)}, + {key: 'state', header: 'State', width: pixel(120)}, +]; + +function jobStatus(job: Job): TableRowStatus | null { + switch (job.state) { + case 'failed': + return {color: 'var(--color-icon-red)', label: 'Failed'}; + case 'running': + return {color: 'var(--color-icon-orange)', label: 'Running'}; + case 'queued': + return {color: 'var(--color-icon-secondary)', label: 'Queued'}; + default: + return null; // succeeded → no bar + } +} + +const meta: Meta = { + title: 'Core/TableRowStatus', + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * A thin colored bar on the leading edge of each row signals per-row status. + * Rows whose `getStatus` returns `null` (here: succeeded jobs) show no bar. + * Hover a bar to see its accessible label. + */ +export const Default: Story = { + render: () => { + const rowStatus = useTableRowStatus({getStatus: jobStatus}); + return ( + + ); + }, +}; + +/** + * Any CSS color works — here raw hex values instead of theme tokens. + */ +export const RawColors: Story = { + render: () => { + const rowStatus = useTableRowStatus({ + getStatus: job => + job.state === 'failed' + ? {color: '#dc2626', label: 'Failed'} + : job.state === 'running' + ? {color: '#f59e0b', label: 'Running'} + : null, + }); + return ( +
+ ); + }, +}; diff --git a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs new file mode 100644 index 000000000000..559cfaf2addd --- /dev/null +++ b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('../../../../../core/src/docs-types').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'useTableRowStatus', + name: 'useTableRowStatus — Status Bars', + displayName: 'useTableRowStatus — Status Bars', + description: + 'A job table using useTableRowStatus to render a colored leading-edge bar per row (failed / running / queued). Rows with no status show no bar.', + isReady: true, + aspectRatio: 16 / 9, + componentsUsed: ['Table'], +}; diff --git a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx new file mode 100644 index 000000000000..3bc21511a3d5 --- /dev/null +++ b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx @@ -0,0 +1,59 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import { + Table, + useTableRowStatus, + proportional, + pixel, +} from '@astryxdesign/core/Table'; +import type {TableColumn, TableRowStatus} from '@astryxdesign/core/Table'; + +interface Job extends Record { + id: string; + name: string; + owner: string; + state: 'failed' | 'running' | 'queued' | 'succeeded'; +} + +const jobs: Job[] = [ + {id: 'j1', name: 'build-core', owner: 'Ava', state: 'failed'}, + {id: 'j2', name: 'lint', owner: 'Liam', state: 'running'}, + {id: 'j3', name: 'unit-tests', owner: 'Zoe', state: 'succeeded'}, + {id: 'j4', name: 'docsite-deploy', owner: 'Max', state: 'queued'}, + {id: 'j5', name: 'smoke-test', owner: 'Mia', state: 'succeeded'}, +]; + +const columns: TableColumn[] = [ + {key: 'name', header: 'Job', width: proportional(2)}, + {key: 'owner', header: 'Owner', width: pixel(120)}, + {key: 'state', header: 'State', width: pixel(120)}, +]; + +function jobStatus(job: Job): TableRowStatus | null { + switch (job.state) { + case 'failed': + return {color: 'var(--color-icon-red)', label: 'Failed'}; + case 'running': + return {color: 'var(--color-icon-orange)', label: 'Running'}; + case 'queued': + return {color: 'var(--color-icon-secondary)', label: 'Queued'}; + default: + return null; // succeeded → no bar + } +} + +export default function TableRowStatusTable() { + const rowStatus = useTableRowStatus({getStatus: jobStatus}); + + return ( +
+ ); +} diff --git a/packages/core/src/Table/index.ts b/packages/core/src/Table/index.ts index b3cca024c76c..7598ca2ef555 100644 --- a/packages/core/src/Table/index.ts +++ b/packages/core/src/Table/index.ts @@ -28,6 +28,7 @@ export {useTableColumnResize} from './plugins/columnResize'; export {useTableStickyColumns} from './plugins/stickyColumns'; export {useTableGroupedRows} from './plugins/groupedRows'; export {useTableRowIndex} from './plugins/rowIndex'; +export {useTableRowStatus} from './plugins/rowStatus'; export { useTableRowExpansion, useTableRowExpansionState, @@ -109,6 +110,10 @@ export type { UseTableGroupedRowsConfig, UseTableGroupedRowsResult, } from './plugins/groupedRows'; +export type { + UseTableRowStatusConfig, + TableRowStatus, +} from './plugins/rowStatus'; export type { UseTableFilteringConfig, TableFilterState, diff --git a/packages/core/src/Table/plugins/rowStatus/index.ts b/packages/core/src/Table/plugins/rowStatus/index.ts new file mode 100644 index 000000000000..35ec2f7528bd --- /dev/null +++ b/packages/core/src/Table/plugins/rowStatus/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +export {useTableRowStatus} from './useTableRowStatus'; +export type { + UseTableRowStatusConfig, + TableRowStatus, +} from './useTableRowStatus'; diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx new file mode 100644 index 000000000000..9aaed86847a6 --- /dev/null +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx @@ -0,0 +1,71 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect} from 'vitest'; +import {render, screen, within} from '@testing-library/react'; +import {Table} from '../../Table'; +import type {TableColumn} from '../../types'; +import {useTableRowStatus, type TableRowStatus} from './useTableRowStatus'; + +interface Row extends Record { + id: string; + name: string; + state: 'error' | 'warning' | 'ok'; +} + +const data: Row[] = [ + {id: 'a', name: 'Alice', state: 'error'}, + {id: 'b', name: 'Bob', state: 'ok'}, + {id: 'c', name: 'Carol', state: 'warning'}, +]; + +const columns: TableColumn[] = [{key: 'name', header: 'Name'}]; + +function getStatus(item: Row): TableRowStatus | null { + if (item.state === 'error') { + return {color: 'rgb(220, 38, 38)', label: 'Error'}; + } + if (item.state === 'warning') { + return {color: 'rgb(245, 158, 11)', label: 'Warning'}; + } + return null; +} + +function Harness() { + const rowStatus = useTableRowStatus({getStatus}); + return ( +
+ ); +} + +describe('useTableRowStatus', () => { + it('prepends a narrow status column with an empty header', () => { + render(); + const headers = screen.getAllByRole('columnheader'); + // Status column is first; its header is empty. + expect(headers[0]).toHaveAttribute('data-column-key', '__rowStatus'); + expect(headers[0].textContent).toBe(''); + }); + + it('renders a labeled bar for rows with a status', () => { + render(); + expect(screen.getByRole('img', {name: 'Error'})).toBeInTheDocument(); + expect(screen.getByRole('img', {name: 'Warning'})).toBeInTheDocument(); + }); + + it('renders no bar for rows returning null', () => { + render(); + const rows = screen.getAllByRole('row'); + // rows[2] is Bob (state ok) — no status bar in his status cell. + const bob = rows[2]; + expect(within(bob).getByText('Bob')).toBeInTheDocument(); + expect(within(bob).queryByRole('img')).not.toBeInTheDocument(); + }); + + it('applies the status color to the bar', () => { + render(); + const errorBar = screen.getByRole('img', {name: 'Error'}); + // StyleX compiles the dynamic color into a CSS variable on the inline + // style; a static class applies `background-color: var(--x-backgroundColor)`. + expect(errorBar.getAttribute('style')).toContain('rgb(220, 38, 38)'); + }); +}); diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx new file mode 100644 index 000000000000..f0f008a3a7a0 --- /dev/null +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx @@ -0,0 +1,116 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTableRowStatus.tsx + * @input React, StyleX, Table types + * @output Exports useTableRowStatus hook + config type + * @position Row-status plugin; consumed by Table via plugins prop + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/Table/index.ts (exports) + */ + +import {useMemo} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type {TableColumn, TablePlugin} from '../../types'; + +/** + * A row's status indicator: a color (any CSS color / token) and an optional + * accessible label. Return `null` for rows with no status. + */ +export interface TableRowStatus { + color: string; + label?: string; +} + +/** Configuration for {@link useTableRowStatus}. */ +export interface UseTableRowStatusConfig> { + /** + * Derive the status indicator for a row. Return `null` for no indicator. + * @example + * ``` + * getStatus: row => + * row.hasError ? {color: 'var(--color-icon-red)', label: 'Error'} : null + * ``` + */ + getStatus: (item: T) => TableRowStatus | null; +} + +const STATUS_COLUMN_WIDTH = {type: 'pixel' as const, value: 8}; + +const styles = stylex.create({ + // The status cell hosts a full-height bar. Zero its own padding so the bar + // reaches the row's vertical edges, and anchor the absolutely-positioned bar. + cell: { + position: 'relative', + paddingInline: 0, + paddingBlock: 0, + }, + bar: (color: string) => ({ + position: 'absolute', + insetBlock: 0, + insetInlineStart: 0, + width: '4px', + backgroundColor: color, + }), +}); + +/** + * Returns a {@link TablePlugin} that prepends a narrow column rendering a + * full-height colored bar on the row's leading edge — a compact way to signal + * per-row status (error, warning, unread, etc.) without a full status column. + * + * @example + * ```tsx + * const rowStatus = useTableRowStatus({ + * getStatus: row => + * row.state === 'error' + * ? {color: 'var(--color-icon-error)', label: 'Error'} + * : null, + * }); + *
; + * ``` + */ +export function useTableRowStatus>( + config: UseTableRowStatusConfig, +): TablePlugin { + const {getStatus} = config; + + return useMemo( + (): TablePlugin => ({ + transformColumns(columns) { + const statusColumn: TableColumn = { + key: '__rowStatus', + header: '', + width: STATUS_COLUMN_WIDTH, + resizable: false, + renderCell: (item: T) => { + const status = getStatus(item); + if (!status) { + return null; + } + return ( +
+ ); + }, + }; + return [statusColumn, ...columns]; + }, + + transformBodyCell(props, column) { + if (column.key !== '__rowStatus') { + return props; + } + return {...props, styles: [...props.styles, styles.cell]}; + }, + }), + [getStatus], + ); +} diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs new file mode 100644 index 000000000000..76637c1319e2 --- /dev/null +++ b/packages/core/src/Table/useTableRowStatus.doc.mjs @@ -0,0 +1,30 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('../docs-types').ComponentDoc} */ + +export const docs = { + name: 'useTableRowStatus', + subComponentOf: 'Table', + displayName: 'useTableRowStatus', + description: + 'Hook that returns a TablePlugin which prepends a narrow column rendering a full-height colored bar on each row\u2019s leading edge \u2014 a compact way to signal per-row status (error, warning, unread, etc.) without a dedicated status column. Provide getStatus to map a row to a color + optional accessible label, or null for no indicator.', + props: [ + { + name: 'getStatus', + type: '(item: T) => { color: string; label?: string } | null', + description: + 'Derive the status indicator for a row: a CSS color (token or raw) and an optional accessible label. Return null for rows with no status.', + required: true, + }, + ], +}; + +/** @type {import('../docs-types').TranslationDoc} */ +export const docsDense = { + description: + 'Returns a TablePlugin that prepends a narrow column with a full-height colored bar on each row\u2019s leading edge \u2014 compact per-row status (error/warning/unread) without a full status column. getStatus maps a row to {color, label?} or null.', + propDescriptions: { + getStatus: + 'Map a row to {color, label?} (CSS color + optional a11y label), or null for no indicator.', + }, +}; From 0882ad9226d66703760b8f305d43c24952fc6a1b Mon Sep 17 00:00:00 2001 From: Humberto Virtudes Date: Sun, 12 Jul 2026 12:45:06 +0800 Subject: [PATCH 2/5] feat(core): semantic colors + icon signifier for useTableRowStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback (cixzhang) and self-review findings: - Fix invalid token in the flagship @example: 'var(--color-icon-error)' does not exist. The API now accepts semantic color NAMES first-class. - Semantic colors (cixzhang): color accepts 'success'|'error'|'warning'| 'accent'|'red'|'orange'|'green'|'yellow'|'blue'|'gray', mapped to theme tokens; raw CSS color strings still pass through as an escape hatch. - Icon signifier (cixzhang): optional `icon` renders an Icon as the status signifier so status is conveyed by shape + color, not color alone — more accessible when multiple statuses coexist. Column widens to fit the icon. - a11y: role="img" + aria-label only when a label is provided; documented that omitting label yields a color-only (inaccessible) indicator. - Document that getStatus should be memoized (useCallback) for a stable plugin identity; updated doc.mjs + config JSDoc. - Tests (8, all green): semantic-color→token mapping, raw-CSS escape hatch, icon-mode renders an svg + keeps the accessible name, label-omitted → no role=img (color-only bar still renders), and empty-data (header, no bars). - Updated the Storybook Default story and Table Lab example to the idiomatic semantic-color + icon form. Tags: ai-generated This diff was generated with AI assistance (Navi). The implementation and tests were produced by an AI agent; the human author reviewed and approved the changes. --- .changeset/table-row-status.md | 14 ++- .../app/(sandbox)/pages/table-lab/page.tsx | 8 +- .../stories/TableRowStatus.stories.tsx | 8 +- .../rowStatus/useTableRowStatus.test.tsx | 77 +++++++++++-- .../plugins/rowStatus/useTableRowStatus.tsx | 108 +++++++++++++++--- .../core/src/Table/useTableRowStatus.doc.mjs | 10 +- 6 files changed, 184 insertions(+), 41 deletions(-) diff --git a/.changeset/table-row-status.md b/.changeset/table-row-status.md index bdff4dc6d0ce..6d4fc36b90d3 100644 --- a/.changeset/table-row-status.md +++ b/.changeset/table-row-status.md @@ -3,11 +3,17 @@ --- [feat] Add `useTableRowStatus` — a plugin that prepends a narrow column -rendering a full-height colored bar on each row's leading edge. +signaling per-row status. - Compact per-row status signal (error, warning, unread, etc.) without a - dedicated status column. -- `getStatus(item)` maps a row to `{color, label?}` (any CSS color/token + - optional accessible label), or `null` for no indicator. + dedicated status column: a full-height colored bar on the leading edge, + or an icon when provided. +- `getStatus(item)` maps a row to `{color, icon?, label?}`, or `null` for no + indicator. `color` accepts a semantic status name + (`success`/`error`/`warning`/`accent`/`red`/`green`/…) mapped to a theme + token, or a raw CSS color as an escape hatch. +- `icon` renders the status as a shape signifier — more accessible than color + alone when multiple statuses coexist. `label` supplies the accessible name. +- Memoize `getStatus` with `useCallback` for a stable plugin identity. @humbertovirtudes diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx index c384ee9575f6..3b8aa25f95be 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx @@ -254,13 +254,15 @@ function useLabPlugins({ // --- row status --- const rowStatusPlugin = useTableRowStatus({ getStatus: item => { + // Semantic color names map to theme tokens; an icon adds a shape + // differentiator so status isn't conveyed by color alone. if (item.status === 'Active') { - return {color: 'var(--color-icon-green)', label: 'Active'}; + return {color: 'success', icon: 'success', label: 'Active'}; } if (item.status === 'Away') { - return {color: 'var(--color-icon-orange)', label: 'Away'}; + return {color: 'warning', icon: 'warning', label: 'Away'}; } - return null; // Offline → no bar + return null; // Offline → no indicator }, }); diff --git a/apps/storybook/stories/TableRowStatus.stories.tsx b/apps/storybook/stories/TableRowStatus.stories.tsx index 2b925f3d2a77..30e45206f607 100644 --- a/apps/storybook/stories/TableRowStatus.stories.tsx +++ b/apps/storybook/stories/TableRowStatus.stories.tsx @@ -37,13 +37,13 @@ const columns: TableColumn[] = [ function jobStatus(job: Job): TableRowStatus | null { switch (job.state) { case 'failed': - return {color: 'var(--color-icon-red)', label: 'Failed'}; + return {color: 'error', icon: 'error', label: 'Failed'}; case 'running': - return {color: 'var(--color-icon-orange)', label: 'Running'}; + return {color: 'warning', icon: 'warning', label: 'Running'}; case 'queued': - return {color: 'var(--color-icon-secondary)', label: 'Queued'}; + return {color: 'gray', label: 'Queued'}; default: - return null; // succeeded → no bar + return null; // succeeded → no indicator } } diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx index 9aaed86847a6..52312b050fba 100644 --- a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx @@ -9,7 +9,7 @@ import {useTableRowStatus, type TableRowStatus} from './useTableRowStatus'; interface Row extends Record { id: string; name: string; - state: 'error' | 'warning' | 'ok'; + state: 'error' | 'warning' | 'ok' | 'done'; } const data: Row[] = [ @@ -22,18 +22,24 @@ const columns: TableColumn[] = [{key: 'name', header: 'Name'}]; function getStatus(item: Row): TableRowStatus | null { if (item.state === 'error') { - return {color: 'rgb(220, 38, 38)', label: 'Error'}; + return {color: 'red', label: 'Error'}; } if (item.state === 'warning') { - return {color: 'rgb(245, 158, 11)', label: 'Warning'}; + return {color: 'orange', label: 'Warning'}; } return null; } -function Harness() { - const rowStatus = useTableRowStatus({getStatus}); +function Harness({ + rows = data, + statusFn = getStatus, +}: { + rows?: Row[]; + statusFn?: (item: Row) => TableRowStatus | null; +}) { + const rowStatus = useTableRowStatus({getStatus: statusFn}); return ( -
+
); } @@ -52,20 +58,67 @@ describe('useTableRowStatus', () => { expect(screen.getByRole('img', {name: 'Warning'})).toBeInTheDocument(); }); - it('renders no bar for rows returning null', () => { + it('renders no indicator for rows returning null', () => { render(); const rows = screen.getAllByRole('row'); - // rows[2] is Bob (state ok) — no status bar in his status cell. + // rows[2] is Bob (state ok) — no status indicator in his status cell. const bob = rows[2]; expect(within(bob).getByText('Bob')).toBeInTheDocument(); expect(within(bob).queryByRole('img')).not.toBeInTheDocument(); }); - it('applies the status color to the bar', () => { + it('maps a semantic color name to its icon color token', () => { render(); const errorBar = screen.getByRole('img', {name: 'Error'}); - // StyleX compiles the dynamic color into a CSS variable on the inline - // style; a static class applies `background-color: var(--x-backgroundColor)`. - expect(errorBar.getAttribute('style')).toContain('rgb(220, 38, 38)'); + // 'red' resolves to var(--color-icon-red); StyleX emits it on inline style. + expect(errorBar.getAttribute('style')).toContain('--color-icon-red'); + }); + + it('passes through a raw CSS color as an escape hatch', () => { + render( + + item.state === 'error' ? {color: 'rgb(1, 2, 3)', label: 'Raw'} : null + } + />, + ); + const bar = screen.getByRole('img', {name: 'Raw'}); + expect(bar.getAttribute('style')).toContain('rgb(1, 2, 3)'); + }); + + it('renders an icon as the status signifier when icon is provided', () => { + render( + + item.state === 'error' + ? {color: 'red', icon: 'error', label: 'Error'} + : null + } + />, + ); + // Icon-mode still exposes the accessible label via role=img. + const indicator = screen.getByRole('img', {name: 'Error'}); + expect(indicator).toBeInTheDocument(); + // An SVG icon is rendered inside the indicator (bar mode has no svg). + expect(indicator.querySelector('svg')).not.toBeNull(); + }); + + it('sets role=img only when a label is provided (bar mode)', () => { + render( + (item.state === 'error' ? {color: 'red'} : null)} + />, + ); + // No label → no accessible name → not exposed as an img role. + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + // But the row still renders (Alice present) with a color-only bar. + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('renders the status header with empty data and no indicators', () => { + render(); + const headers = screen.getAllByRole('columnheader'); + expect(headers[0]).toHaveAttribute('data-column-key', '__rowStatus'); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); }); diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx index f0f008a3a7a0..7e32367e0b7f 100644 --- a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx @@ -4,7 +4,7 @@ /** * @file useTableRowStatus.tsx - * @input React, StyleX, Table types + * @input React, StyleX, Icon, Table types * @output Exports useTableRowStatus hook + config type * @position Row-status plugin; consumed by Table via plugins prop * @@ -14,14 +14,66 @@ import {useMemo} from 'react'; import * as stylex from '@stylexjs/stylex'; +import {Icon, type IconColor, type IconName} from '../../../Icon'; import type {TableColumn, TablePlugin} from '../../types'; /** - * A row's status indicator: a color (any CSS color / token) and an optional - * accessible label. Return `null` for rows with no status. + * Semantic status colors, resolved to the design system's icon color tokens. + * Prefer these over raw CSS so status colors stay consistent with the theme. + */ +export type TableRowStatusColor = + | 'accent' + | 'success' + | 'error' + | 'warning' + | 'red' + | 'orange' + | 'green' + | 'yellow' + | 'blue' + | 'gray'; + +const SEMANTIC_COLORS: Record = { + accent: 'var(--color-icon-accent)', + success: 'var(--color-icon-green)', + error: 'var(--color-icon-red)', + warning: 'var(--color-icon-orange)', + red: 'var(--color-icon-red)', + orange: 'var(--color-icon-orange)', + green: 'var(--color-icon-green)', + yellow: 'var(--color-icon-yellow)', + blue: 'var(--color-icon-blue)', + gray: 'var(--color-icon-gray)', +}; + +/** Icon colors that map cleanly from a semantic status color. */ +const ICON_COLOR_BY_STATUS: Record = { + accent: 'accent', + success: 'success', + error: 'error', + warning: 'warning', + red: 'red', + orange: 'warning', + green: 'green', + yellow: 'warning', + blue: 'blue', + gray: 'gray', +}; + +/** + * A row's status indicator. `color` accepts a semantic status color + * (mapped to a theme token) or any raw CSS color string as an escape hatch. + * Provide `icon` to signal status by shape as well as color — more accessible + * when several statuses coexist. Provide `label` for an accessible name + * (strongly recommended; without it the indicator is color-only). Return + * `null` for rows with no status. */ export interface TableRowStatus { - color: string; + /** Semantic status color (preferred) or a raw CSS color string. */ + color: TableRowStatusColor | (string & {}); + /** Optional icon rendered as the signifier (shape as an a11y differentiator). */ + icon?: IconName; + /** Accessible label; announced via role="img". Recommended. */ label?: string; } @@ -29,16 +81,26 @@ export interface TableRowStatus { export interface UseTableRowStatusConfig> { /** * Derive the status indicator for a row. Return `null` for no indicator. + * Memoize with `useCallback` for a stable plugin identity across renders. + * * @example * ``` * getStatus: row => - * row.hasError ? {color: 'var(--color-icon-red)', label: 'Error'} : null + * row.hasError ? {color: 'error', icon: 'error', label: 'Error'} : null * ``` */ getStatus: (item: T) => TableRowStatus | null; } -const STATUS_COLUMN_WIDTH = {type: 'pixel' as const, value: 8}; +// Bar mode overlays a 4px bar on the leading edge (needs almost no width); +// icon mode needs room for the glyph. Reserve icon width so a mixed table +// never clips — the extra padding is negligible for bar-only tables. +const STATUS_COLUMN_WIDTH = {type: 'pixel' as const, value: 28}; + +/** Resolve a semantic color name to a token, or pass a raw CSS color through. */ +function resolveColor(color: string): string { + return (SEMANTIC_COLORS as Record)[color] ?? color; +} const styles = stylex.create({ // The status cell hosts a full-height bar. Zero its own padding so the bar @@ -55,19 +117,24 @@ const styles = stylex.create({ width: '4px', backgroundColor: color, }), + iconWrap: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, }); /** - * Returns a {@link TablePlugin} that prepends a narrow column rendering a - * full-height colored bar on the row's leading edge — a compact way to signal - * per-row status (error, warning, unread, etc.) without a full status column. + * Returns a {@link TablePlugin} that prepends a narrow column signaling per-row + * status: a full-height colored bar on the leading edge, or an icon when + * `icon` is provided (shape + color is more accessible than color alone). * * @example - * ```tsx + * ``` * const rowStatus = useTableRowStatus({ * getStatus: row => * row.state === 'error' - * ? {color: 'var(--color-icon-error)', label: 'Error'} + * ? {color: 'error', icon: 'error', label: 'Error'} * : null, * }); *
; @@ -91,10 +158,25 @@ export function useTableRowStatus>( if (!status) { return null; } + const role = status.label ? 'img' : undefined; + if (status.icon) { + const iconColor: IconColor = + ICON_COLOR_BY_STATUS[status.color as TableRowStatusColor] ?? + 'primary'; + return ( + + + + ); + } return (
diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs index 76637c1319e2..23753c31a387 100644 --- a/packages/core/src/Table/useTableRowStatus.doc.mjs +++ b/packages/core/src/Table/useTableRowStatus.doc.mjs @@ -7,13 +7,13 @@ export const docs = { subComponentOf: 'Table', displayName: 'useTableRowStatus', description: - 'Hook that returns a TablePlugin which prepends a narrow column rendering a full-height colored bar on each row\u2019s leading edge \u2014 a compact way to signal per-row status (error, warning, unread, etc.) without a dedicated status column. Provide getStatus to map a row to a color + optional accessible label, or null for no indicator.', + 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status — a full-height colored bar on the leading edge, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and an optional accessible label; return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.', props: [ { name: 'getStatus', - type: '(item: T) => { color: string; label?: string } | null', + type: '(item: T) => { color: TableRowStatusColor | string; icon?: IconName; label?: string } | null', description: - 'Derive the status indicator for a row: a CSS color (token or raw) and an optional accessible label. Return null for rows with no status.', + 'Derive the status indicator for a row: a semantic color (accent/success/error/warning/red/orange/green/yellow/blue/gray, mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape (recommended when multiple statuses coexist); and an optional accessible label (announced via role="img" — recommended, since without it the indicator is color-only). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', required: true, }, ], @@ -22,9 +22,9 @@ export const docs = { /** @type {import('../docs-types').TranslationDoc} */ export const docsDense = { description: - 'Returns a TablePlugin that prepends a narrow column with a full-height colored bar on each row\u2019s leading edge \u2014 compact per-row status (error/warning/unread) without a full status column. getStatus maps a row to {color, label?} or null.', + 'Returns a TablePlugin that prepends a narrow per-row status column — a colored bar, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label?} or null. color is a semantic name (success/error/warning/…) mapped to a theme token, or a raw CSS color. Memoize getStatus with useCallback.', propDescriptions: { getStatus: - 'Map a row to {color, label?} (CSS color + optional a11y label), or null for no indicator.', + 'Map a row to {color, icon?, label?} or null. color = semantic status name (mapped to a token) or raw CSS; icon = shape signifier (a11y); label = accessible name (recommended). Memoize with useCallback.', }, }; From 490c4ab62fca552a200bdcd983589539e968a8bc Mon Sep 17 00:00:00 2001 From: Humberto Virtudes Date: Sat, 18 Jul 2026 01:07:17 +0800 Subject: [PATCH 3/5] feat(core): render useTableRowStatus as a status dot (addresses review) Switch the default indicator from a full-height leading-edge bar to a centered status dot, per review feedback: - the bar's shape read as the tab selection state - a dot matches the original status-dot / icon pattern Icon mode is unchanged (shape + color for a11y). Updated tests, docs, story, CLI template, and Table Lab wording accordingly. --- .changeset/table-row-status.md | 13 ++-- .../app/(sandbox)/pages/table-lab/page.tsx | 4 +- .../stories/TableRowStatus.stories.tsx | 10 +-- .../Table/TableRowStatusTable.doc.mjs | 6 +- .../components/Table/TableRowStatusTable.tsx | 8 +-- .../rowStatus/useTableRowStatus.test.tsx | 32 ++++++---- .../plugins/rowStatus/useTableRowStatus.tsx | 64 ++++++++----------- .../core/src/Table/useTableRowStatus.doc.mjs | 6 +- 8 files changed, 72 insertions(+), 71 deletions(-) diff --git a/.changeset/table-row-status.md b/.changeset/table-row-status.md index 6d4fc36b90d3..033cbacd6ddf 100644 --- a/.changeset/table-row-status.md +++ b/.changeset/table-row-status.md @@ -2,18 +2,19 @@ '@astryxdesign/core': patch --- -[feat] Add `useTableRowStatus` — a plugin that prepends a narrow column +[feat] Add `useTableRowStatus`, a plugin that prepends a narrow column signaling per-row status. - Compact per-row status signal (error, warning, unread, etc.) without a - dedicated status column: a full-height colored bar on the leading edge, - or an icon when provided. + dedicated status column: a colored status dot by default, or an icon when + provided. - `getStatus(item)` maps a row to `{color, icon?, label?}`, or `null` for no indicator. `color` accepts a semantic status name - (`success`/`error`/`warning`/`accent`/`red`/`green`/…) mapped to a theme + (`success`/`error`/`warning`/`accent`/`red`/`green`/etc.) mapped to a theme token, or a raw CSS color as an escape hatch. -- `icon` renders the status as a shape signifier — more accessible than color - alone when multiple statuses coexist. `label` supplies the accessible name. +- `icon` renders the status as a shape signifier instead of the dot, which is + more accessible than color alone when multiple statuses coexist. `label` + supplies the accessible name. - Memoize `getStatus` with `useCallback` for a stable plugin identity. @humbertovirtudes diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx index 3b8aa25f95be..141f0a593166 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx @@ -122,7 +122,7 @@ const PLUGIN_REGISTRY: PluginMeta[] = [ { id: 'rowStatus', label: 'Row Status', - description: 'Leading-edge color bar (by member status)', + description: 'Status dot / icon (by member status)', }, ]; @@ -262,7 +262,7 @@ function useLabPlugins({ if (item.status === 'Away') { return {color: 'warning', icon: 'warning', label: 'Away'}; } - return null; // Offline → no indicator + return null; // Offline: no indicator }, }); diff --git a/apps/storybook/stories/TableRowStatus.stories.tsx b/apps/storybook/stories/TableRowStatus.stories.tsx index 30e45206f607..b53660aeaeb1 100644 --- a/apps/storybook/stories/TableRowStatus.stories.tsx +++ b/apps/storybook/stories/TableRowStatus.stories.tsx @@ -43,7 +43,7 @@ function jobStatus(job: Job): TableRowStatus | null { case 'queued': return {color: 'gray', label: 'Queued'}; default: - return null; // succeeded → no indicator + return null; // succeeded: no indicator } } @@ -56,9 +56,9 @@ export default meta; type Story = StoryObj; /** - * A thin colored bar on the leading edge of each row signals per-row status. - * Rows whose `getStatus` returns `null` (here: succeeded jobs) show no bar. - * Hover a bar to see its accessible label. + * A small colored dot in a leading gutter column signals per-row status. + * Rows whose `getStatus` returns `null` (here: succeeded jobs) show no dot. + * Hover a dot to see its accessible label. */ export const Default: Story = { render: () => { @@ -76,7 +76,7 @@ export const Default: Story = { }; /** - * Any CSS color works — here raw hex values instead of theme tokens. + * Any CSS color works: here raw hex values instead of theme tokens. */ export const RawColors: Story = { render: () => { diff --git a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs index 559cfaf2addd..3e8d261aee16 100644 --- a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs +++ b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.doc.mjs @@ -4,10 +4,10 @@ export const doc = { type: 'block', exampleFor: 'useTableRowStatus', - name: 'useTableRowStatus — Status Bars', - displayName: 'useTableRowStatus — Status Bars', + name: 'useTableRowStatus - Status Dots', + displayName: 'useTableRowStatus - Status Dots', description: - 'A job table using useTableRowStatus to render a colored leading-edge bar per row (failed / running / queued). Rows with no status show no bar.', + 'A job table using useTableRowStatus to render a colored status dot (or icon) per row (failed / running / queued). Rows with no status show no dot.', isReady: true, aspectRatio: 16 / 9, componentsUsed: ['Table'], diff --git a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx index 3bc21511a3d5..21ab28b49173 100644 --- a/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx +++ b/packages/cli/templates/blocks/components/Table/TableRowStatusTable.tsx @@ -34,13 +34,13 @@ const columns: TableColumn[] = [ function jobStatus(job: Job): TableRowStatus | null { switch (job.state) { case 'failed': - return {color: 'var(--color-icon-red)', label: 'Failed'}; + return {color: 'error', icon: 'error', label: 'Failed'}; case 'running': - return {color: 'var(--color-icon-orange)', label: 'Running'}; + return {color: 'warning', icon: 'warning', label: 'Running'}; case 'queued': - return {color: 'var(--color-icon-secondary)', label: 'Queued'}; + return {color: 'gray', label: 'Queued'}; default: - return null; // succeeded → no bar + return null; // succeeded: no dot } } diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx index 52312b050fba..c1a0a4828ddb 100644 --- a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx @@ -52,16 +52,23 @@ describe('useTableRowStatus', () => { expect(headers[0].textContent).toBe(''); }); - it('renders a labeled bar for rows with a status', () => { + it('renders a labeled dot for rows with a status', () => { render(); expect(screen.getByRole('img', {name: 'Error'})).toBeInTheDocument(); expect(screen.getByRole('img', {name: 'Warning'})).toBeInTheDocument(); }); + it('renders a dot (not an icon) by default', () => { + render(); + // Default (no icon) renders a plain colored dot: no svg in the indicator. + const dot = screen.getByRole('img', {name: 'Error'}); + expect(dot.querySelector('svg')).toBeNull(); + }); + it('renders no indicator for rows returning null', () => { render(); const rows = screen.getAllByRole('row'); - // rows[2] is Bob (state ok) — no status indicator in his status cell. + // rows[2] is Bob (state ok): no status indicator in his status cell. const bob = rows[2]; expect(within(bob).getByText('Bob')).toBeInTheDocument(); expect(within(bob).queryByRole('img')).not.toBeInTheDocument(); @@ -69,9 +76,11 @@ describe('useTableRowStatus', () => { it('maps a semantic color name to its icon color token', () => { render(); - const errorBar = screen.getByRole('img', {name: 'Error'}); - // 'red' resolves to var(--color-icon-red); StyleX emits it on inline style. - expect(errorBar.getAttribute('style')).toContain('--color-icon-red'); + const errorDot = screen.getByRole('img', {name: 'Error'}); + // 'red' resolves to var(--color-icon-red); StyleX emits it on the inline + // style of the inner dot element. + const dot = errorDot.querySelector('span'); + expect(dot?.getAttribute('style')).toContain('--color-icon-red'); }); it('passes through a raw CSS color as an escape hatch', () => { @@ -82,8 +91,9 @@ describe('useTableRowStatus', () => { } />, ); - const bar = screen.getByRole('img', {name: 'Raw'}); - expect(bar.getAttribute('style')).toContain('rgb(1, 2, 3)'); + const indicator = screen.getByRole('img', {name: 'Raw'}); + const dot = indicator.querySelector('span'); + expect(dot?.getAttribute('style')).toContain('rgb(1, 2, 3)'); }); it('renders an icon as the status signifier when icon is provided', () => { @@ -99,19 +109,19 @@ describe('useTableRowStatus', () => { // Icon-mode still exposes the accessible label via role=img. const indicator = screen.getByRole('img', {name: 'Error'}); expect(indicator).toBeInTheDocument(); - // An SVG icon is rendered inside the indicator (bar mode has no svg). + // An SVG icon is rendered inside the indicator (dot mode has no svg). expect(indicator.querySelector('svg')).not.toBeNull(); }); - it('sets role=img only when a label is provided (bar mode)', () => { + it('sets role=img only when a label is provided (dot mode)', () => { render( (item.state === 'error' ? {color: 'red'} : null)} />, ); - // No label → no accessible name → not exposed as an img role. + // No label means no accessible name, so it is not exposed as an img role. expect(screen.queryByRole('img')).not.toBeInTheDocument(); - // But the row still renders (Alice present) with a color-only bar. + // But the row still renders (Alice present) with a color-only dot. expect(screen.getByText('Alice')).toBeInTheDocument(); }); diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx index 7e32367e0b7f..dcedf6de8b2a 100644 --- a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx @@ -63,15 +63,16 @@ const ICON_COLOR_BY_STATUS: Record = { /** * A row's status indicator. `color` accepts a semantic status color * (mapped to a theme token) or any raw CSS color string as an escape hatch. - * Provide `icon` to signal status by shape as well as color — more accessible - * when several statuses coexist. Provide `label` for an accessible name - * (strongly recommended; without it the indicator is color-only). Return - * `null` for rows with no status. + * By default the plugin renders a colored status dot. Provide `icon` to signal + * status by shape as well as color, which is more accessible when several + * statuses coexist. Provide `label` for an accessible name (strongly + * recommended; without it the indicator is color-only). Return `null` for + * rows with no status. */ export interface TableRowStatus { /** Semantic status color (preferred) or a raw CSS color string. */ color: TableRowStatusColor | (string & {}); - /** Optional icon rendered as the signifier (shape as an a11y differentiator). */ + /** Optional icon rendered as the signifier instead of the dot (shape as an a11y differentiator). */ icon?: IconName; /** Accessible label; announced via role="img". Recommended. */ label?: string; @@ -92,9 +93,8 @@ export interface UseTableRowStatusConfig> { getStatus: (item: T) => TableRowStatus | null; } -// Bar mode overlays a 4px bar on the leading edge (needs almost no width); -// icon mode needs room for the glyph. Reserve icon width so a mixed table -// never clips — the extra padding is negligible for bar-only tables. +// The status column holds a small centered dot (or an icon when provided). +// A fixed narrow width keeps every row's indicator aligned in one gutter. const STATUS_COLUMN_WIDTH = {type: 'pixel' as const, value: 28}; /** Resolve a semantic color name to a token, or pass a raw CSS color through. */ @@ -103,31 +103,25 @@ function resolveColor(color: string): string { } const styles = stylex.create({ - // The status cell hosts a full-height bar. Zero its own padding so the bar - // reaches the row's vertical edges, and anchor the absolutely-positioned bar. - cell: { - position: 'relative', - paddingInline: 0, - paddingBlock: 0, - }, - bar: (color: string) => ({ - position: 'absolute', - insetBlock: 0, - insetInlineStart: 0, - width: '4px', - backgroundColor: color, - }), - iconWrap: { + // Centers the dot or icon within the narrow status column. + wrap: { display: 'flex', alignItems: 'center', justifyContent: 'center', }, + dot: (color: string) => ({ + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: color, + flexShrink: 0, + }), }); /** * Returns a {@link TablePlugin} that prepends a narrow column signaling per-row - * status: a full-height colored bar on the leading edge, or an icon when - * `icon` is provided (shape + color is more accessible than color alone). + * status: a colored status dot by default, or an icon when `icon` is provided + * (shape + color is more accessible than color alone). * * @example * ``` @@ -165,7 +159,7 @@ export function useTableRowStatus>( 'primary'; return ( @@ -174,24 +168,20 @@ export function useTableRowStatus>( ); } return ( -
+ title={status.label}> + + ); }, }; return [statusColumn, ...columns]; }, - - transformBodyCell(props, column) { - if (column.key !== '__rowStatus') { - return props; - } - return {...props, styles: [...props.styles, styles.cell]}; - }, }), [getStatus], ); diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs index 23753c31a387..bc4f849cbae8 100644 --- a/packages/core/src/Table/useTableRowStatus.doc.mjs +++ b/packages/core/src/Table/useTableRowStatus.doc.mjs @@ -7,13 +7,13 @@ export const docs = { subComponentOf: 'Table', displayName: 'useTableRowStatus', description: - 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status — a full-height colored bar on the leading edge, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and an optional accessible label; return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.', + 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and an optional accessible label; return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.', props: [ { name: 'getStatus', type: '(item: T) => { color: TableRowStatusColor | string; icon?: IconName; label?: string } | null', description: - 'Derive the status indicator for a row: a semantic color (accent/success/error/warning/red/orange/green/yellow/blue/gray, mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape (recommended when multiple statuses coexist); and an optional accessible label (announced via role="img" — recommended, since without it the indicator is color-only). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', + 'Derive the status indicator for a row: a semantic color (accent/success/error/warning/red/orange/green/yellow/blue/gray, mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist); and an optional accessible label (announced via role="img", recommended, since without it the indicator is color-only). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', required: true, }, ], @@ -22,7 +22,7 @@ export const docs = { /** @type {import('../docs-types').TranslationDoc} */ export const docsDense = { description: - 'Returns a TablePlugin that prepends a narrow per-row status column — a colored bar, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label?} or null. color is a semantic name (success/error/warning/…) mapped to a theme token, or a raw CSS color. Memoize getStatus with useCallback.', + 'Returns a TablePlugin that prepends a narrow per-row status column: a colored dot, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label?} or null. color is a semantic name (success/error/warning/etc.) mapped to a theme token, or a raw CSS color. Memoize getStatus with useCallback.', propDescriptions: { getStatus: 'Map a row to {color, icon?, label?} or null. color = semantic status name (mapped to a token) or raw CSS; icon = shape signifier (a11y); label = accessible name (recommended). Memoize with useCallback.', From 90a6088f25f6ba1479fdedc7d99c2ae76d7ad7b5 Mon Sep 17 00:00:00 2001 From: Humberto Virtudes Date: Tue, 21 Jul 2026 15:12:31 +0800 Subject: [PATCH 4/5] fix(core): require label and use Tooltip in useTableRowStatus Make the accessible label required so a row status is never conveyed by color alone, and render it through Tooltip instead of the native title attribute for visual consistency. Addresses review feedback. --- .../rowStatus/useTableRowStatus.test.tsx | 14 +++-- .../plugins/rowStatus/useTableRowStatus.tsx | 55 ++++++++++--------- .../core/src/Table/useTableRowStatus.doc.mjs | 10 ++-- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx index c1a0a4828ddb..7f268b3ab0ea 100644 --- a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.test.tsx @@ -113,15 +113,19 @@ describe('useTableRowStatus', () => { expect(indicator.querySelector('svg')).not.toBeNull(); }); - it('sets role=img only when a label is provided (dot mode)', () => { + it('exposes the required label as the accessible name in dot mode', () => { render( (item.state === 'error' ? {color: 'red'} : null)} + statusFn={item => + item.state === 'error' ? {color: 'red', label: 'Error'} : null + } />, ); - // No label means no accessible name, so it is not exposed as an img role. - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - // But the row still renders (Alice present) with a color-only dot. + // Label is required, so every dot is announced via role=img with its name. + const indicator = screen.getByRole('img', {name: 'Error'}); + expect(indicator).toBeInTheDocument(); + // Dot mode renders no svg (that is icon mode). + expect(indicator.querySelector('svg')).toBeNull(); expect(screen.getByText('Alice')).toBeInTheDocument(); }); diff --git a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx index dcedf6de8b2a..870c0d41916e 100644 --- a/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx +++ b/packages/core/src/Table/plugins/rowStatus/useTableRowStatus.tsx @@ -15,6 +15,7 @@ import {useMemo} from 'react'; import * as stylex from '@stylexjs/stylex'; import {Icon, type IconColor, type IconName} from '../../../Icon'; +import {Tooltip} from '../../../Tooltip'; import type {TableColumn, TablePlugin} from '../../types'; /** @@ -65,17 +66,21 @@ const ICON_COLOR_BY_STATUS: Record = { * (mapped to a theme token) or any raw CSS color string as an escape hatch. * By default the plugin renders a colored status dot. Provide `icon` to signal * status by shape as well as color, which is more accessible when several - * statuses coexist. Provide `label` for an accessible name (strongly - * recommended; without it the indicator is color-only). Return `null` for - * rows with no status. + * statuses coexist. `label` is required so the status is never conveyed by + * color alone — it names the indicator for assistive technology and shows on + * hover. Return `null` for rows with no status. */ export interface TableRowStatus { /** Semantic status color (preferred) or a raw CSS color string. */ color: TableRowStatusColor | (string & {}); /** Optional icon rendered as the signifier instead of the dot (shape as an a11y differentiator). */ icon?: IconName; - /** Accessible label; announced via role="img". Recommended. */ - label?: string; + /** + * Accessible name for the status, announced to assistive technology and + * shown in a tooltip on hover. Required: a status must never be conveyed by + * color alone. + */ + label: string; } /** Configuration for {@link useTableRowStatus}. */ @@ -152,31 +157,27 @@ export function useTableRowStatus>( if (!status) { return null; } - const role = status.label ? 'img' : undefined; - if (status.icon) { - const iconColor: IconColor = - ICON_COLOR_BY_STATUS[status.color as TableRowStatusColor] ?? - 'primary'; - return ( + const signifier = status.icon ? ( + + ) : ( + + ); + return ( + - + role="img" + aria-label={status.label}> + {signifier} - ); - } - return ( - - - + ); }, }; diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs index bc4f849cbae8..ae63baf982cf 100644 --- a/packages/core/src/Table/useTableRowStatus.doc.mjs +++ b/packages/core/src/Table/useTableRowStatus.doc.mjs @@ -7,13 +7,13 @@ export const docs = { subComponentOf: 'Table', displayName: 'useTableRowStatus', description: - 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and an optional accessible label; return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.', + 'Hook that returns a TablePlugin which prepends a narrow column signaling per-row status: a colored status dot by default, or an icon when provided (shape + color is more accessible than color alone). getStatus maps a row to a semantic color (mapped to a theme token) or raw CSS color, an optional icon, and a required accessible label (shown in a tooltip on hover and announced to assistive technology, so status is never color-only); return null for no indicator. Memoize getStatus with useCallback for a stable plugin identity.', props: [ { name: 'getStatus', - type: '(item: T) => { color: TableRowStatusColor | string; icon?: IconName; label?: string } | null', + type: '(item: T) => { color: TableRowStatusColor | string; icon?: IconName; label: string } | null', description: - 'Derive the status indicator for a row: a semantic color (accent/success/error/warning/red/orange/green/yellow/blue/gray, mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist); and an optional accessible label (announced via role="img", recommended, since without it the indicator is color-only). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', + 'Derive the status indicator for a row: a semantic color (accent/success/error/warning/red/orange/green/yellow/blue/gray, mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist); and a required accessible label (announced via role="img" and shown in a tooltip on hover, so a status is never conveyed by color alone). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', required: true, }, ], @@ -22,9 +22,9 @@ export const docs = { /** @type {import('../docs-types').TranslationDoc} */ export const docsDense = { description: - 'Returns a TablePlugin that prepends a narrow per-row status column: a colored dot, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label?} or null. color is a semantic name (success/error/warning/etc.) mapped to a theme token, or a raw CSS color. Memoize getStatus with useCallback.', + 'Returns a TablePlugin that prepends a narrow per-row status column: a colored dot, or an icon when provided (shape + color beats color alone for a11y). getStatus maps a row to {color, icon?, label} or null. color is a semantic name (success/error/warning/etc.) mapped to a theme token, or a raw CSS color. label is required (tooltip + accessible name). Memoize getStatus with useCallback.', propDescriptions: { getStatus: - 'Map a row to {color, icon?, label?} or null. color = semantic status name (mapped to a token) or raw CSS; icon = shape signifier (a11y); label = accessible name (recommended). Memoize with useCallback.', + 'Map a row to {color, icon?, label} or null. color = semantic status name (mapped to a token) or raw CSS; icon = shape signifier (a11y); label = required accessible name (tooltip + role="img"). Memoize with useCallback.', }, }; From 666e96d19ad45fe384987d97d4119b2153a3a8bc Mon Sep 17 00:00:00 2001 From: Humberto Virtudes Date: Thu, 23 Jul 2026 16:47:45 +0800 Subject: [PATCH 5/5] docs(core): inline literal values in useTableRowStatus getStatus doc Surface the legal color values and icon names in the getStatus prop doc instead of naming the TableRowStatusColor and IconName aliases, so a reader without an IDE can see what is valid. --- packages/core/src/Table/useTableRowStatus.doc.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Table/useTableRowStatus.doc.mjs b/packages/core/src/Table/useTableRowStatus.doc.mjs index ae63baf982cf..b3e051ecaaf5 100644 --- a/packages/core/src/Table/useTableRowStatus.doc.mjs +++ b/packages/core/src/Table/useTableRowStatus.doc.mjs @@ -11,9 +11,9 @@ export const docs = { props: [ { name: 'getStatus', - type: '(item: T) => { color: TableRowStatusColor | string; icon?: IconName; label: string } | null', + type: "(item: T) => { color: 'accent' | 'success' | 'error' | 'warning' | 'red' | 'orange' | 'green' | 'yellow' | 'blue' | 'gray' | string; icon?: IconName; label: string } | null", description: - 'Derive the status indicator for a row: a semantic color (accent/success/error/warning/red/orange/green/yellow/blue/gray, mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist); and a required accessible label (announced via role="img" and shown in a tooltip on hover, so a status is never conveyed by color alone). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', + 'Derive the status indicator for a row: a semantic color (accent, success, error, warning, red, orange, green, yellow, blue, gray — mapped to a theme token) or a raw CSS color as an escape hatch; an optional icon to signal status by shape instead of the dot (recommended when multiple statuses coexist; valid names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone); and a required accessible label (announced via role="img" and shown in a tooltip on hover, so a status is never conveyed by color alone). Return null for rows with no status. Memoize with useCallback for a stable plugin identity.', required: true, }, ],