UI: доводка выпадашек игроков — без пустых вариантов, фон как у select, быстрее отклик
Из списков убраны варианты-плейсхолдеры («— игрок —» в выборе игрока, «— фракция —» в выборе фракции — disabled+hidden, в закрытом виде текст остаётся). Фон .suggest-list — surface-2, как у input/select; hover строк — --border. Скорость: дебаунс поиска 250→100 мс, в инвайте показанный список дофильтровывается по вводу на клиенте, не дожидаясь ответа сервера; touch-action: manipulation на строках и триггере. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ export function PlayerSelect({
|
|||||||
}: {
|
}: {
|
||||||
value: number | "";
|
value: number | "";
|
||||||
options: PlayerSuggestion[];
|
options: PlayerSuggestion[];
|
||||||
onPick: (userId: number | "") => void;
|
onPick: (userId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const selected = value === "" ? null : options.find((o) => o.user_id === value) ?? null;
|
const selected = value === "" ? null : options.find((o) => o.user_id === value) ?? null;
|
||||||
@@ -48,14 +48,6 @@ export function PlayerSelect({
|
|||||||
onPick(u.user_id);
|
onPick(u.user_id);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
onClear={
|
|
||||||
selected
|
|
||||||
? () => {
|
|
||||||
onPick("");
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,28 +14,13 @@ export interface PlayerSuggestion {
|
|||||||
export function PlayerSuggestions({
|
export function PlayerSuggestions({
|
||||||
items,
|
items,
|
||||||
onPick,
|
onPick,
|
||||||
onClear,
|
|
||||||
}: {
|
}: {
|
||||||
items: PlayerSuggestion[];
|
items: PlayerSuggestion[];
|
||||||
onPick: (item: PlayerSuggestion) => void;
|
onPick: (item: PlayerSuggestion) => void;
|
||||||
// Необязательная строка сброса сверху списка («— игрок —» в PlayerSelect).
|
|
||||||
onClear?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
if (items.length === 0 && !onClear) return null;
|
if (items.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="suggest-list" role="listbox">
|
<div className="suggest-list" role="listbox">
|
||||||
{onClear && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="suggest-row suggest-clear"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onClear();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
— игрок —
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{items.map((u) => (
|
{items.map((u) => (
|
||||||
<button
|
<button
|
||||||
key={u.user_id}
|
key={u.user_id}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { api, unwrap } from "../api/client";
|
import { api, unwrap } from "../api/client";
|
||||||
import { qk } from "../api/queryKeys";
|
import { qk } from "../api/queryKeys";
|
||||||
|
|
||||||
/** Значение с задержкой: не дёргаем поиск на каждый введённый символ. */
|
/** Значение с задержкой: не дёргаем поиск на каждый введённый символ.
|
||||||
function useDebounced<T>(value: T, ms = 250): T {
|
* 100 мс — почти незаметно глазу, но склеивает быструю печать в один запрос. */
|
||||||
|
function useDebounced<T>(value: T, ms = 100): T {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setDebounced(value), ms);
|
const t = setTimeout(() => setDebounced(value), ms);
|
||||||
|
|||||||
@@ -215,7 +215,11 @@ export function CreateMatchPage() {
|
|||||||
}
|
}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
<option value="">— фракция —</option>
|
{/* disabled+hidden: в закрытом select это плейсхолдер, в раскрытом
|
||||||
|
списке варианта «— фракция —» нет. */}
|
||||||
|
<option value="" disabled hidden>
|
||||||
|
— фракция —
|
||||||
|
</option>
|
||||||
{(factions ?? []).map((f) => (
|
{(factions ?? []).map((f) => (
|
||||||
<option key={f.id} value={f.id}>
|
<option key={f.id} value={f.id}>
|
||||||
{f.name_ru}
|
{f.name_ru}
|
||||||
|
|||||||
@@ -57,8 +57,13 @@ export function GroupPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Подсказки: уже состоящих в группе не предлагаем, показываем до 3.
|
// Подсказки: уже состоящих в группе не предлагаем, показываем до 3.
|
||||||
|
// Дофильтровка по текущему вводу — на клиенте: пока сервер отвечает на новую
|
||||||
|
// букву, показанный список (keepPreviousData) сужается мгновенно.
|
||||||
const memberIds = new Set((members ?? []).map((m) => m.user_id));
|
const memberIds = new Set((members ?? []).map((m) => m.user_id));
|
||||||
const suggestions = (foundUsers ?? []).filter((u) => !memberIds.has(u.user_id)).slice(0, 3);
|
const needle = newNick.trim().toLowerCase();
|
||||||
|
const suggestions = (foundUsers ?? [])
|
||||||
|
.filter((u) => !memberIds.has(u.user_id) && u.nickname.toLowerCase().includes(needle))
|
||||||
|
.slice(0, 3);
|
||||||
const showSuggest =
|
const showSuggest =
|
||||||
nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0;
|
nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--surface);
|
background: var(--surface-2); /* как у input/select — выпадашка продолжает поле */
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
@@ -317,11 +317,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
touch-action: manipulation; /* тап без задержки двойного тапа */
|
||||||
}
|
}
|
||||||
.suggest-row:last-child { border-bottom: none; }
|
.suggest-row:last-child { border-bottom: none; }
|
||||||
.suggest-row:hover, .suggest-row:active { background: var(--surface-2); }
|
.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; }
|
||||||
.suggest-clear { color: var(--muted); font-weight: 400; }
|
|
||||||
|
|
||||||
/* Кнопка-триггер PlayerSelect: выглядит как select, раскрывает .suggest-list. */
|
/* Кнопка-триггер PlayerSelect: выглядит как select, раскрывает .suggest-list. */
|
||||||
.picker-trigger {
|
.picker-trigger {
|
||||||
@@ -339,6 +339,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
.picker-trigger:focus { border-color: var(--accent); }
|
.picker-trigger:focus { border-color: var(--accent); }
|
||||||
.picker-trigger .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.picker-trigger .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|||||||
Reference in New Issue
Block a user