Conversation
Add type definitions for extensible sidebar tab system. Tabs can be registered at config.admin.sidebar.tabs with icon, content component, and override/disable support. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Create default tab component for displaying collections and globals. This will be the built-in tab shown by default in sidebar. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Create function that returns built-in sidebar tabs. Currently just the collections tab, but more can be added later. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement config merging logic for sidebar tabs: - Combines built-in + user tabs - Merges by slug (override support) - Filters disabled tabs - Fully tested Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Ensure sidebar tab icons and components are included in import map generation so they can be resolved at runtime. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Load active tab from user preferences on mount and save changes. Ensures tab selection persists across sessions. - Add activeTab field to NavPreferences type - Update getNavPrefs to fetch sidebar preferences separately - Pass navPreferences to SidebarTabsClient - Save tab changes using setPreference hook Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Move sidebar.tabs from admin.sidebar to admin.components.sidebar to be consistent with other component slots - Update styles to be edge-to-edge like original DefaultNavClient - Remove horizontal padding from tabs and content - Add proper width and flex-grow to match nav layout Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Replace made-up CSS variables with actual design system: - Use base() SCSS function for spacing - Use --style-radius-l for border radius - Use --theme-border-color for borders - Use @include shadow-sm mixin - Use proper sr-only pattern for labels Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Use auto-fit grid with 24% minmax for responsive 4-column layout - Remove aspect-ratio for flexible tab sizing - Reduce padding on individual tabs to calc(var(--base) / 4) - Update border radius: container to --style-radius-m, tabs to --style-radius-s - Remove container padding, use gap only - Active tab border color to --theme-elevation-100 Result: 2-4 tabs fill width, 5+ tabs wrap to max 4 per row. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Create packages/payload/src/preferences/keys.ts with PREFERENCE_KEYS constants - Replace all preference key strings with imported constants - Rename sidebar preference key from 'sidebar' to 'nav-sidebar-active-tab' - Update all files using preferences to import PREFERENCE_KEYS This prevents typos and makes preference keys more discoverable and maintainable. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Remove CollectionsTab component (poorly named, showed collections AND globals) - Replace with DefaultNavClient throughout for consistency - Update Nav to fall back to DefaultNavClient when no custom tabs configured - Pass explicit props instead of spreading to SidebarTabs - Move test components from test/_community to test/admin - Add e2e tests for sidebar tabs functionality Tests cover: - Rendering custom sidebar tabs - Switching between tabs - Persisting active tab in preferences - Default nav tab behavior Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
📦 esbuild Bundle Analysis for payloadThis analysis was generated by esbuild-bundle-analyzer. 🤖
Largest pathsThese visualization shows top 20 largest paths in the bundle.Meta file: packages/next/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js
Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js
DetailsNext to the size is how much the size has increased or decreased compared with the base branch of this PR.
|
…links to DefaultNav
Co-authored-by: Cursor <cursoragent@cursor.com>
Allows custom components to be slotted in before the nav, and not specifically beforeNavLinks. `beforeNavLinks` semantically means before the links themselves, but for a PR like #15470, we will want to be able to place things outside of the nav links section. Think about the multi-tenant selector, you would not want that visible in just the first tab, but outside of the tabs - i.e. before the nav.
There was a problem hiding this comment.
Pull request overview
This PR adds a configurable sidebar tabs feature to the admin panel, enabling users to organize navigation content into custom tabs with icons and components. A default Collections tab is automatically included when custom tabs are configured.
Changes:
- Added sidebar tabs configuration with icon and component support
- Implemented tab state persistence via user preferences
- Created lazy-loading mechanism for tab content with loading states
Reviewed changes
Copilot reviewed 27 out of 28 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/uploads/int.spec.ts | Added initialization to uninitialized variable |
| test/admin/e2e/sidebar-tabs/e2e.spec.ts | New E2E test suite for sidebar tabs functionality |
| test/admin/config.ts | Added sidebar tabs configuration for testing |
| test/admin/components/CustomTab.tsx | Test component for custom tab content |
| test/admin/components/CustomIcon.tsx | Test component for custom tab icon |
| packages/ui/src/exports/client/index.ts | Exported Spinner component and types |
| packages/ui/src/elements/Tooltip/index.tsx | Increased tooltip delay from 350ms to 500ms |
| packages/ui/src/elements/Tooltip/index.scss | Removed light theme specific tooltip styles |
| packages/ui/src/elements/Spinner/index.tsx | New Spinner component implementation |
| packages/ui/src/elements/Spinner/index.scss | Spinner component styles |
| packages/payload/src/preferences/keys.ts | Added preference key for active sidebar tab |
| packages/payload/src/index.ts | Reorganized exports and moved baseAccountLockFields |
| packages/payload/src/config/types.ts | Added SidebarTab type definition and config options |
| packages/payload/src/admin/types.ts | Exported sidebar tab props types |
| packages/payload/src/admin/elements/Nav.ts | Added activeTab to NavPreferences and sidebar tab types |
| packages/next/src/utilities/handleServerFunctions.ts | Registered render-tab server function |
| packages/next/src/elements/Nav/index.tsx | Integrated SidebarTabs component into Nav |
| packages/next/src/elements/Nav/getNavPrefs.ts | Added sidebar tab preference retrieval |
| packages/next/src/elements/Nav/SidebarTabs/renderTabServerFn.ts | Server function for lazy-loading tab content |
| packages/next/src/elements/Nav/SidebarTabs/index.tsx | Server component for rendering sidebar tabs |
| packages/next/src/elements/Nav/SidebarTabs/index.scss | Sidebar tabs styles |
| packages/next/src/elements/Nav/SidebarTabs/index.client.tsx | Client component for tab interaction and state |
| packages/next/src/elements/Nav/SidebarTabs/constants.ts | Default nav tab slug constant |
| packages/next/src/elements/Nav/SidebarTabs/TabError/index.tsx | Error state component for failed tab loads |
| packages/next/src/elements/Nav/SidebarTabs/TabError/index.scss | Tab error component styles |
| packages/graphql/src/utilities/select.ts | Formatting improvements for if statements |
| CLAUDE.md | Added translation/label handling guideline |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const customTabContent = page.locator('text=Example folders tab content.') | ||
|
|
||
| await expect(customTabContent).toBeVisible() | ||
| await expect(customTabButton).toHaveClass(/sidebar-tabs__tab--active/) |
There was a problem hiding this comment.
The test expects an active CSS class but doesn't ensure the tab content is actually visible before checking the class. Consider adding await expect(customTabContent).toBeVisible() before the class assertion to ensure the UI state is fully rendered.
| const navPrefs = await req.payload | ||
| .find({ | ||
| collection: 'payload-preferences', | ||
| depth: 0, | ||
| limit: 1, | ||
| pagination: false, | ||
| req, | ||
| where: { | ||
| and: [ | ||
| { | ||
| key: { | ||
| equals: PREFERENCE_KEYS.NAV, | ||
| }, | ||
| }, | ||
| { | ||
| 'user.relationTo': { | ||
| equals: req.user.collection, | ||
| }, | ||
| }, | ||
| { | ||
| 'user.value': { | ||
| equals: req?.user?.id, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }) | ||
| ?.then((res) => res?.docs?.[0]?.value) | ||
|
|
||
| const sidebarPrefs = await req.payload | ||
| .find({ | ||
| collection: 'payload-preferences', | ||
| depth: 0, | ||
| limit: 1, | ||
| pagination: false, | ||
| req, | ||
| where: { | ||
| and: [ | ||
| { | ||
| key: { | ||
| equals: PREFERENCE_KEYS.NAV_SIDEBAR_ACTIVE_TAB, | ||
| }, | ||
| }, | ||
| { | ||
| 'user.relationTo': { | ||
| equals: req.user.collection, | ||
| }, | ||
| }, | ||
| }) | ||
| ?.then((res) => res?.docs?.[0]?.value) | ||
| : null | ||
| { | ||
| 'user.value': { | ||
| equals: req?.user?.id, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }) | ||
| ?.then((res) => res?.docs?.[0]?.value) |
There was a problem hiding this comment.
Two separate database queries are executed sequentially to fetch different preference keys. Consider combining these into a single query with an OR condition on the key field, or use Promise.all to parallelize the queries to reduce database round-trips.
| const preferredTabSlug = | ||
| navPreferences.activeTab || tabs.find((tab) => tab.isDefaultActive)?.slug || tabs[0]?.slug | ||
|
|
||
| // Verify the preferred tab actually exists, otherwise fall back to default or first tab | ||
| const activeTab = | ||
| tabs.find((t) => t.slug === preferredTabSlug) || |
There was a problem hiding this comment.
The fallback logic is duplicated between preferredTabSlug and activeTab. The preferredTabSlug variable is computed but then immediately validated again in activeTab. Simplify by computing activeTab directly without the intermediate preferredTabSlug variable.
| const preferredTabSlug = | |
| navPreferences.activeTab || tabs.find((tab) => tab.isDefaultActive)?.slug || tabs[0]?.slug | |
| // Verify the preferred tab actually exists, otherwise fall back to default or first tab | |
| const activeTab = | |
| tabs.find((t) => t.slug === preferredTabSlug) || | |
| const activeTab = | |
| tabs.find((t) => t.slug === navPreferences.activeTab) || |
| <div className={`${baseClass}__content`}> | ||
| <span>{message}</span> | ||
| <button className={`${baseClass}__retry`} onClick={onRetry} type="button"> | ||
| Refresh |
There was a problem hiding this comment.
The button text 'Refresh' is hardcoded in English. Consider using the translation system to support internationalization, consistent with other UI elements in the codebase.
Summary
Adds configurable sidebar tabs to the admin panel, allowing users to organize navigation content into custom tabs with icons and components.
Changes
admin.components.sidebar.tabsconfig - Configure custom tabs with icons and componentsConfiguration Example
Visual
CleanShot.2026-02-03.at.13.25.59.mp4
Related discussion