diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx index 08746956e..f11c92a9d 100644 --- a/lib/__tests__/plugin-ui-kit.test.tsx +++ b/lib/__tests__/plugin-ui-kit.test.tsx @@ -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(); + 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(); + 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. diff --git a/lib/plugin-sandbox/ui-kit/components.tsx b/lib/plugin-sandbox/ui-kit/components.tsx index b564f5989..a669a2e38 100644 --- a/lib/plugin-sandbox/ui-kit/components.tsx +++ b/lib/plugin-sandbox/ui-kit/components.tsx @@ -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 & { 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 ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.length === 0 && empty !== undefined ? ( + + ) : rows.map((row, i) => ( + + {columns.map((c) => )} + + ))} + + {summary && summary.length > 0 && ( + + {summary.map((s, i) => ( + + {/* On phones the hideNarrow columns are gone; the label still spans what is left. */} + + + + ))} + + )} +
{c.label}
{empty}
{row[c.key]}
{s.label}{s.value}
+
+ ); +} + /** * 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 diff --git a/lib/plugin-sandbox/ui-kit/names.ts b/lib/plugin-sandbox/ui-kit/names.ts index e350e9093..e52fecee0 100644 --- a/lib/plugin-sandbox/ui-kit/names.ts +++ b/lib/plugin-sandbox/ui-kit/names.ts @@ -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', ]);