Compare commits

...
96 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
copecone c21845a579 Remove Frame Delay from Focus Move Code 2026-04-20 12:03:48 +09:00
copecone 5de6d111f2 Make Dropdown to Process List Scroll Properly 2026-04-20 11:59:00 +09:00
copecone 0baba36f7f Make Dropdown to Clear Its Input History Everytime 2026-04-20 10:02:33 +09:00
copecone dceac30c39 Update Dropdown to Maintain Input Format Consistency 2026-04-20 09:06:04 +09:00
copecone 9c7f3cf542 Update Dropdown to Improve Support for Unicode Characters 2026-04-20 01:17:35 +09:00
copecone 35d20eab76 Fix Popup to Don't Interfere Other Component Interactions 2026-04-19 20:13:30 +09:00
copecone 3a415a2525 Polish Mouse Click Response 2026-04-19 19:55:12 +09:00
copecone 3f33c0dfa5 Add Dropdown Search Feature 2026-04-19 18:51:46 +09:00
copecone 7a0af6cc4a Update Build Version 2026-04-17 13:08:33 +09:00
copecone 0c97bc696d Update Some Packages 2026-04-17 13:05:27 +09:00
copecone 4605fd7ee4 Add Header Menu Brand Text Hidden Mode 2026-04-17 12:42:56 +09:00
copecone be48e8ef89 Change Brand Accent Change Detection Strategy 2026-04-17 12:42:19 +09:00
copecone 02f936b345 Improve Brand Accent Renderer 2026-04-17 11:38:34 +09:00
copecone 0801ed3c70 Remove Popup Category Label Color to Ensure Enough Contrast from Content 2026-04-12 20:37:04 +09:00
copecone b0ae9717f5 Update README.md 2026-04-10 01:55:32 -04:00
copecone ae9e86461e Fix Light Mode Brand Accent Rendering 2026-04-09 22:08:01 +09:00
copecone 94ca7defb2 Optimize Brand Accent Component By Caching Dark Mode Ellipse 2026-04-09 16:10:45 +09:00
copecone ed7ecb2994 Update Build Version 2026-04-09 08:22:23 +09:00
copecone 9fbd2db197 Optimize Brand Accent Component 2026-04-08 13:00:55 +09:00
copecone f610849c17 Add Dark Mode Support for Clock Component 2026-04-08 12:41:55 +09:00
copecone 57588d4604 Fix Color Animation Implementation 2026-04-07 16:02:58 +09:00
copecone 7cf7c49937 Fix Dropdown Item Style Implementation 2026-04-07 13:04:57 +09:00
copecone 81bfd4a2b8 Fix Popup Implementation 2026-04-07 12:04:49 +09:00
copecone 44b7d6097e Fix Dark Theme Application Architecture 2026-04-07 11:58:12 +09:00
copecone 1f5453f755 Add Dark Mode Support for Switch Component 2026-04-07 11:27:55 +09:00
copecone f97e9592ad Add Dark Mode Support for Brand Accent Component 2026-04-07 11:27:49 +09:00
copecone 4dc35c5aac Add Dark Mode Support for Global CSS 2026-04-07 11:27:40 +09:00
copecone 98d122c8ef Increase Update Rate 2026-04-05 21:48:48 +09:00
copecone 29f79678e8 Remove Safari from Browser List 2026-04-03 15:46:04 +09:00
copecone e6b6947e08 Optimize Brand Accent Component 2026-04-03 15:22:20 +09:00
copecone 33e8b61065 Add Some Responsive Features 2026-04-03 13:06:21 +09:00
copecone 83ba362c59 Fix .browserslistrc to OKLCH-Supported Browsers 2026-04-03 12:13:30 +09:00
copecone 372c5a53d7 Add Target Browser List 2026-04-03 11:44:10 +09:00
copecone 4e356289a3 Fix Animation Consistency & UX 2026-04-03 11:20:44 +09:00
copecone 9995b8c1e4 Apply Settings Container New Design 2026-04-03 10:44:48 +09:00
copecone da1789c1fe Fix JP Translation 2026-04-03 08:24:43 +09:00
copecone 436a01b455 Remove Redundant Import from switch.ts 2026-04-02 14:58:40 +09:00
copecone f2ac841dfd Remove Redundant Import from switch.ts 2026-04-02 14:55:26 +09:00
copecone 4974a699dc Add Reduce Animation Setting 2026-04-02 14:54:57 +09:00
copecone 9043aa0535 Fix Main Page Title Text 2026-04-02 11:35:53 +09:00
copecone 2d6e0995c7 Merge remote-tracking branch 'origin/main' 2026-04-02 10:17:25 +09:00
copecone 7b1b47fd05 Add Non-Selectable Property to Header Menu 2026-04-02 10:11:26 +09:00
copecone bac135853d Improve Popup Accessibility 2026-04-01 13:12:33 +09:00
copecone 7b5ba462cb Improve UI Structure Consistency 2026-04-01 13:08:14 +09:00
copecone 32911f244a Improve UI Focus Consistency 2026-04-01 12:08:03 +09:00
copecone 8c3d9e4dda Restore Dummy Link Cursor Style 2026-03-31 15:51:49 +09:00
copecone 1a09c96309 Remove Redundant Stylesheet Comments 2026-03-31 15:27:56 +09:00
copecone 69774fab76 Fix SSR using Cookies 2026-03-31 15:25:44 +09:00
copecone 5dfb1dba03 Apply Language Settings 2026-03-31 12:10:28 +09:00
copecone 39113a4d39 Merge remote-tracking branch 'origin/main' 2026-03-31 10:28:59 +09:00
copecone 68ebed341f Adjust Dropdown Component Style 2026-03-31 10:28:48 +09:00
copecone a9276ce86a Add MIT License 2026-03-30 20:53:06 -04:00
copecone c3f24f5e6d Fix Popup Style 2026-03-31 03:17:43 +09:00
copecone 9e6a846bfa Fix Blend Mode 2026-03-30 12:17:52 +09:00
copecone d37bec5cb9 Apply Linear Gradient to Grid 2026-03-30 12:11:30 +09:00
copecone 0405b15b01 Add Prototype Version of Brand Accent Component 2026-03-30 12:06:31 +09:00
copecone 450da37d5b Convert All Color Code to OKLCH 2026-03-29 15:24:01 +09:00
copecone fd182bfce0 Fix Dropdown Item Selection Animation Consistency 2026-03-27 15:44:12 +09:00
copecone 8d55b478eb Fix Animation Consistency 2026-03-27 14:32:20 +09:00
copecone 898df6a318 Adjust Settings Popup Position 2026-03-26 14:18:17 +09:00
copecone 010771b22a Add Settings Popup 2026-03-26 00:14:43 +09:00
copecone d946ee4026 Fix Clock Font 2026-03-20 11:57:21 +09:00
copecone 869fb890d3 Separate Clock Page 2026-03-20 11:40:32 +09:00
copecone 92e0784084 Add Clock Component 2026-03-20 08:25:29 +09:00
copecone 751eb73a94 Add Known Issues Page 2026-03-19 14:26:57 +09:00
copecone c0cd104d8d Fix Dropdown Test Overflow Issue 2026-03-18 11:28:15 +09:00
copecone 913bdea398 Update Dropdown Default Example 2026-03-17 21:28:08 +09:00
copecone f71dc03f67 Add Dropdown Default Feature 2026-03-17 21:27:47 +09:00
copecone 88f72e767f Add Docker Build Script 2026-03-17 13:40:28 +09:00
copecone 99fd518ddb Update Packages & Add SSR For Production 2026-03-17 11:29:16 +09:00
copecone 68655d3078 Add Settings Icon to Header Menu 2026-03-17 08:42:24 +09:00
copecone 50f551df11 Add Header Menu Right 2026-03-17 08:14:53 +09:00
copecone 213a1d5fc0 Add Popup Component 2026-03-17 08:14:41 +09:00
79 changed files with 3686 additions and 530 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
Chrome >= 111
Firefox >= 113
Edge >= 111
not dead
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.idea
.vscode
.angular
README.md
package-lock.json
+5
View File
@@ -1,5 +1,8 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Gemini CLI
/.gemini
# Compiled output # Compiled output
/dist /dist
/tmp /tmp
@@ -42,3 +45,5 @@ __screenshots__/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.tar
+27
View File
@@ -0,0 +1,27 @@
# 1. Build Stage
FROM node:24-alpine AS build
WORKDIR /app
# 의존성 파일 복사 및 설치
COPY package*.json ./
RUN npm install
# 소스 코드 복사 및 빌드
COPY . .
RUN npm install -g @angular/cli
RUN ng build --configuration production
# 2. Production Stage
FROM node:24-alpine AS runner
WORKDIR /app
# 빌드 스테이지에서 생성된 dist 폴더만 복사
COPY --from=build /app/dist /app/dist
# 포트 설정 (Angular SSR 기본 포트: 4000)
EXPOSE 4000
# 서버 실행 (package.json의 serve:ssr:aef-component-test 참고)
CMD ["node", "dist/aef-component-test/server/server.mjs"]
+7
View File
@@ -0,0 +1,7 @@
Copyright © 2026 Yunizun.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2 -58
View File
@@ -1,59 +1,3 @@
# AefComponentTest # Component Test
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.1. For Testing Designed Components for ADOFAI Editor Forum Web
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+17
View File
@@ -33,6 +33,23 @@
"styles": ["src/styles.scss"], "styles": ["src/styles.scss"],
"stylePreprocessorOptions": { "stylePreprocessorOptions": {
"includePaths": ["src", "src/styles"] "includePaths": ["src", "src/styles"]
},
"server": "src/main.server.ts",
"outputMode": "server",
"security": {
"allowedHosts": [
"aef.kr",
"*.aef.kr",
"yunizun.org"
]
},
"ssr": {
"entry": "src/server.ts"
},
"optimization": {
"scripts": true,
"styles": true,
"fonts": false
} }
}, },
"configurations": { "configurations": {
+21
View File
@@ -0,0 +1,21 @@
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트
$IMAGE_NAME = "aef-component-test"
$TAG = "0.0.9"
$OUTPUT_FILE = "aef-component-test.tar"
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
docker build --no-cache -t "$($IMAGE_NAME):$TAG" .
if ($LASTEXITCODE -eq 0) {
Write-Host "--- 이미지 추출 시작: $OUTPUT_FILE ---" -ForegroundColor Cyan
docker save -o $OUTPUT_FILE "$($IMAGE_NAME):$TAG"
if ($LASTEXITCODE -eq 0) {
Write-Host "성공! $OUTPUT_FILE 파일이 생성되었습니다." -ForegroundColor Green
} else {
Write-Host "이미지 추출 중 오류가 발생했습니다." -ForegroundColor Red
}
} else {
Write-Host "빌드 중 오류가 발생했습니다." -ForegroundColor Red
}
+389 -153
View File
File diff suppressed because it is too large. Load diff
+16 -10
View File
@@ -6,24 +6,30 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"serve:ssr:aef-component-test": "node dist/aef-component-test/server/server.mjs"
}, },
"private": true, "private": true,
"packageManager": "npm@11.11.0", "packageManager": "npm@11.11.0",
"dependencies": { "dependencies": {
"@angular/common": "^21.2.0", "@angular/common": "^21.2.4",
"@angular/compiler": "^21.2.0", "@angular/compiler": "^21.2.4",
"@angular/core": "^21.2.0", "@angular/core": "^21.2.4",
"@angular/forms": "^21.2.0", "@angular/forms": "^21.2.4",
"@angular/platform-browser": "^21.2.0", "@angular/platform-browser": "^21.2.4",
"@angular/router": "^21.2.0", "@angular/platform-server": "^21.2.4",
"@angular/router": "^21.2.4",
"@angular/ssr": "^21.2.2",
"express": "^5.1.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.2.1", "@angular/build": "^21.2.2",
"@angular/cli": "^21.2.1", "@angular/cli": "^21.2.2",
"@angular/compiler-cli": "^21.2.0", "@angular/compiler-cli": "^21.2.4",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "~5.9.2", "typescript": "~5.9.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+10
View File
@@ -0,0 +1,10 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
+4 -2
View File
@@ -2,10 +2,12 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes) provideRouter(routes),
] provideClientHydration(withEventReplay()),
],
}; };
+12 -36
View File
@@ -1,39 +1,15 @@
<skip-links ngSkipHydration>
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
</skip-links>
<header-menu></header-menu> <header-menu></header-menu>
<div class="container"> <main #content>
<h1>AEF Component Test</h1>
<section>
<h2>Switch Component</h2>
<switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch>
<p>Switch is: {{ isToggled() ? 'ON' : 'OFF' }}</p>
</section>
<section>
<h2>Dropdown Component</h2>
<div style="display: flex; flex-direction: column; gap: 40px; align-items: flex-start;">
<div>
<h3>Object Options</h3>
<dropdown
[options]="dropdownOptions"
[(value)]="selectedDropdownValue"
(selectionChange)="handleDropdownChange($event)"
icon="language">
</dropdown>
<p>Selected Value: {{ selectedDropdownValue() }}</p>
</div>
<div>
<h3>String Options (Simple)</h3>
<dropdown
[options]="['Small', 'Medium', 'Large']"
[(value)]="selectedSize"
placeholder="Choose Size">
</dropdown>
<p>Selected Size: {{ selectedSize() }}</p>
</div>
</div>
</section>
</div>
<router-outlet /> <router-outlet />
</main>
<div id="global-popup-root" class="popup-outlet">
@for (popup of popupService.activePopups(); track popup.component) {
<ng-container *ngComponentOutlet="popup.component; inputs: popup.inputs" />
}
</div>
+8
View File
@@ -0,0 +1,8 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Server,
},
];
+10 -1
View File
@@ -1,3 +1,12 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { MainPage } from './pages/main/main';
import { KnownIssuesPage } from './pages/known-issues/known-issues';
import { ClockPage } from './pages/clock/clock';
import { LevelPage } from './pages/levels/levels';
export const routes: Routes = []; export const routes: Routes = [
{ path: '', component: MainPage },
{ path: 'known-issues', component: KnownIssuesPage },
{ path: 'clock', component: ClockPage },
{ path: 'levels', component: LevelPage },
];
+4 -26
View File
@@ -1,29 +1,7 @@
.container { :host {
padding: 40px; min-height: 100dvh;
max-width: 600px;
margin: 0 auto;
} }
section { main:focus-visible {
margin-bottom: 40px; outline: none;
padding: 20px;
border: 2px solid rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
h1 {
margin-bottom: 40px;
text-align: center;
}
h2 {
margin-top: 0;
margin-bottom: 20px;
font-size: 1.2rem;
color: #666;
}
p {
margin-top: 15px;
font-weight: 500;
} }
+49 -18
View File
@@ -1,32 +1,63 @@
import { Component, signal } 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 { Switch } from './components/switch/switch'; import { HeaderMenu } from './features/header-menu/header-menu';
import { Dropdown, DropdownOption } from './components/dropdown/dropdown'; import { PopupService } from './services/popup.service';
import { HeaderMenu } from './ui/header-menu/header-menu'; import { SettingsService } from './services/settings.service';
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, Switch, Dropdown, HeaderMenu], imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss',
}) })
export class App { export class App {
isToggled = signal(false); protected popupService = inject(PopupService);
protected settingsService = inject(SettingsService);
protected translation = inject(TranslationService).translation;
private platformID = inject(PLATFORM_ID);
dropdownOptions: DropdownOption[] = [ private contentElement = viewChild<ElementRef<HTMLElement>>("content");
{ label: 'English', value: "EN" },
{ label: '한국어', value: "KR" },
{ label: '日本語', value: "JP" },
];
selectedDropdownValue = signal<any>(null); private document = inject(DOCUMENT);
selectedSize = signal<string | null>(null); private rootHtmlElement!: HTMLElement;
handleToggle(state: boolean) { constructor() {
console.log('Toggle state:', state); if (isPlatformServer(this.platformID)) {
this.rootHtmlElement = this.document.documentElement;
} else if (isPlatformBrowser(this.platformID)) {
this.rootHtmlElement = document.documentElement;
} }
handleDropdownChange(value: any) { effect(() => {
console.log('Dropdown value:', value); this.initializeStatus();
});
}
initializeStatus() {
const isDark = this.settingsService.darkMode();
this.rootHtmlElement.classList.toggle('dark-theme', isDark);
this.rootHtmlElement.classList.toggle('light-theme', !isDark);
const lang = this.settingsService.language();
const langMap: Record<string, string> = {
KR: 'ko',
JP: 'ja',
EN: 'en',
};
this.rootHtmlElement.lang = langMap[lang] || 'en';
const reduceMotion = this.settingsService.reduceAnimation();
this.rootHtmlElement.classList.toggle('reduce-motion', reduceMotion);
}
skipToContent() {
this.contentElement()!.nativeElement.tabIndex = 0;
this.contentElement()!.nativeElement.focus();
// @ts-ignore
this.contentElement()!.nativeElement.tabIndex = undefined;
} }
} }
@@ -0,0 +1,2 @@
<canvas class="canvas" #canvasElement></canvas>
@@ -0,0 +1,9 @@
:host {
display: block;
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
@@ -0,0 +1,312 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
input,
NgZone,
OnDestroy,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { SettingsService } from '../../services/settings.service';
@Component({
selector: 'brand-accent',
standalone: true,
imports: [CommonModule],
templateUrl: './brand-accent.html',
styleUrl: './brand-accent.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrandAccent implements OnDestroy {
canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('canvasElement');
private ctx!: CanvasRenderingContext2D;
private settingsService = inject(SettingsService);
drawGrid = input<boolean>(true);
private bgRendererScale = 1;
private canvasNativeSize: [number, number] = [0, 0];
private bgGradient!: CanvasGradient;
private gridGradient!: CanvasGradient;
private readonly offscreenCanvas!: OffscreenCanvas;
private readonly offscreenCtx!: OffscreenCanvasRenderingContext2D;
private resizeObserver?: ResizeObserver;
private intersectionObserver?: IntersectionObserver;
private rendererScale!: number;
private animation: {
id?: number;
startTime: number;
progress: number;
gridDimension: [number, number];
duration: number;
gridSquareSize: number;
} = {
startTime: 0,
progress: 0,
gridDimension: [0, 0],
duration: 30,
gridSquareSize: 100,
};
constructor(private ngZone: NgZone) {
if (typeof document !== 'undefined') {
this.offscreenCanvas = new OffscreenCanvas(0, 0);
this.offscreenCtx = this.offscreenCanvas.getContext('2d', { colorSpace: 'srgb' })!;
}
effect(() => {
// Trigger cache refresh when dark mode changes
const isDark = this.settingsService.darkMode();
if (this.ctx) {
this.cacheGradients(isDark);
this.cacheBackground(isDark);
}
});
afterNextRender(() => {
const nativeElement = this.canvasRef()?.nativeElement;
if (nativeElement) {
this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!;
this.ngZone.runOutsideAngular(() => {
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0];
this.resize(width, height);
}
});
this.resizeObserver.observe(nativeElement);
this.intersectionObserver = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
if (!this.animation.id) this.animate(performance.now());
} else if (this.animation.id) {
cancelAnimationFrame(this.animation.id);
this.animation.id = undefined;
}
},
{ rootMargin: '200px' },
);
this.intersectionObserver.observe(nativeElement);
// Safe Initialization
const rect = nativeElement.getBoundingClientRect();
this.resize(rect.width, rect.height);
this.animation.startTime = performance.now();
this.draw();
// this.animate(performance.now());
});
}
});
}
private resize(width: number, height: number) {
const canvas = this.canvasRef()?.nativeElement;
if (canvas) {
const dpr = window.devicePixelRatio || 1;
this.rendererScale = dpr;
canvas.width = width * dpr;
canvas.height = height * dpr;
if (this.offscreenCanvas) {
this.offscreenCanvas.width = width * this.bgRendererScale;
this.offscreenCanvas.height = height * this.bgRendererScale;
}
this.canvasNativeSize = [width, height];
this.ctx.scale(dpr, dpr);
this.updateAnimationInfo();
this.animate(performance.now());
}
}
private updateAnimationInfo() {
this.animation.gridDimension = [
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3,
];
const isDark = this.settingsService.darkMode();
this.cacheGradients(isDark);
this.cacheBackground(isDark);
}
private cacheGradients(isDark: boolean) {
let w = this.canvasNativeSize[0];
let h = this.canvasNativeSize[1];
if (w === 0 || h === 0) return;
if (!isDark) {
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(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
}
this.gridGradient = this.ctx.createLinearGradient(0, 0, w, h);
if (isDark) {
// Dark mode: subtle light grid
this.gridGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
this.gridGradient.addColorStop(1, 'rgba(255, 255, 255, 0.4)');
} else {
// Light mode: dark grid
this.gridGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
this.gridGradient.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
}
}
private cacheBackground(isDark: boolean) {
if (!this.offscreenCtx) return;
const w = this.canvasNativeSize[0];
const h = this.canvasNativeSize[1];
if (w === 0 || h === 0) return;
this.offscreenCtx.clearRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
this.offscreenCtx.globalCompositeOperation = 'source-over';
if (!isDark) {
this.offscreenCtx.fillStyle = this.bgGradient;
this.offscreenCtx.fillRect(0, 0, w * this.bgRendererScale, h * this.bgRendererScale);
} else {
this.offscreenCtx.fillStyle = 'oklch(0.1776 0 0)';
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 ellipseX = w / 2;
const ellipseY = h * 0.5763888888 + ellipseDimension[1] / 2;
const blurScale =
Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6) /
this.rendererScale;
this.offscreenCtx.filter = `blur(${blurScale * this.bgRendererScale}px)`;
this.offscreenCtx.beginPath();
this.offscreenCtx.ellipse(
ellipseX * this.bgRendererScale,
ellipseY * this.bgRendererScale,
(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.fill();
this.offscreenCtx.filter = 'none';
}
}
private animate = (currentTime: number) => {
this.animation.id = requestAnimationFrame(this.animate);
this.update(currentTime);
this.draw();
};
private update(currentTime: number) {
let elapsedTime: number;
elapsedTime = (currentTime - this.animation.startTime) / 1000;
const animationTime = elapsedTime % this.animation.duration;
this.animation.progress = animationTime / this.animation.duration;
}
private draw() {
this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
const isDark = this.settingsService.darkMode();
if (this.offscreenCanvas) {
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.imageSmoothingEnabled = false;
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) {
this.ctx.globalCompositeOperation = 'multiply';
} else {
this.ctx.globalCompositeOperation = 'screen';
}
this.ctx.beginPath();
this.ctx.lineWidth = 2;
this.ctx.strokeStyle = this.gridGradient;
this.ctx.lineCap = 'round';
const lineRenderMargin = this.animation.gridSquareSize * 2;
const xOffset =
(this.canvasNativeSize[0] - this.animation.gridDimension[0] * this.animation.gridSquareSize) /
2;
const yOffset =
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
2;
const movingOffset = this.settingsService.reduceAnimation() ? 0 : -(this.animation.gridSquareSize * this.animation.progress);
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
this.ctx.moveTo(
-lineRenderMargin,
y * this.animation.gridSquareSize + yOffset + movingOffset,
);
this.ctx.lineTo(
this.canvasNativeSize[0] + lineRenderMargin,
y * this.animation.gridSquareSize + yOffset + movingOffset,
);
}
for (let x = 0; x < this.animation.gridDimension[0]; x++) {
this.ctx.moveTo(
x * this.animation.gridSquareSize + xOffset + movingOffset,
-lineRenderMargin,
);
this.ctx.lineTo(
x * this.animation.gridSquareSize + xOffset + movingOffset,
this.canvasNativeSize[1] + lineRenderMargin,
);
}
this.ctx.stroke();
}
ngOnDestroy() {
if (this.animation.id) cancelAnimationFrame(this.animation.id);
this.resizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
}
}
+4
View File
@@ -0,0 +1,4 @@
<div class="clock-container">
<div class="clock-date">{{ dateString() }}</div>
<div class="clock-time">{{ timeString() }}</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
:host {
display: block;
width: 100%;
}
.clock-container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-family: inherit;
gap: 4px;
width: 100%;
}
.clock-date {
font-size: 1.2rem;
font-weight: 500;
color: light-dark(oklch(0.3211 0 0), oklch(0.6789 0 0)); // #333
}
.clock-time {
font-size: 2rem;
font-weight: 600;
color: light-dark(oklch(0 0 0), oklch(1 0 0)); // #000
}
+48
View File
@@ -0,0 +1,48 @@
import { Component, signal, computed, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'clock',
standalone: true,
imports: [CommonModule],
templateUrl: './clock.html',
styleUrl: './clock.scss',
})
export class Clock implements OnInit, OnDestroy {
private timer: any;
currentTime = signal(new Date());
dateString = computed(() => {
const d = this.currentTime();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
});
timeString = computed(() => {
const d = this.currentTime();
let hours = d.getHours();
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12; // 0 should be 12
const hoursStr = String(hours).padStart(2, '0');
return `${hoursStr}:${minutes}:${seconds} ${ampm}`;
});
ngOnInit() {
this.timer = setInterval(() => {
this.currentTime.set(new Date());
}, 500);
}
ngOnDestroy() {
if (this.timer) {
clearInterval(this.timer);
}
}
}
+24 -7
View File
@@ -1,18 +1,34 @@
<ng-template #inputTemplate>
<div #searchInput class="dropdown-input" [class.hidden]="!isOpen()" [class.focus-mode]="inputFocusMode()"
contenteditable="true" role="textbox" aria-multiline="false" [aria-placeholder]="placeholder().trim().length > 0 ? placeholder() : translation.common.selectOption" [aria-activedescendant]="ariaActiveDescendant()"
[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)"
(focus)="onInputFocusIn()" (focusout)="onInputFocusOut()"></div>
</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>
} }
<span class="placeholder">{{ selectedLabel() }}</span>
<span class="material-symbols-outlined icon-24 chevron" [class.open]="isOpen()">keyboard_arrow_down</span> @if (recreateKey() % 2 == 0) {<ng-container [ngTemplateOutlet]="inputTemplate" />}
@else {<ng-container [ngTemplateOutlet]="inputTemplate" />}
@if (!isOpen()) {
<span class="placeholder" [class.value-empty]="selectedOption() === undefined">{{ selectedLabel() }}</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()) {
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'"> <div class="dropdown-container" tabindex="-1">
@for (option of normalizedOptions(); track option.value; let i = $index) { <ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
@for (option of filteredOptions(); track option.value; let i = $index) {
<li <li
#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()"
@@ -21,4 +37,5 @@
</li> </li>
} }
</ul> </ul>
</div>
} }
+133 -39
View File
@@ -2,40 +2,54 @@
@use "sass:color"; @use "sass:color";
@use "styles/library" as library; @use "styles/library" as library;
$dropdown_bg: oklch(1 0 0); // FFFFFF $border_color: oklch(0 0 0 / 20%);
$dropdown_border_color: oklch(0 0 0 / 20%); $border_dark_color: oklch(1 0 0 / 20%);
$dropdown_hover: oklch(0.9702 0 0); // F5F5F5 $hover_bg: oklch(0.9702 0 0); // #F5F5F5
$dropdown_selected: oklch(0.9738 0.0151 54.93); // FFF4ED $hover_dark_bg: oklch(0.25 0 0);
$dropdown_accent: oklch(0.7086 0.1896 39.86); // #FF6E37
$dropdown_height: 40px; $height: 40px;
$dropdown_border_radius: 8px; $border_radius: 8px;
$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); $animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$dropdown_animation_duration: 0.2s; $default_animation_duration: 0.2s;
@property --hover-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@property --selected-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host { :host {
display: inline-block; display: inline-block;
position: relative; position: relative;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
padding: 0; margin: 0; padding: 0; margin: 0;
background-color: $dropdown_bg; background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
border-radius: $dropdown_border_radius; border-radius: $border_radius;
box-shadow: 0 0 0 1px $dropdown_border_color inset; box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
outline: none;
cursor: pointer; cursor: pointer;
&:focus-visible { max-width: 100%;
box-shadow: 0 0 0 1px $dropdown_border_color inset, 0 0 0 2px white, 0 0 0 4px $dropdown_accent;
&:has(.dropdown-input.focus-mode:focus-visible) {
outline: 2px solid var(--selection-outline-color);
outline-offset: 2px;
} }
} }
.dropdown-box { .dropdown-box {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 10px;
height: $dropdown_height; height: $height;
box-sizing: border-box; box-sizing: border-box;
gap: 8px; gap: 8px;
@@ -46,19 +60,49 @@ $dropdown_animation_duration: 0.2s;
} }
.placeholder { .placeholder {
text-overflow: ellipsis;
&.value-empty { opacity: 50%; }
}
.dropdown-input {
cursor: text;
&:focus-visible { outline: none; }
&:empty:before {
content: attr(placeholder);
opacity: 50%;
}
&:not(.focus-mode) {
caret-color: transparent;
user-select: none;
}
&.hidden {
position: absolute;
width: 0;
height: 0;
opacity: 0;
visibility: hidden;
}
}
.placeholder, .dropdown-input {
flex-grow: 1; flex-grow: 1;
font-size: 18px; font-size: 18px;
line-height: 1.2; // descender(g, y 등)가 잘리지 않도록 행간 확보 height: 24px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
user-select: none; user-select: none;
align-content: center;
} }
.chevron { .chevron {
width: 24px; height: 24px; width: 24px; height: 24px;
flex-shrink: 0; flex-shrink: 0;
transition: transform $dropdown_animation_duration $dropdown_animation_timing; transition: transform var(--animation-duration) $animation_timing;
user-select: none; user-select: none;
&.open { &.open {
@@ -67,36 +111,69 @@ $dropdown_animation_duration: 0.2s;
} }
} }
.dropdown-list { .dropdown-container {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
width: 100%; width: 100%;
padding: 0; // 아이템이 꽉 차도록 패딩 제거 padding: 0;
margin: 0;
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
border-radius: $border_radius;
z-index: 1000;
max-height: 200px;
overflow-y: hidden;
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
animation: slideIn var(--animation-duration) $animation_timing;
.dropdown-list {
padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
background-color: $dropdown_bg; width: 100%;
border-radius: $dropdown_border_radius; max-height: inherit;
z-index: 1000;
max-height: 240px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
animation: slideIn $dropdown_animation_duration $dropdown_animation_timing;
// 자식 요소의 배경색이 테두리를 침범하지 못하도록 하는 사용자님의 핵심 설계 복구 scrollbar-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%)) transparent;
&::-webkit-scrollbar {
width: 12px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
background-color: light-dark(oklch(0 0 0 / 10%), oklch(1 0 0 / 20%));
border-radius: 10px;
}
}
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
border-radius: $dropdown_border_radius; border-radius: $border_radius;
box-shadow: 0 0 0 1px $dropdown_border_color inset; box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
pointer-events: none; pointer-events: none;
z-index: 1001; // 테두리를 최상단으로! z-index: 1001;
} }
@keyframes slideIn { @keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); } from {
to { opacity: 1; transform: translateY(0); } transform: translateY(-8px);
pointer-events: none;
opacity: 0;
}
to {
transform: translateY(0);
pointer-events: auto;
opacity: 1;
}
} }
} }
@@ -106,16 +183,33 @@ $dropdown_animation_duration: 0.2s;
height: 40px; height: 40px;
padding: 0 16px; padding: 0 16px;
position: relative; position: relative;
line-height: 1.2; // descender 보호를 위해 1에서 1.2로 수정 align-content: center;
transition: background-color $dropdown_animation_duration $dropdown_animation_timing; transition: --hover-state calc(var(--animation-duration) / 3) $animation_timing, --selected-state calc(var(--animation-duration) / 3) $animation_timing;
font-size: 16px;
background-color: color-mix(
in oklch,
color-mix(
in oklch,
transparent,
light-dark($hover_bg, $hover_dark_bg)
calc(var(--hover-state) * 100%)
),
light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 12))
calc(var(--selected-state) * 100%)
);
&.focused {
--animation-duration: 0;
}
&:hover, &.focused { &:hover, &.focused {
background-color: $dropdown_hover; --hover-state: 1;
} }
&.selected { &.selected {
background-color: $dropdown_selected; --selected-state: 1;
color: $dropdown_accent; color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
font-weight: 600; font-weight: 600;
} }
} }
+275 -38
View File
@@ -1,6 +1,22 @@
import { Component, input, model, output, signal, HostListener, ElementRef, computed import {
} from '@angular/core' Component,
import { CommonModule } from '@angular/common' computed,
effect,
ElementRef,
HostListener,
inject,
input,
model,
OnInit,
output,
PLATFORM_ID,
signal,
untracked,
viewChild,
viewChildren
} from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { TranslationService } from '../../services/translation.service';
export interface DropdownOption { export interface DropdownOption {
label: string; label: string;
@@ -14,40 +30,111 @@ export interface DropdownOption {
templateUrl: './dropdown.html', templateUrl: './dropdown.html',
styleUrl: './dropdown.scss', styleUrl: './dropdown.scss',
host: { host: {
'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]': "isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null", '[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()',
'(focusin)': 'onFocusIn($event)',
'(mousedown)': 'onMouseDown($event)',
'(keydown)': 'onKeyDown($event)',
'(keydown.enter)': 'onEnter($event)', '(keydown.enter)': 'onEnter($event)',
'(keydown.space)': 'onSpaceDown($event)', '(keydown.space)': 'onSpaceDown($event)',
'(keydown.escape)': 'close()', '(keydown.escape)': 'close()',
'(keydown.arrowdown)': 'onArrowDown($event)', '(keydown.arrowdown)': 'onArrowDown($event)',
'(keydown.arrowup)': 'onArrowUp($event)' '(keydown.arrowup)': 'onArrowUp($event)',
} },
}) })
export class Dropdown { export class Dropdown implements OnInit {
translation = inject(TranslationService).translation;
private platformId = inject(PLATFORM_ID);
searchInput = viewChild<ElementRef<HTMLElement>>('searchInput');
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>();
value = model<any>(null); value = model<any>(null);
selectionChange = output<any>(); selectionChange = output<any>();
isOpen = signal(false); isOpen = signal(false);
inputFocusMode = signal(false);
focusedIndex = signal(-1); focusedIndex = signal(-1);
searchQuery = signal('');
recreateKey = signal(0);
private static nextId = 0; ariaActiveDescendant = computed(() => {
dropdownId = `dropdown-${Dropdown.nextId++}`; return this.isOpen() && this.focusedIndex() >= 0
? this.dropdownID + '-option-' + this.focusedIndex()
: null;
});
constructor(private el: ElementRef) {} private static nextID = 0;
private isLatestMouseDownInsideInput = false;
dropdownID = `dropdown-${Dropdown.nextID++}`;
private isLatestMouseDownInsideContainer = false;
constructor(private element: ElementRef) {
effect(() => this.filteredOptions());
effect(() => {
const index = this.focusedIndex();
const isDropdownOpen = this.isOpen();
if (isDropdownOpen && index >= 0) {
requestAnimationFrame(() => {
this.scrollToIndex(index);
});
}
});
effect(() => {
const isDropdownOpen = this.isOpen();
if (!isDropdownOpen) {
if (untracked(this.inputFocusMode)) this.inputFocusMode.set(false);
this.searchQuery.set('');
const input = this.searchInput();
if (isPlatformBrowser(this.platformId) && input) {
let currentValue = untracked(this.normalizedOptions).find(
(o) => o.value === this.value(),
);
if (currentValue) input.nativeElement.innerText = currentValue.label;
}
}
});
}
ngOnInit() {
const opts = this.normalizedOptions();
const dIndex = this.defaultIndex();
const currentValue = this.value();
if (currentValue === null && dIndex !== undefined && dIndex >= 0 && dIndex < opts.length) {
this.value.set(opts[dIndex].value);
}
}
private scrollToIndex(index: number) {
if (!isPlatformBrowser(this.platformId)) return;
const items = this.optionItems();
if (items[index]) {
items[index].nativeElement.scrollIntoView({
block: 'nearest',
});
}
}
normalizedOptions = computed(() => { normalizedOptions = computed(() => {
return this.options().map(opt => { return this.options().map((opt) => {
if (typeof opt === 'string') { if (typeof opt === 'string') {
return { label: opt, value: opt }; return { label: opt, value: opt };
} }
@@ -55,15 +142,78 @@ export class Dropdown {
}); });
}); });
filteredOptions = computed(() => {
const query = this.searchQuery().toLowerCase().trim().normalize('NFKD');
const opts = this.normalizedOptions();
if (!this.inputFocusMode()) return opts;
if (!query) return opts;
return opts.filter((o) => o.label.toLowerCase().normalize('NFKD').includes(query));
});
toggle() { toggle() {
this.isOpen.update(v => !v); if (this.isLatestMouseDownInsideInput) {
this.isLatestMouseDownInsideInput = false;
return;
}
if (this.isOpen()) { if (this.isOpen()) {
this.focusedIndex.set(this.normalizedOptions().findIndex(o => o.value === this.value())); this.close();
} else {
this.isOpen.set(true);
this.searchQuery.set('');
this.focusedIndex.set(this.normalizedOptions().findIndex((o) => o.value === this.value()));
setTimeout(() => {
let wasFocusVisible = this.element.nativeElement.matches(':focus-visible');
this.focusSearchInput();
if (wasFocusVisible) this.inputFocusMode.set(true);
}, 0);
} }
} }
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()) {
this.focusSearchInput();
this.inputFocusMode.set(true);
}
}
private focusSearchInput() {
const searchInputElement = this.searchInput()!.nativeElement;
searchInputElement.focus();
this.setCursorToEnd(searchInputElement);
}
private checkIfInputFocus(event: Event) {
let triggerInputFocus = this.searchInput()!.nativeElement.contains(
event.target! as HTMLElement,
);
return this.isOpen() && triggerInputFocus;
}
close() { close() {
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
const isDropdownOpen = this.isOpen();
this.isOpen.set(false); this.isOpen.set(false);
if (isFocusInside) this.element.nativeElement.focus({ preventScroll: true });
if (isDropdownOpen) this.recreateKey.update((v) => (v + 1) % 65536);
this.element.nativeElement.tabIndex = undefined;
this.focusedIndex.set(-1); this.focusedIndex.set(-1);
} }
@@ -73,63 +223,150 @@ export class Dropdown {
this.close(); this.close();
} }
onEnter(event: Event) { onConfirm(event: Event) {
event.preventDefault(); event.preventDefault();
if (this.isOpen()) { if (this.isOpen()) {
const currentIndex = this.focusedIndex(); const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) { const options = this.filteredOptions();
this.selectOption(this.normalizedOptions()[currentIndex]); if (currentIndex >= 0 && currentIndex < options.length) {
this.selectOption(options[currentIndex]);
} else if (options.length === 0) {
this.close();
} }
} else { } else {
this.toggle(); this.toggle();
} }
} }
onEnter(event: Event) {
this.onConfirm(event);
}
onSpaceDown(event: Event) { onSpaceDown(event: Event) {
event.preventDefault(); if (this.isOpen() && this.inputFocusMode()) return;
if (this.isOpen()) { this.onConfirm(event);
const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
this.selectOption(this.normalizedOptions()[currentIndex]);
}
} else {
this.toggle();
}
} }
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;
} }
this.focusedIndex.update(i => {
this.focusedIndex.update((i) => {
const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i === -1) return 0; if (i === -1) return 0;
return (i + 1) % this.normalizedOptions().length; return (i + 1) % options.length;
}); });
} }
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;
} }
this.focusedIndex.update(i => {
if (i <= 0) return this.normalizedOptions().length - 1; this.focusedIndex.update((i) => {
const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i <= 0) return options.length - 1;
return i - 1; return i - 1;
}); });
} }
@HostListener('document:click', ['$event']) onKeyDown(event: KeyboardEvent) {
onDocumentClick(event: MouseEvent) { const textArrowKeys = ['ArrowLeft', 'ArrowRight'];
if (!this.el.nativeElement.contains(event.target)) {
this.close(); if (!textArrowKeys.includes(event.key)) return;
if (!this.isOpen() || this.inputFocusMode()) return;
this.inputFocusMode.set(true);
}
handleInputMouseDown() {
this.isLatestMouseDownInsideInput = true;
}
onMouseDown(event: MouseEvent) {
this.isLatestMouseDownInsideContainer = this.element.nativeElement.contains(event.target);
if (this.checkIfInputFocus(event)) {
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
} }
} }
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent) {
if (
!this.element.nativeElement.contains(event.target) &&
!this.isLatestMouseDownInsideContainer &&
this.isOpen()
) {
this.close();
}
this.isLatestMouseDownInsideInput = false;
this.isLatestMouseDownInsideContainer = false;
}
handleInputClick(event: MouseEvent) {
event.stopPropagation();
this.isLatestMouseDownInsideInput = false;
this.isLatestMouseDownInsideContainer = false;
}
onSearch(event: Event) {
if (!this.isOpen()) return;
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
const element = event.target as HTMLElement;
const text = element.innerText;
if (text.trim().length <= 0) element.innerHTML = '';
this.focusedIndex.set(0);
this.searchQuery.set(text);
}
preprocessInputContent(event: InputEvent) {
if (event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph') {
event.preventDefault();
}
if (event.inputType === 'insertFromPaste') {
const originalText = event.dataTransfer!.getData('text/plain');
const modifiedText = originalText.replace(/[\r\n]+/g, '');
event.preventDefault();
// noinspection JSDeprecatedSymbols
document.execCommand('insertText', false, modifiedText);
}
}
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();
}); });
private setCursorToEnd(element: HTMLElement) {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
}
} }
@@ -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()
}),
);
}
+23
View File
@@ -0,0 +1,23 @@
<div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()">
<div #popupContainer class="popup-container"
[class]="'variant-' + variant()"
[class.custom-pos]="!isCenter"
[style.top]="customStyle.top"
[style.left]="customStyle.left"
[style.right]="customStyle.right"
[style.bottom]="customStyle.bottom">
<header class="popup-header">
<div class="header-left">
<ng-content select="[header-left]"></ng-content>
</div>
<div class="header-right">
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
</div>
</header>
<div class="popup-body">
<ng-content></ng-content>
</div>
</div>
</div>
+123
View File
@@ -0,0 +1,123 @@
$animation_timing: cubic-bezier(0.16, 1, 0.3, 1);
$default_animation_duration: 0.2s;
@property --close-hover-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host {
display: block;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
}
.popup-backdrop {
position: fixed;
inset: 0;
background-color: transparent;
z-index: 100;
&.center {
display: flex;
justify-content: center;
align-items: center;
}
}
.popup-container {
border-radius: 12px;
box-shadow: 0 0 40px oklch(0 0 0 / 15%);
min-width: 40px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: visible;
animation: popup-enter var(--animation-duration) $animation_timing;
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
gap: 12px;
&.variant-glass {
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
backdrop-filter: blur(20px);
}
&.variant-solid {
background-color: white;
backdrop-filter: none;
}
&.custom-pos {
position: absolute;
}
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
}
@keyframes popup-enter {
from {
transform: scale(0.95) translateY(8px);
pointer-events: none;
opacity: 0;
}
to {
transform: scale(1) translateY(0);
pointer-events: auto;
opacity: 1;
}
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
height: 40px;
box-shadow: 0 1px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
.close-btn {
--close-hover-state: 0;
border: none;
padding: 4px;
cursor: pointer;
border-radius: 50%;
background: color-mix(
in oklch,
transparent,
light-dark(oklch(0 0 0 / 5%), oklch(1 0 0 / 5%))
calc(var(--close-hover-state) * 100%)
);
color: color-mix(
in oklch,
light-dark(oklch(0.4 0 0), oklch(0.6 0 0)),
light-dark(oklch(0 0 0), oklch(1 0 0))
calc(var(--close-hover-state) * 100%)
);
transition: --close-hover-state 0.2s;
&:hover {
--close-hover-state: 1;
}
}
}
}
.popup-body {
padding: 0 12px;
overflow-y: visible;
flex-grow: 1;
}
+160
View File
@@ -0,0 +1,160 @@
import {
Component,
input,
output,
HostListener,
ElementRef,
inject,
viewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { TranslationService } from '../../services/translation.service';
export type PopupPosition = {
top?: string;
left?: string;
right?: string;
bottom?: string;
};
export type PopupVariant = 'glass' | 'solid';
@Component({
selector: 'app-popup',
standalone: true,
templateUrl: './popup.html',
styleUrl: './popup.scss',
host: {
role: 'dialog',
'aria-modal': 'true',
tabindex: '-1',
},
})
export class PopupComponent implements OnInit, OnDestroy {
private translation = inject(TranslationService).translation;
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
private previouslyFocusedElement: HTMLElement | null = null;
/**
* 팝업의 위치 (기본값은 'center')
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
*/
position = input<PopupPosition | 'center'>('center');
/**
* 팝업의 스타일 변형 (기본값은 'glass')
*/
variant = input<PopupVariant>('glass');
/**
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
*/
closeButtonLabel = input<string>(this.translation.common.close);
/**
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
*/
closeRequest = output<void>();
constructor(private element: ElementRef) {}
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() {
this.closeRequest.emit();
}
get isCenter() {
return this.position() === 'center';
}
get customStyle() {
const pos = this.position();
return pos === 'center' ? {} : pos;
}
}
@@ -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 {}
+45 -31
View File
@@ -2,35 +2,53 @@
@use "sass:list"; @use "sass:list";
@use "styles/library" as library; @use "styles/library" as library;
$switch_on_bg: oklch(0.7086 0.1896 39.86); // #FF6E37 $off_bg: oklch(0.683 0 0); // #999999
$switch_off_bg: oklch(0.683 0 0); // #999999 $off_dark_bg: oklch(0 0 0);
$switch_dimension: (57px, 30px); $dimension: (57px, 30px);
$switch_slider_dimension: (24px, 24px); $knob_dimension: (24px, 24px);
$switch_padding: (3px, 3px); $internal_padding: (3px, 3px);
$switch_border_radius: 15px;
$switch_border_color: oklch(0 0 0 / 20%);
$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); $border_radius: 15px;
$switch_animation_duration: 0.3s; $border_color: oklch(0 0 0 / 20%);
$border_dark_color: oklch(1 0 0 / 40%);
$animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$default_animation_duration: 0.2s;
@property --animation-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host { :host {
display: inline-block; display: inline-block;
width: list.nth($switch_dimension, 1); height: list.nth($switch_dimension, 2); width: list.nth($dimension, 1); height: list.nth($dimension, 2);
border-radius: $switch_border_radius; border-radius: $border_radius;
background-color: $switch_off_bg;
background-color: color-mix(
in oklch,
light-dark($off_bg, $off_dark_bg),
light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6))
calc(var(--animation-state) * 100%)
);
box-shadow: 0 0 0 1px color-mix(
in oklch,
light-dark($border_color, $border_dark_color),
light-dark($border_color, list.nth(library.$brand_palette, 9))
calc(var(--animation-state) * 100%)
) inset;
--animation-state: 0;
--animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
// transition: background-color var(--animation-duration) $animation_timing, box-shadow var(--animation-duration) $animation_timing;
transition: --animation-state var(--animation-duration) $animation_timing;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0 0 0 1px $switch_border_color inset;
outline: none;
--switch-duration: #{$switch_animation_duration};
transition: background-color var(--switch-duration) $switch_animation_timing;
&.reduce-motion {
--switch-duration: 0s;
}
&:hover, &:focus-within { &:hover, &:focus-within {
.switch-knob { .switch-knob {
@@ -38,17 +56,13 @@ $switch_animation_duration: 0.3s;
} }
} }
&:focus-visible {
box-shadow: 0 0 0 1px $switch_border_color inset, 0 0 0 2px white, 0 0 0 4px $switch_on_bg;
}
&.active { &.active {
background-color: $switch_on_bg; --animation-state: 1;
.switch-knob { .switch-knob {
transform: translate( transform: translate(
list.nth($switch_dimension, 1) - list.nth($switch_slider_dimension, 1) - list.nth($switch_padding, 1) * 2, list.nth($dimension, 1) - list.nth($knob_dimension, 1) - list.nth($internal_padding, 1) * 2,
list.nth($switch_dimension, 2) - list.nth($switch_slider_dimension, 2) - list.nth($switch_padding, 2) * 2 list.nth($dimension, 2) - list.nth($knob_dimension, 2) - list.nth($internal_padding, 2) * 2
); );
} }
} }
@@ -56,10 +70,10 @@ $switch_animation_duration: 0.3s;
.switch-knob { .switch-knob {
position: absolute; position: absolute;
width: list.nth($switch_slider_dimension, 1); height: list.nth($switch_slider_dimension, 2); width: list.nth($knob_dimension, 1); height: list.nth($knob_dimension, 2);
left: list.nth($switch_padding, 1); top: list.nth($switch_padding, 2); left: list.nth($internal_padding, 1); top: list.nth($internal_padding, 2);
background-color: white; background-color: white;
border-radius: 50%; border-radius: 50%;
user-select: none; user-select: none;
transition: transform var(--switch-duration) $switch_animation_timing; transition: transform var(--animation-duration) $animation_timing;
} }
+1 -7
View File
@@ -1,4 +1,4 @@
import { Component, input, model, output } from '@angular/core'; import { Component, model, output } from '@angular/core';
@Component({ @Component({
selector: 'switch', selector: 'switch',
@@ -10,7 +10,6 @@ import { Component, input, model, output } from '@angular/core';
tabindex: '0', tabindex: '0',
'[attr.aria-checked]': 'isActive()', '[attr.aria-checked]': 'isActive()',
'[class.active]': 'isActive()', '[class.active]': 'isActive()',
'[class.reduce-motion]': 'reduceMotion()',
'(click)': 'onClick()', '(click)': 'onClick()',
'(keydown.enter)': 'onClick()', '(keydown.enter)': 'onClick()',
'(keydown.space)': 'onSpaceDown($event)', '(keydown.space)': 'onSpaceDown($event)',
@@ -18,11 +17,6 @@ import { Component, input, model, output } from '@angular/core';
}) })
export class Switch { export class Switch {
isActive = model<boolean>(false); isActive = model<boolean>(false);
reduceMotion = input<boolean>(
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
);
toggle = output<boolean>(); toggle = output<boolean>();
onClick(): void { onClick(): void {
@@ -0,0 +1,31 @@
<div #menuLeft class="menu-left">
<a class="brand" [routerLink]="['/']">
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
<span #brandText [class.hidden]="expandStage() < 2">
<strong>ADOFAI Editor Forum</strong>
</span>
</a>
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
</div>
</div>
<div #menuRight class="menu-right">
<div #rightMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/known-issues']">{{ translation.menu.knownIssues }}</a>
<span class="menu-item dummy-link"><strong>{{ translation.menu.login }}</strong></span>
<span class="menu-item dummy-link">{{ translation.menu.signup }}</span>
</div>
<button type="button" [class.hidden]="expandStage() > 0" [attr.inert]="expandStage() > 0 ? '' : null" class="material-symbols-outlined icon-40 menu" (click)="onHamburgerClick()" [attr.aria-label]="translation.menu.menu">
menu
</button>
<button type="button" class="material-symbols-outlined icon-40 settings" (click)="onSettingsClick()" [attr.aria-label]="translation.settings.title">
settings
</button>
</div>
@@ -0,0 +1,163 @@
@use "sass:map";
:host {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 0;
width: fit-content;
min-width: 100%;
height: 64px;
box-sizing: border-box;
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
backdrop-filter: blur(20px);
contain: layout paint;
isolation: isolate;
z-index: 100;
.menu-left, .menu-right {
display: flex;
flex-direction: row;
align-items: center;
gap: 48px;
.menu-items {
display: flex;
flex-direction: row;
align-items: center;
gap: 48px;
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
& > * {
user-select: none;
}
.menu-item, a {
text-decoration: none;
font-size: 18px;
height: 40px;
align-content: center;
vertical-align: middle;
white-space: nowrap;
font-family: inherit;
color: inherit;
cursor: pointer;
display: flex;
align-items: center;
border-radius: 4px;
&.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;
}
strong {
font-weight: 700;
}
}
.settings, .menu {
border: none;
border-radius: 100%;
background-color: inherit;
padding: 0;
position: relative;
z-index: 10;
cursor: pointer;
}
.settings {
transition: transform 0.3s ease;
&:hover {
transform: rotate(30deg);
}
}
.menu {
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
/* 구체적인 아이콘 예외 처리 */
.icon-40 {
height: auto;
}
}
.menu-left {
position: absolute;
left: 0;
justify-content: start;
padding: 12px;
.brand {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
gap: 0;
cursor: pointer;
margin-left: 10px;
&:hover {
opacity: 0.7;
}
img {
display: inline-block;
height: 20px;
padding: 10px 0;
}
strong {
font-weight: 900 !important;
}
span {
display: inline-block;
padding-left: 10px;
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
}
}
.menu-right {
position: absolute;
right: 0;
justify-content: flex-end;
padding: 12px 20px 12px 12px;
}
}
+165
View File
@@ -0,0 +1,165 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
NgZone,
OnDestroy,
signal,
viewChild,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PopupService } from '../../services/popup.service';
import { SettingsPopup } from '../settings/settings';
import { NavigationMenu } from './navigation-menu';
import { TranslationService } from '../../services/translation.service';
import { SettingsService } from '../../services/settings.service';
@Component({
selector: 'header-menu',
imports: [RouterLink],
templateUrl: './header-menu.html',
styleUrl: './header-menu.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.folded-menu]': 'expandStage() < 1',
},
})
export class HeaderMenu implements OnDestroy {
private popupService = inject(PopupService);
protected translation = inject(TranslationService).translation;
protected settings = inject(SettingsService);
private static readonly LOGICAL_GAP = 24;
private resizeObserver?: ResizeObserver;
private brandTextObserver?: ResizeObserver;
private menuItemsObserver?: ResizeObserver;
private menuWidth: number = 99999;
private menuLeft = viewChild<ElementRef<HTMLElement>>('menuLeft');
private leftMenuItems = viewChild<ElementRef<HTMLElement>>('leftMenuItems');
private menuLeftWidth: number = 0;
private leftMenuItemsWidth: number = 0;
private menuRight = viewChild<ElementRef<HTMLElement>>('menuRight');
private rightMenuItems = viewChild<ElementRef<HTMLElement>>('rightMenuItems');
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);
}
});
afterNextRender(() => {
this.ngZone.runOutsideAngular(() => {
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (width > 0)
switch (entry.target) {
case element.nativeElement:
this.menuWidth = width;
break;
case this.menuLeft()!.nativeElement:
this.menuLeftWidth = width;
break;
case this.menuRight()!.nativeElement:
this.menuRightWidth = width;
break;
}
}
this.updateExpandStatus();
});
this.menuItemsObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.borderBoxSize[0].inlineSize;
if (width > 0)
switch (entry.target) {
case this.leftMenuItems()!.nativeElement:
this.leftMenuItemsWidth = width;
break;
case this.rightMenuItems()!.nativeElement:
this.rightMenuItemsWidth = width;
break;
}
}
this.updateExpandStatus();
});
this.resizeObserver.observe(element.nativeElement);
this.resizeObserver.observe(this.menuLeft()!.nativeElement);
this.resizeObserver.observe(this.menuRight()!.nativeElement);
this.menuItemsObserver.observe(this.leftMenuItems()!.nativeElement);
this.menuItemsObserver.observe(this.rightMenuItems()!.nativeElement);
this.brandTextObserver = new ResizeObserver((entries) => {
const width = entries[0].borderBoxSize[0].inlineSize;
if (width > 0) this.brandTextWidth = width;
this.updateExpandStatus();
});
this.brandTextObserver.observe(this.brandText()!.nativeElement);
});
});
}
ngOnDestroy() {
this.resizeObserver?.disconnect();
this.menuItemsObserver?.disconnect();
this.brandTextObserver?.disconnect();
}
private updateExpandStatus() {
let menuContentWidth = this.menuLeftWidth + this.menuRightWidth;
let enoughSpace = this.menuWidth > menuContentWidth + HeaderMenu.LOGICAL_GAP;
let initialExpandStage = this.expandStage();
if (enoughSpace) {
let extraSpaceToExpand = Number.MAX_VALUE;
switch (initialExpandStage) {
case 1:
extraSpaceToExpand = this.brandTextWidth;
break;
case 0:
extraSpaceToExpand = this.leftMenuItemsWidth + this.rightMenuItemsWidth + 48 - 40;
break;
}
if (
// Add a gap of 2px to prevent flickering
this.menuWidth >= menuContentWidth + HeaderMenu.LOGICAL_GAP + extraSpaceToExpand + 2
) {
this.expandStage.set(initialExpandStage + 1);
}
} else if (initialExpandStage > 0) this.expandStage.set(initialExpandStage - 1);
}
onHamburgerClick(): void {
this.popupService.open(NavigationMenu, {
position: { top: '84px', right: '20px' },
});
}
onSettingsClick() {
this.popupService.open(SettingsPopup, { position: { top: '84px', right: '20px' } });
}
}
@@ -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;
}
}
+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;
}
+104
View File
@@ -0,0 +1,104 @@
import { Component, inject, input, output } from '@angular/core';
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { Switch } from '../../components/switch/switch';
import { NgOptimizedImage } from '@angular/common';
import { PopupService } from '../../services/popup.service';
import { SettingsService, Language } from '../../services/settings.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({
selector: 'settings-popup',
standalone: true,
imports: [
PopupComponent,
NgOptimizedImage,
SettingsCategory,
SettingsContainer,
],
templateUrl: './settings.html',
styleUrl: './settings.scss',
})
export class SettingsPopup {
private popupService = inject(PopupService);
protected settingsService = inject(SettingsService);
protected translation = inject(TranslationService).translation;
/**
* 팝업의 위치 (기본값은 'center')
*/
position = input<PopupPosition | 'center'>('center');
languages: DropdownOption[] = [
{ label: 'English', value: 'EN' },
{ label: '한국어', value: 'KR' },
{ label: '日本語', value: 'JP' },
];
onLanguageChange(lang: Language) {
this.settingsService.setLanguage(lang);
}
onDarkModeChange(isDark: boolean) {
this.settingsService.setDarkMode(isDark);
}
onRomanisationChange(value: boolean) {
this.settingsService.setRomanisation(value);
}
onReduceAnimationChange(value: boolean) {
this.settingsService.setReduceAnimation(value);
}
/**
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
*/
close() {
this.popupService.close(SettingsPopup);
}
protected readonly SettingsType = SettingsType;
}
+33
View File
@@ -0,0 +1,33 @@
export const EN = {
settings: {
title: 'Settings',
guest: 'Guest',
language: 'Language',
appearance: 'Appearance',
darkMode: 'Dark Mode',
romanisation: 'Romanisation',
reduceAnimation: 'Reduce Animation',
},
menu: {
levels: 'Levels',
community: 'Community',
leaderboard: 'Leaderboard',
about: 'About',
knownIssues: 'Known Issues',
login: 'Login',
signup: 'Signup',
menu: 'Menu',
},
common: {
selectOption: 'Select Option...',
close: 'Close',
save: 'Save',
skipToContent: 'Skip to main content',
},
levelCard: {
preview: '{{trackName}} by {{artist}} Preview',
download: "{{creator}}'s Level Download",
},
};
export type Translation = typeof EN;
+33
View File
@@ -0,0 +1,33 @@
import { Translation } from './en';
export const JP: Translation = {
settings: {
title: '設定',
guest: 'ゲスト',
language: '言語',
appearance: '外観',
darkMode: 'ダークモード',
romanisation: 'ローマ字表記',
reduceAnimation: 'アニメーションを減らす',
},
menu: {
levels: 'レベル',
community: 'コミュニティ',
leaderboard: 'リーダーボード',
about: '情報',
knownIssues: '既知の問題',
login: 'ログイン',
signup: '新規登録',
menu: 'メニュー',
},
common: {
selectOption: 'オプションを選択...',
close: '閉じる',
save: '保存',
skipToContent: '本文へスキップ',
},
levelCard: {
preview: '{{artist}}の{{trackName}} プレビュー',
download: '{{creator}}のレベルをダウンロード',
},
};
+33
View File
@@ -0,0 +1,33 @@
import { Translation } from './en';
export const KR: Translation = {
settings: {
title: '설정',
guest: '게스트',
language: '언어',
appearance: '화면 설정',
darkMode: '다크 모드',
romanisation: '로마자 표기',
reduceAnimation: '애니메이션 줄이기',
},
menu: {
levels: '레벨',
community: '커뮤니티',
leaderboard: '리더보드',
about: '정보',
knownIssues: '알려진 문제',
login: '로그인',
signup: '회원가입',
menu: '메뉴',
},
common: {
selectOption: '옵션 선택...',
close: '닫기',
save: '저장',
skipToContent: '본문으로 건너뛰기',
},
levelCard: {
preview: '{{artist}}의 {{trackName}} 미리듣기',
download: '{{creator}}의 레벨 다운로드',
},
};
+11
View File
@@ -0,0 +1,11 @@
<div class="container">
<h1>Clock Component Test</h1>
<section id="clock">
<clock></clock>
</section>
<p style="text-align: center;">
<a routerLink="/" style="color: oklch(0.5103 0 0); /* #666 */ text-decoration: none;">Go to Main</a>
</p>
</div>
+7
View File
@@ -0,0 +1,7 @@
#clock {
font-family: "Wanted Sans Variable", sans-serif !important;
font-feature-settings: "tnum", "ss06";
padding-top: 72px;
padding-bottom: 72px;
}
+12
View File
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Clock } from '../../components/clock/clock';
@Component({
selector: 'app-clock-page',
standalone: true,
imports: [Clock, RouterLink],
templateUrl: './clock.html',
styleUrl: './clock.scss',
})
export class ClockPage {}
@@ -0,0 +1,15 @@
<div class="container">
<h1>Known Issues</h1>
<section class="issue-item feature-block">
<h2>Issue #1: Dropdown Test UI Layout</h2>
<p>Dropdown Component를 테스트하기 위한 UI에서, 상위 컨테이너의 크기가 충분하지 않은 경우 드롭다운이 상위 컨테이너 바깥의 공간을 침범하는 문제가 발생했습니다.</p>
<div class="status-tag resolved">Resolved</div>
</section>
<section class="issue-item feature-block">
<h2>Issue #2: Dropdown Animation Control</h2>
<p>Dropdown Component의 애니메이션 효과가 운영체제의 애니메이션 설정에 영향을 받지 않는 문제가 발생했습니다.</p>
<div class="status-tag resolved">Resolved</div>
</section>
</div>
@@ -0,0 +1,37 @@
:host > .container {
display: flex;
width: 600px;
flex-direction: column;
gap: 40px;
}
section {
p {
line-height: 1.4;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
border-radius: 4px;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
width: fit-content;
&.reported {
background-color: oklch(0.786 0.1379 18.69);
}
&.wip {
background-color: oklch(0.786 0.1379 64.49);
}
&.resolved {
background-color: oklch(0.786 0.1379 154.52);
}
}
}
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-known-issues',
standalone: true,
imports: [],
templateUrl: './known-issues.html',
styleUrl: './known-issues.scss'
})
export class KnownIssuesPage {}
+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 {
}
+40
View File
@@ -0,0 +1,40 @@
<brand-accent></brand-accent>
<div class="container">
<h1>Component Test</h1>
<section id="switch-test" class="feature-block">
<h2>Switch Component</h2>
<div class="test-container">
<switch [(isActive)]="isToggled" (toggle)="handleToggle($event)"></switch>
<p>Switch is: {{ isToggled() ? 'ON' : 'OFF' }}</p>
</div>
</section>
<section id="dropdown-test" class="feature-block">
<h2>Dropdown Component</h2>
<div class="test-group">
<div class="test-container">
<h3>Object Options</h3>
<dropdown
[options]="dropdownOptions"
[(value)]="selectedDropdownValue"
[defaultIndex]="0"
(selectionChange)="handleDropdownChange($event)"
icon="language">
</dropdown>
<p>Selected Value: {{ selectedDropdownValue() }}</p>
</div>
<div class="test-container">
<h3>String Options (Simple)</h3>
<dropdown
[options]="['Small', 'Medium', 'Large']"
[(value)]="selectedSize"
placeholder="Choose Size">
</dropdown>
<p>Selected Size: {{ selectedSize() }}</p>
</div>
</div>
</section>
</div>
+57
View File
@@ -0,0 +1,57 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100%;
}
:host > .container {
display: flex;
width: 100%;
max-width: 600px;
box-sizing: border-box;
flex-direction: column;
gap: 40px;
}
section#dropdown-test {
dropdown {
width: 100%;
max-width: 324px;
}
}
div.test-group {
display: flex;
flex-direction: column;
gap: 36px;
padding-top: 24px;
}
div.test-container {
max-width: 100%;
h3 {
margin-bottom: 6px;
}
p {
margin-top: 6px;
}
}
brand-accent {
position: absolute;
top: -64px;
left: 0;
width: 100%;
min-width: 100dvw;
height: calc(100% + 64px);
min-height: 100dvh;
z-index: -999999;
}
+69
View File
@@ -0,0 +1,69 @@
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';
@Component({
selector: 'app-main-page',
standalone: true,
imports: [Switch, Dropdown, BrandAccent],
templateUrl: './main.html',
styleUrl: './main.scss',
})
export class MainPage {
isToggled = signal(false);
dropdownOptions: DropdownOption[] = [
{ label: 'English', value: 'EN' },
{ label: '한국어', value: 'KR' },
{ label: '日本語', value: 'JP' },
{ label: 'Deutsch', value: 'DE' },
{ label: 'Italiano', value: 'IT' },
{ label: '中文', value: 'CN' },
{ label: 'Français', value: 'FR' },
{ label: 'Español', value: 'ES' },
{ label: 'Português', value: 'PT' },
{ label: 'Русский', value: 'RU' },
{ label: 'العربية', value: 'AR' },
{ label: 'हिन्दी', value: 'HI' },
{ label: 'বাংলা', value: 'BN' },
{ label: 'Tiếng Việt', value: 'VI' },
{ label: 'Türkçe', value: 'TR' },
{ label: 'Polski', value: 'PL' },
{ label: 'Українська', value: 'UK' },
{ label: 'Nederlands', value: 'NL' },
{ label: 'Ελληνικά', value: 'EL' },
{ label: 'Magyar', value: 'HU' },
{ label: 'Čeština', value: 'CS' },
{ label: 'Română', value: 'RO' },
{ label: 'Svenska', value: 'SV' },
{ label: 'Dansk', value: 'DA' },
{ label: 'Suomi', value: 'FI' },
{ label: 'Norsk', value: 'NO' },
{ label: 'ไทย', value: 'TH' },
{ label: 'Bahasa Indonesia', value: 'ID' },
{ label: 'Bahasa Melayu', value: 'MS' },
{ label: 'Filipino', value: 'TL' },
{ label: 'עברית', value: 'HE' },
{ label: 'فارسی', value: 'FA' },
{ label: 'اردو', value: 'UR' },
{ label: 'தமிழ்', value: 'TA' },
{ label: 'తెలుగు', value: 'TE' },
{ label: 'ಕನ್ನಡ', value: 'KN' },
{ label: 'മലയാളം', value: 'ML' },
{ label: 'मराठी', value: 'MR' },
{ label: 'ગુજરાતી', value: 'GU' },
{ label: 'ਪੰਜਾਬੀ', value: 'PA' },
];
selectedDropdownValue = signal<any>(null);
selectedSize = signal<string | null>(null);
handleToggle(state: boolean) {
console.log('Toggle state:', state);
}
handleDropdownChange(value: any) {
console.log('Dropdown value:', value);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Injectable, signal, Type } from '@angular/core';
export interface PopupInstance {
component: Type<any>;
inputs?: Record<string, any>;
}
@Injectable({
providedIn: 'root',
})
export class PopupService {
/**
* 현재 활성화된 팝업 컴포넌트들의 리스트 (Signal)
*/
activePopups = signal<PopupInstance[]>([]);
/**
* 팝업을 엽니다. 이미 열려있는 동일한 컴포넌트가 있다면 중복해서 열지 않습니다.
*/
open<T>(component: Type<T>, inputs?: Record<string, any>) {
this.activePopups.update((popups) => {
if (popups.some((p) => p.component === component)) return popups;
return [...popups, { component, inputs }];
});
}
/**
* 특정 컴포넌트를 닫습니다.
*/
close(component: Type<any>) {
this.activePopups.update((popups) => popups.filter((p) => p.component !== component));
}
/**
* 가장 최근에 열린(마지막) 팝업을 닫습니다.
*/
closeLast() {
this.activePopups.update((popups) => popups.slice(0, -1));
}
/**
* 모든 팝업을 닫습니다.
*/
closeAll() {
this.activePopups.set([]);
}
}
+118
View File
@@ -0,0 +1,118 @@
import { Injectable, signal, inject, PLATFORM_ID, computed, REQUEST } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
export type Language = 'EN' | 'KR' | 'JP';
@Injectable({
providedIn: 'root',
})
export class SettingsService {
private platformId = inject(PLATFORM_ID);
private request: Request | null;
// Signals for state
private _language = signal<Language>('EN');
private _darkMode = signal<boolean>(false);
private _romanisation = signal<boolean>(true);
private _userName = signal<string>('Guest');
private _reduceAnimation = signal<boolean>(false);
// Public computed or read-only signals
language = computed(() => this._language());
darkMode = computed(() => this._darkMode());
romanisation = computed(() => this._romanisation());
userName = computed(() => this._userName());
reduceAnimation = computed(() => this._reduceAnimation());
isGuest = computed(() => true);
constructor() {
this.request = inject(REQUEST);
this.initializeSettings();
}
private initializeSettings() {
let parsedCookie: Record<string, string>;
if (isPlatformBrowser(this.platformId)) {
parsedCookie = this.parseCookies(document.cookie);
} else if (isPlatformServer(this.platformId)) {
const cookieHeader = this.request?.headers.get('Cookie');
if (!cookieHeader) return;
parsedCookie = this.parseCookies(cookieHeader);
} else return;
const settings = {
lang: parsedCookie['lang'],
darkMode: parsedCookie['darkMode'],
romanisation: parsedCookie['romanisation'],
userName: parsedCookie['userName'],
reduceAnimation: parsedCookie['reduceAnimation'],
};
const windowDefined = typeof window !== 'undefined';
if (!settings.lang) {
this.setLanguage('EN');
} else this._language.set(settings.lang as Language);
if (settings.darkMode === undefined) {
const isDarkMode = windowDefined ?
window.matchMedia('(prefers-color-scheme: dark)').matches : false;
this.setDarkMode(isDarkMode);
} else this._darkMode.set(settings.darkMode === 'true');
if (settings.romanisation === undefined) {
this.setRomanisation(true);
} else this._romanisation.set(settings.romanisation === 'true');
if (!settings.userName) {
this.setUserName('Guest');
} else this._userName.set(settings.userName);
if (settings.reduceAnimation === undefined) {
const isReduceAnimation = windowDefined ?
window.matchMedia('(prefers-reduced-motion: reduce)').matches : false;
this.setReduceAnimation(isReduceAnimation);
} else this._reduceAnimation.set(settings.reduceAnimation === 'true');
}
private parseCookies(cookieString: string): Record<string, string> {
const cookies: Record<string, string> = {};
cookieString.split(';').forEach((c) => {
const [key, value] = c.trim().split('=');
if (key && value) cookies[key] = decodeURIComponent(value);
});
return cookies;
}
setLanguage(lang: Language) {
this._language.set(lang);
this.setCookie('lang', lang);
}
setDarkMode(isDark: boolean) {
this._darkMode.set(isDark);
this.setCookie('darkMode', isDark.toString());
}
setRomanisation(value: boolean) {
this._romanisation.set(value);
this.setCookie('romanisation', value.toString());
}
setUserName(name: string) {
this._userName.set(name);
this.setCookie('userName', name);
}
setReduceAnimation(value: boolean) {
this._reduceAnimation.set(value);
this.setCookie('reduceAnimation', value.toString());
}
private setCookie(name: string, value: string) {
if (isPlatformBrowser(this.platformId)) {
document.cookie = `${name}=${value}; path=/; max-age=31536000`; // 1 year
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import { inject, Injectable, computed } from '@angular/core';
import { SettingsService } from './settings.service';
import { EN, Translation } from '../i18n/en';
import { KR } from '../i18n/kr';
import { JP } from '../i18n/jp';
@Injectable({
providedIn: 'root',
})
export class TranslationService {
private settingsService = inject(SettingsService);
/**
* 내부 시그널: 실제 번역 데이터 객체
*/
private _translation = computed(() => {
const lang = this.settingsService.language();
switch (lang) {
case 'KR': return KR;
case 'JP': return JP;
default: return EN;
}
});
/**
* Proxy를 통한 깔끔한 접근용 객체.
* {{ t.settings.title }} 처럼 () 없이 접근해도 내부적으로 _t() 시그널을 읽습니다.
*/
readonly translation = new Proxy({} as 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
);
}
}
-11
View File
@@ -1,11 +0,0 @@
<div class="menu-left">
<div class="brand">
<img src="/logo_inverted.svg" alt="logo"/>
<span>ADOFAI Editor Forum</span>
</div>
<span>Levels</span>
<span>Community</span>
<span>Leaderboard</span>
<span>About</span>
</div>
-56
View File
@@ -1,56 +0,0 @@
:host {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
gap: 30px;
width: fit-content;
min-width: 100%;
box-sizing: border-box;
border-bottom: oklch(0 0 0 / 20%) 2px solid;
background-color: oklch(1 0 0 / 60%);
backdrop-filter: blur(20px);
contain: layout paint;
isolation: isolate;
z-index: 100;
.menu-left {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
gap: 48px;
padding: 12px;
.brand {
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
gap: 0;
img {
display: inline-block;
height: 20px;
padding: 10px;
}
span {
font-weight: 900;
/*text-box-trim: trim-both;
text-box-edge: cap alphabetic;*/
}
}
span {
font-size: 18px;
line-height: 40px;
vertical-align: middle;
white-space: nowrap;
}
}
}
-9
View File
@@ -1,9 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'header-menu',
imports: [],
templateUrl: './header-menu.html',
styleUrl: './header-menu.scss',
})
export class HeaderMenu {}
+1 -2
View File
@@ -2,10 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>AefComponentTest</title> <title>AEF Component Test</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,300,0..1,-50..200" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,300,0..1,-50..200" />
</head> </head>
<body> <body>
+7
View File
@@ -0,0 +1,7 @@
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
export default bootstrap;
+67
View File
@@ -0,0 +1,67 @@
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
/**
* Example Express Rest API endpoints can be defined here.
* Uncomment and define endpoints as necessary.
*
* Example:
* ```ts
* app.get('/api/{*splat}', (req, res) => {
* // Handle API request
* });
* ```
*/
/**
* Serve static files from /browser
*/
app.use(
express.static(browserDistFolder, {
maxAge: '1y',
index: false,
redirect: false,
}),
);
/**
* Handle all other requests by rendering the Angular application.
*/
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
/**
* Start the server if this module is the main entry point, or it is ran via PM2.
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
*/
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = parseInt(process.env['PORT'] || '4000');
const host = '0.0.0.0';
app.listen(port, host, (error) => {
if (error) {
throw error;
}
console.log(`Node Express server listening on http://${host}:${port}`);
});
}
/**
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
*/
export const reqHandler = createNodeRequestHandler(app);
+119 -1
View File
@@ -1,12 +1,130 @@
@use "sass:list";
@use "styles/library" as library;
@import url('https://cdn.jsdelivr.net/npm/nanumsquareneo@0.0.5/nanumsquareneo.css'); @import url('https://cdn.jsdelivr.net/npm/nanumsquareneo@0.0.5/nanumsquareneo.css');
@import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css'); @import url('https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.1/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css');
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css"); @import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard-jp-dynamic-subset.min.css");
:root {
--motion-duration-multiplier: 1;
}
:root.reduce-motion {
--motion-duration-multiplier: 0 !important;
}
html {
color-scheme: light;
--if-dark: initial;
&.dark-theme {
--if-dark: ;
color-scheme: dark;
color: white;
& > body {
background-color: oklch(0.1776 0 0);
}
section.feature-block {
background-color: oklch(0 0 0 / 60%);
outline: 2px solid oklch(1 0 0 / 20%);
}
}
}
body { body {
font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif; font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif;
font-weight: 500; font-weight: 500;
margin: 0; margin: 0;
overscroll-behavior-x: none; overscroll-behavior-x: none;
background-color: white;
min-height: 100dvh;
} }
.icon-24 {
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
font-size: 24px;
line-height: 24px;
}
.icon-40 {
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;
font-size: 40px;
line-height: 40px;
}
strong {
font-weight: 900;
}
h1, h2, h3 {
margin: 0;
}
h1 {
font-size: 48px;
text-align: center;
}
h2 {
font-size: 24px;
color: oklch(0.6830 0 0); // #999999
}
h3 {
font-size: 18px;
}
p {
margin-top: 0; margin-bottom: 0;
font-weight: 500;
font-size: 16px;
}
.container {
padding: 40px;
max-width: 600px;
margin: 0 auto;
}
.popup-outlet {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10000;
}
#global-popup-root > * {
pointer-events: auto;
display: block;
}
section.feature-block {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
outline: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
border-radius: 8px;
background-color: oklch(1 0 0 / 60%);
backdrop-filter: blur(20px);
}
.container {
padding: 40px;
max-width: 600px;
margin: 0 auto;
}
* {
--selection-outline-color: light-dark(#{list.nth(library.$brand_palette, 5)}, #{list.nth(library.$brand_palette, 6)});
}
*:focus-visible {
outline: 2px solid var(--selection-outline-color);
outline-offset: 2px;
}
+29 -18
View File
@@ -1,21 +1,32 @@
@forward 'functions'; @forward 'functions';
.icon-24 { $brand_palette: [
font-variation-settings: oklch(0.9738 0.0151 54.93), // #FFF4ED
'FILL' 0, oklch(0.9386 0.0366 55.36), // #FFE5D4
'wght' 300, oklch(0.8738 0.0761 51.59), // #FFC8A8
'GRAD' 0, oklch(0.7938 0.1292 47.77), // #FFA170
'opsz' 24; oklch(0.7086 0.1896 39.86), // #FF6E37
oklch(0.6662 0.2237 35.33), // #FF4B14
oklch(0.6159 0.2311 31.93), // #F02D06
oklch(0.5313 0.2041 30.75), // #C71D07
oklch(0.4509 0.1688 29.86), // #9E190E
oklch(0.3897 0.1391 29.75), // #7F180F
oklch(0.2547 0.0913 29.02), // #450805
oklch(0.1752 0.0717 29.02), // #280000
];
line-height: 24px; /*
} $brand_palette: [
oklch(0.9999 0.2237 35.33),
.icon-40 { oklch(0.9090 0.2237 35.33),
font-variation-settings: oklch(0.8181 0.2237 35.33),
'FILL' 0, oklch(0.7272 0.2237 35.33),
'wght' 300, oklch(0.6363 0.2237 35.33),
'GRAD' 0, oklch(0.5454 0.2237 35.33),
'opsz' 40; oklch(0.4545 0.2237 35.33),
oklch(0.3636 0.2237 35.33),
line-height: 40px; oklch(0.2727 0.2237 35.33),
} oklch(0.1818 0.2237 35.33),
oklch(0.0909 0.2237 35.33),
];
*/
+3 -7
View File
@@ -4,12 +4,8 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": ["node"]
}, },
"include": [ "include": ["src/**/*.ts"],
"src/**/*.ts" "exclude": ["src/**/*.spec.ts"]
],
"exclude": [
"src/**/*.spec.ts"
]
} }