diff --git a/apps/storybook/src/stories/components/inputs/FieldTextarea.stories.js b/apps/storybook/src/stories/components/inputs/FieldTextarea.stories.js index 54959203d..b92678d3e 100644 --- a/apps/storybook/src/stories/components/inputs/FieldTextarea.stories.js +++ b/apps/storybook/src/stories/components/inputs/FieldTextarea.stories.js @@ -1,27 +1,6 @@ -import { ref } from 'vue' - +import Button from '@aziontech/webkit/button' import FieldTextarea from '@aziontech/webkit/field-textarea' - -const CORE_IMPORT = "import FieldTextarea from '@aziontech/webkit/field-textarea'" - -const basicSource = ({ initial = "''", bind = '' } = {}) => - [ - '', - '', - '' - ].join('\n') +import { ref } from 'vue' /** @type {import('@storybook/vue3').Meta} */ const meta = { @@ -29,38 +8,40 @@ const meta = { component: FieldTextarea, tags: ['autodocs'], parameters: { - layout: 'padded', - backgrounds: { default: 'dark' }, + layout: 'centered', + backgrounds: { + default: 'dark' + }, a11y: { config: { rules: [ { id: 'color-contrast', enabled: true }, - { id: 'label', enabled: true } + { id: 'focus-order-semantics', enabled: true } ] } }, docs: { description: { component: [ - 'Labeled multi-line text field — composes Label, Textarea, and HelperText into a single form-ready control. Sibling of FieldText; hosts Textarea (fixed large size, vertical resize, 80px min-height).', + 'Form field for multi-line text input that composes `Label`, `Textarea`, and `HelperText` into a single vertical stack with consistent spacing. Use it whenever a textarea needs a visible label or helper/error message — i.e. virtually every long-form field outside of inline editing. Acts as the canonical wrapper for `Textarea` in form contexts.', '', '## Usage', '', '```vue', '', '', '', '```' @@ -78,59 +59,75 @@ const meta = { argTypes: { modelValue: { control: 'text', - description: 'Two-way bound value of the underlying Textarea.', - table: { type: { summary: 'string' }, category: 'props' } - }, - name: { - control: 'text', - description: 'HTML name for the underlying textarea (form integration).', - table: { type: { summary: 'string' }, category: 'props' } + description: 'Two-way bound value of the underlying `Textarea`.', + table: { category: 'props', type: { summary: 'string' } } }, label: { control: 'text', - description: 'Text rendered inside the Label.', - table: { type: { summary: 'string' }, category: 'props' } + description: 'Text rendered inside the `Label`. When empty, the label row is omitted.', + table: { category: 'props', type: { summary: 'string' } } }, placeholder: { control: 'text', - description: 'Placeholder forwarded to the Textarea.', - table: { type: { summary: 'string' }, category: 'props' } + description: 'Placeholder forwarded to the `Textarea`.', + table: { category: 'props', type: { summary: 'string' } } }, helperText: { control: 'text', - description: 'Auxiliary helper text shown below the field.', - table: { type: { summary: 'string' }, category: 'props' } + description: + 'Auxiliary text rendered inside `HelperText`. When empty, the helper row is omitted.', + table: { category: 'props', type: { summary: 'string' } } }, disabled: { control: 'boolean', - description: 'Disables interaction and switches helper to kind=disabled.', - table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } + description: + 'Disables the textarea and switches the helper to `kind="disabled"` (lock icon).', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } }, readonly: { control: 'boolean', - description: 'Marks the field as read-only.', - table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } + description: + 'Marks the textarea read-only; value is visible but not editable. Native pass-through.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } }, required: { control: 'boolean', - description: 'Adds the Required tag and sets aria-required.', - table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } + description: + 'Adds the `Required` tag to the `Label` and sets native `required` + `aria-required` on the textarea.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } }, invalid: { control: 'boolean', - description: 'Switches helper to kind=invalid and applies invalid border on the textarea.', - table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' }, category: 'props' } + description: + 'Switches the helper to `kind="invalid"` and applies invalid border/ring tokens on the textarea.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } }, inputId: { control: 'text', - description: 'id for the native textarea.', - table: { type: { summary: 'string' }, category: 'props' } + description: + 'id for the native textarea; consumed by `Label` via `for` and by `aria-describedby` wiring.', + table: { category: 'props', type: { summary: 'string' } } + }, + name: { + control: 'text', + description: 'HTML name for the underlying textarea (form + vee-validate integration).', + table: { category: 'props', type: { summary: 'string' } } }, 'onUpdate:modelValue': { action: 'update:modelValue', - description: 'Emitted when the bound value changes.', - table: { type: { summary: 'string' }, category: 'events' } + description: 'Re-emitted from the underlying `Textarea` on every input event.', + table: { category: 'events', type: { summary: 'string' } } } + }, + args: { + modelValue: '', + label: 'Message', + placeholder: 'Write your message', + helperText: 'Up to 500 characters.', + disabled: false, + readonly: false, + required: false, + invalid: false } } @@ -139,52 +136,103 @@ export default meta const Template = (args) => ({ components: { FieldTextarea }, setup() { - const value = ref(args.modelValue ?? '') - return { args, value } + return { args } }, - template: ` -
- -
- ` + template: '' }) +/** @type {import('@storybook/vue3').StoryObj} */ +export const Default = { + render: Template, + parameters: { + docs: { description: { story: 'Default field with label, textarea, and helper text.' } } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ export const Required = { + render: () => ({ + components: { FieldTextarea, Button }, + setup() { + const value = ref('') + const missing = ref(false) + const loading = ref(false) + + const onSubmit = () => { + if (loading.value) return + missing.value = false + loading.value = true + setTimeout(() => { + loading.value = false + missing.value = !value.value + }, 1200) + } + + const onUpdate = (next) => { + value.value = next + if (next) missing.value = false + } + + return { value, missing, loading, onSubmit, onUpdate } + }, + template: ` +
+ +
+ ` + }), + parameters: { + docs: { + description: { + story: + 'Click **Submit** with the field empty: the button shows the loading spinner for ~1.2s (simulating an async call) and the textarea is locked. When loading finishes, the required check fires — the `Label` gets the Required tag, the textarea border turns warning, and the helper switches to the warning-tone "This field is required." copy. Typing any value clears the error.' + } + } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Invalid = { args: { - name: 'message', + invalid: true, label: 'Message', - placeholder: 'Write your message', - helperText: 'This field is required.', - required: true + modelValue: 'Text filled', + helperText: 'This value is not valid.' }, render: Template, parameters: { docs: { - source: { - code: basicSource({ - bind: 'placeholder="Write your message"\nhelperText="This field is required."\nrequired' - }) + description: { + story: + 'Invalid state — the helper switches to `kind="invalid"` (danger tokens) and the textarea gets the invalid border/ring plus `aria-invalid`.' } } } } -export const Invalid = { +/** @type {import('@storybook/vue3').StoryObj} */ +export const Disabled = { args: { - name: 'message', + disabled: true, label: 'Message', - modelValue: 'Text Filled', - helperText: 'This value is not valid.', - invalid: true + modelValue: 'This message is locked.', + helperText: 'This field is locked.' }, render: Template, parameters: { docs: { - source: { - code: basicSource({ - initial: "'Text Filled'", - bind: 'helperText="This value is not valid."\ninvalid' - }) + description: { + story: + 'Disabled state — the textarea is disabled, the helper switches to `kind="disabled"` (lock icon), and the label dims via inherited muted text token.' } } } diff --git a/packages/webkit/src/components/inputs/textarea/textarea.vue b/packages/webkit/src/components/inputs/textarea/textarea.vue index c7ab0b5aa..00e0e6f6f 100644 --- a/packages/webkit/src/components/inputs/textarea/textarea.vue +++ b/packages/webkit/src/components/inputs/textarea/textarea.vue @@ -44,31 +44,54 @@ const testId = computed(() => (attrs['data-testid'] as string | undefined) ?? 'input-textarea') const isFilled = computed(() => props.modelValue.length > 0) - const hasIconLeft = computed(() => !!slots['iconLeft']) - const hasIconRight = computed(() => !!slots['iconRight'] || props.disabled) + const hasIconLeft = computed(() => Boolean(slots['iconLeft'])) + const hasIconRight = computed(() => Boolean(slots['iconRight']) || props.disabled) - // eslint-disable-next-line no-undef - const handleInput = (event: InputEvent) => { - const target = event.target as HTMLElement & { value: string } + const passthroughAttrs = computed(() => { + const rest: Record = { ...attrs } + delete rest['class'] + delete rest['data-testid'] + return rest + }) + + const handleInput = (event: globalThis.Event) => { + const target = event.target as globalThis.HTMLTextAreaElement emit('update:modelValue', target.value) }