From 37f199e544a3d2693cc7c1689a0623bed86e03b7 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:31:35 +0100 Subject: [PATCH] feat: floating glass navigation and action bars in modals Replace inline Save/Discard/Reset buttons with sticky glass-bubble pills that float at the bottom of scrollable modal content. Make SpaceSettings tab sidebar sticky with glass material. Convert RoleEditView "Back to roles" into a sticky glass pill at the top. UserSettings Log Out + Save always visible in a glass pill with separator. Also includes floating position hook and popover/tooltip improvements from prior work. --- .../src/components/chat/MentionPopover.tsx | 19 +- .../web/src/components/chat/MessageInput.tsx | 3 + .../src/components/modals/SpaceSettings.tsx | 108 ++++++------ .../src/components/modals/UserSettings.tsx | 33 ++-- .../spaceSettingsPanels/OverviewPanel.tsx | 32 ++-- .../modals/spaceSettingsPanels/RolesPanel.tsx | 52 +++--- .../web/src/components/ui/ContextMenu.tsx | 4 +- packages/web/src/components/ui/Modal.tsx | 6 +- packages/web/src/components/ui/Tooltip.tsx | 28 +-- .../voice/ConnectionInfoPopover.tsx | 21 ++- .../voice/ScreenShareSettingsPopover.tsx | 19 +- .../web/src/components/voice/StreamTile.tsx | 3 + .../src/components/voice/VoiceControlBar.tsx | 32 ++-- .../src/components/voice/VoiceControls.tsx | 8 +- packages/web/src/hooks/useFloatingPosition.ts | 166 ++++++++++++++++++ 15 files changed, 389 insertions(+), 145 deletions(-) create mode 100644 packages/web/src/hooks/useFloatingPosition.ts diff --git a/packages/web/src/components/chat/MentionPopover.tsx b/packages/web/src/components/chat/MentionPopover.tsx index e9287881..806c4e74 100644 --- a/packages/web/src/components/chat/MentionPopover.tsx +++ b/packages/web/src/components/chat/MentionPopover.tsx @@ -1,7 +1,9 @@ import React, { useMemo, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import type { MemberWithUser } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { useSpaceStore } from '../../stores/spaceStore'; +import { useFloatingPosition } from '../../hooks/useFloatingPosition'; const MAX_RESULTS = 8; @@ -9,13 +11,15 @@ interface MentionPopoverProps { query: string; selectedIndex: number; onSelect: (member: MemberWithUser) => void; + anchorRef: React.RefObject; } -export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopoverProps) { +export function MentionPopover({ query, selectedIndex, onSelect, anchorRef }: MentionPopoverProps) { const members = useSpaceStore((s) => s.members); const spaces = useSpaceStore((s) => s.spaces); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const selectedRef = useRef(null); + const floatingRef = useRef(null); const ownerId = spaces.find((s) => s.id === currentSpaceId)?.ownerId; @@ -30,6 +34,12 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove .slice(0, MAX_RESULTS); }, [members, query]); + const { style } = useFloatingPosition(anchorRef, floatingRef, { + placement: 'top', + offset: 4, + enabled: filtered.length > 0, + }); + // Scroll selected item into view useEffect(() => { selectedRef.current?.scrollIntoView({ block: 'nearest' }); @@ -46,8 +56,8 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove return undefined; }; - return ( -
+ return createPortal( +
Members @@ -88,6 +98,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove ); })}
-
+
, + document.body, ); } diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index cce54da4..692e0668 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -24,6 +24,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { const [mentionState, setMentionState] = useState(null); const fileInputRef = useRef(null); const textareaRef = useRef(null); + const inputContainerRef = useRef(null); const sendMessage = useChatStore((s) => s.sendMessage); const replyTo = useChatStore((s) => s.replyTo); const setReplyTo = useChatStore((s) => s.setReplyTo); @@ -249,6 +250,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
)}
)} diff --git a/packages/web/src/components/modals/SpaceSettings.tsx b/packages/web/src/components/modals/SpaceSettings.tsx index e4220963..1003d9da 100644 --- a/packages/web/src/components/modals/SpaceSettings.tsx +++ b/packages/web/src/components/modals/SpaceSettings.tsx @@ -196,20 +196,24 @@ function StreamingLimitsPanel() {
Settings saved
)} {hasChanges && ( -
- - +
+
+
+ + +
+
)}
@@ -331,20 +335,24 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
Settings saved
)} {hasChanges && ( -
- - +
+
+
+ + +
+
)} @@ -479,7 +487,7 @@ export function SpaceSettingsModal() { if (!space || !currentSpaceId) return null; const tabClass = (t: typeof tab) => - `w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${ + `w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${ tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover' }`; @@ -487,28 +495,30 @@ export function SpaceSettingsModal() {
{/* Tabs */} -
- - {canManageSpace && ( - - )} - - {canManageRoles && ( - + )} + - )} - {isAdmin && ( - - )} + {canManageRoles && ( + + )} + {isAdmin && ( + + )} +
{/* Content */} diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index 1f3a6f51..651ee3f7 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -172,20 +172,25 @@ export function UserSettingsModal() { {/* Connected Instances */} -
- - +
+
+
+ +
+ +
+
diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index fd4416f9..1467bf47 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -229,20 +229,24 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
Settings saved
)} {canManageSpace && hasChanges && ( -
- - +
+
+
+ + +
+
)} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx index fb89116f..b889b349 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/RolesPanel.tsx @@ -242,15 +242,17 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) { return (
{/* Back button */} - +
+ +
{/* Role Name (not editable for @everyone) */} {!isEveryone && ( @@ -362,20 +364,24 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
Role saved
)} {hasChanges && ( -
- - +
+
+
+ + +
+
)} diff --git a/packages/web/src/components/ui/ContextMenu.tsx b/packages/web/src/components/ui/ContextMenu.tsx index 3c62c620..28871def 100644 --- a/packages/web/src/components/ui/ContextMenu.tsx +++ b/packages/web/src/components/ui/ContextMenu.tsx @@ -49,6 +49,8 @@ export function ContextMenu({ items, children }: ContextMenuProps) { if (rect.bottom > window.innerHeight) { newPosition.y = window.innerHeight - rect.height - 8; } + if (newPosition.x < 8) newPosition.x = 8; + if (newPosition.y < 8) newPosition.y = 8; if (newPosition.x !== position.x || newPosition.y !== position.y) { setPosition(newPosition); @@ -62,7 +64,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) { {isOpen && (
{items.map((item, i) => ( diff --git a/packages/web/src/components/ui/Modal.tsx b/packages/web/src/components/ui/Modal.tsx index 4d14d407..1f648811 100644 --- a/packages/web/src/components/ui/Modal.tsx +++ b/packages/web/src/components/ui/Modal.tsx @@ -30,9 +30,9 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' className="absolute inset-0 bg-surface-overlay" onClick={onClose} /> -
+
{title && ( -
+

{title}

)} -
+
{children}
diff --git a/packages/web/src/components/ui/Tooltip.tsx b/packages/web/src/components/ui/Tooltip.tsx index a449ca64..b7387899 100644 --- a/packages/web/src/components/ui/Tooltip.tsx +++ b/packages/web/src/components/ui/Tooltip.tsx @@ -1,4 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useFloatingPosition } from '../../hooks/useFloatingPosition'; interface TooltipProps { content: string; @@ -10,6 +12,14 @@ interface TooltipProps { export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) { const [isVisible, setIsVisible] = useState(false); const timeoutRef = useRef>(); + const anchorRef = useRef(null); + const floatingRef = useRef(null); + + const { style } = useFloatingPosition(anchorRef, floatingRef, { + placement: position, + offset: 8, + enabled: isVisible, + }); const show = () => { timeoutRef.current = setTimeout(() => setIsVisible(true), delay); @@ -26,22 +36,18 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }: }; }, []); - const positionClasses: Record = { - top: 'bottom-full left-1/2 -translate-x-1/2 mb-2', - right: 'left-full top-1/2 -translate-y-1/2 ml-2', - bottom: 'top-full left-1/2 -translate-x-1/2 mt-2', - left: 'right-full top-1/2 -translate-y-1/2 mr-2', - }; - return ( -
+
{children} - {isVisible && ( + {isVisible && createPortal(
{content} -
+
, + document.body, )}
); diff --git a/packages/web/src/components/voice/ConnectionInfoPopover.tsx b/packages/web/src/components/voice/ConnectionInfoPopover.tsx index 765a9da5..59bbcb42 100644 --- a/packages/web/src/components/voice/ConnectionInfoPopover.tsx +++ b/packages/web/src/components/voice/ConnectionInfoPopover.tsx @@ -1,10 +1,13 @@ import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTrackStats'; import { getActiveRoom } from '../../hooks/useLiveKit'; +import { useFloatingPosition } from '../../hooks/useFloatingPosition'; interface ConnectionInfoPopoverProps { open: boolean; onClose: () => void; + anchorRef: React.RefObject; } function formatBitrate(kbps: number): string { @@ -110,10 +113,16 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) { ); } -export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverProps) { +export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) { const popoverRef = useRef(null); const stats = useTrackStats(open); + const { style } = useFloatingPosition(anchorRef, popoverRef, { + placement: 'top', + offset: 12, + enabled: open, + }); + // Click-outside to close useEffect(() => { if (!open) return; @@ -130,16 +139,17 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr const room = getActiveRoom(); - return ( + return createPortal(
Connection Info
-
+
{!room ? (
Not connected
) : !stats ? ( @@ -197,6 +207,7 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr )}
-
+
, + document.body, ); } diff --git a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx index 85efbbd1..e536127c 100644 --- a/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx +++ b/packages/web/src/components/voice/ScreenShareSettingsPopover.tsx @@ -1,12 +1,15 @@ import React, { useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { useVoiceStore } from '../../stores/voiceStore'; import type { ScreenShareConfig } from '../../stores/voiceStore'; import { useSettingsStore } from '../../stores/settingsStore'; import { buildScreenShareOptions } from '../../utils/screenShare'; +import { useFloatingPosition } from '../../hooks/useFloatingPosition'; interface ScreenShareSettingsPopoverProps { open: boolean; onClose: () => void; + anchorRef: React.RefObject; } const ALL_RESOLUTIONS: { value: ScreenShareConfig['height']; label: string }[] = [ @@ -45,12 +48,18 @@ function formatKbps(kbps: number): string { : `${kbps} kbps`; } -export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSettingsPopoverProps) { +export function ScreenShareSettingsPopover({ open, onClose, anchorRef }: ScreenShareSettingsPopoverProps) { const popoverRef = useRef(null); const config = useVoiceStore((s) => s.screenShareConfig); const setConfig = useVoiceStore((s) => s.setScreenShareConfig); const limits = useSettingsStore((s) => s.streamingLimits); + const { style } = useFloatingPosition(anchorRef, popoverRef, { + placement: 'top', + offset: 12, + enabled: open, + }); + const BITRATE_MIN = limits?.minBitrateKbps ?? 500; const BITRATE_MAX = limits?.maxBitrateKbps ?? 20000; const BITRATE_STEP = limits?.bitrateStepKbps ?? 500; @@ -104,10 +113,11 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting const pillSelected = 'bg-accent-primary text-white'; const pillUnselected = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover'; - return ( + return createPortal(
Stream Settings @@ -215,6 +225,7 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting {formatBitrate(result.publish.videoEncoding.maxBitrate)} ยท {formatDegradation(result.overdrive.degradationPreference)}
-
+
, + document.body, ); } diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index e2942509..cb3e54df 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -38,6 +38,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); + const qualityBtnRef = useRef(null); // --- VIDEO --- use LiveKit's track.attach() to register the element // with the adaptive stream observer (enables SFU layer switching by viewport size) @@ -260,6 +261,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index 6337dd9c..4cbcb580 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useUIStore } from '../../stores/uiStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; @@ -27,6 +27,7 @@ export function VoiceControlBar() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const voiceOrigin = currentVoiceChannelId ? getChannelOrigin(currentVoiceChannelId) : ''; const [qualityOpen, setQualityOpen] = useState(false); + const qualityBtnRef = useRef(null); const handleMute = React.useCallback(async () => { toggleMic(); @@ -207,21 +208,20 @@ export function VoiceControlBar() { {/* Video Quality */} -
- - setQualityOpen(false)} /> -
+ + setQualityOpen(false)} anchorRef={qualityBtnRef} /> {/* Separator */}
diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index d16f4267..a6383a86 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { useVoiceStore } from '../../stores/voiceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; @@ -24,6 +24,8 @@ export function VoiceControls() { const channels = useSpaceStore((s) => s.channels); const [showScreenShareSettings, setShowScreenShareSettings] = useState(false); const [showConnectionInfo, setShowConnectionInfo] = useState(false); + const connectionBtnRef = useRef(null); + const qualityBtnRef = useRef(null); const activeDmCall = useVoiceStore((s) => s.activeDmCall); @@ -109,6 +111,7 @@ export function VoiceControls() { {/* Row 1: Signal icon + status text + disconnect */}
@@ -189,6 +193,7 @@ export function VoiceControls() { {/* Video Quality */}
diff --git a/packages/web/src/hooks/useFloatingPosition.ts b/packages/web/src/hooks/useFloatingPosition.ts new file mode 100644 index 00000000..31d0f277 --- /dev/null +++ b/packages/web/src/hooks/useFloatingPosition.ts @@ -0,0 +1,166 @@ +import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react'; + +type Placement = 'top' | 'bottom' | 'left' | 'right'; + +interface UseFloatingPositionOptions { + placement: Placement; + offset?: number; + enabled?: boolean; +} + +interface UseFloatingPositionResult { + style: CSSProperties; + actualPlacement: Placement; +} + +const VIEWPORT_PADDING = 8; + +const oppositePlacement: Record = { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left', +}; + +function computePosition( + anchorRect: DOMRect, + floatingWidth: number, + floatingHeight: number, + placement: Placement, + offset: number, +): { top: number; left: number; actualPlacement: Placement } { + const vw = window.innerWidth; + const vh = window.innerHeight; + + let top = 0; + let left = 0; + let actual = placement; + + // Compute initial position on primary axis + if (placement === 'top') { + top = anchorRect.top - floatingHeight - offset; + left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2; + } else if (placement === 'bottom') { + top = anchorRect.bottom + offset; + left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2; + } else if (placement === 'left') { + top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2; + left = anchorRect.left - floatingWidth - offset; + } else { + top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2; + left = anchorRect.right + offset; + } + + // Flip: if overflowing on primary axis, try the opposite side + if (placement === 'top' && top < VIEWPORT_PADDING) { + const flippedTop = anchorRect.bottom + offset; + if (flippedTop + floatingHeight <= vh - VIEWPORT_PADDING) { + top = flippedTop; + actual = 'bottom'; + } + } else if (placement === 'bottom' && top + floatingHeight > vh - VIEWPORT_PADDING) { + const flippedTop = anchorRect.top - floatingHeight - offset; + if (flippedTop >= VIEWPORT_PADDING) { + top = flippedTop; + actual = 'top'; + } + } else if (placement === 'left' && left < VIEWPORT_PADDING) { + const flippedLeft = anchorRect.right + offset; + if (flippedLeft + floatingWidth <= vw - VIEWPORT_PADDING) { + left = flippedLeft; + actual = 'right'; + } + } else if (placement === 'right' && left + floatingWidth > vw - VIEWPORT_PADDING) { + const flippedLeft = anchorRect.left - floatingWidth - offset; + if (flippedLeft >= VIEWPORT_PADDING) { + left = flippedLeft; + actual = 'left'; + } + } + + // Clamp: keep within viewport on cross axis + if (actual === 'top' || actual === 'bottom') { + left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING)); + } else { + top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING)); + } + + // Also clamp primary axis as last resort + top = Math.max(VIEWPORT_PADDING, Math.min(top, vh - floatingHeight - VIEWPORT_PADDING)); + left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - floatingWidth - VIEWPORT_PADDING)); + + return { top, left, actualPlacement: actual }; +} + +export function useFloatingPosition( + anchorRef: RefObject, + floatingRef: RefObject, + options: UseFloatingPositionOptions, +): UseFloatingPositionResult { + const { placement, offset = 8, enabled = true } = options; + + const [result, setResult] = useState<{ top: number; left: number; actualPlacement: Placement }>({ + top: -9999, + left: -9999, + actualPlacement: placement, + }); + + const update = useCallback(() => { + const anchor = anchorRef.current; + const floating = floatingRef.current; + if (!anchor || !floating) return; + + const anchorRect = anchor.getBoundingClientRect(); + const floatingRect = floating.getBoundingClientRect(); + + const pos = computePosition( + anchorRect, + floatingRect.width, + floatingRect.height, + placement, + offset, + ); + + setResult((prev) => { + if (prev.top === pos.top && prev.left === pos.left && prev.actualPlacement === pos.actualPlacement) { + return prev; + } + return pos; + }); + }, [anchorRef, floatingRef, placement, offset]); + + useLayoutEffect(() => { + if (!enabled) return; + update(); + + // Observe resize of both elements + const anchor = anchorRef.current; + const floating = floatingRef.current; + const targets: Element[] = []; + if (anchor) targets.push(anchor); + if (floating) targets.push(floating); + + const ro = new ResizeObserver(update); + targets.forEach((t) => ro.observe(t)); + + // Also update on scroll/resize + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + + return () => { + ro.disconnect(); + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [enabled, update, anchorRef, floatingRef]); + + return { + style: { + position: 'fixed', + top: result.top, + left: result.left, + zIndex: 200, + }, + actualPlacement: result.actualPlacement, + }; +}