@spunto/design-system 0.16.0 → 0.18.0
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 +40 -3
- package/package.json +1 -1
- package/src/components/auth/auth-brand-panel.tsx +163 -0
- package/src/components/auth/auth-form.tsx +263 -0
- package/src/components/auth/auth-layout.tsx +79 -0
- package/src/components/auth/auth-separator.tsx +24 -0
- package/src/components/auth/index.ts +38 -0
- package/src/components/auth/oauth-button.tsx +81 -0
- package/src/components/auth/oauth-icons.tsx +53 -0
- package/src/components/auth/types.ts +29 -0
- package/src/components/command-menu.tsx +630 -0
- package/src/components/command-palette.tsx +7 -50
- package/src/components/command-shared.tsx +59 -0
- package/src/components/sheet.tsx +173 -0
- package/src/components/table.tsx +98 -0
- package/src/index.ts +87 -0
package/README.md
CHANGED
|
@@ -63,9 +63,20 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
63
63
|
`Alert` (+`AlertTitle`/`AlertDescription`, +`alertVariants`) — an **inline, persistent,
|
|
64
64
|
declarative** status banner, the counterpart to the ephemeral imperative `toast()`.
|
|
65
65
|
- _Overlays_ (plug into the `SpuntoProvider` umbrella) — `Dialog`, `AlertDialog`
|
|
66
|
-
(a confirmation variant, non-dismissible), `Tooltip`. Their portals render
|
|
67
|
-
the provider's overlay container; `Tooltip`'s shared delay group is mounted by
|
|
68
|
-
provider too.
|
|
66
|
+
(a confirmation variant, non-dismissible), `Sheet`, `Tooltip`. Their portals render
|
|
67
|
+
into the provider's overlay container; `Tooltip`'s shared delay group is mounted by
|
|
68
|
+
the provider too.
|
|
69
|
+
- _Sheet_ — `Sheet` (+`SheetTrigger`/`SheetClose`/`SheetContent`/`SheetHeader`/
|
|
70
|
+
`SheetFooter`/`SheetTitle`/`SheetDescription`), the same Base UI dialog entering from
|
|
71
|
+
an edge. `side` (4) decides the edge, `size` (`sm`…`full`) how far it comes in — two
|
|
72
|
+
props rather than one bundle of `data-[side=right]:`-prefixed classes, precisely so
|
|
73
|
+
`className` stays the layout escape hatch (an app widens a panel or lays it out in two
|
|
74
|
+
columns without `!important`).
|
|
75
|
+
- _Table_ — `Table` (+`TableHeader`/`TableBody`/`TableFooter`/`TableRow`/`TableHead`/
|
|
76
|
+
`TableCell`/`TableCaption`). **Presentational only**: no sorting, no pagination, no
|
|
77
|
+
column model — apps already own that logic and disagree on it; what they were
|
|
78
|
+
duplicating is the markup and the token choices. `containerClassName` reaches the
|
|
79
|
+
scroll container (max-height, border, sticky-header context).
|
|
69
80
|
- _CommandPalette_ — `CommandPalette` (+`CommandPaletteInput`/`List`/`Group`/`Item`/
|
|
70
81
|
`Empty`/`Loading`/`Separator`/`Footer`/`Trigger`), the ⌘K palette: an input, groups,
|
|
71
82
|
items, and a jump. **Domain-free**, hence its place in the root entry — it knows
|
|
@@ -80,6 +91,32 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
|
|
|
80
91
|
`value`/`onValueChange` + `filter={null}`. Navigation is `href` + `render.link`, never
|
|
81
92
|
`next/link` — same rule as `WorkerCard`. Not virtualized: a few hundred items stay
|
|
82
93
|
fluid (a non-matching item renders nothing), beyond that filter server-side.
|
|
94
|
+
- _CommandMenu_ — `CommandMenu` (+`CommandMenuInput`/`List`/`Group`/`Item`/`Empty`/
|
|
95
|
+
`Loading`/`Separator`), the palette's vocabulary **inline**: a Base UI `Popover`
|
|
96
|
+
anchored to an element or a **virtual element** (a caret rect), which never moves
|
|
97
|
+
focus (`initialFocus`/`finalFocus` off, no backdrop, non-modal) and claims ↑ ↓ ↵ Esc
|
|
98
|
+
on `document` in the **capture** phase, before the field underneath. That's what a
|
|
99
|
+
"/" menu inside a block editor needs and what the dialog-based palette can't do
|
|
100
|
+
without losing the caret — hence a separate component rather than a `modal={false}`
|
|
101
|
+
flag: the contract is the opposite one. Enter is only claimed when an item is
|
|
102
|
+
highlighted, so a query with no result still inserts a line break. The query either
|
|
103
|
+
comes from the caller (`query`, the "/" case) or from a `CommandMenuInput` rendered
|
|
104
|
+
inside the popup (a filter dropdown on a button; `autoFocus` is opt-in).
|
|
105
|
+
- _Auth_ — `AuthLayout`, `AuthBrandPanel` (+`AuthBrandMark`), `AuthForm`
|
|
106
|
+
(+`authFormCopy`), `OAuthButton` (+`GoogleIcon`/`GitHubIcon`) and `AuthSeparator`:
|
|
107
|
+
the login/signup screen **in bricks**, not one `<AuthScreen />`. In the root entry
|
|
108
|
+
for the same reason as the palette — nothing in it knows a Spunto concept: every
|
|
109
|
+
word of the brand panel is a prop, and the form authenticates nothing. **No
|
|
110
|
+
framework, no network**: no `next/navigation`, no `fetch`; `AuthForm` hands back
|
|
111
|
+
`onSubmit({ mode, email, password, name })`, `error`/`loading` come back down as
|
|
112
|
+
props, and anything that navigates takes an `href` (+ `render.link` on
|
|
113
|
+
`OAuthButton`) — the route, the endpoints and the redirect stay in the app. Both
|
|
114
|
+
controlled (`mode`, for `/login` + `/signup`) and uncontrolled (`initialMode` + the
|
|
115
|
+
in-place toggle). The fields are the package's own `Input`/`Label`/`Button`, in this
|
|
116
|
+
screen's taller register through `className` — the shape belongs to the page, not to
|
|
117
|
+
inputs in general. Assembling the whole thing:
|
|
118
|
+
[Créer sa page de login](https://design.spunto.net/#/auth-guide)
|
|
119
|
+
([markdown](https://design.spunto.net/auth-guide.md)).
|
|
83
120
|
- _Terminal_ — `Terminal` (+`TerminalHandle`, `TerminalOptions`), a transport-agnostic
|
|
84
121
|
xterm.js surface: mount, shared dark ANSI theme, fit-on-resize. No opinion on where
|
|
85
122
|
bytes come from (WebSocket, SSE, a static string) — feed it via the `write`/`writeln`
|
package/package.json
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { CheckIcon } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../utils"
|
|
5
|
+
|
|
6
|
+
/* ─── AuthBrandPanel ───────────────────────────────────────────────────────
|
|
7
|
+
What fills the dark half of `AuthLayout`: the mark at the top, the promise
|
|
8
|
+
and its arguments in the middle, a quote at the bottom.
|
|
9
|
+
|
|
10
|
+
**Nothing about Spunto is written in here.** The headline, the four lines
|
|
11
|
+
that sell the product and the quote all come down as props — the component is
|
|
12
|
+
the *shape* of that panel (three blocks pushed apart by `justify-between`, a
|
|
13
|
+
flame check per argument, a hairline card for the quote), not its copy. */
|
|
14
|
+
|
|
15
|
+
export interface AuthBrandMarkProps {
|
|
16
|
+
/**
|
|
17
|
+
* The logo, as a node — an inline SVG, an `<img>`, whatever the app has. It
|
|
18
|
+
* sits above the halo, so give it its own size and radius. Use `logoSrc`
|
|
19
|
+
* instead to get a plain, correctly-sized `<img>` for free.
|
|
20
|
+
*/
|
|
21
|
+
logo?: ReactNode
|
|
22
|
+
/** Convenience over `logo`: an image URL the package draws itself. */
|
|
23
|
+
logoSrc?: string
|
|
24
|
+
/** Wordmark beside (or under) the logo. */
|
|
25
|
+
name?: ReactNode
|
|
26
|
+
/**
|
|
27
|
+
* `panel` — horizontal, white text, for the dark brand panel.
|
|
28
|
+
* `compact` — stacked and centered, themed text, for the top of the form
|
|
29
|
+
* column on small screens (where the brand panel is hidden).
|
|
30
|
+
* @default "panel"
|
|
31
|
+
*/
|
|
32
|
+
variant?: "panel" | "compact"
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Logo + wordmark, haloed. The one brand element the auth screen repeats. */
|
|
37
|
+
export function AuthBrandMark({
|
|
38
|
+
logo,
|
|
39
|
+
logoSrc,
|
|
40
|
+
name,
|
|
41
|
+
variant = "panel",
|
|
42
|
+
className,
|
|
43
|
+
}: AuthBrandMarkProps) {
|
|
44
|
+
const panel = variant === "panel"
|
|
45
|
+
const alt = typeof name === "string" ? name : ""
|
|
46
|
+
|
|
47
|
+
const mark = logoSrc ? (
|
|
48
|
+
<img
|
|
49
|
+
src={logoSrc}
|
|
50
|
+
alt={alt}
|
|
51
|
+
className={cn(
|
|
52
|
+
"relative object-cover",
|
|
53
|
+
panel ? "h-10 w-10 rounded-xl ring-1 ring-white/15" : "h-12 w-12 rounded-2xl shadow-lg ring-1 ring-black/5"
|
|
54
|
+
)}
|
|
55
|
+
/>
|
|
56
|
+
) : (
|
|
57
|
+
<span className="relative block">{logo}</span>
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className={cn(
|
|
63
|
+
"relative flex",
|
|
64
|
+
panel ? "items-center gap-3" : "flex-col items-center gap-2",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{(logo || logoSrc) && (
|
|
69
|
+
<div className="relative">
|
|
70
|
+
<div
|
|
71
|
+
className={cn(
|
|
72
|
+
"absolute inset-0 rounded-md",
|
|
73
|
+
panel ? "bg-primary/40 blur-md" : "scale-110 bg-primary/25 blur-lg"
|
|
74
|
+
)}
|
|
75
|
+
/>
|
|
76
|
+
{mark}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
{name && (
|
|
80
|
+
<span
|
|
81
|
+
className={cn(
|
|
82
|
+
"font-display font-bold tracking-tight",
|
|
83
|
+
panel ? "text-xl text-white" : "text-lg text-foreground"
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{name}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface AuthBrandPanelProps extends Omit<AuthBrandMarkProps, "variant" | "className"> {
|
|
94
|
+
/** The promise, big. Takes nodes, so a `<br />` or a gradient span is yours to place. */
|
|
95
|
+
title?: ReactNode
|
|
96
|
+
/** The paragraph under it. */
|
|
97
|
+
description?: ReactNode
|
|
98
|
+
/** The selling points, one flame check each. */
|
|
99
|
+
items?: ReactNode[]
|
|
100
|
+
/** A testimonial in the bottom card. Omit both quote props to drop the card. */
|
|
101
|
+
quote?: ReactNode
|
|
102
|
+
/** Attribution under the quote — write the dash yourself ("— Platform Lead"). */
|
|
103
|
+
quoteAuthor?: ReactNode
|
|
104
|
+
/** Replaces the quote card entirely (a logo strip, a status line…). */
|
|
105
|
+
footer?: ReactNode
|
|
106
|
+
className?: string
|
|
107
|
+
/** Reaches the mark at the top (or hand it `null` to drop it). */
|
|
108
|
+
markClassName?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function AuthBrandPanel({
|
|
112
|
+
logo,
|
|
113
|
+
logoSrc,
|
|
114
|
+
name,
|
|
115
|
+
title,
|
|
116
|
+
description,
|
|
117
|
+
items,
|
|
118
|
+
quote,
|
|
119
|
+
quoteAuthor,
|
|
120
|
+
footer,
|
|
121
|
+
className,
|
|
122
|
+
markClassName,
|
|
123
|
+
}: AuthBrandPanelProps) {
|
|
124
|
+
const hasQuote = quote !== undefined || quoteAuthor !== undefined
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
// `h-full` + `justify-between` rather than the layout's own flex column, so
|
|
128
|
+
// the panel keeps its three-block rhythm wherever it's mounted.
|
|
129
|
+
<div className={cn("relative flex h-full flex-col justify-between", className)}>
|
|
130
|
+
<AuthBrandMark logo={logo} logoSrc={logoSrc} name={name} className={markClassName} />
|
|
131
|
+
|
|
132
|
+
<div className="space-y-8">
|
|
133
|
+
{(title || description) && (
|
|
134
|
+
<div>
|
|
135
|
+
{title && <h1 className="mb-3 text-3xl leading-snug font-bold text-white">{title}</h1>}
|
|
136
|
+
{description && <p className="leading-relaxed text-zinc-400">{description}</p>}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{items && items.length > 0 && (
|
|
141
|
+
<ul className="space-y-4">
|
|
142
|
+
{items.map((item, i) => (
|
|
143
|
+
<li key={i} className="flex items-start gap-3 text-sm text-zinc-300">
|
|
144
|
+
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-primary/30 bg-primary/20">
|
|
145
|
+
<CheckIcon className="h-3 w-3 text-primary" />
|
|
146
|
+
</div>
|
|
147
|
+
{item}
|
|
148
|
+
</li>
|
|
149
|
+
))}
|
|
150
|
+
</ul>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{footer ??
|
|
155
|
+
(hasQuote && (
|
|
156
|
+
<div className="rounded-xl border border-white/10 bg-white/3 p-5">
|
|
157
|
+
{quote && <p className="text-sm leading-relaxed text-zinc-300 italic">{quote}</p>}
|
|
158
|
+
{quoteAuthor && <p className="mt-3 text-xs text-zinc-500">{quoteAuthor}</p>}
|
|
159
|
+
</div>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useId, useState, type FormEvent, type ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { Button } from "../button"
|
|
7
|
+
import { Input } from "../input"
|
|
8
|
+
import { Label } from "../label"
|
|
9
|
+
import { Spinner } from "../spinner"
|
|
10
|
+
import type { AuthMode, AuthSubmitValues } from "./types"
|
|
11
|
+
|
|
12
|
+
/* ─── AuthForm ─────────────────────────────────────────────────────────────
|
|
13
|
+
One form, two modes. `/login` opens on the sign-in form and `/signup` on the
|
|
14
|
+
account form — a home page whose call to action says "Sign up for free" has to
|
|
15
|
+
land on a form that says "Create an account", not on "Welcome back". The
|
|
16
|
+
toggle at the bottom still switches between the two *without a navigation*, so
|
|
17
|
+
nobody is stuck on the wrong one.
|
|
18
|
+
|
|
19
|
+
Hence both a `mode` (controlled — two routes, each pinning its own) and an
|
|
20
|
+
`initialMode` (uncontrolled — one route, the toggle switches in place).
|
|
21
|
+
|
|
22
|
+
**It does not authenticate anything.** No `fetch`, no session, no redirect:
|
|
23
|
+
`onSubmit` hands back `{ mode, email, password, name }` and the app decides
|
|
24
|
+
which endpoint that goes to and where the user lands afterwards. `error` and
|
|
25
|
+
`loading` come back down as props — the request is the app's, so its two
|
|
26
|
+
states are the app's too (`loading` is optional: left out, the form spins by
|
|
27
|
+
itself while an async `onSubmit` is pending). */
|
|
28
|
+
|
|
29
|
+
export interface AuthModeCopy {
|
|
30
|
+
title: ReactNode
|
|
31
|
+
subtitle: ReactNode
|
|
32
|
+
/** The submit button's label. */
|
|
33
|
+
submit: ReactNode
|
|
34
|
+
/** The question at the bottom ("No account?"). */
|
|
35
|
+
switchPrompt: ReactNode
|
|
36
|
+
/** The link that flips the mode ("Create one"). */
|
|
37
|
+
switchAction: ReactNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AuthFieldCopy {
|
|
41
|
+
name: string
|
|
42
|
+
email: string
|
|
43
|
+
password: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AuthFormCopy {
|
|
47
|
+
login: AuthModeCopy
|
|
48
|
+
register: AuthModeCopy
|
|
49
|
+
fields: AuthFieldCopy
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** English defaults — every string is overridable through `copy`. */
|
|
53
|
+
export const authFormCopy: AuthFormCopy = {
|
|
54
|
+
login: {
|
|
55
|
+
title: "Welcome back",
|
|
56
|
+
subtitle: "Sign in to access your environments",
|
|
57
|
+
submit: "Sign in",
|
|
58
|
+
switchPrompt: "No account?",
|
|
59
|
+
switchAction: "Create one",
|
|
60
|
+
},
|
|
61
|
+
register: {
|
|
62
|
+
title: "Create an account",
|
|
63
|
+
subtitle: "Set up your dev account",
|
|
64
|
+
submit: "Create account",
|
|
65
|
+
switchPrompt: "Already have an account?",
|
|
66
|
+
switchAction: "Sign in",
|
|
67
|
+
},
|
|
68
|
+
fields: {
|
|
69
|
+
name: "Display name (optional)",
|
|
70
|
+
email: "Email",
|
|
71
|
+
password: "Password",
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* The auth register of the `Input` primitive: taller, rounder, on the card
|
|
76
|
+
surface — the same 44 px pill as `OAuthButton`, so the provider button and the
|
|
77
|
+
fields under it line up. Kept here rather than added as a `size` on `Input`:
|
|
78
|
+
it is this screen's shape, not a general one. */
|
|
79
|
+
const fieldClass =
|
|
80
|
+
"h-auto rounded-xl border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground dark:bg-card"
|
|
81
|
+
|
|
82
|
+
export interface AuthFormProps {
|
|
83
|
+
/** Controlled mode — set it when the route decides (`/login` vs `/signup`). */
|
|
84
|
+
mode?: AuthMode
|
|
85
|
+
/** Uncontrolled starting mode. @default "login" */
|
|
86
|
+
initialMode?: AuthMode
|
|
87
|
+
/** Fires on both the controlled and the uncontrolled path (clear your `error` here). */
|
|
88
|
+
onModeChange?: (mode: AuthMode) => void
|
|
89
|
+
/** Where the app takes over: call your API, then redirect. */
|
|
90
|
+
onSubmit?: (values: AuthSubmitValues) => void | Promise<void>
|
|
91
|
+
/** Controlled busy state. Omitted, the form is busy while `onSubmit` is pending. */
|
|
92
|
+
loading?: boolean
|
|
93
|
+
/** The failure, under the fields. `null`/`undefined` shows nothing. */
|
|
94
|
+
error?: ReactNode
|
|
95
|
+
/** Pre-filled values — dev credentials, an invited address… */
|
|
96
|
+
defaultEmail?: string
|
|
97
|
+
defaultPassword?: string
|
|
98
|
+
defaultName?: string
|
|
99
|
+
/** Show the display-name field. @default true in `register`, false in `login` */
|
|
100
|
+
showNameField?: boolean
|
|
101
|
+
/** Native `minLength` on the password. @default 8 in `register`, 1 in `login` */
|
|
102
|
+
passwordMinLength?: number
|
|
103
|
+
/** Hides the bottom "No account? Create one" line (two dedicated routes, no toggle). */
|
|
104
|
+
hideModeSwitch?: boolean
|
|
105
|
+
/** Every string on the screen. Merged over the defaults, per mode. */
|
|
106
|
+
copy?: {
|
|
107
|
+
login?: Partial<AuthModeCopy>
|
|
108
|
+
register?: Partial<AuthModeCopy>
|
|
109
|
+
fields?: Partial<AuthFieldCopy>
|
|
110
|
+
}
|
|
111
|
+
/** Slot between the heading and the fields — the OAuth buttons and their separator. */
|
|
112
|
+
children?: ReactNode
|
|
113
|
+
/** Slot under the mode switch — terms, a link back to the site. */
|
|
114
|
+
footer?: ReactNode
|
|
115
|
+
className?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function AuthForm({
|
|
119
|
+
mode: modeProp,
|
|
120
|
+
initialMode = "login",
|
|
121
|
+
onModeChange,
|
|
122
|
+
onSubmit,
|
|
123
|
+
loading,
|
|
124
|
+
error,
|
|
125
|
+
defaultEmail = "",
|
|
126
|
+
defaultPassword = "",
|
|
127
|
+
defaultName = "",
|
|
128
|
+
showNameField,
|
|
129
|
+
passwordMinLength,
|
|
130
|
+
hideModeSwitch,
|
|
131
|
+
copy,
|
|
132
|
+
children,
|
|
133
|
+
footer,
|
|
134
|
+
className,
|
|
135
|
+
}: AuthFormProps) {
|
|
136
|
+
const id = useId()
|
|
137
|
+
const [uncontrolledMode, setUncontrolledMode] = useState<AuthMode>(initialMode)
|
|
138
|
+
const mode = modeProp ?? uncontrolledMode
|
|
139
|
+
|
|
140
|
+
const [email, setEmail] = useState(defaultEmail)
|
|
141
|
+
const [password, setPassword] = useState(defaultPassword)
|
|
142
|
+
const [name, setName] = useState(defaultName)
|
|
143
|
+
const [pending, setPending] = useState(false)
|
|
144
|
+
|
|
145
|
+
const busy = loading ?? pending
|
|
146
|
+
const register = mode === "register"
|
|
147
|
+
const withName = showNameField ?? register
|
|
148
|
+
const fields = { ...authFormCopy.fields, ...copy?.fields }
|
|
149
|
+
const text = { ...authFormCopy[mode], ...copy?.[mode] }
|
|
150
|
+
|
|
151
|
+
function switchMode() {
|
|
152
|
+
const next: AuthMode = register ? "login" : "register"
|
|
153
|
+
if (modeProp === undefined) setUncontrolledMode(next)
|
|
154
|
+
onModeChange?.(next)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
158
|
+
event.preventDefault()
|
|
159
|
+
if (busy) return
|
|
160
|
+
const values: AuthSubmitValues = { mode, email, password }
|
|
161
|
+
if (register && withName && name) values.name = name
|
|
162
|
+
try {
|
|
163
|
+
setPending(true)
|
|
164
|
+
await onSubmit?.(values)
|
|
165
|
+
} finally {
|
|
166
|
+
setPending(false)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className={cn("space-y-6", className)}>
|
|
172
|
+
<div className="text-center">
|
|
173
|
+
<h2 className="text-2xl font-bold tracking-tight text-foreground">{text.title}</h2>
|
|
174
|
+
<p className="mt-2 text-sm text-muted-foreground">{text.subtitle}</p>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{children}
|
|
178
|
+
|
|
179
|
+
<form onSubmit={handleSubmit} className="space-y-3">
|
|
180
|
+
{/* The screen is placeholder-only by design (three fields, no room for a
|
|
181
|
+
column of labels), so each field still carries a real `Label` — just
|
|
182
|
+
visually hidden. A placeholder is not an accessible name. */}
|
|
183
|
+
{register && withName && (
|
|
184
|
+
<div>
|
|
185
|
+
<Label htmlFor={`${id}-name`} className="sr-only">
|
|
186
|
+
{fields.name}
|
|
187
|
+
</Label>
|
|
188
|
+
<Input
|
|
189
|
+
id={`${id}-name`}
|
|
190
|
+
type="text"
|
|
191
|
+
autoComplete="name"
|
|
192
|
+
placeholder={fields.name}
|
|
193
|
+
value={name}
|
|
194
|
+
onChange={(e) => setName(e.target.value)}
|
|
195
|
+
className={fieldClass}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
<div>
|
|
201
|
+
<Label htmlFor={`${id}-email`} className="sr-only">
|
|
202
|
+
{fields.email}
|
|
203
|
+
</Label>
|
|
204
|
+
<Input
|
|
205
|
+
id={`${id}-email`}
|
|
206
|
+
type="email"
|
|
207
|
+
autoComplete="email"
|
|
208
|
+
required
|
|
209
|
+
placeholder={fields.email}
|
|
210
|
+
value={email}
|
|
211
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
212
|
+
className={fieldClass}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div>
|
|
217
|
+
<Label htmlFor={`${id}-password`} className="sr-only">
|
|
218
|
+
{fields.password}
|
|
219
|
+
</Label>
|
|
220
|
+
<Input
|
|
221
|
+
id={`${id}-password`}
|
|
222
|
+
type="password"
|
|
223
|
+
autoComplete={register ? "new-password" : "current-password"}
|
|
224
|
+
required
|
|
225
|
+
minLength={passwordMinLength ?? (register ? 8 : 1)}
|
|
226
|
+
placeholder={fields.password}
|
|
227
|
+
value={password}
|
|
228
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
229
|
+
className={fieldClass}
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{error && <p className="text-center text-xs text-destructive">{error}</p>}
|
|
234
|
+
|
|
235
|
+
<Button
|
|
236
|
+
type="submit"
|
|
237
|
+
disabled={busy}
|
|
238
|
+
className="h-auto w-full gap-2 rounded-xl px-4 py-2.5 text-sm"
|
|
239
|
+
>
|
|
240
|
+
{busy && <Spinner className="size-3.5" />}
|
|
241
|
+
{text.submit}
|
|
242
|
+
</Button>
|
|
243
|
+
</form>
|
|
244
|
+
|
|
245
|
+
{!hideModeSwitch && (
|
|
246
|
+
// A plain <button> and not `Button variant="link"`: this one sits *inside*
|
|
247
|
+
// a sentence, and the primitive is an inline-flex box with a height.
|
|
248
|
+
<p className="text-center text-xs text-muted-foreground">
|
|
249
|
+
{text.switchPrompt}{" "}
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={switchMode}
|
|
253
|
+
className="font-medium text-primary underline-offset-4 hover:underline"
|
|
254
|
+
>
|
|
255
|
+
{text.switchAction}
|
|
256
|
+
</button>
|
|
257
|
+
</p>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{footer}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../utils"
|
|
4
|
+
|
|
5
|
+
/* ─── AuthLayout ───────────────────────────────────────────────────────────
|
|
6
|
+
The page an auth screen sits in: a fixed dark brand panel on the left (gone
|
|
7
|
+
below `lg`), the form column on the right, and the two backgrounds that make
|
|
8
|
+
the left side look like something — the flame halo bleeding out of the bottom
|
|
9
|
+
corner and the dot grid over it.
|
|
10
|
+
|
|
11
|
+
The layout owns the *chrome* (the split, the panel, its backgrounds, the
|
|
12
|
+
`max-w-sm` column the form lives in); `AuthBrandPanel` owns the content that
|
|
13
|
+
goes in it. That seam is what lets an app keep the screen and change every
|
|
14
|
+
word on it, or drop the brand panel entirely (`brand` omitted → the form
|
|
15
|
+
centers on the whole page). */
|
|
16
|
+
|
|
17
|
+
export interface AuthLayoutProps {
|
|
18
|
+
/** Left panel content — usually an `AuthBrandPanel`. Hidden below `lg`. */
|
|
19
|
+
brand?: ReactNode
|
|
20
|
+
/**
|
|
21
|
+
* Shown *above* the form below `lg`, where the brand panel is gone — usually
|
|
22
|
+
* an `AuthBrandMark variant="compact"`. Without it, small screens open
|
|
23
|
+
* straight on the form.
|
|
24
|
+
*/
|
|
25
|
+
brandCompact?: ReactNode
|
|
26
|
+
/** The form column: `AuthForm`, or anything else you want centered there. */
|
|
27
|
+
children: ReactNode
|
|
28
|
+
className?: string
|
|
29
|
+
/** Reaches the dark panel (a different surface, another glow color…). */
|
|
30
|
+
brandClassName?: string
|
|
31
|
+
/** Reaches the form column (a wider container, a different background…). */
|
|
32
|
+
contentClassName?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function AuthLayout({
|
|
36
|
+
brand,
|
|
37
|
+
brandCompact,
|
|
38
|
+
children,
|
|
39
|
+
className,
|
|
40
|
+
brandClassName,
|
|
41
|
+
contentClassName,
|
|
42
|
+
}: AuthLayoutProps) {
|
|
43
|
+
return (
|
|
44
|
+
<div className={cn("flex min-h-screen font-sans antialiased", className)}>
|
|
45
|
+
{brand && (
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"relative hidden w-1/2 overflow-hidden border-r border-white/8 bg-zinc-950 p-12 lg:block",
|
|
49
|
+
brandClassName
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{/* Flame halo, bottom-left, bleeding out of the corner. */}
|
|
53
|
+
<div className="pointer-events-none absolute bottom-0 left-0 h-[500px] w-[500px] bg-primary/20 blur-[120px]" />
|
|
54
|
+
{/* Not the package's `dot-grid` utility: that one is warm and follows
|
|
55
|
+
the theme, and this panel is a *fixed* dark surface (same rule as
|
|
56
|
+
`--night` in /marketing) — it needs a white grid in both themes. */}
|
|
57
|
+
<div
|
|
58
|
+
className="pointer-events-none absolute inset-0"
|
|
59
|
+
style={{
|
|
60
|
+
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px)",
|
|
61
|
+
backgroundSize: "32px 32px",
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
{brand}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div
|
|
69
|
+
className={cn(
|
|
70
|
+
"flex flex-1 flex-col items-center justify-center bg-background px-6 py-12",
|
|
71
|
+
contentClassName
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{brandCompact && <div className="mb-10 lg:hidden">{brandCompact}</div>}
|
|
75
|
+
<div className="w-full max-w-sm space-y-6">{children}</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../utils"
|
|
4
|
+
|
|
5
|
+
/* The "or" between the provider buttons and the password form: two hairlines and
|
|
6
|
+
a word. Not the package's `Separator` — that one is a single `role="separator"`
|
|
7
|
+
rule, and this is a *labelled* divider, so it's a small composition of its own
|
|
8
|
+
rather than a variant that would have to bend `Separator`'s ARIA role. */
|
|
9
|
+
|
|
10
|
+
export interface AuthSeparatorProps {
|
|
11
|
+
/** The word in the middle. @default "or" */
|
|
12
|
+
children?: ReactNode
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AuthSeparator({ children = "or", className }: AuthSeparatorProps) {
|
|
17
|
+
return (
|
|
18
|
+
<div className={cn("flex items-center gap-3 text-xs text-muted-foreground", className)}>
|
|
19
|
+
<div className="h-px flex-1 bg-border" />
|
|
20
|
+
{children}
|
|
21
|
+
<div className="h-px flex-1 bg-border" />
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// The auth screen, in bricks.
|
|
2
|
+
//
|
|
3
|
+
// Re-exported from the package **root**, not from a sub-entry: nothing in here
|
|
4
|
+
// knows a Spunto concept (the brand panel's every word is a prop, the form posts
|
|
5
|
+
// nowhere), so it follows `CommandPalette` rather than `/workers` — see the
|
|
6
|
+
// README, § "Domain components".
|
|
7
|
+
//
|
|
8
|
+
// Five bricks rather than one `<AuthScreen />`, because the parts are what
|
|
9
|
+
// differ between apps: a product with three OAuth providers and no password,
|
|
10
|
+
// an internal tool with a password and no panel, and a marketing signup that
|
|
11
|
+
// wants its own headline all assemble the same pieces differently.
|
|
12
|
+
//
|
|
13
|
+
// AuthLayout — the split screen: dark brand panel, form column, backgrounds.
|
|
14
|
+
// AuthBrandPanel — what fills the dark half (+ `AuthBrandMark`, its logo lockup).
|
|
15
|
+
// AuthForm — email / password / display name, login ⇄ register, error, loading.
|
|
16
|
+
// OAuthButton — "Continue with <provider>" (+ the `GoogleIcon`/`GitHubIcon` marks).
|
|
17
|
+
// AuthSeparator — the labelled "or" between the two.
|
|
18
|
+
//
|
|
19
|
+
// Assembling them end to end: the showcase's "Créer sa page de login" guide.
|
|
20
|
+
|
|
21
|
+
export { AuthLayout } from "./auth-layout"
|
|
22
|
+
export type { AuthLayoutProps } from "./auth-layout"
|
|
23
|
+
|
|
24
|
+
export { AuthBrandPanel, AuthBrandMark } from "./auth-brand-panel"
|
|
25
|
+
export type { AuthBrandPanelProps, AuthBrandMarkProps } from "./auth-brand-panel"
|
|
26
|
+
|
|
27
|
+
export { AuthForm, authFormCopy } from "./auth-form"
|
|
28
|
+
export type { AuthFormProps, AuthFormCopy, AuthModeCopy, AuthFieldCopy } from "./auth-form"
|
|
29
|
+
|
|
30
|
+
export { OAuthButton } from "./oauth-button"
|
|
31
|
+
export type { OAuthButtonProps, OAuthProvider } from "./oauth-button"
|
|
32
|
+
|
|
33
|
+
export { GoogleIcon, GitHubIcon } from "./oauth-icons"
|
|
34
|
+
|
|
35
|
+
export { AuthSeparator } from "./auth-separator"
|
|
36
|
+
export type { AuthSeparatorProps } from "./auth-separator"
|
|
37
|
+
|
|
38
|
+
export type { AuthMode, AuthSubmitValues, AuthLinkProps, AuthLinkRender } from "./types"
|