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>
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 105 KiB |
54
src/components/ui/textarea/Textarea.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
24
src/components/ui/textarea/Textarea.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||