From 5843dced8412567ea995391608da04de7f2acb18 Mon Sep 17 00:00:00 2001 From: Dante Date: Fri, 6 Mar 2026 08:42:53 +0900 Subject: [PATCH] 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) --------- Co-authored-by: Claude Opus 4.6 --- .../components/WidgetInputText.stories.ts | 172 ++++++++++++++++++ .../widgets/components/WidgetInputText.vue | 59 ++++-- .../components/WidgetTextarea.stories.ts | 130 +++++++++++++ .../widgets/components/WidgetTextarea.vue | 10 +- src/utils/widgetPropFilter.ts | 3 +- 5 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.stories.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.stories.ts diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.stories.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.stories.ts new file mode 100644 index 0000000000..6e15ece0dc --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.stories.ts @@ -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 { + readOnly: boolean + disabled: boolean + invalid: boolean + placeholder: string +} + +const meta: Meta = { + 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: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +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>(() => ({ + 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: + '' + }) +} + +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>(() => ({ + name: 'locked', + type: 'STRING', + value: '', + options: { disabled: disabled.value } + })) + return { value, widget } + }, + template: '' + }) +} + +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 = { + name: 'text', + type: 'STRING', + value: '' + } + return { value, widget, invalid } + }, + template: + '' + }) +} + +export const Status: Story = { + args: { loading: true }, + render: (args) => ({ + components: { WidgetInputText }, + setup() { + const { loading } = toRefs(args) + const value = ref('Loading...') + const widget: SimplifiedWidget = { + name: 'text', + type: 'STRING', + value: '' + } + return { value, widget, loading } + }, + template: + '' + }) +} + +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>(() => ({ + name: 'prompt', + type: 'STRING', + value: '', + options: { + ...(placeholder.value ? { placeholder: placeholder.value } : {}) + } + })) + return { value, widget } + }, + template: '' + }) +} + +export const WithLabel: Story = { + render: () => ({ + components: { WidgetInputText }, + setup() { + const value = ref('Some value') + const widget: SimplifiedWidget = { + name: 'seed', + type: 'STRING', + value: '', + label: 'Random Seed' + } + return { value, widget } + }, + template: '' + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue index eff22f1efc..b105747dd3 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue @@ -1,13 +1,28 @@ @@ -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 + size?: 'medium' | 'large' + invalid?: boolean + loading?: boolean }>() const modelValue = defineModel({ 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' + ) +})) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.stories.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.stories.ts new file mode 100644 index 0000000000..b0e8916396 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.stories.ts @@ -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 { + readOnly: boolean + disabled: boolean +} + +const meta: Meta = { + 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: '
' + }) + ] +} + +export default meta +type Story = StoryObj + +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>(() => ({ + name: 'prompt', + type: 'STRING', + value: '', + label: 'Prompt', + options: { + read_only: readOnly.value, + disabled: disabled.value + } + })) + return { value, widget } + }, + template: + '' + }) +} + +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>(() => ({ + name: 'locked', + type: 'STRING', + value: '', + label: 'Locked Field', + options: { disabled: disabled.value } + })) + return { value, widget } + }, + template: '' + }) +} + +export const HiddenLabel: Story = { + render: () => ({ + components: { WidgetTextarea }, + setup() { + provide(HideLayoutFieldKey, true) + const value = ref('Label is hidden via HideLayoutFieldKey injection.') + const widget: SimplifiedWidget = { + name: 'notes', + type: 'STRING', + value: '', + label: 'Notes' + } + return { value, widget } + }, + template: '' + }) +} + +export const WithPlaceholder: Story = { + render: () => ({ + components: { WidgetTextarea }, + setup() { + const value = ref('') + const widget: SimplifiedWidget = { + name: 'negative', + type: 'STRING', + value: '', + label: 'Negative Prompt' + } + return { value, widget } + }, + template: + '' + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue index dcb42db6cc..8949b2a0ef 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue @@ -2,7 +2,7 @@ @@ -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() { diff --git a/src/utils/widgetPropFilter.ts b/src/utils/widgetPropFilter.ts index d5925fbe67..f66a8e8bd3 100644 --- a/src/utils/widgetPropFilter.ts +++ b/src/utils/widgetPropFilter.ts @@ -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 = [