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:
Dante
2026-03-06 08:42:53 +09:00
committed by GitHub
parent b2915ed42a
commit 5843dced84
5 changed files with 357 additions and 17 deletions

View File

@@ -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" />'
})
}

View File

@@ -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>

View File

@@ -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..." />'
})
}

View File

@@ -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() {

View File

@@ -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 = [