528 lines
22 KiB
TypeScript
528 lines
22 KiB
TypeScript
// Sliding horseshoe sheet for the channels workspace switcher. Mirror of
|
|
// `features/settings/MobileSettingsHorseshoe.tsx` (canonical, shipped in
|
|
// commit a7d6fc2) with three deliberate adaptations:
|
|
//
|
|
// • Single code path on web AND native — no
|
|
// `useScreenSizeContext()` mobile-only gate. The horseshoe lives
|
|
// wherever channels render, so a desktop tap on the workspace
|
|
// footer slides the sheet up inside the PageNav column the same
|
|
// way a mobile tap does.
|
|
// • Rail height is a fraction (0.55) of the wrapper container's
|
|
// height, NOT `window.innerHeight`. The sheet is scoped to its
|
|
// PageNav slot, so a viewport-relative rail would overshoot on
|
|
// desktop where the column is short. `ResizeObserver` on the
|
|
// container drives `containerHeightPx`; the rail recomputes
|
|
// on column resize.
|
|
// • Drag-origin data attribute is renamed to
|
|
// `data-channels-workspace-drag-origin` so it doesn't collide with
|
|
// the existing settings-sheet selector elsewhere on the page.
|
|
//
|
|
// Everything else (clip-path carve, void colour, VAUL easing curve,
|
|
// entry rAF gate, hasEntered mirror, keepMounted unmount delay,
|
|
// dialog/aria-label invariants, portal marker for Android back) is
|
|
// preserved verbatim — those were learned through review cycles on the
|
|
// canonical horseshoe and are not re-litigated here.
|
|
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useAtomValue, useSetAtom } from 'jotai';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Room } from 'matrix-js-sdk';
|
|
import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet';
|
|
import {
|
|
useCloseChannelsWorkspaceSheet,
|
|
useOpenChannelsWorkspaceSheet,
|
|
} from '../../../state/hooks/channelsWorkspaceSheet';
|
|
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
|
import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader';
|
|
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
|
|
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
|
|
import * as css from './ChannelsWorkspaceHorseshoe.css';
|
|
|
|
// Data attribute set on `WorkspaceFooter` to mark it as the drag-up /
|
|
// tap-to-open origin for the sheet. The document-level touchstart /
|
|
// pointerdown listeners use `target.closest(SELECTOR)` so the footer's
|
|
// own onClick handler still fires for no-movement taps. Keep this in
|
|
// sync with the attribute spread on the footer row.
|
|
export const CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR = 'data-channels-workspace-drag-origin';
|
|
const DRAG_ORIGIN_SELECTOR = `[${CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR}]`;
|
|
|
|
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
|
const ANIMATION_MS = 250;
|
|
// Commit distance (px). 80px matches the canonical horseshoe so the two
|
|
// gestures (settings open, workspace open) feel identical to muscle
|
|
// memory.
|
|
const COMMIT_THRESHOLD_PX = 80;
|
|
// Sheet rail as a fraction of the PageNav column's measured height.
|
|
// Workspace switcher content is small (spaces list + one create row), so
|
|
// 55% reads as a compact tray rather than a half-screen takeover.
|
|
const RAIL_FRACTION = 0.55;
|
|
// Drag distance over which radii + void gap ramp from 0 to full during
|
|
// finger-drag. Matched to COMMIT_THRESHOLD_PX so the silhouette is
|
|
// fully formed exactly when the gesture qualifies to commit.
|
|
const HORSESHOE_EMERGE_PX = 80;
|
|
|
|
// Symmetric cubic in-out — linear ramp was too snappy in the canonical
|
|
// horseshoe (corners jumped in within ~10px of drag); cubic keeps them
|
|
// subtle until ~40% of the gesture, then blossoms around the midpoint.
|
|
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
|
|
// Axis dead-zone for horizontal-bail. The finger must travel this far
|
|
// on either axis before we resolve as vertical (open/close the sheet)
|
|
// or horizontal (yield to MobileTabsPager on listing surfaces). Note:
|
|
// the workspace sheet only mounts on /channels/!space/, where the
|
|
// pager is inactive — the bail here is defensive symmetry with the
|
|
// settings horseshoe so future layout changes can't surprise us.
|
|
const AXIS_DEAD_ZONE_PX = 12;
|
|
|
|
type DragSource = 'footer' | 'handle';
|
|
|
|
type DragState = {
|
|
source: DragSource;
|
|
inputType: 'touch' | 'pointer';
|
|
startX: number;
|
|
startY: number;
|
|
deltaY: number;
|
|
};
|
|
|
|
type ChannelsWorkspaceHorseshoeProps = {
|
|
space: Room;
|
|
children: ReactNode;
|
|
};
|
|
|
|
export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspaceHorseshoeProps) {
|
|
const { t } = useTranslation();
|
|
const open = useAtomValue(channelsWorkspaceSheetAtom);
|
|
const openSheet = useOpenChannelsWorkspaceSheet();
|
|
const closeSheet = useCloseChannelsWorkspaceSheet();
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [containerHeightPx, setContainerHeightPx] = useState(0);
|
|
const [drag, setDrag] = useState<DragState | null>(null);
|
|
// In pager mode the appBody bg must be transparent so the static
|
|
// pager header tabs (behind the swipe strip in DOM order) show
|
|
// through until covered by the rising chats curtain. See
|
|
// MobileSettingsHorseshoe.tsx for the matching pattern.
|
|
const inPagerMode = useMobilePagerPane() !== null;
|
|
|
|
// ResizeObserver on the wrapper — rail height is scoped to THIS
|
|
// column. PageNav width changes via the resizable handle on desktop
|
|
// and column height changes with viewport / split-screen on Android;
|
|
// ResizeObserver handles both without a manual `window.resize`
|
|
// listener which wouldn't catch the column-resize case.
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return undefined;
|
|
setContainerHeightPx(el.getBoundingClientRect().height);
|
|
const ro = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry) return;
|
|
setContainerHeightPx(entry.contentRect.height);
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
const railHeightPx = Math.round(containerHeightPx * RAIL_FRACTION);
|
|
|
|
// Entry-animation gate. Kept for gesture-feel parity with the
|
|
// canonical horseshoe even though the channels sheet has no
|
|
// deep-link path (atom is only ever flipped by a footer tap/drag on
|
|
// an already-mounted Channels column, so a "snap-open on mount"
|
|
// can't actually happen here). The `hasEnteredRef` mirror still
|
|
// matters because React 18 strict-mode dev runs the unmount
|
|
// cleanup before the rAF fires; without the guard the effect
|
|
// would clear the atom mid-rehearsal and break HMR.
|
|
const [hasEntered, setHasEntered] = useState(false);
|
|
const hasEnteredRef = useRef(false);
|
|
useLayoutEffect(() => {
|
|
const id = requestAnimationFrame(() => {
|
|
hasEnteredRef.current = true;
|
|
setHasEntered(true);
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}, []);
|
|
|
|
// Delay unmount of the sheet content by `ANIMATION_MS` so the slide-
|
|
// down has something to render. Without this, clearing the atom would
|
|
// unmount the spaces list instantly and the user would see an empty
|
|
// panel shrink instead of the menu sliding away with it.
|
|
const [keepMounted, setKeepMounted] = useState(open);
|
|
useEffect(() => {
|
|
if (open) {
|
|
setKeepMounted(true);
|
|
return undefined;
|
|
}
|
|
const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS);
|
|
return () => window.clearTimeout(id);
|
|
}, [open]);
|
|
|
|
const baseExpanded = open && hasEntered ? railHeightPx : 0;
|
|
// CLAMP via Math.max/min, not early-return — see canonical
|
|
// `applyMove` for the bug a wrong-direction early-return introduces
|
|
// (stale deltaY survives a reversed swipe and commits on release).
|
|
const expandedPx = drag
|
|
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
|
: baseExpanded;
|
|
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
|
const isDragging = drag !== null;
|
|
const horseshoeActive = expandedPx > 0;
|
|
|
|
// Bridge our local `horseshoeActive` (geometric) signal up to the
|
|
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
|
|
// static header from the first frame of drag. Mirror of the same
|
|
// bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in
|
|
// `state/mobilePagerHeader.ts` for the «no black flash» rationale.
|
|
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
|
|
useEffect(() => {
|
|
setMobileHorseshoeActive(horseshoeActive);
|
|
return () => setMobileHorseshoeActive(false);
|
|
}, [horseshoeActive, setMobileHorseshoeActive]);
|
|
|
|
const handleRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Refs so the always-installed document listeners see the latest
|
|
// state without re-subscribing on every render. Same pattern as the
|
|
// canonical horseshoe.
|
|
const dragRef = useRef<DragState | null>(null);
|
|
dragRef.current = drag;
|
|
const openRef = useRef(open);
|
|
openRef.current = open;
|
|
const openSheetRef = useRef(openSheet);
|
|
openSheetRef.current = openSheet;
|
|
const closeSheetRef = useRef(closeSheet);
|
|
closeSheetRef.current = closeSheet;
|
|
|
|
// Clear the atom on unmount — switching tabs (Direct, Bots), picking
|
|
// a channel, or navigating to /explore unmounts Channels.tsx; without
|
|
// this clear, returning to /channels later would auto-reopen the
|
|
// sheet. `hasEnteredRef` skips the strict-mode rehearsal cleanup
|
|
// before the rAF flag flips.
|
|
useEffect(
|
|
() => () => {
|
|
if (!hasEnteredRef.current) return;
|
|
if (openRef.current) closeSheetRef.current();
|
|
},
|
|
[]
|
|
);
|
|
|
|
// Hardware Escape (web only, rare on mobile) → close. Plain keydown,
|
|
// no FocusTrap — same rationale as the canonical (focus-trap-react
|
|
// throws when its container has no tabbable nodes, which is exactly
|
|
// the case mid-drag before the sheet has rendered any focusable
|
|
// children).
|
|
useEffect(() => {
|
|
if (!open) return undefined;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key !== 'Escape') return;
|
|
const target = e.target as HTMLElement | null;
|
|
if (
|
|
target &&
|
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
) {
|
|
return;
|
|
}
|
|
closeSheetRef.current();
|
|
};
|
|
window.addEventListener('keydown', onKeyDown);
|
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
}, [open]);
|
|
|
|
// Drag mechanics — two origin paths:
|
|
//
|
|
// 1. Document-level touch/pointer on anything matching
|
|
// `[data-channels-workspace-drag-origin]` (= WorkspaceFooter).
|
|
// Passive touchstart / non-passive listeners are split so the
|
|
// row's own onClick handler still fires for no-movement taps.
|
|
// 2. Element-level touch/pointer on `handleRef` (the 20px drag
|
|
// band at the top of the open sheet). Only triggers when the
|
|
// sheet is open; touches on the spaces list inside the panel
|
|
// are not drag-sensitive so internal scroll works without
|
|
// conflict.
|
|
useEffect(() => {
|
|
const handleEl = handleRef.current;
|
|
|
|
// Per-drag axis-resolution flag (same idiom as MobileSettings-
|
|
// Horseshoe). Reset to false in every touchstart / pointerdown
|
|
// handler, flipped to true in applyMove once the finger leaves the
|
|
// dead-zone with a vertical-dominant delta.
|
|
let axisResolved = false;
|
|
// Sticky bail for the remainder of the current touch series once
|
|
// we've yielded to the pager (axis horizontal). Prevents a late
|
|
// vertical reversal in the same touch from re-engaging the sheet.
|
|
let touchBailed = false;
|
|
|
|
// CLAMP, not early-return — reversal of gesture direction must
|
|
// drag `deltaY` back toward 0. footer source clamps the upward
|
|
// drag-open path to negative deltas; handle source clamps the
|
|
// downward drag-close path to positive deltas.
|
|
const applyMove = (clientX: number, clientY: number, e: TouchEvent | PointerEvent) => {
|
|
if (touchBailed) return;
|
|
const d = dragRef.current;
|
|
if (!d) return;
|
|
const rawDeltaX = clientX - d.startX;
|
|
const rawDeltaY = clientY - d.startY;
|
|
|
|
if (!axisResolved) {
|
|
const dxAbs = Math.abs(rawDeltaX);
|
|
const dyAbs = Math.abs(rawDeltaY);
|
|
if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return;
|
|
if (dxAbs > dyAbs) {
|
|
// Horizontal-dominant — yield to MobileTabsPager. Drop drag
|
|
// state without preventDefault so the pager can take over.
|
|
// Sticky bail prevents a late vertical reversal from
|
|
// re-engaging the sheet within the same touch series.
|
|
touchBailed = true;
|
|
setDrag(null);
|
|
return;
|
|
}
|
|
axisResolved = true;
|
|
}
|
|
|
|
const nextDelta = d.source === 'footer' ? Math.min(0, rawDeltaY) : Math.max(0, rawDeltaY);
|
|
if (e.cancelable) e.preventDefault();
|
|
setDrag({ ...d, deltaY: nextDelta });
|
|
};
|
|
|
|
const applyEnd = () => {
|
|
const d = dragRef.current;
|
|
if (!d) return;
|
|
if (d.source === 'footer' && -d.deltaY > COMMIT_THRESHOLD_PX) {
|
|
openSheetRef.current();
|
|
} else if (d.source === 'handle' && d.deltaY > COMMIT_THRESHOLD_PX) {
|
|
closeSheetRef.current();
|
|
}
|
|
setDrag(null);
|
|
};
|
|
|
|
const targetIsDragOrigin = (target: EventTarget | null): boolean => {
|
|
if (!target || !(target instanceof Element)) return false;
|
|
return target.closest(DRAG_ORIGIN_SELECTOR) !== null;
|
|
};
|
|
|
|
// === Touch path ===
|
|
const onDocTouchStart = (e: TouchEvent) => {
|
|
if (dragRef.current) return;
|
|
if (openRef.current) return; // sheet open → handle owns drag
|
|
if (!targetIsDragOrigin(e.target)) return;
|
|
const touch = e.touches[0];
|
|
axisResolved = false;
|
|
touchBailed = false;
|
|
setDrag({
|
|
source: 'footer',
|
|
inputType: 'touch',
|
|
startX: touch.clientX,
|
|
startY: touch.clientY,
|
|
deltaY: 0,
|
|
});
|
|
};
|
|
const onHandleTouchStart = (e: TouchEvent) => {
|
|
if (dragRef.current) return;
|
|
if (!openRef.current) return;
|
|
const touch = e.touches[0];
|
|
axisResolved = false;
|
|
touchBailed = false;
|
|
setDrag({
|
|
source: 'handle',
|
|
inputType: 'touch',
|
|
startX: touch.clientX,
|
|
startY: touch.clientY,
|
|
deltaY: 0,
|
|
});
|
|
};
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
const d = dragRef.current;
|
|
if (!d || d.inputType !== 'touch') return;
|
|
applyMove(e.touches[0].clientX, e.touches[0].clientY, e);
|
|
};
|
|
const onTouchEnd = () => {
|
|
const d = dragRef.current;
|
|
if (!d || d.inputType !== 'touch') return;
|
|
applyEnd();
|
|
};
|
|
|
|
// === Mouse / pen path ===
|
|
const onDocPointerDown = (e: PointerEvent) => {
|
|
if (e.pointerType === 'touch') return;
|
|
if (dragRef.current) return;
|
|
if (openRef.current) return;
|
|
if (e.button !== 0) return;
|
|
if (!targetIsDragOrigin(e.target)) return;
|
|
axisResolved = false;
|
|
touchBailed = false;
|
|
setDrag({
|
|
source: 'footer',
|
|
inputType: 'pointer',
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
deltaY: 0,
|
|
});
|
|
};
|
|
const onHandlePointerDown = (e: PointerEvent) => {
|
|
if (e.pointerType === 'touch') return;
|
|
if (dragRef.current) return;
|
|
if (!openRef.current) return;
|
|
if (e.button !== 0) return;
|
|
axisResolved = false;
|
|
touchBailed = false;
|
|
setDrag({
|
|
source: 'handle',
|
|
inputType: 'pointer',
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
deltaY: 0,
|
|
});
|
|
};
|
|
const onDocPointerMove = (e: PointerEvent) => {
|
|
if (e.pointerType === 'touch') return;
|
|
const d = dragRef.current;
|
|
if (!d || d.inputType !== 'pointer') return;
|
|
applyMove(e.clientX, e.clientY, e);
|
|
};
|
|
const onDocPointerEnd = (e: PointerEvent) => {
|
|
if (e.pointerType === 'touch') return;
|
|
const d = dragRef.current;
|
|
if (!d || d.inputType !== 'pointer') return;
|
|
applyEnd();
|
|
};
|
|
|
|
document.addEventListener('touchstart', onDocTouchStart, { passive: true });
|
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
document.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
|
document.addEventListener('pointerdown', onDocPointerDown);
|
|
document.addEventListener('pointermove', onDocPointerMove, { passive: false });
|
|
document.addEventListener('pointerup', onDocPointerEnd, { passive: true });
|
|
document.addEventListener('pointercancel', onDocPointerEnd, { passive: true });
|
|
if (handleEl) {
|
|
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
|
|
handleEl.addEventListener('pointerdown', onHandlePointerDown);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('touchstart', onDocTouchStart);
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
document.removeEventListener('touchcancel', onTouchEnd);
|
|
document.removeEventListener('pointerdown', onDocPointerDown);
|
|
document.removeEventListener('pointermove', onDocPointerMove);
|
|
document.removeEventListener('pointerup', onDocPointerEnd);
|
|
document.removeEventListener('pointercancel', onDocPointerEnd);
|
|
if (handleEl) {
|
|
handleEl.removeEventListener('touchstart', onHandleTouchStart);
|
|
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Geometry — radii + void gap ramp via easeInOutCubic during drag;
|
|
// release jumps to the full value and CSS transition (VAUL_EASING)
|
|
// carries the visual. See canonical for the visual-curve rationale.
|
|
let horseshoeRamp: number;
|
|
if (isDragging) {
|
|
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
|
} else {
|
|
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
|
|
}
|
|
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
|
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
|
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
|
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
|
|
|
|
// `inset(top right bottom left round TL TR BR BL)` — only BR/BL carry
|
|
// the radius so the visible top portion of appBody has rounded
|
|
// bottom corners at the clip boundary. Always emitted (even at all
|
|
// zeros) so CSS can transition smoothly between closed and open.
|
|
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
|
|
|
|
const silhouetteTransition = isDragging
|
|
? 'none'
|
|
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
|
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
|
|
|
const containerStyle: React.CSSProperties = {
|
|
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
|
};
|
|
|
|
const renderSheet = keepMounted || isDragging;
|
|
|
|
// Portal marker so `useAndroidBackButton` dispatches Escape instead of
|
|
// `navigate(-1)` while the sheet is open. The keydown handler above
|
|
// catches the synthetic Escape and closes the sheet — same pattern
|
|
// the canonical horseshoe uses. Falls back to body for SSR / tests
|
|
// where `#portalContainer` isn't mounted yet.
|
|
const portalTarget =
|
|
typeof document !== 'undefined'
|
|
? document.getElementById('portalContainer') ?? document.body
|
|
: null;
|
|
|
|
return (
|
|
<div ref={containerRef} className={css.container} style={containerStyle}>
|
|
{open && portalTarget
|
|
? createPortal(
|
|
<div
|
|
data-vojo-channels-workspace-sheet-active="true"
|
|
aria-hidden="true"
|
|
style={{ display: 'none' }}
|
|
/>,
|
|
portalTarget
|
|
)
|
|
: null}
|
|
<div
|
|
className={css.appBody}
|
|
style={{
|
|
clipPath: appBodyClipPath,
|
|
transition: appBodyTransition,
|
|
overscrollBehaviorY: 'contain',
|
|
// See MobileSettingsHorseshoe.tsx for the full rationale:
|
|
// appBody is transparent in pager mode AT REST so the static
|
|
// pager header shows through, but flips back to its CSS-
|
|
// class `SurfaceVariant.Container` whenever the horseshoe is
|
|
// active so the container's `VOJO_HORSESHOE_VOID_COLOR` paint
|
|
// stays contained to the bottom carve cut-out and doesn't
|
|
// bleed into the chip-area band above the curtain.
|
|
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
<div
|
|
className={css.silhouette}
|
|
style={{
|
|
height: `${expandedPx}px`,
|
|
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
|
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
|
transition: silhouetteTransition,
|
|
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
|
// Reset `--vojo-safe-top` for everything mounted inside the
|
|
// sheet — same trick the canonical horseshoe uses (status-bar
|
|
// padding is owned by the outer PageNav column; re-applying
|
|
// it inside the sheet would create dead space above the
|
|
// panel header).
|
|
['--vojo-safe-top' as string]: '0px',
|
|
}}
|
|
// `role="dialog"` + `aria-label` only. NO `aria-modal="true"`
|
|
// (no focus trap; outside content stays interactive). NO
|
|
// `aria-labelledby` (the labelled target may unmount during
|
|
// the close animation). See canonical for the rationale.
|
|
role="dialog"
|
|
aria-label={t('Channels.workspace_switcher_aria')}
|
|
>
|
|
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
|
|
<div
|
|
ref={handleRef}
|
|
className={css.panelHandle}
|
|
aria-label={t('Channels.workspace_switcher_drag_to_close')}
|
|
>
|
|
<div className={css.panelHandleBar} />
|
|
</div>
|
|
<div className={css.panelBody}>
|
|
{renderSheet && (
|
|
<WorkspaceSwitcherSheet space={space} requestClose={() => closeSheetRef.current()} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|