Skip to content

Commit 5bd7508

Browse files
committed
Feat: White label
1 parent 3b59d24 commit 5bd7508

File tree

3 files changed

+323
-10
lines changed

3 files changed

+323
-10
lines changed

src/mui.tsx

Lines changed: 218 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"use client";
22

33
/* eslint-disable @typescript-eslint/no-non-null-assertion */
4-
import React, { useMemo, type ReactNode } from "react";
5-
import type { Theme as MuiTheme, ThemeOptions } from "@mui/material/styles";
6-
import { createTheme, ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
4+
import React, { useMemo, useEffect, createContext, useContext, type ReactNode } from "react";
5+
import * as mui from "@mui/material/styles";
76
import type { Shadows } from "@mui/material/styles";
87
import { fr } from "./fr";
98
import { useIsDark } from "./useIsDark";
@@ -13,11 +12,17 @@ import { assert } from "tsafe/assert";
1312
import { objectKeys } from "tsafe/objectKeys";
1413
import { id } from "tsafe/id";
1514
import { useBreakpointsValuesPx, type BreakpointsValues } from "./useBreakpointsValuesPx";
15+
import { structuredCloneButFunctions } from "./tools/structuredCloneButFunctions";
16+
import { deepAssign } from "./tools/deepAssign";
17+
import { Global, css } from "@emotion/react";
18+
import { getAssetUrl } from "./tools/getAssetUrl";
19+
import marianneFaviconSvgUrl from "@codegouvfr/react-dsfr/favicon/favicon.svg";
20+
import blankFaviconSvgUrl from "./assets/blank-favicon.svg";
1621

1722
export function getMuiDsfrThemeOptions(params: {
1823
isDark: boolean;
1924
breakpointsValues: BreakpointsValues;
20-
}): ThemeOptions {
25+
}): mui.ThemeOptions {
2126
const { isDark, breakpointsValues } = params;
2227

2328
const { options, decisions } = fr.colors.getHex({ isDark });
@@ -139,7 +144,7 @@ export function getMuiDsfrThemeOptions(params: {
139144
})();
140145
})(),
141146
"shadows": (() => {
142-
const [, , , , , , , , ...rest] = createTheme().shadows;
147+
const [, , , , , , , , ...rest] = mui.createTheme().shadows;
143148

144149
return id<Shadows>([
145150
"none",
@@ -336,8 +341,8 @@ export function getMuiDsfrThemeOptions(params: {
336341
export function createMuiDsfrTheme(
337342
params: { isDark: boolean; breakpointsValues: BreakpointsValues },
338343
...args: object[]
339-
): MuiTheme {
340-
const muiTheme = createTheme(getMuiDsfrThemeOptions(params), ...args);
344+
): mui.Theme {
345+
const muiTheme = mui.createTheme(getMuiDsfrThemeOptions(params), ...args);
341346

342347
return muiTheme;
343348
}
@@ -349,9 +354,9 @@ export function createMuiDsfrThemeProvider(params: {
349354
* It's a Theme as defined in import type { Theme } from "@mui/material/styles";
350355
* That is to say before augmentation.
351356
**/
352-
nonAugmentedMuiTheme: MuiTheme;
357+
nonAugmentedMuiTheme: mui.Theme;
353358
isDark: boolean;
354-
}) => MuiTheme;
359+
}) => mui.Theme;
355360
}) {
356361
const { augmentMuiTheme, useIsDark: useIsDark_props = useIsDark } = params;
357362

@@ -378,7 +383,7 @@ export function createMuiDsfrThemeProvider(params: {
378383
});
379384
}, [isDark, breakpointsValues]);
380385

381-
return <MuiThemeProvider theme={theme}>{children}</MuiThemeProvider>;
386+
return <mui.ThemeProvider theme={theme}>{children}</mui.ThemeProvider>;
382387
}
383388

384389
return { MuiDsfrThemeProvider };
@@ -387,3 +392,206 @@ export function createMuiDsfrThemeProvider(params: {
387392
export const { MuiDsfrThemeProvider } = createMuiDsfrThemeProvider({});
388393

389394
export default MuiDsfrThemeProvider;
395+
396+
export function createDsfrCustomBrandingProvider(params: {
397+
createMuiTheme: (params: {
398+
isDark: boolean;
399+
/**
400+
* WARNING: The types can be lying here if you have augmented the theme.
401+
* It's a Theme as defined in `import type { Theme } from "@mui/material/styles";`
402+
* That is to say before augmentation.
403+
* Make sure to set your custom properties if any are declared at the type level.
404+
**/
405+
theme_gov: mui.Theme;
406+
}) => { theme: mui.Theme; faviconUrl?: string };
407+
}) {
408+
const { createMuiTheme } = params;
409+
410+
function useMuiTheme() {
411+
const { isDark } = useIsDark();
412+
const { breakpointsValues } = useBreakpointsValuesPx();
413+
414+
const { theme, isGov, faviconUrl_userProvided } = useMemo(() => {
415+
const theme_gov = createMuiDsfrTheme({ isDark, breakpointsValues });
416+
417+
// @ts-expect-error: Technic to detect if user is using the government theme
418+
theme_gov.palette.isGov = true;
419+
420+
const { theme, faviconUrl: faviconUrl_userProvided } = createMuiTheme({
421+
isDark,
422+
theme_gov
423+
});
424+
425+
let isGov: boolean;
426+
427+
// @ts-expect-error: We know what we are doing
428+
if (theme.palette.isGov) {
429+
isGov = true;
430+
// @ts-expect-error: We know what we are doing
431+
delete theme.palette.isGov;
432+
} else {
433+
isGov = false;
434+
}
435+
436+
// NOTE: We do not allow customization of the spacing and breakpoints
437+
if (!isGov) {
438+
theme.spacing = structuredCloneButFunctions(theme_gov.spacing);
439+
theme.breakpoints = structuredCloneButFunctions(theme_gov.breakpoints);
440+
441+
theme.components ??= {};
442+
443+
deepAssign({
444+
target: theme.components as any,
445+
source: structuredCloneButFunctions({
446+
MuiTablePagination: theme_gov.components!.MuiTablePagination
447+
}) as any
448+
});
449+
450+
theme.typography = structuredCloneButFunctions(
451+
theme_gov.typography,
452+
({ key, value }) => (key !== "fontFamily" ? value : theme.typography.fontFamily)
453+
);
454+
}
455+
456+
return { theme, isGov, faviconUrl_userProvided };
457+
}, [isDark, breakpointsValues]);
458+
459+
return { theme, isGov, faviconUrl_userProvided };
460+
}
461+
462+
function useFavicon(params: { faviconUrl: string }) {
463+
const { faviconUrl } = params;
464+
465+
useEffect(() => {
466+
document
467+
.querySelectorAll(
468+
'link[rel="apple-touch-icon"], link[rel="icon"], link[rel="shortcut icon"]'
469+
)
470+
.forEach(link => link.remove());
471+
472+
const link = document.createElement("link");
473+
link.rel = "icon";
474+
link.href = faviconUrl;
475+
link.type = (() => {
476+
if (faviconUrl.startsWith("data:")) {
477+
return faviconUrl.split("data:")[1].split(",")[0];
478+
}
479+
switch (faviconUrl.split(".").pop()?.toLowerCase()) {
480+
case "svg":
481+
return "image/svg+xml";
482+
case "png":
483+
return "image/png";
484+
case "ico":
485+
return "image/x-icon";
486+
default:
487+
throw new Error("Unsupported favicon file type");
488+
}
489+
})();
490+
document.head.appendChild(link);
491+
492+
return () => {
493+
link.remove();
494+
};
495+
}, [faviconUrl]);
496+
}
497+
498+
function DsfrCustomBrandingProvider(props: { children: ReactNode }) {
499+
const { children } = props;
500+
501+
const { theme, isGov, faviconUrl_userProvided } = useMuiTheme();
502+
503+
useFavicon({
504+
faviconUrl:
505+
faviconUrl_userProvided ??
506+
getAssetUrl(isGov ? marianneFaviconSvgUrl : blankFaviconSvgUrl)
507+
});
508+
509+
return (
510+
<>
511+
{!isGov && (
512+
<Global
513+
styles={css({
514+
":root": {
515+
"--text-active-blue-france": theme.palette.primary.main,
516+
"--background-active-blue-france": theme.palette.primary.main,
517+
"--text-action-high-blue-france": theme.palette.primary.main,
518+
"--border-plain-blue-france": theme.palette.primary.main,
519+
"--border-active-blue-france": theme.palette.primary.main,
520+
"--text-title-grey": theme.palette.text.primary,
521+
"--background-action-high-blue-france": theme.palette.primary.main,
522+
"--border-default-grey": theme.palette.divider,
523+
"--border-action-high-blue-france": theme.palette.primary.main
524+
525+
// options:
526+
/*
527+
"--blue-france-sun-113-625": theme.palette.primary.main,
528+
"--blue-france-sun-113-625-active": theme.palette.primary.light,
529+
"--blue-france-sun-113-625-hover": theme.palette.primary.dark,
530+
"--blue-france-975-sun-113": theme.palette.primary.contrastText,
531+
532+
"--blue-france-950-100": theme.palette.secondary.main,
533+
"--blue-france-950-100-active": theme.palette.secondary.light,
534+
"--blue-france-950-100-hover": theme.palette.secondary.dark,
535+
//"--blue-france-sun-113-625": theme.palette.secondary.contrastText,
536+
537+
"--grey-50-1000": theme.palette.text.primary,
538+
"--grey-200-850": theme.palette.text.secondary,
539+
"--grey-625-425": theme.palette.text.disabled,
540+
541+
"--grey-900-175": theme.palette.divider,
542+
543+
//"--grey-200-850": theme.palette.action.active,
544+
"--grey-975-100": theme.palette.action.hover,
545+
"--blue-france-925-125-active": theme.palette.action.selected,
546+
//"--grey-625-425": theme.palette.action.disabled,
547+
"--grey-925-125": theme.palette.action.disabledBackground,
548+
//"--blue-france-sun-113-625-active": theme.palette.action.focus,
549+
550+
"--grey-1000-50": theme.palette.background.default,
551+
"--grey-1000-100": theme.palette.background.paper
552+
*/
553+
},
554+
body: {
555+
fontFamily: theme.typography.fontFamily,
556+
fontSize: theme.typography.fontSize,
557+
//"lineHeight": theme.typography.lineHeight,
558+
559+
color: theme.palette.text.primary,
560+
backgroundColor: theme.palette.background.default
561+
},
562+
[`.${fr.cx("fr-header__logo")}`]: {
563+
display: "none"
564+
},
565+
[`.${fr.cx("fr-footer__brand")} .${fr.cx("fr-logo")}`]: {
566+
display: "none"
567+
},
568+
[`.${fr.cx("fr-footer__content-list")}`]: {
569+
display: "none"
570+
},
571+
[`.${fr.cx("fr-footer__bottom-copy")}`]: {
572+
display: "none"
573+
}
574+
})}
575+
/>
576+
)}
577+
<context_isGov.Provider value={isGov}>
578+
<mui.ThemeProvider theme={theme}>{children}</mui.ThemeProvider>
579+
</context_isGov.Provider>
580+
</>
581+
);
582+
}
583+
584+
return { DsfrCustomBrandingProvider };
585+
}
586+
587+
const context_isGov = createContext<boolean | undefined>(undefined);
588+
589+
export function useIsGov() {
590+
const isGov = useContext(context_isGov);
591+
592+
if (isGov === undefined) {
593+
throw new Error("useIsGov must be used within a MuiThemeProvider");
594+
}
595+
596+
return { isGov };
597+
}

src/tools/deepAssign.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { assert, is } from "tsafe/assert";
2+
import { structuredCloneButFunctions } from "./structuredCloneButFunctions";
3+
4+
/** NOTE: Array a copied over, not merged. */
5+
export function deepAssign(params: {
6+
target: Record<string, unknown>;
7+
source: Record<string, unknown>;
8+
}): void {
9+
const { target, source } = params;
10+
11+
Object.keys(source).forEach(key => {
12+
const dereferencedSource = source[key];
13+
14+
if (dereferencedSource === undefined) {
15+
delete target[key];
16+
return;
17+
}
18+
19+
if (dereferencedSource instanceof Date) {
20+
assign({
21+
target,
22+
key,
23+
value: new Date(dereferencedSource.getTime())
24+
});
25+
26+
return;
27+
}
28+
29+
if (dereferencedSource instanceof Array) {
30+
assign({
31+
target,
32+
key,
33+
value: structuredCloneButFunctions(dereferencedSource)
34+
});
35+
36+
return;
37+
}
38+
39+
if (dereferencedSource instanceof Function || !(dereferencedSource instanceof Object)) {
40+
assign({
41+
target,
42+
key,
43+
value: dereferencedSource
44+
});
45+
46+
return;
47+
}
48+
49+
if (!(target[key] instanceof Object)) {
50+
target[key] = {};
51+
}
52+
53+
const dereferencedTarget = target[key];
54+
55+
assert(is<Record<string, unknown>>(dereferencedTarget));
56+
assert(is<Record<string, unknown>>(dereferencedSource));
57+
58+
deepAssign({
59+
target: dereferencedTarget,
60+
source: dereferencedSource
61+
});
62+
});
63+
}
64+
65+
function assign(params: { target: Record<string, unknown>; key: string; value: unknown }): void {
66+
const { target, key, value } = params;
67+
68+
Object.defineProperty(target, key, {
69+
enumerable: true,
70+
writable: true,
71+
configurable: true,
72+
value
73+
});
74+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Functionally equivalent to structuredClone but
3+
* functions are not cloned but kept as is.
4+
* (as opposed to structuredClone that chokes if it encounters a function)
5+
*/
6+
export function structuredCloneButFunctions<T>(
7+
o: T,
8+
replacer?: (params: { key: string; value: unknown }) => unknown
9+
): T {
10+
if (!(o instanceof Object)) {
11+
return o;
12+
}
13+
14+
if (typeof o === "function") {
15+
return o;
16+
}
17+
18+
if (o instanceof Array) {
19+
return o.map(o => structuredCloneButFunctions(o, replacer)) as any;
20+
}
21+
22+
return Object.fromEntries(
23+
Object.entries(o).map(([key, value]) => [
24+
key,
25+
structuredCloneButFunctions(
26+
replacer === undefined ? value : replacer({ key, value }),
27+
replacer
28+
)
29+
])
30+
) as any;
31+
}

0 commit comments

Comments
 (0)