diff --git a/src/components/EnhancedTable/EnhancedTable.tsx b/src/components/EnhancedTable/EnhancedTable.tsx index d89626697..b4cb138e1 100644 --- a/src/components/EnhancedTable/EnhancedTable.tsx +++ b/src/components/EnhancedTable/EnhancedTable.tsx @@ -18,7 +18,7 @@ import { defaultComparator } from './sorter'; * loses its boundary here and edits trigger a full page reload. */ export default function EnhancedTable(props: EnhancedTableProps) { - const { rowActions, actionColumn, columns, className, dataSource, compactHeader, autoSortColumns, actionMaxIcons, pagination, ...rest } = props; + const { rowActions, actionColumn, columns, className, dataSource, compactHeader, autoSortColumns, pagination, ...rest } = props; // Every paginated table gets the quick jumper, regardless of whether the caller spreads // usePagination. `pagination={false}` stays off, and an explicit caller value still wins. @@ -49,16 +49,21 @@ export default function EnhancedTable(props: En }); if (hasRowActions) { - // Auto-widen the action column so expanded icon rows never overflow legacy - // kebab-era widths: scan the rows for the widest icon layout (cheap builder - // calls; capped to bound client-side-paginated datasets). An explicit - // `actionColumn.width` still wins when it is larger. + // Scan the rows (cheap builder calls; capped to bound client-side-paginated + // datasets) to keep the whole table on one layout and one width: + // - kebabMode: if any row needs a kebab, every row renders in kebab layout, + // so icons align vertically and the kebab sits in a fixed position. + // - contentWidth: auto-widen the action column so expanded icon rows never + // overflow legacy kebab-era widths; explicit `actionColumn.width` still + // wins when it is larger. let contentWidth = 0; + let kebabMode = false; if (Array.isArray(dataSource)) { - dataSource.slice(0, 200).forEach((record, index) => { - const cfg = rowActionsRef.current?.(record, index); + const rowCfgs = dataSource.slice(0, 200).map((record, index) => rowActionsRef.current?.(record, index)); + kebabMode = rowCfgs.some((cfg) => cfg && splitRowActions(cfg).kebab.length > 0); + rowCfgs.forEach((cfg) => { if (!cfg) return; - const { icons, kebab } = splitRowActions(cfg, actionMaxIcons); + const { icons, kebab } = splitRowActions(cfg, kebabMode); const items = icons.length + (kebab.length ? 1 : 0); if (!items) return; // cell padding 16 + 24px per icon + 28px kebab trigger + 4px gaps @@ -74,7 +79,7 @@ export default function EnhancedTable(props: En ...actionColumn, render: (_value: unknown, record: RecordType, index: number) => { const cfg = rowActionsRef.current?.(record, index); - return cfg ? : null; + return cfg ? : null; }, }; if (typeof opColumn.width === 'number' && contentWidth > opColumn.width) { @@ -84,7 +89,7 @@ export default function EnhancedTable(props: En } return allColumns; - }, [columns, actionColumn, hasRowActions, autoSortColumns, actionMaxIcons, dataSource]); + }, [columns, actionColumn, hasRowActions, autoSortColumns, dataSource]); return ( diff --git a/src/components/EnhancedTable/RowActionCell.test.ts b/src/components/EnhancedTable/RowActionCell.test.ts index 18c7bf027..ca6a614f4 100644 --- a/src/components/EnhancedTable/RowActionCell.test.ts +++ b/src/components/EnhancedTable/RowActionCell.test.ts @@ -25,47 +25,80 @@ describe('splitRowActions', () => { const act = (key: string, extra: Partial = {}): RowAction => ({ key, ...extra }); const keys = (list: RowAction[]) => list.map((a) => a.key); - it('expands kebab actions into icons when the row fits the limit, danger last', () => { + it('expands a row within the limit entirely into icons, danger last, no kebab', () => { const actions: RowActions = { inline: [act('run')], - menu: [act('delete', { danger: true }), act('edit'), act('copy')], + menu: [act('delete', { danger: true }), act('edit')], }; const { icons, kebab } = splitRowActions(actions); - expect(keys(icons)).toEqual(['run', 'edit', 'copy', 'delete']); + expect(keys(icons)).toEqual(['run', 'edit', 'delete']); expect(kebab).toEqual([]); }); - it('keeps the full kebab when the row exceeds the limit', () => { + it('surfaces at most 2 icons and collapses the rest when the row exceeds the limit', () => { const actions: RowActions = { inline: [act('run')], - menu: [act('edit'), act('copy'), act('export'), act('delete', { danger: true })], + menu: [act('history'), act('edit'), act('copy'), act('delete', { danger: true })], }; const { icons, kebab } = splitRowActions(actions); - expect(keys(icons)).toEqual(['run']); - expect(keys(kebab)).toEqual(['edit', 'copy', 'export', 'delete']); + expect(keys(icons)).toEqual(['run', 'history']); + expect(keys(kebab)).toEqual(['edit', 'copy', 'delete']); }); - it('honors a custom limit', () => { - const actions: RowActions = { menu: [act('edit'), act('copy'), act('export'), act('offline'), act('delete')] }; - expect(keys(splitRowActions(actions, 5).icons)).toEqual(['edit', 'copy', 'export', 'offline', 'delete']); - expect(keys(splitRowActions(actions, 2).kebab)).toEqual(['edit', 'copy', 'export', 'offline', 'delete']); + it('sinks danger items into the kebab instead of promoting them', () => { + const actions: RowActions = { + menu: [act('delete', { danger: true }), act('edit'), act('copy'), act('export')], + }; + const { icons, kebab } = splitRowActions(actions); + expect(keys(icons)).toEqual(['edit', 'copy']); + expect(keys(kebab)).toEqual(['delete', 'export']); + }); + + it('skips collapsed items during promotion so pinned low-frequency actions stay inside', () => { + const actions: RowActions = { + inline: [act('run')], + menu: [act('history', { collapsed: true }), act('edit'), act('copy'), act('export', { collapsed: true }), act('delete', { danger: true })], + }; + const { icons, kebab } = splitRowActions(actions); + expect(keys(icons)).toEqual(['run', 'edit']); + expect(keys(kebab)).toEqual(['history', 'copy', 'export', 'delete']); }); - it('pins node and collapsed items in the kebab while the rest expand', () => { + it('forces a kebab when a node item exists, even within the limit', () => { const actions: RowActions = { - menu: [act('edit'), act('bespoke', { node: 'x' }), act('reset', { collapsed: true }), act('delete', { danger: true })], + menu: [act('edit'), act('bespoke', { node: 'x' }), act('copy'), act('delete', { danger: true })], }; const { icons, kebab } = splitRowActions(actions); - expect(keys(icons)).toEqual(['edit', 'delete']); - expect(keys(kebab)).toEqual(['bespoke', 'reset']); + expect(keys(icons)).toEqual(['edit', 'copy']); + expect(keys(kebab)).toEqual(['bespoke', 'delete']); + }); + + it('forceKebab collapses a row that would otherwise fit, keeping table layouts uniform', () => { + const actions: RowActions = { + menu: [act('edit'), act('copy'), act('delete', { danger: true })], + }; + const fits = splitRowActions(actions); + expect(keys(fits.icons)).toEqual(['edit', 'copy', 'delete']); + expect(fits.kebab).toEqual([]); + + const forced = splitRowActions(actions, true); + expect(keys(forced.icons)).toEqual(['edit', 'copy']); + expect(keys(forced.kebab)).toEqual(['delete']); + }); + + it('honors a custom limit', () => { + const actions: RowActions = { menu: [act('edit'), act('copy'), act('export'), act('offline')] }; + expect(keys(splitRowActions(actions, false, 4).icons)).toEqual(['edit', 'copy', 'export', 'offline']); + expect(keys(splitRowActions(actions, false, 2).icons)).toEqual(['edit', 'copy']); + expect(keys(splitRowActions(actions, false, 2).kebab)).toEqual(['export', 'offline']); }); it('ignores hidden actions when counting against the limit', () => { const actions: RowActions = { - menu: [act('edit'), act('copy'), act('export', { visible: false }), act('offline'), act('delete', { danger: true })], + menu: [act('edit'), act('copy', { visible: false }), act('offline'), act('delete', { danger: true })], }; const { icons, kebab } = splitRowActions(actions); - expect(keys(icons)).toEqual(['edit', 'copy', 'offline', 'delete']); + expect(keys(icons)).toEqual(['edit', 'offline', 'delete']); expect(kebab).toEqual([]); }); }); diff --git a/src/components/EnhancedTable/RowActionCell.tsx b/src/components/EnhancedTable/RowActionCell.tsx index 5c17a89a2..11b903709 100644 --- a/src/components/EnhancedTable/RowActionCell.tsx +++ b/src/components/EnhancedTable/RowActionCell.tsx @@ -8,25 +8,42 @@ import type { RowAction, RowActions } from './types'; const visibleOnly = (list?: RowAction[]) => (list || []).filter((a) => a.visible !== false); -export const DEFAULT_ACTION_MAX_ICONS = 4; +export const DEFAULT_ACTION_MAX_ICONS = 3; +// Once a kebab exists, cap surfaced icons at 2 so heavy rows stay compact. +const MAX_SURFACED_ICONS = 2; /** * Split a row's actions into surfaced icon buttons and kebab leftovers. - * Kebab actions expand into icon buttons when the whole row fits within `maxIcons` - * (danger items last); `node` and `collapsed: true` items always stay in the kebab. - * Rows exceeding the limit keep today's layout: inline icons + full kebab. + * A row with no `node`/`collapsed: true` items and at most `maxIcons` actions + * expands entirely into icon buttons (danger items last), with no kebab. + * Any other row gets a kebab: `inline` items stay surfaced, non-danger menu + * items are promoted until 2 icons show, and everything else — including all + * danger items — goes into the kebab (menu order preserved). + * `forceKebab` puts a row in kebab layout even when it would fit expanded: + * EnhancedTable sets it when any row of the table needs a kebab, so all rows + * of one table share the same layout (light rows may then hold a single + * kebab item — the accepted price of column-aligned consistency). */ -export function splitRowActions(actions: RowActions, maxIcons = DEFAULT_ACTION_MAX_ICONS) { +export function splitRowActions(actions: RowActions, forceKebab = false, maxIcons = DEFAULT_ACTION_MAX_ICONS) { const inline = visibleOnly(actions.inline); const menu = visibleOnly(actions.menu); const pinned = menu.filter((a) => a.node || a.collapsed); const expandable = menu.filter((a) => !a.node && !a.collapsed); - if (inline.length + expandable.length > maxIcons) { - return { icons: inline, kebab: menu }; + if (!forceKebab && !pinned.length && inline.length + expandable.length <= maxIcons) { + return { + icons: [...inline, ...expandable.filter((a) => !a.danger), ...expandable.filter((a) => a.danger)], + kebab: [] as RowAction[], + }; + } + const promoted: RowAction[] = []; + for (const action of expandable) { + if (inline.length + promoted.length >= MAX_SURFACED_ICONS) break; + if (action.danger) continue; + promoted.push(action); } return { - icons: [...inline, ...expandable.filter((a) => !a.danger), ...expandable.filter((a) => a.danger)], - kebab: pinned, + icons: [...inline, ...promoted], + kebab: menu.filter((a) => !promoted.includes(a)), }; } @@ -120,9 +137,9 @@ function renderMenuItem(action: RowAction, key: string, onAction: () => void) { ); } -export function RowActionCell({ actions, maxIcons }: { actions: RowActions; maxIcons?: number }) { +export function RowActionCell({ actions, forceKebab }: { actions: RowActions; forceKebab?: boolean }) { const [menuOpen, setMenuOpen] = useState(false); - const { icons, kebab } = splitRowActions(actions, maxIcons); + const { icons, kebab } = splitRowActions(actions, forceKebab); if (!icons.length && !kebab.length) return null; const normal = kebab.filter((a) => !a.danger); diff --git a/src/components/EnhancedTable/types.ts b/src/components/EnhancedTable/types.ts index 5e4210523..b655b483c 100644 --- a/src/components/EnhancedTable/types.ts +++ b/src/components/EnhancedTable/types.ts @@ -23,9 +23,16 @@ export interface RowAction { } export interface RowActions { - /** surfaced as icon buttons, left of the kebab */ + /** signature actions, always surfaced as icon buttons (left of secondary actions) */ inline?: RowAction[]; - /** expanded into icon buttons when the row fits `actionMaxIcons`; kept in the kebab menu otherwise */ + /** + * Secondary actions. Presentation belongs to the component, not the caller: + * when every row of the table has at most 3 actions, all rows expand + * entirely into icon buttons; otherwise the whole table switches to kebab + * layout — each row surfaces at most 2 icons and collapses the rest (danger + * items included) into the kebab, so all rows stay aligned. Listing an action + * here no longer means it renders collapsed — set `collapsed: true` to force that. + */ menu?: RowAction[]; } @@ -36,8 +43,6 @@ export interface EnhancedTableProps extends TableProps { actionColumn?: Partial>; /** compact header: tighter thead padding + smaller sort hit-area, for tables embedded inside tabs/cards */ compactHeader?: boolean; - /** max icon buttons per row; rows exceeding it keep kebab actions collapsed (default 4) */ - actionMaxIcons?: number; /** auto-inject default sorter for columns without `sorter` (default false); column `sorter` always wins */ autoSortColumns?: boolean; }