diff --git a/apps/nextjs/src/components/layout/header/configurable-header.module.css b/apps/nextjs/src/components/layout/header/configurable-header.module.css index 9fd00d423..90d7ff292 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.module.css +++ b/apps/nextjs/src/components/layout/header/configurable-header.module.css @@ -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; + } +} diff --git a/apps/nextjs/src/components/layout/header/configurable-header.tsx b/apps/nextjs/src/components/layout/header/configurable-header.tsx index 807444e7c..b9946f087 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.tsx +++ b/apps/nextjs/src/components/layout/header/configurable-header.tsx @@ -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 = ({ ) : ( - + {hasNavigation ? : null} - + )} @@ -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(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 ( + <> +
+ { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + endInteraction(); + }} + > + {children} + + + ); +}; + interface HeaderItemProps { item: HeaderItem; logo: ReactNode;