From 1b687c115bd417e26e65ec0bc18371c4749c13bb Mon Sep 17 00:00:00 2001 From: zack <6351754+zackkrida@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:12:15 -0400 Subject: [PATCH] Add color mode to ui store (#4810) --- frontend/feat/feature-flags.json | 2 +- frontend/src/composables/use-dark-mode.ts | 38 ++++++-- frontend/src/pages/preferences.vue | 4 + frontend/src/stores/feature-flag.ts | 6 +- frontend/src/stores/ui.ts | 55 +++++++++--- frontend/src/styles/tailwind.css | 89 ++++++++++++++++++- frontend/src/types/cookies.ts | 38 +++----- frontend/src/types/feature-flag.ts | 4 + .../specs/composables/use-dark-mode.spec.ts | 53 ++++++----- .../{ui-store.spec.js => ui-store.spec.ts} | 35 +++++--- 10 files changed, 239 insertions(+), 85 deletions(-) rename frontend/test/unit/specs/stores/{ui-store.spec.js => ui-store.spec.ts} (90%) diff --git a/frontend/feat/feature-flags.json b/frontend/feat/feature-flags.json index f301b0848b1..5d8c5fe4b48 100644 --- a/frontend/feat/feature-flags.json +++ b/frontend/feat/feature-flags.json @@ -46,7 +46,7 @@ "production": "disabled" }, "defaultState": "off", - "description": "Display the UI toggle to change the site color theme.", + "description": "Display the UI toggle to change the site color theme and respect system preferences.", "storage": "cookie" } }, diff --git a/frontend/src/composables/use-dark-mode.ts b/frontend/src/composables/use-dark-mode.ts index bba4069ab19..66c0df3c48b 100644 --- a/frontend/src/composables/use-dark-mode.ts +++ b/frontend/src/composables/use-dark-mode.ts @@ -1,4 +1,4 @@ -import { computed } from "#imports" +import { computed, useUiStore } from "#imports" import { useFeatureFlagStore } from "~/stores/feature-flag" @@ -6,21 +6,41 @@ export const DARK_MODE_CLASS = "dark-mode" export const LIGHT_MODE_CLASS = "light-mode" /** - * TODO: Replace with the user's actual dark mode preference. - * Dark mode detection will be based on user preference, - * overwritten by the "force_dark_mode" feature flag. + * Determines the dark mode setting based on user preference or feature flag. + * + * When dark mode toggling is disabled, the site is in "light mode" unless + * the `force_dark_mode` feature flag is on. + * + * When the "dark_mode_ui_toggle" flag is enabled, the site will respect + * the user system preference by default. + * */ export function useDarkMode() { + const uiStore = useUiStore() const featureFlagStore = useFeatureFlagStore() - const isDarkMode = computed(() => featureFlagStore.isOn("force_dark_mode")) - const cssClass = computed(() => - isDarkMode.value ? DARK_MODE_CLASS : LIGHT_MODE_CLASS + const darkModeToggleable = computed(() => + featureFlagStore.isOn("dark_mode_ui_toggle") ) + const forceDarkMode = computed(() => featureFlagStore.isOn("force_dark_mode")) + + const colorMode = computed(() => { + if (darkModeToggleable.value && !forceDarkMode.value) { + return uiStore.colorMode + } + return forceDarkMode.value ? "dark" : "light" + }) + + const cssClass = computed(() => { + return { + light: LIGHT_MODE_CLASS, + dark: DARK_MODE_CLASS, + system: "", + }[colorMode.value] + }) return { - isDarkMode, - /** The CSS class representing the current color mode. */ + colorMode, cssClass, } } diff --git a/frontend/src/pages/preferences.vue b/frontend/src/pages/preferences.vue index ab271de94ac..b914c5db5ea 100644 --- a/frontend/src/pages/preferences.vue +++ b/frontend/src/pages/preferences.vue @@ -4,6 +4,7 @@ import { definePageMeta } from "#imports" import { computed } from "vue" import { useFeatureFlagStore } from "~/stores/feature-flag" +import { isFlagName } from "~/types/feature-flag" import { SWITCHABLE, ON, OFF } from "~/constants/feature-flag" import VContentPage from "~/components/VContentPage.vue" @@ -33,6 +34,9 @@ const handleChange = ({ name: string checked?: boolean }) => { + if (!isFlagName(name)) { + return + } featureFlagStore.toggleFeature(name, checked ? ON : OFF) } diff --git a/frontend/src/stores/feature-flag.ts b/frontend/src/stores/feature-flag.ts index ead2e161cef..ff1f362c266 100644 --- a/frontend/src/stores/feature-flag.ts +++ b/frontend/src/stores/feature-flag.ts @@ -293,7 +293,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, { * @param name - the name of the flag to toggle * @param targetState - the desired state of the feature flag */ - toggleFeature(name: string, targetState: FeatureState) { + toggleFeature(name: FlagName, targetState: FeatureState) { if (!isFlagName(name)) { throw new Error(`Toggling invalid feature flag: ${name}`) } @@ -315,7 +315,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, { storage.value = this.flags.analytics.state === ON ? null : true }, - isSwitchable(name: string) { + isSwitchable(name: FlagName) { if (!isFlagName(name)) { throw new Error(`Invalid feature flag accessed: ${name}`) } @@ -328,7 +328,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, { * * @returns `true` if the flag is on, false otherwise */ - isOn(name: string) { + isOn(name: FlagName) { if (!isFlagName(name)) { throw new Error(`Invalid feature flag accessed: ${name}`) } diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts index 7d13217021b..ba3f5aa9da5 100644 --- a/frontend/src/stores/ui.ts +++ b/frontend/src/stores/ui.ts @@ -5,9 +5,9 @@ import { defineStore } from "pinia" import { LocaleObject } from "@nuxtjs/i18n" import { + defaultPersistientCookieState, OpenverseCookieState, persistentCookieOptions, - SnackbarState, } from "~/types/cookies" import type { BannerId } from "~/types/banners" @@ -17,6 +17,15 @@ import { needsTranslationBanner } from "~/utils/translation-banner" const desktopBreakpoints: RealBreakpoint[] = ["2xl", "xl", "lg"] +export type SnackbarState = "not_shown" | "visible" | "dismissed" +export type ColorMode = "dark" | "light" | "system" + +export function isColorMode(value: undefined | string): value is ColorMode { + return ( + typeof value === "string" && ["light", "dark", "system"].includes(value) + ) +} + export interface UiState { /** * whether to show the instructions snackbar. @@ -53,22 +62,28 @@ export interface UiState { /* A list of sensitive single result UUIDs the user has opted-into seeing */ revealedSensitiveResults: string[] headerHeight: number + + /* The user-chosen color theme of the site. */ + colorMode: ColorMode } export const breakpoints = Object.keys(ALL_SCREEN_SIZES) +export const defaultUiState: UiState = { + instructionsSnackbarState: "not_shown", + innerFilterVisible: false, + isFilterDismissed: false, + isDesktopLayout: false, + breakpoint: "sm", + dismissedBanners: [], + shouldBlurSensitive: true, + revealedSensitiveResults: [], + headerHeight: 80, + colorMode: "system", +} + export const useUiStore = defineStore("ui", { - state: (): UiState => ({ - instructionsSnackbarState: "not_shown", - innerFilterVisible: false, - isFilterDismissed: false, - isDesktopLayout: false, - breakpoint: "sm", - dismissedBanners: [], - shouldBlurSensitive: true, - revealedSensitiveResults: [], - headerHeight: 80, - }), + state: (): UiState => ({ ...defaultUiState }), getters: { cookieState(state): OpenverseCookieState["ui"] { @@ -77,6 +92,7 @@ export const useUiStore = defineStore("ui", { isFilterDismissed: state.isFilterDismissed, breakpoint: state.breakpoint, dismissedBanners: Array.from(this.dismissedBanners), + colorMode: state.colorMode, } }, areInstructionsVisible(state): boolean { @@ -171,6 +187,10 @@ export const useUiStore = defineStore("ui", { this.dismissedBanners = cookies.dismissedBanners } + if (isColorMode(cookies.colorMode)) { + this.setColorMode(cookies.colorMode) + } + this.writeToCookie() }, /** @@ -182,7 +202,11 @@ export const useUiStore = defineStore("ui", { "ui", persistentCookieOptions ) - uiCookie.value = this.cookieState + + uiCookie.value = { + ...defaultPersistientCookieState.ui, + ...this.cookieState, + } }, /** @@ -257,6 +281,11 @@ export const useUiStore = defineStore("ui", { this.shouldBlurSensitive = value this.revealedSensitiveResults = [] }, + setColorMode(colorMode: ColorMode) { + this.colorMode = colorMode + + this.writeToCookie() + }, setHeaderHeight(height: number) { this.headerHeight = Math.max(height, 80) }, diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css index cff065fbed5..5e0af78cbbd 100644 --- a/frontend/src/styles/tailwind.css +++ b/frontend/src/styles/tailwind.css @@ -124,7 +124,94 @@ --color-error-13: #1e0302; } - :root, + @media (prefers-color-scheme: light) { + :root { + --color-bg: var(--color-white); + --color-bg-surface: var(--color-gray-1); + --color-bg-overlay: var(--color-white); + --color-bg-primary: var(--color-pink-8); + --color-bg-primary-hover: var(--color-pink-9); + --color-bg-secondary: var(--color-gray-2); + --color-bg-secondary-hover: var(--color-gray-12); + --color-bg-tertiary: var(--color-gray-12); + --color-bg-tertiary-hover: var(--color-gray-11); + --color-bg-transparent-hover: var(--color-gray-12-10); + --color-bg-complementary: var(--color-yellow-3); + --color-bg-warning: var(--color-warning-2); + --color-bg-info: var(--color-info-2); + --color-bg-success: var(--color-success-2); + --color-bg-error: var(--color-error-2); + --color-bg-disabled: var(--color-gray-5); + --color-bg-zero: var(--color-white-0); + --color-border: var(--color-gray-3); + --color-border-hover: var(--color-gray-12); + --color-border-secondary: var(--color-gray-12-20); + --color-border-secondary-hover: var(--color-gray-12); + --color-border-tertiary: var(--color-gray-12); + --color-border-transparent-hover: var(--color-gray-3); + --color-border-focus: var(--color-pink-8); + --color-border-bg-ring: var(--color-white); + --color-border-disabled: var(--color-gray-5); + --color-text: var(--color-gray-12); + --color-text-secondary: var(--color-gray-8); + --color-text-disabled: var(--color-gray-5); + --color-text-link: var(--color-pink-8); + --color-text-over-dark: var(--color-white); + --color-text-secondary-over-dark: var(--color-gray-5); + --color-icon-warning: var(--color-warning-8); + --color-icon-info: var(--color-info-8); + --color-icon-success: var(--color-success-8); + --color-icon-error: var(--color-error-8); + --color-wave-active: var(--color-yellow-9); + --color-wave-inactive: var(--color-gray-12-20); + --color-modal-layer: var(--color-gray-12-80); + } + } + + @media (prefers-color-scheme: dark) { + :root { + --color-bg: var(--color-gray-13); + --color-bg-surface: var(--color-gray-12); + --color-bg-overlay: var(--color-gray-11); + --color-bg-primary: var(--color-yellow-4); + --color-bg-primary-hover: var(--color-yellow-3); + --color-bg-secondary: var(--color-gray-11); + --color-bg-secondary-hover: var(--color-gray-1); + --color-bg-tertiary: var(--color-gray-1); + --color-bg-tertiary-hover: var(--color-gray-2); + --color-bg-transparent-hover: var(--color-gray-1-10); + --color-bg-complementary: var(--color-pink-9); + --color-bg-warning: var(--color-warning-11); + --color-bg-info: var(--color-info-11); + --color-bg-success: var(--color-success-11); + --color-bg-error: var(--color-error-11); + --color-bg-disabled: var(--color-gray-8); + --color-bg-zero: var(--color-gray-13-0); + --color-border: var(--color-gray-11); + --color-border-hover: var(--color-gray-1); + --color-border-secondary: var(--color-gray-1-20); + --color-border-secondary-hover: var(--color-gray-1); + --color-border-tertiary: var(--color-gray-1); + --color-border-transparent-hover: var(--color-gray-11); + --color-border-focus: var(--color-yellow-4); + --color-border-bg-ring: var(--color-gray-13); + --color-border-disabled: var(--color-gray-8); + --color-text: var(--color-gray-1); + --color-text-secondary: var(--color-gray-5); + --color-text-disabled: var(--color-gray-8); + --color-text-link: var(--color-yellow-4); + --color-text-over-dark: var(--color-gray-13); + --color-text-secondary-over-dark: var(--color-gray-8); + --color-icon-warning: var(--color-warning-5); + --color-icon-info: var(--color-info-5); + --color-icon-success: var(--color-success-5); + --color-icon-error: var(--color-error-5); + --color-wave-active: var(--color-pink-4); + --color-wave-inactive: var(--color-gray-1-30); + --color-modal-layer: var(--color-gray-12-60); + } + } + :is(.light-mode), :is(.light-mode *) { --color-bg: var(--color-white); diff --git a/frontend/src/types/cookies.ts b/frontend/src/types/cookies.ts index 4d011389e5c..8db284bfcde 100644 --- a/frontend/src/types/cookies.ts +++ b/frontend/src/types/cookies.ts @@ -1,8 +1,5 @@ import type { FeatureState } from "~/constants/feature-flag" -import type { RealBreakpoint } from "~/constants/screens" -import type { BannerId } from "~/types/banners" - -export type SnackbarState = "not_shown" | "visible" | "dismissed" +import { UiState } from "~/stores/ui" const baseCookieOptions = { path: "/", @@ -15,6 +12,12 @@ export const persistentCookieOptions = { maxAge: 60 * 60 * 24 * 60, // 60 days; Makes the cookie persistent. } as const +export const defaultPersistientCookieState: OpenverseCookieState = { + ui: { + colorMode: "system", + }, +} + export const sessionCookieOptions = { ...baseCookieOptions, maxAge: undefined, // these cookies are not persistent and will be deleted by the browser after the session. @@ -24,28 +27,11 @@ export const sessionCookieOptions = { * The cookies that Openverse uses to store the UI state. */ export interface OpenverseCookieState { - ui: { - /** - * The state of the instructions snackbar for audio component. - */ - instructionsSnackbarState?: SnackbarState - /** - * Whether the filters were dismissed on desktop layout. - */ - isFilterDismissed?: boolean - /** - * The screen's max-width breakpoint. - */ - breakpoint?: RealBreakpoint - /** - * Whether the request user agent is mobile or not. - */ - isMobileUa?: boolean - /** - * The list of ids of dismissed banners. - */ - dismissedBanners?: BannerId[] - } + /** + * Values used to SSR the site, + * persisted by the ui store. + */ + ui: Partial /** * The state of the persistent feature flags. */ diff --git a/frontend/src/types/feature-flag.ts b/frontend/src/types/feature-flag.ts index 7af6b6a938a..43cb4b1dc6f 100644 --- a/frontend/src/types/feature-flag.ts +++ b/frontend/src/types/feature-flag.ts @@ -9,6 +9,10 @@ import type { DeployEnv } from "~/constants/deploy-env" export type FlagName = keyof (typeof featureData)["features"] +export function isFlagName(flag: string): flag is FlagName { + return flag in featureData.features +} + export type FlagStatusRecord = string | Partial> /** * The record of a feature flag from the json file. diff --git a/frontend/test/unit/specs/composables/use-dark-mode.spec.ts b/frontend/test/unit/specs/composables/use-dark-mode.spec.ts index 46d65fcfedb..0c8144cb90f 100644 --- a/frontend/test/unit/specs/composables/use-dark-mode.spec.ts +++ b/frontend/test/unit/specs/composables/use-dark-mode.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest" +import { describe, expect, test } from "vitest" import { DARK_MODE_CLASS, @@ -7,29 +7,42 @@ import { } from "~/composables/use-dark-mode" import { OFF, ON } from "~/constants/feature-flag" import { useFeatureFlagStore } from "~/stores/feature-flag" +import { useUiStore } from "~/stores/ui" describe("useDarkMode", () => { - it(`should report isDarkMode as true and cssClass as ${DARK_MODE_CLASS} when the feature flag is enabled`, () => { - const featureFlagStore = useFeatureFlagStore() - featureFlagStore.toggleFeature("force_dark_mode", ON) + test.each` + description | featureFlags | uiColorMode | expectedColorMode | expectedCssClass + ${"Force dark mode and disable toggling"} | ${{ force_dark_mode: ON, dark_mode_ui_toggle: OFF }} | ${"light"} | ${"dark"} | ${DARK_MODE_CLASS} + ${"Don't force dark mode and disable toggling"} | ${{ force_dark_mode: OFF, dark_mode_ui_toggle: OFF }} | ${"dark"} | ${"light"} | ${LIGHT_MODE_CLASS} + ${"Force dark mode and enable toggling"} | ${{ force_dark_mode: ON, dark_mode_ui_toggle: ON }} | ${"light"} | ${"dark"} | ${DARK_MODE_CLASS} + ${"Enable toggling, User preference: light"} | ${{ force_dark_mode: OFF, dark_mode_ui_toggle: ON }} | ${"light"} | ${"light"} | ${LIGHT_MODE_CLASS} + ${"Enable toggling, User preference: dark"} | ${{ force_dark_mode: OFF, dark_mode_ui_toggle: ON }} | ${"dark"} | ${"dark"} | ${DARK_MODE_CLASS} + ${"Enable toggling, User preference: system"} | ${{ force_dark_mode: OFF, dark_mode_ui_toggle: ON }} | ${"system"} | ${"system"} | ${""} + `( + "$description: should report colorMode as $expectedColorMode and cssClass as $expectedCssClass", + ({ featureFlags, uiColorMode, expectedColorMode, expectedCssClass }) => { + const featureFlagStore = useFeatureFlagStore() - // Call the composable - const { isDarkMode, cssClass } = useDarkMode() + // Set the feature flags + featureFlagStore.toggleFeature( + "force_dark_mode", + featureFlags.force_dark_mode + ) + featureFlagStore.toggleFeature( + "dark_mode_ui_toggle", + featureFlags.dark_mode_ui_toggle + ) - // Assert the computed properties - expect(isDarkMode.value).toBe(true) - expect(cssClass.value).toBe(DARK_MODE_CLASS) - }) + // Set the user preference for color mode + const uiStore = useUiStore() + uiStore.colorMode = uiColorMode - it(`should report isDarkMode as false and cssClass as ${LIGHT_MODE_CLASS} when the feature flag is disabled`, () => { - const featureFlagStore = useFeatureFlagStore() - featureFlagStore.toggleFeature("force_dark_mode", OFF) + // Call the composable + const { colorMode, cssClass } = useDarkMode() - // Call the composable - const { isDarkMode, cssClass } = useDarkMode() - - // Assert the computed properties - expect(isDarkMode.value).toBe(false) - expect(cssClass.value).toBe(LIGHT_MODE_CLASS) - }) + // Assert the computed properties + expect(colorMode.value).toBe(expectedColorMode) + expect(cssClass.value).toBe(expectedCssClass) + } + ) }) diff --git a/frontend/test/unit/specs/stores/ui-store.spec.js b/frontend/test/unit/specs/stores/ui-store.spec.ts similarity index 90% rename from frontend/test/unit/specs/stores/ui-store.spec.js rename to frontend/test/unit/specs/stores/ui-store.spec.ts index 750c4637932..bfec36471d2 100644 --- a/frontend/test/unit/specs/stores/ui-store.spec.js +++ b/frontend/test/unit/specs/stores/ui-store.spec.ts @@ -1,11 +1,15 @@ import { nextTick } from "vue" +import { vi, describe, beforeEach, it, expect, test } from "vitest" + import { setActivePinia, createPinia } from "~~/test/unit/test-utils/pinia" -import { useUiStore } from "~/stores/ui" +import { defaultUiState, UiState, useUiStore } from "~/stores/ui" +import { BannerId } from "~/types/banners" vi.mock("~/types/cookies", async () => { - const actual = await vi.importActual("~/types/cookies") + const actual = + await vi.importActual("~/types/cookies") return { ...actual, persistentCookieOptions: { @@ -15,13 +19,7 @@ vi.mock("~/types/cookies", async () => { } }) -const initialState = { - instructionsSnackbarState: "not_shown", - innerFilterVisible: false, - isFilterDismissed: false, - isDesktopLayout: false, - dismissedBanners: [], -} +const initialState = defaultUiState const VISIBLE_AND_DISMISSED = { innerFilterVisible: true, @@ -44,10 +42,11 @@ describe("Ui Store", () => { beforeEach(() => { setActivePinia(createPinia()) }) + describe("state", () => { it("sets the initial state correctly", () => { const uiStore = useUiStore() - for (const key of Object.keys(initialState)) { + for (const key of Object.keys(initialState) as Array) { expect(uiStore[key]).toEqual(initialState[key]) } }) @@ -103,7 +102,7 @@ describe("Ui Store", () => { it("initFromCookies sets initial state without cookie", () => { const uiStore = useUiStore() uiStore.initFromCookies({}) - for (const key of Object.keys(initialState)) { + for (const key of Object.keys(initialState) as Array) { expect(uiStore[key]).toEqual(initialState[key]) } }) @@ -113,9 +112,11 @@ describe("Ui Store", () => { uiStore.initFromCookies({ breakpoint: "lg", isFilterDismissed: true, + colorMode: "system", }) expect(uiStore.instructionsSnackbarState).toBe("not_shown") + expect(uiStore.colorMode).toBe("system") expect(uiStore.breakpoint).toBe("lg") expect(uiStore.isDesktopLayout).toBe(true) expect(uiStore.isFilterVisible).toBe(false) @@ -124,13 +125,23 @@ describe("Ui Store", () => { it("initFromCookies sets initial state with a dismissed banner", () => { const uiStore = useUiStore() - const dismissedBanners = ["ru", "ar"] + const dismissedBanners: BannerId[] = ["translation-ru", "translation-ar"] uiStore.initFromCookies({ dismissedBanners: dismissedBanners, }) expect(uiStore.dismissedBanners).toEqual(dismissedBanners) }) + + it("setColorMode correctly sets the color mode", () => { + const newColorMode = "light" + const uiStore = useUiStore() + const initialColorMode = uiStore.colorMode + uiStore.setColorMode(newColorMode) + + expect(initialColorMode).toEqual(initialState.colorMode) + expect(uiStore.colorMode).toEqual(newColorMode) + }) }) test.each`