Form with all fields to /components

This commit is contained in:
Kalle 2026-01-18 22:22:31 +02:00
parent 97ef317ae8
commit b6d9c1cec3
2 changed files with 306 additions and 0 deletions

View File

@ -0,0 +1,157 @@
import { z } from "zod";
import {
checkboxGroup,
customField,
datetimeOptional,
datetimeRequired,
dayMonthYearRequired,
dualSelectOptional,
numberFieldOptional,
radioGroup,
select,
selectDynamicOptional,
selectOptional,
stageSelect,
textAreaOptional,
textAreaRequired,
textFieldOptional,
textFieldRequired,
timeRangeOptional,
toggle,
userSearchOptional,
weaponPool,
weaponSelectOptional,
} from "~/form/fields";
export const formFieldsShowcaseSchema = z.object({
// Text fields
requiredText: textFieldRequired({
label: "labels.name",
maxLength: 100,
}),
optionalText: textFieldOptional({
label: "labels.bio",
maxLength: 200,
}),
optionalNumber: numberFieldOptional({
label: "labels.vodTeamSize",
}),
// Text areas
requiredTextArea: textAreaRequired({
label: "labels.description",
maxLength: 500,
}),
optionalTextArea: textAreaOptional({
label: "labels.text",
maxLength: 1000,
}),
// Toggles
isPublic: toggle({
label: "labels.buildPrivate",
}),
enableNotifications: toggle({
label: "labels.isEstablished",
}),
// Selects
requiredSelect: select({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
],
}),
optionalSelect: selectOptional({
label: "labels.vodType",
items: [
{ label: "vodTypes.TOURNAMENT", value: "TOURNAMENT" },
{ label: "vodTypes.CAST", value: "CAST" },
{ label: "vodTypes.SCRIM", value: "SCRIM" },
],
}),
dynamicSelect: selectDynamicOptional({
label: "labels.orgSeries",
}),
divisionRange: dualSelectOptional({
fields: [
{
label: "labels.scrimMaxDiv",
items: [
{ label: () => "S+", value: "S+" },
{ label: () => "S", value: "S" },
{ label: () => "A", value: "A" },
{ label: () => "B", value: "B" },
],
},
{
label: "labels.scrimMinDiv",
items: [
{ label: () => "S+", value: "S+" },
{ label: () => "S", value: "S" },
{ label: () => "A", value: "A" },
{ label: () => "B", value: "B" },
],
},
],
}),
// Radio & Checkbox groups
matchType: radioGroup({
label: "labels.scrimMaps",
items: [
{ label: "options.scrimMaps.noPreference", value: "NO_PREFERENCE" },
{ label: "options.scrimMaps.szOnly", value: "SZ_ONLY" },
{ label: "options.scrimMaps.rankedOnly", value: "RANKED_ONLY" },
],
}),
selectedModes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.SZ", value: "SZ" },
{ label: "modes.TC", value: "TC" },
{ label: "modes.RM", value: "RM" },
{ label: "modes.CB", value: "CB" },
],
}),
// Date & Time
requiredDatetime: datetimeRequired({
label: "labels.startTime",
}),
optionalDatetime: datetimeOptional({
label: "labels.vodDate",
}),
birthDate: dayMonthYearRequired({
label: "labels.banUserExpiresAt",
}),
availableTime: timeRangeOptional({
label: "labels.weekdayTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
// Game-specific fields
weapons: weaponPool({
label: "labels.weaponPool",
maxCount: 5,
minCount: 0,
}),
stage: stageSelect({
label: "labels.vodStage",
}),
weapon: weaponSelectOptional({
label: "labels.vodWeapon",
}),
user: userSearchOptional({
label: "labels.banUserPlayer",
}),
// Custom field
customValue: customField(
{ initialValue: "custom initial value" },
z.string().optional(),
),
});

View File

@ -47,8 +47,11 @@ import { SubmitButton } from "~/components/SubmitButton";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { Table } from "~/components/Table";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { CustomFieldRenderProps } from "~/form/FormField";
import { SendouForm } from "~/form/SendouForm";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import styles from "../components-showcase.module.css";
import { formFieldsShowcaseSchema } from "../form-examples-schema";
export const SECTIONS = [
{ title: "Buttons", id: "buttons", component: ButtonsSection },
@ -90,6 +93,7 @@ export const SECTIONS = [
{ title: "Placements", id: "placements", component: PlacementSection },
{ title: "Badges", id: "badges", component: BadgeSection },
{ title: "Game Selects", id: "game-selects", component: GameSelectSection },
{ title: "Form Fields", id: "form-fields", component: FormFieldsSection },
{ title: "Miscellaneous", id: "miscellaneous", component: MiscSection },
] as const;
@ -2039,6 +2043,151 @@ function GameSelectSection({ id }: { id: string }) {
);
}
const DYNAMIC_SELECT_OPTIONS = [
{ value: "1", label: "Tournament A" },
{ value: "2", label: "Tournament B" },
{ value: "3", label: "Tournament C" },
];
function FormFieldsSection({ id }: { id: string }) {
return (
<Section>
<SectionTitle id={id}>Form Fields</SectionTitle>
<p className="mb-4" style={{ fontSize: "var(--fonts-sm)", opacity: 0.8 }}>
Schema-based form fields using SendouForm. Each field type is defined
with Zod schemas that generate both UI and validation.
</p>
<SendouForm schema={formFieldsShowcaseSchema} autoSubmit>
{({ FormField }) => (
<div className="stack lg">
<Divider smallText>Text Fields</Divider>
<ComponentRow label="textFieldRequired">
<FormField name="requiredText" />
</ComponentRow>
<ComponentRow label="textFieldOptional">
<FormField name="optionalText" />
</ComponentRow>
<ComponentRow label="numberFieldOptional">
<FormField name="optionalNumber" />
</ComponentRow>
<Divider smallText>Text Areas</Divider>
<ComponentRow label="textAreaRequired">
<FormField name="requiredTextArea" />
</ComponentRow>
<ComponentRow label="textAreaOptional">
<FormField name="optionalTextArea" />
</ComponentRow>
<Divider smallText>Toggle (Switch)</Divider>
<ComponentRow label="toggle">
<div className="stack sm">
<FormField name="isPublic" />
<FormField name="enableNotifications" />
</div>
</ComponentRow>
<Divider smallText>Select Fields</Divider>
<ComponentRow label="select (required)">
<FormField name="requiredSelect" />
</ComponentRow>
<ComponentRow label="selectOptional (clearable)">
<FormField name="optionalSelect" />
</ComponentRow>
<ComponentRow label="selectDynamicOptional">
<FormField
name="dynamicSelect"
options={DYNAMIC_SELECT_OPTIONS}
/>
</ComponentRow>
<ComponentRow label="dualSelectOptional">
<FormField name="divisionRange" />
</ComponentRow>
<Divider smallText>Radio & Checkbox Groups</Divider>
<ComponentRow label="radioGroup">
<FormField name="matchType" />
</ComponentRow>
<ComponentRow label="checkboxGroup">
<FormField name="selectedModes" />
</ComponentRow>
<Divider smallText>Date & Time</Divider>
<ComponentRow label="datetimeRequired">
<FormField name="requiredDatetime" />
</ComponentRow>
<ComponentRow label="datetimeOptional">
<FormField name="optionalDatetime" />
</ComponentRow>
<ComponentRow label="dayMonthYearRequired">
<FormField name="birthDate" />
</ComponentRow>
<ComponentRow label="timeRangeOptional">
<FormField name="availableTime" />
</ComponentRow>
<Divider smallText>Game-specific Fields</Divider>
<ComponentRow label="weaponPool">
<FormField name="weapons" />
</ComponentRow>
<ComponentRow label="stageSelect">
<FormField name="stage" />
</ComponentRow>
<ComponentRow label="weaponSelectOptional">
<FormField name="weapon" />
</ComponentRow>
<ComponentRow label="userSearchOptional">
<FormField name="user" />
</ComponentRow>
<Divider smallText>Custom Field</Divider>
<ComponentRow label="customField">
<FormField name="customValue">
{(props: CustomFieldRenderProps) => (
<div className="stack sm">
<Label htmlFor="custom-input">Custom Field</Label>
<Input
id="custom-input"
value={(props.value as string) ?? ""}
onChange={(e) => props.onChange(e.target.value)}
aria-invalid={Boolean(props.error)}
/>
{props.error ? (
<FormMessage type="error">{props.error}</FormMessage>
) : null}
</div>
)}
</FormField>
</ComponentRow>
</div>
)}
</SendouForm>
</Section>
);
}
function MiscSection({ id }: { id: string }) {
const [rangeValue, setRangeValue] = useState(50);
const [colorValue, setColorValue] = useState("#3b82f6");