Simplify client overview access controls and layout
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.8.3',
|
macClient: '0.8.4',
|
||||||
gatewayClient: '0.8.3',
|
gatewayClient: '0.8.4',
|
||||||
gatewayBackend: '0.8.0',
|
gatewayBackend: '0.8.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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,
|
||||||
@@ -27,10 +26,6 @@ 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>;
|
||||||
@@ -632,7 +627,6 @@ export function ClientOverviewPage({
|
|||||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||||||
const [accessTab, setAccessTab] = useState('gateway');
|
|
||||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
const [subscriptionValidation, setSubscriptionValidation] = useState({
|
const [subscriptionValidation, setSubscriptionValidation] = useState({
|
||||||
url: '',
|
url: '',
|
||||||
@@ -666,6 +660,11 @@ export function ClientOverviewPage({
|
|||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
const usage = subscriptionUsage(state?.userInfo);
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||||||
|
const wordClockDuration = [
|
||||||
|
['hours', duration.hours],
|
||||||
|
['minutes', duration.minutes],
|
||||||
|
['seconds', duration.seconds],
|
||||||
|
].filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
|
||||||
const connectionTitle = connected
|
const connectionTitle = connected
|
||||||
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||||||
: 'Подключение выключено';
|
: 'Подключение выключено';
|
||||||
@@ -932,14 +931,6 @@ export function ClientOverviewPage({
|
|||||||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
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() {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
setRefreshingInfo(true);
|
setRefreshingInfo(true);
|
||||||
@@ -1158,24 +1149,27 @@ export function ClientOverviewPage({
|
|||||||
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
||||||
</time>
|
</time>
|
||||||
<time
|
<time
|
||||||
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
|
||||||
aria-hidden={durationMode !== 'words'}
|
aria-hidden={durationMode !== 'words'}
|
||||||
>
|
>
|
||||||
{[
|
{duration.days.value > 0 && (
|
||||||
['days', duration.days],
|
<span className="client-duration-word-row is-calendar">
|
||||||
['hours', duration.hours],
|
<span className="client-duration-unit" data-unit="days">
|
||||||
['minutes', duration.minutes],
|
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
|
||||||
['seconds', duration.seconds],
|
<DurationPart name="days-label">{duration.days.label}</DurationPart>
|
||||||
]
|
</span>
|
||||||
.filter(([name, part]) => part.value || name === 'seconds')
|
|
||||||
.map(([name, part]) => (
|
|
||||||
<span className="client-duration-unit" data-unit={name} key={name}>
|
|
||||||
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
|
||||||
? <AnimatedSeconds value={part.value} padded={false} />
|
|
||||||
: part.value}</DurationPart>{' '}
|
|
||||||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
<span className="client-duration-word-row is-clock">
|
||||||
|
{wordClockDuration.map(([name, part]) => (
|
||||||
|
<span className="client-duration-unit" data-unit={name} key={name}>
|
||||||
|
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
||||||
|
? <AnimatedSeconds value={part.value} padded={false} />
|
||||||
|
: part.value}</DurationPart>{' '}
|
||||||
|
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
</time>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
|
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
|
||||||
@@ -1188,54 +1182,8 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||||
{isGateway && (
|
<div className="client-access-point">
|
||||||
<div className="client-access-tabs" role="tablist" aria-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 && (
|
|
||||||
<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' : ''}`}
|
|
||||||
type="button"
|
|
||||||
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
|
||||||
onClick={() => copyProxy('gateway')}
|
|
||||||
>
|
|
||||||
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
|
||||||
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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>
|
||||||
@@ -1245,24 +1193,28 @@ export function ClientOverviewPage({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<strong className="client-proxy-address">
|
<strong className="client-proxy-address">
|
||||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
</strong>
|
</strong>
|
||||||
<div className="client-proxy-actions">
|
<div className="client-proxy-actions">
|
||||||
{[
|
{(isGateway ? [
|
||||||
['socks5', 'SOCKS5'],
|
['gateway', 'GATEWAY'],
|
||||||
['http', 'HTTP'],
|
['socks5', 'SOCKS5'],
|
||||||
].map(([kind, label]) => (
|
['http', 'HTTP'],
|
||||||
<button
|
] : [
|
||||||
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
['socks5', 'SOCKS5'],
|
||||||
type="button"
|
['http', 'HTTP'],
|
||||||
key={kind}
|
]).map(([kind, label]) => (
|
||||||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
<button
|
||||||
onClick={() => copyProxy(kind)}
|
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
>
|
type="button"
|
||||||
<span className="client-copy-label">{label}</span>
|
key={kind}
|
||||||
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
|
||||||
</button>
|
onClick={() => copyProxy(kind)}
|
||||||
))}
|
>
|
||||||
|
<span className="client-copy-label">{label}</span>
|
||||||
|
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1682,7 +1682,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-state-copy {
|
.client-state-copy {
|
||||||
min-height: 54px;
|
min-height: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power {
|
.client-power {
|
||||||
@@ -1829,7 +1829,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-state-detail {
|
.client-state-detail {
|
||||||
min-height: 20px;
|
min-height: 44px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -1863,6 +1863,20 @@ p {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-duration-words {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-word-row {
|
||||||
|
min-height: 19px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.client-duration-unit {
|
.client-duration-unit {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -1908,7 +1922,7 @@ p {
|
|||||||
|
|
||||||
.client-duration-toggle {
|
.client-duration-toggle {
|
||||||
width: min(290px, 100%);
|
width: min(290px, 100%);
|
||||||
min-height: 24px;
|
min-height: 44px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1966,6 +1980,19 @@ p {
|
|||||||
transition-delay: 20ms, 20ms, 20ms, 0s;
|
transition-delay: 20ms, 20ms, 20ms, 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-duration-toggle > .client-tooltip {
|
||||||
|
top: 50%;
|
||||||
|
right: calc(100% + 12px);
|
||||||
|
bottom: auto;
|
||||||
|
left: auto;
|
||||||
|
transform: translate(2px, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-duration-toggle:hover > .client-tooltip,
|
||||||
|
.client-duration-toggle:focus-visible > .client-tooltip {
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
.client-icon-tooltip {
|
.client-icon-tooltip {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -3071,8 +3098,12 @@ p {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies.has-tabs {
|
.client-proxies.is-gateway {
|
||||||
min-height: 91px;
|
width: 270px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies.is-gateway .client-copy-button {
|
||||||
|
width: 82px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxy-label {
|
.client-proxy-label {
|
||||||
@@ -3136,47 +3167,11 @@ 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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-access-tabs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
width: 220px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-bottom: 1px solid var(--client-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-access-tabs .client-access-tab {
|
|
||||||
width: auto;
|
|
||||||
padding: 6px 4px 7px;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 220ms ease, border-color 300ms ease, filter 300ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-access-tabs .client-access-tab.is-active {
|
|
||||||
border-bottom-color: var(--client-accent);
|
|
||||||
color: var(--client-text);
|
|
||||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 45%, transparent));
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-access-tab:focus-visible {
|
|
||||||
outline: 2px solid var(--client-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-actions {
|
.client-proxy-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -3457,7 +3452,11 @@ p {
|
|||||||
right: 8px;
|
right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-access-tabs .client-access-tab,
|
.client-duration-toggle > .client-tooltip {
|
||||||
|
right: calc(50% + 36px);
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-copy-button {
|
.client-copy-button {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -3574,7 +3573,6 @@ p {
|
|||||||
.client-server-group-toggle,
|
.client-server-group-toggle,
|
||||||
.client-server-more,
|
.client-server-more,
|
||||||
.client-copy-button,
|
.client-copy-button,
|
||||||
.client-access-tab,
|
|
||||||
.client-access-point,
|
.client-access-point,
|
||||||
.client-copy-feedback,
|
.client-copy-feedback,
|
||||||
.client-duration,
|
.client-duration,
|
||||||
|
|||||||
@@ -98,15 +98,6 @@ 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,7 +2,6 @@ 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,
|
||||||
@@ -94,16 +93,6 @@ 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,
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ test('secondary menus share one right rail and both drawers open from the right'
|
|||||||
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
|
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('duration and Gateway access keep stable geometry without tabs', () => {
|
||||||
|
assert.match(rule('.client-state-detail'), /min-height:\s*44px/);
|
||||||
|
assert.match(rule('.client-duration-toggle'), /min-height:\s*44px/);
|
||||||
|
assert.match(component, /client-duration-word-row is-calendar/);
|
||||||
|
assert.match(component, /client-duration-word-row is-clock/);
|
||||||
|
assert.match(rule('.client-duration-toggle > .client-tooltip'), /right:\s*calc\(100% \+ 12px\)/);
|
||||||
|
assert.match(component, /\['gateway', 'GATEWAY'\]/);
|
||||||
|
assert.match(component, /\['socks5', 'SOCKS5'\]/);
|
||||||
|
assert.match(component, /\['http', 'HTTP'\]/);
|
||||||
|
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
|
||||||
|
assert.match(rule('.client-proxies.is-gateway'), /width:\s*270px/);
|
||||||
|
});
|
||||||
|
|
||||||
test('responsive motion has a reduced-motion fallback', () => {
|
test('responsive motion has a reduced-motion fallback', () => {
|
||||||
const reducedMotion = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '';
|
const reducedMotion = /@media \(prefers-reduced-motion: reduce\) \{([\s\S]*)\n\}/.exec(styles)?.[1] || '';
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ test('critical confirmations share one accessible blocking popup', () => {
|
|||||||
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', () => {
|
test('copy feedback, drawers and Gateway access actions expose complete semantics', () => {
|
||||||
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
|
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
|
||||||
assert.match(component, /Не удалось скопировать/);
|
assert.match(component, /Не удалось скопировать/);
|
||||||
assert.match(component, /Скопировано/);
|
assert.match(component, /Скопировано/);
|
||||||
@@ -59,12 +59,8 @@ test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics
|
|||||||
assert.match(component, /aria-label="Закрыть локальные правила"/);
|
assert.match(component, /aria-label="Закрыть локальные правила"/);
|
||||||
assert.match(component, /instructionsCloseRef\.current\?\.focus\(\)/);
|
assert.match(component, /instructionsCloseRef\.current\?\.focus\(\)/);
|
||||||
assert.match(component, /localRulesCloseRef\.current\?\.focus\(\)/);
|
assert.match(component, /localRulesCloseRef\.current\?\.focus\(\)/);
|
||||||
assert.match(component, /client-access-tab-\$\{tab\}/);
|
assert.match(component, /aria-label={`Скопировать \$\{label\}: \$\{kind === 'gateway' \? gatewayAddress : proxyUrls\[kind\]\}`}/);
|
||||||
assert.match(component, /aria-controls={`client-access-panel-\$\{tab\}`}/);
|
assert.doesNotMatch(component, /client-access-tabs|role="tab"|role="tabpanel"/);
|
||||||
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, /\.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-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/);
|
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