Skip to content

Commit 47e8e91

Browse files
committed
feat(palette-picker): add runtime theme switcher to navbar
Moves all theme CSS to demo/static/themes/ for URL-accessible dynamic loading. Registers a custom-PalettePicker navbar item that injects the selected /themes/<id>.css at runtime, persists the choice in localStorage, and restores it on reload via an inline <head> script.
1 parent 0fd4b2f commit 47e8e91

11 files changed

Lines changed: 2452 additions & 4 deletions

File tree

demo/docusaurus.config.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,7 @@ const config: Config = {
4141
},
4242
blog: false,
4343
theme: {
44-
customCss: [
45-
"./src/css/custom.css",
46-
"./src/css/themes/evergreen.css", // swap this line to try a different theme
47-
],
44+
customCss: ["./src/css/custom.css"],
4845
},
4946
gtag: {
5047
trackingID: "GTM-THVM29S",
@@ -96,6 +93,7 @@ const config: Config = {
9693
},
9794
],
9895
},
96+
{ type: "custom-PalettePicker", position: "right" },
9997
{
10098
href: "https://medium.com/palo-alto-networks-developer-blog",
10199
position: "right",
@@ -382,6 +380,22 @@ const config: Config = {
382380
} satisfies Plugin.PluginOptions,
383381
},
384382
],
383+
// FOUC prevention: restore saved palette before React hydrates
384+
function paletteScript() {
385+
return {
386+
name: "palette-fouc-script",
387+
injectHtmlTags() {
388+
return {
389+
headTags: [
390+
{
391+
tagName: "script",
392+
innerHTML: `try{var p=localStorage.getItem('openapi-demo-palette');if(p){var l=document.createElement('link');l.id='openapi-palette-link';l.rel='stylesheet';l.href='/themes/'+p+'.css';document.head.appendChild(l);}}catch(e){}`,
393+
},
394+
],
395+
};
396+
},
397+
};
398+
},
385399
],
386400
themes: ["docusaurus-theme-openapi-docs"],
387401
stylesheets: [
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/* ============================================================================
2+
* PalettePicker — runtime color palette switcher for the navbar.
3+
*
4+
* Dynamically injects /themes/<id>.css into <head> and persists
5+
* the choice in localStorage under 'openapi-demo-palette'.
6+
* ========================================================================== */
7+
8+
import React, { useEffect, useRef, useState } from "react";
9+
10+
import styles from "./styles.module.css";
11+
12+
const THEMES = [
13+
{ id: "evergreen", label: "Evergreen", color: "#2e8555" },
14+
{ id: "panw", label: "PANW", color: "#004c9d" },
15+
{ id: "violet", label: "Violet", color: "#7c3aed" },
16+
{ id: "midnight", label: "Midnight", color: "#0284c7" },
17+
{ id: "indigo", label: "Indigo", color: "#6366f1" },
18+
{ id: "nord", label: "Nord", color: "#5e81ac" },
19+
{ id: "cyber", label: "Cyber", color: "#059669" },
20+
] as const;
21+
22+
type ThemeId = (typeof THEMES)[number]["id"];
23+
24+
const STORAGE_KEY = "openapi-demo-palette";
25+
const DEFAULT_PALETTE: ThemeId = "evergreen";
26+
27+
function applyPalette(id: ThemeId): void {
28+
let link = document.getElementById(
29+
"openapi-palette-link"
30+
) as HTMLLinkElement | null;
31+
if (!link) {
32+
link = document.createElement("link");
33+
link.id = "openapi-palette-link";
34+
link.rel = "stylesheet";
35+
document.head.appendChild(link);
36+
}
37+
link.href = `/themes/${id}.css`;
38+
localStorage.setItem(STORAGE_KEY, id);
39+
}
40+
41+
export default function PalettePicker({
42+
mobile,
43+
}: {
44+
mobile?: boolean;
45+
}): JSX.Element | null {
46+
const [open, setOpen] = useState(false);
47+
const [active, setActive] = useState<ThemeId>(DEFAULT_PALETTE);
48+
const containerRef = useRef<HTMLDivElement>(null);
49+
50+
// Read saved preference and apply on mount
51+
useEffect(() => {
52+
const saved =
53+
(localStorage.getItem(STORAGE_KEY) as ThemeId) ?? DEFAULT_PALETTE;
54+
setActive(saved);
55+
if (!document.getElementById("openapi-palette-link")) {
56+
applyPalette(saved);
57+
}
58+
}, []);
59+
60+
// Close on outside pointer-down
61+
useEffect(() => {
62+
function onPointerDown(e: PointerEvent) {
63+
if (
64+
containerRef.current &&
65+
!containerRef.current.contains(e.target as Node)
66+
) {
67+
setOpen(false);
68+
}
69+
}
70+
document.addEventListener("pointerdown", onPointerDown);
71+
return () => document.removeEventListener("pointerdown", onPointerDown);
72+
}, []);
73+
74+
// Close on Escape
75+
useEffect(() => {
76+
function onKeyDown(e: KeyboardEvent) {
77+
if (e.key === "Escape") setOpen(false);
78+
}
79+
document.addEventListener("keydown", onKeyDown);
80+
return () => document.removeEventListener("keydown", onKeyDown);
81+
}, []);
82+
83+
function select(id: ThemeId) {
84+
setActive(id);
85+
applyPalette(id);
86+
setOpen(false);
87+
}
88+
89+
// Hide in mobile nav drawer
90+
if (mobile) return null;
91+
92+
const current = THEMES.find((t) => t.id === active) ?? THEMES[0];
93+
94+
return (
95+
<div ref={containerRef} className={styles.root}>
96+
<button
97+
className={styles.trigger}
98+
onClick={() => setOpen((o) => !o)}
99+
aria-haspopup="listbox"
100+
aria-expanded={open}
101+
aria-label={`Color palette: ${current.label}`}
102+
>
103+
<span
104+
className={styles.swatch}
105+
style={{ background: current.color }}
106+
aria-hidden="true"
107+
/>
108+
<span className={styles.triggerLabel}>{current.label}</span>
109+
<svg
110+
className={`${styles.chevron} ${open ? styles.chevronOpen : ""}`}
111+
width="10"
112+
height="10"
113+
viewBox="0 0 10 10"
114+
fill="none"
115+
aria-hidden="true"
116+
>
117+
<path
118+
d="M2 3.5L5 6.5L8 3.5"
119+
stroke="currentColor"
120+
strokeWidth="1.5"
121+
strokeLinecap="round"
122+
strokeLinejoin="round"
123+
/>
124+
</svg>
125+
</button>
126+
127+
{open && (
128+
<div
129+
className={styles.dropdown}
130+
role="listbox"
131+
aria-label="Select color palette"
132+
>
133+
{THEMES.map((theme) => (
134+
<button
135+
key={theme.id}
136+
role="option"
137+
aria-selected={theme.id === active}
138+
className={`${styles.option} ${theme.id === active ? styles.optionActive : ""}`}
139+
onClick={() => select(theme.id)}
140+
>
141+
<span
142+
className={styles.swatch}
143+
style={{ background: theme.color }}
144+
aria-hidden="true"
145+
/>
146+
{theme.label}
147+
{theme.id === active && (
148+
<svg
149+
className={styles.check}
150+
width="12"
151+
height="12"
152+
viewBox="0 0 12 12"
153+
fill="none"
154+
aria-hidden="true"
155+
>
156+
<path
157+
d="M2 6L5 9L10 3"
158+
stroke="currentColor"
159+
strokeWidth="1.5"
160+
strokeLinecap="round"
161+
strokeLinejoin="round"
162+
/>
163+
</svg>
164+
)}
165+
</button>
166+
))}
167+
</div>
168+
)}
169+
</div>
170+
);
171+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
.root {
2+
position: relative;
3+
display: flex;
4+
align-items: center;
5+
}
6+
7+
.trigger {
8+
display: flex;
9+
align-items: center;
10+
gap: 6px;
11+
padding: 4px 10px 4px 8px;
12+
border: 1px solid var(--ifm-color-emphasis-300);
13+
border-radius: 6px;
14+
background: transparent;
15+
color: var(--ifm-color-content);
16+
cursor: pointer;
17+
font-size: 13px;
18+
font-weight: 500;
19+
font-family: inherit;
20+
height: 32px;
21+
line-height: 1;
22+
transition:
23+
background 150ms,
24+
border-color 150ms;
25+
white-space: nowrap;
26+
}
27+
28+
.trigger:hover {
29+
background: var(--ifm-color-emphasis-100);
30+
border-color: var(--ifm-color-emphasis-400);
31+
}
32+
33+
.swatch {
34+
width: 10px;
35+
height: 10px;
36+
border-radius: 50%;
37+
flex-shrink: 0;
38+
display: inline-block;
39+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
40+
}
41+
42+
.triggerLabel {
43+
/* label text — inherits trigger styles */
44+
}
45+
46+
.chevron {
47+
margin-left: 2px;
48+
flex-shrink: 0;
49+
color: var(--ifm-color-emphasis-600);
50+
transition: transform 150ms ease;
51+
}
52+
53+
.chevronOpen {
54+
transform: rotate(180deg);
55+
}
56+
57+
.dropdown {
58+
position: absolute;
59+
top: calc(100% + 8px);
60+
right: 0;
61+
z-index: 500;
62+
min-width: 160px;
63+
background: var(--ifm-background-color);
64+
border: 1px solid var(--ifm-color-emphasis-200);
65+
border-radius: 8px;
66+
box-shadow:
67+
0 4px 16px rgba(0, 0, 0, 0.12),
68+
0 1px 4px rgba(0, 0, 0, 0.08);
69+
padding: 4px;
70+
display: flex;
71+
flex-direction: column;
72+
}
73+
74+
.option {
75+
display: flex;
76+
align-items: center;
77+
gap: 8px;
78+
padding: 7px 10px;
79+
border: none;
80+
border-radius: 6px;
81+
background: transparent;
82+
color: var(--ifm-color-content);
83+
cursor: pointer;
84+
font-size: 13px;
85+
font-family: inherit;
86+
font-weight: 400;
87+
text-align: left;
88+
width: 100%;
89+
transition: background 100ms;
90+
}
91+
92+
.option:hover {
93+
background: var(--ifm-color-emphasis-100);
94+
}
95+
96+
.optionActive {
97+
font-weight: 600;
98+
}
99+
100+
.check {
101+
margin-left: auto;
102+
flex-shrink: 0;
103+
color: var(--ifm-color-primary);
104+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import ComponentTypes from "@theme-original/NavbarItem/ComponentTypes";
2+
3+
import PalettePicker from "@site/src/components/PalettePicker";
4+
5+
export default {
6+
...ComponentTypes,
7+
"custom-PalettePicker": PalettePicker,
8+
};

0 commit comments

Comments
 (0)