mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 15:56:19 -05:00
Add useForm hook
This commit is contained in:
parent
bcd5983ebf
commit
052b023f98
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
components/ErrorMessage.tsx
Normal file
10
components/ErrorMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
|
||||
.content {
|
||||
margin-block-start: var(--s-4);
|
||||
width: min(24rem, 100%);
|
||||
}
|
||||
|
||||
.buttonsContainer {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
5
styles/ErrorMessage.module.css
Normal file
5
styles/ErrorMessage.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.container {
|
||||
color: var(--theme-error);
|
||||
font-size: var(--fonts-xs);
|
||||
margin-top: var(--s-1);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
82
utils/useForm.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user