@djangocfg/layouts 2.1.606 → 2.1.607
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/package.json +12 -12
- package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +17 -11
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +1 -1
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +1 -4
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +17 -3
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +21 -25
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +2 -2
- package/src/layouts/AuthLayout/styles/oauth.css +45 -0
- package/src/layouts/PrivateLayout/README.md +4 -2
- package/src/layouts/PrivateLayout/components/PrivateContent.test.tsx +65 -0
- package/src/layouts/PrivateLayout/components/PrivateContent.tsx +16 -9
- package/src/layouts/PrivateLayout/components/PrivateSidebarAccount.tsx +1 -1
- package/src/layouts/PrivateLayout/components/SidebarBody.tsx +1 -1
- package/src/layouts/PrivateLayout/components/SidebarBrand.tsx +2 -2
- package/src/layouts/PrivateLayout/components/SidebarNavGroup.tsx +5 -5
- package/src/layouts/PrivateLayout/components/SidebarNavItem.tsx +2 -2
- package/src/layouts/PrivateLayout/geometry.test.ts +32 -0
- package/src/layouts/PrivateLayout/geometry.ts +18 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.607",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"test": "vitest run"
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
|
-
"@djangocfg/analytics": "^2.1.
|
|
95
|
-
"@djangocfg/api": "^2.1.
|
|
96
|
-
"@djangocfg/devtools": "^2.1.
|
|
97
|
-
"@djangocfg/i18n": "^2.1.
|
|
98
|
-
"@djangocfg/ui-core": "^2.1.
|
|
94
|
+
"@djangocfg/analytics": "^2.1.607",
|
|
95
|
+
"@djangocfg/api": "^2.1.607",
|
|
96
|
+
"@djangocfg/devtools": "^2.1.607",
|
|
97
|
+
"@djangocfg/i18n": "^2.1.607",
|
|
98
|
+
"@djangocfg/ui-core": "^2.1.607",
|
|
99
99
|
"@hookform/resolvers": "^5.2.2",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^0.545.0",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"uuid": "^11.1.1"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@djangocfg/analytics": "^2.1.
|
|
129
|
-
"@djangocfg/api": "^2.1.
|
|
130
|
-
"@djangocfg/devtools": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
133
|
-
"@djangocfg/ui-core": "^2.1.
|
|
128
|
+
"@djangocfg/analytics": "^2.1.607",
|
|
129
|
+
"@djangocfg/api": "^2.1.607",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.607",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.607",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.607",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.607",
|
|
134
134
|
"@storybook/react-vite": "^10.5.0",
|
|
135
135
|
"@types/node": "^25.9.5",
|
|
136
136
|
"@types/react": "19.2.15",
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { memo, useCallback } from 'react';
|
|
3
|
+
import React, { memo, useCallback, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
import { isOAuthProviderId } from '@djangocfg/api/auth';
|
|
4
6
|
|
|
5
7
|
import { useAuthFormContext } from '../../context';
|
|
6
8
|
import { AuthDivider } from '../shared/AuthDivider';
|
|
7
9
|
import { OAuthButtons } from './buttons/OAuthButtons';
|
|
10
|
+
import { describeOAuthFailure } from './errors/errorCopy';
|
|
8
11
|
import { OAuthNotice } from './notice/OAuthNotice';
|
|
9
12
|
|
|
10
13
|
export interface OAuthSectionProps {
|
|
@@ -13,8 +16,8 @@ export interface OAuthSectionProps {
|
|
|
13
16
|
disabled?: boolean;
|
|
14
17
|
/** A provider sign-in that could not start: the sentence to show under the form field. */
|
|
15
18
|
onError?: (message: string) => void;
|
|
16
|
-
/** Called
|
|
17
|
-
|
|
19
|
+
/** Called when a notice appears whose only way forward is email: put the cursor in the email field. */
|
|
20
|
+
onFocusEmail: () => void;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -25,20 +28,23 @@ export interface OAuthSectionProps {
|
|
|
25
28
|
* the list loads it holds the room the buttons will take, so the email field
|
|
26
29
|
* does not jump; with no providers it renders nothing.
|
|
27
30
|
*/
|
|
28
|
-
function OAuthSectionRaw({ sourceUrl, disabled, onError,
|
|
31
|
+
function OAuthSectionRaw({ sourceUrl, disabled, onError, onFocusEmail }: OAuthSectionProps) {
|
|
29
32
|
const { oauth, oauthNotice, setOAuthNotice } = useAuthFormContext();
|
|
30
33
|
|
|
31
34
|
const dismissNotice = useCallback(() => setOAuthNotice(null), [setOAuthNotice]);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
|
|
36
|
+
// The email field is right below: when the notice says email is the way
|
|
37
|
+
// forward (nothing to retry), the cursor goes there instead of a button.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!oauthNotice) return;
|
|
40
|
+
const action = describeOAuthFailure(oauthNotice.code)?.action;
|
|
41
|
+
// A retry for a provider this client has no button for is email too.
|
|
42
|
+
if (action === 'email' || (action === 'retry' && !isOAuthProviderId(oauthNotice.provider))) onFocusEmail();
|
|
43
|
+
}, [oauthNotice, onFocusEmail]);
|
|
36
44
|
|
|
37
45
|
return (
|
|
38
46
|
<>
|
|
39
|
-
{oauthNotice &&
|
|
40
|
-
<OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} onUseEmail={useEmail} />
|
|
41
|
-
)}
|
|
47
|
+
{oauthNotice && <OAuthNotice failure={oauthNotice} sourceUrl={sourceUrl} onDismiss={dismissNotice} />}
|
|
42
48
|
|
|
43
49
|
{oauth.providers.length > 0 && (
|
|
44
50
|
<>
|
|
@@ -19,7 +19,7 @@ export const OAUTH_ERROR_KEYS = {
|
|
|
19
19
|
|
|
20
20
|
export type OAuthErrorKey = (typeof OAUTH_ERROR_KEYS)[keyof typeof OAUTH_ERROR_KEYS];
|
|
21
21
|
|
|
22
|
-
/** The
|
|
22
|
+
/** The next step a notice points at: the email field below (no button; focus moves there), a retry button, or nothing the visitor can do. */
|
|
23
23
|
export type OAuthNoticeAction = 'email' | 'retry' | 'none';
|
|
24
24
|
|
|
25
25
|
interface Rule {
|
|
@@ -12,12 +12,10 @@ export interface OAuthNoticeProps {
|
|
|
12
12
|
sourceUrl?: string;
|
|
13
13
|
/** Closes the notice (button or Escape). */
|
|
14
14
|
onDismiss: () => void;
|
|
15
|
-
/** Takes the visitor to the email field; the notice closes with it. */
|
|
16
|
-
onUseEmail: () => void;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
/** The failure notice with its retry wired to `useOAuthLogin`. */
|
|
20
|
-
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss
|
|
18
|
+
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss }: OAuthNoticeProps) {
|
|
21
19
|
const { start, isLoading } = useOAuthLogin({ sourceUrl });
|
|
22
20
|
const { provider } = failure;
|
|
23
21
|
|
|
@@ -33,7 +31,6 @@ function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoti
|
|
|
33
31
|
onRetry={isOAuthProviderId(provider) ? handleRetry : undefined}
|
|
34
32
|
retrying={isLoading}
|
|
35
33
|
onDismiss={onDismiss}
|
|
36
|
-
onUseEmail={onUseEmail}
|
|
37
34
|
/>
|
|
38
35
|
);
|
|
39
36
|
}
|
|
@@ -16,7 +16,7 @@ function render(props: Partial<OAuthNoticeViewProps> = {}) {
|
|
|
16
16
|
const host = document.createElement('div');
|
|
17
17
|
document.body.append(host);
|
|
18
18
|
const root = createRoot(host);
|
|
19
|
-
const handlers = { onDismiss: vi.fn(),
|
|
19
|
+
const handlers = { onDismiss: vi.fn(), onRetry: vi.fn() };
|
|
20
20
|
act(() => root.render(<OAuthNoticeView provider="github" code="invalid_state" {...handlers} {...props} />));
|
|
21
21
|
return { host, ...handlers, buttons: () => [...host.querySelectorAll('button')] };
|
|
22
22
|
}
|
|
@@ -37,10 +37,24 @@ describe('OAuthNoticeView', () => {
|
|
|
37
37
|
expect(onRetry).toHaveBeenCalledOnce();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it('offers
|
|
40
|
+
it('offers no retry when the provider has no button, and never a button for email', () => {
|
|
41
41
|
const { buttons } = render({ onRetry: undefined });
|
|
42
42
|
expect(buttons().some((b) => b.textContent!.includes('notice.retry'))).toBe(false);
|
|
43
|
-
expect(buttons().
|
|
43
|
+
expect(render({ code: 'link_not_allowed' }).buttons().map((b) => b.getAttribute('aria-label'))).toEqual([
|
|
44
|
+
'layouts.auth.oauth.notice.dismiss',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses one layout for every kind of notice: icon, text, close', () => {
|
|
49
|
+
for (const code of ['invalid_state', 'link_not_allowed', 'origin_not_allowed', 'rate_limited'] as const) {
|
|
50
|
+
const { host } = render({ code, origin: 'https://app.example.com' });
|
|
51
|
+
const notice = host.querySelector('.auth-oauth-notice')!;
|
|
52
|
+
expect(notice.querySelector('.auth-oauth-notice-icon')).not.toBeNull();
|
|
53
|
+
expect(notice.querySelector('.auth-oauth-notice-text .auth-oauth-notice-body')).not.toBeNull();
|
|
54
|
+
expect(notice.querySelector('.auth-oauth-notice-close')!.getAttribute('aria-label')).toBe(
|
|
55
|
+
'layouts.auth.oauth.notice.dismiss',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
44
58
|
});
|
|
45
59
|
|
|
46
60
|
it('closes with the dismiss button and with Escape', () => {
|
|
@@ -5,7 +5,7 @@ import React, { memo, useEffect } from 'react';
|
|
|
5
5
|
|
|
6
6
|
import type { OAuthFailureCode } from '@djangocfg/api/auth';
|
|
7
7
|
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
-
import {
|
|
8
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
9
9
|
|
|
10
10
|
import { describeOAuthFailure } from '../errors/errorCopy';
|
|
11
11
|
import { providerName } from '../providers/registry';
|
|
@@ -16,24 +16,25 @@ export interface OAuthNoticeViewProps {
|
|
|
16
16
|
code?: OAuthFailureCode;
|
|
17
17
|
/** The page origin, shown in the developer hint for `origin_not_allowed`. */
|
|
18
18
|
origin?: string;
|
|
19
|
-
/** Runs the provider again. Absent when this client has no button for the provider: the notice offers
|
|
19
|
+
/** Runs the provider again. Absent when this client has no button for the provider: the notice then offers no retry. */
|
|
20
20
|
onRetry?: () => void;
|
|
21
21
|
retrying?: boolean;
|
|
22
22
|
/** Closes the notice (button or Escape). */
|
|
23
23
|
onDismiss: () => void;
|
|
24
|
-
/** Takes the visitor to the email field; the notice closes with it. */
|
|
25
|
-
onUseEmail: () => void;
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
27
|
* What the sign-in form says after a provider round trip failed: one calm
|
|
30
|
-
* sentence, at most one
|
|
31
|
-
*
|
|
28
|
+
* sentence, at most one retry, and a way to close it. Not a dialog: the form
|
|
29
|
+
* underneath stays usable and focus stays where it was. The email field is
|
|
30
|
+
* already on the page, so the notice never offers a button for it.
|
|
32
31
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* One fixed layout for every code: icon | text | close, the close button
|
|
33
|
+
* centred on the first line. `role="status"` (polite): the visitor did nothing
|
|
34
|
+
* wrong, so it informs without interrupting. Renders nothing for a cancelled
|
|
35
|
+
* sign-in.
|
|
35
36
|
*/
|
|
36
|
-
function OAuthNoticeViewRaw({ provider, code, origin, onRetry, retrying = false, onDismiss
|
|
37
|
+
function OAuthNoticeViewRaw({ provider, code, origin, onRetry, retrying = false, onDismiss }: OAuthNoticeViewProps) {
|
|
37
38
|
const t = useAppT();
|
|
38
39
|
const copy = describeOAuthFailure(code);
|
|
39
40
|
|
|
@@ -48,25 +49,20 @@ function OAuthNoticeViewRaw({ provider, code, origin, onRetry, retrying = false,
|
|
|
48
49
|
if (!copy) return null;
|
|
49
50
|
|
|
50
51
|
const name = providerName(provider);
|
|
51
|
-
// A retry for a provider this client has no button for would go nowhere
|
|
52
|
-
const
|
|
52
|
+
// A retry for a provider this client has no button for would go nowhere.
|
|
53
|
+
const canRetry = copy.action === 'retry' && !!onRetry;
|
|
53
54
|
|
|
54
55
|
return (
|
|
55
|
-
<
|
|
56
|
-
<Info className="
|
|
57
|
-
<div>
|
|
58
|
-
{copy.titleKey && <
|
|
59
|
-
<
|
|
56
|
+
<div role="status" className="auth-oauth-notice">
|
|
57
|
+
<Info className="auth-oauth-notice-icon" aria-hidden="true" />
|
|
58
|
+
<div className="auth-oauth-notice-text">
|
|
59
|
+
{copy.titleKey && <p className="auth-oauth-notice-title">{t(copy.titleKey)}</p>}
|
|
60
|
+
<p className="auth-oauth-notice-body">{t(copy.bodyKey, { provider: name })}</p>
|
|
60
61
|
{copy.developerHintKey && origin && (
|
|
61
62
|
<p className="auth-oauth-hint">{t(copy.developerHintKey, { origin, id: provider })}</p>
|
|
62
63
|
)}
|
|
63
|
-
{
|
|
64
|
-
<Button type="button" size="sm" className="mt-3" onClick={
|
|
65
|
-
{t('layouts.auth.oauth.notice.useEmail')}
|
|
66
|
-
</Button>
|
|
67
|
-
)}
|
|
68
|
-
{action === 'retry' && (
|
|
69
|
-
<Button type="button" size="sm" className="mt-3" disabled={retrying} onClick={onRetry}>
|
|
64
|
+
{canRetry && (
|
|
65
|
+
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={retrying} onClick={onRetry}>
|
|
70
66
|
{t('layouts.auth.oauth.notice.retry', { provider: name })}
|
|
71
67
|
</Button>
|
|
72
68
|
)}
|
|
@@ -75,13 +71,13 @@ function OAuthNoticeViewRaw({ provider, code, origin, onRetry, retrying = false,
|
|
|
75
71
|
type="button"
|
|
76
72
|
variant="ghost"
|
|
77
73
|
size="icon"
|
|
78
|
-
className="
|
|
74
|
+
className="auth-oauth-notice-close"
|
|
79
75
|
aria-label={t('layouts.auth.oauth.notice.dismiss')}
|
|
80
76
|
onClick={onDismiss}
|
|
81
77
|
>
|
|
82
78
|
<X className="h-4 w-4" aria-hidden="true" />
|
|
83
79
|
</Button>
|
|
84
|
-
</
|
|
80
|
+
</div>
|
|
85
81
|
);
|
|
86
82
|
}
|
|
87
83
|
|
|
@@ -105,7 +105,7 @@ function IdentifierStepRaw() {
|
|
|
105
105
|
}
|
|
106
106
|
}, [oauth.isReady, hasOAuth]);
|
|
107
107
|
|
|
108
|
-
//
|
|
108
|
+
// A notice that points at email moves the cursor to the field.
|
|
109
109
|
const focusEmail = useCallback(() => emailRef.current?.focus(), []);
|
|
110
110
|
|
|
111
111
|
return (
|
|
@@ -119,7 +119,7 @@ function IdentifierStepRaw() {
|
|
|
119
119
|
/>
|
|
120
120
|
)}
|
|
121
121
|
|
|
122
|
-
<OAuthSection sourceUrl={sourceUrl} disabled={isLoading} onError={setError}
|
|
122
|
+
<OAuthSection sourceUrl={sourceUrl} disabled={isLoading} onError={setError} onFocusEmail={focusEmail} />
|
|
123
123
|
|
|
124
124
|
<form onSubmit={handleIdentifierSubmit} className="auth-form-group">
|
|
125
125
|
<Input
|
|
@@ -31,3 +31,48 @@
|
|
|
31
31
|
line-height: 1.4;
|
|
32
32
|
overflow-wrap: anywhere;
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/* The failure notice: icon | text | close, one layout for every code. Radius,
|
|
36
|
+
border and padding follow the provider button; colours are theme tokens. */
|
|
37
|
+
.auth-oauth-notice {
|
|
38
|
+
display: grid;
|
|
39
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
40
|
+
align-items: start;
|
|
41
|
+
column-gap: 0.75rem;
|
|
42
|
+
padding: 0.75rem 0.5rem 0.75rem 1rem;
|
|
43
|
+
border: 1px solid var(--border);
|
|
44
|
+
border-radius: var(--radius);
|
|
45
|
+
background: color-mix(in oklab, var(--muted) 50%, transparent);
|
|
46
|
+
font-size: 0.875rem;
|
|
47
|
+
line-height: 1.25rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The icon box is one text line tall, so it centres on the first line. */
|
|
51
|
+
.auth-oauth-notice-icon {
|
|
52
|
+
box-sizing: content-box;
|
|
53
|
+
width: 1rem;
|
|
54
|
+
height: 1rem;
|
|
55
|
+
padding-block: 0.125rem;
|
|
56
|
+
color: var(--auth-muted);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.auth-oauth-notice-title {
|
|
60
|
+
font-weight: 500;
|
|
61
|
+
color: var(--foreground);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.auth-oauth-notice-body {
|
|
65
|
+
color: var(--auth-muted);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.auth-oauth-notice-title + .auth-oauth-notice-body {
|
|
69
|
+
margin-top: 0.125rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* 32px hit area, centred on the first text line (20px) via the negative margin. */
|
|
73
|
+
.auth-oauth-notice-close {
|
|
74
|
+
width: 2rem;
|
|
75
|
+
height: 2rem;
|
|
76
|
+
margin-block: -0.375rem;
|
|
77
|
+
color: var(--auth-muted);
|
|
78
|
+
}
|
|
@@ -279,5 +279,7 @@ const header: HeaderConfig = {
|
|
|
279
279
|
| State | Behaviour |
|
|
280
280
|
|---|---|
|
|
281
281
|
| Docked | Width from `--sidebar-width`; drag the right edge, arrow keys, double-click to reset. |
|
|
282
|
-
| Collapsed (desktop) | Hidden. A trigger stays top-left; hovering
|
|
283
|
-
| Mobile (< md) | Drawer up to 22rem: 44px rows,
|
|
282
|
+
| Collapsed (desktop) | Hidden. A trigger stays top-left in a 48px top bar (in flow, so content never sits under it); hovering the trigger (or the free area around it) floats the sidebar over the page. The panel stays while the pointer is on it or a menu opened from it is open; Esc, navigation and moving away close it. Clicking the trigger docks it. |
|
|
283
|
+
| Mobile (< md) | Drawer up to 22rem: 44px rows, 14px text, 18px icons, 8px side padding, 12px vertical body padding, close button. The drawer header and footer respect the safe areas (at least 1rem). The trigger is a 40px ghost button in a 48px in-flow top bar (also for `contentPadding="none"` pages) that adds `env(safe-area-inset-top)`. |
|
|
284
|
+
|
|
285
|
+
Row metrics (`geometry.ts`): desktop rows 36px, mobile rows 44px (touch target), both `text-sm` with 16px (desktop) or 18px (mobile) icons and group labels in `text-xs`. Safe-area insets only exist when the host app sets `viewportFit: "cover"` in its Next.js `viewport` export; without it the offsets fall back to the fixed minimums.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { act } from 'react';
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { SidebarProvider } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
import { PrivateContent } from './PrivateContent';
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
let host: HTMLElement | null = null;
|
|
13
|
+
let root: Root | null = null;
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
window.matchMedia ??= ((query: string) => ({
|
|
17
|
+
matches: false,
|
|
18
|
+
media: query,
|
|
19
|
+
onchange: null,
|
|
20
|
+
addEventListener: () => {},
|
|
21
|
+
removeEventListener: () => {},
|
|
22
|
+
addListener: () => {},
|
|
23
|
+
removeListener: () => {},
|
|
24
|
+
dispatchEvent: () => false,
|
|
25
|
+
})) as typeof window.matchMedia;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
act(() => root?.unmount());
|
|
30
|
+
host?.remove();
|
|
31
|
+
host = root = null;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function render(defaultOpen: boolean, padding: 'none' | 'default') {
|
|
35
|
+
host = document.createElement('div');
|
|
36
|
+
document.body.appendChild(host);
|
|
37
|
+
root = createRoot(host);
|
|
38
|
+
act(() =>
|
|
39
|
+
root!.render(
|
|
40
|
+
<SidebarProvider defaultOpen={defaultOpen}>
|
|
41
|
+
<PrivateContent padding={padding} scroll={padding === 'none' ? 'hidden' : 'auto'}>
|
|
42
|
+
<p>page</p>
|
|
43
|
+
</PrivateContent>
|
|
44
|
+
</SidebarProvider>,
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
return host;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('PrivateContent top bar', () => {
|
|
51
|
+
it.each(['none', 'default'] as const)('reserves an in-flow, safe-area aware bar for the trigger (padding %s)', (padding) => {
|
|
52
|
+
const el = render(false, padding);
|
|
53
|
+
const bar = el.querySelector('[data-sidebar="topbar"]')!;
|
|
54
|
+
expect(bar).not.toBeNull();
|
|
55
|
+
expect(bar.className).toContain('env(safe-area-inset-top');
|
|
56
|
+
expect(bar.className).not.toMatch(/\babsolute\b/);
|
|
57
|
+
expect(bar.querySelector('[data-sidebar="trigger"]')).not.toBeNull();
|
|
58
|
+
// The page is a sibling after the bar, never underneath it.
|
|
59
|
+
expect(bar.nextElementSibling?.textContent).toBe('page');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('draws no bar while the sidebar is docked', () => {
|
|
63
|
+
expect(render(true, 'default').querySelector('[data-sidebar="topbar"]')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private layout main column: the scroll surface, plus the sidebar trigger
|
|
3
|
-
* whenever the sidebar is not docked (collapsed on desktop, always on mobile).
|
|
2
|
+
* Private layout main column: the scroll surface, plus a top bar holding the sidebar trigger
|
|
3
|
+
* whenever the sidebar is not docked (collapsed on desktop, always on mobile). The bar is in
|
|
4
|
+
* flow and safe-area aware, so no page content (padded or full-bleed) sits under the trigger.
|
|
4
5
|
* On desktop (fine pointer) the trigger sits in a hover zone that peeks the sidebar open after a short intent delay.
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -50,11 +51,12 @@ export function PrivateContent({
|
|
|
50
51
|
|
|
51
52
|
const showTrigger = hasSidebar && (isMobile || state === 'collapsed');
|
|
52
53
|
|
|
53
|
-
// The trigger
|
|
54
|
+
// The trigger lives in its own in-flow bar (below), so content never sits under it, with or without
|
|
55
|
+
// content padding. Without a bar the scroll surface starts at the top edge, so keep the bar's room only.
|
|
54
56
|
const topPadding = showTrigger
|
|
55
|
-
? 'pt-
|
|
57
|
+
? 'pt-2 sm:pt-4 lg:pt-4'
|
|
56
58
|
: hasSidebar
|
|
57
|
-
? '
|
|
59
|
+
? 'pt-6 lg:pt-8'
|
|
58
60
|
: 'pt-4 sm:pt-6 lg:pt-8';
|
|
59
61
|
|
|
60
62
|
const scrollAreaClass = cn(
|
|
@@ -73,11 +75,16 @@ export function PrivateContent({
|
|
|
73
75
|
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
|
74
76
|
{showTrigger ? (
|
|
75
77
|
<div
|
|
76
|
-
data-sidebar="
|
|
77
|
-
|
|
78
|
-
className="absolute left-0 top-[env(safe-area-inset-top,0px)] z-20 flex size-14 items-center justify-center"
|
|
78
|
+
data-sidebar="topbar"
|
|
79
|
+
className="relative z-20 flex h-12 shrink-0 box-content items-center bg-background pl-2 pt-[env(safe-area-inset-top,0px)]"
|
|
79
80
|
>
|
|
80
|
-
<
|
|
81
|
+
<div
|
|
82
|
+
data-sidebar="trigger-zone"
|
|
83
|
+
{...(isMobile || !peek?.hoverEnabled ? undefined : peek.zoneProps)}
|
|
84
|
+
className="flex size-12 items-center justify-center"
|
|
85
|
+
>
|
|
86
|
+
<PrivateSidebarTrigger className="text-muted-foreground hover:text-foreground" />
|
|
87
|
+
</div>
|
|
81
88
|
</div>
|
|
82
89
|
) : null}
|
|
83
90
|
<div className={scrollAreaClass}>
|
|
@@ -146,7 +146,7 @@ function PrivateSidebarAccountRaw({ header }: PrivateSidebarAccountProps) {
|
|
|
146
146
|
const expandedMeta = (
|
|
147
147
|
<>
|
|
148
148
|
<span className="flex min-w-0 items-baseline gap-1 text-left">
|
|
149
|
-
<span className="truncate text-
|
|
149
|
+
<span className="truncate text-sm text-sidebar-foreground">{account.displayName}</span>
|
|
150
150
|
{account.plan ? (
|
|
151
151
|
<span className="shrink-0 text-xs text-sidebar-foreground/50">· {account.plan}</span>
|
|
152
152
|
) : null}
|
|
@@ -39,7 +39,7 @@ export function SidebarBody() {
|
|
|
39
39
|
<div
|
|
40
40
|
data-sidebar="content"
|
|
41
41
|
className={cn(
|
|
42
|
-
'flex min-h-0 flex-1 flex-col
|
|
42
|
+
'flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden',
|
|
43
43
|
SIDEBAR_SPACING[isMobile ? 'mobile' : 'desktop'].body,
|
|
44
44
|
)}
|
|
45
45
|
>
|
|
@@ -38,7 +38,7 @@ function SidebarBrandRaw() {
|
|
|
38
38
|
<span
|
|
39
39
|
className={cn(
|
|
40
40
|
'flex shrink-0 items-center justify-center rounded-lg bg-sidebar-primary',
|
|
41
|
-
isMobile ? 'size-
|
|
41
|
+
isMobile ? 'size-8' : 'size-7',
|
|
42
42
|
)}
|
|
43
43
|
>
|
|
44
44
|
{header?.brandIcon ? (
|
|
@@ -49,7 +49,7 @@ function SidebarBrandRaw() {
|
|
|
49
49
|
</span>
|
|
50
50
|
)}
|
|
51
51
|
</span>
|
|
52
|
-
<span className={
|
|
52
|
+
<span className={'truncate text-base font-medium tracking-tight'}>
|
|
53
53
|
{brandTitle}
|
|
54
54
|
</span>
|
|
55
55
|
</Link>
|
|
@@ -29,8 +29,8 @@ import { SidebarNavItem } from './SidebarNavItem';
|
|
|
29
29
|
|
|
30
30
|
const groupClassFor = (inset: string) =>
|
|
31
31
|
cn(
|
|
32
|
-
`gap-0 ${inset} py-1`,
|
|
33
|
-
'[&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-
|
|
32
|
+
`gap-0 ${inset} py-1.5`,
|
|
33
|
+
'[&:not(:first-child)]:mt-1.5 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-sidebar-border/60 [&:not(:first-child)]:pt-3',
|
|
34
34
|
);
|
|
35
35
|
|
|
36
36
|
/** A `separated` group draws its own, stronger divider (below), so it drops the hairline and takes more air. */
|
|
@@ -43,7 +43,7 @@ function GroupDivider({ inset }: { inset: string }) {
|
|
|
43
43
|
role="separator"
|
|
44
44
|
aria-orientation="horizontal"
|
|
45
45
|
data-sidebar="group-divider"
|
|
46
|
-
className={cn(inset, 'mb-
|
|
46
|
+
className={cn(inset, 'mb-3 mt-3 h-px shrink-0 bg-sidebar-border')}
|
|
47
47
|
/>
|
|
48
48
|
);
|
|
49
49
|
}
|
|
@@ -98,7 +98,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
|
98
98
|
type="button"
|
|
99
99
|
aria-expanded={open}
|
|
100
100
|
className={cn(
|
|
101
|
-
'flex w-full items-center
|
|
101
|
+
'flex w-full items-center rounded-lg font-normal text-sidebar-foreground',
|
|
102
102
|
'transition-colors duration-150 hover:bg-sidebar-accent/60',
|
|
103
103
|
'outline-none ring-sidebar-ring focus-visible:ring-2',
|
|
104
104
|
geometry.item,
|
|
@@ -117,7 +117,7 @@ function SidebarNavGroupRaw({ group }: { group: SidebarGroupConfig }) {
|
|
|
117
117
|
</CollapsibleTrigger>
|
|
118
118
|
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
|
119
119
|
<SidebarGroupContent>
|
|
120
|
-
<SidebarMenu className="mt-
|
|
120
|
+
<SidebarMenu className="mt-1 gap-0.5">{rows}</SidebarMenu>
|
|
121
121
|
</SidebarGroupContent>
|
|
122
122
|
</CollapsibleContent>
|
|
123
123
|
</Collapsible>
|
|
@@ -66,7 +66,7 @@ function SidebarSettingsNavItem({ item, hideItemIcons }: { item: SidebarSettings
|
|
|
66
66
|
onClick={open}
|
|
67
67
|
aria-haspopup="dialog"
|
|
68
68
|
aria-current={active ? 'true' : undefined}
|
|
69
|
-
className={cn(ITEM_CLASS, geometry.item, hideItemIcons &&
|
|
69
|
+
className={cn(ITEM_CLASS, geometry.item, hideItemIcons && geometry.indent)}
|
|
70
70
|
>
|
|
71
71
|
<ItemContent item={item} hideItemIcons={hideItemIcons} />
|
|
72
72
|
</SidebarMenuButton>
|
|
@@ -83,7 +83,7 @@ function SidebarLinkNavItem({ item, hideItemIcons }: { item: SidebarLinkItem; hi
|
|
|
83
83
|
<SidebarMenuButton
|
|
84
84
|
asChild
|
|
85
85
|
isActive={active}
|
|
86
|
-
className={cn(ITEM_CLASS, geometry.item, hideItemIcons &&
|
|
86
|
+
className={cn(ITEM_CLASS, geometry.item, hideItemIcons && geometry.indent)}
|
|
87
87
|
>
|
|
88
88
|
<Link href={item.href} aria-current={active ? 'page' : undefined}>
|
|
89
89
|
<ItemContent item={item} hideItemIcons={hideItemIcons} />
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { NAV_GEOMETRY, SIDEBAR_SPACING } from './geometry';
|
|
4
|
+
|
|
5
|
+
const SIZES = /\btext-(base|lg|xl|2xl)\b/;
|
|
6
|
+
|
|
7
|
+
describe('sidebar geometry', () => {
|
|
8
|
+
for (const form of ['desktop', 'mobile'] as const) {
|
|
9
|
+
it(`${form}: rows use 14px text and a 16-18px icon, not a large font`, () => {
|
|
10
|
+
expect(NAV_GEOMETRY[form].item).toMatch(/\btext-sm\b/);
|
|
11
|
+
expect(NAV_GEOMETRY[form].item).not.toMatch(SIZES);
|
|
12
|
+
expect(NAV_GEOMETRY[form].item).toMatch(/\[&>svg\]:size-(4|\[18px\])/);
|
|
13
|
+
expect(NAV_GEOMETRY[form].label).toMatch(/\btext-xs\b/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it(`${form}: horizontal inset is tight (8px), vertical body padding is roomier`, () => {
|
|
17
|
+
expect(NAV_GEOMETRY[form].inset).toBe('px-2');
|
|
18
|
+
expect(NAV_GEOMETRY[form].item).toMatch(/\bpx-2\b/);
|
|
19
|
+
expect(SIDEBAR_SPACING[form].body).toMatch(/\bpy-3\b/);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it('mobile rows keep a 44px touch target; desktop rows are 36px', () => {
|
|
24
|
+
expect(NAV_GEOMETRY.mobile.item).toMatch(/\bh-11\b/);
|
|
25
|
+
expect(NAV_GEOMETRY.desktop.item).toMatch(/\bh-9\b/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('mobile header and footer never go under the status bar or home indicator', () => {
|
|
29
|
+
expect(SIDEBAR_SPACING.mobile.header).toContain('env(safe-area-inset-top');
|
|
30
|
+
expect(SIDEBAR_SPACING.mobile.footer).toContain('env(safe-area-inset-bottom');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -5,17 +5,21 @@
|
|
|
5
5
|
|
|
6
6
|
export const NAV_GEOMETRY = {
|
|
7
7
|
desktop: {
|
|
8
|
-
item: 'h-
|
|
9
|
-
label: 'h-7 px-2
|
|
8
|
+
item: 'h-9 gap-2.5 px-2 text-sm [&>svg]:size-4',
|
|
9
|
+
label: 'h-7 px-2 text-xs',
|
|
10
10
|
/** Horizontal inset of groups, slots and the divider; equals the header and footer inset. */
|
|
11
11
|
inset: 'px-2',
|
|
12
12
|
dividerInset: 'mx-2',
|
|
13
|
+
/** Left padding of a row without an icon, so its text lines up with the text of rows that have one (8 + 16 + 10px). */
|
|
14
|
+
indent: 'pl-[34px]',
|
|
13
15
|
},
|
|
14
16
|
mobile: {
|
|
15
|
-
item: 'h-11 gap-
|
|
16
|
-
label: 'h-8 px-
|
|
17
|
-
inset: 'px-
|
|
18
|
-
dividerInset: 'mx-
|
|
17
|
+
item: 'h-11 gap-2.5 px-2 text-sm [&>svg]:size-[18px]',
|
|
18
|
+
label: 'h-8 px-2 text-xs',
|
|
19
|
+
inset: 'px-2',
|
|
20
|
+
dividerInset: 'mx-2',
|
|
21
|
+
/** 8 + 18 + 10px. */
|
|
22
|
+
indent: 'pl-9',
|
|
19
23
|
},
|
|
20
24
|
} as const;
|
|
21
25
|
|
|
@@ -23,19 +27,19 @@ export type NavGeometry = (typeof NAV_GEOMETRY)[keyof typeof NAV_GEOMETRY];
|
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* One spacing scale for the three blocks of the sidebar (header, list, footer). On mobile every
|
|
26
|
-
* block
|
|
27
|
-
*
|
|
30
|
+
* block keeps tight sides (0.5rem rows, 0.75rem header) and roomier verticals, with the top and
|
|
31
|
+
* bottom edges safe-area aware (never less than 1rem).
|
|
28
32
|
*/
|
|
29
33
|
export const SIDEBAR_SPACING = {
|
|
30
34
|
desktop: {
|
|
31
|
-
header: '
|
|
32
|
-
body: 'py-
|
|
33
|
-
footer: '
|
|
35
|
+
header: 'pl-4 pr-2 pb-2 pt-3',
|
|
36
|
+
body: 'gap-1 py-3',
|
|
37
|
+
footer: 'px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]',
|
|
34
38
|
},
|
|
35
39
|
mobile: {
|
|
36
|
-
header: '
|
|
37
|
-
body: 'py-
|
|
38
|
-
footer: '
|
|
40
|
+
header: 'pl-4 pr-2 pb-3 pt-[max(1rem,env(safe-area-inset-top,0px))]',
|
|
41
|
+
body: 'gap-1 py-3',
|
|
42
|
+
footer: 'px-2 pt-3 pb-[max(1rem,env(safe-area-inset-bottom,0px))]',
|
|
39
43
|
},
|
|
40
44
|
} as const;
|
|
41
45
|
|