minka-ds 0.4.0 → 0.4.2
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
|
@@ -17,6 +17,8 @@ interface AvatarProps {
|
|
|
17
17
|
size?: "sm" | "md" | "lg"
|
|
18
18
|
/** Background for the initials state. Defaults to a brand color. */
|
|
19
19
|
background?: string
|
|
20
|
+
/** Foreground (initials) color. Defaults to the inverse text token. */
|
|
21
|
+
color?: string
|
|
20
22
|
className?: string
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -25,7 +27,7 @@ function deriveInitials(name?: string): string {
|
|
|
25
27
|
return name.trim().split(/\s+/).map(p => p[0]).join("").slice(0, 2).toUpperCase()
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
function Avatar({ src, name, initials, size = "md", background, className }: AvatarProps) {
|
|
30
|
+
function Avatar({ src, name, initials, size = "md", background, color, className }: AvatarProps) {
|
|
29
31
|
return (
|
|
30
32
|
<div
|
|
31
33
|
data-slot="avatar"
|
|
@@ -34,7 +36,10 @@ function Avatar({ src, name, initials, size = "md", background, className }: Ava
|
|
|
34
36
|
SIZE[size],
|
|
35
37
|
className
|
|
36
38
|
)}
|
|
37
|
-
style={{
|
|
39
|
+
style={{
|
|
40
|
+
background: src ? undefined : (background ?? "var(--color-brand-blue)"),
|
|
41
|
+
...(color ? { color } : {}),
|
|
42
|
+
}}
|
|
38
43
|
>
|
|
39
44
|
{src
|
|
40
45
|
? <img src={src} alt={name ?? ""} className="size-full object-cover" />
|
|
@@ -47,6 +47,44 @@ function DialogOverlay({
|
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
type PanelPlacement = "side" | "top"
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optional contextual panel rendered inside DialogContent. Holds supporting
|
|
54
|
+
* media or guidance (illustration, brand texture, action summary, help text).
|
|
55
|
+
* Place it as a direct child of DialogContent; the content adapts its layout.
|
|
56
|
+
*
|
|
57
|
+
* By default the panel bleeds to the dialog edges. Pass `inset` to float it
|
|
58
|
+
* inside the dialog with an 8px frame and rounded corners.
|
|
59
|
+
*/
|
|
60
|
+
function DialogPanel({
|
|
61
|
+
className,
|
|
62
|
+
placement = "side",
|
|
63
|
+
inset = false,
|
|
64
|
+
...props
|
|
65
|
+
}: React.ComponentProps<"div"> & { placement?: PanelPlacement; inset?: boolean }) {
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
data-slot="dialog-panel"
|
|
69
|
+
data-placement={placement}
|
|
70
|
+
data-inset={inset || undefined}
|
|
71
|
+
className={cn(
|
|
72
|
+
"relative flex flex-col justify-center bg-[var(--color-bg-canvas)]",
|
|
73
|
+
// inset panels are tighter — the 8px frame already adds breathing room
|
|
74
|
+
inset ? "p-4" : "p-6",
|
|
75
|
+
// side: a column down the left; top: a banner across the top
|
|
76
|
+
placement === "side" ? "shrink-0 sm:w-2/5" : "min-h-32",
|
|
77
|
+
// inset: float inside the dialog with an 8px frame on the dialog-facing
|
|
78
|
+
// edges only — the interior edge stays flush, the body padding separates
|
|
79
|
+
inset && "overflow-hidden [border-radius:var(--radius-card)]",
|
|
80
|
+
inset && (placement === "side" ? "ml-2 my-2" : "mt-2 mx-2"),
|
|
81
|
+
className
|
|
82
|
+
)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
function DialogContent({
|
|
51
89
|
className,
|
|
52
90
|
children,
|
|
@@ -57,31 +95,66 @@ function DialogContent({
|
|
|
57
95
|
showCloseButton?: boolean
|
|
58
96
|
container?: HTMLElement | null
|
|
59
97
|
}) {
|
|
98
|
+
// Detect an optional DialogPanel child and split it out from the body.
|
|
99
|
+
const childArray = React.Children.toArray(children)
|
|
100
|
+
const panel = childArray.find(
|
|
101
|
+
(c): c is React.ReactElement<{ placement?: PanelPlacement; children?: React.ReactNode }> =>
|
|
102
|
+
React.isValidElement(c) && (c.type as { displayName?: string })?.displayName === "DialogPanel"
|
|
103
|
+
)
|
|
104
|
+
const body = childArray.filter(c => c !== panel)
|
|
105
|
+
const placement = panel?.props.placement ?? "side"
|
|
106
|
+
const hasPanel = Boolean(panel)
|
|
107
|
+
|
|
108
|
+
const closeButton = showCloseButton && (
|
|
109
|
+
<DialogPrimitive.Close asChild>
|
|
110
|
+
<Button
|
|
111
|
+
data-slot="dialog-close"
|
|
112
|
+
variant="ghost"
|
|
113
|
+
size="icon-sm"
|
|
114
|
+
aria-label="Close"
|
|
115
|
+
className="absolute top-5 right-5 z-10 translate-y-[5px] text-current opacity-70 hover:opacity-100"
|
|
116
|
+
>
|
|
117
|
+
<XIcon />
|
|
118
|
+
</Button>
|
|
119
|
+
</DialogPrimitive.Close>
|
|
120
|
+
)
|
|
121
|
+
|
|
60
122
|
return (
|
|
61
123
|
<DialogPortal container={container}>
|
|
62
124
|
<DialogOverlay />
|
|
63
125
|
<DialogPrimitive.Content
|
|
64
126
|
data-slot="dialog-content"
|
|
127
|
+
data-panel={hasPanel ? placement : undefined}
|
|
65
128
|
className={cn(
|
|
66
|
-
"fixed top-[50%] left-[50%] [z-index:var(--z-modal)]
|
|
129
|
+
"fixed top-[50%] left-[50%] [z-index:var(--z-modal)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-hidden [border-radius:var(--radius-modal)] border border-[var(--color-border-default)] bg-[var(--color-bg-overlay)] shadow-[var(--shadow-modal)] duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
130
|
+
// layout: plain (padded grid) vs panelled (flex split, panel bleeds to edges)
|
|
131
|
+
hasPanel
|
|
132
|
+
? placement === "side"
|
|
133
|
+
? "flex flex-col sm:flex-row sm:max-w-2xl"
|
|
134
|
+
: "flex flex-col"
|
|
135
|
+
: "grid gap-4 p-5",
|
|
67
136
|
className
|
|
68
137
|
)}
|
|
69
138
|
{...props}
|
|
70
139
|
>
|
|
71
|
-
{
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
140
|
+
{/* top: close lives inside the panel so it inherits the panel's foreground */}
|
|
141
|
+
{placement === "top" && panel
|
|
142
|
+
? React.cloneElement(panel, {}, panel.props.children, closeButton)
|
|
143
|
+
: panel}
|
|
144
|
+
{hasPanel ? (
|
|
145
|
+
<div data-slot="dialog-body" className="relative flex flex-1 flex-col gap-4 p-5">
|
|
146
|
+
{body}
|
|
147
|
+
</div>
|
|
148
|
+
) : (
|
|
149
|
+
body
|
|
80
150
|
)}
|
|
151
|
+
{/* side / no panel: close sits over the content body */}
|
|
152
|
+
{placement !== "top" && closeButton}
|
|
81
153
|
</DialogPrimitive.Content>
|
|
82
154
|
</DialogPortal>
|
|
83
155
|
)
|
|
84
156
|
}
|
|
157
|
+
DialogPanel.displayName = "DialogPanel"
|
|
85
158
|
|
|
86
159
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
87
160
|
return (
|
|
@@ -127,7 +200,7 @@ function DialogTitle({
|
|
|
127
200
|
return (
|
|
128
201
|
<DialogPrimitive.Title
|
|
129
202
|
data-slot="dialog-title"
|
|
130
|
-
className={cn("text-heading-
|
|
203
|
+
className={cn("text-heading-2-serif", className)}
|
|
131
204
|
{...props}
|
|
132
205
|
/>
|
|
133
206
|
)
|
|
@@ -154,6 +227,7 @@ export {
|
|
|
154
227
|
DialogFooter,
|
|
155
228
|
DialogHeader,
|
|
156
229
|
DialogOverlay,
|
|
230
|
+
DialogPanel,
|
|
157
231
|
DialogPortal,
|
|
158
232
|
DialogTitle,
|
|
159
233
|
DialogTrigger,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
/** Brand pair used to give a node / the flow a semantic accent. */
|
|
6
|
+
export type FlowAccent =
|
|
7
|
+
| "yellow-darkforest"
|
|
8
|
+
| "rose-coral"
|
|
9
|
+
| "blue-navy"
|
|
10
|
+
| "beige-bronze"
|
|
11
|
+
| "gray-black"
|
|
12
|
+
|
|
13
|
+
export interface FlowNode {
|
|
14
|
+
/** Node label (party name). */
|
|
15
|
+
name: string
|
|
16
|
+
/** Current balance; null when hidden (e.g. balance not shown). */
|
|
17
|
+
current: number | null
|
|
18
|
+
/** Projected balance after the movement; null when no amount yet or hidden. */
|
|
19
|
+
after: number | null
|
|
20
|
+
/** Shown in place of a balance when `current` is null (e.g. "Master balance"). */
|
|
21
|
+
subtitle?: string
|
|
22
|
+
/** True when this node is an unfilled slot (e.g. nothing selected yet). */
|
|
23
|
+
empty?: boolean
|
|
24
|
+
/** Optional leading icon (used by state nodes like issue/destroy). */
|
|
25
|
+
icon?: React.ReactNode
|
|
26
|
+
/**
|
|
27
|
+
* When set, this node is a "state" node (no balance) rendered in the pair's
|
|
28
|
+
* colors — used for the abstract create/destroy end of issue/destroy flows.
|
|
29
|
+
*/
|
|
30
|
+
accent?: FlowAccent
|
|
31
|
+
/**
|
|
32
|
+
* Invert the accent fill: light member as background, dark as text/border
|
|
33
|
+
* (instead of dark bg + light text). Used to contrast destroy vs issue.
|
|
34
|
+
*/
|
|
35
|
+
accentInverted?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type FormatFn = (n: number) => string
|
|
39
|
+
const defaultFormat: FormatFn = (n) => "$" + n.toLocaleString("en-US")
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Exact-dash rounded border via an SVG rect overlay (CSS border-dashed has a
|
|
43
|
+
* fixed dash pattern that can't be sized). 6px dash / 6px gap. Fills its
|
|
44
|
+
* relatively-positioned parent.
|
|
45
|
+
*/
|
|
46
|
+
function DashedBorder({ radius = 10 }: { radius?: number }) {
|
|
47
|
+
return (
|
|
48
|
+
<svg className="pointer-events-none absolute inset-0 size-full" aria-hidden>
|
|
49
|
+
<rect
|
|
50
|
+
x="0.75" y="0.75"
|
|
51
|
+
width="100%" height="100%"
|
|
52
|
+
rx={radius} ry={radius}
|
|
53
|
+
fill="none"
|
|
54
|
+
stroke="var(--color-border-default)"
|
|
55
|
+
strokeWidth="1.5"
|
|
56
|
+
strokeDasharray="6 6"
|
|
57
|
+
style={{ width: "calc(100% - 1.5px)", height: "calc(100% - 1.5px)" }}
|
|
58
|
+
/>
|
|
59
|
+
</svg>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FlowDiagramProps {
|
|
64
|
+
/** Top node (fixed position). */
|
|
65
|
+
top: FlowNode
|
|
66
|
+
/** Bottom node (fixed position). */
|
|
67
|
+
bottom: FlowNode
|
|
68
|
+
/** The amount flowing; 0 = idle/empty state. */
|
|
69
|
+
amount: number
|
|
70
|
+
/** Flow direction — arrow + sheen travel this way. */
|
|
71
|
+
direction: "up" | "down"
|
|
72
|
+
/** Unit shown beside the amount. */
|
|
73
|
+
currency?: string
|
|
74
|
+
/** Custom amount/balance formatter (defaults to "$" + en-US grouping). */
|
|
75
|
+
format?: FormatFn
|
|
76
|
+
/** Optional content rendered below the diagram (e.g. a reference pill). */
|
|
77
|
+
footer?: React.ReactNode
|
|
78
|
+
/**
|
|
79
|
+
* Semantic accent (brand pair) for the flow — tints the arrow, sheen and
|
|
80
|
+
* amount chip. Used by issue/destroy to read as generative/destructive.
|
|
81
|
+
*/
|
|
82
|
+
accent?: FlowAccent
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Vertical flow diagram for a value movement between two parties. Positions are
|
|
87
|
+
* FIXED (`top` / `bottom`); only the `direction` changes which way the arrow
|
|
88
|
+
* points and the light sheen travels. Each node shows its current balance,
|
|
89
|
+
* which demotes to a "was …" caption as the new (after) balance slides in once
|
|
90
|
+
* an amount is entered. Unfilled nodes render as a dashed empty slot. Neutral
|
|
91
|
+
* styling — suited to neutral transfers.
|
|
92
|
+
*/
|
|
93
|
+
function FlowDiagram({ top, bottom, amount, direction, currency = "COP", format = defaultFormat, footer, accent }: FlowDiagramProps) {
|
|
94
|
+
const active = amount > 0
|
|
95
|
+
const glintKey = active ? amount : 0
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div data-slot="flow-diagram" className="relative flex flex-col items-center">
|
|
99
|
+
<style>{`
|
|
100
|
+
@keyframes flow-slide-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
|
101
|
+
@keyframes flow-pop { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
|
|
102
|
+
`}</style>
|
|
103
|
+
|
|
104
|
+
<FlowNodeCard key={top.empty ? "top-empty" : `top-${top.name}`} node={top} active={active} format={format} pop />
|
|
105
|
+
|
|
106
|
+
<Connector active={active} direction={direction} amount={amount} currency={currency} format={format} glintKey={glintKey} accent={accent} />
|
|
107
|
+
|
|
108
|
+
<FlowNodeCard key={bottom.empty ? "bottom-empty" : `bottom-${bottom.name}`} node={bottom} active={active} format={format} pop />
|
|
109
|
+
|
|
110
|
+
{/* footer (e.g. reference pill) is absolutely positioned above the diagram
|
|
111
|
+
so it doesn't shift the diagram's vertical centering when it appears */}
|
|
112
|
+
{footer && (
|
|
113
|
+
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-6">{footer}</div>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Full connector height: enough for top gap + badge + bottom gap.
|
|
120
|
+
const CONNECTOR_H = 80
|
|
121
|
+
|
|
122
|
+
function Connector({
|
|
123
|
+
active, direction, amount, currency, format, glintKey, accent,
|
|
124
|
+
}: {
|
|
125
|
+
active: boolean
|
|
126
|
+
direction: "up" | "down"
|
|
127
|
+
amount: number
|
|
128
|
+
currency: string
|
|
129
|
+
format: FormatFn
|
|
130
|
+
glintKey: number
|
|
131
|
+
accent?: FlowAccent
|
|
132
|
+
}) {
|
|
133
|
+
const gid = React.useId()
|
|
134
|
+
const H = CONNECTOR_H
|
|
135
|
+
const headUp = direction === "up"
|
|
136
|
+
|
|
137
|
+
// active stroke: semantic pair-dark when accented, else default ink
|
|
138
|
+
const activeStroke = accent ? `var(--color-pair-${accent}-dark)` : "var(--color-text-default)"
|
|
139
|
+
|
|
140
|
+
const head = headUp ? "M2 6 L7 0 L12 6" : `M2 ${H - 6} L7 ${H} L12 ${H - 6}`
|
|
141
|
+
const shapes = (
|
|
142
|
+
<>
|
|
143
|
+
<line x1="7" y1="0" x2="7" y2={H} />
|
|
144
|
+
<path d={head} />
|
|
145
|
+
</>
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="relative flex items-center justify-center my-1" style={{ height: H }}>
|
|
150
|
+
<svg width="14" height={H} viewBox={`0 0 14 ${H}`} className="block overflow-visible" aria-hidden>
|
|
151
|
+
<g stroke={active ? activeStroke : "var(--color-border-strong)"} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
152
|
+
{shapes}
|
|
153
|
+
</g>
|
|
154
|
+
{active && (
|
|
155
|
+
<>
|
|
156
|
+
<defs>
|
|
157
|
+
<linearGradient id={gid} gradientUnits="userSpaceOnUse" x1="7" y1="0" x2="7" y2="18">
|
|
158
|
+
<stop offset="0%" stopColor="var(--color-bg-raised)" stopOpacity="0" />
|
|
159
|
+
<stop offset="50%" stopColor="var(--color-bg-raised)" stopOpacity="0.5" />
|
|
160
|
+
<stop offset="100%" stopColor="var(--color-bg-raised)" stopOpacity="0" />
|
|
161
|
+
<animate attributeName="y1" values={direction === "down" ? `-20;${H}` : `${H};-20`} dur="2.4s" repeatCount="indefinite" />
|
|
162
|
+
<animate attributeName="y2" values={direction === "down" ? `-2;${H + 18}` : `${H + 18};-2`} dur="2.4s" repeatCount="indefinite" />
|
|
163
|
+
</linearGradient>
|
|
164
|
+
</defs>
|
|
165
|
+
<g stroke={`url(#${gid})`} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none">
|
|
166
|
+
{shapes}
|
|
167
|
+
</g>
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
</svg>
|
|
171
|
+
|
|
172
|
+
<span
|
|
173
|
+
key={glintKey}
|
|
174
|
+
className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 px-2.5 py-1 [border-radius:var(--radius-badge)] text-label-mono transition-colors duration-300 ${
|
|
175
|
+
active
|
|
176
|
+
? "bg-[var(--color-bg-inverted)] text-[var(--color-text-inverse)]"
|
|
177
|
+
: "bg-[var(--color-bg-base)] text-[var(--color-text-hint)]"
|
|
178
|
+
}`}
|
|
179
|
+
>
|
|
180
|
+
{!active && <DashedBorder radius={16} />}
|
|
181
|
+
{active ? format(amount) : format(0)}
|
|
182
|
+
<span className={`text-caption ml-1 ${active ? "text-[var(--color-text-inverse-muted)]" : "text-[var(--color-text-muted)]"}`}>{currency}</span>
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function FlowNodeCard({ node, active, format, pop }: { node: FlowNode; active: boolean; format: FormatFn; pop?: boolean }) {
|
|
189
|
+
const showAfter = active && node.after !== null
|
|
190
|
+
const overdrawn = node.after !== null && node.after < 0
|
|
191
|
+
|
|
192
|
+
if (node.empty) {
|
|
193
|
+
return (
|
|
194
|
+
<div className="relative flex flex-col items-center justify-center gap-1 [border-radius:var(--radius-card)] bg-[var(--color-bg-base)] px-5 py-3 text-center min-w-[180px] min-h-[64px]">
|
|
195
|
+
<DashedBorder radius={10} />
|
|
196
|
+
<span className="text-body-sm text-[var(--color-text-hint)]">{node.name}</span>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// State node (issue/destroy abstract end): pair-colored, no balance. Icon sits
|
|
202
|
+
// inline next to the main label; subtext below.
|
|
203
|
+
if (node.accent) {
|
|
204
|
+
// default: dark fill + light text; inverted: light fill + dark text.
|
|
205
|
+
const fill = node.accentInverted ? `var(--color-pair-${node.accent}-light)` : `var(--color-pair-${node.accent}-dark)`
|
|
206
|
+
const ink = node.accentInverted ? `var(--color-pair-${node.accent}-dark)` : `var(--color-pair-${node.accent}-light)`
|
|
207
|
+
const ring = node.accentInverted ? `var(--color-pair-${node.accent}-dark)` : `var(--color-pair-${node.accent}-light)`
|
|
208
|
+
const subInk = node.accentInverted ? "var(--color-text-default)" : "var(--color-text-inverse)"
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
className="flex flex-col items-center gap-1 [border-radius:var(--radius-card)] border px-5 py-3 text-center min-w-[180px]"
|
|
212
|
+
style={{ backgroundColor: fill, color: ink, borderColor: ring }}
|
|
213
|
+
>
|
|
214
|
+
<span className="flex items-center gap-1.5 text-body-sm">
|
|
215
|
+
{node.icon && <span className="flex items-center [&_svg]:size-4">{node.icon}</span>}
|
|
216
|
+
{node.name}
|
|
217
|
+
</span>
|
|
218
|
+
{node.subtitle && <span className="text-caption" style={{ color: subInk }}>{node.subtitle}</span>}
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div
|
|
225
|
+
className="flex flex-col items-center gap-1 [border-radius:var(--radius-card)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] px-5 py-3 text-center min-w-[180px]"
|
|
226
|
+
style={pop ? { animation: "flow-pop .18s cubic-bezier(0.16,1,0.3,1) both" } : undefined}
|
|
227
|
+
>
|
|
228
|
+
<span className="text-body-sm text-[var(--color-text-default)]">{node.name}</span>
|
|
229
|
+
|
|
230
|
+
{node.current === null ? (
|
|
231
|
+
node.subtitle ? <span className="text-caption text-[var(--color-text-muted)]">{node.subtitle}</span> : null
|
|
232
|
+
) : (
|
|
233
|
+
<div className="flex flex-col items-center leading-none">
|
|
234
|
+
<span
|
|
235
|
+
className={`origin-bottom transition-all duration-300 ${
|
|
236
|
+
showAfter
|
|
237
|
+
? "scale-[0.78] text-caption text-[var(--color-text-muted)] mb-0.5"
|
|
238
|
+
: "text-label-mono text-[var(--color-text-default)]"
|
|
239
|
+
}`}
|
|
240
|
+
>
|
|
241
|
+
{showAfter ? `was ${format(node.current)}` : format(node.current)}
|
|
242
|
+
</span>
|
|
243
|
+
{showAfter && (
|
|
244
|
+
<span
|
|
245
|
+
className={`text-label-mono ${overdrawn ? "text-[var(--color-feedback-error)]" : "text-[var(--color-text-default)]"}`}
|
|
246
|
+
style={{ animation: "flow-slide-in .3s ease both" }}
|
|
247
|
+
>
|
|
248
|
+
{format(node.after!)}
|
|
249
|
+
</span>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { FlowDiagram }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
5
|
+
import { HelpCircle, X, ExternalLink } from "lucide-react"
|
|
6
|
+
import { cn } from "../../lib/utils"
|
|
7
|
+
|
|
8
|
+
type Anchor = "bottom-right" | "bottom-left" | "top-right" | "top-left"
|
|
9
|
+
|
|
10
|
+
export interface HelpExpanderProps {
|
|
11
|
+
/** Card heading. */
|
|
12
|
+
title: string
|
|
13
|
+
/** Card body (text or nodes). */
|
|
14
|
+
children: React.ReactNode
|
|
15
|
+
/**
|
|
16
|
+
* "popover" (default) floats the card next to the trigger (portaled — works
|
|
17
|
+
* anywhere). "inset" expands the card inside the nearest positioned container,
|
|
18
|
+
* frosting whatever is behind it — for panels with empty space to fill.
|
|
19
|
+
*/
|
|
20
|
+
mode?: "popover" | "inset"
|
|
21
|
+
/** Trigger appearance. Defaults to a circular "?" icon button, no label. */
|
|
22
|
+
trigger?: { icon?: React.ReactNode; label?: string }
|
|
23
|
+
/** inset mode: which corner the button sits in / the card expands from. */
|
|
24
|
+
anchor?: Anchor
|
|
25
|
+
/** Optional doc link rendered under the body. */
|
|
26
|
+
docHref?: string
|
|
27
|
+
docLabel?: string
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ANCHOR_POS: Record<Anchor, string> = {
|
|
32
|
+
"bottom-right": "bottom-3 right-3 items-end",
|
|
33
|
+
"bottom-left": "bottom-3 left-3 items-start",
|
|
34
|
+
"top-right": "top-3 right-3 items-end",
|
|
35
|
+
"top-left": "top-3 left-3 items-start",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// shared trigger button
|
|
39
|
+
function TriggerButton({ trigger, ...props }: { trigger?: HelpExpanderProps["trigger"] } & React.ComponentProps<"button">) {
|
|
40
|
+
const icon = trigger?.icon ?? <HelpCircle className="size-4" />
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-label="Help"
|
|
45
|
+
className={cn(
|
|
46
|
+
"flex items-center justify-center gap-1.5 [border-radius:var(--radius-button)] border border-[var(--color-border-default)] bg-[var(--color-bg-raised)] text-[var(--color-text-muted)] shadow-[var(--shadow-card)] transition-colors hover:text-[var(--color-text-default)] hover:border-[var(--color-border-strong)]",
|
|
47
|
+
trigger?.label ? "h-8 px-3 text-label-sm" : "size-8",
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{icon}
|
|
52
|
+
{trigger?.label}
|
|
53
|
+
</button>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// shared frosted card body
|
|
58
|
+
function CardBody({ title, children, docHref, docLabel, onClose }: {
|
|
59
|
+
title: string
|
|
60
|
+
children: React.ReactNode
|
|
61
|
+
docHref?: string
|
|
62
|
+
docLabel?: string
|
|
63
|
+
onClose: () => void
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<div
|
|
67
|
+
className="w-full [border-radius:var(--radius-card)] border border-[var(--color-border-default)] backdrop-blur-md shadow-[var(--shadow-popover)] p-4 [animation:help-in_.2s_cubic-bezier(0.16,1,0.3,1)]"
|
|
68
|
+
style={{ backgroundColor: "color-mix(in srgb, var(--color-bg-overlay) 70%, transparent)" }}
|
|
69
|
+
>
|
|
70
|
+
<style>{`@keyframes help-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }`}</style>
|
|
71
|
+
<div className="flex items-start justify-between gap-3">
|
|
72
|
+
<span className="text-heading-4-serif text-[var(--color-text-default)]">{title}</span>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
aria-label="Close help"
|
|
76
|
+
onClick={onClose}
|
|
77
|
+
className="shrink-0 -mr-1 -mt-1 size-6 flex items-center justify-center rounded-[var(--radius-button)] text-[var(--color-text-muted)] hover:bg-[var(--color-action-ghost-hover)] hover:text-[var(--color-text-default)] transition-colors"
|
|
78
|
+
>
|
|
79
|
+
<X className="size-3.5" />
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="mt-1.5 text-caption text-[var(--color-text-muted)] leading-relaxed">{children}</div>
|
|
83
|
+
{docHref && (
|
|
84
|
+
<a
|
|
85
|
+
href={docHref}
|
|
86
|
+
target="_blank"
|
|
87
|
+
rel="noreferrer"
|
|
88
|
+
className="mt-2.5 inline-flex items-center gap-1 text-caption text-[var(--color-text-link)] hover:text-[var(--color-text-link-hover)]"
|
|
89
|
+
>
|
|
90
|
+
{docLabel ?? "Learn more"}
|
|
91
|
+
<ExternalLink className="size-3" />
|
|
92
|
+
</a>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function HelpExpander({
|
|
99
|
+
title, children, mode = "popover", trigger, anchor = "bottom-right", docHref, docLabel, className,
|
|
100
|
+
}: HelpExpanderProps) {
|
|
101
|
+
const [open, setOpen] = React.useState(false)
|
|
102
|
+
|
|
103
|
+
// ── inset: expands inside the nearest positioned container ──
|
|
104
|
+
if (mode === "inset") {
|
|
105
|
+
return (
|
|
106
|
+
<div className={cn("absolute z-10 flex flex-col w-[calc(100%-1.5rem)]", ANCHOR_POS[anchor], className)}>
|
|
107
|
+
{open ? (
|
|
108
|
+
<CardBody title={title} docHref={docHref} docLabel={docLabel} onClose={() => setOpen(false)}>
|
|
109
|
+
{children}
|
|
110
|
+
</CardBody>
|
|
111
|
+
) : (
|
|
112
|
+
<TriggerButton trigger={trigger} onClick={() => setOpen(true)} />
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── popover: floats next to the trigger (portaled) ──
|
|
119
|
+
return (
|
|
120
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
121
|
+
<PopoverPrimitive.Trigger asChild>
|
|
122
|
+
<TriggerButton trigger={trigger} className={className} />
|
|
123
|
+
</PopoverPrimitive.Trigger>
|
|
124
|
+
<PopoverPrimitive.Portal>
|
|
125
|
+
<PopoverPrimitive.Content
|
|
126
|
+
side="top"
|
|
127
|
+
align="end"
|
|
128
|
+
sideOffset={8}
|
|
129
|
+
className="z-[var(--z-popover)] w-72 outline-none"
|
|
130
|
+
>
|
|
131
|
+
<CardBody title={title} docHref={docHref} docLabel={docLabel} onClose={() => setOpen(false)}>
|
|
132
|
+
{children}
|
|
133
|
+
</CardBody>
|
|
134
|
+
</PopoverPrimitive.Content>
|
|
135
|
+
</PopoverPrimitive.Portal>
|
|
136
|
+
</PopoverPrimitive.Root>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { HelpExpander }
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,8 @@ export * from "./components/ui/tab-count"
|
|
|
43
43
|
export * from "./components/ui/tabs"
|
|
44
44
|
export * from "./components/ui/textarea"
|
|
45
45
|
export * from "./components/ui/tooltip"
|
|
46
|
+
export * from "./components/ui/flow-diagram"
|
|
47
|
+
export * from "./components/ui/help-expander"
|
|
46
48
|
|
|
47
49
|
// Brand textures + logo
|
|
48
50
|
export * from "./textures"
|