Skip to content

Commit

Permalink
Add the UI toggle for changing theme (#4840)
Browse files Browse the repository at this point in the history
Co-authored-by: zackdotcat <[email protected]>
Co-authored-by: zack <[email protected]>
  • Loading branch information
3 people authored Sep 5, 2024
1 parent f286651 commit 3711b60
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 38 deletions.
9 changes: 0 additions & 9 deletions frontend/feat/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/assets/svg/raw/moon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions frontend/src/assets/svg/raw/sun.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/assets/svg/sprite/icons.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 26 additions & 5 deletions frontend/src/components/VFooter/VFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<HTMLElement | null>(null)
const initialWidth = SCREEN_SIZES[uiStore.breakpoint]
Expand Down Expand Up @@ -90,11 +97,25 @@ const linkColumnHeight = computed(() => ({

<!-- Locale chooser and WordPress affiliation graphic -->
<div class="locale-and-wp flex flex-col justify-between">
<VLanguageSelect
v-bind="languageProps"
class="language max-w-full border-secondary"
/>
<VWordPressLink mode="light" />
<template v-if="showThemeSwitcher">
<VWordPressLink mode="light" />
<div class="flex flex-row items-center gap-6">
<VLanguageSelect
v-bind="languageProps"
class="language max-w-full border-secondary"
/>
<ClientOnly>
<VThemeSelect class="border-secondary" />
</ClientOnly>
</div>
</template>
<template v-else>
<VLanguageSelect
v-bind="languageProps"
class="language max-w-full border-secondary"
/>
<VWordPressLink mode="light" />
</template>
</div>
</footer>
</template>
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/components/VSelectField/VSelectField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ const props = withDefaults(
fieldId: string
labelText: string
choices: Choice[]
showSelected?: boolean
}>(),
{
modelValue: "",
blankText: "",
showSelected: true,
}
)
Expand Down Expand Up @@ -70,7 +72,7 @@ const splitAttrs = computed(() => {

<template>
<div
class="relative m-0.5px box-content block w-fit overflow-hidden rounded-sm border border-default text-sm focus-within:m-0 focus-within:border-1.5 focus-within:border-focus hover:border-hover focus-within:hover:border-focus"
class="relative m-0.5px box-content block w-fit rounded-sm border border-default text-sm focus-within:m-0 focus-within:border-1.5 focus-within:border-focus hover:border-hover focus-within:hover:border-focus"
:class="splitAttrs.classAttrs"
>
<div class="pointer-events-none absolute inset-y-0 start-2 my-auto h-fit">
Expand All @@ -82,8 +84,11 @@ const splitAttrs = computed(() => {
<select
:id="fieldId"
v-model="selectValue"
class="flex h-[calc(theme(spacing.10)_-_2_*_theme(borderWidth.DEFAULT))] w-full appearance-none truncate bg-tx pe-10"
:class="hasStartContent ? 'ps-10' : 'ps-2'"
class="flex h-[calc(theme(spacing.10)_-_2_*_theme(borderWidth.DEFAULT))] appearance-none truncate bg-tx pe-10"
:class="[
showSelected ? 'w-full' : 'w-0 max-w-0',
hasStartContent ? 'ps-10' : 'ps-2',
]"
:name="fieldName"
v-bind="splitAttrs.nonClassAttrs"
:aria-label="labelText"
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/VThemeSelect/VThemeSelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!--
Present users with a way to change the app theme between three options:
light, dark and system.
-->

<script setup lang="ts">
import { useI18n, useDarkMode } from "#imports"

import { computed, type ComputedRef } from "vue"

import { useUiStore } from "~/stores/ui"

import VIcon from "~/components/VIcon/VIcon.vue"
import VSelectField, {
type Choice,
} from "~/components/VSelectField/VSelectField.vue"

const i18n = useI18n({ useScope: "global" })
const uiStore = useUiStore()

const THEME_ICON_NAME = Object.freeze({
light: "sun",
dark: "moon",
})

const THEME_TEXT = {
light: i18n.t(`theme.choices.light`),
dark: i18n.t(`theme.choices.dark`),
}

const colorMode = computed({
get: () => uiStore.colorMode,
set: (value) => {
uiStore.setColorMode(value)
},
})

const darkMode = useDarkMode()

/**
* The icon always reflects the actual theme applied to the site.
* Therefore, it must be based on the value of `effectiveColorMode`.
*/
const currentThemeIcon = computed(
() => THEME_ICON_NAME[darkMode.effectiveColorMode.value]
)

/**
* The choices are computed because the text for the color mode choice
* "system" is dynamic and reflects the user's preferred color scheme at
* the OS-level.
*/
const choices: ComputedRef<Choice[]> = computed(() => {
const systemText = `${i18n.t(`theme.choices.system`)} (${THEME_TEXT[darkMode.osColorMode.value]})`
return [
{ key: "light", text: THEME_TEXT.light },
{ key: "dark", text: THEME_TEXT.dark },
{ key: "system", text: systemText },
]
})
</script>

<template>
<VSelectField
v-model="colorMode"
field-id="theme"
:choices="choices"
:blank-text="$t('theme.theme')"
:label-text="$t('theme.theme')"
:show-selected="false"
>
<template #start>
<VIcon :name="currentThemeIcon" />
</template>
</VSelectField>
</template>
46 changes: 41 additions & 5 deletions frontend/src/composables/use-dark-mode.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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.
Expand All @@ -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(() => {
Expand All @@ -41,6 +75,8 @@ export function useDarkMode() {

return {
colorMode,
osColorMode,
effectiveColorMode,
cssClass,
}
}
8 changes: 8 additions & 0 deletions frontend/src/locales/scripts/en.json5
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,14 @@
language: {
language: "Language",
},
theme: {
theme: "Theme",
choices: {
dark: "Dark",
light: "Light",
system: "System",
},
},
recentSearches: {
heading: "Recent searches",
clear: {
Expand Down
47 changes: 31 additions & 16 deletions frontend/test/unit/specs/composables/use-dark-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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)
}
)
Expand Down

0 comments on commit 3711b60

Please sign in to comment.