From e58c2296643dfc78c80d28d1e8afff7737a64fbd Mon Sep 17 00:00:00 2001 From: Bergasha Date: Sat, 29 Aug 2026 14:56:02 +1000 Subject: [PATCH] feat(header): add opt-in auto-hide-on-scroll for the header (#6738) * feat(header): add opt-in auto-hide-on-scroll for the header New per-user header preference (default off): when enabled, the header slides away while scrolling down and reappears on scroll-up or near the top of the page. Disabled while in board edit mode, and skipped entirely while focus is inside the header. Only applies when the header is set to visible - the existing hover-to-reveal floating controls already cover the hidden-header case. * chore(api): regenerate the checked-in OpenAPI schema check:openapi was failing in CI - the schema on disk hadn't been regenerated after the header preferences gained autoHideOnScroll. Add scripts/generate-openapi-schema.mts (and a generate:openapi package script) alongside the existing check script so this is a one-command fix next time the schema drifts. * docs(users): document the header studio's visibility and auto-hide options Header customization (drag-to-arrange controls, hide/show, hover-reveal and pin, auto-hide-on-scroll) had no documentation on the user general settings page. --- apps/docs/docs/management/users/index.mdx | 11 +++++ apps/docs/static/api/open-api-schema.json | 4 ++ .../general/_components/header-composer.tsx | 12 ++++- .../header/configurable-header.module.css | 6 +++ .../layout/header/configurable-header.tsx | 7 +++ .../layout/header/use-header-auto-hide.ts | 48 +++++++++++++++++++ package.json | 1 + packages/boards/src/edit-mode.tsx | 5 ++ packages/translation/src/lang/en.json | 4 +- packages/validation/src/header-preferences.ts | 3 ++ scripts/generate-openapi-schema.mts | 8 ++++ 11 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 apps/nextjs/src/components/layout/header/use-header-auto-hide.ts create mode 100644 scripts/generate-openapi-schema.mts diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 9c32ba56f..655308000 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -77,6 +77,17 @@ Choose the first day of the week for the user. This will affect the calendar wid Option to show icons for status check instead of colored dots. +#### Header studio + +Personal control over what appears in the header and where. Drag controls between the Left, Center, and Right +sections, add optional board shortcuts, and choose how the search and logo controls are displayed. + +The header can be hidden entirely with the visibility toggle. While hidden, hovering the top edge of the page +reveals it temporarily; clicking inside it pins it open until the page is refreshed or you click elsewhere. + +When the header is visible, the **Auto-hide on scroll** toggle makes it slide away while scrolling down and +reappear on scroll-up or near the top of the page. It has no effect while the header itself is hidden. + #### Delete This action will permanently delete the user from the database, including any preferences they set. diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 5791847a3..7ce94b623 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -4391,6 +4391,10 @@ "type": "string", "enum": ["logo", "logoAndText"] }, + "autoHideOnScroll": { + "default": false, + "type": "boolean" + }, "zones": { "type": "object", "properties": { diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx index 318e17f69..f85bc5acf 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/header-composer.tsx @@ -18,6 +18,7 @@ import { Paper, SegmentedControl, Stack, + Switch, Text, ThemeIcon, Tooltip, @@ -202,7 +203,16 @@ export const HeaderComposer = ({ value, onChange, boards, homeBoardId }: HeaderC {t("description")} - + + + onChange({ ...value, autoHideOnScroll: event.currentTarget.checked })} + /> + new Map(boards.map((board) => [board.id, board])), [boards]); + const isEditMode = useOptionalEditMode(); + const isAutoHidden = useHeaderAutoHide( + headerPreferences.visible && headerPreferences.autoHideOnScroll && !isEditMode, + ); return ( @@ -114,6 +120,7 @@ export const ConfigurableHeader = ({ zIndex="var(--homarr-z-index-board-header)" className={classes.header} data-visible={headerPreferences.visible} + data-auto-hidden={isAutoHidden || undefined} data-advanced-focus-background data-app-shell-header > diff --git a/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts b/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts new file mode 100644 index 000000000..759de931d --- /dev/null +++ b/apps/nextjs/src/components/layout/header/use-header-auto-hide.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const SCROLL_HIDE_THRESHOLD = 24; +const SCROLL_DIRECTION_HYSTERESIS = 6; + +export const useHeaderAutoHide = (enabled: boolean) => { + const [isHidden, setIsHidden] = useState(false); + const lastScrollYRef = useRef(0); + + useEffect(() => { + if (!enabled) { + setIsHidden(false); + return; + } + + lastScrollYRef.current = window.scrollY; + let frame: number | null = null; + + const handleScroll = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + const currentY = window.scrollY; + const delta = currentY - lastScrollYRef.current; + + if (currentY <= SCROLL_HIDE_THRESHOLD) { + setIsHidden(false); + } else if (delta > SCROLL_DIRECTION_HYSTERESIS) { + setIsHidden(true); + } else if (delta < -SCROLL_DIRECTION_HYSTERESIS) { + setIsHidden(false); + } + + lastScrollYRef.current = currentY; + }); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", handleScroll); + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, [enabled]); + + return enabled && isHidden; +}; diff --git a/package.json b/package.json index 477724057..22aeb6b30 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "check:custom-widgets": "pnpm --filter @homarr/custom-widgets check:architecture", "check:custom-widget-bundle": "node ./scripts/check-custom-widget-bundle.mjs", "check:openapi": "tsx ./scripts/check-openapi-schema.mts", + "generate:openapi": "tsx ./scripts/generate-openapi-schema.mts", "ci:act:all": "bash scripts/ci/act.sh all", "ci:act:cleanup": "bash scripts/ci/act.sh cleanup", "ci:act:docker": "bash scripts/ci/act.sh docker", diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 58288ce2a..7503f872f 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -26,3 +26,8 @@ export const useEditMode = () => { return context; }; + +export const useOptionalEditMode = (): boolean => { + const context = useContext(EditModeContext); + return context ? context[0] : false; +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 04dabe9f7..ccf13fd16 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5725,7 +5725,9 @@ "show": "Show the header", "configure": "Move or configure {item}", "remove": "Remove {item}", - "dropIntoZone": "Move into the {zone} section" + "dropIntoZone": "Move into the {zone} section", + "autoHideOnScroll": "Auto-hide on scroll", + "autoHideOnScrollDescription": "Slides the header away while scrolling down and brings it back when scrolling up" }, "preview": { "board": "Dashboard preview for {name}", diff --git a/packages/validation/src/header-preferences.ts b/packages/validation/src/header-preferences.ts index 1235809b5..3c2896a59 100644 --- a/packages/validation/src/header-preferences.ts +++ b/packages/validation/src/header-preferences.ts @@ -105,6 +105,7 @@ export const headerPreferencesSchema = z visible: z.boolean(), searchDisplay: z.enum(headerSearchDisplayValues), logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"), + autoHideOnScroll: z.boolean().default(false), zones: headerZonesSchema, }) .strict(); @@ -155,6 +156,7 @@ const migrateLegacyItems = ( visible, searchDisplay: "input", logoDisplay: "logoAndText", + autoHideOnScroll: false, zones: { left: zones.left.map(createBuiltinHeaderItem), center: zones.center.map(createBuiltinHeaderItem), @@ -209,6 +211,7 @@ export const defaultHeaderPreferences = { visible: true, searchDisplay: "input", logoDisplay: "logoAndText", + autoHideOnScroll: false, zones: { left: [createBuiltinHeaderItem("logo")], center: [createBuiltinHeaderItem("search")], diff --git a/scripts/generate-openapi-schema.mts b/scripts/generate-openapi-schema.mts new file mode 100644 index 000000000..4ab47e67a --- /dev/null +++ b/scripts/generate-openapi-schema.mts @@ -0,0 +1,8 @@ +import { writeFile } from "node:fs/promises"; + +import { openApiDocument } from "../packages/api/src/open-api"; + +const baseUrl = "http://localhost:3000"; +const generated = openApiDocument(baseUrl); + +await writeFile("apps/docs/static/api/open-api-schema.json", `${JSON.stringify(generated, null, 2)}\n`, "utf8");