diff --git a/docs/pages/_app.js b/docs/pages/_app.js
index b2da57eb2ea405..d9bbb06f10588f 100644
--- a/docs/pages/_app.js
+++ b/docs/pages/_app.js
@@ -26,6 +26,7 @@ import DocsStyledEngineProvider from 'docs/src/modules/utils/StyledEngineProvide
import createEmotionCache from 'docs/src/createEmotionCache';
import findActivePage from 'docs/src/modules/utils/findActivePage';
import getProductInfoFromUrl from 'docs/src/modules/utils/getProductInfoFromUrl';
+import { AnalyticsProvider } from 'docs/src/modules/components/AnalyticsProvider';
import { DocsProvider } from '@mui/docs/DocsProvider';
import { mapTranslations } from '@mui/docs/i18n';
import SvgMuiLogomark, {
@@ -428,8 +429,10 @@ function AppWrapper(props) {
- {children}
-
+
+ {children}
+
+
diff --git a/docs/pages/_document.js b/docs/pages/_document.js
index 873bbba1fa2bd2..f3c2d3b3a9025a 100644
--- a/docs/pages/_document.js
+++ b/docs/pages/_document.js
@@ -15,6 +15,7 @@ const PRODUCTION_GA =
process.env.DEPLOY_ENV === 'production' || process.env.DEPLOY_ENV === 'staging';
const GOOGLE_ANALYTICS_ID_V4 = PRODUCTION_GA ? 'G-5NXDQLC2ZK' : 'G-XJ83JQEK7J';
+const APOLLO_TRACKING_ID = PRODUCTION_GA ? '691c2e920c5e20000d7801b6' : 'dev-id';
export default class MyDocument extends Document {
render() {
@@ -104,10 +105,47 @@ export default class MyDocument extends Document {
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
+
+${/* Set default consent to denied (Google Consent Mode v2) */ ''}
+gtag('consent', 'default', {
+ 'ad_storage': 'denied',
+ 'ad_user_data': 'denied',
+ 'ad_personalization': 'denied',
+ 'analytics_storage': 'denied',
+ 'wait_for_update': 500
+});
+gtag('set', 'ads_data_redaction', true);
+gtag('set', 'url_passthrough', true);
+
gtag('js', new Date());
gtag('config', '${GOOGLE_ANALYTICS_ID_V4}', {
send_page_view: false,
});
+
+${/* Apollo initialization - called by AnalyticsProvider when consent is granted */ ''}
+window.initApollo = function() {
+ if (window.apolloInitialized) return;
+ window.apolloInitialized = true;
+ var n = Math.random().toString(36).substring(7),
+ o = document.createElement('script');
+ o.src = 'https://assets.apollo.io/micro/website-tracker/tracker.iife.js?nocache=' + n;
+ o.async = true;
+ o.defer = true;
+ o.onload = function () {
+ window.trackingFunctions.onLoad({ appId: '${APOLLO_TRACKING_ID}' });
+ };
+ document.head.appendChild(o);
+};
+
+${/* Check localStorage for existing consent and initialize if already granted */ ''}
+(function() {
+ try {
+ var consent = localStorage.getItem('docs-cookie-consent');
+ if (consent === 'analytics') {
+ window.initApollo();
+ }
+ } catch (e) {}
+})();
`,
}}
/>
diff --git a/docs/src/BrandingCssVarsProvider.tsx b/docs/src/BrandingCssVarsProvider.tsx
index cb5e731d886cde..334c87f12da180 100644
--- a/docs/src/BrandingCssVarsProvider.tsx
+++ b/docs/src/BrandingCssVarsProvider.tsx
@@ -127,15 +127,11 @@ export function resetDocsSpacing() {
}
}
-export default function BrandingCssVarsProvider(props: {
- children: React.ReactNode;
- direction?: 'ltr' | 'rtl';
-}) {
- const { direction = 'ltr', children } = props;
- const { asPath } = useRouter();
- const { canonicalAs } = pathnameToLanguage(asPath);
- const theme = React.useMemo(() => {
- return createTheme({
+const themeCache: Map> = new Map();
+function getTheme(direction: 'ltr' | 'rtl') {
+ let cachedTheme = themeCache.get(direction);
+ if (!cachedTheme) {
+ cachedTheme = createTheme({
cssVariables: {
cssVarPrefix: 'muidocs',
colorSchemeSelector: 'data-mui-color-scheme',
@@ -143,7 +139,35 @@ export default function BrandingCssVarsProvider(props: {
direction,
...themeOptions,
});
- }, [direction]);
+ themeCache.set(direction, cachedTheme);
+ }
+ return cachedTheme!;
+}
+
+export function BrandingCssThemeProvider({
+ children,
+ direction = 'ltr',
+ forceThemeRerender = false,
+}: React.PropsWithChildren<{ direction?: 'ltr' | 'rtl'; forceThemeRerender?: boolean }>) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default function BrandingCssVarsProvider(props: {
+ children: React.ReactNode;
+ direction?: 'ltr' | 'rtl';
+}) {
+ const { direction = 'ltr', children } = props;
+ const { asPath } = useRouter();
+ const { canonicalAs } = pathnameToLanguage(asPath);
useEnhancedEffect(() => {
const nextPaletteColors = JSON.parse(getCookie('paletteColors') || 'null');
if (nextPaletteColors) {
@@ -164,10 +188,7 @@ export default function BrandingCssVarsProvider(props: {
}
}, [direction]);
return (
-
@@ -175,6 +196,6 @@ export default function BrandingCssVarsProvider(props: {
{children}
-
+
);
}
diff --git a/docs/src/modules/components/AnalyticsProvider.tsx b/docs/src/modules/components/AnalyticsProvider.tsx
new file mode 100644
index 00000000000000..09c725d7429eed
--- /dev/null
+++ b/docs/src/modules/components/AnalyticsProvider.tsx
@@ -0,0 +1,219 @@
+import * as React from 'react';
+import Button from '@mui/material/Button';
+import Fade from '@mui/material/Fade';
+import Paper from '@mui/material/Paper';
+import Box from '@mui/material/Box';
+import Stack from '@mui/material/Stack';
+import Typography from '@mui/material/Typography';
+import useLocalStorageState from '@mui/utils/useLocalStorageState';
+import { alpha } from '@mui/system';
+import Portal from '@mui/material/Portal';
+import TrapFocus from '@mui/material/Unstable_TrapFocus';
+import CookieOutlinedIcon from '@mui/icons-material/CookieOutlined';
+import { BrandingCssThemeProvider } from 'docs/src/BrandingCssVarsProvider';
+
+const COOKIE_CONSENT_KEY = 'docs-cookie-consent';
+
+type ConsentStatus = 'analytics' | 'essential' | null;
+
+function getDoNotTrack(): boolean {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+ // Check for Do Not Track (DNT)
+ return navigator.doNotTrack === '1' || (window as { doNotTrack?: string }).doNotTrack === '1';
+}
+
+// DNT doesn't change during a session, so we can use a simple external store
+const subscribeToNothing = () => () => {};
+const getDoNotTrackSnapshot = () => getDoNotTrack();
+const getDoNotTrackServerSnapshot = () => true; // Assume DNT until we know the actual value
+
+function useDoNotTrack(): boolean {
+ return React.useSyncExternalStore(
+ subscribeToNothing,
+ getDoNotTrackSnapshot,
+ getDoNotTrackServerSnapshot,
+ );
+}
+
+interface AnalyticsContextValue {
+ consentStatus: ConsentStatus;
+ hasAnalyticsConsent: boolean;
+ needsConsent: boolean;
+ setAnalyticsConsent: () => void;
+ setEssentialOnly: () => void;
+}
+
+const AnalyticsContext = React.createContext({
+ consentStatus: null,
+ hasAnalyticsConsent: false,
+ needsConsent: false,
+ setAnalyticsConsent: () => {},
+ setEssentialOnly: () => {},
+});
+
+export function useAnalyticsConsent() {
+ return React.useContext(AnalyticsContext);
+}
+
+export function CookieConsentDialog() {
+ const { needsConsent, setAnalyticsConsent, setEssentialOnly } = useAnalyticsConsent();
+ const [show, setShow] = React.useState(false);
+
+ React.useEffect(() => {
+ if (needsConsent) {
+ // Double rAF to ensure the initial opacity: 0 state is painted before transitioning
+ const frame = requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setShow(true);
+ });
+ });
+ return () => cancelAnimationFrame(frame);
+ }
+ setShow(false);
+ return undefined;
+ }, [needsConsent]);
+
+ return (
+
+
+
+ ({
+ position: 'fixed',
+ bottom: 0,
+ right: 0,
+ p: 2,
+ m: 2,
+ maxWidth: 340,
+ pointerEvents: 'auto',
+ boxShadow: theme.shadows[2],
+ zIndex: theme.zIndex.snackbar,
+ ...theme.applyDarkStyles({
+ bgcolor: 'primaryDark.900',
+ }),
+ })}
+ >
+
+
+ ({
+ borderRadius: '50%',
+ bgcolor: alpha(theme.palette.primary.main, 0.1),
+ p: 1,
+ display: 'inline-block',
+ width: 40,
+ height: 40,
+ mb: -1,
+ alignSelf: { xs: 'center', sm: 'flex-start' },
+ })}
+ >
+
+
+
+
+ Cookie Preferences
+
+
+ We use cookies to understand site usage and improve our content. This includes
+ third-party analytics.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function updateGoogleConsent(hasAnalytics: boolean) {
+ if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
+ window.gtag('consent', 'update', {
+ ad_storage: 'denied',
+ ad_user_data: 'denied',
+ ad_personalization: 'denied',
+ analytics_storage: hasAnalytics ? 'granted' : 'denied',
+ });
+
+ // Initialize Apollo when analytics consent is granted
+ const win = window as typeof window & { initApollo?: () => void };
+ if (hasAnalytics && typeof win.initApollo === 'function') {
+ win.initApollo();
+ }
+ }
+}
+
+export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
+ const [consentStatus, setConsentStatus] = useLocalStorageState(COOKIE_CONSENT_KEY, null);
+ const doNotTrack = useDoNotTrack();
+
+ // Respect Do Not Track - don't show dialog and treat as essential only
+ const needsConsent = consentStatus === null && !doNotTrack;
+
+ // Update Google consent when status changes or on mount if already set
+ React.useEffect(() => {
+ if (doNotTrack) {
+ // DNT is enabled - always deny analytics
+ updateGoogleConsent(false);
+ } else if (consentStatus !== null) {
+ updateGoogleConsent(consentStatus === 'analytics');
+ }
+ }, [consentStatus, doNotTrack]);
+
+ const setAnalyticsConsent = React.useCallback(() => {
+ setConsentStatus('analytics');
+ }, [setConsentStatus]);
+
+ const setEssentialOnly = React.useCallback(() => {
+ setConsentStatus('essential');
+ }, [setConsentStatus]);
+
+ const contextValue = React.useMemo(
+ () => ({
+ consentStatus: doNotTrack ? 'essential' : (consentStatus as ConsentStatus),
+ hasAnalyticsConsent: !doNotTrack && consentStatus === 'analytics',
+ needsConsent,
+ setAnalyticsConsent,
+ setEssentialOnly,
+ }),
+ [consentStatus, doNotTrack, needsConsent, setAnalyticsConsent, setEssentialOnly],
+ );
+
+ return (
+
+ {children}
+
+
+
+
+ );
+}