@olwiba/ui 0.1.4 → 0.1.7

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,7 +41,7 @@
41
41
  "@dnd-kit/modifiers": "^9.0.0",
42
42
  "@dnd-kit/sortable": "^10.0.0",
43
43
  "@dnd-kit/utilities": "^3.2.2",
44
- "@olwiba/docs": "0.1.27",
44
+ "@olwiba/docs": "0.1.29",
45
45
  "@olwiba/dx": "0.0.18",
46
46
  "@tanstack/react-table": "^8.21.3",
47
47
  "clsx": "^2.1.1",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "devDependencies": {
66
66
  "@content-collections/mdx": "^0.2.2",
67
- "@olwiba/cn": "0.1.20",
67
+ "@olwiba/cn": "0.1.21",
68
68
  "@tailwindcss/vite": "^4.1.18",
69
69
  "@tanstack/react-router": "1.154.8",
70
70
  "@tanstack/react-router-devtools": "1.154.8",
@@ -84,6 +84,10 @@ export interface AuthFormProps {
84
84
  renderLink?: AppShellRenderLink;
85
85
  /** Optional content rendered below the form — use for social auth buttons or other additions */
86
86
  footer?: React.ReactNode;
87
+ /** Pre-fill the email field (e.g. from demo credential links). Triggers a brief border highlight animation. */
88
+ defaultEmail?: string;
89
+ /** Pre-fill the password field (e.g. from demo credential links). Triggers a brief border highlight animation. */
90
+ defaultPassword?: string;
87
91
  }
88
92
 
89
93
  function DefaultForm({
@@ -98,11 +102,19 @@ function DefaultForm({
98
102
  loading,
99
103
  renderLink = defaultRenderLink,
100
104
  footer,
105
+ defaultEmail,
106
+ defaultPassword,
101
107
  }: AuthFormProps) {
102
108
  const isSignUp = mode === 'signup';
109
+ const hasPrefill = !!(defaultEmail || defaultPassword);
110
+ const prefillStyle = (active: boolean): React.CSSProperties | undefined =>
111
+ active ? { animation: 'auth-prefill 1.6s ease-out 0.35s 1 both' } : undefined;
103
112
 
104
113
  return (
105
114
  <Card className="w-full">
115
+ {hasPrefill && (
116
+ <style>{`@keyframes auth-prefill{0%{box-shadow:0 0 0 0 hsl(var(--primary)/0)}40%{box-shadow:0 0 0 3px hsl(var(--primary)/0.4)}100%{box-shadow:0 0 0 0 hsl(var(--primary)/0)}}`}</style>
117
+ )}
106
118
  <CardHeader>
107
119
  {brand && <div className="mb-2">{brand}</div>}
108
120
  <CardTitle>{isSignUp ? 'Create an account' : 'Sign in'}</CardTitle>
@@ -122,7 +134,15 @@ function DefaultForm({
122
134
  )}
123
135
  <div className="space-y-2">
124
136
  <Label htmlFor="auth-email">Email address</Label>
125
- <Input id="auth-email" name="email" type="email" placeholder="name@company.com" autoComplete="email" />
137
+ <Input
138
+ id="auth-email"
139
+ name="email"
140
+ type="email"
141
+ placeholder="name@company.com"
142
+ autoComplete="email"
143
+ defaultValue={defaultEmail}
144
+ style={prefillStyle(!!defaultEmail)}
145
+ />
126
146
  </div>
127
147
  <div className="space-y-2">
128
148
  <div className="flex items-center justify-between">
@@ -139,6 +159,8 @@ function DefaultForm({
139
159
  type="password"
140
160
  placeholder="••••••••"
141
161
  autoComplete={isSignUp ? 'new-password' : 'current-password'}
162
+ defaultValue={defaultPassword}
163
+ style={prefillStyle(!!defaultPassword)}
142
164
  />
143
165
  </div>
144
166
  {error && (
@@ -2,18 +2,40 @@
2
2
 
3
3
  import * as React from 'react';
4
4
  import { Badge, cn } from '@olwiba/cn';
5
+ import { ArrowRight } from 'lucide-react';
5
6
  import type { AppShellRenderLink } from '../app/AppShell';
6
7
 
8
+ export type ChangelogReleaseType = 'release' | 'fix' | 'enhancement';
9
+
10
+ export interface ChangelogHighlight {
11
+ title: string;
12
+ description: string;
13
+ }
14
+
7
15
  export interface ChangelogCardProps {
8
16
  title: string;
9
17
  summary: string;
10
18
  date: string;
11
19
  slug: string;
12
20
  version?: string;
21
+ releaseType?: ChangelogReleaseType;
22
+ highlights?: ChangelogHighlight[];
13
23
  hrefPrefix?: string;
14
24
  renderLink?: AppShellRenderLink;
15
25
  }
16
26
 
27
+ const releaseTypeLabel: Record<ChangelogReleaseType, string> = {
28
+ release: 'Major Release',
29
+ fix: 'Fix',
30
+ enhancement: 'Enhancement',
31
+ };
32
+
33
+ const releaseTypeVariant: Record<ChangelogReleaseType, 'default' | 'secondary' | 'outline'> = {
34
+ release: 'default',
35
+ fix: 'outline',
36
+ enhancement: 'secondary',
37
+ };
38
+
17
39
  function formatDate(dateStr: string) {
18
40
  return new Date(dateStr).toLocaleDateString('en-US', {
19
41
  year: 'numeric',
@@ -28,6 +50,8 @@ export function ChangelogCard({
28
50
  date,
29
51
  slug,
30
52
  version,
53
+ releaseType,
54
+ highlights,
31
55
  hrefPrefix = '/changelog',
32
56
  renderLink,
33
57
  }: ChangelogCardProps) {
@@ -48,26 +72,60 @@ export function ChangelogCard({
48
72
  </a>
49
73
  );
50
74
 
75
+ const hasHighlights = highlights && highlights.length > 0;
76
+
51
77
  return (
52
- <article className="group flex flex-col gap-3 border-b border-border/60 pb-8 last:border-0 last:pb-0">
53
- <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
54
- <time dateTime={date}>{formatDate(date)}</time>
55
- {version && (
56
- <>
57
- <span>·</span>
78
+ <article
79
+ className={cn(
80
+ 'flex flex-col gap-5 rounded-2xl border border-border bg-card p-6 sm:p-8',
81
+ !hasHighlights && 'border-b border-border/60 rounded-none bg-transparent p-0 pb-8 last:pb-0 last:border-0',
82
+ )}
83
+ >
84
+ {/* Header row */}
85
+ <div className="flex flex-wrap items-center justify-between gap-3">
86
+ <div className="flex flex-wrap items-center gap-2">
87
+ <time className="text-sm text-muted-foreground" dateTime={date}>
88
+ {formatDate(date)}
89
+ </time>
90
+ {version && (
58
91
  <Badge variant="secondary" className="font-mono text-xs">
59
92
  {version}
60
93
  </Badge>
61
- </>
62
- )}
94
+ )}
95
+ {releaseType && (
96
+ <Badge variant={releaseTypeVariant[releaseType]} className="text-xs">
97
+ {releaseTypeLabel[releaseType]}
98
+ </Badge>
99
+ )}
100
+ </div>
101
+ <LinkWrapper className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
102
+ Read update
103
+ <ArrowRight className="size-3.5" />
104
+ </LinkWrapper>
63
105
  </div>
64
- <h2 className="text-lg font-semibold leading-snug tracking-tight">
65
- <LinkWrapper className="transition-colors hover:text-primary">{title}</LinkWrapper>
66
- </h2>
67
- <p className={cn('line-clamp-3 text-sm text-muted-foreground')}>{summary}</p>
68
- <LinkWrapper className="text-sm font-medium text-primary hover:underline">
69
- Read release notes →
70
- </LinkWrapper>
106
+
107
+ {/* Title + summary */}
108
+ <div>
109
+ <h2 className="text-lg font-semibold leading-snug tracking-tight">
110
+ <LinkWrapper className="transition-colors hover:text-primary">{title}</LinkWrapper>
111
+ </h2>
112
+ <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{summary}</p>
113
+ </div>
114
+
115
+ {/* Highlights grid */}
116
+ {hasHighlights && (
117
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
118
+ {highlights.map((h) => (
119
+ <div
120
+ key={h.title}
121
+ className="rounded-xl border border-border/70 bg-background p-4"
122
+ >
123
+ <p className="text-sm font-semibold leading-snug">{h.title}</p>
124
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">{h.description}</p>
125
+ </div>
126
+ ))}
127
+ </div>
128
+ )}
71
129
  </article>
72
130
  );
73
131
  }
@@ -0,0 +1,172 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Palette } from 'lucide-react';
5
+ import { Button, Popover, PopoverContent, PopoverTrigger } from '@olwiba/cn';
6
+
7
+ interface BrandColor {
8
+ name: string;
9
+ label: string;
10
+ css: string;
11
+ }
12
+
13
+ const BRAND_COLORS: BrandColor[] = [
14
+ {
15
+ name: 'zinc',
16
+ label: 'Zinc',
17
+ css: `
18
+ :root { --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.708 0 0); }
19
+ .dark { --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --ring: oklch(0.556 0 0); }
20
+ `,
21
+ },
22
+ {
23
+ name: 'blue',
24
+ label: 'Blue',
25
+ css: `
26
+ :root { --primary: oklch(0.546 0.245 262.881); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.546 0.245 262.881); }
27
+ .dark { --primary: oklch(0.623 0.214 259.815); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.623 0.214 259.815); }
28
+ `,
29
+ },
30
+ {
31
+ name: 'emerald',
32
+ label: 'Emerald',
33
+ css: `
34
+ :root { --primary: oklch(0.596 0.145 163.225); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.596 0.145 163.225); }
35
+ .dark { --primary: oklch(0.765 0.177 163.223); --primary-foreground: oklch(0.145 0 0); --ring: oklch(0.765 0.177 163.223); }
36
+ `,
37
+ },
38
+ {
39
+ name: 'purple',
40
+ label: 'Purple',
41
+ css: `
42
+ :root { --primary: oklch(0.558 0.288 302.321); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.558 0.288 302.321); }
43
+ .dark { --primary: oklch(0.714 0.203 305.504); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.714 0.203 305.504); }
44
+ `,
45
+ },
46
+ {
47
+ name: 'rose',
48
+ label: 'Rose',
49
+ css: `
50
+ :root { --primary: oklch(0.645 0.246 16.439); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.645 0.246 16.439); }
51
+ .dark { --primary: oklch(0.717 0.194 17.428); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.717 0.194 17.428); }
52
+ `,
53
+ },
54
+ {
55
+ name: 'orange',
56
+ label: 'Orange',
57
+ css: `
58
+ :root { --primary: oklch(0.705 0.213 47.604); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.705 0.213 47.604); }
59
+ .dark { --primary: oklch(0.792 0.184 70.08); --primary-foreground: oklch(0.145 0 0); --ring: oklch(0.792 0.184 70.08); }
60
+ `,
61
+ },
62
+ {
63
+ name: 'slate',
64
+ label: 'Slate',
65
+ css: `
66
+ :root { --primary: oklch(0.446 0.043 257.281); --primary-foreground: oklch(0.985 0 0); --ring: oklch(0.446 0.043 257.281); }
67
+ .dark { --primary: oklch(0.704 0.04 256.788); --primary-foreground: oklch(0.129 0.042 264.695); --ring: oklch(0.704 0.04 256.788); }
68
+ `,
69
+ },
70
+ ];
71
+
72
+ // Preview hex values matched to each preset (used for swatch circles only)
73
+ const PREVIEW_HEX: Record<string, string> = {
74
+ zinc: '#27272a',
75
+ blue: '#3b82f6',
76
+ emerald: '#10b981',
77
+ purple: '#a855f7',
78
+ rose: '#f43f5e',
79
+ orange: '#f97316',
80
+ slate: '#64748b',
81
+ };
82
+
83
+ const STORAGE_KEY = 'brand-color';
84
+ const STYLE_ID = 'brand-color-override';
85
+
86
+ function applyColor(color: BrandColor) {
87
+ let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
88
+ if (!el) {
89
+ el = document.createElement('style');
90
+ el.id = STYLE_ID;
91
+ document.head.appendChild(el);
92
+ }
93
+ el.textContent = color.css;
94
+ localStorage.setItem(STORAGE_KEY, color.name);
95
+ }
96
+
97
+ function getInitialColorName(): string {
98
+ if (typeof localStorage === 'undefined') return 'emerald';
99
+ return localStorage.getItem(STORAGE_KEY) ?? 'emerald';
100
+ }
101
+
102
+ export function BrandColorSwitchMinimal() {
103
+ const [active, setActive] = React.useState<string>(getInitialColorName);
104
+ const [open, setOpen] = React.useState(false);
105
+
106
+ React.useEffect(() => {
107
+ const saved = localStorage.getItem(STORAGE_KEY) ?? 'emerald';
108
+ const color = BRAND_COLORS.find((c) => c.name === saved);
109
+ if (color) applyColor(color);
110
+ }, []);
111
+
112
+ function select(color: BrandColor) {
113
+ applyColor(color);
114
+ setActive(color.name);
115
+ setOpen(false);
116
+ }
117
+
118
+ const activeHex = PREVIEW_HEX[active] ?? '#27272a';
119
+
120
+ return (
121
+ <Popover open={open} onOpenChange={setOpen}>
122
+ <PopoverTrigger asChild>
123
+ <Button
124
+ variant="ghost"
125
+ size="icon"
126
+ className="size-8"
127
+ aria-label="Change brand color"
128
+ >
129
+ <span className="relative inline-flex size-4 items-center justify-center">
130
+ <Palette className="size-4" />
131
+ <span
132
+ className="absolute -bottom-0.5 -right-0.5 size-1.5 rounded-full ring-1 ring-background"
133
+ style={{ background: activeHex }}
134
+ />
135
+ </span>
136
+ </Button>
137
+ </PopoverTrigger>
138
+ <PopoverContent className="w-auto p-3" align="end">
139
+ <p className="mb-2.5 text-xs font-medium text-muted-foreground">Brand color</p>
140
+ <div className="grid grid-cols-4 gap-1.5">
141
+ {BRAND_COLORS.map((color) => (
142
+ <button
143
+ key={color.name}
144
+ type="button"
145
+ onClick={() => select(color)}
146
+ title={color.label}
147
+ className="group flex flex-col items-center gap-1"
148
+ >
149
+ <span
150
+ className="flex size-7 items-center justify-center rounded-full ring-offset-background transition-all group-hover:scale-110"
151
+ style={{
152
+ background: PREVIEW_HEX[color.name],
153
+ boxShadow:
154
+ active === color.name
155
+ ? `0 0 0 2px var(--background), 0 0 0 4px ${PREVIEW_HEX[color.name]}`
156
+ : undefined,
157
+ }}
158
+ >
159
+ {active === color.name && (
160
+ <svg className="size-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
161
+ <path d="M20 6L9 17l-5-5" strokeLinecap="round" strokeLinejoin="round" />
162
+ </svg>
163
+ )}
164
+ </span>
165
+ <span className="text-[10px] text-muted-foreground">{color.label}</span>
166
+ </button>
167
+ ))}
168
+ </div>
169
+ </PopoverContent>
170
+ </Popover>
171
+ );
172
+ }
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ export interface FlowBracketProps {
6
+ /**
7
+ * Vertical anchor for the top connection as a percentage of the wrapper height (0–100).
8
+ * Defaults to 8 (near the top of the first child).
9
+ */
10
+ anchorTop?: number;
11
+ /**
12
+ * Vertical anchor for the bottom connection as a percentage of the wrapper height (0–100).
13
+ * Defaults to 92 (near the bottom of the last child).
14
+ */
15
+ anchorBottom?: number;
16
+ /**
17
+ * How far outside the wrapper the bracket extends, in SVG units (viewBox width = 1000).
18
+ * Defaults to 22.
19
+ */
20
+ extent?: number;
21
+ /** Show open-chevron arrowhead at the top (destination) end. Default: true. */
22
+ arrow?: boolean;
23
+ /** CSS color for both bracket lines and animated dots. Defaults to muted slate. */
24
+ color?: string;
25
+ /** Override color for the left bracket only. */
26
+ colorLeft?: string;
27
+ /** Override color for the right bracket only. */
28
+ colorRight?: string;
29
+ /** Show left bracket. Default: true. */
30
+ left?: boolean;
31
+ /** Show right bracket. Default: true. */
32
+ right?: boolean;
33
+ /**
34
+ * Animate a dot traveling the L-path from the bottom anchor up to the top anchor
35
+ * (sync → genesis/docs direction).
36
+ * Dots are placed after {children} in DOM so positive-z-index card stacking contexts
37
+ * naturally render above them.
38
+ */
39
+ animate?: boolean;
40
+ /** Duration of one full dot journey. Default: '4s'. */
41
+ animateDur?: string;
42
+ /** Reverse the right bracket path direction so its arrowhead points DOWN (into sync). Default: false. */
43
+ reverseRight?: boolean;
44
+ style?: React.CSSProperties;
45
+ className?: string;
46
+ children: React.ReactNode;
47
+ }
48
+
49
+ const MARKER_L = 'fb-ml';
50
+ const MARKER_R = 'fb-mr';
51
+
52
+ /**
53
+ * Wraps children in a relative container and draws square bracket lines on the
54
+ * left and/or right outside edges — connecting the top of the first child down
55
+ * to the bottom of the last child (e.g. docs → SYNC direction).
56
+ *
57
+ * Bracket lines use `overflow: visible` on the SVG to draw into the parent's padding.
58
+ * Ensure the parent section/container has enough horizontal padding for `extent`.
59
+ */
60
+ export function FlowBracket({
61
+ anchorTop = 8,
62
+ anchorBottom = 92,
63
+ extent = 22,
64
+ arrow = true,
65
+ color = 'rgba(148,163,184,0.4)',
66
+ colorLeft,
67
+ colorRight,
68
+ left = true,
69
+ right = true,
70
+ animate = false,
71
+ animateDur = '4s',
72
+ reverseRight = false,
73
+ style,
74
+ className,
75
+ children,
76
+ }: FlowBracketProps) {
77
+ const L = -extent;
78
+ const R = 1000 + extent;
79
+
80
+ const cl = colorLeft ?? color;
81
+ const cr = colorRight ?? color;
82
+
83
+ // Left bracket: bottom → top (arrowhead at top, pointing into genesis)
84
+ const leftD = `M 0,${anchorBottom} L ${L},${anchorBottom} L ${L},${anchorTop} L 0,${anchorTop}`;
85
+ // Right bracket: reversed = top → bottom (arrowhead at bottom, pointing into sync)
86
+ const rightD = reverseRight
87
+ ? `M 1000,${anchorTop} L ${R},${anchorTop} L ${R},${anchorBottom} L 1000,${anchorBottom}`
88
+ : `M 1000,${anchorBottom} L ${R},${anchorBottom} L ${R},${anchorTop} L 1000,${anchorTop}`;
89
+
90
+ // CSS dot: extent as % of container width
91
+ const ep = `${(extent / 10).toFixed(1)}%`;
92
+ // Dots travel bottom → top (sync → genesis/docs direction), fade in/out at card edges
93
+ const dotKeyframes = animate ? `
94
+ @keyframes fb-dl{
95
+ 0%{top:${anchorBottom}%;left:0;opacity:0}
96
+ 6%{opacity:1}
97
+ 20%{top:${anchorBottom}%;left:-${ep}}
98
+ 80%{top:${anchorTop}%;left:-${ep};opacity:1}
99
+ 94%{top:${anchorTop}%;left:0;opacity:0}
100
+ 100%{top:${anchorTop}%;left:0;opacity:0}
101
+ }
102
+ @keyframes fb-dr{
103
+ 0%{top:${anchorBottom}%;left:100%;opacity:0}
104
+ 6%{opacity:1}
105
+ 20%{top:${anchorBottom}%;left:calc(100% + ${ep})}
106
+ 80%{top:${anchorTop}%;left:calc(100% + ${ep});opacity:1}
107
+ 94%{top:${anchorTop}%;left:100%;opacity:0}
108
+ 100%{top:${anchorTop}%;left:100%;opacity:0}
109
+ }` : '';
110
+
111
+ const dotBase: React.CSSProperties = {
112
+ position: 'absolute',
113
+ width: 5,
114
+ height: 5,
115
+ borderRadius: '50%',
116
+ transform: 'translate(-50%, -50%)',
117
+ pointerEvents: 'none',
118
+ opacity: 0,
119
+ };
120
+
121
+ return (
122
+ <div className={className} style={{ position: 'relative', ...style }}>
123
+ {animate && <style>{dotKeyframes}</style>}
124
+ <svg
125
+ viewBox="0 0 1000 100"
126
+ preserveAspectRatio="none"
127
+ aria-hidden
128
+ className="pointer-events-none absolute inset-0"
129
+ style={{ width: '100%', height: '100%', overflow: 'visible' }}
130
+ >
131
+ <defs>
132
+ <marker id={MARKER_L} markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
133
+ <polyline points="1,1 8,4 1,7" fill="none" stroke={cl} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
134
+ </marker>
135
+ <marker id={MARKER_R} markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
136
+ <polyline points="1,1 8,4 1,7" fill="none" stroke={cr} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
137
+ </marker>
138
+ </defs>
139
+ {left && (
140
+ <path d={leftD} fill="none" stroke={cl} strokeWidth={1.5} strokeDasharray="5,4"
141
+ vectorEffect="non-scaling-stroke" {...(arrow ? { markerEnd: `url(#${MARKER_L})` } : {})} />
142
+ )}
143
+ {right && (
144
+ <path d={rightD} fill="none" stroke={cr} strokeWidth={1.5} strokeDasharray="5,4"
145
+ vectorEffect="non-scaling-stroke" {...(arrow ? { markerEnd: `url(#${MARKER_R})` } : {})} />
146
+ )}
147
+ </svg>
148
+
149
+ {children}
150
+
151
+ {/* Dots placed after children so card stacking contexts (z-index ≥ 1) render above */}
152
+ {animate && left && (
153
+ <div
154
+ aria-hidden
155
+ style={{ ...dotBase, backgroundColor: cl, animation: `fb-dl ${animateDur} ease-in-out 0s infinite` }}
156
+ />
157
+ )}
158
+ {animate && right && (
159
+ <div
160
+ aria-hidden
161
+ style={{ ...dotBase, backgroundColor: cr, animation: `fb-dr ${animateDur} ease-in-out 1.8s infinite` }}
162
+ />
163
+ )}
164
+ </div>
165
+ );
166
+ }
@@ -0,0 +1,135 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { cn } from '@olwiba/cn';
5
+
6
+ export interface FlowConnectorProps {
7
+ /** Line axis. Default: 'vertical'. */
8
+ direction?: 'vertical' | 'horizontal';
9
+ /**
10
+ * CSS color string for the line and arrowhead.
11
+ * Falls back to currentColor at reduced opacity when omitted.
12
+ */
13
+ color?: string;
14
+ /** Show an open-chevron arrowhead at the destination end. Default: false. */
15
+ arrow?: boolean;
16
+ /** Show a traveling dot animation along the connector. Default: false. */
17
+ animate?: boolean;
18
+ /** Duration of the traveling dot animation. Default: '1.4s'. */
19
+ animateDur?: string;
20
+ /** CSS delay before the traveling dot animation starts. Default: '0s'. */
21
+ animateDelay?: string;
22
+ className?: string;
23
+ style?: React.CSSProperties;
24
+ }
25
+
26
+ const DASH_ON = '5px';
27
+ const DASH_OFF = '4px';
28
+
29
+ /**
30
+ * Dashed connecting line between diagram nodes.
31
+ * Control size via className (e.g. `h-10` for vertical, `w-16` for horizontal).
32
+ * Pass `style` for fade/entrance animations — the wrapper inherits it.
33
+ */
34
+ export function FlowConnector({
35
+ direction = 'vertical',
36
+ color,
37
+ arrow = false,
38
+ animate = false,
39
+ animateDur = '1.4s',
40
+ animateDelay = '0s',
41
+ className,
42
+ style,
43
+ }: FlowConnectorProps) {
44
+ const stroke = color ?? 'currentColor';
45
+ const opacity = color ? 0.75 : 0.4;
46
+
47
+ if (direction === 'vertical') {
48
+ return (
49
+ <div
50
+ className={cn('relative flex flex-col items-center', className)}
51
+ style={style}
52
+ aria-hidden
53
+ >
54
+ {animate && (
55
+ <style>{`@keyframes fc-dot-v{0%{top:-4px;opacity:0}8%{opacity:1}88%{opacity:1}100%{top:calc(100% + 4px);opacity:0}}`}</style>
56
+ )}
57
+ <div
58
+ className="flex-1"
59
+ style={{
60
+ width: 2,
61
+ opacity,
62
+ background: `repeating-linear-gradient(to bottom, ${stroke} 0, ${stroke} ${DASH_ON}, transparent ${DASH_ON}, transparent calc(${DASH_ON} + ${DASH_OFF}))`,
63
+ }}
64
+ />
65
+ {animate && (
66
+ <div
67
+ style={{
68
+ position: 'absolute',
69
+ left: '50%',
70
+ transform: 'translateX(-50%)',
71
+ width: 5,
72
+ height: 5,
73
+ borderRadius: '50%',
74
+ backgroundColor: stroke,
75
+ animation: `fc-dot-v ${animateDur} ease-in-out ${animateDelay} infinite both`,
76
+ }}
77
+ />
78
+ )}
79
+ {arrow && (
80
+ <svg
81
+ width="12"
82
+ height="7"
83
+ viewBox="0 0 12 7"
84
+ style={{ display: 'block', opacity }}
85
+ aria-hidden
86
+ >
87
+ <polyline
88
+ points="1,1 6,6 11,1"
89
+ fill="none"
90
+ stroke={stroke}
91
+ strokeWidth="1.5"
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ />
95
+ </svg>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <div
103
+ className={cn('flex items-center', className)}
104
+ style={style}
105
+ aria-hidden
106
+ >
107
+ <div
108
+ className="flex-1"
109
+ style={{
110
+ height: 2,
111
+ opacity,
112
+ background: `repeating-linear-gradient(to right, ${stroke} 0, ${stroke} ${DASH_ON}, transparent ${DASH_ON}, transparent calc(${DASH_ON} + ${DASH_OFF}))`,
113
+ }}
114
+ />
115
+ {arrow && (
116
+ <svg
117
+ width="7"
118
+ height="12"
119
+ viewBox="0 0 7 12"
120
+ style={{ display: 'block', opacity }}
121
+ aria-hidden
122
+ >
123
+ <polyline
124
+ points="1,1 6,6 1,11"
125
+ fill="none"
126
+ stroke={stroke}
127
+ strokeWidth="1.5"
128
+ strokeLinecap="round"
129
+ strokeLinejoin="round"
130
+ />
131
+ </svg>
132
+ )}
133
+ </div>
134
+ );
135
+ }