feat: reveal floating header from corner

This commit is contained in:
ajnart committed 2026-08-24 00:55:34 +02:00
1 parent f8d3d5893a
commit af394577b8
2 files changed
+119 -7

No files matched your search

@@ -96,14 +96,27 @@
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
inset-block-start: max(rem(10px), env(safe-area-inset-top));
inset-inline-end: max(rem(10px), env(safe-area-inset-right));
top: max(rem(10px), env(safe-area-inset-top));
right: max(rem(10px), env(safe-area-inset-right));
padding: rem(8px);
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
box-shadow: 0 rem(12px) rem(36px) rgb(0 0 0 / 20%);
backdrop-filter: blur(rem(20px)) saturate(140%);
opacity: 0;
pointer-events: none;
transform: translateY(rem(-8px)) scale(0.98);
transition:
opacity 160ms ease,
transform 180ms ease;
}
.floatingControls[data-visible="true"],
.floatingControls:focus-within {
opacity: 1;
pointer-events: auto;
transform: translateY(0) scale(1);
}
.floatingControls :global(.mantine-UnstyledButton-root) {
@@ -112,8 +125,18 @@
justify-content: center;
}
:global([dir="rtl"]) .floatingControls {
inset-inline-end: max(rem(10px), env(safe-area-inset-left));
.floatingControlsCorner {
position: fixed;
z-index: calc(var(--homarr-z-index-board-header) - 1);
top: 0;
right: 0;
width: rem(112px);
height: rem(112px);
pointer-events: none;
}
.floatingControlsCorner[data-active="true"] {
pointer-events: auto;
}
@media (max-width: $mantine-breakpoint-sm) {
@@ -131,3 +154,9 @@
flex: 0 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.floatingControls {
transition: none;
}
}
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
AppShellHeader,
Avatar,
@@ -57,6 +57,10 @@ interface ConfigurableHeaderProps {
}
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
const floatingControlsIntroDurationMs = 5_000;
const floatingControlsDismissDelayMs = 900;
export const ConfigurableHeader = ({
logo,
actions,
@@ -132,7 +136,7 @@ export const ConfigurableHeader = ({
</div>
</AppShellHeader>
) : (
<Group className={classes.floatingControls} gap={4} wrap="nowrap">
<FloatingHeaderControls>
{hasNavigation ? <ClientBurger /> : null}
<UserButtonClient
avatar={avatar}
@@ -140,7 +144,7 @@ export const ConfigurableHeader = ({
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</Group>
</FloatingHeaderControls>
)}
<LazySpotlight />
</>
@@ -150,6 +154,85 @@ export const ConfigurableHeader = ({
);
};
const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
const [isIntroComplete, setIsIntroComplete] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const isInteractingRef = useRef(false);
const dismissTimeoutRef = useRef<number | null>(null);
const clearDismissTimeout = () => {
if (dismissTimeoutRef.current === null) return;
window.clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
};
const dismissSoon = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
dismissTimeoutRef.current = window.setTimeout(() => {
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsDismissDelayMs);
};
const revealFromCorner = () => {
if (!isIntroComplete) return;
clearDismissTimeout();
setIsVisible(true);
};
const startInteraction = () => {
isInteractingRef.current = true;
clearDismissTimeout();
setIsVisible(true);
};
const endInteraction = () => {
isInteractingRef.current = false;
dismissSoon();
};
useEffect(() => {
const introTimeout = window.setTimeout(() => {
setIsIntroComplete(true);
if (!isInteractingRef.current) setIsVisible(false);
}, floatingControlsIntroDurationMs);
return () => {
window.clearTimeout(introTimeout);
if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
};
}, []);
return (
<>
<div
className={classes.floatingControlsCorner}
data-active={isIntroComplete || undefined}
aria-hidden
onPointerEnter={revealFromCorner}
onPointerMove={revealFromCorner}
onPointerDown={revealFromCorner}
onPointerLeave={dismissSoon}
/>
<Group
className={classes.floatingControls}
data-visible={isVisible || undefined}
gap={4}
wrap="nowrap"
onPointerEnter={startInteraction}
onPointerLeave={endInteraction}
onFocusCapture={startInteraction}
onBlurCapture={(event) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
endInteraction();
}}
>
{children}
</Group>
</>
);
};
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;