refactor(app): migrate small components to tailwind

This commit is contained in:
2026-07-13 10:22:10 +08:00
parent 965e159a95
commit da04eaa372
9 changed files with 77 additions and 306 deletions
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Checkbox } from "../checkbox";
import { LoadingIndicator } from "../loading-indicator";
import { PageLoadingFallback } from "../page-loading-fallback";
@@ -32,4 +33,25 @@ describe("core Tailwind components", () => {
expect(html).toContain("text-[rgba(255,255,255,0.72)]");
expect(html).toContain("Loading chat...");
});
it("renders Checkbox with checked and labeled states", () => {
const checkedHtml = renderToStaticMarkup(
<Checkbox checked onChange={() => undefined} ariaLabel="Accept terms" />,
);
const labeledHtml = renderToStaticMarkup(
<Checkbox checked={false} onChange={() => undefined} label="Keep me signed in" />,
);
expect(checkedHtml).toContain('role="checkbox"');
expect(checkedHtml).toContain('aria-checked="true"');
expect(checkedHtml).toContain('aria-label="Accept terms"');
expect(checkedHtml).toContain("size-[var(--auth-legal-checkbox-size)]");
expect(checkedHtml).toContain(
"size-[var(--auth-legal-checkbox-inner-size)]",
);
expect(labeledHtml).toContain("<label");
expect(labeledHtml).toContain("Keep me signed in");
expect(labeledHtml).toContain("gap-[var(--spacing-sm)]");
});
});
@@ -1,56 +0,0 @@
/* 通用圆形复选框
*
* 视觉规格:
* - 16px 白色圆形复选框 + 1px 浅阴影
* - 选中时 10px 粉色实心圆
* - 行内标签可点击
*/
.row {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
width: 100%;
cursor: pointer;
}
.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);
}
.checkbox:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.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 {
font-size: var(--font-size-sm);
line-height: 1.4;
color: var(--color-auth-text-primary);
text-align: left;
flex: 1 1 auto;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
+16 -8
View File
@@ -9,8 +9,6 @@
*/
import type { ReactNode } from "react";
import styles from "./checkbox.module.css";
export interface CheckboxProps {
checked: boolean;
onChange: (next: boolean) => void;
@@ -30,6 +28,9 @@ export function Checkbox({
className,
ariaLabel,
}: CheckboxProps) {
const checkboxClassName = [
"mt-0.5 inline-flex size-[var(--auth-legal-checkbox-size)] shrink-0 cursor-pointer items-center justify-center rounded-full border border-[var(--color-auth-surface)] bg-[var(--color-auth-surface)] p-0 shadow-[0_0_0_1px_rgba(0,0,0,0.06)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)] disabled:cursor-not-allowed disabled:opacity-50",
].join(" ");
const button = (
<button
type="button"
@@ -38,11 +39,11 @@ export function Checkbox({
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={[styles.checkbox, disabled ? styles.disabled : ""]
.filter(Boolean)
.join(" ")}
className={checkboxClassName}
>
{checked ? <span className={styles.checkboxInner} /> : null}
{checked ? (
<span className="block size-[var(--auth-legal-checkbox-inner-size)] rounded-full bg-[var(--color-auth-legal-check)]" />
) : null}
</button>
);
@@ -56,10 +57,17 @@ export function Checkbox({
return (
<label
className={[styles.row, className].filter(Boolean).join(" ")}
className={[
"flex w-full cursor-pointer items-start gap-[var(--spacing-sm)]",
className,
]
.filter(Boolean)
.join(" ")}
>
{button}
<span className={styles.text}>{label}</span>
<span className="flex-auto text-left text-[var(--font-size-sm)] leading-[1.4] text-[var(--color-auth-text-primary)]">
{label}
</span>
</label>
);
}
-1
View File
@@ -11,4 +11,3 @@ export * from "./mobile-shell";
export * from "./page-loading-fallback";
export * from "./page-scaffold";
export * from "./responsive-mobile-shell";
export * from "./settings-section";
@@ -1,107 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.title {
margin: 0;
padding: 0 var(--spacing-xs);
font-size: var(--font-size-md);
font-weight: 600;
color: #000000;
text-transform: none;
letter-spacing: 0;
}
.list {
margin: 0;
padding: 0;
list-style: none;
background: var(--color-settings-card-background, #ffffff);
border-radius: var(--radius-lg, 12px);
overflow: hidden;
}
.list > li {
background: var(--color-settings-row-background, #f5f5f5);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-md);
width: 100%;
padding: var(--spacing-md);
border: none;
background: transparent;
color: #000000;
font-size: var(--font-size-lg);
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.row:hover {
background: rgba(0, 0, 0, 0.04);
}
.row:focus-visible {
outline: var(--border-medium) solid var(--color-accent);
outline-offset: -2px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--responsive-icon-size-md, 24px);
height: var(--responsive-icon-size-md, 24px);
color: var(--color-chevron-icon, #000000);
flex-shrink: 0;
}
.body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
}
.rowTitle {
font-size: var(--font-size-lg);
font-weight: 400;
color: #000000;
}
.subtitle {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.trailing {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.chevron {
color: var(--color-chevron-icon, #000000);
flex-shrink: 0;
width: 20px;
height: 20px;
}
.divider {
height: 1px;
background: rgba(61, 49, 74, 0.5);
margin: 0 var(--spacing-md);
}
.destructive {
color: var(--color-destructive, #ff6b6b);
}
.destructive .rowTitle {
color: var(--color-destructive, #ff6b6b);
}
@@ -1,82 +0,0 @@
"use client";
/**
* 设置项列表 Section
*
*
*
* 渲染一组 SettingItem:图标 + 标题 + 副标题/值 + 右侧 chevron + 可选 divider。
*/
import { type ReactNode } from "react";
import styles from "./settings-section.module.css";
export interface SettingsItemModel {
id: string;
title: string;
subtitle?: string;
trailing?: string;
icon?: ReactNode;
onClick?: () => void;
destructive?: boolean;
}
export interface SettingsSectionProps {
title?: string;
items: SettingsItemModel[];
}
export function SettingsSection({ title, items }: SettingsSectionProps) {
return (
<section className={styles.section}>
{title ? <h3 className={styles.title}>{title}</h3> : null}
<ul className={styles.list}>
{items.map((item, idx) => (
<li key={item.id}>
<button
type="button"
onClick={item.onClick}
className={[
styles.row,
item.destructive ? styles.destructive : "",
]
.filter(Boolean)
.join(" ")}
>
{item.icon ? (
<span className={styles.icon}>{item.icon}</span>
) : null}
<span className={styles.body}>
<span className={styles.rowTitle}>{item.title}</span>
{item.subtitle ? (
<span className={styles.subtitle}>{item.subtitle}</span>
) : null}
</span>
{item.trailing ? (
<span className={styles.trailing}>{item.trailing}</span>
) : null}
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={styles.chevron}
>
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{idx < items.length - 1 ? (
<div className={styles.divider} />
) : null}
</li>
))}
</ul>
</section>
);
}