Compare commits

...
23 Commits
Author SHA1 Message Date
copecone fe01a365a8 Add Renderer Scale to Brand Accent 2026-05-10 15:12:29 +09:00
copecone f68aa880db Update Skip Link Animation 2026-05-09 01:33:22 +09:00
copecone a2ec29adca Update Brand Accent to Reflect Reduce Animation 2026-05-06 08:12:26 +09:00
copecone 0908064688 Adjust Brand Accent & Dropdown 2026-05-05 16:19:31 +09:00
copecone 25a9bca291 Add Navigation Menu Component 2026-05-05 15:17:52 +09:00
copecone 7363684b2f Improve Adaptive Navigation of Header Menu 2026-05-05 13:55:29 +09:00
copecone 2f5e79e300 Fix Level Card Buttons Selection Outline 2026-05-04 13:44:41 +09:00
copecone 912bd0d755 Fix Header Menu Link Color 2026-05-04 09:58:43 +09:00
copecone e01e628d94 Improve Accessibility by Improve Level Card Button Label 2026-05-04 00:10:04 +09:00
copecone 214dfb498e Fix Skip to Main Content Button 2026-05-03 17:21:02 +09:00
copecone f343f3e977 Update Build Version 2026-05-02 18:06:48 +09:00
copecone 1a49cd132b Add Skip Links Component 2026-05-02 18:04:10 +09:00
copecone 713f0ef75b Add Popup Focus Trap Feature 2026-05-02 17:01:54 +09:00
copecone 3bece368b3 Improve Language Accessibility 2026-04-25 14:00:25 +09:00
copecone 405f2808a0 Improve Readability of Level Card 2026-04-24 14:54:36 +09:00
copecone 88d2b6a546 Add Levels to Properly Test Level Card 2026-04-24 14:15:08 +09:00
copecone e86f500d8f Implement Level Card Prototype 2026-04-24 12:38:41 +09:00
copecone 31f78de70b Add Basic Prototype of Level Card Component 2026-04-24 08:01:02 +09:00
copecone 96894c81e0 Resolve IDE Compatibility Issue 2026-04-23 14:48:15 +09:00
copecone 1885ed3926 Add Settings Container Component 2026-04-23 14:13:33 +09:00
copecone 96f62b0e7c Update Dark Mode Color Palette 2026-04-23 09:53:00 +09:00
copecone 0586005191 Improve Settings Popup Accessibility 2026-04-21 15:54:00 +09:00
copecone 9092c97b9c Update Dropdown Focus Behavior to Be More Accessible 2026-04-21 15:52:20 +09:00
46 changed files with 1204 additions and 307 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트 # AefComponentTest Docker 이미지 빌드 및 추출 스크립트
$IMAGE_NAME = "aef-component-test" $IMAGE_NAME = "aef-component-test"
$TAG = "0.0.8" $TAG = "0.0.9"
$OUTPUT_FILE = "aef-component-test.tar" $OUTPUT_FILE = "aef-component-test.tar"
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
+8 -1
View File
@@ -1,5 +1,12 @@
<skip-links ngSkipHydration>
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
</skip-links>
<header-menu></header-menu> <header-menu></header-menu>
<router-outlet />
<main #content>
<router-outlet />
</main>
<div id="global-popup-root" class="popup-outlet"> <div id="global-popup-root" class="popup-outlet">
@for (popup of popupService.activePopups(); track popup.component) { @for (popup of popupService.activePopups(); track popup.component) {
+2
View File
@@ -2,9 +2,11 @@ import { Routes } from '@angular/router';
import { MainPage } from './pages/main/main'; import { MainPage } from './pages/main/main';
import { KnownIssuesPage } from './pages/known-issues/known-issues'; import { KnownIssuesPage } from './pages/known-issues/known-issues';
import { ClockPage } from './pages/clock/clock'; import { ClockPage } from './pages/clock/clock';
import { LevelPage } from './pages/levels/levels';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: MainPage }, { path: '', component: MainPage },
{ path: 'known-issues', component: KnownIssuesPage }, { path: 'known-issues', component: KnownIssuesPage },
{ path: 'clock', component: ClockPage }, { path: 'clock', component: ClockPage },
{ path: 'levels', component: LevelPage },
]; ];
+7
View File
@@ -0,0 +1,7 @@
:host {
min-height: 100dvh;
}
main:focus-visible {
outline: none;
}
+24 -6
View File
@@ -1,28 +1,33 @@
import { Component, inject, effect, PLATFORM_ID } from '@angular/core'; import { Component, inject, effect, PLATFORM_ID, ElementRef, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { HeaderMenu } from './features/header-menu/header-menu'; import { HeaderMenu } from './features/header-menu/header-menu';
import { PopupService } from './services/popup.service'; import { PopupService } from './services/popup.service';
import { SettingsService } from './services/settings.service'; import { SettingsService } from './services/settings.service';
import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common'; import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common';
import { SkipLinks } from './components/skip-links/skip-links';
import { TranslationService } from './services/translation.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet], imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })
export class App { export class App {
protected popupService = inject(PopupService); protected popupService = inject(PopupService);
protected settingsService = inject(SettingsService); protected settingsService = inject(SettingsService);
private platformId = inject(PLATFORM_ID); protected translation = inject(TranslationService).translation;
private platformID = inject(PLATFORM_ID);
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
private document = inject(DOCUMENT); private document = inject(DOCUMENT);
private rootHtmlElement!: HTMLElement; private rootHtmlElement!: HTMLElement;
constructor() { constructor() {
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformID)) {
this.rootHtmlElement = this.document.documentElement; this.rootHtmlElement = this.document.documentElement;
} else if (isPlatformBrowser(this.platformId)) { } else if (isPlatformBrowser(this.platformID)) {
this.rootHtmlElement = document.documentElement; this.rootHtmlElement = document.documentElement;
} }
@@ -37,9 +42,22 @@ export class App {
this.rootHtmlElement.classList.toggle('light-theme', !isDark); this.rootHtmlElement.classList.toggle('light-theme', !isDark);
const lang = this.settingsService.language(); const lang = this.settingsService.language();
this.rootHtmlElement.lang = lang.toLowerCase(); const langMap: Record<string, string> = {
KR: 'ko',
JP: 'ja',
EN: 'en',
};
this.rootHtmlElement.lang = langMap[lang] || 'en';
const reduceMotion = this.settingsService.reduceAnimation(); const reduceMotion = this.settingsService.reduceAnimation();
this.rootHtmlElement.classList.toggle('reduce-motion', reduceMotion); this.rootHtmlElement.classList.toggle('reduce-motion', reduceMotion);
} }
skipToContent() {
this.contentElement()!.nativeElement.tabIndex = 0;
this.contentElement()!.nativeElement.focus();
// @ts-ignore
this.contentElement()!.nativeElement.tabIndex = undefined;
}
} }
+35 -13
View File
@@ -1,9 +1,11 @@
import { import {
afterNextRender, ChangeDetectionStrategy, afterNextRender,
ChangeDetectionStrategy,
Component, Component,
effect, effect,
ElementRef, ElementRef,
inject, inject,
input,
NgZone, NgZone,
OnDestroy, OnDestroy,
viewChild, viewChild,
@@ -24,6 +26,9 @@ export class BrandAccent implements OnDestroy {
private ctx!: CanvasRenderingContext2D; private ctx!: CanvasRenderingContext2D;
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
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;
@@ -119,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];
@@ -149,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)');
} }
@@ -173,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))];
@@ -192,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();
@@ -230,11 +244,19 @@ 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]);
} }
if (!this.drawGrid()) return;
if (!isDark) { if (!isDark) {
this.ctx.globalCompositeOperation = 'multiply'; this.ctx.globalCompositeOperation = 'multiply';
} else { } else {
@@ -255,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(
+10 -9
View File
@@ -1,33 +1,34 @@
<ng-template #inputTemplate> <ng-template #inputTemplate>
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()" <div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()" contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
[attr.placeholder]="translation.common.selectOption" tabindex="-1" spellcheck="false" [attr.placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" tabindex="-1" spellcheck="false"
(input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"></div> (input)="onSearch($event)" (click)="handleInputClick($event)" (mousedown)="handleInputMouseDown()" (beforeinput)="preprocessInputContent($event)"
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
</ng-template> </ng-template>
<div class="dropdown-box"> <div class="dropdown-box">
@if (icon()) { @if (icon()?.trim()?.length ?? 0 > 0) {
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span> <span class="material-symbols-outlined icon-24 prefix-icon" aria-hidden="true">{{ icon() }}</span>
} }
@if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />} @if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />} @else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
@if (!isOpen()) { @if (!isOpen()) {
<span class="placeholder">{{ selectedLabel() }}</span> <span class="placeholder" [class.value-empty]="selectedOption() === undefined">{{ selectedLabel() }}</span>
} }
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span> <span class="material-symbols-outlined icon-24 chevron" aria-hidden="true" [class.open]="isOpen()">keyboard_arrow_down</span>
</div> </div>
@if (isOpen()) { @if (isOpen()) {
<div class="dropdown-container" tabindex="-1"> <div class="dropdown-container" tabindex="-1">
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'" tabindex="-1"> <ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
@for (option of filteredOptions(); track option.value; let i = $index) { @for (option of filteredOptions(); track option.value; let i = $index) {
<li <li
#optionItem #optionItem
class="dropdown-item" class="dropdown-item"
role="option" role="option"
[id]="dropdownId + '-option-' + i" [id]="dropdownID + '-option-' + i"
[class.selected]="option.value === value()" [class.selected]="option.value === value()"
[class.focused]="i === focusedIndex()" [class.focused]="i === focusedIndex()"
[attr.aria-selected]="option.value === value()" [attr.aria-selected]="option.value === value()"
+12 -14
View File
@@ -39,8 +39,8 @@ $default_animation_duration: 0.2s;
max-width: 100%; max-width: 100%;
&:focus-within { &:has(.dropdown-input.focus-mode:focus-visible) {
outline: 2px solid list.nth(library.$brand_palette, 5); outline: 2px solid var(--selection-outline-color);
outline-offset: 2px; outline-offset: 2px;
} }
} }
@@ -61,23 +61,24 @@ $default_animation_duration: 0.2s;
.placeholder { .placeholder {
text-overflow: ellipsis; text-overflow: ellipsis;
&.value-empty { opacity: 50%; }
} }
.dropdown-input:focus-visible { .dropdown-input {
outline: none; cursor: text;
}
.dropdown-input:empty:before { &:focus-visible { outline: none; }
&:empty:before {
content: attr(placeholder); content: attr(placeholder);
opacity: 50%; opacity: 50%;
} }
.dropdown-input:not(.focus-mode) { &:not(.focus-mode) {
caret-color: transparent; caret-color: transparent;
user-select: none; user-select: none;
} }
.dropdown-input.hidden { &.hidden {
position: absolute; position: absolute;
width: 0; width: 0;
@@ -85,9 +86,6 @@ $default_animation_duration: 0.2s;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
} }
.dropdown-input {
cursor: text;
} }
.placeholder, .dropdown-input { .placeholder, .dropdown-input {
@@ -123,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;
@@ -197,7 +195,7 @@ $default_animation_duration: 0.2s;
light-dark($hover_bg, $hover_dark_bg) light-dark($hover_bg, $hover_dark_bg)
calc(var(--hover-state) * 100%) calc(var(--hover-state) * 100%)
), ),
light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 11)) light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 12))
calc(var(--selected-state) * 100%) calc(var(--selected-state) * 100%)
); );
@@ -211,7 +209,7 @@ $default_animation_duration: 0.2s;
&.selected { &.selected {
--selected-state: 1; --selected-state: 1;
color: list.nth(library.$brand_palette, 5); color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
font-weight: 600; font-weight: 600;
} }
} }
+47 -23
View File
@@ -1,19 +1,19 @@
import { import {
Component, Component,
computed,
effect,
ElementRef,
HostListener,
inject,
input, input,
model, model,
output,
signal,
HostListener,
ElementRef,
computed,
OnInit, OnInit,
inject, output,
effect,
viewChild,
viewChildren,
untracked,
PLATFORM_ID, PLATFORM_ID,
signal,
untracked,
viewChild,
viewChildren
} from '@angular/core'; } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { TranslationService } from '../../services/translation.service'; import { TranslationService } from '../../services/translation.service';
@@ -33,13 +33,13 @@ export interface DropdownOption {
role: 'combobox', role: 'combobox',
'aria-haspopup': 'listbox', 'aria-haspopup': 'listbox',
'[attr.aria-expanded]': 'isOpen()', '[attr.aria-expanded]': 'isOpen()',
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null", '[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
'[attr.aria-activedescendant]': 'ariaActiveDescendant()', '[attr.aria-activedescendant]': 'ariaActiveDescendant()',
'[attr.aria-label]': 'ariaLabel()', '[attr.aria-label]': 'ariaLabel()',
'[class.open]': 'isOpen()', '[class.open]': 'isOpen()',
tabindex: '0', tabindex: '0',
'(click)': 'toggle()', '(click)': 'toggle()',
'(focus)': 'onFocus()', '(focusin)': 'onFocusIn($event)',
'(mousedown)': 'onMouseDown($event)', '(mousedown)': 'onMouseDown($event)',
'(keydown)': 'onKeyDown($event)', '(keydown)': 'onKeyDown($event)',
'(keydown.enter)': 'onEnter($event)', '(keydown.enter)': 'onEnter($event)',
@@ -56,7 +56,7 @@ export class Dropdown implements OnInit {
optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem'); optionItems = viewChildren<ElementRef<HTMLElement>>('optionItem');
options = input<Array<DropdownOption | string>>([]); options = input<Array<DropdownOption | string>>([]);
placeholder = input<string>('Select an option'); placeholder = input<string>('');
ariaLabel = input<string | undefined>(undefined); ariaLabel = input<string | undefined>(undefined);
icon = input<string>(); icon = input<string>();
defaultIndex = input<number>(); defaultIndex = input<number>();
@@ -71,13 +71,13 @@ export class Dropdown implements OnInit {
ariaActiveDescendant = computed(() => { ariaActiveDescendant = computed(() => {
return this.isOpen() && this.focusedIndex() >= 0 return this.isOpen() && this.focusedIndex() >= 0
? this.dropdownId + '-option-' + this.focusedIndex() ? this.dropdownID + '-option-' + this.focusedIndex()
: null; : null;
}); });
private static nextId = 0; private static nextID = 0;
private isLatestMouseDownInsideInput = false; private isLatestMouseDownInsideInput = false;
dropdownId = `dropdown-${Dropdown.nextId++}`; dropdownID = `dropdown-${Dropdown.nextID++}`;
private isLatestMouseDownInsideContainer = false; private isLatestMouseDownInsideContainer = false;
constructor(private element: ElementRef) { constructor(private element: ElementRef) {
@@ -90,7 +90,7 @@ export class Dropdown implements OnInit {
if (isDropdownOpen && index >= 0) { if (isDropdownOpen && index >= 0) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.scrollToIndex(index); this.scrollToIndex(index);
}) });
} }
}); });
@@ -166,22 +166,37 @@ export class Dropdown implements OnInit {
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value())); this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
setTimeout(() => { setTimeout(() => {
const searchInputElement = this.searchInput()!.nativeElement; let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
this.focusSearchInput();
searchInputElement.focus(); if (wasFocusVisible) this.inputFocusMode.set(true);
this.setCursorToEnd(searchInputElement);
}, 0); }, 0);
} }
} }
onFocus() { onInputFocusIn() {
if (this.isOpen()) this.element.nativeElement.tabIndex = -1;
}
onInputFocusOut() {
if (this.isOpen()) this.element.nativeElement.tabIndex = undefined;
}
onFocusIn(event: FocusEvent) {
if (this.element.nativeElement.contains(event.relatedTarget)) return;
if (this.isOpen()) { if (this.isOpen()) {
this.focusSearchInput();
this.inputFocusMode.set(true);
}
}
private focusSearchInput() {
const searchInputElement = this.searchInput()!.nativeElement; const searchInputElement = this.searchInput()!.nativeElement;
searchInputElement.focus(); searchInputElement.focus();
this.setCursorToEnd(searchInputElement); this.setCursorToEnd(searchInputElement);
} }
}
private checkIfInputFocus(event: Event) { private checkIfInputFocus(event: Event) {
let triggerInputFocus = this.searchInput()!.nativeElement.contains( let triggerInputFocus = this.searchInput()!.nativeElement.contains(
@@ -198,6 +213,7 @@ export class Dropdown implements OnInit {
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true }); if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536); if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
this.element.nativeElement.tabIndex = undefined;
this.focusedIndex.set(-1); this.focusedIndex.set(-1);
} }
@@ -234,6 +250,8 @@ export class Dropdown implements OnInit {
onArrowDown(event: Event) { onArrowDown(event: Event) {
event.preventDefault(); event.preventDefault();
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
if (!this.isOpen()) { if (!this.isOpen()) {
this.toggle(); this.toggle();
return; return;
@@ -250,6 +268,8 @@ export class Dropdown implements OnInit {
onArrowUp(event: Event) { onArrowUp(event: Event) {
event.preventDefault(); event.preventDefault();
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
if (!this.isOpen()) { if (!this.isOpen()) {
this.toggle(); this.toggle();
return; return;
@@ -332,8 +352,12 @@ export class Dropdown implements OnInit {
} }
} }
selectedOption = computed(() => {
return this.normalizedOptions().find((o) => o.value === this.value());
})
selectedLabel = computed(() => { selectedLabel = computed(() => {
const selected = this.normalizedOptions().find((o) => o.value === this.value()); const selected = this.selectedOption();
return selected ? selected.label : this.placeholder(); return selected ? selected.label : this.placeholder();
}); });
@@ -0,0 +1,22 @@
<section class="information">
<div class="soundtrack">
<span class="artist">{{ artist() }}</span>
<span class="track-name">{{ trackName() }}</span>
</div>
<div class="other">
<div class="creator">
<span>by</span>
<span>{{ creatorString() }}</span>
</div>
</div>
</section>
<section class="buttons">
<button [aria-label]="previewButtonLabel()">
<div class="material-symbols-outlined icon-40 button-icon">play_arrow</div>
</button>
<button [aria-label]="downloadButtonLabel()">
<div class="material-symbols-outlined icon-40 button-icon">download</div>
</button>
</section>
@@ -0,0 +1,162 @@
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$default_animation_duration: 0.2s;
@property --hover-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host {
width: 100%;
max-width: 400px;
height: 160px;
display: flex;
flex-direction: row;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
justify-content: space-between;
border-radius: 8px;
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
// overflow: hidden;
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
section.information {
width: calc(100% - 64px);
padding: 12px;
box-sizing: border-box;
flex-shrink: 0;
z-index: 1;
background-color: light-dark(white, black);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
// outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 1px solid;
.soundtrack {
display: flex;
flex-direction: column;
gap: 4px;
.artist {
display: inline-block;
font-size: 14px;
height: 20px;
align-content: center;
font-weight: 700;
white-space: nowrap;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
.track-name {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
align-self: stretch;
overflow: hidden;
text-overflow: ellipsis;
word-wrap: break-word;
font-size: 18px;
font-weight: 900;
line-height: 22px;
height: 44px;
}
}
.other {
display: flex;
width: 312px;
height: 68px;
padding: 3px 0;
flex-direction: column;
box-sizing: border-box;
justify-content: space-between;
align-items: flex-start;
.creator {
display: flex;
flex-direction: row;
font-size: 14px;
height: 20px;
gap: 3px;
span {
align-content: center;
&:nth-child(2) {
font-weight: 700;
}
}
}
}
}
section.buttons {
display: flex;
flex-direction: column;
align-self: stretch;
flex-shrink: 0;
flex-grow: 1;
margin-left: 0;
button {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
z-index: 10;
width: 100%;
height: 50%;
border: none;
transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing;
background-color: color-mix(
in oklch,
light-dark(oklch(1 0 0), oklch(0 0 0)),
light-dark(oklch(0.9702 0 0), oklch(0.25 0 0))
calc(var(--hover-state) * 100%)
);
padding: 0;
&:hover {
--hover-state: 1;
}
&:not(:focus-visible) {
box-shadow: -1px 0 0 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
}
&:focus-visible {
z-index: 11;
outline-offset: 0;
}
div.button-icon {
width: 40px;
height: 40px;
margin-right: 12px;
user-select: none;
}
&:first-child {
border-top-right-radius: 8px;
}
&:last-child {
border-bottom-right-radius: 8px;
}
}
}
}
@@ -0,0 +1,35 @@
import { Component, computed, inject, input } from '@angular/core';
import { TranslationService } from '../../services/translation.service';
@Component({
selector: 'level-card',
imports: [],
templateUrl: './level-card.html',
styleUrl: './level-card.scss',
host: {
role: 'article',
},
})
export class LevelCard {
private translationSvc = inject(TranslationService);
protected translation = this.translationSvc.translation;
artist = input.required<string>();
trackName = input.required<string>();
creator = input<string[]>([]);
creatorString = computed(() => this.creator().join(' & '));
previewButtonLabel = computed(() =>
this.translationSvc.interpolate(this.translation.levelCard.preview, {
artist: this.artist(),
trackName: this.trackName(),
}),
);
downloadButtonLabel = computed(() =>
this.translationSvc.interpolate(this.translation.levelCard.download, {
creator: this.creatorString()
}),
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
<div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()"> <div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()">
<div class="popup-container" <div #popupContainer class="popup-container"
[class]="'variant-' + variant()" [class]="'variant-' + variant()"
[class.custom-pos]="!isCenter" [class.custom-pos]="!isCenter"
[style.top]="customStyle.top" [style.top]="customStyle.top"
@@ -12,7 +12,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()">close</button> <button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
</div> </div>
</header> </header>
+6 -1
View File
@@ -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%));
@@ -52,6 +52,11 @@ $default_animation_duration: 0.2s;
&.custom-pos { &.custom-pos {
position: absolute; position: absolute;
} }
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
} }
@keyframes popup-enter { @keyframes popup-enter {
+111 -8
View File
@@ -1,4 +1,15 @@
import { Component, input, output, HostListener } from '@angular/core'; import {
Component,
input,
output,
HostListener,
ElementRef,
inject,
viewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { TranslationService } from '../../services/translation.service';
export type PopupPosition = { export type PopupPosition = {
top?: string; top?: string;
@@ -15,12 +26,16 @@ export type PopupVariant = 'glass' | 'solid';
templateUrl: './popup.html', templateUrl: './popup.html',
styleUrl: './popup.scss', styleUrl: './popup.scss',
host: { host: {
'role': 'dialog', role: 'dialog',
'aria-modal': 'true', 'aria-modal': 'true',
'tabindex': '-1' tabindex: '-1',
} },
}) })
export class PopupComponent { export class PopupComponent implements OnInit, OnDestroy {
private translation = inject(TranslationService).translation;
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
private previouslyFocusedElement: HTMLElement | null = null;
/** /**
* 팝업의 위치 (기본값은 'center') * 팝업의 위치 (기본값은 'center')
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다. * 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
@@ -32,14 +47,102 @@ export class PopupComponent {
*/ */
variant = input<PopupVariant>('glass'); variant = input<PopupVariant>('glass');
/**
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
*/
closeButtonLabel = input<string>(this.translation.common.close);
/** /**
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼) * 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
*/ */
closeRequest = output<void>(); closeRequest = output<void>();
@HostListener('document:keydown.escape') constructor(private element: ElementRef) {}
onKeydownHandler() {
this.onClose(); ngOnInit() {
if (typeof document !== 'undefined') {
this.previouslyFocusedElement = document.activeElement as HTMLElement;
}
setTimeout(() => {
this.element.nativeElement.focus();
this.popupContainer()!.nativeElement.tabIndex = 0;
this.popupContainer()!.nativeElement.focus();
}, 0);
}
ngOnDestroy() {
if (
this.previouslyFocusedElement &&
typeof this.previouslyFocusedElement.focus === 'function'
) {
this.previouslyFocusedElement.focus();
}
}
@HostListener('keydown', ['$event'])
onKeydownHandler(event: KeyboardEvent) {
if (event.key === 'Tab') this.handleTabKey(event);
if (event.key === 'Escape') this.onClose();
}
private handleTabKey(event: KeyboardEvent) {
const focusableElements = this.getOrderedFocusableElements(this.popupContainer()!.nativeElement);
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
const activeElement = document.activeElement;
if (event.shiftKey) {
// If Shift + Tab and focus is on the first element (or the container), move to the last element
if (
activeElement === firstElement ||
activeElement === this.popupContainer()!.nativeElement
) {
lastElement.focus();
event.preventDefault();
}
} else {
// If Tab and focus is on the last element, move to the first element
if (activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
getOrderedFocusableElements(container: HTMLElement) {
const selector = 'button, [href], input, select, textarea, [tabindex]';
const elements = Array.from(container.querySelectorAll(selector)).filter((el) => {
const htmlEl = el as HTMLElement;
// 비활성 상태거나 tabindex가 -1이면 제외
if ((htmlEl as HTMLInputElement).disabled || el.getAttribute('tabindex') === '-1')
return false;
// 화면에 보이지 않는 요소 제외 (필요 시 추가)
return !!(htmlEl.offsetWidth || htmlEl.offsetHeight || el.getClientRects().length);
});
return elements.sort((a, b) => {
const tabA = parseInt(a.getAttribute('tabindex') ?? '0', 10);
const tabB = parseInt(b.getAttribute('tabindex') ?? '0', 10);
// 1. 양수 tabindex 처리 (낮은 숫자 우선)
if (tabA > 0 && tabB > 0) return tabA - tabB;
if (tabA > 0) return -1;
if (tabB > 0) return 1;
// 2. 나머지는(0 또는 기본값) DOM 순서 유지
// querySelectorAll 결과가 이미 DOM 순서이므로 0 반환으로 충분
return 0;
});
} }
onClose() { onClose() {
@@ -0,0 +1 @@
<ng-content></ng-content>
@@ -0,0 +1,67 @@
$default_animation_duration: 0.4s;
:host {
display: flex;
position: fixed;
align-items: center;
justify-content: center;
top: -140px;
left: 20px;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
background: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
padding: calc(40px + 12px) 24px 12px;
z-index: 20000;
transition: top var(--animation-duration) cubic-bezier(0.18, 0.89, 0.32, 1.28);
border-radius: 0 0 12px 12px;
box-shadow: 0 0 40px oklch(0 0 0 / 15%);
vertical-align: middle;
font-weight: 700;
font-size: 18px;
height: 40px;
&:focus-within {
top: -40px;
outline: 2px solid var(--selection-outline-color);
::ng-deep * {
// 내부에서 탭 이동 시에는 지연 없이 즉시 전환되도록 합니다.
transition: none;
}
}
::ng-deep * {
appearance: none;
color: light-dark(black, white);
text-decoration: none;
background: none;
font: inherit;
border: none;
outline: none;
min-width: unset;
min-height: unset;
padding: 0;
margin: 0;
width: 0;
opacity: 0;
overflow: hidden;
// Component에서 포커스가 나갈 때(애니메이션 중) 마지막 포커스된 요소가 유지되도록 지연 시간을 둡니다.
transition: opacity 0s var(--animation-duration), width 0s var(--animation-duration);
interpolate-size: allow-keywords;
&:focus {
width: auto;
opacity: 1;
transition: none;
}
}
}
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
@Component({
selector: 'skip-links',
imports: [],
templateUrl: './skip-links.html',
styleUrl: './skip-links.scss',
host: {
'tabindex': '-1',
}
})
export class SkipLinks {}
+2 -2
View File
@@ -30,14 +30,14 @@ $default_animation_duration: 0.2s;
background-color: color-mix( background-color: color-mix(
in oklch, in oklch,
light-dark($off_bg, $off_dark_bg), light-dark($off_bg, $off_dark_bg),
light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 8)) light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6))
calc(var(--animation-state) * 100%) calc(var(--animation-state) * 100%)
); );
box-shadow: 0 0 0 1px color-mix( box-shadow: 0 0 0 1px color-mix(
in oklch, in oklch,
light-dark($border_color, $border_dark_color), light-dark($border_color, $border_dark_color),
light-dark($border_color, list.nth(library.$brand_palette, 10)) light-dark($border_color, list.nth(library.$brand_palette, 9))
calc(var(--animation-state) * 100%) calc(var(--animation-state) * 100%)
) inset; ) inset;
+12 -4
View File
@@ -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>
<span class="menu-item dummy-link">{{ translation.menu.levels }}</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.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span> <span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span> <span class="menu-item dummy-link">{{ translation.menu.about }}</span>
</div>
</div> </div>
<div class="menu-right" #menuRight> <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> <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"><strong>{{ translation.menu.login }}</strong></span>
<span class="menu-item dummy-link">{{ translation.menu.signup }}</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
+41 -8
View File
@@ -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,10 +56,11 @@
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;
color: oklch(0.5 0.15 250); color: oklch(0.6 0.15 250);
transition: opacity 0.3s; transition: opacity 0.3s;
&:hover { &:hover {
@@ -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;
} }
} }
+102 -61
View File
@@ -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; 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;
} }
this.cachedRightMenuContentWidth = if (
widths.reduce((acc, w) => acc + w, 0) + (widths.length - 1) * gap; // 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);
}
}
+2
View File
@@ -0,0 +1,2 @@
<label class="category-header">{{ headerLabel() }}</label>
<ng-content></ng-content>
+17
View File
@@ -0,0 +1,17 @@
:host {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
justify-content: flex-start;
margin: 0; padding: 0;
border: none;
}
label {
display: block;
font-weight: 500;
font-size: 14px;
height: 15px;
// color: oklch(0.6830 0 0); // #999999
}
+17
View File
@@ -0,0 +1,17 @@
@if (settingsType() == SettingsType.Switch) {
<div class="settings-name">
@if (labelIcon()?.trim()?.length ?? 0 > 0) {
<span class="material-symbols-outlined icon-24 settings-icon" aria-hidden="true">{{ labelIcon() }}</span>
}
<label [id]="labelID">{{ labelText() }}</label>
</div>
<switch [isActive]="value()" (toggle)="onChange.emit($event)" [aria-labelledby]="labelID"></switch>
} @else if (settingsType() == SettingsType.Dropdown) {
<label [id]="labelID" class="hidden-label">{{ labelText() }}</label>
<dropdown [options]="options()"
[value]="value()"
(selectionChange)="onChange.emit($event)"
[aria-labelledby]="labelID"
[placeholder]="placeholder()?.trim() ?? ''"
[icon]="labelIcon()"></dropdown>
}
+55
View File
@@ -0,0 +1,55 @@
:host {
width: 100%;
display: flex;
&:has(> switch) {
height: 32px;
&:nth-child(1 of .settings-container) {
margin-top: 4px;
}
}
&:has(> dropdown) {
height: 40px;
}
flex-direction: row;
align-items: center;
justify-content: space-between;
.settings-name {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
gap: 8px;
span, label {
display: inline-block;
font-size: 18px;
&.settings-icon {
display: inline-block;
width: 24px;
height: 24px;
font-size: 24px;
/*
line-height: 24px;
*/
}
}
}
dropdown {
width: 324px;
}
.hidden-label {
width: 0;
height: 0;
overflow: hidden;
}
}
-47
View File
@@ -1,47 +0,0 @@
<app-popup (closeRequest)="close()" [position]="position()">
<div header-left class="profile-section">
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
<span>{{ settingsService.userName() }}</span>
</div>
<div class="settings-content">
<section class="settings-category">
<label class="category-header">{{ translation.settings.language }}</label>
<div class="settings-container">
<dropdown [options]="languages"
[value]="settingsService.language()"
(selectionChange)="onLanguageChange($event)"
icon="language"></dropdown>
</div>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
<span>{{ translation.settings.romanisation }}</span>
</div>
<switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch>
</div>
</section>
<section class="settings-category">
<label class="category-header">{{ translation.settings.appearance }}</label>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
<span>{{ translation.settings.darkMode }}</span>
</div>
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
</div>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">motion_photos_paused</span>
<span>{{ translation.settings.reduceAnimation }}</span>
</div>
<switch [isActive]="settingsService.reduceAnimation()" (toggle)="onReduceAnimationChange($event)"></switch>
</div>
</section>
</div>
</app-popup>
-92
View File
@@ -1,92 +0,0 @@
:host {
display: block;
user-select: none;
}
.profile-section {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
.avatar {
border-radius: 50%;
outline: 1px solid oklch(0 0 0 / 20%);
object-fit: cover;
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 12px;
section.settings-category {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
justify-content: flex-start;
margin: 0; padding: 0;
border: none;
label {
display: block;
font-weight: 500;
font-size: 14px;
height: 15px;
// color: oklch(0.6830 0 0); // #999999
}
& > .settings-container {
width: 100%;
display: flex;
&:has(> switch) {
height: 32px;
&:nth-child(1 of .settings-container) {
margin-top: 4px;
}
}
&:has(> dropdown) {
height: 40px;
}
flex-direction: row;
align-items: center;
justify-content: space-between;
.settings-name {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
gap: 8px;
span {
display: inline-block;
font-size: 18px;
&.settings-icon {
display: inline-block;
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
}
}
}
}
dropdown {
width: 324px;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<app-popup (closeRequest)="close()" [position]="position()">
<div header-left class="profile-section">
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
<span>{{ settingsService.isGuest() ? translation.settings.guest : settingsService.userName() }}</span>
</div>
<div class="settings-content">
<settings-category [headerLabel]="translation.settings.language">
<settings-container
[settingsType]="SettingsType.Dropdown"
[options]="languages"
[value]="settingsService.language()"
[labelText]="translation.settings.language" labelIcon="language"
(onChange)="onLanguageChange($event)">
</settings-container>
<settings-container
[settingsType]="SettingsType.Switch"
[value]="settingsService.romanisation()"
[labelText]="translation.settings.romanisation" labelIcon="match_case"
(onChange)="onRomanisationChange($event)">
</settings-container>
</settings-category>
<settings-category [headerLabel]="translation.settings.appearance">
<settings-container
[settingsType]="SettingsType.Switch"
[value]="settingsService.darkMode()"
[labelText]="translation.settings.darkMode" labelIcon="dark_mode"
(onChange)="onDarkModeChange($event)">
</settings-container>
<settings-container
[settingsType]="SettingsType.Switch"
[value]="settingsService.reduceAnimation()"
[labelText]="translation.settings.reduceAnimation" labelIcon="motion_photos_paused"
(onChange)="onReduceAnimationChange($event)">
</settings-container>
</settings-category>
</div>
</app-popup>
+29
View File
@@ -0,0 +1,29 @@
:host {
display: block;
user-select: none;
}
.profile-section {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
.avatar {
border-radius: 50%;
outline: 1px solid oklch(0 0 0 / 20%);
object-fit: cover;
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
padding-bottom: 12px;
}
+53 -4
View File
@@ -1,4 +1,4 @@
import { Component, inject, input } from '@angular/core'; import { Component, inject, input, output } from '@angular/core';
import { PopupComponent, PopupPosition } from '../../components/popup/popup'; import { PopupComponent, PopupPosition } from '../../components/popup/popup';
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown'; import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { Switch } from '../../components/switch/switch'; import { Switch } from '../../components/switch/switch';
@@ -7,12 +7,59 @@ import { PopupService } from '../../services/popup.service';
import { SettingsService, Language } from '../../services/settings.service'; import { SettingsService, Language } from '../../services/settings.service';
import { TranslationService } from '../../services/translation.service'; import { TranslationService } from '../../services/translation.service';
export enum SettingsType {
Switch = 'switch', Dropdown = 'dropdown',
}
@Component({
selector: 'settings-category',
standalone: true,
templateUrl: './category.html',
styleUrl: './category.scss',
})
export class SettingsCategory {
headerLabel = input<string>();
}
@Component({
selector: 'settings-container',
standalone: true,
templateUrl: './container.html',
styleUrl: './container.scss',
imports: [Switch, Dropdown],
host: {
// For IDE Compatibility
'[class.settings-container]': 'true',
},
})
export class SettingsContainer {
settingsType = input.required<SettingsType>();
protected readonly SettingsType = SettingsType;
labelIcon = input<string>();
labelText = input.required<string>();
value = input.required<any>();
onChange = output<any>();
placeholder = input<string>();
options = input<any>();
private static nextID = 0;
labelID = `settings-${SettingsContainer.nextID++}-label`;
}
@Component({ @Component({
selector: 'settings-popup', selector: 'settings-popup',
standalone: true, standalone: true,
imports: [PopupComponent, Dropdown, Switch, NgOptimizedImage], imports: [
templateUrl: './popup.html', PopupComponent,
styleUrl: './popup.scss', NgOptimizedImage,
SettingsCategory,
SettingsContainer,
],
templateUrl: './settings.html',
styleUrl: './settings.scss',
}) })
export class SettingsPopup { export class SettingsPopup {
private popupService = inject(PopupService); private popupService = inject(PopupService);
@@ -52,4 +99,6 @@ export class SettingsPopup {
close() { close() {
this.popupService.close(SettingsPopup); this.popupService.close(SettingsPopup);
} }
protected readonly SettingsType = SettingsType;
} }
+6
View File
@@ -16,11 +16,17 @@ 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...',
close: 'Close', close: 'Close',
save: 'Save', save: 'Save',
skipToContent: 'Skip to main content',
},
levelCard: {
preview: '{{trackName}} by {{artist}} Preview',
download: "{{creator}}'s Level Download",
}, },
}; };
+7 -1
View File
@@ -18,10 +18,16 @@ export const JP: Translation = {
knownIssues: '既知の問題', knownIssues: '既知の問題',
login: 'ログイン', login: 'ログイン',
signup: '新規登録', signup: '新規登録',
menu: 'メニュー',
}, },
common: { common: {
selectOption: '選択...', selectOption: 'オプションを選択...',
close: '閉じる', close: '閉じる',
save: '保存', save: '保存',
skipToContent: '本文へスキップ',
},
levelCard: {
preview: '{{artist}}の{{trackName}} プレビュー',
download: '{{creator}}のレベルをダウンロード',
}, },
}; };
+7 -1
View File
@@ -18,10 +18,16 @@ export const KR: Translation = {
knownIssues: '알려진 문제', knownIssues: '알려진 문제',
login: '로그인', login: '로그인',
signup: '회원가입', signup: '회원가입',
menu: '메뉴',
}, },
common: { common: {
selectOption: '선택...', selectOption: '옵션 선택...',
close: '닫기', close: '닫기',
save: '저장', save: '저장',
skipToContent: '본문으로 건너뛰기',
},
levelCard: {
preview: '{{artist}}의 {{trackName}} 미리듣기',
download: '{{creator}}의 레벨 다운로드',
}, },
}; };
+40
View File
@@ -0,0 +1,40 @@
<h1>Level Card Test</h1>
<div class="container">
<level-card
artist="HyuN"
trackName="Disorder (Low VFX)"
[creator]="['YOON']"></level-card>
<level-card
artist="Cinamoro"
trackName="Little Rabbit and Deliverance"
[creator]="['한가지']"></level-card>
<level-card
artist="linear ring"
trackName="Dream By Coma"
[creator]="['Ryuha']"></level-card>
<level-card
artist="Plum"
trackName="Champagne Pop"
[creator]="['Zizak']"></level-card>
<level-card
artist="JinoBeats ft. JinoBeats"
trackName="~ Black Forsaken Wings ~"
[creator]="['Sakura']"></level-card>
<level-card
artist="seatrus"
trackName="M A X I M U M B L A S T E R"
[creator]="['TraZ']"></level-card>
<level-card
artist="MegaSphere"
trackName="Inferno"
[creator]="['-K']"></level-card>
<level-card
artist="Rinne"
trackName="Aurelia"
[creator]="['토끼로봇']"></level-card>
<level-card
artist="Sot-C"
trackName="Escalation"
[creator]="['Chartic']"></level-card>
</div>
+17
View File
@@ -0,0 +1,17 @@
@scope(:host) {
width: 100%;
text-align: center;
}
:host > h1 {
margin-top: 40px;
}
:host > .container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 40px;
max-width: calc(40px * 2 + 400px * 3);
margin: 0 auto;
}
+15
View File
@@ -0,0 +1,15 @@
import { Component, signal } from '@angular/core';
import { Switch } from '../../components/switch/switch';
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { BrandAccent } from '../../components/brand-accent/brand-accent';
import { LevelCard } from '../../components/level-card/level-card';
@Component({
selector: 'app-levels',
standalone: true,
imports: [LevelCard],
templateUrl: './levels.html',
styleUrl: './levels.scss',
})
export class LevelPage {
}
+2 -2
View File
@@ -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">
+18 -6
View File
@@ -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;
} }
+1
View File
@@ -23,6 +23,7 @@ export class SettingsService {
romanisation = computed(() => this._romanisation()); romanisation = computed(() => this._romanisation());
userName = computed(() => this._userName()); userName = computed(() => this._userName());
reduceAnimation = computed(() => this._reduceAnimation()); reduceAnimation = computed(() => this._reduceAnimation());
isGuest = computed(() => true);
constructor() { constructor() {
this.request = inject(REQUEST); this.request = inject(REQUEST);
+10
View File
@@ -29,4 +29,14 @@ export class TranslationService {
readonly translation = new Proxy({} as Translation, { readonly translation = new Proxy({} as Translation, {
get: (_, prop) => this._translation()[prop as keyof Translation] get: (_, prop) => this._translation()[prop as keyof Translation]
}); });
/**
* 문자열 내의 플레이스홀더(예: {{name}})를 실제 값으로 치환합니다.
*/
interpolate(text: string, params: Record<string, string | number>): string {
return Object.entries(params).reduce(
(acc, [key, value]) => acc.replace(new RegExp(`{{${key}}}`, 'g'), String(value)),
text
);
}
} }
+7 -1
View File
@@ -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 {
@@ -118,7 +120,11 @@ section.feature-block {
margin: 0 auto; margin: 0 auto;
} }
* {
--selection-outline-color: light-dark(#{list.nth(library.$brand_palette, 5)}, #{list.nth(library.$brand_palette, 6)});
}
*:focus-visible { *:focus-visible {
outline: 2px solid list.nth(library.$brand_palette, 5); outline: 2px solid var(--selection-outline-color);
outline-offset: 2px; outline-offset: 2px;
} }
+16
View File
@@ -12,5 +12,21 @@ $brand_palette: [
oklch(0.4509 0.1688 29.86), // #9E190E oklch(0.4509 0.1688 29.86), // #9E190E
oklch(0.3897 0.1391 29.75), // #7F180F oklch(0.3897 0.1391 29.75), // #7F180F
oklch(0.2547 0.0913 29.02), // #450805 oklch(0.2547 0.0913 29.02), // #450805
oklch(0.1752 0.0717 29.02), // #280000
]; ];
/*
$brand_palette: [
oklch(0.9999 0.2237 35.33),
oklch(0.9090 0.2237 35.33),
oklch(0.8181 0.2237 35.33),
oklch(0.7272 0.2237 35.33),
oklch(0.6363 0.2237 35.33),
oklch(0.5454 0.2237 35.33),
oklch(0.4545 0.2237 35.33),
oklch(0.3636 0.2237 35.33),
oklch(0.2727 0.2237 35.33),
oklch(0.1818 0.2237 35.33),
oklch(0.0909 0.2237 35.33),
];
*/