Animate vertical transitions between client drawers
This commit is contained in:
@@ -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' : ''}`}>
|
||||
|
||||
Reference in New Issue
Block a user