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' : ''}`}>
+2 -6
View File
@@ -1,7 +1,3 @@
.client-instructions.client-devices {
width: min(580px, 100vw);
}
.client-devices-header {
margin-bottom: 28px;
}
@@ -128,8 +124,8 @@
transform: translate(0, 2px);
}
.client-devices-reset-wrap:hover > .client-tooltip,
.client-devices-reset-wrap:has(> :focus-visible) > .client-tooltip {
.client-devices-reset-wrap.client-tooltip-anchor:hover > .client-tooltip,
.client-devices-reset-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
transform: translate(0, 0);
}
-4
View File
@@ -1,7 +1,3 @@
.client-instructions.client-diagnostics {
width: min(580px, 100vw);
}
.client-diagnostics-header {
margin-bottom: 12px;
}
-4
View File
@@ -1,7 +1,3 @@
.client-instructions {
width: min(470px, 100vw);
}
.client-instruction-list {
display: grid;
gap: 15px;
-4
View File
@@ -1,7 +1,3 @@
.client-local-rules {
width: min(480px, 100vw);
}
.client-local-rules-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
+1
View File
@@ -434,6 +434,7 @@
.client-server-scroll {
max-height: 330px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 5px;
+1 -8
View File
@@ -38,13 +38,6 @@
padding: 32px 30px 76px;
}
@media (min-width: 921px) {
.client-subscription-drawer {
right: 64px;
width: min(480px, calc(100vw - 64px));
}
}
.client-profiles-header {
min-height: 44px;
display: flex;
@@ -303,7 +296,7 @@
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 0 8px;
padding: 0 8px 0 32px;
border: 0;
background: transparent;
color: var(--client-text);
+9 -4
View File
@@ -183,6 +183,8 @@
position: fixed;
inset: 0 0 0 auto;
z-index: 50;
width: min(580px, 100vw);
overflow-x: hidden;
overflow-y: auto;
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
color: var(--client-text);
@@ -200,6 +202,13 @@
transition-delay: 0s;
}
@media (min-width: 921px) {
.client-drawer {
right: 64px;
width: min(580px, calc(100vw - 64px));
}
}
.client-drawer-sheet {
position: relative;
min-height: 100%;
@@ -484,10 +493,6 @@
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-drawer {
width: min(480px, 100vw);
}
.client-confirmation-popup {
position: fixed;
inset: 0;