diff --git a/src/components/form/.storybook/decorators.tsx b/src/components/form/.storybook/decorators.tsx index a8b9507d..7c048b4a 100644 --- a/src/components/form/.storybook/decorators.tsx +++ b/src/components/form/.storybook/decorators.tsx @@ -1,12 +1,22 @@ import { Decorator } from "@storybook/react"; import * as React from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; + + import { serializeForm } from "../../../lib"; + export const FORM_TEST_DECORATOR: Decorator = (Story) => { // Solely here to force re-rendering story on change. const [count, setCount] = useState(0); + const formRef = useRef(null); + + useEffect(() => { + const update = () => setCount(count + 1); + formRef.current?.addEventListener("change", update); + return () => formRef.current?.removeEventListener("change", update); + }, [formRef.current, count]); const getData = () => { const form = document.forms[0]; @@ -14,11 +24,7 @@ export const FORM_TEST_DECORATOR: Decorator = (Story) => { }; return ( -
setCount(count + 1)} - aria-label="form" - style={{ width: "100%" }} - > +
{JSON.stringify(getData())}
diff --git a/src/components/form/dateinput/dateinput.scss b/src/components/form/dateinput/dateinput.scss new file mode 100644 index 00000000..b640d17a --- /dev/null +++ b/src/components/form/dateinput/dateinput.scss @@ -0,0 +1,36 @@ +.mykn-dateinput { + display: inline-flex; + + &__input[aria-hidden="true"] { + opacity: 0; + position: absolute; + visibility: hidden; + z-index: -10; + } + + .mykn-input { + box-sizing: content-box; + text-align: center; + width: 2em !important; + } + + .mykn-input:focus { + z-index: 10; + } + + .mykn-input[data-section="YY"] { + width: 3em !important; + } + + .mykn-input:has(+ .mykn-input) { + border-start-end-radius: 0; + border-end-end-radius: 0; + border-inline-end-style: dashed; + } + + .mykn-input + .mykn-input { + border-inline-start: none; + border-start-start-radius: 0; + border-end-start-radius: 0; + } +} diff --git a/src/components/form/dateinput/dateinput.stories.tsx b/src/components/form/dateinput/dateinput.stories.tsx new file mode 100644 index 00000000..ab0681dd --- /dev/null +++ b/src/components/form/dateinput/dateinput.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; + +import { FORM_TEST_DECORATOR } from "../.storybook/decorators"; +import { DateInput } from "./dateinput"; + +const meta = { + title: "Form/DateInput", + component: DateInput, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DateInputComponent: Story = { + args: { + name: "date", + }, +}; + +export const SeparatedInputs: Story = { + args: { + name: "date", + format: "DDMMYYYY", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const day = await canvas.getAllByRole("textbox")[0]; + const month = await canvas.getAllByRole("textbox")[1]; + const year = await canvas.getAllByRole("textbox")[2]; + + await userEvent.type(day, "15", { delay: 60 }); + await userEvent.type(month, "09", { delay: 60 }); + await userEvent.type(year, "2023", { delay: 60 }); + + await expect(JSON.parse(log.textContent || "{}").date).toBe("2023-09-15"); + }, +}; + +export const ContinuousTyping: Story = { + args: { + name: "date", + format: "DDMMYYYY", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const input = await canvas.getAllByRole("textbox")[0]; + + await userEvent.type(input, "15092023", { delay: 60 }); + await expect(JSON.parse(log.textContent || "{}").date).toBe("2023-09-15"); + }, +}; + +export const ClearSections: Story = { + args: { + name: "date", + format: "DDMMYYYY", + value: "2023-09-15", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const day = await canvas.getAllByRole("textbox")[0]; + const year = await canvas.getAllByRole("textbox")[2]; + const log = canvas.getByRole("log"); + + await userEvent.click(year); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + + await expect(document.activeElement).toBe(day); + await userEvent.type(day, "02081988", { delay: 60 }); + await expect(JSON.parse(log.textContent || "{}").date).toBe("1988-08-02"); + }, +}; + +export const IsoFormat: Story = { + args: { + name: "date", + format: "YYYYMMDD", + value: "2023-09-15", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const input = await canvas.getAllByRole("textbox")[0]; + + await userEvent.type(input, "20230915", { delay: 60 }); + await expect(JSON.parse(log.textContent || "{}").date).toBe("2023-09-15"); + }, +}; diff --git a/src/components/form/dateinput/dateinput.translations.ts b/src/components/form/dateinput/dateinput.translations.ts new file mode 100644 index 00000000..09039a65 --- /dev/null +++ b/src/components/form/dateinput/dateinput.translations.ts @@ -0,0 +1,41 @@ +// Define the structure of a single message descriptor +import { defineMessages } from "../../../lib"; + +export const TRANSLATIONS = defineMessages({ + LABEL_DATE: { + id: "mykn.components.DateInput.labelDate", + description: "mykn.components.DateInput: The date field (accessible) label", + defaultMessage: "dag van de maand", + }, + + LABEL_MONTH: { + id: "mykn.components.DateInput.labelMonth", + description: + "mykn.components.DateInput: The month field (accessible) label", + defaultMessage: "maand", + }, + + LABEL_YEAR: { + id: "mykn.components.DateInput.labelYear", + description: "mykn.components.DateInput: The year field (accessible) label", + defaultMessage: "jaar", + }, + + PLACEHOLDER_DATE: { + id: "mykn.components.DateInput.placeholderDate", + description: "mykn.components.DateInput: The date field placeholder", + defaultMessage: "dd", + }, + + PLACEHOLDER_MONTH: { + id: "mykn.components.DateInput.placeholderMonth", + description: "mykn.components.DateInput: The month field placeholder", + defaultMessage: "mm", + }, + + PLACEHOLDER_YEAR: { + id: "mykn.components.DateInput.placeholderYear", + description: "mykn.components.DateInput: The year field placeholder", + defaultMessage: "jjjj", + }, +}); diff --git a/src/components/form/dateinput/dateinput.tsx b/src/components/form/dateinput/dateinput.tsx new file mode 100644 index 00000000..cd00700b --- /dev/null +++ b/src/components/form/dateinput/dateinput.tsx @@ -0,0 +1,486 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { date2DateString, useIntl, value2Date } from "../../../lib/"; +import { eventFactory } from "../eventFactory"; +import { Input, InputProps } from "../input"; +import "./dateinput.scss"; +import { TRANSLATIONS } from "./dateinput.translations"; + +export type DateInputProps = { + /** The associated form's id. */ + form?: string; + + /** The order of fields. */ + format?: "DDMMYYYY" | "YYYYMMDD"; + + /** Whether a date or date range should be provided. */ + type?: "date"; + + /** Props to pass to child inputs. */ + inputProps?: InputProps; + + /** DateInput label. */ + label?: string; + + /** Date input label. */ + labelDate?: string; + /** Month input label. */ + labelMonth?: string; + /** Year input label. */ + labelYear?: string; + + /** Date input placeholder. */ + placeholderDate?: string; + /** Month input placeholder. */ + placeholderMonth?: string; + /** Year input placeholder. */ + placeholderYear?: string; + + /** The name. */ + name?: string; + + /** Whether to apply padding. */ + pad?: boolean | "h" | "v"; + + /** Whether a value is required. */ + required?: boolean; + + /** The size. */ + size?: "xl" | "s" | "xs" | "xxs"; + + /** (Date) string. */ + value?: string; + + /** Gets called when the value is changed. */ + onChange?: React.ChangeEventHandler; +}; + +/** + * DateInput component, can be used to type a date using separated inputs. + */ +export const DateInput: React.FC = ({ + form, + format = "DDMMYYYY", + inputProps, + label, + labelDate, + labelMonth, + labelYear, + placeholderDate, + placeholderMonth, + placeholderYear, + pad, + required, + size, + value = null, + onChange, + ...props +}) => { + type SanitizedValues = { DD: string; MM: string; YY: string }; + const debounceRef = useRef(); + const fakeInputRef = useRef(null); + const [isPristine, setIsPristine] = useState(true); + const [sanitizedValuesState, setSanitizedValuesState] = useState< + SanitizedValues | undefined + >(); + const intl = useIntl(); + + /** + * 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. + * @param dateString + */ + const dispatchEvent = useCallback( + (dateString: string) => { + const input = fakeInputRef.current as HTMLInputElement; + input.value = dateString; + + // Construct custom event. + const changeEvent = eventFactory( + "change", + dateString, + true, + false, + false, + ); + + // Dispatch event and trigger callback. + setTimeout(() => { + input.dispatchEvent(changeEvent); + onChange && + onChange( + changeEvent as unknown as React.ChangeEvent, + ); + }, 0); + }, + [fakeInputRef, onChange], + ); + + // Update sanitizedValuesState. + useEffect(() => { + if (!value) { + return; + } + const date = value2Date(value); + + if (date) { + const sanitizedValues = date2SanitizedValues(date); + setSanitizedValuesState(sanitizedValues); + } + }, [value]); + + // Respect form reset. + useEffect(() => { + const input = fakeInputRef.current as HTMLInputElement; + const form = input.form; + if (!form) return; + + const clear = () => setSanitizedValuesState(undefined); + form.addEventListener("reset", clear); + return () => input.removeEventListener("reset", clear); + }, [form, fakeInputRef]); + + // Dispatch event on change. + useEffect(() => { + // Event handling. + const date = + sanitizedValuesState && sanitizedValues2Date(sanitizedValuesState); + + if (date) { + // Date is valid, dispatch `dateString`. + const dateString = date2DateString(date); + dispatchEvent(dateString); + setIsPristine(false); + } else if (!isPristine) { + // Date is invalid after previous valid value, dispatch "". + dispatchEvent(""); + setIsPristine(false); + } + }, [sanitizedValuesState]); + + /** + * Returns `SanitizedValues` for the optionally given `Date`, if date is omitted, result contains empty values. + */ + const date2SanitizedValues = useCallback((sanitizedValue: Date) => { + return { + DD: sanitizedValue.getDate().toString().padStart(2, "0"), + MM: (sanitizedValue.getMonth() + 1).toString().padStart(2, "0"), + YY: sanitizedValue.getFullYear().toString().padStart(2, "0"), + }; + }, []); + + /** + * Returns whether `date` is a valid date. + * @param date + */ + const isValidDate = useCallback((date: Date): boolean => { + return !isNaN(date.getTime()); + }, []); + + /** + * Returns `Date` for the given `sanitizedValues`, is resulting date is not a + * valid date string: `undefined` is returned instead. + */ + const sanitizedValues2Date = useCallback( + ({ DD, MM, YY }: SanitizedValues) => { + // No valid value. + if (!DD || !MM || !YY) { + return; + } + + const date = parseInt(DD); + const month = parseInt(MM); + const monthZeroIndexed = month - 1; + const year = parseInt(YY); + + const dateObject = new Date(); + dateObject.setDate(date); + dateObject.setMonth(monthZeroIndexed); + dateObject.setFullYear(year); + + if (!isValidDate(dateObject)) { + return; + } + + return dateObject; + }, + [isValidDate], + ); + + /** + * Focuses input section identified by `section`. + * @param section + */ + const focusSection = useCallback( + (direction: "forwards" | "backwards") => { + const fn = () => { + const active = document.activeElement as HTMLElement; + const activeSection = active.dataset?.section; + const nextSection = activeSection === "DD" ? "MM" : "YY"; + const previousSection = activeSection === "YY" ? "MM" : "DD"; + const section = + direction === "forwards" ? nextSection : previousSection; + + const input = fakeInputRef.current; + if (!input) return; + + const parent = input.parentElement; + parent + ?.querySelector(`[data-section="${section}"]`) + ?.focus(); + }; + + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(fn, 60); + }, + [debounceRef.current, fakeInputRef.current], + ); + + /** + * Gets called when any of the section inputs is changed. + */ + const handleChange = useCallback>( + (event) => { + event.preventDefault(); + event.stopPropagation(); + + // Construct SanitizedValues. + const { dataset, value } = event.target; + const section = dataset.section as "DD" | "MM" | "YY"; + const newSanitizedValues = { + ...(sanitizedValuesState || { + DD: "", + MM: "", + YY: "", + }), + }; + + // State update. + newSanitizedValues[section] = value; + const newSanitizedValuesState = { + ...sanitizedValuesState, + ...newSanitizedValues, + }; + setSanitizedValuesState((state) => ({ + ...state, + ...newSanitizedValuesState, + })); + }, + [sanitizedValuesState], + ); + + /** + * Gets called when a key is pressed when any of the section inputs has focus. + * Validates the input + * @param event + */ + const onKeyDown = useCallback>( + (event) => { + // Character key, prevent input. + if (event.code.startsWith("Key")) { + event.preventDefault(); + } + + const target = event.target as HTMLInputElement; + const { dataset, selectionStart, selectionEnd, value } = target; + const section = dataset.section as "DD" | "MM" | "YY"; + const currentValue = + sanitizedValuesState?.[section] && + parseInt(sanitizedValuesState?.[section]).toString(); + const isSelected = + value && selectionStart === 0 && selectionEnd === value.length; + + // No numeric value, keep native behaviour. + if (!event.key.match(/^\d$/)) { + return; + } + + // Ignore year section or completed inputs. + if (section === "YY" || currentValue?.length === 2) return; + + const newValue = parseInt(`${currentValue}${event.key}`); + const limit = section === "DD" ? 31 : 12; + + if (newValue > limit && !isSelected) { + event.preventDefault(); + } + }, + [sanitizedValuesState], + ); + + /** + * Gets called when a key is pressed when any of the section inputs has focus. + * Focuses the next applicable section input. + * @param event + */ + const onKeyUp = useCallback>( + (event) => { + // No numeric value and no backspace. + if (!event.key.match(/^\d$/) && event.key !== "Backspace") { + return; + } + const { dataset, value } = event.target as HTMLInputElement; + const section = dataset.section as "DD" | "MM" | "YY"; + const isCompleted = section !== "YY" && value.length === 2; + const isCleared = !value && event.key === "Backspace"; + + if (isCompleted) { + focusSection("forwards"); + } else if (isCleared) { + focusSection("backwards"); + } + }, + [focusSection], + ); + + // Format as array of sections, "YYYY" is replaced with "YY". + const sanitizedFormat = useMemo(() => { + return format.replace("YYYY", "YY").match(/([\w]{2})/g) as ( + | "DD" + | "MM" + | "YY" + )[]; + }, [format]); + + // Base props for all section inputs. + const baseProps = useMemo(() => { + return { + form, + pattern: "[0-9]*", + onChange: handleChange, + onFocus: (e: React.FocusEvent) => e.target.select(), + onKeyDown: onKeyDown, + onKeyUp: onKeyUp, + size, + type: "text", + pad: pad, + required, + }; + }, [props]); + + // Input sections. + const inputs = useMemo(() => { + return sanitizedFormat.map((section) => { + switch (section) { + case "DD": + return ( + + ); + + case "MM": + return ( + + ); + + case "YY": + return ( + + ); + + default: + throw new Error(`Invalid date format (${format})!`); + } + }); + }, [ + inputProps, + labelDate, + labelMonth, + labelYear, + sanitizedFormat, + sanitizedValuesState, + baseProps, + ]); + + // Current date for value attribute. + const date = useMemo(() => { + return sanitizedValuesState && sanitizedValues2Date(sanitizedValuesState); + }, [sanitizedValuesState]); + + // Value attribute. + const dateString = useMemo(() => { + return (date && date2DateString(date)) || ""; + }, [date]); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopPropagation(); + e.nativeEvent.preventDefault(); + }} + > + + {inputs} +
+ ); +}; diff --git a/src/components/form/dateinput/index.ts b/src/components/form/dateinput/index.ts new file mode 100644 index 00000000..1f795360 --- /dev/null +++ b/src/components/form/dateinput/index.ts @@ -0,0 +1 @@ +export * from "./dateinput"; diff --git a/src/components/form/datepicker/datepicker.stories.tsx b/src/components/form/datepicker/datepicker.stories.tsx index eec14a53..a6f19b74 100644 --- a/src/components/form/datepicker/datepicker.stories.tsx +++ b/src/components/form/datepicker/datepicker.stories.tsx @@ -21,7 +21,7 @@ type Story = StoryObj; export const DatepickerComponent: Story = { args: { - type: "date", + type: "datepicker", name: "date", value: "2023-09-15", }, @@ -71,7 +71,7 @@ export const DatePickerWithoutValue: Story = { ...DatepickerComponent, args: { ...DatepickerComponent.args, - type: "date", + type: "datepicker", }, }; @@ -79,7 +79,7 @@ export const DatePickerWithDateAsValue: Story = { ...DatepickerComponent, args: { ...DatepickerComponent.args, - type: "date", + type: "datepicker", value: new Date("2023-09-15"), }, }; @@ -88,7 +88,7 @@ export const DatePickerWithNumberAsValue: Story = { ...DatepickerComponent, args: { ...DatepickerComponent.args, - type: "date", + type: "datepicker", value: 1694736000000, }, }; @@ -98,7 +98,7 @@ export const DatePickerWithNumberAsValue: Story = { export const DateRangePicker: Story = { args: { name: "daterange", - type: "daterange", + type: "daterangepicker", value: "2023-09-14/2023-09-15", }, decorators: [FORM_TEST_DECORATOR], @@ -148,7 +148,7 @@ export const DateRangePickerWithoutValue: Story = { ...DateRangePicker, args: { ...DateRangePicker.args, - type: "daterange", + type: "daterangepicker", }, }; @@ -156,7 +156,7 @@ export const DateRangePickerWithDatesAsValue: Story = { ...DateRangePicker, args: { ...DateRangePicker.args, - type: "daterange", + type: "daterangepicker", value: [new Date("2023-09-14"), new Date("2023-09-15")], }, }; diff --git a/src/components/form/datepicker/datepicker.tsx b/src/components/form/datepicker/datepicker.tsx index 8e034781..2c70abab 100644 --- a/src/components/form/datepicker/datepicker.tsx +++ b/src/components/form/datepicker/datepicker.tsx @@ -21,7 +21,7 @@ export type DatePickerProps = Omit< form?: string; /** Whether a date or date range should be provided. */ - type?: "date" | "daterange"; + type?: "datepicker" | "daterangepicker"; /** DatePicker label. */ label?: string; @@ -365,6 +365,7 @@ export const DatePicker: React.FC = ({ hidden defaultValue={value2string(valueState)} form={form} + data-mykn-type={type === "daterangepicker" ? "daterange" : "date"} /> = ({ locale={locale} placeholderText={placeholder} selected={date} - selectsRange={type === "daterange"} + selectsRange={type === "daterangepicker"} startDate={dateRange[0]} endDate={dateRange[1]} showYearDropdown diff --git a/src/components/form/daterangeinput/daterangeinput.scss b/src/components/form/daterangeinput/daterangeinput.scss new file mode 100644 index 00000000..31f454bf --- /dev/null +++ b/src/components/form/daterangeinput/daterangeinput.scss @@ -0,0 +1,16 @@ +.mykn-daterangeinput { + display: inline-flex; + align-items: center; + column-gap: var(--spacing-h); + + .mykn-icon { + color: var(--typography-color-muted); + } + + &__input[aria-hidden="true"] { + opacity: 0; + position: absolute; + visibility: hidden; + z-index: -10; + } +} diff --git a/src/components/form/daterangeinput/daterangeinput.stories.tsx b/src/components/form/daterangeinput/daterangeinput.stories.tsx new file mode 100644 index 00000000..4361c8ce --- /dev/null +++ b/src/components/form/daterangeinput/daterangeinput.stories.tsx @@ -0,0 +1,170 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; + +import { FORM_TEST_DECORATOR } from "../.storybook/decorators"; +import { DateRangeInput } from "./daterangeinput"; + +const meta = { + title: "Form/DateRangeInput", + component: DateRangeInput, + argTypes: { + onChange: { + action: "onChange", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const DateRangeInputComponent: Story = { + args: { + name: "daterange", + value: "1988-08-02/2023-09-15", + }, + decorators: [FORM_TEST_DECORATOR], +}; + +export const SeparatedInputs: Story = { + args: { + name: "date", + format: "DDMMYYYY", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const dayStart = await canvas.getAllByRole("textbox")[0]; + const monthStart = await canvas.getAllByRole("textbox")[1]; + const yearStart = await canvas.getAllByRole("textbox")[2]; + + await userEvent.type(dayStart, "02", { delay: 60 }); + await userEvent.type(monthStart, "08", { delay: 60 }); + await userEvent.type(yearStart, "1988", { delay: 60 }); + await userEvent.tab({ delay: 60 }); + + const dayEnd = await canvas.getAllByRole("textbox")[3]; + const monthEnd = await canvas.getAllByRole("textbox")[4]; + const yearEnd = await canvas.getAllByRole("textbox")[5]; + + await userEvent.type(dayEnd, "15", { delay: 60 }); + await userEvent.type(monthEnd, "09", { delay: 60 }); + await userEvent.type(yearEnd, "2023", { delay: 60 }); + await userEvent.tab({ delay: 60 }); + + await expect(JSON.parse(log.textContent || "{}").date).toBe( + "1988-08-02/2023-09-15", + ); + }, +}; + +export const ContinuousTyping: Story = { + args: { + name: "date", + format: "DDMMYYYY", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const inputStart = await canvas.getAllByRole("textbox")[0]; + await userEvent.type(inputStart, "02081988", { delay: 60 }); + await userEvent.tab({ delay: 60 }); + + const inputEnd = await canvas.getAllByRole("textbox")[3]; + await userEvent.type(inputEnd, "15092023", { delay: 60 }); + await userEvent.tab({ delay: 60 }); + + await expect(JSON.parse(log.textContent || "{}").date).toBe( + "1988-08-02/2023-09-15", + ); + }, +}; + +export const NormalizeStartEnd: Story = { + args: { + name: "date", + format: "DDMMYYYY", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const inputStart = await canvas.getAllByRole("textbox")[0]; + await userEvent.type(inputStart, "15092023", { delay: 60 }); + await userEvent.tab({ delay: 60 }); + + const inputEnd = await canvas.getAllByRole("textbox")[3]; + await userEvent.type(inputEnd, "02081988", { delay: 60 }); + await userEvent.tab({ delay: 60 }); + + await expect(JSON.parse(log.textContent || "{}").date).toBe( + "1988-08-02/2023-09-15", + ); + }, +}; + +export const ClearSections: Story = { + args: { + name: "date", + format: "DDMMYYYY", + value: "1988-08-02/2023-09-15", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const dayEnd = await canvas.getAllByRole("textbox")[3]; + const yearEnd = await canvas.getAllByRole("textbox")[5]; + + await userEvent.click(yearEnd); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + + await expect(document.activeElement).toBe(dayEnd); + + const dayStart = await canvas.getAllByRole("textbox")[0]; + const yearStart = await canvas.getAllByRole("textbox")[2]; + + await userEvent.click(yearStart); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + await userEvent.keyboard("{Backspace}", { delay: 60 }); + + await expect(document.activeElement).toBe(dayStart); + + await userEvent.click(yearEnd); + await userEvent.tab({ delay: 60 }); + await expect(JSON.parse(log.textContent || "{}").date).toBe(""); + }, +}; + +export const IsoFormat: Story = { + args: { + name: "date", + format: "YYYYMMDD", + value: "1988-08-02/2023-09-15", + }, + decorators: [FORM_TEST_DECORATOR], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const log = canvas.getByRole("log"); + + const inputStart = await canvas.getAllByRole("textbox")[0]; + await userEvent.type(inputStart, "19880802", { delay: 60 }); + + await userEvent.tab({ delay: 60 }); + + const inputEnd = await canvas.getAllByRole("textbox")[3]; + await userEvent.type(inputEnd, "20230915", { delay: 60 }); + + await expect(JSON.parse(log.textContent || "{}").date).toBe( + "1988-08-02/2023-09-15", + ); + }, +}; diff --git a/src/components/form/daterangeinput/daterangeinput.translations.ts b/src/components/form/daterangeinput/daterangeinput.translations.ts new file mode 100644 index 00000000..42027890 --- /dev/null +++ b/src/components/form/daterangeinput/daterangeinput.translations.ts @@ -0,0 +1,18 @@ +// Define the structure of a single message descriptor +import { defineMessages } from "../../../lib"; + +export const TRANSLATIONS = defineMessages({ + LABEL_START_DATE: { + id: "mykn.components.DateRangeInput.labelStartDate", + description: + "mykn.components.DateRangeInput: The start date (accessible) label", + defaultMessage: "startdatum", + }, + + LABEL_END_DATE: { + id: "mykn.components.DateRangeInput.labelEndDate", + description: + "mykn.components.DateRangeInput: The end date (accessible) label", + defaultMessage: "einddatum", + }, +}); diff --git a/src/components/form/daterangeinput/daterangeinput.tsx b/src/components/form/daterangeinput/daterangeinput.tsx new file mode 100644 index 00000000..b93d29ac --- /dev/null +++ b/src/components/form/daterangeinput/daterangeinput.tsx @@ -0,0 +1,219 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; + +import { date2DateString, useIntl, value2Date } from "../../../lib"; +import { Outline } from "../../icon/icon"; +import { DateInput, DateInputProps } from "../dateinput"; +import { eventFactory } from "../eventFactory"; +import "./daterangeinput.scss"; +import { TRANSLATIONS } from "./daterangeinput.translations"; + +export type DateRangeInputProps = Omit & { + /** Whether a date or date range should be provided. */ + type?: "daterange"; + + /** (Date range) string. */ + value?: string; + + /** The start date (accessible) label */ + labelStartDate: string; + + /** The end date (accessible) label */ + labelEndDate: string; +}; + +/** + * DateRangeInput component, can be used to type a daterange using separated inputs. + */ +export const DateRangeInput: React.FC = ({ + form, + labelStartDate, + labelEndDate, + name, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + type, + value = null, + onChange, + ...props +}) => { + const nodeRef = useRef(null); + const fakeInputRef = useRef(null); + const [valuesState, setValuesState] = useState(); + const queuedValueState = useRef(); + const intl = useIntl(); + + /** + * 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. + * @param dateString + */ + const dispatchEvent = useCallback( + (dateString: string) => { + const input = fakeInputRef.current as HTMLInputElement; + input.value = dateString; + + // Construct custom event. + const changeEvent = eventFactory( + "change", + dateString, + true, + false, + false, + ); + + // Dispatch event and trigger callback. + setTimeout(() => { + input.dispatchEvent(changeEvent); + onChange && + onChange( + changeEvent as unknown as React.ChangeEvent, + ); + }, 0); + }, + [fakeInputRef, onChange], + ); + + // Intercept the child events on DOM level using listener. + useEffect(() => { + const fn: EventListener = (e) => { + const target = e.target as HTMLElement; + if (!target.className.includes("mykn-daterangeinput")) { + e.preventDefault(); + e.stopPropagation(); + } + }; + nodeRef.current?.addEventListener("change", fn); + return () => nodeRef.current?.removeEventListener("change", fn); + }, [nodeRef.current]); + + // Update valueState. + useEffect(() => { + const values = typeof value === "string" ? value.split("/") : value; + const dates = values + ?.map(value2Date) + .filter((v): v is Date => Boolean(v)) + .map(date2DateString); + + setValuesState(dates); + }, [value]); + + // Dispatch event on change. + useEffect(() => { + dispatchEvent(valuesState?.join("/") || ""); + }, [valuesState]); + + /** + * Normalizes value state, making sure that end date is later than startDate. + * @param valuesState + */ + const normalizeValuesState = useCallback((values?: string[]) => { + if (!values?.some((v) => v)) { + return; + } + + const [startDate, endDate] = values + .map(value2Date) + .filter((v): v is Date => Boolean(v)); + + if (startDate > endDate) { + return [date2DateString(endDate), date2DateString(startDate)]; + } + return values; + }, []); + + /** + * Gets called when the start date is changed. + * @param event + */ + const handleStartChange: React.ChangeEventHandler = ( + event, + ) => { + handleChange(event, 0); + }; + + /** + * Gets called when the end date is changed. + * @param event + */ + const handleEndChange: React.ChangeEventHandler = ( + event, + ) => { + handleChange(event, 1); + }; + + /** + * Gets called when any of the dates is changed. + * @param event + * @param index The index of the date input (0: start, 1: end). + */ + const handleChange = useCallback( + (event: React.ChangeEvent, index: number) => { + event?.preventDefault(); + event?.stopPropagation(); + + const flippedIndex = index ? 0 : 1; + const _values = queuedValueState.current || []; + const value = event.target.value; + + const newValues = new Array(2); + newValues[index] = value; + newValues[flippedIndex] = _values[flippedIndex] || value; + console.log("handleChange", _values, newValues); + queuedValueState.current = newValues; + }, + [queuedValueState.current], + ); + + /** + * Gets called when a blur event is received. + * @param event + * @param index The index of the date input (0: start, 1: end). + */ + const handleBlur = useCallback>( + ({ currentTarget, relatedTarget }) => { + if (!relatedTarget || !currentTarget.contains(relatedTarget)) { + const newValuesState = normalizeValuesState(queuedValueState.current); + setValuesState(newValuesState); + } + }, + [document.activeElement, valuesState, normalizeValuesState], + ); + return ( +
+ []} + /> + + {" "} + +
+ ); +}; diff --git a/src/components/form/daterangeinput/index.ts b/src/components/form/daterangeinput/index.ts new file mode 100644 index 00000000..898be1f7 --- /dev/null +++ b/src/components/form/daterangeinput/index.ts @@ -0,0 +1 @@ +export * from "./daterangeinput"; diff --git a/src/components/form/form/form.stories.tsx b/src/components/form/form/form.stories.tsx index 6228659d..30d7dad1 100644 --- a/src/components/form/form/form.stories.tsx +++ b/src/components/form/form/form.stories.tsx @@ -65,7 +65,7 @@ const playFormComponent = async ({ const schoolYear = canvas.getByLabelText("Select school year"); const address = canvas.getByLabelText("Address"); const address_addition = canvas.getByLabelText("Address (addition)"); - const dateOfBirth = canvas.getByLabelText("Date of birth"); + const dayOfMonth = canvas.getByLabelText("day of month"); const english = canvas.getByLabelText("English"); const math = canvas.getByLabelText("Math"); const yes = canvas.getByLabelText("Yes"); @@ -116,14 +116,14 @@ const playFormComponent = async ({ typedResults ? ["Keizersgracht 117", 2] : ["Keizersgracht 117", "2"], ); - await userEvent.clear(dateOfBirth); - await userEvent.type(dateOfBirth, "2023-09-15", { delay: 10 }); - await userEvent.type(dateOfBirth, "{enter}"); - await expect(dateOfBirth).toHaveValue("09/15/2023"); + await userEvent.clear(dayOfMonth); + await userEvent.type(dayOfMonth, "15092023", { delay: 60 }); + await userEvent.type(dayOfMonth, "{enter}"); + await expect(dayOfMonth).toHaveValue("15"); await expectLogToBe( canvasElement, "date_of_birth", - typedResults ? "2023-09-15" : "2023-09-15", + typedResults && !formik ? "2023-09-15T00:00:00.000Z" : "2023-09-15", ); await userEvent.click(schoolYear); @@ -165,14 +165,20 @@ export const FormComponent: Story = { { label: "First name", name: "first_name", required: true }, { label: "Last name", name: "last_name", required: true }, { label: "Age", name: "age", type: "number", required: true }, - { label: "Address", name: "address", required: true }, + { label: "Address", name: "address", required: true, inputSize: 50 }, { label: "Address (addition)", name: "address", type: "number", required: true, + inputSize: 10, + }, + { + label: "Date of birth", + name: "date_of_birth", + type: "date", + required: true, }, - { label: "Date of birth", name: "date_of_birth", type: "date" }, { label: "Select school year", name: "school_year", diff --git a/src/components/form/form/form.tsx b/src/components/form/form/form.tsx index fe5baea8..f401da88 100644 --- a/src/components/form/form/form.tsx +++ b/src/components/form/form/form.tsx @@ -180,7 +180,7 @@ export const Form: React.FC = ({ * Defaults event handler for form submission. * @param event */ - const defaultOnSubmit: React.FormEventHandler = (event) => { + const handleSubmit: React.FormEventHandler = (event) => { event.preventDefault(); if (validate) { @@ -203,10 +203,19 @@ export const Form: React.FC = ({ setValuesState(data); }; + /** + * Gets called when the form is reset. + */ + const handleReset: React.FormEventHandler = () => { + setValuesState({}); + setErrorsState({}); + }; + return (
{_nonFieldErrors?.length && ( diff --git a/src/components/form/formcontrol/formcontrol.tsx b/src/components/form/formcontrol/formcontrol.tsx index 393bc492..c7688447 100644 --- a/src/components/form/formcontrol/formcontrol.tsx +++ b/src/components/form/formcontrol/formcontrol.tsx @@ -6,14 +6,18 @@ import { isCheckbox, isCheckboxGroup, isChoiceField, + isDateInput, isDatePicker, + isDateRangeInput, isInput, isRadio, isRadioGroup, } from "../../../lib/form/typeguards"; import { Checkbox } from "../checkbox"; import { ChoiceField } from "../choicefield"; +import { DateInput } from "../dateinput"; import { DatePicker } from "../datepicker"; +import { DateRangeInput } from "../daterangeinput"; import { ErrorMessage } from "../errormessage"; import { Input, InputProps } from "../input"; import { Label } from "../label"; @@ -103,6 +107,14 @@ export const FormWidget: React.FC = ({ ...props }) => { return ; } + if (isDateInput(props)) { + return ; + } + + if (isDateRangeInput(props)) { + return ; + } + if (isDatePicker(props)) { return ; } diff --git a/src/components/form/input/input.tsx b/src/components/form/input/input.tsx index 2bf7aca9..7efb07e8 100644 --- a/src/components/form/input/input.tsx +++ b/src/components/form/input/input.tsx @@ -53,6 +53,7 @@ export const Input: React.FC = ({ ...props }) => { const inputRef = React.useRef(null); + // TODO: Investigate whether stats is actually (still) required here? const [valueState, setValueState] = useState(value || ""); /** diff --git a/src/lib/form/typeguards.ts b/src/lib/form/typeguards.ts index 3ae003ea..e300927c 100644 --- a/src/lib/form/typeguards.ts +++ b/src/lib/form/typeguards.ts @@ -6,8 +6,15 @@ import { RadioProps, SelectProps, } from "../../components"; +import { DateInputProps } from "../../components/form/dateinput"; +import { DateRangeInputProps } from "../../components/form/daterangeinput"; -export type FormField = DatePickerProps | InputProps | SelectProps; +export type FormField = + | DateInputProps + | DateRangeInputProps + | DatePickerProps + | InputProps + | SelectProps; /** Typeguard for CheckboxProps. */ export const isCheckbox = (props: FormField): props is CheckboxProps => @@ -22,9 +29,20 @@ export const isRadio = (props: FormField): props is RadioProps => typeof props.checked !== "undefined" || typeof props.defaultChecked !== "undefined"); +/** Typeguard for DateInputProps. */ +export const isDateInput = (props: FormField): props is DateInputProps => + _isInput(props) && props.type === "date"; + +/** Typeguard for DateInputProps. */ +export const isDateRangeInput = ( + props: FormField, +): props is DateRangeInputProps => + _isInput(props) && props.type === "daterange"; + /** Typeguard for DatePickerProps. */ export const isDatePicker = (props: FormField): props is DatePickerProps => - _isInput(props) && Boolean(props.type?.match("date")); + _isInput(props) && + Boolean(props.type?.match("date") && props.type?.match("picker")); /** * Typeguard for InputProps. diff --git a/src/lib/form/utils.ts b/src/lib/form/utils.ts index 6bc6a605..baaf41e7 100644 --- a/src/lib/form/utils.ts +++ b/src/lib/form/utils.ts @@ -4,6 +4,7 @@ import { Primitive, isPrimitive, } from "../data/attributedata"; +import { date2DateString } from "../format"; import { FormField } from "./typeguards"; export type SerializedFormData = Record< @@ -96,7 +97,17 @@ export const getInputTypeConstructor = ( index?: number, ) => { const type = getInputType(form, name, index); + console.log({ form, name, value, index, type }, typeof value); + if (value === "") return () => null; + switch (type) { + case "date": + return (value: string) => new Date(value); + case "daterange": + return (value: string) => { + const values = value.split("/").map((value) => new Date(value)); + return values.length ? values : null; + }; case "number": return Number; case "checkbox": @@ -113,15 +124,14 @@ export const getInputTypeConstructor = ( export const getInputType = ( form: HTMLFormElement, name: string, - index?: number, + index: number = 0, ) => { + // FIXME: const inputs = form.elements.namedItem(name); ? const inputs = [...form.elements].filter( (n) => n.getAttribute("name") === name, - ); - if (typeof index !== "undefined" && inputs.length > 1) { - return inputs[index]?.getAttribute("type"); - } - return inputs[0]?.getAttribute("type"); + ) as HTMLElement[]; + const input = inputs[index]; + return input?.dataset.myknType || input?.getAttribute("type"); }; /** @@ -166,11 +176,16 @@ export const attribute2Value = ( if (value === null || value === undefined) { return undefined; } - switch (typeof value) { case "boolean": return String(value); + case "object": + if (value instanceof Date) { + return date2DateString(value); + } + break; + default: return isPrimitive>(value) ? value diff --git a/src/lib/format/date.ts b/src/lib/format/date.ts new file mode 100644 index 00000000..92214d21 --- /dev/null +++ b/src/lib/format/date.ts @@ -0,0 +1,29 @@ +/** + * Converts `value` to `Date` or `undefined`. + * @param dateOrDateString + */ +export const value2Date = (dateOrDateString: Date | string | number) => { + if (!dateOrDateString) return undefined; + + if (dateOrDateString instanceof Date && !isNaN(dateOrDateString.getTime())) { + return dateOrDateString; + } + + const date = new Date(dateOrDateString); + return isNaN(date.getTime()) ? undefined : date; +}; + +/** + * Returns date `string` for the given `Date`. + */ +export const date2DateString = (dateObject: Date) => { + const date = dateObject.getDate(); + const month = dateObject.getMonth() + 1; + const year = dateObject.getFullYear(); + + const DD = date.toString().padStart(2, "0"); + const MM = month.toString().padStart(2, "0"); + const YY = year.toString().padStart(4, "0"); + + return `${YY}-${MM}-${DD}`; +}; diff --git a/src/lib/format/index.ts b/src/lib/format/index.ts index ec9346fd..a3a1564a 100644 --- a/src/lib/format/index.ts +++ b/src/lib/format/index.ts @@ -1,2 +1,3 @@ export * from "./array"; +export * from "./date"; export * from "./string"; diff --git a/src/lib/i18n/compiled/en.json b/src/lib/i18n/compiled/en.json index 8d773115..48209f22 100644 --- a/src/lib/i18n/compiled/en.json +++ b/src/lib/i18n/compiled/en.json @@ -9,6 +9,12 @@ "mykn.components.DataGrid.labelSelectAll": "(de)select {countPage} rows", "mykn.components.DataGrid.labelSelectAllPages": "(de)select {pages} pages", "mykn.components.DataGrid.labelSelectFields": "select columns", + "mykn.components.DateInput.labelDate": "day of month", + "mykn.components.DateInput.labelMonth": "month", + "mykn.components.DateInput.labelYear": "year", + "mykn.components.DateInput.placeholderDate": "dd", + "mykn.components.DateInput.placeholderMonth": "mm", + "mykn.components.DateInput.placeholderYear": "yyyy", "mykn.components.DatePicker.labelChooseDayPrefix": "Choose day", "mykn.components.DatePicker.labelClose": "Close", "mykn.components.DatePicker.labelDisabledDayPrefix": "Disabled day", @@ -20,6 +26,8 @@ "mykn.components.DatePicker.labelTimeInput": "Choose time:", "mykn.components.DatePicker.labelWeek": "Week", "mykn.components.DatePicker.labelWeekPrefix": "Week", + "mykn.components.DateRangeInput.labelEndDate": "end date", + "mykn.components.DateRangeInput.labelStartDate": "start date", "mykn.components.Form.labelSubmit": "submit", "mykn.components.Form.labelValidationErrorRequired": "Field {label} is required", "mykn.components.Kanban.labelMoveObject": "change position of item", diff --git a/src/lib/i18n/compiled/nl.json b/src/lib/i18n/compiled/nl.json index 5357271a..c4e6d4f7 100644 --- a/src/lib/i18n/compiled/nl.json +++ b/src/lib/i18n/compiled/nl.json @@ -9,6 +9,12 @@ "mykn.components.DataGrid.labelSelectAll": "(de)selecteer {countPage} rijen", "mykn.components.DataGrid.labelSelectAllPages": "(de)selecteer {pages} pagina's", "mykn.components.DataGrid.labelSelectFields": "selecteer kolommen", + "mykn.components.DateInput.labelDate": "dag van de maand", + "mykn.components.DateInput.labelMonth": "maand", + "mykn.components.DateInput.labelYear": "jaar", + "mykn.components.DateInput.placeholderDate": "dd", + "mykn.components.DateInput.placeholderMonth": "mm", + "mykn.components.DateInput.placeholderYear": "jjjj", "mykn.components.DatePicker.labelChooseDayPrefix": "Kies dag", "mykn.components.DatePicker.labelClose": "Sluiten", "mykn.components.DatePicker.labelDisabledDayPrefix": "Uitgeschakelde dag", @@ -20,6 +26,8 @@ "mykn.components.DatePicker.labelTimeInput": "Kies tijd:", "mykn.components.DatePicker.labelWeek": "Week", "mykn.components.DatePicker.labelWeekPrefix": "week", + "mykn.components.DateRangeInput.labelEndDate": "einddatum", + "mykn.components.DateRangeInput.labelStartDate": "startdatum", "mykn.components.Form.labelSubmit": "verzenden", "mykn.components.Form.labelValidationErrorRequired": "Veld {label} is verplicht", "mykn.components.Kanban.labelMoveObject": "wijzig positie van onderdeel", diff --git a/src/lib/i18n/messages/en.json b/src/lib/i18n/messages/en.json index f752780f..e2470ab1 100644 --- a/src/lib/i18n/messages/en.json +++ b/src/lib/i18n/messages/en.json @@ -49,6 +49,36 @@ "description": "mykn.components.Modal: The datagrid select fields label", "originalDefault": "selecteer kolommen" }, + "mykn.components.DateInput.labelDate": { + "defaultMessage": "day of month", + "description": "mykn.components.DateInput: The date field (accessible) label", + "originalDefault": "dag van de maand" + }, + "mykn.components.DateInput.labelMonth": { + "defaultMessage": "month", + "description": "mykn.components.DateInput: The month field (accessible) label", + "originalDefault": "maand" + }, + "mykn.components.DateInput.labelYear": { + "defaultMessage": "year", + "description": "mykn.components.DateInput: The year field (accessible) label", + "originalDefault": "jaar" + }, + "mykn.components.DateInput.placeholderDate": { + "defaultMessage": "dd", + "description": "mykn.components.DateInput: The date field placeholder", + "originalDefault": "dd" + }, + "mykn.components.DateInput.placeholderMonth": { + "defaultMessage": "mm", + "description": "mykn.components.DateInput: The month field placeholder", + "originalDefault": "mm" + }, + "mykn.components.DateInput.placeholderYear": { + "defaultMessage": "yyyy", + "description": "mykn.components.DateInput: The year field placeholder", + "originalDefault": "jjjj" + }, "mykn.components.DatePicker.labelChooseDayPrefix": { "defaultMessage": "Choose day", "description": "mykn.components.DatePicker: The choose day prefix", @@ -104,6 +134,16 @@ "description": "mykn.components.DatePicker: The week prefix", "originalDefault": "week" }, + "mykn.components.DateRangeInput.labelEndDate": { + "defaultMessage": "end date", + "description": "mykn.components.DateRangeInput: The end date (accessible) label", + "originalDefault": "einddatum" + }, + "mykn.components.DateRangeInput.labelStartDate": { + "defaultMessage": "start date", + "description": "mykn.components.DateRangeInput: The start date (accessible) label", + "originalDefault": "startdatum" + }, "mykn.components.Form.labelSubmit": { "defaultMessage": "submit", "description": "mykn.components.Form: The submit form label", diff --git a/src/lib/i18n/messages/nl.json b/src/lib/i18n/messages/nl.json index b39d48d8..42743724 100644 --- a/src/lib/i18n/messages/nl.json +++ b/src/lib/i18n/messages/nl.json @@ -49,6 +49,36 @@ "description": "mykn.components.Modal: The datagrid select fields label", "originalDefault": "selecteer kolommen" }, + "mykn.components.DateInput.labelDate": { + "defaultMessage": "dag van de maand", + "description": "mykn.components.DateInput: The date field (accessible) label", + "originalDefault": "dag van de maand" + }, + "mykn.components.DateInput.labelMonth": { + "defaultMessage": "maand", + "description": "mykn.components.DateInput: The month field (accessible) label", + "originalDefault": "maand" + }, + "mykn.components.DateInput.labelYear": { + "defaultMessage": "jaar", + "description": "mykn.components.DateInput: The year field (accessible) label", + "originalDefault": "jaar" + }, + "mykn.components.DateInput.placeholderDate": { + "defaultMessage": "dd", + "description": "mykn.components.DateInput: The date field placeholder", + "originalDefault": "dd" + }, + "mykn.components.DateInput.placeholderMonth": { + "defaultMessage": "mm", + "description": "mykn.components.DateInput: The month field placeholder", + "originalDefault": "mm" + }, + "mykn.components.DateInput.placeholderYear": { + "defaultMessage": "jjjj", + "description": "mykn.components.DateInput: The year field placeholder", + "originalDefault": "jjjj" + }, "mykn.components.DatePicker.labelChooseDayPrefix": { "defaultMessage": "Kies dag", "description": "mykn.components.DatePicker: The choose day prefix", @@ -104,6 +134,16 @@ "description": "mykn.components.DatePicker: The week prefix", "originalDefault": "week" }, + "mykn.components.DateRangeInput.labelEndDate": { + "defaultMessage": "einddatum", + "description": "mykn.components.DateRangeInput: The end date (accessible) label", + "originalDefault": "einddatum" + }, + "mykn.components.DateRangeInput.labelStartDate": { + "defaultMessage": "startdatum", + "description": "mykn.components.DateRangeInput: The start date (accessible) label", + "originalDefault": "startdatum" + }, "mykn.components.Form.labelSubmit": { "defaultMessage": "verzenden", "description": "mykn.components.Form: The submit form label",