Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,39 @@
.card {
border: var(--border-style);
border-radius: var(--radius-box);
margin: 0;
padding: 0;
}
.header {
--header-border-radius: calc(var(--radius-field) - 2px);
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
background-color: var(--color-bg-higher);
border-bottom: var(--border-style);
border-radius: var(--header-border-radius) var(--header-border-radius) 0 0;
}
.headerLabel {
flex: 1;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.content {
padding: var(--s-4);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.itemInput {
flex: 1;
}
.removeButton {
height: var(--field-size);
align-self: flex-end;
}

View File

@@ -0,0 +1,259 @@
import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { isDeepEqual, omit } from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import type { FormFieldProps } from "../types";
import styles from "./ArrayFormField.module.css";
import { useTranslatedTexts } from "./FormFieldWrapper";
type ArrayFormFieldProps = Omit<FormFieldProps<"array">, "field"> & {
name: string;
value: unknown[];
onChange: (value: unknown[]) => void;
renderItem: (index: number, name: string) => React.ReactNode;
isObjectArray?: boolean;
sortable?: boolean;
itemInitialValue?: unknown;
addable?: boolean;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
disabled?: boolean;
};
export function ArrayFormField({
label,
name,
bottomText,
error,
min = 0,
max,
value,
onChange,
renderItem,
isObjectArray,
sortable,
itemInitialValue,
addable = true,
canRemoveItem,
disabled,
}: ArrayFormFieldProps) {
const { t } = useTranslation(["common"]);
const { translatedLabel, translatedBottomText, translatedError } =
useTranslatedTexts({ label, bottomText, error });
const count = value.length;
// Always render at least one item so an empty array still shows an input
// the user can fill, rather than only an "Add" button. The underlying value
// stays empty until edited, so submitting an untouched field sends nothing.
const minVisible = Math.max(min, 1);
const visibleCount = Math.max(count, minVisible);
const makeNewItem = () => {
const baseValue =
itemInitialValue !== undefined
? itemInitialValue
: isObjectArray
? {}
: undefined;
return typeof baseValue === "object" && baseValue !== null
? {
...(baseValue as Record<string, unknown>),
_key: crypto.randomUUID(),
}
: baseValue;
};
const handleAdd = () => {
// While the array is empty we still render one placeholder row that isn't
// part of `value` yet. Pad `value` up to the number of visible rows first so
// the added item appears below them instead of only backing the placeholder.
const padded = [...value];
while (padded.length < visibleCount) {
padded.push(makeNewItem());
}
onChange([...padded, makeNewItem()]);
};
// An item the user hasn't touched still equals the freshly added template, so
// it's indistinguishable from the placeholder shown for an empty array.
const isPristineItem = (item: unknown) => {
const template = itemInitialValue;
if (typeof template === "object" && template !== null) {
if (typeof item !== "object" || item === null) return true;
return isDeepEqual(
omit(item as Record<string, unknown>, ["_key"]),
template,
);
}
return template === undefined
? item === null || item === undefined || item === ""
: isDeepEqual(item, template);
};
// A single pristine row is indistinguishable from the empty-array placeholder,
// so it shouldn't offer a remove button (you can't go below one visible row
// anyway). A lone edited row stays removable so the only item can be cleared.
const canRemoveAt = (index: number) =>
!disabled &&
(canRemoveItem ? canRemoveItem(value[index], index) : true) &&
count > min &&
(count > minVisible || !isPristineItem(value[index]));
const handleRemoveAt = (index: number) => {
const next = value.filter((_, i) => i !== index);
// Removing down to a single pristine row would leave a stray entry that
// looks untouched but still fails validation on submit; collapse it back to
// an empty array so it matches the pristine state.
onChange(next.length === 1 && isPristineItem(next[0]) ? [] : next);
};
const itemKey = (idx: number) => {
if (!isObjectArray) return idx;
return ((value[idx] as Record<string, unknown>)?._key as string) ?? idx;
};
// Sorting is only offered for object arrays; primitive arrays are rendered
// inline without the fieldset header that carries the reorder controls.
const isSortable = Boolean(sortable) && isObjectArray && !disabled;
const handleMoveAt = (index: number, direction: 1 | -1) => {
const target = index + direction;
if (target < 0 || target >= count) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
return (
<div className="stack md w-full">
{translatedLabel ? (
<div className="text-xs font-semi-bold">{translatedLabel}</div>
) : null}
{isObjectArray
? Array.from({ length: visibleCount }).map((_, idx) => (
<ArrayItemFieldset
key={itemKey(idx)}
index={idx}
canRemove={canRemoveAt(idx)}
removeButtonTestId={`${name}-remove-item-button`}
onRemove={() => handleRemoveAt(idx)}
sortable={isSortable}
canMoveUp={idx > 0}
canMoveDown={idx < count - 1}
onMoveUp={() => handleMoveAt(idx, -1)}
onMoveDown={() => handleMoveAt(idx, 1)}
>
{renderItem(idx, `${name}[${idx}]`)}
</ArrayItemFieldset>
))
: Array.from({ length: visibleCount }).map((_, idx) => (
<div
key={itemKey(idx)}
className="stack horizontal sm items-start w-full"
>
<div className={styles.itemInput}>
{renderItem(idx, `${name}[${idx}]`)}
</div>
{canRemoveAt(idx) ? (
<SendouButton
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={() => handleRemoveAt(idx)}
className={styles.removeButton}
data-testid={`${name}-remove-item-button`}
/>
) : null}
</div>
))}
{translatedError ? (
<FormMessage type="error">{translatedError}</FormMessage>
) : null}
{translatedBottomText && !translatedError ? (
<FormMessage type="info">{translatedBottomText}</FormMessage>
) : null}
{addable ? (
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
onPress={handleAdd}
isDisabled={count >= max || disabled}
className="m-0-auto"
data-testid={`${name}-add-item-button`}
>
{t("common:actions.add")}
</SendouButton>
) : null}
</div>
);
}
function ArrayItemFieldset({
index,
children,
canRemove,
removeButtonTestId,
onRemove,
sortable,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
}: {
index: number;
children: React.ReactNode;
canRemove: boolean;
removeButtonTestId?: string;
onRemove: () => void;
sortable?: boolean;
canMoveUp?: boolean;
canMoveDown?: boolean;
onMoveUp?: () => void;
onMoveDown?: () => void;
}) {
return (
<fieldset className={styles.card}>
<div className={styles.header}>
<legend className={styles.headerLabel}>#{index + 1}</legend>
{sortable ? (
<>
<SendouButton
shape="circle"
icon={<ChevronDown />}
aria-label="Move down"
size="small"
variant="minimal"
onPress={onMoveDown}
isDisabled={!canMoveDown}
/>
<SendouButton
shape="circle"
icon={<ChevronUp />}
aria-label="Move up"
size="small"
variant="minimal"
onPress={onMoveUp}
isDisabled={!canMoveUp}
/>
</>
) : null}
<SendouButton
className={canRemove ? undefined : "invisible"}
shape="circle"
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={onRemove}
isDisabled={!canRemove}
data-testid={removeButtonTestId}
/>
</div>
<div className={styles.content}>{children}</div>
</fieldset>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { BadgesSelector } from "~/features/badges/components/BadgesSelector";
import type { BadgeOption, FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
type BadgesFormFieldProps = Omit<FormFieldProps<"badges">, "onBlur"> & {
value: number[];
onChange: (value: number[]) => void;
onBlur?: () => void;
options: BadgeOption[];
disabled?: boolean;
};
export function BadgesFormField({
name,
label,
bottomText,
error,
maxCount,
value,
onChange,
onBlur,
options,
disabled,
}: BadgesFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<BadgesSelector
options={options}
selectedBadges={value}
onChange={onChange}
onBlur={onBlur}
maxCount={maxCount}
disabled={disabled}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,76 @@
import type { CalendarDate, CalendarDateTime } from "@internationalized/date";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import { dateToCalendarDate, dateToDateValue } from "~/utils/dates";
import type { FormFieldProps } from "../types";
import { errorMessageId } from "../utils";
import { FormFieldWrapper, useTranslatedTexts } from "./FormFieldWrapper";
type DatetimeFormFieldProps = Omit<
FormFieldProps<"datetime">,
"min" | "max"
> & {
value: Date | undefined;
onChange: (value: Date | undefined) => void;
granularity?: "day" | "minute";
disabled?: boolean;
};
export function DatetimeFormField({
name,
label,
bottomText,
error,
required,
onBlur,
value,
onChange,
granularity = "minute",
disabled,
}: DatetimeFormFieldProps) {
const { translatedLabel, translatedError, translatedBottomText } =
useTranslatedTexts({ label, error, bottomText });
const handleChange = (val: CalendarDateTime | CalendarDate | null) => {
if (val) {
if (granularity === "day") {
onChange(new Date(val.year, val.month - 1, val.day));
} else {
const dateTimeVal = val as CalendarDateTime;
onChange(
new Date(
dateTimeVal.year,
dateTimeVal.month - 1,
dateTimeVal.day,
dateTimeVal.hour,
dateTimeVal.minute,
),
);
}
} else {
onChange(undefined);
}
};
return (
<FormFieldWrapper id={name} name={name}>
<SendouDatePicker
label={translatedLabel ?? ""}
granularity={granularity}
errorText={translatedError}
errorId={errorMessageId(name)}
bottomText={translatedBottomText}
isRequired={required}
isDisabled={disabled}
value={
value
? granularity === "day"
? dateToCalendarDate(value)
: dateToDateValue(value)
: null
}
onChange={handleChange}
onBlur={() => onBlur?.()}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,53 @@
import type { FormFieldItems, FormFieldProps } from "../types";
import { FormFieldMessages } from "./FormFieldWrapper";
import { SelectFormField } from "./SelectFormField";
type DualSelectFormFieldProps<V extends string> = Omit<
FormFieldProps<"dual-select">,
"clearable"
> & {
fields: [
{ label?: string; items: FormFieldItems<V> },
{ label?: string; items: FormFieldItems<V> },
];
value: [V | null, V | null];
onChange: (value: [V | null, V | null]) => void;
disabled?: boolean;
};
export function DualSelectFormField<V extends string>({
name,
bottomText,
error,
onBlur,
value,
onChange,
fields,
disabled,
}: DualSelectFormFieldProps<V>) {
return (
<div className="stack xs">
<div className="stack horizontal md">
<SelectFormField
label={fields[0].label}
items={fields[0].items}
value={value[0]}
onChange={(newValue) => onChange([newValue, value[1]])}
onBlur={onBlur}
clearable
disabled={disabled}
/>
<SelectFormField
label={fields[1].label}
items={fields[1].items}
value={value[1]}
onChange={(newValue) => onChange([value[0], newValue])}
onBlur={onBlur}
clearable
disabled={disabled}
/>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { z } from "zod";
import { FormMessage } from "~/components/FormMessage";
import { FormField } from "../FormField";
import type { FormFieldProps } from "../types";
import { useTranslatedTexts } from "./FormFieldWrapper";
type FieldsetFormFieldProps<S extends z.ZodRawShape> = Omit<
FormFieldProps<"fieldset">,
"fields"
> & {
name: string;
fields: z.ZodObject<S>;
disabled?: boolean;
};
export function FieldsetFormField<S extends z.ZodRawShape>({
label,
name,
bottomText,
error,
fields,
disabled,
}: FieldsetFormFieldProps<S>) {
const fieldNames = Object.keys(fields.shape);
const { translatedLabel, translatedBottomText, translatedError } =
useTranslatedTexts({ label, bottomText, error });
return (
<div className="stack md">
{translatedLabel ? (
<div className="text-xs font-semi-bold">{translatedLabel}</div>
) : null}
{fieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={`${name}.${fieldName}`}
field={fields.shape[fieldName] as z.ZodType}
disabled={disabled}
/>
))}
{translatedError ? (
<FormMessage type="error">{translatedError}</FormMessage>
) : null}
{translatedBottomText && !translatedError ? (
<FormMessage type="info">{translatedBottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -0,0 +1,12 @@
.root {
width: 100%;
& input:not([type="radio"], [type="checkbox"]),
& textarea,
& select,
& .react-aria-Group,
& .react-aria-Select button,
& .react-aria-DatePicker {
width: 100%;
}
}

View File

@@ -0,0 +1,119 @@
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { errorMessageId, infoMessageId } from "../utils";
import styles from "./FormFieldWrapper.module.css";
export function useTranslatedTexts({
label,
error,
bottomText,
}: {
label?: string;
error?: string;
bottomText?: string;
}) {
const { t } = useTranslation(["forms"]);
return {
translatedLabel: label?.includes(":") ? t(label as never) : label,
translatedError: error?.includes(":") ? t(error as never) : error,
translatedBottomText: bottomText?.includes(":")
? t(bottomText as never)
: bottomText,
};
}
export function FormFieldMessages({
name,
error,
bottomText,
}: {
name?: string;
error?: string;
bottomText?: string;
}) {
const { translatedError, translatedBottomText } = useTranslatedTexts({
error,
bottomText,
});
return (
<>
{translatedError ? (
<FormMessage
type="error"
spaced={false}
id={name ? errorMessageId(name) : undefined}
>
{translatedError}
</FormMessage>
) : null}
{translatedBottomText ? (
<FormMessage
type="info"
spaced={false}
id={name ? infoMessageId(name) : undefined}
>
{translatedBottomText}
</FormMessage>
) : null}
</>
);
}
interface FormFieldWrapperProps {
id: string;
name?: string;
label?: string;
/** Extra element rendered next to the label, e.g. an `<InfoPopover />` explaining the field's syntax. */
labelPopover?: React.ReactNode;
required?: boolean;
error?: string;
bottomText?: string;
valueLimits?: { current: number; max: number };
children: React.ReactNode;
}
export function FormFieldWrapper({
id,
name,
label,
labelPopover,
required,
error,
bottomText,
valueLimits,
children,
}: FormFieldWrapperProps) {
const { translatedLabel } = useTranslatedTexts({ label });
const labelElement = translatedLabel ? (
<Label
htmlFor={id}
required={required}
valueLimits={valueLimits}
spaced={false}
>
{translatedLabel}
</Label>
) : null;
return (
<div className={styles.root}>
<div className="stack xs">
{labelElement && labelPopover ? (
<div className="stack horizontal xs items-center">
{labelElement}
{labelPopover}
</div>
) : (
labelElement
)}
{children}
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
.preview {
width: 144px;
height: 144px;
border-radius: 50%;
object-fit: cover;
&.banner {
width: 100%;
height: auto;
max-width: 320px;
border-radius: var(--radius-box);
}
}

View File

@@ -0,0 +1,111 @@
import clsx from "clsx";
import Compressor from "compressorjs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { logger } from "~/utils/logger";
import {
type ImageFieldValue,
resolveImageFieldDimensions,
} from "../image-field";
import type { FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
import styles from "./ImageFormField.module.css";
type ImageFormFieldProps = Omit<FormFieldProps<"image">, "onBlur"> & {
value: ImageFieldValue;
onChange: (value: ImageFieldValue) => void;
disabled?: boolean;
};
export function ImageFormField({
name,
label,
bottomText,
dimensions,
autoValidate,
error,
value,
onChange,
disabled,
}: ImageFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["common"]);
const resolvedDimensions = resolveImageFieldDimensions(dimensions);
const previewUrl =
value?.type === "EXISTING"
? value.url
: value?.type === "NEW"
? value.dataUrl
: null;
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const uploadedFile = event.target.files?.[0];
if (!uploadedFile) return;
new Compressor(uploadedFile, {
width: resolvedDimensions.width,
height: resolvedDimensions.height,
maxWidth: resolvedDimensions.width,
maxHeight: resolvedDimensions.height,
resize: "cover",
mimeType: "image/webp",
success(result) {
const reader = new FileReader();
reader.onload = () =>
onChange({ type: "NEW", dataUrl: reader.result as string });
reader.onerror = () => logger.error("Failed to read compressed image");
reader.readAsDataURL(result);
},
error(err) {
logger.error(err.message);
},
});
};
const isBanner =
dimensions === "thick-banner" ||
(typeof dimensions === "object" && dimensions.width > dimensions.height);
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={
bottomText ??
(autoValidate ? undefined : "forms:bottomTexts.imageModeration")
}
>
<div className="stack sm items-start">
{previewUrl ? (
<img
src={previewUrl}
alt=""
className={clsx(styles.preview, { [styles.banner]: isBanner })}
/>
) : null}
{value ? (
<SendouButton
variant="minimal-destructive"
size="small"
onPress={() => onChange(null)}
isDisabled={disabled}
>
{t("common:actions.remove")}
</SendouButton>
) : (
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/webp"
onChange={handleFileChange}
disabled={disabled}
/>
)}
</div>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { IngameNameInput } from "~/components/IngameNameInput";
import { inGameNameLength } from "~/features/user-page/in-game-name";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type InGameNameFormFieldProps = FormFieldProps<"in-game-name"> & {
disabled?: boolean;
value: string;
onChange: (value: string) => void;
};
export function InGameNameFormField({
name,
label,
bottomText,
maxLength,
error,
onBlur,
required,
disabled,
value,
onChange,
}: InGameNameFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
valueLimits={{ current: inGameNameLength(value), max: maxLength }}
>
<IngameNameInput
id={id}
name={name}
value={value}
onChange={onChange}
onBlur={() => onBlur?.()}
disabled={disabled}
{...ariaAttributes({ name, bottomText, error, required })}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type InputFormFieldProps = FormFieldProps<"text-field"> & {
disabled?: boolean;
autoFocus?: boolean;
labelPopover?: React.ReactNode;
value: string;
onChange: (value: string) => void;
};
export function InputFormField({
name,
label,
labelPopover,
bottomText,
leftAddon,
transformValue,
placeholder,
maxLength,
error,
onBlur,
required,
inputType = "text",
disabled,
autoFocus,
value,
onChange,
}: InputFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["forms"]);
const translatedPlaceholder = placeholder?.includes(":")
? t(placeholder as never)
: placeholder;
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
labelPopover={labelPopover}
required={required}
error={error}
bottomText={bottomText}
>
<div className={leftAddon ? "input-container" : undefined}>
{leftAddon ? <span className="input-addon">{leftAddon}</span> : null}
<input
id={id}
className={leftAddon ? "in-container" : undefined}
type={inputType}
value={value}
onChange={(e) =>
onChange(
transformValue ? transformValue(e.target.value) : e.target.value,
)
}
onBlur={() => onBlur?.()}
maxLength={maxLength}
disabled={disabled}
// biome-ignore lint/a11y/noAutofocus: opt-in per call site, used for inline edit forms
autoFocus={autoFocus}
placeholder={translatedPlaceholder}
{...ariaAttributes({
name,
bottomText,
error,
required,
})}
/>
</div>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,170 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormFieldItemsWithImage, FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
type RadioGroupFormFieldProps<V extends string> = Omit<
FormFieldProps<"radio-group">,
"items"
> & {
items: FormFieldItemsWithImage<V>;
value: V;
onChange: (value: V) => void;
disabled?: boolean;
};
export function RadioGroupFormField<V extends string>({
name,
label,
bottomText,
items,
error,
onBlur,
value,
onChange,
minLength,
disabled,
}: RadioGroupFormFieldProps<V>) {
const id = React.useId();
const itemsWithLabels = useItemsWithResolvedLabels(items);
const required = typeof minLength !== "number" || minLength > 0;
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
>
<div
role="radiogroup"
aria-orientation="vertical"
aria-labelledby={id}
className="stack sm items-start"
>
{itemsWithLabels.map((item) => (
<div key={item.value} className="stack horizontal sm items-center">
<input
type="radio"
id={`${id}-${item.value}`}
name={id}
value={item.value}
checked={value === item.value}
onChange={() => onChange(item.value)}
onBlur={() => onBlur?.()}
disabled={disabled}
/>
<label
htmlFor={`${id}-${item.value}`}
className="stack horizontal sm items-center mb-0 whitespace-nowrap"
>
{item.imgSrc ? (
<img src={item.imgSrc} width={24} height={24} alt="" />
) : null}
{item.resolvedLabel}
</label>
</div>
))}
</div>
</FormFieldWrapper>
);
}
type CheckboxGroupFormFieldProps<V extends string> = Omit<
FormFieldProps<"checkbox-group">,
"items"
> & {
items: FormFieldItemsWithImage<V>;
value: V[];
onChange: (value: V[]) => void;
disabled?: boolean;
};
export function CheckboxGroupFormField<V extends string>({
name,
label,
bottomText,
items,
error,
onBlur,
value,
onChange,
minLength,
disabled,
}: CheckboxGroupFormFieldProps<V>) {
const id = React.useId();
const itemsWithLabels = useItemsWithResolvedLabels(items);
const required = typeof minLength !== "number" || minLength > 0;
const handleChange = (itemValue: V, checked: boolean) => {
const newValue = checked
? [...value, itemValue]
: value.filter((v) => v !== itemValue);
onChange(newValue);
// validate with the value the click produced — the click event would
// otherwise validate the pre-click value and show a stale error
onBlur?.(newValue);
};
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
>
<div className="stack sm items-start">
{itemsWithLabels.map((item) => (
<div key={item.value} className="stack horizontal sm items-center">
<input
type="checkbox"
id={`${id}-${item.value}`}
value={item.value}
checked={value.includes(item.value)}
onChange={(e) => handleChange(item.value, e.target.checked)}
disabled={disabled}
/>
<label
htmlFor={`${id}-${item.value}`}
className="stack horizontal sm items-center mb-0 whitespace-nowrap"
>
{item.imgSrc ? (
<img src={item.imgSrc} width={24} height={24} alt="" />
) : null}
{item.resolvedLabel}
</label>
</div>
))}
</div>
</FormFieldWrapper>
);
}
function useItemsWithResolvedLabels<V extends string>(
items: FormFieldItemsWithImage<V>,
) {
const { t, i18n } = useTranslation();
return items.map((item) => {
const itemLabel = item.label;
const resolvedLabel =
typeof itemLabel === "function"
? itemLabel(i18n.language)
: typeof itemLabel === "string" && itemLabel.includes(":")
? t(itemLabel as never)
: String(itemLabel);
return {
...item,
resolvedLabel,
};
});
}

View File

@@ -0,0 +1,3 @@
.searchable {
--select-width: 100%;
}

View File

@@ -0,0 +1,172 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import type { FormFieldItems, FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import {
FormFieldMessages,
FormFieldWrapper,
useTranslatedTexts,
} from "./FormFieldWrapper";
import styles from "./SelectFormField.module.css";
type SelectFormFieldProps<V extends string> = Omit<
FormFieldProps<"select">,
"items" | "clearable" | "onBlur" | "name" | "searchable"
> & {
name?: string;
items: FormFieldItems<V>;
value: V | null;
onChange: (value: V | null) => void;
onSelect?: (value: V) => void;
onBlur?: () => void;
clearable?: boolean;
searchable?: boolean;
disabled?: boolean;
};
export function SelectFormField<V extends string>({
name,
label,
bottomText,
items,
error,
onBlur,
value,
onChange,
onSelect,
clearable,
searchable,
disabled,
}: SelectFormFieldProps<V>) {
const { t, i18n } = useTranslation(["common"]);
const id = React.useId();
const itemsWithResolvedLabels = items.map((item) => {
const itemLabel = item.label;
const resolvedLabel =
typeof itemLabel === "function"
? itemLabel(i18n.language)
: typeof itemLabel === "string" &&
itemLabel.includes(":") &&
i18n.exists(itemLabel)
? t(itemLabel as never)
: String(itemLabel);
return {
value: item.value,
resolvedLabel,
};
});
if (searchable) {
return (
<SearchableSelect
name={name}
label={label}
bottomText={bottomText}
error={error}
items={itemsWithResolvedLabels}
value={value}
onChange={onChange}
onBlur={onBlur}
clearable={clearable}
disabled={disabled}
searchPlaceholder={t("common:actions.search")}
/>
);
}
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as V);
onChange(newValue);
if (newValue && onSelect) {
onSelect(newValue);
}
};
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<select
id={id}
name={name}
value={value ?? ""}
onChange={handleChange}
onBlur={() => onBlur?.()}
disabled={disabled}
{...ariaAttributes({ name, error, bottomText })}
>
{clearable ? <option value="">—</option> : null}
{itemsWithResolvedLabels.map((item) => (
<option key={item.value} value={item.value}>
{item.resolvedLabel}
</option>
))}
</select>
</FormFieldWrapper>
);
}
function SearchableSelect<V extends string>({
name,
label,
bottomText,
error,
items,
value,
onChange,
onBlur,
clearable,
disabled,
searchPlaceholder,
}: {
name?: string;
label?: string;
bottomText?: string;
error?: string;
items: Array<{ value: V; resolvedLabel: string }>;
value: V | null;
onChange: (value: V | null) => void;
onBlur?: () => void;
clearable?: boolean;
disabled?: boolean;
searchPlaceholder: string;
}) {
const { translatedLabel } = useTranslatedTexts({ label });
const selectItems = items.map((item) => ({
id: item.value,
textValue: item.resolvedLabel,
}));
return (
<div className={styles.searchable}>
<SendouSelect
label={translatedLabel}
selectedKey={value}
onSelectionChange={(key) => {
const newValue = key === "" ? null : (key as V);
onChange(newValue);
onBlur?.();
}}
items={selectItems}
search={{ placeholder: searchPlaceholder }}
clearable={clearable}
isDisabled={disabled}
>
{(item) => (
<SendouSelectItem id={item.id} textValue={item.textValue}>
{item.textValue}
</SendouSelectItem>
)}
</SendouSelect>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
.root {
& button[class] {
width: 100%;
}
}

View File

@@ -0,0 +1,41 @@
import { StageSelect } from "~/components/StageSelect";
import type { StageId } from "~/modules/in-game-lists/types";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./StageSelectFormField.module.css";
type StageSelectFormFieldProps = FormFieldProps<"stage-select"> & {
value: StageId | null;
onChange: (value: StageId) => void;
disabled?: boolean;
};
export function StageSelectFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onBlur,
disabled,
}: StageSelectFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({ label });
return (
<div className={styles.root}>
<StageSelect
label={translatedLabel}
value={value}
onChange={(id) => {
onChange(id);
onBlur?.(id);
}}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import { SendouSwitch } from "~/components/elements/Switch";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
type SwitchFormFieldProps = Omit<FormFieldProps<"switch">, "onBlur"> & {
checked: boolean;
onChange: (checked: boolean) => void;
isDisabled?: boolean;
};
export function SwitchFormField({
name,
label,
bottomText,
error,
checked,
onChange,
isDisabled,
}: SwitchFormFieldProps) {
const id = React.useId();
const { translatedLabel } = useTranslatedTexts({ label });
return (
<div className="stack xs">
<div className="stack horizontal sm items-center">
<SendouSwitch
id={id}
isSelected={checked}
onChange={onChange}
isDisabled={isDisabled}
>
{translatedLabel}
</SendouSwitch>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { TeamSearch } from "~/components/elements/TeamSearch";
import type { FormFieldProps, TeamSearchFieldOptions } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type TeamSearchFormFieldProps = FormFieldProps<"team-search"> &
TeamSearchFieldOptions & {
onChange: (value: number | null) => void;
disabled?: boolean;
};
export function TeamSearchFormField({
name,
label,
bottomText,
error,
required,
onChange,
onBlur,
onTeamSelected,
initialTeam,
disabled,
}: TeamSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<TeamSearch
initialTeam={initialTeam}
onChange={(team) => {
onChange(team?.id ?? null);
onTeamSelected?.(team);
}}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import * as React from "react";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type TextareaFormFieldProps = FormFieldProps<"text-area"> & {
disabled?: boolean;
autoFocus?: boolean;
value: string;
onChange: (value: string) => void;
};
export function TextareaFormField({
name,
label,
bottomText,
maxLength,
error,
onBlur,
required,
disabled,
autoFocus,
value,
onChange,
}: TextareaFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
valueLimits={
maxLength ? { current: value?.length ?? 0, max: maxLength } : undefined
}
>
<textarea
id={id}
ref={autoFocus ? moveCaretToEnd : undefined}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onBlur?.()}
disabled={disabled}
// biome-ignore lint/a11y/noAutofocus: opt-in per call site, used for inline edit forms
autoFocus={autoFocus}
{...ariaAttributes({
name,
bottomText,
error,
required,
})}
/>
</FormFieldWrapper>
);
}
/**
* Autofocusing a textarea leaves the caret before any existing text, which is the wrong place when
* editing something already written. Defined at module level so the ref identity stays stable and
* React only runs it on mount.
*/
function moveCaretToEnd(element: HTMLTextAreaElement | null) {
element?.setSelectionRange(element.value.length, element.value.length);
}

View File

@@ -0,0 +1,91 @@
import * as React from "react";
import type { FormFieldProps } from "../types";
import {
FormFieldMessages,
FormFieldWrapper,
useTranslatedTexts,
} from "./FormFieldWrapper";
type TimeRange = { start: string; end: string } | null;
type TimeRangeFormFieldProps = Omit<FormFieldProps<"time-range">, "onBlur"> & {
value: TimeRange;
onChange: (value: TimeRange) => void;
onBlur?: () => void;
disabled?: boolean;
};
export function TimeRangeFormField({
name,
label,
bottomText,
startLabel,
endLabel,
error,
onBlur,
value,
onChange,
disabled,
}: TimeRangeFormFieldProps) {
const startId = React.useId();
const endId = React.useId();
const { translatedLabel } = useTranslatedTexts({ label });
const { translatedLabel: translatedStartLabel } = useTranslatedTexts({
label: startLabel,
});
const { translatedLabel: translatedEndLabel } = useTranslatedTexts({
label: endLabel,
});
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = e.target.value;
if (!newStart && !value?.end) {
onChange(null);
} else {
onChange({ start: newStart, end: value?.end ?? "" });
}
};
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = e.target.value;
if (!newEnd && !value?.start) {
onChange(null);
} else {
onChange({ start: value?.start ?? "", end: newEnd });
}
};
return (
<div className="stack xs">
{translatedLabel ? (
<span className="text-sm font-semi-bold">{translatedLabel}</span>
) : null}
<div className="stack horizontal sm">
<FormFieldWrapper id={startId} label={translatedStartLabel}>
<input
id={startId}
type="time"
value={value?.start ?? ""}
onChange={handleStartChange}
onBlur={() => onBlur?.()}
disabled={disabled}
className="size-extra-small"
/>
</FormFieldWrapper>
<FormFieldWrapper id={endId} label={translatedEndLabel}>
<input
id={endId}
type="time"
value={value?.end ?? ""}
onChange={handleEndChange}
onBlur={() => onBlur?.()}
disabled={disabled}
className="size-extra-small"
/>
</FormFieldWrapper>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,53 @@
import {
TournamentSearch,
type TournamentSearchItem,
} from "~/components/elements/TournamentSearch";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type TournamentSearchFormFieldProps = FormFieldProps<"tournament-search"> & {
value: number | null;
onChange: (value: number | null) => void;
onTournamentSelected?: (tournament: TournamentSearchItem | null) => void;
pastOnly?: boolean;
disabled?: boolean;
};
export function TournamentSearchFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onTournamentSelected,
onBlur,
pastOnly,
disabled,
}: TournamentSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<TournamentSearch
initialTournamentId={value ?? undefined}
pastOnly={pastOnly}
onChange={(tournament) => {
onChange(tournament?.id ?? null);
onTournamentSelected?.(tournament);
}}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { TrophiesSelector } from "~/features/trophies/components/TrophiesSelector";
import type { FormFieldProps, TrophyOption } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
type TrophiesFormFieldProps = Omit<FormFieldProps<"trophies">, "onBlur"> & {
value: number[];
onChange: (value: number[]) => void;
onBlur?: () => void;
options: TrophyOption[];
};
export function TrophiesFormField({
name,
label,
bottomText,
error,
maxCount,
value,
onChange,
onBlur,
options,
}: TrophiesFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<TrophiesSelector
options={options}
selectedTrophies={value}
onChange={onChange}
onBlur={onBlur}
maxCount={maxCount}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,7 @@
.root {
width: 100%;
& button[class] {
width: 100%;
}
}

View File

@@ -0,0 +1,47 @@
import { UserSearch } from "~/components/elements/UserSearch";
import type { FormFieldProps, UserSearchFieldOptions } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type UserSearchFormFieldProps = FormFieldProps<"user-search"> &
UserSearchFieldOptions & {
value: number | null;
onChange: (value: number | null) => void;
disabled?: boolean;
};
export function UserSearchFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onUserSelected,
onBlur,
disabled,
}: UserSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => {
onChange(user?.id ?? null);
onUserSelected?.(user);
}}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
.list {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: 0;
margin: 0;
margin-block-start: var(--s-2);
list-style: none;
}
.item {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
transition: opacity 0.2s;
&.isDragging {
opacity: 0.5;
}
}
.dragHandle {
cursor: grab;
user-select: none;
color: var(--color-text-high);
font-size: var(--font-md);
touch-action: none;
background: none;
border: none;
padding: 0;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.weaponName {
text-transform: uppercase;
font-weight: var(--weight-semi);
color: var(--color-text);
font-size: var(--font-2xs);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weaponImage {
flex-shrink: 0;
}
.actions {
display: flex;
gap: var(--s-2);
flex-shrink: 0;
}
.starIconOutlined {
color: var(--color-text-accent);
}
.starIconFilled {
fill: var(--color-text-accent);
}

View File

@@ -0,0 +1,324 @@
import type { DragEndEvent } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
import { Star, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { WeaponImage } from "~/components/Image";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
import type { FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
import styles from "./WeaponPoolFormField.module.css";
export type WeaponPoolItem = {
id: MainWeaponId;
isFavorite: boolean;
};
type WeaponPoolFormFieldProps = FormFieldProps<"weapon-pool"> & {
value: WeaponPoolItem[];
onChange: (value: WeaponPoolItem[]) => void;
disabled?: boolean;
};
export function WeaponPoolFormField({
name,
label,
bottomText,
error,
maxCount,
disableSorting,
disableFavorites,
disableAltSkinDuplicates,
value,
onChange,
onBlur,
disabled,
}: WeaponPoolFormFieldProps) {
const { t } = useTranslation(["forms"]);
const id = React.useId();
const isFull = value.length >= maxCount;
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const disabledWeaponIds = disableAltSkinDuplicates
? value.flatMap((weapon) => weaponIdToArrayWithAlts(weapon.id))
: value.map((weapon) => weapon.id);
const handleSelect = (weaponId: MainWeaponId) => {
const newWeapon = {
id: weaponId,
isFavorite: false,
};
const newValue = [...value, newWeapon];
if (disableSorting) {
newValue.sort((a, b) => a.id - b.id);
}
onChange(newValue);
onBlur(newValue);
};
const handleToggleFavorite = (weaponId: MainWeaponId) => {
onChange(
value.map((weapon) =>
weapon.id === weaponId
? { ...weapon, isFavorite: !weapon.isFavorite }
: weapon,
),
);
};
const handleRemove = (weaponId: MainWeaponId) => {
const newValue = value.filter((weapon) => weapon.id !== weaponId);
onChange(newValue);
onBlur(newValue);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = value.findIndex((weapon) => weapon.id === active.id);
const newIndex = value.findIndex((weapon) => weapon.id === over.id);
const newValue = [...value];
const [removed] = newValue.splice(oldIndex, 1);
newValue.splice(newIndex, 0, removed);
onChange(newValue);
}
};
const weaponList = (
<ul className={styles.list}>
{value.map((weapon) =>
disableSorting || disabled ? (
<StaticWeaponItem
key={weapon.id}
weapon={weapon}
showFavoriteToggle={!disableFavorites}
onToggleFavorite={handleToggleFavorite}
onRemove={handleRemove}
disabled={disabled}
/>
) : (
<SortableWeaponItem
key={weapon.id}
weapon={weapon}
showFavoriteToggle={!disableFavorites}
onToggleFavorite={handleToggleFavorite}
onRemove={handleRemove}
/>
),
)}
</ul>
);
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<WeaponSelect
key={value.length}
onChange={(weaponId) => {
if (weaponId !== null) {
handleSelect(weaponId);
}
}}
disabledWeaponIds={disabledWeaponIds}
clearable
isDisabled={isFull || disabled}
placeholder={
isFull ? t("forms:placeholders.weaponPoolFull") : undefined
}
/>
{value.length > 0 ? (
disableSorting ? (
weaponList
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={value.map((weapon) => weapon.id)}
strategy={verticalListSortingStrategy}
>
{weaponList}
</SortableContext>
</DndContext>
)
) : null}
</FormFieldWrapper>
);
}
function StaticWeaponItem({
weapon,
showFavoriteToggle,
onToggleFavorite,
onRemove,
disabled,
}: {
weapon: WeaponPoolItem;
showFavoriteToggle: boolean;
onToggleFavorite: (id: MainWeaponId) => void;
onRemove: (id: MainWeaponId) => void;
disabled?: boolean;
}) {
const { t } = useTranslation(["weapons"]);
return (
<li className={styles.item}>
<WeaponImage
weaponSplId={weapon.id}
variant={
showFavoriteToggle && weapon.isFavorite ? "badge-5-star" : "badge"
}
size={32}
className={styles.weaponImage}
/>
<span className={styles.weaponName}>
{t(`weapons:MAIN_${weapon.id}`)}
</span>
<div className={styles.actions}>
{showFavoriteToggle ? (
<SendouButton
variant="minimal"
size="small"
icon={
weapon.isFavorite ? (
<Star className={styles.starIconFilled} fill="currentColor" />
) : (
<Star className={styles.starIconOutlined} />
)
}
aria-label="Toggle favorite"
onPress={() => onToggleFavorite(weapon.id)}
isDisabled={disabled}
/>
) : null}
<SendouButton
variant="minimal-destructive"
size="small"
icon={<Trash />}
aria-label="Delete"
onPress={() => onRemove(weapon.id)}
isDisabled={disabled}
/>
</div>
</li>
);
}
function SortableWeaponItem({
weapon,
showFavoriteToggle,
onToggleFavorite,
onRemove,
}: {
weapon: WeaponPoolItem;
showFavoriteToggle: boolean;
onToggleFavorite: (id: MainWeaponId) => void;
onRemove: (id: MainWeaponId) => void;
}) {
const { t } = useTranslation(["weapons"]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: weapon.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
className={clsx(styles.item, { [styles.isDragging]: isDragging })}
{...attributes}
>
<button
type="button"
className={styles.dragHandle}
aria-label="Drag to reorder"
{...listeners}
>
☰
</button>
<WeaponImage
weaponSplId={weapon.id}
variant={
showFavoriteToggle && weapon.isFavorite ? "badge-5-star" : "badge"
}
size={32}
className={styles.weaponImage}
/>
<span className={styles.weaponName}>
{t(`weapons:MAIN_${weapon.id}`)}
</span>
<div className={styles.actions}>
{showFavoriteToggle ? (
<SendouButton
variant="minimal"
size="small"
icon={
weapon.isFavorite ? (
<Star className={styles.starIconFilled} fill="currentColor" />
) : (
<Star className={styles.starIconOutlined} />
)
}
aria-label="Toggle favorite"
onPress={() => onToggleFavorite(weapon.id)}
/>
) : null}
<SendouButton
variant="minimal-destructive"
size="small"
icon={<Trash />}
aria-label="Delete"
onPress={() => onRemove(weapon.id)}
/>
</div>
</li>
);
}

View File

@@ -0,0 +1,5 @@
.root {
& button[class] {
width: 100%;
}
}

View File

@@ -0,0 +1,42 @@
import { WeaponSelect } from "~/components/WeaponSelect";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./WeaponSelectFormField.module.css";
type WeaponSelectFormFieldProps = FormFieldProps<"weapon-select"> & {
value: MainWeaponId | null;
onChange: (value: MainWeaponId | null) => void;
disabled?: boolean;
};
export function WeaponSelectFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onBlur,
disabled,
}: WeaponSelectFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({ label });
return (
<div className={styles.root}>
<WeaponSelect
label={translatedLabel}
value={value}
onChange={(id) => {
onChange(id);
onBlur?.(id);
}}
isRequired={required}
clearable={!required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}