feat: add a table to the plugin UI kit for line items and totals

This commit is contained in:
Linus Rath
2026-10-29 23:33:25 +01:00
parent 23767b9c80
commit fb751a30df
3 changed files with 106 additions and 2 deletions
+29
View File
@@ -127,6 +127,35 @@ describe('plugin UI kit components', () => {
expect(container.querySelector('dd')).toHaveTextContent('2 MB');
});
it('draws a table with amounts aligned to the end and totals under the last column', () => {
render(<kit.Table
label="Items"
columns={[
{ key: 'name', label: 'Item' },
{ key: 'qty', label: 'Qty', align: 'end', hideNarrow: true },
{ key: 'net', label: 'Net', align: 'end' },
]}
rows={[{ key: 'a', name: 'Consulting', qty: '8 h', net: '800.00' }, { name: 'Travel', qty: '1', net: '120.50' }]}
summary={[{ label: 'VAT 19 %', value: '174.80' }, { label: 'Total', value: '1,095.30', strong: true }]}
/>);
const table = screen.getByRole('table', { name: 'Items' });
expect(screen.getAllByRole('columnheader').map((h) => h.textContent)).toEqual(['Item', 'Qty', 'Net']);
const travel = screen.getByText('Travel').closest('tr')!;
expect([...travel.children].map((c) => c.textContent)).toEqual(['Travel', '1', '120.50']);
expect(screen.getByText('120.50').className).toContain('text-end');
expect(screen.getByText('1').className).toContain('hidden sm:table-cell');
const total = screen.getByRole('rowheader', { name: 'Total' });
expect(total.getAttribute('colspan')).toBe('2');
expect(total.closest('tr')!.lastElementChild!).toHaveTextContent('1,095.30');
expect(total.className).toContain('font-semibold');
expect(table.closest('.bk')).not.toBeNull();
});
it('says so when a table has no rows', () => {
render(<kit.Table columns={[{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]} rows={[]} empty="No items" />);
expect(screen.getByText('No items').getAttribute('colspan')).toBe('2');
});
it('makes sidebar rows as tall as folder rows on touch screens', () => {
// The page sets --touch-row-min-h to 44px below lg, where folder rows
// are max-lg:min-h-[44px]; the frame can't see the page's width itself.
+75
View File
@@ -1363,6 +1363,81 @@ export function DetailRow({ label, children, mono = false }: { label: React.Reac
);
}
export interface KitTableColumn {
/** The key of the cell in each row. */
key: string;
label: React.ReactNode;
/** `end` for amounts and numbers: right-aligned (left in RTL), in tabular figures. */
align?: 'start' | 'end';
/** Hidden when the frame is narrower than 640 px (a phone). */
hideNarrow?: boolean;
/** A fixed width such as `6rem`; the other columns share the rest. */
width?: string;
}
export interface KitTableSummaryRow {
label: React.ReactNode;
value: React.ReactNode;
/** The line that matters most, such as the amount due. */
strong?: boolean;
}
/**
* A table of data: a header row, one row per entry, and summary rows under
* it whose label lines up with the last column (totals of an invoice).
* Rows are objects keyed by column key; `key` names the row for React.
* Wider than the frame, it scrolls sideways.
*/
export function Table({ columns, rows, summary, label, empty }: {
columns: KitTableColumn[];
rows: (Record<string, React.ReactNode> & { key?: string | number })[];
summary?: KitTableSummaryRow[];
/** The table's name for screen readers. */
label?: string;
/** Shown in place of the rows when there are none. */
empty?: React.ReactNode;
}) {
const cell = (c: KitTableColumn) => cn(
'px-3 align-top',
c.align === 'end' ? 'text-end tabular-nums whitespace-nowrap' : 'text-start',
c.hideNarrow && 'hidden sm:table-cell',
);
const visibleSpan = columns.length - 1;
return (
<div className="bk overflow-x-auto">
<table className="w-full border-collapse text-sm" aria-label={label}>
<thead>
<tr className="border-b border-border text-xs font-medium text-muted-foreground">
{columns.map((c) => (
<th key={c.key} scope="col" className={cn(cell(c), 'py-2 font-medium')} style={c.width ? { width: c.width } : undefined}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && empty !== undefined ? (
<tr><td colSpan={columns.length} className="px-3 py-3 text-muted-foreground">{empty}</td></tr>
) : rows.map((row, i) => (
<tr key={row.key ?? i} className="border-b border-border/60">
{columns.map((c) => <td key={c.key} className={cn(cell(c), 'py-2 text-foreground break-words')}>{row[c.key]}</td>)}
</tr>
))}
</tbody>
{summary && summary.length > 0 && (
<tfoot>
{summary.map((s, i) => (
<tr key={i} className={cn(s.strong && 'border-t border-border')}>
{/* On phones the hideNarrow columns are gone; the label still spans what is left. */}
<th scope="row" colSpan={visibleSpan} className={cn('px-3 text-end font-normal', s.strong ? 'py-2 font-semibold text-foreground' : 'py-1 text-muted-foreground')}>{s.label}</th>
<td className={cn('px-3 text-end tabular-nums whitespace-nowrap', s.strong ? 'py-2 font-semibold text-foreground' : 'py-1 text-foreground')}>{s.value}</td>
</tr>
))}
</tfoot>
)}
</table>
</div>
);
}
/**
* A small round dot in a colour the data carries, like a calendar's or a
* tag's colour. Only #rgb/#rrggbb values are used; anything else shows the
+2 -2
View File
@@ -4,13 +4,13 @@
// plugin-ui-kit.test.ts checks this list against the kit.
/** Bumped when a component is added or its props change in a way plugins can see. */
export const KIT_VERSION = 2;
export const KIT_VERSION = 3;
export const KIT_NAMES: readonly string[] = Object.freeze([
'Alert', 'Badge', 'BarButton', 'Breadcrumbs', 'Button', 'Card', 'Checkbox', 'Chip', 'ColorDot', 'Combobox',
'DetailHeading', 'DetailList', 'DetailRow', 'DialogButtons', 'DiffView', 'Divider', 'EmptyState', 'Field',
'FileIcon', 'Heading', 'Icon', 'InfoBar', 'Input', 'KIT_VERSION', 'Link', 'LinkButton', 'List', 'ListRow',
'Loading', 'Page', 'PageHeader', 'Panel', 'ProgressBar', 'Row', 'RowAction', 'SearchInput', 'Section',
'Segmented', 'Select', 'SettingRow', 'SidebarEmpty', 'SidebarRow', 'Spinner', 'Stack', 'Switch', 'Tabs',
'Segmented', 'Select', 'SettingRow', 'SidebarEmpty', 'SidebarRow', 'Spinner', 'Stack', 'Switch', 'Table', 'Tabs',
'Text', 'Textarea', 'Toolbar', 'ToolbarButton', 'ToolbarSpacer', 'useSlotProps',
]);