@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,133 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Tooltip, type TooltipContent, type TooltipPosition } from './tooltip.tsx'
|
|
3
|
+
|
|
4
|
+
export interface SegmentedToggleOption<T extends string> {
|
|
5
|
+
value: T
|
|
6
|
+
icon: ReactNode
|
|
7
|
+
tooltip: TooltipContent
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SegmentedToggleProps<T extends string> {
|
|
11
|
+
options: SegmentedToggleOption<T>[]
|
|
12
|
+
value: T
|
|
13
|
+
onChange: (value: T) => void
|
|
14
|
+
accentColor?: 'blue' | 'purple' | 'orange' | 'green' | 'pink' | 'amber' | 'emerald' | 'teal' | 'sky'
|
|
15
|
+
/** Visual style: 'filled' (default) has a container background, 'outline' is transparent */
|
|
16
|
+
variant?: 'filled' | 'outline'
|
|
17
|
+
size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
|
|
18
|
+
tooltipPosition?: TooltipPosition
|
|
19
|
+
testId?: string
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
/** Tooltip shown on the entire toggle when disabled */
|
|
22
|
+
disabledTooltip?: TooltipContent
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Button sizes match IconButton dimensions */
|
|
26
|
+
const BUTTON_SIZE_CLASSES = {
|
|
27
|
+
xss: 'w-[18px] h-[18px]',
|
|
28
|
+
xs: 'w-6 h-6',
|
|
29
|
+
sm: 'w-7 h-7',
|
|
30
|
+
md: 'w-8 h-8',
|
|
31
|
+
lg: 'w-9 h-9',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ROUNDING_CLASSES = {
|
|
35
|
+
xss: 'rounded-[3px]',
|
|
36
|
+
xs: 'rounded-[5px]',
|
|
37
|
+
sm: 'rounded-md',
|
|
38
|
+
md: 'rounded-md',
|
|
39
|
+
lg: 'rounded-md',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ACTIVE_COLORS: Record<string, string> = {
|
|
43
|
+
blue: 'bg-blue-500/20 text-blue-300 border-blue-500/40',
|
|
44
|
+
purple: 'bg-purple-500/20 text-purple-300 border-purple-500/40',
|
|
45
|
+
orange: 'bg-orange-500/20 text-orange-300 border-orange-500/40',
|
|
46
|
+
green: 'bg-green-500/20 text-green-300 border-green-500/40',
|
|
47
|
+
pink: 'bg-pink-500/20 text-pink-300 border-pink-500/40',
|
|
48
|
+
amber: 'bg-amber-500/20 text-amber-300 border-amber-500/40',
|
|
49
|
+
emerald: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40',
|
|
50
|
+
teal: 'bg-teal-500/20 text-teal-300 border-teal-500/40',
|
|
51
|
+
sky: 'bg-sky-500/20 text-sky-300 border-sky-500/40',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const HOVER_COLORS: Record<string, string> = {
|
|
55
|
+
blue: 'hover:bg-blue-500/15 hover:text-blue-300',
|
|
56
|
+
purple: 'hover:bg-purple-500/15 hover:text-purple-300',
|
|
57
|
+
orange: 'hover:bg-orange-500/15 hover:text-orange-300',
|
|
58
|
+
green: 'hover:bg-green-500/15 hover:text-green-300',
|
|
59
|
+
pink: 'hover:bg-pink-500/15 hover:text-pink-300',
|
|
60
|
+
amber: 'hover:bg-amber-500/15 hover:text-amber-300',
|
|
61
|
+
emerald: 'hover:bg-emerald-500/15 hover:text-emerald-300',
|
|
62
|
+
teal: 'hover:bg-teal-500/15 hover:text-teal-300',
|
|
63
|
+
sky: 'hover:bg-sky-500/15 hover:text-sky-300',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const OUTLINE_CONTAINER: Record<string, string> = {
|
|
67
|
+
blue: 'flex items-center border border-blue-500/50 rounded-md',
|
|
68
|
+
purple: 'flex items-center border border-purple-500/50 rounded-md',
|
|
69
|
+
orange: 'flex items-center border border-orange-500/50 rounded-md',
|
|
70
|
+
green: 'flex items-center border border-green-500/50 rounded-md',
|
|
71
|
+
pink: 'flex items-center border border-pink-500/50 rounded-md',
|
|
72
|
+
amber: 'flex items-center border border-amber-500/50 rounded-md',
|
|
73
|
+
emerald: 'flex items-center border border-emerald-500/50 rounded-md',
|
|
74
|
+
teal: 'flex items-center border border-teal-500/50 rounded-md',
|
|
75
|
+
sky: 'flex items-center border border-sky-500/50 rounded-md',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function SegmentedToggle<T extends string>({
|
|
79
|
+
options,
|
|
80
|
+
value,
|
|
81
|
+
onChange,
|
|
82
|
+
accentColor = 'blue',
|
|
83
|
+
variant = 'outline',
|
|
84
|
+
size = 'sm',
|
|
85
|
+
tooltipPosition = 'bottom',
|
|
86
|
+
testId,
|
|
87
|
+
disabled = false,
|
|
88
|
+
disabledTooltip,
|
|
89
|
+
}: SegmentedToggleProps<T>) {
|
|
90
|
+
const isOutline = variant === 'outline'
|
|
91
|
+
const containerClasses = isOutline
|
|
92
|
+
? OUTLINE_CONTAINER[accentColor] || OUTLINE_CONTAINER.blue
|
|
93
|
+
: 'flex items-center bg-neutral-800/50 border border-neutral-700 rounded-md'
|
|
94
|
+
|
|
95
|
+
const toggle = (
|
|
96
|
+
<div
|
|
97
|
+
className={`${containerClasses} ${disabled ? 'opacity-40 pointer-events-none' : ''}`}
|
|
98
|
+
data-testid={testId}
|
|
99
|
+
>
|
|
100
|
+
{options.map((option, i) => {
|
|
101
|
+
const isActive = value === option.value
|
|
102
|
+
const isFirst = i === 0
|
|
103
|
+
const isLast = i === options.length - 1
|
|
104
|
+
const rounding = isFirst && isLast ? ROUNDING_CLASSES[size] : isFirst ? `rounded-l-[5px]` : isLast ? `rounded-r-[5px]` : ''
|
|
105
|
+
return (
|
|
106
|
+
<Tooltip key={option.value} content={option.tooltip} position={tooltipPosition}>
|
|
107
|
+
<button
|
|
108
|
+
onClick={() => onChange(option.value)}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
className={`flex items-center justify-center ${BUTTON_SIZE_CLASSES[size]} ${rounding} text-xs font-medium transition-all cursor-pointer ${
|
|
111
|
+
isActive
|
|
112
|
+
? ACTIVE_COLORS[accentColor] || ACTIVE_COLORS.blue
|
|
113
|
+
: `text-neutral-400 ${HOVER_COLORS[accentColor] || HOVER_COLORS.blue}`
|
|
114
|
+
}`}
|
|
115
|
+
>
|
|
116
|
+
{option.icon}
|
|
117
|
+
</button>
|
|
118
|
+
</Tooltip>
|
|
119
|
+
)
|
|
120
|
+
})}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if (disabled && disabledTooltip) {
|
|
125
|
+
return (
|
|
126
|
+
<Tooltip content={disabledTooltip} position={tooltipPosition}>
|
|
127
|
+
<div className="pointer-events-auto">{toggle}</div>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return toggle
|
|
133
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { ChevronDown, Check } from 'lucide-react'
|
|
4
|
+
import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
|
|
5
|
+
import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
|
|
6
|
+
|
|
7
|
+
export interface SelectOption<T extends string | number = string> {
|
|
8
|
+
value: T
|
|
9
|
+
label: string
|
|
10
|
+
icon?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SelectProps<T extends string | number = string> {
|
|
14
|
+
value: T
|
|
15
|
+
options: SelectOption<T>[]
|
|
16
|
+
onChange: (value: T) => void
|
|
17
|
+
placeholder?: string
|
|
18
|
+
variant?: 'filled' | 'outline'
|
|
19
|
+
color?: FormColor
|
|
20
|
+
size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
|
|
21
|
+
align?: 'left' | 'right'
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const VARIANT_CLASSES = {
|
|
27
|
+
filled: { bg: 'bg-neutral-800', hoverBg: 'hover:bg-neutral-700', menuBg: 'bg-neutral-800' },
|
|
28
|
+
outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-800', menuBg: 'bg-neutral-800/90 backdrop-blur-sm' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SIZE_CLASSES = {
|
|
32
|
+
xss: 'h-[18px] px-1.5 text-[10px]',
|
|
33
|
+
xs: 'h-6 px-2 text-xs',
|
|
34
|
+
sm: 'h-7 px-2 text-xs',
|
|
35
|
+
md: 'h-8 px-3 text-sm',
|
|
36
|
+
lg: 'h-9 px-3 text-sm',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Select<T extends string | number = string>({
|
|
40
|
+
value,
|
|
41
|
+
options,
|
|
42
|
+
onChange,
|
|
43
|
+
placeholder = 'Select...',
|
|
44
|
+
variant = 'outline',
|
|
45
|
+
color = 'blue',
|
|
46
|
+
size = 'sm',
|
|
47
|
+
align = 'left',
|
|
48
|
+
disabled = false,
|
|
49
|
+
className = '',
|
|
50
|
+
}: SelectProps<T>) {
|
|
51
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
52
|
+
const [highlightIdx, setHighlightIdx] = useState(-1)
|
|
53
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
54
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
55
|
+
const menuRef = useDropdownMaxHeight<HTMLDivElement>(isOpen)
|
|
56
|
+
const [menuPos, setMenuPos] = useState<{ top: number; left: number; minWidth: number } | null>(null)
|
|
57
|
+
|
|
58
|
+
const updateMenuPos = useCallback(() => {
|
|
59
|
+
if (!buttonRef.current) return
|
|
60
|
+
const rect = buttonRef.current.getBoundingClientRect()
|
|
61
|
+
const left = align === 'right' ? rect.right : rect.left
|
|
62
|
+
setMenuPos({ top: rect.bottom + 4, left, minWidth: Math.max(rect.width, 140) })
|
|
63
|
+
}, [align])
|
|
64
|
+
|
|
65
|
+
const open = useCallback(() => {
|
|
66
|
+
setHighlightIdx(options.findIndex((o) => o.value === value))
|
|
67
|
+
updateMenuPos()
|
|
68
|
+
setIsOpen(true)
|
|
69
|
+
}, [options, value, updateMenuPos])
|
|
70
|
+
|
|
71
|
+
const close = useCallback(() => {
|
|
72
|
+
setHighlightIdx(-1)
|
|
73
|
+
setIsOpen(false)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
// Close on click outside both the trigger and the portal menu
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!isOpen) return
|
|
79
|
+
const handleClick = (event: MouseEvent) => {
|
|
80
|
+
const target = event.target as Node
|
|
81
|
+
if (ref.current?.contains(target)) return
|
|
82
|
+
if (menuRef.current?.contains(target)) return
|
|
83
|
+
close()
|
|
84
|
+
}
|
|
85
|
+
document.addEventListener('mousedown', handleClick)
|
|
86
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
87
|
+
}, [isOpen, close])
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (highlightIdx >= 0 && menuRef.current) {
|
|
91
|
+
menuRef.current.querySelector<HTMLElement>(`[data-idx="${highlightIdx}"]`)?.scrollIntoView({ block: 'nearest' })
|
|
92
|
+
}
|
|
93
|
+
}, [highlightIdx, menuRef])
|
|
94
|
+
|
|
95
|
+
const v = VARIANT_CLASSES[variant]
|
|
96
|
+
const s = SIZE_CLASSES[size]
|
|
97
|
+
const selectedOption = options.find((o) => o.value === value)
|
|
98
|
+
|
|
99
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
100
|
+
if (!isOpen) return
|
|
101
|
+
if (e.key === 'ArrowDown') {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
setHighlightIdx((i) => Math.min(i + 1, options.length - 1))
|
|
104
|
+
} else if (e.key === 'ArrowUp') {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
setHighlightIdx((i) => Math.max(i - 1, 0))
|
|
107
|
+
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
onChange(options[highlightIdx].value)
|
|
110
|
+
close()
|
|
111
|
+
} else if (e.key === 'Escape') {
|
|
112
|
+
e.preventDefault()
|
|
113
|
+
close()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className={`inline-flex ${className}`} ref={ref} onKeyDown={handleKeyDown}>
|
|
119
|
+
<button
|
|
120
|
+
ref={buttonRef}
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => !disabled && (isOpen ? close() : open())}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
className={`flex items-center gap-1.5 min-w-0 rounded-lg border ${v.bg} ${FORM_COLORS[color].border} text-neutral-200 focus:outline-none ${FORM_COLORS[color].focus} transition-colors ${
|
|
125
|
+
disabled ? 'opacity-50 cursor-not-allowed' : `cursor-pointer ${FORM_COLORS[color].hover}`
|
|
126
|
+
} ${s}`}
|
|
127
|
+
>
|
|
128
|
+
{selectedOption?.icon}
|
|
129
|
+
<span className={`whitespace-nowrap ${selectedOption ? '' : 'text-neutral-500'}`}>
|
|
130
|
+
{selectedOption?.label ?? placeholder}
|
|
131
|
+
</span>
|
|
132
|
+
<ChevronDown className={`w-3 h-3 ml-auto text-neutral-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
|
|
133
|
+
</button>
|
|
134
|
+
{isOpen && menuPos && createPortal(
|
|
135
|
+
<div
|
|
136
|
+
ref={menuRef}
|
|
137
|
+
className={`fixed z-[9999] whitespace-nowrap ${v.menuBg} border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}
|
|
138
|
+
style={{
|
|
139
|
+
top: menuPos.top,
|
|
140
|
+
left: align === 'right' ? undefined : menuPos.left,
|
|
141
|
+
right: align === 'right' ? window.innerWidth - menuPos.left : undefined,
|
|
142
|
+
minWidth: menuPos.minWidth,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{options.map((opt, idx) => {
|
|
146
|
+
const isHighlighted = highlightIdx === idx
|
|
147
|
+
const isSelected = value === opt.value
|
|
148
|
+
return (
|
|
149
|
+
<button
|
|
150
|
+
key={String(opt.value)}
|
|
151
|
+
type="button"
|
|
152
|
+
data-idx={idx}
|
|
153
|
+
onClick={() => { onChange(opt.value); close() }}
|
|
154
|
+
onPointerEnter={() => setHighlightIdx(idx)}
|
|
155
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
|
|
156
|
+
isHighlighted
|
|
157
|
+
? 'bg-neutral-600 text-neutral-200'
|
|
158
|
+
: isSelected ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
|
|
159
|
+
}`}
|
|
160
|
+
>
|
|
161
|
+
<Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[color].accent : 'invisible'}`} />
|
|
162
|
+
{opt.icon}
|
|
163
|
+
<span>{opt.label}</span>
|
|
164
|
+
</button>
|
|
165
|
+
)
|
|
166
|
+
})}
|
|
167
|
+
</div>,
|
|
168
|
+
document.body,
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Check, Settings, Code, Folder, File, Terminal, Globe, Database, Cloud,
|
|
3
|
+
Sparkles, Zap, Shield, ShieldCheck, Wand2, Star, Heart, Bell,
|
|
4
|
+
Search, Filter, Eye, Lock, User, Users, Image, Tag, Pin, Mail,
|
|
5
|
+
Send, Bookmark, Play, Pause, Bot, Plug, Puzzle, Webhook, Scan,
|
|
6
|
+
} from 'lucide-react'
|
|
7
|
+
import type { LucideIcon } from 'lucide-react'
|
|
8
|
+
import type { IconName } from './icon-button.tsx'
|
|
9
|
+
import { ConfirmBadge, type ConfirmBadgeColor } from './confirm-badge.tsx'
|
|
10
|
+
import { cn } from '../lib/cn.ts'
|
|
11
|
+
import { AiToolIcon, AI_TOOL_NAMES, type AiToolKey } from '../lib/ai-tools.tsx'
|
|
12
|
+
|
|
13
|
+
const iconMap: Partial<Record<IconName, LucideIcon>> = {
|
|
14
|
+
'check': Check,
|
|
15
|
+
'settings': Settings,
|
|
16
|
+
'code': Code,
|
|
17
|
+
'folder': Folder,
|
|
18
|
+
'file': File,
|
|
19
|
+
'terminal': Terminal,
|
|
20
|
+
'globe': Globe,
|
|
21
|
+
'database': Database,
|
|
22
|
+
'cloud': Cloud,
|
|
23
|
+
'sparkles': Sparkles,
|
|
24
|
+
'zap': Zap,
|
|
25
|
+
'shield': Shield,
|
|
26
|
+
'shield-check': ShieldCheck,
|
|
27
|
+
'wand': Wand2,
|
|
28
|
+
'star': Star,
|
|
29
|
+
'heart': Heart,
|
|
30
|
+
'bell': Bell,
|
|
31
|
+
'search': Search,
|
|
32
|
+
'filter': Filter,
|
|
33
|
+
'eye': Eye,
|
|
34
|
+
'lock': Lock,
|
|
35
|
+
'user': User,
|
|
36
|
+
'users': Users,
|
|
37
|
+
'image': Image,
|
|
38
|
+
'tag': Tag,
|
|
39
|
+
'pin': Pin,
|
|
40
|
+
'mail': Mail,
|
|
41
|
+
'send': Send,
|
|
42
|
+
'bookmark': Bookmark,
|
|
43
|
+
'play': Play,
|
|
44
|
+
'pause': Pause,
|
|
45
|
+
'bot': Bot,
|
|
46
|
+
'plug': Plug,
|
|
47
|
+
'puzzle': Puzzle,
|
|
48
|
+
'webhook': Webhook,
|
|
49
|
+
'scan': Scan,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ── Preset logos (shared AiToolIcon) ─────────────────────── */
|
|
53
|
+
|
|
54
|
+
type IconProps = { className?: string; style?: React.CSSProperties }
|
|
55
|
+
|
|
56
|
+
function makeToolLogo(toolKey: AiToolKey): React.ComponentType<IconProps> {
|
|
57
|
+
function ToolLogo({ className, style }: IconProps) {
|
|
58
|
+
return <AiToolIcon tool={toolKey} className={className} style={style} />
|
|
59
|
+
}
|
|
60
|
+
return ToolLogo
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ── Preset types & data ──────────────────────────────────── */
|
|
64
|
+
|
|
65
|
+
export type CodingToolId = 'claude-code' | 'github-copilot' | 'codex-cli' | 'gemini-cli' | 'opencode'
|
|
66
|
+
|
|
67
|
+
export type CodingToolPresetConfig = CodingToolId | {
|
|
68
|
+
id: CodingToolId
|
|
69
|
+
description?: string
|
|
70
|
+
disabled?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const CODING_TOOL_PRESETS: Record<CodingToolId, { name: string; description: string; color: string; badgeColor: ConfirmBadgeColor; Logo: React.ComponentType<IconProps> }> = {
|
|
74
|
+
'claude-code': { name: AI_TOOL_NAMES.claude, description: 'Security & architecture focus', color: '#a78bfa', badgeColor: 'violet', Logo: makeToolLogo('claude') },
|
|
75
|
+
'github-copilot': { name: AI_TOOL_NAMES.copilot, description: 'Code quality & patterns', color: '#f97316', badgeColor: 'orange', Logo: makeToolLogo('copilot') },
|
|
76
|
+
'codex-cli': { name: AI_TOOL_NAMES.codex, description: 'Performance & optimization', color: '#60a5fa', badgeColor: 'blue', Logo: makeToolLogo('codex') },
|
|
77
|
+
'gemini-cli': { name: AI_TOOL_NAMES.gemini, description: 'Best practices & docs', color: '#22d3ee', badgeColor: 'cyan', Logo: makeToolLogo('gemini') },
|
|
78
|
+
'opencode': { name: AI_TOOL_NAMES.opencode, description: 'Provider-agnostic AI coding agent', color: '#a1a1aa', badgeColor: 'neutral', Logo: makeToolLogo('opencode') },
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolvePresets(presets: CodingToolPresetConfig[]): SelectionCardItem[] {
|
|
82
|
+
const result: SelectionCardItem[] = []
|
|
83
|
+
for (const p of presets) {
|
|
84
|
+
const id = typeof p === 'string' ? p : p.id
|
|
85
|
+
const data = CODING_TOOL_PRESETS[id]
|
|
86
|
+
if (!data) continue
|
|
87
|
+
const item: SelectionCardItem = {
|
|
88
|
+
id,
|
|
89
|
+
IconComponent: data.Logo,
|
|
90
|
+
name: data.name,
|
|
91
|
+
description: data.description,
|
|
92
|
+
color: data.color,
|
|
93
|
+
badgeColor: data.badgeColor,
|
|
94
|
+
}
|
|
95
|
+
if (typeof p === 'object') {
|
|
96
|
+
if (p.description !== undefined) item.description = p.description
|
|
97
|
+
if (p.disabled !== undefined) item.disabled = p.disabled
|
|
98
|
+
}
|
|
99
|
+
result.push(item)
|
|
100
|
+
}
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── Public types ─────────────────────────────────────────── */
|
|
105
|
+
|
|
106
|
+
export interface SelectionCardItem {
|
|
107
|
+
id: string
|
|
108
|
+
icon?: IconName
|
|
109
|
+
/** Custom SVG icon component. Receives className and style. Takes precedence over `icon`. */
|
|
110
|
+
IconComponent?: React.ComponentType<IconProps>
|
|
111
|
+
name: string
|
|
112
|
+
description?: string
|
|
113
|
+
/** Accent color for selected state border/bg. CSS color string like '#a78bfa' */
|
|
114
|
+
color?: string
|
|
115
|
+
/** Named color for the confirm badge. Defaults to 'blue'. */
|
|
116
|
+
badgeColor?: ConfirmBadgeColor
|
|
117
|
+
disabled?: boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface SelectionGridProps {
|
|
121
|
+
/** Custom items with manual icon/name/color configuration */
|
|
122
|
+
items?: SelectionCardItem[]
|
|
123
|
+
/** Built-in coding tool presets. Pass tool IDs or config objects. Ignored when `items` is provided. */
|
|
124
|
+
presets?: CodingToolPresetConfig[]
|
|
125
|
+
selectedIds: string[]
|
|
126
|
+
onSelect: (ids: string[]) => void
|
|
127
|
+
/** Single selection or multiple */
|
|
128
|
+
mode?: 'single' | 'multiple'
|
|
129
|
+
/** Grid (icon-top cards) or list (icon-left rows) */
|
|
130
|
+
layout?: 'grid' | 'list'
|
|
131
|
+
/** Number of columns for grid layout (auto if not set) */
|
|
132
|
+
columns?: number
|
|
133
|
+
className?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Helpers ───────────────────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
const DEFAULT_COLOR = 'blue-400'
|
|
139
|
+
|
|
140
|
+
function resolveColor(color?: string): string {
|
|
141
|
+
return color || DEFAULT_COLOR
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function autoColumns(itemCount: number): number {
|
|
145
|
+
if (itemCount <= 2) return itemCount
|
|
146
|
+
if (itemCount <= 4) return itemCount
|
|
147
|
+
return 5
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── Component ─────────────────────────────────────────────── */
|
|
151
|
+
|
|
152
|
+
export function SelectionGrid({
|
|
153
|
+
items: itemsProp,
|
|
154
|
+
presets,
|
|
155
|
+
selectedIds,
|
|
156
|
+
onSelect,
|
|
157
|
+
mode = 'multiple',
|
|
158
|
+
layout = 'grid',
|
|
159
|
+
columns,
|
|
160
|
+
className,
|
|
161
|
+
}: SelectionGridProps) {
|
|
162
|
+
const items = itemsProp || (presets ? resolvePresets(presets) : [])
|
|
163
|
+
|
|
164
|
+
function handleClick(item: SelectionCardItem) {
|
|
165
|
+
if (item.disabled) return
|
|
166
|
+
|
|
167
|
+
if (mode === 'single') {
|
|
168
|
+
onSelect([item.id])
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (selectedIds.includes(item.id)) {
|
|
173
|
+
onSelect(selectedIds.filter((id) => id !== item.id))
|
|
174
|
+
} else {
|
|
175
|
+
onSelect([...selectedIds, item.id])
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cols = columns || autoColumns(items.length)
|
|
180
|
+
|
|
181
|
+
if (layout === 'list') {
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
className={cn('grid gap-3', className)}
|
|
185
|
+
style={{ gridTemplateColumns: `repeat(${columns || 1}, 1fr)` }}
|
|
186
|
+
>
|
|
187
|
+
{items.map((item) => (
|
|
188
|
+
<ListCard
|
|
189
|
+
key={item.id}
|
|
190
|
+
item={item}
|
|
191
|
+
selected={selectedIds.includes(item.id)}
|
|
192
|
+
onClick={() => handleClick(item)}
|
|
193
|
+
/>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div
|
|
201
|
+
className={cn('grid gap-3', className)}
|
|
202
|
+
style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}
|
|
203
|
+
>
|
|
204
|
+
{items.map((item) => (
|
|
205
|
+
<GridCard
|
|
206
|
+
key={item.id}
|
|
207
|
+
item={item}
|
|
208
|
+
selected={selectedIds.includes(item.id)}
|
|
209
|
+
onClick={() => handleClick(item)}
|
|
210
|
+
/>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ── Card subcomponents ────────────────────────────────────── */
|
|
217
|
+
|
|
218
|
+
interface CardProps {
|
|
219
|
+
item: SelectionCardItem
|
|
220
|
+
selected: boolean
|
|
221
|
+
onClick: () => void
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function GridCard({ item, selected, onClick }: CardProps) {
|
|
225
|
+
const color = resolveColor(item.color)
|
|
226
|
+
const Icon = item.IconComponent || (item.icon ? iconMap[item.icon] : undefined)
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={onClick}
|
|
232
|
+
disabled={item.disabled}
|
|
233
|
+
className={cn(
|
|
234
|
+
'relative p-3 rounded-lg text-center transition-all border-2 flex flex-col items-center',
|
|
235
|
+
item.disabled
|
|
236
|
+
? 'opacity-30 cursor-not-allowed border-neutral-800 bg-black'
|
|
237
|
+
: selected
|
|
238
|
+
? 'cursor-pointer'
|
|
239
|
+
: 'bg-neutral-800/50 border-neutral-700 hover:bg-neutral-800 hover:border-neutral-600 cursor-pointer',
|
|
240
|
+
)}
|
|
241
|
+
style={
|
|
242
|
+
selected && !item.disabled
|
|
243
|
+
? { borderColor: color, backgroundColor: `${color}15` }
|
|
244
|
+
: undefined
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
{selected && !item.disabled && (
|
|
248
|
+
<span className="absolute top-1 right-1">
|
|
249
|
+
<ConfirmBadge color={item.badgeColor || 'blue'} size="xs" />
|
|
250
|
+
</span>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{Icon && (
|
|
254
|
+
<Icon
|
|
255
|
+
className="w-7 h-7 mb-1"
|
|
256
|
+
style={{ color: item.disabled ? undefined : (item.color || '#9ca3af') }}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
<span className="text-xs font-medium text-neutral-200 block">{item.name}</span>
|
|
261
|
+
|
|
262
|
+
{item.description && (
|
|
263
|
+
<span className="text-xs text-neutral-500 block mt-0.5">{item.description}</span>
|
|
264
|
+
)}
|
|
265
|
+
</button>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function ListCard({ item, selected, onClick }: CardProps) {
|
|
270
|
+
const color = resolveColor(item.color)
|
|
271
|
+
const Icon = item.IconComponent || (item.icon ? iconMap[item.icon] : undefined)
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
onClick={onClick}
|
|
277
|
+
disabled={item.disabled}
|
|
278
|
+
className={cn(
|
|
279
|
+
'relative p-3 rounded-lg transition-all border-2 flex items-center gap-3 text-left',
|
|
280
|
+
item.disabled
|
|
281
|
+
? 'opacity-30 cursor-not-allowed border-neutral-800 bg-black'
|
|
282
|
+
: selected
|
|
283
|
+
? 'cursor-pointer'
|
|
284
|
+
: 'bg-neutral-800/50 border-neutral-700 hover:bg-neutral-800 hover:border-neutral-600 cursor-pointer',
|
|
285
|
+
)}
|
|
286
|
+
style={
|
|
287
|
+
selected && !item.disabled
|
|
288
|
+
? { borderColor: color, backgroundColor: `${color}15` }
|
|
289
|
+
: undefined
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
{selected && !item.disabled && (
|
|
293
|
+
<span className="absolute top-1 right-1">
|
|
294
|
+
<ConfirmBadge color={item.badgeColor || 'blue'} size="xs" />
|
|
295
|
+
</span>
|
|
296
|
+
)}
|
|
297
|
+
|
|
298
|
+
{Icon && (
|
|
299
|
+
<Icon
|
|
300
|
+
className="w-5 h-5 flex-shrink-0"
|
|
301
|
+
style={{ color: item.disabled ? undefined : (item.color || '#9ca3af') }}
|
|
302
|
+
/>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
<div className="min-w-0">
|
|
306
|
+
<span className="text-xs font-medium text-neutral-200 block">{item.name}</span>
|
|
307
|
+
{item.description && (
|
|
308
|
+
<span className="text-xs text-neutral-500 block mt-0.5">{item.description}</span>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
</button>
|
|
312
|
+
)
|
|
313
|
+
}
|