Improve client accessibility and bump Harbor versions
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.7.10',
|
macClient: '0.7.11',
|
||||||
gatewayClient: '0.7.10',
|
gatewayClient: '0.7.11',
|
||||||
gatewayBackend: '0.7.0',
|
gatewayBackend: '0.7.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
|
accessTabForKey,
|
||||||
connectionAction,
|
connectionAction,
|
||||||
connectionDurationParts,
|
connectionDurationParts,
|
||||||
copyText,
|
copyText,
|
||||||
@@ -24,6 +25,10 @@ import {
|
|||||||
|
|
||||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||||
|
const ACCESS_TABS = [
|
||||||
|
['gateway', 'Gateway'],
|
||||||
|
['proxy', 'Gateway Proxy'],
|
||||||
|
];
|
||||||
|
|
||||||
function CloudTooltip({ children }) {
|
function CloudTooltip({ children }) {
|
||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
@@ -340,6 +345,7 @@ function LocalRulesPanel({
|
|||||||
error,
|
error,
|
||||||
operations,
|
operations,
|
||||||
panelRef,
|
panelRef,
|
||||||
|
closeRef,
|
||||||
onAdd,
|
onAdd,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -361,6 +367,13 @@ function LocalRulesPanel({
|
|||||||
inert={!open ? true : undefined}
|
inert={!open ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-local-rules-sheet">
|
<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">
|
<header className="client-local-rules-header">
|
||||||
<span>Маршрутизация</span>
|
<span>Маршрутизация</span>
|
||||||
<button
|
<button
|
||||||
@@ -629,8 +642,10 @@ export function ClientOverviewPage({
|
|||||||
const copyTimerRef = useRef(null);
|
const copyTimerRef = useRef(null);
|
||||||
const instructionsPanelRef = useRef(null);
|
const instructionsPanelRef = useRef(null);
|
||||||
const instructionsToggleRef = useRef(null);
|
const instructionsToggleRef = useRef(null);
|
||||||
|
const instructionsCloseRef = useRef(null);
|
||||||
const localRulesPanelRef = useRef(null);
|
const localRulesPanelRef = useRef(null);
|
||||||
const localRulesToggleRef = useRef(null);
|
const localRulesToggleRef = useRef(null);
|
||||||
|
const localRulesCloseRef = useRef(null);
|
||||||
const localRulesBaselineRef = useRef('[]');
|
const localRulesBaselineRef = useRef('[]');
|
||||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||||
const serverKey = servers.map((server) => server.id).join('|');
|
const serverKey = servers.map((server) => server.id).join('|');
|
||||||
@@ -787,13 +802,31 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instructionsOpen) return undefined;
|
if (!instructionsOpen) return undefined;
|
||||||
|
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
|
||||||
const closeOnEscape = (event) => {
|
const closeOnEscape = (event) => {
|
||||||
if (event.key === 'Escape') setInstructionsOpen(false);
|
if (event.key === 'Escape') setInstructionsOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', closeOnEscape);
|
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!localRulesOpen) return undefined;
|
if (!localRulesOpen) return undefined;
|
||||||
const closeLocalRules = (event) => {
|
const closeLocalRules = (event) => {
|
||||||
@@ -857,13 +890,21 @@ export function ClientOverviewPage({
|
|||||||
async function copyProxy(kind) {
|
async function copyProxy(kind) {
|
||||||
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
||||||
clearTimeout(copyTimerRef.current);
|
clearTimeout(copyTimerRef.current);
|
||||||
setCopyFeedback({ kind, failed: false });
|
|
||||||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
|
||||||
try {
|
try {
|
||||||
await copyText(value);
|
await copyText(value);
|
||||||
|
setCopyFeedback({ kind, failed: false });
|
||||||
} catch {
|
} catch {
|
||||||
setCopyFeedback({ kind, failed: true });
|
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() {
|
async function refreshSubscription() {
|
||||||
@@ -984,6 +1025,9 @@ export function ClientOverviewPage({
|
|||||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
||||||
>
|
>
|
||||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
|
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||||
|
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||||
|
</div>
|
||||||
<HarborBrand
|
<HarborBrand
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
gatewayAvailable={gatewayAvailable}
|
gatewayAvailable={gatewayAvailable}
|
||||||
@@ -1113,25 +1157,32 @@ export function ClientOverviewPage({
|
|||||||
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||||
{isGateway && (
|
{isGateway && (
|
||||||
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
||||||
{[
|
{ACCESS_TABS.map(([tab, label]) => (
|
||||||
['gateway', 'Gateway'],
|
|
||||||
['proxy', 'Gateway Proxy'],
|
|
||||||
].map(([tab, label]) => (
|
|
||||||
<button
|
<button
|
||||||
|
id={`client-access-tab-${tab}`}
|
||||||
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
|
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
key={tab}
|
key={tab}
|
||||||
aria-selected={accessTab === tab}
|
aria-selected={accessTab === tab}
|
||||||
|
aria-controls={`client-access-panel-${tab}`}
|
||||||
|
tabIndex={accessTab === tab ? 0 : -1}
|
||||||
onClick={() => setAccessTab(tab)}
|
onClick={() => setAccessTab(tab)}
|
||||||
|
onKeyDown={(event) => navigateAccessTabs(event, tab)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(isGateway ? accessTab === 'gateway' : false) ? (
|
{isGateway && (
|
||||||
<div className="client-access-point" role="tabpanel" key="gateway">
|
<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>
|
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||||||
<button
|
<button
|
||||||
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
@@ -1140,11 +1191,17 @@ export function ClientOverviewPage({
|
|||||||
onClick={() => copyProxy('gateway')}
|
onClick={() => copyProxy('gateway')}
|
||||||
>
|
>
|
||||||
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
<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>
|
</button>
|
||||||
</div>
|
</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 && (
|
{!isGateway && (
|
||||||
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
||||||
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
|
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
|
||||||
@@ -1169,12 +1226,11 @@ export function ClientOverviewPage({
|
|||||||
onClick={() => copyProxy(kind)}
|
onClick={() => copyProxy(kind)}
|
||||||
>
|
>
|
||||||
<span className="client-copy-label">{label}</span>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
<InlineError error={error} context="connection" />
|
<InlineError error={error} context="connection" />
|
||||||
<InlineProgress operations={operations} context="connection" />
|
<InlineProgress operations={operations} context="connection" />
|
||||||
@@ -1368,6 +1424,13 @@ export function ClientOverviewPage({
|
|||||||
inert={!instructionsOpen ? true : undefined}
|
inert={!instructionsOpen ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-instructions-sheet">
|
<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">
|
<header className="client-instructions-header">
|
||||||
<span>Подключение</span>
|
<span>Подключение</span>
|
||||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||||
@@ -1400,6 +1463,7 @@ export function ClientOverviewPage({
|
|||||||
error={error}
|
error={error}
|
||||||
operations={operations}
|
operations={operations}
|
||||||
panelRef={localRulesPanelRef}
|
panelRef={localRulesPanelRef}
|
||||||
|
closeRef={localRulesCloseRef}
|
||||||
onAdd={() => setLocalRulesDraft((rules) => [
|
onAdd={() => setLocalRulesDraft((rules) => [
|
||||||
...rules,
|
...rules,
|
||||||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||||||
|
|||||||
@@ -192,6 +192,15 @@ p {
|
|||||||
--client-accent: var(--harbor-gateway);
|
--client-accent: var(--harbor-gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-live-region {
|
||||||
|
position: fixed;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.harbor-versions {
|
.harbor-versions {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: max(14px, env(safe-area-inset-right));
|
right: max(14px, env(safe-area-inset-right));
|
||||||
@@ -265,10 +274,9 @@ p {
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 12px 13px;
|
padding: 12px 13px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: color-mix(in oklch, var(--client-panel) 84%, transparent);
|
background: oklch(0.14 0.012 145);
|
||||||
box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16);
|
box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16);
|
||||||
backdrop-filter: blur(12px) saturate(0.9);
|
color: oklch(0.88 0.012 145);
|
||||||
color: var(--client-muted);
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -282,7 +290,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.harbor-version-tooltip strong {
|
.harbor-version-tooltip strong {
|
||||||
color: var(--client-text);
|
color: oklch(0.96 0.008 145);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,10 +600,9 @@ p {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: color-mix(in oklch, var(--client-panel) 68%, transparent);
|
background: oklch(0.14 0.012 145);
|
||||||
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
|
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
|
||||||
backdrop-filter: blur(8px) saturate(0.9);
|
color: oklch(0.88 0.012 145);
|
||||||
color: var(--client-muted);
|
|
||||||
font-size: 4px;
|
font-size: 4px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
@@ -609,7 +616,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.harbor-mode-tooltip strong {
|
.harbor-mode-tooltip strong {
|
||||||
color: var(--client-text);
|
color: oklch(0.96 0.008 145);
|
||||||
font-size: 4.2px;
|
font-size: 4.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,6 +872,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules-sheet {
|
.client-local-rules-sheet {
|
||||||
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 54px 72px 72px 34px;
|
padding: 54px 72px 72px 34px;
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
@@ -1393,6 +1401,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-instructions-sheet {
|
.client-instructions-sheet {
|
||||||
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 54px 34px 72px 72px;
|
padding: 54px 34px 72px 72px;
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
@@ -1404,6 +1413,31 @@ p {
|
|||||||
margin: 0 8px 38px;
|
margin: 0 8px 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-drawer-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font: 400 24px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 220ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-drawer-close:hover {
|
||||||
|
color: var(--client-text);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-drawer-close:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-header span,
|
.client-instructions-header span,
|
||||||
.client-instruction-summary > span {
|
.client-instruction-summary > span {
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
@@ -1661,6 +1695,12 @@ p {
|
|||||||
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-power-section:has(.client-tooltip-anchor:hover),
|
||||||
|
.client-power-section:has(.client-tooltip-anchor:focus-visible),
|
||||||
|
.client-power-section:has(.client-tooltip-anchor > :focus-visible) {
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
.client-route-rules-pending {
|
.client-route-rules-pending {
|
||||||
min-height: 17px;
|
min-height: 17px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1958,15 +1998,14 @@ p {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 6px);
|
bottom: calc(100% + 6px);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 50;
|
z-index: 1000;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: color-mix(in oklch, var(--client-panel) 78%, transparent);
|
background: oklch(0.14 0.012 145);
|
||||||
box-shadow: 0 8px 26px oklch(0.08 0.015 145 / 0.16);
|
box-shadow: 0 8px 26px oklch(0.08 0.015 145 / 0.16);
|
||||||
backdrop-filter: blur(10px) saturate(0.9);
|
color: oklch(0.94 0.008 145);
|
||||||
color: var(--client-text);
|
|
||||||
font: 600 10px/1.35 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font: 600 10px/1.35 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1980,7 +2019,8 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-tooltip-anchor:hover > .client-tooltip,
|
.client-tooltip-anchor:hover > .client-tooltip,
|
||||||
.client-tooltip-anchor:focus-within > .client-tooltip {
|
.client-tooltip-anchor:focus-visible > .client-tooltip,
|
||||||
|
.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
@@ -2752,6 +2792,10 @@ p {
|
|||||||
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-access-point[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes client-access-reveal {
|
@keyframes client-access-reveal {
|
||||||
0% { opacity: 0; filter: blur(4px); }
|
0% { opacity: 0; filter: blur(4px); }
|
||||||
100% { opacity: 1; filter: blur(0); }
|
100% { opacity: 1; filter: blur(0); }
|
||||||
@@ -3054,6 +3098,29 @@ p {
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-access-tabs .client-access-tab,
|
||||||
|
.client-copy-button {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule {
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr) 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rule-enabled,
|
||||||
|
.client-local-rule-delete {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header > span,
|
||||||
|
.client-local-rules-group > span,
|
||||||
|
.client-instructions-header span,
|
||||||
|
.client-instruction-summary > span {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-sheet {
|
.client-instructions-sheet {
|
||||||
padding: 40px 18px 60px 58px;
|
padding: 40px 18px 60px 58px;
|
||||||
}
|
}
|
||||||
@@ -3067,7 +3134,6 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rule {
|
.client-local-rule {
|
||||||
grid-template-columns: 24px minmax(0, 1fr) 28px;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3134,6 +3200,7 @@ p {
|
|||||||
.client-duration-seconds-value,
|
.client-duration-seconds-value,
|
||||||
.client-duration-second-digit,
|
.client-duration-second-digit,
|
||||||
.client-instructions,
|
.client-instructions,
|
||||||
|
.client-drawer-close,
|
||||||
.client-instructions-toggle,
|
.client-instructions-toggle,
|
||||||
.client-instructions-toggle svg,
|
.client-instructions-toggle svg,
|
||||||
.client-instructions-toggle path,
|
.client-instructions-toggle path,
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ export async function copyText(text, options = {}) {
|
|||||||
await clipboard.writeText(text);
|
await clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function accessTabForKey(tabs, current, key) {
|
||||||
|
const index = tabs.indexOf(current);
|
||||||
|
if (key === 'Home') return tabs[0];
|
||||||
|
if (key === 'End') return tabs.at(-1);
|
||||||
|
if (key === 'ArrowLeft') return tabs[(index - 1 + tabs.length) % tabs.length];
|
||||||
|
if (key === 'ArrowRight') return tabs[(index + 1) % tabs.length];
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
export function subscriptionUsage(userInfo = {}) {
|
export function subscriptionUsage(userInfo = {}) {
|
||||||
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||||
const download = Math.max(0, Number(userInfo.download) || 0);
|
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
accessTabForKey,
|
||||||
connectionAction,
|
connectionAction,
|
||||||
copyText,
|
copyText,
|
||||||
formatConnectionDuration,
|
formatConnectionDuration,
|
||||||
@@ -93,6 +94,16 @@ test('copy uses the synchronous native path available on gateway HTTP', async ()
|
|||||||
assert.equal(textarea.removed, true);
|
assert.equal(textarea.removed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Gateway access tabs wrap with arrows and support Home and End', () => {
|
||||||
|
const tabs = ['gateway', 'proxy'];
|
||||||
|
|
||||||
|
assert.equal(accessTabForKey(tabs, 'gateway', 'ArrowLeft'), 'proxy');
|
||||||
|
assert.equal(accessTabForKey(tabs, 'proxy', 'ArrowRight'), 'gateway');
|
||||||
|
assert.equal(accessTabForKey(tabs, 'proxy', 'Home'), 'gateway');
|
||||||
|
assert.equal(accessTabForKey(tabs, 'gateway', 'End'), 'proxy');
|
||||||
|
assert.equal(accessTabForKey(tabs, 'gateway', 'Enter'), 'gateway');
|
||||||
|
});
|
||||||
|
|
||||||
test('subscription usage combines traffic and caps progress', () => {
|
test('subscription usage combines traffic and caps progress', () => {
|
||||||
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
||||||
upload: 30,
|
upload: 30,
|
||||||
|
|||||||
@@ -60,3 +60,17 @@ test('responsive motion has a reduced-motion fallback', () => {
|
|||||||
assert.match(reducedMotion, /transition:\s*none/);
|
assert.match(reducedMotion, /transition:\s*none/);
|
||||||
assert.match(reducedMotion, /animation:\s*none/);
|
assert.match(reducedMotion, /animation:\s*none/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tooltips stay opaque, above adjacent content, and do not stick after pointer clicks', () => {
|
||||||
|
const tooltip = rule('.client-tooltip');
|
||||||
|
|
||||||
|
assert.match(tooltip, /z-index:\s*1000/);
|
||||||
|
assert.match(tooltip, /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||||
|
assert.doesNotMatch(tooltip, /transparent|backdrop-filter/);
|
||||||
|
assert.match(styles, /\.client-power-section:has\(\.client-tooltip-anchor:hover\)[\s\S]*z-index:\s*90/);
|
||||||
|
assert.match(styles, /\.client-tooltip-anchor:focus-visible > \.client-tooltip/);
|
||||||
|
assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
|
||||||
|
assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
|
||||||
|
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||||
|
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,3 +46,23 @@ test('critical confirmations share one accessible blocking popup', () => {
|
|||||||
assert.match(popup, /element\.inert = true/);
|
assert.match(popup, /element\.inert = true/);
|
||||||
assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/);
|
assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics', () => {
|
||||||
|
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
|
||||||
|
assert.match(component, /Не удалось скопировать/);
|
||||||
|
assert.match(component, /Скопировано/);
|
||||||
|
assert.doesNotMatch(component, />Error<|>Copied</);
|
||||||
|
assert.match(component, /aria-label="Закрыть инструкции"/);
|
||||||
|
assert.match(component, /aria-label="Закрыть локальные правила"/);
|
||||||
|
assert.match(component, /instructionsCloseRef\.current\?\.focus\(\)/);
|
||||||
|
assert.match(component, /localRulesCloseRef\.current\?\.focus\(\)/);
|
||||||
|
assert.match(component, /client-access-tab-\$\{tab\}/);
|
||||||
|
assert.match(component, /aria-controls={`client-access-panel-\$\{tab\}`}/);
|
||||||
|
assert.match(component, /tabIndex={accessTab === tab \? 0 : -1}/);
|
||||||
|
assert.match(component, /onKeyDown={\(event\) => navigateAccessTabs\(event, tab\)}/);
|
||||||
|
assert.match(component, /aria-labelledby="client-access-tab-gateway"/);
|
||||||
|
assert.match(component, /aria-labelledby={isGateway \? 'client-access-tab-proxy' : undefined}/);
|
||||||
|
assert.match(styles, /\.client-drawer-close \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user