feat: reveal floating header from corner
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user