Improve Adaptive Navigation of Header Menu

This commit is contained in:
copecone committed 2026-05-05 13:55:29 +09:00
1 parent 2f5e79e300
commit 7363684b2f
7 files changed
+156 -80

No files matched your search

+2 -1
View File
@@ -57,6 +57,7 @@ export class App {
this.contentElement()!.nativeElement.tabIndex = 0;
this.contentElement()!.nativeElement.focus();
this.contentElement()!.nativeElement.tabIndex = -1;
// @ts-ignore
this.contentElement()!.nativeElement.tabIndex = undefined;
}
}
+18 -10
View File
@@ -1,21 +1,29 @@
<div class="menu-left">
<div #menuLeft class="menu-left">
<a class="brand" [routerLink]="['/']">
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
<span #brandText [class.hidden]="!hasExtraSpace()">
<span #brandText [class.hidden]="expandStage() < 2">
<strong>ADOFAI Editor Forum</strong>
</span>
</a>
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
</div>
</div>
<div class="menu-right" #menuRight>
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
<div #menuRight class="menu-right">
<div #rightMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
</div>
<button type="button" [class.hidden]="expandStage() > 0" [attr.inert]="expandStage() > 0 ? '' : null" class="material-symbols-outlined icon-40 menu" (click)="onHamburgerClick()" [attr.aria-label]="translation.menu.menu">
menu
</button>
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
settings
+39 -7
View File
@@ -4,11 +4,12 @@
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
justify-content: space-between;
gap: 0;
width: fit-content;
min-width: 100%;
height: 64px;
box-sizing: border-box;
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
@@ -26,11 +27,24 @@
align-items: center;
gap: 48px;
.menu-items {
display: flex;
flex-direction: row;
align-items: center;
gap: 48px;
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
& > * {
user-select: none;
}
.menu-item, a:not(.settings) {
.menu-item, a {
text-decoration: none;
font-size: 18px;
height: 40px;
@@ -62,7 +76,7 @@
}
}
.settings {
.settings, .menu {
border: none;
border-radius: 100%;
background-color: inherit;
@@ -71,6 +85,9 @@
position: relative;
z-index: 10;
cursor: pointer;
}
.settings {
transition: transform 0.3s ease;
&:hover {
@@ -78,6 +95,14 @@
}
}
.menu {
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
/* 구체적인 아이콘 예외 처리 */
.icon-40 {
height: auto;
@@ -85,6 +110,9 @@
}
.menu-left {
position: absolute;
left: 0;
justify-content: start;
padding: 12px;
@@ -104,7 +132,7 @@
img {
display: inline-block;
height: 20px;
padding: 10px 10px 10px 0;
padding: 10px 0;
}
strong {
@@ -113,9 +141,11 @@
span {
display: inline-block;
padding-left: 10px;
&.hidden {
display: none;
position: absolute;
top: -100000px;
pointer-events: none;
}
}
@@ -123,8 +153,10 @@
}
.menu-right {
flex-grow: 1;
position: absolute;
right: 0;
justify-content: flex-end;
padding: 12px 20px;
padding: 12px 20px 12px 12px;
}
}
+94 -62
View File
@@ -6,6 +6,7 @@ import {
inject,
NgZone,
OnDestroy,
OnInit,
signal,
viewChild,
} from '@angular/core';
@@ -21,101 +22,132 @@ import { SettingsService } from '../../services/settings.service';
templateUrl: './header-menu.html',
styleUrl: './header-menu.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.folded-menu]': 'expandStage() < 1',
},
})
export class HeaderMenu implements OnDestroy {
private popupService = inject(PopupService);
protected translation = inject(TranslationService).translation;
protected settings = inject(SettingsService);
menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
brandText = viewChild<ElementRef<HTMLElement>>('brandText');
private static readonly LOGICAL_GAP = 24;
hasExtraSpace = signal(true);
private resizeObserver?: ResizeObserver;
private brandTextObserver?: ResizeObserver;
private menuItemsObserver?: ResizeObserver;
private menuWidth: number = 99999;
private menuResizeObserver: ResizeObserver | null = null;
private textResizeObserver: ResizeObserver | null = null;
private menuLeft = viewChild<ElementRef<HTMLElement>>('menuLeft');
private leftMenuItems = viewChild<ElementRef<HTMLElement>>('leftMenuItems');
private menuLeftWidth: number = 0;
private leftMenuItemsWidth: number = 0;
private childWidths = new Map<Element, number>();
private cachedTextWidth = 0;
private cachedRightMenuContentWidth = 0;
private menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
private rightMenuItems = viewChild<ElementRef<HTMLElement>>('rightMenuItems');
private menuRightWidth: number = 0;
private rightMenuItemsWidth: number = 0;
constructor(private ngZone: NgZone) {
private brandText = viewChild<ElementRef<HTMLElement>>('brandText');
private brandTextWidth: number = 0;
expandStage = signal(2);
constructor(
private element: ElementRef,
private ngZone: NgZone,
) {
afterNextRender(() => {
const rightElement = this.menuRight()?.nativeElement;
const textElement = this.brandText()?.nativeElement;
if (!rightElement || !textElement) return;
this.ngZone.runOutsideAngular(() => {
// 1. Right Menu & Children Observer
this.menuResizeObserver = new ResizeObserver((entries) => {
let containerWidth = -1;
let contentChanged = false;
for (const entry of entries) {
if (entry.target === rightElement) {
containerWidth = entry.borderBoxSize[0].inlineSize;
continue;
}
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (this.childWidths.get(entry.target) !== width) {
this.childWidths.set(entry.target, width);
contentChanged = true;
}
if (width > 0)
switch (entry.target) {
case element.nativeElement:
this.menuWidth = width;
break;
case this.menuLeft()!.nativeElement:
this.menuLeftWidth = width;
break;
case this.menuRight()!.nativeElement:
this.menuRightWidth = width;
break;
}
}
if (contentChanged) this.updateCachedRightMenuContentWidth();
if (containerWidth !== -1) this.resize(containerWidth);
this.updateExpandStatus();
});
this.menuResizeObserver.observe(rightElement);
Array.from(rightElement.children).forEach((child) => {
this.childWidths.set(child, (child as HTMLElement).offsetWidth);
this.menuResizeObserver?.observe(child);
this.menuItemsObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (width > 0)
switch (entry.target) {
case this.leftMenuItems()!.nativeElement:
this.leftMenuItemsWidth = width;
break;
case this.rightMenuItems()!.nativeElement:
this.rightMenuItemsWidth = width;
break;
}
}
this.updateExpandStatus();
});
// 2. Brand Text Observer
this.textResizeObserver = new ResizeObserver((entries) => {
this.resizeObserver.observe(element.nativeElement);
this.resizeObserver.observe(this.menuLeft()!.nativeElement);
this.resizeObserver.observe(this.menuRight()!.nativeElement);
this.menuItemsObserver.observe(this.leftMenuItems()!.nativeElement);
this.menuItemsObserver.observe(this.rightMenuItems()!.nativeElement);
this.brandTextObserver = new ResizeObserver((entries) => {
const width = entries[0].borderBoxSize[0].inlineSize;
if (width > 0) this.cachedTextWidth = width;
if (width > 0) this.brandTextWidth = width;
this.updateExpandStatus();
});
this.textResizeObserver.observe(textElement);
// Initial calculation
this.cachedTextWidth = textElement.offsetWidth;
this.updateCachedRightMenuContentWidth();
this.resize(rightElement.offsetWidth);
this.brandTextObserver.observe(this.brandText()!.nativeElement);
});
});
}
ngOnDestroy() {
this.menuResizeObserver?.disconnect();
this.textResizeObserver?.disconnect();
this.childWidths.clear();
this.resizeObserver?.disconnect();
this.menuItemsObserver?.disconnect();
this.brandTextObserver?.disconnect();
}
private updateCachedRightMenuContentWidth() {
const gap = 48;
const widths = Array.from(this.childWidths.values());
if (widths.length === 0) {
this.cachedRightMenuContentWidth = 0;
return;
}
private updateExpandStatus() {
let menuContentWidth = this.menuLeftWidth + this.menuRightWidth;
let enoughSpace = this.menuWidth > menuContentWidth + HeaderMenu.LOGICAL_GAP;
let initialExpandStage = this.expandStage();
this.cachedRightMenuContentWidth =
widths.reduce((acc, w) => acc + w, 0) + (widths.length - 1) * gap;
if (enoughSpace) {
let extraSpaceToExpand = Number.MAX_VALUE;
switch (initialExpandStage) {
case 1:
extraSpaceToExpand = this.brandTextWidth;
break;
case 0:
extraSpaceToExpand = this.leftMenuItemsWidth + this.rightMenuItemsWidth + 48 - 40;
break;
}
if (
// Add a gap of 2px to prevent flickering
this.menuWidth >= menuContentWidth + HeaderMenu.LOGICAL_GAP + extraSpaceToExpand + 2
) {
this.expandStage.set(initialExpandStage + 1);
}
} else if (initialExpandStage > 0) this.expandStage.set(initialExpandStage - 1);
}
private resize(availableWidth: number) {
const hadSpace = this.hasExtraSpace();
onHamburgerClick(): void {
const buffer = 20 * 2 + 24; // Internal Padding + Logical Gap
const threshold = hadSpace ? 0 : this.cachedTextWidth;
const hasSpace = availableWidth > this.cachedRightMenuContentWidth + threshold + buffer;
if (hadSpace !== hasSpace) this.ngZone.run(() => this.hasExtraSpace.set(hasSpace));
}
onSettingsClick() {
+1
View File
@@ -16,6 +16,7 @@ export const EN = {
knownIssues: 'Known Issues',
login: 'Login',
signup: 'Signup',
menu: 'Menu',
},
common: {
selectOption: 'Select Option...',
+1
View File
@@ -18,6 +18,7 @@ export const JP: Translation = {
knownIssues: '既知の問題',
login: 'ログイン',
signup: '新規登録',
menu: 'メニュー',
},
common: {
selectOption: 'オプションを選択...',
+1
View File
@@ -18,6 +18,7 @@ export const KR: Translation = {
knownIssues: '알려진 문제',
login: '로그인',
signup: '회원가입',
menu: '메뉴',
},
common: {
selectOption: '옵션 선택...',