srcdev-nuxt-components 9.6.8 → 9.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/component-compliance-checklist.md +12 -1
- package/.claude/skills/component-inline-action-button.md +1 -1
- package/.claude/skills/component-local-style-override.md +1 -1
- package/.claude/skills/component-naming.md +11 -11
- package/.claude/skills/components/container-glow.md +2 -2
- package/.claude/skills/components/form-field.md +2 -2
- package/.claude/skills/components/form-wrapper.md +1 -1
- package/.claude/skills/components/glowing-border.md +1 -1
- package/.claude/skills/components/input-description.md +7 -7
- package/.claude/skills/components/input-error.md +3 -3
- package/.claude/skills/components/input-label.md +7 -8
- package/.claude/skills/components/input-number.md +17 -17
- package/.claude/skills/components/input-select.md +153 -0
- package/.claude/skills/components/input-text.md +220 -0
- package/.claude/skills/components/input-textarea.md +147 -0
- package/.claude/skills/components/select-menu.md +4 -4
- package/.claude/skills/components/toggle-switch.md +143 -0
- package/.claude/skills/composable-zod-validation.md +1 -1
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/icon-sizing.md +1 -1
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/storybook-add-story.md +1 -1
- package/.claude/skills/theming-component-token-pattern.md +8 -8
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.vscode/srcdev-component-input-select.code-snippets +20 -14
- package/.vscode/srcdev-component-input-text.code-snippets +46 -40
- package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
- package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
- package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +6 -6
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +5 -5
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +9 -5
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
- package/app/components/05.forms/input-select/InputSelect.vue +198 -0
- package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
- package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
- package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
- package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
- package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
- package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
- package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
- package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
- package/app/components/05.forms/input-text/InputTextField.vue +168 -0
- package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
- package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
- package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
- package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
- package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
- package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
- package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
- package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
- package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
- package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
- package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
- package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +30 -32
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
- package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
- package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-select-core.md +0 -151
- package/.claude/skills/components/input-text-core.md +0 -204
- package/.claude/skills/components/input-textarea-core.md +0 -154
- package/.claude/skills/components/toggle-switch-core.md +0 -169
- package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
- package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
- package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
- package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
- package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
- package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="input-textarea-field"
|
|
4
|
+
:class="elementClasses"
|
|
5
|
+
:data-theme="theme"
|
|
6
|
+
:data-input-variant="inputVariant"
|
|
7
|
+
:data-invalid="fieldHasError ? '' : null"
|
|
8
|
+
:data-dirty="isDirty ? '' : null"
|
|
9
|
+
:data-active="isActive ? '' : null"
|
|
10
|
+
>
|
|
11
|
+
<InputLabel
|
|
12
|
+
:id
|
|
13
|
+
:required
|
|
14
|
+
:input-variant
|
|
15
|
+
:field-has-error
|
|
16
|
+
:style-class-passthrough="['input-textarea-label']"
|
|
17
|
+
>
|
|
18
|
+
<template #textLabel>{{ label }}</template>
|
|
19
|
+
</InputLabel>
|
|
20
|
+
|
|
21
|
+
<InputDescription
|
|
22
|
+
v-if="inputVariant !== 'outlined'"
|
|
23
|
+
:description-id
|
|
24
|
+
:input-variant
|
|
25
|
+
:field-has-error="fieldHasError"
|
|
26
|
+
:style-class-passthrough="['input-text-description']"
|
|
27
|
+
>
|
|
28
|
+
<template v-if="slots.descriptionHtml" #descriptionHtml>
|
|
29
|
+
<slot name="descriptionHtml"></slot>
|
|
30
|
+
</template>
|
|
31
|
+
<template v-if="slots.descriptionText" #descriptionText>
|
|
32
|
+
<slot name="descriptionText"></slot>
|
|
33
|
+
</template>
|
|
34
|
+
</InputDescription>
|
|
35
|
+
|
|
36
|
+
<InputTextarea
|
|
37
|
+
:id
|
|
38
|
+
v-model="modelValue"
|
|
39
|
+
v-model:is-dirty="isDirty"
|
|
40
|
+
v-model:is-active="isActive"
|
|
41
|
+
:maxlength
|
|
42
|
+
:name
|
|
43
|
+
:placeholder
|
|
44
|
+
:field-has-error
|
|
45
|
+
:required
|
|
46
|
+
:readonly
|
|
47
|
+
:autocomplete
|
|
48
|
+
:theme
|
|
49
|
+
:input-variant
|
|
50
|
+
:aria-describedby="ariaDescribedby() ?? undefined"
|
|
51
|
+
>
|
|
52
|
+
<template v-if="slots.left" #left>
|
|
53
|
+
<slot name="left"></slot>
|
|
54
|
+
</template>
|
|
55
|
+
<template v-if="slots.right" #right>
|
|
56
|
+
<slot name="right"></slot>
|
|
57
|
+
</template>
|
|
58
|
+
</InputTextarea>
|
|
59
|
+
|
|
60
|
+
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="false" :input-variant />
|
|
61
|
+
|
|
62
|
+
<InputDescription
|
|
63
|
+
v-if="inputVariant === 'outlined'"
|
|
64
|
+
:description-id
|
|
65
|
+
:input-variant
|
|
66
|
+
:field-has-error="fieldHasError"
|
|
67
|
+
:style-class-passthrough="['input-text-description']"
|
|
68
|
+
>
|
|
69
|
+
<template v-if="slots.descriptionHtml" #descriptionHtml>
|
|
70
|
+
<slot name="descriptionHtml"></slot>
|
|
71
|
+
</template>
|
|
72
|
+
<template v-if="slots.descriptionText" #descriptionText>
|
|
73
|
+
<slot name="descriptionText"></slot>
|
|
74
|
+
</template>
|
|
75
|
+
</InputDescription>
|
|
76
|
+
</div>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<script setup lang="ts">
|
|
80
|
+
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
|
|
81
|
+
|
|
82
|
+
interface Props {
|
|
83
|
+
maxlength?: number;
|
|
84
|
+
name: string;
|
|
85
|
+
placeholder?: string;
|
|
86
|
+
label: string;
|
|
87
|
+
errorMessage: object | string;
|
|
88
|
+
fieldHasError?: boolean;
|
|
89
|
+
required?: boolean;
|
|
90
|
+
readonly?: boolean;
|
|
91
|
+
autocomplete?: string;
|
|
92
|
+
styleClassPassthrough?: string | string[];
|
|
93
|
+
theme?: FormUiTheme;
|
|
94
|
+
inputVariant?: InputUiVariant;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
98
|
+
maxlength: 255,
|
|
99
|
+
placeholder: "",
|
|
100
|
+
fieldHasError: false,
|
|
101
|
+
required: false,
|
|
102
|
+
readonly: false,
|
|
103
|
+
autocomplete: undefined,
|
|
104
|
+
styleClassPassthrough: () => [],
|
|
105
|
+
theme: "default",
|
|
106
|
+
inputVariant: "normal",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const slots = useSlots();
|
|
110
|
+
|
|
111
|
+
const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
112
|
+
props.name,
|
|
113
|
+
toRef(props, "fieldHasError"),
|
|
114
|
+
slots
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
|
|
118
|
+
required: true,
|
|
119
|
+
});
|
|
120
|
+
const isActive = ref<boolean>(false);
|
|
121
|
+
const isDirty = ref<boolean>(false);
|
|
122
|
+
|
|
123
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
124
|
+
|
|
125
|
+
watch(
|
|
126
|
+
() => props.styleClassPassthrough,
|
|
127
|
+
() => {
|
|
128
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const testDirty = () => {
|
|
133
|
+
const watchValue = modelValue.value ?? "";
|
|
134
|
+
|
|
135
|
+
if (!isDirty.value && typeof watchValue === "string" && watchValue.length > 0) {
|
|
136
|
+
isDirty.value = true;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
onMounted(() => {
|
|
141
|
+
testDirty();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
watch(
|
|
145
|
+
() => modelValue.value,
|
|
146
|
+
() => {
|
|
147
|
+
testDirty();
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<style lang="css">
|
|
153
|
+
@layer components {
|
|
154
|
+
.input-textarea-field {
|
|
155
|
+
min-inline-size: 0;
|
|
156
|
+
overflow-wrap: anywhere;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import InputTextarea from "../InputTextarea.vue";
|
|
4
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
7
|
+
|
|
8
|
+
interface StoryArgs {
|
|
9
|
+
modelValue: string;
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
placeholder: string;
|
|
13
|
+
maxlength: number;
|
|
14
|
+
required: boolean;
|
|
15
|
+
readonly: boolean;
|
|
16
|
+
fieldHasError: boolean;
|
|
17
|
+
theme: FormUiTheme;
|
|
18
|
+
inputVariant: InputUiVariant;
|
|
19
|
+
styleClassPassthrough: string[];
|
|
20
|
+
leftSlotContent: string;
|
|
21
|
+
rightSlotContent: string;
|
|
22
|
+
maxHeightToken: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const meta: Meta<StoryArgs> = {
|
|
26
|
+
title: "Components/Forms/Input Textarea/InputTextarea",
|
|
27
|
+
component: InputTextarea,
|
|
28
|
+
argTypes: {
|
|
29
|
+
modelValue: { control: "text", table: { category: "Model" } },
|
|
30
|
+
id: { control: "text", table: { category: "Basic" } },
|
|
31
|
+
name: { control: "text", table: { category: "Basic" } },
|
|
32
|
+
placeholder: { control: "text", table: { category: "Basic" } },
|
|
33
|
+
maxlength: { control: "number", table: { category: "Basic" } },
|
|
34
|
+
required: { control: "boolean", table: { category: "Validation" } },
|
|
35
|
+
readonly: { control: "boolean", table: { category: "States" } },
|
|
36
|
+
fieldHasError: { control: "boolean", table: { category: "Validation" } },
|
|
37
|
+
theme: {
|
|
38
|
+
control: { type: "select" },
|
|
39
|
+
options: ["default", "success", "error", "warning"],
|
|
40
|
+
table: { category: "Styling" },
|
|
41
|
+
},
|
|
42
|
+
inputVariant: {
|
|
43
|
+
control: { type: "select" },
|
|
44
|
+
options: ["normal", "outlined", "underlined"],
|
|
45
|
+
table: { category: "Styling" },
|
|
46
|
+
},
|
|
47
|
+
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
48
|
+
leftSlotContent: { control: "text", description: "Story-only: fills the left slot", table: { category: "Slots" } },
|
|
49
|
+
rightSlotContent: { control: "text", description: "Story-only: fills the right slot", table: { category: "Slots" } },
|
|
50
|
+
maxHeightToken: {
|
|
51
|
+
control: { type: "select" },
|
|
52
|
+
options: ["none", "12lh", "20rem"],
|
|
53
|
+
description: "Story-only CSS token, not a prop: sets `--input-textarea-max-height` on a wrapper",
|
|
54
|
+
table: { category: "CSS tokens (story-only)" },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
args: {
|
|
58
|
+
modelValue: "",
|
|
59
|
+
id: "message",
|
|
60
|
+
name: "message",
|
|
61
|
+
placeholder: "Write your message",
|
|
62
|
+
maxlength: 255,
|
|
63
|
+
required: false,
|
|
64
|
+
readonly: false,
|
|
65
|
+
fieldHasError: false,
|
|
66
|
+
theme: "default",
|
|
67
|
+
inputVariant: "normal",
|
|
68
|
+
styleClassPassthrough: [],
|
|
69
|
+
leftSlotContent: "",
|
|
70
|
+
rightSlotContent: "",
|
|
71
|
+
maxHeightToken: "none",
|
|
72
|
+
},
|
|
73
|
+
decorators: [
|
|
74
|
+
(story, context) => ({
|
|
75
|
+
components: { story, CanvasSwitcher },
|
|
76
|
+
setup() {
|
|
77
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
78
|
+
return { canvasName };
|
|
79
|
+
},
|
|
80
|
+
template: `
|
|
81
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
82
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
83
|
+
</div>
|
|
84
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
85
|
+
<story />
|
|
86
|
+
</div>
|
|
87
|
+
`,
|
|
88
|
+
}),
|
|
89
|
+
],
|
|
90
|
+
parameters: {
|
|
91
|
+
layout: "fullscreen",
|
|
92
|
+
docs: {
|
|
93
|
+
description: {
|
|
94
|
+
component:
|
|
95
|
+
"Bare native `<textarea>` for the `05.forms` textarea family: no label, description or error (use `InputTextareaField` for those, or pair it with your own `<label for>`). It grows with its content where `field-sizing: content` is supported, from `--input-textarea-min-height` (6 lines) up to `--input-textarea-max-height` (no cap by default), then scrolls. `left`/`right` slots hold decorations. State is exposed as `data-*` attributes on `.input-textarea-wrapper`.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default meta;
|
|
102
|
+
type Story = StoryObj<StoryArgs>;
|
|
103
|
+
|
|
104
|
+
const render = (args: StoryArgs) => ({
|
|
105
|
+
components: { InputTextarea },
|
|
106
|
+
setup() {
|
|
107
|
+
const componentArgs = computed(() => {
|
|
108
|
+
const {
|
|
109
|
+
modelValue: _modelValue,
|
|
110
|
+
leftSlotContent: _left,
|
|
111
|
+
rightSlotContent: _right,
|
|
112
|
+
maxHeightToken: _maxHeight,
|
|
113
|
+
...rest
|
|
114
|
+
} = args;
|
|
115
|
+
return rest;
|
|
116
|
+
});
|
|
117
|
+
return { args, componentArgs };
|
|
118
|
+
},
|
|
119
|
+
template: `
|
|
120
|
+
<div :style="{ '--input-textarea-max-height': args.maxHeightToken }">
|
|
121
|
+
<InputTextarea v-model="args.modelValue" v-bind="componentArgs">
|
|
122
|
+
<template v-if="args.leftSlotContent" #left>{{ args.leftSlotContent }}</template>
|
|
123
|
+
<template v-if="args.rightSlotContent" #right>{{ args.rightSlotContent }}</template>
|
|
124
|
+
</InputTextarea>
|
|
125
|
+
</div>
|
|
126
|
+
<p style="margin-block-start: 1.6rem; font-family: monospace; white-space: pre-wrap;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
|
|
127
|
+
`,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const Default: Story = { render };
|
|
131
|
+
|
|
132
|
+
export const MultiLine: Story = {
|
|
133
|
+
args: { modelValue: "First line\nSecond line\n\n Indented after a blank line" },
|
|
134
|
+
render,
|
|
135
|
+
parameters: { docs: { description: { story: "Line breaks and indentation show as typed." } } },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const CappedHeight: Story = {
|
|
139
|
+
args: {
|
|
140
|
+
maxHeightToken: "12lh",
|
|
141
|
+
maxlength: 5000,
|
|
142
|
+
modelValue: Array.from({ length: 30 }, (_, index) => `Line ${index + 1}`).join("\n"),
|
|
143
|
+
},
|
|
144
|
+
render,
|
|
145
|
+
parameters: { docs: { description: { story: "`--input-textarea-max-height: 12lh` stops it growing past 12 lines; the rest scrolls." } } },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const WithSlots: Story = { args: { leftSlotContent: "📝", rightSlotContent: "✨" }, render };
|
|
149
|
+
|
|
150
|
+
export const SlotsOnFirstLine: Story = {
|
|
151
|
+
args: { leftSlotContent: "📝", rightSlotContent: "✨", modelValue: "First line\nSecond line\nThird line" },
|
|
152
|
+
render: (args: StoryArgs) => ({
|
|
153
|
+
...render(args),
|
|
154
|
+
template: `
|
|
155
|
+
<div style="--input-textarea-slot-align: start; --input-textarea-slot-padding-block: var(--form-textarea-padding-block);">
|
|
156
|
+
${render(args).template}
|
|
157
|
+
</div>
|
|
158
|
+
`,
|
|
159
|
+
}),
|
|
160
|
+
parameters: {
|
|
161
|
+
docs: {
|
|
162
|
+
description: {
|
|
163
|
+
story:
|
|
164
|
+
"`--input-textarea-slot-align: start` with `--input-textarea-slot-padding-block: var(--form-textarea-padding-block)` lines the slots up with the first line of text instead of centring them on the field. Story-only CSS tokens, set on a wrapper.",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const WithError: Story = { args: { fieldHasError: true, theme: "error" }, render };
|
|
171
|
+
|
|
172
|
+
export const Readonly: Story = { args: { readonly: true, modelValue: "This message can't be edited." }, render };
|
|
173
|
+
|
|
174
|
+
export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
|
|
175
|
+
|
|
176
|
+
export const StressTest: Story = {
|
|
177
|
+
name: "Stress Test (Worst-Case Data)",
|
|
178
|
+
args: {
|
|
179
|
+
maxlength: 5000,
|
|
180
|
+
modelValue:
|
|
181
|
+
"Donaudampfschifffahrtsgesellschaftskapitänswitwenrentenversicherungsbeitragsrückerstattungsantrag https://example.com/a/very/long/unbroken/url/that/never/wraps/anywhere/at/all\n<script>alert(1)</script> <b>not bold</b>\n🎉🎉🎉\nمرحبا بكم في النموذج\n" +
|
|
182
|
+
"Lorem ipsum dolor sit amet. ".repeat(40),
|
|
183
|
+
placeholder: "Bitte beschreiben Sie Ihr Anliegen so ausführlich wie möglich",
|
|
184
|
+
leftSlotContent: "📝📝📝",
|
|
185
|
+
rightSlotContent: "عربى",
|
|
186
|
+
fieldHasError: true,
|
|
187
|
+
},
|
|
188
|
+
render,
|
|
189
|
+
parameters: {
|
|
190
|
+
initialCanvas: "mobileCanvas",
|
|
191
|
+
docs: {
|
|
192
|
+
description: {
|
|
193
|
+
story:
|
|
194
|
+
"A long unbroken German word and URL, HTML-like text, emoji, right-to-left text and a long paragraph, with text in both slots. Check at every canvas width: the textarea stays inside the dashed outline and wraps the unbroken strings instead of scrolling sideways, line breaks are kept, markup shows as text, and the slots keep their size.",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import InputTextareaField from "../InputTextareaField.vue";
|
|
4
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
7
|
+
|
|
8
|
+
interface StoryArgs {
|
|
9
|
+
modelValue: string;
|
|
10
|
+
name: string;
|
|
11
|
+
label: string;
|
|
12
|
+
placeholder: string;
|
|
13
|
+
maxlength: number;
|
|
14
|
+
errorMessage: string;
|
|
15
|
+
fieldHasError: boolean;
|
|
16
|
+
required: boolean;
|
|
17
|
+
readonly: boolean;
|
|
18
|
+
theme: FormUiTheme;
|
|
19
|
+
inputVariant: InputUiVariant;
|
|
20
|
+
styleClassPassthrough: string[];
|
|
21
|
+
descriptionText: string;
|
|
22
|
+
descriptionHtml: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const meta: Meta<StoryArgs> = {
|
|
26
|
+
title: "Components/Forms/Input Textarea/InputTextareaField",
|
|
27
|
+
component: InputTextareaField,
|
|
28
|
+
argTypes: {
|
|
29
|
+
modelValue: { control: "text", table: { category: "Model" } },
|
|
30
|
+
name: { control: "text", table: { category: "Basic" } },
|
|
31
|
+
label: { control: "text", table: { category: "Basic" } },
|
|
32
|
+
placeholder: { control: "text", table: { category: "Basic" } },
|
|
33
|
+
maxlength: { control: "number", table: { category: "Basic" } },
|
|
34
|
+
errorMessage: { control: "text", table: { category: "Validation" } },
|
|
35
|
+
fieldHasError: { control: "boolean", table: { category: "Validation" } },
|
|
36
|
+
required: { control: "boolean", table: { category: "Validation" } },
|
|
37
|
+
readonly: { control: "boolean", table: { category: "States" } },
|
|
38
|
+
theme: {
|
|
39
|
+
control: { type: "select" },
|
|
40
|
+
options: ["default", "success", "error", "warning"],
|
|
41
|
+
table: { category: "Styling" },
|
|
42
|
+
},
|
|
43
|
+
inputVariant: {
|
|
44
|
+
control: { type: "select" },
|
|
45
|
+
options: ["normal", "outlined", "underlined"],
|
|
46
|
+
table: { category: "Styling" },
|
|
47
|
+
},
|
|
48
|
+
styleClassPassthrough: { control: "object", table: { category: "Styling" } },
|
|
49
|
+
descriptionText: { control: "text", description: "Story-only: fills the descriptionText slot", table: { category: "Slots" } },
|
|
50
|
+
descriptionHtml: { control: "text", description: "Story-only: fills the descriptionHtml slot", table: { category: "Slots" } },
|
|
51
|
+
},
|
|
52
|
+
args: {
|
|
53
|
+
modelValue: "",
|
|
54
|
+
name: "message",
|
|
55
|
+
label: "Your message",
|
|
56
|
+
placeholder: "",
|
|
57
|
+
maxlength: 1000,
|
|
58
|
+
errorMessage: "",
|
|
59
|
+
fieldHasError: false,
|
|
60
|
+
required: false,
|
|
61
|
+
readonly: false,
|
|
62
|
+
theme: "default",
|
|
63
|
+
inputVariant: "normal",
|
|
64
|
+
styleClassPassthrough: [],
|
|
65
|
+
descriptionText: "",
|
|
66
|
+
descriptionHtml: "",
|
|
67
|
+
},
|
|
68
|
+
decorators: [
|
|
69
|
+
(story, context) => ({
|
|
70
|
+
components: { story, CanvasSwitcher },
|
|
71
|
+
setup() {
|
|
72
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
73
|
+
return { canvasName };
|
|
74
|
+
},
|
|
75
|
+
template: `
|
|
76
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
77
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
78
|
+
</div>
|
|
79
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
80
|
+
<story />
|
|
81
|
+
</div>
|
|
82
|
+
`,
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
parameters: {
|
|
86
|
+
layout: "fullscreen",
|
|
87
|
+
docs: {
|
|
88
|
+
description: {
|
|
89
|
+
component:
|
|
90
|
+
"Labelled textarea field: `InputLabel`, an optional description (`descriptionText`/`descriptionHtml` slots, moved after the control for the `outlined` variant), `InputTextarea` and an error strip, linked through `aria-describedby`. `left`/`right` slots pass through to `InputTextarea`. `style-class-passthrough` lands on the `.input-textarea-field` root only.",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default meta;
|
|
97
|
+
type Story = StoryObj<StoryArgs>;
|
|
98
|
+
|
|
99
|
+
const render = (args: StoryArgs) => ({
|
|
100
|
+
components: { InputTextareaField },
|
|
101
|
+
setup() {
|
|
102
|
+
const componentArgs = computed(() => {
|
|
103
|
+
const { modelValue: _modelValue, descriptionText: _text, descriptionHtml: _html, ...rest } = args;
|
|
104
|
+
return rest;
|
|
105
|
+
});
|
|
106
|
+
return { args, componentArgs };
|
|
107
|
+
},
|
|
108
|
+
template: `
|
|
109
|
+
<InputTextareaField v-model="args.modelValue" v-bind="componentArgs">
|
|
110
|
+
<template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
|
|
111
|
+
<template v-if="args.descriptionHtml" #descriptionHtml><span v-html="args.descriptionHtml"></span></template>
|
|
112
|
+
</InputTextareaField>
|
|
113
|
+
`,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const Default: Story = { render };
|
|
117
|
+
|
|
118
|
+
export const WithDescription: Story = { args: { descriptionText: "Tell us how we can help." }, render };
|
|
119
|
+
|
|
120
|
+
export const WithHtmlDescription: Story = {
|
|
121
|
+
args: { descriptionHtml: "Keep it under <strong>1000 characters</strong>. <em>No personal details, please.</em>" },
|
|
122
|
+
render,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const WithError: Story = {
|
|
126
|
+
args: { fieldHasError: true, errorMessage: "Please enter a message", required: true },
|
|
127
|
+
render,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const Required: Story = { args: { required: true }, render };
|
|
131
|
+
|
|
132
|
+
export const Outlined: Story = {
|
|
133
|
+
args: { inputVariant: "outlined", descriptionText: "Tell us how we can help." },
|
|
134
|
+
render,
|
|
135
|
+
parameters: { docs: { description: { story: "The `outlined` variant renders the description after the control and error." } } },
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
|
|
139
|
+
|
|
140
|
+
export const StressTest: Story = {
|
|
141
|
+
name: "Stress Test (Worst-Case Data)",
|
|
142
|
+
args: {
|
|
143
|
+
maxlength: 5000,
|
|
144
|
+
modelValue: "Kundenzufriedenheitsrückmeldungsformularausfüllanleitung https://example.com/a/very/long/unbroken/url\n<b>not bold</b> 🎉\n" + "Alex Morgan from Northtown wrote a long message. ".repeat(20),
|
|
145
|
+
label: "💬 Ausführlichebeschreibungihresanliegensfürunserkundenserviceteam https://example.com/a/very/long/unbroken/url <b>not bold</b> رسالتك",
|
|
146
|
+
descriptionText: "يرجى وصف طلبك بالتفصيل Bitte beschreiben Sie Ihr Anliegen so ausführlich wie möglich, damit wir Ihnen schnell helfen können.",
|
|
147
|
+
errorMessage: "IhrenachrichtüberschreitetleiderdiezulässigeZeichenanzahlbittekürzenSiesie",
|
|
148
|
+
fieldHasError: true,
|
|
149
|
+
required: true,
|
|
150
|
+
},
|
|
151
|
+
render,
|
|
152
|
+
parameters: {
|
|
153
|
+
initialCanvas: "mobileCanvas",
|
|
154
|
+
docs: {
|
|
155
|
+
description: {
|
|
156
|
+
story:
|
|
157
|
+
"Long unbroken German, URL, emoji, HTML-like and right-to-left copy in the label, description, value and error, with a long multi-line value. Check at every canvas width: everything wraps inside the dashed outline, line breaks in the value are kept, and markup renders as text.",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import { nextTick } from "vue";
|
|
3
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
4
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
5
|
+
import ComponentUnderTest from "../InputTextarea.vue";
|
|
6
|
+
|
|
7
|
+
const initialPropsData = {
|
|
8
|
+
id: "test-textarea",
|
|
9
|
+
name: "testName",
|
|
10
|
+
modelValue: "",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
14
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
15
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
16
|
+
|
|
17
|
+
return mountSuspended(ComponentUnderTest, {
|
|
18
|
+
attachTo: document.body,
|
|
19
|
+
props: mockPropsData,
|
|
20
|
+
slots,
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
wrapper?.unmount();
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("InputTextarea", () => {
|
|
30
|
+
it("mounts without error", async () => {
|
|
31
|
+
wrapper = await wrapperFactory();
|
|
32
|
+
expect(wrapper.vm).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders a native textarea with the .input-textarea class", async () => {
|
|
36
|
+
wrapper = await wrapperFactory();
|
|
37
|
+
expect(wrapper.find("textarea.input-textarea").exists()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("binds v-model to the textarea value", async () => {
|
|
41
|
+
wrapper = await wrapperFactory({ modelValue: "hello world" });
|
|
42
|
+
expect(wrapper.find("textarea").element.value).toBe("hello world");
|
|
43
|
+
|
|
44
|
+
await wrapper.find("textarea").setValue("line one\nline two");
|
|
45
|
+
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["line one\nline two"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("forwards placeholder", async () => {
|
|
49
|
+
wrapper = await wrapperFactory({ placeholder: "Enter your message" });
|
|
50
|
+
expect(wrapper.find("textarea").attributes("placeholder")).toBe("Enter your message");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("forwards maxlength, defaulting to 255", async () => {
|
|
54
|
+
wrapper = await wrapperFactory();
|
|
55
|
+
expect(wrapper.find("textarea").attributes("maxlength")).toBe("255");
|
|
56
|
+
|
|
57
|
+
await wrapper.setProps({ maxlength: 100 });
|
|
58
|
+
expect(wrapper.find("textarea").attributes("maxlength")).toBe("100");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("forwards readonly and autocomplete", async () => {
|
|
62
|
+
wrapper = await wrapperFactory({ readonly: true, autocomplete: "street-address" });
|
|
63
|
+
expect(wrapper.find("textarea").attributes("readonly")).toBeDefined();
|
|
64
|
+
expect(wrapper.find("textarea").attributes("autocomplete")).toBe("street-address");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("forwards aria-describedby, omitting it when empty", async () => {
|
|
68
|
+
wrapper = await wrapperFactory();
|
|
69
|
+
expect(wrapper.find("textarea").attributes("aria-describedby")).toBeUndefined();
|
|
70
|
+
|
|
71
|
+
await wrapper.setProps({ ariaDescribedby: "some-description-id" });
|
|
72
|
+
expect(wrapper.find("textarea").attributes("aria-describedby")).toBe("some-description-id");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("sets data-theme and data-input-variant on the wrapper", async () => {
|
|
76
|
+
wrapper = await wrapperFactory({ theme: "error", inputVariant: "underlined" });
|
|
77
|
+
const root = wrapper.find(".input-textarea-wrapper");
|
|
78
|
+
|
|
79
|
+
expect(root.attributes("data-theme")).toBe("error");
|
|
80
|
+
expect(root.attributes("data-input-variant")).toBe("underlined");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("defaults data-input-variant to normal", async () => {
|
|
84
|
+
wrapper = await wrapperFactory();
|
|
85
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-input-variant")).toBe("normal");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("sets data-invalid and aria-invalid only when fieldHasError is true", async () => {
|
|
89
|
+
wrapper = await wrapperFactory();
|
|
90
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-invalid")).toBeUndefined();
|
|
91
|
+
expect(wrapper.find("textarea").attributes("aria-invalid")).toBeUndefined();
|
|
92
|
+
|
|
93
|
+
await wrapper.setProps({ fieldHasError: true });
|
|
94
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-invalid")).toBe("");
|
|
95
|
+
expect(wrapper.find("textarea").attributes("aria-invalid")).toBe("true");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not render the slot holders when the slots are not used", async () => {
|
|
99
|
+
wrapper = await wrapperFactory();
|
|
100
|
+
expect(wrapper.find(".input-textarea-slot-left").exists()).toBe(false);
|
|
101
|
+
expect(wrapper.find(".input-textarea-slot-right").exists()).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders the left/right slots when provided", async () => {
|
|
105
|
+
wrapper = await wrapperFactory(
|
|
106
|
+
{},
|
|
107
|
+
{
|
|
108
|
+
left: '<span data-testid="left-slot">📝</span>',
|
|
109
|
+
right: '<span data-testid="right-slot">✨</span>',
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
const root = wrapper.find(".input-textarea-wrapper");
|
|
113
|
+
|
|
114
|
+
expect(wrapper.find(".input-textarea-slot-left [data-testid='left-slot']").exists()).toBe(true);
|
|
115
|
+
expect(wrapper.find(".input-textarea-slot-right [data-testid='right-slot']").exists()).toBe(true);
|
|
116
|
+
expect(root.attributes("data-has-left-slot")).toBe("");
|
|
117
|
+
expect(root.attributes("data-has-right-slot")).toBe("");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("applies styleClassPassthrough to the textarea and reacts to changes", async () => {
|
|
121
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-textarea-class"] });
|
|
122
|
+
expect(wrapper.find("textarea").classes()).toContain("custom-textarea-class");
|
|
123
|
+
|
|
124
|
+
await wrapper.setProps({ styleClassPassthrough: ["other-class"] });
|
|
125
|
+
await nextTick();
|
|
126
|
+
expect(wrapper.find("textarea").classes()).toContain("other-class");
|
|
127
|
+
expect(wrapper.find("textarea").classes()).not.toContain("custom-textarea-class");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("sets required attribute when required is true", async () => {
|
|
131
|
+
wrapper = await wrapperFactory({ required: true });
|
|
132
|
+
expect(wrapper.find("textarea").attributes("required")).toBeDefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("sets isActive on focus and blur, reflected as data-active", async () => {
|
|
136
|
+
wrapper = await wrapperFactory({ isActive: false });
|
|
137
|
+
const textarea = wrapper.find("textarea");
|
|
138
|
+
|
|
139
|
+
await textarea.trigger("focusin");
|
|
140
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([true]);
|
|
141
|
+
|
|
142
|
+
await textarea.trigger("focusout");
|
|
143
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([false]);
|
|
144
|
+
|
|
145
|
+
await wrapper.setProps({ isActive: true, isDirty: true });
|
|
146
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-active")).toBe("");
|
|
147
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-dirty")).toBe("");
|
|
148
|
+
});
|
|
149
|
+
});
|