Skip to content

Commit dfb6904

Browse files
authored
Add SplitChart details and document Table sizing (#126)
## Summary - add the `SplitChart` detailed variant with value and percentage display plus test coverage - document the Table column sizing pattern and add a `ColumnSizing` story fixture - tighten the SplitChart legend assertion so the new detailed legend coverage matches the component structure ## Test plan - [x] `npm run lint` - [x] `npm test -- src/components/Chart/Chart.test.tsx` Made with [Cursor](https://cursor.com)
1 parent 9d004cb commit dfb6904

8 files changed

Lines changed: 359 additions & 1 deletion

File tree

docs/using-origin-in-your-app.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,62 @@ If you need app-specific tokens or mixins, create them in a local `src/tokens/`
130130
@use "pkg:@lightsparkdev/origin/tokens/text-styles" as *; // from Origin
131131
```
132132

133+
## Table Column Sizing
134+
135+
`Table.Root` uses `table-layout: fixed` so column widths are predictable and text truncates cleanly. The sizing model is simple:
136+
137+
- **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.
138+
- **Fill columns** — Don't set a width. The browser divides remaining space equally among columns without explicit widths.
139+
140+
```tsx
141+
<Table.HeaderCell variant="checkbox" style={{ width: 40 }}>
142+
{/* checkbox — hug */}
143+
</Table.HeaderCell>
144+
145+
<Table.HeaderCell>
146+
Name {/* fill — no width */}
147+
</Table.HeaderCell>
148+
149+
<Table.HeaderCell>
150+
Email {/* fill — no width */}
151+
</Table.HeaderCell>
152+
153+
<Table.HeaderCell style={{ width: 64 }} align="right">
154+
<span className="visuallyHidden">Actions</span> {/* hug */}
155+
</Table.HeaderCell>
156+
```
157+
158+
### With TanStack React Table
159+
160+
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.
161+
162+
Only set width on columns that need it:
163+
164+
```tsx
165+
// In your column definitions, tag hug columns via meta:
166+
columnHelper.display({
167+
id: 'select',
168+
meta: { sizing: 'hug' },
169+
size: 40,
170+
})
171+
172+
columnHelper.accessor('name', {
173+
header: 'Name',
174+
// no size, no meta — this column fills
175+
})
176+
177+
// In the render loop:
178+
<Table.HeaderCell
179+
style={{
180+
width: (header.column.columnDef.meta as { sizing?: string })?.sizing === 'hug'
181+
? header.getSize()
182+
: undefined,
183+
}}
184+
>
185+
```
186+
187+
See the **ColumnSizing** story in Storybook for a working example.
188+
133189
## Sync
134190

135191
Tokens are imported directly from the package — no manual copying needed. When Origin publishes a new version:

src/components/Chart/Chart.module.scss

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,44 @@
789789
}
790790

791791

792+
.splitDetailedLegend {
793+
display: flex;
794+
flex-wrap: wrap;
795+
gap: var(--spacing-sm) var(--spacing-xl);
796+
padding-top: var(--spacing-xs);
797+
}
798+
799+
.splitDetailedItem {
800+
display: flex;
801+
flex-direction: column;
802+
gap: var(--spacing-3xs);
803+
}
804+
805+
.splitDetailedLabel {
806+
@include label-sm;
807+
808+
display: flex;
809+
align-items: center;
810+
gap: var(--spacing-2xs);
811+
color: var(--text-secondary);
812+
white-space: nowrap;
813+
}
814+
815+
.splitDetailedValue {
816+
@include body;
817+
818+
font-weight: var(--font-weight-medium, 500);
819+
color: var(--text-primary);
820+
white-space: nowrap;
821+
}
822+
823+
.splitDetailedCount {
824+
@include body-sm;
825+
826+
color: var(--text-tertiary);
827+
white-space: nowrap;
828+
}
829+
792830
.splitTooltipInline {
793831
display: flex;
794832
align-items: center;

src/components/Chart/Chart.stories.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,21 +506,52 @@ const SPLIT_DATA = [
506506
{ label: 'Refunds', value: 320, color: 'var(--color-blue-100)' },
507507
];
508508

509+
const SPLIT_DETAILED_DATA = [
510+
{ label: 'Incoming', value: 246_100_000, color: 'var(--color-blue-700)' },
511+
{ label: 'Outgoing', value: 87_800_000, color: 'var(--color-blue-400)' },
512+
{ label: 'Bidirectional', value: 4_600_000, color: 'var(--color-green-600)' },
513+
];
514+
509515
export const Split: Story = {
510516
args: {
517+
variant: 'default',
511518
height: 24,
512519
showValues: true,
513520
showPercentage: true,
514521
legend: true,
515522
loading: false,
516523
},
524+
argTypes: {
525+
variant: { control: 'inline-radio', options: ['default', 'detailed'] },
526+
},
517527
render: (args) => (
518528
<div style={{ width: 500 }}>
519529
<Chart.Split data={SPLIT_DATA} formatValue={(v: number) => `$${v.toLocaleString()}`} {...args} />
520530
</div>
521531
),
522532
};
523533

534+
export const SplitDetailed: Story = {
535+
args: {
536+
height: 24,
537+
showPercentage: true,
538+
},
539+
render: (args) => (
540+
<div style={{ width: 600 }}>
541+
<Chart.Split
542+
data={SPLIT_DETAILED_DATA}
543+
variant="detailed"
544+
formatValue={(v: number) => {
545+
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`;
546+
if (v >= 1_000) return `$${(v / 1_000).toFixed(0)}K`;
547+
return `$${v}`;
548+
}}
549+
{...args}
550+
/>
551+
</div>
552+
),
553+
};
554+
524555
export const BarListRanked: Story = {
525556
render: () => (
526557
<div style={{ width: 400 }}>

src/components/Chart/Chart.test-stories.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,24 @@ export function SplitBasic() {
294294
);
295295
}
296296

297+
export function SplitDetailed() {
298+
return (
299+
<Chart.Split
300+
data={[
301+
{ label: 'Incoming', value: 246_100_000 },
302+
{ label: 'Outgoing', value: 87_800_000 },
303+
{ label: 'Bidirectional', value: 4_600_000 },
304+
]}
305+
variant="detailed"
306+
formatValue={(v: number) => {
307+
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`;
308+
return `$${v}`;
309+
}}
310+
data-testid="split-chart"
311+
/>
312+
);
313+
}
314+
297315
export function BarListRanked() {
298316
return (
299317
<Chart.BarList

src/components/Chart/Chart.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ScatterBasic,
2020
ScatterMultiSeries,
2121
SplitBasic,
22+
SplitDetailed,
2223
BarListRanked,
2324
WaterfallBasic,
2425
SankeyBasic,
@@ -531,6 +532,36 @@ test.describe('Split chart', () => {
531532
});
532533
});
533534

535+
// ---------------------------------------------------------------------------
536+
// Split detailed variant
537+
// ---------------------------------------------------------------------------
538+
539+
test.describe('Split detailed variant', () => {
540+
test('renders formatted value per segment', async ({ mount, page }) => {
541+
await mount(<SplitDetailed />);
542+
const root = page.locator('[data-testid="split-chart"]');
543+
await expect(root.getByText('$246.1M')).toBeVisible();
544+
await expect(root.getByText('$87.8M')).toBeVisible();
545+
await expect(root.getByText('Incoming')).toBeVisible();
546+
});
547+
548+
test('shows percentage with one decimal place', async ({ mount, page }) => {
549+
await mount(<SplitDetailed />);
550+
const root = page.locator('[data-testid="split-chart"]');
551+
const countText = root.locator('[class*="splitDetailedCount"]').first();
552+
await expect(countText).toContainText('%');
553+
const text = await countText.textContent();
554+
expect(text).toMatch(/\d+\.\d%/);
555+
});
556+
557+
test('does not render default dot legend', async ({ mount, page }) => {
558+
await mount(<SplitDetailed />);
559+
const root = page.locator('[data-testid="split-chart"]');
560+
const defaultLegend = root.locator('[class*="legendItem"]');
561+
await expect(defaultLegend).toHaveCount(0);
562+
});
563+
});
564+
534565
// ---------------------------------------------------------------------------
535566
// BarList ranked variant
536567
// ---------------------------------------------------------------------------

src/components/Chart/SplitChart.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface SplitSegment {
1616

1717
export interface SplitChartProps extends React.ComponentPropsWithoutRef<'div'> {
1818
data: SplitSegment[];
19+
/** Display variant. `default` shows a dot legend; `detailed` shows formatted value and percentage per segment. */
20+
variant?: 'default' | 'detailed';
1921
formatValue?: (value: number) => string;
2022
showPercentage?: boolean;
2123
showValues?: boolean;
@@ -35,6 +37,7 @@ export const Split = React.forwardRef<HTMLDivElement, SplitChartProps>(
3537
function Split(
3638
{
3739
data,
40+
variant = 'default',
3841
formatValue,
3942
showPercentage = true,
4043
showValues = false,
@@ -161,7 +164,7 @@ export const Split = React.forwardRef<HTMLDivElement, SplitChartProps>(
161164
})}
162165
</div>
163166

164-
{legend && (
167+
{legend && variant === 'default' && (
165168
<div className={styles.legend}>
166169
{activeIndex !== null && segments[activeIndex] ? (
167170
<div className={styles.legendItem}>
@@ -188,6 +191,30 @@ export const Split = React.forwardRef<HTMLDivElement, SplitChartProps>(
188191
)}
189192
</div>
190193
)}
194+
195+
{variant === 'detailed' && (
196+
<div className={styles.splitDetailedLegend}>
197+
{segments.map((seg, i) => (
198+
<div
199+
key={seg.key ?? i}
200+
className={styles.splitDetailedItem}
201+
>
202+
<div className={styles.splitDetailedLabel}>
203+
<span className={styles.legendDot} style={{ backgroundColor: seg.color }} />
204+
{seg.label}
205+
</div>
206+
<div className={styles.splitDetailedValue}>
207+
{fmtValue(seg.value)}
208+
</div>
209+
{showPercentage && (
210+
<div className={styles.splitDetailedCount}>
211+
{`${seg.pct.toFixed(1)}%`}
212+
</div>
213+
)}
214+
</div>
215+
))}
216+
</div>
217+
)}
191218
<div role="status" aria-live="polite" aria-atomic="true" className={styles.srOnly}>
192219
{activeIndex !== null && segments[activeIndex]
193220
? `${segments[activeIndex].label}: ${fmtValue(segments[activeIndex].value)} (${Math.round(segments[activeIndex].pct)}%)`

src/components/Table/Table.stories.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AlignedTable,
88
LoadingTable,
99
ResizableTable,
10+
ColumnSizingTable,
1011
SlotsTable,
1112
DescriptionTable,
1213
FooterTable,
@@ -91,6 +92,18 @@ export const Resizable: Story = {
9192
},
9293
};
9394

95+
export const ColumnSizing: Story = {
96+
render: () => <ColumnSizingTable />,
97+
parameters: {
98+
docs: {
99+
description: {
100+
story:
101+
'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.',
102+
},
103+
},
104+
},
105+
};
106+
94107
export const WithSlots: Story = {
95108
render: () => <SlotsTable />,
96109
parameters: {

0 commit comments

Comments
 (0)