Skip to content
Draft
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
724 changes: 724 additions & 0 deletions apps/www/src/app/examples/timeline/page.tsx

Large diffs are not rendered by default.

668 changes: 668 additions & 0 deletions packages/raystack/components/data-view/__tests__/timeline.test.tsx

Large diffs are not rendered by default.

779 changes: 779 additions & 0 deletions packages/raystack/components/data-view/components/timeline.tsx

Large diffs are not rendered by default.

156 changes: 156 additions & 0 deletions packages/raystack/components/data-view/data-view.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,162 @@
pointer-events: none;
}

/* Timeline renderer — cards absolutely positioned on a continuous time axis.
The root is the single scroll container for both axes; the axis strip
sticks to the top on vertical scroll and moves with the canvas on
horizontal scroll because both share the same explicit content width. */
.timelineRoot {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
/* Reaching the domain edge must not chain into browser navigation
(horizontal swipe-back) or scroll the page behind. */
overscroll-behavior: contain;
background: var(--rs-color-background-base-primary);
}

/* Drag-to-pan in progress — grabbing cursor everywhere (descendants too, so
cards' own cursor styles don't flicker mid-drag) and selection locked. */
.timelineRoot[data-dragging],
.timelineRoot[data-dragging] * {
cursor: grabbing;
user-select: none;
}

/* Two-tier ruler: major bands (month/year) on top, minor tick labels below.
Marker badges (today, milestones) are pinned here so they stay visible
while the marker line runs down the canvas. */
.timelineAxis {
position: sticky;
top: 0;
z-index: 3;
height: 52px;
box-sizing: border-box;
background: var(--rs-color-background-base-primary);
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
}

.timelineAxisBand {
position: absolute;
top: var(--rs-space-2);
display: flex;
box-sizing: border-box;
color: var(--rs-color-foreground-base-primary);
font-family: var(--rs-font-body);
font-size: var(--rs-font-size-mini);
font-weight: var(--rs-font-weight-medium);
line-height: var(--rs-line-height-mini);
letter-spacing: var(--rs-letter-spacing-mini);
text-transform: uppercase;
}

/* Sticks to the viewport's left edge while its band is in view (the nearest
scroll container is the timeline root). No overflow:hidden on the band —
that would break the sticky behavior. */
.timelineAxisBandLabel {
position: sticky;
left: var(--rs-space-5);
white-space: nowrap;
}

.timelineAxisTick {
position: absolute;
bottom: var(--rs-space-2);
transform: translateX(-50%);
white-space: nowrap;
color: var(--rs-color-foreground-base-tertiary);
font-family: var(--rs-font-body);
font-size: var(--rs-font-size-mini);
font-weight: var(--rs-font-weight-regular);
line-height: var(--rs-line-height-mini);
letter-spacing: var(--rs-letter-spacing-mini);
}

.timelineAxisMarker {
position: absolute;
top: var(--rs-space-2);
transform: translateX(-50%);
z-index: 1;
display: flex;
}

/* Hover-cursor date badge — above marker badges, never intercepts events. */
.timelineAxisCursor {
position: absolute;
top: var(--rs-space-2);
transform: translateX(-50%);
z-index: 2;
display: flex;
pointer-events: none;
}

/* min-height keeps gridlines and marker lines running the full pane height
even when there are only a few lanes (52px = axis height). overflow:hidden
clips cards whose span crosses the domain edge at that edge, instead of
letting them dangle past the axis into unscaled space. */
.timelineCanvas {
position: relative;
box-sizing: border-box;
min-height: calc(100% - 52px);
overflow: hidden;
}

/* Dashed so the grid recedes; marker/today/cursor lines stay solid so they
read as data, not grid. */
.timelineGridline {
position: absolute;
top: 0;
bottom: 0;
width: 0;
border-left: 0.5px dashed var(--rs-color-border-base-primary);
pointer-events: none;
}

.timelineMarkerLine {
position: absolute;
top: 0;
bottom: 0;
width: 0;
border-left: 0.5px solid var(--rs-color-border-base-tertiary);
pointer-events: none;
}

.timelineMarkerLine[data-variant="accent"] {
border-left-color: var(--rs-color-border-accent-emphasis);
}

.timelineMarkerLine[data-variant="danger"] {
border-left-color: var(--rs-color-border-danger-emphasis);
}

/* Hover crosshair — darker than gridlines so the snapped position reads
clearly while tracking the pointer. */
.timelineCursorLine {
position: absolute;
top: 0;
bottom: 0;
width: 0;
border-left: 0.5px solid var(--rs-color-border-base-focus);
pointer-events: none;
}

/* Positioning wrapper only — visually neutral. The card interior (chrome,
states, truncation) is consumer-owned via `renderCard`. */
.timelineCard {
position: absolute;
box-sizing: border-box;
min-width: 0;
}

/* Sticky-left so the filter-summary footer stays aligned to the viewport
(not the full canvas width) under horizontal scroll. */
.timelineFooter {
position: sticky;
left: 0;
width: 100%;
}

/* Empty/zero sibling container */
.dataStateContainer {
display: flex;
Expand Down
2 changes: 2 additions & 0 deletions packages/raystack/components/data-view/data-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DataViewEmptyState } from './components/empty-state';
import { Filters } from './components/filters';
import { DataViewList } from './components/list';
import { DataViewSearch } from './components/search';
import { DataViewTimeline } from './components/timeline';
import { Toolbar } from './components/toolbar';
import { DataViewZeroState } from './components/zero-state';
import { DataViewContext } from './context';
Expand Down Expand Up @@ -305,6 +306,7 @@ DataViewRoot.displayName = 'DataView';
// biome-ignore lint/suspicious/noShadowRestrictedNames: public component name intentionally matches the package export
export const DataView = Object.assign(DataViewRoot, {
List: DataViewList,
Timeline: DataViewTimeline,
Custom: DataViewCustom,
DisplayAccess: DisplayAccess,
EmptyState: DataViewEmptyState,
Expand Down
117 changes: 117 additions & 0 deletions packages/raystack/components/data-view/data-view.types.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ColumnDef,
Row,
Table,
Updater,
VisibilityState
Expand Down Expand Up @@ -202,6 +203,122 @@ export interface DataViewListProps<TData, TValue = unknown> {
classNames?: DataViewListClassNames;
}

/** Date inputs accepted by Timeline props and row fields: Date, epoch ms, or a parseable string. */
export type TimelineDateInput = Date | number | string;

/** Tick granularity of the Timeline axis. */
export type TimelineScale = 'day' | 'week' | 'month' | 'quarter';

/** Full-height marker line with a badge pinned to the axis (milestones, deadlines). */
export interface TimelineMarker {
date: TimelineDateInput;
/** Badge content. Defaults to the marker date formatted as "17 Jan". */
label?: React.ReactNode;
variant?: 'default' | 'accent' | 'danger';
}

/**
* Geometry + state handed to `renderCard`. The Timeline owns positioning; the
* consumer owns the card visual and uses this context to adapt it (e.g. render
* a compact stub when `collapsed`).
*/
export interface TimelineCardContext {
/** Pixel width of the time span (0 when `endField` is omitted). */
width: number;
/** True when the span is narrower than `minCardWidth`. */
collapsed: boolean;
laneIndex: number;
start: Date;
/** Null when `endField` is omitted (point marker). */
end: Date | null;
}

export type DataViewTimelineClassNames = {
root?: string;
axis?: string;
band?: string;
tick?: string;
marker?: string;
gridline?: string;
cursor?: string;
canvas?: string;
card?: string;
};

export interface DataViewTimelineProps<TData> {
/** Multi-view name. When set, the renderer gates itself on the active view. */
name?: string;
/** Optional view-scoped field override. Full replacement of root `fields` for this view's active session. */
fields?: DataViewField<TData>[];

/** Accessor key on the row yielding the start date. Rows with a missing/invalid value are skipped. */
startField: string;
/** Accessor key for the end date. Omitted → point markers; present → variable-width span cards. */
endField?: string;

/**
* Renders the card interior. The Timeline owns positioning (x from start,
* width from span, lane from packing, scroll); the consumer owns the card
* visual entirely — chrome, states, truncation, and the collapsed variant.
* Compose `<DataView.DisplayAccess>` inside for Display Properties support.
*/
renderCard: (
row: Row<TData>,
context: TimelineCardContext
) => React.ReactNode;

/** Tick granularity of the time axis. Default 'day'. */
scale?: TimelineScale;
/** Pixel width of one `scale` unit — density/zoom override. */
unitWidth?: number;
/** Explicit time domain. Defaults to the data extent (plus today when shown) with padding. */
range?: [TimelineDateInput, TimelineDateInput];
/** Vertical "today" line + axis badge. `true` (default) uses the current date; a date pins it. */
today?: boolean | TimelineDateInput;
/** Additional full-height marker lines with axis badges. */
markers?: TimelineMarker[];
/** Vertical gridlines at every axis tick. Default true. */
showGridlines?: boolean;
/**
* Label every Nth `scale` unit on the axis, counted from the domain start
* (e.g. `2` on a day scale labels every other day). Labels never render
* closer than the collision floor, so a too-dense value degrades gracefully.
* Default: the densest interval whose labels fit.
*/
tickInterval?: number;
/**
* Draw a gridline every Nth `scale` unit, counted from the domain start.
* Independent of `tickInterval` and purely visual — cards, the today line,
* and the hover cursor still land on every unit. Default 1.
*/
gridlineInterval?: number;
/**
* Hover crosshair: a darker line snapped to the sub-interval (tick unit)
* under the pointer, with a date badge pinned to the axis. Default true.
*/
showCursorLine?: boolean;
/** Initial horizontal scroll target. Default 'today'. */
defaultScrollTo?: TimelineDateInput | 'today' | 'start';
/** Fires (rAF-throttled) with the visible time range as the user scrolls or resizes. */
onVisibleRangeChange?: (range: [Date, Date]) => void;

/**
* 'auto' (default) packs non-overlapping cards into shared lanes (greedy
* interval scheduling); 'one-per-row' gives every row its own lane.
*/
lanePacking?: 'auto' | 'one-per-row';
/** Card height in px. Default 66. */
rowHeight?: number;
/** Vertical gap between lanes in px. Default 16. */
laneGap?: number;
/** Spans narrower than this (px) flip `context.collapsed` for `renderCard`. Default 60. */
minCardWidth?: number;

/** When true, only cards/gridlines near the visible viewport are rendered (horizontal culling). */
virtualized?: boolean;
classNames?: DataViewTimelineClassNames;
}

export type TableQueryUpdateFn = (query: InternalQuery) => InternalQuery;

export type DataViewContextType<TData> = {
Expand Down
6 changes: 6 additions & 0 deletions packages/raystack/components/data-view/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,16 @@ export type {
DataViewProps,
DataViewQuery,
DataViewSort,
DataViewTimelineClassNames,
DataViewTimelineProps,
GroupByResolver,
InternalFilter,
InternalQuery,
SortOrdersValues,
TimelineCardContext,
TimelineDateInput,
TimelineMarker,
TimelineScale,
ViewSpec
} from './data-view.types';
export { defaultGroupOption } from './data-view.types';
Expand Down
43 changes: 43 additions & 0 deletions packages/raystack/components/data-view/utils/pack-lanes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export interface PackLaneItem {
/** Left edge in px (time-scale space). */
x: number;
/** Rendered width in px. */
width: number;
}

export interface PackLanesResult {
/** Lane index per input item, in the input's original order. */
lanes: number[];
laneCount: number;
}

const DEFAULT_LANE_GAP_PX = 8;

/**
* Greedy interval scheduling. Items are visited in ascending `x` order and
* each is dropped into the first lane whose last occupant ends at least
* `gapPx` before the item starts; a new lane is opened when none fits.
* Produces the dense "packed" layout of the timeline design — many
* non-overlapping cards share a lane.
*/
export function packLanes(
items: PackLaneItem[],
gapPx: number = DEFAULT_LANE_GAP_PX
): PackLanesResult {
const order = items
.map((_, i) => i)
.sort((a, b) => items[a].x - items[b].x || a - b);
const laneEnds: number[] = [];
const lanes = new Array<number>(items.length).fill(0);
for (const index of order) {
const item = items[index];
let lane = laneEnds.findIndex(end => end + gapPx <= item.x);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(0);
}
laneEnds[lane] = item.x + item.width;
lanes[index] = lane;
}
return { lanes, laneCount: laneEnds.length };
}
Loading
Loading