ai-design-system 0.1.114 → 0.1.116

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.
@@ -7,7 +7,7 @@ import {
7
7
  type Node,
8
8
  useReactFlow,
9
9
  } from "@xyflow/react";
10
- import { useCallback, useEffect } from "react";
10
+ import { useCallback, useEffect, useRef } from "react";
11
11
  import { Canvas } from "@/components/ai-elements/canvas";
12
12
  import { Connection as ConnectionLine } from "@/components/ai-elements/connection";
13
13
  import { Controls } from "@/components/ai-elements/controls";
@@ -58,6 +58,36 @@ function WorkflowCanvasInner({
58
58
  className,
59
59
  }: WorkflowCanvasProps) {
60
60
  const { fitView } = useReactFlow();
61
+ const containerRef = useRef<HTMLDivElement>(null);
62
+ const wasZeroSizeRef = useRef<boolean>(true);
63
+
64
+ // Automatically fit view when the canvas container transitions from hidden (0x0)
65
+ // to visible (e.g. mobile Overview icon clicked or tab activated)
66
+ useEffect(() => {
67
+ const el = containerRef.current;
68
+ if (!el || typeof ResizeObserver === "undefined") return;
69
+
70
+ const observer = new ResizeObserver((entries) => {
71
+ for (const entry of entries) {
72
+ const { width, height } = entry.contentRect;
73
+ if (width > 0 && height > 0) {
74
+ if (wasZeroSizeRef.current) {
75
+ wasZeroSizeRef.current = false;
76
+ // Delay slightly so xyflow's internal node measurements can settle
77
+ const timer = setTimeout(() => {
78
+ fitView({ padding: 0.2, duration: 300 });
79
+ }, 60);
80
+ return () => clearTimeout(timer);
81
+ }
82
+ } else {
83
+ wasZeroSizeRef.current = true;
84
+ }
85
+ }
86
+ });
87
+
88
+ observer.observe(el);
89
+ return () => observer.disconnect();
90
+ }, [fitView]);
61
91
 
62
92
  // Opt-in re-fit, e.g. after the device-preview toolbar's Route select
63
93
  // switches to "All" and the node count/extent changes underneath the
@@ -67,20 +97,16 @@ function WorkflowCanvasInner({
67
97
  // every such change would override the user's own pan/zoom constantly.
68
98
  useEffect(() => {
69
99
  if (fitViewSignal === undefined) return;
70
- // A plain synchronous call, and even a double-rAF, both raced xyflow's
71
- // own internal node measurement — verified live in both cases: the
72
- // resulting scale was far smaller than the two device-preview nodes'
73
- // true combined width needed (computed against their real DOM rects:
74
- // ~1.2 would tightly fit them, but fitView produced ~0.57 and one node
75
- // still landed partly outside the pane). xyflow measures each node via
76
- // ResizeObserver, which fires as a separate, unsynchronized callback —
77
- // not guaranteed to have run even two animation frames after the nodes
78
- // prop change lands. A short real-time delay is the standard, if
79
- // inelegant, workaround for this exact class of race.
80
- const timer = setTimeout(() => {
100
+ const timer1 = setTimeout(() => {
81
101
  fitView({ padding: 0.2, duration: 300 });
82
102
  }, 50);
83
- return () => clearTimeout(timer);
103
+ const timer2 = setTimeout(() => {
104
+ fitView({ padding: 0.2, duration: 300 });
105
+ }, 200);
106
+ return () => {
107
+ clearTimeout(timer1);
108
+ clearTimeout(timer2);
109
+ };
84
110
  }, [fitViewSignal, fitView]);
85
111
 
86
112
  useEffect(() => {
@@ -105,6 +131,7 @@ function WorkflowCanvasInner({
105
131
 
106
132
  return (
107
133
  <div
134
+ ref={containerRef}
108
135
  className={className}
109
136
  data-testid="workflow-canvas"
110
137
  style={{
@@ -5,10 +5,11 @@
5
5
  * Displays open documents as closeable tabs with dirty indicators.
6
6
  */
7
7
 
8
- import React from 'react'
8
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
9
9
  import { Tabs, TabsList, TabsTrigger, Button, ScrollArea } from '@/components/primitives'
10
10
  import { Icon } from '@/components/primitives/Icon'
11
11
  import { cn } from '@/lib/utils'
12
+
12
13
  export interface DocumentFile {
13
14
  id: string
14
15
  name: string
@@ -50,6 +51,12 @@ export interface DocumentTabBarProps {
50
51
  * Callback to open/toggle the file explorer (e.g. mobile drawer)
51
52
  */
52
53
  onToggleExplorer?: () => void
54
+
55
+ /**
56
+ * Whether to show navigation scroll arrows when tabs overflow
57
+ * @default true
58
+ */
59
+ showScrollArrows?: boolean
53
60
  }
54
61
 
55
62
  /**
@@ -62,13 +69,103 @@ export interface DocumentTabBarProps {
62
69
  * - Close button (X)
63
70
  *
64
71
  * Features:
65
- * - Scrollable when tabs overflow horizontally
72
+ * - Scrollable when tabs overflow horizontally with multi-device support
73
+ * - Mouse wheel vertical-to-horizontal scroll translation
74
+ * - Auto-scroll active tab into view
75
+ * - Overflow indicators (edge gradients & navigation chevrons)
66
76
  * - Accessible tab navigation via Radix UI Tabs
67
77
  * - Close button to remove tabs
68
78
  * - Dirty state indicator
69
79
  */
70
80
  export const DocumentTabBar = React.memo<DocumentTabBarProps>(
71
- ({ tabs, activeTabId, onTabSelect, onTabClose, onToggleExplorer, className }) => {
81
+ ({
82
+ tabs,
83
+ activeTabId,
84
+ onTabSelect,
85
+ onTabClose,
86
+ onToggleExplorer,
87
+ className,
88
+ showScrollArrows = true,
89
+ }) => {
90
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
91
+ const [canScrollLeft, setCanScrollLeft] = useState(false)
92
+ const [canScrollRight, setCanScrollRight] = useState(false)
93
+
94
+ const getViewport = useCallback((): HTMLElement | null => {
95
+ return (
96
+ scrollContainerRef.current?.querySelector<HTMLElement>(
97
+ '[data-slot="scroll-area-viewport"]'
98
+ ) ?? null
99
+ )
100
+ }, [])
101
+
102
+ const updateScrollState = useCallback(() => {
103
+ const viewport = getViewport()
104
+ if (!viewport) return
105
+ const { scrollLeft, scrollWidth, clientWidth } = viewport
106
+ setCanScrollLeft(scrollLeft > 2)
107
+ setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 2)
108
+ }, [getViewport])
109
+
110
+ useEffect(() => {
111
+ const viewport = getViewport()
112
+ if (!viewport) return
113
+
114
+ updateScrollState()
115
+ viewport.addEventListener('scroll', updateScrollState, { passive: true })
116
+
117
+ const resizeObserver = new ResizeObserver(() => {
118
+ updateScrollState()
119
+ })
120
+ resizeObserver.observe(viewport)
121
+
122
+ return () => {
123
+ viewport.removeEventListener('scroll', updateScrollState)
124
+ resizeObserver.disconnect()
125
+ }
126
+ }, [getViewport, updateScrollState, tabs])
127
+
128
+ // Convert mouse wheel vertical scroll to horizontal scroll
129
+ const handleWheel = useCallback(
130
+ (e: React.WheelEvent) => {
131
+ if (e.deltaY === 0) return
132
+ const viewport = getViewport()
133
+ if (viewport) {
134
+ viewport.scrollLeft += e.deltaY
135
+ }
136
+ },
137
+ [getViewport]
138
+ )
139
+
140
+ // Auto-scroll active tab into view
141
+ useEffect(() => {
142
+ if (!activeTabId || !scrollContainerRef.current) return
143
+ const activeEl = scrollContainerRef.current.querySelector<HTMLElement>(
144
+ '[data-state="active"]'
145
+ )
146
+ if (activeEl) {
147
+ activeEl.scrollIntoView({
148
+ behavior: 'smooth',
149
+ block: 'nearest',
150
+ inline: 'nearest',
151
+ })
152
+ }
153
+ }, [activeTabId, tabs])
154
+
155
+ const handleScrollLeft = useCallback(() => {
156
+ const viewport = getViewport()
157
+ if (viewport) {
158
+ viewport.scrollBy({ left: -200, behavior: 'smooth' })
159
+ }
160
+ }, [getViewport])
161
+
162
+ const handleScrollRight = useCallback(() => {
163
+ const viewport = getViewport()
164
+ if (viewport) {
165
+ viewport.scrollBy({ left: 200, behavior: 'smooth' })
166
+ }
167
+ }, [getViewport])
168
+
72
169
  if (tabs.length === 0 && !onToggleExplorer) {
73
170
  return null
74
171
  }
@@ -76,7 +173,7 @@ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
76
173
  return (
77
174
  <div
78
175
  className={cn(
79
- 'flex items-center border-b border-border bg-background',
176
+ 'flex items-center border-b border-border bg-background relative',
80
177
  className
81
178
  )}
82
179
  data-slot="document-tab-bar"
@@ -92,67 +189,114 @@ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
92
189
  <Icon name="folder" size="xs" />
93
190
  </Button>
94
191
  )}
192
+
95
193
  {tabs.length > 0 && (
96
- <ScrollArea className="flex-1">
97
- <Tabs
98
- value={activeTabId || tabs[0]?.id}
99
- onValueChange={onTabSelect}
100
- className="h-auto flex-1"
101
- >
102
- <TabsList className="h-10 rounded-none border-none bg-transparent p-0 w-full justify-start gap-0">
103
- {tabs.map((tab) => (
194
+ <div
195
+ ref={scrollContainerRef}
196
+ onWheel={handleWheel}
197
+ className="flex-1 min-w-0 flex items-center relative"
198
+ >
199
+ {showScrollArrows && canScrollLeft && (
200
+ <Button
201
+ variant="ghost"
202
+ size="icon-sm"
203
+ onClick={handleScrollLeft}
204
+ className="h-10 w-7 shrink-0 rounded-none border-r border-border/50 text-muted-foreground hover:text-foreground transition-colors z-10"
205
+ aria-label="Scroll tabs left"
206
+ >
207
+ <Icon name="chevron-left" size="xs" />
208
+ </Button>
209
+ )}
210
+
211
+ <div className="flex-1 min-w-0 relative h-10">
212
+ {canScrollLeft && (
104
213
  <div
105
- key={tab.id}
106
- className="flex items-center border-r border-border/50 last:border-r-0"
214
+ className="pointer-events-none absolute left-0 top-0 bottom-0 w-5 bg-gradient-to-r from-background to-transparent z-10"
215
+ aria-hidden="true"
216
+ />
217
+ )}
218
+
219
+ <ScrollArea orientation="horizontal" className="w-full h-10">
220
+ <Tabs
221
+ value={activeTabId || tabs[0]?.id}
222
+ onValueChange={onTabSelect}
223
+ className="h-auto flex-1"
107
224
  >
108
- <TabsTrigger
109
- value={tab.id}
110
- className={cn(
111
- 'data-[state=inactive]:bg-muted/20 data-[state=inactive]:text-muted-foreground',
112
- 'rounded-none border-b-2 border-transparent data-[state=active]:border-primary',
113
- 'px-2.5 sm:px-3 py-2 text-xs sm:text-sm font-medium whitespace-nowrap',
114
- 'flex items-center gap-1.5 sm:gap-2 h-10',
115
- 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0',
116
- 'transition-colors'
117
- )}
118
- >
119
- <div className="flex items-center gap-1.5 sm:gap-2">
120
- {/* Dirty indicator */}
121
- {tab.isDirty && (
122
- <Icon
123
- name="circle"
124
- className="size-2 fill-primary text-primary"
125
- aria-label="unsaved changes"
126
- />
127
- )}
128
- {/* Tab name */}
129
- <span className="truncate max-w-[120px] sm:max-w-[200px]">{tab.name}</span>
130
- </div>
131
- </TabsTrigger>
132
-
133
- {/* Close button */}
134
- <Button
135
- variant="ghost"
136
- size="icon-sm"
137
- onClick={(e) => {
138
- e.stopPropagation()
139
- onTabClose?.(tab.id)
140
- }}
141
- className={cn(
142
- 'h-8 w-8 mr-0.5',
143
- 'hover:bg-destructive/10 hover:text-destructive',
144
- 'focus-visible:ring-2 focus-visible:ring-ring',
145
- 'transition-colors'
146
- )}
147
- aria-label={`Close ${tab.name}`}
148
- >
149
- <Icon name="x" size="xs" />
150
- </Button>
151
- </div>
152
- ))}
153
- </TabsList>
154
- </Tabs>
155
- </ScrollArea>
225
+ <TabsList className="h-10 rounded-none border-none bg-transparent p-0 w-full justify-start gap-0">
226
+ {tabs.map((tab) => (
227
+ <div
228
+ key={tab.id}
229
+ className="flex items-center shrink-0 border-r border-border/50 last:border-r-0"
230
+ >
231
+ <TabsTrigger
232
+ value={tab.id}
233
+ className={cn(
234
+ 'data-[state=inactive]:bg-muted/20 data-[state=inactive]:text-muted-foreground',
235
+ 'rounded-none border-b-2 border-transparent data-[state=active]:border-primary',
236
+ 'px-2.5 sm:px-3 py-2 text-xs sm:text-sm font-medium whitespace-nowrap',
237
+ 'flex items-center gap-1.5 sm:gap-2 h-10',
238
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0',
239
+ 'transition-colors'
240
+ )}
241
+ >
242
+ <div className="flex items-center gap-1.5 sm:gap-2">
243
+ {/* Dirty indicator */}
244
+ {tab.isDirty && (
245
+ <Icon
246
+ name="circle"
247
+ className="size-2 fill-primary text-primary"
248
+ aria-label="unsaved changes"
249
+ />
250
+ )}
251
+ {/* Tab name */}
252
+ <span className="truncate max-w-[120px] sm:max-w-[200px]">{tab.name}</span>
253
+ </div>
254
+ </TabsTrigger>
255
+
256
+ {/* Close button */}
257
+ <Button
258
+ variant="ghost"
259
+ size="icon-sm"
260
+ onClick={(e) => {
261
+ e.stopPropagation()
262
+ onTabClose?.(tab.id)
263
+ }}
264
+ className={cn(
265
+ 'h-8 w-8 mr-0.5',
266
+ 'hover:bg-destructive/10 hover:text-destructive',
267
+ 'focus-visible:ring-2 focus-visible:ring-ring',
268
+ 'transition-colors'
269
+ )}
270
+ aria-label={`Close ${tab.name}`}
271
+ >
272
+ <Icon name="x" size="xs" />
273
+ </Button>
274
+ </div>
275
+ ))}
276
+ </TabsList>
277
+ </Tabs>
278
+ </ScrollArea>
279
+
280
+ {canScrollRight && (
281
+ <div
282
+ className="pointer-events-none absolute right-0 top-0 bottom-0 w-5 bg-gradient-to-l from-background to-transparent z-10"
283
+ aria-hidden="true"
284
+ />
285
+ )}
286
+ </div>
287
+
288
+ {showScrollArrows && canScrollRight && (
289
+ <Button
290
+ variant="ghost"
291
+ size="icon-sm"
292
+ onClick={handleScrollRight}
293
+ className="h-10 w-7 shrink-0 rounded-none border-l border-border/50 text-muted-foreground hover:text-foreground transition-colors z-10"
294
+ aria-label="Scroll tabs right"
295
+ >
296
+ <Icon name="chevron-right" size="xs" />
297
+ </Button>
298
+ )}
299
+ </div>
156
300
  )}
157
301
  </div>
158
302
  )
@@ -160,3 +304,4 @@ export const DocumentTabBar = React.memo<DocumentTabBarProps>(
160
304
  )
161
305
 
162
306
  DocumentTabBar.displayName = 'DocumentTabBar'
307
+
@@ -1,2 +1,2 @@
1
1
  export { DocumentTabBar } from './DocumentTabBar'
2
- export type { DocumentTabBarProps } from './DocumentTabBar'
2
+ export type { DocumentTabBarProps, DocumentFile } from './DocumentTabBar'
@@ -75,7 +75,7 @@ export type { ModeToggleProps } from './ModeToggle'
75
75
 
76
76
  // DocumentTabBar Composite
77
77
  export { DocumentTabBar } from './DocumentTabBar'
78
- export type { DocumentTabBarProps } from './DocumentTabBar'
78
+ export type { DocumentTabBarProps, DocumentFile } from './DocumentTabBar'
79
79
 
80
80
  // DefaultSwitcher Composite
81
81
  export { DefaultSwitcher } from './DefaultSwitcher'
@@ -5,11 +5,17 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5
5
 
6
6
  import { cn } from "@/lib/utils"
7
7
 
8
+ export interface ScrollAreaProps
9
+ extends React.ComponentProps<typeof ScrollAreaPrimitive.Root> {
10
+ orientation?: "vertical" | "horizontal" | "both"
11
+ }
12
+
8
13
  function ScrollArea({
9
14
  className,
10
15
  children,
16
+ orientation = "vertical",
11
17
  ...props
12
- }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
18
+ }: ScrollAreaProps) {
13
19
  return (
14
20
  <ScrollAreaPrimitive.Root
15
21
  data-slot="scroll-area"
@@ -22,7 +28,12 @@ function ScrollArea({
22
28
  >
23
29
  {children}
24
30
  </ScrollAreaPrimitive.Viewport>
25
- <ScrollBar />
31
+ {(orientation === "vertical" || orientation === "both") && (
32
+ <ScrollBar orientation="vertical" />
33
+ )}
34
+ {(orientation === "horizontal" || orientation === "both") && (
35
+ <ScrollBar orientation="horizontal" className="h-1.5 border-t border-t-transparent" />
36
+ )}
26
37
  <ScrollAreaPrimitive.Corner />
27
38
  </ScrollAreaPrimitive.Root>
28
39
  )