@toolr/ui-design 0.1.0
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/README.md +63 -0
- package/components/content/info-panel-primitives.tsx +297 -0
- package/components/diagrams/diagram-utils.tsx +908 -0
- package/components/hooks/use-click-outside.ts +27 -0
- package/components/hooks/use-dropdown-max-height.ts +20 -0
- package/components/hooks/use-navigation-history.ts +94 -0
- package/components/lib/ai-tools.tsx +44 -0
- package/components/lib/cn.ts +6 -0
- package/components/lib/form-colors.ts +32 -0
- package/components/lib/theme-engine.ts +97 -0
- package/components/lib/toolr-brand.tsx +31 -0
- package/components/sections/ai-tools-paths/index.ts +37 -0
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
- package/components/sections/ai-tools-paths/types.ts +111 -0
- package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
- package/components/sections/captured-issues/index.ts +38 -0
- package/components/sections/captured-issues/types.ts +113 -0
- package/components/sections/captured-issues/use-captured-issues.ts +111 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
- package/components/sections/golden-snapshots/index.ts +145 -0
- package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
- package/components/sections/golden-snapshots/status-overview.tsx +305 -0
- package/components/sections/golden-snapshots/types.ts +288 -0
- package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
- package/components/sections/golden-snapshots/version-manager.tsx +186 -0
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
- package/components/sections/prompt-editor/index.ts +121 -0
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
- package/components/sections/prompt-editor/types.ts +101 -0
- package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
- package/components/sections/report-bug/error-logger.ts +392 -0
- package/components/sections/report-bug/index.ts +59 -0
- package/components/sections/report-bug/issue-reporter-api.ts +83 -0
- package/components/sections/report-bug/report-bug-form.tsx +282 -0
- package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
- package/components/sections/report-bug/use-report-bug.ts +170 -0
- package/components/sections/snapshot-browser/index.ts +53 -0
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
- package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
- package/components/sections/snapshot-browser/types.ts +106 -0
- package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
- package/components/sections/snippets-editor/index.ts +31 -0
- package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
- package/components/sections/snippets-editor/types.ts +48 -0
- package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
- package/components/ui/action-dialog.tsx +309 -0
- package/components/ui/ai-action-button.tsx +137 -0
- package/components/ui/ai-execution-action-buttons.tsx +106 -0
- package/components/ui/badge.tsx +67 -0
- package/components/ui/bottom-panel-header.tsx +240 -0
- package/components/ui/breadcrumb.tsx +168 -0
- package/components/ui/checkbox.tsx +102 -0
- package/components/ui/collapsible-section.tsx +100 -0
- package/components/ui/confirm-badge.tsx +71 -0
- package/components/ui/detail-section.tsx +67 -0
- package/components/ui/detail-view-wrapper.tsx +55 -0
- package/components/ui/editor-placeholder-card.tsx +197 -0
- package/components/ui/editor-toolbar.tsx +123 -0
- package/components/ui/execution-details-panel.tsx +93 -0
- package/components/ui/extension-list-card.tsx +105 -0
- package/components/ui/file-structure-section.tsx +373 -0
- package/components/ui/file-tree.tsx +171 -0
- package/components/ui/files-panel.tsx +251 -0
- package/components/ui/filter-dropdown.tsx +173 -0
- package/components/ui/form-actions.tsx +127 -0
- package/components/ui/frontmatter-form-header.tsx +80 -0
- package/components/ui/icon-button.tsx +388 -0
- package/components/ui/input.tsx +211 -0
- package/components/ui/label.tsx +159 -0
- package/components/ui/layout-tab-bar.tsx +289 -0
- package/components/ui/modal.tsx +194 -0
- package/components/ui/nav-card.tsx +81 -0
- package/components/ui/navigation-bar.tsx +285 -0
- package/components/ui/number-input.tsx +165 -0
- package/components/ui/registry-browser.tsx +261 -0
- package/components/ui/registry-card.tsx +710 -0
- package/components/ui/registry-detail.tsx +224 -0
- package/components/ui/resizable-textarea.tsx +290 -0
- package/components/ui/scope-badge.tsx +67 -0
- package/components/ui/segmented-toggle.tsx +133 -0
- package/components/ui/select.tsx +172 -0
- package/components/ui/selection-grid.tsx +313 -0
- package/components/ui/setting-row.tsx +97 -0
- package/components/ui/snapshot-card.tsx +107 -0
- package/components/ui/snippets-panel.tsx +161 -0
- package/components/ui/sort-dropdown.tsx +109 -0
- package/components/ui/status-card.tsx +96 -0
- package/components/ui/tab-bar.tsx +340 -0
- package/components/ui/toggle.tsx +142 -0
- package/components/ui/tooltip.tsx +326 -0
- package/dist/content.d.ts +110 -0
- package/dist/content.js +195 -0
- package/dist/diagrams.d.ts +371 -0
- package/dist/diagrams.js +702 -0
- package/dist/index.d.ts +2714 -0
- package/dist/index.js +11220 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +17 -0
- package/dist/tokens/tokens/primitives.css +45 -0
- package/dist/tokens/tokens/semantic.css +46 -0
- package/dist/tokens/tokens/theme.css +11 -0
- package/dist/tokens/tokens/tokens.json +65 -0
- package/index.ts +123 -0
- package/package.json +63 -0
- package/tailwind-preset.ts +22 -0
- package/tokens/primitives.css +45 -0
- package/tokens/semantic.css +46 -0
- package/tokens/theme.css +11 -0
- package/tokens/tokens.json +65 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input - Controlled text input with error state support
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - Forms - text fields, URLs, names
|
|
6
|
+
* - Search bars - filter inputs
|
|
7
|
+
* - Dialogs - single-line text entry
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Visual variants (filled, outline)
|
|
11
|
+
* - Size variants (sm, md)
|
|
12
|
+
* - Error state with optional message
|
|
13
|
+
* - Action slot inside the field (hover/focus reveal)
|
|
14
|
+
* - Search type with built-in icon, clear button, and auto-attributes
|
|
15
|
+
* - Password type with built-in reveal/hide toggle button
|
|
16
|
+
* - Optional debounce with visual SVG animation
|
|
17
|
+
* - Extends native input attributes
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { forwardRef, useEffect, useRef, useState, type InputHTMLAttributes, type ReactNode } from 'react'
|
|
21
|
+
import { Search, X, Eye, EyeOff } from 'lucide-react'
|
|
22
|
+
import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
|
|
23
|
+
|
|
24
|
+
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'type'> {
|
|
25
|
+
value: string
|
|
26
|
+
onChange: (value: string) => void
|
|
27
|
+
type?: 'text' | 'search' | 'password'
|
|
28
|
+
debounceMs?: number
|
|
29
|
+
error?: boolean | string
|
|
30
|
+
variant?: 'filled' | 'outline'
|
|
31
|
+
color?: FormColor
|
|
32
|
+
size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
|
|
33
|
+
mono?: boolean
|
|
34
|
+
/** Test ID for E2E testing */
|
|
35
|
+
testId?: string
|
|
36
|
+
/** Element rendered inside the input (right side), visible on hover/focus. Use IconButton size="xss". */
|
|
37
|
+
actionSlot?: ReactNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sizeClasses = {
|
|
41
|
+
xss: 'px-1 py-0.5 text-[10px]',
|
|
42
|
+
xs: 'px-1.5 py-0.5 text-xs',
|
|
43
|
+
sm: 'px-2 py-1 text-xs',
|
|
44
|
+
md: 'px-3 py-1.5 text-sm',
|
|
45
|
+
lg: 'px-3 py-2 text-sm',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const variantClasses = {
|
|
49
|
+
filled: 'bg-neutral-800',
|
|
50
|
+
outline: 'bg-transparent',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
54
|
+
value,
|
|
55
|
+
onChange,
|
|
56
|
+
type = 'text',
|
|
57
|
+
debounceMs = 0,
|
|
58
|
+
error,
|
|
59
|
+
variant = 'outline',
|
|
60
|
+
color = 'blue',
|
|
61
|
+
size = 'sm',
|
|
62
|
+
disabled = false,
|
|
63
|
+
className = '',
|
|
64
|
+
mono = false,
|
|
65
|
+
testId,
|
|
66
|
+
actionSlot,
|
|
67
|
+
onFocus: onFocusProp,
|
|
68
|
+
onBlur: onBlurProp,
|
|
69
|
+
...props
|
|
70
|
+
}, ref) {
|
|
71
|
+
const hasError = Boolean(error)
|
|
72
|
+
const hasAction = actionSlot && !disabled
|
|
73
|
+
const [hovered, setHovered] = useState(false)
|
|
74
|
+
const [focused, setFocused] = useState(false)
|
|
75
|
+
const showAction = hasAction && (hovered || focused)
|
|
76
|
+
const isSearch = type === 'search'
|
|
77
|
+
const isPassword = type === 'password'
|
|
78
|
+
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
|
|
79
|
+
|
|
80
|
+
// Debounce state
|
|
81
|
+
const [internalValue, setInternalValue] = useState(value)
|
|
82
|
+
const [debounceKey, setDebounceKey] = useState(0)
|
|
83
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
84
|
+
|
|
85
|
+
// Sync internalValue when prop value changes externally
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
setInternalValue(value)
|
|
88
|
+
}, [value])
|
|
89
|
+
|
|
90
|
+
const displayValue = debounceMs > 0 ? internalValue : value
|
|
91
|
+
|
|
92
|
+
function handleChange(newValue: string) {
|
|
93
|
+
if (debounceMs > 0) {
|
|
94
|
+
setInternalValue(newValue)
|
|
95
|
+
setDebounceKey(k => k + 1)
|
|
96
|
+
clearTimeout(timerRef.current)
|
|
97
|
+
timerRef.current = setTimeout(() => {
|
|
98
|
+
onChange(newValue)
|
|
99
|
+
setDebounceKey(0)
|
|
100
|
+
}, debounceMs)
|
|
101
|
+
} else {
|
|
102
|
+
onChange(newValue)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleClear() {
|
|
107
|
+
clearTimeout(timerRef.current)
|
|
108
|
+
setInternalValue('')
|
|
109
|
+
setDebounceKey(0)
|
|
110
|
+
onChange('')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Cleanup timer on unmount
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
return () => clearTimeout(timerRef.current)
|
|
116
|
+
}, [])
|
|
117
|
+
|
|
118
|
+
const searchAutoProps = isSearch ? {
|
|
119
|
+
autoComplete: 'off' as const,
|
|
120
|
+
autoCorrect: 'off' as const,
|
|
121
|
+
autoCapitalize: 'off' as const,
|
|
122
|
+
spellCheck: false as const,
|
|
123
|
+
} : {}
|
|
124
|
+
|
|
125
|
+
const showClear = isSearch && displayValue && !disabled
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div
|
|
129
|
+
className="w-full"
|
|
130
|
+
onMouseEnter={hasAction ? () => setHovered(true) : undefined}
|
|
131
|
+
onMouseLeave={hasAction ? () => setHovered(false) : undefined}
|
|
132
|
+
>
|
|
133
|
+
<div className="relative">
|
|
134
|
+
{isSearch && (
|
|
135
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500 z-10 pointer-events-none" />
|
|
136
|
+
)}
|
|
137
|
+
<input
|
|
138
|
+
ref={ref}
|
|
139
|
+
type={isSearch ? 'text' : isPassword ? (isPasswordVisible ? 'text' : 'password') : type}
|
|
140
|
+
value={displayValue}
|
|
141
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
142
|
+
onFocus={(e) => { setFocused(true); onFocusProp?.(e) }}
|
|
143
|
+
onBlur={(e) => { setFocused(false); onBlurProp?.(e) }}
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
data-testid={testId}
|
|
146
|
+
{...searchAutoProps}
|
|
147
|
+
className={`
|
|
148
|
+
w-full border rounded-lg text-neutral-200 placeholder-neutral-500
|
|
149
|
+
focus:outline-none transition-colors
|
|
150
|
+
disabled:opacity-50 disabled:cursor-not-allowed
|
|
151
|
+
${sizeClasses[size]}
|
|
152
|
+
${hasError ? 'border-red-500 focus:border-red-500' : `${variantClasses[variant]} ${FORM_COLORS[color].border} ${FORM_COLORS[color].focus}`}
|
|
153
|
+
${mono ? 'font-mono' : ''}
|
|
154
|
+
${isSearch ? 'pl-8 pr-8' : (hasAction || isPassword) ? 'pr-8' : ''}
|
|
155
|
+
${className}
|
|
156
|
+
`}
|
|
157
|
+
{...props}
|
|
158
|
+
/>
|
|
159
|
+
{showClear && (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={handleClear}
|
|
163
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors z-10 cursor-pointer"
|
|
164
|
+
>
|
|
165
|
+
<X className="w-2.5 h-2.5" />
|
|
166
|
+
</button>
|
|
167
|
+
)}
|
|
168
|
+
{showAction && (
|
|
169
|
+
<div className="absolute right-1.5 top-1/2 -translate-y-1/2">
|
|
170
|
+
{actionSlot}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
{isPassword && !disabled && (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
|
177
|
+
title={isPasswordVisible ? 'Hide' : 'Reveal'}
|
|
178
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors z-10 cursor-pointer"
|
|
179
|
+
>
|
|
180
|
+
{isPasswordVisible ? <EyeOff className="w-2.5 h-2.5" /> : <Eye className="w-2.5 h-2.5" />}
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
{debounceMs > 0 && debounceKey > 0 && (
|
|
184
|
+
<svg
|
|
185
|
+
key={debounceKey}
|
|
186
|
+
className="absolute inset-0 pointer-events-none"
|
|
187
|
+
style={{ width: '100%', height: '100%' }}
|
|
188
|
+
>
|
|
189
|
+
<rect
|
|
190
|
+
x="1" y="1" rx="5" ry="5"
|
|
191
|
+
fill="none"
|
|
192
|
+
stroke="rgb(52 211 153 / 0.7)"
|
|
193
|
+
strokeWidth="1.5"
|
|
194
|
+
pathLength="100"
|
|
195
|
+
strokeDasharray="100"
|
|
196
|
+
strokeDashoffset="0"
|
|
197
|
+
style={{
|
|
198
|
+
width: 'calc(100% - 2px)',
|
|
199
|
+
height: 'calc(100% - 2px)',
|
|
200
|
+
animation: `debounce-border-drain ${debounceMs}ms linear forwards`,
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
</svg>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
{typeof error === 'string' && error && (
|
|
207
|
+
<p className="text-xs text-red-400 mt-1 text-right">{error}</p>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label - Border-focused colored badge/tag with icon and tooltip
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for all badge/tag patterns in the app.
|
|
5
|
+
* Matches the SeedrBadges border-focused style: colored border + text, no background.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - 14 color variants
|
|
9
|
+
* - Required leading icon and tooltip (always cursor-help)
|
|
10
|
+
* - Optional trailing icons
|
|
11
|
+
* - Text transform with smart capitalize (handles after-dash)
|
|
12
|
+
* - Clickable with hover state
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { iconMap, type IconName } from './icon-button.tsx'
|
|
16
|
+
import { Tooltip } from './tooltip.tsx'
|
|
17
|
+
|
|
18
|
+
export type LabelColor =
|
|
19
|
+
| 'neutral'
|
|
20
|
+
| 'green'
|
|
21
|
+
| 'red'
|
|
22
|
+
| 'blue'
|
|
23
|
+
| 'yellow'
|
|
24
|
+
| 'orange'
|
|
25
|
+
| 'purple'
|
|
26
|
+
| 'amber'
|
|
27
|
+
| 'emerald'
|
|
28
|
+
| 'cyan'
|
|
29
|
+
| 'indigo'
|
|
30
|
+
| 'teal'
|
|
31
|
+
| 'violet'
|
|
32
|
+
| 'pink'
|
|
33
|
+
|
|
34
|
+
export interface LabelProps {
|
|
35
|
+
text: string
|
|
36
|
+
color: LabelColor
|
|
37
|
+
/** Leading icon(s). Pass a single name or an array for multiple icons before text. */
|
|
38
|
+
icon?: IconName | IconName[]
|
|
39
|
+
/** Custom icon component. Takes precedence over icon. */
|
|
40
|
+
IconComponent?: React.ComponentType<{ className?: string }>
|
|
41
|
+
tooltip: { title?: string; description: string }
|
|
42
|
+
size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
|
|
43
|
+
textTransform?: 'capitalize' | 'lowercase' | 'uppercase'
|
|
44
|
+
onClick?: () => void
|
|
45
|
+
className?: string
|
|
46
|
+
testId?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const colorClasses: Record<LabelColor, string> = {
|
|
50
|
+
neutral: 'border-neutral-500/50 text-neutral-400',
|
|
51
|
+
green: 'border-green-500/50 text-green-400',
|
|
52
|
+
red: 'border-red-500/50 text-red-400',
|
|
53
|
+
blue: 'border-blue-500/50 text-blue-400',
|
|
54
|
+
yellow: 'border-yellow-500/50 text-yellow-400',
|
|
55
|
+
orange: 'border-orange-500/50 text-orange-400',
|
|
56
|
+
purple: 'border-purple-500/50 text-purple-400',
|
|
57
|
+
amber: 'border-amber-500/50 text-amber-400',
|
|
58
|
+
emerald: 'border-emerald-500/50 text-emerald-400',
|
|
59
|
+
cyan: 'border-cyan-500/50 text-cyan-400',
|
|
60
|
+
indigo: 'border-indigo-500/50 text-indigo-400',
|
|
61
|
+
teal: 'border-teal-500/50 text-teal-400',
|
|
62
|
+
violet: 'border-violet-500/50 text-violet-400',
|
|
63
|
+
pink: 'border-pink-500/50 text-pink-400',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sizeConfig = {
|
|
67
|
+
xss: { height: 14, padding: 'px-1', text: 'text-[9px]', iconSize: 'w-2 h-2', gap: 'gap-0.5' },
|
|
68
|
+
xs: { height: 16, padding: 'px-1.5', text: 'text-[10px]', iconSize: 'w-2.5 h-2.5', gap: 'gap-1' },
|
|
69
|
+
sm: { height: 18, padding: 'px-1.5', text: 'text-[10px]', iconSize: 'w-2.5 h-2.5', gap: 'gap-1.5' },
|
|
70
|
+
md: { height: 20, padding: 'px-1.5', text: 'text-[11px]', iconSize: 'w-3 h-3', gap: 'gap-1' },
|
|
71
|
+
lg: { height: 22, padding: 'px-2', text: 'text-xs', iconSize: 'w-3 h-3', gap: 'gap-1' },
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Smart capitalize: capitalizes first letter of each word separated by spaces or dashes */
|
|
75
|
+
function smartCapitalize(value: string): string {
|
|
76
|
+
return value.replace(/(^|[\s-])(\w)/g, (_match, separator: string, char: string) => separator + char.toUpperCase())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function transformText(text: string, textTransform?: 'capitalize' | 'lowercase' | 'uppercase'): string {
|
|
80
|
+
if (textTransform === 'capitalize') return smartCapitalize(text)
|
|
81
|
+
return text
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function Label({
|
|
85
|
+
text,
|
|
86
|
+
color,
|
|
87
|
+
icon,
|
|
88
|
+
IconComponent: CustomIcon,
|
|
89
|
+
tooltip,
|
|
90
|
+
size = 'sm',
|
|
91
|
+
textTransform,
|
|
92
|
+
onClick,
|
|
93
|
+
className = '',
|
|
94
|
+
testId,
|
|
95
|
+
}: LabelProps) {
|
|
96
|
+
const s = sizeConfig[size]
|
|
97
|
+
const cssTransform = textTransform === 'lowercase' || textTransform === 'uppercase' ? textTransform : undefined
|
|
98
|
+
|
|
99
|
+
const baseClasses = [
|
|
100
|
+
`inline-flex items-center ${s.gap}`,
|
|
101
|
+
s.padding,
|
|
102
|
+
s.text,
|
|
103
|
+
'border rounded font-medium leading-none',
|
|
104
|
+
colorClasses[color],
|
|
105
|
+
onClick ? 'cursor-pointer hover:brightness-125 transition-all' : 'cursor-help',
|
|
106
|
+
className,
|
|
107
|
+
]
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join(' ')
|
|
110
|
+
|
|
111
|
+
const icons: IconName[] = CustomIcon ? [] : !icon ? [] : Array.isArray(icon) ? icon : [icon]
|
|
112
|
+
|
|
113
|
+
const content = (
|
|
114
|
+
<>
|
|
115
|
+
{CustomIcon && (
|
|
116
|
+
<span className={`${s.iconSize} flex-shrink-0`}><CustomIcon className={s.iconSize} /></span>
|
|
117
|
+
)}
|
|
118
|
+
{icons.map((iconName, i) => {
|
|
119
|
+
const Icon = iconMap[iconName]
|
|
120
|
+
return Icon ? (
|
|
121
|
+
<span key={i} className={`${s.iconSize} flex-shrink-0`}><Icon className={s.iconSize} /></span>
|
|
122
|
+
) : null
|
|
123
|
+
})}
|
|
124
|
+
<span className="min-w-0 truncate" style={cssTransform ? { textTransform: cssTransform } : undefined}>
|
|
125
|
+
{transformText(text, textTransform)}
|
|
126
|
+
</span>
|
|
127
|
+
</>
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const labelElement = onClick ? (
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={(e) => { e.stopPropagation(); onClick() }}
|
|
134
|
+
data-testid={testId}
|
|
135
|
+
className={baseClasses}
|
|
136
|
+
style={{ height: `${s.height}px` }}
|
|
137
|
+
>
|
|
138
|
+
{content}
|
|
139
|
+
</button>
|
|
140
|
+
) : (
|
|
141
|
+
<span
|
|
142
|
+
data-testid={testId}
|
|
143
|
+
className={baseClasses}
|
|
144
|
+
style={{ height: `${s.height}px` }}
|
|
145
|
+
>
|
|
146
|
+
{content}
|
|
147
|
+
</span>
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Tooltip
|
|
152
|
+
content={tooltip}
|
|
153
|
+
position="top"
|
|
154
|
+
align="start"
|
|
155
|
+
>
|
|
156
|
+
{labelElement}
|
|
157
|
+
</Tooltip>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/** Browser-style chrome tab bar with two-line tabs, accent borders, gradient backgrounds, close on hover, scroll arrows, and drag reordering. */
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
|
5
|
+
import { cn } from '../lib/cn.ts'
|
|
6
|
+
|
|
7
|
+
export interface LayoutTab {
|
|
8
|
+
id: string
|
|
9
|
+
title: string
|
|
10
|
+
subtitle?: string
|
|
11
|
+
subtitleIcon?: ReactNode
|
|
12
|
+
icon?: ReactNode
|
|
13
|
+
color?: string
|
|
14
|
+
selectedColor?: string
|
|
15
|
+
titleColor?: string
|
|
16
|
+
iconColor?: string
|
|
17
|
+
subtitleColor?: string
|
|
18
|
+
subtitleIconColor?: string
|
|
19
|
+
closable?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LayoutTabBarProps {
|
|
23
|
+
tabs: LayoutTab[]
|
|
24
|
+
activeId: string | null
|
|
25
|
+
onSelect: (id: string) => void
|
|
26
|
+
onClose?: (id: string) => void
|
|
27
|
+
onReorder?: (fromIndex: number, toIndex: number) => void
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COLORS: Record<string, { border: string; text: string }> = {
|
|
32
|
+
white: { border: 'border-t-white', text: 'text-white' },
|
|
33
|
+
blue: { border: 'border-t-blue-400', text: 'text-blue-400' },
|
|
34
|
+
green: { border: 'border-t-green-400', text: 'text-green-400' },
|
|
35
|
+
purple: { border: 'border-t-purple-400', text: 'text-purple-400' },
|
|
36
|
+
orange: { border: 'border-t-orange-400', text: 'text-orange-400' },
|
|
37
|
+
red: { border: 'border-t-red-400', text: 'text-red-400' },
|
|
38
|
+
cyan: { border: 'border-t-cyan-400', text: 'text-cyan-400' },
|
|
39
|
+
amber: { border: 'border-t-amber-400', text: 'text-amber-400' },
|
|
40
|
+
emerald: { border: 'border-t-emerald-400', text: 'text-emerald-400' },
|
|
41
|
+
teal: { border: 'border-t-teal-400', text: 'text-teal-400' },
|
|
42
|
+
indigo: { border: 'border-t-indigo-400', text: 'text-indigo-400' },
|
|
43
|
+
violet: { border: 'border-t-violet-400', text: 'text-violet-400' },
|
|
44
|
+
pink: { border: 'border-t-pink-400', text: 'text-pink-400' },
|
|
45
|
+
neutral: { border: 'border-t-neutral-500', text: 'text-neutral-400' },
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// RGB values for gradient computation (Tailwind 400-level)
|
|
49
|
+
const COLOR_RGB: Record<string, string> = {
|
|
50
|
+
blue: '96, 165, 250',
|
|
51
|
+
green: '74, 222, 128',
|
|
52
|
+
purple: '192, 132, 252',
|
|
53
|
+
orange: '251, 146, 60',
|
|
54
|
+
red: '248, 113, 113',
|
|
55
|
+
cyan: '34, 211, 238',
|
|
56
|
+
amber: '251, 191, 36',
|
|
57
|
+
emerald: '52, 211, 153',
|
|
58
|
+
teal: '45, 212, 191',
|
|
59
|
+
indigo: '129, 140, 248',
|
|
60
|
+
violet: '167, 139, 250',
|
|
61
|
+
pink: '244, 114, 182',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getTextClass(color?: string): string {
|
|
65
|
+
return color && COLORS[color] ? COLORS[color].text : ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getBorderClass(color?: string): string {
|
|
69
|
+
return color && COLORS[color] ? COLORS[color].border : COLORS.neutral.border
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getGradient(color?: string, isActive?: boolean): string | undefined {
|
|
73
|
+
if (!color || !COLOR_RGB[color]) return undefined
|
|
74
|
+
const rgb = COLOR_RGB[color]
|
|
75
|
+
return isActive
|
|
76
|
+
? `linear-gradient(135deg, rgba(${rgb}, 0.54) 0%, rgba(${rgb}, 0.10) 100%)`
|
|
77
|
+
: `linear-gradient(135deg, rgba(${rgb}, 0.36) 0%, rgba(${rgb}, 0.06) 100%)`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function LayoutTabBar({ tabs, activeId, onSelect, onClose, onReorder, className }: LayoutTabBarProps) {
|
|
81
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
82
|
+
const [showLeft, setShowLeft] = useState(false)
|
|
83
|
+
const [showRight, setShowRight] = useState(false)
|
|
84
|
+
const [drag, setDrag] = useState<{ from: number; insertAt: number | null; x: number; y: number } | null>(null)
|
|
85
|
+
const skipClickRef = useRef(false)
|
|
86
|
+
|
|
87
|
+
// ── Scroll arrows ──────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const updateArrows = useCallback(() => {
|
|
90
|
+
const el = scrollRef.current
|
|
91
|
+
if (!el) return
|
|
92
|
+
setShowLeft(el.scrollLeft > 0)
|
|
93
|
+
setShowRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1)
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const el = scrollRef.current
|
|
98
|
+
if (!el) return
|
|
99
|
+
updateArrows()
|
|
100
|
+
el.addEventListener('scroll', updateArrows)
|
|
101
|
+
const ro = new ResizeObserver(updateArrows)
|
|
102
|
+
ro.observe(el)
|
|
103
|
+
return () => { el.removeEventListener('scroll', updateArrows); ro.disconnect() }
|
|
104
|
+
}, [updateArrows, tabs.length])
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!activeId || !scrollRef.current) return
|
|
108
|
+
const idx = tabs.findIndex(t => t.id === activeId)
|
|
109
|
+
const child = scrollRef.current.children[idx] as HTMLElement | undefined
|
|
110
|
+
child?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
|
111
|
+
}, [activeId, tabs])
|
|
112
|
+
|
|
113
|
+
const scroll = useCallback((dir: 'left' | 'right') => {
|
|
114
|
+
scrollRef.current?.scrollBy({ left: dir === 'left' ? -200 : 200, behavior: 'smooth' })
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
// ── Drag and drop ─────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const handleMouseDown = useCallback((e: React.MouseEvent, index: number) => {
|
|
120
|
+
if (e.button !== 0 || !onReorder) return
|
|
121
|
+
if ((e.target as HTMLElement).closest('[role="button"]')) return
|
|
122
|
+
const sx = e.clientX, sy = e.clientY
|
|
123
|
+
let active = false
|
|
124
|
+
let lastInsert: number | null = null
|
|
125
|
+
|
|
126
|
+
const findInsert = (cx: number): number | null => {
|
|
127
|
+
const el = scrollRef.current
|
|
128
|
+
if (!el) return null
|
|
129
|
+
const kids = el.children
|
|
130
|
+
for (let i = 0; i < kids.length; i++) {
|
|
131
|
+
const r = kids[i].getBoundingClientRect()
|
|
132
|
+
if (cx >= r.left && cx <= r.right) return cx < r.left + r.width / 2 ? i : i + 1
|
|
133
|
+
}
|
|
134
|
+
if (kids.length) {
|
|
135
|
+
const last = kids[kids.length - 1].getBoundingClientRect()
|
|
136
|
+
if (cx > last.right) return kids.length
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const onMove = (e: MouseEvent) => {
|
|
142
|
+
if (!active) {
|
|
143
|
+
const dx = e.clientX - sx, dy = e.clientY - sy
|
|
144
|
+
if (Math.sqrt(dx * dx + dy * dy) > 5) {
|
|
145
|
+
active = true
|
|
146
|
+
skipClickRef.current = true
|
|
147
|
+
} else return
|
|
148
|
+
}
|
|
149
|
+
lastInsert = findInsert(e.clientX)
|
|
150
|
+
setDrag({ from: index, insertAt: lastInsert, x: e.clientX, y: e.clientY })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const onUp = () => {
|
|
154
|
+
window.removeEventListener('mousemove', onMove)
|
|
155
|
+
window.removeEventListener('mouseup', onUp)
|
|
156
|
+
if (active && lastInsert !== null && lastInsert !== index && lastInsert !== index + 1) {
|
|
157
|
+
onReorder(index, lastInsert > index ? lastInsert - 1 : lastInsert)
|
|
158
|
+
}
|
|
159
|
+
setDrag(null)
|
|
160
|
+
setTimeout(() => { skipClickRef.current = false }, 50)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
window.addEventListener('mousemove', onMove)
|
|
164
|
+
window.addEventListener('mouseup', onUp)
|
|
165
|
+
}, [onReorder])
|
|
166
|
+
|
|
167
|
+
// ── Render ────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className={cn('relative flex items-center min-h-12 flex-shrink-0 bg-neutral-900/80 border-b border-neutral-700/50', className)}>
|
|
171
|
+
{showLeft && (
|
|
172
|
+
<div className="flex items-center px-2 shrink-0">
|
|
173
|
+
<button type="button" onClick={() => scroll('left')} className="text-neutral-500 hover:text-neutral-400 transition-colors cursor-pointer">
|
|
174
|
+
<ChevronLeft className="w-3.5 h-3.5" />
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
<div ref={scrollRef} className="flex items-end gap-1 px-3 overflow-x-auto flex-1 h-full" style={{ scrollbarWidth: 'none' }}>
|
|
180
|
+
{tabs.map((tab, i) => {
|
|
181
|
+
const isActive = tab.id === activeId
|
|
182
|
+
const borderC = getBorderClass(tab.selectedColor || tab.color)
|
|
183
|
+
const titleC = getTextClass(tab.titleColor || 'white')
|
|
184
|
+
const iconC = getTextClass(tab.iconColor || tab.titleColor || 'white')
|
|
185
|
+
const subC = getTextClass(tab.subtitleColor || tab.selectedColor || tab.color)
|
|
186
|
+
const subIconC = getTextClass(tab.subtitleIconColor || tab.subtitleColor || tab.selectedColor || tab.color)
|
|
187
|
+
const isDragged = drag?.from === i
|
|
188
|
+
const insertHere = drag && drag.insertAt === i && drag.from !== i && drag.from !== i - 1
|
|
189
|
+
const insertAfterLast = drag && drag.insertAt === tabs.length && i === tabs.length - 1 && drag.from !== i
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div key={tab.id} className="relative flex-shrink-0">
|
|
193
|
+
{insertHere && <div className="absolute -left-1 top-0 bottom-0 w-0.5 bg-blue-500 rounded-full z-10" />}
|
|
194
|
+
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={() => { if (!skipClickRef.current) onSelect(tab.id) }}
|
|
198
|
+
onMouseDown={(e) => {
|
|
199
|
+
if (e.button === 1 && onClose && tab.closable) {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
onClose(tab.id)
|
|
202
|
+
} else {
|
|
203
|
+
handleMouseDown(e, i)
|
|
204
|
+
}
|
|
205
|
+
}}
|
|
206
|
+
className={cn(
|
|
207
|
+
'group flex flex-col justify-start gap-0.5 px-2.5 rounded-t-lg cursor-pointer transition-all shrink-0 select-none relative border-t-2',
|
|
208
|
+
isActive
|
|
209
|
+
? cn('bg-neutral-800', borderC)
|
|
210
|
+
: 'bg-neutral-800/50 text-neutral-400 hover:bg-neutral-800/80 hover:text-neutral-300 border-transparent opacity-60 hover:opacity-90',
|
|
211
|
+
isDragged && 'opacity-50',
|
|
212
|
+
)}
|
|
213
|
+
style={{
|
|
214
|
+
minWidth: 160,
|
|
215
|
+
maxWidth: 260,
|
|
216
|
+
height: 42,
|
|
217
|
+
backgroundImage: getGradient(tab.color, isActive),
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
{/* Line 1: Icon + Title */}
|
|
221
|
+
<div className="flex items-center gap-1.5 w-full transition-[padding] duration-150 group-hover:pr-5">
|
|
222
|
+
{tab.icon && <span className={cn('shrink-0 inline-flex', isActive ? iconC : 'text-neutral-400')} style={{ width: 14, height: 14 }}>{tab.icon}</span>}
|
|
223
|
+
<span className={cn('truncate text-sm font-medium', isActive ? titleC : 'text-neutral-300')}>{tab.title}</span>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Line 2: Subtitle with optional icon */}
|
|
227
|
+
{tab.subtitle && (
|
|
228
|
+
<div className="flex items-center gap-1 w-full">
|
|
229
|
+
{tab.subtitleIcon && <span className={cn('shrink-0 inline-flex', isActive ? subIconC : 'text-neutral-500')} style={{ width: 10, height: 10 }}>{tab.subtitleIcon}</span>}
|
|
230
|
+
<span className={cn('truncate text-xs', isActive ? subC : 'text-neutral-500')}>{tab.subtitle}</span>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{/* Close button */}
|
|
235
|
+
{tab.closable && onClose && (
|
|
236
|
+
<span
|
|
237
|
+
role="button"
|
|
238
|
+
tabIndex={-1}
|
|
239
|
+
onClick={(e) => { e.stopPropagation(); onClose(tab.id) }}
|
|
240
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onClose(tab.id) } }}
|
|
241
|
+
className="absolute right-1.5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 hover:bg-neutral-700 rounded p-0.5 transition-opacity cursor-pointer"
|
|
242
|
+
>
|
|
243
|
+
<X className="w-3 h-3" />
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
</button>
|
|
247
|
+
|
|
248
|
+
{insertAfterLast && <div className="absolute -right-1 top-0 bottom-0 w-0.5 bg-blue-500 rounded-full z-10" />}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
{showRight && (
|
|
255
|
+
<div className="flex items-center px-2 shrink-0">
|
|
256
|
+
<button type="button" onClick={() => scroll('right')} className="text-neutral-500 hover:text-neutral-400 transition-colors cursor-pointer">
|
|
257
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{/* Drag ghost */}
|
|
263
|
+
{drag && tabs[drag.from] && (() => {
|
|
264
|
+
const t = tabs[drag.from]
|
|
265
|
+
const ghostBorder = getBorderClass(t.selectedColor || t.color)
|
|
266
|
+
const ghostTitleC = getTextClass(t.titleColor || 'white')
|
|
267
|
+
const ghostIconC = getTextClass(t.iconColor || t.titleColor || 'white')
|
|
268
|
+
const ghostSubC = getTextClass(t.subtitleColor || t.selectedColor || t.color)
|
|
269
|
+
const ghostSubIconC = getTextClass(t.subtitleIconColor || t.subtitleColor || t.selectedColor || t.color)
|
|
270
|
+
return (
|
|
271
|
+
<div className="fixed z-50 pointer-events-none opacity-90" style={{ left: drag.x + 12, top: drag.y - 20 }}>
|
|
272
|
+
<div className={cn('flex flex-col gap-0.5 px-2.5 py-1.5 rounded-t-lg border-t-2 shadow-xl bg-neutral-800', ghostBorder)}>
|
|
273
|
+
<div className="flex items-center gap-1.5">
|
|
274
|
+
{t.icon && <span className={cn('shrink-0 inline-flex', ghostIconC)} style={{ width: 14, height: 14 }}>{t.icon}</span>}
|
|
275
|
+
<span className={cn('text-sm font-medium', ghostTitleC)}>{t.title}</span>
|
|
276
|
+
</div>
|
|
277
|
+
{t.subtitle && (
|
|
278
|
+
<div className="flex items-center gap-1">
|
|
279
|
+
{t.subtitleIcon && <span className={cn('shrink-0 inline-flex', ghostSubIconC)} style={{ width: 10, height: 10 }}>{t.subtitleIcon}</span>}
|
|
280
|
+
<span className={cn('text-xs', ghostSubC)}>{t.subtitle}</span>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
)
|
|
286
|
+
})()}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|