diff --git a/.storybook/styles.scss b/.storybook/styles.scss
index d9addb775..eaa4beb5d 100644
--- a/.storybook/styles.scss
+++ b/.storybook/styles.scss
@@ -52,6 +52,11 @@ $carbon--theme: $carbon--theme--white;
padding: $layout-04;
background-color: $ui-background;
color: $text-01;
+
+ // set space between form items
+ .bx--form-item {
+ margin-bottom: 2rem;
+ }
}
/**
diff --git a/src/components/CvForm/CvForm.stories.mdx b/src/components/CvForm/CvForm.stories.mdx
new file mode 100644
index 000000000..556faa016
--- /dev/null
+++ b/src/components/CvForm/CvForm.stories.mdx
@@ -0,0 +1,193 @@
+import { Canvas, Meta, Story } from '@storybook/addon-docs';
+import { action } from '@storybook/addon-actions';
+import { sbCompPrefix } from '../../global/storybook-utils';
+import CvButton from '../CvButton';
+import CvTextInput from '../CvTextInput';
+import CvTextArea from '../CvTextArea';
+import { CvForm, CvFormGroup, CvFormItem } from '.';
+
+
+
+export const Template = args => ({
+ components: {
+ CvForm,
+ CvFormGroup,
+ CvFormItem,
+ CvButton,
+ CvTextInput,
+ CvTextArea,
+ },
+ setup: () => {
+ const doSubmit = action('cv-form -submit event');
+ return {
+ args: { ...args, template: undefined },
+ onSubmit(ev) {
+ console.dir([].slice.call(ev.target, [0, ev.target.length]));
+ doSubmit(ev);
+ },
+ }
+ },
+ template: args.template,
+});
+
+export const defaultTemplate = `
+
+
+
+ Submit
+
+`;
+
+export const formGroupTemplate = `
+
+
+ FormGroup label-legend
+
+
+
+
+
+
+`;
+
+export const formItemTemplate = `
+
+
+
+
+`;
+
+# CvForm
+
+These components are purely wrapper elements for use in creating forms.
+
+## Usage CvForm
+
+CvForm has no properties and a single default slot
+
+
+
+
+## CvFormGroup
+
+Used inside a form to group components such as checkboxes and radio buttons.
+
+
+
+## CvFormItem
+
+Used inside a form to provide positional styling.
+
+
diff --git a/src/components/CvForm/CvForm.vue b/src/components/CvForm/CvForm.vue
new file mode 100644
index 000000000..c28d1daa4
--- /dev/null
+++ b/src/components/CvForm/CvForm.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/src/components/CvForm/CvFormGroup.vue b/src/components/CvForm/CvFormGroup.vue
new file mode 100644
index 000000000..da314529c
--- /dev/null
+++ b/src/components/CvForm/CvFormGroup.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/src/components/CvForm/CvFormItem.vue b/src/components/CvForm/CvFormItem.vue
new file mode 100644
index 000000000..31f782660
--- /dev/null
+++ b/src/components/CvForm/CvFormItem.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/src/components/CvForm/__tests__/CvForm.spec.js b/src/components/CvForm/__tests__/CvForm.spec.js
new file mode 100644
index 000000000..3cf4c3c92
--- /dev/null
+++ b/src/components/CvForm/__tests__/CvForm.spec.js
@@ -0,0 +1,32 @@
+import { render } from '@testing-library/vue';
+import { CvForm } from '..';
+
+describe('CvForm', () => {
+ it('defines a form as the root element', () => {
+ const { container } = render(CvForm);
+
+ const wrapper = container.firstElementChild;
+ expect(wrapper.tagName).toBe('FORM');
+ });
+
+ it('slots content inside the form', () => {
+ const dummyElement = '';
+ const { container } = render(CvForm, {
+ slots: { default: dummyElement },
+ });
+
+ const form = container.firstElementChild;
+ const button = form.querySelector('button');
+ expect(button).not.toBeNull();
+ });
+
+ it('sets attributes at the form element', () => {
+ const dummyId = 'dummy-id';
+ const { getByTestId } = render(CvForm, {
+ attrs: { 'data-testid': dummyId },
+ });
+
+ const form = getByTestId('dummy-id');
+ expect(form).not.toBeNull();
+ });
+});
diff --git a/src/components/CvForm/__tests__/CvFormGroup.spec.js b/src/components/CvForm/__tests__/CvFormGroup.spec.js
new file mode 100644
index 000000000..396313722
--- /dev/null
+++ b/src/components/CvForm/__tests__/CvFormGroup.spec.js
@@ -0,0 +1,61 @@
+import { render } from '@testing-library/vue';
+import { CvFormGroup } from '..';
+
+describe('CvFormGroup', () => {
+ it('defines a fieldset as the root element', () => {
+ const { container } = render(CvFormGroup);
+
+ const wrapper = container.firstElementChild;
+ expect(wrapper.tagName).toBe('FIELDSET');
+ });
+
+ it('sets a legend element with a slot space', () => {
+ const dummyLabel = 'dummy label';
+ const { getByText } = render(CvFormGroup, {
+ slots: { label: dummyLabel },
+ });
+
+ const legend = getByText(dummyLabel);
+ expect(legend).not.toBeNull();
+ expect(legend.tagName).toBe('LEGEND');
+ });
+
+ it('slots content inside the fieldset', () => {
+ const dummyElement = '';
+ const { container } = render(CvFormGroup, {
+ slots: { content: dummyElement },
+ });
+
+ const fieldset = container.firstElementChild;
+ const input = fieldset.querySelector('input');
+ expect(input).not.toBeNull();
+ });
+
+ it('displays a message if one is passed', () => {
+ const dummyMessage = 'some dummy message';
+ const { getByText } = render(CvFormGroup, {
+ props: { message: dummyMessage },
+ });
+
+ const element = getByText(dummyMessage);
+ expect(element).not.toBeNull();
+ });
+
+ it('styles fieldset with no margin class when noMargin is passed', () => {
+ const { container } = render(CvFormGroup, {
+ props: { noMargin: true },
+ });
+
+ const wrapper = container.firstElementChild;
+ expect(wrapper.classList.contains('bx--fieldset--no-margin')).toBeTruthy();
+ });
+
+ it('defines "data-invalid" attribute when invalid is passed', () => {
+ const { container } = render(CvFormGroup, {
+ props: { invalid: true },
+ });
+
+ const wrapper = container.firstElementChild;
+ expect(wrapper.getAttribute('data-invalid')).toBe('true');
+ });
+});
diff --git a/src/components/CvForm/__tests__/CvFormItem.spec.js b/src/components/CvForm/__tests__/CvFormItem.spec.js
new file mode 100644
index 000000000..52ee15fe5
--- /dev/null
+++ b/src/components/CvForm/__tests__/CvFormItem.spec.js
@@ -0,0 +1,34 @@
+import { render } from '@testing-library/vue';
+import { CvFormItem } from '..';
+
+describe('CvForm', () => {
+ it('defines a div with corresponding form item classes as the root element', () => {
+ const { container } = render(CvFormItem);
+
+ const wrapper = container.firstElementChild;
+ expect(wrapper.tagName).toBe('DIV');
+ expect(wrapper.classList.contains('cv-form-item')).toBeTruthy();
+ expect(wrapper.classList.contains('bx--form-item')).toBeTruthy();
+ });
+
+ it('slots content inside the form item wrapper', () => {
+ const dummyElement = '';
+ const { container } = render(CvFormItem, {
+ slots: { default: dummyElement },
+ });
+
+ const form = container.firstElementChild;
+ const input = form.querySelector('input');
+ expect(input).not.toBeNull();
+ });
+
+ it('sets attributes at the form item wrapper element', () => {
+ const dummyId = 'dummy-id';
+ const { getByTestId } = render(CvFormItem, {
+ attrs: { 'data-testid': dummyId },
+ });
+
+ const element = getByTestId('dummy-id');
+ expect(element).not.toBeNull();
+ });
+});
diff --git a/src/components/CvForm/index.js b/src/components/CvForm/index.js
new file mode 100644
index 000000000..0ed9c1d8b
--- /dev/null
+++ b/src/components/CvForm/index.js
@@ -0,0 +1,5 @@
+import CvForm from './CvForm';
+import CvFormGroup from './CvFormGroup';
+import CvFormItem from './CvFormItem';
+
+export { CvForm, CvFormGroup, CvFormItem };