Compare commits

...
44 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
50 changed files with 1963 additions and 417 deletions

No files matched your search

+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.
## 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.
For Testing Designed Components for ADOFAI Editor Forum Web
+1 -1
View File
@@ -1,7 +1,7 @@
# AefComponentTest Docker 이미지 빌드 및 추출 스크립트
$IMAGE_NAME = "aef-component-test"
$TAG = "0.0.5"
$TAG = "0.0.9"
$OUTPUT_FILE = "aef-component-test.tar"
Write-Host "--- Docker 이미지 빌드 시작: ${IMAGE_NAME}:${TAG} ---" -ForegroundColor Cyan
+179 -37
View File
@@ -310,13 +310,13 @@
}
},
"node_modules/@angular-devkit/schematics": {
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.2.tgz",
"integrity": "sha512-CCeyQxGUq+oyGnHd7PfcYIVbj9pRnqjQq0rAojoAqs1BJdtInx9weLBCLy+AjM3NHePeZrnwm+wEVr8apED8kg==",
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz",
"integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": "21.2.2",
"@angular-devkit/core": "21.2.7",
"jsonc-parser": "3.3.1",
"magic-string": "0.30.21",
"ora": "9.3.0",
@@ -328,6 +328,47 @@
"yarn": ">= 1.13.0"
}
},
"node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"jsonc-parser": "3.3.1",
"picomatch": "4.0.4",
"rxjs": "7.8.2",
"source-map": "0.7.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"chokidar": "^5.0.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/@angular-devkit/schematics/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@angular/build": {
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.2.tgz",
@@ -429,19 +470,19 @@
}
},
"node_modules/@angular/cli": {
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.2.tgz",
"integrity": "sha512-eZo8/qX+ZIpIWc0CN+cCX13Lbgi/031wAp8DRVhDDO6SMVtcr/ObOQ2S16+pQdOMXxiG3vby6IhzJuz9WACzMQ==",
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.7.tgz",
"integrity": "sha512-N/wj8fFRB718efIFYpwnYfy+MecZREZXsUNMTVndFLH6T0jCheb9PVetR6jsyZp6h46USNPOmJYJ/9255lME+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/architect": "0.2102.2",
"@angular-devkit/core": "21.2.2",
"@angular-devkit/schematics": "21.2.2",
"@angular-devkit/architect": "0.2102.7",
"@angular-devkit/core": "21.2.7",
"@angular-devkit/schematics": "21.2.7",
"@inquirer/prompts": "7.10.1",
"@listr2/prompt-adapter-inquirer": "3.0.5",
"@modelcontextprotocol/sdk": "1.26.0",
"@schematics/angular": "21.2.2",
"@schematics/angular": "21.2.7",
"@yarnpkg/lockfile": "1.1.0",
"algoliasearch": "5.48.1",
"ini": "6.0.0",
@@ -463,6 +504,66 @@
"yarn": ">= 1.13.0"
}
},
"node_modules/@angular/cli/node_modules/@angular-devkit/architect": {
"version": "0.2102.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz",
"integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": "21.2.7",
"rxjs": "7.8.2"
},
"bin": {
"architect": "bin/cli.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
}
},
"node_modules/@angular/cli/node_modules/@angular-devkit/core": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"jsonc-parser": "3.3.1",
"picomatch": "4.0.4",
"rxjs": "7.8.2",
"source-map": "0.7.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"chokidar": "^5.0.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/@angular/cli/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@angular/common": {
"version": "21.2.4",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.4.tgz",
@@ -629,9 +730,9 @@
}
},
"node_modules/@angular/ssr": {
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.2.tgz",
"integrity": "sha512-ZEdE0G3+v0JCtSxpBynSlGImXi47HwWGqANtfHcOJUdf6GGKzSzoyliT1+7Lu2TR4TyCjtDq/RQH5bETr3zghA==",
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular/ssr/-/ssr-21.2.7.tgz",
"integrity": "sha512-NhrkeD32s3H/jU9yJLqDy2JBNNatFyzqNkwieJw0waEvBRNbxXlcg5+g6rilcg2nHlH5hyzMQUzs7ZwZH9wCqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -1660,9 +1761,9 @@
"optional": true
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3880,14 +3981,14 @@
]
},
"node_modules/@schematics/angular": {
"version": "21.2.2",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.2.tgz",
"integrity": "sha512-Ywa6HDtX7TRBQZTVMMnxX3Mk7yVnG8KtSFaXWrkx779+q8tqYdBwNwAqbNd4Zatr1GccKaz9xcptHJta5+DTxw==",
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.7.tgz",
"integrity": "sha512-aqEj3RyBtmH+41HZvrbfrpCo0e+0NzwyQyNSC/wLDShVqoidBtPbEdHU1FZ4+ni41da7rI3F12gUuAHws27kMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@angular-devkit/core": "21.2.2",
"@angular-devkit/schematics": "21.2.2",
"@angular-devkit/core": "21.2.7",
"@angular-devkit/schematics": "21.2.7",
"jsonc-parser": "3.3.1"
},
"engines": {
@@ -3896,6 +3997,47 @@
"yarn": ">= 1.13.0"
}
},
"node_modules/@schematics/angular/node_modules/@angular-devkit/core": {
"version": "21.2.7",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz",
"integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"jsonc-parser": "3.3.1",
"picomatch": "4.0.4",
"rxjs": "7.8.2",
"source-map": "0.7.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0",
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
"yarn": ">= 1.13.0"
},
"peerDependencies": {
"chokidar": "^5.0.0"
},
"peerDependenciesMeta": {
"chokidar": {
"optional": true
}
}
},
"node_modules/@schematics/angular/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@sigstore/bundle": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz",
@@ -4503,9 +4645,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5671,9 +5813,9 @@
}
},
"node_modules/hono": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
"version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7125,9 +7267,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -7954,9 +8096,9 @@
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz",
"integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==",
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
"integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8007,9 +8149,9 @@
"license": "MIT"
},
"node_modules/tar": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
+7
View File
@@ -1,5 +1,12 @@
<skip-links ngSkipHydration>
<button (click)="skipToContent()">{{ translation.common.skipToContent }}</button>
</skip-links>
<header-menu></header-menu>
<main #content>
<router-outlet />
</main>
<div id="global-popup-root" class="popup-outlet">
@for (popup of popupService.activePopups(); track popup.component) {
+2
View File
@@ -2,9 +2,11 @@ import { Routes } from '@angular/router';
import { MainPage } from './pages/main/main';
import { KnownIssuesPage } from './pages/known-issues/known-issues';
import { ClockPage } from './pages/clock/clock';
import { LevelPage } from './pages/levels/levels';
export const routes: Routes = [
{ path: '', component: MainPage },
{ path: 'known-issues', component: KnownIssuesPage },
{ path: 'clock', component: ClockPage },
{ path: 'levels', component: LevelPage },
];
+7
View File
@@ -0,0 +1,7 @@
:host {
min-height: 100dvh;
}
main:focus-visible {
outline: none;
}
+45 -16
View File
@@ -1,34 +1,63 @@
import { Component, inject, effect, PLATFORM_ID } from '@angular/core';
import { Component, inject, effect, PLATFORM_ID, ElementRef, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { HeaderMenu } from './features/header-menu/header-menu';
import { PopupService } from './services/popup.service';
import { SettingsService } from './services/settings.service';
import { NgComponentOutlet, isPlatformBrowser } from '@angular/common';
import { NgComponentOutlet, isPlatformBrowser, isPlatformServer, DOCUMENT } from '@angular/common';
import { SkipLinks } from './components/skip-links/skip-links';
import { TranslationService } from './services/translation.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet],
imports: [RouterOutlet, HeaderMenu, NgComponentOutlet, SkipLinks],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrl: './app.scss',
})
export class App {
protected popupService = inject(PopupService);
protected settingsService = inject(SettingsService);
private platformId = inject(PLATFORM_ID);
protected translation = inject(TranslationService).translation;
private platformID = inject(PLATFORM_ID);
private contentElement = viewChild<ElementRef<HTMLElement>>("content");
private document = inject(DOCUMENT);
private rootHtmlElement!: HTMLElement;
constructor() {
effect(() => {
if (isPlatformBrowser(this.platformId)) {
const isDark = this.settingsService.darkMode();
document.documentElement.classList.toggle('dark-theme', isDark);
document.documentElement.classList.toggle('light-theme', !isDark);
const lang = this.settingsService.language();
document.documentElement.lang = lang.toLowerCase();
const reduceMotion = this.settingsService.reduceAnimation();
document.documentElement.classList.toggle('reduce-motion', reduceMotion);
if (isPlatformServer(this.platformID)) {
this.rootHtmlElement = this.document.documentElement;
} else if (isPlatformBrowser(this.platformID)) {
this.rootHtmlElement = document.documentElement;
}
effect(() => {
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;
}
}
+151 -66
View File
@@ -1,12 +1,14 @@
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
input,
NgZone,
OnDestroy,
ViewChild,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { SettingsService } from '../../services/settings.service';
@@ -17,17 +19,28 @@ import { SettingsService } from '../../services/settings.service';
imports: [CommonModule],
templateUrl: './brand-accent.html',
styleUrl: './brand-accent.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrandAccent implements OnDestroy {
@ViewChild('canvasElement') canvasRef!: ElementRef<HTMLCanvasElement>;
canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('canvasElement');
private ctx!: CanvasRenderingContext2D;
private resizeObserver?: ResizeObserver;
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;
@@ -46,65 +59,107 @@ export class BrandAccent implements OnDestroy {
};
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
this.settingsService.darkMode();
const isDark = this.settingsService.darkMode();
if (this.ctx) {
this.cacheGradients();
this.cacheGradients(isDark);
this.cacheBackground(isDark);
}
});
afterNextRender(() => {
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
const nativeElement = this.canvasRef()?.nativeElement;
if (nativeElement) {
this.ctx = nativeElement.getContext('2d', { colorSpace: 'srgb' })!;
this.ngZone.runOutsideAngular(() => {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(this.canvasRef.nativeElement);
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.resize(); // Safe Initialization
this.animation.startTime = performance.now();
this.draw();
this.animate(performance.now());
// this.animate(performance.now());
});
}
});
}
private resize() {
const canvas = this.canvasRef.nativeElement;
private resize(width: number, height: number) {
const canvas = this.canvasRef()?.nativeElement;
if (canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
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.draw();
this.animate(performance.now());
}
}
private updateAnimationInfo() {
this.canvasNativeSize = [
this.canvasRef.nativeElement.clientWidth,
this.canvasRef.nativeElement.clientHeight,
];
this.animation.gridDimension = [
Math.trunc(this.canvasNativeSize[0] / this.animation.gridSquareSize) + 3,
Math.trunc(this.canvasNativeSize[1] / this.animation.gridSquareSize) + 3,
];
this.cacheGradients();
}
private cacheGradients() {
let w = this.canvasNativeSize[0];
let h = this.canvasNativeSize[1];
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, h);
this.bgGradient = this.ctx.createLinearGradient(
0,
0,
w * this.bgRendererScale,
h * this.bgRendererScale,
);
this.bgGradient.addColorStop(0, 'oklch(1 0 0 / 0.6)');
this.bgGradient.addColorStop(1, 'oklch(0.7938 0.1292 47.77 / 0.6)');
}
@@ -121,17 +176,54 @@ export class BrandAccent implements OnDestroy {
}
}
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);
const canvas = this.canvasRef.nativeElement;
if (
canvas.clientWidth !== this.canvasNativeSize[0] ||
canvas.clientHeight !== this.canvasNativeSize[1]
) {
return;
}
this.update(currentTime);
this.draw();
};
@@ -148,33 +240,26 @@ export class BrandAccent implements OnDestroy {
this.ctx.clearRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
const isDark = this.settingsService.darkMode();
if (!isDark) {
if (this.offscreenCanvas) {
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.fillStyle = this.bgGradient;
this.ctx.fillRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
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 = 'source-over';
this.ctx.fillStyle = 'oklch(0.1776 0 0)';
this.ctx.fillRect(0, 0, this.canvasNativeSize[0], this.canvasNativeSize[1]);
const ellipseDimension = [
Math.max(100, this.canvasNativeSize[0] * 0.9421875),
Math.max(200 / 3, this.canvasNativeSize[1] * (203 / 120)),
];
const ellipseX = this.canvasNativeSize[0] / 2;
const ellipseY = this.canvasNativeSize[1] * 0.5763888888 + ellipseDimension[1] / 2;
this.ctx.filter = `blur(${Math.min(780, Math.min(ellipseDimension[0], ellipseDimension[1]) * 0.6)}px)`;
this.ctx.beginPath();
this.ctx.ellipse(ellipseX, ellipseY, ellipseDimension[0] / 2, ellipseDimension[1] / 2, 0, 0, 2 * Math.PI);
this.ctx.fillStyle = 'oklch(0.2547 0.0913 29.02)';
this.ctx.fill();
this.ctx.filter = 'none';
this.ctx.globalCompositeOperation = 'screen';
}
@@ -191,7 +276,8 @@ export class BrandAccent implements OnDestroy {
const yOffset =
(this.canvasNativeSize[1] - this.animation.gridDimension[1] * this.animation.gridSquareSize) /
2;
const movingOffset = -(this.animation.gridSquareSize * this.animation.progress);
const movingOffset = this.settingsService.reduceAnimation() ? 0 : -(this.animation.gridSquareSize * this.animation.progress);
for (let y = 0; y < this.animation.gridDimension[1]; y++) {
this.ctx.moveTo(
@@ -219,9 +305,8 @@ export class BrandAccent implements OnDestroy {
}
ngOnDestroy() {
if (this.animation.id) {
cancelAnimationFrame(this.animation.id);
}
if (this.animation.id) cancelAnimationFrame(this.animation.id);
this.resizeObserver?.disconnect();
this.intersectionObserver?.disconnect();
}
}
+2 -2
View File
@@ -16,11 +16,11 @@
.clock-date {
font-size: 1.2rem;
font-weight: 500;
color: oklch(0.3211 0 0); // #333
color: light-dark(oklch(0.3211 0 0), oklch(0.6789 0 0)); // #333
}
.clock-time {
font-size: 2rem;
font-weight: 600;
color: oklch(0 0 0); // #000
color: light-dark(oklch(0 0 0), oklch(1 0 0)); // #000
}
+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">
@if (icon()) {
<span class="material-symbols-outlined icon-24 prefix-icon">{{ icon() }}</span>
@if (icon()?.trim()?.length ?? 0 > 0) {
<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>
@if (isOpen()) {
<ul class="dropdown-list" role="listbox" [id]="dropdownId + '-listbox'">
@for (option of normalizedOptions(); track option.value; let i = $index) {
<div class="dropdown-container" tabindex="-1">
<ul class="dropdown-list" role="listbox" [id]="dropdownID + '-listbox'" tabindex="-1">
@for (option of filteredOptions(); track option.value; let i = $index) {
<li
#optionItem
class="dropdown-item"
role="option"
[id]="dropdownId + '-option-' + i"
[id]="dropdownID + '-option-' + i"
[class.selected]="option.value === value()"
[class.focused]="i === focusedIndex()"
[attr.aria-selected]="option.value === value()"
@@ -21,4 +37,5 @@
</li>
}
</ul>
</div>
}
+96 -9
View File
@@ -13,6 +13,18 @@ $border_radius: 8px;
$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;
}
@property --selected-state {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
:host {
display: inline-block;
position: relative;
@@ -26,6 +38,11 @@ $default_animation_duration: 0.2s;
cursor: pointer;
max-width: 100%;
&:has(.dropdown-input.focus-mode:focus-visible) {
outline: 2px solid var(--selection-outline-color);
outline-offset: 2px;
}
}
.dropdown-box {
@@ -43,12 +60,40 @@ $default_animation_duration: 0.2s;
}
.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;
font-size: 18px;
height: 24px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
align-content: center;
@@ -66,22 +111,48 @@ $default_animation_duration: 0.2s;
}
}
.dropdown-list {
.dropdown-container {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 100%;
padding: 0;
margin: 0;
list-style: none;
background-color: light-dark(oklch(1 0 0), oklch(0 0 0));
border-radius: $border_radius;
z-index: 1000;
max-height: 240px;
overflow-y: auto;
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;
list-style: none;
width: 100%;
max-height: inherit;
overflow-y: auto;
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 {
content: "";
position: absolute;
@@ -113,16 +184,32 @@ $default_animation_duration: 0.2s;
padding: 0 16px;
position: relative;
align-content: center;
transition: background-color var(--animation-duration) $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 {
background-color: light-dark($hover_bg, $hover_dark_bg);
--hover-state: 1;
}
&.selected {
background-color: light-dark(list.nth(library.$brand_palette, 1), list.nth(library.$brand_palette, 11));
color: list.nth(library.$brand_palette, 5);
--selected-state: 1;
color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 6));
font-weight: 600;
}
}
+255 -30
View File
@@ -1,6 +1,22 @@
import { Component, input, model, output, signal, HostListener, ElementRef, computed, OnInit
} from '@angular/core'
import { CommonModule } from '@angular/common'
import {
Component,
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 {
label: string;
@@ -17,13 +33,15 @@ export interface DropdownOption {
role: 'combobox',
'aria-haspopup': 'listbox',
'[attr.aria-expanded]': 'isOpen()',
'[attr.aria-controls]': "isOpen() ? dropdownId + '-listbox' : null",
'[attr.aria-activedescendant]':
"isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
'[attr.aria-controls]': "isOpen() ? dropdownID + '-listbox' : null",
'[attr.aria-activedescendant]': 'ariaActiveDescendant()',
'[attr.aria-label]': 'ariaLabel()',
'[class.open]': 'isOpen()',
tabindex: '0',
'(click)': 'toggle()',
'(focusin)': 'onFocusIn($event)',
'(mousedown)': 'onMouseDown($event)',
'(keydown)': 'onKeyDown($event)',
'(keydown.enter)': 'onEnter($event)',
'(keydown.space)': 'onSpaceDown($event)',
'(keydown.escape)': 'close()',
@@ -32,8 +50,13 @@ export interface DropdownOption {
},
})
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>>([]);
placeholder = input<string>('Select an option');
placeholder = input<string>('');
ariaLabel = input<string | undefined>(undefined);
icon = input<string>();
defaultIndex = input<number>();
@@ -41,12 +64,53 @@ export class Dropdown implements OnInit {
selectionChange = output<any>();
isOpen = signal(false);
inputFocusMode = signal(false);
focusedIndex = signal(-1);
searchQuery = signal('');
recreateKey = signal(0);
private static nextId = 0;
dropdownId = `dropdown-${Dropdown.nextId++}`;
ariaActiveDescendant = computed(() => {
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();
@@ -58,6 +122,17 @@ export class Dropdown implements OnInit {
}
}
private scrollToIndex(index: number) {
if (!isPlatformBrowser(this.platformId)) return;
const items = this.optionItems();
if (items[index]) {
items[index].nativeElement.scrollIntoView({
block: 'nearest',
});
}
}
normalizedOptions = computed(() => {
return this.options().map((opt) => {
if (typeof opt === 'string') {
@@ -67,15 +142,78 @@ export class Dropdown implements OnInit {
});
});
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() {
this.isOpen.update((v) => !v);
if (this.isLatestMouseDownInsideInput) {
this.isLatestMouseDownInsideInput = false;
return;
}
if (this.isOpen()) {
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() {
const isFocusInside = this.element.nativeElement.contains(document.activeElement);
const isDropdownOpen = this.isOpen();
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);
}
@@ -85,63 +223,150 @@ export class Dropdown implements OnInit {
this.close();
}
onEnter(event: Event) {
onConfirm(event: Event) {
event.preventDefault();
if (this.isOpen()) {
const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
this.selectOption(this.normalizedOptions()[currentIndex]);
const options = this.filteredOptions();
if (currentIndex >= 0 && currentIndex < options.length) {
this.selectOption(options[currentIndex]);
} else if (options.length === 0) {
this.close();
}
} else {
this.toggle();
}
}
onEnter(event: Event) {
this.onConfirm(event);
}
onSpaceDown(event: Event) {
event.preventDefault();
if (this.isOpen()) {
const currentIndex = this.focusedIndex();
if (currentIndex >= 0 && currentIndex < this.normalizedOptions().length) {
this.selectOption(this.normalizedOptions()[currentIndex]);
}
} else {
this.toggle();
}
if (this.isOpen() && this.inputFocusMode()) return;
this.onConfirm(event);
}
onArrowDown(event: Event) {
event.preventDefault();
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
if (!this.isOpen()) {
this.toggle();
return;
}
this.focusedIndex.update((i) => {
const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i === -1) return 0;
return (i + 1) % this.normalizedOptions().length;
return (i + 1) % options.length;
});
}
onArrowUp(event: Event) {
event.preventDefault();
if (!this.inputFocusMode()) this.inputFocusMode.set(true);
if (!this.isOpen()) {
this.toggle();
return;
}
this.focusedIndex.update((i) => {
if (i <= 0) return this.normalizedOptions().length - 1;
const options = this.filteredOptions();
if (options.length === 0) return -1;
if (i <= 0) return options.length - 1;
return i - 1;
});
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent) {
if (!this.el.nativeElement.contains(event.target)) {
this.close();
onKeyDown(event: KeyboardEvent) {
const textArrowKeys = ['ArrowLeft', 'ArrowRight'];
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(() => {
const selected = this.normalizedOptions().find((o) => o.value === this.value());
const selected = this.selectedOption();
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()
}),
);
}
+4 -5
View File
@@ -1,19 +1,18 @@
<div class="popup-backdrop" [class.center]="isCenter" (click)="onClose()">
<div class="popup-container"
<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"
(click)="$event.stopPropagation()">
[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()">close</button>
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
</div>
</header>
+29 -6
View File
@@ -1,6 +1,12 @@
$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));
@@ -31,7 +37,7 @@ $default_animation_duration: 0.2s;
animation: popup-enter var(--animation-duration) $animation_timing;
outline: light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%)) 2px solid;
gap: 16px;
gap: 12px;
&.variant-glass {
background-color: light-dark(oklch(1 0 0 / 60%), oklch(0 0 0 / 60%));
@@ -46,6 +52,11 @@ $default_animation_duration: 0.2s;
&.custom-pos {
position: absolute;
}
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
}
@keyframes popup-enter {
@@ -77,17 +88,29 @@ $default_animation_duration: 0.2s;
.header-right {
.close-btn {
background: none;
--close-hover-state: 0;
border: none;
padding: 4px;
cursor: pointer;
border-radius: 50%;
color: oklch(0.4 0 0);
transition: background-color 0.2s, color 0.2s;
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 {
background-color: oklch(0 0 0 / 5%);
color: oklch(0 0 0);
--close-hover-state: 1;
}
}
}
+111 -8
View File
@@ -1,4 +1,15 @@
import { Component, input, output, HostListener } from '@angular/core';
import {
Component,
input,
output,
HostListener,
ElementRef,
inject,
viewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { TranslationService } from '../../services/translation.service';
export type PopupPosition = {
top?: string;
@@ -15,12 +26,16 @@ export type PopupVariant = 'glass' | 'solid';
templateUrl: './popup.html',
styleUrl: './popup.scss',
host: {
'role': 'dialog',
role: 'dialog',
'aria-modal': 'true',
'tabindex': '-1'
}
tabindex: '-1',
},
})
export class PopupComponent {
export class PopupComponent implements OnInit, OnDestroy {
private translation = inject(TranslationService).translation;
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
private previouslyFocusedElement: HTMLElement | null = null;
/**
* 팝업의 위치 (기본값은 'center')
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
@@ -32,14 +47,102 @@ export class PopupComponent {
*/
variant = input<PopupVariant>('glass');
/**
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
*/
closeButtonLabel = input<string>(this.translation.common.close);
/**
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
*/
closeRequest = output<void>();
@HostListener('document:keydown.escape')
onKeydownHandler() {
this.onClose();
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() {
@@ -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 {}
+23 -5
View File
@@ -16,16 +16,35 @@ $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 {
display: inline-block;
width: list.nth($dimension, 1); height: list.nth($dimension, 2);
border-radius: $border_radius;
background-color: light-dark($off_bg, $off_dark_bg);
box-shadow: 0 0 0 1px light-dark($border_color, $border_dark_color) inset;
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: background-color var(--animation-duration) $animation_timing, box-shadow var(--animation-duration) $animation_timing;
transition: --animation-state var(--animation-duration) $animation_timing;
cursor: pointer;
position: relative;
@@ -38,8 +57,7 @@ $default_animation_duration: 0.2s;
}
&.active {
background-color: light-dark(list.nth(library.$brand_palette, 5), list.nth(library.$brand_palette, 9));
box-shadow: var(--if-dark, 0 0 0 1px list.nth(library.$brand_palette, 10) inset);
--animation-state: 1;
.switch-knob {
transform: translate(
+14 -4
View File
@@ -1,19 +1,29 @@
<div class="menu-left">
<div #menuLeft class="menu-left">
<a class="brand" [routerLink]="['/']">
<img [src]="settings.darkMode() ? '/logo.svg' : '/logo_inverted.svg'" alt="logo" height="20" width="59.52" />
<span><strong>ADOFAI Editor Forum</strong></span>
<span #brandText [class.hidden]="expandStage() < 2">
<strong>ADOFAI Editor Forum</strong>
</span>
</a>
<span class="menu-item dummy-link">{{ translation.menu.levels }}</span>
<div #leftMenuItems [class.hidden]="expandStage() < 1" [attr.inert]="expandStage() < 1 ? '' : null" class="menu-items">
<a class="menu-item nav-link" [routerLink]="['/levels']">{{ translation.menu.levels }}</a>
<span class="menu-item dummy-link">{{ translation.menu.community }}</span>
<span class="menu-item dummy-link">{{ translation.menu.leaderboard }}</span>
<span class="menu-item dummy-link">{{ translation.menu.about }}</span>
</div>
</div>
<div class="menu-right">
<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
+47 -10
View File
@@ -4,11 +4,12 @@
display: flex;
flex-direction: row;
align-items: center;
justify-content: start;
gap: 30px;
justify-content: space-between;
gap: 0;
width: fit-content;
min-width: 100%;
height: 64px;
box-sizing: border-box;
box-shadow: 0 2px 0 light-dark(oklch(0 0 0 / 20%), oklch(1 0 0 / 20%));
@@ -26,11 +27,24 @@
align-items: center;
gap: 48px;
.menu-items {
display: flex;
flex-direction: row;
align-items: center;
gap: 48px;
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
& > * {
user-select: none;
}
.menu-item, a:not(.settings) {
.menu-item, a {
text-decoration: none;
font-size: 18px;
height: 40px;
@@ -42,10 +56,11 @@
cursor: pointer;
display: flex;
align-items: center;
border-radius: 4px;
&.nav-link {
font-weight: 700;
color: oklch(0.5 0.15 250);
color: oklch(0.6 0.15 250);
transition: opacity 0.3s;
&:hover {
@@ -62,7 +77,7 @@
}
}
.settings {
.settings, .menu {
border: none;
border-radius: 100%;
background-color: inherit;
@@ -71,6 +86,9 @@
position: relative;
z-index: 10;
cursor: pointer;
}
.settings {
transition: transform 0.3s ease;
&:hover {
@@ -78,6 +96,14 @@
}
}
.menu {
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
/* 구체적인 아이콘 예외 처리 */
.icon-40 {
height: auto;
@@ -85,6 +111,9 @@
}
.menu-left {
position: absolute;
left: 0;
justify-content: start;
padding: 12px;
@@ -104,7 +133,7 @@
img {
display: inline-block;
height: 20px;
padding: 10px 10px 10px 0;
padding: 10px 0;
}
strong {
@@ -112,15 +141,23 @@
}
span {
/*text-box-trim: trim-both;
text-box-edge: cap alphabetic;*/
display: inline-block;
padding-left: 10px;
&.hidden {
position: absolute;
top: -100000px;
pointer-events: none;
}
}
}
}
.menu-right {
flex-grow: 1;
position: absolute;
right: 0;
justify-content: flex-end;
padding: 12px 20px;
padding: 12px 20px 12px 12px;
}
}
+145 -2
View File
@@ -1,7 +1,19 @@
import { Component, inject } from '@angular/core';
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';
@@ -10,12 +22,143 @@ import { SettingsService } from '../../services/settings.service';
imports: [RouterLink],
templateUrl: './header-menu.html',
styleUrl: './header-menu.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.folded-menu]': 'expandStage() < 1',
},
})
export class HeaderMenu {
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;
}
}
-47
View File
@@ -1,47 +0,0 @@
<app-popup (closeRequest)="close()" [position]="position()">
<div header-left class="profile-section">
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
<span>{{ settingsService.userName() }}</span>
</div>
<div class="settings-content">
<section class="settings-category">
<label class="category-header">{{ translation.settings.language }}</label>
<div class="settings-container">
<dropdown [options]="languages"
[value]="settingsService.language()"
(selectionChange)="onLanguageChange($event)"
icon="language"></dropdown>
</div>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">match_case</span>
<span>{{ translation.settings.romanisation }}</span>
</div>
<switch [isActive]="settingsService.romanisation()" (toggle)="onRomanisationChange($event)"></switch>
</div>
</section>
<section class="settings-category">
<label class="category-header">{{ translation.settings.appearance }}</label>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">dark_mode</span>
<span>{{ translation.settings.darkMode }}</span>
</div>
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
</div>
<div class="settings-container">
<div class="settings-name">
<span class="material-symbols-outlined icon-24 settings-icon">motion_photos_paused</span>
<span>{{ translation.settings.reduceAnimation }}</span>
</div>
<switch [isActive]="settingsService.reduceAnimation()" (toggle)="onReduceAnimationChange($event)"></switch>
</div>
</section>
</div>
</app-popup>
-92
View File
@@ -1,92 +0,0 @@
:host {
display: block;
user-select: none;
}
.profile-section {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
.avatar {
border-radius: 50%;
border: 1px solid oklch(0 0 0 / 20%);
object-fit: cover;
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 12px;
section.settings-category {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
justify-content: flex-start;
margin: 0; padding: 0;
border: none;
label {
display: block;
font-weight: 500;
font-size: 14px;
height: 15px;
color: oklch(0.6830 0 0); // #999999
}
& > .settings-container {
width: 100%;
display: flex;
&:has(> switch) {
height: 32px;
&:nth-child(1 of .settings-container) {
margin-top: 4px;
}
}
&:has(> dropdown) {
height: 40px;
}
flex-direction: row;
align-items: center;
justify-content: space-between;
.settings-name {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
gap: 8px;
span {
display: inline-block;
font-size: 18px;
&.settings-icon {
display: inline-block;
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
}
}
}
}
dropdown {
width: 324px;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<app-popup (closeRequest)="close()" [position]="position()">
<div header-left class="profile-section">
<img ngSrc="/guest_profile.png" alt="profile" class="avatar" width="24" height="24">
<span>{{ settingsService.isGuest() ? translation.settings.guest : settingsService.userName() }}</span>
</div>
<div class="settings-content">
<settings-category [headerLabel]="translation.settings.language">
<settings-container
[settingsType]="SettingsType.Dropdown"
[options]="languages"
[value]="settingsService.language()"
[labelText]="translation.settings.language" labelIcon="language"
(onChange)="onLanguageChange($event)">
</settings-container>
<settings-container
[settingsType]="SettingsType.Switch"
[value]="settingsService.romanisation()"
[labelText]="translation.settings.romanisation" labelIcon="match_case"
(onChange)="onRomanisationChange($event)">
</settings-container>
</settings-category>
<settings-category [headerLabel]="translation.settings.appearance">
<settings-container
[settingsType]="SettingsType.Switch"
[value]="settingsService.darkMode()"
[labelText]="translation.settings.darkMode" labelIcon="dark_mode"
(onChange)="onDarkModeChange($event)">
</settings-container>
<settings-container
[settingsType]="SettingsType.Switch"
[value]="settingsService.reduceAnimation()"
[labelText]="translation.settings.reduceAnimation" labelIcon="motion_photos_paused"
(onChange)="onReduceAnimationChange($event)">
</settings-container>
</settings-category>
</div>
</app-popup>
+29
View File
@@ -0,0 +1,29 @@
:host {
display: block;
user-select: none;
}
.profile-section {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 500;
.avatar {
border-radius: 50%;
outline: 1px solid oklch(0 0 0 / 20%);
object-fit: cover;
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
padding-bottom: 12px;
}
+53 -4
View File
@@ -1,4 +1,4 @@
import { Component, inject, input } from '@angular/core';
import { Component, inject, input, output } from '@angular/core';
import { PopupComponent, PopupPosition } from '../../components/popup/popup';
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { Switch } from '../../components/switch/switch';
@@ -7,12 +7,59 @@ 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, Dropdown, Switch, NgOptimizedImage],
templateUrl: './popup.html',
styleUrl: './popup.scss',
imports: [
PopupComponent,
NgOptimizedImage,
SettingsCategory,
SettingsContainer,
],
templateUrl: './settings.html',
styleUrl: './settings.scss',
})
export class SettingsPopup {
private popupService = inject(PopupService);
@@ -52,4 +99,6 @@ export class SettingsPopup {
close() {
this.popupService.close(SettingsPopup);
}
protected readonly SettingsType = SettingsType;
}
+7
View File
@@ -16,10 +16,17 @@ export const EN = {
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",
},
};
+7
View File
@@ -18,9 +18,16 @@ export const JP: Translation = {
knownIssues: '既知の問題',
login: 'ログイン',
signup: '新規登録',
menu: 'メニュー',
},
common: {
selectOption: 'オプションを選択...',
close: '閉じる',
save: '保存',
skipToContent: '本文へスキップ',
},
levelCard: {
preview: '{{artist}}の{{trackName}} プレビュー',
download: '{{creator}}のレベルをダウンロード',
},
};
+7
View File
@@ -18,9 +18,16 @@ export const KR: Translation = {
knownIssues: '알려진 문제',
login: '로그인',
signup: '회원가입',
menu: '메뉴',
},
common: {
selectOption: '옵션 선택...',
close: '닫기',
save: '저장',
skipToContent: '본문으로 건너뛰기',
},
levelCard: {
preview: '{{artist}}의 {{trackName}} 미리듣기',
download: '{{creator}}의 레벨 다운로드',
},
};
+40
View File
@@ -0,0 +1,40 @@
<h1>Level Card Test</h1>
<div class="container">
<level-card
artist="HyuN"
trackName="Disorder (Low VFX)"
[creator]="['YOON']"></level-card>
<level-card
artist="Cinamoro"
trackName="Little Rabbit and Deliverance"
[creator]="['한가지']"></level-card>
<level-card
artist="linear ring"
trackName="Dream By Coma"
[creator]="['Ryuha']"></level-card>
<level-card
artist="Plum"
trackName="Champagne Pop"
[creator]="['Zizak']"></level-card>
<level-card
artist="JinoBeats ft. JinoBeats"
trackName="~ Black Forsaken Wings ~"
[creator]="['Sakura']"></level-card>
<level-card
artist="seatrus"
trackName="M A X I M U M B L A S T E R"
[creator]="['TraZ']"></level-card>
<level-card
artist="MegaSphere"
trackName="Inferno"
[creator]="['-K']"></level-card>
<level-card
artist="Rinne"
trackName="Aurelia"
[creator]="['토끼로봇']"></level-card>
<level-card
artist="Sot-C"
trackName="Escalation"
[creator]="['Chartic']"></level-card>
</div>
+17
View File
@@ -0,0 +1,17 @@
@scope(:host) {
width: 100%;
text-align: center;
}
:host > h1 {
margin-top: 40px;
}
:host > .container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 40px;
max-width: calc(40px * 2 + 400px * 3);
margin: 0 auto;
}
+15
View File
@@ -0,0 +1,15 @@
import { Component, signal } from '@angular/core';
import { Switch } from '../../components/switch/switch';
import { Dropdown, DropdownOption } from '../../components/dropdown/dropdown';
import { BrandAccent } from '../../components/brand-accent/brand-accent';
import { LevelCard } from '../../components/level-card/level-card';
@Component({
selector: 'app-levels',
standalone: true,
imports: [LevelCard],
templateUrl: './levels.html',
styleUrl: './levels.scss',
})
export class LevelPage {
}
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="container">
<brand-accent></brand-accent>
<div class="container">
<h1>Component Test</h1>
<section id="switch-test" class="feature-block">
+18 -6
View File
@@ -1,6 +1,16 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100%;
}
:host > .container {
display: flex;
width: 600px;
width: 100%;
max-width: 600px;
box-sizing: border-box;
flex-direction: column;
gap: 40px;
@@ -8,7 +18,8 @@
section#dropdown-test {
dropdown {
width: 324px;
width: 100%;
max-width: 324px;
}
}
@@ -35,11 +46,12 @@ div.test-container {
brand-accent {
position: absolute;
width: 100%;
height: 100%;
top: 0;
top: -64px;
left: 0;
width: 100%;
min-width: 100dvw;
height: calc(100% + 64px);
min-height: 100dvh;
z-index: -999999;
}
+37
View File
@@ -17,6 +17,43 @@ export class MainPage {
{ 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);
+1
View File
@@ -23,6 +23,7 @@ export class SettingsService {
romanisation = computed(() => this._romanisation());
userName = computed(() => this._userName());
reduceAnimation = computed(() => this._reduceAnimation());
isGuest = computed(() => true);
constructor() {
this.request = inject(REQUEST);
+10
View File
@@ -29,4 +29,14 @@ export class TranslationService {
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
);
}
}
+7 -1
View File
@@ -39,6 +39,8 @@ body {
margin: 0;
overscroll-behavior-x: none;
background-color: white;
min-height: 100dvh;
}
.icon-24 {
@@ -118,7 +120,11 @@ section.feature-block {
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 list.nth(library.$brand_palette, 5);
outline: 2px solid var(--selection-outline-color);
outline-offset: 2px;
}
+16
View File
@@ -12,5 +12,21 @@ $brand_palette: [
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
];
/*
$brand_palette: [
oklch(0.9999 0.2237 35.33),
oklch(0.9090 0.2237 35.33),
oklch(0.8181 0.2237 35.33),
oklch(0.7272 0.2237 35.33),
oklch(0.6363 0.2237 35.33),
oklch(0.5454 0.2237 35.33),
oklch(0.4545 0.2237 35.33),
oklch(0.3636 0.2237 35.33),
oklch(0.2727 0.2237 35.33),
oklch(0.1818 0.2237 35.33),
oklch(0.0909 0.2237 35.33),
];
*/