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 { 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 { EmailRegisterForm } from "../email-register-form";
|
||||
|
||||
@@ -49,4 +52,42 @@ describe("auth Tailwind components", () => {
|
||||
expect(html).toContain("object-cover");
|
||||
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 styles from "./auth-legal-text.module.css";
|
||||
|
||||
export function AuthLegalText() {
|
||||
const [checked, setChecked] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className="flex w-full items-start gap-sm">
|
||||
<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)}
|
||||
aria-label="Agree to terms"
|
||||
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>
|
||||
<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 {" "}
|
||||
<a
|
||||
href={AppConstants.privacyPolicyUrl}
|
||||
className={styles.link}
|
||||
className="font-bold text-auth-text-primary no-underline hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -44,7 +44,7 @@ export function AuthLegalText() {
|
||||
and{" "}
|
||||
<a
|
||||
href={AppConstants.termsOfServiceUrl}
|
||||
className={styles.link}
|
||||
className="font-bold text-auth-text-primary no-underline hover:underline"
|
||||
target="_blank"
|
||||
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,
|
||||
} from "react";
|
||||
|
||||
import styles from "./auth-social-button.module.css";
|
||||
|
||||
export interface AuthSocialButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
icon?: ReactNode;
|
||||
@@ -37,11 +35,20 @@ export function AuthSocialButton({
|
||||
<button
|
||||
type={type}
|
||||
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}
|
||||
>
|
||||
{icon ? <span className={styles.icon}>{icon}</span> : null}
|
||||
<span className={styles.label}>{children}</span>
|
||||
{icon ? (
|
||||
<span className="inline-flex size-5 flex-none items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-none text-center">{children}</span>
|
||||
</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,
|
||||
} from "react";
|
||||
|
||||
import styles from "./auth-text-field.module.css";
|
||||
|
||||
export interface AuthTextFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -44,14 +42,25 @@ export const AuthTextField = forwardRef<HTMLInputElement, AuthTextFieldProps>(
|
||||
},
|
||||
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 (
|
||||
<label className={styles.wrapper}>
|
||||
{label ? <span className={styles.label}>{label}</span> : null}
|
||||
<label className="flex w-full flex-col gap-xs">
|
||||
{label ? (
|
||||
<span className="text-[var(--responsive-caption)] text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
ref={ref}
|
||||
className={[styles.input, errorMessage ? styles.invalid : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
className={inputClassName}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(e.target.value)
|
||||
@@ -70,7 +79,9 @@ export const AuthTextField = forwardRef<HTMLInputElement, AuthTextFieldProps>(
|
||||
}}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<span className={styles.error}>{errorMessage}</span>
|
||||
<span className="text-[var(--responsive-caption)] text-error">
|
||||
{errorMessage}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user