fix: replace PrimeVue FloatLabel in WidgetTextarea with CSS-only IFTA label (#9076)

## Summary

Replace PrimeVue `FloatLabel` + `Textarea` in `WidgetTextarea` with a
CSS-only IFTA label and a new shadcn-vue Textarea component, fixing the
label-obscures-content bug.

<img width="965" height="754" alt="image"
src="https://github.com/user-attachments/assets/cab98527-834c-496d-a0ef-942fb21fd862"
/>


## Changes

- **What**: Add `src/components/ui/textarea/Textarea.vue` — thin wrapper
around native `<textarea>` with `cn()` class merging and `defineModel`.
Rewrite `WidgetTextarea.vue` to use a plain `<div>` wrapper with an
absolutely-positioned label and the new Textarea, replacing PrimeVue's
`FloatLabel variant="in"`. Add Storybook stories (Default, Disabled,
WithLabel). Update tests to remove PrimeVue plugin setup.

## Review Focus

- The label uses `absolute left-3 top-1.5 z-10 text-xxs` positioning —
verify it clears textarea content with `pt-5` padding
- `filteredProps` forwards widget options to a native textarea via
`v-bind="restAttrs"` — unknown attrs are silently ignored by the browser

Supersedes #8536

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9076-fix-replace-PrimeVue-FloatLabel-in-WidgetTextarea-with-CSS-only-IFTA-label-30f6d73d3650816fabe5ee30de0c793e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2026-02-21 22:09:56 -08:00
committed by GitHub
parent b41f162607
commit 2639248867
22 changed files with 96 additions and 16 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Textarea from './Textarea.vue'
const meta: Meta<typeof Textarea> = {
title: 'UI/Textarea',
component: Textarea,
tags: ['autodocs']
}
export default meta
type Story = StoryObj<typeof Textarea>
export const Default: Story = {
render: () => ({
components: { Textarea },
setup() {
const value = ref('Hello world')
return { value }
},
template:
'<Textarea v-model="value" placeholder="Type something..." class="max-w-sm" />'
})
}
export const Disabled: Story = {
render: () => ({
components: { Textarea },
template:
'<Textarea model-value="Disabled textarea" disabled class="max-w-sm" />'
})
}
export const WithLabel: Story = {
render: () => ({
components: { Textarea },
setup() {
const value = ref('Content that sits below the label')
return { value }
},
template: `
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
Prompt
</label>
<Textarea
v-model="value"
class="size-full resize-none border-none bg-transparent pt-5 text-xs"
/>
</div>
`
})
}

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restAttrs } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<string | number>()
</script>
<template>
<textarea
v-bind="restAttrs"
v-model="modelValue"
:class="
cn(
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)
"
/>
</template>

View File

@@ -1,6 +1,4 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -28,10 +26,6 @@ function mountComponent(
placeholder?: string
) {
return mount(WidgetTextarea, {
global: {
plugins: [PrimeVue],
components: { Textarea }
},
props: {
widget,
modelValue,

View File

@@ -1,37 +1,45 @@
<template>
<FloatLabel
variant="in"
:unstyled="hideLayoutField"
<div
:class="
cn(
'rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
widget.borderStyle
)
"
>
<label
v-if="!hideLayoutField"
:for="id"
class="pointer-events-none absolute left-3 top-1.5 z-10 text-xxs text-muted-foreground"
>
{{ displayName }}
</label>
<Textarea
v-bind="filteredProps"
:id
v-model="modelValue"
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
:class="
cn(
WidgetInputBaseClass,
'size-full text-xs resize-none',
!hideLayoutField && 'pt-5'
)
"
:placeholder
:readonly="isReadOnly"
fluid
data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@contextmenu.capture.stop
/>
<label v-if="!hideLayoutField" :for="id">{{ displayName }}</label>
</FloatLabel>
</div>
</template>
<script setup lang="ts">
import FloatLabel from 'primevue/floatlabel'
import Textarea from 'primevue/textarea'
import { computed, useId } from 'vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'