Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/web/components/PreferenceControls.tsx
blob: 9296b77725c2d0863524ed7346f9aade9750e011 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1

"use client";

import { useEffect, useSyncExternalStore, useTransition } from "react";
import { selectLocale } from "@/lib/locale.actions";
import {
  isLocale,
  localeNames,
  supportedLocales,
  type Locale,
} from "@/lib/localization";

interface PreferenceControlsProps {
  locale: Locale;
  labels: {
    dark: string;
    language: string;
    light: string;
    theme: string;
  };
}

type Theme = "light" | "dark";

const themeEvent = "progmasoft-theme-change";

function readTheme(): Theme {
  return window.localStorage.getItem("progmasoft_theme") === "dark"
    ? "dark"
    : "light";
}

/**
 * Applies a theme to the document at once, in the click that chose it.
 *
 * Transitions are switched off for the two frames of the change: otherwise
 * every button and link would animate its colours while the page around it
 * has already changed, which looks like flicker.
 */
function applyTheme(theme: Theme) {
  const root = document.documentElement;
  root.classList.add("theme-switching");
  root.dataset.theme = theme;
  root.style.colorScheme = theme;
  window.requestAnimationFrame(() =>
    window.requestAnimationFrame(() =>
      root.classList.remove("theme-switching"),
    ),
  );
}

function subscribeTheme(onStoreChange: () => void) {
  window.addEventListener("storage", onStoreChange);
  window.addEventListener(themeEvent, onStoreChange);
  return () => {
    window.removeEventListener("storage", onStoreChange);
    window.removeEventListener(themeEvent, onStoreChange);
  };
}

export function PreferenceControls({
  locale,
  labels,
}: PreferenceControlsProps) {
  const theme = useSyncExternalStore<Theme>(
    subscribeTheme,
    readTheme,
    () => "light",
  );
  const [switchingLocale, startLocaleSwitch] = useTransition();

  // Another tab, or another control on this page, changed the theme.
  useEffect(() => {
    if (document.documentElement.dataset.theme !== theme) {
      applyTheme(theme);
    }
  }, [theme]);

  function toggleTheme() {
    const selected: Theme = theme === "light" ? "dark" : "light";
    applyTheme(selected);
    window.localStorage.setItem("progmasoft_theme", selected);
    window.dispatchEvent(new Event(themeEvent));
  }

  // The server stores the preference and re-renders the current route in the
  // selected language, so the page changes in place: nothing is reloaded and
  // scroll position, theme and client state stay as they are.
  function chooseLocale(selected: string) {
    if (!isLocale(selected) || selected === locale) {
      return;
    }
    startLocaleSwitch(async () => {
      try {
        await selectLocale(selected);
      } catch {
        // The request did not reach the server. The query parameter asks the
        // proxy for the same preference with an ordinary navigation.
        const url = new URL(window.location.href);
        url.searchParams.set("lang", selected);
        window.location.assign(url);
      }
    });
  }

  return (
    <div className="preference-controls">
      <button
        type="button"
        onClick={toggleTheme}
        aria-label={labels.theme}
        title={labels.theme}
      >
        <span aria-hidden="true">{theme === "light" ? "◐" : "◑"}</span>
        <span>{theme === "light" ? labels.dark : labels.light}</span>
      </button>
      <select
        value={locale}
        onChange={(event) => chooseLocale(event.target.value)}
        aria-busy={switchingLocale}
        disabled={switchingLocale}
        aria-label={labels.language}
        title={labels.language}
      >
        {supportedLocales.map((supported) => (
          <option key={supported} value={supported} lang={supported}>
            {localeNames[supported]}
          </option>
        ))}
      </select>
    </div>
  );
}