Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ #13 - feat: add input component #13

Merged
merged 1 commit into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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