Add useForm hook

This commit is contained in:
Kalle (Sendou) 2021-11-20 17:41:36 +02:00
parent bcd5983ebf
commit 052b023f98
11 changed files with 216 additions and 104 deletions

View File

@ -1,14 +0,0 @@
import type { JSX } from "solid-js";
import s from "../styles/Button.module.css";
export function Button(
p: JSX.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "outlined" }
) {
return (
<button
class={s.button}
classList={{ [s.outlined]: p.variant === "outlined" }}
{...p}
/>
);
}

View File

@ -0,0 +1,10 @@
import { Show } from "solid-js";
import s from "../styles/ErrorMessage.module.css";
export function ErrorMessage(p: { error?: string }) {
return (
<Show when={p.error}>
<div class={s.container}>{p.error}</div>
</Show>
);
}

View File

@ -1,17 +0,0 @@
import type { JSX } from "solid-js";
import s from "../styles/Input.module.css";
export function Input(
p: JSX.InputHTMLAttributes<HTMLInputElement> & { labelText?: string }
) {
return (
<>
{p.labelText && (
<label class={s.label} htmlFor={p.id}>
{p.labelText}
</label>
)}
<input class={s.input} {...p} />
</>
);
}

View File

@ -1,10 +1,40 @@
import { createSignal } from "solid-js";
import { Button } from "../../../components/Button";
import { Input } from "../../../components/Input";
import { createMemo, createSignal } from "solid-js";
import { ErrorMessage } from "../../../components/ErrorMessage";
import { trpcClient } from "../../../utils/trpc-client";
import { useForm } from "../../../utils/useForm";
import s from "../styles/ActionSection.module.css";
import { useTournamentData } from "../TournamentPage.data";
const lengthIsCorrect = ({ value }: { value: string }) => {
if (value.length >= 2 && value.length <= 40) {
return;
}
return "Team name has to be between 2 and 40 characters long.";
};
const uniqueName =
(registeredTeamNames: string[]) =>
({ value }: { value: string }) => {
if (!registeredTeamNames.includes(value)) return;
return `There is already a team called "${value}" registered`;
};
export function ActionSection() {
const [expanded, setExpanded] = createSignal(true);
const [expanded, setExpanded] = createSignal(false);
const tournament = useTournamentData();
const { validate: _validate, formSubmit: _formSubmit, errors } = useForm();
const fn = (form: HTMLFormElement) => {
return trpcClient.mutation("tournament.createTournamentTeam", {
name: "Team Olive",
tournamentId: 1,
});
};
const registeredTeamNames = createMemo(() =>
(tournament()?.teams ?? []).map((t) => t.name.trim())
);
return (
<div
@ -23,23 +53,29 @@ export function ActionSection() {
<div class={s.header}>Register now</div>
{expanded() && (
<div class={s.content} onKeyDown={(e) => e.stopPropagation()}>
<form>
<Input
{/* @ts-expect-error */}
<form use:_formSubmit={fn}>
<label for="team-name">Team name</label>
<input
name="team-name"
id="team-name"
labelText="Team name"
required
minLength={2}
maxLength={40}
// @ts-expect-error
use:_validate={[
lengthIsCorrect,
uniqueName(registeredTeamNames()),
]}
/>
<ErrorMessage error={errors["team-name"]} />
<div class={s.buttonsContainer}>
<Button type="submit">Submit</Button>
<Button
variant="outlined"
<button type="submit">Submit</button>
<button
class="outlined"
type="button"
onClick={() => setExpanded(false)}
>
Cancel
</Button>
</button>
</div>
</form>
</div>

View File

@ -47,7 +47,7 @@ export function InfoBanner() {
<div class={s.bottomRow}>
<div class={s.infos}>
<div class={s.infoContainer}>
<label class={s.infoLabel}>Starting time</label>
<div class={s.infoLabel}>Starting time</div>
<div>{weekdayAndStartTime(tournament.startTime)}</div>
</div>
<div class={s.infoContainer}>

View File

@ -31,6 +31,7 @@
.content {
margin-block-start: var(--s-4);
width: min(24rem, 100%);
}
.buttonsContainer {

View File

@ -1,31 +0,0 @@
.button {
display: inline-flex;
appearance: none;
align-items: center;
justify-content: center;
user-select: none;
width: auto;
line-height: 1.2;
border-radius: var(--rounded-sm);
font-weight: var(--bold);
padding-inline: var(--s-2-5);
padding-block: var(--s-1-5);
font-size: var(--fonts-sm);
background: var(--theme);
color: var(--text);
border: 2px solid var(--theme);
outline-offset: 2px;
cursor: pointer;
}
.button:focus-visible {
outline: 2px solid var(--theme);
}
.button:active {
transform: translateY(1px);
}
.outlined {
background-color: transparent;
}

View File

@ -0,0 +1,5 @@
.container {
color: var(--theme-error);
font-size: var(--fonts-xs);
margin-top: var(--s-1);
}

View File

@ -1,28 +0,0 @@
.input {
height: 1rem;
padding: var(--s-4) var(--s-3);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
outline: none;
border: none;
color: var(--text);
border: 1px solid var(--border);
}
.input:focus-within {
outline: 2px solid var(--theme);
border-color: transparent;
}
.input::placeholder {
color: var(--text-lighter);
letter-spacing: 0.5px;
font-weight: var(--semi-bold);
}
.label {
display: block;
font-size: var(--fonts-xs);
font-weight: var(--bold);
color: var(--text-lighter);
margin-block-end: var(--s-1);
}

View File

@ -4,6 +4,7 @@
--border: hsl(237.3, 42.3%, 45.6%);
--text: rgba(255, 255, 255, 0.95);
--text-lighter: rgba(255, 255, 255, 0.55);
--theme-error: rgb(219, 70, 65);
--theme: hsl(255, 66.7%, 55.3%);
--theme-transparent: hsla(255, 66.7%, 55.3%, 0.4);
--theme-secondary: hsl(85, 66.7%, 55.3%);
@ -77,3 +78,70 @@ body {
*:focus:not(:focus-visible) {
outline: none !important;
}
button {
display: inline-flex;
appearance: none;
align-items: center;
justify-content: center;
user-select: none;
width: auto;
line-height: 1.2;
border-radius: var(--rounded-sm);
font-weight: var(--bold);
padding-inline: var(--s-2-5);
padding-block: var(--s-1-5);
font-size: var(--fonts-sm);
background: var(--theme);
color: var(--text);
border: 2px solid var(--theme);
outline-offset: 2px;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--theme);
}
button:active {
transform: translateY(1px);
}
button.outlined {
background-color: transparent;
}
input {
height: 1rem;
padding: var(--s-4) var(--s-3);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
outline: none;
border: none;
color: var(--text);
border: 1px solid var(--border);
}
input:focus-within {
outline: 2px solid var(--theme);
border-color: transparent;
}
input::placeholder {
color: var(--text-lighter);
letter-spacing: 0.5px;
font-weight: var(--semi-bold);
}
input.error {
outline: 2px solid var(--theme-error);
border-color: transparent;
}
label {
display: block;
font-size: var(--fonts-xs);
font-weight: var(--bold);
color: var(--text-lighter);
margin-block-end: var(--s-1);
}

82
utils/useForm.ts Normal file
View File

@ -0,0 +1,82 @@
// https://www.solidjs.com/examples/forms
import { createStore, SetStoreFunction } from "solid-js/store";
type FormFieldElement = HTMLInputElement;
type Validator = (el: FormFieldElement) => string | undefined;
type FormFieldAccessor = () => Validator | undefined;
type FormAccessor = () => (el: HTMLFormElement) => void;
type ElementAndValidators = {
element: FormFieldElement;
validators: Validator[];
};
const INPUT_ERROR_CLASSNAME = "error";
function checkValid(
{
element,
validators = [],
}: { element: FormFieldElement; validators: Array<Validator> },
setErrors: SetStoreFunction<{}>
) {
return async () => {
element.setCustomValidity("");
element.checkValidity();
let message = element.validationMessage;
if (!message) {
for (const validator of validators) {
const text = validator(element);
if (text) {
element.setCustomValidity(text);
break;
}
}
message = element.validationMessage;
}
if (message) {
element.classList.toggle(INPUT_ERROR_CLASSNAME, true);
setErrors({ [element.name]: message });
}
};
}
export function useForm() {
const [errors, setErrors] = createStore<Record<string, string>>({});
const fields: Record<
string,
{ element: FormFieldElement; validators: Validator[] }
> = {};
const validate = (ref: FormFieldElement, accessor: FormFieldAccessor) => {
const validators = (accessor() || []) as Validator[];
let config: ElementAndValidators;
fields[ref.name] = config = { element: ref, validators };
ref.onblur = checkValid(config, setErrors);
ref.oninput = () => {
if (!errors[ref.name]) return;
setErrors({ [ref.name]: undefined });
ref.classList.toggle(INPUT_ERROR_CLASSNAME, false);
};
};
const formSubmit = (ref: HTMLFormElement, accessor: FormAccessor) => {
const callback = accessor() || (() => {});
ref.setAttribute("novalidate", "");
ref.onsubmit = async (e) => {
e.preventDefault();
let errored = false;
for (const k in fields) {
const field = fields[k];
await checkValid(field, setErrors)();
if (!errored && field.element.validationMessage) {
field.element.focus();
errored = true;
}
}
!errored && callback(ref);
};
};
return { validate, formSubmit, errors };
}