Add Reduce Animation Setting

This commit is contained in:
copecone committed 2026-04-02 14:54:57 +09:00
1 parent 9043aa0535
commit 4974a699dc
15 files changed
+124 -102

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ export class App {
const lang = this.settingsService.language(); const lang = this.settingsService.language();
document.documentElement.lang = lang.toLowerCase(); document.documentElement.lang = lang.toLowerCase();
const reduceMotion = this.settingsService.reduceAnimation();
document.documentElement.classList.toggle('reduce-motion', reduceMotion);
} }
}); });
} }
+20 -24
View File
@@ -2,40 +2,36 @@
@use "sass:color"; @use "sass:color";
@use "styles/library" as library; @use "styles/library" as library;
$dropdown_bg: oklch(1 0 0); // #FFFFFF $default_bg: oklch(1 0 0); // #FFFFFF
$dropdown_border_color: oklch(0 0 0 / 20%); $border_color: oklch(0 0 0 / 20%);
$dropdown_hover: oklch(0.9702 0 0); // #F5F5F5 $hover_bg: oklch(0.9702 0 0); // #F5F5F5
$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;
: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: $default_bg;
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 $border_color inset;
cursor: pointer; cursor: pointer;
max-width: 100%; max-width: 100%;
--dropdown-duration: #{$dropdown_animation_duration};
&.reduce-motion {
--dropdown-duration: 0s;
}
} }
.dropdown-box { .dropdown-box {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 10px; padding: 0 10px;
height: $dropdown_height; height: $height;
box-sizing: border-box; box-sizing: border-box;
gap: 8px; gap: 8px;
@@ -60,7 +56,7 @@ $dropdown_animation_duration: 0.2s;
.chevron { .chevron {
width: 24px; height: 24px; width: 24px; height: 24px;
flex-shrink: 0; flex-shrink: 0;
transition: transform var(--dropdown-duration) $dropdown_animation_timing; transition: transform var(--dropdown-duration) $animation_timing;
user-select: none; user-select: none;
&.open { &.open {
@@ -77,20 +73,20 @@ $dropdown_animation_duration: 0.2s;
padding: 0; padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
background-color: $dropdown_bg; background-color: $default_bg;
border-radius: $dropdown_border_radius; border-radius: $border_radius;
z-index: 1000; z-index: 1000;
max-height: 240px; max-height: 240px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1) box-shadow: 0 4px 12px oklch(0 0 0 / 10%); // rgba(0, 0, 0, 0.1)
animation: slideIn var(--dropdown-duration) $dropdown_animation_timing; animation: slideIn var(--animation-duration) $animation_timing;
&::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 $border_color inset;
pointer-events: none; pointer-events: none;
z-index: 1001; z-index: 1001;
} }
@@ -108,10 +104,10 @@ $dropdown_animation_duration: 0.2s;
padding: 0 16px; padding: 0 16px;
position: relative; position: relative;
align-content: center; align-content: center;
transition: background-color var(--dropdown-duration) $dropdown_animation_timing; transition: background-color var(--animation-duration) $animation_timing;
&:hover, &.focused { &:hover, &.focused {
background-color: $dropdown_hover; background-color: $hover_bg;
} }
&.selected { &.selected {
-7
View File
@@ -22,7 +22,6 @@ export interface DropdownOption {
"isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null", "isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null",
'[attr.aria-label]': 'ariaLabel()', '[attr.aria-label]': 'ariaLabel()',
'[class.open]': 'isOpen()', '[class.open]': 'isOpen()',
'[class.reduce-motion]': 'reduceMotion()',
tabindex: '0', tabindex: '0',
'(click)': 'toggle()', '(click)': 'toggle()',
'(keydown.enter)': 'onEnter($event)', '(keydown.enter)': 'onEnter($event)',
@@ -47,12 +46,6 @@ export class Dropdown implements OnInit {
private static nextId = 0; private static nextId = 0;
dropdownId = `dropdown-${Dropdown.nextId++}`; dropdownId = `dropdown-${Dropdown.nextId++}`;
reduceMotion = input<boolean>(
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
);
constructor(private el: ElementRef) {} constructor(private el: ElementRef) {}
ngOnInit() { ngOnInit() {
+3 -3
View File
@@ -26,8 +26,8 @@
overflow: visible; overflow: visible;
animation: popup-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1); animation: popup-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1);
border: oklch(0 0 0 / 20%) 2px solid; outline: oklch(0 0 0 / 20%) 2px solid;
gap: 15px; gap: 16px;
&.variant-glass { &.variant-glass {
background-color: oklch(1 0 0 / 60%); background-color: oklch(1 0 0 / 60%);
@@ -61,7 +61,7 @@
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
height: 40px; height: 40px;
border-bottom: 1px solid oklch(0 0 0 / 5%); box-shadow: 0 1px 0 oklch(0 0 0 / 20%);
.header-left { .header-left {
display: flex; display: flex;
+20 -20
View File
@@ -2,32 +2,32 @@
@use "sass:list"; @use "sass:list";
@use "styles/library" as library; @use "styles/library" as library;
$switch_off_bg: oklch(0.683 0 0); // #999999 $off_bg: oklch(0.683 0 0); // #999999
$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; $border_radius: 15px;
$switch_border_color: oklch(0 0 0 / 20%); $border_color: oklch(0 0 0 / 20%);
$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); $animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
$switch_animation_duration: 0.2s; $default_animation_duration: 0.2s;
: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: $off_bg;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0 0 0 1px $switch_border_color inset; box-shadow: 0 0 0 1px $border_color inset;
--switch-duration: #{$switch_animation_duration}; --animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier));
transition: background-color var(--switch-duration) $switch_animation_timing; transition: background-color var(--animation-duration) $animation_timing;
&.reduce-motion { &.reduce-motion {
--switch-duration: 0s; --animation-duration: 0s;
} }
&:hover, &:focus-within { &:hover, &:focus-within {
@@ -41,8 +41,8 @@ $switch_animation_duration: 0.2s;
.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
); );
} }
} }
@@ -50,10 +50,10 @@ $switch_animation_duration: 0.2s;
.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;
} }
-7
View File
@@ -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)',
@@ -20,12 +19,6 @@ export class Switch {
isActive = model<boolean>(false); isActive = model<boolean>(false);
toggle = output<boolean>(); toggle = output<boolean>();
reduceMotion = input<boolean>(
typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
);
onClick(): void { onClick(): void {
this.isActive.update((value) => !value); this.isActive.update((value) => !value);
this.onToggle(); this.onToggle();
@@ -9,7 +9,7 @@
min-width: 100%; min-width: 100%;
box-sizing: border-box; box-sizing: border-box;
border-bottom: oklch(0 0 0 / 20%) 2px solid; box-shadow: 0 2px 0 oklch(0 0 0 / 20%);
background-color: oklch(1 0 0 / 60%); background-color: oklch(1 0 0 / 60%);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
+1 -1
View File
@@ -17,6 +17,6 @@ export class HeaderMenu {
onSettingsClick() { onSettingsClick() {
console.log('HeaderMenu: Settings clicked'); console.log('HeaderMenu: Settings clicked');
this.popupService.open(SettingsPopup, { position: { top: '82px', right: '18px' } }); this.popupService.open(SettingsPopup, { position: { top: '84px', right: '20px' } });
} }
} }
+8 -1
View File
@@ -10,7 +10,6 @@
<div class="settings-container"> <div class="settings-container">
<dropdown [options]="languages" <dropdown [options]="languages"
[placeholder]="translation.settings.chooseLanguage"
[value]="settingsService.language()" [value]="settingsService.language()"
(selectionChange)="onLanguageChange($event)" (selectionChange)="onLanguageChange($event)"
icon="language"></dropdown> icon="language"></dropdown>
@@ -34,6 +33,14 @@
</div> </div>
<switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch> <switch [isActive]="settingsService.darkMode()" (toggle)="onDarkModeChange($event)"></switch>
</div> </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> </section>
</div> </div>
+4
View File
@@ -42,6 +42,10 @@ export class SettingsPopup {
this.settingsService.setRomanisation(value); this.settingsService.setRomanisation(value);
} }
onReduceAnimationChange(value: boolean) {
this.settingsService.setReduceAnimation(value);
}
/** /**
* 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다. * 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다.
*/ */
+4 -5
View File
@@ -1,12 +1,11 @@
export const EN = { export const EN = {
settings: { settings: {
title: 'Settings', guest: 'Guest',
language: 'Language', language: 'Language',
appearance: 'Appearance', appearance: 'Appearance',
darkMode: 'Dark Mode', darkMode: 'Dark Mode',
romanisation: 'Romanisation', romanisation: 'Romanisation',
guest: 'Guest', reduceAnimation: 'Reduce Animation',
chooseLanguage: 'Choose Language'
}, },
menu: { menu: {
levels: 'Levels', levels: 'Levels',
@@ -19,8 +18,8 @@ export const EN = {
}, },
common: { common: {
close: 'Close', close: 'Close',
save: 'Save' save: 'Save',
} },
}; };
export type Translation = typeof EN; export type Translation = typeof EN;
+2 -3
View File
@@ -2,13 +2,12 @@ import { Translation } from './en';
export const JP: Translation = { export const JP: Translation = {
settings: { settings: {
title: '設定', guest: 'ゲスト',
language: '言語', language: '言語',
appearance: '外観', appearance: '外観',
darkMode: 'ダークモード', darkMode: 'ダークモード',
romanisation: 'ローマ字表記', romanisation: 'ローマ字表記',
guest: 'ゲスト', reduceAnimation: 'アニメーションを減らす',
chooseLanguage: '言語を選択',
}, },
menu: { menu: {
levels: 'レベル', levels: 'レベル',
+2 -3
View File
@@ -2,13 +2,12 @@ import { Translation } from './en';
export const KR: Translation = { export const KR: Translation = {
settings: { settings: {
title: '설정', guest: '게스트',
language: '언어', language: '언어',
appearance: '화면 설정', appearance: '화면 설정',
darkMode: '다크 모드', darkMode: '다크 모드',
romanisation: '로마자 표기', romanisation: '로마자 표기',
guest: '게스트', reduceAnimation: '애니메이션 줄이기',
chooseLanguage: '언어 선택',
}, },
menu: { menu: {
levels: '레벨', levels: '레벨',
+47 -26
View File
@@ -12,15 +12,17 @@ export class SettingsService {
// Signals for state // Signals for state
private _language = signal<Language>('EN'); private _language = signal<Language>('EN');
private _darkMode = signal<boolean>(true); private _darkMode = signal<boolean>(false);
private _romanisation = signal<boolean>(false); private _romanisation = signal<boolean>(true);
private _userName = signal<string>('Guest'); private _userName = signal<string>('Guest');
private _reduceAnimation = signal<boolean>(false);
// Public computed or read-only signals // Public computed or read-only signals
language = computed(() => this._language()); language = computed(() => this._language());
darkMode = computed(() => this._darkMode()); darkMode = computed(() => this._darkMode());
romanisation = computed(() => this._romanisation()); romanisation = computed(() => this._romanisation());
userName = computed(() => this._userName()); userName = computed(() => this._userName());
reduceAnimation = computed(() => this._reduceAnimation());
constructor() { constructor() {
this.request = inject(REQUEST); this.request = inject(REQUEST);
@@ -28,30 +30,54 @@ export class SettingsService {
} }
private initializeSettings() { private initializeSettings() {
let parsedCookie: Record<string, string>;
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
// 클라이언트: 브라우저 API 사용 parsedCookie = this.parseCookies(document.cookie);
this._language.set((this.getCookie('lang') as Language) || 'EN');
this._darkMode.set(this.getCookie('darkMode') !== 'false');
this._romanisation.set(this.getCookie('romanisation') === 'true');
this._userName.set(this.getCookie('userName') || 'Guest');
} else if (isPlatformServer(this.platformId)) { } else if (isPlatformServer(this.platformId)) {
const cookieHeader = this.request?.headers.get('Cookie'); const cookieHeader = this.request?.headers.get('Cookie');
if (cookieHeader) { if (!cookieHeader) return;
const cookies = this.parseServerCookies(cookieHeader); parsedCookie = this.parseCookies(cookieHeader);
} else return;
// console.log(cookies); const settings = {
if (cookies['lang']) this._language.set(cookies['lang'] as Language); lang: parsedCookie['lang'],
if (cookies['darkMode'] !== undefined) this._darkMode.set(cookies['darkMode'] !== 'false'); darkMode: parsedCookie['darkMode'],
if (cookies['romanisation'] !== undefined) this._romanisation.set(cookies['romanisation'] === 'true'); romanisation: parsedCookie['romanisation'],
if (cookies['userName']) this._userName.set(cookies['userName']); 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 parseServerCookies(cookieString: string): Record<string, string> { private parseCookies(cookieString: string): Record<string, string> {
const cookies: Record<string, string> = {}; const cookies: Record<string, string> = {};
cookieString.split(';').forEach(c => { cookieString.split(';').forEach((c) => {
const [key, value] = c.trim().split('='); const [key, value] = c.trim().split('=');
if (key && value) cookies[key] = decodeURIComponent(value); if (key && value) cookies[key] = decodeURIComponent(value);
}); });
@@ -78,14 +104,9 @@ export class SettingsService {
this.setCookie('userName', name); this.setCookie('userName', name);
} }
private getCookie(name: string): string | undefined { setReduceAnimation(value: boolean) {
if (isPlatformBrowser(this.platformId)) { this._reduceAnimation.set(value);
const value = `; ${document.cookie}`; this.setCookie('reduceAnimation', value.toString());
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
}
return undefined;
} }
private setCookie(name: string, value: string) { private setCookie(name: string, value: string) {
+9 -1
View File
@@ -5,6 +5,14 @@
@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;
}
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;
@@ -77,7 +85,7 @@ section.feature-block {
gap: 12px; gap: 12px;
padding: 20px; padding: 20px;
border: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2) outline: 2px solid oklch(0 0 0 / 20%); // rgba(0, 0, 0, 0.2)
border-radius: 8px; border-radius: 8px;
background-color: oklch(1 0 0 / 60%); background-color: oklch(1 0 0 / 60%);