Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6628c6a

Browse files
author
Brijesh Bittu
committedMar 14, 2025·
Add theme selector menu
1 parent 4ccc94b commit 6628c6a

8 files changed

+292
-34
lines changed
 

‎docs/src/components/Alert.pigment.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const AlertRoot = styled.blockquote(({ theme }) => ({
1414
color: '$alertColor',
1515
'& p': {
1616
marginBottom: spacing(theme, 1),
17+
whiteSpace: 'normal',
1718
},
1819
variants: {
1920
variant: {

‎docs/src/components/Header.tsx

+17-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as React from 'react';
22
import NextLink from 'next/link';
33
import Image from 'next/image';
4+
import dynamic from 'next/dynamic';
45

56
import { NpmIcon } from 'docs/icons/NpmIcon';
67
import { GitHubIcon } from 'docs/icons/GitHubIcon';
7-
import { nav } from 'docs/nav';
8+
import { filteredNav } from 'docs/nav';
89
import {
910
headerButton,
1011
headerLink,
@@ -18,7 +19,9 @@ import {
1819
Root,
1920
} from './Header.pigment';
2021
import * as MobileNav from './MobileNav';
21-
import { ThemeSelector } from './ThemeSelector';
22+
import { navItem, navLink } from './MobileNav.pigment';
23+
24+
const ThemeSelector = dynamic(() => import('./ThemeSelector'));
2225

2326
export function Header() {
2427
return (
@@ -52,7 +55,7 @@ export function Header() {
5255
<MobileNav.Portal>
5356
<MobileNav.Backdrop />
5457
<MobileNav.Popup>
55-
{nav.map((section) => (
58+
{filteredNav.map((section) => (
5659
<MobileNav.Section key={section.label}>
5760
<MobileNav.Heading>{section.label}</MobileNav.Heading>
5861
<MobileNav.List>
@@ -81,6 +84,17 @@ export function Header() {
8184
</MobileNav.Item>
8285
</MobileNav.List>
8386
</MobileNav.Section>
87+
88+
<MobileNav.Section>
89+
<MobileNav.Heading>Site settings</MobileNav.Heading>
90+
<MobileNav.List>
91+
<li className={`${navItem}`}>
92+
<span className={`${navLink}`}>
93+
<ThemeSelector showLabel />
94+
</span>
95+
</li>
96+
</MobileNav.List>
97+
</MobileNav.Section>
8498
</MobileNav.Popup>
8599
</MobileNav.Portal>
86100
</MobileNav.Root>

‎docs/src/components/MobileNav.pigment.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { css, t } from '@pigment-css/react-new';
2+
23
import { applyText } from '../utils/theme';
34

45
export const backdrop = css`

‎docs/src/components/MobileNav.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export function Heading({ children, className, ...props }: React.ComponentProps<
168168
}
169169

170170
export function List({ className, ...props }: React.ComponentProps<'ul'>) {
171-
return <ul className={clsx('MobileNavList', className)} {...props} />;
171+
return <ul className={className} {...props} />;
172172
}
173173

174174
interface ItemProps extends React.ComponentPropsWithoutRef<'li'> {
+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { CheckIcon as LucideCheckIcon } from 'lucide-react';
2+
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
3+
import { css, styled, t } from '@pigment-css/react-new';
4+
5+
import { spacing } from 'docs/utils/theme';
6+
7+
const Trigger = styled(BaseMenu.Trigger)`
8+
box-sizing: border-box;
9+
display: flex;
10+
align-items: center;
11+
justify-content: center;
12+
gap: 0.375rem;
13+
height: 2.5rem;
14+
padding: 0 0.5rem;
15+
margin: 0;
16+
outline: 0;
17+
border-radius: 0.375rem;
18+
font-family: inherit;
19+
font-size: 1rem;
20+
font-weight: 500;
21+
line-height: 1.5rem;
22+
color: ${t('$color.gray.900')};
23+
user-select: none;
24+
25+
@media (hover: hover) {
26+
&:hover {
27+
background-color: ${t('$color.gray.100')};
28+
}
29+
}
30+
31+
&:active {
32+
background-color: ${t('$color.gray.100')};
33+
}
34+
35+
&[data-popup-open] {
36+
background-color: ${t('$color.gray.100')};
37+
}
38+
39+
&:focus-visible {
40+
outline: 2px solid ${t('$color.blue')};
41+
outline-offset: -1px;
42+
}
43+
`;
44+
45+
const Positioner = styled(BaseMenu.Positioner)`
46+
outline: 0;
47+
`;
48+
49+
const Popup = styled(BaseMenu.Popup)`
50+
box-sizing: border-box;
51+
padding-block: 0.25rem;
52+
border-radius: 0.375rem;
53+
background-color: canvas;
54+
color: ${t('$color.gray.900')};
55+
transform-origin: var(--transform-origin);
56+
transition:
57+
transform 150ms,
58+
opacity 150ms;
59+
60+
&[data-starting-style],
61+
&[data-ending-style] {
62+
opacity: 0;
63+
transform: scale(0.9);
64+
}
65+
66+
@media (prefers-color-scheme: light) {
67+
outline: 1px solid ${t('$color.gray.200')};
68+
box-shadow:
69+
0 10px 15px -3px ${t('$color.gray.200')},
70+
0 4px 6px -4px ${t('$color.gray.200')};
71+
}
72+
73+
@media (prefers-color-scheme: dark) {
74+
outline: 1px solid ${t('$color.gray.300')};
75+
outline-offset: -1px;
76+
}
77+
`;
78+
79+
const Arrow = styled(BaseMenu.Arrow)`
80+
display: flex;
81+
82+
&[data-side='top'] {
83+
bottom: -8px;
84+
rotate: 180deg;
85+
}
86+
87+
&[data-side='bottom'] {
88+
top: -8px;
89+
rotate: 0deg;
90+
}
91+
92+
&[data-side='left'] {
93+
right: -13px;
94+
rotate: 90deg;
95+
}
96+
97+
&[data-side='right'] {
98+
left: -13px;
99+
rotate: -90deg;
100+
}
101+
`;
102+
103+
export const CheckIcon = styled(LucideCheckIcon)`
104+
stroke: ${t('$color.green')};
105+
`;
106+
107+
export const RadioItem = styled(BaseMenu.RadioItem)`
108+
display: flex;
109+
align-items: center;
110+
gap: 0.25rem;
111+
outline: 0;
112+
cursor: default;
113+
user-select: none;
114+
padding-block: 0.5rem;
115+
padding-inline: 1rem;
116+
font-size: 0.875rem;
117+
line-height: 1rem;
118+
119+
&[data-highlighted] {
120+
z-index: 0;
121+
position: relative;
122+
color: ${t('$color.gray.50')};
123+
124+
${CheckIcon} {
125+
stroke: ${t('$color.background')};
126+
}
127+
}
128+
129+
&[data-highlighted]::before {
130+
content: '';
131+
z-index: -1;
132+
position: absolute;
133+
inset-block: 0;
134+
inset-inline: 0.25rem;
135+
border-radius: 0.25rem;
136+
background-color: ${t('$color.gray.900')};
137+
}
138+
`;
139+
140+
export const triggerIcon = css(({ theme }) => ({
141+
marginRight: spacing(theme, -0.25),
142+
}));
143+
144+
export const arrowIconCls = {
145+
fill: css`
146+
fill: canvas;
147+
`,
148+
outerStroke: css`
149+
@media (prefers-color-scheme: light) {
150+
fill: var(--color-gray-200);
151+
}
152+
`,
153+
innerStroke: css`
154+
@media (prefers-color-scheme: dark) {
155+
fill: var(--color-gray-300);
156+
}
157+
`,
158+
};
159+
160+
export const Menu = {
161+
...BaseMenu,
162+
Trigger,
163+
Positioner,
164+
Popup,
165+
Arrow,
166+
RadioItem,
167+
};

‎docs/src/components/SelectMenu.tsx

+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import * as React from 'react';
2+
import { ChevronDownIcon } from 'lucide-react';
3+
import { Menu, triggerIcon, arrowIconCls, CheckIcon } from './SelectMenu.pigment';
4+
5+
type MenuProps<T> = {
6+
children?: React.ReactNode;
7+
options: { label: string; value: T }[];
8+
value?: T | null;
9+
onChange?: (value: T) => void;
10+
openOnHover?: boolean;
11+
label?: string;
12+
};
13+
14+
function ArrowSvg(props: React.ComponentProps<'svg'>) {
15+
return (
16+
<svg width="20" height="10" viewBox="0 0 20 10" fill="none" {...props}>
17+
<path
18+
d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
19+
className={`${arrowIconCls.fill}`}
20+
/>
21+
<path
22+
d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
23+
className={`${arrowIconCls.outerStroke}`}
24+
/>
25+
<path
26+
d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
27+
className={`${arrowIconCls.innerStroke}`}
28+
/>
29+
</svg>
30+
);
31+
}
32+
33+
export function SelectMenu<T>({
34+
children,
35+
options,
36+
value,
37+
onChange,
38+
openOnHover = false,
39+
label,
40+
}: MenuProps<T>) {
41+
return (
42+
<Menu.Root openOnHover={openOnHover}>
43+
<Menu.Trigger aria-label={label}>
44+
{children} <ChevronDownIcon className={`${triggerIcon}`} size={12} />
45+
</Menu.Trigger>
46+
<Menu.Portal>
47+
<Menu.Positioner sideOffset={8}>
48+
<Menu.Popup>
49+
<Menu.Arrow>
50+
<ArrowSvg />
51+
</Menu.Arrow>
52+
<Menu.RadioGroup value={value as string} onValueChange={onChange}>
53+
{options.map((opt) => (
54+
<Menu.RadioItem
55+
closeOnClick
56+
key={opt.value as string | number}
57+
value={opt.value as string}
58+
>
59+
<span>{opt.label}</span>
60+
<Menu.RadioItemIndicator>
61+
<CheckIcon size={12} strokeWidth={3} />
62+
</Menu.RadioItemIndicator>
63+
</Menu.RadioItem>
64+
))}
65+
</Menu.RadioGroup>
66+
</Menu.Popup>
67+
</Menu.Positioner>
68+
</Menu.Portal>
69+
</Menu.Root>
70+
);
71+
}

‎docs/src/components/ThemeSelector.tsx

+28-24
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import * as React from 'react';
44
import { MonitorCog, SunMediumIcon, MoonIcon } from 'lucide-react';
55

6-
import { GhostButton } from './GhostButton';
6+
import { SelectMenu } from './SelectMenu';
77

88
type Mode = 'light' | 'dark' | 'system' | null;
99

@@ -12,53 +12,57 @@ const LS_KEY = 'mode';
1212
const MODE_FLOW: Record<
1313
Exclude<Mode, null>,
1414
{
15-
next: Exclude<Mode, null>;
16-
title: string;
15+
label: string;
1716
icon: typeof SunMediumIcon;
1817
}
1918
> = {
2019
light: {
21-
next: 'dark',
22-
title: 'Switch to dark mode',
23-
icon: MoonIcon,
20+
label: 'Light mode',
21+
icon: SunMediumIcon,
2422
},
2523
dark: {
26-
next: 'system',
27-
title: 'Switch to system mode',
28-
icon: MonitorCog,
24+
label: 'Dark mode',
25+
icon: MoonIcon,
2926
},
3027
system: {
31-
next: 'light',
32-
title: 'Switch to light mode',
33-
icon: SunMediumIcon,
28+
label: 'System mode',
29+
icon: MonitorCog,
3430
},
3531
};
3632

37-
export function ThemeSelector() {
33+
const OPTIONS: { value: Mode; label: string }[] = Object.entries(MODE_FLOW).map(([key, value]) => ({
34+
value: key as Mode,
35+
label: value.label,
36+
}));
37+
38+
export default function ThemeSelector({ showLabel = false }: { showLabel?: boolean }) {
3839
const [mode, setMode] = React.useState<Mode>(null);
39-
const {
40-
title,
41-
icon: Icon,
42-
next: nextMode,
43-
} = React.useMemo(() => MODE_FLOW[mode ?? 'system'], [mode]);
40+
const { icon: Icon } = React.useMemo(() => MODE_FLOW[mode ?? 'system'], [mode]);
4441

4542
React.useLayoutEffect(() => {
4643
setMode((window.localStorage.getItem(LS_KEY) as Mode) ?? 'system');
4744
}, []);
4845

49-
const handleModeChange = React.useCallback(() => {
46+
const handleModeChange = React.useCallback((nextMode: Mode) => {
5047
setMode(nextMode);
5148
if (nextMode === 'system') {
5249
window.localStorage.removeItem(LS_KEY);
53-
} else {
50+
} else if (nextMode) {
5451
window.localStorage.setItem(LS_KEY, nextMode);
5552
}
56-
document.documentElement.dataset.theme = nextMode;
57-
}, [nextMode]);
53+
document.documentElement.dataset.theme = nextMode ?? 'system';
54+
}, []);
5855

5956
return (
60-
<GhostButton title={title} aria-label={title} onClick={handleModeChange}>
57+
<SelectMenu
58+
label="Select theme"
59+
options={OPTIONS}
60+
value={mode}
61+
onChange={handleModeChange}
62+
openOnHover
63+
>
6164
{mode ? <Icon size={16} color="var(--color-foreground)" /> : null}
62-
</GhostButton>
65+
{showLabel && <span>Select Theme</span>}
66+
</SelectMenu>
6367
);
6468
}

‎docs/src/mdx-components.pigment.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,10 @@ export const Heading = styled('h1')(({ theme }) => ({
4848
fontWeight: t('$font.weight.medium'),
4949
},
5050
other: {
51-
three: {
52-
marginTop: spacing(theme, 8),
53-
marginBottom: spacing(theme, 1.5),
54-
scrollMarginTop: spacing(theme, 6),
55-
...applyText(theme, 'lg'),
56-
},
51+
marginTop: spacing(theme, 8),
52+
marginBottom: spacing(theme, 1.5),
53+
scrollMarginTop: spacing(theme, 6),
54+
...applyText(theme, 'lg'),
5755
},
5856
},
5957
},
@@ -65,11 +63,13 @@ export const Heading = styled('h1')(({ theme }) => ({
6563
export const Paragraph = styled.p(
6664
({ theme }) => `
6765
margin-bottom: ${spacing(theme, 4)};
66+
white-space: pre-line;
6867
`,
6968
);
7069

7170
export const Li = styled.li(({ theme }) => ({
7271
marginBottom: spacing(theme, 0.5),
72+
whiteSpace: 'pre-line',
7373
}));
7474

7575
export const List = styled.ul(({ theme }) => ({

0 commit comments

Comments
 (0)
Please sign in to comment.