Skip to content
Open
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
150 changes: 123 additions & 27 deletions apps/www/src/components/dataview-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -671,10 +671,21 @@ type Task = {
title: string;
team: 'Eng' | 'Design' | 'Ops';
status: 'todo' | 'active' | 'done';
priority: 'High' | 'Medium' | 'Low';
/* Priority as a number. Sorting the label alphabetically gives High, Low,
Medium — this is what "sort by priority" has to mean to be useful, and it
is what the sort-value lane timeline lanes on. */
rank: 1 | 2 | 3;
start: string;
end: string;
};

const TASK_RANK: Record<Task['priority'], Task['rank']> = {
High: 1,
Medium: 2,
Low: 3
};

const TASK_DAY_MS = 86_400_000;

/* Dates relative to today, pinned to midnight so the today line is always
Expand All @@ -686,38 +697,50 @@ const taskDate = (days: number) => {
};

const taskSpec: Array<
[string, string, Task['team'], Task['status'], number, number]
[
string,
string,
Task['team'],
Task['status'],
Task['priority'],
number,
number
]
> = [
['t1', 'Design audit', 'Design', 'done', -16, -9],
['t2', 'API contracts', 'Eng', 'done', -13, -6],
['t3', 'Billing revamp', 'Eng', 'active', -7, 2],
['t4', 'Docs sprint', 'Design', 'active', -4, 4],
['t5', 'Bug bash', 'Ops', 'todo', 1, 2],
['t6', 'Load testing', 'Ops', 'todo', 3, 9],
['t7', 'Beta rollout', 'Eng', 'todo', 6, 14],
['t8', 'Launch comms', 'Design', 'todo', 10, 16],
['t1', 'Design audit', 'Design', 'done', 'High', -16, -9],
['t2', 'API contracts', 'Eng', 'done', 'High', -13, -6],
['t3', 'Billing revamp', 'Eng', 'active', 'High', -7, 2],
['t4', 'Docs sprint', 'Design', 'active', 'Medium', -4, 4],
['t5', 'Bug bash', 'Ops', 'todo', 'Low', 1, 2],
['t6', 'Load testing', 'Ops', 'todo', 'Low', 3, 9],
['t7', 'Beta rollout', 'Eng', 'todo', 'High', 6, 14],
['t8', 'Launch comms', 'Design', 'todo', 'Medium', 10, 16],
// Overlapping work per team, so packing stacks a few lanes deep inside each
// group section rather than every band being a single row.
['t9', 'Schema migration', 'Eng', 'done', -15, -10],
['t10', 'Icon refresh', 'Design', 'done', -12, -5],
['t11', 'On-call rotation', 'Ops', 'active', -11, -2],
['t12', 'Runbook cleanup', 'Ops', 'done', -14, -8],
['t13', 'Search indexing', 'Eng', 'active', -3, 6],
['t14', 'Motion pass', 'Design', 'active', -2, 5],
['t15', 'Cost review', 'Ops', 'todo', 4, 12],
['t16', 'SSO hardening', 'Eng', 'todo', 8, 15],
['t17', 'Empty states', 'Design', 'todo', 7, 13],
['t18', 'Chaos drill', 'Ops', 'todo', 11, 15]
['t9', 'Schema migration', 'Eng', 'done', 'Medium', -15, -10],
['t10', 'Icon refresh', 'Design', 'done', 'Low', -12, -5],
['t11', 'On-call rotation', 'Ops', 'active', 'Medium', -11, -2],
['t12', 'Runbook cleanup', 'Ops', 'done', 'Low', -14, -8],
['t13', 'Search indexing', 'Eng', 'active', 'Medium', -3, 6],
['t14', 'Motion pass', 'Design', 'active', 'Low', -2, 5],
['t15', 'Cost review', 'Ops', 'todo', 'Medium', 4, 12],
['t16', 'SSO hardening', 'Eng', 'todo', 'Low', 8, 15],
['t17', 'Empty states', 'Design', 'todo', 'Medium', 7, 13],
['t18', 'Chaos drill', 'Ops', 'todo', 'High', 11, 15]
];

const tasks: Task[] = taskSpec.map(([id, title, team, status, from, to]) => ({
id,
title,
team,
status,
start: taskDate(from),
end: taskDate(to)
}));
const tasks: Task[] = taskSpec.map(
([id, title, team, status, priority, from, to]) => ({
id,
title,
team,
status,
priority,
rank: TASK_RANK[priority],
start: taskDate(from),
end: taskDate(to)
})
);

const taskFields: DataViewField<Task>[] = [
{
Expand Down Expand Up @@ -757,6 +780,32 @@ const taskFields: DataViewField<Task>[] = [
{ label: 'Done', value: 'done' }
]
},
{
accessorKey: 'priority',
label: 'Priority',
filterable: true,
filterType: 'select',
hideable: true,
// groupOrder ranks the sections when grouping by priority — text sort
// would give High, Low, Medium.
groupable: true,
showGroupCount: true,
groupOrder: ['High', 'Medium', 'Low'],
filterOptions: [
{ label: 'High', value: 'High' },
{ label: 'Medium', value: 'Medium' },
{ label: 'Low', value: 'Low' }
]
},
{
// Sortable because the sort-value lane timeline lanes by whatever is sorted:
// sorting on rank yields a High lane, a Medium lane and a Low lane.
accessorKey: 'rank',
label: 'Priority rank',
sortable: true,
hideable: true,
defaultHidden: true
},
{
accessorKey: 'start',
label: 'Start',
Expand Down Expand Up @@ -839,6 +888,11 @@ function TaskCard({
{task.team}
</Badge>
</DataView.DisplayAccess>
<DataView.DisplayAccess accessorKey='priority'>
<Badge size='micro' variant='neutral'>
{task.priority}
</Badge>
</DataView.DisplayAccess>
</Flex>
</Flex>
);
Expand Down Expand Up @@ -1035,3 +1089,45 @@ export function DataViewTimelineGroupingDemo() {
</Flex>
);
}

/* ── Timeline sort-value lane demo (lanePacking="one-per-sort-value") ────────────── */

export function DataViewTimelineSortValueLaneDemo() {
return (
<Flex
direction='column'
style={{ width: '100%', height: 460, overflow: 'hidden' }}
>
<DataView<Task>
data={tasks}
fields={taskFields}
// The sort defines the lanes: rank asc → High, Medium, Low.
defaultSort={{ name: 'rank', order: 'asc' }}
getRowId={task => task.id}
>
<DataView.Toolbar>
<DataView.Filters />
{/* Ordering stays visible — it repositions and rebuilds lanes. */}
<DataView.DisplayControls />
</DataView.Toolbar>
<Flex
direction='column'
justify='center'
style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}
>
<DataView.Timeline<Task>
startField='start'
endField='end'
lanePacking='one-per-sort-value'
renderCard={(row, context) => (
<TaskCard task={row.original} context={context} />
)}
/>
<DataView.EmptyState>
<Text>No tasks match your filters.</Text>
</DataView.EmptyState>
</Flex>
</DataView>
</Flex>
);
}
2 changes: 2 additions & 0 deletions apps/www/src/components/demo/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
DataViewTimelineDemo,
DataViewTimelineGroupingDemo,
DataViewTimelinePointDemo,
DataViewTimelineSortValueLaneDemo,
DataViewVirtualizedDemo,
DataViewVirtualizedGroupingDemo
} from '../dataview-demo';
Expand Down Expand Up @@ -84,6 +85,7 @@ export default function Demo(props: DemoProps) {
DataViewSearchDemo,
DataViewSelectionDemo,
DataViewTimelineDemo,
DataViewTimelineSortValueLaneDemo,
DataViewTimelineGroupingDemo,
DataViewTimelinePointDemo,
ChipInputDemo,
Expand Down
38 changes: 38 additions & 0 deletions apps/www/src/content/docs/components/dataview/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,44 @@ export const timelineGroupingPreview = {
]
};

export const timelineSortValueLanePreview = {
type: 'code',
style: { padding: 0 },
previewCode: false,
code: `<DataViewTimelineSortValueLaneDemo />`,
codePreview: [
{
label: 'index.tsx',
code: `
/* One lane per priority: rows sharing a value share a lane, and a value
only takes a second lane where two of its own cards overlap in time.
The sort picks the lane field and orders the lanes, so try the Ordering
control. Sorting the "High"/"Medium"/"Low" label alphabetically gives
High, Low, Medium — carry a numeric rank and sort on that instead:

{ accessorKey: "rank", label: "Priority rank", sortable: true } */

<DataView
data={tasks}
fields={fields}
defaultSort={{ name: "rank", order: "asc" }}
getRowId={(t) => t.id}>
<DataView.Toolbar>
<DataView.Filters />
<DataView.DisplayControls />
</DataView.Toolbar>

<DataView.Timeline
startField="start"
endField="end"
lanePacking="one-per-sort-value"
renderCard={(row, context) => <TaskCard task={row.original} context={context} />}
/>
</DataView>`
}
]
};

export const timelinePointPreview = {
type: 'code',
style: { padding: 0 },
Expand Down
54 changes: 49 additions & 5 deletions apps/www/src/content/docs/components/dataview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
perViewFieldsPreview,
rowSelectionPreview,
timelinePreview,
timelineSortValueLanePreview,
timelineGroupingPreview,
timelinePointPreview,
} from "./demo.ts";
Expand Down Expand Up @@ -419,7 +420,7 @@ In the demo below: drag the background to pan (with a momentum glide), hover for

### Cards

The Timeline owns **positioning** — the time scale (date → x, span → width, using real timestamps so variable-length months don't distort placement), lane packing (non-overlapping cards share a lane; `lanePacking="one-per-row"` opts out), the sticky two-tier axis, and scrolling. You own the **card**: `renderCard(row, context)` draws everything visual, the same split as `DataView.List`'s `columns[].cell`.
The Timeline owns **positioning** — the time scale (date → x, span → width, using real timestamps so variable-length months don't distort placement), lane packing (non-overlapping cards share a lane; `lanePacking` opts out — see [Lane packing](#lane-packing)), the sticky two-tier axis, and scrolling. You own the **card**: `renderCard(row, context)` draws everything visual, the same split as `DataView.List`'s `columns[].cell`.

```tsx
<DataView.Timeline
Expand All @@ -443,6 +444,45 @@ Omit `endField` and rows render as point markers at their date — releases, inc

<Demo data={timelinePointPreview} />

### Lane packing

`lanePacking` decides what a lane *means*. All three modes run per group section — a card never shares a lane across sections.

| Mode | A lane is | Use it for |
| --- | --- | --- |
| `auto` (default) | a dense chronological track: cards that don't overlap in time share it | fitting many cards into the least vertical space |
| `one-per-row` | one row | a Gantt chart, where every row needs its own visible track |
| `one-per-sort-value` | one distinct value of the **sorted-by** field | grouping by a property while keeping the flat single-axis layout |

Under `one-per-sort-value`, rows sharing a value share a lane and are packed by date within it; a value only claims a **sub-lane** where two of its own cards overlap in time. So a timeline sorted by priority shows a High lane, a Medium lane and a Low lane, and only the priority with genuinely concurrent work grows a second row.

The active sort does double duty here: it picks the field lanes are built from *and* orders them, so the Ordering control repositions lanes live and there's no second ordering vocabulary to keep in sync.

<Demo data={timelineSortValueLanePreview} />

```tsx
<DataView
data={tasks}
fields={fields}
// The sort is the lane definition: sort by rank → one lane per rank value
defaultSort={{ name: "rank", order: "asc" }}
getRowId={(t) => t.id}>
<DataView.Timeline
startField="start"
endField="end"
lanePacking="one-per-sort-value"
renderCard={renderCard}
/>
</DataView>
```

- **Rank what doesn't sort naturally.** Sorting `priority` alphabetically gives High, Low, Medium. Carry a numeric `rank` alongside it and sort on that for High, Medium, Low — the lane values are then the ranks, which is invisible unless your card shows them.
- **Rows with no usable value** — null, `undefined`, `""`, or a non-primitive (which also logs a dev warning) — share one lane, always last, wherever the sort would have put them.
- **Values are keyed by their string form**, so `1` and `"1"` share a lane. Resolve an object-valued field to a primitive before sorting on it.
- **No sort, no lanes.** The mode falls back to `auto` if the query carries no sort — `defaultSort` is required on the root, so that's a guard rather than a configuration.
- **With `group_by` active**, each section gets its own lane set and `context.laneIndex` stays section-relative. Grouping by the sorted field is allowed and simply degenerates: a section already holds one value, so it renders as one lane (plus sub-lanes on overlap).


### Grouping

Set `group_by` (from `DataView.DisplayControls` → Grouping, or on the initial `query`) and the timeline splits into **swim-lane sections** stacked under the single shared time axis: a full-width header band per group, that group's cards lane-packed beneath it. Horizontal position stays purely time — grouping reorganizes vertically only.
Expand All @@ -461,9 +501,11 @@ const fields = [
</DataView>
```

The timeline consumes the **same group rows** `DataView.List` renders as section headers — the root's `groupData` output, in first-occurrence order — so section order, labels (`groupLabelsMap`), and counts (`groupCountMap`, or the bucket size) match between views, in client and server mode alike. There's no timeline-specific grouping path to keep in sync.
The timeline consumes the **same group rows** `DataView.List` renders as section headers — the root's `groupData` output — so section order, labels (`groupLabelsMap`), and counts (`groupCountMap`, or the bucket size) match between views, in client and server mode alike. There's no timeline-specific grouping path to keep in sync.

- **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative (every section starts at lane 0). `lanePacking="one-per-row"` applies within each section too.
Section order is the field's `groupOrder` where it declares one (`['High', 'Medium', 'Low']` — the ranking sorting can't express), then values it doesn't list in first-occurrence order, with rows that have no value in the last section. A declared value with no rows renders no section.

- **Packing is per section.** A card only ever shares a lane with cards in its own group, and `context.laneIndex` is section-relative (every section starts at lane 0). `lanePacking="one-per-row"` and `"one-per-sort-value"` apply within each section too.
- **Bands pin while their section is in view.** The active band sticks directly under the time axis and is pushed off by the next section's band; its label sticks to the left edge so it stays readable while you pan to a distant month. Always on — pure CSS, no prop.
- **Empty sections disappear.** A group whose cards all fall outside an explicit `range` (or that has no valid `startField` values) renders nothing — no band, no empty strip. A band that *does* render shows the full group count, even when some of its cards are culled, matching List.
- **`showGroupHeaders={false}`** hides the bands but keeps the sections — same semantics as the prop on `DataView.List`. Style a band with `classNames.groupHeader`.
Expand All @@ -472,7 +514,9 @@ Bands are labels only in this release: no chevron, no collapsing.

### Ordering

Sort can't move a card horizontally — x is locked to the start date — so it surfaces in exactly one place: `lanePacking="one-per-row"`, where vertical row order follows the active sort (within each section when grouped). With the default `auto` packing, lanes are assigned by dense chronological first-fit and the sort has no visible effect, so hide the Ordering control (`<DataView.DisplayControls hideOrdering />`) or leave `sortable` off the timeline's per-view `fields` unless you use `one-per-row`.
Sort can't move a card horizontally — x is locked to the start date — so it reaches the vertical axis only, and only under two of the three packing modes. With `lanePacking="one-per-row"`, row order follows the active sort (within each section when grouped). With `"one-per-sort-value"` it does more than reorder: the sorted-by field *defines* the lanes, so changing the sort field rebuilds them (see [Lane packing](#lane-packing)). Leave the Ordering control visible for both.

Under the default `auto` packing the sort has no visible effect at all — lanes are assigned by dense chronological first-fit — so there, hide the control (`<DataView.DisplayControls hideOrdering />`) or leave `sortable` off the timeline's per-view `fields`.

### Scale and axis

Expand Down Expand Up @@ -553,7 +597,7 @@ Start with `isLoading={true}` and fire an initial fetch on mount: with no data a

### Notes

- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (`<DataView.DisplayControls hideOrdering />`), or pass the timeline a per-view `fields` override without `sortable`/`groupable`.
- **Grouping** renders as swim-lane sections and **sorting** only reaches `lanePacking="one-per-row"` and `"one-per-sort-value"` (where it defines the lanes) — see [Grouping](#grouping) and [Ordering](#ordering). Hide a control that has no meaning for your configuration (`<DataView.DisplayControls hideOrdering />`), or pass the timeline a per-view `fields` override without `sortable`/`groupable`.
- **`virtualized`** culls both axes: cards, gridlines, tick labels, month bands, and markers render only near the viewport, and the grid and marker lines span the visible window rather than the full canvas height. A frame costs what is on screen rather than what is in the data, so a long domain and deep grouping stay affordable. It defaults to `false` — pass it explicitly. Recommended whenever the domain is long or rows are numerous.
- **Without `virtualized`, nothing is culled vertically.** Every lane in the domain stays mounted and every card in the visible time window renders, so deep grouping over thousands of rows builds a tall, fully populated canvas. The trade is content-driven lane heights (see [Cards](#cards)), which virtualization gives up.
- **Interaction** — cards receive row clicks via the root's `onRowClick`; the background supports mouse drag-to-pan with a momentum glide; scrolling past the domain edge won't trigger browser back-swipe. The pane is a focusable, labelled region (`aria-label`, default "Timeline"), so keyboard users can Tab to it and scroll with the arrow keys.
Expand Down
21 changes: 18 additions & 3 deletions apps/www/src/content/docs/components/dataview/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ export interface DataViewField {

/** Override group bucket labels (key → label). */
groupLabelsMap?: Record<string, string>;

/**
* Section order while this field is the active `group_by`, by raw group value —
* e.g. `['High', 'Medium', 'Low']`. Undeclared values follow in first-seen order,
* rows with no value land in the last section, and a declared value with no rows
* takes no section.
*/
groupOrder?: string[];
}

export interface DataViewListProps {
Expand Down Expand Up @@ -279,11 +287,18 @@ export interface DataViewTimelineProps {

/**
* `auto` packs non-overlapping cards into shared lanes; `one-per-row` gives every row
* its own lane, in row-model (sorted) order. Both apply per group section while
* `group_by` is active — cards never share a lane across sections.
* its own lane, in row-model (sorted) order; `one-per-sort-value` gives every distinct value
* of the *sorted-by* field its own lane, packing that value's cards by date within it.
* All apply per group section while `group_by` is active — cards never share a lane
* across sections.
*
* Under `one-per-sort-value` the active sort picks the field lanes are built from and orders
* them, so the Ordering control moves lanes live. Rank values that don't sort naturally
* (High/Medium/Low) with a numeric field and sort on that. Rows with no usable value
* (null, empty, non-primitive) share the last lane.
* @defaultValue "auto"
*/
lanePacking?: 'auto' | 'one-per-row';
lanePacking?: 'auto' | 'one-per-row' | 'one-per-sort-value';

/**
* Estimated card height in px, same contract as `DataView.List`: cards render at their
Expand Down
Loading
Loading