Skip to content

Commit ee8b3cf

Browse files
authored
feat: adds settingsMenu to admin navigation sidebar (#14139)
### What? Adds a new `admin.components.settingsMenu` configuration option that allows users to inject custom menu items into the navigation. When configured, a gear icon appears above the logout button in the navigation sidebar. Clicking the gear icon opens a popup menu displaying the custom components. <img width="130" height="177" alt="Screenshot 2025-10-14 at 11 43 18 AM" src="https://github.com/user-attachments/assets/5583e32d-cde3-45f0-9d58-a6724c12509f" /> <img width="174" height="239" alt="Screenshot 2025-10-14 at 11 43 37 AM" src="https://github.com/user-attachments/assets/5dca8ea7-ed99-4bc1-89c2-bc6da7d8f323" /> ### Why? Users need a way to add custom actions and menu items to the admin navigation without replacing the entire navigation component. This provides a standardized location for admin-level utilities and actions that don't fit into collection or global navigation. ### How? - Added `settingsMenu` property to `admin.components` config type definition - Created `GearIcon` component following the existing icon patterns - Exported `GearIcon` from the UI package exports - Implemented `SettingsMenuButton` client component that renders a popup menu with the gear icon trigger - Integrated `SettingsMenuButton` into `DefaultNav` component, positioned above the logout button - Updated import map generation to include `settingsMenu` components
1 parent 1077aa7 commit ee8b3cf

File tree

14 files changed

+267
-6
lines changed

14 files changed

+267
-6
lines changed

docs/custom-components/root-components.mdx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ The following options are available:
4545
| `header` | An array of Custom Components to be injected above the Payload header. [More details](#header). |
4646
| `logout.Button` | The button displayed in the sidebar that logs the user out. [More details](#logoutbutton). |
4747
| `Nav` | Contains the sidebar / mobile menu in its entirety. [More details](#nav). |
48+
| `settingsMenu` | An array of Custom Components to inject into a popup menu accessible via a gear icon above the logout button. [More details](#settingsMenu). |
4849
| `providers` | Custom [React Context](https://react.dev/learn/scaling-up-with-reducer-and-context) providers that will wrap the entire Admin Panel. [More details](./custom-providers). |
4950
| `views` | Override or create new views within the Admin Panel. [More details](./custom-views). |
5051

@@ -271,6 +272,65 @@ export default function MyAfterNavLinksComponent() {
271272
}
272273
```
273274

275+
### settingsMenu
276+
277+
The `settingsMenu` property allows you to inject Custom Components into a popup menu accessible via a gear icon in the navigation controls, positioned above the logout button. This is ideal for adding custom actions, utilities, or settings that are relevant at the admin level.
278+
279+
To add `settingsMenu` components, use the `admin.components.settingsMenu` property in your Payload Config:
280+
281+
```ts
282+
import { buildConfig } from 'payload'
283+
284+
export default buildConfig({
285+
// ...
286+
admin: {
287+
// highlight-start
288+
components: {
289+
settingsMenu: ['/path/to/your/component#ComponentName'],
290+
},
291+
// highlight-end
292+
},
293+
})
294+
```
295+
296+
Here is an example of a simple `settingsMenu` component:
297+
298+
```tsx
299+
'use client'
300+
import { PopupList } from '@payloadcms/ui'
301+
302+
export function MySettingsMenu() {
303+
return (
304+
<PopupList.ButtonGroup>
305+
<PopupList.Button onClick={() => console.log('Action triggered')}>
306+
Custom Action
307+
</PopupList.Button>
308+
<PopupList.Button onClick={() => window.open('/admin/custom-page')}>
309+
Custom Page
310+
</PopupList.Button>
311+
</PopupList.ButtonGroup>
312+
)
313+
}
314+
```
315+
316+
You can pass multiple components to create organized groups of menu items:
317+
318+
```ts
319+
import { buildConfig } from 'payload'
320+
321+
export default buildConfig({
322+
// ...
323+
admin: {
324+
components: {
325+
settingsMenu: [
326+
'/components/SystemActions#SystemActions',
327+
'/components/DataManagement#DataManagement',
328+
],
329+
},
330+
},
331+
})
332+
```
333+
274334
### Nav
275335

276336
The `Nav` property contains the sidebar / mobile menu in its entirety. Use this property to completely replace the built-in Nav with your own custom navigation.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@import '~@payloadcms/ui/scss';
2+
3+
@layer payload-default {
4+
.settings-menu-button {
5+
&.popup--h-align-left {
6+
.popup__content {
7+
left: calc(var(--nav-padding-inline-start) * -0.5);
8+
}
9+
}
10+
}
11+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client'
2+
import { GearIcon, Popup, useTranslation } from '@payloadcms/ui'
3+
import React, { Fragment } from 'react'
4+
5+
import './index.scss'
6+
7+
const baseClass = 'settings-menu-button'
8+
9+
export type SettingsMenuButtonProps = {
10+
settingsMenu?: React.ReactNode[]
11+
}
12+
13+
export const SettingsMenuButton: React.FC<SettingsMenuButtonProps> = ({ settingsMenu }) => {
14+
const { t } = useTranslation()
15+
16+
if (!settingsMenu || settingsMenu.length === 0) {
17+
return null
18+
}
19+
20+
return (
21+
<Popup
22+
button={<GearIcon ariaLabel={t('general:menu')} />}
23+
className={baseClass}
24+
horizontalAlign="left"
25+
id="settings-menu"
26+
size="small"
27+
verticalAlign="bottom"
28+
>
29+
{settingsMenu.map((item, i) => (
30+
<Fragment key={`settings-menu-item-${i}`}>{item}</Fragment>
31+
))}
32+
</Popup>
33+
)
34+
}

packages/next/src/elements/Nav/index.scss

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
border-right: 1px solid var(--theme-elevation-100);
1212
opacity: 0;
1313
overflow: hidden;
14+
--nav-padding-inline-start: var(--base);
15+
--nav-padding-inline-end: var(--base);
16+
--nav-padding-block-start: var(--app-header-height);
17+
--nav-padding-block-end: calc(var(--base) * 2);
1418

1519
[dir='rtl'] & {
1620
border-right: none;
@@ -51,7 +55,8 @@
5155
height: 100%;
5256
display: flex;
5357
flex-direction: column;
54-
padding: var(--app-header-height) base(1) base(2) base(1);
58+
padding: var(--nav-padding-block-start) var(--nav-padding-inline-end)
59+
var(--nav-padding-block-end) var(--nav-padding-inline-start);
5560
overflow-y: auto;
5661

5762
// remove the scrollbar here to prevent layout shift as nav groups are toggled
@@ -73,10 +78,13 @@
7378
}
7479

7580
&__controls {
81+
display: flex;
82+
flex-direction: column;
83+
gap: base(0.75);
7684
margin-top: auto;
7785
margin-bottom: 0;
7886

79-
> * {
87+
> :first-child {
8088
margin-top: base(1);
8189
}
8290

@@ -140,13 +148,15 @@
140148

141149
@include mid-break {
142150
&__scroll {
143-
padding: var(--app-header-height) base(0.5) base(2);
151+
--nav-padding-inline-start: calc(var(--base) * 0.5);
152+
--nav-padding-inline-end: calc(var(--base) * 0.5);
144153
}
145154
}
146155

147156
@include small-break {
148157
&__scroll {
149-
padding: var(--app-header-height) var(--gutter-h) base(2);
158+
--nav-padding-inline-start: var(--gutter-h);
159+
--nav-padding-inline-end: var(--gutter-h);
150160
}
151161

152162
&__link {

packages/next/src/elements/Nav/index.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import React from 'react'
88

99
import { NavHamburger } from './NavHamburger/index.js'
1010
import { NavWrapper } from './NavWrapper/index.js'
11+
import { SettingsMenuButton } from './SettingsMenuButton/index.js'
1112
import './index.scss'
1213

1314
const baseClass = 'nav'
@@ -40,7 +41,7 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
4041

4142
const {
4243
admin: {
43-
components: { afterNavLinks, beforeNavLinks, logout },
44+
components: { afterNavLinks, beforeNavLinks, logout, settingsMenu },
4445
},
4546
collections,
4647
globals,
@@ -92,6 +93,30 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
9293
},
9394
})
9495

96+
const renderedSettingsMenu =
97+
settingsMenu && Array.isArray(settingsMenu)
98+
? settingsMenu.map((item, index) =>
99+
RenderServerComponent({
100+
clientProps: {
101+
documentSubViewType,
102+
viewType,
103+
},
104+
Component: item,
105+
importMap: payload.importMap,
106+
key: `settings-menu-item-${index}`,
107+
serverProps: {
108+
i18n,
109+
locale,
110+
params,
111+
payload,
112+
permissions,
113+
searchParams,
114+
user,
115+
},
116+
}),
117+
)
118+
: []
119+
95120
return (
96121
<NavWrapper baseClass={baseClass}>
97122
<nav className={`${baseClass}__wrap`}>
@@ -130,7 +155,10 @@ export const DefaultNav: React.FC<NavProps> = async (props) => {
130155
user,
131156
},
132157
})}
133-
<div className={`${baseClass}__controls`}>{LogoutComponent}</div>
158+
<div className={`${baseClass}__controls`}>
159+
<SettingsMenuButton settingsMenu={renderedSettingsMenu} />
160+
{LogoutComponent}
161+
</div>
134162
</nav>
135163
<div className={`${baseClass}__header`}>
136164
<div className={`${baseClass}__header-content`}>

packages/payload/src/bin/generateImportMap/iterateConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export function iterateConfig({
5757
addToImportMap(config.admin?.components?.Nav)
5858
addToImportMap(config.admin?.components?.header)
5959
addToImportMap(config.admin?.components?.logout?.Button)
60+
addToImportMap(config.admin?.components?.settingsMenu)
6061
addToImportMap(config.admin?.components?.graphics?.Icon)
6162
addToImportMap(config.admin?.components?.graphics?.Logo)
6263

packages/payload/src/config/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,11 @@ export type Config = {
833833
* Wrap the admin dashboard in custom context providers
834834
*/
835835
providers?: PayloadComponent<{ children?: React.ReactNode }, { children?: React.ReactNode }>[]
836+
/**
837+
* Add custom menu items to the navigation menu accessible via the gear icon.
838+
* These components will be rendered in a popup menu above the logout button.
839+
*/
840+
settingsMenu?: CustomComponent[]
836841
/**
837842
* Replace or modify top-level admin routes, or add new ones:
838843
* + `Account` - `/admin/account`

packages/ui/src/exports/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ export { SearchIcon } from '../../icons/Search/index.js'
282282
export { SwapIcon } from '../../icons/Swap/index.js'
283283
export { XIcon } from '../../icons/X/index.js'
284284
export { FolderIcon } from '../../icons/Folder/index.js'
285+
export { GearIcon } from '../../icons/Gear/index.js'
285286
export { DocumentIcon } from '../../icons/Document/index.js'
286287
export { MoveFolderIcon } from '../../icons/MoveFolder/index.js'
287288
export { GridViewIcon } from '../../icons/GridView/index.js'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@layer payload-default {
2+
.gear {
3+
// Additional styling can be added here if needed
4+
}
5+
6+
.icon--gear {
7+
height: var(--base);
8+
width: var(--base);
9+
10+
path {
11+
stroke: currentColor;
12+
stroke-width: 1px;
13+
}
14+
}
15+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react'
2+
3+
import './index.scss'
4+
5+
const baseClass = 'gear'
6+
7+
export const GearIcon: React.FC<{
8+
ariaLabel?: string
9+
className?: string
10+
}> = ({ ariaLabel, className }) => (
11+
<div aria-label={ariaLabel} className={[className, baseClass].filter(Boolean).join(' ')}>
12+
<svg
13+
className="icon icon--gear"
14+
fill="none"
15+
height="20"
16+
viewBox="0 0 20 20"
17+
width="20"
18+
xmlns="http://www.w3.org/2000/svg"
19+
>
20+
<path
21+
d="M9.33337 8.84671L6.66671 4.22671M9.33337 11.1534L6.66671 15.7734M10 16.6667V15.3334M10 15.3334C12.9456 15.3334 15.3334 12.9456 15.3334 10C15.3334 7.05452 12.9456 4.66671 10 4.66671M10 15.3334C7.05452 15.3334 4.66671 12.9456 4.66671 10M10 3.33337V4.66671M10 4.66671C7.05452 4.66671 4.66671 7.05452 4.66671 10M11.3334 10H16.6667M11.3334 10C11.3334 10.7364 10.7364 11.3334 10 11.3334C9.26366 11.3334 8.66671 10.7364 8.66671 10C8.66671 9.26366 9.26366 8.66671 10 8.66671C10.7364 8.66671 11.3334 9.26366 11.3334 10ZM13.3334 15.7734L12.6667 14.62M13.3334 4.22671L12.6667 5.38004M3.33337 10H4.66671M15.7734 13.3334L14.62 12.6667M15.7734 6.66671L14.62 7.33337M4.22671 13.3334L5.38004 12.6667M4.22671 6.66671L5.38004 7.33337"
22+
strokeLinecap="round"
23+
strokeLinejoin="round"
24+
/>
25+
</svg>
26+
</div>
27+
)

0 commit comments

Comments
 (0)