Skip to content
Open
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
25 changes: 15 additions & 10 deletions src/components/EnhancedTable/EnhancedTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { defaultComparator } from './sorter';
* loses its boundary here and edits trigger a full page reload.
*/
export default function EnhancedTable<RecordType extends object = any>(props: EnhancedTableProps<RecordType>) {
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.
Expand Down Expand Up @@ -49,16 +49,21 @@ export default function EnhancedTable<RecordType extends object = any>(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
Expand All @@ -74,7 +79,7 @@ export default function EnhancedTable<RecordType extends object = any>(props: En
...actionColumn,
render: (_value: unknown, record: RecordType, index: number) => {
const cfg = rowActionsRef.current?.(record, index);
return cfg ? <RowActionCell actions={cfg} maxIcons={actionMaxIcons} /> : null;
return cfg ? <RowActionCell actions={cfg} forceKebab={kebabMode} /> : null;
},
};
if (typeof opColumn.width === 'number' && contentWidth > opColumn.width) {
Expand All @@ -84,7 +89,7 @@ export default function EnhancedTable<RecordType extends object = any>(props: En
}

return allColumns;
}, [columns, actionColumn, hasRowActions, autoSortColumns, actionMaxIcons, dataSource]);
}, [columns, actionColumn, hasRowActions, autoSortColumns, dataSource]);

return (
<Table<RecordType>
Expand Down
67 changes: 50 additions & 17 deletions src/components/EnhancedTable/RowActionCell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,47 +25,80 @@ describe('splitRowActions', () => {
const act = (key: string, extra: Partial<RowAction> = {}): 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([]);
});
});
39 changes: 28 additions & 11 deletions src/components/EnhancedTable/RowActionCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
}

Expand Down Expand Up @@ -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);
Expand Down
13 changes: 9 additions & 4 deletions src/components/EnhancedTable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand All @@ -36,8 +43,6 @@ export interface EnhancedTableProps<RecordType> extends TableProps<RecordType> {
actionColumn?: Partial<ColumnType<RecordType>>;
/** 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;
}