From 4974a699dcb1c4f5dc3ea1c0c143daabdfab4783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4DELTA?= Date: Thu, 2 Apr 2026 14:54:57 +0900 Subject: [PATCH] Add Reduce Animation Setting --- src/app/app.ts | 5 +- src/app/components/dropdown/dropdown.scss | 44 +++++------ src/app/components/dropdown/dropdown.ts | 7 -- src/app/components/popup/popup.scss | 6 +- src/app/components/switch/switch.scss | 40 +++++----- src/app/components/switch/switch.ts | 7 -- src/app/features/header-menu/header-menu.scss | 2 +- src/app/features/header-menu/header-menu.ts | 2 +- src/app/features/settings/popup.html | 9 ++- src/app/features/settings/settings.ts | 4 + src/app/i18n/en.ts | 9 +-- src/app/i18n/jp.ts | 5 +- src/app/i18n/kr.ts | 5 +- src/app/services/settings.service.ts | 73 ++++++++++++------- src/styles.scss | 10 ++- 15 files changed, 125 insertions(+), 103 deletions(-) diff --git a/src/app/app.ts b/src/app/app.ts index 123dc83..02faf7e 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -22,9 +22,12 @@ export class App { 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); } }); } diff --git a/src/app/components/dropdown/dropdown.scss b/src/app/components/dropdown/dropdown.scss index 55e3242..6693107 100644 --- a/src/app/components/dropdown/dropdown.scss +++ b/src/app/components/dropdown/dropdown.scss @@ -2,40 +2,36 @@ @use "sass:color"; @use "styles/library" as library; -$dropdown_bg: oklch(1 0 0); // #FFFFFF -$dropdown_border_color: oklch(0 0 0 / 20%); -$dropdown_hover: oklch(0.9702 0 0); // #F5F5F5 +$default_bg: oklch(1 0 0); // #FFFFFF +$border_color: oklch(0 0 0 / 20%); +$hover_bg: oklch(0.9702 0 0); // #F5F5F5 -$dropdown_height: 40px; -$dropdown_border_radius: 8px; +$height: 40px; +$border_radius: 8px; -$dropdown_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); -$dropdown_animation_duration: 0.2s; +$animation_timing: cubic-bezier(0.4, 0, 0.2, 1); +$default_animation_duration: 0.2s; :host { display: inline-block; position: relative; + --animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier)); padding: 0; margin: 0; - background-color: $dropdown_bg; - border-radius: $dropdown_border_radius; - box-shadow: 0 0 0 1px $dropdown_border_color inset; + background-color: $default_bg; + border-radius: $border_radius; + box-shadow: 0 0 0 1px $border_color inset; cursor: pointer; max-width: 100%; - --dropdown-duration: #{$dropdown_animation_duration}; - - &.reduce-motion { - --dropdown-duration: 0s; - } } .dropdown-box { display: flex; align-items: center; padding: 0 10px; - height: $dropdown_height; + height: $height; box-sizing: border-box; gap: 8px; @@ -60,7 +56,7 @@ $dropdown_animation_duration: 0.2s; .chevron { width: 24px; height: 24px; flex-shrink: 0; - transition: transform var(--dropdown-duration) $dropdown_animation_timing; + transition: transform var(--dropdown-duration) $animation_timing; user-select: none; &.open { @@ -77,20 +73,20 @@ $dropdown_animation_duration: 0.2s; padding: 0; margin: 0; list-style: none; - background-color: $dropdown_bg; - border-radius: $dropdown_border_radius; + background-color: $default_bg; + border-radius: $border_radius; z-index: 1000; max-height: 240px; overflow-y: auto; 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 { content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0; - border-radius: $dropdown_border_radius; - box-shadow: 0 0 0 1px $dropdown_border_color inset; + border-radius: $border_radius; + box-shadow: 0 0 0 1px $border_color inset; pointer-events: none; z-index: 1001; } @@ -108,10 +104,10 @@ $dropdown_animation_duration: 0.2s; padding: 0 16px; position: relative; align-content: center; - transition: background-color var(--dropdown-duration) $dropdown_animation_timing; + transition: background-color var(--animation-duration) $animation_timing; &:hover, &.focused { - background-color: $dropdown_hover; + background-color: $hover_bg; } &.selected { diff --git a/src/app/components/dropdown/dropdown.ts b/src/app/components/dropdown/dropdown.ts index 41dd8a4..d7475b0 100644 --- a/src/app/components/dropdown/dropdown.ts +++ b/src/app/components/dropdown/dropdown.ts @@ -22,7 +22,6 @@ export interface DropdownOption { "isOpen() && focusedIndex() >= 0 ? dropdownId + '-option-' + focusedIndex() : null", '[attr.aria-label]': 'ariaLabel()', '[class.open]': 'isOpen()', - '[class.reduce-motion]': 'reduceMotion()', tabindex: '0', '(click)': 'toggle()', '(keydown.enter)': 'onEnter($event)', @@ -47,12 +46,6 @@ export class Dropdown implements OnInit { private static nextId = 0; dropdownId = `dropdown-${Dropdown.nextId++}`; - reduceMotion = input( - typeof window !== 'undefined' - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches - : false, - ); - constructor(private el: ElementRef) {} ngOnInit() { diff --git a/src/app/components/popup/popup.scss b/src/app/components/popup/popup.scss index d99bfac..ed04f29 100644 --- a/src/app/components/popup/popup.scss +++ b/src/app/components/popup/popup.scss @@ -26,8 +26,8 @@ overflow: visible; animation: popup-enter 0.2s cubic-bezier(0.16, 1, 0.3, 1); - border: oklch(0 0 0 / 20%) 2px solid; - gap: 15px; + outline: oklch(0 0 0 / 20%) 2px solid; + gap: 16px; &.variant-glass { background-color: oklch(1 0 0 / 60%); @@ -61,7 +61,7 @@ align-items: center; padding: 0 12px; height: 40px; - border-bottom: 1px solid oklch(0 0 0 / 5%); + box-shadow: 0 1px 0 oklch(0 0 0 / 20%); .header-left { display: flex; diff --git a/src/app/components/switch/switch.scss b/src/app/components/switch/switch.scss index 404ca94..506adb3 100644 --- a/src/app/components/switch/switch.scss +++ b/src/app/components/switch/switch.scss @@ -2,32 +2,32 @@ @use "sass:list"; @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); -$switch_slider_dimension: (24px, 24px); -$switch_padding: (3px, 3px); -$switch_border_radius: 15px; -$switch_border_color: oklch(0 0 0 / 20%); +$dimension: (57px, 30px); +$knob_dimension: (24px, 24px); +$internal_padding: (3px, 3px); +$border_radius: 15px; +$border_color: oklch(0 0 0 / 20%); -$switch_animation_timing: cubic-bezier(0.4, 0, 0.2, 1); -$switch_animation_duration: 0.2s; +$animation_timing: cubic-bezier(0.4, 0, 0.2, 1); +$default_animation_duration: 0.2s; :host { display: inline-block; - width: list.nth($switch_dimension, 1); height: list.nth($switch_dimension, 2); - border-radius: $switch_border_radius; - background-color: $switch_off_bg; + width: list.nth($dimension, 1); height: list.nth($dimension, 2); + border-radius: $border_radius; + background-color: $off_bg; cursor: pointer; position: relative; 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}; - transition: background-color var(--switch-duration) $switch_animation_timing; + --animation-duration: calc(#{$default_animation_duration} * var(--motion-duration-multiplier)); + transition: background-color var(--animation-duration) $animation_timing; &.reduce-motion { - --switch-duration: 0s; + --animation-duration: 0s; } &:hover, &:focus-within { @@ -41,8 +41,8 @@ $switch_animation_duration: 0.2s; .switch-knob { transform: translate( - list.nth($switch_dimension, 1) - list.nth($switch_slider_dimension, 1) - list.nth($switch_padding, 1) * 2, - list.nth($switch_dimension, 2) - list.nth($switch_slider_dimension, 2) - list.nth($switch_padding, 2) * 2 + list.nth($dimension, 1) - list.nth($knob_dimension, 1) - list.nth($internal_padding, 1) * 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 { position: absolute; - width: list.nth($switch_slider_dimension, 1); height: list.nth($switch_slider_dimension, 2); - left: list.nth($switch_padding, 1); top: list.nth($switch_padding, 2); + width: list.nth($knob_dimension, 1); height: list.nth($knob_dimension, 2); + left: list.nth($internal_padding, 1); top: list.nth($internal_padding, 2); background-color: white; border-radius: 50%; user-select: none; - transition: transform var(--switch-duration) $switch_animation_timing; + transition: transform var(--animation-duration) $animation_timing; } diff --git a/src/app/components/switch/switch.ts b/src/app/components/switch/switch.ts index 2900954..7382704 100644 --- a/src/app/components/switch/switch.ts +++ b/src/app/components/switch/switch.ts @@ -10,7 +10,6 @@ import { Component, input, model, output } from '@angular/core'; tabindex: '0', '[attr.aria-checked]': 'isActive()', '[class.active]': 'isActive()', - '[class.reduce-motion]': 'reduceMotion()', '(click)': 'onClick()', '(keydown.enter)': 'onClick()', '(keydown.space)': 'onSpaceDown($event)', @@ -20,12 +19,6 @@ export class Switch { isActive = model(false); toggle = output(); - reduceMotion = input( - typeof window !== 'undefined' - ? window.matchMedia('(prefers-reduced-motion: reduce)').matches - : false, - ); - onClick(): void { this.isActive.update((value) => !value); this.onToggle(); diff --git a/src/app/features/header-menu/header-menu.scss b/src/app/features/header-menu/header-menu.scss index a2e8809..b2fa1a6 100644 --- a/src/app/features/header-menu/header-menu.scss +++ b/src/app/features/header-menu/header-menu.scss @@ -9,7 +9,7 @@ min-width: 100%; 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%); backdrop-filter: blur(20px); diff --git a/src/app/features/header-menu/header-menu.ts b/src/app/features/header-menu/header-menu.ts index 5835517..d8e88e4 100644 --- a/src/app/features/header-menu/header-menu.ts +++ b/src/app/features/header-menu/header-menu.ts @@ -17,6 +17,6 @@ export class HeaderMenu { onSettingsClick() { console.log('HeaderMenu: Settings clicked'); - this.popupService.open(SettingsPopup, { position: { top: '82px', right: '18px' } }); + this.popupService.open(SettingsPopup, { position: { top: '84px', right: '20px' } }); } } diff --git a/src/app/features/settings/popup.html b/src/app/features/settings/popup.html index eb30415..961a8cb 100644 --- a/src/app/features/settings/popup.html +++ b/src/app/features/settings/popup.html @@ -10,7 +10,6 @@
@@ -34,6 +33,14 @@
+ +
+
+ motion_photos_paused + {{ translation.settings.reduceAnimation }} +
+ +
diff --git a/src/app/features/settings/settings.ts b/src/app/features/settings/settings.ts index 2e1c2f8..de77252 100644 --- a/src/app/features/settings/settings.ts +++ b/src/app/features/settings/settings.ts @@ -42,6 +42,10 @@ export class SettingsPopup { this.settingsService.setRomanisation(value); } + onReduceAnimationChange(value: boolean) { + this.settingsService.setReduceAnimation(value); + } + /** * 팝업을 닫을 때, 자기 자신(클래스)을 명확하게 지정하여 닫습니다. */ diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 9f63fed..e0ce207 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -1,12 +1,11 @@ export const EN = { settings: { - title: 'Settings', + guest: 'Guest', language: 'Language', appearance: 'Appearance', darkMode: 'Dark Mode', romanisation: 'Romanisation', - guest: 'Guest', - chooseLanguage: 'Choose Language' + reduceAnimation: 'Reduce Animation', }, menu: { levels: 'Levels', @@ -19,8 +18,8 @@ export const EN = { }, common: { close: 'Close', - save: 'Save' - } + save: 'Save', + }, }; export type Translation = typeof EN; diff --git a/src/app/i18n/jp.ts b/src/app/i18n/jp.ts index daf3c4c..bb638bb 100644 --- a/src/app/i18n/jp.ts +++ b/src/app/i18n/jp.ts @@ -2,13 +2,12 @@ import { Translation } from './en'; export const JP: Translation = { settings: { - title: '設定', + guest: 'ゲスト', language: '言語', appearance: '外観', darkMode: 'ダークモード', romanisation: 'ローマ字表記', - guest: 'ゲスト', - chooseLanguage: '言語を選択', + reduceAnimation: 'アニメーションを減らす', }, menu: { levels: 'レベル', diff --git a/src/app/i18n/kr.ts b/src/app/i18n/kr.ts index b71d937..85feca4 100644 --- a/src/app/i18n/kr.ts +++ b/src/app/i18n/kr.ts @@ -2,13 +2,12 @@ import { Translation } from './en'; export const KR: Translation = { settings: { - title: '설정', + guest: '게스트', language: '언어', appearance: '화면 설정', darkMode: '다크 모드', romanisation: '로마자 표기', - guest: '게스트', - chooseLanguage: '언어 선택', + reduceAnimation: '애니메이션 줄이기', }, menu: { levels: '레벨', diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 66b26ef..1d1d581 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -12,15 +12,17 @@ export class SettingsService { // Signals for state private _language = signal('EN'); - private _darkMode = signal(true); - private _romanisation = signal(false); + private _darkMode = signal(false); + private _romanisation = signal(true); private _userName = signal('Guest'); + private _reduceAnimation = signal(false); // Public computed or read-only signals language = computed(() => this._language()); darkMode = computed(() => this._darkMode()); romanisation = computed(() => this._romanisation()); userName = computed(() => this._userName()); + reduceAnimation = computed(() => this._reduceAnimation()); constructor() { this.request = inject(REQUEST); @@ -28,30 +30,54 @@ export class SettingsService { } private initializeSettings() { + let parsedCookie: Record; if (isPlatformBrowser(this.platformId)) { - // 클라이언트: 브라우저 API 사용 - 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'); + parsedCookie = this.parseCookies(document.cookie); } else if (isPlatformServer(this.platformId)) { const cookieHeader = this.request?.headers.get('Cookie'); - if (cookieHeader) { - const cookies = this.parseServerCookies(cookieHeader); + if (!cookieHeader) return; + parsedCookie = this.parseCookies(cookieHeader); + } else return; - // console.log(cookies); - if (cookies['lang']) this._language.set(cookies['lang'] as Language); - if (cookies['darkMode'] !== undefined) this._darkMode.set(cookies['darkMode'] !== 'false'); - if (cookies['romanisation'] !== undefined) this._romanisation.set(cookies['romanisation'] === 'true'); - if (cookies['userName']) this._userName.set(cookies['userName']); - } - } + const settings = { + lang: parsedCookie['lang'], + darkMode: parsedCookie['darkMode'], + romanisation: parsedCookie['romanisation'], + userName: parsedCookie['userName'], + reduceAnimation: parsedCookie['reduceAnimation'], + }; + + const windowDefined = typeof window !== 'undefined'; + + if (!settings.lang) { + this.setLanguage('EN'); + } else this._language.set(settings.lang as Language); + + if (settings.darkMode === undefined) { + const isDarkMode = windowDefined ? + window.matchMedia('(prefers-color-scheme: dark)').matches : false; + this.setDarkMode(isDarkMode); + } else this._darkMode.set(settings.darkMode === 'true'); + + if (settings.romanisation === undefined) { + this.setRomanisation(true); + } else this._romanisation.set(settings.romanisation === 'true'); + + if (!settings.userName) { + this.setUserName('Guest'); + } else this._userName.set(settings.userName); + + if (settings.reduceAnimation === undefined) { + const isReduceAnimation = windowDefined ? + window.matchMedia('(prefers-reduced-motion: reduce)').matches : false; + this.setReduceAnimation(isReduceAnimation); + } else this._reduceAnimation.set(settings.reduceAnimation === 'true'); } - private parseServerCookies(cookieString: string): Record { + private parseCookies(cookieString: string): Record { const cookies: Record = {}; - cookieString.split(';').forEach(c => { + cookieString.split(';').forEach((c) => { const [key, value] = c.trim().split('='); if (key && value) cookies[key] = decodeURIComponent(value); }); @@ -78,14 +104,9 @@ export class SettingsService { this.setCookie('userName', name); } - private getCookie(name: string): string | undefined { - if (isPlatformBrowser(this.platformId)) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift(); - } - - return undefined; + setReduceAnimation(value: boolean) { + this._reduceAnimation.set(value); + this.setCookie('reduceAnimation', value.toString()); } private setCookie(name: string, value: string) { diff --git a/src/styles.scss b/src/styles.scss index f011f97..ac66451 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -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/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 { font-family: 'NanumSquareNeo', 'Wanted Sans Variable', 'Pretendard JP', sans-serif; font-weight: 500; @@ -77,7 +85,7 @@ section.feature-block { gap: 12px; 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; background-color: oklch(1 0 0 / 60%);