UI: выбор фракции — выпадашка в стиле выбора игрока, без занятых фракций

PlayerSelect обобщён в PickerSelect (рендер опций задаёт родитель) — им
оформлены оба поля создания партии. Фракции, выбранные в других строках,
не предлагаются (сервер при рандоме и так их исключал); набор фракций
по-прежнему приходит из available_factions группы с учётом дополнений.
Выбранное значение ищется в полном списке, а не в отфильтрованном:
после «Рандом всем» с повторами фракция строки может быть занята соседней.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 03:23:14 +03:00
co-authored by Claude Fable 5
parent 8a2b53d0b2
commit 93d81010f7
4 changed files with 100 additions and 88 deletions
+69
View File
@@ -0,0 +1,69 @@
import { ChevronDown } from "lucide-react";
import { type ReactNode, useState } from "react";
/**
* Выпадающий выбор «как select» в стиле подсказок ников (.suggest-*):
* кнопка-триггер с выбранным значением, под ней список опций. Родитель сам
* фильтрует options (например, убирает выбранное в других строках) и передаёт
* selected, найденный в ПОЛНОМ списке: выбранное значение может отсутствовать
* в options (например, фракция занята другой строкой после «Рандом всем»).
*/
export function PickerSelect<T extends { id: number }>({
selected,
options,
placeholder,
renderOption,
renderValue,
onPick,
}: {
selected: T | null;
options: T[];
placeholder: string;
renderOption: (option: T) => ReactNode;
/** Отрисовка выбранного в триггере; по умолчанию — как renderOption. */
renderValue?: (option: T) => ReactNode;
onPick: (option: T) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="suggest-wrap">
<button
type="button"
className="picker-trigger"
onClick={() => setOpen((v) => !v)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
>
{selected ? (
(renderValue ?? renderOption)(selected)
) : (
<span className="picker-placeholder">{placeholder}</span>
)}
<ChevronDown size={16} className="picker-chevron" />
</button>
{open && options.length > 0 && (
<div className="suggest-list" role="listbox">
{options.map((o) => (
<button
key={o.id}
type="button"
className="suggest-row"
role="option"
// mousedown, а не click: mousedown срабатывает ДО blur триггера —
// иначе onBlur скрыл бы список раньше клика (см. PlayerSuggestions).
onMouseDown={(e) => {
e.preventDefault();
onPick(o);
setOpen(false);
}}
>
{renderOption(o)}
</button>
))}
</div>
)}
</div>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { Avatar } from "./Avatar";
import { PlayerSuggestions, type PlayerSuggestion } from "./PlayerSuggestions";
/**
* Выбор игрока «как select», но в стиле выпадающих подсказок ников:
* кнопка-триггер с аватаром и ником, под ней список options. Родитель сам
* фильтрует options (например, убирает выбранных в других строках).
*/
export function PlayerSelect({
value,
options,
onPick,
}: {
value: number | "";
options: PlayerSuggestion[];
onPick: (userId: number) => void;
}) {
const [open, setOpen] = useState(false);
const selected = value === "" ? null : options.find((o) => o.user_id === value) ?? null;
return (
<div className="suggest-wrap">
<button
type="button"
className="picker-trigger"
onClick={() => setOpen((v) => !v)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
>
{selected ? (
<>
<Avatar url={selected.avatar_url} nickname={selected.nickname} size={24} />
<span className="nick">{selected.nickname}</span>
</>
) : (
<span className="picker-placeholder">— игрок —</span>
)}
<ChevronDown size={16} className="picker-chevron" />
</button>
{open && (
<PlayerSuggestions
items={options}
onPick={(u) => {
onPick(u.user_id);
setOpen(false);
}}
/>
)}
</div>
);
}