Compare commits
6
Commits
2f5e79e300
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe01a365a8 | ||
|
|
f68aa880db | ||
|
|
a2ec29adca | ||
|
|
0908064688 | ||
|
|
25a9bca291 | ||
|
|
7363684b2f |
No files matched your search
@@ -1,3 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
main:focus-visible {
|
main:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
+2
-1
@@ -57,6 +57,7 @@ export class App {
|
|||||||
this.contentElement()!.nativeElement.tabIndex = 0;
|
this.contentElement()!.nativeElement.tabIndex = 0;
|
||||||
this.contentElement()!.nativeElement.focus();
|
this.contentElement()!.nativeElement.focus();
|
||||||
|
|
||||||
this.contentElement()!.nativeElement.tabIndex = -1;
|
// @ts-ignore
|
||||||
|
this.contentElement()!.nativeElement.tabIndex = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ export class BrandAccent implements OnDestroy {
|
|||||||
|
|
||||||
drawGrid = input<boolean>(true);
|
drawGrid = input<boolean>(true);
|
||||||
|
|
||||||
|
private bgRendererScale = 1;
|
||||||
private canvasNativeSize: [number, number] = [0, 0];
|
private canvasNativeSize: [number, number] = [0, 0];
|
||||||
private bgGradient!: CanvasGradient;
|
private bgGradient!: CanvasGradient;
|
||||||
private gridGradient!: CanvasGradient;
|
private gridGradient!: CanvasGradient;
|
||||||
@@ -123,8 +124,8 @@ export class BrandAccent implements OnDestroy {
|
|||||||
canvas.height = height * dpr;
|
canvas.height = height * dpr;
|
||||||
|
|
||||||
if (this.offscreenCanvas) {
|
if (this.offscreenCanvas) {
|
||||||
this.offscreenCanvas.width = width;
|
this.offscreenCanvas.width = width * this.bgRendererScale;
|
||||||
this.offscreenCanvas.height = height;
|
this.offscreenCanvas.height = height * this.bgRendererScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.canvasNativeSize = [width, height];
|
this.canvasNativeSize = [width, height];
|
||||||
@@ -153,7 +154,12 @@ export class BrandAccent implements OnDestroy {
|
|||||||
if (w === 0 || h === 0) return;
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
this.bgGradient = this.ctx.createLinearGradient(0, 0, w, h);
|
this.bgGradient = this.ctx.createLinearGradient(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
w * this.bgRendererScale,
|
||||||
|
h * this.bgRendererScale,
|
||||||
|
);
|
||||||
this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
|
this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
|
||||||
this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
|
this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
|
||||||
}
|
}
|
||||||
@@ -177,15 +183,15 @@ export class BrandAccent implements OnDestroy {
|
|||||||
const h = this.canvasNativeSize[1];
|
const h = this.canvasNativeSize[1];
|
||||||
if (w === 0 || h === 0) return;
|
if (w === 0 || h === 0) return;
|
||||||
|
|
||||||
this.offscreenCtx.clearRect(0, 0, w, h);
|
this.offscreenCtx.clearRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
|
||||||
this.offscreenCtx.globalCompositeOperation = 'source-over';
|
this.offscreenCtx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
if (!isDark) {
|
if (!isDark) {
|
||||||
this.offscreenCtx.fillStyle = this.bgGradient;
|
this.offscreenCtx.fillStyle = this.bgGradient;
|
||||||
this.offscreenCtx.fillRect(0, 0, w, h);
|
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
|
||||||
} else {
|
} else {
|
||||||
this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)';
|
this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)';
|
||||||
this.offscreenCtx.fillRect(0, 0, w, h);
|
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
|
||||||
|
|
||||||
const ellipseDimension = [Math.max(100, w * 0.9421875), Math.max(200 / 3, h * (203 / 120))];
|
const ellipseDimension = [Math.max(100, w * 0.9421875), Math.max(200 / 3, h * (203 / 120))];
|
||||||
|
|
||||||
@@ -196,13 +202,17 @@ export class BrandAccent implements OnDestroy {
|
|||||||
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
|
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
|
||||||
this.rendererScale;
|
this.rendererScale;
|
||||||
|
|
||||||
this.offscreenCtx.filter = `blur(${blurScale}px)`;
|
this.offscreenCtx.filter = `blur(${blurScale * this.bgRendererScale}px)`;
|
||||||
|
|
||||||
this.offscreenCtx.beginPath();
|
this.offscreenCtx.beginPath();
|
||||||
this.offscreenCtx.ellipse(
|
this.offscreenCtx.ellipse(
|
||||||
ellipseX, ellipseY,
|
ellipseX * this.bgRendererScale,
|
||||||
ellipseDimension[0] / 2, ellipseDimension[1] / 2,
|
ellipseY * this.bgRendererScale,
|
||||||
0, 0, 2 * Math.PI,
|
(ellipseDimension[0] / 2) * this.bgRendererScale,
|
||||||
|
(ellipseDimension[1] / 2) * this.bgRendererScale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
2 * Math.PI,
|
||||||
);
|
);
|
||||||
this.offscreenCtx.fillStyle = 'oklch(0.2547 0.0913 29.02)';
|
this.offscreenCtx.fillStyle = 'oklch(0.2547 0.0913 29.02)';
|
||||||
this.offscreenCtx.fill();
|
this.offscreenCtx.fill();
|
||||||
@@ -234,7 +244,13 @@ export class BrandAccent implements OnDestroy {
|
|||||||
this.ctx.globalCompositeOperation = 'source-over';
|
this.ctx.globalCompositeOperation = 'source-over';
|
||||||
|
|
||||||
this.ctx.imageSmoothingEnabled = false;
|
this.ctx.imageSmoothingEnabled = false;
|
||||||
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
this.ctx.drawImage(
|
||||||
|
this.offscreenCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this.canvasNativeSize[0],
|
||||||
|
this.canvasNativeSize[1],
|
||||||
|
);
|
||||||
|
|
||||||
// this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
// this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
|
||||||
}
|
}
|
||||||
@@ -261,7 +277,7 @@ export class BrandAccent implements OnDestroy {
|
|||||||
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
|
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
|
||||||
2;
|
2;
|
||||||
|
|
||||||
const movingOffset = -(this.animation.gridSquareSize * this.animation.progress);
|
const movingOffset = this.settingsService.reduceAnimation() ? 0 : -(this.animation.gridSquareSize * this.animation.progress);
|
||||||
|
|
||||||
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
|
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
|
||||||
this.ctx.moveTo(
|
this.ctx.moveTo(
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ $default_animation_duration: 0.2s;
|
|||||||
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
|
||||||
border-radius: $border_radius;
|
border-radius: $border_radius;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
max-height: 240px;
|
max-height: 200px;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
|
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
|
||||||
animation: slideIn var(--animation-duration) $animation_timing;
|
animation: slideIn var(--animation-duration) $animation_timing;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ $default_animation_duration: 0.2s;
|
|||||||
}
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
width: 400px;
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ $default_animation_duration: 0.2s;
|
|||||||
animation: popup-enter var(--animation-duration) $animation_timing;
|
animation: popup-enter var(--animation-duration) $animation_timing;
|
||||||
|
|
||||||
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
|
|
||||||
&.variant-glass {
|
&.variant-glass {
|
||||||
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ $default_animation_duration: 0.4s;
|
|||||||
&:focus-within {
|
&:focus-within {
|
||||||
top: -40px;
|
top: -40px;
|
||||||
outline: 2px solid var(--selection-outline-color);
|
outline: 2px solid var(--selection-outline-color);
|
||||||
|
|
||||||
|
::ng-deep * {
|
||||||
|
// 내부에서 탭 이동 시에는 지연 없이 즉시 전환되도록 합니다.
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep * {
|
::ng-deep * {
|
||||||
@@ -48,9 +53,15 @@ $default_animation_duration: 0.4s;
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Component에서 포커스가 나갈 때(애니메이션 중) 마지막 포커스된 요소가 유지되도록 지연 시간을 둡니다.
|
||||||
|
transition: opacity 0s var(--animation-duration), width 0s var(--animation-duration);
|
||||||
|
interpolate-size: allow-keywords;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
width: unset;
|
width: auto;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
<div class="menu-left">
|
<div #menuLeft class="menu-left">
|
||||||
<a class="brand" [routerLink]="['/']">
|
<a class="brand" [routerLink]="['/']">
|
||||||
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
|
<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>
|
<strong>ADOFAI Editor Forum</strong>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
|
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
|
||||||
|
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-right" #menuRight>
|
<div #menuRight class="menu-right">
|
||||||
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
|
<div #rightMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
|
||||||
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
|
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
|
||||||
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
|
<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">
|
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
|
||||||
settings
|
settings
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: start;
|
justify-content: space-between;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
@@ -26,11 +27,24 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 48px;
|
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;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item, a:not(.settings) {
|
.menu-item, a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -42,6 +56,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
&.nav-link {
|
&.nav-link {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -62,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings {
|
.settings, .menu {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
@@ -71,6 +86,9 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -78,6 +96,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
&.hidden {
|
||||||
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 구체적인 아이콘 예외 처리 */
|
/* 구체적인 아이콘 예외 처리 */
|
||||||
.icon-40 {
|
.icon-40 {
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -85,6 +111,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-left {
|
.menu-left {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
@@ -104,7 +133,7 @@
|
|||||||
img {
|
img {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 10px 10px 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
@@ -113,9 +142,11 @@
|
|||||||
|
|
||||||
span {
|
span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
padding-left: 10px;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
display: none;
|
position: absolute;
|
||||||
|
top: -100000px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,8 +154,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-right {
|
.menu-right {
|
||||||
flex-grow: 1;
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px 12px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
afterNextRender,
|
afterNextRender,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
effect,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
NgZone,
|
NgZone,
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { PopupService } from '../../services/popup.service';
|
import { PopupService } from '../../services/popup.service';
|
||||||
import { SettingsPopup } from '../settings/settings';
|
import { SettingsPopup } from '../settings/settings';
|
||||||
|
import { NavigationMenu } from './navigation-menu';
|
||||||
import { TranslationService } from '../../services/translation.service';
|
import { TranslationService } from '../../services/translation.service';
|
||||||
import { SettingsService } from '../../services/settings.service';
|
import { SettingsService } from '../../services/settings.service';
|
||||||
|
|
||||||
@@ -21,101 +23,140 @@ import { SettingsService } from '../../services/settings.service';
|
|||||||
templateUrl: './header-menu.html',
|
templateUrl: './header-menu.html',
|
||||||
styleUrl: './header-menu.scss',
|
styleUrl: './header-menu.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: {
|
||||||
|
'[class.folded-menu]': 'expandStage() < 1',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class HeaderMenu implements OnDestroy {
|
export class HeaderMenu implements OnDestroy {
|
||||||
private popupService = inject(PopupService);
|
private popupService = inject(PopupService);
|
||||||
protected translation = inject(TranslationService).translation;
|
protected translation = inject(TranslationService).translation;
|
||||||
protected settings = inject(SettingsService);
|
protected settings = inject(SettingsService);
|
||||||
|
|
||||||
menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
|
private static readonly LOGICAL_GAP = 24;
|
||||||
brandText = viewChild<ElementRef<HTMLElement>>('brandText');
|
|
||||||
|
|
||||||
hasExtraSpace = signal(true);
|
private resizeObserver?: ResizeObserver;
|
||||||
|
private brandTextObserver?: ResizeObserver;
|
||||||
|
private menuItemsObserver?: ResizeObserver;
|
||||||
|
private menuWidth: number = 99999;
|
||||||
|
|
||||||
private menuResizeObserver: ResizeObserver | null = null;
|
private menuLeft = viewChild<ElementRef<HTMLElement>>('menuLeft');
|
||||||
private textResizeObserver: ResizeObserver | null = null;
|
private leftMenuItems = viewChild<ElementRef<HTMLElement>>('leftMenuItems');
|
||||||
|
private menuLeftWidth: number = 0;
|
||||||
|
private leftMenuItemsWidth: number = 0;
|
||||||
|
|
||||||
private childWidths = new Map<Element, number>();
|
private menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
|
||||||
private cachedTextWidth = 0;
|
private rightMenuItems = viewChild<ElementRef<HTMLElement>>('rightMenuItems');
|
||||||
private cachedRightMenuContentWidth = 0;
|
private menuRightWidth: number = 0;
|
||||||
|
private rightMenuItemsWidth: number = 0;
|
||||||
|
|
||||||
|
private brandText = viewChild<ElementRef<HTMLElement>>('brandText');
|
||||||
|
private brandTextWidth: number = 0;
|
||||||
|
|
||||||
|
expandStage = signal(2);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private element: ElementRef,
|
||||||
|
private ngZone: NgZone,
|
||||||
|
) {
|
||||||
|
effect(() => {
|
||||||
|
if (this.expandStage() > 0) {
|
||||||
|
this.popupService.close(NavigationMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
constructor(private ngZone: NgZone) {
|
|
||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
const rightElement = this.menuRight()?.nativeElement;
|
|
||||||
const textElement = this.brandText()?.nativeElement;
|
|
||||||
if (!rightElement || !textElement) return;
|
|
||||||
|
|
||||||
this.ngZone.runOutsideAngular(() => {
|
this.ngZone.runOutsideAngular(() => {
|
||||||
// 1. Right Menu & Children Observer
|
this.resizeObserver = new ResizeObserver((entries) => {
|
||||||
this.menuResizeObserver = new ResizeObserver((entries) => {
|
for (let entry of entries) {
|
||||||
let containerWidth = -1;
|
|
||||||
let contentChanged = false;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.target === rightElement) {
|
|
||||||
containerWidth = entry.borderBoxSize[0].inlineSize;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = entry.borderBoxSize[0].inlineSize;
|
const width = entry.borderBoxSize[0].inlineSize;
|
||||||
if (this.childWidths.get(entry.target) !== width) {
|
if (width > 0)
|
||||||
this.childWidths.set(entry.target, width);
|
switch (entry.target) {
|
||||||
contentChanged = true;
|
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();
|
this.updateExpandStatus();
|
||||||
if (containerWidth !== -1) this.resize(containerWidth);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.menuResizeObserver.observe(rightElement);
|
this.menuItemsObserver = new ResizeObserver((entries) => {
|
||||||
Array.from(rightElement.children).forEach((child) => {
|
for (let entry of entries) {
|
||||||
this.childWidths.set(child, (child as HTMLElement).offsetWidth);
|
const width = entry.borderBoxSize[0].inlineSize;
|
||||||
this.menuResizeObserver?.observe(child);
|
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.resizeObserver.observe(element.nativeElement);
|
||||||
this.textResizeObserver = new ResizeObserver((entries) => {
|
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;
|
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);
|
this.brandTextObserver.observe(this.brandText()!.nativeElement);
|
||||||
|
|
||||||
// Initial calculation
|
|
||||||
this.cachedTextWidth = textElement.offsetWidth;
|
|
||||||
this.updateCachedRightMenuContentWidth();
|
|
||||||
this.resize(rightElement.offsetWidth);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.menuResizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
this.textResizeObserver?.disconnect();
|
this.menuItemsObserver?.disconnect();
|
||||||
this.childWidths.clear();
|
this.brandTextObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCachedRightMenuContentWidth() {
|
private updateExpandStatus() {
|
||||||
const gap = 48;
|
let menuContentWidth = this.menuLeftWidth + this.menuRightWidth;
|
||||||
const widths = Array.from(this.childWidths.values());
|
let enoughSpace = this.menuWidth > menuContentWidth + HeaderMenu.LOGICAL_GAP;
|
||||||
if (widths.length === 0) {
|
let initialExpandStage = this.expandStage();
|
||||||
this.cachedRightMenuContentWidth = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cachedRightMenuContentWidth =
|
if (enoughSpace) {
|
||||||
widths.reduce((acc, w) => acc + w, 0) + (widths.length - 1) * gap;
|
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) {
|
onHamburgerClick(): void {
|
||||||
const hadSpace = this.hasExtraSpace();
|
this.popupService.open(NavigationMenu, {
|
||||||
|
position: { top: '84px', right: '20px' },
|
||||||
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() {
|
onSettingsClick() {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<app-popup [position]="position()" (closeRequest)="close()">
|
||||||
|
<div header-left class="menu-header-title">
|
||||||
|
<span class="material-symbols-outlined">menu</span>
|
||||||
|
<span>{{ translation.menu.menu }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="menu-content">
|
||||||
|
<div class="menu-section">
|
||||||
|
<a class="menu-item nav-link" [routerLink]="['/levels']" (click)="close()">{{ 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>
|
||||||
|
|
||||||
|
<hr class="menu-divider" />
|
||||||
|
|
||||||
|
<div class="menu-section">
|
||||||
|
<a class="menu-item nav-link" [routerLink]="['/known-issues']" (click)="close()">{{ 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>
|
||||||
|
</div>
|
||||||
|
</app-popup>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 276px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
border: none;
|
||||||
|
background-color: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
height: 40px;
|
||||||
|
padding-left: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.material-symbols-outlined {
|
||||||
|
font-size: 24px;
|
||||||
|
color: light-dark(oklch(0.4 0 0), oklch(0.8 0 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nav-link {
|
||||||
|
font-weight: 700;
|
||||||
|
color: oklch(0.6 0.15 250);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dummy-link {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, inject, input } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
|
||||||
|
import { PopupService } from '../../services/popup.service';
|
||||||
|
import { TranslationService } from '../../services/translation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'navigation-menu',
|
||||||
|
standalone: true,
|
||||||
|
imports: [PopupComponent, RouterLink],
|
||||||
|
templateUrl: './navigation-menu.html',
|
||||||
|
styleUrl: './navigation-menu.scss',
|
||||||
|
})
|
||||||
|
export class NavigationMenu {
|
||||||
|
private popupService = inject(PopupService);
|
||||||
|
protected translation = inject(TranslationService).translation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 팝업의 위치
|
||||||
|
*/
|
||||||
|
position = input<PopupPosition | 'center'>('center');
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.popupService.close(NavigationMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
|
padding-top: 4px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export const EN = {
|
|||||||
knownIssues: 'Known Issues',
|
knownIssues: 'Known Issues',
|
||||||
login: 'Login',
|
login: 'Login',
|
||||||
signup: 'Signup',
|
signup: 'Signup',
|
||||||
|
menu: 'Menu',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
selectOption: 'Select Option...',
|
selectOption: 'Select Option...',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const JP: Translation = {
|
|||||||
knownIssues: '既知の問題',
|
knownIssues: '既知の問題',
|
||||||
login: 'ログイン',
|
login: 'ログイン',
|
||||||
signup: '新規登録',
|
signup: '新規登録',
|
||||||
|
menu: 'メニュー',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
selectOption: 'オプションを選択...',
|
selectOption: 'オプションを選択...',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const KR: Translation = {
|
|||||||
knownIssues: '알려진 문제',
|
knownIssues: '알려진 문제',
|
||||||
login: '로그인',
|
login: '로그인',
|
||||||
signup: '회원가입',
|
signup: '회원가입',
|
||||||
|
menu: '메뉴',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
selectOption: '옵션 선택...',
|
selectOption: '옵션 선택...',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="container">
|
<brand-accent></brand-accent>
|
||||||
<brand-accent></brand-accent>
|
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
<h1>Component Test</h1>
|
<h1>Component Test</h1>
|
||||||
|
|
||||||
<section id="switch-test" class="feature-block">
|
<section id="switch-test" class="feature-block">
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:host > .container {
|
:host > .container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 600px;
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
@@ -8,7 +18,8 @@
|
|||||||
|
|
||||||
section#dropdown-test {
|
section#dropdown-test {
|
||||||
dropdown {
|
dropdown {
|
||||||
width: 324px;
|
width: 100%;
|
||||||
|
max-width: 324px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +46,12 @@ div.test-container {
|
|||||||
brand-accent {
|
brand-accent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
width: 100%;
|
top: -64px;
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
min-width: 100dvw;
|
||||||
|
height: calc(100% + 64px);
|
||||||
|
min-height: 100dvh;
|
||||||
z-index: -999999;
|
z-index: -999999;
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,8 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
overscroll-behavior-x: none;
|
overscroll-behavior-x: none;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-24 {
|
.icon-24 {
|
||||||
|
|||||||
Reference in new issue
Block a user