diff --git a/src/components/form/.storybook/decorators.tsx b/src/components/form/.storybook/decorators.tsx new file mode 100644 index 00000000..ed78b5db --- /dev/null +++ b/src/components/form/.storybook/decorators.tsx @@ -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 + >((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + }; + return ( +
setCount(count + 1)} aria-label="form"> + +
{JSON.stringify(getData())}
+ + ); +}; diff --git a/src/components/form/index.ts b/src/components/form/index.ts index e1856bb3..50cf6932 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -1 +1,2 @@ +export * from "./input"; export * from "./select"; diff --git a/src/components/form/input/index.ts b/src/components/form/input/index.ts new file mode 100644 index 00000000..4ce4a889 --- /dev/null +++ b/src/components/form/input/index.ts @@ -0,0 +1 @@ +export * from "./input"; diff --git a/src/components/form/input/input.scss b/src/components/form/input/input.scss new file mode 100644 index 00000000..afe117f1 --- /dev/null +++ b/src/components/form/input/input.scss @@ -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; + } +} diff --git a/src/components/form/input/input.stories.tsx b/src/components/form/input/input.stories.tsx new file mode 100644 index 00000000..b0b44606 --- /dev/null +++ b/src/components/form/input/input.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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. johndoe@example.com", + 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 ( + + {({ handleChange, handleSubmit, values }) => ( +
+ +
{JSON.stringify(values)}
+ +
+ )} +
+ ); + }, + decorators: [(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"); + } + }, +}; diff --git a/src/components/form/input/input.tsx b/src/components/form/input/input.tsx new file mode 100644 index 00000000..791cb7fb --- /dev/null +++ b/src/components/form/input/input.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useState } from "react"; + +import { eventFactory } from "../eventFactory"; +import "./input.scss"; + +export type InputProps = Omit< + React.InputHTMLAttributes, + "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 = ({ + type = "text", + value, + onChange, + ...props +}) => { + const inputRef = React.useRef(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 = (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 ( + + ); +}; diff --git a/src/components/form/select/select.stories.tsx b/src/components/form/select/select.stories.tsx index 25e2008f..a89d63a3 100644 --- a/src/components/form/select/select.stories.tsx +++ b/src/components/form/select/select.stories.tsx @@ -2,9 +2,10 @@ 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, { useState } from "react"; +import React from "react"; import { Button } from "../../button"; +import { FORM_TEST_DECORATOR } from "../.storybook/decorators"; import { Select } from "./select"; const meta = { @@ -31,28 +32,7 @@ export const SelectComponent: Story = { argTypes: { onChange: { action: "onChange" }, }, - decorators: [ - (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 - >((acc, [key, value]) => ({ ...acc, [key]: value }), {}); - }; - return ( -
setCount(count + 1)} aria-label="form"> - -
{JSON.stringify(getData())}
- - ); - }, - ], + decorators: [FORM_TEST_DECORATOR], play: async ({ canvasElement }) => { const canvas = within(canvasElement); const select = canvas.getByRole("combobox"); @@ -61,9 +41,9 @@ export const SelectComponent: Story = { const spy = fn(); select.addEventListener("change", spy); - userEvent.click(select, { delay: 10 }); + await userEvent.click(select, { delay: 10 }); const junior = await canvas.findByText("Junior"); - userEvent.click(junior, { delay: 10 }); + await userEvent.click(junior, { delay: 10 }); // Test that event listener on the (custom) select gets called. await waitFor(testEventListener); diff --git a/src/components/form/select/select.tsx b/src/components/form/select/select.tsx index 23ffe9e4..3f771889 100644 --- a/src/components/form/select/select.tsx +++ b/src/components/form/select/select.tsx @@ -29,7 +29,7 @@ export type SelectProps = React.HTMLAttributes & { options: Option[]; /** - * Get called when the selected option is changed + * Gets called when the selected option is changed * * A custom "change" event created with `detail` set to the selected option. * The event is dispatched on `fakeInputRef.current` setting `target` to a diff --git a/src/components/index.ts b/src/components/index.ts index 14c7d226..eaaaa227 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,11 +2,11 @@ export * from "./button"; export * from "./card"; export * from "./dropdown"; +export * from "./form"; export * from "./icon"; export * from "./layout"; export * from "./logo"; export * from "./navbar"; export * from "./page"; -export * from "./form"; export * from "./toolbar"; export * from "./typography";