mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-08 06:30:04 +00:00
feat: add Storybook stories for WidgetInputText and WidgetTextarea (#9398)
Add Storybook stories for WidgetInputText and WidgetTextarea, aligned with the Figma Design System spec. Task: COM-15821 ## Summary Add comprehensive Storybook stories for text widget components and implement missing Figma design system variants for WidgetInputText. ## Changes - **WidgetInputText component enhancements**: - Add `size` prop (`medium` | `large`) matching Figma size variants (32px / 40px) - Add `invalid` prop with destructive border style per Figma Invalid state - Add `loading` prop showing spinning loader icon per Figma Status state - Add hover background (`bg-component-node-widget-background-hovered`) - Fix `readonly` not being applied from `widget.options.read_only` - **WidgetTextarea component fixes**: - Show copy button on hover for all states (not just read-only) - Apply `text-component-node-foreground` token to copy icon - Add hover background to wrapper - **Storybook stories**: - WidgetInputText: Default, Disabled, Invalid, Status, WithPlaceholder, WithLabel stories - WidgetTextarea: Default, Disabled, HiddenLabel, WithPlaceholder stories - Interactive controls for size, readOnly, disabled, invalid, loading ## Review Focus - Figma alignment: size/invalid/loading/status variants for WidgetInputText - Copy icon color token (`text-component-node-foreground`) for light/dark theme support - `layoutWidget` computed pattern to merge `borderStyle` with invalid state ## Screenshots (if applicable) <!-- Add screenshots or video recording to help explain your changes --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputText from './WidgetInputText.vue'
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetInputText> {
|
||||
readOnly: boolean
|
||||
disabled: boolean
|
||||
invalid: boolean
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/WidgetInputText',
|
||||
component: WidgetInputText,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['medium', 'large']
|
||||
},
|
||||
readOnly: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
invalid: { control: 'boolean' },
|
||||
loading: { control: 'boolean' },
|
||||
placeholder: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
size: 'medium',
|
||||
readOnly: false,
|
||||
disabled: false,
|
||||
invalid: false,
|
||||
loading: false,
|
||||
placeholder: ''
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="grid grid-cols-[auto_1fr] gap-1 w-80"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { WidgetInputText },
|
||||
setup() {
|
||||
const { size, readOnly, disabled, invalid, loading, placeholder } =
|
||||
toRefs(args)
|
||||
const value = ref('Hello world')
|
||||
const widget = computed<SimplifiedWidget<string>>(() => ({
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: {
|
||||
read_only: readOnly.value,
|
||||
disabled: disabled.value,
|
||||
...(placeholder.value ? { placeholder: placeholder.value } : {})
|
||||
}
|
||||
}))
|
||||
return { value, widget, size, invalid, loading }
|
||||
},
|
||||
template:
|
||||
'<WidgetInputText :widget="widget" :size="size" :invalid="invalid" :loading="loading" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { WidgetInputText },
|
||||
setup() {
|
||||
const { disabled } = toRefs(args)
|
||||
const value = ref('This text is disabled')
|
||||
const widget = computed<SimplifiedWidget<string>>(() => ({
|
||||
name: 'locked',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { disabled: disabled.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetInputText :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Invalid: Story = {
|
||||
args: { invalid: true },
|
||||
render: (args) => ({
|
||||
components: { WidgetInputText },
|
||||
setup() {
|
||||
const { invalid } = toRefs(args)
|
||||
const value = ref('Invalid input value')
|
||||
const widget: SimplifiedWidget<string> = {
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
value: ''
|
||||
}
|
||||
return { value, widget, invalid }
|
||||
},
|
||||
template:
|
||||
'<WidgetInputText :widget="widget" :invalid="invalid" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Status: Story = {
|
||||
args: { loading: true },
|
||||
render: (args) => ({
|
||||
components: { WidgetInputText },
|
||||
setup() {
|
||||
const { loading } = toRefs(args)
|
||||
const value = ref('Loading...')
|
||||
const widget: SimplifiedWidget<string> = {
|
||||
name: 'text',
|
||||
type: 'STRING',
|
||||
value: ''
|
||||
}
|
||||
return { value, widget, loading }
|
||||
},
|
||||
template:
|
||||
'<WidgetInputText :widget="widget" :loading="loading" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
args: { placeholder: 'Enter your prompt here...' },
|
||||
render: (args) => ({
|
||||
components: { WidgetInputText },
|
||||
setup() {
|
||||
const { placeholder } = toRefs(args)
|
||||
const value = ref('')
|
||||
const widget = computed<SimplifiedWidget<string>>(() => ({
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: {
|
||||
...(placeholder.value ? { placeholder: placeholder.value } : {})
|
||||
}
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetInputText :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetInputText },
|
||||
setup() {
|
||||
const value = ref('Some value')
|
||||
const widget: SimplifiedWidget<string> = {
|
||||
name: 'seed',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Random Seed'
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetInputText :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<InputText
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{ root: 'truncate min-w-[4ch]' }"
|
||||
/>
|
||||
<WidgetLayoutField :widget="layoutWidget">
|
||||
<div class="relative">
|
||||
<Loader
|
||||
v-if="loading"
|
||||
size="sm"
|
||||
class="absolute left-3 top-1/2 z-10 -translate-y-1/2 text-component-node-foreground"
|
||||
/>
|
||||
<InputText
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'w-full px-4 hover:bg-component-node-widget-background-hovered',
|
||||
size === 'large' ? 'text-sm py-3' : 'text-xs py-2',
|
||||
loading && 'pl-9'
|
||||
)
|
||||
"
|
||||
:aria-label="widget.name"
|
||||
:readonly="isReadOnly"
|
||||
size="small"
|
||||
:pt="{ root: 'truncate min-w-[4ch]' }"
|
||||
/>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -15,6 +30,7 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Loader from '@/components/common/Loader.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
@@ -25,13 +41,34 @@ import {
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const {
|
||||
widget,
|
||||
size = 'medium',
|
||||
invalid = false,
|
||||
loading = false
|
||||
} = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
size?: 'medium' | 'large'
|
||||
invalid?: boolean
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const isReadOnly = computed(() =>
|
||||
Boolean(widget.options?.read_only || widget.options?.disabled)
|
||||
)
|
||||
|
||||
const layoutWidget = computed(() => ({
|
||||
name: widget.name,
|
||||
label: widget.label,
|
||||
borderStyle: cn(
|
||||
widget.borderStyle,
|
||||
invalid && 'border border-destructive-background'
|
||||
)
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, provide, ref, toRefs } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import WidgetTextarea from './WidgetTextarea.vue'
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetTextarea> {
|
||||
readOnly: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/WidgetTextarea',
|
||||
component: WidgetTextarea,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: [
|
||||
'Multi-line text input with optional label.',
|
||||
'Captures wheel, pointer, and context menu events to prevent canvas interference.',
|
||||
'Shows a copy-to-clipboard button on hover.'
|
||||
].join(' ')
|
||||
}
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
readOnly: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' }
|
||||
},
|
||||
args: {
|
||||
readOnly: false,
|
||||
disabled: false
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-80 h-40"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { WidgetTextarea },
|
||||
setup() {
|
||||
const { readOnly, disabled } = toRefs(args)
|
||||
const value = ref('A multi-line text area for entering prompts.')
|
||||
const widget = computed<SimplifiedWidget<string>>(() => ({
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Prompt',
|
||||
options: {
|
||||
read_only: readOnly.value,
|
||||
disabled: disabled.value
|
||||
}
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template:
|
||||
'<WidgetTextarea :widget="widget" v-model="value" placeholder="Enter prompt..." />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { disabled: true },
|
||||
render: (args) => ({
|
||||
components: { WidgetTextarea },
|
||||
setup() {
|
||||
const { disabled } = toRefs(args)
|
||||
const value = ref('This textarea is disabled via widget options.')
|
||||
const widget = computed<SimplifiedWidget<string>>(() => ({
|
||||
name: 'locked',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Locked Field',
|
||||
options: { disabled: disabled.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetTextarea :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const HiddenLabel: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetTextarea },
|
||||
setup() {
|
||||
provide(HideLayoutFieldKey, true)
|
||||
const value = ref('Label is hidden via HideLayoutFieldKey injection.')
|
||||
const widget: SimplifiedWidget<string> = {
|
||||
name: 'notes',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Notes'
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetTextarea :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPlaceholder: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetTextarea },
|
||||
setup() {
|
||||
const value = ref('')
|
||||
const widget: SimplifiedWidget<string> = {
|
||||
name: 'negative',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Negative Prompt'
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template:
|
||||
'<WidgetTextarea :widget="widget" v-model="value" placeholder="Describe what to avoid..." />'
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
'group relative rounded-lg hover:bg-component-node-widget-background-hovered focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
@@ -37,13 +37,13 @@
|
||||
v-if="isReadOnly"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="invisible absolute top-1.5 right-1.5 z-10 hover:bg-base-foreground/10 group-hover:visible"
|
||||
class="invisible absolute top-1.5 right-1.5 z-10 hover:bg-base-foreground/10 group-hover:visible group-focus-within:visible"
|
||||
:title="$t('g.copyToClipboard')"
|
||||
:aria-label="$t('g.copyToClipboard')"
|
||||
@click="handleCopy"
|
||||
@pointerdown.capture.stop
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
<i class="icon-[lucide--copy] size-4 text-component-node-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -81,8 +81,8 @@ const filteredProps = computed(() =>
|
||||
const displayName = computed(() => widget.label || widget.name)
|
||||
const id = useId()
|
||||
|
||||
const isReadOnly = computed(
|
||||
() => widget.options?.read_only ?? widget.options?.disabled ?? false
|
||||
const isReadOnly = computed(() =>
|
||||
Boolean(widget.options?.read_only || widget.options?.disabled)
|
||||
)
|
||||
|
||||
function handleCopy() {
|
||||
|
||||
@@ -17,7 +17,8 @@ export const STANDARD_EXCLUDED_PROPS = [
|
||||
export const INPUT_EXCLUDED_PROPS = [
|
||||
...STANDARD_EXCLUDED_PROPS,
|
||||
'inputClass',
|
||||
'inputStyle'
|
||||
'inputStyle',
|
||||
'read_only'
|
||||
] as const
|
||||
|
||||
export const PANEL_EXCLUDED_PROPS = [
|
||||
|
||||
Reference in New Issue
Block a user