UI: выбор фракции — выпадашка в стиле выбора игрока, без занятых фракций
PlayerSelect обобщён в PickerSelect (рендер опций задаёт родитель) — им оформлены оба поля создания партии. Фракции, выбранные в других строках, не предлагаются (сервер при рандоме и так их исключал); набор фракций по-прежнему приходит из available_factions группы с учётом дополнений. Выбранное значение ищется в полном списке, а не в отфильтрованном: после «Рандом всем» с повторами фракция строки может быть занята соседней. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { PlayerSelect } from "../components/PlayerSelect";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { PickerSelect } from "../components/PickerSelect";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { MAX_MATCH_PLAYERS } from "../domain/constants";
|
import { MAX_MATCH_PLAYERS } from "../domain/constants";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
@@ -168,6 +169,20 @@ export function CreateMatchPage() {
|
|||||||
const memberOptions = (members ?? []) as MemberRead[];
|
const memberOptions = (members ?? []) as MemberRead[];
|
||||||
const spinning = Object.values(spin).some((v) => v != null);
|
const spinning = Object.values(spin).some((v) => v != null);
|
||||||
|
|
||||||
|
// Опции выпадашек. Игрок — аватар + ник (в триггере аватар мельче, чтобы
|
||||||
|
// поле не разъезжалось по высоте с соседними).
|
||||||
|
const playerOptions = memberOptions.map((m) => ({
|
||||||
|
id: m.user_id,
|
||||||
|
nickname: m.nickname,
|
||||||
|
avatar_url: m.avatar_url,
|
||||||
|
}));
|
||||||
|
const playerLabel = (m: { nickname: string; avatar_url?: string | null }, size: number) => (
|
||||||
|
<>
|
||||||
|
<Avatar url={m.avatar_url} nickname={m.nickname} size={size} />
|
||||||
|
<span className="nick">{m.nickname}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -189,43 +204,26 @@ export function CreateMatchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlayerSelect
|
<PickerSelect
|
||||||
value={row.user_id}
|
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
||||||
options={memberOptions
|
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))}
|
||||||
.filter((m) => !takenUserIds(idx).includes(m.user_id))
|
placeholder="— игрок —"
|
||||||
.map((m) => ({
|
renderOption={(m) => playerLabel(m, 28)}
|
||||||
user_id: m.user_id,
|
renderValue={(m) => playerLabel(m, 24)}
|
||||||
nickname: m.nickname,
|
onPick={(m) => update(idx, { user_id: m.id })}
|
||||||
avatar_url: m.avatar_url,
|
|
||||||
}))}
|
|
||||||
onPick={(id) => update(idx, { user_id: id })}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{spin[idx] != null ? (
|
{spin[idx] != null ? (
|
||||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<PickerSelect
|
||||||
value={row.faction_id}
|
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
||||||
onChange={(e) =>
|
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))}
|
||||||
update(idx, {
|
placeholder="— фракция —"
|
||||||
faction_id: e.target.value ? Number(e.target.value) : "",
|
renderOption={(f) => f.name_ru}
|
||||||
was_random: false,
|
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })}
|
||||||
})
|
/>
|
||||||
}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
{/* disabled+hidden: в закрытом select это плейсхолдер, в раскрытом
|
|
||||||
списке варианта «— фракция —» нет. */}
|
|
||||||
<option value="" disabled hidden>
|
|
||||||
— фракция —
|
|
||||||
</option>
|
|
||||||
{(factions ?? []).map((f) => (
|
|
||||||
<option key={f.id} value={f.id}>
|
|
||||||
{f.name_ru}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.suggest-row:hover, .suggest-row:active { background: var(--border); }
|
.suggest-row:hover, .suggest-row:active { background: var(--border); }
|
||||||
.suggest-row .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.suggest-row .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* Кнопка-триггер PlayerSelect: выглядит как select, раскрывает .suggest-list. */
|
/* Кнопка-триггер PickerSelect: выглядит как select, раскрывает .suggest-list. */
|
||||||
.picker-trigger {
|
.picker-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user