@djangocfg/layouts 2.1.605 → 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/AuthLayout.tsx +1 -0
- package/src/layouts/AuthLayout/README.md +20 -3
- package/src/layouts/AuthLayout/components/oauth/OAuthSection.tsx +60 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.test.tsx +52 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtonList.tsx +49 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthButtons.tsx +44 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.test.tsx +56 -0
- package/src/layouts/AuthLayout/components/oauth/buttons/OAuthProviderButton.tsx +51 -0
- package/src/layouts/AuthLayout/components/oauth/{OAuthCallback.tsx → callback/OAuthCallback.tsx} +7 -33
- package/src/layouts/AuthLayout/components/oauth/callback/OAuthCallbackOverlay.tsx +41 -0
- package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.test.ts → callback/callbackParams.test.ts} +1 -1
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.test.ts +63 -0
- package/src/layouts/AuthLayout/components/oauth/errors/errorCopy.ts +95 -0
- package/src/layouts/AuthLayout/components/oauth/errors/useOAuthErrorText.ts +25 -0
- package/src/layouts/AuthLayout/components/oauth/index.ts +29 -4
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNotice.tsx +38 -0
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.test.tsx +78 -0
- package/src/layouts/AuthLayout/components/oauth/notice/OAuthNoticeView.tsx +84 -0
- package/src/layouts/AuthLayout/components/oauth/{GithubIcon.tsx → providers/icons/GithubIcon.tsx} +0 -1
- package/src/layouts/AuthLayout/components/oauth/{GoogleIcon.tsx → providers/icons/GoogleIcon.tsx} +1 -1
- package/src/layouts/AuthLayout/components/oauth/providers/index.ts +2 -0
- package/src/layouts/AuthLayout/components/oauth/providers/registry.test.ts +23 -0
- package/src/layouts/AuthLayout/components/oauth/providers/registry.ts +32 -0
- package/src/layouts/AuthLayout/components/oauth/{oauthProviders.test.ts → providers/resolveProviders.test.ts} +1 -1
- package/src/layouts/AuthLayout/components/shared/AuthButton.tsx +1 -2
- package/src/layouts/AuthLayout/components/steps/IdentifierStep.tsx +4 -26
- package/src/layouts/AuthLayout/context.tsx +1 -1
- package/src/layouts/AuthLayout/styles/auth.css +3 -70
- package/src/layouts/AuthLayout/styles/oauth.css +78 -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/src/testing/MockAuthFormProvider.tsx +1 -1
- package/src/layouts/AuthLayout/components/oauth/OAuthButtons.tsx +0 -82
- package/src/layouts/AuthLayout/components/oauth/OAuthNotice.tsx +0 -94
- package/src/layouts/AuthLayout/components/oauth/oauthErrors.ts +0 -23
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.test.ts +0 -36
- package/src/layouts/AuthLayout/components/oauth/oauthNoticeCopy.ts +0 -86
- /package/src/layouts/AuthLayout/components/oauth/{oauthCallbackParams.ts → callback/callbackParams.ts} +0 -0
- /package/src/layouts/AuthLayout/components/oauth/{oauthProviders.ts → providers/resolveProviders.ts} +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider sign-in (components/oauth). The buttons themselves are theme-token
|
|
3
|
+
* utilities in `OAuthProviderButton`; this file holds only what a utility
|
|
4
|
+
* cannot: the column, the room held while the provider list loads, and the
|
|
5
|
+
* developer hint. Sizes are tokens so the reserve and the buttons agree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
/* A provider button is at least this tall (above the 44px touch minimum). */
|
|
10
|
+
--auth-oauth-height: 3rem;
|
|
11
|
+
--auth-oauth-gap: 0.75rem;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.auth-oauth {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: var(--auth-oauth-gap);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Holds the room of two buttons, the gap and the divider while the server's
|
|
21
|
+
provider list loads, so the email field does not jump when it arrives. */
|
|
22
|
+
.auth-oauth-reserve {
|
|
23
|
+
height: calc(2 * var(--auth-oauth-height) + var(--auth-oauth-gap) + var(--auth-gap) + 1rem);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* The line for whoever deploys the site, under a notice about configuration. */
|
|
27
|
+
.auth-oauth-hint {
|
|
28
|
+
margin-top: 0.5rem;
|
|
29
|
+
color: var(--auth-muted);
|
|
30
|
+
font-size: 0.75rem;
|
|
31
|
+
line-height: 1.4;
|
|
32
|
+
overflow-wrap: anywhere;
|
|
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
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from '@djangocfg/api/auth';
|
|
12
12
|
|
|
13
13
|
import { AuthFormContext } from '../layouts/AuthLayout/context';
|
|
14
|
-
import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/
|
|
14
|
+
import { resolveOAuthProviders } from '../layouts/AuthLayout/components/oauth/providers/resolveProviders';
|
|
15
15
|
import type { AuthFormContextType, AuthLayoutConfig, OAuthFailure } from '../layouts/AuthLayout/types';
|
|
16
16
|
|
|
17
17
|
// Storybook-only provider. Reproduces the AuthFormContext shape with
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import React, { memo, useCallback, useMemo } from 'react';
|
|
4
|
-
|
|
5
|
-
import { type OAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
|
|
6
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
|
|
8
|
-
import { AuthButton, type AuthButtonProps } from '../shared';
|
|
9
|
-
import { GithubIcon } from './GithubIcon';
|
|
10
|
-
import { GoogleIcon } from './GoogleIcon';
|
|
11
|
-
import { useOAuthErrorText } from './oauthErrors';
|
|
12
|
-
|
|
13
|
-
interface OAuthProviderEntry {
|
|
14
|
-
Icon: React.FC;
|
|
15
|
-
variant: NonNullable<AuthButtonProps['variant']>;
|
|
16
|
-
labelKey: 'layouts.auth.identifier.oauth.github' | 'layouts.auth.identifier.oauth.google';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** One entry per provider the client can run; keyed by the API package's id type, so a new provider fails typecheck until it has a button. */
|
|
20
|
-
export const OAUTH_PROVIDER_UI: Record<OAuthProviderId, OAuthProviderEntry> = {
|
|
21
|
-
github: { Icon: GithubIcon, variant: 'secondary', labelKey: 'layouts.auth.identifier.oauth.github' },
|
|
22
|
-
google: { Icon: GoogleIcon, variant: 'google', labelKey: 'layouts.auth.identifier.oauth.google' },
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export interface OAuthButtonsProps {
|
|
26
|
-
providers: readonly OAuthProviderId[];
|
|
27
|
-
sourceUrl?: string;
|
|
28
|
-
/** Disables every button, e.g. while the email form is submitting. */
|
|
29
|
-
disabled?: boolean;
|
|
30
|
-
onError?: (message: string) => void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* The provider buttons: same size and weight each, progress only on the one
|
|
35
|
-
* clicked, the rest disabled while its flow is in flight.
|
|
36
|
-
*/
|
|
37
|
-
function OAuthButtonsRaw({ providers, sourceUrl, disabled, onError }: OAuthButtonsProps) {
|
|
38
|
-
const t = useAppT();
|
|
39
|
-
const errorText = useOAuthErrorText();
|
|
40
|
-
|
|
41
|
-
const { start, loadingProvider, isLoading } = useOAuthLogin({
|
|
42
|
-
sourceUrl,
|
|
43
|
-
onError: (message, code) => onError?.(errorText(code, message)),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
const labels = useMemo(
|
|
47
|
-
() => Object.fromEntries(providers.map((id) => [id, t(OAUTH_PROVIDER_UI[id].labelKey)])) as Record<string, string>,
|
|
48
|
-
[providers, t],
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const handleClick = useCallback(
|
|
52
|
-
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
53
|
-
const id = event.currentTarget.dataset.provider as OAuthProviderId;
|
|
54
|
-
void start(id);
|
|
55
|
-
},
|
|
56
|
-
[start],
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div className="auth-oauth" role="group">
|
|
61
|
-
{providers.map((id) => {
|
|
62
|
-
const { Icon, variant } = OAUTH_PROVIDER_UI[id];
|
|
63
|
-
return (
|
|
64
|
-
<AuthButton
|
|
65
|
-
key={id}
|
|
66
|
-
type="button"
|
|
67
|
-
variant={variant}
|
|
68
|
-
data-provider={id}
|
|
69
|
-
loading={loadingProvider === id}
|
|
70
|
-
disabled={disabled || (isLoading && loadingProvider !== id)}
|
|
71
|
-
onClick={handleClick}
|
|
72
|
-
>
|
|
73
|
-
<Icon />
|
|
74
|
-
{labels[id]}
|
|
75
|
-
</AuthButton>
|
|
76
|
-
);
|
|
77
|
-
})}
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export const OAuthButtons = memo(OAuthButtonsRaw);
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Info, X } from 'lucide-react';
|
|
4
|
-
import React, { memo, useCallback, useEffect } from 'react';
|
|
5
|
-
|
|
6
|
-
import { isOAuthProviderId, useOAuthLogin } from '@djangocfg/api/auth';
|
|
7
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
8
|
-
import { Alert, AlertDescription, AlertTitle, Button } from '@djangocfg/ui-core/components';
|
|
9
|
-
|
|
10
|
-
import type { OAuthFailure } from '../../types';
|
|
11
|
-
import { describeOAuthFailure } from './oauthNoticeCopy';
|
|
12
|
-
|
|
13
|
-
export interface OAuthNoticeProps {
|
|
14
|
-
failure: OAuthFailure;
|
|
15
|
-
sourceUrl?: string;
|
|
16
|
-
/** Closes the notice (button or Escape). */
|
|
17
|
-
onDismiss: () => void;
|
|
18
|
-
/** Takes the visitor to the email field; the notice closes with it. */
|
|
19
|
-
onUseEmail: () => void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Brand names are not translated; an id we do not know is shown as it came. */
|
|
23
|
-
const providerLabel = (id: string): string => {
|
|
24
|
-
if (id === 'github') return 'GitHub';
|
|
25
|
-
if (id === 'google') return 'Google';
|
|
26
|
-
return id;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* What the sign-in form says after a provider round trip failed: one calm
|
|
31
|
-
* sentence, at most one next step, and a way to close it. Not a dialog: the
|
|
32
|
-
* form underneath stays usable and focus stays where it was.
|
|
33
|
-
*
|
|
34
|
-
* `role="status"` (polite): the visitor did nothing wrong, so it informs
|
|
35
|
-
* without interrupting. Renders nothing for a cancelled sign-in.
|
|
36
|
-
*/
|
|
37
|
-
function OAuthNoticeRaw({ failure, sourceUrl, onDismiss, onUseEmail }: OAuthNoticeProps) {
|
|
38
|
-
const t = useAppT();
|
|
39
|
-
const copy = describeOAuthFailure(failure.code);
|
|
40
|
-
const provider = providerLabel(failure.provider);
|
|
41
|
-
const canRetry = isOAuthProviderId(failure.provider);
|
|
42
|
-
|
|
43
|
-
const { start, isLoading } = useOAuthLogin({ sourceUrl });
|
|
44
|
-
const handleRetry = useCallback(() => {
|
|
45
|
-
if (isOAuthProviderId(failure.provider)) void start(failure.provider);
|
|
46
|
-
}, [start, failure.provider]);
|
|
47
|
-
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
const onKeyDown = (event: KeyboardEvent) => {
|
|
50
|
-
if (event.key === 'Escape') onDismiss();
|
|
51
|
-
};
|
|
52
|
-
document.addEventListener('keydown', onKeyDown);
|
|
53
|
-
return () => document.removeEventListener('keydown', onKeyDown);
|
|
54
|
-
}, [onDismiss]);
|
|
55
|
-
|
|
56
|
-
if (!copy) return null;
|
|
57
|
-
|
|
58
|
-
// A retry for a provider this client has no button for would go nowhere: send the visitor to email instead.
|
|
59
|
-
const action = copy.action === 'retry' && !canRetry ? 'email' : copy.action;
|
|
60
|
-
const title = copy.titleKey ? t(copy.titleKey) : null;
|
|
61
|
-
const body = t(copy.bodyKey, { provider });
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<Alert role="status" className="auth-oauth-notice relative pr-10">
|
|
65
|
-
<Info className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
66
|
-
<div>
|
|
67
|
-
{title && <AlertTitle>{title}</AlertTitle>}
|
|
68
|
-
<AlertDescription className="text-muted-foreground">{body}</AlertDescription>
|
|
69
|
-
{action === 'email' && (
|
|
70
|
-
<Button type="button" size="sm" className="mt-3" onClick={onUseEmail}>
|
|
71
|
-
{t('layouts.auth.oauth.notice.useEmail')}
|
|
72
|
-
</Button>
|
|
73
|
-
)}
|
|
74
|
-
{action === 'retry' && (
|
|
75
|
-
<Button type="button" size="sm" className="mt-3" disabled={isLoading} onClick={handleRetry}>
|
|
76
|
-
{t('layouts.auth.oauth.notice.retry', { provider })}
|
|
77
|
-
</Button>
|
|
78
|
-
)}
|
|
79
|
-
</div>
|
|
80
|
-
<Button
|
|
81
|
-
type="button"
|
|
82
|
-
variant="ghost"
|
|
83
|
-
size="icon"
|
|
84
|
-
className="absolute right-1 top-1 h-8 w-8"
|
|
85
|
-
aria-label={t('layouts.auth.oauth.notice.dismiss')}
|
|
86
|
-
onClick={onDismiss}
|
|
87
|
-
>
|
|
88
|
-
<X className="h-4 w-4" aria-hidden="true" />
|
|
89
|
-
</Button>
|
|
90
|
-
</Alert>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export const OAuthNotice = memo(OAuthNoticeRaw);
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useCallback } from 'react';
|
|
4
|
-
|
|
5
|
-
import type { OAuthFailureCode } from '@djangocfg/api/auth';
|
|
6
|
-
import { useAppT } from '@djangocfg/i18n';
|
|
7
|
-
|
|
8
|
-
import { OAUTH_CODE_TO_KEY, OAUTH_ERROR_KEYS } from './oauthNoticeCopy';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* The visitor-facing sentence for a failure: the translated copy of its code,
|
|
12
|
-
* else the text the hook carried (server prose), else the generic sentence.
|
|
13
|
-
*/
|
|
14
|
-
export const useOAuthErrorText = () => {
|
|
15
|
-
const t = useAppT();
|
|
16
|
-
return useCallback(
|
|
17
|
-
(code?: OAuthFailureCode | null, fallback?: string | null): string => {
|
|
18
|
-
if (code) return t(OAUTH_CODE_TO_KEY[code]);
|
|
19
|
-
return fallback || t(OAUTH_ERROR_KEYS.generic);
|
|
20
|
-
},
|
|
21
|
-
[t],
|
|
22
|
-
);
|
|
23
|
-
};
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { describeOAuthFailure } from './oauthNoticeCopy';
|
|
4
|
-
|
|
5
|
-
describe('describeOAuthFailure', () => {
|
|
6
|
-
it('says nothing when the visitor cancelled', () => {
|
|
7
|
-
expect(describeOAuthFailure('cancelled')).toBeNull();
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it('points a staff account at the email code, with a heading', () => {
|
|
11
|
-
expect(describeOAuthFailure('link_not_allowed')).toEqual({
|
|
12
|
-
titleKey: 'layouts.auth.oauth.notice.linkNotAllowedTitle',
|
|
13
|
-
bodyKey: 'layouts.auth.oauth.error.link_not_allowed',
|
|
14
|
-
action: 'email',
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('offers a retry where trying again can help', () => {
|
|
19
|
-
for (const code of ['invalid_state', 'oauth_exchange_failed', 'provider_unavailable', 'server_error'] as const) {
|
|
20
|
-
expect(describeOAuthFailure(code)?.action).toBe('retry');
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('offers nothing the visitor cannot act on', () => {
|
|
25
|
-
expect(describeOAuthFailure('account_inactive')?.action).toBe('none');
|
|
26
|
-
expect(describeOAuthFailure('rate_limited')?.action).toBe('none');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('treats a missing code as the generic failure', () => {
|
|
30
|
-
expect(describeOAuthFailure(undefined)).toEqual({
|
|
31
|
-
titleKey: undefined,
|
|
32
|
-
bodyKey: 'layouts.auth.oauth.error.generic',
|
|
33
|
-
action: 'retry',
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
});
|