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.
This commit is contained in:
Jannis Braun
2026-03-09 01:31:35 +01:00
parent 31db1a7bd3
commit 37f199e544
15 changed files with 389 additions and 145 deletions
@@ -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<HTMLElement | null>;
}
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<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(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 (
<div className="absolute bottom-full left-0 w-[280px] mb-1 z-50">
return createPortal(
<div ref={floatingRef} style={style} className="w-[280px]">
<div className="bg-surface-elevated rounded-lg shadow-[0_0_0_1px_rgba(0,0,0,0.15),0_8px_16px_rgba(0,0,0,0.24)] overflow-hidden max-h-[320px] overflow-y-auto scrollbar-thin">
<div className="px-2 py-1.5 text-[11px] font-bold text-txt-tertiary uppercase tracking-wider">
Members
@@ -88,6 +98,7 @@ export function MentionPopover({ query, selectedIndex, onSelect }: MentionPopove
);
})}
</div>
</div>
</div>,
document.body,
);
}
@@ -24,6 +24,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputContainerRef = useRef<HTMLDivElement>(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) {
</div>
)}
<div
ref={inputContainerRef}
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
onDrop={handleDrop}
onDragOver={handleDragOver}
@@ -259,6 +261,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
query={mentionState.query}
selectedIndex={mentionState.selectedIndex}
onSelect={selectMention}
anchorRef={inputContainerRef}
/>
)}
@@ -196,20 +196,24 @@ function StreamingLimitsPanel() {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)}
{hasChanges && (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
<button
onClick={handleReset}
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
</div>
@@ -331,20 +335,24 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)}
{hasChanges && (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
<button
onClick={handleReset}
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
@@ -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,7 +495,8 @@ export function SpaceSettingsModal() {
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
{/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1">
<div className="w-32 flex-shrink-0 sticky top-0 self-start z-10">
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
Overview
</button>
@@ -510,6 +519,7 @@ export function SpaceSettingsModal() {
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
@@ -172,22 +172,27 @@ export function UserSettingsModal() {
{/* Connected Instances */}
<ConnectedInstances />
<div className="flex items-center justify-between pt-2">
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-3 py-2 flex items-center gap-3 pointer-events-auto">
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
className="px-3 py-1 text-sm text-txt-danger hover:bg-accent-rose/10 rounded-full transition-colors"
>
Log Out
</button>
<div className="w-px h-5 bg-white/10" />
<button
onClick={handleSave}
disabled={isLoading}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
</div>
</Modal>
);
}
@@ -229,20 +229,24 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)}
{canManageSpace && hasChanges && (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving || uploadingIcon || !spaceName.trim()}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
<button
onClick={handleDiscard}
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Discard
</button>
<button
onClick={handleSave}
disabled={saving || uploadingIcon || !spaceName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
@@ -242,15 +242,17 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
return (
<div className="space-y-4">
{/* Back button */}
<div className="sticky top-0 z-10 pointer-events-none pb-3">
<button
onClick={onBack}
className="flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
className="glass-bubble rounded-full px-3 py-1.5 flex items-center gap-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors pointer-events-auto"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to roles
</button>
</div>
{/* Role Name (not editable for @everyone) */}
{!isEveryone && (
@@ -362,20 +364,24 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
)}
{hasChanges && (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={saving || (!isEveryone && !draftName.trim())}
className="px-4 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
<button
onClick={handleDiscard}
className="px-4 py-1.5 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Discard
</button>
<button
onClick={handleSave}
disabled={saving || (!isEveryone && !draftName.trim())}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)}
@@ -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 && (
<div
ref={menuRef}
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in"
className="fixed z-[200] min-w-[180px] py-1.5 bg-surface-elevated rounded-md shadow-elevation-high animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
+3 -3
View File
@@ -30,9 +30,9 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
className="absolute inset-0 bg-surface-overlay"
onClick={onClose}
/>
<div className={`relative ${maxWidth} w-full mx-4 bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
<div className={`relative ${maxWidth} w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up`}>
{title && (
<div className="flex items-center justify-between px-4 pt-4">
<div className="flex items-center justify-between px-4 pt-4 flex-shrink-0">
<h2 className="text-xl font-bold text-txt-primary">{title}</h2>
<button
onClick={onClose}
@@ -44,7 +44,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md'
</button>
</div>
)}
<div className="p-4">
<div className="p-4 overflow-y-auto scrollbar-thin flex-1 min-h-0">
{children}
</div>
</div>
+17 -11
View File
@@ -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<ReturnType<typeof setTimeout>>();
const anchorRef = useRef<HTMLDivElement>(null);
const floatingRef = useRef<HTMLDivElement>(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<string, string> = {
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 (
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
<div ref={anchorRef} className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
{children}
{isVisible && (
{isVisible && createPortal(
<div
className={`absolute z-[200] px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
ref={floatingRef}
style={style}
className="px-3 py-1.5 text-sm font-medium text-txt-primary bg-surface-elevated rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none"
>
{content}
</div>
</div>,
document.body,
)}
</div>
);
@@ -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<HTMLElement | null>;
}
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<HTMLDivElement>(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(
<div
ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[300px] glass rounded-lg z-50 overflow-hidden"
style={style}
className="w-[300px] glass rounded-lg overflow-hidden"
>
<div className="px-3 py-2 border-b border-border-hard">
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
</div>
<div className="px-3 py-2">
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
{!room ? (
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
) : !stats ? (
@@ -197,6 +207,7 @@ export function ConnectionInfoPopover({ open, onClose }: ConnectionInfoPopoverPr
</>
)}
</div>
</div>
</div>,
document.body,
);
}
@@ -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<HTMLElement | null>;
}
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<HTMLDivElement>(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(
<div
ref={popoverRef}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[260px] glass rounded-lg z-50 overflow-hidden"
style={style}
className="w-[260px] glass rounded-lg overflow-hidden"
>
<div className="px-3 py-2 border-b border-border-hard">
<span className="text-[14px] font-bold text-txt-primary">Stream Settings</span>
@@ -215,6 +225,7 @@ export function ScreenShareSettingsPopover({ open, onClose }: ScreenShareSetting
{formatBitrate(result.publish.videoEncoding.maxBitrate)} · {formatDegradation(result.overdrive.degradationPreference)}
</span>
</div>
</div>
</div>,
document.body,
);
}
@@ -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<HTMLButtonElement>(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) {
</div>
<div className="relative">
<button
ref={qualityBtnRef}
onClick={() => setQualityPopoverOpen(!qualityPopoverOpen)}
className="w-full flex items-center justify-between px-2 py-1.5 text-sm text-txt-secondary hover:bg-interactive-hover rounded transition-colors"
>
@@ -277,6 +279,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
<ScreenShareSettingsPopover
open={qualityPopoverOpen}
onClose={() => setQualityPopoverOpen(false)}
anchorRef={qualityBtnRef}
/>
)}
</div>
@@ -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<HTMLButtonElement>(null);
const handleMute = React.useCallback(async () => {
toggleMic();
@@ -207,8 +208,8 @@ export function VoiceControlBar() {
</button>
{/* Video Quality */}
<div className="relative">
<button
ref={qualityBtnRef}
onClick={() => setQualityOpen(!qualityOpen)}
className={qualityOpen
? `${btnBase} bg-surface-channel text-txt-primary`
@@ -220,8 +221,7 @@ export function VoiceControlBar() {
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
</div>
<ScreenShareSettingsPopover open={qualityOpen} onClose={() => setQualityOpen(false)} anchorRef={qualityBtnRef} />
{/* Separator */}
<div className="w-[1px] h-6 bg-white/10 mx-0.5" />
@@ -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<HTMLButtonElement>(null);
const qualityBtnRef = useRef<HTMLButtonElement>(null);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
@@ -109,6 +111,7 @@ export function VoiceControls() {
{/* Row 1: Signal icon + status text + disconnect */}
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
<button
ref={connectionBtnRef}
onClick={() => {
setShowConnectionInfo(!showConnectionInfo);
if (!showConnectionInfo) setShowScreenShareSettings(false);
@@ -146,6 +149,7 @@ export function VoiceControls() {
<ConnectionInfoPopover
open={showConnectionInfo}
onClose={() => setShowConnectionInfo(false)}
anchorRef={connectionBtnRef}
/>
</div>
@@ -189,6 +193,7 @@ export function VoiceControls() {
{/* Video Quality */}
<button
ref={qualityBtnRef}
onClick={() => {
setShowScreenShareSettings(!showScreenShareSettings);
if (!showScreenShareSettings) setShowConnectionInfo(false);
@@ -238,6 +243,7 @@ export function VoiceControls() {
<ScreenShareSettingsPopover
open={showScreenShareSettings}
onClose={() => setShowScreenShareSettings(false)}
anchorRef={qualityBtnRef}
/>
</div>
</>
@@ -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<Placement, Placement> = {
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<HTMLElement | null>,
floatingRef: RefObject<HTMLElement | null>,
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,
};
}