mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 08:38:42 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
39
apps/web-react/app/form/fields/ArrayFormField.module.css
Normal file
39
apps/web-react/app/form/fields/ArrayFormField.module.css
Normal 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;
|
||||
}
|
||||
259
apps/web-react/app/form/fields/ArrayFormField.tsx
Normal file
259
apps/web-react/app/form/fields/ArrayFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web-react/app/form/fields/BadgesFormField.tsx
Normal file
46
apps/web-react/app/form/fields/BadgesFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web-react/app/form/fields/DatetimeFormField.tsx
Normal file
76
apps/web-react/app/form/fields/DatetimeFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/web-react/app/form/fields/DualSelectFormField.tsx
Normal file
53
apps/web-react/app/form/fields/DualSelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web-react/app/form/fields/FieldsetFormField.tsx
Normal file
49
apps/web-react/app/form/fields/FieldsetFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/web-react/app/form/fields/FormFieldWrapper.module.css
Normal file
12
apps/web-react/app/form/fields/FormFieldWrapper.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
119
apps/web-react/app/form/fields/FormFieldWrapper.tsx
Normal file
119
apps/web-react/app/form/fields/FormFieldWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/web-react/app/form/fields/ImageFormField.module.css
Normal file
13
apps/web-react/app/form/fields/ImageFormField.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
111
apps/web-react/app/form/fields/ImageFormField.tsx
Normal file
111
apps/web-react/app/form/fields/ImageFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web-react/app/form/fields/InGameNameFormField.tsx
Normal file
49
apps/web-react/app/form/fields/InGameNameFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web-react/app/form/fields/InputFormField.tsx
Normal file
78
apps/web-react/app/form/fields/InputFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
apps/web-react/app/form/fields/InputGroupFormField.tsx
Normal file
170
apps/web-react/app/form/fields/InputGroupFormField.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.searchable {
|
||||
--select-width: 100%;
|
||||
}
|
||||
172
apps/web-react/app/form/fields/SelectFormField.tsx
Normal file
172
apps/web-react/app/form/fields/SelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
& button[class] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
41
apps/web-react/app/form/fields/StageSelectFormField.tsx
Normal file
41
apps/web-react/app/form/fields/StageSelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web-react/app/form/fields/SwitchFormField.tsx
Normal file
39
apps/web-react/app/form/fields/SwitchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web-react/app/form/fields/TeamSearchFormField.tsx
Normal file
46
apps/web-react/app/form/fields/TeamSearchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/web-react/app/form/fields/TextareaFormField.tsx
Normal file
67
apps/web-react/app/form/fields/TextareaFormField.tsx
Normal 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);
|
||||
}
|
||||
91
apps/web-react/app/form/fields/TimeRangeFormField.tsx
Normal file
91
apps/web-react/app/form/fields/TimeRangeFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/web-react/app/form/fields/TournamentSearchFormField.tsx
Normal file
53
apps/web-react/app/form/fields/TournamentSearchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web-react/app/form/fields/TrophiesFormField.tsx
Normal file
43
apps/web-react/app/form/fields/TrophiesFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
|
||||
& button[class] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
47
apps/web-react/app/form/fields/UserSearchFormField.tsx
Normal file
47
apps/web-react/app/form/fields/UserSearchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
324
apps/web-react/app/form/fields/WeaponPoolFormField.tsx
Normal file
324
apps/web-react/app/form/fields/WeaponPoolFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
& button[class] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
42
apps/web-react/app/form/fields/WeaponSelectFormField.tsx
Normal file
42
apps/web-react/app/form/fields/WeaponSelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user