mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-21 18:44:51 -05:00
Ingame name input (#3163)
This commit is contained in:
59
app/components/IngameNameInput.module.css
Normal file
59
app/components/IngameNameInput.module.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.inputRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& input {
|
||||
padding-right: calc(var(--field-size) + var(--s-1));
|
||||
}
|
||||
|
||||
& button {
|
||||
position: absolute;
|
||||
right: var(--s-1-5);
|
||||
}
|
||||
}
|
||||
|
||||
.picker {
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-high);
|
||||
gap: var(--s-1);
|
||||
padding-top: var(--s-0-5);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr));
|
||||
gap: var(--s-1);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-sm);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
165
app/components/IngameNameInput.tsx
Normal file
165
app/components/IngameNameInput.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import clsx from "clsx";
|
||||
import { Languages } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import {
|
||||
IN_GAME_NAME_CHARACTER_CATEGORIES,
|
||||
IN_GAME_NAME_MAX_LENGTH,
|
||||
inGameNameLength,
|
||||
sanitizeInGameName,
|
||||
} from "~/features/user-page/in-game-name";
|
||||
import styles from "./IngameNameInput.module.css";
|
||||
|
||||
interface IngameNameInputProps
|
||||
extends Pick<
|
||||
React.AriaAttributes,
|
||||
"aria-invalid" | "aria-describedby" | "aria-errormessage" | "aria-required"
|
||||
> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
id?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function IngameNameInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
id,
|
||||
name,
|
||||
disabled,
|
||||
placeholder,
|
||||
...ariaProps
|
||||
}: IngameNameInputProps) {
|
||||
const { t } = useTranslation(["forms"]);
|
||||
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const selectionRef = React.useRef({ start: value.length, end: value.length });
|
||||
const pendingCaretRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (pendingCaretRef.current === null) return;
|
||||
|
||||
const caret = pendingCaretRef.current;
|
||||
pendingCaretRef.current = null;
|
||||
|
||||
const input = inputRef.current;
|
||||
input?.focus();
|
||||
input?.setSelectionRange(caret, caret);
|
||||
});
|
||||
|
||||
const rememberSelection = () => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
selectionRef.current = {
|
||||
start: input.selectionStart ?? value.length,
|
||||
end: input.selectionEnd ?? value.length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = event.target.value;
|
||||
const cleaned = sanitizeInGameName(raw);
|
||||
|
||||
if (cleaned !== raw) {
|
||||
const caret = event.target.selectionStart ?? cleaned.length;
|
||||
pendingCaretRef.current = Math.max(
|
||||
0,
|
||||
Math.min(cleaned.length, caret - (raw.length - cleaned.length)),
|
||||
);
|
||||
}
|
||||
|
||||
onChange(cleaned);
|
||||
};
|
||||
|
||||
const insertCharacter = (character: string) => {
|
||||
const { start, end } = selectionRef.current;
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(end);
|
||||
|
||||
if (
|
||||
inGameNameLength(before + after) + inGameNameLength(character) >
|
||||
IN_GAME_NAME_MAX_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const caret = before.length + character.length;
|
||||
pendingCaretRef.current = caret;
|
||||
selectionRef.current = { start: caret, end: caret };
|
||||
onChange(before + character + after);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.inputRow}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
name={name}
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
maxLength={IN_GAME_NAME_MAX_LENGTH}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
onSelect={rememberSelection}
|
||||
{...ariaProps}
|
||||
/>
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
shape="square"
|
||||
icon={<Languages />}
|
||||
isDisabled={disabled}
|
||||
aria-label={t("forms:inGameName.addCharacter")}
|
||||
aria-expanded={isPickerOpen}
|
||||
onPress={() => setIsPickerOpen((open) => !open)}
|
||||
/>
|
||||
</div>
|
||||
{isPickerOpen ? (
|
||||
<SendouTabs className={styles.picker}>
|
||||
<SendouTabList>
|
||||
{IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => (
|
||||
<SendouTab key={category.id} id={category.id}>
|
||||
{t(category.label)}
|
||||
</SendouTab>
|
||||
))}
|
||||
</SendouTabList>
|
||||
{IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => (
|
||||
<SendouTabPanel key={category.id} id={category.id}>
|
||||
<div className={clsx(styles.grid, "scrollbar")}>
|
||||
{category.characters.map((character) => (
|
||||
<button
|
||||
key={character}
|
||||
type="button"
|
||||
className={styles.glyph}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => insertCharacter(character)}
|
||||
>
|
||||
{character}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
))}
|
||||
</SendouTabs>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { IN_GAME_NAME_REGEXP } from "~/features/user-page/user-page-constants";
|
||||
import { inGameNameIsValid } from "~/features/user-page/in-game-name";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
@@ -23,7 +23,7 @@ const paramsSchema = z.object({
|
||||
|
||||
const bodySchema = z.object({
|
||||
userId: id,
|
||||
inGameName: z.string().regex(IN_GAME_NAME_REGEXP),
|
||||
inGameName: z.string().refine(inGameNameIsValid),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
fieldset,
|
||||
idConstantOptional,
|
||||
image,
|
||||
inGameName,
|
||||
selectDynamic,
|
||||
stringConstant,
|
||||
teamSearchOptional,
|
||||
@@ -14,21 +15,12 @@ import {
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { TEAM } from "../team/team-constants";
|
||||
import { IN_GAME_NAME_REGEXP } from "../user-page/user-page-constants";
|
||||
|
||||
/** Combined in-game name e.g. `Sendou#1234` is at most 10 + `#` + 5 characters. */
|
||||
const IN_GAME_NAME_MAX_LENGTH = 16;
|
||||
|
||||
const memberFieldset = fieldset({
|
||||
fields: z.object({
|
||||
userId: userSearch({ label: "labels.player" }),
|
||||
inGameName: textFieldOptional({
|
||||
inGameName: inGameName({
|
||||
label: "labels.inGameName",
|
||||
maxLength: IN_GAME_NAME_MAX_LENGTH,
|
||||
regExp: {
|
||||
pattern: IN_GAME_NAME_REGEXP,
|
||||
message: "forms:errors.profileInGameName",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
58
app/features/user-page/in-game-name.test.ts
Normal file
58
app/features/user-page/in-game-name.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inGameNameIsValid } from "./in-game-name";
|
||||
|
||||
describe("inGameNameIsValid", () => {
|
||||
it("should pass valid in-game names", () => {
|
||||
const validNames = [
|
||||
"Sendou#12345",
|
||||
"The Player#12345",
|
||||
" a#1234",
|
||||
"A#1234",
|
||||
"Player#abcd",
|
||||
"Café#1234",
|
||||
"Ελλαδα#1234",
|
||||
"テストab#1234",
|
||||
"★Test★#1234",
|
||||
"½#1234",
|
||||
"naïve#1234",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(inGameNameIsValid(name), `expected "${name}" to pass`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not pass invalid in-game names", () => {
|
||||
const invalidNames = [
|
||||
"#1234",
|
||||
"Sendou1234",
|
||||
"Sendou#123",
|
||||
"Sendou# 1234",
|
||||
"Sendou#123456",
|
||||
"Sendou#ABCD",
|
||||
"12345678901#1234",
|
||||
"𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔#1234",
|
||||
"名前テスト1234#ab12c",
|
||||
"☆CR☆Sh𝓔𝓔p!#1234",
|
||||
"日本語#1234",
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(inGameNameIsValid(name), `expected "${name}" to fail`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject characters the Switch keyboard does not allow in names", () => {
|
||||
const invalidNames = [
|
||||
"test@me#1234",
|
||||
"100%#1234",
|
||||
"a\\b#1234",
|
||||
"●#1234",
|
||||
"♥#1234",
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(inGameNameIsValid(name), `expected "${name}" to fail`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
107
app/features/user-page/in-game-name.ts
Normal file
107
app/features/user-page/in-game-name.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { FormsTranslationKey } from "~/form/types";
|
||||
|
||||
const IN_GAME_NAME = {
|
||||
NAME_MAX_LENGTH: 10,
|
||||
DISCRIMINATOR_MIN_LENGTH: 4,
|
||||
DISCRIMINATOR_MAX_LENGTH: 5,
|
||||
};
|
||||
|
||||
export const IN_GAME_NAME_MAX_LENGTH =
|
||||
IN_GAME_NAME.NAME_MAX_LENGTH + 1 + IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH;
|
||||
|
||||
/**
|
||||
* @see {@link https://github.com/kjhf/NintendoSwitchKeyboard}
|
||||
*/
|
||||
export const IN_GAME_NAME_CHARACTER_CATEGORIES = [
|
||||
{
|
||||
id: "symbols",
|
||||
label: "inGameName.categories.symbols",
|
||||
characters: [
|
||||
..."¿¡′‘’‚‛•…″“”„«»←→↑↓⇒⇔˜ˊˋ¢€£¥¤𝑓×÷±∞√¬∀⊂⊃∴∵⁀∂№°¹²³¼½¾♪♭♀♂⚪⚫◎◻◼◇◆△▲▽▼☆★©®™§¶†⍑",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "accented",
|
||||
label: "inGameName.categories.accented",
|
||||
characters: [
|
||||
..."àáâãäåæāăąçćċčðďdždzèéêëēęěğġģħìíîïīįıijķĺļľłÀÁÂÃÄÅÆĀĂĄÇĆĊČÐĎDžDzÈÉÊËĒĘĚĞĠĢĦÌÍÎÏĪĮİIJĶĹĻĽŁñńņňòóôõöøœőŕřšßśşþťţùúûüūůűųýÿźżžÑŃŅŇÒÓÔÕÖØŒŐŔŘŠẞŚŞÞŤŢÙÚÛÜŪŮŰŲÝŸŹŻŽ",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "greek",
|
||||
label: "inGameName.categories.greek",
|
||||
characters: [..."αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"],
|
||||
},
|
||||
{
|
||||
id: "cyrillic",
|
||||
label: "inGameName.categories.cyrillic",
|
||||
characters: range(0x0410, 0x044f),
|
||||
},
|
||||
{
|
||||
id: "hiragana",
|
||||
label: "inGameName.categories.hiragana",
|
||||
characters: range(0x3041, 0x3096),
|
||||
},
|
||||
{
|
||||
id: "katakana",
|
||||
label: "inGameName.categories.katakana",
|
||||
characters: range(0x30a1, 0x30fa),
|
||||
},
|
||||
] as const satisfies ReadonlyArray<{
|
||||
id: string;
|
||||
label: FormsTranslationKey;
|
||||
characters: ReadonlyArray<string>;
|
||||
}>;
|
||||
|
||||
const SPECIAL_CHARACTERS = IN_GAME_NAME_CHARACTER_CATEGORIES.flatMap(
|
||||
(category) => category.characters,
|
||||
);
|
||||
|
||||
const ASCII_NOT_VALID = new Set(["%", "@", "\\"]);
|
||||
const ASCII_CHARACTERS = range(0x20, 0x7e).filter(
|
||||
(character) => !ASCII_NOT_VALID.has(character),
|
||||
);
|
||||
|
||||
const ALLOWED_CHARACTERS = new Set<string>([
|
||||
...ASCII_CHARACTERS,
|
||||
...SPECIAL_CHARACTERS,
|
||||
]);
|
||||
|
||||
const IN_GAME_NAME_REGEXP = new RegExp(
|
||||
`^(.+)#([0-9a-z]{${IN_GAME_NAME.DISCRIMINATOR_MIN_LENGTH},${IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH}})$`,
|
||||
"u",
|
||||
);
|
||||
|
||||
/** Length of a string counted in code points (so astral characters count as one). */
|
||||
export function inGameNameLength(value: string): number {
|
||||
return [...value].length;
|
||||
}
|
||||
|
||||
export function sanitizeInGameName(value: string): string {
|
||||
return [...value.normalize("NFC")]
|
||||
.filter((character) => ALLOWED_CHARACTERS.has(character))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function inGameNameIsValid(value: string): boolean {
|
||||
const match = IN_GAME_NAME_REGEXP.exec(value);
|
||||
if (!match) return false;
|
||||
|
||||
const nameCharacters = [...match[1]];
|
||||
if (
|
||||
nameCharacters.length < 1 ||
|
||||
nameCharacters.length > IN_GAME_NAME.NAME_MAX_LENGTH
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nameCharacters.every((character) => ALLOWED_CHARACTERS.has(character));
|
||||
}
|
||||
|
||||
function range(from: number, to: number): string[] {
|
||||
const characters: string[] = [];
|
||||
for (let codePoint = from; codePoint <= to; codePoint++) {
|
||||
characters.push(String.fromCodePoint(codePoint));
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IN_GAME_NAME_REGEXP } from "./user-page-constants";
|
||||
|
||||
describe("IN_GAME_NAME_REGEXP", () => {
|
||||
it("should pass valid in-game names", () => {
|
||||
const validNames = [
|
||||
"Sendou#12345",
|
||||
"The Player#12345",
|
||||
" a#1234",
|
||||
"A#1234",
|
||||
"Player#abcd",
|
||||
"名前テスト1234#ab12c",
|
||||
"☆CR☆Sh𝓔𝓔p!#1234",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(IN_GAME_NAME_REGEXP.test(name), `expected "${name}" to pass`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not pass invalid in-game names", () => {
|
||||
const invalidNames = [
|
||||
"#1234",
|
||||
"Sendou1234",
|
||||
"Sendou#123",
|
||||
"Sendou# 1234",
|
||||
"Sendou#123456",
|
||||
"Sendou#ABCD",
|
||||
"12345678901#1234",
|
||||
"𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔𝓔#1234",
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(IN_GAME_NAME_REGEXP.test(name), `expected "${name}" to fail`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,6 @@ export const USER = {
|
||||
CUSTOM_URL_MAX_LENGTH: 32,
|
||||
CUSTOM_NAME_MAX_LENGTH: 32,
|
||||
BATTLEFY_MAX_LENGTH: 32,
|
||||
IN_GAME_NAME_TEXT_MAX_LENGTH: 20,
|
||||
IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH: 5,
|
||||
WEAPON_POOL_MAX_SIZE: 5,
|
||||
COMMISSION_TEXT_MAX_LENGTH: 1000,
|
||||
MOD_NOTE_MAX_LENGTH: 2000,
|
||||
@@ -20,8 +18,6 @@ export const USER = {
|
||||
|
||||
export const SPL2_JOIN_ORDER_CUTOFF = 13_589;
|
||||
|
||||
export const IN_GAME_NAME_REGEXP = /^.{1,10}#[0-9a-z]{4,5}$/u;
|
||||
|
||||
export const MATCHES_PER_SEASONS_PAGE = 8;
|
||||
export const RESULTS_PER_PAGE = 25;
|
||||
export const HIGHLIGHTS_RESULTS_MAX = 500;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
dualSelectOptional,
|
||||
idConstantOptional,
|
||||
image,
|
||||
inGameName,
|
||||
selectDynamicOptional,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
@@ -38,7 +39,6 @@ import { allWidgetsFlat, findWidgetById } from "./core/widgets/portfolio";
|
||||
import {
|
||||
HIGHLIGHT_CHECKBOX_NAME,
|
||||
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
|
||||
IN_GAME_NAME_REGEXP,
|
||||
USER,
|
||||
} from "./user-page-constants";
|
||||
|
||||
@@ -87,17 +87,9 @@ export const userEditProfileBaseSchema = z.object({
|
||||
message: "forms:errors.profileCustomUrlNumbers",
|
||||
},
|
||||
}),
|
||||
inGameName: textFieldOptional({
|
||||
inGameName: inGameName({
|
||||
label: "labels.inGameName",
|
||||
bottomText: "bottomTexts.profileInGameName",
|
||||
maxLength:
|
||||
USER.IN_GAME_NAME_TEXT_MAX_LENGTH +
|
||||
1 +
|
||||
USER.IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH,
|
||||
regExp: {
|
||||
pattern: IN_GAME_NAME_REGEXP,
|
||||
message: "forms:errors.profileInGameName",
|
||||
},
|
||||
}),
|
||||
sensitivity: dualSelectOptional({
|
||||
fields: [
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DatetimeFormField } from "./fields/DatetimeFormField";
|
||||
import { DualSelectFormField } from "./fields/DualSelectFormField";
|
||||
import { FieldsetFormField } from "./fields/FieldsetFormField";
|
||||
import { ImageFormField } from "./fields/ImageFormField";
|
||||
import { InGameNameFormField } from "./fields/InGameNameFormField";
|
||||
import { InputFormField } from "./fields/InputFormField";
|
||||
import {
|
||||
CheckboxGroupFormField,
|
||||
@@ -189,6 +190,18 @@ export function FormField({
|
||||
);
|
||||
}
|
||||
|
||||
if (formField.type === "in-game-name") {
|
||||
return (
|
||||
<InGameNameFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={disabled}
|
||||
value={value as string}
|
||||
onChange={handleChange as (v: string) => void}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (formField.type === "switch") {
|
||||
return (
|
||||
<SwitchFormField
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as R from "remeda";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
IN_GAME_NAME_MAX_LENGTH,
|
||||
inGameNameIsValid,
|
||||
} from "~/features/user-page/in-game-name";
|
||||
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
|
||||
import {
|
||||
date,
|
||||
@@ -199,6 +203,29 @@ function textFieldRefined<T extends z.ZodType<string | null>>(
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export function inGameName(
|
||||
args: WithTypedTranslationKeys<{
|
||||
label?: FormsTranslationKey;
|
||||
bottomText?: FormsTranslationKey;
|
||||
}>,
|
||||
) {
|
||||
const schema = safeNullableStringSchema({
|
||||
max: IN_GAME_NAME_MAX_LENGTH,
|
||||
}).refine((val) => val === null || inGameNameIsValid(val), {
|
||||
message: "forms:errors.profileInGameName",
|
||||
});
|
||||
|
||||
return schema.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
maxLength: IN_GAME_NAME_MAX_LENGTH,
|
||||
required: false,
|
||||
type: "in-game-name",
|
||||
initialValue: "",
|
||||
});
|
||||
}
|
||||
|
||||
export function numberField(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
|
||||
49
app/form/fields/InGameNameFormField.tsx
Normal file
49
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({ id, bottomText, error, required })}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,11 @@ interface FormFieldTextarea<T extends string> extends FormFieldBase<T> {
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
interface FormFieldInGameName<T extends string> extends FormFieldBase<T> {
|
||||
maxLength: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldItem<V extends string> {
|
||||
label: string | number | ((lang: string) => string);
|
||||
value: V;
|
||||
@@ -181,6 +186,7 @@ interface FormFieldWeaponSelect<T extends string> extends FormFieldBase<T> {
|
||||
export type FormField<V extends string = string> =
|
||||
| FormFieldBase<"custom">
|
||||
| FormFieldText<"text-field">
|
||||
| FormFieldInGameName<"in-game-name">
|
||||
| FormFieldTextarea<"text-area">
|
||||
| FormFieldBase<"switch">
|
||||
| FormFieldSelect<"select", V>
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Brugerdefineret URL er allerede i brug",
|
||||
"errors.profileSensBothOrNeither": "Bevægelsesfølsomhed kan ikke indstilles før at Styrepindsfølsomheden er indstillet",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Diese Benutzerdefinierte URL wird bereits verwendet",
|
||||
"errors.profileSensBothOrNeither": "Empfindlichkeit der Bewegungssteuerung kann nur festgelegt werden, wenn Empfindlichkeit R-Stick festgelegt ist",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Someone is already using this custom URL",
|
||||
"errors.profileSensBothOrNeither": "Motion sens can't be set if R-stick sens isn't",
|
||||
"errors.profileInGameName": "Must match format: Name#disc (1-10 characters, #, 4-5 alphanumeric)",
|
||||
"inGameName.addCharacter": "Add special character",
|
||||
"inGameName.categories.symbols": "Symbols",
|
||||
"inGameName.categories.accented": "Accented",
|
||||
"inGameName.categories.greek": "Greek",
|
||||
"inGameName.categories.cyrillic": "Cyrillic",
|
||||
"inGameName.categories.hiragana": "Hiragana",
|
||||
"inGameName.categories.katakana": "Katakana",
|
||||
"labels.pronoun": "Pronoun",
|
||||
"bottomTexts.profilePronouns": "This setting is optional! Your pronouns will be displayed on your profile, tournament rosters, SendouQ groups, and text channels.",
|
||||
"errors.profilePronounsBothOrNeither": "Select both pronouns or neither",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado",
|
||||
"errors.profileSensBothOrNeither": "Motion sens can't be set if R-stick sens isn't",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Alguien ya tiene ese enlace personalizado",
|
||||
"errors.profileSensBothOrNeither": "Sens de giroscopio no se poner sin la sens de palanca",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Cette URL a déjà été choisie par quelqu'un",
|
||||
"errors.profileSensBothOrNeither": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Cette URL a déjà été choisie par quelqu'un",
|
||||
"errors.profileSensBothOrNeither": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "מישהו כבר משתמש בכתובת URL המותאמת אישית הזו",
|
||||
"errors.profileSensBothOrNeither": "לא ניתן להגדיר את רגישות התנועה אם רגישות הסטיק לא מוגדרת",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "L'URL personalizzato è già in uso da un altro utente",
|
||||
"errors.profileSensBothOrNeither": "La sensibilità del giroscopio non può essere impostata se non hai impostato la sensibilità del joystick destro",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "このカスタム URL はすでに使用されています",
|
||||
"errors.profileSensBothOrNeither": "右スティックの感度が設定されていない場合、感度を設定することはできません",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "",
|
||||
"errors.profileSensBothOrNeither": "",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Deze URL is al in gebruik",
|
||||
"errors.profileSensBothOrNeither": "Bewegingsgevoeligheid kan niet worden ingesteld als er niets voor de R-stick ingevoerd is",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Te niestandardowe URl jest już przez kogoś zajęte",
|
||||
"errors.profileSensBothOrNeither": "Motion sens nie może być ustawione jeśli R-stick sens nie jest",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Alguém já está usando esse URL personalizado",
|
||||
"errors.profileSensBothOrNeither": "A sensibilidade de Movimento não pode ser definida se a sensibilidade do Analógico Direito não está",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "Кто-то уже использует этот пользовательский URL",
|
||||
"errors.profileSensBothOrNeither": "Чувствительность наклона не может быть указана, если не указана чувствительность стика",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
@@ -264,6 +264,13 @@
|
||||
"errors.profileCustomUrlDuplicate": "这个自定义URL已被使用",
|
||||
"errors.profileSensBothOrNeither": "设置体感感度前请先设置摇杆感度",
|
||||
"errors.profileInGameName": "",
|
||||
"inGameName.addCharacter": "",
|
||||
"inGameName.categories.symbols": "",
|
||||
"inGameName.categories.accented": "",
|
||||
"inGameName.categories.greek": "",
|
||||
"inGameName.categories.cyrillic": "",
|
||||
"inGameName.categories.hiragana": "",
|
||||
"inGameName.categories.katakana": "",
|
||||
"labels.pronoun": "",
|
||||
"bottomTexts.profilePronouns": "",
|
||||
"errors.profilePronounsBothOrNeither": "",
|
||||
|
||||
Reference in New Issue
Block a user