@djangocfg/layouts 2.1.541 → 2.1.543

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +17 -17
  3. package/src/components/core/JsonLd.tsx +1 -1
  4. package/src/components/errors/ErrorsTracker/components/ErrorButtons.tsx +4 -7
  5. package/src/components/errors/ErrorsTracker/components/ErrorToast.tsx +3 -41
  6. package/src/components/errors/ErrorsTracker/index.ts +1 -4
  7. package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +40 -59
  8. package/src/components/errors/ErrorsTracker/types.ts +2 -53
  9. package/src/components/errors/ErrorsTracker/utils/curl-generator.ts +2 -2
  10. package/src/components/errors/ErrorsTracker/utils/formatters.ts +2 -29
  11. package/src/components/errors/README.md +1 -1
  12. package/src/configurator/auth/schema.ts +1 -1
  13. package/src/configurator/index.ts +1 -1
  14. package/src/configurator/private/schema.ts +1 -1
  15. package/src/layouts/AppLayout/BaseApp.tsx +25 -43
  16. package/src/layouts/AppLayout/README.md +1 -2
  17. package/src/layouts/AuthLayout/AuthLayout.tsx +6 -6
  18. package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +2 -1
  19. package/src/layouts/AuthLayout/context.tsx +0 -1
  20. package/src/layouts/AuthLayout/types.ts +6 -3
  21. package/src/layouts/PrivateLayout/PrivateLayout.tsx +2 -7
  22. package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +1 -1
  23. package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +3 -8
  24. package/src/layouts/PrivateLayout/components/SidebarFeatured.tsx +10 -6
  25. package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +7 -7
  26. package/src/layouts/PublicLayout/components/UserMenu.tsx +0 -1
  27. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +1 -1
  28. package/src/layouts/PublicLayout/hooks/useResponsiveOverflow.ts +13 -10
  29. package/src/layouts/PublicLayout/primitives/NavActions.tsx +7 -9
  30. package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +1 -22
  31. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +5 -1
  32. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +7 -25
  33. package/src/layouts/types/index.ts +0 -1
  34. package/src/layouts/types/layout.types.ts +1 -4
  35. package/src/layouts/types/providers.types.ts +1 -14
  36. package/src/snippets/Breadcrumbs.tsx +3 -3
  37. package/src/testing/MockAuthFormProvider.tsx +0 -1
  38. package/src/utils/navItemsShallowEqual.ts +34 -0
  39. package/src/utils/shallowEqualBy.ts +29 -0
@@ -48,7 +48,7 @@ This is a *user-facing recovery* path. `ErrorsTracker` below is the
48
48
  the generated API client and runtime, then renders a Sonner toast per error.
49
49
 
50
50
  Tracked error types: `validation` (zod response mismatch), `network` (failed/4xx/5xx
51
- requests, incl. a CORS heuristic), `centrifugo` (WS), and `runtime` (uncaught JS).
51
+ requests, incl. a CORS heuristic), and `runtime` (uncaught JS).
52
52
 
53
53
  ```tsx
54
54
  import { ErrorTrackingProvider } from '@djangocfg/layouts'; // (or the module path)
@@ -6,7 +6,7 @@
6
6
  * - `authSplitSchema` / `authSplitUiSchema` — two-column card with sidebar
7
7
  *
8
8
  * Pair with `<JsonSchemaForm density="compact" schema={...} uiSchema={...}>`
9
- * from `@djangocfg/ui-tools` to get a ready-made sidebar configurator.
9
+ * from `@djangocfg/widget-forms` to get a ready-made sidebar configurator.
10
10
  */
11
11
 
12
12
  import type { CustomJsonSchema7, CustomJsonUiSchema7 } from '@djangocfg/ui-core/lib';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * JSON Schema descriptors for `<JsonSchemaForm>`-driven layout configurators.
3
3
  *
4
- * Drop a schema + uiSchema into `@djangocfg/ui-tools`'s `<JsonSchemaForm>` and
4
+ * Drop a schema + uiSchema into `@djangocfg/widget-forms`'s `<JsonSchemaForm>` and
5
5
  * you get a ready-made sidebar that edits the matching `<Layout>` props live.
6
6
  *
7
7
  * Public surface today:
@@ -8,7 +8,7 @@
8
8
  * - `header.*` → fields consumed by `HeaderConfig`
9
9
  *
10
10
  * Pair with `<JsonSchemaForm density="compact" schema={...} uiSchema={...}>`
11
- * from `@djangocfg/ui-tools` to get a ready-made sidebar configurator. The
11
+ * from `@djangocfg/widget-forms` to get a ready-made sidebar configurator. The
12
12
  * `defaultPrivateLayoutConfiguratorData` is a safe initial value that already
13
13
  * matches the schema (no nullable fields, no missing keys).
14
14
  *
@@ -15,7 +15,6 @@
15
15
  * - AuthProvider (authentication context)
16
16
  * - AuthDialog (global auth prompt dialog)
17
17
  * - AnalyticsProvider (Google Analytics, optional)
18
- * - CentrifugoProvider (WebSocket real-time, optional)
19
18
  * - PwaProvider (PWA installation, optional)
20
19
  * - ErrorTrackingProvider (error handling)
21
20
  * - ErrorBoundary (React error boundary, enabled by default)
@@ -49,9 +48,7 @@ import { SWRConfig } from 'swr';
49
48
 
50
49
  import { Devtools, devtools } from '@djangocfg/devtools';
51
50
  import { errorDetailToDevtoolsEvent } from '../../components/errors/ErrorsTracker';
52
- import { CfgCentrifugo } from '@djangocfg/api';
53
51
  import { AuthProvider } from '@djangocfg/api/auth';
54
- import { CentrifugoProvider } from '@djangocfg/centrifugo';
55
52
  import { UiProviders } from '@djangocfg/ui-core/providers';
56
53
  import { NextRouterAdapter, NextLinkProvider } from '@djangocfg/ui-core/adapters/nextjs';
57
54
  import { NextIntlLinkBridge } from './NextIntlLinkBridge';
@@ -119,7 +116,7 @@ function AppErrorFallback({
119
116
  * BaseApp - Core providers wrapper for any React/Next.js app
120
117
  *
121
118
  * Includes: ThemeProvider, TooltipProvider, SWRConfig, AuthProvider, AnalyticsProvider,
122
- * CentrifugoProvider, ErrorTrackingProvider, ErrorBoundary (optional)
119
+ * ErrorTrackingProvider, ErrorBoundary (optional)
123
120
  * Also renders global Toaster and PageProgress components
124
121
  */
125
122
  export function BaseApp({
@@ -128,7 +125,6 @@ export function BaseApp({
128
125
  theme,
129
126
  auth,
130
127
  analytics,
131
- centrifugo,
132
128
  errorTracking,
133
129
  errorBoundary,
134
130
  swr,
@@ -142,10 +138,6 @@ export function BaseApp({
142
138
  // ErrorBoundary is enabled by default
143
139
  const enableErrorBoundary = errorBoundary?.enabled !== false;
144
140
 
145
- // Centrifugo configuration
146
- const centrifugoUrl = centrifugo?.url || process.env.NEXT_PUBLIC_CENTRIFUGO_URL;
147
- const centrifugoEnabled = centrifugo?.enabled !== false;
148
-
149
141
  // Devtools — capture + ingest + debug panel in one component.
150
142
  // Project prop is the default; the devtools prop overrides field by field.
151
143
  const devtoolsProps = {
@@ -164,7 +156,7 @@ export function BaseApp({
164
156
  * (useNavigate, useQueryState, etc.) to next/navigation so
165
157
  * server components, route loaders, and prefetch fire
166
158
  * correctly. Placed near the top so every provider below
167
- * (Auth, Centrifugo, anything that may navigate) gets it.
159
+ * (Auth, anything that may navigate) gets it.
168
160
  */}
169
161
  <NextRouterAdapter>
170
162
  <NextLinkProvider>
@@ -192,41 +184,31 @@ export function BaseApp({
192
184
  >
193
185
  <AuthProvider config={authEnabled ? authConfig : undefined} enabled={authEnabled}>
194
186
  <AnalyticsProvider trackingId={analytics?.googleTrackingId}>
195
- <CentrifugoProvider
196
- enabled={centrifugoEnabled}
197
- autoConnect={centrifugoEnabled && centrifugo?.autoConnect}
198
- url={centrifugoUrl}
199
- onTokenRefresh={async () => {
200
- const result = await CfgCentrifugo.cfgCentrifugoAuthTokenRetrieve({ throwOnError: true });
201
- return result.data.token;
202
- }}
187
+ <ErrorTrackingProvider
188
+ validation={errorTracking?.validation}
189
+ cors={errorTracking?.cors}
190
+ network={errorTracking?.network}
191
+ onError={errorTracking?.onError}
192
+ onMonitorCapture={(d) => devtools.capture(errorDetailToDevtoolsEvent(d))}
203
193
  >
204
- <ErrorTrackingProvider
205
- validation={errorTracking?.validation}
206
- cors={errorTracking?.cors}
207
- network={errorTracking?.network}
208
- onError={errorTracking?.onError}
209
- onMonitorCapture={(d) => devtools.capture(errorDetailToDevtoolsEvent(d))}
210
- >
211
- <Devtools {...devtoolsProps} />
212
- <LayoutI18nProvider value={i18n}>
213
- {i18n?.routing ? (
214
- <NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
215
- ) : (
216
- children
217
- )}
218
- </LayoutI18nProvider>
219
- <NextTopLoader
220
- color="var(--primary)"
221
- height={3}
222
- showSpinner={false}
223
- shadow="0 0 10px var(--primary), 0 0 5px var(--primary)"
224
- />
194
+ <Devtools {...devtoolsProps} />
195
+ <LayoutI18nProvider value={i18n}>
196
+ {i18n?.routing ? (
197
+ <NextIntlLinkBridge routing={i18n.routing}>{children}</NextIntlLinkBridge>
198
+ ) : (
199
+ children
200
+ )}
201
+ </LayoutI18nProvider>
202
+ <NextTopLoader
203
+ color="var(--primary)"
204
+ height={3}
205
+ showSpinner={false}
206
+ shadow="0 0 10px var(--primary), 0 0 5px var(--primary)"
207
+ />
225
208
 
226
- {/* Auth Dialog - only when auth is enabled */}
227
- {authEnabled && <AuthDialog authPath={authConfig?.routes?.auth} />}
228
- </ErrorTrackingProvider>
229
- </CentrifugoProvider>
209
+ {/* Auth Dialog - only when auth is enabled */}
210
+ {authEnabled && <AuthDialog authPath={authConfig?.routes?.auth} />}
211
+ </ErrorTrackingProvider>
230
212
  </AnalyticsProvider>
231
213
  </AuthProvider>
232
214
  </SWRConfig>
@@ -25,7 +25,7 @@ export default function RootLayout({ children }) {
25
25
  theme={{ defaultTheme: 'system', storageKey: 'myapp-theme' }}
26
26
  auth={{ apiUrl: process.env.NEXT_PUBLIC_API_URL,
27
27
  routes: { auth: '/auth', defaultCallback: '/private' } }}
28
- // analytics / centrifugo / monitor / errorTracking / debug — all optional
28
+ // analytics / monitor / errorTracking / debug — all optional
29
29
  >
30
30
  {children}
31
31
  </BaseApp>
@@ -78,7 +78,6 @@ interface BaseAppProps {
78
78
  theme?: ThemeConfig; // defaultTheme, storageKey, style preset
79
79
  auth?: AuthConfig | false; // false = no AuthProvider (fully public app)
80
80
  analytics?: AnalyticsConfig; // Google Analytics
81
- centrifugo?: CentrifugoConfig;
82
81
  errorTracking?: ErrorTrackingConfig;
83
82
  errorBoundary?: ErrorBoundaryConfig; // enabled by default
84
83
  swr?: SWRConfigOptions;
@@ -104,7 +104,7 @@ export const AuthLayout: React.FC<AuthLayoutProps> = (props) => {
104
104
  * where a person stopped in the layout, and quietly becomes a no-op when an
105
105
  * app intentionally does not mount AnalyticsProvider.
106
106
  */
107
- const AuthFunnelTracker: React.FC = memo(() => {
107
+ const AuthFunnelTracker: React.FC = memo(function AuthFunnelTracker() {
108
108
  const { step } = useAuthFormContext();
109
109
  const { event, isEnabled } = useAnalytics();
110
110
  const lastStep = React.useRef<string | null>(null);
@@ -119,13 +119,13 @@ const AuthFunnelTracker: React.FC = memo(() => {
119
119
  });
120
120
 
121
121
  /** Renders custom children only on the identifier step — hides them on otp / 2fa / etc. */
122
- const AuthHeaderSlot: React.FC<{ children?: React.ReactNode }> = memo(({ children }) => {
122
+ const AuthHeaderSlot: React.FC<{ children?: React.ReactNode }> = memo(function AuthHeaderSlot({ children }) {
123
123
  const { step } = useAuthFormContext();
124
124
  if (!children || step !== 'identifier') return null;
125
125
  return <>{children}</>;
126
126
  });
127
127
 
128
- const AuthContent: React.FC = memo(() => {
128
+ const AuthContent: React.FC = memo(function AuthContent() {
129
129
  const { step } = useAuthFormContext();
130
130
 
131
131
  switch (step) {
@@ -145,7 +145,7 @@ const AuthContent: React.FC = memo(() => {
145
145
  }
146
146
  });
147
147
 
148
- const AuthSuccessOverlay: React.FC = memo(() => {
148
+ const AuthSuccessOverlay: React.FC = memo(function AuthSuccessOverlay() {
149
149
  const { step, logo, redirectUrl } = useAuthFormContext();
150
150
 
151
151
  if (step !== 'success') {
@@ -162,11 +162,11 @@ interface AuthSuccessInlineProps {
162
162
  redirectDelay?: number;
163
163
  }
164
164
 
165
- const AuthSuccess: React.FC<AuthSuccessInlineProps> = memo(({
165
+ const AuthSuccess: React.FC<AuthSuccessInlineProps> = memo(function AuthSuccess({
166
166
  logo,
167
167
  redirectUrl,
168
168
  redirectDelay = AUTH.REDIRECT_DELAY,
169
- }) => {
169
+ }) {
170
170
  const router = useCfgRouter();
171
171
  const { routes } = useAuth();
172
172
  const t = useAppT();
@@ -12,7 +12,8 @@ import {
12
12
  import { useAuthFormContext } from '../../context';
13
13
 
14
14
  export interface OAuthCallbackProps {
15
- onSuccess?: (user: any, isNewUser: boolean, provider: string) => void;
15
+ /** `user` is the provider callback's user payload, untyped upstream — narrow it before reading fields. */
16
+ onSuccess?: (user: unknown, isNewUser: boolean, provider: string) => void;
16
17
  onError?: (error: string) => void;
17
18
  redirectUrl?: string;
18
19
  }
@@ -67,7 +67,6 @@ export const AuthFormProvider: React.FC<AuthLayoutProps> = ({
67
67
  marketingConsentConfig: marketingConsent,
68
68
  rememberMeConfig: rememberMe,
69
69
  }),
70
- // eslint-disable-next-line react-hooks/exhaustive-deps
71
70
  [
72
71
  authForm,
73
72
  sourceUrl,
@@ -20,7 +20,7 @@ export type {
20
20
 
21
21
  import type { AuthFormReturn } from '@djangocfg/api/auth';
22
22
 
23
- import type { AuthBackgroundConfig, AuthShellVariant, AuthShellContextValue } from './shells';
23
+ import type { AuthBackgroundConfig, AuthShellVariant } from './shells';
24
24
 
25
25
  // Re-export shell types so consumers can import everything from ./types
26
26
  export type { AuthBackgroundConfig, AuthShellVariant, AuthShellContextValue } from './shells';
@@ -114,8 +114,11 @@ export interface AuthLayoutProps extends AuthLayoutConfig {
114
114
  onIdentifierSuccess?: (identifier: string) => void;
115
115
  /** Callback when OTP verification succeeds */
116
116
  onOTPSuccess?: () => void;
117
- /** Callback when OAuth succeeds */
118
- onOAuthSuccess?: (user: any, isNewUser: boolean, provider: string) => void;
117
+ /**
118
+ * Callback when OAuth succeeds. `user` is the provider callback's user
119
+ * payload, untyped upstream — narrow it before reading fields.
120
+ */
121
+ onOAuthSuccess?: (user: unknown, isNewUser: boolean, provider: string) => void;
119
122
  /** Callback on any error */
120
123
  onError?: (message: string) => void;
121
124
  }
@@ -8,22 +8,17 @@
8
8
 
9
9
  'use client';
10
10
 
11
- import React, { ReactNode } from 'react';
11
+ import React from 'react';
12
12
 
13
13
  import { Preloader } from '@djangocfg/ui-core/components';
14
14
  import { SidebarInset, SidebarProvider } from '@djangocfg/ui-core/components';
15
15
 
16
- import type { LayoutVisualConfig } from '../types';
17
16
  import { PrivateContent, PrivateSidebar } from './components';
18
17
  import { useAuthGuard } from './hooks';
19
18
  import { useLayoutVisual } from './hooks';
20
19
  import { useSidebarDefaultOpen } from './hooks';
21
20
 
22
- import type {
23
- HeaderConfig,
24
- PrivateLayoutProps,
25
- SidebarConfig,
26
- } from './types';
21
+ import type { PrivateLayoutProps } from './types';
27
22
 
28
23
  export type {
29
24
  SidebarItem,
@@ -219,7 +219,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
219
219
  // Always open UPWARD over the avatar (Claude pattern). The footer sits at the
220
220
  // bottom of the rail in both expanded and collapsed states, so `top` reads
221
221
  // correctly either way (vs. `right`, which floated off to the side).
222
- const dropdownSide: 'top' = 'top';
222
+ const dropdownSide = 'top' as const;
223
223
  const avatarClass = cn(
224
224
  'h-9 w-9 shrink-0 border border-transparent transition-colors',
225
225
  'group-hover:border-sidebar-border/70',
@@ -17,6 +17,8 @@ import { usePrivateLayoutContext } from '../context';
17
17
  import { useShellVisualState } from '../hooks';
18
18
  import { SidebarBrandSwitcher } from './SidebarBrandSwitcher';
19
19
 
20
+ const HEADER_ROW_CLASS = 'flex items-center gap-2 mb-2';
21
+
20
22
  function SidebarBrandRaw() {
21
23
  const { header, homeHref, brandTitle, brandMonogram, isMobile } =
22
24
  usePrivateLayoutContext();
@@ -39,14 +41,7 @@ function SidebarBrandRaw() {
39
41
 
40
42
  const customBrand = header?.brand;
41
43
 
42
- const headerRowClass = useMemo(
43
- () =>
44
- cn(
45
- 'flex items-center gap-2 mb-2',
46
- // content.showLabels ? 'px-2' : 'px-1.5',
47
- ),
48
- [content.showLabels],
49
- );
44
+ const headerRowClass = HEADER_ROW_CLASS;
50
45
 
51
46
  const expandedHeader = useMemo(
52
47
  () => (
@@ -15,17 +15,21 @@ import { LucideIcon } from '../../../components';
15
15
 
16
16
  import type { SidebarFeaturedConfig } from '../types';
17
17
 
18
+ // The accent names are the public prop's vocabulary; the status tokens they map
19
+ // to carry the meaning and already define both themes, so no `dark:` pair here.
18
20
  const ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
19
- green: 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 dark:bg-emerald-400/10 dark:hover:bg-emerald-400/15',
20
- blue: 'bg-sky-500/10 text-sky-700 hover:bg-sky-500/15 dark:text-sky-300 dark:bg-sky-400/10 dark:hover:bg-sky-400/15',
21
- amber: 'bg-amber-500/10 text-amber-800 hover:bg-amber-500/15 dark:text-amber-300 dark:bg-amber-400/10 dark:hover:bg-amber-400/15',
21
+ green: 'bg-success/10 text-success-foreground hover:bg-success/15',
22
+ blue: 'bg-info/10 text-info-foreground hover:bg-info/15',
23
+ amber: 'bg-warning/10 text-warning-foreground hover:bg-warning/15',
22
24
  primary: 'bg-primary/10 text-primary hover:bg-primary/15',
23
25
  };
24
26
 
27
+ // Solid fill, so the text is the on-fill contrast color — `*-foreground` is the
28
+ // tint for the soft surface above and fails contrast on the fill.
25
29
  const BADGE_ACCENT_CLASS: Record<NonNullable<SidebarFeaturedConfig['accent']>, string> = {
26
- green: 'bg-emerald-500 text-white dark:bg-emerald-400 dark:text-emerald-950',
27
- blue: 'bg-sky-500 text-white dark:bg-sky-400 dark:text-sky-950',
28
- amber: 'bg-amber-500 text-white dark:bg-amber-400 dark:text-amber-950',
30
+ green: 'bg-success text-on-success',
31
+ blue: 'bg-info text-on-info',
32
+ amber: 'bg-warning text-on-warning',
29
33
  primary: 'bg-primary text-primary-foreground',
30
34
  };
31
35
 
@@ -25,6 +25,7 @@ import { usePrivateLayoutContext } from '../context';
25
25
  import { useShellVisualState } from '../hooks';
26
26
  import type { SidebarGroupConfig } from '../types';
27
27
  import { SidebarNavItem } from './SidebarNavItem';
28
+ import { shallowEqualBy } from '../../../utils/shallowEqualBy';
28
29
 
29
30
  interface SidebarNavGroupProps {
30
31
  group: SidebarGroupConfig;
@@ -164,18 +165,17 @@ function groupShallowEqual(a: SidebarGroupConfig, b: SidebarGroupConfig): boolea
164
165
  a.icon === b.icon &&
165
166
  a.hideItemIcons === b.hideItemIcons &&
166
167
  a.dynamic === b.dynamic &&
167
- a.items.length === b.items.length &&
168
- a.items.every((ai, i) => {
169
- const bi = b.items[i];
170
- return (
168
+ shallowEqualBy(
169
+ a.items,
170
+ b.items,
171
+ (ai, bi) =>
171
172
  ai.href === bi.href &&
172
173
  ai.label === bi.label &&
173
174
  ai.icon === bi.icon &&
174
175
  ai.badge === bi.badge &&
175
176
  ai.badgeVariant === bi.badgeVariant &&
176
- ai.tooltip === bi.tooltip
177
- );
178
- })
177
+ ai.tooltip === bi.tooltip,
178
+ )
179
179
  );
180
180
  }
181
181
 
@@ -156,7 +156,6 @@ export function UserMenu({
156
156
 
157
157
  // Authenticated user
158
158
  const displayName = user.display_username || user.email || 'User';
159
- const userInitial = displayName.charAt(0).toUpperCase();
160
159
  const userAvatar = user.avatar || '';
161
160
 
162
161
  if (variant === 'mobile') {
@@ -6,7 +6,7 @@
6
6
 
7
7
  'use client';
8
8
 
9
- import React, { memo, useEffect, useMemo, useState } from 'react';
9
+ import React, { memo, useEffect, useState } from 'react';
10
10
  import { Laptop, Moon, Sun } from 'lucide-react';
11
11
 
12
12
  import { Button } from '@djangocfg/ui-core/components';
@@ -58,17 +58,18 @@ export function useResponsiveOverflow({
58
58
  const available = container.clientWidth;
59
59
  if (available <= 0) return;
60
60
 
61
- const widths: number[] = [];
62
- for (let i = 0; i < total; i++) {
61
+ // Built with `from` rather than an index loop so `widths` and `total` cannot
62
+ // disagree on length — the two reads below then need no bounds check.
63
+ const widths = Array.from({ length: total }, (_, i) => {
63
64
  const el = itemElsRef.current[i];
64
- widths.push(el ? Math.ceil(el.getBoundingClientRect().width) : 0);
65
- }
65
+ return el ? Math.ceil(el.getBoundingClientRect().width) : 0;
66
+ });
66
67
 
67
68
  // Try to fit all first.
68
- let usedAll = 0;
69
- for (let i = 0; i < total; i++) {
70
- usedAll += widths[i] + (i > 0 ? gap : 0);
71
- }
69
+ const usedAll = widths.reduce(
70
+ (sum, width, i) => sum + width + (i > 0 ? gap : 0),
71
+ 0,
72
+ );
72
73
  if (usedAll <= available) {
73
74
  setVisibleCount(total);
74
75
  setMeasured(true);
@@ -79,8 +80,10 @@ export function useResponsiveOverflow({
79
80
  const budget = available - moreWidth - gap;
80
81
  let used = 0;
81
82
  let fit = 0;
82
- for (let i = 0; i < total; i++) {
83
- const next = used + widths[i] + (i > 0 ? gap : 0);
83
+ // Iterating values, not indices: this loop breaks early, so `reduce` does
84
+ // not fit, and `entries()` still keeps each width tied to its position.
85
+ for (const [i, width] of widths.entries()) {
86
+ const next = used + width + (i > 0 ? gap : 0);
84
87
  if (next > budget) break;
85
88
  used = next;
86
89
  fit = i + 1;
@@ -10,6 +10,7 @@ import { UserMenu } from '../components/UserMenu';
10
10
  import type { UserMenuConfig } from '../../types';
11
11
 
12
12
  import { NavActionItem, type NavAction } from './NavActionItem';
13
+ import { shallowEqualBy } from '../../../utils/shallowEqualBy';
13
14
 
14
15
  interface NavActionsProps {
15
16
  userMenu?: UserMenuConfig;
@@ -133,18 +134,15 @@ function NavActionsRaw({
133
134
  * skip on reference churn from parent re-renders.
134
135
  */
135
136
  function actionsEqual(a?: NavAction[], b?: NavAction[]): boolean {
136
- if (a === b) return true;
137
- if (!a || !b) return false;
138
- if (a.length !== b.length) return false;
139
- return a.every((ai, i) => {
140
- const bi = b[i];
141
- return (
137
+ return shallowEqualBy(
138
+ a,
139
+ b,
140
+ (ai, bi) =>
142
141
  ai.label === bi.label &&
143
142
  ai.href === bi.href &&
144
143
  ai.variant === bi.variant &&
145
- ai.external === bi.external
146
- );
147
- });
144
+ ai.external === bi.external,
145
+ );
148
146
  }
149
147
 
150
148
  /**
@@ -8,6 +8,7 @@ import { Button, Link } from '@djangocfg/ui-core/components';
8
8
  import { cn } from '@djangocfg/ui-core/lib';
9
9
 
10
10
  import type { NavigationItem } from '../../types';
11
+ import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
11
12
  import type { UseDropdownMenuReturn } from '../hooks/useDropdownMenu';
12
13
  import { useResponsiveOverflow } from '../hooks/useResponsiveOverflow';
13
14
  import type { PublicDesktopDropdownRenderer } from '../navbarTypes';
@@ -317,28 +318,6 @@ function NavDesktopItemsRaw({
317
318
  );
318
319
  }
319
320
 
320
- /**
321
- * Shallow equality for NavigationItem arrays. Compares href, label, external
322
- * and one-level-deep sub-items so that nav label/badge changes re-render,
323
- * but object-reference churn from parent is ignored.
324
- */
325
- function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
326
- if (a === b) return true;
327
- if (a.length !== b.length) return false;
328
- return a.every((ai, i) => {
329
- const bi = b[i];
330
- if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
331
- if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
332
- if (ai.items) {
333
- return ai.items.every((sub, j) => {
334
- const bsub = bi.items![j];
335
- return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
336
- });
337
- }
338
- return true;
339
- });
340
- }
341
-
342
321
  /**
343
322
  * Memoised desktop navigation row. Re-renders only when:
344
323
  * - the items array content changes (via navItemsShallowEqual),
@@ -40,11 +40,15 @@ export interface MobileDrawerShellProps {
40
40
  };
41
41
  }
42
42
 
43
+ // Module-level so the "no navigation" case keeps one identity: a fresh `[]` per
44
+ // render would make `mobileNavigation`'s memo recompute on every render.
45
+ const NO_NAVIGATION: NavigationItem[] = [];
46
+
43
47
  export function MobileDrawerShell(props: MobileDrawerShellProps) {
44
48
  const context = usePublicLayoutOptional();
45
49
  const mobileMenuOpen = props.isOpen ?? context?.mobileMenuOpen ?? false;
46
50
  const closeMobileMenu = props.onClose ?? context?.closeMobileMenu ?? (() => {});
47
- const navigation = props.navigation ?? [];
51
+ const navigation = props.navigation ?? NO_NAVIGATION;
48
52
  const userMenu = props.userMenu;
49
53
 
50
54
  const { isAuthenticated, user, routes } = useAuth();
@@ -29,6 +29,7 @@ import { useIsTabletOrBelow } from '@djangocfg/ui-core/hooks';
29
29
  import { cn } from '@djangocfg/ui-core/lib';
30
30
 
31
31
  import { usePathnameWithoutLocale } from '../../../hooks';
32
+ import { navItemsShallowEqual } from '../../../utils/navItemsShallowEqual';
32
33
  import { LocaleSwitcher } from '../../_components/LocaleSwitcher';
33
34
  import { useLayoutI18nOptional } from '../../AppLayout/LayoutI18nProvider';
34
35
  import { usePublicLayoutOptional } from '../context';
@@ -259,10 +260,13 @@ function NavbarShellRaw(props: NavbarShellProps) {
259
260
  // this being present — same contract `NavControls` applies internally.
260
261
  const i18n = useLayoutI18nOptional();
261
262
 
262
- // Close dropdowns when switching to mobile
263
+ // Close dropdowns when switching to mobile. Depends on `closeDropdown` alone,
264
+ // not on `dropdown`: the object changes identity whenever a menu opens, and
265
+ // depending on it would close the dropdown the moment it is opened.
266
+ const { closeDropdown } = dropdown;
263
267
  useEffect(() => {
264
- if (isTabletOrBelow) dropdown.closeDropdown();
265
- }, [isTabletOrBelow, dropdown.closeDropdown]);
268
+ if (isTabletOrBelow) closeDropdown();
269
+ }, [isTabletOrBelow, closeDropdown]);
266
270
 
267
271
  // Sync navbar surface into context
268
272
  const setNavbarSurface = context?.setNavbarSurface;
@@ -417,28 +421,6 @@ function NavbarShellRaw(props: NavbarShellProps) {
417
421
  );
418
422
  }
419
423
 
420
- /**
421
- * Shallow equality for NavigationItem arrays. Compares href, label, external
422
- * and one-level-deep sub-items so that nav label/badge changes re-render,
423
- * but object-reference churn from parent is ignored.
424
- */
425
- function navItemsShallowEqual(a: NavigationItem[], b: NavigationItem[]): boolean {
426
- if (a === b) return true;
427
- if (a.length !== b.length) return false;
428
- return a.every((ai, i) => {
429
- const bi = b[i];
430
- if (ai.href !== bi.href || ai.label !== bi.label || ai.external !== bi.external) return false;
431
- if ((ai.items?.length ?? 0) !== (bi.items?.length ?? 0)) return false;
432
- if (ai.items) {
433
- return ai.items.every((sub, j) => {
434
- const bsub = bi.items![j];
435
- return sub.href === bsub.href && sub.label === bsub.label && sub.external === bsub.external;
436
- });
437
- }
438
- return true;
439
- });
440
- }
441
-
442
424
  /**
443
425
  * Memoised navbar orchestrator. Re-renders only when any config prop changes
444
426
  * (variant, position, nav items, actions, slots, etc.). The heavy inner JSX
@@ -14,7 +14,6 @@ export type {
14
14
  ThemeConfig,
15
15
  ThemeStylePresetId,
16
16
  SWRConfigOptions,
17
- CentrifugoConfig,
18
17
  } from './providers.types';
19
18
 
20
19
  // External provider configs (re-export for convenience)
@@ -21,7 +21,7 @@ import type { ErrorBoundaryConfig, ErrorTrackingConfig } from '../../components/
21
21
  export type NextIntlRouting = object & { __nextIntlRouting?: never };
22
22
 
23
23
  // Import local provider configs
24
- import type { ThemeConfig, SWRConfigOptions, CentrifugoConfig } from './providers.types';
24
+ import type { ThemeConfig, SWRConfigOptions } from './providers.types';
25
25
  import type { DevtoolsProps } from '@djangocfg/devtools';
26
26
 
27
27
  // ============================================================================
@@ -49,9 +49,6 @@ export interface BaseLayoutProps {
49
49
  /** Analytics configuration (from snippets/Analytics) */
50
50
  analytics?: AnalyticsConfig;
51
51
 
52
- /** Centrifugo configuration */
53
- centrifugo?: CentrifugoConfig;
54
-
55
52
  /** Error tracking configuration (from components/errors) */
56
53
  errorTracking?: ErrorTrackingConfig;
57
54