From cc22b825967ad6bd7c0259882addb9a14d7bbf95 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 4 Oct 2026 20:23:30 +0200 Subject: [PATCH 01/32] Add a configurable page header layout style variable --- com.woltlab.wcf/package.xml | 1 + .../install/files/acp/templates/styleAdd.tpl | 12 ++++++++++ ...ate_com.woltlab.wcf_6.3_styleVariables.php | 24 +++++++++++++++++++ .../files/lib/acp/form/StyleAddForm.class.php | 9 +++++++ .../files/lib/data/style/Style.class.php | 6 +++++ wcfsetup/install/lang/de.xml | 6 +++++ wcfsetup/install/lang/en.xml | 6 +++++ wcfsetup/setup/db/install.sql | 1 + 8 files changed, 65 insertions(+) create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleVariables.php 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/wcfsetup/install/files/acp/templates/styleAdd.tpl b/wcfsetup/install/files/acp/templates/styleAdd.tpl index 5607935a5c5..2d511504abd 100644 --- a/wcfsetup/install/files/acp/templates/styleAdd.tpl +++ b/wcfsetup/install/files/acp/templates/styleAdd.tpl @@ -433,6 +433,18 @@

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

+
+
+
+ + {lang}wcf.acp.style.globals.pageHeaderLayout.description{/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..c7d7fcdb4e4 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_styleVariables.php @@ -0,0 +1,24 @@ +prepare($sql); + +foreach ($styleVariables as $data) { + [$variableName, $defaultValue, $defaultValueDarkMode] = $data; + + $statement->execute([ + $variableName, + $defaultValue, + $defaultValueDarkMode, + ]); +} diff --git a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php index b32683273f6..856e4550ed2 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php @@ -369,6 +369,13 @@ public function readFormParameters() unset($this->variables['wcfFontFamilyFallback']); } + if ( + isset($this->variables['pageHeaderLayout']) + && !\in_array($this->variables['pageHeaderLayout'], Style::PAGE_HEADER_LAYOUTS, true) + ) { + unset($this->variables['pageHeaderLayout']); + } + $this->variables['useFluidLayout'] = isset($_POST['useFluidLayout']) ? 1 : 0; // style data @@ -821,6 +828,7 @@ protected function setVariables() 'individualScss', 'individualScssDarkMode', 'overrideScss', + 'pageHeaderLayout', 'pageLogoWidth', 'pageLogoHeight', 'useFluidLayout', @@ -941,6 +949,7 @@ public function assignVariables() 'authorName' => $this->authorName, 'authorURL' => $this->authorURL, 'availableFontFamilies' => $this->availableFontFamilies, + 'availablePageHeaderLayouts' => Style::PAGE_HEADER_LAYOUTS, 'availableTemplateGroups' => $this->availableTemplateGroups, 'availableUnits' => $this->availableUnits, 'colorCategories' => $this->colorCategories, diff --git a/wcfsetup/install/files/lib/data/style/Style.class.php b/wcfsetup/install/files/lib/data/style/Style.class.php index bae550efdee..abca33eafd6 100644 --- a/wcfsetup/install/files/lib/data/style/Style.class.php +++ b/wcfsetup/install/files/lib/data/style/Style.class.php @@ -85,6 +85,12 @@ class Style extends DatabaseObject implements ITitledObject, \Stringable const DARK_MODE_PREFIX = "darkMode\0"; + /** + * Accepted values of the `pageHeaderLayout` style variable. + * @since 6.3 + */ + const PAGE_HEADER_LAYOUTS = ['classic', 'logoTop', 'logoBelow', 'logoInBar']; + /** * Returns the name of this style. */ diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index ae48c4b58a9..d0432b13ce8 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2958,6 +2958,12 @@ 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..f12b2ae1af8 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2888,6 +2888,12 @@ 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..537480d485f 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', 'classic', 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); From b7b12dccc77c2eacb363a240e9407196894caa4d Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Sun, 4 Oct 2026 20:36:33 +0200 Subject: [PATCH 02/32] Resolve `pageHeader` to `system_pageHeader` through style-driven template variants --- .../templates/system_pageHeader.tpl | 37 ++++++++++++++ .../files/lib/data/style/Style.class.php | 19 +++++++ .../data/template/TemplateEditor.class.php | 2 +- .../lib/system/style/StyleHandler.class.php | 1 + .../template/ACPTemplateEngine.class.php | 12 +++++ .../template/EmailTemplateEngine.class.php | 12 +++++ .../system/template/TemplateEngine.class.php | 50 ++++++++++++++++++- 7 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 com.woltlab.wcf/templates/system_pageHeader.tpl diff --git a/com.woltlab.wcf/templates/system_pageHeader.tpl b/com.woltlab.wcf/templates/system_pageHeader.tpl new file mode 100644 index 00000000000..04d6867c71d --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeader.tpl @@ -0,0 +1,37 @@ +
+ + + {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/wcfsetup/install/files/lib/data/style/Style.class.php b/wcfsetup/install/files/lib/data/style/Style.class.php index abca33eafd6..c2ec15953b8 100644 --- a/wcfsetup/install/files/lib/data/style/Style.class.php +++ b/wcfsetup/install/files/lib/data/style/Style.class.php @@ -176,6 +176,25 @@ 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 + { + $templateVariants = []; + + // Unknown values may come from an imported style and fall back to the classic header. + $pageHeaderLayout = $this->getVariables()['pageHeaderLayout'] ?? 'classic'; + if ($pageHeaderLayout !== 'classic' && \in_array($pageHeaderLayout, self::PAGE_HEADER_LAYOUTS, true)) { + $templateVariants[] = 'pageHeader'; + } + + return $templateVariants; + } + /** * @since 5.4 */ 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/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/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..dd33e23f643 100755 --- a/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php +++ b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php @@ -208,6 +208,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 +508,10 @@ public function getSourceFilename(string $templateName, string $application) $templateName = TemplateEngine::SHARED_TEMPLATES[$templateName]; } + if (\in_array($templateName, $this->templateVariants, true)) { + $templateName = 'system_' . $templateName; + } + if (TemplateEngine::isSharedTemplate($templateName)) { $sourceFilename = $this->getPath(TemplateEngine::getInstance()->templatePaths[$application], $templateName); } else { @@ -903,6 +914,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. * @@ -1091,7 +1134,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; } } From b88ff86c89f3a35e91f1af887613b8e325c7beb6 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 11:05:53 +0200 Subject: [PATCH 03/32] Reserve the `system_` template prefix as non-customizable --- .../files/lib/acp/form/TemplateAddForm.class.php | 14 +++++++++++++- .../files/lib/data/style/StyleEditor.class.php | 7 +++++-- .../files/lib/data/template/Template.class.php | 6 ++++-- .../gridView/admin/TemplateGridView.class.php | 4 +++- .../admin/TemplateInteractions.class.php | 7 ++++++- .../package/ACPTemplatesFileHandler.class.php | 6 ++++++ .../lib/system/template/TemplateEngine.class.php | 5 +++++ wcfsetup/install/lang/de.xml | 1 + wcfsetup/install/lang/en.xml | 1 + 9 files changed, 44 insertions(+), 7 deletions(-) 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/style/StyleEditor.class.php b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php index 0157ab04bf9..1ac11185ed8 100644 --- a/wcfsetup/install/files/lib/data/style/StyleEditor.class.php +++ b/wcfsetup/install/files/lib/data/style/StyleEditor.class.php @@ -590,6 +590,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 +604,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, 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/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/template/TemplateEngine.class.php b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php index dd33e23f643..87359663c1e 100755 --- a/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php +++ b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php @@ -512,6 +512,11 @@ public function getSourceFilename(string $templateName, string $application) $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 { diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index d0432b13ce8..371b0993679 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -3029,6 +3029,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 f12b2ae1af8..cbd34a49894 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2958,6 +2958,7 @@ If you have already bought the licenses for the listed apps, th + From 20e579c56b4ae9f590df0df6cd01f6ece9b99a90 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 11:16:12 +0200 Subject: [PATCH 04/32] Apply the template listeners of `foo` to its `system_foo` variant --- .../lib/system/template/TemplateEngine.class.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php index 87359663c1e..d5f52e897b6 100755 --- a/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php +++ b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php @@ -1064,10 +1064,23 @@ 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 = \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])) { From 1d6d9f6e75b77f7767847ba3fd38f0c19fd12aea Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 11:35:33 +0200 Subject: [PATCH 05/32] Compile the page header SCSS per style from `layout/pageHeader/{classic,system}.scss` --- .../files/lib/data/style/Style.class.php | 14 ++- .../lib/system/style/StyleCompiler.class.php | 40 +++++--- .../classic.scss} | 0 .../files/style/layout/pageHeader/system.scss | 91 +++++++++++++++++++ 4 files changed, 132 insertions(+), 13 deletions(-) rename wcfsetup/install/files/style/layout/{pageHeader.scss => pageHeader/classic.scss} (100%) create mode 100644 wcfsetup/install/files/style/layout/pageHeader/system.scss diff --git a/wcfsetup/install/files/lib/data/style/Style.class.php b/wcfsetup/install/files/lib/data/style/Style.class.php index c2ec15953b8..9a32aaade9f 100644 --- a/wcfsetup/install/files/lib/data/style/Style.class.php +++ b/wcfsetup/install/files/lib/data/style/Style.class.php @@ -183,11 +183,23 @@ public function getVariable(string $variableName, bool $toHex = false) * @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 = []; // Unknown values may come from an imported style and fall back to the classic header. - $pageHeaderLayout = $this->getVariables()['pageHeaderLayout'] ?? 'classic'; + $pageHeaderLayout = $variables['pageHeaderLayout'] ?? 'classic'; if ($pageHeaderLayout !== 'classic' && \in_array($pageHeaderLayout, self::PAGE_HEADER_LAYOUTS, true)) { $templateVariants[] = 'pageHeader'; } diff --git a/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php b/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php index e8c10c24cdc..677e4e04e62 100644 --- a/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php +++ b/wcfsetup/install/files/lib/system/style/StyleCompiler.class.php @@ -31,10 +31,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 +119,8 @@ public function testStyle( array $variables, ?string $customCustomSCSSFile = null, ): ?\Exception { + $pageHeader = self::getPageHeaderStylesheet(Style::getTemplateVariantsFromVariables($variables)); + $individualScss = ''; if (isset($variables['individualScss'])) { $individualScss = $variables['individualScss']; @@ -166,7 +168,7 @@ public function testStyle( $parameters = ['scss' => '']; EventHandler::getInstance()->fireAction($this, 'compile', $parameters); - $files = $this->getFiles(); + $files = $this->getFiles($pageHeader); if ($customCustomSCSSFile !== null) { if (($customSCSSFileKey = \array_search(\WCF_DIR . self::FILE_GLOBAL_VALUES, $files, true)) !== false) { @@ -214,10 +216,10 @@ public function testStyle( * @return string[] * @since 5.3 */ - private function getFiles(): array + private function getFiles(string $pageHeader): array { - if ($this->files === null) { - $files = $this->getCoreFiles(); + if (!isset($this->files[$pageHeader])) { + $files = $this->getCoreFiles($pageHeader); // read stylesheets in dependency order $sql = "SELECT filename, application @@ -244,10 +246,10 @@ private function getFiles(): array $files[] = \WCF_DIR . self::FILE_GLOBAL_VALUES; } - $this->files = $files; + $this->files[$pageHeader] = $files; } - return $this->files; + return $this->files[$pageHeader]; } /** @@ -306,7 +308,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::getPageHeaderStylesheet($style->getTemplateVariants())) as $file) { $scss .= $this->prepareFile($file); } $scss .= $individualScss; @@ -421,7 +423,7 @@ private function extractPreloadRequests(string $css): iterable */ public function compileACP(): void { - $files = $this->getCoreFiles(); + $files = $this->getCoreFiles('classic'); // ACP uses a slightly different layout $files[] = \WCF_DIR . 'acp/style/layout.scss'; @@ -471,12 +473,22 @@ public function compileACP(): void $this->writeCss(\WCF_DIR . 'acp/style/style', $css); } + /** + * Returns the name of the page header stylesheet in `style/layout/pageHeader/`. + * + * @param list $templateVariants + */ + private static function getPageHeaderStylesheet(array $templateVariants): string + { + return \in_array('pageHeader', $templateVariants, true) ? 'system' : 'classic'; + } + /** * Returns a list of common stylesheets provided by the core. * * @return string[] list of common stylesheets */ - private function getCoreFiles(): array + private function getCoreFiles(string $pageHeader): array { $files = []; if ($handle = \opendir(\WCF_DIR . 'style/')) { @@ -505,6 +517,10 @@ private function getCoreFiles(): array \closedir($handle); + // The subdirectory is skipped above. Sorting places the selected file + // at the position of the former `layout/pageHeader.scss`. + $files[] = \WCF_DIR . "style/layout/pageHeader/{$pageHeader}.scss"; + // 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/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..19527ed03f3 --- /dev/null +++ b/wcfsetup/install/files/style/layout/pageHeader/system.scss @@ -0,0 +1,91 @@ +/* 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; + + a { + color: var(--wcfHeaderLink); + + &:hover { + color: var(--wcfHeaderLinkActive); + } + } + + @include screen-md-down { + padding-top: 50px; + } + } + + .pageHeaderFacade > .layoutBoundary { + align-items: center; + display: flex; + + @include screen-lg { + padding-bottom: 30px; + padding-top: 30px; + } + } + + .pageHeaderLogo { + @include screen-lg { + flex: 1 1 auto; + + .pageHeaderLogoLarge { + height: auto; + max-width: 100%; + } + + .pageHeaderLogoSmall { + display: none; + } + + > a { + display: block; + padding: 10px 0; + } + } + + @include screen-md-down { + .pageHeaderLogoLarge { + display: none; + } + + .pageHeaderLogoSmall { + max-height: 30px; + max-width: 100%; + object-fit: contain; + width: auto; + } + + a { + align-items: center; + display: inline-flex; + height: 40px; + padding: 0 4px; + } + } + } + + @include screen-md-down { + .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; + } + + .pageHeaderFacade, + .pageHeaderFacade .layoutBoundary { + display: contents; + position: static; + } + } +} From 9ad48c1d2c8301eae48e1b6a852c316cb6e29260 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 13:03:44 +0200 Subject: [PATCH 06/32] Brighten the dark mode header active states and the search box placeholder --- .../acp/update_com.woltlab.wcf_6.3_styleVariables.php | 4 ++++ wcfsetup/setup/db/install.sql | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) 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 index c7d7fcdb4e4..30356b91afe 100644 --- 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 @@ -4,6 +4,10 @@ $styleVariables = [ ['pageHeaderLayout', 'classic', null], + ['wcfHeaderMenuLinkBackgroundActive', 'rgba(36, 66, 95, 1)', 'rgba(54, 72, 96, 1)'], + ['wcfHeaderMenuDropdownBackgroundActive', 'rgba(65, 121, 173, 1)', 'rgba(63, 82, 112, 1)'], + ['wcfHeaderSearchBoxPlaceholder', 'rgba(218, 218, 218, 1)', 'rgba(207, 207, 207, 1)'], + ['wcfHeaderSearchBoxPlaceholderActive', 'rgba(218, 218, 218, 1)', 'rgba(207, 207, 207, 1)'], ]; $sql = "INSERT INTO wcf1_style_variable diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index 537480d485f..42947e275e5 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -103,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)'); From 811a0729367f11096dacff6c4dd60d9735cf5472 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 13:13:35 +0200 Subject: [PATCH 07/32] Add `Component/Drawer` for modal header panels on small screens --- ts/WoltLabSuite/Core/Component/Drawer.ts | 197 ++++++++++++++++++ .../js/WoltLabSuite/Core/Component/Drawer.js | 156 ++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 ts/WoltLabSuite/Core/Component/Drawer.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Drawer.js 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/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(), + }); + } +}); From b882bfa6cc8e63c52e1cf56498f3fbee424cc94b Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 13:30:51 +0200 Subject: [PATCH 08/32] Add logo row positions (`logoTop`/`logoBelow`/`logoInBar`) to `system_pageHeader` --- .../templates/system_pageHeader.tpl | 37 +++++++++++++++---- .../templates/system_pageHeaderLogo.tpl | 16 ++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 com.woltlab.wcf/templates/system_pageHeaderLogo.tpl diff --git a/com.woltlab.wcf/templates/system_pageHeader.tpl b/com.woltlab.wcf/templates/system_pageHeader.tpl index 04d6867c71d..df74eae788e 100644 --- a/com.woltlab.wcf/templates/system_pageHeader.tpl +++ b/com.woltlab.wcf/templates/system_pageHeader.tpl @@ -1,21 +1,44 @@ -
+{assign var='__pageHeaderLayout' value=$__wcf->getStyleHandler()->getStyle()->getVariable('pageHeaderLayout')} +
+ {if $__pageHeaderLayout === 'logoTop'} +
+
+ {include file='system_pageHeaderLogo'} +
+
+ {/if} + + + {if $__pageHeaderLayout === 'logoBelow'}
- {include file='pageHeaderLogo'} - - {include file='pageHeaderSearch'} + {include file='system_pageHeaderLogo'}
- + {/if} {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..28e40084776 --- /dev/null +++ b/com.woltlab.wcf/templates/system_pageHeaderLogo.tpl @@ -0,0 +1,16 @@ + From f4303224016f72530784b10140b743ef4da1fd04 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 15:11:29 +0200 Subject: [PATCH 09/32] Add `system_pageHeaderMenu` with drawer-ready main menu and `MenuItemNode::isCurrentNode()` --- .../templates/system_pageHeader.tpl | 13 +- .../templates/system_pageHeaderMenu.tpl | 116 ++++++++++++++++++ .../lib/data/menu/item/MenuItemNode.class.php | 28 +++++ .../data/menu/item/MenuItemNodeTree.class.php | 2 +- .../system/template/TemplateEngine.class.php | 13 +- 5 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 com.woltlab.wcf/templates/system_pageHeaderMenu.tpl diff --git a/com.woltlab.wcf/templates/system_pageHeader.tpl b/com.woltlab.wcf/templates/system_pageHeader.tpl index df74eae788e..c1255e18aee 100644 --- a/com.woltlab.wcf/templates/system_pageHeader.tpl +++ b/com.woltlab.wcf/templates/system_pageHeader.tpl @@ -23,11 +23,22 @@ {/if} - {unsafe:$__wcf->getBoxHandler()->getBoxByIdentifier('com.woltlab.wcf.MainMenu')->render()} + {include file='system_pageHeaderMenu' sandbox=true} {include file='pageHeaderSearch'} {include file='pageHeaderUser'} + +
diff --git a/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl b/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl new file mode 100644 index 00000000000..676a5f71707 --- /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/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/system/template/TemplateEngine.class.php b/wcfsetup/install/files/lib/system/template/TemplateEngine.class.php index d5f52e897b6..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 @@ -1076,7 +1086,8 @@ public function getTemplateListenerCode(string $templateName, string $eventName) } // `system_foo` replaces `foo`, therefore the listeners of `foo` apply as well. if (\str_starts_with($templateName, 'system_')) { - $classicTemplateName = \substr($templateName, \strlen('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]); } From e7a532542e2cebb395f1b18cc4095a36e9e35a6f Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 15:22:01 +0200 Subject: [PATCH 10/32] Add `system_pageHeaderUser` with reordered user panel, guest language dropdown and drawer-ready avatar button --- .../templates/system_pageHeader.tpl | 2 +- .../templates/system_pageHeaderUser.tpl | 230 ++++++++++++++++++ 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 com.woltlab.wcf/templates/system_pageHeaderUser.tpl diff --git a/com.woltlab.wcf/templates/system_pageHeader.tpl b/com.woltlab.wcf/templates/system_pageHeader.tpl index c1255e18aee..abbda3681a2 100644 --- a/com.woltlab.wcf/templates/system_pageHeader.tpl +++ b/com.woltlab.wcf/templates/system_pageHeader.tpl @@ -27,7 +27,7 @@ {include file='pageHeaderSearch'} - {include file='pageHeaderUser'} + {include file='system_pageHeaderUser'} + {csrfToken} + +
+ + + + {else} + {if $__wcf->getLanguage()->getLanguages()|count > 1} + + {/if} +
  • + {lang}wcf.user.button.login{/lang} +
  • + {if $__userAuthConfig->canRegister} +
  • + {lang}wcf.user.button.register{/lang} +
  • + {/if} + {/if} + + +{if !$__wcf->user->isGuest()} + +{/if} From c90d09fc646a28f86c2750f3232dd78d5e55742d Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 15:35:34 +0200 Subject: [PATCH 11/32] Add desktop styles for the system page header and switch the frontend bootstrap to drawers --- .../templates/headIncludeJavaScript.tpl | 1 + ts/WoltLabSuite/Core/BootstrapFrontend.ts | 13 +- .../js/WoltLabSuite/Core/BootstrapFrontend.js | 13 +- .../files/style/layout/pageHeader/system.scss | 354 ++++++++++++++++++ 4 files changed, 376 insertions(+), 5 deletions(-) diff --git a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl index 95d7ec42e90..00049b12cef 100644 --- a/com.woltlab.wcf/templates/headIncludeJavaScript.tpl +++ b/com.woltlab.wcf/templates/headIncludeJavaScript.tpl @@ -108,6 +108,7 @@ window.addEventListener('pageshow', function(event) { ]), {/if} reportEndpoint: '{link controller="Report"}{/link}', + templateVariants: [{implode from=$__wcf->getStyleHandler()->getStyle()->getTemplateVariants() item=_templateVariant}'{unsafe:$_templateVariant|encodeJS}'{/implode}], }); }); diff --git a/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts index c6e189dda65..5587cc2b293 100644 --- a/ts/WoltLabSuite/Core/BootstrapFrontend.ts +++ b/ts/WoltLabSuite/Core/BootstrapFrontend.ts @@ -21,6 +21,7 @@ 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"; interface BootstrapOptions { backgroundQueue: { @@ -40,6 +41,7 @@ interface BootstrapOptions { removeQuotes?: string[]; usedQuotes?: Map; reportEndpoint: string; + templateVariants: string[]; } /** @@ -78,9 +80,12 @@ 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, pageMenuMainProvider: new UiPageMenuMainFrontend(), }); @@ -97,7 +102,11 @@ export function setup(options: BootstrapOptions): void { }); } - UiPageHeaderMenu.init(); + if (hasSystemPageHeader) { + setupDrawers(); + } else { + UiPageHeaderMenu.init(); + } if (options.styleChanger) { void import("./Controller/Style/Changer").then((ControllerStyleChanger) => { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js index bf847739b1e..6e2ad512e8a 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"], 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) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -49,9 +49,11 @@ 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, pageMenuMainProvider: new Frontend_1.default(), }); if (options.removeQuotes?.length) { @@ -66,7 +68,12 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui }); }); } - UiPageHeaderMenu.init(); + if (hasSystemPageHeader) { + (0, Drawer_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/style/layout/pageHeader/system.scss b/wcfsetup/install/files/style/layout/pageHeader/system.scss index 19527ed03f3..fc930e70d1d 100644 --- a/wcfsetup/install/files/style/layout/pageHeader/system.scss +++ b/wcfsetup/install/files/style/layout/pageHeader/system.scss @@ -1,3 +1,357 @@ +/* The auth flow uses the classic markup, see below, the redesigned header is scoped to its layouts. */ +.pageHeaderContainer:is( + .pageHeaderContainer--logoTop, + .pageHeaderContainer--logoBelow, + .pageHeaderContainer--logoInBar +) { + /* The bar must stick relative to `#pageContainer`, not to this short wrapper. */ + display: contents; + + .pageHeader { + 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); + } + } + + .pageHeaderPanel > .layoutBoundary { + align-items: center; + display: flex; + } + + .pageHeaderFacade { + background-color: var(--wcfHeaderBackground); + color: var(--wcfHeaderText); + + > .layoutBoundary { + align-items: center; + display: flex; + padding-bottom: 20px; + padding-top: 20px; + } + } + + .pageHeaderLogo > a { + display: block; + } + + .pageHeaderLogoLarge { + height: auto; + max-width: 100%; + } + + @include screen-lg { + .pageHeaderPanel > .layoutBoundary { + height: 64px; + } + + .pageHeaderBarLogo, + .pageHeaderLogoSmall, + .pageHeaderMenuMobile, + .pageHeaderUserMobile, + .mainMenuMobileOnly { + display: none; + } + + /* logoInBar: the regular logo at 36px height */ + .pageHeaderPanel .pageHeaderLogo { + flex: 0 0 auto; + margin-right: 24px; + + .pageHeaderLogoLarge { + max-height: 36px; + width: auto; + } + } + + /* main menu */ + .mainMenu { + flex: 0 1 auto; + min-width: 0; + } + + .mainMenu .boxMenu { + align-items: center; + display: flex; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; + + > li { + display: flex; + position: relative; + + &[hidden] { + display: none; + } + + > .boxMenuLink { + align-items: center; + background-color: var(--wcfHeaderMenuLinkBackground); + border: 0; + border-radius: var(--wcfBorderRadius); + box-sizing: border-box; + color: var(--wcfHeaderMenuLink); + column-gap: 6px; + display: flex; + font: inherit; + height: 36px; + padding: 0 12px; + transition: + background-color 0.12s ease, + color 0.12s ease; + white-space: nowrap; + + &:hover { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + color: var(--wcfHeaderMenuLinkActive); + } + } + + &.active > .boxMenuLink { + background-color: var(--wcfHeaderMenuLinkBackgroundActive); + color: var(--wcfHeaderMenuLinkActive); + font-weight: 600; + } + + /* An open submenu continues the item's fill into the panel ("tray"). */ + &:has(> .boxMenuToggle[aria-expanded="true"]) > .boxMenuLink { + background-color: var(--wcfHeaderMenuDropdownBackground); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + color: var(--wcfHeaderMenuDropdownLink); + } + } + + :focus-visible { + outline: 2px solid var(--wcfHeaderMenuLinkActive); + outline-offset: 2px; + } + } + + /* 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: 36px; + 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; + } + } + + .mainMenu .boxMenuDepth1 { + background-color: var(--wcfHeaderMenuDropdownBackground); + 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: 100%; + z-index: 1; + + .boxMenuLink { + align-items: center; + border-radius: var(--wcfBorderRadius); + color: var(--wcfHeaderMenuDropdownLink); + column-gap: 6px; + display: flex; + min-height: 34px; + padding: 0 8px; + white-space: nowrap; + + &:hover { + background-color: var(--wcfHeaderMenuDropdownBackgroundActive); + color: var(--wcfHeaderMenuDropdownLinkActive); + } + + &[aria-current="page"] { + font-weight: 600; + } + } + + :focus-visible { + outline-color: var(--wcfHeaderMenuDropdownLinkActive); + outline-offset: -2px; + } + + /* Deeper levels are indented inside the panel, there is no second flyout. */ + .boxMenuToggle { + display: none; + } + + ol { + list-style: none; + margin: 0; + padding: 0 0 0 12px; + } + } + + .mainMenu .boxMenuToggle[aria-expanded="true"] + .boxMenuDepth1 { + display: block; + } + + /* user panel */ + .userPanel { + flex: 0 0 auto; + margin-left: auto; + padding-left: 24px; + } + + .userPanelItems { + align-items: center; + column-gap: 4px; + display: flex; + list-style: none; + margin: 0; + padding: 0; + + > li { + position: relative; + } + + > li > a { + align-items: center; + background-color: var(--wcfHeaderMenuLinkBackground); + border-radius: 50%; + color: var(--wcfHeaderMenuLink); + display: flex; + height: 40px; + justify-content: center; + 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; + } + } + + :focus-visible { + outline: 2px solid var(--wcfHeaderMenuLinkActive); + outline-offset: 2px; + } + + .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 is redesigned in a later step, until then it stays out of the bar. */ + .pageHeaderSearchMobile, + .pageHeaderSearch { + display: none; + } + } +} + /* The auth flow keeps the classic, logo-only header for every style, see `authFlowHeader.tpl`. */ .authFlow { .pageHeaderContainer { From 7fa3eb0830d4b2c22d2477b3adb4fff8e615ceab Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 15:54:03 +0200 Subject: [PATCH 12/32] Add `Component/MainMenu` with hover intent, submenu toggles and RTL-aware priority+ overflow for the system page header --- ts/WoltLabSuite/Core/BootstrapFrontend.ts | 2 + ts/WoltLabSuite/Core/Component/MainMenu.ts | 256 ++++++++++++++++++ .../js/WoltLabSuite/Core/BootstrapFrontend.js | 3 +- .../WoltLabSuite/Core/Component/MainMenu.js | 213 +++++++++++++++ .../files/style/layout/pageHeader/system.scss | 11 + 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 ts/WoltLabSuite/Core/Component/MainMenu.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/MainMenu.js diff --git a/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts index 5587cc2b293..a06cffdc95e 100644 --- a/ts/WoltLabSuite/Core/BootstrapFrontend.ts +++ b/ts/WoltLabSuite/Core/BootstrapFrontend.ts @@ -22,6 +22,7 @@ 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"; interface BootstrapOptions { backgroundQueue: { @@ -104,6 +105,7 @@ export function setup(options: BootstrapOptions): void { if (hasSystemPageHeader) { setupDrawers(); + setupMainMenu(); } else { UiPageHeaderMenu.init(); } diff --git a/ts/WoltLabSuite/Core/Component/MainMenu.ts b/ts/WoltLabSuite/Core/Component/MainMenu.ts new file mode 100644 index 00000000000..2313730c21f --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/MainMenu.ts @@ -0,0 +1,256 @@ +/** + * 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)); + } + } + }); + + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !(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)) { + // Collapses the submenu instead of closing the drawer that contains the menu. + event.stopPropagation(); + + 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(); +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js index 6e2ad512e8a..218a139aa92 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", "./Component/Drawer"], 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) { +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"], 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) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -70,6 +70,7 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui } if (hasSystemPageHeader) { (0, Drawer_1.setup)(); + (0, MainMenu_1.setup)(); } else { UiPageHeaderMenu.init(); 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..4755319c71c --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/MainMenu.js @@ -0,0 +1,213 @@ +/** + * 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)); + } + } + }); + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !(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)) { + // Collapses the submenu instead of closing the drawer that contains the menu. + event.stopPropagation(); + 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(); + } +}); diff --git a/wcfsetup/install/files/style/layout/pageHeader/system.scss b/wcfsetup/install/files/style/layout/pageHeader/system.scss index fc930e70d1d..c0c73ff5288 100644 --- a/wcfsetup/install/files/style/layout/pageHeader/system.scss +++ b/wcfsetup/install/files/style/layout/pageHeader/system.scss @@ -86,6 +86,7 @@ > li { display: flex; + flex: 0 0 auto; position: relative; &[hidden] { @@ -249,6 +250,16 @@ display: block; } + /* Submenus of items that were moved into "More" are shown inline in its panel. */ + .mainMenu .boxMenuDepth1 .boxMenuDepth1 { + background-color: transparent; + box-shadow: none; + display: block; + min-width: 0; + padding: 0 0 0 12px; + position: static; + } + /* user panel */ .userPanel { flex: 0 0 auto; From 6237599fab5a646644985e05f9a575ecb4e63da0 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 16:10:58 +0200 Subject: [PATCH 13/32] Add the mobile drawer for the system page header with a delegated guest language switcher --- .../templates/system_pageHeaderMenu.tpl | 2 +- .../templates/system_pageHeaderUser.tpl | 2 +- ts/WoltLabSuite/Core/BootstrapFrontend.ts | 2 + .../Core/Component/LanguageSwitcher.ts | 41 ++ ts/WoltLabSuite/Core/Component/MainMenu.ts | 14 +- .../js/WoltLabSuite/Core/BootstrapFrontend.js | 3 +- .../Core/Component/LanguageSwitcher.js | 40 ++ .../WoltLabSuite/Core/Component/MainMenu.js | 12 +- .../files/style/layout/pageHeader/system.scss | 414 ++++++++++++++++++ 9 files changed, 520 insertions(+), 10 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Component/LanguageSwitcher.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/LanguageSwitcher.js diff --git a/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl b/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl index 676a5f71707..b5ea7ded8fe 100644 --- a/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl +++ b/com.woltlab.wcf/templates/system_pageHeaderMenu.tpl @@ -89,7 +89,7 @@ + + {* 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/ts/WoltLabSuite/Core/BootstrapFrontend.ts b/ts/WoltLabSuite/Core/BootstrapFrontend.ts index 570227187ad..1490679c58e 100644 --- a/ts/WoltLabSuite/Core/BootstrapFrontend.ts +++ b/ts/WoltLabSuite/Core/BootstrapFrontend.ts @@ -24,6 +24,7 @@ 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"; interface BootstrapOptions { backgroundQueue: { @@ -108,6 +109,7 @@ export function setup(options: BootstrapOptions): void { setupDrawers(); setupMainMenu(); setupLanguageSwitcher(); + setupUserMenuDrawer(); } else { UiPageHeaderMenu.init(); } diff --git a/ts/WoltLabSuite/Core/Component/UserMenuDrawer.ts b/ts/WoltLabSuite/Core/Component/UserMenuDrawer.ts new file mode 100644 index 00000000000..a1b7421b773 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/UserMenuDrawer.ts @@ -0,0 +1,240 @@ +/** + * 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. + new MutationObserver(() => { + if (activeTab !== undefined) { + refreshCounters(); + } + }).observe(document.querySelector(".userPanelItems")!, { characterData: true, childList: true, subtree: true }); + + // 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/User/Menu/ControlPanel.ts b/ts/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.ts index e1615e95227..21fb74533bb 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; 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/js/WoltLabSuite/Core/BootstrapFrontend.js b/wcfsetup/install/files/js/WoltLabSuite/Core/BootstrapFrontend.js index 8b806471277..f8d5e44f90c 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", "./Component/Drawer", "./Component/MainMenu", "./Component/LanguageSwitcher"], 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) { +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"], 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) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; @@ -72,6 +72,7 @@ define(["require", "exports", "tslib", "./BackgroundQueue", "./Bootstrap", "./Ui (0, Drawer_1.setup)(); (0, MainMenu_1.setup)(); (0, LanguageSwitcher_1.setup)(); + (0, UserMenuDrawer_1.setup)(); } else { UiPageHeaderMenu.init(); 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..b9b2ff80b63 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/UserMenuDrawer.js @@ -0,0 +1,190 @@ +/** + * 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. + new MutationObserver(() => { + if (activeTab !== undefined) { + refreshCounters(); + } + }).observe(document.querySelector(".userPanelItems"), { characterData: true, childList: true, subtree: true }); + // 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/User/Menu/ControlPanel.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/User/Menu/ControlPanel.js index 2a3a5fc9b96..874db859d11 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"); 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/style/layout/pageHeader/system.scss b/wcfsetup/install/files/style/layout/pageHeader/system.scss index 3a6520666d8..089632f0e06 100644 --- a/wcfsetup/install/files/style/layout/pageHeader/system.scss +++ b/wcfsetup/install/files/style/layout/pageHeader/system.scss @@ -55,7 +55,8 @@ .pageHeaderLogoSmall, .pageHeaderMenuMobile, .pageHeaderUserMobile, - .mainMenuMobileOnly { + .mainMenuMobileOnly, + .userMenuDrawer { display: none; } @@ -490,7 +491,8 @@ z-index: 1; } - .mainMenu { + .mainMenu, + .userMenuDrawer { background-color: var(--wcfUserMenuBackground); border-radius: 12px 0 0 12px; box-shadow: var(--wcfBoxShadow); @@ -545,12 +547,14 @@ } .mainMenuClose, + .userMenuDrawerClose, .mainMenu .boxMenuToggle, .mainMenuSettingsToggle fa-icon { color: var(--wcfUserMenuTextDimmed); } - .mainMenuClose { + .mainMenuClose, + .userMenuDrawerClose { align-items: center; background-color: transparent; border: 0; @@ -755,6 +759,138 @@ } } + /* The head and the tabs stay in place, the panel scrolls. */ + .userMenuDrawer { + overflow: hidden; + } + + .userMenuDrawerHead { + align-items: center; + column-gap: 14px; + display: flex; + flex: 0 0 auto; + padding: 8px 8px 12px 20px; + + .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; + flex-direction: column; + font: inherit; + font-size: 11px; + gap: 3px; + height: 52px; + justify-content: center; + min-width: 0; + padding: 0 4px; + position: relative; + + fa-icon { + --font-size: 18px; + --icon-size: 20px; + } + + &[aria-selected="true"] { + background-color: var(--wcfUserMenuBackground); + box-shadow: var(--wcfBoxShadowCard); + font-weight: 600; + } + } + + .userMenuDrawerTabLabel { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .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: 3px; + } + + .userMenuDrawerPanel { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + + > .userMenu { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + } + + .userMenuContentScrollable { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + + /* The head of the drawer already shows the user. */ + .userMenuControlPanel { + > .userMenuHeader, + > .userMenuHeader + .userMenuContent, + > .userMenuHeader + .userMenuContent + .userMenuContentDivider { + display: none; + } + } + } + .mainMenuFooter { border-top: 1px solid var(--wcfUserMenuBorder); display: flex; From 1f7329756e3dcb66d758b4df9916a6620517dcb7 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Mon, 5 Oct 2026 16:33:20 +0200 Subject: [PATCH 15/32] Add the in-bar search with a toggle to the system page header --- .../templates/system_pageHeader.tpl | 2 +- .../templates/system_pageHeaderSearch.tpl | 70 +++++++++ .../templates/system_pageHeaderUser.tpl | 12 +- ts/WoltLabSuite/Core/Bootstrap.ts | 7 +- ts/WoltLabSuite/Core/BootstrapFrontend.ts | 3 + .../Core/Component/PageHeaderSearch.ts | 147 ++++++++++++++++++ .../files/js/WoltLabSuite/Core/Bootstrap.js | 5 +- .../js/WoltLabSuite/Core/BootstrapFrontend.js | 4 +- .../Core/Component/PageHeaderSearch.js | 126 +++++++++++++++ .../files/style/layout/pageHeader/system.scss | 120 ++++++++++++-- 10 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 com.woltlab.wcf/templates/system_pageHeaderSearch.tpl create mode 100644 ts/WoltLabSuite/Core/Component/PageHeaderSearch.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/PageHeaderSearch.js diff --git a/com.woltlab.wcf/templates/system_pageHeader.tpl b/com.woltlab.wcf/templates/system_pageHeader.tpl index abbda3681a2..60df40a0d9e 100644 --- a/com.woltlab.wcf/templates/system_pageHeader.tpl +++ b/com.woltlab.wcf/templates/system_pageHeader.tpl @@ -25,7 +25,7 @@ {include file='system_pageHeaderMenu' sandbox=true} - {include file='pageHeaderSearch'} + {include file='system_pageHeaderSearch'} {include file='system_pageHeaderUser'} 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 index e9520efb2dd..f5d786a4ea2 100644 --- a/com.woltlab.wcf/templates/system_pageHeaderUser.tpl +++ b/com.woltlab.wcf/templates/system_pageHeaderUser.tpl @@ -1,8 +1,16 @@
    + {/foreach} +
    + {* layout *}

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

    @@ -433,18 +457,6 @@

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

    -
    -
    -
    - - {lang}wcf.acp.style.globals.pageHeaderLayout.description{/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 index d5493a73315..033edd30479 100644 --- 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 @@ -3,7 +3,7 @@ use wcf\system\WCF; $styleVariables = [ - ['pageHeaderLayout', 'classic', null], + ['pageHeaderLayout', 'logoTop', null], ['wcfHeaderMenuLinkBackgroundActive', 'rgba(36, 66, 95, 1)', 'rgba(54, 72, 96, 1)'], ['wcfHeaderMenuDropdownBackgroundActive', 'rgba(65, 121, 173, 1)', 'rgba(63, 82, 112, 1)'], ['wcfHeaderSearchBoxPlaceholder', 'rgba(218, 218, 218, 1)', 'rgba(207, 207, 207, 1)'], @@ -27,3 +27,18 @@ $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/lib/acp/form/StyleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php index 856e4550ed2..6babe5e1da2 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php @@ -369,13 +369,6 @@ public function readFormParameters() unset($this->variables['wcfFontFamilyFallback']); } - if ( - isset($this->variables['pageHeaderLayout']) - && !\in_array($this->variables['pageHeaderLayout'], Style::PAGE_HEADER_LAYOUTS, true) - ) { - unset($this->variables['pageHeaderLayout']); - } - $this->variables['useFluidLayout'] = isset($_POST['useFluidLayout']) ? 1 : 0; // style data @@ -503,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(); } @@ -828,13 +828,15 @@ protected function setVariables() 'individualScss', 'individualScssDarkMode', 'overrideScss', - 'pageHeaderLayout', 'pageLogoWidth', 'pageLogoHeight', 'useFluidLayout', 'wcfFontFamilyGoogle', 'wcfFontFamilyFallback', ]; + foreach (Style::getAvailableOptions() as $option) { + $this->specialVariables[] = $option::getVariableName(); + } EventHandler::getInstance()->fireAction($this, 'setVariables'); } @@ -944,12 +946,20 @@ public function assignVariables() I18nHandler::getInstance()->assignVariables(); + $styleOptions = []; + foreach (Style::getAvailableOptions() as $option) { + $variableName = $option::getVariableName(); + $styleOptions[$variableName] = [ + 'cases' => $option::cases(), + 'selected' => $option::tryFromString($this->variables[$variableName] ?? ''), + ]; + } + WCF::getTPL()->assign([ 'action' => 'add', 'authorName' => $this->authorName, 'authorURL' => $this->authorURL, 'availableFontFamilies' => $this->availableFontFamilies, - 'availablePageHeaderLayouts' => Style::PAGE_HEADER_LAYOUTS, 'availableTemplateGroups' => $this->availableTemplateGroups, 'availableUnits' => $this->availableUnits, 'colorCategories' => $this->colorCategories, @@ -961,6 +971,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/data/style/Style.class.php b/wcfsetup/install/files/lib/data/style/Style.class.php index 694d91721e2..edd486c0622 100644 --- a/wcfsetup/install/files/lib/data/style/Style.class.php +++ b/wcfsetup/install/files/lib/data/style/Style.class.php @@ -87,12 +87,6 @@ class Style extends DatabaseObject implements ITitledObject, \Stringable const DARK_MODE_PREFIX = "darkMode\0"; - /** - * Accepted values of the `pageHeaderLayout` style variable. - * @since 6.3 - */ - const PAGE_HEADER_LAYOUTS = ['classic', 'logoTop', 'logoBelow', 'logoInBar']; - /** * Per-style options stored as style variables. * @var list> @@ -218,6 +212,17 @@ public static function getTemplateVariantsFromVariables(array $variables): array return $templateVariants; } + /** + * 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`. * 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/lang/de.xml b/wcfsetup/install/lang/de.xml index 371b0993679..8ca66e2a0cb 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2958,12 +2958,13 @@ 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 cbd34a49894..ed8dca40490 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2888,12 +2888,13 @@ 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 5079b2f07ae..abb3bf7ce35 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -28,7 +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', 'classic', 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); From 4c27f4c29812a923d9b76ebf528d7c50a4790c1c Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 7 Oct 2026 18:09:42 +0200 Subject: [PATCH 28/32] Render style options as radio cards with schematic previews and map missing or deprecated values on import --- .../files/acp/style/acpStyleEditor.css | 74 +++++++++++++++++++ .../__styleOptionPreview_pageHeaderLayout.tpl | 51 +++++++++++++ .../install/files/acp/templates/styleAdd.tpl | 23 ++++-- .../files/lib/acp/form/StyleAddForm.class.php | 1 + .../lib/data/style/StyleEditor.class.php | 27 +++++++ .../style/option/IStyleOption.class.php | 11 +++ .../style/option/PageHeaderLayout.class.php | 8 +- wcfsetup/install/lang/de.xml | 6 +- wcfsetup/install/lang/en.xml | 6 +- 9 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 wcfsetup/install/files/acp/templates/__styleOptionPreview_pageHeaderLayout.tpl 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 dec379aea3f..eeb92ded147 100644 --- a/wcfsetup/install/files/acp/templates/styleAdd.tpl +++ b/wcfsetup/install/files/acp/templates/styleAdd.tpl @@ -385,19 +385,28 @@ {foreach from=$styleOptions key=variableName item=styleOption} -
    +
    {lang}wcf.acp.style.option.{$variableName}{/lang}
    - + + {lang}wcf.acp.style.option.{$variableName}.{$case->toString()}{/lang} + {if $case->isDeprecated()} + {lang}wcf.acp.style.option.deprecated{/lang} + {/if} + + + {lang}wcf.acp.style.option.{$variableName}.{$case->toString()}.description{/lang} + {/foreach} - + {if $errorField == $variableName} {lang}wcf.global.form.error.{$errorType}{/lang} {/if} - {if $styleOption[selected] !== null && $styleOption[selected]->isDeprecated()} - {lang}wcf.acp.style.option.deprecated{/lang} - {/if}
    {/foreach} diff --git a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php index 6babe5e1da2..97f3efb2173 100644 --- a/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/StyleAddForm.class.php @@ -951,6 +951,7 @@ public function assignVariables() $variableName = $option::getVariableName(); $styleOptions[$variableName] = [ 'cases' => $option::cases(), + 'previewTemplate' => '__styleOptionPreview_' . $variableName, 'selected' => $option::tryFromString($this->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 1ac11185ed8..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; @@ -637,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)); @@ -683,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/system/style/option/IStyleOption.class.php b/wcfsetup/install/files/lib/system/style/option/IStyleOption.class.php index 43ba132bd60..5e4808c2679 100644 --- a/wcfsetup/install/files/lib/system/style/option/IStyleOption.class.php +++ b/wcfsetup/install/files/lib/system/style/option/IStyleOption.class.php @@ -6,6 +6,10 @@ * A per-style option stored as a style variable. Each value selects the * template variant and the stylesheet that the style uses for this area. * + * The style editor renders each value with a schematic preview from the ACP + * template `__styleOptionPreview_`, which receives the value as + * `$styleOptionValue`. + * * @author Alexander Ebert * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License @@ -18,6 +22,13 @@ interface IStyleOption extends \UnitEnum */ 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. diff --git a/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php b/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php index b121092f452..b7c0fb93ce8 100644 --- a/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php +++ b/wcfsetup/install/files/lib/system/style/option/PageHeaderLayout.class.php @@ -12,10 +12,10 @@ */ enum PageHeaderLayout implements IStyleOption { - case Classic; case LogoTop; case LogoBelow; case LogoInBar; + case Classic; #[\Override] public static function getVariableName(): string @@ -23,6 +23,12 @@ public static function getVariableName(): string return 'pageHeaderLayout'; } + #[\Override] + public static function getFallback(): static + { + return self::Classic; + } + #[\Override] public static function fromString(string $value): static { diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 8ca66e2a0cb..cbe7d690377 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -2959,12 +2959,16 @@ 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.]]>
    + + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index ed8dca40490..0a75dbd975b 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -2889,12 +2889,16 @@ 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.]]>
    + + + From 7351bca5a2779faaa9ac242883ef0b160c67b528 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 7 Oct 2026 18:49:52 +0200 Subject: [PATCH 29/32] Updated the default style --- com.woltlab.wcf/defaultStyle.tar | Bin 20480 -> 20480 bytes defaultStyle/variables.xml | 4 +++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/com.woltlab.wcf/defaultStyle.tar b/com.woltlab.wcf/defaultStyle.tar index e519df514ac275e12c62ee80f86e65b07f92ae22..268e6e1bc065fb11cf4f5bf721f91da2e4ba9ffc 100644 GIT binary patch delta 456 zcmaKoPfEj35XN8BMH)o9@BnEKpfm5yyg`V;qKhuv2;G$~p5zgPYD)bB?s|lhT%dRa zHy%PcsvB~Na7>UXqZVnBih8fuXL ILN90iZxlRhzW@LL delta 465 zcmaiwJqiLr425@e3lZ@LEG*{+=68aPSbC6!y@p2+_X28Vt>+Ltl5xRiik&nefqd`f zovz_@4d*r`kyt@rc?UG+h$28qfk7gm7gQ0tRcN{pj*INl_mAhOt&^GMxc>~m3K7{* ziI}j*8gT)8nY?QP+;yMHdc56FU$@yNT&9B`(9d2jg@y&G=d6u`!I;~1ESj= - + + + From bedbf553608be6a66794ced4351b06447b04c672 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Wed, 7 Oct 2026 21:50:55 +0200 Subject: [PATCH 30/32] Extend the open submenu's fill to the bar edge and align user panel menus to the bar bottom --- .../files/style/layout/pageHeader/system.scss | 35 ++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/wcfsetup/install/files/style/layout/pageHeader/system.scss b/wcfsetup/install/files/style/layout/pageHeader/system.scss index 9333373dbec..9c70fb8f02a 100644 --- a/wcfsetup/install/files/style/layout/pageHeader/system.scss +++ b/wcfsetup/install/files/style/layout/pageHeader/system.scss @@ -160,6 +160,8 @@ } .mainMenu .boxMenu { + --mainMenuItemHeight: 36px; + align-items: center; display: flex; gap: 8px; @@ -193,7 +195,7 @@ column-gap: 6px; display: flex; font: inherit; - height: 36px; + height: var(--mainMenuItemHeight); padding: 0 12px; transition: background-color 0.12s ease, @@ -212,12 +214,26 @@ } /* 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. */ + 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. @@ -259,7 +275,7 @@ .mainMenu .mainMenuOverflowToggle { clip-path: none; cursor: pointer; - height: 36px; + height: var(--mainMenuItemHeight); margin-top: 0; opacity: 1; position: static; @@ -290,7 +306,7 @@ min-width: 176px; padding: 4px; position: absolute; - top: 100%; + top: calc(100% + (var(--wcfPageHeaderHeight) - var(--mainMenuItemHeight)) / 2); z-index: 1; :focus-visible { @@ -336,14 +352,16 @@ 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 { - align-items: center; column-gap: 4px; display: flex; list-style: none; @@ -357,7 +375,13 @@ } .userPanelItems > li { + align-items: center; + display: flex; position: relative; + + &[hidden] { + display: none; + } } .userPanelItems > li > :is(a, button) { @@ -372,6 +396,7 @@ height: 40px; justify-content: center; padding: 0; + position: relative; transition: background-color 0.12s ease, color 0.12s ease; From 85f2286831b6e3d127ef8fa5293ab3a2340d8cd7 Mon Sep 17 00:00:00 2001 From: Alexander Ebert Date: Thu, 8 Oct 2026 10:23:06 +0200 Subject: [PATCH 31/32] Add Style::usesClassicPageHeader() and switch the new user panel to regular flag/bell icons --- .../templates/__devtoolsLanguageChooser.tpl | 2 +- .../templates/system_pageHeaderUser.tpl | 4 ++-- .../install/files/lib/data/style/Style.class.php | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl b/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl index 7b1a0ca24d6..9183c08f0ed 100644 --- a/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl +++ b/com.woltlab.wcf/templates/__devtoolsLanguageChooser.tpl @@ -1,5 +1,5 @@ {* The `system_pageHeader` renders this chooser on the server. *} -{if ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS && !$__wcf->user->isGuest() && !'pageHeader'|in_array:$__wcf->getStyleHandler()->getStyle()->getTemplateVariants()} +{if ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS && !$__wcf->user->isGuest() && $__wcf->getStyleHandler()->getStyle()->usesClassicPageHeader()}