@seed-ship/mcp-ui-solid 2.2.4 → 2.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ComponentToolbar.cjs +76 -0
- package/dist/components/ComponentToolbar.cjs.map +1 -0
- package/dist/components/ComponentToolbar.d.ts +47 -0
- package/dist/components/ComponentToolbar.d.ts.map +1 -0
- package/dist/components/ComponentToolbar.js +76 -0
- package/dist/components/ComponentToolbar.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/services/component-registry.cjs +405 -1
- package/dist/services/component-registry.cjs.map +1 -1
- package/dist/services/component-registry.d.ts +40 -0
- package/dist/services/component-registry.d.ts.map +1 -1
- package/dist/services/component-registry.js +406 -2
- package/dist/services/component-registry.js.map +1 -1
- package/dist/services/validation.cjs +12 -1
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +12 -1
- package/dist/services/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ComponentToolbar.tsx +101 -0
- package/src/index.ts +2 -0
- package/src/services/component-registry.test.ts +49 -41
- package/src/services/component-registry.ts +444 -0
- package/src/services/validation.ts +12 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComponentToolbar - Unified toolbar for component actions
|
|
3
|
+
* v2.2.5: Consistent icon set, position, and hover behavior across all components
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Component, For, Show, createSignal } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
/** SVG icon paths for toolbar actions */
|
|
9
|
+
const ICONS = {
|
|
10
|
+
copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
|
|
11
|
+
check: 'M5 13l4 4L19 7',
|
|
12
|
+
download: 'M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
|
13
|
+
expand: 'M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5',
|
|
14
|
+
wordwrap: 'M3 10h10a4 4 0 010 8H9m4 0l-3-3m3 3l-3 3M3 6h18M3 14h4',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export type ToolbarIcon = keyof typeof ICONS
|
|
18
|
+
|
|
19
|
+
export interface ToolbarAction {
|
|
20
|
+
/** Icon to display */
|
|
21
|
+
icon: ToolbarIcon
|
|
22
|
+
/** Tooltip label */
|
|
23
|
+
label: string
|
|
24
|
+
/** Click handler */
|
|
25
|
+
onClick: () => void
|
|
26
|
+
/** Whether to show a success state (green check) after click */
|
|
27
|
+
showFeedback?: boolean
|
|
28
|
+
/** Active/toggled state (e.g. word wrap on) */
|
|
29
|
+
active?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ComponentToolbarProps {
|
|
33
|
+
/** Actions to display */
|
|
34
|
+
actions: ToolbarAction[]
|
|
35
|
+
/** Corner position */
|
|
36
|
+
position?: 'top-right' | 'top-left' | 'bottom-right'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Renders a row of small icon buttons, visible on parent hover (requires parent `group` class).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* <div class="relative group">
|
|
44
|
+
* <MyContent />
|
|
45
|
+
* <ComponentToolbar actions={[
|
|
46
|
+
* { icon: 'copy', label: 'Copy', onClick: handleCopy, showFeedback: true },
|
|
47
|
+
* { icon: 'download', label: 'Download CSV', onClick: handleDownload },
|
|
48
|
+
* ]} />
|
|
49
|
+
* </div>
|
|
50
|
+
*/
|
|
51
|
+
export const ComponentToolbar: Component<ComponentToolbarProps> = (props) => {
|
|
52
|
+
const positionClasses = () => {
|
|
53
|
+
switch (props.position) {
|
|
54
|
+
case 'top-left': return 'top-2 left-2'
|
|
55
|
+
case 'bottom-right': return 'bottom-2 right-2'
|
|
56
|
+
default: return 'top-2 right-2'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div class={`absolute ${positionClasses()} z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity`}>
|
|
62
|
+
<For each={props.actions}>
|
|
63
|
+
{(action) => <ToolbarButton action={action} />}
|
|
64
|
+
</For>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Individual toolbar button with optional feedback state */
|
|
70
|
+
const ToolbarButton: Component<{ action: ToolbarAction }> = (props) => {
|
|
71
|
+
const [showCheck, setShowCheck] = createSignal(false)
|
|
72
|
+
|
|
73
|
+
const handleClick = () => {
|
|
74
|
+
props.action.onClick()
|
|
75
|
+
if (props.action.showFeedback) {
|
|
76
|
+
setShowCheck(true)
|
|
77
|
+
setTimeout(() => setShowCheck(false), 2000)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iconPath = () => showCheck() ? ICONS.check : ICONS[props.action.icon]
|
|
82
|
+
const isActive = () => props.action.active
|
|
83
|
+
const colorClass = () => {
|
|
84
|
+
if (showCheck()) return 'text-green-500'
|
|
85
|
+
if (isActive()) return 'text-blue-500 dark:text-blue-400'
|
|
86
|
+
return 'text-gray-500 dark:text-gray-400'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<button
|
|
91
|
+
onClick={handleClick}
|
|
92
|
+
class={`p-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm`}
|
|
93
|
+
title={props.action.label}
|
|
94
|
+
aria-label={props.action.label}
|
|
95
|
+
>
|
|
96
|
+
<svg class={`w-3 h-3 ${colorClass()}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
97
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPath()} />
|
|
98
|
+
</svg>
|
|
99
|
+
</button>
|
|
100
|
+
)
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export { DraggableGridItem } from './components/DraggableGridItem'
|
|
|
36
36
|
export { ResizeHandle } from './components/ResizeHandle'
|
|
37
37
|
export { EditableUIResourceRenderer } from './components/EditableUIResourceRenderer'
|
|
38
38
|
export { ExpandableWrapper } from './components/ExpandableWrapper'
|
|
39
|
+
export { ComponentToolbar } from './components/ComponentToolbar'
|
|
39
40
|
|
|
40
41
|
// Autocomplete Components
|
|
41
42
|
export { GhostText, GhostTextInput } from './components/GhostText'
|
|
@@ -52,6 +53,7 @@ export type { DraggableGridItemProps } from './components/DraggableGridItem'
|
|
|
52
53
|
export type { ResizeHandleProps as ResizeHandleComponentProps } from './components/ResizeHandle'
|
|
53
54
|
export type { EditableUIResourceRendererProps } from './components/EditableUIResourceRenderer'
|
|
54
55
|
export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
|
|
56
|
+
export type { ComponentToolbarProps, ToolbarAction, ToolbarIcon } from './components/ComponentToolbar'
|
|
55
57
|
export type { GhostTextProps, GhostTextInputProps } from './components/GhostText'
|
|
56
58
|
export type { AutocompleteDropdownProps } from './components/AutocompleteDropdown'
|
|
57
59
|
export type { AutocompleteFormFieldProps, AutocompleteFormFieldParams } from './components/AutocompleteFormField'
|
|
@@ -1,35 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
3
|
-
*
|
|
4
|
-
* Ensures unregistered component types (code, map, form, modal, etc.)
|
|
5
|
-
* pass validation with warnings instead of failing with errors.
|
|
2
|
+
* Tests for ComponentRegistry — all 19 component types registered
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import { describe, it, expect } from 'vitest'
|
|
9
6
|
import { validateAgainstRegistry, getComponentEntry, ComponentRegistry } from './component-registry'
|
|
10
7
|
import type { ComponentType } from '../types'
|
|
11
8
|
|
|
12
|
-
/**
|
|
13
|
-
const
|
|
9
|
+
/** All 19 component types in the registry */
|
|
10
|
+
const ALL_TYPES: ComponentType[] = [
|
|
14
11
|
'chart', 'table', 'metric', 'text', 'grid',
|
|
15
12
|
'action', 'footer', 'carousel', 'artifact',
|
|
16
|
-
]
|
|
17
|
-
|
|
18
|
-
/** The 9 types with renderers but NO registry entry */
|
|
19
|
-
const UNREGISTERED_TYPES: ComponentType[] = [
|
|
20
13
|
'code', 'map', 'form', 'modal', 'action-group',
|
|
21
14
|
'image-gallery', 'video', 'iframe', 'image', 'link',
|
|
22
15
|
]
|
|
23
16
|
|
|
24
|
-
describe('
|
|
25
|
-
describe('
|
|
26
|
-
it
|
|
17
|
+
describe('ComponentRegistry', () => {
|
|
18
|
+
describe('registry completeness', () => {
|
|
19
|
+
it('has exactly 19 registered types', () => {
|
|
20
|
+
expect(ComponentRegistry.size).toBe(19)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it.each(ALL_TYPES)('has registry entry for "%s"', (type) => {
|
|
27
24
|
const entry = getComponentEntry(type)
|
|
28
25
|
expect(entry).toBeDefined()
|
|
26
|
+
expect(entry!.type).toBe(type)
|
|
27
|
+
expect(entry!.name).toBeTruthy()
|
|
28
|
+
expect(entry!.description).toBeTruthy()
|
|
29
|
+
expect(entry!.schema).toBeDefined()
|
|
30
|
+
expect(entry!.examples).toBeDefined()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
describe('validateAgainstRegistry', () => {
|
|
35
|
+
it.each(ALL_TYPES)('validates "%s" with valid: true when params satisfy required fields', (type) => {
|
|
36
|
+
const entry = getComponentEntry(type)!
|
|
31
37
|
const params: Record<string, any> = {}
|
|
32
|
-
const required = entry
|
|
38
|
+
const required = entry.schema.required || []
|
|
33
39
|
for (const key of required) {
|
|
34
40
|
params[key] = 'test-value'
|
|
35
41
|
}
|
|
@@ -37,45 +43,47 @@ describe('validateAgainstRegistry', () => {
|
|
|
37
43
|
const result = validateAgainstRegistry(type, params)
|
|
38
44
|
expect(result.valid).toBe(true)
|
|
39
45
|
expect(result.errors).toBeUndefined()
|
|
40
|
-
expect(result.warnings).toBeUndefined()
|
|
41
46
|
})
|
|
42
47
|
|
|
43
|
-
it.each(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
it.each(
|
|
49
|
+
ALL_TYPES.filter((type) => {
|
|
50
|
+
const entry = getComponentEntry(type)
|
|
51
|
+
return entry && (entry.schema.required || []).length > 0
|
|
52
|
+
})
|
|
53
|
+
)('rejects "%s" when required fields are missing', (type) => {
|
|
48
54
|
const result = validateAgainstRegistry(type, {})
|
|
49
55
|
expect(result.valid).toBe(false)
|
|
50
56
|
expect(result.errors).toBeDefined()
|
|
51
|
-
expect(result.errors!.length).toBeGreaterThan(0)
|
|
52
57
|
expect(result.errors![0]).toContain('Missing required field')
|
|
53
58
|
})
|
|
54
59
|
})
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
expect(result.errors).toBeUndefined()
|
|
61
|
-
expect(result.warnings).toBeDefined()
|
|
62
|
-
expect(result.warnings![0]).toContain(`No registry entry for type: ${type}`)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it.each(UNREGISTERED_TYPES)('getComponentEntry returns undefined for "%s"', (type) => {
|
|
66
|
-
expect(getComponentEntry(type)).toBeUndefined()
|
|
67
|
-
})
|
|
61
|
+
/** Types that have examples defined */
|
|
62
|
+
const TYPES_WITH_EXAMPLES = ALL_TYPES.filter((type) => {
|
|
63
|
+
const entry = getComponentEntry(type)
|
|
64
|
+
return entry && entry.examples.length > 0
|
|
68
65
|
})
|
|
69
66
|
|
|
70
|
-
describe('
|
|
71
|
-
it('
|
|
72
|
-
|
|
67
|
+
describe('example components', () => {
|
|
68
|
+
it.each(TYPES_WITH_EXAMPLES)('"%s" examples have valid structure', (type) => {
|
|
69
|
+
const entry = getComponentEntry(type)!
|
|
70
|
+
for (const example of entry.examples) {
|
|
71
|
+
expect(example.query).toBeTruthy()
|
|
72
|
+
expect(example.component).toBeDefined()
|
|
73
|
+
expect(example.component.id).toBeTruthy()
|
|
74
|
+
expect(example.component.type).toBe(type)
|
|
75
|
+
expect(example.component.position).toBeDefined()
|
|
76
|
+
expect(example.component.params).toBeDefined()
|
|
77
|
+
}
|
|
73
78
|
})
|
|
79
|
+
})
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
describe('warns for truly unknown types', () => {
|
|
82
|
+
it('returns warning for unknown type "foobar"', () => {
|
|
83
|
+
const result = validateAgainstRegistry('foobar' as ComponentType, {})
|
|
84
|
+
expect(result.valid).toBe(true)
|
|
85
|
+
expect(result.warnings).toBeDefined()
|
|
86
|
+
expect(result.warnings![0]).toContain('No registry entry for type: foobar')
|
|
79
87
|
})
|
|
80
88
|
})
|
|
81
89
|
})
|