diff --git a/frontend/feat/feature-flags.json b/frontend/feat/feature-flags.json index 5d8c5fe4b48..74c70dd3e75 100644 --- a/frontend/feat/feature-flags.json +++ b/frontend/feat/feature-flags.json @@ -31,15 +31,6 @@ "supportsQuery": false, "storage": "session" }, - "force_dark_mode": { - "status": { - "staging": "switchable", - "production": "disabled" - }, - "defaultState": "off", - "description": "Force the site to render in dark mode.", - "storage": "session" - }, "dark_mode_ui_toggle": { "status": { "staging": "switchable", diff --git a/frontend/src/assets/svg/raw/moon.svg b/frontend/src/assets/svg/raw/moon.svg new file mode 100644 index 00000000000..0d13e051cac --- /dev/null +++ b/frontend/src/assets/svg/raw/moon.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/frontend/src/assets/svg/raw/sun.svg b/frontend/src/assets/svg/raw/sun.svg new file mode 100644 index 00000000000..00f4ae1d09d --- /dev/null +++ b/frontend/src/assets/svg/raw/sun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/src/assets/svg/sprite/icons.svg b/frontend/src/assets/svg/sprite/icons.svg index 54d9127acc8..615db5de8ae 100644 --- a/frontend/src/assets/svg/sprite/icons.svg +++ b/frontend/src/assets/svg/sprite/icons.svg @@ -35,4 +35,6 @@ + + diff --git a/frontend/src/components/VFooter/VFooter.vue b/frontend/src/components/VFooter/VFooter.vue index 87880908dcb..330422e22fe 100644 --- a/frontend/src/components/VFooter/VFooter.vue +++ b/frontend/src/components/VFooter/VFooter.vue @@ -11,11 +11,13 @@ import useResizeObserver from "~/composables/use-resize-observer" import { SCREEN_SIZES } from "~/constants/screens" import { useUiStore } from "~/stores/ui" +import { useFeatureFlagStore } from "~/stores/feature-flag" import type { SelectFieldProps } from "~/components/VSelectField/VSelectField.vue" import VLink from "~/components/VLink.vue" import VBrand from "~/components/VBrand/VBrand.vue" import VLanguageSelect from "~/components/VLanguageSelect/VLanguageSelect.vue" +import VThemeSelect from "~/components/VThemeSelect/VThemeSelect.vue" import VPageLinks from "~/components/VHeader/VPageLinks.vue" import VWordPressLink from "~/components/VHeader/VWordPressLink.vue" @@ -42,6 +44,11 @@ const { all: allPages } = usePages() const isContentMode = computed(() => props.mode === "content") +const featureFlagStore = useFeatureFlagStore() +const showThemeSwitcher = computed(() => + featureFlagStore.isOn("dark_mode_ui_toggle") +) + /** JS-based responsiveness */ const footerEl = ref(null) const initialWidth = SCREEN_SIZES[uiStore.breakpoint] @@ -90,11 +97,25 @@ const linkColumnHeight = computed(() => ({ - - + + + + + + + + + + + + + diff --git a/frontend/src/components/VSelectField/VSelectField.vue b/frontend/src/components/VSelectField/VSelectField.vue index dd89bcb7ec5..390895c72ab 100644 --- a/frontend/src/components/VSelectField/VSelectField.vue +++ b/frontend/src/components/VSelectField/VSelectField.vue @@ -33,10 +33,12 @@ const props = withDefaults( fieldId: string labelText: string choices: Choice[] + showSelected?: boolean }>(), { modelValue: "", blankText: "", + showSelected: true, } ) @@ -70,7 +72,7 @@ const splitAttrs = computed(() => { @@ -82,8 +84,11 @@ const splitAttrs = computed(() => { + + + + + + + + + + diff --git a/frontend/src/composables/use-dark-mode.ts b/frontend/src/composables/use-dark-mode.ts index 66c0df3c48b..598da0ed0b0 100644 --- a/frontend/src/composables/use-dark-mode.ts +++ b/frontend/src/composables/use-dark-mode.ts @@ -1,5 +1,7 @@ import { computed, useUiStore } from "#imports" +import { usePreferredColorScheme } from "@vueuse/core" + import { useFeatureFlagStore } from "~/stores/feature-flag" export const DARK_MODE_CLASS = "dark-mode" @@ -8,8 +10,7 @@ export const LIGHT_MODE_CLASS = "light-mode" /** * 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 dark mode toggling is disabled, the site is in "light mode". * * When the "dark_mode_ui_toggle" flag is enabled, the site will respect * the user system preference by default. @@ -22,13 +23,46 @@ export function useDarkMode() { const darkModeToggleable = computed(() => featureFlagStore.isOn("dark_mode_ui_toggle") ) - const forceDarkMode = computed(() => featureFlagStore.isOn("force_dark_mode")) + /** + * the color mode setting for the app; + * + * This can be one of "dark", "light" or "system". If the toggle + * feature is disabled, we default to "light". + */ const colorMode = computed(() => { - if (darkModeToggleable.value && !forceDarkMode.value) { + if (darkModeToggleable.value) { return uiStore.colorMode } - return forceDarkMode.value ? "dark" : "light" + return "light" + }) + + /** + * the color mode setting for the OS; + * + * This can be one of "dark" or "light". If the OS does not specify + * a preference, we default to "light". + */ + const osColorMode = computed(() => { + const pref = usePreferredColorScheme() + return pref.value === "no-preference" ? "light" : pref.value + }) + + /** + * the effective color mode of the app; + * + * This can be one of "dark" or "light". This is a combination of the + * toggle feature flag, the user's preference at the app and OS levels + * and the default value of "light". + */ + const effectiveColorMode = computed(() => { + if (!darkModeToggleable.value) { + return "light" + } + if (colorMode.value === "system") { + return osColorMode.value + } + return colorMode.value }) const cssClass = computed(() => { @@ -41,6 +75,8 @@ export function useDarkMode() { return { colorMode, + osColorMode, + effectiveColorMode, cssClass, } } diff --git a/frontend/src/locales/scripts/en.json5 b/frontend/src/locales/scripts/en.json5 index 8f4038b7560..5f1d0f9761b 100644 --- a/frontend/src/locales/scripts/en.json5 +++ b/frontend/src/locales/scripts/en.json5 @@ -1085,6 +1085,14 @@ language: { language: "Language", }, + theme: { + theme: "Theme", + choices: { + dark: "Dark", + light: "Light", + system: "System", + }, + }, recentSearches: { heading: "Recent searches", clear: { 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 0c8144cb90f..22fd8388a95 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,8 @@ -import { describe, expect, test } from "vitest" +import { computed } from "#imports" + +import { describe, expect, test, vi } from "vitest" + +import { usePreferredColorScheme } from "@vueuse/core" import { DARK_MODE_CLASS, @@ -9,25 +13,35 @@ import { OFF, ON } from "~/constants/feature-flag" import { useFeatureFlagStore } from "~/stores/feature-flag" import { useUiStore } from "~/stores/ui" +vi.mock("@vueuse/core", () => ({ + usePreferredColorScheme: vi.fn(), +})) + describe("useDarkMode", () => { 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 | featureFlags | uiColorMode | osColorMode | expectedColorMode | expectedEffectiveColorMode | expectedCssClass + ${"Toggle: off"} | ${{ dark_mode_ui_toggle: OFF }} | ${"dark"} | ${"dark"} | ${"light"} | ${"light"} | ${LIGHT_MODE_CLASS} + ${"Toggle: on, Preference: light"} | ${{ dark_mode_ui_toggle: ON }} | ${"light"} | ${"dark"} | ${"light"} | ${"light"} | ${LIGHT_MODE_CLASS} + ${"Toggle: on, Preference: dark"} | ${{ dark_mode_ui_toggle: ON }} | ${"dark"} | ${"light"} | ${"dark"} | ${"dark"} | ${DARK_MODE_CLASS} + ${"Toggle: on, Preference: system, System: light"} | ${{ dark_mode_ui_toggle: ON }} | ${"system"} | ${"light"} | ${"system"} | ${"light"} | ${""} + ${"Toggle: on, Preference: system, System: dark"} | ${{ dark_mode_ui_toggle: ON }} | ${"system"} | ${"dark"} | ${"system"} | ${"dark"} | ${""} + ${"Toggle: on, Preference: system, System: no-preference"} | ${{ dark_mode_ui_toggle: ON }} | ${"system"} | ${"no-preference"} | ${"system"} | ${"light"} | ${""} `( - "$description: should report colorMode as $expectedColorMode and cssClass as $expectedCssClass", - ({ featureFlags, uiColorMode, expectedColorMode, expectedCssClass }) => { + "$description: should report colorMode as $expectedColorMode, effectiveColorMode as $expectedEffectiveColorMode and cssClass as $expectedCssClass", + ({ + featureFlags, + uiColorMode, + osColorMode, + expectedColorMode, + expectedEffectiveColorMode, + expectedCssClass, + }) => { + vi.mocked(usePreferredColorScheme).mockReturnValue( + computed(() => osColorMode) + ) + const featureFlagStore = useFeatureFlagStore() - // Set the feature flags - featureFlagStore.toggleFeature( - "force_dark_mode", - featureFlags.force_dark_mode - ) featureFlagStore.toggleFeature( "dark_mode_ui_toggle", featureFlags.dark_mode_ui_toggle @@ -38,10 +52,11 @@ describe("useDarkMode", () => { uiStore.colorMode = uiColorMode // Call the composable - const { colorMode, cssClass } = useDarkMode() + const { colorMode, effectiveColorMode, cssClass } = useDarkMode() // Assert the computed properties expect(colorMode.value).toBe(expectedColorMode) + expect(effectiveColorMode.value).toBe(expectedEffectiveColorMode) expect(cssClass.value).toBe(expectedCssClass) } )