@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.
- package/README.md +1 -1
- package/package.json +17 -17
- package/src/components/core/JsonLd.tsx +1 -1
- package/src/components/errors/ErrorsTracker/components/ErrorButtons.tsx +4 -7
- package/src/components/errors/ErrorsTracker/components/ErrorToast.tsx +3 -41
- package/src/components/errors/ErrorsTracker/index.ts +1 -4
- package/src/components/errors/ErrorsTracker/providers/ErrorTrackingProvider.tsx +40 -59
- package/src/components/errors/ErrorsTracker/types.ts +2 -53
- package/src/components/errors/ErrorsTracker/utils/curl-generator.ts +2 -2
- package/src/components/errors/ErrorsTracker/utils/formatters.ts +2 -29
- package/src/components/errors/README.md +1 -1
- package/src/configurator/auth/schema.ts +1 -1
- package/src/configurator/index.ts +1 -1
- package/src/configurator/private/schema.ts +1 -1
- package/src/layouts/AppLayout/BaseApp.tsx +25 -43
- package/src/layouts/AppLayout/README.md +1 -2
- package/src/layouts/AuthLayout/AuthLayout.tsx +6 -6
- package/src/layouts/AuthLayout/components/oauth/OAuthCallback.tsx +2 -1
- package/src/layouts/AuthLayout/context.tsx +0 -1
- package/src/layouts/AuthLayout/types.ts +6 -3
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +2 -7
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +1 -1
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +3 -8
- package/src/layouts/PrivateLayout/components/SidebarFeatured.tsx +10 -6
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +7 -7
- package/src/layouts/PublicLayout/components/UserMenu.tsx +0 -1
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +1 -1
- package/src/layouts/PublicLayout/hooks/useResponsiveOverflow.ts +13 -10
- package/src/layouts/PublicLayout/primitives/NavActions.tsx +7 -9
- package/src/layouts/PublicLayout/primitives/NavDesktopItems.tsx +1 -22
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +5 -1
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +7 -25
- package/src/layouts/types/index.ts +0 -1
- package/src/layouts/types/layout.types.ts +1 -4
- package/src/layouts/types/providers.types.ts +1 -14
- package/src/snippets/Breadcrumbs.tsx +3 -3
- package/src/testing/MockAuthFormProvider.tsx +0 -1
- package/src/utils/navItemsShallowEqual.ts +34 -0
- 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),
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 /
|
|
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
|
-
|
|
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
|
}
|
|
@@ -20,7 +20,7 @@ export type {
|
|
|
20
20
|
|
|
21
21
|
import type { AuthFormReturn } from '@djangocfg/api/auth';
|
|
22
22
|
|
|
23
|
-
import type { AuthBackgroundConfig, AuthShellVariant
|
|
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
|
-
/**
|
|
118
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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-
|
|
20
|
-
blue: 'bg-
|
|
21
|
-
amber: 'bg-
|
|
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-
|
|
27
|
-
blue: 'bg-
|
|
28
|
-
amber: 'bg-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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') {
|
|
@@ -58,17 +58,18 @@ export function useResponsiveOverflow({
|
|
|
58
58
|
const available = container.clientWidth;
|
|
59
59
|
if (available <= 0) return;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
}
|
|
65
|
+
return el ? Math.ceil(el.getBoundingClientRect().width) : 0;
|
|
66
|
+
});
|
|
66
67
|
|
|
67
68
|
// Try to fit all first.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)
|
|
265
|
-
}, [isTabletOrBelow,
|
|
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
|
|
@@ -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
|
|
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
|
|