Animate vertical transitions between client drawers
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-14 15:23:00 +03:00
parent 019930924d
commit 32217f4d17
17 changed files with 260 additions and 93 deletions
+125 -36
View File
@@ -5,6 +5,7 @@ import React, {
useState,
type CSSProperties,
} from 'react';
import { flushSync } from 'react-dom';
import {
copyText,
localProxyUrls,
@@ -63,6 +64,10 @@ const VERSION_PARTS = [
['hotfix', 'Hotfix'],
] as const;
const DRAWER_SWITCH_MS = 620;
const DRAWER_ORDER = ['subscription', 'instructions', 'devices', 'diagnostics', 'routing'] as const;
type DrawerKey = typeof DRAWER_ORDER[number];
interface UiError {
context?: string;
profileId?: string;
@@ -442,6 +447,10 @@ export function ClientOverviewPage({
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({});
const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({});
const drawerSwitchRef = useRef<{
target: DrawerKey;
finish: () => void;
} | null>(null);
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
@@ -523,6 +532,41 @@ export function ClientOverviewPage({
controlHost,
});
const diagnosticsAvailable = hasSubscription;
const drawerControls = {
subscription: {
isOpen: subscriptionFeature.open,
panelRef: subscriptionFeature.panelRef,
show: subscriptionFeature.toggle,
close: subscriptionFeature.close,
},
instructions: {
isOpen: instructionsFeature.isOpen,
panelRef: instructionsFeature.panelRef,
show: instructionsFeature.toggle,
close: instructionsFeature.close,
},
devices: {
isOpen: devicesFeature.isOpen,
panelRef: devicesFeature.panelRef,
show: devicesFeature.toggle,
close: devicesFeature.close,
},
diagnostics: {
isOpen: diagnosticsFeature.isOpen,
panelRef: diagnosticsFeature.panelRef,
show: diagnosticsFeature.toggle,
close: diagnosticsFeature.close,
},
routing: {
isOpen: routingFeature.isOpen,
panelRef: routingFeature.panelRef,
show: routingFeature.open,
close: routingFeature.forceClose,
},
};
const drawerOrder = isGateway
? DRAWER_ORDER
: DRAWER_ORDER.filter((drawer) => drawer !== 'devices');
useEffect(() => {
setNow(Date.now());
@@ -598,12 +642,77 @@ export function ClientOverviewPage({
}, 800);
}
function openRouting() {
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
routingFeature.open();
function switchDrawer(target: DrawerKey) {
const activeSwitch = drawerSwitchRef.current;
const current = activeSwitch?.target
|| drawerOrder.find((drawer) => drawerControls[drawer].isOpen)
|| null;
activeSwitch?.finish();
if (current === target) {
drawerControls[target].close();
return;
}
if (current === 'routing' && routingFeature.dirty) {
routingFeature.requestClose();
return;
}
if (!current) {
drawerControls[target].show();
return;
}
const fromControl = drawerControls[current];
const toControl = drawerControls[target];
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
flushSync(() => {
fromControl.close();
toControl.show();
});
return;
}
flushSync(() => toControl.show());
const from = fromControl.panelRef.current;
const to = toControl.panelRef.current;
if (!from || !to || typeof from.animate !== 'function' || typeof to.animate !== 'function') {
fromControl.close();
return;
}
from.inert = true;
from.setAttribute('aria-hidden', 'true');
const direction = DRAWER_ORDER.indexOf(target) > DRAWER_ORDER.indexOf(current) ? -1 : 1;
const options: KeyframeAnimationOptions = {
duration: DRAWER_SWITCH_MS,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'both',
};
const outgoing = from.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: `translateY(${direction * 100}%)`, opacity: 1 },
], options);
const incoming = to.animate([
{ transform: `translateY(${-direction * 100}%)`, opacity: 1 },
{ transform: 'translateY(0)', opacity: 1 },
], options);
let finished = false;
const cancel = () => {
from.inert = false;
from.removeAttribute('aria-hidden');
outgoing.cancel();
incoming.cancel();
};
const finish = () => {
if (finished) return;
finished = true;
flushSync(() => fromControl.close());
cancel();
if (drawerSwitchRef.current?.target === target) drawerSwitchRef.current = null;
};
incoming.addEventListener('finish', finish, { once: true });
drawerSwitchRef.current = { target, finish };
}
const mainIdentity = gatewayDirect
@@ -629,53 +738,33 @@ export function ClientOverviewPage({
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div>
{hasSubscription && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{hasSubscription && <nav
className="client-secondary-menu"
aria-label="Дополнительные меню"
onPointerDown={(event) => event.stopPropagation()}
>
<SubscriptionToggle
feature={subscriptionFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
subscriptionFeature.toggle();
}}
onToggle={() => switchDrawer('subscription')}
/>
<InstructionsToggle
feature={instructionsFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
instructionsFeature.toggle();
}}
onToggle={() => switchDrawer('instructions')}
/>
{isGateway && <DevicesToggle
feature={devicesFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
diagnosticsFeature.close();
devicesFeature.toggle();
}}
onToggle={() => switchDrawer('devices')}
/>}
<DiagnosticsToggle
feature={diagnosticsFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.toggle();
}}
onToggle={() => switchDrawer('diagnostics')}
/>
<RoutingToggle
feature={routingFeature}
gatewayDirect={gatewayDirect}
isGateway={isGateway}
hasSubscription={hasSubscription}
onOpen={openRouting}
onOpen={() => switchDrawer('routing')}
/>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>