fix(ui): исправить фокус списка серверов и контраст общих элементов
This commit is contained in:
1 parent
9b0d7bb6e8
commit
3c9096804b
21 files changed
+6952
-58
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
} from "@tanstack/react-table"
|
||||
import type { Server, ServerType } from "@/lib/data"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Badge, type BadgeProps } from "@/components/reui/badge"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -49,10 +50,10 @@ const TYPE_LABELS: Record<ServerType, string> = {
|
||||
"home-router": "Home Router",
|
||||
}
|
||||
|
||||
const TYPE_STYLES: Record<ServerType, string> = {
|
||||
"jump-host": "bg-violet-500/10 text-violet-400 border-violet-500/20",
|
||||
"exit-node": "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
||||
"home-router": "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
const TYPE_VARIANTS: Record<ServerType, BadgeProps["variant"]> = {
|
||||
"jump-host": "primary-light",
|
||||
"exit-node": "info-light",
|
||||
"home-router": "success-light",
|
||||
}
|
||||
|
||||
function rosVer(os: string): number {
|
||||
@@ -63,13 +64,13 @@ function rosVer(os: string): number {
|
||||
|
||||
function TypeBadge({ type }: { type: ServerType }) {
|
||||
const icon =
|
||||
type === "jump-host" ? <ServerIcon className="size-3 mr-1" />
|
||||
: type === "exit-node" ? <NetworkIcon className="size-3 mr-1" />
|
||||
: <HomeIcon className="size-3 mr-1" />
|
||||
type === "jump-host" ? <ServerIcon aria-hidden="true" />
|
||||
: type === "exit-node" ? <NetworkIcon aria-hidden="true" />
|
||||
: <HomeIcon aria-hidden="true" />
|
||||
return (
|
||||
<span className={cn("inline-flex items-center text-xs font-medium border rounded px-2 py-0.5", TYPE_STYLES[type])}>
|
||||
<Badge variant={TYPE_VARIANTS[type]} className="text-foreground">
|
||||
{icon}{TYPE_LABELS[type]}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function SegmentedControl<T extends string>({
|
||||
>
|
||||
{option.label}
|
||||
{option.count != null && (
|
||||
<span className="text-xs tabular-nums opacity-60">{option.count}</span>
|
||||
<span className="text-xs tabular-nums">{option.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -35,6 +35,10 @@ import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
||||
export const ALL_SERVERS_ID = "all"
|
||||
|
||||
// Device URL changes can remount the page. Keep only the current keyboard intent,
|
||||
// and never restore it after the user has focused another control.
|
||||
let pendingKeyboardFocus: { path: string; id: string; source: Element | null; until: number } | null = null
|
||||
|
||||
export interface ServerTileItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -185,7 +189,15 @@ function ServerTileRail({
|
||||
useEffect(() => {
|
||||
const viewport = listRef.current?.closest<HTMLElement>('[data-slot="scroll-area-viewport"]')
|
||||
viewport?.scrollTo({ top: 0 })
|
||||
listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" })
|
||||
const selected = listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||
selected?.scrollIntoView({ block: "nearest" })
|
||||
const pending = pendingKeyboardFocus
|
||||
if (pending && (pending.until < performance.now() || pending.path !== window.location.pathname)) {
|
||||
pendingKeyboardFocus = null
|
||||
} else if (pending && pending.id === resolvedId && selected?.getClientRects().length) {
|
||||
if (document.activeElement === document.body || document.activeElement === pending.source) selected.focus({ preventScroll: true })
|
||||
pendingKeyboardFocus = null
|
||||
}
|
||||
}, [pageIndex, resolvedId, q, pageSize])
|
||||
const visibleIds = new Set(table.getRowModel().rows.map(row => row.id))
|
||||
const visibleGroups = groups.map(group => ({ ...group, items: group.items.filter(item => visibleIds.has(item.id)) })).filter(group => group.items.length > 0)
|
||||
@@ -200,6 +212,19 @@ function ServerTileRail({
|
||||
return ids
|
||||
}, [filtered, showAllTile])
|
||||
|
||||
const tabStopId = activeId && selectableIds.includes(activeId)
|
||||
? activeId
|
||||
: showAllTile ? ALL_SERVERS_ID : table.getRowModel().rows.find(row => isSelectable(row.original))?.id
|
||||
|
||||
function selectWithKeyboard(id: string) {
|
||||
if (id === resolvedId) {
|
||||
listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.focus()
|
||||
return
|
||||
}
|
||||
pendingKeyboardFocus = { path: window.location.pathname, id, source: document.activeElement, until: performance.now() + 10000 }
|
||||
onSelect(id)
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (selectableIds.length === 0) return
|
||||
const idx = selectableIds.indexOf(resolvedId)
|
||||
@@ -208,7 +233,7 @@ function ServerTileRail({
|
||||
? delta > 0 ? 0 : selectableIds.length - 1
|
||||
: Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
|
||||
const nextId = selectableIds[nextIdx]
|
||||
if (nextId) onSelect(nextId)
|
||||
if (nextId) selectWithKeyboard(nextId)
|
||||
}
|
||||
|
||||
function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
@@ -225,13 +250,13 @@ function ServerTileRail({
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
if (selectableIds[0]) onSelect(selectableIds[0])
|
||||
if (selectableIds[0]) selectWithKeyboard(selectableIds[0])
|
||||
return
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault()
|
||||
const last = selectableIds[selectableIds.length - 1]
|
||||
if (last) onSelect(last)
|
||||
if (last) selectWithKeyboard(last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,8 +308,6 @@ function ServerTileRail({
|
||||
className="flex flex-col gap-0.5 p-1.5"
|
||||
role="listbox"
|
||||
aria-label="Серверы"
|
||||
aria-activedescendant={activeId ? `${railId}-server-tile-${activeId}` : undefined}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleListKeyDown}
|
||||
>
|
||||
{showAllTile ? (
|
||||
@@ -292,6 +315,7 @@ function ServerTileRail({
|
||||
id={`${railId}-server-tile-${ALL_SERVERS_ID}`}
|
||||
item={allItem}
|
||||
selected={resolvedId === ALL_SERVERS_ID}
|
||||
tabStop={tabStopId === ALL_SERVERS_ID}
|
||||
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||
showCount={showCount}
|
||||
showType={false}
|
||||
@@ -315,6 +339,7 @@ function ServerTileRail({
|
||||
id={`${railId}-server-tile-${item.id}`}
|
||||
item={item}
|
||||
selected={resolvedId === item.id}
|
||||
tabStop={tabStopId === item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
@@ -354,6 +379,7 @@ function ServerTileButton({
|
||||
id,
|
||||
item,
|
||||
selected,
|
||||
tabStop,
|
||||
onSelect,
|
||||
showCount,
|
||||
showType,
|
||||
@@ -363,6 +389,7 @@ function ServerTileButton({
|
||||
id: string
|
||||
item: ServerTileItem
|
||||
selected: boolean
|
||||
tabStop: boolean
|
||||
onSelect: () => void
|
||||
showCount: boolean
|
||||
showType: boolean
|
||||
@@ -385,6 +412,7 @@ function ServerTileButton({
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabIndex={tabStop ? 0 : -1}
|
||||
title={item.title ?? hostname}
|
||||
disabled={!selectable}
|
||||
onClick={onSelect}
|
||||
|
||||
Reference in new issue
Block a user