diff --git a/app/components/IngameNameInput.module.css b/app/components/IngameNameInput.module.css new file mode 100644 index 000000000..e58e3574b --- /dev/null +++ b/app/components/IngameNameInput.module.css @@ -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; + } +} diff --git a/app/components/IngameNameInput.tsx b/app/components/IngameNameInput.tsx new file mode 100644 index 000000000..9098474fa --- /dev/null +++ b/app/components/IngameNameInput.tsx @@ -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(null); + const selectionRef = React.useRef({ start: value.length, end: value.length }); + const pendingCaretRef = React.useRef(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) => { + 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 ( +
+
+ + } + isDisabled={disabled} + aria-label={t("forms:inGameName.addCharacter")} + aria-expanded={isPickerOpen} + onPress={() => setIsPickerOpen((open) => !open)} + /> +
+ {isPickerOpen ? ( + + + {IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => ( + + {t(category.label)} + + ))} + + {IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => ( + +
+ {category.characters.map((character) => ( + + ))} +
+
+ ))} +
+ ) : null} +
+ ); +} diff --git a/app/components/SubNav.tsx b/app/components/SubNav.tsx index b0600a5c6..e32d8cd5c 100644 --- a/app/components/SubNav.tsx +++ b/app/components/SubNav.tsx @@ -7,14 +7,16 @@ import styles from "./SubNav.module.css"; export function SubNav({ children, secondary, + className, }: { children: React.ReactNode; secondary?: boolean; + className?: string; }) { return (