diff --git a/vue-components/src/components/ResizeLimit.ts b/vue-components/src/components/ResizeLimit.ts
new file mode 100644
index 000000000..8e1f5e8b9
--- /dev/null
+++ b/vue-components/src/components/ResizeLimit.ts
@@ -0,0 +1,14 @@
+enum ResizeLimit {
+ Horizontal = 'horizontal',
+ Vertical = 'vertical',
+ None = 'none'
+}
+
+function validateLimit( limit: string ): boolean {
+ return Object.values( ResizeLimit ).includes( limit as ResizeLimit );
+}
+
+export {
+ ResizeLimit,
+ validateLimit,
+};
diff --git a/vue-components/src/components/TextArea.vue b/vue-components/src/components/TextArea.vue
new file mode 100644
index 000000000..ca100e15a
--- /dev/null
+++ b/vue-components/src/components/TextArea.vue
@@ -0,0 +1,201 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vue-components/stories/TextArea.stories.ts b/vue-components/stories/TextArea.stories.ts
new file mode 100644
index 000000000..8e566e586
--- /dev/null
+++ b/vue-components/stories/TextArea.stories.ts
@@ -0,0 +1,84 @@
+import TextArea from '@/components/TextArea';
+import { Component } from 'vue';
+
+export default {
+ component: TextArea,
+ title: 'TextArea',
+};
+
+export function basic( args: object ): Component {
+ return {
+ data(): object {
+ return { args };
+ },
+ components: { TextArea },
+ props: Object.keys( args ),
+ template: `
+
+
+
+ `,
+ };
+}
+
+basic.args = {
+ label: 'Label',
+ placeholder: 'Placeholder'
+};
+
+basic.argTypes = {
+ value: {
+ control: false
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ },
+ placeholder: {
+ control: {
+ type: 'text',
+ },
+ },
+ rows: {
+ control: {
+ type: 'number',
+ },
+ },
+ resize: {
+ table: {
+ defaultValue: {
+ summary: 'vertical'
+ }
+ },
+ control: {
+ type: 'select',
+ options: ['vertical', 'horizontal', 'none'],
+ default: 'vertical'
+ },
+ },
+ input : {
+ description: 'Emitted on each character input to the textarea, contains the entire string value of the textarea itself.',
+ table: {
+ type: {
+ summary: 'string'
+ }
+ }
+ }
+};
+
+export function all(): Component {
+ return {
+ components: { TextArea },
+ template: `
+
+
+
+ `,
+ };
+}
diff --git a/vue-components/tests/unit/components/TextArea.spec.ts b/vue-components/tests/unit/components/TextArea.spec.ts
new file mode 100644
index 000000000..538178e97
--- /dev/null
+++ b/vue-components/tests/unit/components/TextArea.spec.ts
@@ -0,0 +1,73 @@
+import { mount } from '@vue/test-utils';
+import TextArea from '@/components/TextArea.vue';
+import { ResizeLimit } from '@/components/ResizeLimit.ts';
+
+describe( 'TextArea.vue', () => {
+ it( 'accepts rows property', () => {
+ const wrapper = mount( TextArea, {
+ propsData: { rows: 42 },
+ } );
+
+ expect( wrapper.props().rows ).toBe( 42 );
+ expect( wrapper.find( 'textarea' ).attributes( 'rows' ) ).toBe( '42' );
+ } );
+
+ it( 'accepts resize property', () => {
+ const wrapper = mount( TextArea, {
+ propsData: { resize: ResizeLimit.Horizontal },
+ } );
+
+ expect( wrapper.props().resize ).toBe( ResizeLimit.Horizontal );
+ expect( wrapper.find( 'textarea' ).classes() ).toContain( 'wikit-TextArea__textarea--horizontal' );
+ } );
+
+ it( 'uses default resize value', () => {
+ const wrapper = mount( TextArea );
+
+ expect( wrapper.find( 'textarea' ).classes() ).toContain( 'wikit-TextArea__textarea--vertical' );
+ } );
+
+ it( 'throws on invalid resize values', () => {
+ expect( () => mount( TextArea, {
+ propsData: { resize: 'nonsense' },
+ } ) ).toThrow( 'Invalid prop: custom validator check failed for prop "resize"' );
+ } );
+
+ it( 'accepts a textarea value', () => {
+ const value = 'Some beautiful value!';
+ const wrapper = mount( TextArea, {
+ propsData: { value },
+ } );
+ const element = wrapper.find( 'textarea' ).element as HTMLFormElement;
+
+ expect( wrapper.props().value ).toBe( value );
+ expect( element.value ).toBe( value );
+ } );
+
+ it( 'accepts label property', () => {
+ const label = 'da Label';
+ const wrapper = mount( TextArea, {
+ propsData: { label },
+ } );
+
+ expect( wrapper.props().label ).toBe( label );
+ expect( wrapper.find( 'label' ).text() ).toBe( label );
+ } );
+
+ it( 'accepts placeholder property', () => {
+ const placeholder = 'This is a placeholder';
+ const wrapper = mount( TextArea, {
+ propsData: { placeholder },
+ } );
+
+ expect( wrapper.find( 'textarea' ).attributes( 'placeholder' ) ).toBe( placeholder );
+ } );
+
+ it( 'should emit a change event with textarea value', () => {
+ const userInput = 'hello';
+ const wrapper = mount( TextArea );
+
+ wrapper.find( 'textarea' ).setValue( userInput );
+ expect( wrapper.emitted( 'input' )![ 0 ] ).toEqual( [ userInput ] );
+ } );
+} );