@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.
@@ -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 P0 fix: ComponentRegistry validation leniency
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
- /** The 9 types currently registered in ComponentRegistry */
13
- const REGISTERED_TYPES: ComponentType[] = [
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('validateAgainstRegistry', () => {
25
- describe('registered types', () => {
26
- it.each(REGISTERED_TYPES)('validates "%s" with valid: true when params are correct', (type) => {
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
- // Build minimal valid params from required fields
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!.schema.required || []
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(REGISTERED_TYPES)('rejects "%s" when required fields are missing', (type) => {
44
- const entry = getComponentEntry(type)
45
- const required = entry!.schema.required || []
46
- if (required.length === 0) return // skip types with no required fields
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
- describe('unregistered types (have renderers, no registry entry)', () => {
57
- it.each(UNREGISTERED_TYPES)('passes "%s" with valid: true and a warning', (type) => {
58
- const result = validateAgainstRegistry(type, { anything: true })
59
- expect(result.valid).toBe(true)
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('registry consistency', () => {
71
- it('has exactly 9 registered types', () => {
72
- expect(ComponentRegistry.size).toBe(9)
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
- it('all registered types are in REGISTERED_TYPES', () => {
76
- for (const [type] of ComponentRegistry) {
77
- expect(REGISTERED_TYPES).toContain(type)
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
  })