From 4ab38d3f9af314cf70fe7e375ada748291dccb89 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 16 May 2026 12:52:05 +0200 Subject: [PATCH] toolbar: always-mount nav rows + drive show/hide via framer variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the outer AnimatePresence wrapper around the four toolbar rows (desktop backplate, left-nav, right-nav, mobile-nav) with always-mounted motion.div elements driven by an isVisible-derived variant string ('visible' or 'hidden'). This eliminates the spam-toggle bug: rapid clicks on the show/hide chevron previously left motion children in inconsistent intermediate states (stuck opacity 0, phantom scale 0.8) because AnimatePresence + Fragment + multiple keyed children breaks when enter/exit cycles overlap. With variants, framer-motion's spring solver picks up from the current animated value on each retarget, so spam-clicking just settles smoothly toward whichever target is current. Refactor details: - containerVariants drops its 'exit' state (now lives in 'hidden'). - itemVariants drops 'exit' as well — animation target is the same as hidden, and exit doesn't apply without AnimatePresence. - New shellVariants for the backplate. - pointer-events is animated per-variant ('auto' visible / 'none' hidden) instead of pinned via a Tailwind class, so the hidden rows don't intercept clicks. - Wrapper variants are computed inside the component because leftNavVariants.hidden depends on isInRoom (the nav slides in from the side in-room, from the bottom otherwise). - Variant inheritance: outer wrapper drives 'visible'/'hidden'; inner container (containerVariants) and items (itemVariants) inherit via framer's variant propagation, so stagger runs in both directions without needing AnimatePresence. - Inner AnimatePresence around the Me popover stays — it has a single keyed child with a clean conditional and doesn't suffer from the Fragment-wrapping issue. Cleanups while here: - Dropped hasDesktopUnifiedShell: always equal to isToolbarOpen inside the isInRoom-gated block, so the ternary was always picking one branch. Inlined. - Dropped showDesktopShell: same redundancy inside the (now removed) AnimatePresence. The 'else' branch of its ternary was dead code. - Extracted spring transition constants (SHELL_TRANSITION, NAV_TRANSITION, ME_POPOVER_TRANSITION) so they're declared once. - Removed pointer-events-auto from wrapper className strings where the variant now owns it (mobile-nav, left-nav, right-nav). Behaviour: identical to before for a single click cycle (open → close animates with the same spring). The previously broken spam-click path now settles cleanly. Tests still 193/193, typecheck 0 errors, prod build unchanged. --- src/components/toolbar/ToolbarView.tsx | 444 ++++++++++++++----------- 1 file changed, 244 insertions(+), 200 deletions(-) diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index a3c7d4c..d960bd3 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -8,18 +8,33 @@ import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; +// The 4 nav rows + backplate are ALWAYS mounted and animate between +// hidden/visible via framer-motion variants. Rapid show/hide toggles +// just retarget the in-flight spring instead of interrupting an +// AnimatePresence enter/exit cycle, so the pre-refactor artifacts +// (icons stuck at opacity 0 or scale 0.8 after spam-clicking the +// toggle) can no longer happen — framer's spring solver picks up from +// whatever the current animated value is. + const containerVariants: Variants = { - hidden: {}, - visible: { transition: { staggerChildren: 0.05 } }, - exit: { transition: { staggerChildren: 0.03, staggerDirection: -1 } } + hidden: { transition: { staggerChildren: 0.03, staggerDirection: -1 } }, + visible: { transition: { staggerChildren: 0.05 } } }; const itemVariants: Variants = { hidden: { opacity: 0, y: 10, scale: 0.8 }, - visible: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 400, damping: 22 } }, - exit: { opacity: 0, y: 6, scale: 0.85, transition: { duration: 0.1 } } + visible: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 400, damping: 22 } } }; +const shellVariants: Variants = { + hidden: { opacity: 0, y: 8 }, + visible: { opacity: 1, y: 0 } +}; + +const SHELL_TRANSITION = { type: 'spring' as const, stiffness: 260, damping: 26 }; +const NAV_TRANSITION = { type: 'spring' as const, stiffness: 300, damping: 28 }; +const ME_POPOVER_TRANSITION = { type: 'spring' as const, stiffness: 420, damping: 28 }; + export const ToolbarView: FC<{ isInRoom: boolean }> = props => { const { isInRoom } = props; @@ -35,8 +50,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { openMonitor, showToolbarButton } = useWiredTools(); const isMod = GetSessionDataManager().isModerator; - const hasDesktopUnifiedShell = (isInRoom && isToolbarOpen); - const showDesktopShell = (isToolbarOpen || !isInRoom); + const isVisible = (isToolbarOpen || !isInRoom); + const visibilityVariant = isVisible ? 'visible' : 'hidden'; + const desktopToolbarFrameClasses = isTouchLayout ? '' : 'md:left-1/2 md:right-auto md:h-[52px] md:w-[420px] md:-translate-x-1/2 md:items-center md:px-[6px] md:py-[4px] lg:w-[460px]'; const desktopToolbarOpenClasses = isTouchLayout ? '' : 'md:rounded-none md:border-0 md:bg-transparent md:shadow-none'; const desktopToggleClasses = isTouchLayout ? '' : 'md:mb-0'; @@ -46,6 +62,22 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden md:block'; const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden md:flex'; + // Compute the wrapper variants. `isInRoom` affects the hidden-state + // offset (the nav slides in from the side when in a room, from the + // bottom otherwise) so the variant object is derived from props. + const leftNavVariants: Variants = { + hidden: { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8, pointerEvents: 'none' }, + visible: { opacity: 1, x: 0, y: 0, pointerEvents: 'auto' } + }; + const rightNavVariants: Variants = { + hidden: { opacity: 0, x: 10, pointerEvents: 'none' }, + visible: { opacity: 1, x: 0, pointerEvents: 'auto' } + }; + const mobileNavVariants: Variants = { + hidden: { opacity: 0, y: 8, pointerEvents: 'none' }, + visible: { opacity: 1, y: 0, pointerEvents: 'auto' } + }; + useMessageEvent(YouTubeRoomSettingsEvent, event => { const enabled = event.getParser().youtubeEnabled; @@ -123,7 +155,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { youtubeEnabled && } { isInRoom && -
+
setIsToolbarOpen(value => !value) } @@ -148,199 +180,211 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
} - - { (isToolbarOpen || !isInRoom) && - <> - { showDesktopShell && + { /* Desktop backplate. Always mounted; opacity-driven. */ } + + + { /* Left nav — desktop. Container variant inheritance staggers items in/out. */ } + + + + { isInRoom + ? VisitDesktop() } className="tb-icon" /> + : CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> } + + + CreateLinkEvent('navigator/toggle') } className="tb-icon" /> + + { GetConfigurationValue('game.center.enabled') && + + CreateLinkEvent('games/toggle') } className="tb-icon" /> + } + + CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> + + + CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> + + + CreateLinkEvent('inventory/toggle') } className="tb-icon" /> + { (getFullCount > 0) && + } + + + + { isMeExpanded && + + + } + + + { + setMeExpanded(value => !value); + event.stopPropagation(); + } }> + + + { (getTotalUnseen > 0) && + } + + { (isInRoom && showToolbarButton) && + + + } + { isInRoom && + + CreateLinkEvent('camera/toggle') } className="tb-icon" /> + } + { (isInRoom && youtubeEnabled) && + + + } + { isMod && + + CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> + } + { isMod && + + CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> + } + + + + { /* Right nav — desktop */ } + + + + CreateLinkEvent('friends/toggle') } className="tb-icon" /> + { (requests.length > 0) && + } + + { ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) && + + OpenMessengerChat() } /> + } +
+
+ + + + { /* Mobile nav. Two staggered halves split by the Me avatar. */ } + + + + { isInRoom + ? VisitDesktop() } className="tb-icon" /> + : CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> } + + + CreateLinkEvent('navigator/toggle') } className="tb-icon" /> + + { GetConfigurationValue('game.center.enabled') && + + CreateLinkEvent('games/toggle') } className="tb-icon" /> + } + + CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> + + + CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> + + + CreateLinkEvent('inventory/toggle') } className="tb-icon" /> + { (getFullCount > 0) && + } + + + + + { isMeExpanded && } - - - - - { isInRoom - ? VisitDesktop() } className="tb-icon" /> - : CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> } - - - CreateLinkEvent('navigator/toggle') } className="tb-icon" /> - - { GetConfigurationValue('game.center.enabled') && - - CreateLinkEvent('games/toggle') } className="tb-icon" /> - } - - CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> - - - CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> - - - CreateLinkEvent('inventory/toggle') } className="tb-icon" /> - { (getFullCount > 0) && - } - - - - { isMeExpanded && - - - } - - - { - setMeExpanded(value => !value); event.stopPropagation(); - } }> - - - { (getTotalUnseen > 0) && - } - - { (isInRoom && showToolbarButton) && - - - } - { isInRoom && - - CreateLinkEvent('camera/toggle') } className="tb-icon" /> - } - { (isInRoom && youtubeEnabled) && - - - } - { isMod && - - CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> - } - { isMod && - - CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> - } - - - - - - - CreateLinkEvent('friends/toggle') } className="tb-icon" /> - { (requests.length > 0) && - } - - { ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) && - - OpenMessengerChat() } /> - } -
-
- - - - - - - { isInRoom - ? VisitDesktop() } className="tb-icon" /> - : CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> } - - - CreateLinkEvent('navigator/toggle') } className="tb-icon" /> - - { GetConfigurationValue('game.center.enabled') && - - CreateLinkEvent('games/toggle') } className="tb-icon" /> - } - - CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> - - - CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> - - - CreateLinkEvent('inventory/toggle') } className="tb-icon" /> - { (getFullCount > 0) && - } - - - - - { isMeExpanded && - - - } - - - { - setMeExpanded(value => !value); event.stopPropagation(); - } }> - - - { (getTotalUnseen > 0) && - } - - - { (isInRoom && showToolbarButton) && - - - } - { isInRoom && - - CreateLinkEvent('camera/toggle') } className="tb-icon" /> - } - { (isInRoom && youtubeEnabled) && - - - } - { isMod && - - CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> - } - { isMod && - - CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> - } - { !isInRoom && - - CreateLinkEvent('friends/toggle') } className="tb-icon" /> - { (requests.length > 0) && - } - } - - - } - + initial={ { opacity: 0, y: 6, scale: 0.97 } } + animate={ { opacity: 1, y: 0, scale: 1 } } + exit={ { opacity: 0, y: 6, scale: 0.97 } } + transition={ ME_POPOVER_TRANSITION } + className="pointer-events-auto absolute bottom-[calc(100%+10px)] left-1/2 z-[70] -translate-x-1/2"> + + } + + + { + setMeExpanded(value => !value); + event.stopPropagation(); + } }> + + + { (getTotalUnseen > 0) && + } + + + { (isInRoom && showToolbarButton) && + + + } + { isInRoom && + + CreateLinkEvent('camera/toggle') } className="tb-icon" /> + } + { (isInRoom && youtubeEnabled) && + + + } + { isMod && + + CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> + } + { isMod && + + CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> + } + { !isInRoom && + + CreateLinkEvent('friends/toggle') } className="tb-icon" /> + { (requests.length > 0) && + } + } + + ); };