minka-ds 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -83,7 +83,7 @@ function DiagramNode({
|
|
|
83
83
|
return (
|
|
84
84
|
<div
|
|
85
85
|
data-slot="diagram-node"
|
|
86
|
-
className={cn("relative [border-radius:var(--radius-card)]", className)}
|
|
86
|
+
className={cn("relative inline-flex [border-radius:var(--radius-card)]", className)}
|
|
87
87
|
style={filled ? { animation: "diagram-node-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
|
|
88
88
|
>
|
|
89
89
|
<style>{`@keyframes diagram-node-pop { from { opacity:0; transform: scale(.96) } to { opacity:1; transform: scale(1) } }`}</style>
|
|
@@ -94,10 +94,12 @@ function DiagramNode({
|
|
|
94
94
|
style={{ boxShadow: filled ? `inset 0 0 0 4px ${stroke}` : "inset 0 0 0 0px transparent" }}
|
|
95
95
|
/>
|
|
96
96
|
|
|
97
|
-
{/* top: surface — inset so the ring shows; bg + lift fade in on fill
|
|
97
|
+
{/* top: surface — inset so the ring shows; bg + lift fade in on fill.
|
|
98
|
+
flex-1 so it fills the wrapper when an explicit width is imposed
|
|
99
|
+
(e.g. w-full), while still shrink-wrapping content otherwise. */}
|
|
98
100
|
<div
|
|
99
101
|
className={cn(
|
|
100
|
-
"relative m-1 flex items-center justify-center [border-radius:calc(var(--radius-card)-4px)] text-center transition-[background-color,box-shadow] duration-300 ease-out",
|
|
102
|
+
"relative m-1 flex flex-1 items-center justify-center [border-radius:calc(var(--radius-card)-4px)] text-center transition-[background-color,box-shadow] duration-300 ease-out",
|
|
101
103
|
sizeClass,
|
|
102
104
|
)}
|
|
103
105
|
style={{
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronDown } from "lucide-react"
|
|
5
|
+
import { Collapsible, CollapsibleContent } from "./collapsible"
|
|
6
|
+
import { Button } from "./button"
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
|
|
9
|
+
// A composed expand/collapse panel: a header row (title + optional subtitle on
|
|
10
|
+
// the left, optional meta + chevron on the right) over a styled body that
|
|
11
|
+
// expands. Built on the DS Collapsible primitive. Domain-agnostic — pass any
|
|
12
|
+
// content as children.
|
|
13
|
+
//
|
|
14
|
+
// When rendered inside an <ExpandablePanelGroup>, panels drop their own border +
|
|
15
|
+
// rounding (the Group owns the frame + shared dividers).
|
|
16
|
+
|
|
17
|
+
const GroupContext = React.createContext(false)
|
|
18
|
+
|
|
19
|
+
export interface ExpandablePanelProps {
|
|
20
|
+
title: React.ReactNode
|
|
21
|
+
/** Secondary line under the title (e.g. a timestamp). */
|
|
22
|
+
subtitle?: React.ReactNode
|
|
23
|
+
/** Right-aligned meta beside the chevron (e.g. "1 proof"). */
|
|
24
|
+
meta?: React.ReactNode
|
|
25
|
+
/** Uncontrolled initial open state. */
|
|
26
|
+
defaultOpen?: boolean
|
|
27
|
+
/** Controlled open state (pair with onOpenChange). */
|
|
28
|
+
open?: boolean
|
|
29
|
+
onOpenChange?: (open: boolean) => void
|
|
30
|
+
/** Body content, revealed when expanded. */
|
|
31
|
+
children?: React.ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
/** Extra classes for the body region. */
|
|
34
|
+
contentClassName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ExpandablePanel({
|
|
38
|
+
title, subtitle, meta, defaultOpen = false, open, onOpenChange, children, className, contentClassName,
|
|
39
|
+
}: ExpandablePanelProps) {
|
|
40
|
+
// Controlled or uncontrolled open state. The whole header row toggles it; the
|
|
41
|
+
// chevron is the DS ghost icon Button (carries the hover), wired to the same state.
|
|
42
|
+
const [internal, setInternal] = React.useState(defaultOpen)
|
|
43
|
+
const isOpen = open ?? internal
|
|
44
|
+
const setOpen = (next: boolean) => {
|
|
45
|
+
if (open === undefined) setInternal(next)
|
|
46
|
+
onOpenChange?.(next)
|
|
47
|
+
}
|
|
48
|
+
const toggle = () => setOpen(!isOpen)
|
|
49
|
+
const inGroup = React.useContext(GroupContext)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Collapsible
|
|
53
|
+
open={isOpen}
|
|
54
|
+
onOpenChange={setOpen}
|
|
55
|
+
data-slot="expandable-panel"
|
|
56
|
+
className={cn(
|
|
57
|
+
"overflow-hidden bg-[var(--color-bg-raised)]",
|
|
58
|
+
// standalone: own border + rounded frame. In a group, the Group owns the
|
|
59
|
+
// frame and the shared dividers, so the panel is borderless/square.
|
|
60
|
+
!inGroup && "[border-radius:var(--radius-card)] border border-[var(--color-border-default)]",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<style>{`
|
|
65
|
+
@keyframes expandable-down { from { height: 0 } to { height: var(--radix-collapsible-content-height) } }
|
|
66
|
+
@keyframes expandable-up { from { height: var(--radix-collapsible-content-height) } to { height: 0 } }
|
|
67
|
+
[data-slot="expandable-panel-content"][data-state="open"] { animation: expandable-down .22s ease-out }
|
|
68
|
+
[data-slot="expandable-panel-content"][data-state="closed"]{ animation: expandable-up .2s ease-in }
|
|
69
|
+
`}</style>
|
|
70
|
+
|
|
71
|
+
{/* Header — the whole row is clickable; only the chevron Button shows hover. */}
|
|
72
|
+
<div
|
|
73
|
+
data-slot="expandable-panel-header"
|
|
74
|
+
onClick={toggle}
|
|
75
|
+
className="flex w-full cursor-pointer items-center gap-4 px-5 py-3.5 text-left select-none"
|
|
76
|
+
>
|
|
77
|
+
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
|
78
|
+
<span className="text-label text-[var(--color-text-default)]">{title}</span>
|
|
79
|
+
{subtitle && <span className="truncate text-caption-light text-[var(--color-text-muted)]">{subtitle}</span>}
|
|
80
|
+
</div>
|
|
81
|
+
{meta && <span className="shrink-0 text-overline text-[var(--color-text-muted)]">{meta}</span>}
|
|
82
|
+
<Button
|
|
83
|
+
variant="ghost"
|
|
84
|
+
size="icon-sm"
|
|
85
|
+
aria-label={isOpen ? "Collapse" : "Expand"}
|
|
86
|
+
aria-expanded={isOpen}
|
|
87
|
+
onClick={(e) => { e.stopPropagation(); toggle() }}
|
|
88
|
+
>
|
|
89
|
+
<ChevronDown className={cn("size-4 transition-transform duration-200", isOpen && "rotate-180")} />
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Body — white like the header, no divider */}
|
|
94
|
+
<CollapsibleContent data-slot="expandable-panel-content" className="overflow-hidden">
|
|
95
|
+
<div className={cn("bg-[var(--color-bg-raised)] px-5 pb-4", contentClassName)}>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
</CollapsibleContent>
|
|
99
|
+
</Collapsible>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A stacked group of ExpandablePanels that read as one connected unit: the
|
|
104
|
+
// group owns the outer border + rounded corners (top of first / bottom of last),
|
|
105
|
+
// inner edges are square, and adjacent panels share a single divider. Children
|
|
106
|
+
// drop their own border/rounding via context. `overflow-hidden` clips the inner
|
|
107
|
+
// panels' square corners to the group's rounded frame.
|
|
108
|
+
export interface ExpandablePanelGroupProps {
|
|
109
|
+
children: React.ReactNode
|
|
110
|
+
className?: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ExpandablePanelGroup({ children, className }: ExpandablePanelGroupProps) {
|
|
114
|
+
return (
|
|
115
|
+
<GroupContext.Provider value={true}>
|
|
116
|
+
<div
|
|
117
|
+
data-slot="expandable-panel-group"
|
|
118
|
+
className={cn(
|
|
119
|
+
"overflow-hidden [border-radius:var(--radius-card)] border border-[var(--color-border-default)]",
|
|
120
|
+
"divide-y divide-[var(--color-border-default)]",
|
|
121
|
+
className,
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</div>
|
|
126
|
+
</GroupContext.Provider>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { ExpandablePanel, ExpandablePanelGroup }
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Info } from "lucide-react"
|
|
5
|
+
import { Badge } from "./badge"
|
|
6
|
+
import { Alert, AlertTitle, AlertDescription } from "./alert"
|
|
7
|
+
import { cn } from "../../lib/utils"
|
|
8
|
+
|
|
9
|
+
// A vertical process timeline: ordered sections, each anchored by a milestone
|
|
10
|
+
// badge, with items (steps) connected by a two-layer rail — a 4px muted "route"
|
|
11
|
+
// always drawn, and a 2px dark "progress" line riding on it from the top down to
|
|
12
|
+
// the step in progress. Sections connect via rounded CSS elbows. Optional
|
|
13
|
+
// annotation rows (a DS Alert) mark boundaries. On failure the timeline can
|
|
14
|
+
// truncate at the failed item.
|
|
15
|
+
//
|
|
16
|
+
// Status vocabulary (fixed): done = success(green), current = warning(amber,
|
|
17
|
+
// pulsing), failed = error(red), upcoming = border-strong(grey).
|
|
18
|
+
|
|
19
|
+
export type TimelineItemStatus = "done" | "current" | "upcoming" | "failed"
|
|
20
|
+
|
|
21
|
+
export interface TimelineItem {
|
|
22
|
+
label: string
|
|
23
|
+
status: TimelineItemStatus
|
|
24
|
+
/** Secondary tag shown before the label (e.g. an actor: "Ledger · …"). */
|
|
25
|
+
meta?: string
|
|
26
|
+
/** Preformatted timestamp shown first in the row (mono, hint). */
|
|
27
|
+
timestamp?: string
|
|
28
|
+
/** Optional "+N" multi-day suffix on the timestamp. */
|
|
29
|
+
dayOffset?: number
|
|
30
|
+
/** Failure reason, shown red inline when status is "failed". */
|
|
31
|
+
detail?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TimelineMilestone {
|
|
35
|
+
label: string
|
|
36
|
+
variant: "warning" | "info" | "success" | "error"
|
|
37
|
+
/** Heading shown left of the badge (e.g. a ledger phase term). */
|
|
38
|
+
lead?: string
|
|
39
|
+
/** Full-color when true; dimmed when false (not yet reached). */
|
|
40
|
+
active?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TimelineAnnotation {
|
|
44
|
+
title: string
|
|
45
|
+
description: React.ReactNode
|
|
46
|
+
/** DS Alert variant; defaults to "info". */
|
|
47
|
+
variant?: "info" | "success" | "warning" | "error"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TimelineSection {
|
|
51
|
+
milestone: TimelineMilestone
|
|
52
|
+
/** Elbow direction: "top" leads into the items below, "end" closes them. */
|
|
53
|
+
position: "top" | "end"
|
|
54
|
+
items: TimelineItem[]
|
|
55
|
+
/** Optional Alert row rendered before this section's items. */
|
|
56
|
+
annotationBefore?: TimelineAnnotation
|
|
57
|
+
/** Open a stub gap above the first item (separates from the section above). */
|
|
58
|
+
gapBefore?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TimelineProps {
|
|
62
|
+
sections: TimelineSection[]
|
|
63
|
+
/** When true, render stops after the first failed item (and its section). */
|
|
64
|
+
truncateAtFailure?: boolean
|
|
65
|
+
className?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Geometry (shared so dots, lines, and elbows align on one axis) ────────────
|
|
69
|
+
const RAIL_W = 20
|
|
70
|
+
const RAIL_X = RAIL_W / 2
|
|
71
|
+
const RAIL_MUTED = "var(--color-border-default)"
|
|
72
|
+
const RAIL_DARK = "var(--color-text-default)"
|
|
73
|
+
// Dot center aligns to the vertical center of the first text line (text-body-sm
|
|
74
|
+
// = 14px × 1.5 = 21px line box → center at 10.5px). Connectors/elbows derive
|
|
75
|
+
// from DOT_TOP so they shift with it.
|
|
76
|
+
const DOT_TOP = 10.5
|
|
77
|
+
const STROKE = 4 // muted base stroke width — the structural rail
|
|
78
|
+
const STROKE_DARK = 2 // dark progress stroke — thinner, rides over the base
|
|
79
|
+
const STUB_LEN = 12 // partial stub length across a section gap
|
|
80
|
+
const MILESTONE_H = 30 // fixed milestone row height; title-center = H/2
|
|
81
|
+
const ELBOW_RADIUS = 8
|
|
82
|
+
const ELBOW_REACH = RAIL_W / 2 + 2
|
|
83
|
+
const ROW_PB = 12 // pb-3 below a milestone; the top elbow's arm spans it
|
|
84
|
+
const ANNOTATION_ANCHOR = 18 // px to the alert's title line (elbow lands here)
|
|
85
|
+
const ANNOTATION_PB = 16 // pb-4 below the annotation; arm spans it to next dot
|
|
86
|
+
|
|
87
|
+
type LineBelow = "none" | "muted" | "dark"
|
|
88
|
+
type Stub = "muted" | "dark" | null
|
|
89
|
+
|
|
90
|
+
// Proof-dot color reads status alone: green done, amber current (pulse), red
|
|
91
|
+
// failed, grey upcoming.
|
|
92
|
+
const DOT_CLASS: Record<TimelineItemStatus, string> = {
|
|
93
|
+
done: "bg-[var(--color-feedback-success)]",
|
|
94
|
+
current: "bg-[var(--color-feedback-warning)] [animation:tl-pulse_1.6s_ease-in-out_infinite]",
|
|
95
|
+
failed: "bg-[var(--color-feedback-error)]",
|
|
96
|
+
upcoming: "bg-[var(--color-border-strong)]",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Rail segment ──────────────────────────────────────────────────────────────
|
|
100
|
+
// The 4px muted base (the route) is ALWAYS drawn; a 2px dark line overlays it,
|
|
101
|
+
// centered, when `dark` (progress reached here). `bottom` xor `height` sets the
|
|
102
|
+
// lower end.
|
|
103
|
+
function RailSeg({ top, bottom, height, dark }: { top: number; bottom?: number; height?: number; dark: boolean }) {
|
|
104
|
+
const span: React.CSSProperties = { top, ...(height != null ? { height } : { bottom }) }
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<span className="absolute rounded-full" style={{ left: RAIL_X, width: STROKE, backgroundColor: RAIL_MUTED, transform: `translateX(-${STROKE / 2}px)`, ...span }} />
|
|
108
|
+
{dark && <span className="absolute rounded-full" style={{ left: RAIL_X, width: STROKE_DARK, backgroundColor: RAIL_DARK, transform: `translateX(-${STROKE_DARK / 2}px)`, ...span }} />}
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Item row ──────────────────────────────────────────────────────────────────
|
|
114
|
+
// The dot sits at a fixed offset from the row top; the connector runs from this
|
|
115
|
+
// dot's center to the NEXT dot's center. Since every dot shares the same offset,
|
|
116
|
+
// `top: DOT_TOP` → `bottom: -DOT_TOP` lands exactly on the next dot regardless of
|
|
117
|
+
// row height. The dot is painted last, over the connector's start.
|
|
118
|
+
function ItemRow({ item, lineBelow, stubAbove = null, stubBelow = null }: { item: TimelineItem; lineBelow: LineBelow; stubAbove?: Stub; stubBelow?: Stub }) {
|
|
119
|
+
const upcoming = item.status === "upcoming"
|
|
120
|
+
const failed = item.status === "failed"
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex gap-2">
|
|
123
|
+
<div className="relative shrink-0" style={{ width: RAIL_W }}>
|
|
124
|
+
{lineBelow !== "none" && <RailSeg top={DOT_TOP} bottom={-DOT_TOP} dark={lineBelow === "dark"} />}
|
|
125
|
+
{stubAbove && <RailSeg top={DOT_TOP - STUB_LEN} height={STUB_LEN} dark={stubAbove === "dark"} />}
|
|
126
|
+
{stubBelow && <RailSeg top={DOT_TOP} height={STUB_LEN} dark={stubBelow === "dark"} />}
|
|
127
|
+
<span className={`absolute size-2.5 rounded-full ${DOT_CLASS[item.status]}`} style={{ left: RAIL_X, top: DOT_TOP, transform: "translate(-50%, -50%)" }} />
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* single line: timestamp (mono, hint) · meta · label */}
|
|
131
|
+
<div className={`flex flex-1 items-baseline gap-2 pb-4 text-body-sm ${upcoming ? "opacity-45" : ""}`}>
|
|
132
|
+
{item.timestamp && (
|
|
133
|
+
<span className="shrink-0 text-caption text-[var(--color-text-hint)] [font-family:var(--font-mono)]">
|
|
134
|
+
{item.timestamp}
|
|
135
|
+
{item.dayOffset != null && item.dayOffset > 0 && <span className="ml-0.5 align-super text-[0.7em]">+{item.dayOffset}</span>}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
{item.meta && <span className="text-[var(--color-text-muted)]">{item.meta} ·</span>}
|
|
139
|
+
<span className="text-[var(--color-text-default)]">{item.label}</span>
|
|
140
|
+
{failed && item.detail && <span className="text-[var(--color-feedback-error)]">· {item.detail}</span>}
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Milestone elbow ───────────────────────────────────────────────────────────
|
|
147
|
+
// CSS elbow (bordered box, no SVG): a vertical arm + a horizontal arm into the
|
|
148
|
+
// badge, with a rounded corner. Two layers so the 2px dark rides centered on the
|
|
149
|
+
// 4px muted base. Both endpoints anchor to centers (badge-center = MILESTONE_H/2;
|
|
150
|
+
// the adjacent dot is DOT_TOP into the flush neighbour row).
|
|
151
|
+
function Elbow({ position, reached }: { position: "top" | "end"; reached: boolean }) {
|
|
152
|
+
const TITLE_C = MILESTONE_H / 2
|
|
153
|
+
const layer = (w: number, c: string): React.CSSProperties => {
|
|
154
|
+
const d = (STROKE - w) / 2
|
|
155
|
+
return {
|
|
156
|
+
position: "absolute",
|
|
157
|
+
left: RAIL_X,
|
|
158
|
+
width: ELBOW_REACH,
|
|
159
|
+
transform: `translateX(-${w / 2}px)`,
|
|
160
|
+
borderStyle: "solid",
|
|
161
|
+
borderColor: "transparent",
|
|
162
|
+
borderLeftWidth: w,
|
|
163
|
+
borderLeftColor: c,
|
|
164
|
+
...(position === "end"
|
|
165
|
+
? { top: -DOT_TOP, height: TITLE_C + DOT_TOP - d, borderBottomWidth: w, borderBottomColor: c, borderBottomLeftRadius: ELBOW_RADIUS - d }
|
|
166
|
+
: { top: TITLE_C + d, height: MILESTONE_H - TITLE_C + ROW_PB + DOT_TOP - d, borderTopWidth: w, borderTopColor: c, borderTopLeftRadius: ELBOW_RADIUS - d }),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<span style={layer(STROKE, RAIL_MUTED)} />
|
|
172
|
+
{reached && <span style={layer(STROKE_DARK, RAIL_DARK)} />}
|
|
173
|
+
</>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function MilestoneRow({ milestone, position, reached }: { milestone: TimelineMilestone; position: "top" | "end"; reached: boolean }) {
|
|
178
|
+
const active = milestone.active ?? true
|
|
179
|
+
return (
|
|
180
|
+
<div className="pb-3">
|
|
181
|
+
<div className="flex items-center gap-2" style={{ height: MILESTONE_H }}>
|
|
182
|
+
<div className="relative shrink-0 self-stretch" style={{ width: RAIL_W }}>
|
|
183
|
+
<Elbow position={position} reached={reached} />
|
|
184
|
+
</div>
|
|
185
|
+
<div className={`flex items-center gap-2.5 ${active ? "" : "opacity-55"}`}>
|
|
186
|
+
{milestone.lead && (
|
|
187
|
+
<span className="text-body-lg font-semibold text-[var(--color-text-default)]">{milestone.lead}</span>
|
|
188
|
+
)}
|
|
189
|
+
<Badge variant={milestone.variant} className="text-label px-2.5 py-1">{milestone.label}</Badge>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Annotation row ────────────────────────────────────────────────────────────
|
|
197
|
+
// A label (not an item — no dot): a DS Alert whose elbow corner sits beside it
|
|
198
|
+
// and whose arm runs DOWN into the next item's dot. Two-layer like the rail.
|
|
199
|
+
function AnnotationRow({ annotation, dark }: { annotation: TimelineAnnotation; dark: boolean }) {
|
|
200
|
+
const elbowLayer = (w: number, c: string): React.CSSProperties => {
|
|
201
|
+
const d = (STROKE - w) / 2
|
|
202
|
+
return {
|
|
203
|
+
position: "absolute",
|
|
204
|
+
left: RAIL_X,
|
|
205
|
+
width: ELBOW_REACH,
|
|
206
|
+
top: ANNOTATION_ANCHOR + d,
|
|
207
|
+
bottom: -(DOT_TOP + ANNOTATION_PB),
|
|
208
|
+
transform: `translateX(-${w / 2}px)`,
|
|
209
|
+
borderStyle: "solid",
|
|
210
|
+
borderColor: "transparent",
|
|
211
|
+
borderLeftWidth: w,
|
|
212
|
+
borderLeftColor: c,
|
|
213
|
+
borderTopWidth: w,
|
|
214
|
+
borderTopColor: c,
|
|
215
|
+
borderTopLeftRadius: ELBOW_RADIUS - d,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return (
|
|
219
|
+
<div className="flex items-start gap-2 pb-4">
|
|
220
|
+
<div className="relative shrink-0 self-stretch" style={{ width: RAIL_W }}>
|
|
221
|
+
<span style={elbowLayer(STROKE, RAIL_MUTED)} />
|
|
222
|
+
{dark && <span style={elbowLayer(STROKE_DARK, RAIL_DARK)} />}
|
|
223
|
+
</div>
|
|
224
|
+
<Alert variant={annotation.variant ?? "info"}>
|
|
225
|
+
<Info />
|
|
226
|
+
<AlertTitle>{annotation.title}</AlertTitle>
|
|
227
|
+
<AlertDescription>{annotation.description}</AlertDescription>
|
|
228
|
+
</Alert>
|
|
229
|
+
</div>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
234
|
+
const isDone = (i?: TimelineItem) => i?.status === "done"
|
|
235
|
+
const reachedDot = (i?: TimelineItem) => i?.status === "done" || i?.status === "current"
|
|
236
|
+
|
|
237
|
+
// Connector below an item: none if nothing follows it in-section (gap/elbow
|
|
238
|
+
// joins); dark once flow passed it (done); muted otherwise.
|
|
239
|
+
const lineBelowFor = (i: TimelineItem, nextJoins: boolean): LineBelow =>
|
|
240
|
+
!nextJoins ? "none" : i.status === "done" ? "dark" : "muted"
|
|
241
|
+
const stubColor = (i?: TimelineItem): Stub => (reachedDot(i) ? "dark" : "muted")
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Generic vertical process timeline. See the file header for the model. The
|
|
245
|
+
* component owns all layout (rail, connectors, elbows, stubs, truncation); the
|
|
246
|
+
* consumer supplies the data and wraps it (e.g. in a Card) as needed.
|
|
247
|
+
*/
|
|
248
|
+
function Timeline({ sections, truncateAtFailure = false, className }: TimelineProps) {
|
|
249
|
+
// Find the failing item (across sections) to optionally truncate there.
|
|
250
|
+
let failedSectionIdx = -1
|
|
251
|
+
let failedItemIdx = -1
|
|
252
|
+
if (truncateAtFailure) {
|
|
253
|
+
for (let s = 0; s < sections.length && failedSectionIdx === -1; s++) {
|
|
254
|
+
const fi = sections[s].items.findIndex(i => i.status === "failed")
|
|
255
|
+
if (fi !== -1) { failedSectionIdx = s; failedItemIdx = fi }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const truncated = failedSectionIdx !== -1
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div className={cn("flex flex-col", className)}>
|
|
262
|
+
<style>{`@keyframes tl-pulse {
|
|
263
|
+
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-feedback-warning) 45%, transparent); }
|
|
264
|
+
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-feedback-warning) 0%, transparent); }
|
|
265
|
+
}`}</style>
|
|
266
|
+
|
|
267
|
+
{sections.map((section, s) => {
|
|
268
|
+
if (truncated && s > failedSectionIdx) return null
|
|
269
|
+
const items = truncated && s === failedSectionIdx
|
|
270
|
+
? section.items.slice(0, failedItemIdx + 1)
|
|
271
|
+
: section.items
|
|
272
|
+
const sectionFailed = truncated && s === failedSectionIdx
|
|
273
|
+
const lastItem = items[items.length - 1]
|
|
274
|
+
// A gap sits between two sections when the FOLLOWING section opts in via
|
|
275
|
+
// gapBefore: this section's last item gets a stub below, the next
|
|
276
|
+
// section's first item a stub above — together forming the broken gap.
|
|
277
|
+
const next = sections[s + 1]
|
|
278
|
+
const closeWithGap = section.position === "top" && !sectionFailed && !!next?.gapBefore
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<React.Fragment key={s}>
|
|
282
|
+
{section.position === "top" && (
|
|
283
|
+
<MilestoneRow milestone={section.milestone} position="top" reached={isDone(items[0])} />
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{section.annotationBefore && (
|
|
287
|
+
<AnnotationRow annotation={section.annotationBefore} dark={stubColor(items[0]) === "dark"} />
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{items.map((item, i) => {
|
|
291
|
+
const isLast = i === items.length - 1
|
|
292
|
+
return (
|
|
293
|
+
<ItemRow
|
|
294
|
+
key={i}
|
|
295
|
+
item={item}
|
|
296
|
+
lineBelow={lineBelowFor(item, !isLast)}
|
|
297
|
+
stubBelow={isLast && closeWithGap ? stubColor(item) : null}
|
|
298
|
+
// a stub above the first item only when this section opts into a
|
|
299
|
+
// gap AND it isn't introduced by an annotation (the annotation's
|
|
300
|
+
// elbow already connects down into the first item).
|
|
301
|
+
stubAbove={i === 0 && section.gapBefore && !section.annotationBefore ? stubColor(item) : null}
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
})}
|
|
305
|
+
|
|
306
|
+
{section.position === "end" && !sectionFailed && (
|
|
307
|
+
<MilestoneRow milestone={section.milestone} position="end" reached={isDone(lastItem)} />
|
|
308
|
+
)}
|
|
309
|
+
</React.Fragment>
|
|
310
|
+
)
|
|
311
|
+
})}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export { Timeline }
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,8 @@ export * from "./components/ui/tooltip"
|
|
|
46
46
|
export * from "./components/ui/flow-diagram"
|
|
47
47
|
export * from "./components/ui/diagram-node"
|
|
48
48
|
export * from "./components/ui/help-expander"
|
|
49
|
+
export * from "./components/ui/timeline"
|
|
50
|
+
export * from "./components/ui/expandable-panel"
|
|
49
51
|
|
|
50
52
|
// Brand textures + logo
|
|
51
53
|
export * from "./textures"
|