Skip to content
Merged
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
56 changes: 56 additions & 0 deletions docs/using-origin-in-your-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Table.HeaderCell variant="checkbox" style={{ width: 40 }}>
{/* checkbox — hug */}
</Table.HeaderCell>

<Table.HeaderCell>
Name {/* fill — no width */}
</Table.HeaderCell>

<Table.HeaderCell>
Email {/* fill — no width */}
</Table.HeaderCell>

<Table.HeaderCell style={{ width: 64 }} align="right">
<span className="visuallyHidden">Actions</span> {/* hug */}
</Table.HeaderCell>
```

### 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:
<Table.HeaderCell
style={{
width: (header.column.columnDef.meta as { sizing?: string })?.sizing === 'hug'
? header.getSize()
: undefined,
}}
>
```

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:
Expand Down
38 changes: 38 additions & 0 deletions src/components/Chart/Chart.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/components/Chart/Chart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,21 +506,52 @@ 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) => (
<div style={{ width: 500 }}>
<Chart.Split data={SPLIT_DATA} formatValue={(v: number) => `$${v.toLocaleString()}`} {...args} />
</div>
),
};

export const SplitDetailed: Story = {
args: {
height: 24,
showPercentage: true,
},
render: (args) => (
<div style={{ width: 600 }}>
<Chart.Split
data={SPLIT_DETAILED_DATA}
variant="detailed"
formatValue={(v: number) => {
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}
/>
</div>
),
};

export const BarListRanked: Story = {
render: () => (
<div style={{ width: 400 }}>
Expand Down
18 changes: 18 additions & 0 deletions src/components/Chart/Chart.test-stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,24 @@ export function SplitBasic() {
);
}

export function SplitDetailed() {
return (
<Chart.Split
data={[
{ label: 'Incoming', value: 246_100_000 },
{ label: 'Outgoing', value: 87_800_000 },
{ label: 'Bidirectional', value: 4_600_000 },
]}
variant="detailed"
formatValue={(v: number) => {
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`;
return `$${v}`;
}}
data-testid="split-chart"
/>
);
}

export function BarListRanked() {
return (
<Chart.BarList
Expand Down
31 changes: 31 additions & 0 deletions src/components/Chart/Chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ScatterBasic,
ScatterMultiSeries,
SplitBasic,
SplitDetailed,
BarListRanked,
WaterfallBasic,
SankeyBasic,
Expand Down Expand Up @@ -531,6 +532,36 @@ test.describe('Split chart', () => {
});
});

// ---------------------------------------------------------------------------
// Split detailed variant
// ---------------------------------------------------------------------------

test.describe('Split detailed variant', () => {
test('renders formatted value per segment', async ({ mount, page }) => {
await mount(<SplitDetailed />);
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(<SplitDetailed />);
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(<SplitDetailed />);
const root = page.locator('[data-testid="split-chart"]');
const defaultLegend = root.locator('[class*="legendItem"]');
await expect(defaultLegend).toHaveCount(0);
});
});

// ---------------------------------------------------------------------------
// BarList ranked variant
// ---------------------------------------------------------------------------
Expand Down
29 changes: 28 additions & 1 deletion src/components/Chart/SplitChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +37,7 @@ export const Split = React.forwardRef<HTMLDivElement, SplitChartProps>(
function Split(
{
data,
variant = 'default',
formatValue,
showPercentage = true,
showValues = false,
Expand Down Expand Up @@ -161,7 +164,7 @@ export const Split = React.forwardRef<HTMLDivElement, SplitChartProps>(
})}
</div>

{legend && (
{legend && variant === 'default' && (
<div className={styles.legend}>
{activeIndex !== null && segments[activeIndex] ? (
<div className={styles.legendItem}>
Expand All @@ -188,6 +191,30 @@ export const Split = React.forwardRef<HTMLDivElement, SplitChartProps>(
)}
</div>
)}

{variant === 'detailed' && (
<div className={styles.splitDetailedLegend}>
{segments.map((seg, i) => (
<div
key={seg.key ?? i}
className={styles.splitDetailedItem}
>
<div className={styles.splitDetailedLabel}>
<span className={styles.legendDot} style={{ backgroundColor: seg.color }} />
{seg.label}
</div>
<div className={styles.splitDetailedValue}>
{fmtValue(seg.value)}
</div>
{showPercentage && (
<div className={styles.splitDetailedCount}>
{`${seg.pct.toFixed(1)}%`}
</div>
)}
</div>
))}
</div>
)}
<div role="status" aria-live="polite" aria-atomic="true" className={styles.srOnly}>
{activeIndex !== null && segments[activeIndex]
? `${segments[activeIndex].label}: ${fmtValue(segments[activeIndex].value)} (${Math.round(segments[activeIndex].pct)}%)`
Expand Down
13 changes: 13 additions & 0 deletions src/components/Table/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AlignedTable,
LoadingTable,
ResizableTable,
ColumnSizingTable,
SlotsTable,
DescriptionTable,
FooterTable,
Expand Down Expand Up @@ -91,6 +92,18 @@ export const Resizable: Story = {
},
};

export const ColumnSizing: Story = {
render: () => <ColumnSizingTable />,
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: () => <SlotsTable />,
parameters: {
Expand Down
Loading
Loading