diff --git a/com.woltlab.wcf/defaultStyle.tar b/com.woltlab.wcf/defaultStyle.tar index e519df514ac..268e6e1bc06 100644 Binary files a/com.woltlab.wcf/defaultStyle.tar and b/com.woltlab.wcf/defaultStyle.tar differ diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index 7e7e5e9fe4a..d4f63a11a66 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -74,6 +74,7 @@ because deleting the legacy condition object types cascades to `wcf1_condition`. acp/update_com.woltlab.wcf_6.3_styleTemplateGroup.php + acp/update_com.woltlab.wcf_6.3_styleVariables.php acp/database/update_com.woltlab.wcf_6.3_step2.php acp/update_com.woltlab.wcf_6.3_embeddedObjects.php diff --git a/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl b/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl index 372fbf0fc0b..9183c08f0ed 100644 --- a/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl +++ b/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl @@ -1,4 +1,5 @@ -{if ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS && !$__wcf->user->isGuest()} +{* The `system_pageHeader` renders this chooser on the server. *} +{if ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS && !$__wcf->user->isGuest() && $__wcf->getStyleHandler()->getStyle()->usesClassicPageHeader()} diff --git a/com.woltlab.wcf/templates/system_pageHeader.tpl b/com.woltlab.wcf/templates/system_pageHeader.tpl new file mode 100644 index 00000000000..60df40a0d9e --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeader.tpl @@ -0,0 +1,71 @@ +{assign var='__pageHeaderLayout' value=$__wcf->getStyleHandler()->getStyle()->getVariable('pageHeaderLayout')} +
+ {if $__pageHeaderLayout === 'logoTop'} +
+
+ {include file='system_pageHeaderLogo'} +
+
+ {/if} + + + + {if $__pageHeaderLayout === 'logoBelow'} +
+
+ {include file='system_pageHeaderLogo'} +
+
+ {/if} + + {hascontent} +
+
+
+ {content} + {if !$boxesHero|empty} + {unsafe:$boxesHero} + {/if} + + {foreach from=$__wcf->getBoxHandler()->getBoxes('hero') item=box} + {unsafe:$box->render()} + {/foreach} + {/content} +
+
+
+ {/hascontent} +
diff --git a/com.woltlab.wcf/templates/system_pageHeaderLogo.tpl b/com.woltlab.wcf/templates/system_pageHeaderLogo.tpl new file mode 100644 index 00000000000..a91a1efeb4e --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeaderLogo.tpl @@ -0,0 +1,19 @@ + diff --git a/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl b/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl new file mode 100644 index 00000000000..b5ea7ded8fe --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl @@ -0,0 +1,116 @@ +{* The variables of `__menu` are required by its template listeners, see `TemplateEngine::SYSTEM_TEMPLATE_LISTENER_ALIASES`. *} +{assign var='menuIdentifier' value='com.woltlab.wcf.MainMenu'} +{assign var='__mainMenu' value=$__wcf->getBoxHandler()->getBoxByIdentifier($menuIdentifier)->getMenu()} +{assign var='menuItemNodeList' value=$__mainMenu->getMenuItemNodeList()} +{assign var='menuTitle' value=$__mainMenu->getTitle()} + diff --git a/com.woltlab.wcf/templates/system_pageHeaderSearch.tpl b/com.woltlab.wcf/templates/system_pageHeaderSearch.tpl new file mode 100644 index 00000000000..96c0b360505 --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeaderSearch.tpl @@ -0,0 +1,70 @@ +{if $__searchTypeLabel|empty} + {capture assign='__searchTypeLabel'}{lang}wcf.search.type.{if !$__searchObjectTypeName|empty}{$__searchObjectTypeName}{else}everywhere{/if}{/lang}{/capture} +{/if} + +{if MODULE_ARTICLE && SEARCH_ENABLE_ARTICLES && $__wcf->getActivePage() != null && ($__wcf->getActivePage()->identifier == 'com.woltlab.wcf.ArticleList' || $__wcf->getActivePage()->identifier == 'com.woltlab.wcf.CategoryArticleList' || $__wcf->getActivePage()->identifier == 'com.woltlab.wcf.Article')} + {if $category|isset} + {capture assign='__searchTypeLabel'}{$category->getTitle()}{/capture} + {else} + {capture assign='__searchTypeLabel'}{lang}wcf.search.type.com.woltlab.wcf.article{/lang}{/capture} + {/if} + + {assign var='__searchObjectTypeName' value='com.woltlab.wcf.article'} + + {capture assign='__searchTypesScoped'} + {if $category|isset}
  • {$category->getTitle()}
  • {/if} + {/capture} + {assign var='__searchAreaInitialized' value=true} +{/if} + +{event name='settings'} + +
    +
    +
    + {icon name='magnifying-glass'} + + + + + +
    + + {if !$__searchStaticOptions|empty}{unsafe:$__searchStaticOptions}{/if} +
    +
    +
    + +{if (!OFFLINE || $__wcf->session->getPermission('admin.general.canViewPageDuringOfflineMode')) && (!FORCE_LOGIN || !$__wcf->user->isGuest())} + +{/if} diff --git a/com.woltlab.wcf/templates/system_pageHeaderUser.tpl b/com.woltlab.wcf/templates/system_pageHeaderUser.tpl new file mode 100644 index 00000000000..932679e6ac9 --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeaderUser.tpl @@ -0,0 +1,257 @@ + +{if !$__wcf->user->isGuest()} + + + {* The tabs and their panels are built on the client from the registered user menus. *} +
    +
    + {unsafe:$__wcf->getUserProfileHandler()->getAvatar()->getImageTag(48, false)} +
    + {$__wcf->user->username} + {if MODULE_USER_RANK && $__wcf->getUserProfileHandler()->getUserTitle()} + {$__wcf->getUserProfileHandler()->getUserTitle()} + {/if} +
    + +
    +
    +
    +
    +{/if} diff --git a/defaultStyle/variables.xml b/defaultStyle/variables.xml index 19df1be311f..1b04934cf4e 100644 --- a/defaultStyle/variables.xml +++ b/defaultStyle/variables.xml @@ -1,2 +1,4 @@ - + + + diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index f7eac678b0f..43ba568a342 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -52,6 +52,8 @@ window.__wcf_bc_eventHandler = EventHandler; export interface BoostrapOptions { dynamicColorScheme: boolean; enableMobileMenu: boolean; + /** @since 6.3 */ + enableSearch?: boolean; pageMenuMainProvider: PageMenuMainProvider; } @@ -79,6 +81,7 @@ export function setup(options: BoostrapOptions): void { { colorScheme: "light", enableMobileMenu: true, + enableSearch: true, pageMenuMainProvider: undefined, }, options, @@ -104,7 +107,9 @@ export function setup(options: BoostrapOptions): void { UiObjectAction.setup(); UiObjectActionDelete.setup(); UiObjectActionToggle.setup(); - initSearch(); + if (options.enableSearch) { + initSearch(); + } // Convert forms with `method="get"` into `method="post"` document.querySelectorAll("form[method=get]").forEach((form: HTMLFormElement) => { diff --git a/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts index c6e189dda65..23150028229 100644 --- a/ts/WoltLabSuite/Core/BootstrapFrontend.ts +++ b/ts/WoltLabSuite/Core/BootstrapFrontend.ts @@ -21,6 +21,12 @@ import { prepareRequest } from "./Ajax/Backend"; import { setup as serviceWorkerSetup } from "./Notification/ServiceWorker"; import { getArticlePopover } from "./Api/Articles/GetArticlePopover"; import { getUserPopover } from "./Api/Users/GetUserPopover"; +import { setup as setupDrawers } from "./Component/Drawer"; +import { setup as setupMainMenu } from "./Component/MainMenu"; +import { setup as setupLanguageSwitcher } from "./Component/LanguageSwitcher"; +import { setup as setupUserMenuDrawer } from "./Component/UserMenuDrawer"; +import { setup as setupPageHeaderSearch } from "./Component/PageHeaderSearch"; +import { setup as setupPageHeaderShadow } from "./Component/PageHeaderShadow"; interface BootstrapOptions { backgroundQueue: { @@ -40,6 +46,7 @@ interface BootstrapOptions { removeQuotes?: string[]; usedQuotes?: Map; reportEndpoint: string; + templateVariants: string[]; } /** @@ -78,9 +85,13 @@ export function setup(options: BootstrapOptions): void { // Modify the URL of the background queue URL to always target the current domain to avoid CORS. options.backgroundQueue.url = window.WSC_API_URL + options.backgroundQueue.url.substr(window.WCF_PATH.length); + // The `system_pageHeader` renders the mobile menus on the server. + const hasSystemPageHeader = options.templateVariants.includes("pageHeader"); + Bootstrap.setup({ dynamicColorScheme: options.dynamicColorScheme, - enableMobileMenu: true, + enableMobileMenu: !hasSystemPageHeader, + enableSearch: !hasSystemPageHeader, pageMenuMainProvider: new UiPageMenuMainFrontend(), }); @@ -97,7 +108,16 @@ export function setup(options: BootstrapOptions): void { }); } - UiPageHeaderMenu.init(); + if (hasSystemPageHeader) { + setupDrawers(); + setupMainMenu(); + setupLanguageSwitcher(); + setupUserMenuDrawer(); + setupPageHeaderSearch(); + setupPageHeaderShadow(); + } else { + UiPageHeaderMenu.init(); + } if (options.styleChanger) { void import("./Controller/Style/Changer").then((ControllerStyleChanger) => { diff --git a/ts/WoltLabSuite/Core/Component/Drawer.ts b/ts/WoltLabSuite/Core/Component/Drawer.ts new file mode 100644 index 00000000000..b42c556ce20 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Drawer.ts @@ -0,0 +1,197 @@ +/** + * Opens and closes drawers, the modal panels the page header uses on small screens. + * + * Openers reference a drawer through `data-drawer-target=""`, elements with + * `data-drawer-close` inside a drawer close it. The state is exposed as `data-open` + * on the drawer and as `aria-expanded` on its openers, the visibility is up to CSS. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { createFocusTrap, FocusTrap } from "focus-trap"; +import UiCloseOverlay, { Origin } from "../Ui/CloseOverlay"; +import { on as onMediaQuery, pageOverlayClose, pageOverlayOpen, scrollDisable, scrollEnable } from "../Ui/Screen"; + +type DrawerState = { + backdrop: HTMLElement; + focusTrap: FocusTrap; + opener: HTMLElement | undefined; +}; + +export type OpenOptions = { + /** Receives the focus when the drawer closes. */ + opener?: HTMLElement; + /** Passed as `detail` of the `drawer:open` event. */ + detail?: unknown; +}; + +const drawers = new Map(); +let initialized = false; + +function getDrawer(id: string): HTMLElement { + const drawer = document.getElementById(id); + if (drawer === null) { + throw new Error(`Unable to find the drawer '${id}'.`); + } + + return drawer; +} + +function getState(drawer: HTMLElement): DrawerState { + let state = drawers.get(drawer); + if (state === undefined) { + const backdrop = document.createElement("div"); + backdrop.classList.add("drawerBackdrop"); + backdrop.hidden = true; + backdrop.addEventListener("click", () => close(drawer.id)); + drawer.before(backdrop); + + drawer.tabIndex = -1; + drawer.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + event.preventDefault(); + close(drawer.id); + } + }); + + const newState: DrawerState = { + backdrop, + focusTrap: createFocusTrap(drawer, { + allowOutsideClick: true, + escapeDeactivates: false, + fallbackFocus: drawer, + setReturnFocus: (previousActiveElement) => newState.opener ?? previousActiveElement, + }), + opener: undefined, + }; + + drawers.set(drawer, newState); + state = newState; + } + + return state; +} + +function setExpanded(id: string, expanded: boolean): void { + document.querySelectorAll(`[data-drawer-target="${CSS.escape(id)}"]`).forEach((opener) => { + opener.setAttribute("aria-expanded", expanded ? "true" : "false"); + }); +} + +function closeAll(): void { + drawers.forEach((_state, drawer) => { + close(drawer.id); + }); +} + +export function isOpen(id: string): boolean { + return document.getElementById(id)?.hasAttribute("data-open") ?? false; +} + +export function open(id: string, options: OpenOptions = {}): void { + const drawer = getDrawer(id); + if (drawer.hasAttribute("data-open")) { + return; + } + + // Closes dropdowns, the search and other drawers. + UiCloseOverlay.execute(); + + const state = getState(drawer); + state.opener = options.opener; + + drawer.setAttribute("data-open", ""); + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-modal", "true"); + state.backdrop.hidden = false; + setExpanded(id, true); + + pageOverlayOpen(); + scrollDisable(); + + drawer.dispatchEvent(new CustomEvent("drawer:open", { detail: options.detail })); + + state.focusTrap.activate(); +} + +export function close(id: string): void { + const drawer = document.getElementById(id); + if (drawer === null || !drawer.hasAttribute("data-open")) { + return; + } + + const state = getState(drawer); + + drawer.removeAttribute("data-open"); + drawer.removeAttribute("role"); + drawer.removeAttribute("aria-modal"); + state.backdrop.hidden = true; + setExpanded(id, false); + + pageOverlayClose(); + scrollEnable(); + + // The focus is returned asynchronously, `state.opener` must survive until then. + state.focusTrap.deactivate(); + + drawer.dispatchEvent(new CustomEvent("drawer:close")); +} + +export function setup(): void { + if (initialized) { + return; + } + initialized = true; + + document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const opener = event.target.closest("[data-drawer-target]"); + if (opener !== null) { + event.preventDefault(); + + const id = opener.dataset.drawerTarget!; + if (isOpen(id)) { + close(id); + } else { + open(id, { opener }); + } + + return; + } + + const closeButton = event.target.closest("[data-drawer-close]"); + if (closeButton !== null) { + const drawer = closeButton.closest("[data-open]"); + if (drawer !== null) { + close(drawer.id); + } + } + }); + + // Clicks inside an open drawer bubble up to the body and must not close it, + // clicks outside of it can only reach the backdrop. Dropdowns close all other + // overlays when they open, which must spare the drawer containing them. + UiCloseOverlay.add("WoltLabSuite/Core/Component/Drawer", (origin, identifier) => { + if (origin === Origin.Document) { + return; + } + + if (origin === Origin.DropDown && identifier !== undefined) { + if (document.getElementById(identifier)?.closest("[data-open]")) { + return; + } + } + + closeAll(); + }); + + onMediaQuery("screen-lg", { + match: () => closeAll(), + }); +} diff --git a/ts/WoltLabSuite/Core/Component/LanguageSwitcher.ts b/ts/WoltLabSuite/Core/Component/LanguageSwitcher.ts new file mode 100644 index 00000000000..57848e89c65 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/LanguageSwitcher.ts @@ -0,0 +1,78 @@ +/** + * Switches the language through the language lists of the `system_pageHeader`, for guests and, + * with the developer tools enabled, for members. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { dboAction } from "../Ajax"; +import User from "../User"; + +function switchLanguage(languageId: string, languageCode: string): void { + // Multilingual content links its translations, the current page is used otherwise. + const alternate = document.querySelector(`link[hreflang="${CSS.escape(languageCode)}"]`); + const url = new URL(alternate?.href ?? window.location.href); + + // `URLSearchParams` would encode the route in the query of non-rewritten URLs, e.g. `?thread/1-foo/`. + const parameters = url.search + .slice(1) + .split("&") + .filter((parameter) => parameter !== "" && !parameter.startsWith("l=")); + parameters.push(`l=${languageId}`); + + url.search = parameters.join("&"); + url.hash = window.location.hash; + window.location.href = url.toString(); +} + +// Members can only switch the language through the developer tools. +async function switchLanguageDevtools(languageId: string, languageCode: string): Promise { + const currentLanguageCode = document.documentElement.lang; + if (languageCode === currentLanguageCode) { + window.location.reload(); + return; + } + + const alternate = document.querySelector( + `link[rel="alternate"][hreflang="${CSS.escape(languageCode)}"]`, + ); + if (alternate !== null && document.body.dataset.application === "wcf" && document.body.dataset.template === "cms") { + // Pages like the landing page share one link for every language. + const current = document.querySelector( + `link[rel="alternate"][hreflang="${CSS.escape(currentLanguageCode)}"]`, + ); + if (current === null || current.href !== alternate.href) { + window.location.href = alternate.href; + return; + } + } + + await dboAction("devtoolsSetLanguage", "wcf\\data\\user\\UserAction") + .payload({ languageID: parseInt(languageId, 10) }) + .dispatch(); + + window.location.reload(); +} + +export function setup(): void { + // The dropdown of the desktop bar moves its menu out of the header when it opens. + document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const element = event.target.closest("[data-switch-language]"); + if (element !== null) { + event.preventDefault(); + + if (User.userId) { + void switchLanguageDevtools(element.dataset.switchLanguage!, element.dataset.languageCode!); + } else { + switchLanguage(element.dataset.switchLanguage!, element.dataset.languageCode!); + } + } + }); +} diff --git a/ts/WoltLabSuite/Core/Component/MainMenu.ts b/ts/WoltLabSuite/Core/Component/MainMenu.ts new file mode 100644 index 00000000000..d25b3e17113 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/MainMenu.ts @@ -0,0 +1,272 @@ +/** + * Submenus and the overflow ("priority+") of the main menu rendered by `system_pageHeaderMenu`. + * + * Submenus are opened through the `aria-expanded` state of their toggle button. On desktop + * they also open on hover intent, items that do not fit into the bar are moved into the + * trailing overflow item, except for the item of the current page. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import UiCloseOverlay, { Origin } from "../Ui/CloseOverlay"; +import { is as isMediaQuery, on as onMediaQuery } from "../Ui/Screen"; + +const OPEN_DELAY = 150; +const CLOSE_DELAY = 300; + +let menu: HTMLElement; +let overflowItem: HTMLElement; +let overflowList: HTMLElement; +let items: HTMLElement[] = []; +let lastPointerType = ""; +let isUpdatingOverflow = false; +const timers = new WeakMap(); + +function isDesktop(): boolean { + return isMediaQuery("screen-lg"); +} + +function getToggle(item: Element): HTMLButtonElement | null { + return item.querySelector(":scope > .boxMenuToggle"); +} + +function isOpen(item: Element): boolean { + return getToggle(item)?.getAttribute("aria-expanded") === "true"; +} + +function setOpen(item: HTMLElement, open: boolean): void { + window.clearTimeout(timers.get(item)); + + const toggle = getToggle(item); + if (toggle === null) { + return; + } + + if (open) { + for (const sibling of item.parentElement!.children) { + if (sibling !== item) { + getToggle(sibling)?.setAttribute("aria-expanded", "false"); + } + } + } + + toggle.setAttribute("aria-expanded", open ? "true" : "false"); +} + +function closeAll(): void { + menu.querySelectorAll(".boxMenuToggle").forEach((toggle) => { + toggle.setAttribute("aria-expanded", "false"); + }); +} + +function scheduleOpen(item: HTMLElement, open: boolean): void { + window.clearTimeout(timers.get(item)); + timers.set( + item, + window.setTimeout(() => setOpen(item, open), open ? OPEN_DELAY : CLOSE_DELAY), + ); +} + +function setupHoverIntent(item: HTMLElement): void { + // Items that were moved into the overflow item are part of its panel and do not open on their own. + const isTopLevel = (event: PointerEvent) => + event.pointerType === "mouse" && item.parentElement === menu && isDesktop(); + + item.addEventListener("pointerenter", (event) => { + if (isTopLevel(event)) { + scheduleOpen(item, true); + } + }); + item.addEventListener("pointerleave", (event) => { + if (isTopLevel(event)) { + scheduleOpen(item, false); + } + }); + item.addEventListener("focusout", (event) => { + // Moving the focused element into or out of the overflow item blurs it, the focus is restored afterwards. + if (isUpdatingOverflow || item.parentElement !== menu || !isDesktop()) { + return; + } + + if (!(event.relatedTarget instanceof Node) || !item.contains(event.relatedTarget)) { + setOpen(item, false); + } + }); +} + +function isOverflowing(): boolean { + const { left, right } = menu.getBoundingClientRect(); + + // The toggles overlap the edge of their item, therefore the items are measured + // instead of relying on the scroll width of the menu. Both edges are checked + // because the items overflow to the left in RTL. + return Array.from(menu.children).some((item) => { + if ((item as HTMLElement).hidden) { + return false; + } + + const rect = item.getBoundingClientRect(); + return rect.left < left - 0.5 || rect.right > right + 0.5; + }); +} + +function updateOverflow(): void { + const previousOverflow = new Set(overflowList.children); + const focusedElement = document.activeElement; + + isUpdatingOverflow = true; + + // Only the items in the overflow are moved back, items that stay in place keep their focus. + let reference: Element = overflowItem; + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].parentElement !== menu) { + menu.insertBefore(items[i], reference); + } + reference = items[i]; + } + overflowItem.hidden = true; + + // The bar is only collapsed on desktop, the mobile drawer lists all items. + if (isDesktop() && isOverflowing()) { + overflowItem.hidden = false; + + const movableItems = items.filter((item) => !item.classList.contains("active")); + for (let i = movableItems.length - 1; i >= 0 && isOverflowing(); i--) { + overflowList.prepend(movableItems[i]); + } + } + + // Submenus stay open unless their item changed places, the bar is resized + // for unrelated reasons too, e.g. by a scrollbar appearing. + for (const item of items) { + if (previousOverflow.has(item) !== (item.parentElement === overflowList)) { + item.querySelectorAll(".boxMenuToggle").forEach((toggle) => { + toggle.setAttribute("aria-expanded", "false"); + }); + } + } + if (overflowItem.hidden) { + setOpen(overflowItem, false); + } + + if ( + focusedElement instanceof HTMLElement && + document.activeElement !== focusedElement && + menu.contains(focusedElement) + ) { + focusedElement.focus({ preventScroll: true }); + } + + isUpdatingOverflow = false; +} + +export function setup(): void { + const nav = document.getElementById("mainMenu"); + if (nav === null) { + return; + } + + menu = nav.querySelector(".boxMenu")!; + overflowItem = menu.querySelector(":scope > .mainMenuOverflow")!; + overflowList = overflowItem.querySelector("ol")!; + items = Array.from(menu.children).filter((item) => item !== overflowItem) as HTMLElement[]; + + for (const item of menu.children) { + if (getToggle(item) !== null) { + setupHoverIntent(item as HTMLElement); + } + } + + menu.addEventListener("pointerdown", (event) => { + lastPointerType = event.pointerType; + }); + + menu.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const toggle = event.target.closest(".boxMenuToggle"); + if (toggle !== null && menu.contains(toggle)) { + const item = toggle.parentElement!; + + // Hover intent has usually opened the submenu by the time the click arrives, a click + // with the mouse must not close it again. Clicks from the keyboard have no `detail`. + const isMouseClick = event.detail > 0 && lastPointerType === "mouse"; + if (isMouseClick && item.parentElement === menu && isDesktop()) { + setOpen(item, true); + } else { + setOpen(item, !isOpen(item)); + } + } + }); + + const settingsToggle = nav.querySelector(".mainMenuSettingsToggle"); + settingsToggle?.addEventListener("click", () => { + const list = document.getElementById(settingsToggle.getAttribute("aria-controls")!)!; + list.hidden = !list.hidden; + settingsToggle.setAttribute("aria-expanded", list.hidden ? "false" : "true"); + }); + + // Flyouts are popups that close on Escape, the expanded submenus of the drawer are not, + // there Escape closes the drawer. + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !isDesktop() || !(document.activeElement instanceof HTMLElement)) { + return; + } + + const item = document.activeElement.closest(".boxMenuHasChildren"); + if (item === null || !menu.contains(item)) { + return; + } + + const openItem = isOpen(item) ? item : item.parentElement?.closest(".boxMenuHasChildren"); + if (openItem && isOpen(openItem)) { + setOpen(openItem, false); + getToggle(openItem)!.focus(); + } + }); + + document.addEventListener("click", (event) => { + if (isDesktop() && event.target instanceof Node && !nav.contains(event.target)) { + closeAll(); + } + }); + + UiCloseOverlay.add("WoltLabSuite/Core/Component/MainMenu", (origin) => { + if (origin !== Origin.Document && isDesktop()) { + closeAll(); + } + }); + + // Submenus behave differently in the bar and in the drawer, therefore none stays open. + const resetMenu = () => { + closeAll(); + updateOverflow(); + }; + onMediaQuery("screen-lg", { + match: resetMenu, + unmatch: resetMenu, + }); + + // The bar changes its width with the viewport, the menu shrinks when other items in the bar grow. + const observer = new ResizeObserver(() => updateOverflow()); + observer.observe(nav.parentElement!); + observer.observe(nav); + void document.fonts.ready.then(() => updateOverflow()); + + updateOverflow(); + + // Apps remove the counters when their content is marked as read. + const menuButton = document.querySelector(".pageHeaderMenuMobile"); + if (menuButton !== null) { + const refreshUnread = () => { + menuButton.toggleAttribute("data-has-unread", nav.querySelector(".boxMenuLinkOutstandingItems") !== null); + }; + new MutationObserver(refreshUnread).observe(menu, { childList: true, subtree: true }); + refreshUnread(); + } +} diff --git a/ts/WoltLabSuite/Core/Component/PageHeaderSearch.ts b/ts/WoltLabSuite/Core/Component/PageHeaderSearch.ts new file mode 100644 index 00000000000..a642d98fc83 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/PageHeaderSearch.ts @@ -0,0 +1,147 @@ +/** + * Opens the search of the `system_pageHeader` in place of the main menu. + * + * The suggestions, the search scope and the submission are handled by `Ui/Search/Page`. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import UiCloseOverlay, { Origin } from "../Ui/CloseOverlay"; +import UiDropdownSimple from "../Ui/Dropdown/Simple"; +import DomUtil from "../Dom/Util"; + +let header: HTMLElement; +let search: HTMLElement; +let input: HTMLInputElement; +let button: HTMLAnchorElement; + +function isOpen(): boolean { + return header.classList.contains("searchBarOpen"); +} + +function open(): void { + if (isOpen()) { + return; + } + + UiCloseOverlay.execute(Origin.Search); + + header.classList.add("searchBarOpen"); + button.setAttribute("aria-expanded", "true"); + button.querySelector("fa-icon")!.setIcon("xmark"); + + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); +} + +function close(returnFocus: boolean): void { + if (!isOpen()) { + return; + } + + header.classList.remove("searchBarOpen"); + button.setAttribute("aria-expanded", "false"); + button.querySelector("fa-icon")!.setIcon("magnifying-glass"); + + if (returnFocus) { + button.focus(); + } +} + +export function setup(): void { + const element = document.getElementById("pageHeaderSearch"); + if (element === null) { + return; + } + + search = element; + header = document.getElementById("pageHeader")!; + input = document.getElementById("pageHeaderSearchInput") as HTMLInputElement; + button = document.getElementById("userPanelSearchButton") as HTMLAnchorElement; + + button.addEventListener("click", (event) => { + event.preventDefault(); + + if (isOpen()) { + close(true); + } else { + open(); + } + }); + + // The link has the role of a button, which is activated by the space key too. + button.addEventListener("keydown", (event) => { + if (event.key === " ") { + event.preventDefault(); + button.click(); + } + }); + + const searchType = search.querySelector(".pageHeaderSearchType")!; + const searchTypeId = DomUtil.identify(searchType); + const dropdownIds = [DomUtil.identify(input.parentElement!), searchTypeId]; + + // The scope list is moved out of the search when it opens. + UiDropdownSimple.getDropdownMenu(searchTypeId)?.addEventListener("click", (event) => { + if (event.target instanceof Element && event.target.closest("a[data-object-type]") !== null) { + input.focus(); + } + }); + + // The suggestions and the scope list live outside the search, Escape closes them before the search. + search.addEventListener("keydown", (event) => { + if (event.key !== "Escape") { + return; + } + + event.preventDefault(); + + const openDropdownId = dropdownIds.find((id) => UiDropdownSimple.isOpen(id)); + if (openDropdownId !== undefined) { + UiDropdownSimple.close(openDropdownId); + } else { + close(true); + } + }); + + // `UiCloseOverlay` does not expose the target of a click, which is needed to spare the dropdowns of the search. + document.addEventListener("click", (event) => { + if (!isOpen()) { + return; + } + + const target = event.target; + if (!(target instanceof Node) || !target.isConnected) { + return; + } + + if (search.contains(target) || button.contains(target)) { + return; + } + + if (dropdownIds.some((id) => UiDropdownSimple.getDropdownMenu(id)?.contains(target))) { + return; + } + + close(false); + }); + + // The suggestions and the scope selection are dropdowns that must not close the search. + UiCloseOverlay.add("WoltLabSuite/Core/Component/PageHeaderSearch", (origin, identifier) => { + if (origin === Origin.Document || origin === Origin.Search) { + return; + } + + if (origin === Origin.DropDown && identifier !== undefined) { + const dropdown = document.getElementById(identifier); + if (dropdown !== null && search.contains(dropdown)) { + return; + } + } + + close(false); + }); +} diff --git a/ts/WoltLabSuite/Core/Component/PageHeaderShadow.ts b/ts/WoltLabSuite/Core/Component/PageHeaderShadow.ts new file mode 100644 index 00000000000..76889fd1092 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/PageHeaderShadow.ts @@ -0,0 +1,28 @@ +/** + * Marks the bar of the `system_pageHeader` with `data-stuck` while it sticks to the top, + * the styles show a shadow then. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +export function setup(): void { + const header = document.getElementById("pageHeader"); + // The auth flow keeps the classic header, which does not stick. + if (header === null || header.classList.contains("pageHeader--authFlow")) { + return; + } + + // CSS cannot tell a stuck element apart, a sentinel right above the bar leaves the + // viewport exactly when the bar starts to stick. + const sentinel = document.createElement("div"); + sentinel.classList.add("pageHeaderSentinel"); + sentinel.setAttribute("aria-hidden", "true"); + header.before(sentinel); + + new IntersectionObserver(([entry]) => { + header.toggleAttribute("data-stuck", !entry.isIntersecting && entry.boundingClientRect.top < 0); + }).observe(sentinel); +} diff --git a/ts/WoltLabSuite/Core/Component/UserMenuDrawer.ts b/ts/WoltLabSuite/Core/Component/UserMenuDrawer.ts new file mode 100644 index 00000000000..2f3704c1509 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/UserMenuDrawer.ts @@ -0,0 +1,253 @@ +/** + * Shows the control panel and the user menus (notifications, moderation, conversations, …) + * as tabs inside the user drawer of the `system_pageHeader` on small screens. + * + * The tabs are built on every open from the registered user menus, their panels borrow + * the elements that are shown as dropdowns on large screens. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { getContainer, getUserMenuProviders } from "../Ui/User/Menu/Manager"; +import { UserMenuProvider } from "../Ui/User/Menu/Data/Provider"; +import { getElement as getControlPanelElement } from "../Ui/User/Menu/ControlPanel"; +import { is as isMediaQuery } from "../Ui/Screen"; +import DomUtil from "../Dom/Util"; +import { open as openDrawer } from "./Drawer"; + +type OpenDetail = { + tab?: string; +}; + +type Tab = { + button: HTMLButtonElement; + origin: HTMLElement; + provider: UserMenuProvider | undefined; +}; + +const CONTROL_PANEL = "userMenu"; + +let drawer: HTMLElement; +let tabList: HTMLElement; +let panel: HTMLElement; +let tabs: Tab[] = []; +let activeTab: Tab | undefined = undefined; +let borrowed: { element: HTMLElement; parent: HTMLElement; provider: UserMenuProvider | undefined } | undefined = + undefined; + +function getProviders(): UserMenuProvider[] { + // The notifications come first, the others keep the order of the user panel. + return Array.from(getUserMenuProviders()).sort((a, b) => { + if (a.getIdentifier() === "com.woltlab.wcf.notifications") { + return -1; + } + if (b.getIdentifier() === "com.woltlab.wcf.notifications") { + return 1; + } + + return a.getPanelButton().compareDocumentPosition(b.getPanelButton()) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); +} + +function createTab(origin: HTMLElement, provider: UserMenuProvider | undefined, icon: string): Tab { + const link = origin.querySelector("a")!; + + const button = document.createElement("button"); + button.type = "button"; + button.id = DomUtil.getUniqueId(); + button.classList.add("userMenuDrawerTab"); + button.dataset.origin = origin.id; + button.setAttribute("role", "tab"); + button.setAttribute("aria-controls", panel.id); + button.setAttribute("aria-selected", "false"); + button.tabIndex = -1; + button.innerHTML = icon; + + const label = document.createElement("span"); + label.classList.add("userMenuDrawerTabLabel"); + // `jsTooltip` moves the title into `data-tooltip`. + label.textContent = link.dataset.tooltip || link.title; + button.append(label); + + const tab: Tab = { button, origin, provider }; + button.addEventListener("click", () => selectTab(tab)); + button.addEventListener("keydown", (event) => keydown(event, tab)); + + return tab; +} + +function refreshCounters(): void { + for (const { button, origin } of tabs) { + const counter = origin.querySelector(".badge")?.textContent?.trim() ?? ""; + + let badge = button.querySelector(".userMenuDrawerTabCount"); + if (counter === "") { + badge?.remove(); + } else { + if (badge === null) { + badge = document.createElement("span"); + badge.classList.add("userMenuDrawerTabCount"); + button.append(badge); + } + badge.textContent = counter; + } + } +} + +function buildTabs(): void { + tabList.innerHTML = ""; + tabs = []; + + const controlPanel = document.getElementById(CONTROL_PANEL)!; + tabs.push(createTab(controlPanel, undefined, '')); + + for (const provider of getProviders()) { + const origin = provider.getPanelButton(); + const icon = origin.querySelector("fa-icon")?.outerHTML ?? ''; + + tabs.push(createTab(origin, provider, icon)); + } + + tabList.append(...tabs.map(({ button }) => button)); + refreshCounters(); +} + +function returnBorrowedElement(): void { + if (borrowed === undefined) { + return; + } + + if (borrowed.provider !== undefined) { + // Also releases the focus trap of the view. + borrowed.provider.getView().close(); + } else { + borrowed.element.hidden = true; + } + + borrowed.parent.append(borrowed.element); + borrowed = undefined; +} + +function selectTab(tab: Tab): void { + if (tab === activeTab) { + return; + } + + returnBorrowedElement(); + + for (const { button } of tabs) { + const selected = button === tab.button; + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.tabIndex = selected ? 0 : -1; + } + panel.setAttribute("aria-labelledby", tab.button.id); + activeTab = tab; + + const element = tab.provider ? tab.provider.getView().getElement() : getControlPanelElement(); + // A view that was never opened as a dropdown is not part of the document yet. + borrowed = { element, parent: element.parentElement ?? getContainer(), provider: tab.provider }; + panel.append(element); + + if (tab.provider) { + void tab.provider.getView().open(false); + } else { + element.hidden = false; + } +} + +function keydown(event: KeyboardEvent, tab: Tab): void { + const index = tabs.indexOf(tab); + const previous = index === 0 ? tabs.length - 1 : index - 1; + const next = index === tabs.length - 1 ? 0 : index + 1; + const isRtl = document.documentElement.dir === "rtl"; + + let target: number; + switch (event.key) { + case "ArrowLeft": + target = isRtl ? next : previous; + break; + case "ArrowRight": + target = isRtl ? previous : next; + break; + case "Home": + target = 0; + break; + case "End": + target = tabs.length - 1; + break; + default: + return; + } + + event.preventDefault(); + tabs[target].button.focus(); +} + +function onOpen(detail: OpenDetail | undefined): void { + buildTabs(); + + selectTab(tabs.find(({ origin }) => origin.id === detail?.tab) ?? tabs[0]); +} + +function onClose(): void { + returnBorrowedElement(); + activeTab = undefined; +} + +export function setup(): void { + const element = document.getElementById("userMenuDrawer"); + if (element === null) { + return; + } + + drawer = element; + tabList = drawer.querySelector(".userMenuDrawerTabs")!; + panel = drawer.querySelector(".userMenuDrawerPanel")!; + panel.id = DomUtil.getUniqueId(); + + drawer.addEventListener("drawer:open", (event: CustomEvent) => onOpen(event.detail)); + drawer.addEventListener("drawer:close", () => onClose()); + + // The counters of the user panel are updated by polling and push notifications. + // The bell shows its own counter in the bar, the avatar signals the content behind the other tabs. + const avatarButton = document.querySelector(".pageHeaderUserMobile")!; + const userPanelItems = document.querySelector(".userPanelItems")!; + const refreshUnread = () => { + avatarButton.toggleAttribute( + "data-has-unread", + userPanelItems.querySelector(":scope > li:not(#userNotifications) .badge.badgeUpdate") !== null, + ); + }; + + new MutationObserver(() => { + refreshUnread(); + + if (activeTab !== undefined) { + refreshCounters(); + } + }).observe(userPanelItems, { characterData: true, childList: true, subtree: true }); + refreshUnread(); + + // The bell opens the notifications in the drawer instead of the dropdown on small screens. + const notifications = document.getElementById("userNotifications"); + notifications?.addEventListener( + "click", + (event) => { + if (isMediaQuery("screen-lg")) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + openDrawer(drawer.id, { + opener: notifications.querySelector("a")!, + detail: { tab: notifications.id } satisfies OpenDetail, + }); + }, + { capture: true }, + ); +} diff --git a/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts index c450c286c1d..f7a14e6d595 100644 --- a/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts +++ b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts @@ -354,7 +354,9 @@ function dropdownMenuKeyDown(event: KeyboardEvent): void { let target = activeItem; if ( target.childElementCount === 1 && - (target.children[0].nodeName === "SPAN" || target.children[0].nodeName === "A") + (target.children[0].nodeName === "SPAN" || + target.children[0].nodeName === "A" || + target.children[0].nodeName === "BUTTON") ) { target = target.children[0] as HTMLElement; } diff --git a/ts/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.ts b/ts/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.ts index e1615e95227..09dd3e8f269 100644 --- a/ts/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.ts +++ b/ts/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.ts @@ -45,6 +45,11 @@ function setAlignment(element: HTMLElement, referenceElement: HTMLElement): void } function close(): void { + // The element is shown elsewhere while it is not open as a dropdown, e.g. in the user drawer. + if (!button.classList.contains("open")) { + return; + } + focusTrap.deactivate(); element.hidden = true; @@ -69,17 +74,18 @@ export function setup(): void { element.addEventListener("click", (event) => event.stopPropagation()); - window.addEventListener( - "resize", - () => { - if (element.hidden) { - return; + // The element is visible without being open while it is shown in the user drawer. + let realignFrame = 0; + const scheduleRealign = () => { + window.cancelAnimationFrame(realignFrame); + realignFrame = window.requestAnimationFrame(() => { + if (button.classList.contains("open")) { + setAlignment(element, button); } - - setAlignment(element, button); - }, - { passive: true }, - ); + }); + }; + window.addEventListener("resize", scheduleRealign, { passive: true }); + window.addEventListener("scroll", scheduleRealign, { passive: true }); button.addEventListener("click", (event) => { event.preventDefault(); diff --git a/ts/WoltLabSuite/Core/Ui/User/Menu/Manager.ts b/ts/WoltLabSuite/Core/Ui/User/Menu/Manager.ts index 9eaa335d274..0d0707792b3 100644 --- a/ts/WoltLabSuite/Core/Ui/User/Menu/Manager.ts +++ b/ts/WoltLabSuite/Core/Ui/User/Menu/Manager.ts @@ -136,21 +136,24 @@ export function registerProvider(provider: UserMenuProvider): void { }); }); - // Update the position of the user menu if the browser is - // resized while the menu is visible. - window.addEventListener( - "resize", - () => { - providers.forEach((provider) => { - const button = provider.getPanelButton(); - if (button.classList.contains("open")) { - const view = getView(provider); - setAlignment(view.getElement(), button); - } - }); - }, - { passive: true }, - ); + // Update the position of the user menu if the browser is resized while the menu is + // visible, or if the page is scrolled while the header has not stuck to the top yet. + const realign = () => { + providers.forEach((provider) => { + const button = provider.getPanelButton(); + if (button.classList.contains("open")) { + const view = getView(provider); + setAlignment(view.getElement(), button); + } + }); + }; + let realignFrame = 0; + const scheduleRealign = () => { + window.cancelAnimationFrame(realignFrame); + realignFrame = window.requestAnimationFrame(realign); + }; + window.addEventListener("resize", scheduleRealign, { passive: true }); + window.addEventListener("scroll", scheduleRealign, { passive: true }); UiScreen.on("screen-md-down", { match() { diff --git a/ts/WoltLabSuite/Core/Ui/User/Menu/View.ts b/ts/WoltLabSuite/Core/Ui/User/Menu/View.ts index 1177ae0f847..94b68caf4da 100644 --- a/ts/WoltLabSuite/Core/Ui/User/Menu/View.ts +++ b/ts/WoltLabSuite/Core/Ui/User/Menu/View.ts @@ -75,14 +75,19 @@ export class UserMenuView { return this.element; } - async open(): Promise { + /** + * @param activateFocusTrap is `false` when the view is embedded into a container that traps the focus itself + */ + async open(activateFocusTrap = true): Promise { const isStale = this.provider.isStale(); if (isStale) { this.reset(); } this.element.hidden = false; - this.focusTrap.activate(); + if (activateFocusTrap) { + this.focusTrap.activate(); + } if (isStale) { const data = await this.provider.getData(); diff --git a/wcfsetup/install/files/acp/style/acpStyleEditor.css b/wcfsetup/install/files/acp/style/acpStyleEditor.css index 4ec45a53246..51529c656e8 100644 --- a/wcfsetup/install/files/acp/style/acpStyleEditor.css +++ b/wcfsetup/install/files/acp/style/acpStyleEditor.css @@ -861,3 +861,77 @@ html:not(.touch) .spColorBox { padding: 1px 4px; } } + +/* ### style options ### */ +.styleOptionList { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} +.styleOption { + border: 1px solid var(--wcfContentBorderInner); + border-radius: var(--wcfBorderRadius); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 5px; + padding: 10px; +} +.styleOption:has(input:checked) { + border-color: var(--wcfInputBorderActive); + box-shadow: 0 0 0 1px var(--wcfInputBorderActive); +} +.styleOption:has(input:focus-visible) { + outline: 2px solid var(--wcfInputBorderActive); + outline-offset: 2px; +} +.styleOption__title { + align-items: baseline; + display: flex; + gap: 5px; +} +.styleOption__name { + flex: 1 1 auto; +} +.styleOption__description { + color: var(--wcfContentDimmedText); +} + +.styleOptionPreview { + color: var(--wcfContentDimmedText); + display: block; + height: auto; + margin-bottom: 5px; + max-width: 320px; + width: 100%; +} +.styleOption:has(input:checked) .styleOptionPreview { + color: var(--wcfInputBorderActive); +} +.styleOptionPreview__page { + fill: var(--wcfContentBackground); +} +.styleOptionPreview__frame { + fill: none; + stroke: currentColor; + stroke-opacity: 0.4; +} +.styleOptionPreview__facade { + fill: currentColor; + fill-opacity: 0.1; +} +.styleOptionPreview__logo { + fill: var(--wcfContentText); + fill-opacity: 0.7; +} +.styleOptionPreview__bar { + fill: currentColor; + fill-opacity: 0.8; +} +.styleOptionPreview__item { + fill: var(--wcfContentBackground); +} +.styleOptionPreview__line { + fill: currentColor; + fill-opacity: 0.2; +} diff --git a/wcfsetup/install/files/acp/templates/__styleOptionPreview_pageHeaderLayout.tpl b/wcfsetup/install/files/acp/templates/__styleOptionPreview_pageHeaderLayout.tpl new file mode 100644 index 00000000000..6790d8c4d6c --- /dev/null +++ b/wcfsetup/install/files/acp/templates/__styleOptionPreview_pageHeaderLayout.tpl @@ -0,0 +1,51 @@ + diff --git a/wcfsetup/install/files/acp/templates/styleAdd.tpl b/wcfsetup/install/files/acp/templates/styleAdd.tpl index 5607935a5c5..eeb92ded147 100644 --- a/wcfsetup/install/files/acp/templates/styleAdd.tpl +++ b/wcfsetup/install/files/acp/templates/styleAdd.tpl @@ -379,6 +379,39 @@ {* globals *}
    + {* options *} +
    +

    {lang}wcf.acp.style.option{/lang}

    + + {foreach from=$styleOptions key=variableName item=styleOption} + +
    {lang}wcf.acp.style.option.{$variableName}{/lang}
    +
    +
    + {foreach from=$styleOption[cases] item=case} + + {/foreach} +
    + {if $errorField == $variableName} + {lang}wcf.global.form.error.{$errorType}{/lang} + {/if} +
    + + {/foreach} +
    + {* layout *}

    {lang}wcf.acp.style.globals.layout{/lang}

    diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleVariables.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleVariables.php new file mode 100644 index 00000000000..033edd30479 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleVariables.php @@ -0,0 +1,44 @@ +prepare($sql); + +foreach ($styleVariables as $data) { + [$variableName, $defaultValue, $defaultValueDarkMode] = $data; + + $statement->execute([ + $variableName, + $defaultValue, + $defaultValueDarkMode, + ]); +} + +// Existing styles keep the classic page header, only an explicit value switches +// them to the new one. `IFNULL()` preserves values that are already set. +$sql = "INSERT INTO wcf1_style_variable_value + (styleID, variableID, variableValue) + SELECT style.styleID, variable.variableID, ? + FROM wcf1_style style + CROSS JOIN wcf1_style_variable variable + WHERE variable.variableName = ? + ON DUPLICATE KEY UPDATE variableValue = IFNULL(variableValue, VALUES(variableValue))"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([ + 'classic', + 'pageHeaderLayout', +]); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index 335113f277a..d37c005af59 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -64,6 +64,7 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", options = Core.extend({ colorScheme: "light", enableMobileMenu: true, + enableSearch: true, pageMenuMainProvider: undefined, }, options); XsrfToken.setup(); @@ -83,7 +84,9 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", UiObjectAction.setup(); UiObjectActionDelete.setup(); UiObjectActionToggle.setup(); - (0, Search_1.init)(); + if (options.enableSearch) { + (0, Search_1.init)(); + } // Convert forms with `method="get"` into `method="post"` document.querySelectorAll("form[method=get]").forEach((form) => { form.method = "post"; diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js index bf847739b1e..bef1e559184 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js @@ -5,7 +5,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui/User/Ignore", "./Ui/Page/Header/Menu", "./Ui/Message/UserConsent", "./Ui/Message/Share/Dialog", "./Ui/Message/Share/Providers", "./Ui/Feed/Dialog", "./User", "./Ui/Page/Menu/Main/Frontend", "./LazyLoader", "./Ajax/Backend", "./Notification/ServiceWorker", "./Api/Articles/GetArticlePopover", "./Api/Users/GetUserPopover"], function (require, exports, tslib_1, BackgroundQueue, Bootstrap, UiUserIgnore, UiPageHeaderMenu, UiMessageUserConsent, UiMessageShareDialog, Providers_1, UiFeedDialog, User_1, Frontend_1, LazyLoader_1, Backend_1, ServiceWorker_1, GetArticlePopover_1, GetUserPopover_1) { +define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui/User/Ignore", "./Ui/Page/Header/Menu", "./Ui/Message/UserConsent", "./Ui/Message/Share/Dialog", "./Ui/Message/Share/Providers", "./Ui/Feed/Dialog", "./User", "./Ui/Page/Menu/Main/Frontend", "./LazyLoader", "./Ajax/Backend", "./Notification/ServiceWorker", "./Api/Articles/GetArticlePopover", "./Api/Users/GetUserPopover", "./Component/Drawer", "./Component/MainMenu", "./Component/LanguageSwitcher", "./Component/UserMenuDrawer", "./Component/PageHeaderSearch", "./Component/PageHeaderShadow"], function (require, exports, tslib_1, BackgroundQueue, Bootstrap, UiUserIgnore, UiPageHeaderMenu, UiMessageUserConsent, UiMessageShareDialog, Providers_1, UiFeedDialog, User_1, Frontend_1, LazyLoader_1, Backend_1, ServiceWorker_1, GetArticlePopover_1, GetUserPopover_1, Drawer_1, MainMenu_1, LanguageSwitcher_1, UserMenuDrawer_1, PageHeaderSearch_1, PageHeaderShadow_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -49,9 +49,12 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui function setup(options) { // Modify the URL of the background queue URL to always target the current domain to avoid CORS. options.backgroundQueue.url = window.WSC_API_URL + options.backgroundQueue.url.substr(window.WCF_PATH.length); + // The `system_pageHeader` renders the mobile menus on the server. + const hasSystemPageHeader = options.templateVariants.includes("pageHeader"); Bootstrap.setup({ dynamicColorScheme: options.dynamicColorScheme, - enableMobileMenu: true, + enableMobileMenu: !hasSystemPageHeader, + enableSearch: !hasSystemPageHeader, pageMenuMainProvider: new Frontend_1.default(), }); if (options.removeQuotes?.length) { @@ -66,7 +69,17 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui }); }); } - UiPageHeaderMenu.init(); + if (hasSystemPageHeader) { + (0, Drawer_1.setup)(); + (0, MainMenu_1.setup)(); + (0, LanguageSwitcher_1.setup)(); + (0, UserMenuDrawer_1.setup)(); + (0, PageHeaderSearch_1.setup)(); + (0, PageHeaderShadow_1.setup)(); + } + else { + UiPageHeaderMenu.init(); + } if (options.styleChanger) { void new Promise((resolve_5, reject_5) => { require(["./Controller/Style/Changer"], resolve_5, reject_5); }).then(tslib_1.__importStar).then((ControllerStyleChanger) => { ControllerStyleChanger.setup(); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Drawer.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Drawer.js new file mode 100644 index 00000000000..e366ed8a970 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Drawer.js @@ -0,0 +1,156 @@ +/** + * Opens and closes drawers, the modal panels the page header uses on small screens. + * + * Openers reference a drawer through `data-drawer-target=""`, elements with + * `data-drawer-close` inside a drawer close it. The state is exposed as `data-open` + * on the drawer and as `aria-expanded` on its openers, the visibility is up to CSS. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "focus-trap", "../Ui/CloseOverlay", "../Ui/Screen"], function (require, exports, tslib_1, focus_trap_1, CloseOverlay_1, Screen_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.isOpen = isOpen; + exports.open = open; + exports.close = close; + exports.setup = setup; + CloseOverlay_1 = tslib_1.__importStar(CloseOverlay_1); + const drawers = new Map(); + let initialized = false; + function getDrawer(id) { + const drawer = document.getElementById(id); + if (drawer === null) { + throw new Error(`Unable to find the drawer '${id}'.`); + } + return drawer; + } + function getState(drawer) { + let state = drawers.get(drawer); + if (state === undefined) { + const backdrop = document.createElement("div"); + backdrop.classList.add("drawerBackdrop"); + backdrop.hidden = true; + backdrop.addEventListener("click", () => close(drawer.id)); + drawer.before(backdrop); + drawer.tabIndex = -1; + drawer.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + event.preventDefault(); + close(drawer.id); + } + }); + const newState = { + backdrop, + focusTrap: (0, focus_trap_1.createFocusTrap)(drawer, { + allowOutsideClick: true, + escapeDeactivates: false, + fallbackFocus: drawer, + setReturnFocus: (previousActiveElement) => newState.opener ?? previousActiveElement, + }), + opener: undefined, + }; + drawers.set(drawer, newState); + state = newState; + } + return state; + } + function setExpanded(id, expanded) { + document.querySelectorAll(`[data-drawer-target="${CSS.escape(id)}"]`).forEach((opener) => { + opener.setAttribute("aria-expanded", expanded ? "true" : "false"); + }); + } + function closeAll() { + drawers.forEach((_state, drawer) => { + close(drawer.id); + }); + } + function isOpen(id) { + return document.getElementById(id)?.hasAttribute("data-open") ?? false; + } + function open(id, options = {}) { + const drawer = getDrawer(id); + if (drawer.hasAttribute("data-open")) { + return; + } + // Closes dropdowns, the search and other drawers. + CloseOverlay_1.default.execute(); + const state = getState(drawer); + state.opener = options.opener; + drawer.setAttribute("data-open", ""); + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-modal", "true"); + state.backdrop.hidden = false; + setExpanded(id, true); + (0, Screen_1.pageOverlayOpen)(); + (0, Screen_1.scrollDisable)(); + drawer.dispatchEvent(new CustomEvent("drawer:open", { detail: options.detail })); + state.focusTrap.activate(); + } + function close(id) { + const drawer = document.getElementById(id); + if (drawer === null || !drawer.hasAttribute("data-open")) { + return; + } + const state = getState(drawer); + drawer.removeAttribute("data-open"); + drawer.removeAttribute("role"); + drawer.removeAttribute("aria-modal"); + state.backdrop.hidden = true; + setExpanded(id, false); + (0, Screen_1.pageOverlayClose)(); + (0, Screen_1.scrollEnable)(); + // The focus is returned asynchronously, `state.opener` must survive until then. + state.focusTrap.deactivate(); + drawer.dispatchEvent(new CustomEvent("drawer:close")); + } + function setup() { + if (initialized) { + return; + } + initialized = true; + document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const opener = event.target.closest("[data-drawer-target]"); + if (opener !== null) { + event.preventDefault(); + const id = opener.dataset.drawerTarget; + if (isOpen(id)) { + close(id); + } + else { + open(id, { opener }); + } + return; + } + const closeButton = event.target.closest("[data-drawer-close]"); + if (closeButton !== null) { + const drawer = closeButton.closest("[data-open]"); + if (drawer !== null) { + close(drawer.id); + } + } + }); + // Clicks inside an open drawer bubble up to the body and must not close it, + // clicks outside of it can only reach the backdrop. Dropdowns close all other + // overlays when they open, which must spare the drawer containing them. + CloseOverlay_1.default.add("WoltLabSuite/Core/Component/Drawer", (origin, identifier) => { + if (origin === CloseOverlay_1.Origin.Document) { + return; + } + if (origin === CloseOverlay_1.Origin.DropDown && identifier !== undefined) { + if (document.getElementById(identifier)?.closest("[data-open]")) { + return; + } + } + closeAll(); + }); + (0, Screen_1.on)("screen-lg", { + match: () => closeAll(), + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/LanguageSwitcher.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/LanguageSwitcher.js new file mode 100644 index 00000000000..0aba42623f0 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/LanguageSwitcher.js @@ -0,0 +1,68 @@ +/** + * Switches the language through the language lists of the `system_pageHeader`, for guests and, + * with the developer tools enabled, for members. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "../Ajax", "../User"], function (require, exports, tslib_1, Ajax_1, User_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + User_1 = tslib_1.__importDefault(User_1); + function switchLanguage(languageId, languageCode) { + // Multilingual content links its translations, the current page is used otherwise. + const alternate = document.querySelector(`link[hreflang="${CSS.escape(languageCode)}"]`); + const url = new URL(alternate?.href ?? window.location.href); + // `URLSearchParams` would encode the route in the query of non-rewritten URLs, e.g. `?thread/1-foo/`. + const parameters = url.search + .slice(1) + .split("&") + .filter((parameter) => parameter !== "" && !parameter.startsWith("l=")); + parameters.push(`l=${languageId}`); + url.search = parameters.join("&"); + url.hash = window.location.hash; + window.location.href = url.toString(); + } + // Members can only switch the language through the developer tools. + async function switchLanguageDevtools(languageId, languageCode) { + const currentLanguageCode = document.documentElement.lang; + if (languageCode === currentLanguageCode) { + window.location.reload(); + return; + } + const alternate = document.querySelector(`link[rel="alternate"][hreflang="${CSS.escape(languageCode)}"]`); + if (alternate !== null && document.body.dataset.application === "wcf" && document.body.dataset.template === "cms") { + // Pages like the landing page share one link for every language. + const current = document.querySelector(`link[rel="alternate"][hreflang="${CSS.escape(currentLanguageCode)}"]`); + if (current === null || current.href !== alternate.href) { + window.location.href = alternate.href; + return; + } + } + await (0, Ajax_1.dboAction)("devtoolsSetLanguage", "wcf\\data\\user\\UserAction") + .payload({ languageID: parseInt(languageId, 10) }) + .dispatch(); + window.location.reload(); + } + function setup() { + // The dropdown of the desktop bar moves its menu out of the header when it opens. + document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const element = event.target.closest("[data-switch-language]"); + if (element !== null) { + event.preventDefault(); + if (User_1.default.userId) { + void switchLanguageDevtools(element.dataset.switchLanguage, element.dataset.languageCode); + } + else { + switchLanguage(element.dataset.switchLanguage, element.dataset.languageCode); + } + } + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/MainMenu.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/MainMenu.js new file mode 100644 index 00000000000..6fa69fc7ef2 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/MainMenu.js @@ -0,0 +1,228 @@ +/** + * Submenus and the overflow ("priority+") of the main menu rendered by `system_pageHeaderMenu`. + * + * Submenus are opened through the `aria-expanded` state of their toggle button. On desktop + * they also open on hover intent, items that do not fit into the bar are moved into the + * trailing overflow item, except for the item of the current page. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "../Ui/CloseOverlay", "../Ui/Screen"], function (require, exports, tslib_1, CloseOverlay_1, Screen_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + CloseOverlay_1 = tslib_1.__importStar(CloseOverlay_1); + const OPEN_DELAY = 150; + const CLOSE_DELAY = 300; + let menu; + let overflowItem; + let overflowList; + let items = []; + let lastPointerType = ""; + let isUpdatingOverflow = false; + const timers = new WeakMap(); + function isDesktop() { + return (0, Screen_1.is)("screen-lg"); + } + function getToggle(item) { + return item.querySelector(":scope > .boxMenuToggle"); + } + function isOpen(item) { + return getToggle(item)?.getAttribute("aria-expanded") === "true"; + } + function setOpen(item, open) { + window.clearTimeout(timers.get(item)); + const toggle = getToggle(item); + if (toggle === null) { + return; + } + if (open) { + for (const sibling of item.parentElement.children) { + if (sibling !== item) { + getToggle(sibling)?.setAttribute("aria-expanded", "false"); + } + } + } + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + } + function closeAll() { + menu.querySelectorAll(".boxMenuToggle").forEach((toggle) => { + toggle.setAttribute("aria-expanded", "false"); + }); + } + function scheduleOpen(item, open) { + window.clearTimeout(timers.get(item)); + timers.set(item, window.setTimeout(() => setOpen(item, open), open ? OPEN_DELAY : CLOSE_DELAY)); + } + function setupHoverIntent(item) { + // Items that were moved into the overflow item are part of its panel and do not open on their own. + const isTopLevel = (event) => event.pointerType === "mouse" && item.parentElement === menu && isDesktop(); + item.addEventListener("pointerenter", (event) => { + if (isTopLevel(event)) { + scheduleOpen(item, true); + } + }); + item.addEventListener("pointerleave", (event) => { + if (isTopLevel(event)) { + scheduleOpen(item, false); + } + }); + item.addEventListener("focusout", (event) => { + // Moving the focused element into or out of the overflow item blurs it, the focus is restored afterwards. + if (isUpdatingOverflow || item.parentElement !== menu || !isDesktop()) { + return; + } + if (!(event.relatedTarget instanceof Node) || !item.contains(event.relatedTarget)) { + setOpen(item, false); + } + }); + } + function isOverflowing() { + const { left, right } = menu.getBoundingClientRect(); + // The toggles overlap the edge of their item, therefore the items are measured + // instead of relying on the scroll width of the menu. Both edges are checked + // because the items overflow to the left in RTL. + return Array.from(menu.children).some((item) => { + if (item.hidden) { + return false; + } + const rect = item.getBoundingClientRect(); + return rect.left < left - 0.5 || rect.right > right + 0.5; + }); + } + function updateOverflow() { + const previousOverflow = new Set(overflowList.children); + const focusedElement = document.activeElement; + isUpdatingOverflow = true; + // Only the items in the overflow are moved back, items that stay in place keep their focus. + let reference = overflowItem; + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].parentElement !== menu) { + menu.insertBefore(items[i], reference); + } + reference = items[i]; + } + overflowItem.hidden = true; + // The bar is only collapsed on desktop, the mobile drawer lists all items. + if (isDesktop() && isOverflowing()) { + overflowItem.hidden = false; + const movableItems = items.filter((item) => !item.classList.contains("active")); + for (let i = movableItems.length - 1; i >= 0 && isOverflowing(); i--) { + overflowList.prepend(movableItems[i]); + } + } + // Submenus stay open unless their item changed places, the bar is resized + // for unrelated reasons too, e.g. by a scrollbar appearing. + for (const item of items) { + if (previousOverflow.has(item) !== (item.parentElement === overflowList)) { + item.querySelectorAll(".boxMenuToggle").forEach((toggle) => { + toggle.setAttribute("aria-expanded", "false"); + }); + } + } + if (overflowItem.hidden) { + setOpen(overflowItem, false); + } + if (focusedElement instanceof HTMLElement && + document.activeElement !== focusedElement && + menu.contains(focusedElement)) { + focusedElement.focus({ preventScroll: true }); + } + isUpdatingOverflow = false; + } + function setup() { + const nav = document.getElementById("mainMenu"); + if (nav === null) { + return; + } + menu = nav.querySelector(".boxMenu"); + overflowItem = menu.querySelector(":scope > .mainMenuOverflow"); + overflowList = overflowItem.querySelector("ol"); + items = Array.from(menu.children).filter((item) => item !== overflowItem); + for (const item of menu.children) { + if (getToggle(item) !== null) { + setupHoverIntent(item); + } + } + menu.addEventListener("pointerdown", (event) => { + lastPointerType = event.pointerType; + }); + menu.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + const toggle = event.target.closest(".boxMenuToggle"); + if (toggle !== null && menu.contains(toggle)) { + const item = toggle.parentElement; + // Hover intent has usually opened the submenu by the time the click arrives, a click + // with the mouse must not close it again. Clicks from the keyboard have no `detail`. + const isMouseClick = event.detail > 0 && lastPointerType === "mouse"; + if (isMouseClick && item.parentElement === menu && isDesktop()) { + setOpen(item, true); + } + else { + setOpen(item, !isOpen(item)); + } + } + }); + const settingsToggle = nav.querySelector(".mainMenuSettingsToggle"); + settingsToggle?.addEventListener("click", () => { + const list = document.getElementById(settingsToggle.getAttribute("aria-controls")); + list.hidden = !list.hidden; + settingsToggle.setAttribute("aria-expanded", list.hidden ? "false" : "true"); + }); + // Flyouts are popups that close on Escape, the expanded submenus of the drawer are not, + // there Escape closes the drawer. + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !isDesktop() || !(document.activeElement instanceof HTMLElement)) { + return; + } + const item = document.activeElement.closest(".boxMenuHasChildren"); + if (item === null || !menu.contains(item)) { + return; + } + const openItem = isOpen(item) ? item : item.parentElement?.closest(".boxMenuHasChildren"); + if (openItem && isOpen(openItem)) { + setOpen(openItem, false); + getToggle(openItem).focus(); + } + }); + document.addEventListener("click", (event) => { + if (isDesktop() && event.target instanceof Node && !nav.contains(event.target)) { + closeAll(); + } + }); + CloseOverlay_1.default.add("WoltLabSuite/Core/Component/MainMenu", (origin) => { + if (origin !== CloseOverlay_1.Origin.Document && isDesktop()) { + closeAll(); + } + }); + // Submenus behave differently in the bar and in the drawer, therefore none stays open. + const resetMenu = () => { + closeAll(); + updateOverflow(); + }; + (0, Screen_1.on)("screen-lg", { + match: resetMenu, + unmatch: resetMenu, + }); + // The bar changes its width with the viewport, the menu shrinks when other items in the bar grow. + const observer = new ResizeObserver(() => updateOverflow()); + observer.observe(nav.parentElement); + observer.observe(nav); + void document.fonts.ready.then(() => updateOverflow()); + updateOverflow(); + // Apps remove the counters when their content is marked as read. + const menuButton = document.querySelector(".pageHeaderMenuMobile"); + if (menuButton !== null) { + const refreshUnread = () => { + menuButton.toggleAttribute("data-has-unread", nav.querySelector(".boxMenuLinkOutstandingItems") !== null); + }; + new MutationObserver(refreshUnread).observe(menu, { childList: true, subtree: true }); + refreshUnread(); + } + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderSearch.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderSearch.js new file mode 100644 index 00000000000..f84232c65be --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderSearch.js @@ -0,0 +1,126 @@ +/** + * Opens the search of the `system_pageHeader` in place of the main menu. + * + * The suggestions, the search scope and the submission are handled by `Ui/Search/Page`. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "../Ui/CloseOverlay", "../Ui/Dropdown/Simple", "../Dom/Util"], function (require, exports, tslib_1, CloseOverlay_1, Simple_1, Util_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + CloseOverlay_1 = tslib_1.__importStar(CloseOverlay_1); + Simple_1 = tslib_1.__importDefault(Simple_1); + Util_1 = tslib_1.__importDefault(Util_1); + let header; + let search; + let input; + let button; + function isOpen() { + return header.classList.contains("searchBarOpen"); + } + function open() { + if (isOpen()) { + return; + } + CloseOverlay_1.default.execute(CloseOverlay_1.Origin.Search); + header.classList.add("searchBarOpen"); + button.setAttribute("aria-expanded", "true"); + button.querySelector("fa-icon").setIcon("xmark"); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + function close(returnFocus) { + if (!isOpen()) { + return; + } + header.classList.remove("searchBarOpen"); + button.setAttribute("aria-expanded", "false"); + button.querySelector("fa-icon").setIcon("magnifying-glass"); + if (returnFocus) { + button.focus(); + } + } + function setup() { + const element = document.getElementById("pageHeaderSearch"); + if (element === null) { + return; + } + search = element; + header = document.getElementById("pageHeader"); + input = document.getElementById("pageHeaderSearchInput"); + button = document.getElementById("userPanelSearchButton"); + button.addEventListener("click", (event) => { + event.preventDefault(); + if (isOpen()) { + close(true); + } + else { + open(); + } + }); + // The link has the role of a button, which is activated by the space key too. + button.addEventListener("keydown", (event) => { + if (event.key === " ") { + event.preventDefault(); + button.click(); + } + }); + const searchType = search.querySelector(".pageHeaderSearchType"); + const searchTypeId = Util_1.default.identify(searchType); + const dropdownIds = [Util_1.default.identify(input.parentElement), searchTypeId]; + // The scope list is moved out of the search when it opens. + Simple_1.default.getDropdownMenu(searchTypeId)?.addEventListener("click", (event) => { + if (event.target instanceof Element && event.target.closest("a[data-object-type]") !== null) { + input.focus(); + } + }); + // The suggestions and the scope list live outside the search, Escape closes them before the search. + search.addEventListener("keydown", (event) => { + if (event.key !== "Escape") { + return; + } + event.preventDefault(); + const openDropdownId = dropdownIds.find((id) => Simple_1.default.isOpen(id)); + if (openDropdownId !== undefined) { + Simple_1.default.close(openDropdownId); + } + else { + close(true); + } + }); + // `UiCloseOverlay` does not expose the target of a click, which is needed to spare the dropdowns of the search. + document.addEventListener("click", (event) => { + if (!isOpen()) { + return; + } + const target = event.target; + if (!(target instanceof Node) || !target.isConnected) { + return; + } + if (search.contains(target) || button.contains(target)) { + return; + } + if (dropdownIds.some((id) => Simple_1.default.getDropdownMenu(id)?.contains(target))) { + return; + } + close(false); + }); + // The suggestions and the scope selection are dropdowns that must not close the search. + CloseOverlay_1.default.add("WoltLabSuite/Core/Component/PageHeaderSearch", (origin, identifier) => { + if (origin === CloseOverlay_1.Origin.Document || origin === CloseOverlay_1.Origin.Search) { + return; + } + if (origin === CloseOverlay_1.Origin.DropDown && identifier !== undefined) { + const dropdown = document.getElementById(identifier); + if (dropdown !== null && search.contains(dropdown)) { + return; + } + } + close(false); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderShadow.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderShadow.js new file mode 100644 index 00000000000..1f3e33798a2 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderShadow.js @@ -0,0 +1,30 @@ +/** + * Marks the bar of the `system_pageHeader` with `data-stuck` while it sticks to the top, + * the styles show a shadow then. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports"], function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + function setup() { + const header = document.getElementById("pageHeader"); + // The auth flow keeps the classic header, which does not stick. + if (header === null || header.classList.contains("pageHeader--authFlow")) { + return; + } + // CSS cannot tell a stuck element apart, a sentinel right above the bar leaves the + // viewport exactly when the bar starts to stick. + const sentinel = document.createElement("div"); + sentinel.classList.add("pageHeaderSentinel"); + sentinel.setAttribute("aria-hidden", "true"); + header.before(sentinel); + new IntersectionObserver(([entry]) => { + header.toggleAttribute("data-stuck", !entry.isIntersecting && entry.boundingClientRect.top < 0); + }).observe(sentinel); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/UserMenuDrawer.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/UserMenuDrawer.js new file mode 100644 index 00000000000..69068682bf7 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/UserMenuDrawer.js @@ -0,0 +1,198 @@ +/** + * Shows the control panel and the user menus (notifications, moderation, conversations, …) + * as tabs inside the user drawer of the `system_pageHeader` on small screens. + * + * The tabs are built on every open from the registered user menus, their panels borrow + * the elements that are shown as dropdowns on large screens. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "../Ui/User/Menu/Manager", "../Ui/User/Menu/ControlPanel", "../Ui/Screen", "../Dom/Util", "./Drawer"], function (require, exports, tslib_1, Manager_1, ControlPanel_1, Screen_1, Util_1, Drawer_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + Util_1 = tslib_1.__importDefault(Util_1); + const CONTROL_PANEL = "userMenu"; + let drawer; + let tabList; + let panel; + let tabs = []; + let activeTab = undefined; + let borrowed = undefined; + function getProviders() { + // The notifications come first, the others keep the order of the user panel. + return Array.from((0, Manager_1.getUserMenuProviders)()).sort((a, b) => { + if (a.getIdentifier() === "com.woltlab.wcf.notifications") { + return -1; + } + if (b.getIdentifier() === "com.woltlab.wcf.notifications") { + return 1; + } + return a.getPanelButton().compareDocumentPosition(b.getPanelButton()) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; + }); + } + function createTab(origin, provider, icon) { + const link = origin.querySelector("a"); + const button = document.createElement("button"); + button.type = "button"; + button.id = Util_1.default.getUniqueId(); + button.classList.add("userMenuDrawerTab"); + button.dataset.origin = origin.id; + button.setAttribute("role", "tab"); + button.setAttribute("aria-controls", panel.id); + button.setAttribute("aria-selected", "false"); + button.tabIndex = -1; + button.innerHTML = icon; + const label = document.createElement("span"); + label.classList.add("userMenuDrawerTabLabel"); + // `jsTooltip` moves the title into `data-tooltip`. + label.textContent = link.dataset.tooltip || link.title; + button.append(label); + const tab = { button, origin, provider }; + button.addEventListener("click", () => selectTab(tab)); + button.addEventListener("keydown", (event) => keydown(event, tab)); + return tab; + } + function refreshCounters() { + for (const { button, origin } of tabs) { + const counter = origin.querySelector(".badge")?.textContent?.trim() ?? ""; + let badge = button.querySelector(".userMenuDrawerTabCount"); + if (counter === "") { + badge?.remove(); + } + else { + if (badge === null) { + badge = document.createElement("span"); + badge.classList.add("userMenuDrawerTabCount"); + button.append(badge); + } + badge.textContent = counter; + } + } + } + function buildTabs() { + tabList.innerHTML = ""; + tabs = []; + const controlPanel = document.getElementById(CONTROL_PANEL); + tabs.push(createTab(controlPanel, undefined, '')); + for (const provider of getProviders()) { + const origin = provider.getPanelButton(); + const icon = origin.querySelector("fa-icon")?.outerHTML ?? ''; + tabs.push(createTab(origin, provider, icon)); + } + tabList.append(...tabs.map(({ button }) => button)); + refreshCounters(); + } + function returnBorrowedElement() { + if (borrowed === undefined) { + return; + } + if (borrowed.provider !== undefined) { + // Also releases the focus trap of the view. + borrowed.provider.getView().close(); + } + else { + borrowed.element.hidden = true; + } + borrowed.parent.append(borrowed.element); + borrowed = undefined; + } + function selectTab(tab) { + if (tab === activeTab) { + return; + } + returnBorrowedElement(); + for (const { button } of tabs) { + const selected = button === tab.button; + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.tabIndex = selected ? 0 : -1; + } + panel.setAttribute("aria-labelledby", tab.button.id); + activeTab = tab; + const element = tab.provider ? tab.provider.getView().getElement() : (0, ControlPanel_1.getElement)(); + // A view that was never opened as a dropdown is not part of the document yet. + borrowed = { element, parent: element.parentElement ?? (0, Manager_1.getContainer)(), provider: tab.provider }; + panel.append(element); + if (tab.provider) { + void tab.provider.getView().open(false); + } + else { + element.hidden = false; + } + } + function keydown(event, tab) { + const index = tabs.indexOf(tab); + const previous = index === 0 ? tabs.length - 1 : index - 1; + const next = index === tabs.length - 1 ? 0 : index + 1; + const isRtl = document.documentElement.dir === "rtl"; + let target; + switch (event.key) { + case "ArrowLeft": + target = isRtl ? next : previous; + break; + case "ArrowRight": + target = isRtl ? previous : next; + break; + case "Home": + target = 0; + break; + case "End": + target = tabs.length - 1; + break; + default: + return; + } + event.preventDefault(); + tabs[target].button.focus(); + } + function onOpen(detail) { + buildTabs(); + selectTab(tabs.find(({ origin }) => origin.id === detail?.tab) ?? tabs[0]); + } + function onClose() { + returnBorrowedElement(); + activeTab = undefined; + } + function setup() { + const element = document.getElementById("userMenuDrawer"); + if (element === null) { + return; + } + drawer = element; + tabList = drawer.querySelector(".userMenuDrawerTabs"); + panel = drawer.querySelector(".userMenuDrawerPanel"); + panel.id = Util_1.default.getUniqueId(); + drawer.addEventListener("drawer:open", (event) => onOpen(event.detail)); + drawer.addEventListener("drawer:close", () => onClose()); + // The counters of the user panel are updated by polling and push notifications. + // The bell shows its own counter in the bar, the avatar signals the content behind the other tabs. + const avatarButton = document.querySelector(".pageHeaderUserMobile"); + const userPanelItems = document.querySelector(".userPanelItems"); + const refreshUnread = () => { + avatarButton.toggleAttribute("data-has-unread", userPanelItems.querySelector(":scope > li:not(#userNotifications) .badge.badgeUpdate") !== null); + }; + new MutationObserver(() => { + refreshUnread(); + if (activeTab !== undefined) { + refreshCounters(); + } + }).observe(userPanelItems, { characterData: true, childList: true, subtree: true }); + refreshUnread(); + // The bell opens the notifications in the drawer instead of the dropdown on small screens. + const notifications = document.getElementById("userNotifications"); + notifications?.addEventListener("click", (event) => { + if ((0, Screen_1.is)("screen-lg")) { + return; + } + event.preventDefault(); + event.stopPropagation(); + (0, Drawer_1.open)(drawer.id, { + opener: notifications.querySelector("a"), + detail: { tab: notifications.id }, + }); + }, { capture: true }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js index 5d03c94d534..00e475d0e7d 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js @@ -303,7 +303,9 @@ define(["require", "exports", "tslib", "../../CallbackList", "../../Core", "../. event.preventDefault(); let target = activeItem; if (target.childElementCount === 1 && - (target.children[0].nodeName === "SPAN" || target.children[0].nodeName === "A")) { + (target.children[0].nodeName === "SPAN" || + target.children[0].nodeName === "A" || + target.children[0].nodeName === "BUTTON")) { target = target.children[0]; } const dropdown = _dropdowns.get(_activeTargetId); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.js index 2a3a5fc9b96..dde1528b71f 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.js @@ -37,6 +37,10 @@ define(["require", "exports", "tslib", "../../CloseOverlay", "./Manager", "focus } } function close() { + // The element is shown elsewhere while it is not open as a dropdown, e.g. in the user drawer. + if (!button.classList.contains("open")) { + return; + } focusTrap.deactivate(); element.hidden = true; button.classList.remove("open"); @@ -54,12 +58,18 @@ define(["require", "exports", "tslib", "../../CloseOverlay", "./Manager", "focus CloseOverlay_1.default.add("WoltLabSuite/Core/Ui/User/Menu/ControlPanel", () => close()); (0, Manager_1.getContainer)().append(element); element.addEventListener("click", (event) => event.stopPropagation()); - window.addEventListener("resize", () => { - if (element.hidden) { - return; - } - setAlignment(element, button); - }, { passive: true }); + // The element is visible without being open while it is shown in the user drawer. + let realignFrame = 0; + const scheduleRealign = () => { + window.cancelAnimationFrame(realignFrame); + realignFrame = window.requestAnimationFrame(() => { + if (button.classList.contains("open")) { + setAlignment(element, button); + } + }); + }; + window.addEventListener("resize", scheduleRealign, { passive: true }); + window.addEventListener("scroll", scheduleRealign, { passive: true }); button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Manager.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Manager.js index 3a5738bef75..5b55013bb14 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Manager.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/Manager.js @@ -111,9 +111,9 @@ define(["require", "exports", "tslib", "../../Alignment", "../../CloseOverlay", } }); }); - // Update the position of the user menu if the browser is - // resized while the menu is visible. - window.addEventListener("resize", () => { + // Update the position of the user menu if the browser is resized while the menu is + // visible, or if the page is scrolled while the header has not stuck to the top yet. + const realign = () => { providers.forEach((provider) => { const button = provider.getPanelButton(); if (button.classList.contains("open")) { @@ -121,7 +121,14 @@ define(["require", "exports", "tslib", "../../Alignment", "../../CloseOverlay", setAlignment(view.getElement(), button); } }); - }, { passive: true }); + }; + let realignFrame = 0; + const scheduleRealign = () => { + window.cancelAnimationFrame(realignFrame); + realignFrame = window.requestAnimationFrame(realign); + }; + window.addEventListener("resize", scheduleRealign, { passive: true }); + window.addEventListener("scroll", scheduleRealign, { passive: true }); UiScreen.on("screen-md-down", { match() { providers.forEach((provider) => { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/View.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/View.js index ce51175da38..2bbf11ed206 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/View.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/View.js @@ -62,13 +62,18 @@ define(["require", "exports", "tslib", "../../../Date/Util", "../../../StringUti getElement() { return this.element; } - async open() { + /** + * @param activateFocusTrap is `false` when the view is embedded into a container that traps the focus itself + */ + async open(activateFocusTrap = true) { const isStale = this.provider.isStale(); if (isStale) { this.reset(); } this.element.hidden = false; - this.focusTrap.activate(); + if (activateFocusTrap) { + this.focusTrap.activate(); + } if (isStale) { const data = await this.provider.getData(); this.setContent(data); diff --git a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php index b32683273f6..97f3efb2173 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php @@ -496,6 +496,13 @@ public function validate() } } + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + if ($option::tryFromString($this->variables[$variableName] ?? '') === null) { + throw new UserInputException($variableName, 'noValidSelection'); + } + } + if (!empty($this->variables['overrideScss'])) { $this->parseOverrides(); } @@ -827,6 +834,9 @@ protected function setVariables() 'wcfFontFamilyGoogle', 'wcfFontFamilyFallback', ]; + foreach (Style::getAvailableOptions() as $option) { + $this->specialVariables[] = $option::getVariableName(); + } EventHandler::getInstance()->fireAction($this, 'setVariables'); } @@ -936,6 +946,16 @@ public function assignVariables() I18nHandler::getInstance()->assignVariables(); + $styleOptions = []; + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + $styleOptions[$variableName] = [ + 'cases' => $option::cases(), + 'previewTemplate' => '__styleOptionPreview_' . $variableName, + 'selected' => $option::tryFromString($this->variables[$variableName] ?? ''), + ]; + } + WCF::getTPL()->assign([ 'action' => 'add', 'authorName' => $this->authorName, @@ -952,6 +972,7 @@ public function assignVariables() 'styleDate' => $this->styleDate, 'styleDescription' => $this->styleDescription, 'styleName' => $this->styleName, + 'styleOptions' => $styleOptions, 'styleVersion' => $this->styleVersion, 'templateGroupID' => $this->templateGroupID, 'tmpHash' => $this->tmpHash, diff --git a/wcfsetup/install/files/lib/acp/form/TemplateAddForm.class.php b/wcfsetup/install/files/lib/acp/form/TemplateAddForm.class.php index c96a5d11c9d..b1f5538d049 100644 --- a/wcfsetup/install/files/lib/acp/form/TemplateAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/TemplateAddForm.class.php @@ -129,6 +129,18 @@ protected function createForm() } }) ) + ->addValidator( + new FormFieldValidator('systemCritical', static function (TextFormField $formField) { + if (Template::isSystemCritical($formField->getSaveValue())) { + $formField->addValidationError( + new FormFieldValidationError( + 'systemCritical', + 'wcf.acp.template.name.error.systemCritical' + ) + ); + } + }) + ) ->addValidator( new FormFieldValidator('unique', function (TextFormField $formField) { $templateGroupIDFormField = $formField->getDocument()->getFormField('templateGroupID'); @@ -249,7 +261,7 @@ public function readParameters() if ($copy !== 0) { $this->copy = $copy; $this->copiedTemplate = new Template($this->copy); - if ($this->copiedTemplate->isNil()) { + if ($this->copiedTemplate->isNil() || !$this->copiedTemplate->canCopy()) { throw new IllegalLinkException(); } } diff --git a/wcfsetup/install/files/lib/data/menu/item/MenuItemNode.class.php b/wcfsetup/install/files/lib/data/menu/item/MenuItemNode.class.php index 68fe79e7be7..43b2aa86008 100644 --- a/wcfsetup/install/files/lib/data/menu/item/MenuItemNode.class.php +++ b/wcfsetup/install/files/lib/data/menu/item/MenuItemNode.class.php @@ -30,6 +30,12 @@ class MenuItemNode extends DatabaseObjectDecorator implements IObjectTreeNode */ protected bool $isActive = false; + /** + * true if this item itself represents the current page + * @since 6.3 + */ + protected bool $isCurrent = false; + /** * @inheritDoc */ @@ -79,6 +85,28 @@ public function isActiveNode(): bool return $this->isActive; } + /** + * Marks this item as the current page, and it and all its direct ancestors as active. + * + * @since 6.3 + */ + public function setIsCurrent(): void + { + $this->isCurrent = true; + + $this->setIsActive(); + } + + /** + * Returns true if this item itself represents the current page, unlike its active ancestors. + * + * @since 6.3 + */ + public function isCurrentNode(): bool + { + return $this->isCurrent; + } + /** * Returns node depth. */ diff --git a/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php b/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php index ecd9bc0156d..f3edfc93850 100644 --- a/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php +++ b/wcfsetup/install/files/lib/data/menu/item/MenuItemNodeTree.class.php @@ -121,7 +121,7 @@ public function __construct(int $menuID, ?MenuItemList $menuItemList = null, boo /** @var MenuItemNode $node */ foreach ($nodeList as $node) { if ($node->itemID === $itemIDs[$i]) { - $node->setIsActive(); + $node->setIsCurrent(); // only one effective item can be marked as active, use the first // occurrence with the highest priority and ignore everything else diff --git a/wcfsetup/install/files/lib/data/style/Style.class.php b/wcfsetup/install/files/lib/data/style/Style.class.php index bae550efdee..cf8c56ebcf9 100644 --- a/wcfsetup/install/files/lib/data/style/Style.class.php +++ b/wcfsetup/install/files/lib/data/style/Style.class.php @@ -4,6 +4,8 @@ use wcf\data\DatabaseObject; use wcf\data\ITitledObject; +use wcf\system\style\option\IStyleOption; +use wcf\system\style\option\PageHeaderLayout; use wcf\system\style\StyleCompiler; use wcf\system\WCF; use wcf\util\FileUtil; @@ -85,6 +87,15 @@ class Style extends DatabaseObject implements ITitledObject, \Stringable const DARK_MODE_PREFIX = "darkMode\0"; + /** + * Per-style options stored as style variables. + * @var list> + * @since 6.3 + */ + private const OPTIONS = [ + PageHeaderLayout::class, + ]; + /** * Returns the name of this style. */ @@ -170,6 +181,77 @@ public function getVariable(string $variableName, bool $toHex = false) return null; } + /** + * Returns the template variants this style activates, see `TemplateEngine::setTemplateVariants()`. + * + * @return list + * @since 6.3 + */ + public function getTemplateVariants(): array + { + return self::getTemplateVariantsFromVariables($this->getVariables()); + } + + /** + * Returns the template variants the given style variables activate. + * + * @param array $variables + * @return list + * @since 6.3 + */ + public static function getTemplateVariantsFromVariables(array $variables): array + { + $templateVariants = []; + foreach (self::getOptionsFromVariables($variables) as $option) { + $templateVariant = $option->getTemplateVariant(); + if ($templateVariant !== null) { + $templateVariants[] = $templateVariant; + } + } + + return $templateVariants; + } + + /** + * Returns true if this style renders the classic page header. Templates that are + * shared by both headers, such as listeners on `pageHeaderUser`, use this to adapt + * their markup without relying on the template variants. + * + * @since 6.3 + */ + public function usesClassicPageHeader(): bool + { + $variables = $this->getVariables(); + + return PageHeaderLayout::fromString($variables[PageHeaderLayout::getVariableName()]) === PageHeaderLayout::Classic; + } + + /** + * Returns the per-style options. + * + * @return list> + * @since 6.3 + */ + public static function getAvailableOptions(): array + { + return self::OPTIONS; + } + + /** + * Returns the selected value of each option in `self::OPTIONS`. + * + * @param array $variables + * @return list + * @since 6.3 + */ + public static function getOptionsFromVariables(array $variables): array + { + return \array_map( + static fn(string $option) => $option::fromString($variables[$option::getVariableName()]), + self::OPTIONS + ); + } + /** * @since 5.4 */ diff --git a/wcfsetup/install/files/lib/data/style/StyleAction.class.php b/wcfsetup/install/files/lib/data/style/StyleAction.class.php index b6e6328001f..dbcb5f977fc 100644 --- a/wcfsetup/install/files/lib/data/style/StyleAction.class.php +++ b/wcfsetup/install/files/lib/data/style/StyleAction.class.php @@ -190,6 +190,13 @@ private function setStyleVariables(Style $style, bool $isDarkMode): void $supportsDarkMode = Style::getVariablesWithDarkModeSupport(); + // Options are always stored explicitly. A style relying on the default + // would switch when the default changes and lose its value on export. + $optionVariables = \array_map( + static fn(string $option) => $option::getVariableName(), + Style::getAvailableOptions() + ); + $sql = "SELECT variableID, variableName, defaultValue, defaultValueDarkMode FROM wcf1_style_variable"; $statement = WCF::getDB()->prepare($sql); @@ -212,7 +219,10 @@ private function setStyleVariables(Style $style, bool $isDarkMode): void } $value = null; - if ($this->parameters['variables'][$variableName] !== $compareAgainst) { + if ( + $this->parameters['variables'][$variableName] !== $compareAgainst + || \in_array($variableName, $optionVariables, true) + ) { $value = $this->parameters['variables'][$variableName]; } diff --git a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php index 0157ab04bf9..31b8afb30f4 100644 --- a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php +++ b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php @@ -22,6 +22,7 @@ use wcf\system\Regex; use wcf\system\style\exception\FontDownloadFailed; use wcf\system\style\FontManager; +use wcf\system\style\option\IStyleOption; use wcf\system\style\StyleCompiler; use wcf\system\style\StyleHandler; use wcf\system\WCF; @@ -590,6 +591,11 @@ public static function import(string $filename, int $packageID = 1, ?self $style continue; } + $templateName = \str_replace('.tpl', '', $template['filename']); + if (Template::isSystemCritical($templateName)) { + continue; + } + // The filename originates from the archive and must not be // able to escape the template group of this style. $targetFile = FileUtil::getRealPath($templatesDir . $template['filename']); @@ -599,8 +605,6 @@ public static function import(string $filename, int $packageID = 1, ?self $style $templatesTar->extract($template['index'], $targetFile); - $templateName = \str_replace('.tpl', '', $template['filename']); - if (isset($knownTemplates[Package::getAbbreviation($package) . '-' . $templateName])) { $knownTemplates[Package::getAbbreviation($package) . '-' . $templateName]->update([ 'lastModificationTime' => \TIME_NOW, @@ -634,8 +638,20 @@ public static function import(string $filename, int $packageID = 1, ?self $style $duplicateLogo = true; } + /** @var array $importedOptions */ + $importedOptions = []; + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + $importedOptions[$variableName] = $option::tryFromString($styleData['variables'][$variableName] ?? ''); + } + // save style if ($style === null) { + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + $styleData['variables'][$variableName] = ($importedOptions[$variableName] ?? $option::getFallback())->toString(); + } + $styleData['packageID'] = $packageID; $style = new self(self::create($styleData)); @@ -680,6 +696,20 @@ public static function import(string $filename, int $packageID = 1, ?self $style $overrideScss['custom'] ); + // The installed value is kept unless it is deprecated, which lets + // an update move the style off a deprecated value. + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + $importedOption = $importedOptions[$variableName]; + if ( + $importedOption !== null + && !$importedOption->isDeprecated() + && $option::fromString($variables[$variableName])->isDeprecated() + ) { + $variables[$variableName] = $importedOption->toString(); + } + } + // Import variables for the dark mode if the style previously had none. if ($style->hasDarkMode === 0 && $styleData['hasDarkMode'] !== 0) { foreach ($styleData['variablesDarkMode'] as $k => $v) { diff --git a/wcfsetup/install/files/lib/data/template/Template.class.php b/wcfsetup/install/files/lib/data/template/Template.class.php index 182bc8df7cf..b6aca81719f 100644 --- a/wcfsetup/install/files/lib/data/template/Template.class.php +++ b/wcfsetup/install/files/lib/data/template/Template.class.php @@ -106,12 +106,14 @@ public function canCopy() /** * Returns true if current template is considered system critical and - * may not be customized at any point. + * may not be customized at any point. This includes every template whose + * name starts with `system_`. * * @return bool */ public static function isSystemCritical(string $templateName) { - return \in_array($templateName, self::$systemCriticalTemplates, true); + return \str_starts_with($templateName, 'system_') + || \in_array($templateName, self::$systemCriticalTemplates, true); } } diff --git a/wcfsetup/install/files/lib/data/template/TemplateEditor.class.php b/wcfsetup/install/files/lib/data/template/TemplateEditor.class.php index 2eae2cf59cf..63e71651ff0 100644 --- a/wcfsetup/install/files/lib/data/template/TemplateEditor.class.php +++ b/wcfsetup/install/files/lib/data/template/TemplateEditor.class.php @@ -143,7 +143,7 @@ public function deleteCompiledFiles() DirectoryUtil::getInstance(\WCF_DIR . 'templates/compiled/') ->removePattern( new Regex( - $this->templateGroupID . '_' . $this->application . '_.*_' . \preg_quote($this->templateName) . '.php$' + $this->templateGroupID . '(-[a-zA-Z0-9.]+)?_' . $this->application . '_.*_' . \preg_quote($this->templateName) . '.php$' ) ); diff --git a/wcfsetup/install/files/lib/system/gridView/admin/TemplateGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/TemplateGridView.class.php index 5f75e23a040..b6e9a72a53a 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/TemplateGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/TemplateGridView.class.php @@ -97,8 +97,10 @@ public function render(mixed $value, DatabaseObject $row, bool $isPrimaryColumn if ($row->templateGroupID !== null) { $link = LinkHandler::getInstance()->getControllerLink(TemplateEditForm::class, ['object' => $row]); - } else { + } elseif ($row->canCopy()) { $link = LinkHandler::getInstance()->getControllerLink(TemplateAddForm::class, ['copy' => $row->templateID]); + } else { + return $value; } return \sprintf( diff --git a/wcfsetup/install/files/lib/system/interaction/admin/TemplateInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/TemplateInteractions.class.php index 707c0edc59a..5fe55768953 100644 --- a/wcfsetup/install/files/lib/system/interaction/admin/TemplateInteractions.class.php +++ b/wcfsetup/install/files/lib/system/interaction/admin/TemplateInteractions.class.php @@ -31,7 +31,12 @@ public function __construct() } $this->addInteractions([ - new class("copy", TemplateAddForm::class, "wcf.acp.template.copy") extends LinkInteraction { + new class( + "copy", + TemplateAddForm::class, + "wcf.acp.template.copy", + static fn(Template $template) => $template->canCopy() + ) extends LinkInteraction { #[\Override] protected function getLink(DatabaseObject $object): string { diff --git a/wcfsetup/install/files/lib/system/package/ACPTemplatesFileHandler.class.php b/wcfsetup/install/files/lib/system/package/ACPTemplatesFileHandler.class.php index 2a55b2ea74a..1afa0cdaa1e 100644 --- a/wcfsetup/install/files/lib/system/package/ACPTemplatesFileHandler.class.php +++ b/wcfsetup/install/files/lib/system/package/ACPTemplatesFileHandler.class.php @@ -45,6 +45,12 @@ public function checkFiles(array $files) return \pathinfo($file, \PATHINFO_FILENAME); }, $files); + foreach ($files as $file) { + if (\str_starts_with($file, 'system_')) { + throw new SystemException("The template prefix 'system_' is reserved for WoltLab Suite Core. (Package '" . $this->packageInstallation->getPackage()->package . "' tries to install template '" . $file . "')"); + } + } + // get by other packages registered files $conditions = new PreparedStatementConditionBuilder(); $conditions->add('packageID <> ?', [$this->packageInstallation->getPackageID()]); diff --git a/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php b/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php index e8c10c24cdc..bcace2b3b0c 100644 --- a/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php +++ b/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php @@ -14,6 +14,8 @@ use wcf\system\event\EventHandler; use wcf\system\exception\SystemException; use wcf\system\SingletonFactory; +use wcf\system\style\option\IStyleOption; +use wcf\system\style\option\PageHeaderLayout; use wcf\command\style\CreateManifest; use wcf\system\WCF; use wcf\util\FileUtil; @@ -31,10 +33,10 @@ final class StyleCompiler extends SingletonFactory { /** - * Contains all files, which are compiled for a style. - * @var string[]|null + * Contains all files, which are compiled for a style, grouped by the page header stylesheet. + * @var array */ - private ?array $files = null; + private array $files = []; /** * names of option types which are supported as additional variables @@ -119,6 +121,8 @@ public function testStyle( array $variables, ?string $customCustomSCSSFile = null, ): ?\Exception { + $optionStylesheets = self::getOptionStylesheets($variables); + $individualScss = ''; if (isset($variables['individualScss'])) { $individualScss = $variables['individualScss']; @@ -166,7 +170,7 @@ public function testStyle( $parameters = ['scss' => '']; EventHandler::getInstance()->fireAction($this, 'compile', $parameters); - $files = $this->getFiles(); + $files = $this->getFiles($optionStylesheets); if ($customCustomSCSSFile !== null) { if (($customSCSSFileKey = \array_search(\WCF_DIR . self::FILE_GLOBAL_VALUES, $files, true)) !== false) { @@ -211,13 +215,15 @@ public function testStyle( /** * Returns a array with all files, which should be compiled for a style. * + * @param list $optionStylesheets * @return string[] * @since 5.3 */ - private function getFiles(): array + private function getFiles(array $optionStylesheets): array { - if ($this->files === null) { - $files = $this->getCoreFiles(); + $key = \implode(',', $optionStylesheets); + if (!isset($this->files[$key])) { + $files = $this->getCoreFiles($optionStylesheets); // read stylesheets in dependency order $sql = "SELECT filename, application @@ -244,10 +250,10 @@ private function getFiles(): array $files[] = \WCF_DIR . self::FILE_GLOBAL_VALUES; } - $this->files = $files; + $this->files[$key] = $files; } - return $this->files; + return $this->files[$key]; } /** @@ -306,7 +312,7 @@ public function compile(Style $style): void $scss = "/*!\n\nstylesheet for '" . \str_replace(['*', '/'], '', $style->styleName) . "', generated on " . \gmdate('r') . " -- DO NOT EDIT\n\n*/\n"; $scss .= $this->bootstrap($variables); - foreach ($this->getFiles() as $file) { + foreach ($this->getFiles(self::getOptionStylesheets($style->getVariables())) as $file) { $scss .= $this->prepareFile($file); } $scss .= $individualScss; @@ -421,7 +427,7 @@ private function extractPreloadRequests(string $css): iterable */ public function compileACP(): void { - $files = $this->getCoreFiles(); + $files = $this->getCoreFiles([PageHeaderLayout::Classic->getStylesheet()]); // ACP uses a slightly different layout $files[] = \WCF_DIR . 'acp/style/layout.scss'; @@ -471,12 +477,27 @@ public function compileACP(): void $this->writeCss(\WCF_DIR . 'acp/style/style', $css); } + /** + * Returns the stylesheets selected by the style options, relative to `style/`. + * + * @param array $variables + * @return list + */ + private static function getOptionStylesheets(array $variables): array + { + return \array_map( + static fn(IStyleOption $option) => $option->getStylesheet(), + Style::getOptionsFromVariables($variables) + ); + } + /** * Returns a list of common stylesheets provided by the core. * + * @param list $optionStylesheets * @return string[] list of common stylesheets */ - private function getCoreFiles(): array + private function getCoreFiles(array $optionStylesheets): array { $files = []; if ($handle = \opendir(\WCF_DIR . 'style/')) { @@ -505,6 +526,12 @@ private function getCoreFiles(): array \closedir($handle); + // Subdirectories are skipped above. Sorting places the selected files + // next to the files of their top-level directory. + foreach ($optionStylesheets as $optionStylesheet) { + $files[] = \WCF_DIR . 'style/' . $optionStylesheet; + } + // Directory order is not deterministic in some cases, // also the `darkMode.scss` must be at the end. \usort($files, static function (string $a, string $b) { diff --git a/wcfsetup/install/files/lib/system/style/StyleHandler.class.php b/wcfsetup/install/files/lib/system/style/StyleHandler.class.php index 6c5a0b384e9..904be995343 100644 --- a/wcfsetup/install/files/lib/system/style/StyleHandler.class.php +++ b/wcfsetup/install/files/lib/system/style/StyleHandler.class.php @@ -118,6 +118,7 @@ public function changeStyle(int $styleID = 0, bool $ignorePermissions = false) // set template group id WCF::getTPL()->setTemplateGroupID($this->style->templateGroupID ?? 0); + WCF::getTPL()->setTemplateVariants($this->style->getTemplateVariants()); } /** diff --git a/wcfsetup/install/files/lib/system/style/option/IStyleOption.class.php b/wcfsetup/install/files/lib/system/style/option/IStyleOption.class.php new file mode 100644 index 00000000000..5e4808c2679 --- /dev/null +++ b/wcfsetup/install/files/lib/system/style/option/IStyleOption.class.php @@ -0,0 +1,71 @@ +`, which receives the value as + * `$styleOptionValue`. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +interface IStyleOption extends \UnitEnum +{ + /** + * Returns the name of the style variable that stores this option. + */ + public static function getVariableName(): string; + + /** + * Returns the value for imported styles that do not set this option or + * set a value that is not valid for it, typically styles that predate + * the option. + */ + public static function getFallback(): static; + + /** + * Returns the case for the stored value, throwing a `\ValueError` for + * values that are not valid for this option. + */ + public static function fromString(string $value): static; + + /** + * Returns the case for the stored value or `null` if it is not valid for + * this option. + */ + public static function tryFromString(string $value): ?static; + + /** + * Returns the value stored in the style variable. + */ + public function toString(): string; + + /** + * Returns true if this value is scheduled for removal in a future major + * release. + */ + public function isDeprecated(): bool; + + /** + * Returns the template variant this value activates or `null` for none. + * + * The variant name is the name of the template that is replaced by its + * `system_` counterpart, see `TemplateEngine::setTemplateVariants()`. + */ + public function getTemplateVariant(): ?string; + + /** + * Returns the stylesheet for this value, relative to `WCF_DIR . 'style/'`. + * + * The stylesheet must live in a subdirectory of a top-level style + * directory, e.g. `layout/pageHeader/system.scss`, because those are not + * picked up by `StyleCompiler::getCoreFiles()` on their own. + */ + public function getStylesheet(): string; +} diff --git a/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php b/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php new file mode 100644 index 00000000000..b7c0fb93ce8 --- /dev/null +++ b/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php @@ -0,0 +1,92 @@ + + * @since 6.3 + */ +enum PageHeaderLayout implements IStyleOption +{ + case LogoTop; + case LogoBelow; + case LogoInBar; + case Classic; + + #[\Override] + public static function getVariableName(): string + { + return 'pageHeaderLayout'; + } + + #[\Override] + public static function getFallback(): static + { + return self::Classic; + } + + #[\Override] + public static function fromString(string $value): static + { + $variant = self::tryFromString($value); + if ($variant === null) { + throw new \ValueError("Unknown value '{$value}' for '" . self::getVariableName() . "'."); + } + + return $variant; + } + + #[\Override] + public static function tryFromString(string $value): ?static + { + foreach (self::cases() as $case) { + if ($case->toString() === $value) { + return $case; + } + } + + return null; + } + + #[\Override] + public function toString(): string + { + return match ($this) { + self::Classic => 'classic', + self::LogoTop => 'logoTop', + self::LogoBelow => 'logoBelow', + self::LogoInBar => 'logoInBar', + }; + } + + #[\Override] + public function isDeprecated(): bool + { + return match ($this) { + self::Classic => true, + default => false, + }; + } + + #[\Override] + public function getTemplateVariant(): ?string + { + return match ($this) { + self::Classic => null, + default => 'pageHeader', + }; + } + + #[\Override] + public function getStylesheet(): string + { + return match ($this) { + self::Classic => 'layout/pageHeader/classic.scss', + default => 'layout/pageHeader/system.scss', + }; + } +} diff --git a/wcfsetup/install/files/lib/system/template/ACPTemplateEngine.class.php b/wcfsetup/install/files/lib/system/template/ACPTemplateEngine.class.php index d82f1fa80f9..cd0bff8e9e5 100644 --- a/wcfsetup/install/files/lib/system/template/ACPTemplateEngine.class.php +++ b/wcfsetup/install/files/lib/system/template/ACPTemplateEngine.class.php @@ -63,6 +63,18 @@ public function setTemplateGroupID(int $templateGroupID) throw new \BadMethodCallException("You may not change the template group of the acp template engine"); } + /** + * This method always throws, because template variants are not supported. + * + * @param string[] $templateVariants + * @throws \BadMethodCallException + */ + #[\Override] + public function setTemplateVariants(array $templateVariants): void + { + throw new \BadMethodCallException("You may not set template variants for the acp template engine"); + } + #[\Override] public function getTemplateListenerCode(string $templateName, string $eventName) { diff --git a/wcfsetup/install/files/lib/system/template/EmailTemplateEngine.class.php b/wcfsetup/install/files/lib/system/template/EmailTemplateEngine.class.php index 56fd7429014..427b26685cd 100644 --- a/wcfsetup/install/files/lib/system/template/EmailTemplateEngine.class.php +++ b/wcfsetup/install/files/lib/system/template/EmailTemplateEngine.class.php @@ -47,4 +47,16 @@ public function setTemplateGroupID(int $templateGroupID) { throw new \BadMethodCallException("You may not change the template group of the email template engine"); } + + /** + * This method always throws, because template variants are not supported. + * + * @param string[] $templateVariants + * @throws \BadMethodCallException + */ + #[\Override] + public function setTemplateVariants(array $templateVariants): void + { + throw new \BadMethodCallException("You may not set template variants for the email template engine"); + } } diff --git a/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php index ad8f4fe0b96..7c6e73c6403 100755 --- a/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php +++ b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php @@ -148,6 +148,16 @@ class TemplateEngine extends SingletonFactory '__messageFormSmilies' => 'shared_messageFormSmilies', ]; + /** + * Maps `system_` templates to the template whose listeners they inherit when it is + * not the name without the prefix, see `getTemplateListenerCode()`. + * @since 6.3 + */ + private const SYSTEM_TEMPLATE_LISTENER_ALIASES = [ + // The main menu was rendered by the generic menu template before. + 'system_pageHeaderMenu' => '__menu', + ]; + /** * directory used to cache previously compiled templates * @var string @@ -208,6 +218,13 @@ class TemplateEngine extends SingletonFactory */ protected $templateGroupID = 0; + /** + * sorted names of the active template variants, see `setTemplateVariants()` + * @var list + * @since 6.3 + */ + private array $templateVariants = []; + /** * all available template variables and those assigned during runtime * @var array> @@ -501,6 +518,15 @@ public function getSourceFilename(string $templateName, string $application) $templateName = TemplateEngine::SHARED_TEMPLATES[$templateName]; } + if (\in_array($templateName, $this->templateVariants, true)) { + $templateName = 'system_' . $templateName; + } + + // `system_` templates are reserved for the Core, files of the same name in other apps are ignored. + if (\str_starts_with($templateName, 'system_')) { + $application = 'wcf'; + } + if (TemplateEngine::isSharedTemplate($templateName)) { $sourceFilename = $this->getPath(TemplateEngine::getInstance()->templatePaths[$application], $templateName); } else { @@ -903,6 +929,38 @@ public function setTemplateGroupID(int $templateGroupID) $this->templateGroupID = $templateGroupID; } + /** + * Returns the sorted names of the active template variants. + * + * @return list + * @since 6.3 + */ + public function getTemplateVariants(): array + { + return $this->templateVariants; + } + + /** + * Sets the active template variants. An active variant `foo` resolves the template `foo` + * to `system_foo`. All templates are compiled under a key derived from the variants, + * because `system_foo` may be included statically into any other template. + * + * @param string[] $templateVariants + * @since 6.3 + */ + public function setTemplateVariants(array $templateVariants): void + { + $templateVariants = \array_values(\array_unique($templateVariants)); + foreach ($templateVariants as $templateVariant) { + if (!\preg_match('~^[a-zA-Z0-9]+$~', $templateVariant)) { + throw new \InvalidArgumentException("The template variant '{$templateVariant}' is invalid."); + } + } + \sort($templateVariants); + + $this->templateVariants = $templateVariants; + } + /** * Loads cached template group information. * @@ -1016,10 +1074,24 @@ protected function loadTemplateListenerCode() public function getTemplateListenerCode(string $templateName, string $eventName) { $this->loadTemplateListenerCode(); + + // The source of an active variant is compiled under the classic template name. + if (\in_array($templateName, $this->templateVariants, true)) { + $templateName = 'system_' . $templateName; + } + $listeners = []; if (isset($this->templateListeners[$templateName][$eventName])) { $listeners = $this->templateListeners[$templateName][$eventName]; } + // `system_foo` replaces `foo`, therefore the listeners of `foo` apply as well. + if (\str_starts_with($templateName, 'system_')) { + $classicTemplateName = self::SYSTEM_TEMPLATE_LISTENER_ALIASES[$templateName] + ?? \substr($templateName, \strlen('system_')); + if (isset($this->templateListeners[$classicTemplateName][$eventName])) { + $listeners = \array_merge($listeners, $this->templateListeners[$classicTemplateName][$eventName]); + } + } // Load old template listener code if ($templateName = \array_search($templateName, TemplateEngine::SHARED_TEMPLATES, true)) { if (isset($this->templateListeners[$templateName][$eventName])) { @@ -1091,7 +1163,12 @@ protected function getCompileFilePrefix(string $templateName): string if (TemplateEngine::isSharedTemplate($templateName)) { return TemplateEngine::getInstance()->compileDir . $this->getSharedTemplateGroupID(); } else { - return $this->compileDir . $this->getTemplateGroupID(); + $prefix = $this->compileDir . $this->getTemplateGroupID(); + if ($this->templateVariants !== []) { + $prefix .= '-' . \implode('.', $this->templateVariants); + } + + return $prefix; } } diff --git a/wcfsetup/install/files/style/layout/layout.scss b/wcfsetup/install/files/style/layout/layout.scss index fe1bee8ebe1..9bd830f65c8 100644 --- a/wcfsetup/install/files/style/layout/layout.scss +++ b/wcfsetup/install/files/style/layout/layout.scss @@ -37,6 +37,9 @@ body { html { overflow-y: scroll; + + /* The height of the bar that sticks to the top, sticky elements are offset by it. */ + --wcfPageHeaderHeight: 50px; } body { diff --git a/wcfsetup/install/files/style/layout/pageHeader.scss b/wcfsetup/install/files/style/layout/pageHeader/classic.scss similarity index 100% rename from wcfsetup/install/files/style/layout/pageHeader.scss rename to wcfsetup/install/files/style/layout/pageHeader/classic.scss diff --git a/wcfsetup/install/files/style/layout/pageHeader/system.scss b/wcfsetup/install/files/style/layout/pageHeader/system.scss new file mode 100644 index 00000000000..4cb4519cb88 --- /dev/null +++ b/wcfsetup/install/files/style/layout/pageHeader/system.scss @@ -0,0 +1,1181 @@ +/* The bar must stick relative to `#pageContainer`, not to this short wrapper. The auth flow keeps the + classic markup (see below), therefore the container is selected through its layout. */ +.pageHeaderContainer--logoTop, +.pageHeaderContainer--logoBelow, +.pageHeaderContainer--logoInBar { + display: contents; +} + +.pageHeader:not(.pageHeader--authFlow) { + background-color: var(--wcfHeaderMenuBackground); + color: var(--wcfHeaderMenuLink); + position: sticky; + top: 0; + transition: box-shadow 0.2s ease; + z-index: 300; + + &[data-stuck] { + box-shadow: var(--wcfBoxShadow); + } +} + +/* Leaves the viewport when the bar starts to stick, see `Component/PageHeaderShadow`. */ +.pageHeaderSentinel { + height: 1px; + margin-bottom: -1px; +} + +/* Matches the classic header, third-party styles offset sticky elements by 50px. */ +.pageHeaderPanel > .layoutBoundary { + align-items: center; + display: flex; + height: var(--wcfPageHeaderHeight); +} + +/* The child combinator skips the auth flow, which nests the facade inside its header. */ +.pageHeaderContainer > .pageHeaderFacade { + background-color: var(--wcfHeaderBackground); + color: var(--wcfHeaderText); +} + +.pageHeaderContainer > .pageHeaderFacade > .layoutBoundary { + align-items: center; + display: flex; + padding-bottom: 20px; + padding-top: 20px; +} + +.pageHeaderLogo > a { + display: block; +} + +.pageHeaderLogoLarge { + height: auto; + max-width: 100%; +} + +.pageHeaderSearch { + display: none; +} + +.pageHeaderSearchInputContainer { + align-items: center; + background-color: var(--wcfHeaderSearchBoxBackgroundActive); + border: 2px solid var(--wcfHeaderSearchBoxTextActive); + border-radius: var(--wcfBorderRadius); + box-sizing: border-box; + color: var(--wcfHeaderSearchBoxTextActive); + display: flex; + height: 40px; +} + +.pageHeaderSearchInputContainer > fa-icon { + flex: 0 0 auto; + margin-left: 10px; +} + +/* The id outranks the global styles of search fields. */ +#pageHeaderSearchInput { + appearance: none; + background-color: transparent; + border: 0; + color: var(--wcfHeaderSearchBoxTextActive); + flex: 1 1 auto; + font: inherit; + height: 100%; + min-width: 0; + padding: 0 12px 0 8px; + + &::placeholder { + color: var(--wcfHeaderSearchBoxPlaceholderActive); + } + + /* The button in the bar closes the search, a second clear button would compete with it. */ + &::-webkit-search-cancel-button { + appearance: none; + } + + /* The border of the field already marks the focus. */ + &:focus-visible { + outline: none; + } +} + +/* The search scope is a select at the end of the field. */ +.pageHeaderSearchType { + flex: 0 0 auto; + margin-right: 4px; +} + +.pageHeaderSearchType > .button { + align-items: center; + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + border: 0; + border-radius: 3px; + color: var(--wcfHeaderMenuLinkActive); + column-gap: 4px; + display: flex; + font-size: 14px; + height: 28px; + line-height: 1; + padding: 0 6px 0 10px; + white-space: nowrap; + + &:hover { + background-color: var(--wcfHeaderMenuDropdownBackgroundActive); + color: var(--wcfHeaderMenuDropdownLinkActive); + } +} + +.pageHeaderSearchType.dropdownOpen > .button { + background-color: var(--wcfHeaderMenuDropdownBackgroundActive); + color: var(--wcfHeaderMenuDropdownLinkActive); +} + +@include screen-lg { + .pageHeaderBarLogo, + .pageHeaderFacade .pageHeaderLogoSmall, + .pageHeaderMenuMobile, + .pageHeaderUserMobile, + .mainMenuMobileOnly, + .userMenuDrawer { + display: none; + } + + /* logoInBar: the mobile logo at up to 36px height */ + .pageHeaderPanel .pageHeaderLogo { + flex: 0 0 auto; + margin-right: 24px; + } + + .pageHeaderPanel .pageHeaderLogoSmall { + height: auto; + max-height: 36px; + width: auto; + } + + .mainMenu { + flex: 0 1 auto; + min-width: 0; + } + + .mainMenu .boxMenu { + --mainMenuItemHeight: 36px; + + align-items: center; + display: flex; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; + + :focus-visible { + outline: 2px solid var(--wcfHeaderMenuLinkActive); + outline-offset: 2px; + } + } + + .mainMenu .boxMenu > li { + display: flex; + flex: 0 0 auto; + position: relative; + + &[hidden] { + display: none; + } + } + + .mainMenu .boxMenu > li > .boxMenuLink { + align-items: center; + background-color: transparent; + border: 0; + border-radius: var(--wcfBorderRadius); + box-sizing: border-box; + color: var(--wcfHeaderMenuLink); + column-gap: 6px; + display: flex; + font: inherit; + height: var(--mainMenuItemHeight); + padding: 0 12px; + transition: + background-color 0.12s ease, + color 0.12s ease; + white-space: nowrap; + + &:hover { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + color: var(--wcfHeaderMenuLinkActive); + } + } + + .mainMenu .boxMenu > li.active > .boxMenuLink { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + color: var(--wcfHeaderMenuLinkActive); + } + + /* An open submenu continues the item's fill into the panel ("tray"). The tray uses the + hover fill, otherwise the item would change its colour again when the submenu opens. + The panel cannot fade in, therefore the item does not either. */ + .mainMenu .boxMenu > li:has(> .boxMenuToggle[aria-expanded="true"]) > .boxMenuLink { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + color: var(--wcfHeaderMenuLinkActive); + transition: none; + } + + /* The fill runs down to the edge of the bar, where the panel starts, so that it never + touches the filled item of the current page next to it. */ + .mainMenu .boxMenu > li:has(> .boxMenuToggle[aria-expanded="true"])::after { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + content: ""; + height: calc((var(--wcfPageHeaderHeight) - var(--mainMenuItemHeight)) / 2); + left: 0; + position: absolute; + right: 0; + top: 100%; + } + + /* The toggle is a real button in the tab order, but only visible once it has the keyboard focus. + It overlaps the item's edge, therefore revealing it never shifts the layout. */ + .mainMenu .boxMenuToggle { + align-items: center; + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + border: 0; + border-radius: 50%; + clip-path: inset(50%); + color: var(--wcfHeaderMenuLinkActive); + display: flex; + height: 20px; + justify-content: center; + margin-top: -10px; + opacity: 0; + padding: 0; + position: absolute; + right: -14px; + top: 50%; + width: 20px; + z-index: 1; + + &:focus-visible { + clip-path: none; + opacity: 1; + } + + fa-icon { + transition: transform 0.16s ease; + } + + &[aria-expanded="true"] fa-icon { + transform: rotate(180deg); + } + } + + /* "More" is a toggle with a visible label. */ + .mainMenu .mainMenuOverflowToggle { + clip-path: none; + cursor: pointer; + height: var(--mainMenuItemHeight); + margin-top: 0; + opacity: 1; + position: static; + width: auto; + } + + @media (pointer: coarse) { + .mainMenu .boxMenu > li.boxMenuHasChildren:not(.mainMenuOverflow) > .boxMenuLink { + padding-right: 36px; + } + + .mainMenu .boxMenuToggle { + clip-path: none; + opacity: 1; + right: 6px; + } + } + + /* `ui/wsc31.scss` adds a shadow through `.mainMenu .boxMenu .boxMenuDepth1`, which must be outranked. */ + .mainMenu .boxMenu > .boxMenuHasChildren > .boxMenuDepth1 { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + border-radius: 0 var(--wcfBorderRadius) var(--wcfBorderRadius) var(--wcfBorderRadius); + box-shadow: rgb(0 0 0 / 18%) 0 10px 20px -4px; + display: none; + left: 0; + list-style: none; + margin: 0; + min-width: 176px; + padding: 4px; + position: absolute; + top: calc(100% + (var(--wcfPageHeaderHeight) - var(--mainMenuItemHeight)) / 2); + z-index: 1; + + :focus-visible { + outline-color: var(--wcfHeaderMenuDropdownLinkActive); + outline-offset: -2px; + } + } + + .mainMenu .boxMenuDepth1 .boxMenuLink { + align-items: center; + border-radius: var(--wcfBorderRadius); + color: var(--wcfHeaderMenuLinkActive); + column-gap: 6px; + display: flex; + min-height: 34px; + padding: 0 8px; + white-space: nowrap; + + &:hover { + background-color: var(--wcfHeaderMenuDropdownBackgroundActive); + color: var(--wcfHeaderMenuDropdownLinkActive); + } + } + + /* Deeper levels are indented inside the panel, there is no second flyout. */ + .mainMenu .boxMenuDepth1 .boxMenuToggle { + display: none; + } + + .mainMenu .boxMenuDepth1 ol { + list-style: none; + margin: 0; + padding: 0 0 0 12px; + } + + .mainMenu .boxMenu > .boxMenuHasChildren > .boxMenuToggle[aria-expanded="true"] + .boxMenuDepth1 { + display: block; + } + + /* Submenus of items that were moved into "More" are shown inline in its panel. */ + .mainMenu .boxMenu .boxMenuDepth1 .boxMenuDepth1 { + box-shadow: none; + display: block; + } + + /* Menus are aligned to the bottom edge of their item, therefore the items span the height of the bar. */ + .userPanel { + align-self: stretch; + display: flex; + flex: 0 0 auto; + margin-left: auto; + padding-left: 24px; + } + + .userPanelItems { + column-gap: 4px; + display: flex; + list-style: none; + margin: 0; + padding: 0; + + :focus-visible { + outline: 2px solid var(--wcfHeaderMenuLinkActive); + outline-offset: 2px; + } + } + + .userPanelItems > li { + align-items: center; + display: flex; + position: relative; + + &[hidden] { + display: none; + } + } + + .userPanelItems > li > :is(a, button) { + align-items: center; + background-color: transparent; + border: 0; + border-radius: 50%; + color: var(--wcfHeaderMenuLink); + cursor: pointer; + display: flex; + font: inherit; + height: 40px; + justify-content: center; + padding: 0; + position: relative; + transition: + background-color 0.12s ease, + color 0.12s ease; + width: 40px; + + &:hover, + &[aria-expanded="true"] { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + color: var(--wcfHeaderMenuLinkActive); + } + + > span:not(.badge) { + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + overflow: hidden; + position: absolute; + width: 1px; + } + + fa-icon[size="32"] { + --font-size: 20px; + --icon-size: 24px; + } + + .userAvatarImage { + border-radius: 50%; + height: 30px; + width: 30px; + } + } + + .userPanelItems .badgeUpdate { + background-color: var(--wcfHeaderMenuLinkActive); + box-shadow: 0 0 0 2px var(--wcfHeaderMenuBackground); + color: var(--wcfHeaderMenuBackground); + position: absolute; + right: 0; + top: 2px; + } + + /* Guests: login as text, registration as a filled button. */ + #userLogin > a, + #userRegistration > a { + border-radius: var(--wcfBorderRadius); + height: 36px; + padding: 0 12px; + white-space: nowrap; + width: auto; + } + + #userRegistration > a { + background-color: var(--wcfHeaderMenuLinkActive); + color: var(--wcfHeaderMenuBackground); + font-weight: 600; + padding: 0 16px; + + &:hover { + text-decoration: underline; + text-underline-offset: 3px; + } + } + + /* The search replaces the menu while it is open, the bar keeps its height. */ + .searchBarOpen .mainMenu { + display: none; + } + + .searchBarOpen .pageHeaderSearch { + display: block; + flex: 1 1 auto; + margin-right: 16px; + min-width: 0; + } +} + +@include screen-md-down { + .pageHeaderPanel > .layoutBoundary { + column-gap: 0; + padding: 0 4px; + } + + /* The logo row is replaced by the mobile logo in the bar. */ + .pageHeaderFacade { + display: none; + } + + .pageHeaderBarLogo, + .pageHeaderPanel .pageHeaderLogo > a { + align-items: center; + display: flex; + flex: 0 0 auto; + height: 44px; + margin-left: 6px; + } + + .pageHeaderLogoSmall { + height: auto; + max-height: 32px; + max-width: 100%; + object-fit: contain; + width: auto; + } + + /* The bar keeps the bell and, for guests, the login, everything else lives in the drawers. */ + .userPanel { + margin-left: auto; + } + + .userPanelItems { + align-items: center; + display: flex; + list-style: none; + margin: 0; + padding: 0; + } + + .userPanelItems > li { + position: relative; + } + + .userPanelItems > li:not(.userPanelSearch, #userNotifications, #userLogin) { + display: none; + } + + .userPanelItems > li > :is(a, button) { + align-items: center; + background-color: transparent; + border: 0; + border-radius: 22px; + color: var(--wcfHeaderMenuLink); + display: flex; + font: inherit; + height: 44px; + justify-content: center; + min-width: 44px; + + > span:not(.badge) { + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + overflow: hidden; + position: absolute; + width: 1px; + } + + fa-icon[size="32"] { + --font-size: 20px; + --icon-size: 24px; + } + } + + #userLogin > a { + font-weight: 600; + padding: 0 12px; + } + + .userPanelItems .badgeUpdate { + background-color: var(--wcfHeaderMenuLinkActive); + box-shadow: 0 0 0 2px var(--wcfHeaderMenuBackground); + color: var(--wcfHeaderMenuBackground); + position: absolute; + right: 2px; + top: 4px; + } + + .pageHeaderMenuMobile, + .pageHeaderUserMobile { + align-items: center; + background-color: transparent; + border: 0; + border-radius: 50%; + color: var(--wcfHeaderMenuLink); + display: flex; + flex: 0 0 auto; + height: 44px; + justify-content: center; + padding: 0; + width: 44px; + + &[aria-expanded="true"] { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + color: var(--wcfHeaderMenuLinkActive); + } + + .userAvatarImage { + border-radius: 50%; + height: 30px; + width: 30px; + } + + /* Unread content inside the drawer, see `Component/MainMenu` and `Component/UserMenuDrawer`. */ + &[data-has-unread] { + position: relative; + } + + &[data-has-unread]::after { + background-color: var(--wcfHeaderMenuLinkActive); + border-radius: 50%; + box-shadow: 0 0 0 2px var(--wcfHeaderMenuBackground); + content: ""; + height: 10px; + position: absolute; + right: 5px; + top: 5px; + width: 10px; + } + } + + /* The search fills the bar while it is open, only the button to close it remains. */ + .searchBarOpen :is(.pageHeaderBarLogo, .pageHeaderLogo, .pageHeaderUserMobile, .pageHeaderMenuMobile), + .searchBarOpen .userPanelItems > li:not(.userPanelSearch) { + display: none; + } + + .searchBarOpen .pageHeaderSearch { + display: block; + flex: 1 1 auto; + margin: 0 4px 0 8px; + min-width: 0; + } + + /* 16px keeps iOS from zooming into the field. */ + #pageHeaderSearchInput { + font-size: 16px; + } + + /* The main menu and the user menu are drawers from the right, see `Component/Drawer`. */ + .drawerBackdrop { + background-color: rgba(var(--wcfHeaderMenuBackground-rgb) / 0.5); + inset: 0; + position: fixed; + z-index: 1; + } + + .mainMenu, + .userMenuDrawer { + background-color: var(--wcfUserMenuBackground); + border-radius: 12px 0 0 12px; + box-shadow: var(--wcfBoxShadow); + box-sizing: border-box; + color: var(--wcfUserMenuText); + display: flex; + flex-direction: column; + height: 100dvh; + overflow-y: auto; + overscroll-behavior: contain; + position: fixed; + right: 0; + top: 0; + transform: translateX(100%); + /* Hidden only once the slide-out has finished. */ + transition: + transform 0.2s ease, + visibility 0s 0.2s; + visibility: hidden; + width: min(88%, 400px); + z-index: 2; + + /* Visible at once, the focus trap looks for focusable elements right away. */ + &[data-open] { + transform: none; + transition: transform 0.2s ease; + visibility: visible; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + :focus-visible { + outline: 2px solid var(--wcfContentLink); + outline-offset: -2px; + } + } + + /* The head and the tabs stay in place, the panel scrolls. */ + .userMenuDrawer { + overflow: hidden; + } + + .mainMenuHead { + align-items: center; + display: flex; + flex: 0 0 auto; + min-height: 56px; + padding: 0 8px 0 20px; + } + + .mainMenuTitle { + flex: 1 1 auto; + font-size: var(--wcfFontSizeHeadline); + font-weight: 600; + } + + .mainMenuClose, + .userMenuDrawerClose { + align-items: center; + background-color: transparent; + border: 0; + border-radius: 50%; + color: var(--wcfUserMenuTextDimmed); + display: flex; + height: 44px; + justify-content: center; + padding: 0; + width: 44px; + + &:hover { + background-color: var(--wcfUserMenuBackgroundActive); + color: var(--wcfUserMenuTextActive); + } + } + + .mainMenuGuest { + display: flex; + gap: 8px; + padding: 4px 16px 10px; + } + + .mainMenuGuest .button { + flex: 1 1 0; + justify-content: center; + } + + .mainMenu .boxMenu { + list-style: none; + margin: 0; + padding: 4px 8px 8px; + } + + .mainMenu .boxMenu li { + display: grid; + grid-template-areas: + "link toggle" + "list list"; + grid-template-columns: 1fr auto; + + &[hidden] { + display: none; + } + } + + .mainMenu .boxMenuLink { + align-items: center; + border-radius: var(--wcfBorderRadius); + color: var(--wcfUserMenuText); + column-gap: 6px; + display: flex; + grid-area: link; + min-height: 48px; + padding: 0 6px 0 12px; + + &:hover { + background-color: var(--wcfUserMenuBackgroundActive); + color: var(--wcfUserMenuTextActive); + } + + &[aria-current="page"] { + background-color: var(--wcfUserMenuBackgroundActive); + color: var(--wcfUserMenuTextActive); + } + } + + .mainMenu .boxMenuToggle { + align-items: center; + align-self: center; + background-color: transparent; + border: 0; + border-radius: 50%; + color: var(--wcfUserMenuTextDimmed); + display: flex; + grid-area: toggle; + height: 40px; + justify-content: center; + padding: 0; + width: 40px; + + &:hover { + background-color: var(--wcfUserMenuBorder); + color: var(--wcfUserMenuText); + } + + fa-icon { + transition: transform 0.16s ease; + } + + &[aria-expanded="true"] fa-icon { + transform: rotate(180deg); + } + } + + /* `ui/wsc31.scss` adds a shadow through `.mainMenu .boxMenu .boxMenuDepth1`, which must be outranked. */ + .mainMenu .boxMenu .boxMenuHasChildren > ol { + box-shadow: none; + display: none; + grid-area: list; + list-style: none; + margin: 0; + padding: 0 0 4px; + } + + .mainMenu .boxMenu .boxMenuHasChildren > .boxMenuToggle[aria-expanded="true"] + ol { + display: block; + } + + /* An expanded parent and its children share one tinted block ("tray"). */ + .mainMenu .boxMenu li:has(> .boxMenuToggle[aria-expanded="true"]) { + background-color: var(--wcfUserMenuBackgroundActive); + border-radius: var(--wcfBorderRadius); + margin: 2px 0; + } + + .mainMenu .boxMenu li:has(> .boxMenuToggle[aria-expanded="true"]) .boxMenuLink { + color: var(--wcfUserMenuTextActive); + + &:hover { + background-color: var(--wcfUserMenuBackground); + } + } + + .mainMenu .boxMenu ol .boxMenuLink { + min-height: 42px; + padding-left: 28px; + } + + .mainMenu .boxMenu ol ol .boxMenuLink { + padding-left: 40px; + } + + .mainMenuSettings { + border-top: 1px solid var(--wcfUserMenuBorder); + margin: 6px 8px 0; + padding: 8px 0 4px; + } + + .mainMenuSettingsToggle { + align-items: center; + background-color: transparent; + border: 0; + border-radius: var(--wcfBorderRadius); + color: var(--wcfUserMenuText); + column-gap: 8px; + display: flex; + font: inherit; + min-height: 48px; + padding: 0 6px 0 12px; + text-align: start; + width: 100%; + + &:hover { + background-color: var(--wcfUserMenuBackgroundActive); + color: var(--wcfUserMenuTextActive); + } + + fa-icon { + color: var(--wcfUserMenuTextDimmed); + transition: transform 0.16s ease; + } + + &[aria-expanded="true"] fa-icon { + transform: rotate(180deg); + } + } + + .mainMenuSettingsLabel { + flex: 1 1 auto; + } + + .mainMenuSettingsValue { + color: var(--wcfUserMenuTextDimmed); + } + + .mainMenuLanguageList { + list-style: none; + margin: 0; + padding: 0 0 4px; + } + + .mainMenuLanguage { + align-items: center; + background-color: transparent; + border: 0; + border-radius: var(--wcfBorderRadius); + color: var(--wcfUserMenuText); + column-gap: 10px; + display: flex; + font: inherit; + min-height: 42px; + padding: 0 12px 0 28px; + width: 100%; + + &:hover, + &[aria-current="true"] { + background-color: var(--wcfUserMenuBackgroundActive); + color: var(--wcfUserMenuTextActive); + } + } + + .mainMenuFooter { + border-top: 1px solid var(--wcfUserMenuBorder); + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + list-style: none; + margin: auto 0 0; + padding: 14px 20px 18px; + } + + .mainMenuFooter a { + color: var(--wcfUserMenuTextDimmed); + font-size: var(--wcfFontSizeSmall); + + &:hover { + color: var(--wcfUserMenuText); + text-decoration: underline; + } + } + + .userMenuDrawerHead { + align-items: center; + column-gap: 14px; + display: flex; + flex: 0 0 auto; + padding: 8px 8px 12px 20px; + } + + .userMenuDrawerHead .userAvatarImage { + border-radius: 50%; + flex: 0 0 auto; + height: 48px; + width: 48px; + } + + .userMenuDrawerUser { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + } + + .userMenuDrawerUsername { + font-size: var(--wcfFontSizeHeadline); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .userMenuDrawerUserTitle { + color: var(--wcfUserMenuTextDimmed); + font-size: var(--wcfFontSizeSmall); + } + + .userMenuDrawerTabs { + background-color: var(--wcfUserMenuBackgroundActive); + border-radius: 10px; + column-gap: 4px; + display: flex; + flex: 0 0 auto; + margin: 0 12px 6px; + padding: 4px; + } + + .userMenuDrawerTab { + align-items: center; + background-color: transparent; + border: 0; + border-radius: 7px; + color: var(--wcfUserMenuTextActive); + display: flex; + flex: 1 1 0; + font: inherit; + justify-content: center; + min-width: 0; + padding: 16px 4px; + position: relative; + + fa-icon { + --font-size: 18px; + --icon-size: 20px; + } + + &[aria-selected="true"] { + background-color: var(--wcfUserMenuBackground); + box-shadow: var(--wcfBoxShadowCard); + } + } + + /* The labels do not fit on small phones, they remain as the accessible name of the tab. */ + .userMenuDrawerTabLabel { + clip: rect(1px, 1px, 1px, 1px); + height: 1px; + overflow: hidden; + position: absolute; + width: 1px; + } + + .userMenuDrawerTabCount { + background-color: var(--wcfUserMenuText); + border-radius: 9px; + box-sizing: border-box; + color: var(--wcfUserMenuBackground); + font-size: 11px; + font-weight: 700; + left: calc(50% + 4px); + line-height: 18px; + min-width: 18px; + padding: 0 5px; + position: absolute; + text-align: center; + top: 12px; + } + + .userMenuDrawerPanel { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + } + + .userMenuDrawerPanel > .userMenu { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + } + + .userMenuDrawerPanel .userMenuContentScrollable { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + + /* The head of the drawer already shows the user. */ + .userMenuDrawerPanel .userMenuControlPanel > .userMenuHeader, + .userMenuDrawerPanel .userMenuControlPanel > .userMenuHeader + .userMenuContent, + .userMenuDrawerPanel .userMenuControlPanel > .userMenuHeader + .userMenuContent + .userMenuContentDivider { + display: none; + } +} + +/* Breadcrumbs without the bar, the space comes out of the padding of `.main`. `ui/breadcrumb.scss` + is compiled after this file, the selectors must outrank it. */ +@include screen-md-up { + .pageNavigation:has(.breadcrumbs) { + background-color: transparent; + color: var(--wcfContentDimmedText); + padding-bottom: 0; + padding-top: 20px; + } + + .pageNavigation:not(:has(.breadcrumbs)) { + display: none; + } + + .pageNavigation:has(.breadcrumbs) + .main { + padding-top: 10px; + } + + .pageNavigation .breadcrumbs__link, + .pageNavigation .breadcrumbs__link:hover { + color: inherit; + } + + /* Flex drops the whitespace between the link and the separator. */ + .pageNavigation .breadcrumbs__item { + align-items: center; + column-gap: 5px; + display: flex; + } + + .pageNavigation .breadcrumbs__item:not(:last-child) { + margin-right: 5px; + } + + /* The chevron of the ACP breadcrumbs, a mask keeps the markup shared with the classic header. */ + .pageNavigation .breadcrumbs__item:not(:last-child)::after { + background-color: currentColor; + color: inherit; + content: ""; + flex: 0 0 auto; + height: 12px; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 6l6 6-6 6'/%3E%3C/svg%3E") + center / contain no-repeat; + width: 12px; + } + + html[dir="rtl"] .pageNavigation .breadcrumbs__item:not(:last-child)::after { + transform: scaleX(-1); + } + + .pageNavigation .breadcrumbs__item:not(:last-child) .breadcrumbs__link { + margin-right: 0; + } +} + +/* The auth flow keeps the classic, logo-only header for every style, see `authFlowHeader.tpl`. */ +.authFlow .pageHeaderContainer { + background-color: var(--wcfHeaderBackground); + color: var(--wcfHeaderText); + z-index: 100; + + @include screen-md-down { + padding-top: 50px; + } +} + +.authFlow .pageHeaderContainer a { + color: var(--wcfHeaderLink); + + &:hover { + color: var(--wcfHeaderLinkActive); + } +} + +.authFlow .pageHeaderFacade > .layoutBoundary { + align-items: center; + display: flex; + + @include screen-lg { + padding-bottom: 30px; + padding-top: 30px; + } +} + +@include screen-lg { + .authFlow .pageHeaderLogo { + flex: 1 1 auto; + } + + .authFlow .pageHeaderLogoLarge { + height: auto; + max-width: 100%; + } + + .authFlow .pageHeaderLogoSmall { + display: none; + } + + .authFlow .pageHeaderLogo > a { + display: block; + padding: 10px 0; + } +} + +@include screen-md-down { + .authFlow .pageHeaderLogoLarge { + display: none; + } + + .authFlow .pageHeaderLogoSmall { + max-height: 30px; + max-width: 100%; + object-fit: contain; + width: auto; + } + + .authFlow .pageHeaderLogo a { + align-items: center; + display: inline-flex; + height: 40px; + padding: 0 4px; + } + + .authFlow .pageHeader { + align-items: center; + background-color: var(--wcfHeaderBackground); + display: flex; + height: 50px; + justify-content: center; + left: 0; + padding: 0 6px; + position: fixed; + right: 0; + top: 0; + } + + .authFlow .pageHeaderFacade, + .authFlow .pageHeaderFacade .layoutBoundary { + display: contents; + position: static; + } +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index ae48c4b58a9..cbe7d690377 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2958,6 +2958,17 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z + + + + +
    Dieser Seitenkopf wird in einer zukünftigen Version entfernt. Bis dahin steht diese Option für Stile zur Verfügung, deren Templates, CSS oder JavaScript darauf angewiesen sind und die noch nicht angepasst wurden.]]>
    + + + + + + @@ -3023,6 +3034,7 @@ Abschnitte dürfen nicht leer sein und nur folgende Zeichen enthalten: [a-z + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 5ae3b402e3d..0a75dbd975b 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2888,6 +2888,17 @@ If you have already bought the licenses for the listed apps, th + + + + +
    This page header will be removed in a future version. Until then, it remains an option for styles whose templates, CSS or JavaScript depend on it and have not been updated yet.]]>
    + + + + + + @@ -2952,6 +2963,7 @@ If you have already bought the licenses for the listed apps, th + diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index adeb69e8d86..abb3bf7ce35 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -28,6 +28,7 @@ INSERT INTO wcf1_package_update_server (serverURL, status, isDisabled, errorMess INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('individualScss', '', NULL); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('messageSidebarOrientation', 'left', NULL); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('overrideScss', '', NULL); +INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('pageHeaderLayout', 'logoTop', NULL); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('pageLogo', '', NULL); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('pageLogoWidth', '281', NULL); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('pageLogoHeight', '40', NULL); @@ -50,7 +51,7 @@ INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMod INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentContainerBorder', 'rgba(236, 241, 247, 1)', 'rgba(54, 55, 59, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentDimmedLink', 'rgba(52, 73, 94, 1)', 'rgba(29, 155, 209, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentDimmedLinkActive', 'rgba(52, 73, 94, 1)', 'rgba(64, 179, 228, 1)'); -INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentDimmedText', 'rgba(113, 117, 122, 1)', 'rgba(138, 140, 143, 1)'); +INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentDimmedText', 'rgba(106, 110, 115, 1)', 'rgba(139, 141, 144, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentHeadlineBorder', 'rgba(238, 238, 238, 1)', 'rgba(54, 55, 59, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentHeadlineLink', 'rgba(58, 58, 61, 1)', 'rgba(209, 210, 211, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfContentHeadlineLinkActive', 'rgba(58, 58, 61, 1)', 'rgba(158, 158, 158, 1)'); @@ -102,19 +103,19 @@ INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMod INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderLinkActive', 'rgba(255, 255, 255, 1)', 'rgba(255, 255, 255, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuBackground', 'rgba(50, 92, 132, 1)', 'rgba(36, 46, 61, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuLinkBackground', 'rgba(43, 79, 113, 1)', 'rgba(36, 46, 61, 1)'); -INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuLinkBackgroundActive', 'rgba(36, 66, 95, 1)', 'rgba(43, 56, 74, 1)'); +INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuLinkBackgroundActive', 'rgba(36, 66, 95, 1)', 'rgba(54, 72, 96, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuLink', 'rgba(255, 255, 255, 1)', 'rgba(183, 186, 191, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuLinkActive', 'rgba(255, 255, 255, 1)', 'rgba(224, 227, 230, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuDropdownBackground', 'rgba(36, 66, 95, 1)', 'rgba(43, 56, 74, 1)'); -INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuDropdownBackgroundActive', 'rgba(65, 121, 173, 1)', 'rgba(38, 49, 64, 1)'); +INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuDropdownBackgroundActive', 'rgba(65, 121, 173, 1)', 'rgba(63, 82, 112, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuDropdownLink', 'rgba(255, 255, 255, 1)', 'rgba(224, 227, 230, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderMenuDropdownLinkActive', 'rgba(255, 255, 255, 1)', 'rgba(229, 231, 234, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxBackground', 'rgba(50, 92, 132, 1)', 'rgba(36, 46, 61, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxBackgroundActive', 'rgba(50, 92, 132, 1)', 'rgba(43, 56, 74, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxText', 'rgba(255, 255, 255, 1)', 'rgba(255, 255, 255, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxTextActive', 'rgba(255, 255, 255, 1)', 'rgba(255, 255, 255, 1)'); -INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxPlaceholder', 'rgba(207, 207, 207, 1)', 'rgba(207, 207, 207, 1)'); -INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxPlaceholderActive', 'rgba(207, 207, 207, 1)', 'rgba(207, 207, 207, 1)'); +INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxPlaceholder', 'rgba(218, 218, 218, 1)', 'rgba(207, 207, 207, 1)'); +INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfHeaderSearchBoxPlaceholderActive', 'rgba(218, 218, 218, 1)', 'rgba(207, 207, 207, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfInputBackground', 'rgba(241, 246, 251, 1)', 'rgba(26, 29, 33, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfInputBackgroundActive', 'rgba(241, 246, 251, 1)', 'rgba(26, 29, 33, 1)'); INSERT INTO wcf1_style_variable (variableName, defaultValue, defaultValueDarkMode) VALUES('wcfInputBorder', 'rgba(176, 200, 224, 1)', 'rgba(87, 88, 86, 1)');