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:
@@ -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,21 +208,20 @@ export function VoiceControlBar() {
|
||||
</button>
|
||||
|
||||
{/* Video Quality */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setQualityOpen(!qualityOpen)}
|
||||
className={qualityOpen
|
||||
? `${btnBase} bg-surface-channel text-txt-primary`
|
||||
: btnDefault
|
||||
}
|
||||
title="Video Quality"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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>
|
||||
<button
|
||||
ref={qualityBtnRef}
|
||||
onClick={() => setQualityOpen(!qualityOpen)}
|
||||
className={qualityOpen
|
||||
? `${btnBase} bg-surface-channel text-txt-primary`
|
||||
: btnDefault
|
||||
}
|
||||
title="Video Quality"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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)} 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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user