refactor(auth): migrate form controls to tailwind
This commit is contained in:
@@ -3,6 +3,9 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { AuthBackground } from "../auth-background";
|
import { AuthBackground } from "../auth-background";
|
||||||
import { AuthErrorMessage } from "../auth-error-message";
|
import { AuthErrorMessage } from "../auth-error-message";
|
||||||
|
import { AuthLegalText } from "../auth-legal-text";
|
||||||
|
import { AuthSocialButton } from "../auth-social-button";
|
||||||
|
import { AuthTextField } from "../auth-text-field";
|
||||||
import { EmailLoginForm } from "../email-login-form";
|
import { EmailLoginForm } from "../email-login-form";
|
||||||
import { EmailRegisterForm } from "../email-register-form";
|
import { EmailRegisterForm } from "../email-register-form";
|
||||||
|
|
||||||
@@ -49,4 +52,42 @@ describe("auth Tailwind components", () => {
|
|||||||
expect(html).toContain("object-cover");
|
expect(html).toContain("object-cover");
|
||||||
expect(html).toContain("%2Fimages%2Fauth%2Fbg-login.png");
|
expect(html).toContain("%2Fimages%2Fauth%2Fbg-login.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders AuthSocialButton with Tailwind button layout", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AuthSocialButton icon={<span>G</span>}>Continue</AuthSocialButton>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("min-h-[var(--auth-social-button-height)]");
|
||||||
|
expect(html).toContain("border-auth-border");
|
||||||
|
expect(html).toContain("text-auth-text-primary");
|
||||||
|
expect(html).toContain("Continue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders AuthLegalText with Tailwind checkbox and links", () => {
|
||||||
|
const html = renderToStaticMarkup(<AuthLegalText />);
|
||||||
|
|
||||||
|
expect(html).toContain('aria-label="Agree to terms"');
|
||||||
|
expect(html).toContain("size-[var(--auth-legal-checkbox-size)]");
|
||||||
|
expect(html).toContain("bg-auth-legal-check");
|
||||||
|
expect(html).toContain("Privacy Agreement");
|
||||||
|
expect(html).toContain("Terms of User");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders AuthTextField with Tailwind input states", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<AuthTextField
|
||||||
|
value="bad"
|
||||||
|
onChange={() => {}}
|
||||||
|
label="Email"
|
||||||
|
placeholder="Email"
|
||||||
|
errorMessage="Invalid email"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("min-h-[var(--auth-field-height)]");
|
||||||
|
expect(html).toContain("placeholder:text-auth-input-placeholder");
|
||||||
|
expect(html).toContain("text-auth-text-primary");
|
||||||
|
expect(html).toContain("Invalid email");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
/*
|
|
||||||
*
|
|
||||||
* 视觉规格(与 Dart 对齐):
|
|
||||||
* - 行:16px 白色圆形复选框 + 12px 富文本
|
|
||||||
* - 复选框选中时 10px 粉色实心圆
|
|
||||||
* - 链接加粗(Privacy Policy / Terms of Service)
|
|
||||||
*/
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: var(--auth-legal-checkbox-size);
|
|
||||||
height: var(--auth-legal-checkbox-size);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-auth-surface);
|
|
||||||
border: 1px solid var(--color-auth-surface);
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 2px;
|
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkboxInner {
|
|
||||||
display: block;
|
|
||||||
width: var(--auth-legal-checkbox-inner-size);
|
|
||||||
height: var(--auth-legal-checkbox-inner-size);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-auth-legal-check);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--responsive-caption, var(--font-size-sm));
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-auth-text-primary);
|
|
||||||
text-align: left;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
color: var(--color-auth-text-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
@@ -15,27 +15,27 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { AppConstants } from "@/core/app_constants";
|
import { AppConstants } from "@/core/app_constants";
|
||||||
|
|
||||||
import styles from "./auth-legal-text.module.css";
|
|
||||||
|
|
||||||
export function AuthLegalText() {
|
export function AuthLegalText() {
|
||||||
const [checked, setChecked] = useState(true);
|
const [checked, setChecked] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className="flex w-full items-start gap-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.checkbox}
|
className="mt-0.5 inline-flex size-[var(--auth-legal-checkbox-size)] flex-none cursor-pointer items-center justify-center rounded-full border border-auth-surface bg-auth-surface p-0 shadow-[0_0_0_1px_rgba(0,0,0,0.06)]"
|
||||||
onClick={() => setChecked((c) => !c)}
|
onClick={() => setChecked((c) => !c)}
|
||||||
aria-label="Agree to terms"
|
aria-label="Agree to terms"
|
||||||
aria-pressed={checked}
|
aria-pressed={checked}
|
||||||
>
|
>
|
||||||
{checked ? <span className={styles.checkboxInner} /> : null}
|
{checked ? (
|
||||||
|
<span className="block size-[var(--auth-legal-checkbox-inner-size)] rounded-full bg-auth-legal-check" />
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<p className={styles.text}>
|
<p className="m-0 flex-auto text-left text-[var(--responsive-caption)] leading-[1.4] text-auth-text-primary">
|
||||||
I agree to the Cozsweet {" "}
|
I agree to the Cozsweet {" "}
|
||||||
<a
|
<a
|
||||||
href={AppConstants.privacyPolicyUrl}
|
href={AppConstants.privacyPolicyUrl}
|
||||||
className={styles.link}
|
className="font-bold text-auth-text-primary no-underline hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
@@ -44,7 +44,7 @@ export function AuthLegalText() {
|
|||||||
and{" "}
|
and{" "}
|
||||||
<a
|
<a
|
||||||
href={AppConstants.termsOfServiceUrl}
|
href={AppConstants.termsOfServiceUrl}
|
||||||
className={styles.link}
|
className="font-bold text-auth-text-primary no-underline hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
*
|
|
||||||
* 视觉规格(与 Dart 弹层按钮对齐):
|
|
||||||
* - 40px 高
|
|
||||||
* - 白底 + 1px 边框 `--color-auth-border` (#d5d7db)
|
|
||||||
* - 半径 20
|
|
||||||
* - 无阴影
|
|
||||||
* - 图标 20×20 + 标签
|
|
||||||
* - 14px 文字深色
|
|
||||||
*/
|
|
||||||
.button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--auth-social-button-height);
|
|
||||||
padding: 0 var(--spacing-lg);
|
|
||||||
border: 1px solid var(--color-auth-border);
|
|
||||||
border-radius: var(--radius-full, 999px);
|
|
||||||
background: var(--color-auth-surface);
|
|
||||||
color: var(--color-auth-text-primary);
|
|
||||||
font-size: var(--responsive-body, var(--font-size-md));
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease, opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover:not(:disabled) {
|
|
||||||
background: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import styles from "./auth-social-button.module.css";
|
|
||||||
|
|
||||||
export interface AuthSocialButtonProps
|
export interface AuthSocialButtonProps
|
||||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
@@ -37,11 +35,20 @@ export function AuthSocialButton({
|
|||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={[styles.button, className].filter(Boolean).join(" ")}
|
className={[
|
||||||
|
"inline-flex min-h-[var(--auth-social-button-height)] w-full cursor-pointer items-center justify-center gap-md rounded-full border border-auth-border bg-auth-surface px-lg text-[var(--responsive-body)] font-medium text-auth-text-primary transition-[background,opacity] duration-150 enabled:hover:bg-[rgba(0,0,0,0.02)] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{icon ? <span className={styles.icon}>{icon}</span> : null}
|
{icon ? (
|
||||||
<span className={styles.label}>{children}</span>
|
<span className="inline-flex size-5 flex-none items-center justify-center">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="flex-none text-center">{children}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
*
|
|
||||||
* 视觉规格(与 Dart 对齐):
|
|
||||||
* - 46px 高
|
|
||||||
* - 白底胶囊(半径 24)
|
|
||||||
* - 无边框
|
|
||||||
* - 10% 粉色阴影(accent #f759a8 @ 10%)
|
|
||||||
* - padding: 0 16 0 24
|
|
||||||
* - 文字深色 #333,占位符 #999
|
|
||||||
*/
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: var(--responsive-caption, var(--font-size-sm));
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--auth-field-height);
|
|
||||||
padding: 0 var(--spacing-lg) 0 var(--spacing-xxl);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-full, 999px);
|
|
||||||
background: var(--color-auth-surface);
|
|
||||||
color: var(--color-auth-text-primary);
|
|
||||||
font-size: var(--responsive-body, var(--font-size-md));
|
|
||||||
box-shadow: 0 3px 5px var(--color-auth-input-shadow);
|
|
||||||
outline: none;
|
|
||||||
transition: box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input::placeholder {
|
|
||||||
color: var(--color-auth-input-placeholder);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
box-shadow: 0 3px 5px var(--color-auth-input-shadow),
|
|
||||||
0 0 0 2px var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid {
|
|
||||||
box-shadow: 0 3px 5px rgba(255, 77, 79, 0.18),
|
|
||||||
0 0 0 1px var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid:focus {
|
|
||||||
box-shadow: 0 3px 5px rgba(255, 77, 79, 0.18),
|
|
||||||
0 0 0 2px var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
font-size: var(--responsive-caption, var(--font-size-sm));
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import styles from "./auth-text-field.module.css";
|
|
||||||
|
|
||||||
export interface AuthTextFieldProps {
|
export interface AuthTextFieldProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -44,14 +42,25 @@ export const AuthTextField = forwardRef<HTMLInputElement, AuthTextFieldProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
const inputClassName = [
|
||||||
|
"min-h-[var(--auth-field-height)] w-full rounded-full border-0 bg-auth-surface py-0 pl-xxl pr-lg text-[var(--responsive-body)] text-auth-text-primary shadow-[0_3px_5px_var(--color-auth-input-shadow)] outline-none transition-shadow duration-150 placeholder:text-auth-input-placeholder focus:shadow-[0_3px_5px_var(--color-auth-input-shadow),0_0_0_2px_var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-70",
|
||||||
|
errorMessage
|
||||||
|
? "shadow-[0_3px_5px_rgba(255,77,79,0.18),0_0_0_1px_var(--color-error)] focus:shadow-[0_3px_5px_rgba(255,77,79,0.18),0_0_0_2px_var(--color-error)]"
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={styles.wrapper}>
|
<label className="flex w-full flex-col gap-xs">
|
||||||
{label ? <span className={styles.label}>{label}</span> : null}
|
{label ? (
|
||||||
|
<span className="text-[var(--responsive-caption)] text-text-secondary">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={[styles.input, errorMessage ? styles.invalid : ""]
|
className={inputClassName}
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
onChange(e.target.value)
|
onChange(e.target.value)
|
||||||
@@ -70,7 +79,9 @@ export const AuthTextField = forwardRef<HTMLInputElement, AuthTextFieldProps>(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<span className={styles.error}>{errorMessage}</span>
|
<span className="text-[var(--responsive-caption)] text-error">
|
||||||
|
{errorMessage}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user