Skip to content

Commit

Permalink
#13 - feat: add input component
Browse files Browse the repository at this point in the history
  • Loading branch information
svenvandescheur committed Jan 23, 2024
1 parent 3f0359f commit de86333
Show file tree
Hide file tree
Showing 9 changed files with 382 additions and 27 deletions.
23 changes: 23 additions & 0 deletions src/components/form/.storybook/decorators.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Decorator } from "@storybook/react";
import React, { useState } from "react";

export const FORM_TEST_DECORATOR: Decorator = (Story) => {
// Solely here to force re-rendering story on change.
const [count, setCount] = useState(0);

const getData = () => {
const form = document.forms[0];
const formData = new FormData(form);

// Convert FormData to JSON using Array.from and reduce
return Array.from(formData.entries()).reduce<
Record<string, FormDataEntryValue>
>((acc, [key, value]) => ({ ...acc, [key]: value }), {});
};
return (
<form onChange={() => setCount(count + 1)} aria-label="form">
<Story />
<pre role="log">{JSON.stringify(getData())}</pre>
</form>
);
};
1 change: 1 addition & 0 deletions src/components/form/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./input";
export * from "./select";
1 change: 1 addition & 0 deletions src/components/form/input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./input";
59 changes: 59 additions & 0 deletions src/components/form/input/input.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
.mykn-input {
appearance: none;
align-items: center;
background: var(--typography-color-background);
border: 1px solid var(--theme-color-primary-800);
border-radius: 6px;
box-sizing: border-box;
color: var(--typography-color-body);
font-family: Inter, sans-serif;
font-size: var(--typography-font-size-body-s);
line-height: var(--typography-line-height-body-s);
padding: var(--spacing-v-s) var(--spacing-h-s);
position: relative;
width: min(320px, 100%);
max-width: 100%;

&[size] {
width: auto;
}

&[type="color"] {
min-height: 38px;
overflow: hidden;
padding: 0;

&::-webkit-color-swatch-wrapper {
padding: 0;
}

&::-webkit-color-swatch {
border: none;
}

&:before,
&:after {
align-items: center;
color: var(--typography-color-body);
content: attr(value);
display: flex;
height: 50%;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
}

&:before {
opacity: 0.9;
background-color: var(--typography-color-background);
border-radius: 6px;
color: transparent;
padding: var(--spacing-v-s) var(--spacing-h-s);
}
}

&[type="file"] {
border: none;
}
}
221 changes: 221 additions & 0 deletions src/components/form/input/input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
import { Formik } from "formik";
import React from "react";

import { Button } from "../../button";
import { FORM_TEST_DECORATOR } from "../.storybook/decorators";
import { Input } from "./input";

const meta = {
title: "Form/Input",
component: Input,
play: async ({ canvasElement, args }) => {
const testValue =
args.value || args.placeholder?.replace("e.g. ", "") || "Hello world!";
const canvas = within(canvasElement);
let input;

switch (args.type) {
case "number":
input = await canvas.getByRole("spinbutton");
break;
case "password":
input = await canvas.getByPlaceholderText("Enter password");
break;
default:
input = await canvas.getByRole("textbox");
break;
}

const spy = fn();
input.addEventListener("change", spy);

await userEvent.click(input, { delay: 10 });
await userEvent.clear(input);
await userEvent.type(input, String(testValue));

// Test that event listener on the (custom) select gets called.
await waitFor(testEventListener);

async function testEventListener() {
await expect(spy).toHaveBeenCalled();
}

// Test that the FormData serialization returns the correct value.
await waitFor(testFormDataSerialization, {
timeout: String(testValue).length * 100,
});

async function testFormDataSerialization() {
const pre = await canvas.findByRole("log");
const data = JSON.parse(pre?.textContent || "{}");
await expect(data.input).toBe(testValue);
}
},
} satisfies Meta<typeof Input>;

export default meta;
type Story = StoryObj<typeof meta>;

const FORM_TEST_ARG_TYPES = {
onChange: { action: "onChange" },
};

export const InputComponent: Story = {
args: {
name: "input",
placeholder: "e.g. John Doe",
type: "text",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

export const InputTypeColor: Story = {
args: {
name: "input",
type: "color",
value: "#00bfcb",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
play: () => undefined,
};

// TODO: DateInput.
export const InputTypeDate: Story = {
args: {
name: "input",
placeholder: "e.g. 15-09-2023",
type: "date",
},
argTypes: FORM_TEST_ARG_TYPES,
play: () => undefined,
};

export const InputTypeEmail: Story = {
args: {
name: "input",
placeholder: "e.g. [email protected]",
type: "email",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

// TODO: FileInput.
export const InputTypeFile: Story = {
args: {
name: "input",
type: "file",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
play: () => undefined,
};

export const InputTypeNumber: Story = {
args: {
name: "input",
placeholder: "e.g. 3",
type: "number",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

export const InputTypePassword: Story = {
args: {
name: "input",
placeholder: "Enter password",
type: "password",
value: "p4$$w0rd",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

export const InputTypeTel: Story = {
args: {
name: "input",
placeholder: "e.g. +31 (0)20 753 05 23",
type: "tel",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

export const InputTypeUrl: Story = {
args: {
name: "input",
type: "Url",
placeholder: "e.g. https://www.maykinmedia.nl",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

export const InputWithCustomSize: Story = {
args: {
name: "input",
placeholder: "e.g. 1015CJ",
size: 6,
type: "text",
},
argTypes: FORM_TEST_ARG_TYPES,
decorators: [FORM_TEST_DECORATOR],
};

export const UsageWithFormik: Story = {
args: {
name: "input",
placeholder: "e.g. John Doe",
type: "text",
},
argTypes: {
// @ts-expect-error - Using FormikProps here while SelectProps is expected.
validate: { action: "validate" },
onSubmit: { action: "onSubmit" },
},
render: (args) => {
return (
<Formik
initialValues={{ input: "" }}
validate={action("validate")}
onSubmit={action("onSubmit")}
>
{({ handleChange, handleSubmit, values }) => (
<form onSubmit={handleSubmit}>
<Input
value={values.input}
onChange={handleChange}
{...args}
></Input>
<pre role="log">{JSON.stringify(values)}</pre>
<Button type="submit">Verzenden</Button>
</form>
)}
</Formik>
);
},
decorators: [(Story) => <Story />],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("textbox");

userEvent.clear(input);
userEvent.click(input, { delay: 10 });
userEvent.type(input, "John Doe");

// Test that the FormData serialization returns the correct value.
await waitFor(testFormikSerialization);

async function testFormikSerialization() {
const pre = await canvas.findByRole("log");
const data = JSON.parse(pre?.textContent || "{}");
await expect(data.input).toBe("John Doe");
}
},
};
70 changes: 70 additions & 0 deletions src/components/form/input/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useEffect, useState } from "react";

import { eventFactory } from "../eventFactory";
import "./input.scss";

export type InputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value"
> & {
/** Gets called when the value is changed */
onChange?: (event: Event) => void;

/** Input value. */
value?: string | number;
};

/**
* Input component
* @param children
* @param props
* @constructor
*/
export const Input: React.FC<InputProps> = ({
type = "text",
value,
onChange,
...props
}) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [valueState, setValueState] = useState(value || "");

/**
* Syncs value state with value prop change.
*/
useEffect(() => setValueState(value || ""), [value]);

/**
* Handles a change of value.
* @param event
*/
const _onChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setValueState(event.target.value);

/*
* Dispatch change event.
*
* A custom "change" event with `detail` set to the `event.target.value` is
* dispatched on `input.current`.
*
* This aims to improve compatibility with various approaches to dealing
* with forms.
*/
const input = inputRef.current as HTMLInputElement;
const detail = type === "file" ? input.files : event.target.value;
const changeEvent = eventFactory("change", detail, true, false, false);
input.dispatchEvent(changeEvent);
onChange && onChange(changeEvent);
};

return (
<input
ref={inputRef}
className="mykn-input"
type={type}
value={valueState}
onChange={_onChange}
{...props}
/>
);
};
Loading

0 comments on commit de86333

Please sign in to comment.