diff --git a/docs/using-origin-in-your-app.md b/docs/using-origin-in-your-app.md index 62225ab..95929bf 100644 --- a/docs/using-origin-in-your-app.md +++ b/docs/using-origin-in-your-app.md @@ -130,6 +130,62 @@ If you need app-specific tokens or mixins, create them in a local `src/tokens/` @use "pkg:@lightsparkdev/origin/tokens/text-styles" as *; // from Origin ``` +## Table Column Sizing + +`Table.Root` uses `table-layout: fixed` so column widths are predictable and text truncates cleanly. The sizing model is simple: + +- **Hug columns** — Set an explicit width via `style`. The column stays at that size. Use for checkboxes, status badges, action menus, and other fixed-width content. +- **Fill columns** — Don't set a width. The browser divides remaining space equally among columns without explicit widths. + +```tsx + + {/* checkbox — hug */} + + + + Name {/* fill — no width */} + + + + Email {/* fill — no width */} + + + + Actions {/* hug */} + +``` + +### With TanStack React Table + +TanStack defaults every column to `size: 150`, so `header.getSize()` always returns a number. If you pass that to every header cell, `table-layout: fixed` distributes surplus space proportionally and inflates hug columns. + +Only set width on columns that need it: + +```tsx +// In your column definitions, tag hug columns via meta: +columnHelper.display({ + id: 'select', + meta: { sizing: 'hug' }, + size: 40, +}) + +columnHelper.accessor('name', { + header: 'Name', + // no size, no meta — this column fills +}) + +// In the render loop: + +``` + +See the **ColumnSizing** story in Storybook for a working example. + ## Sync Tokens are imported directly from the package — no manual copying needed. When Origin publishes a new version: diff --git a/src/components/Chart/Chart.module.scss b/src/components/Chart/Chart.module.scss index 66e7331..e455cb8 100644 --- a/src/components/Chart/Chart.module.scss +++ b/src/components/Chart/Chart.module.scss @@ -789,6 +789,44 @@ } +.splitDetailedLegend { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm) var(--spacing-xl); + padding-top: var(--spacing-xs); +} + +.splitDetailedItem { + display: flex; + flex-direction: column; + gap: var(--spacing-3xs); +} + +.splitDetailedLabel { + @include label-sm; + + display: flex; + align-items: center; + gap: var(--spacing-2xs); + color: var(--text-secondary); + white-space: nowrap; +} + +.splitDetailedValue { + @include body; + + font-weight: var(--font-weight-medium, 500); + color: var(--text-primary); + white-space: nowrap; +} + +.splitDetailedCount { + @include body-sm; + + color: var(--text-tertiary); + white-space: nowrap; +} + .splitTooltipInline { display: flex; align-items: center; diff --git a/src/components/Chart/Chart.stories.tsx b/src/components/Chart/Chart.stories.tsx index 8f28015..34917d3 100644 --- a/src/components/Chart/Chart.stories.tsx +++ b/src/components/Chart/Chart.stories.tsx @@ -506,14 +506,24 @@ const SPLIT_DATA = [ { label: 'Refunds', value: 320, color: 'var(--color-blue-100)' }, ]; +const SPLIT_DETAILED_DATA = [ + { label: 'Incoming', value: 246_100_000, color: 'var(--color-blue-700)' }, + { label: 'Outgoing', value: 87_800_000, color: 'var(--color-blue-400)' }, + { label: 'Bidirectional', value: 4_600_000, color: 'var(--color-green-600)' }, +]; + export const Split: Story = { args: { + variant: 'default', height: 24, showValues: true, showPercentage: true, legend: true, loading: false, }, + argTypes: { + variant: { control: 'inline-radio', options: ['default', 'detailed'] }, + }, render: (args) => (
`$${v.toLocaleString()}`} {...args} /> @@ -521,6 +531,27 @@ export const Split: Story = { ), }; +export const SplitDetailed: Story = { + args: { + height: 24, + showPercentage: true, + }, + render: (args) => ( +
+ { + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`; + if (v >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; + return `$${v}`; + }} + {...args} + /> +
+ ), +}; + export const BarListRanked: Story = { render: () => (
diff --git a/src/components/Chart/Chart.test-stories.tsx b/src/components/Chart/Chart.test-stories.tsx index 667f101..7f2639e 100644 --- a/src/components/Chart/Chart.test-stories.tsx +++ b/src/components/Chart/Chart.test-stories.tsx @@ -294,6 +294,24 @@ export function SplitBasic() { ); } +export function SplitDetailed() { + return ( + { + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`; + return `$${v}`; + }} + data-testid="split-chart" + /> + ); +} + export function BarListRanked() { return ( { }); }); +// --------------------------------------------------------------------------- +// Split detailed variant +// --------------------------------------------------------------------------- + +test.describe('Split detailed variant', () => { + test('renders formatted value per segment', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="split-chart"]'); + await expect(root.getByText('$246.1M')).toBeVisible(); + await expect(root.getByText('$87.8M')).toBeVisible(); + await expect(root.getByText('Incoming')).toBeVisible(); + }); + + test('shows percentage with one decimal place', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="split-chart"]'); + const countText = root.locator('[class*="splitDetailedCount"]').first(); + await expect(countText).toContainText('%'); + const text = await countText.textContent(); + expect(text).toMatch(/\d+\.\d%/); + }); + + test('does not render default dot legend', async ({ mount, page }) => { + await mount(); + const root = page.locator('[data-testid="split-chart"]'); + const defaultLegend = root.locator('[class*="legendItem"]'); + await expect(defaultLegend).toHaveCount(0); + }); +}); + // --------------------------------------------------------------------------- // BarList ranked variant // --------------------------------------------------------------------------- diff --git a/src/components/Chart/SplitChart.tsx b/src/components/Chart/SplitChart.tsx index 7f57e93..941f431 100644 --- a/src/components/Chart/SplitChart.tsx +++ b/src/components/Chart/SplitChart.tsx @@ -16,6 +16,8 @@ export interface SplitSegment { export interface SplitChartProps extends React.ComponentPropsWithoutRef<'div'> { data: SplitSegment[]; + /** Display variant. `default` shows a dot legend; `detailed` shows formatted value and percentage per segment. */ + variant?: 'default' | 'detailed'; formatValue?: (value: number) => string; showPercentage?: boolean; showValues?: boolean; @@ -35,6 +37,7 @@ export const Split = React.forwardRef( function Split( { data, + variant = 'default', formatValue, showPercentage = true, showValues = false, @@ -161,7 +164,7 @@ export const Split = React.forwardRef( })}
- {legend && ( + {legend && variant === 'default' && (
{activeIndex !== null && segments[activeIndex] ? (
@@ -188,6 +191,30 @@ export const Split = React.forwardRef( )}
)} + + {variant === 'detailed' && ( +
+ {segments.map((seg, i) => ( +
+
+ + {seg.label} +
+
+ {fmtValue(seg.value)} +
+ {showPercentage && ( +
+ {`${seg.pct.toFixed(1)}%`} +
+ )} +
+ ))} +
+ )}
{activeIndex !== null && segments[activeIndex] ? `${segments[activeIndex].label}: ${fmtValue(segments[activeIndex].value)} (${Math.round(segments[activeIndex].pct)}%)` diff --git a/src/components/Table/Table.stories.tsx b/src/components/Table/Table.stories.tsx index 5792948..314bb47 100644 --- a/src/components/Table/Table.stories.tsx +++ b/src/components/Table/Table.stories.tsx @@ -7,6 +7,7 @@ import { AlignedTable, LoadingTable, ResizableTable, + ColumnSizingTable, SlotsTable, DescriptionTable, FooterTable, @@ -91,6 +92,18 @@ export const Resizable: Story = { }, }; +export const ColumnSizing: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Hug columns (checkbox, status, amount, actions) get explicit widths; fill columns (customer, product, note) omit width and split remaining space equally. Tag hug columns with `meta: { sizing: "hug" }` and only pass `style.width` when that flag is set.', + }, + }, + }, +}; + export const WithSlots: Story = { render: () => , parameters: { diff --git a/src/components/Table/Table.test-stories.tsx b/src/components/Table/Table.test-stories.tsx index aeafa2d..a49ddb8 100644 --- a/src/components/Table/Table.test-stories.tsx +++ b/src/components/Table/Table.test-stories.tsx @@ -760,6 +760,150 @@ export function ClickableRowTable() { ); } +/** + * Table with hug/fill column sizing. + * Hug columns get an explicit width; fill columns omit width + * and split remaining space equally via table-layout: fixed. + */ +export function ColumnSizingTable() { + interface Order { + id: string; + customer: string; + product: string; + note: string; + amount: number; + status: 'completed' | 'pending' | 'failed'; + } + + const data: Order[] = [ + { id: 'ORD-001', customer: 'Alice Johnson', product: 'Widget Pro', note: 'Rush delivery requested', amount: 249.99, status: 'completed' }, + { id: 'ORD-002', customer: 'Bob Smith', product: 'Gadget Max', note: 'Gift wrap', amount: 149.50, status: 'pending' }, + { id: 'ORD-003', customer: 'Carol White', product: 'Gizmo Ultra', note: '', amount: 89.00, status: 'failed' }, + { id: 'ORD-004', customer: 'David Brown', product: 'Widget Pro', note: 'Include invoice', amount: 499.99, status: 'completed' }, + { id: 'ORD-005', customer: 'Eve Davis', product: 'Gadget Max', note: 'Second order this month', amount: 149.50, status: 'pending' }, + ]; + + const orderColumnHelper = createColumnHelper(); + + const columns = [ + orderColumnHelper.display({ + id: 'select', + meta: { sizing: 'hug' }, + size: 40, + header: () => null, + cell: () => ( + + + ✓ + + + ), + }), + orderColumnHelper.accessor('customer', { + header: 'Customer', + }), + orderColumnHelper.accessor('product', { + header: 'Product', + }), + orderColumnHelper.accessor('note', { + header: 'Note', + }), + orderColumnHelper.accessor('status', { + header: 'Status', + meta: { sizing: 'hug' }, + size: 100, + }), + orderColumnHelper.accessor('amount', { + header: 'Amount', + meta: { sizing: 'hug', align: 'right' as const }, + size: 100, + cell: (info) => `$${info.getValue().toFixed(2)}`, + }), + orderColumnHelper.display({ + id: 'actions', + meta: { sizing: 'hug' }, + size: 64, + header: () => null, + cell: () => ( + + ), + }), + ]; + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as + | { sizing?: string; align?: 'left' | 'right' } + | undefined; + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as + | { sizing?: string; align?: 'left' | 'right' } + | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} + + + ); +} + /** * Table with cell description (secondary text) */