Improve client accessibility and bump Harbor versions
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
accessTabForKey,
|
||||
connectionAction,
|
||||
connectionDurationParts,
|
||||
copyText,
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
|
||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||
const ACCESS_TABS = [
|
||||
['gateway', 'Gateway'],
|
||||
['proxy', 'Gateway Proxy'],
|
||||
];
|
||||
|
||||
function CloudTooltip({ children }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
@@ -340,6 +345,7 @@ function LocalRulesPanel({
|
||||
error,
|
||||
operations,
|
||||
panelRef,
|
||||
closeRef,
|
||||
onAdd,
|
||||
onChange,
|
||||
onRemove,
|
||||
@@ -361,6 +367,13 @@ function LocalRulesPanel({
|
||||
inert={!open ? true : undefined}
|
||||
>
|
||||
<div className="client-local-rules-sheet">
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть локальные правила"
|
||||
onClick={onClose}
|
||||
>×</button>
|
||||
<header className="client-local-rules-header">
|
||||
<span>Маршрутизация</span>
|
||||
<button
|
||||
@@ -629,8 +642,10 @@ export function ClientOverviewPage({
|
||||
const copyTimerRef = useRef(null);
|
||||
const instructionsPanelRef = useRef(null);
|
||||
const instructionsToggleRef = useRef(null);
|
||||
const instructionsCloseRef = useRef(null);
|
||||
const localRulesPanelRef = useRef(null);
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const localRulesCloseRef = useRef(null);
|
||||
const localRulesBaselineRef = useRef('[]');
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
const serverKey = servers.map((server) => server.id).join('|');
|
||||
@@ -787,13 +802,31 @@ export function ClientOverviewPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (!instructionsOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
|
||||
const closeOnEscape = (event) => {
|
||||
if (event.key === 'Escape') setInstructionsOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => document.removeEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
requestAnimationFrame(() => {
|
||||
if (instructionsPanelRef.current?.contains(document.activeElement)) instructionsToggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [instructionsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localRulesOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => localRulesCloseRef.current?.focus());
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
requestAnimationFrame(() => {
|
||||
if (localRulesPanelRef.current?.contains(document.activeElement)) localRulesToggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [localRulesOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localRulesOpen) return undefined;
|
||||
const closeLocalRules = (event) => {
|
||||
@@ -857,13 +890,21 @@ export function ClientOverviewPage({
|
||||
async function copyProxy(kind) {
|
||||
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
||||
clearTimeout(copyTimerRef.current);
|
||||
setCopyFeedback({ kind, failed: false });
|
||||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||
try {
|
||||
await copyText(value);
|
||||
setCopyFeedback({ kind, failed: false });
|
||||
} catch {
|
||||
setCopyFeedback({ kind, failed: true });
|
||||
}
|
||||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
||||
}
|
||||
|
||||
function navigateAccessTabs(event, current) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = accessTabForKey(ACCESS_TABS.map(([tab]) => tab), current, event.key);
|
||||
setAccessTab(next);
|
||||
requestAnimationFrame(() => document.getElementById(`client-access-tab-${next}`)?.focus());
|
||||
}
|
||||
|
||||
async function refreshSubscription() {
|
||||
@@ -984,6 +1025,9 @@ export function ClientOverviewPage({
|
||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
||||
>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||
</div>
|
||||
<HarborBrand
|
||||
isGateway={isGateway}
|
||||
gatewayAvailable={gatewayAvailable}
|
||||
@@ -1113,25 +1157,32 @@ export function ClientOverviewPage({
|
||||
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||
{isGateway && (
|
||||
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
||||
{[
|
||||
['gateway', 'Gateway'],
|
||||
['proxy', 'Gateway Proxy'],
|
||||
].map(([tab, label]) => (
|
||||
{ACCESS_TABS.map(([tab, label]) => (
|
||||
<button
|
||||
id={`client-access-tab-${tab}`}
|
||||
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
key={tab}
|
||||
aria-selected={accessTab === tab}
|
||||
aria-controls={`client-access-panel-${tab}`}
|
||||
tabIndex={accessTab === tab ? 0 : -1}
|
||||
onClick={() => setAccessTab(tab)}
|
||||
onKeyDown={(event) => navigateAccessTabs(event, tab)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(isGateway ? accessTab === 'gateway' : false) ? (
|
||||
<div className="client-access-point" role="tabpanel" key="gateway">
|
||||
{isGateway && (
|
||||
<div
|
||||
id="client-access-panel-gateway"
|
||||
className="client-access-point"
|
||||
role="tabpanel"
|
||||
aria-labelledby="client-access-tab-gateway"
|
||||
hidden={accessTab !== 'gateway'}
|
||||
>
|
||||
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||||
<button
|
||||
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
@@ -1140,11 +1191,17 @@ export function ClientOverviewPage({
|
||||
onClick={() => copyProxy('gateway')}
|
||||
>
|
||||
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
||||
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
|
||||
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="client-access-point" role={isGateway ? 'tabpanel' : undefined} key="proxy">
|
||||
)}
|
||||
<div
|
||||
id={isGateway ? 'client-access-panel-proxy' : undefined}
|
||||
className="client-access-point"
|
||||
role={isGateway ? 'tabpanel' : undefined}
|
||||
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
|
||||
hidden={isGateway && accessTab !== 'proxy'}
|
||||
>
|
||||
{!isGateway && (
|
||||
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
||||
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
|
||||
@@ -1169,12 +1226,11 @@ export function ClientOverviewPage({
|
||||
onClick={() => copyProxy(kind)}
|
||||
>
|
||||
<span className="client-copy-label">{label}</span>
|
||||
{copyFeedback?.kind === kind && <span className="client-copy-feedback">{copyFeedback.failed ? 'Error' : 'Copied'}</span>}
|
||||
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<InlineError error={error} context="connection" />
|
||||
<InlineProgress operations={operations} context="connection" />
|
||||
@@ -1368,6 +1424,13 @@ export function ClientOverviewPage({
|
||||
inert={!instructionsOpen ? true : undefined}
|
||||
>
|
||||
<div className="client-instructions-sheet">
|
||||
<button
|
||||
ref={instructionsCloseRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть инструкции"
|
||||
onClick={() => setInstructionsOpen(false)}
|
||||
>×</button>
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
@@ -1400,6 +1463,7 @@ export function ClientOverviewPage({
|
||||
error={error}
|
||||
operations={operations}
|
||||
panelRef={localRulesPanelRef}
|
||||
closeRef={localRulesCloseRef}
|
||||
onAdd={() => setLocalRulesDraft((rules) => [
|
||||
...rules,
|
||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||||
|
||||
Reference in New Issue
Block a user