From 11c46d92501967376f737c244e0b187b01ae4ac0 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 13 May 2026 14:21:39 +0300 Subject: [PATCH] feat(channels): replace workspace switcher popout with sliding horseshoe sheet and inline create-channel row, retire sidebar CreateTab --- public/locales/en.json | 7 +- public/locales/ru.json | 9 +- .../join-address-prompt/JoinAddressPrompt.tsx | 133 ----- .../components/join-address-prompt/index.ts | 1 - src/app/hooks/router/useCreateSelected.ts | 12 - src/app/pages/client/SidebarNav.tsx | 2 - .../client/channels/ChannelCreateRow.tsx | 57 +++ src/app/pages/client/channels/Channels.tsx | 22 +- .../ChannelsWorkspaceHorseshoe.css.ts | 113 +++++ .../channels/ChannelsWorkspaceHorseshoe.tsx | 464 ++++++++++++++++++ src/app/pages/client/channels/SpaceAvatar.tsx | 47 ++ .../client/channels/WorkspaceFooter.css.ts | 29 +- .../pages/client/channels/WorkspaceFooter.tsx | 307 +++--------- .../channels/WorkspaceSwitcherSheet.css.ts | 19 + .../channels/WorkspaceSwitcherSheet.tsx | 200 ++++++++ src/app/pages/client/sidebar/CreateTab.tsx | 136 ----- src/app/state/channelsWorkspaceSheet.ts | 6 + src/app/state/hooks/channelsWorkspaceSheet.ts | 17 + 18 files changed, 1036 insertions(+), 545 deletions(-) delete mode 100644 src/app/components/join-address-prompt/JoinAddressPrompt.tsx delete mode 100644 src/app/components/join-address-prompt/index.ts delete mode 100644 src/app/hooks/router/useCreateSelected.ts create mode 100644 src/app/pages/client/channels/ChannelCreateRow.tsx create mode 100644 src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts create mode 100644 src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx create mode 100644 src/app/pages/client/channels/SpaceAvatar.tsx create mode 100644 src/app/pages/client/channels/WorkspaceSwitcherSheet.css.ts create mode 100644 src/app/pages/client/channels/WorkspaceSwitcherSheet.tsx delete mode 100644 src/app/pages/client/sidebar/CreateTab.tsx create mode 100644 src/app/state/channelsWorkspaceSheet.ts create mode 100644 src/app/state/hooks/channelsWorkspaceSheet.ts diff --git a/public/locales/en.json b/public/locales/en.json index 10cc2995..2a097ed4 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -398,7 +398,12 @@ "pick_channel_desc": "Choose a channel from the list on the left to start reading.", "root_category": "Channels", "workspace_switcher_aria": "Switch community", - "workspace_switcher_active_marker": "Current" + "workspace_switcher_create_space": "Create community", + "workspace_switcher_drag_to_close": "Drag down to close", + "workspace_switcher_member_count_one": "{{count}} member", + "workspace_switcher_member_count_other": "{{count}} members", + "workspace_footer_subtitle": "Community", + "create_channel": "Create channel" }, "Call": { "start": "Start call", diff --git a/public/locales/ru.json b/public/locales/ru.json index e7efb3d9..6f31ddab 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -400,7 +400,14 @@ "pick_channel_desc": "Откройте канал из списка слева, чтобы начать читать.", "root_category": "Каналы", "workspace_switcher_aria": "Сменить сообщество", - "workspace_switcher_active_marker": "Текущее" + "workspace_switcher_create_space": "Создать сообщество", + "workspace_switcher_drag_to_close": "Потяните вниз, чтобы закрыть", + "workspace_switcher_member_count_one": "{{count}} участник", + "workspace_switcher_member_count_few": "{{count}} участника", + "workspace_switcher_member_count_many": "{{count}} участников", + "workspace_switcher_member_count_other": "{{count}} участника", + "workspace_footer_subtitle": "Сообщество", + "create_channel": "Создать канал" }, "Call": { "start": "Позвонить", diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx deleted file mode 100644 index cc243a21..00000000 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { FormEventHandler, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import FocusTrap from 'focus-trap-react'; -import { - Dialog, - Overlay, - OverlayCenter, - OverlayBackdrop, - Header, - config, - Box, - Text, - IconButton, - Icon, - Icons, - Button, - Input, - color, -} from 'folds'; -import { stopPropagation } from '../../utils/keyboard'; -import { isRoomAlias, isRoomId } from '../../utils/matrix'; -import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to'; -import { tryDecodeURIComponent } from '../../utils/dom'; - -type JoinAddressProps = { - onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void; - onCancel: () => void; -}; -export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) { - const { t } = useTranslation(); - const [invalid, setInvalid] = useState(false); - - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - setInvalid(false); - - const target = evt.target as HTMLFormElement | undefined; - const addressInput = target?.addressInput as HTMLInputElement | undefined; - const address = addressInput?.value.trim(); - if (!address) return; - - if (isRoomId(address) || isRoomAlias(address)) { - onOpen(address); - return; - } - - if (testMatrixTo(address)) { - const decodedAddress = tryDecodeURIComponent(address); - const toRoom = parseMatrixToRoom(decodedAddress); - if (toRoom) { - onOpen(toRoom.roomIdOrAlias, toRoom.viaServers); - return; - } - - const toEvent = parseMatrixToRoomEvent(decodedAddress); - if (toEvent) { - onOpen(toEvent.roomIdOrAlias, toEvent.viaServers, toEvent.eventId); - return; - } - } - - setInvalid(true); - }; - - return ( - }> - - - -
- - {t('Home.join_with_address')} - - - - -
- - - - {t('Home.join_address_desc')} - - -
  • #community:server
  • -
  • https://matrix.to/#/#community:server
  • -
  • https://matrix.to/#/!xYzAj?via=server
  • -
    -
    - - {t('Home.address')} - - {invalid && ( - - {t('Home.invalid_address')} - - )} - - -
    -
    -
    -
    -
    - ); -} diff --git a/src/app/components/join-address-prompt/index.ts b/src/app/components/join-address-prompt/index.ts deleted file mode 100644 index b14b8a61..00000000 --- a/src/app/components/join-address-prompt/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './JoinAddressPrompt'; diff --git a/src/app/hooks/router/useCreateSelected.ts b/src/app/hooks/router/useCreateSelected.ts deleted file mode 100644 index 2034a449..00000000 --- a/src/app/hooks/router/useCreateSelected.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMatch } from 'react-router-dom'; -import { getCreatePath } from '../../pages/pathUtils'; - -export const useCreateSelected = (): boolean => { - const match = useMatch({ - path: getCreatePath(), - caseSensitive: true, - end: false, - }); - - return !!match; -}; diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 98141716..e4cc45f6 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -15,7 +15,6 @@ import { UnverifiedTab, SearchTab, } from './sidebar'; -import { CreateTab } from './sidebar/CreateTab'; export function SidebarNav() { const scrollRef = useRef(null); @@ -32,7 +31,6 @@ export function SidebarNav() { - } diff --git a/src/app/pages/client/channels/ChannelCreateRow.tsx b/src/app/pages/client/channels/ChannelCreateRow.tsx new file mode 100644 index 00000000..ff866daf --- /dev/null +++ b/src/app/pages/client/channels/ChannelCreateRow.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds'; +import { Room } from 'matrix-js-sdk'; +import { NavButton, NavItem, NavItemContent } from '../../../components/nav'; +import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal'; +import { CreateRoomType } from '../../../components/create-room/types'; + +const ROW_MIN_HEIGHT = toRem(56); + +type ChannelCreateRowProps = { + space: Room; +}; + +export function ChannelCreateRow({ space }: ChannelCreateRowProps) { + const { t } = useTranslation(); + const openCreateRoomModal = useOpenCreateRoomModal(); + + return ( + + + openCreateRoomModal(space.roomId, CreateRoomType.TextRoom)} + aria-label={t('Channels.create_channel')} + > + + + + + + + + {t('Channels.create_channel')} + + + + + + + + ); +} diff --git a/src/app/pages/client/channels/Channels.tsx b/src/app/pages/client/channels/Channels.tsx index 5939bfec..5662aec7 100644 --- a/src/app/pages/client/channels/Channels.tsx +++ b/src/app/pages/client/channels/Channels.tsx @@ -3,6 +3,8 @@ import { useSpace } from '../../../hooks/useSpace'; import { PageNav, PageNavContent } from '../../../components/page'; import { DirectStreamHeader } from '../direct/DirectStreamHeader'; import { ChannelsList } from './ChannelsList'; +import { ChannelCreateRow } from './ChannelCreateRow'; +import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe'; import { WorkspaceFooter } from './WorkspaceFooter'; import { ACTIVE_SPACE_KEY } from './useActiveSpace'; @@ -51,11 +53,21 @@ export function Channels() { return ( - - - - - + {/* The horseshoe wraps the entire channels column so the workspace + switcher sheet slides up from the bottom of THIS PageNav slot + (not the viewport). The wrapped children stay put; their + bottom edge is carved by an animated clip-path with a 12px + void between the carve and the sliding silhouette. See + ChannelsWorkspaceHorseshoe.tsx for the canonical idioms it + inherits from MobileSettingsHorseshoe (commit a7d6fc2). */} + + + + + + + + ); } diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts new file mode 100644 index 00000000..feae9b9c --- /dev/null +++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts @@ -0,0 +1,113 @@ +import { style } from '@vanilla-extract/css'; +import { color, toRem } from 'folds'; +import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../../styles/horseshoe'; + +// Re-exported so the TSX can pick up the constants without crossing the +// vanilla-extract / runtime boundary twice. Mirror of the canonical +// MobileSettingsHorseshoe css module. +export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX; +export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; + +// Outer container — anchor for the two absolutely-positioned panes +// (`appBody` and `silhouette`). `flex: 1` fills PageNav's inner column +// slot. `overflow: hidden` clips the rounded carves against the +// container's bg, which is painted inline with the void colour when +// the sheet is active. See MobileSettingsHorseshoe.css.ts for the full +// rationale on every property here — kept verbatim except for the file +// header. +export const container = style({ + position: 'relative', + display: 'flex', + flex: 1, + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + overflow: 'hidden', +}); + +// Wrapped children (DirectStreamHeader → ChannelsList → ChannelCreateRow → +// WorkspaceFooter). Stays put — the bottom is carved away by an animated +// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is +// load-bearing: the container is painted with the void colour when the +// sheet is active, so without an opaque bg the void would bleed through +// every transparent gap between list rows. See canonical for the full +// reasoning on clip-path vs translate vs flex-shrink. +export const appBody = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + backgroundColor: color.Background.Container, + willChange: 'clip-path', +}); + +// Workspace switcher sheet surface. Anchored at the bottom of the +// container; height animates 0 → railHeight as the user drags / clicks. +// `SurfaceVariant.Container` matches the chat-pane tone and keeps the +// safe-area / handle gaps from reading as dark stripes on edge-to-edge +// Android — same rationale as the canonical settings horseshoe. +export const silhouette = style({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: color.SurfaceVariant.Container, + willChange: 'height, border-top-left-radius, border-top-right-radius', +}); + +// Top-anchored panel content. Padding-bottom reserves Android nav-bar +// inset so the create-space row never tucks under the gesture pill. +export const panelContent = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + paddingBottom: 'env(safe-area-inset-bottom, 0px)', +}); + +// 20px drag-to-close band at the top of the silhouette. The ONLY +// drag-down origin once the sheet is open — touches on the spaces list +// below this strip are not drag-sensitive, so internal scroll keeps +// working without a gesture conflict. +export const panelHandle = style({ + flexShrink: 0, + height: toRem(20), + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'grab', + touchAction: 'none', + userSelect: 'none', + selectors: { + '&:active': { cursor: 'grabbing' }, + }, +}); + +export const panelHandleBar = style({ + width: toRem(36), + height: toRem(4), + borderRadius: toRem(4), + backgroundColor: color.Background.Container, +}); + +// Holds the workspace switcher list. `flex: 1` so it grows below the +// 20px handle to fill the remaining panel height; internal Scroll inside +// the switcher handles overflow when the user has many orphan spaces. +export const panelBody = style({ + flex: 1, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + minWidth: 0, +}); diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx new file mode 100644 index 00000000..c6597da6 --- /dev/null +++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.tsx @@ -0,0 +1,464 @@ +// 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 } 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 { 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; + +type DragSource = 'footer' | 'handle'; + +type DragState = { + source: DragSource; + inputType: 'touch' | 'pointer'; + 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(null); + const [containerHeightPx, setContainerHeightPx] = useState(0); + const [drag, setDrag] = useState(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; + + const handleRef = useRef(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(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; + + // 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 = (clientY: number, e: TouchEvent | PointerEvent) => { + const d = dragRef.current; + if (!d) return; + const rawDelta = clientY - d.startY; + const nextDelta = + d.source === 'footer' ? Math.min(0, rawDelta) : Math.max(0, rawDelta); + 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]; + setDrag({ + source: 'footer', + inputType: 'touch', + startY: touch.clientY, + deltaY: 0, + }); + }; + const onHandleTouchStart = (e: TouchEvent) => { + if (dragRef.current) return; + if (!openRef.current) return; + const touch = e.touches[0]; + setDrag({ + source: 'handle', + inputType: 'touch', + startY: touch.clientY, + deltaY: 0, + }); + }; + const onTouchMove = (e: TouchEvent) => { + const d = dragRef.current; + if (!d || d.inputType !== 'touch') return; + applyMove(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; + setDrag({ + source: 'footer', + inputType: 'pointer', + 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; + setDrag({ + source: 'handle', + inputType: 'pointer', + 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.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 ( +
    + {open && portalTarget + ? createPortal( +