@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,194 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Info, AlertTriangle, AlertCircle, Check } from 'lucide-react'
|
|
4
|
+
import { IconButton, type ActionItem } from './icon-button.tsx'
|
|
5
|
+
import { FormActions } from './form-actions.tsx'
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
export type ModalKind = 'info' | 'warning' | 'error' | 'orange' | 'success'
|
|
9
|
+
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
10
|
+
|
|
11
|
+
const SIZE_CLASSES: Record<ModalSize, string> = {
|
|
12
|
+
sm: 'max-w-sm',
|
|
13
|
+
md: 'max-w-md',
|
|
14
|
+
lg: 'max-w-2xl',
|
|
15
|
+
xl: 'max-w-4xl',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KIND_ICON: Record<ModalKind, ReactNode> = {
|
|
19
|
+
info: <Info className="w-6 h-6 text-blue-400" />,
|
|
20
|
+
warning: <AlertTriangle className="w-6 h-6 text-yellow-400" />,
|
|
21
|
+
error: <AlertCircle className="w-6 h-6 text-red-400" />,
|
|
22
|
+
orange: <AlertTriangle className="w-6 h-6 text-orange-400" />,
|
|
23
|
+
success: <Check className="w-6 h-6 text-green-400" />,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ModalProps {
|
|
27
|
+
isOpen: boolean
|
|
28
|
+
onClose: () => void
|
|
29
|
+
title: string
|
|
30
|
+
children: ReactNode
|
|
31
|
+
kind?: ModalKind
|
|
32
|
+
/** Modal width size */
|
|
33
|
+
size?: ModalSize
|
|
34
|
+
/** Hide the close button in the header (useful when footer has action buttons) */
|
|
35
|
+
hideCloseButton?: boolean
|
|
36
|
+
/** Optional actions to render in the header (top right) */
|
|
37
|
+
headerActions?: ActionItem[]
|
|
38
|
+
/** Test ID for E2E testing */
|
|
39
|
+
testId?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Modal({ isOpen, onClose, title, children, kind = 'info', size = 'md', hideCloseButton = false, headerActions, testId }: ModalProps) {
|
|
43
|
+
const modalRef = useRef<HTMLDivElement>(null)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isOpen) return
|
|
47
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
48
|
+
if (e.key === 'Escape') onClose()
|
|
49
|
+
}
|
|
50
|
+
document.addEventListener('keydown', handleEscape)
|
|
51
|
+
return () => document.removeEventListener('keydown', handleEscape)
|
|
52
|
+
}, [isOpen, onClose])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!isOpen) return
|
|
56
|
+
document.body.style.overflow = 'hidden'
|
|
57
|
+
return () => { document.body.style.overflow = '' }
|
|
58
|
+
}, [isOpen])
|
|
59
|
+
|
|
60
|
+
if (!isOpen) return null
|
|
61
|
+
|
|
62
|
+
return createPortal(
|
|
63
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
64
|
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
65
|
+
<div
|
|
66
|
+
ref={modalRef}
|
|
67
|
+
data-testid={testId}
|
|
68
|
+
className={`relative bg-neutral-900 border border-neutral-700 rounded-xl shadow-2xl ${SIZE_CLASSES[size]} w-full mx-4 overflow-hidden`}
|
|
69
|
+
>
|
|
70
|
+
<div className="flex items-center gap-3 px-5 py-4 border-b border-neutral-800">
|
|
71
|
+
{KIND_ICON[kind]}
|
|
72
|
+
<h3 className="text-lg font-semibold text-white flex-1">{title}</h3>
|
|
73
|
+
{headerActions?.map((a, i) => <IconButton key={i} {...a} />)}
|
|
74
|
+
{!hideCloseButton && (
|
|
75
|
+
<IconButton
|
|
76
|
+
icon="x"
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
size="sm"
|
|
79
|
+
color="neutral"
|
|
80
|
+
tooltip={{ description: 'Close this modal' }}
|
|
81
|
+
testId="modal-close"
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
<div className="px-5 py-4">{children}</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>,
|
|
88
|
+
document.body,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ConfirmModalProps {
|
|
93
|
+
isOpen: boolean
|
|
94
|
+
onClose: () => void
|
|
95
|
+
onConfirm: () => void | Promise<void>
|
|
96
|
+
title: string
|
|
97
|
+
message: string
|
|
98
|
+
warning?: string
|
|
99
|
+
warningItems?: string[]
|
|
100
|
+
kind?: ModalKind
|
|
101
|
+
confirmColor?: 'red' | 'blue' | 'orange' | 'yellow'
|
|
102
|
+
isLoading?: boolean
|
|
103
|
+
confirmDisabled?: boolean
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ConfirmModal({
|
|
107
|
+
isOpen,
|
|
108
|
+
onClose,
|
|
109
|
+
onConfirm,
|
|
110
|
+
title,
|
|
111
|
+
message,
|
|
112
|
+
warning,
|
|
113
|
+
warningItems,
|
|
114
|
+
kind = 'warning',
|
|
115
|
+
confirmColor = 'blue',
|
|
116
|
+
isLoading = false,
|
|
117
|
+
confirmDisabled = false,
|
|
118
|
+
}: ConfirmModalProps) {
|
|
119
|
+
const [isConfirming, setIsConfirming] = useState(false)
|
|
120
|
+
|
|
121
|
+
const isDisabled = isLoading || isConfirming || confirmDisabled
|
|
122
|
+
const isInProgress = isLoading || isConfirming
|
|
123
|
+
|
|
124
|
+
const handleConfirm = async () => {
|
|
125
|
+
setIsConfirming(true)
|
|
126
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
128
|
+
try {
|
|
129
|
+
await onConfirm()
|
|
130
|
+
} finally {
|
|
131
|
+
setIsConfirming(false)
|
|
132
|
+
onClose()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Modal isOpen={isOpen} onClose={onClose} title={title} kind={kind} hideCloseButton>
|
|
138
|
+
<div className="text-neutral-300 mb-6">
|
|
139
|
+
{message}
|
|
140
|
+
{warning && (
|
|
141
|
+
<div className="mt-3 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
|
142
|
+
<p className="text-amber-300 text-sm font-medium">{warning}</p>
|
|
143
|
+
{warningItems && warningItems.length > 0 && (
|
|
144
|
+
<ul className="mt-2 space-y-1">
|
|
145
|
+
{warningItems.map((item, i) => (
|
|
146
|
+
<li key={i} className="text-amber-300/80 text-sm ml-4 list-disc">{item}</li>
|
|
147
|
+
))}
|
|
148
|
+
</ul>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
<FormActions
|
|
154
|
+
padding="compact"
|
|
155
|
+
border={false}
|
|
156
|
+
onCancel={onClose}
|
|
157
|
+
cancelTooltip="Cancel this action"
|
|
158
|
+
onConfirm={handleConfirm}
|
|
159
|
+
confirmTooltip={`Confirm: ${title}`}
|
|
160
|
+
confirmColor={confirmColor}
|
|
161
|
+
confirmDisabled={isDisabled}
|
|
162
|
+
confirmStatus={isInProgress ? 'loading' : undefined}
|
|
163
|
+
/>
|
|
164
|
+
</Modal>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface AlertModalProps {
|
|
169
|
+
isOpen: boolean
|
|
170
|
+
onClose: () => void
|
|
171
|
+
title: string
|
|
172
|
+
message: string
|
|
173
|
+
kind?: ModalKind
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function AlertModal({
|
|
177
|
+
isOpen,
|
|
178
|
+
onClose,
|
|
179
|
+
title,
|
|
180
|
+
message,
|
|
181
|
+
kind = 'info',
|
|
182
|
+
}: AlertModalProps) {
|
|
183
|
+
return (
|
|
184
|
+
<Modal isOpen={isOpen} onClose={onClose} title={title} kind={kind} hideCloseButton>
|
|
185
|
+
<div className="text-neutral-300 mb-6">{message}</div>
|
|
186
|
+
<FormActions
|
|
187
|
+
padding="compact"
|
|
188
|
+
border={false}
|
|
189
|
+
onConfirm={onClose}
|
|
190
|
+
confirmTooltip="Dismiss this alert"
|
|
191
|
+
/>
|
|
192
|
+
</Modal>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Navigation card with icon, description, badge, and hover lift effect. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Settings, Puzzle, Zap, Shield, Folder, Code, Database, Globe,
|
|
5
|
+
Terminal, Star, Bell, User, Users, File, Tag, Sparkles, Search,
|
|
6
|
+
Lock, Mail, Cloud, Heart, Bookmark, Pin, Bot, Plug,
|
|
7
|
+
} from 'lucide-react'
|
|
8
|
+
import type { LucideIcon } from 'lucide-react'
|
|
9
|
+
import type { IconName } from './icon-button.tsx'
|
|
10
|
+
import { Badge, type BadgeColor } from './badge.tsx'
|
|
11
|
+
import { cn } from '../lib/cn.ts'
|
|
12
|
+
|
|
13
|
+
const iconSubset: Partial<Record<IconName, LucideIcon>> = {
|
|
14
|
+
settings: Settings, puzzle: Puzzle, zap: Zap, shield: Shield,
|
|
15
|
+
folder: Folder, code: Code, database: Database, globe: Globe,
|
|
16
|
+
terminal: Terminal, star: Star, bell: Bell, user: User,
|
|
17
|
+
users: Users, file: File, tag: Tag, sparkles: Sparkles,
|
|
18
|
+
search: Search, lock: Lock, mail: Mail, cloud: Cloud,
|
|
19
|
+
heart: Heart, bookmark: Bookmark, pin: Pin, bot: Bot, plug: Plug,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NavCardProps {
|
|
23
|
+
title: string
|
|
24
|
+
description?: string
|
|
25
|
+
icon?: IconName
|
|
26
|
+
iconColor?: string
|
|
27
|
+
badge?: number | string
|
|
28
|
+
badgeColor?: BadgeColor
|
|
29
|
+
onClick?: () => void
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function NavCard({
|
|
35
|
+
title,
|
|
36
|
+
description,
|
|
37
|
+
icon,
|
|
38
|
+
iconColor = '#60a5fa',
|
|
39
|
+
badge,
|
|
40
|
+
badgeColor = 'blue',
|
|
41
|
+
onClick,
|
|
42
|
+
disabled = false,
|
|
43
|
+
className,
|
|
44
|
+
}: NavCardProps) {
|
|
45
|
+
const Icon = icon ? iconSubset[icon] : undefined
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={disabled ? undefined : onClick}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
className={cn(
|
|
53
|
+
'relative w-full text-left rounded-lg border border-neutral-700 bg-neutral-800 p-4 transition-all duration-200 cursor-pointer',
|
|
54
|
+
!disabled && 'hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/20 hover:border-neutral-600 hover:bg-neutral-700',
|
|
55
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{badge !== undefined && (
|
|
60
|
+
<span className="absolute top-3 right-3">
|
|
61
|
+
<Badge value={badge} color={badgeColor} size="xs" />
|
|
62
|
+
</span>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{Icon && (
|
|
66
|
+
<div
|
|
67
|
+
className="w-9 h-9 rounded-lg flex items-center justify-center mb-3"
|
|
68
|
+
style={{ backgroundColor: `${iconColor}15` }}
|
|
69
|
+
>
|
|
70
|
+
<Icon className="w-4.5 h-4.5" style={{ color: iconColor }} />
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
<h3 className="text-sm font-medium text-neutral-200">{title}</h3>
|
|
75
|
+
|
|
76
|
+
{description && (
|
|
77
|
+
<p className="mt-1 text-xs text-neutral-500 leading-relaxed line-clamp-2">{description}</p>
|
|
78
|
+
)}
|
|
79
|
+
</button>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** Navigation bar with back/forward controls, history dropdown, and inline breadcrumb path. */
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ChevronLeft, ChevronRight, History,
|
|
6
|
+
Menu, Home, Layers,
|
|
7
|
+
Settings, Folder, File, Code, Terminal, Database,
|
|
8
|
+
Globe, Star, Users, User, Tag, Search, Heart,
|
|
9
|
+
Zap, Shield, ShieldCheck, Sparkles, Eye, Lock,
|
|
10
|
+
Cloud, Wand2, Bell, Bookmark, Pin, Mail, Send,
|
|
11
|
+
Image, Bot, Puzzle, Plug, Webhook,
|
|
12
|
+
} from 'lucide-react'
|
|
13
|
+
import type { LucideIcon } from 'lucide-react'
|
|
14
|
+
import type { IconName } from './icon-button.tsx'
|
|
15
|
+
import type { BreadcrumbSegment } from './breadcrumb.tsx'
|
|
16
|
+
import { cn } from '../lib/cn.ts'
|
|
17
|
+
import { useClickOutside } from '../hooks/use-click-outside.ts'
|
|
18
|
+
|
|
19
|
+
const iconSubset: Partial<Record<IconName, LucideIcon>> = {
|
|
20
|
+
menu: Menu,
|
|
21
|
+
home: Home,
|
|
22
|
+
layers: Layers,
|
|
23
|
+
folder: Folder,
|
|
24
|
+
file: File,
|
|
25
|
+
settings: Settings,
|
|
26
|
+
code: Code,
|
|
27
|
+
terminal: Terminal,
|
|
28
|
+
database: Database,
|
|
29
|
+
globe: Globe,
|
|
30
|
+
star: Star,
|
|
31
|
+
users: Users,
|
|
32
|
+
user: User,
|
|
33
|
+
tag: Tag,
|
|
34
|
+
zap: Zap,
|
|
35
|
+
shield: Shield,
|
|
36
|
+
'shield-check': ShieldCheck,
|
|
37
|
+
sparkles: Sparkles,
|
|
38
|
+
eye: Eye,
|
|
39
|
+
lock: Lock,
|
|
40
|
+
search: Search,
|
|
41
|
+
heart: Heart,
|
|
42
|
+
cloud: Cloud,
|
|
43
|
+
wand: Wand2,
|
|
44
|
+
bell: Bell,
|
|
45
|
+
bookmark: Bookmark,
|
|
46
|
+
pin: Pin,
|
|
47
|
+
mail: Mail,
|
|
48
|
+
send: Send,
|
|
49
|
+
image: Image,
|
|
50
|
+
bot: Bot,
|
|
51
|
+
puzzle: Puzzle,
|
|
52
|
+
plug: Plug,
|
|
53
|
+
webhook: Webhook,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface NavigationBarProps {
|
|
57
|
+
segments: BreadcrumbSegment[]
|
|
58
|
+
canGoBack?: boolean
|
|
59
|
+
canGoForward?: boolean
|
|
60
|
+
onBack?: () => void
|
|
61
|
+
onForward?: () => void
|
|
62
|
+
showHistory?: boolean
|
|
63
|
+
historyEntries?: BreadcrumbSegment[][]
|
|
64
|
+
onHistorySelect?: (index: number) => void
|
|
65
|
+
leadingAction?: { icon: IconName; onClick?: () => void }
|
|
66
|
+
separator?: 'chevron' | 'slash' | 'dot'
|
|
67
|
+
size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
|
|
68
|
+
className?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sizeConfig = {
|
|
72
|
+
xss: { text: 'text-[10px]', segIcon: 'w-2.5 h-2.5', navIcon: 'w-2.5 h-2.5', navBtn: 'w-[18px] h-[18px] rounded-[3px]', px: 'px-1', py: 'py-0.5', sep: 'w-2 h-2', divH: 'h-3' },
|
|
73
|
+
xs: { text: 'text-xs', segIcon: 'w-3 h-3', navIcon: 'w-3 h-3', navBtn: 'w-6 h-6 rounded-[5px]', px: 'px-1.5', py: 'py-0.5', sep: 'w-2.5 h-2.5', divH: 'h-3.5' },
|
|
74
|
+
sm: { text: 'text-sm', segIcon: 'w-3.5 h-3.5', navIcon: 'w-3.5 h-3.5', navBtn: 'w-7 h-7 rounded-md', px: 'px-2', py: 'py-1', sep: 'w-3 h-3', divH: 'h-4' },
|
|
75
|
+
md: { text: 'text-base', segIcon: 'w-4 h-4', navIcon: 'w-4 h-4', navBtn: 'w-8 h-8 rounded-md', px: 'px-2.5', py: 'py-1', sep: 'w-3.5 h-3.5', divH: 'h-5' },
|
|
76
|
+
lg: { text: 'text-lg', segIcon: 'w-5 h-5', navIcon: 'w-5 h-5', navBtn: 'w-9 h-9 rounded-md', px: 'px-3', py: 'py-1.5', sep: 'w-4 h-4', divH: 'h-6' },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const colorMap: Record<string, { bg: string; text: string }> = {
|
|
80
|
+
blue: { bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
|
81
|
+
green: { bg: 'bg-green-500/10', text: 'text-green-400' },
|
|
82
|
+
purple: { bg: 'bg-purple-500/10', text: 'text-purple-400' },
|
|
83
|
+
red: { bg: 'bg-red-500/10', text: 'text-red-400' },
|
|
84
|
+
orange: { bg: 'bg-orange-500/10', text: 'text-orange-400' },
|
|
85
|
+
cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400' },
|
|
86
|
+
yellow: { bg: 'bg-yellow-500/10', text: 'text-yellow-400' },
|
|
87
|
+
amber: { bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
|
88
|
+
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
|
|
89
|
+
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' },
|
|
90
|
+
violet: { bg: 'bg-violet-500/10', text: 'text-violet-400' },
|
|
91
|
+
sky: { bg: 'bg-sky-500/10', text: 'text-sky-400' },
|
|
92
|
+
pink: { bg: 'bg-pink-500/10', text: 'text-pink-400' },
|
|
93
|
+
teal: { bg: 'bg-teal-500/10', text: 'text-teal-400' },
|
|
94
|
+
neutral: { bg: 'bg-neutral-500/10', text: 'text-neutral-400' },
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function NavButton({ icon: Icon, onClick, disabled, size, active }: {
|
|
98
|
+
icon: LucideIcon
|
|
99
|
+
onClick?: () => void
|
|
100
|
+
disabled?: boolean
|
|
101
|
+
size: keyof typeof sizeConfig
|
|
102
|
+
active?: boolean
|
|
103
|
+
}) {
|
|
104
|
+
const s = sizeConfig[size]
|
|
105
|
+
return (
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={onClick}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
className={cn(
|
|
111
|
+
'flex items-center justify-center transition-colors',
|
|
112
|
+
s.navBtn,
|
|
113
|
+
disabled
|
|
114
|
+
? 'text-neutral-600 cursor-not-allowed'
|
|
115
|
+
: active
|
|
116
|
+
? 'text-white bg-neutral-700/50 border border-neutral-600 cursor-pointer'
|
|
117
|
+
: 'text-neutral-400 border border-neutral-700/50 hover:text-white hover:bg-neutral-700/50 cursor-pointer',
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
<Icon className={s.navIcon} />
|
|
121
|
+
</button>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function Divider({ size }: { size: keyof typeof sizeConfig }) {
|
|
126
|
+
return <div className={cn('w-px bg-neutral-700/50 mx-1', sizeConfig[size].divH)} />
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SegmentSeparator({ type, size }: { type: 'chevron' | 'slash' | 'dot'; size: keyof typeof sizeConfig }) {
|
|
130
|
+
const s = sizeConfig[size]
|
|
131
|
+
if (type === 'chevron') return <ChevronRight className={cn(s.sep, 'text-neutral-600 flex-shrink-0')} />
|
|
132
|
+
if (type === 'slash') return <span className={cn(s.text, 'text-neutral-600 flex-shrink-0')}>/</span>
|
|
133
|
+
return <span className={cn(s.text, 'text-neutral-600 flex-shrink-0')}>·</span>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function SegmentIcon({ icon, color, size }: { icon: IconName; color?: string; size: keyof typeof sizeConfig }) {
|
|
137
|
+
const Icon = iconSubset[icon]
|
|
138
|
+
if (!Icon) return null
|
|
139
|
+
const c = color && colorMap[color] ? colorMap[color] : null
|
|
140
|
+
return (
|
|
141
|
+
<span className={c?.text || ''}>
|
|
142
|
+
<Icon className={sizeConfig[size].segIcon} />
|
|
143
|
+
</span>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function NavigationBar({
|
|
148
|
+
segments,
|
|
149
|
+
canGoBack = false,
|
|
150
|
+
canGoForward = false,
|
|
151
|
+
onBack,
|
|
152
|
+
onForward,
|
|
153
|
+
showHistory = false,
|
|
154
|
+
historyEntries,
|
|
155
|
+
onHistorySelect,
|
|
156
|
+
leadingAction,
|
|
157
|
+
separator = 'chevron',
|
|
158
|
+
size = 'sm',
|
|
159
|
+
className,
|
|
160
|
+
}: NavigationBarProps) {
|
|
161
|
+
const s = sizeConfig[size]
|
|
162
|
+
const hasNav = !!(onBack || onForward)
|
|
163
|
+
const LeadIcon = leadingAction ? iconSubset[leadingAction.icon] : null
|
|
164
|
+
|
|
165
|
+
const [historyOpen, setHistoryOpen] = useState(false)
|
|
166
|
+
const historyRef = useRef<HTMLDivElement>(null)
|
|
167
|
+
const closeHistory = useCallback(() => setHistoryOpen(false), [])
|
|
168
|
+
useClickOutside(historyRef, historyOpen, closeHistory)
|
|
169
|
+
|
|
170
|
+
const hasHistoryEntries = historyEntries && historyEntries.length > 0
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<nav className={cn('flex items-center', className)}>
|
|
174
|
+
<div className={cn('flex items-center gap-1', s.px, s.py, 'bg-neutral-800/50 border border-neutral-700/50 rounded-lg')}>
|
|
175
|
+
{leadingAction && LeadIcon && (
|
|
176
|
+
<>
|
|
177
|
+
<NavButton icon={LeadIcon} onClick={leadingAction.onClick} size={size} />
|
|
178
|
+
<Divider size={size} />
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{hasNav && (
|
|
183
|
+
<>
|
|
184
|
+
<div className="flex items-center gap-0.5">
|
|
185
|
+
<NavButton icon={ChevronLeft} onClick={onBack} disabled={!canGoBack} size={size} />
|
|
186
|
+
<NavButton icon={ChevronRight} onClick={onForward} disabled={!canGoForward} size={size} />
|
|
187
|
+
</div>
|
|
188
|
+
<Divider size={size} />
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{showHistory && (
|
|
193
|
+
<>
|
|
194
|
+
<div className="relative" ref={historyRef}>
|
|
195
|
+
<NavButton
|
|
196
|
+
icon={History}
|
|
197
|
+
onClick={() => setHistoryOpen(o => !o)}
|
|
198
|
+
disabled={!hasHistoryEntries}
|
|
199
|
+
size={size}
|
|
200
|
+
active={historyOpen}
|
|
201
|
+
/>
|
|
202
|
+
{historyOpen && hasHistoryEntries && (
|
|
203
|
+
<div className="absolute left-0 top-full mt-1 w-max min-w-[200px] max-w-[420px] bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-50">
|
|
204
|
+
<div className="px-3 py-1.5 border-b border-neutral-700/50">
|
|
205
|
+
<p className="text-xs font-medium text-neutral-500">History</p>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="max-h-[300px] overflow-y-auto py-1">
|
|
208
|
+
{historyEntries.map((entry, i) => (
|
|
209
|
+
<button
|
|
210
|
+
key={i}
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => {
|
|
213
|
+
onHistorySelect?.(i)
|
|
214
|
+
setHistoryOpen(false)
|
|
215
|
+
}}
|
|
216
|
+
className="w-full px-3 py-1.5 flex items-center gap-1 text-left hover:bg-neutral-800 transition-colors cursor-pointer"
|
|
217
|
+
>
|
|
218
|
+
{entry.map((seg, segIdx) => (
|
|
219
|
+
<span key={seg.id} className="flex items-center gap-1 min-w-0">
|
|
220
|
+
{segIdx > 0 && <ChevronRight className="w-2.5 h-2.5 text-neutral-600 flex-shrink-0" />}
|
|
221
|
+
{seg.icon && <SegmentIcon icon={seg.icon} color={seg.color} size="xs" />}
|
|
222
|
+
<span className={cn(
|
|
223
|
+
'text-xs truncate',
|
|
224
|
+
seg.color && colorMap[seg.color] ? colorMap[seg.color].text : 'text-neutral-300',
|
|
225
|
+
)}>
|
|
226
|
+
{seg.label}
|
|
227
|
+
</span>
|
|
228
|
+
</span>
|
|
229
|
+
))}
|
|
230
|
+
</button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="px-3 py-1.5 border-t border-neutral-700/50">
|
|
234
|
+
<p className="text-xs text-neutral-600">Click to navigate</p>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
<Divider size={size} />
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{segments.map((segment, index) => {
|
|
244
|
+
const isLast = index === segments.length - 1
|
|
245
|
+
const isClickable = !isLast && !!segment.onClick
|
|
246
|
+
const colors = segment.color && colorMap[segment.color] ? colorMap[segment.color] : null
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div key={segment.id} className="flex items-center gap-1">
|
|
250
|
+
{index > 0 && <SegmentSeparator type={separator} size={size} />}
|
|
251
|
+
{isClickable ? (
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
onClick={segment.onClick}
|
|
255
|
+
className={cn(
|
|
256
|
+
'flex items-center gap-1.5 px-2 py-0.5 rounded-md transition-colors cursor-pointer',
|
|
257
|
+
s.text,
|
|
258
|
+
'font-medium hover:text-white',
|
|
259
|
+
colors ? [colors.text, `hover:${colors.bg}`] : ['text-neutral-300', 'hover:bg-neutral-700/50'],
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
{segment.icon && <SegmentIcon icon={segment.icon} color={segment.color} size={size} />}
|
|
263
|
+
<span className="truncate max-w-[200px]">{segment.label}</span>
|
|
264
|
+
</button>
|
|
265
|
+
) : (
|
|
266
|
+
<div
|
|
267
|
+
className={cn(
|
|
268
|
+
'flex items-center gap-1.5 px-2 py-0.5 rounded-md',
|
|
269
|
+
s.text,
|
|
270
|
+
isLast
|
|
271
|
+
? ['font-medium bg-neutral-700/50', colors ? colors.text : 'text-white']
|
|
272
|
+
: ['font-medium', colors ? colors.text : 'text-neutral-300'],
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
{segment.icon && <SegmentIcon icon={segment.icon} color={segment.color} size={size} />}
|
|
276
|
+
<span className="truncate max-w-[200px]">{segment.label}</span>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
)
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
</nav>
|
|
284
|
+
)
|
|
285
|
+
}
|