@majapisoftwares/ui 6.1.2 → 6.2.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/docs/STANDARDS.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Padrão v1 de código e UI/UX
2
2
 
3
+ `LoginForm` aceita `mode="providers"` com `alternative` obrigatório para autenticação exclusiva por OAuth/SSO, sem campos de senha nem callback de credenciais. O modo padrão continua `credentials`. Catálogo e starter demonstram em `/login/provider`. A identidade autenticada usa `UserMenu` no slot `footer` do `AppShell`; tema, idioma e saída permanecem no padrão compartilhado.
4
+
3
5
  Base atual: shadcn base-luma com @base-ui/react. Os componentes interativos expõem fronteiras use client para uso no Next.js App Router. A migração da API Radix está documentada em MIGRATION-0.2.md.
4
6
 
5
7
  ## Arquitetura e código
@@ -1,22 +1,22 @@
1
- # Saved-language titles
2
-
3
- The initial preference state matched server HTML in English. Child title effects
4
- ran before browser preferences were loaded, briefly writing the English title.
5
- The pre-paint visibility mask also expired after two seconds, independently of
6
- whether hydration had completed.
7
-
8
- `Preferences` now exposes internal readiness to `useDocumentTitle`. Titles are
9
- written only after the stored/browser language is resolved. The pre-paint mask
10
- is removed only when React has committed that language. The public API remains
11
- compatible, including `useDocumentTitle` outside Preferences.
12
-
13
- The catalog and generated starter consume the shared implementation. Cloud and
14
- the starter pin the corrected public Git source archive by its full commit SHA;
15
- no registry authentication or mutable dependency ref is required.
16
-
17
- Validation: Portuguese and English hydration tests record every document-title
18
- write and verify that all writes use the selected language. Library typecheck,
19
- lint, standards and build pass. Generated-starter lint, typecheck, UI standards
20
- and build pass. Local Chrome refresh shows `Padrões · Majapi UI` and
21
- `Repositórios · Majapi Cloud`, with visible Portuguese content and the button
22
- `Conectar repositórios`. No production deployment was performed.
1
+ # Saved-language titles
2
+
3
+ The initial preference state matched server HTML in English. Child title effects
4
+ ran before browser preferences were loaded, briefly writing the English title.
5
+ The pre-paint visibility mask also expired after two seconds, independently of
6
+ whether hydration had completed.
7
+
8
+ `Preferences` now exposes internal readiness to `useDocumentTitle`. Titles are
9
+ written only after the stored/browser language is resolved. The pre-paint mask
10
+ is removed only when React has committed that language. The public API remains
11
+ compatible, including `useDocumentTitle` outside Preferences.
12
+
13
+ The catalog and generated starter consume the shared implementation. Cloud and
14
+ the starter pin the corrected public Git source archive by its full commit SHA;
15
+ no registry authentication or mutable dependency ref is required.
16
+
17
+ Validation: Portuguese and English hydration tests record every document-title
18
+ write and verify that all writes use the selected language. Library typecheck,
19
+ lint, standards and build pass. Generated-starter lint, typecheck, UI standards
20
+ and build pass. Local Chrome refresh shows `Padrões · Majapi UI` and
21
+ `Repositórios · Majapi Cloud`, with visible Portuguese content and the button
22
+ `Conectar repositórios`. No production deployment was performed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@majapisoftwares/ui",
3
- "version": "6.1.2",
3
+ "version": "6.2.0",
4
4
  "description": "Shared Majapi UI and UX standards based on shadcn/ui",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -84,8 +84,13 @@ const clampWidth = (width: number) =>
84
84
  * Applies the saved sidebar state to the server-rendered markup before the
85
85
  * browser paints (static pages can't read the cookie on the server).
86
86
  */
87
+ const subscribeNoop = () => () => {};
88
+
87
89
  function SidebarRestoreScript() {
90
+ // Only SSR and hydration emit the script; React warns about client-created scripts, which never run.
91
+ const hydrating = React.useSyncExternalStore(subscribeNoop, () => false, () => true);
88
92
  const script = `(function(){try{var m=document.cookie.match(/(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)/);if(!m)return;var p=m[1].split(":"),mode=p[0],w=Number(p[1]),wrap=document.currentScript.parentElement;if(w)wrap.setAttribute("style",(wrap.getAttribute("style")||"").replace(/--sidebar-width:[^;]*/,"--sidebar-width:"+Math.round(Math.min(${SIDEBAR_WIDTH_MAX},Math.max(${SIDEBAR_WIDTH_MIN},w)))+"px"));var sb=wrap.querySelector('[data-slot="sidebar"]');var r=wrap.querySelector('[data-slot="sidebar-rail"]');if(r)r.setAttribute("aria-valuenow",String(mode==="compact"?48:mode==="closed"?0:Math.round(Math.min(${SIDEBAR_WIDTH_MAX},Math.max(${SIDEBAR_WIDTH_MIN},w||${SIDEBAR_WIDTH})))));if(sb&&(mode==="compact"||mode==="closed")){sb.setAttribute("data-state","collapsed");sb.setAttribute("data-collapsible",mode==="closed"?"offcanvas":"icon")}}catch(e){}})()`;
93
+ if (!hydrating) return null;
89
94
  return (
90
95
  <script
91
96
  suppressHydrationWarning
@@ -1,180 +1,180 @@
1
- "use client";
2
-
3
- import {
4
- createContext,
5
- useContext,
6
- useEffect,
7
- useState,
8
- type ReactNode,
9
- } from "react";
10
- import { LanguagesIcon, MoonIcon, SunIcon } from "lucide-react";
11
- import {
12
- LocaleProvider,
13
- locales,
14
- matchLocale,
15
- useUiMessages,
16
- type Locale,
17
- } from "../lib/i18n";
18
- import { Button } from "../components/ui/button";
19
- import { Toaster } from "../components/ui/sonner";
20
- import {
21
- DropdownMenu,
22
- DropdownMenuContent,
23
- DropdownMenuRadioGroup,
24
- DropdownMenuRadioItem,
25
- DropdownMenuTrigger,
26
- } from "../components/ui/dropdown-menu";
27
-
28
- type Theme = "light" | "dark";
29
- const PreferencesContext = createContext<{
30
- locale: Locale;
31
- setLocale: (locale: Locale) => void;
32
- theme: Theme;
33
- setTheme: (theme: Theme) => void;
34
- } | null>(null);
35
- const PreferencesReadyContext = createContext(true);
36
-
37
- function read(key: string) {
38
- try {
39
- return localStorage.getItem(key);
40
- } catch {
41
- return null;
42
- }
43
- }
44
- function write(key: string, value: string) {
45
- try {
46
- localStorage.setItem(key, value);
47
- } catch {
48
- // Storage can be unavailable (private mode); preferences just won't persist.
49
- }
50
- }
51
-
52
- /**
53
- * Mirrors matchLocale; keep both in sync.
54
- * Inline this in `<head>` (e.g. `<script dangerouslySetInnerHTML>`) so the
55
- * stored theme applies before paint.
56
- */
57
- export const preferencesScript = `try{var d=document.documentElement;if(localStorage.getItem("theme")==="dark")d.classList.add("dark");var l=localStorage.getItem("locale");if(l!=="en"&&l!=="pt-BR"){l="en";var a=navigator.languages||[];for(var i=0;i<a.length;i++){if(/^pt/i.test(a[i])){l="pt-BR";break}if(/^en/i.test(a[i]))break}}if(l!==d.lang){d.lang=l;d.setAttribute("data-locale-pending",l)}}catch(e){}`;
58
-
59
- /**
60
- * Locale + theme for the whole app. Renders `defaultLocale` first (matches
61
- * static/server HTML), then applies the stored or browser locale on mount.
62
- */
63
- export function Preferences({
64
- defaultLocale = "en",
65
- children,
66
- }: {
67
- defaultLocale?: Locale;
68
- children: ReactNode;
69
- }) {
70
- const [locale, setLocale] = useState<Locale>(defaultLocale);
71
- const [theme, setTheme] = useState<Theme>("light");
72
- const [ready, setReady] = useState(false);
73
- useEffect(() => {
74
- const stored = read("locale");
75
- // eslint-disable-next-line react-hooks/set-state-in-effect -- sync from browser-only storage after hydration
76
- setLocale(
77
- locales.includes(stored as Locale)
78
- ? (stored as Locale)
79
- : matchLocale(navigator.languages),
80
- );
81
- setTheme(read("theme") === "dark" ? "dark" : "light");
82
- setReady(true);
83
- }, []);
84
- useEffect(() => {
85
- if (!ready) return;
86
- const root = document.documentElement;
87
- root.lang = locale;
88
- // The pre-paint script hides the page until the saved locale is rendered.
89
- if (root.dataset.localePending === locale)
90
- delete root.dataset.localePending;
91
- }, [locale, ready]);
92
- useEffect(() => {
93
- document.documentElement.classList.toggle("dark", theme === "dark");
94
- }, [theme]);
95
- return (
96
- <PreferencesContext.Provider
97
- value={{
98
- locale,
99
- setLocale: (next) => {
100
- setLocale(next);
101
- write("locale", next);
102
- },
103
- theme,
104
- setTheme: (next) => {
105
- setTheme(next);
106
- write("theme", next);
107
- },
108
- }}
109
- >
110
- <PreferencesReadyContext.Provider value={ready}>
111
- <LocaleProvider locale={locale}>
112
- {children}
113
- <Toaster theme={theme} />
114
- </LocaleProvider>
115
- </PreferencesReadyContext.Provider>
116
- </PreferencesContext.Provider>
117
- );
118
- }
119
-
120
- /**
121
- * Owns `document.title` so it follows the active language. Leave `title` out
122
- * of Next.js metadata, or Next will overwrite it after hydration.
123
- */
124
- export function useDocumentTitle(title: string) {
125
- const ready = useContext(PreferencesReadyContext);
126
- useEffect(() => {
127
- if (!ready) return;
128
- document.title = title;
129
- }, [title, ready]);
130
- }
131
-
132
- export function usePreferences() {
133
- const context = useContext(PreferencesContext);
134
- if (!context)
135
- throw new Error("usePreferences must be used within Preferences.");
136
- return context;
137
- }
138
-
139
- const localeNames: Record<Locale, string> = {
140
- en: "English",
141
- "pt-BR": "Português",
142
- };
143
-
144
- /** Language menu and theme toggle, e.g. for `AppShell` actions. */
145
- export function PreferenceControls() {
146
- const { locale, setLocale, theme, setTheme } = usePreferences();
147
- const t = useUiMessages();
148
- return (
149
- <>
150
- <DropdownMenu>
151
- <DropdownMenuTrigger
152
- render={<Button variant="ghost" size="sm" aria-label={t.language} />}
153
- >
154
- <LanguagesIcon />
155
- {locale === "en" ? "EN" : "PT"}
156
- </DropdownMenuTrigger>
157
- <DropdownMenuContent align="end">
158
- <DropdownMenuRadioGroup
159
- value={locale}
160
- onValueChange={(value) => setLocale(value as Locale)}
161
- >
162
- {locales.map((value) => (
163
- <DropdownMenuRadioItem key={value} value={value}>
164
- {localeNames[value]}
165
- </DropdownMenuRadioItem>
166
- ))}
167
- </DropdownMenuRadioGroup>
168
- </DropdownMenuContent>
169
- </DropdownMenu>
170
- <Button
171
- variant="ghost"
172
- size="icon-sm"
173
- aria-label={t.toggleTheme}
174
- onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
175
- >
176
- {theme === "dark" ? <SunIcon /> : <MoonIcon />}
177
- </Button>
178
- </>
179
- );
180
- }
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { LanguagesIcon, MoonIcon, SunIcon } from "lucide-react";
11
+ import {
12
+ LocaleProvider,
13
+ locales,
14
+ matchLocale,
15
+ useUiMessages,
16
+ type Locale,
17
+ } from "../lib/i18n";
18
+ import { Button } from "../components/ui/button";
19
+ import { Toaster } from "../components/ui/sonner";
20
+ import {
21
+ DropdownMenu,
22
+ DropdownMenuContent,
23
+ DropdownMenuRadioGroup,
24
+ DropdownMenuRadioItem,
25
+ DropdownMenuTrigger,
26
+ } from "../components/ui/dropdown-menu";
27
+
28
+ type Theme = "light" | "dark";
29
+ const PreferencesContext = createContext<{
30
+ locale: Locale;
31
+ setLocale: (locale: Locale) => void;
32
+ theme: Theme;
33
+ setTheme: (theme: Theme) => void;
34
+ } | null>(null);
35
+ const PreferencesReadyContext = createContext(true);
36
+
37
+ function read(key: string) {
38
+ try {
39
+ return localStorage.getItem(key);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ function write(key: string, value: string) {
45
+ try {
46
+ localStorage.setItem(key, value);
47
+ } catch {
48
+ // Storage can be unavailable (private mode); preferences just won't persist.
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Mirrors matchLocale; keep both in sync.
54
+ * Inline this in `<head>` (e.g. `<script dangerouslySetInnerHTML>`) so the
55
+ * stored theme applies before paint.
56
+ */
57
+ export const preferencesScript = `try{var d=document.documentElement;if(localStorage.getItem("theme")==="dark")d.classList.add("dark");var l=localStorage.getItem("locale");if(l!=="en"&&l!=="pt-BR"){l="en";var a=navigator.languages||[];for(var i=0;i<a.length;i++){if(/^pt/i.test(a[i])){l="pt-BR";break}if(/^en/i.test(a[i]))break}}if(l!==d.lang){d.lang=l;d.setAttribute("data-locale-pending",l)}}catch(e){}`;
58
+
59
+ /**
60
+ * Locale + theme for the whole app. Renders `defaultLocale` first (matches
61
+ * static/server HTML), then applies the stored or browser locale on mount.
62
+ */
63
+ export function Preferences({
64
+ defaultLocale = "en",
65
+ children,
66
+ }: {
67
+ defaultLocale?: Locale;
68
+ children: ReactNode;
69
+ }) {
70
+ const [locale, setLocale] = useState<Locale>(defaultLocale);
71
+ const [theme, setTheme] = useState<Theme>("light");
72
+ const [ready, setReady] = useState(false);
73
+ useEffect(() => {
74
+ const stored = read("locale");
75
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- sync from browser-only storage after hydration
76
+ setLocale(
77
+ locales.includes(stored as Locale)
78
+ ? (stored as Locale)
79
+ : matchLocale(navigator.languages),
80
+ );
81
+ setTheme(read("theme") === "dark" ? "dark" : "light");
82
+ setReady(true);
83
+ }, []);
84
+ useEffect(() => {
85
+ if (!ready) return;
86
+ const root = document.documentElement;
87
+ root.lang = locale;
88
+ // The pre-paint script hides the page until the saved locale is rendered.
89
+ if (root.dataset.localePending === locale)
90
+ delete root.dataset.localePending;
91
+ }, [locale, ready]);
92
+ useEffect(() => {
93
+ document.documentElement.classList.toggle("dark", theme === "dark");
94
+ }, [theme]);
95
+ return (
96
+ <PreferencesContext.Provider
97
+ value={{
98
+ locale,
99
+ setLocale: (next) => {
100
+ setLocale(next);
101
+ write("locale", next);
102
+ },
103
+ theme,
104
+ setTheme: (next) => {
105
+ setTheme(next);
106
+ write("theme", next);
107
+ },
108
+ }}
109
+ >
110
+ <PreferencesReadyContext.Provider value={ready}>
111
+ <LocaleProvider locale={locale}>
112
+ {children}
113
+ <Toaster theme={theme} />
114
+ </LocaleProvider>
115
+ </PreferencesReadyContext.Provider>
116
+ </PreferencesContext.Provider>
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Owns `document.title` so it follows the active language. Leave `title` out
122
+ * of Next.js metadata, or Next will overwrite it after hydration.
123
+ */
124
+ export function useDocumentTitle(title: string) {
125
+ const ready = useContext(PreferencesReadyContext);
126
+ useEffect(() => {
127
+ if (!ready) return;
128
+ document.title = title;
129
+ }, [title, ready]);
130
+ }
131
+
132
+ export function usePreferences() {
133
+ const context = useContext(PreferencesContext);
134
+ if (!context)
135
+ throw new Error("usePreferences must be used within Preferences.");
136
+ return context;
137
+ }
138
+
139
+ const localeNames: Record<Locale, string> = {
140
+ en: "English",
141
+ "pt-BR": "Português",
142
+ };
143
+
144
+ /** Language menu and theme toggle, e.g. for `AppShell` actions. */
145
+ export function PreferenceControls() {
146
+ const { locale, setLocale, theme, setTheme } = usePreferences();
147
+ const t = useUiMessages();
148
+ return (
149
+ <>
150
+ <DropdownMenu>
151
+ <DropdownMenuTrigger
152
+ render={<Button variant="ghost" size="sm" aria-label={t.language} />}
153
+ >
154
+ <LanguagesIcon />
155
+ {locale === "en" ? "EN" : "PT"}
156
+ </DropdownMenuTrigger>
157
+ <DropdownMenuContent align="end">
158
+ <DropdownMenuRadioGroup
159
+ value={locale}
160
+ onValueChange={(value) => setLocale(value as Locale)}
161
+ >
162
+ {locales.map((value) => (
163
+ <DropdownMenuRadioItem key={value} value={value}>
164
+ {localeNames[value]}
165
+ </DropdownMenuRadioItem>
166
+ ))}
167
+ </DropdownMenuRadioGroup>
168
+ </DropdownMenuContent>
169
+ </DropdownMenu>
170
+ <Button
171
+ variant="ghost"
172
+ size="icon-sm"
173
+ aria-label={t.toggleTheme}
174
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
175
+ >
176
+ {theme === "dark" ? <SunIcon /> : <MoonIcon />}
177
+ </Button>
178
+ </>
179
+ );
180
+ }
@@ -1,129 +1,140 @@
1
- "use client";
2
-
3
- import { useId, type ReactNode } from "react";
4
- import { Button } from "../components/ui/button";
5
- import { Input } from "../components/ui/input";
6
- import { Field, FieldGroup, FieldLabel } from "../components/ui/field";
7
- import { Alert, AlertDescription } from "../components/ui/alert";
8
- import {
9
- Card,
10
- CardContent,
11
- CardDescription,
12
- CardHeader,
13
- CardTitle,
14
- } from "../components/ui/card";
15
- import { defineMessages, useMessages } from "../lib/i18n";
16
-
17
- const messages = defineMessages({
18
- en: {
19
- title: "Welcome back",
20
- email: "Email",
21
- password: "Password",
22
- submit: "Log in",
23
- pending: "Logging in…",
24
- },
25
- "pt-BR": {
26
- title: "Bem-vindo de volta",
27
- email: "E-mail",
28
- password: "Senha",
29
- submit: "Entrar",
30
- pending: "Entrando…",
31
- },
32
- });
33
-
34
- export interface LoginValues {
35
- email: string;
36
- password: string;
37
- }
38
- export interface LoginFormProps {
39
- onSubmit: (values: LoginValues) => void;
40
- pending?: boolean;
41
- error?: string;
42
- title?: string;
43
- description?: string;
44
- submitLabel?: string;
45
- /** Shown next to the password label, e.g. a "Forgot password?" link. */
46
- recovery?: ReactNode;
47
- /** Shown above the fields, e.g. OAuth buttons. */
48
- alternative?: ReactNode;
49
- footer?: ReactNode;
50
- }
51
- export function LoginForm({
52
- onSubmit,
53
- pending = false,
54
- error,
55
- title,
56
- description,
57
- submitLabel,
58
- recovery,
59
- alternative,
60
- footer,
61
- }: LoginFormProps) {
62
- const id = useId();
63
- const t = useMessages(messages);
64
- return (
65
- <Card>
66
- <CardHeader className="text-center">
67
- <CardTitle className="text-xl">{title ?? t.title}</CardTitle>
68
- {description ? <CardDescription>{description}</CardDescription> : null}
69
- </CardHeader>
70
- <CardContent>
71
- <form
72
- aria-busy={pending}
73
- onSubmit={(event) => {
74
- event.preventDefault();
75
- const data = new FormData(event.currentTarget);
76
- onSubmit({
77
- email: String(data.get("email") ?? ""),
78
- password: String(data.get("password") ?? ""),
79
- });
80
- }}
81
- >
82
- <FieldGroup>
83
- {alternative}
84
- {error ? (
85
- <Alert variant="destructive">
86
- <AlertDescription>{error}</AlertDescription>
87
- </Alert>
88
- ) : null}
89
- <Field>
90
- <FieldLabel htmlFor={`${id}-email`}>{t.email}</FieldLabel>
91
- <Input
92
- id={`${id}-email`}
93
- name="email"
94
- type="email"
95
- autoComplete="username"
96
- required
97
- disabled={pending}
98
- />
99
- </Field>
100
- <Field>
101
- <div className="flex items-center">
102
- <FieldLabel htmlFor={`${id}-password`}>{t.password}</FieldLabel>
103
- {recovery ? (
104
- <div className="ml-auto text-sm">{recovery}</div>
105
- ) : null}
106
- </div>
107
- <Input
108
- id={`${id}-password`}
109
- name="password"
110
- type="password"
111
- autoComplete="current-password"
112
- required
113
- disabled={pending}
114
- />
115
- </Field>
116
- <Button type="submit" disabled={pending}>
117
- {pending ? t.pending : (submitLabel ?? t.submit)}
118
- </Button>
119
- {footer ? (
120
- <div className="text-center text-sm text-muted-foreground">
121
- {footer}
122
- </div>
123
- ) : null}
124
- </FieldGroup>
125
- </form>
126
- </CardContent>
127
- </Card>
128
- );
129
- }
1
+ "use client";
2
+
3
+ import { useId, type ReactNode } from "react";
4
+ import { Button } from "../components/ui/button";
5
+ import { Input } from "../components/ui/input";
6
+ import { Field, FieldGroup, FieldLabel } from "../components/ui/field";
7
+ import { Alert, AlertDescription } from "../components/ui/alert";
8
+ import {
9
+ Card,
10
+ CardContent,
11
+ CardDescription,
12
+ CardHeader,
13
+ CardTitle,
14
+ } from "../components/ui/card";
15
+ import { defineMessages, useMessages } from "../lib/i18n";
16
+
17
+ const messages = defineMessages({
18
+ en: {
19
+ title: "Welcome back",
20
+ email: "Email",
21
+ password: "Password",
22
+ submit: "Log in",
23
+ pending: "Logging in…",
24
+ },
25
+ "pt-BR": {
26
+ title: "Bem-vindo de volta",
27
+ email: "E-mail",
28
+ password: "Senha",
29
+ submit: "Entrar",
30
+ pending: "Entrando…",
31
+ },
32
+ });
33
+
34
+ export interface LoginValues {
35
+ email: string;
36
+ password: string;
37
+ }
38
+ interface LoginFormOptions {
39
+ pending?: boolean;
40
+ error?: string;
41
+ title?: string;
42
+ description?: string;
43
+ submitLabel?: string;
44
+ /** Shown next to the password label, e.g. a "Forgot password?" link. */
45
+ recovery?: ReactNode;
46
+ /** Shown above the fields, e.g. OAuth buttons. */
47
+ alternative?: ReactNode;
48
+ footer?: ReactNode;
49
+ }
50
+ export type LoginFormProps = LoginFormOptions &
51
+ (
52
+ | { mode?: "credentials"; onSubmit: (values: LoginValues) => void }
53
+ | { mode: "providers"; onSubmit?: never; alternative: ReactNode }
54
+ );
55
+ export function LoginForm({
56
+ mode = "credentials",
57
+ onSubmit,
58
+ pending = false,
59
+ error,
60
+ title,
61
+ description,
62
+ submitLabel,
63
+ recovery,
64
+ alternative,
65
+ footer,
66
+ }: LoginFormProps) {
67
+ const id = useId();
68
+ const t = useMessages(messages);
69
+ return (
70
+ <Card>
71
+ <CardHeader className="text-center">
72
+ <CardTitle className="text-xl">{title ?? t.title}</CardTitle>
73
+ {description ? <CardDescription>{description}</CardDescription> : null}
74
+ </CardHeader>
75
+ <CardContent>
76
+ <form
77
+ aria-busy={pending}
78
+ onSubmit={(event) => {
79
+ event.preventDefault();
80
+ const data = new FormData(event.currentTarget);
81
+ onSubmit?.({
82
+ email: String(data.get("email") ?? ""),
83
+ password: String(data.get("password") ?? ""),
84
+ });
85
+ }}
86
+ >
87
+ <FieldGroup>
88
+ {alternative}
89
+ {error ? (
90
+ <Alert variant="destructive">
91
+ <AlertDescription>{error}</AlertDescription>
92
+ </Alert>
93
+ ) : null}
94
+ {mode === "credentials" ? (
95
+ <>
96
+ <Field>
97
+ <FieldLabel htmlFor={`${id}-email`}>{t.email}</FieldLabel>
98
+ <Input
99
+ id={`${id}-email`}
100
+ name="email"
101
+ type="email"
102
+ autoComplete="username"
103
+ required
104
+ disabled={pending}
105
+ />
106
+ </Field>
107
+ <Field>
108
+ <div className="flex items-center">
109
+ <FieldLabel htmlFor={`${id}-password`}>
110
+ {t.password}
111
+ </FieldLabel>
112
+ {recovery ? (
113
+ <div className="ml-auto text-sm">{recovery}</div>
114
+ ) : null}
115
+ </div>
116
+ <Input
117
+ id={`${id}-password`}
118
+ name="password"
119
+ type="password"
120
+ autoComplete="current-password"
121
+ required
122
+ disabled={pending}
123
+ />
124
+ </Field>
125
+ <Button type="submit" disabled={pending}>
126
+ {pending ? t.pending : (submitLabel ?? t.submit)}
127
+ </Button>
128
+ </>
129
+ ) : null}
130
+ {footer ? (
131
+ <div className="text-center text-sm text-muted-foreground">
132
+ {footer}
133
+ </div>
134
+ ) : null}
135
+ </FieldGroup>
136
+ </form>
137
+ </CardContent>
138
+ </Card>
139
+ );
140
+ }