Add in-tab display mode toggle for users
Let signed-in visitors override the OS color scheme from the account menu without persisting across reloads, and keep themed logos in sync with the effective mode. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -13,12 +13,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// FOUC guard: apply the OS preference before JS bundles load. Runtime
|
||||||
|
// overrides (and further system-preference changes) are owned by
|
||||||
|
// src/lib/displayMode/displayMode.ts once the app boots.
|
||||||
(function () {
|
(function () {
|
||||||
if (!window.matchMedia) return;
|
if (!window.matchMedia) return;
|
||||||
var mq = window.matchMedia("(prefers-color-scheme: dark)");
|
document.documentElement.classList.toggle(
|
||||||
var apply = function () { document.documentElement.classList.toggle("dark", mq.matches); };
|
"dark",
|
||||||
apply();
|
window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||||
mq.addEventListener("change", apply);
|
);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Aktualisierungen abonnieren",
|
"subscribe": "Aktualisierungen abonnieren",
|
||||||
"subscribed": "Aktualisierungen abonniert",
|
"subscribed": "Aktualisierungen abonniert",
|
||||||
"signOut": "Abmelden",
|
"signOut": "Abmelden",
|
||||||
"signOutFailed": "Abmelden nicht möglich"
|
"signOutFailed": "Abmelden nicht möglich",
|
||||||
|
"switchToDarkMode": "Zum dunklen Modus wechseln",
|
||||||
|
"switchToLightMode": "Zum hellen Modus wechseln"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Subscribe to updates",
|
"subscribe": "Subscribe to updates",
|
||||||
"subscribed": "Subscribed to updates",
|
"subscribed": "Subscribed to updates",
|
||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
"signOutFailed": "Cannot sign out"
|
"signOutFailed": "Cannot sign out",
|
||||||
|
"switchToDarkMode": "Switch to dark mode",
|
||||||
|
"switchToLightMode": "Switch to light mode"
|
||||||
},
|
},
|
||||||
"locale": {
|
"locale": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Suscribirse a las actualizaciones",
|
"subscribe": "Suscribirse a las actualizaciones",
|
||||||
"subscribed": "Suscrito a las actualizaciones",
|
"subscribed": "Suscrito a las actualizaciones",
|
||||||
"signOut": "Cerrar sesión",
|
"signOut": "Cerrar sesión",
|
||||||
"signOutFailed": "No se puede cerrar sesión"
|
"signOutFailed": "No se puede cerrar sesión",
|
||||||
|
"switchToDarkMode": "Cambiar a modo oscuro",
|
||||||
|
"switchToLightMode": "Cambiar a modo claro"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "S'abonner aux mises à jour",
|
"subscribe": "S'abonner aux mises à jour",
|
||||||
"subscribed": "Abonné aux mises à jour",
|
"subscribed": "Abonné aux mises à jour",
|
||||||
"signOut": "Se déconnecter",
|
"signOut": "Se déconnecter",
|
||||||
"signOutFailed": "Impossible de se déconnecter"
|
"signOutFailed": "Impossible de se déconnecter",
|
||||||
|
"switchToDarkMode": "Passer en mode sombre",
|
||||||
|
"switchToLightMode": "Passer en mode clair"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Berlangganan pembaruan",
|
"subscribe": "Berlangganan pembaruan",
|
||||||
"subscribed": "Sudah berlangganan pembaruan",
|
"subscribed": "Sudah berlangganan pembaruan",
|
||||||
"signOut": "Keluar",
|
"signOut": "Keluar",
|
||||||
"signOutFailed": "Tidak dapat keluar"
|
"signOutFailed": "Tidak dapat keluar",
|
||||||
|
"switchToDarkMode": "Beralih ke mode gelap",
|
||||||
|
"switchToLightMode": "Beralih ke mode terang"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Iscriviti agli aggiornamenti",
|
"subscribe": "Iscriviti agli aggiornamenti",
|
||||||
"subscribed": "Iscritto agli aggiornamenti",
|
"subscribed": "Iscritto agli aggiornamenti",
|
||||||
"signOut": "Esci",
|
"signOut": "Esci",
|
||||||
"signOutFailed": "Impossibile uscire"
|
"signOutFailed": "Impossibile uscire",
|
||||||
|
"switchToDarkMode": "Passa alla modalità scura",
|
||||||
|
"switchToLightMode": "Passa alla modalità chiara"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "アップデートを購読",
|
"subscribe": "アップデートを購読",
|
||||||
"subscribed": "アップデート購読中",
|
"subscribed": "アップデート購読中",
|
||||||
"signOut": "サインアウト",
|
"signOut": "サインアウト",
|
||||||
"signOutFailed": "サインアウトできません"
|
"signOutFailed": "サインアウトできません",
|
||||||
|
"switchToDarkMode": "ダークモードに切り替え",
|
||||||
|
"switchToLightMode": "ライトモードに切り替え"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "업데이트 구독",
|
"subscribe": "업데이트 구독",
|
||||||
"subscribed": "업데이트 구독 중",
|
"subscribed": "업데이트 구독 중",
|
||||||
"signOut": "로그아웃",
|
"signOut": "로그아웃",
|
||||||
"signOutFailed": "로그아웃할 수 없습니다"
|
"signOutFailed": "로그아웃할 수 없습니다",
|
||||||
|
"switchToDarkMode": "다크 모드로 전환",
|
||||||
|
"switchToLightMode": "라이트 모드로 전환"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Subskrybuj aktualizacje",
|
"subscribe": "Subskrybuj aktualizacje",
|
||||||
"subscribed": "Subskrybowano aktualizacje",
|
"subscribed": "Subskrybowano aktualizacje",
|
||||||
"signOut": "Wyloguj się",
|
"signOut": "Wyloguj się",
|
||||||
"signOutFailed": "Nie można się wylogować"
|
"signOutFailed": "Nie można się wylogować",
|
||||||
|
"switchToDarkMode": "Przełącz na tryb ciemny",
|
||||||
|
"switchToLightMode": "Przełącz na tryb jasny"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Subscrever atualizações",
|
"subscribe": "Subscrever atualizações",
|
||||||
"subscribed": "Subscrito às atualizações",
|
"subscribed": "Subscrito às atualizações",
|
||||||
"signOut": "Terminar sessão",
|
"signOut": "Terminar sessão",
|
||||||
"signOutFailed": "Não é possível terminar a sessão"
|
"signOutFailed": "Não é possível terminar a sessão",
|
||||||
|
"switchToDarkMode": "Mudar para o modo escuro",
|
||||||
|
"switchToLightMode": "Mudar para o modo claro"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Güncellemelere abone ol",
|
"subscribe": "Güncellemelere abone ol",
|
||||||
"subscribed": "Güncellemelere abone olundu",
|
"subscribed": "Güncellemelere abone olundu",
|
||||||
"signOut": "Çıkış yap",
|
"signOut": "Çıkış yap",
|
||||||
"signOutFailed": "Çıkış yapılamıyor"
|
"signOutFailed": "Çıkış yapılamıyor",
|
||||||
|
"switchToDarkMode": "Karanlık moda geç",
|
||||||
|
"switchToLightMode": "Aydınlık moda geç"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "Підписатися на оновлення",
|
"subscribe": "Підписатися на оновлення",
|
||||||
"subscribed": "Підписано на оновлення",
|
"subscribed": "Підписано на оновлення",
|
||||||
"signOut": "Вийти",
|
"signOut": "Вийти",
|
||||||
"signOutFailed": "Не вдалося вийти"
|
"signOutFailed": "Не вдалося вийти",
|
||||||
|
"switchToDarkMode": "Перейти в темний режим",
|
||||||
|
"switchToLightMode": "Перейти у світлий режим"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Скасувати",
|
"cancel": "Скасувати",
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
"subscribe": "订阅更新",
|
"subscribe": "订阅更新",
|
||||||
"subscribed": "已订阅更新",
|
"subscribed": "已订阅更新",
|
||||||
"signOut": "退出登录",
|
"signOut": "退出登录",
|
||||||
"signOutFailed": "无法退出登录"
|
"signOutFailed": "无法退出登录",
|
||||||
|
"switchToDarkMode": "切换到深色模式",
|
||||||
|
"switchToLightMode": "切换到浅色模式"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
BellRingingIcon,
|
BellRingingIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
LockSimpleIcon,
|
LockSimpleIcon,
|
||||||
|
MoonIcon,
|
||||||
SignOutIcon,
|
SignOutIcon,
|
||||||
|
SunIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
@@ -45,6 +47,7 @@ import { useLocation } from "react-router";
|
|||||||
|
|
||||||
import { getSafeContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
import { getSafeContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
|
||||||
import { useSignOut } from "#/lib/auth/useSignOut";
|
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||||
|
import { useDisplayMode } from "#/lib/displayMode/useDisplayMode";
|
||||||
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
import { useLocalizedPath } from "#/lib/i18n/useLocale";
|
||||||
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
||||||
const [signOut, isSigningOut] = useSignOut();
|
const [signOut, isSigningOut] = useSignOut();
|
||||||
|
const { displayMode, toggleDisplayMode } = useDisplayMode();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// Select menus portal to body at z-3 by default; mount them on the drawer
|
// Select menus portal to body at z-3 by default; mount them on the drawer
|
||||||
// popup so they stack inside the modal layer (z-5) instead of under it.
|
// popup so they stack inside the modal layer (z-5) instead of under it.
|
||||||
@@ -202,6 +206,20 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
|||||||
>
|
>
|
||||||
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="gold"
|
||||||
|
size={3}
|
||||||
|
className="w-full justify-start"
|
||||||
|
iconStart={displayMode === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
toggleDisplayMode();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayMode === "dark"
|
||||||
|
? t("userMenu.switchToLightMode")
|
||||||
|
: t("userMenu.switchToDarkMode")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
BellRingingIcon,
|
BellRingingIcon,
|
||||||
CaretDownIcon,
|
CaretDownIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
MoonIcon,
|
||||||
SignOutIcon,
|
SignOutIcon,
|
||||||
|
SunIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
import { Avatar } from "@probo/ui/src/v2/Avatar/Avatar";
|
||||||
@@ -42,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
import { useSignOut } from "#/lib/auth/useSignOut";
|
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||||
|
import { useDisplayMode } from "#/lib/displayMode/useDisplayMode";
|
||||||
import {
|
import {
|
||||||
URL_LOCALE_LABELS,
|
URL_LOCALE_LABELS,
|
||||||
URL_LOCALES,
|
URL_LOCALES,
|
||||||
@@ -70,6 +73,7 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
|||||||
const identity = useFragment(topBarUserMenuFragment, identityKey);
|
const identity = useFragment(topBarUserMenuFragment, identityKey);
|
||||||
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
||||||
const [signOut, isSigningOut] = useSignOut();
|
const [signOut, isSigningOut] = useSignOut();
|
||||||
|
const { displayMode, toggleDisplayMode } = useDisplayMode();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const [changeLocale, isChangingLocale] = useChangeLocale();
|
const [changeLocale, isChangingLocale] = useChangeLocale();
|
||||||
|
|
||||||
@@ -123,6 +127,14 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
|||||||
>
|
>
|
||||||
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
iconStart={displayMode === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
onClick={toggleDisplayMode}
|
||||||
|
>
|
||||||
|
{displayMode === "dark"
|
||||||
|
? t("userMenu.switchToLightMode")
|
||||||
|
: t("userMenu.switchToDarkMode")}
|
||||||
|
</DropdownItem>
|
||||||
<DropdownSubmenu>
|
<DropdownSubmenu>
|
||||||
<DropdownSubmenuTrigger iconStart={<GlobeIcon />}>
|
<DropdownSubmenuTrigger iconStart={<GlobeIcon />}>
|
||||||
{t("locale.label")}
|
{t("locale.label")}
|
||||||
|
|||||||
87
apps/compliance-portal/src/lib/displayMode/displayMode.ts
Normal file
87
apps/compliance-portal/src/lib/displayMode/displayMode.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
export type DisplayMode = "light" | "dark";
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
// Tab-scoped override. Null means follow the OS preference. Never persisted.
|
||||||
|
let override: DisplayMode | null = null;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
function getSystemDisplayMode(): DisplayMode {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) {
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayMode(): DisplayMode {
|
||||||
|
return override ?? getSystemDisplayMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDisplayMode(mode: DisplayMode): void {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDisplayMode(mode: DisplayMode): void {
|
||||||
|
override = mode;
|
||||||
|
applyDisplayMode(mode);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleDisplayMode(): void {
|
||||||
|
setDisplayMode(getDisplayMode() === "dark" ? "light" : "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeDisplayMode(listener: Listener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDisplayMode(): void {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDisplayMode(getDisplayMode());
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
mediaQuery.addEventListener("change", () => {
|
||||||
|
if (override != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyDisplayMode(getDisplayMode());
|
||||||
|
notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initDisplayMode();
|
||||||
41
apps/compliance-portal/src/lib/displayMode/useDisplayMode.ts
Normal file
41
apps/compliance-portal/src/lib/displayMode/useDisplayMode.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DisplayMode,
|
||||||
|
getDisplayMode,
|
||||||
|
subscribeDisplayMode,
|
||||||
|
toggleDisplayMode,
|
||||||
|
} from "./displayMode";
|
||||||
|
|
||||||
|
export function useDisplayMode(): {
|
||||||
|
displayMode: DisplayMode;
|
||||||
|
toggleDisplayMode: () => void;
|
||||||
|
} {
|
||||||
|
const displayMode = useSyncExternalStore(
|
||||||
|
subscribeDisplayMode,
|
||||||
|
getDisplayMode,
|
||||||
|
() => "light" as const,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { displayMode, toggleDisplayMode };
|
||||||
|
}
|
||||||
@@ -21,23 +21,23 @@
|
|||||||
import { graphql } from "react-relay";
|
import { graphql } from "react-relay";
|
||||||
import { type LiveState, readFragment } from "relay-runtime";
|
import { type LiveState, readFragment } from "relay-runtime";
|
||||||
|
|
||||||
import type { CompliancePortalLogoResolverFragment$key } from "./__generated__/CompliancePortalLogoResolverFragment.graphql";
|
import {
|
||||||
|
getDisplayMode,
|
||||||
|
subscribeDisplayMode,
|
||||||
|
} from "#/lib/displayMode/displayMode";
|
||||||
|
|
||||||
function prefersDark(): boolean {
|
import type { CompliancePortalLogoResolverFragment$key } from "./__generated__/CompliancePortalLogoResolverFragment.graphql";
|
||||||
return typeof window !== "undefined"
|
|
||||||
&& !!window.matchMedia
|
|
||||||
&& window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @relayField CompliancePortal.themedLogoUrl: String
|
* @relayField CompliancePortal.themedLogoUrl: String
|
||||||
* @rootFragment CompliancePortalLogoResolverFragment
|
* @rootFragment CompliancePortalLogoResolverFragment
|
||||||
* @live
|
* @live
|
||||||
*
|
*
|
||||||
* Resolves the compliance portal logo download URL for the current system color
|
* Resolves the compliance portal logo download URL for the effective display
|
||||||
* scheme: the dark logo (falling back to the light one) when the OS prefers
|
* mode (OS preference, or an in-tab override): the dark logo (falling back to
|
||||||
* dark, otherwise the light logo. Lives in the graph so consumers select a
|
* the light one) in dark mode, otherwise the light logo. Lives in the graph so
|
||||||
* single field instead of threading `useSystemTheme` through URL selection.
|
* consumers select a single field instead of threading display-mode state
|
||||||
|
* through URL selection.
|
||||||
*/
|
*/
|
||||||
export function themedLogoUrl(
|
export function themedLogoUrl(
|
||||||
key: CompliancePortalLogoResolverFragment$key,
|
key: CompliancePortalLogoResolverFragment$key,
|
||||||
@@ -59,11 +59,7 @@ export function themedLogoUrl(
|
|||||||
const darkUrl = data.darkLogo?.downloadUrl ?? lightUrl;
|
const darkUrl = data.darkLogo?.downloadUrl ?? lightUrl;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read: () => (prefersDark() ? darkUrl : lightUrl),
|
read: () => (getDisplayMode() === "dark" ? darkUrl : lightUrl),
|
||||||
subscribe: (callback) => {
|
subscribe: subscribeDisplayMode,
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mediaQuery.addEventListener("change", callback);
|
|
||||||
return () => mediaQuery.removeEventListener("change", callback);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,23 +21,23 @@
|
|||||||
import { graphql } from "react-relay";
|
import { graphql } from "react-relay";
|
||||||
import { type LiveState, readFragment } from "relay-runtime";
|
import { type LiveState, readFragment } from "relay-runtime";
|
||||||
|
|
||||||
import type { FrameworkLogoResolverFragment$key } from "./__generated__/FrameworkLogoResolverFragment.graphql";
|
import {
|
||||||
|
getDisplayMode,
|
||||||
|
subscribeDisplayMode,
|
||||||
|
} from "#/lib/displayMode/displayMode";
|
||||||
|
|
||||||
function prefersDark(): boolean {
|
import type { FrameworkLogoResolverFragment$key } from "./__generated__/FrameworkLogoResolverFragment.graphql";
|
||||||
return typeof window !== "undefined"
|
|
||||||
&& !!window.matchMedia
|
|
||||||
&& window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @relayField Framework.themedLogoUrl: String
|
* @relayField Framework.themedLogoUrl: String
|
||||||
* @rootFragment FrameworkLogoResolverFragment
|
* @rootFragment FrameworkLogoResolverFragment
|
||||||
* @live
|
* @live
|
||||||
*
|
*
|
||||||
* Resolves the framework logo download URL for the current system color scheme:
|
* Resolves the framework logo download URL for the effective display mode (OS
|
||||||
* the dark logo (falling back to the light one) when the OS prefers dark,
|
* preference, or an in-tab override): the dark logo (falling back to the light
|
||||||
* otherwise the light logo. Lives in the graph so consumers select a single
|
* one) in dark mode, otherwise the light logo. Lives in the graph so consumers
|
||||||
* field instead of threading `useSystemTheme` through URL selection.
|
* select a single field instead of threading display-mode state through URL
|
||||||
|
* selection.
|
||||||
*/
|
*/
|
||||||
export function themedLogoUrl(
|
export function themedLogoUrl(
|
||||||
key: FrameworkLogoResolverFragment$key,
|
key: FrameworkLogoResolverFragment$key,
|
||||||
@@ -59,11 +59,7 @@ export function themedLogoUrl(
|
|||||||
const darkUrl = data.darkLogo?.downloadUrl ?? lightUrl;
|
const darkUrl = data.darkLogo?.downloadUrl ?? lightUrl;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
read: () => (prefersDark() ? darkUrl : lightUrl),
|
read: () => (getDisplayMode() === "dark" ? darkUrl : lightUrl),
|
||||||
subscribe: (callback) => {
|
subscribe: subscribeDisplayMode,
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
mediaQuery.addEventListener("change", callback);
|
|
||||||
return () => mediaQuery.removeEventListener("change", callback);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "#/lib/displayMode/displayMode";
|
||||||
import "#/lib/i18n/i18n";
|
import "#/lib/i18n/i18n";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
|||||||
Reference in New Issue
Block a user