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>
);
}
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ChatSendButton } from "../chat-send-button";
import { ImageBubble } from "../image-bubble";
import { MessageAvatar } from "../message-avatar";
import { PrivateMessageCard } from "../private-message-card";
import { TextBubble } from "../text-bubble";
@@ -90,4 +91,29 @@ describe("chat Tailwind components", () => {
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]");
expect(disabledHtml).toContain("disabled");
});
it("renders ImageBubble openable and paywalled states", () => {
const openableHtml = renderToStaticMarkup(
<ImageBubble
messageId="message-1"
imageUrl="/chat-image.png"
onOpenImage={() => undefined}
/>,
);
const paywalledHtml = renderToStaticMarkup(
<ImageBubble imageUrl="/locked-image.png" imagePaywalled />,
);
expect(openableHtml).toContain('role="button"');
expect(openableHtml).toContain('tabindex="0"');
expect(openableHtml).toContain('aria-label="Open image in fullscreen"');
expect(openableHtml).toContain("rounded-tl-none");
expect(openableHtml).toContain("block h-auto w-full object-cover");
expect(openableHtml).toContain("data:image/png;base64,/chat-image.png");
expect(paywalledHtml).toContain("scale-[1.04] blur-[8px]");
expect(paywalledHtml).toContain(
"bg-[linear-gradient(180deg,rgba(255,255,255,0.04)_0%,rgba(255,255,255,0.14)_100%)]",
);
expect(paywalledHtml).toContain("data:image/png;base64,/locked-image.png");
});
});
@@ -1,47 +0,0 @@
/* ImageBubble 图片气泡样式 */
.bubble {
position: relative;
max-width: var(--chat-media-size, 220px);
max-height: var(--chat-media-size, 220px);
border-radius: var(--radius-lg, 12px);
border-top-left-radius: 0;
overflow: hidden;
cursor: pointer;
background: var(--color-bubble-background, #fff);
}
.image {
display: block;
width: 100%;
height: auto;
object-fit: cover;
}
.paywalled .image {
filter: blur(8px);
transform: scale(1.04);
}
.paywallOverlay {
position: absolute;
inset: 0;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.14) 100%
);
pointer-events: none;
}
.errorFallback {
display: flex;
align-items: center;
justify-content: center;
width: var(--chat-media-size, 220px);
height: var(--chat-media-size, 220px);
background: var(--color-bubble-background, #fff);
color: var(--color-text-secondary, #9e9e9e);
font-size: var(--icon-size-xl, 24px);
}
+13 -5
View File
@@ -9,7 +9,6 @@
*/
import { ChatMediaImage } from "./chat-media-image";
import styles from "./image-bubble.module.css";
export interface ImageBubbleProps {
messageId?: string;
@@ -25,6 +24,12 @@ export function ImageBubble({
onOpenImage,
}: ImageBubbleProps) {
const canOpen = Boolean(messageId && onOpenImage);
const imageClassName = [
"block h-auto w-full object-cover",
imagePaywalled ? "scale-[1.04] blur-[8px]" : "",
]
.filter(Boolean)
.join(" ");
const openImage = () => {
if (!messageId || !onOpenImage) return;
@@ -33,7 +38,7 @@ export function ImageBubble({
return (
<div
className={`${styles.bubble} ${imagePaywalled ? styles.paywalled : ""}`}
className="relative max-h-[var(--chat-media-size,220px)] max-w-[var(--chat-media-size,220px)] cursor-pointer overflow-hidden rounded-[var(--radius-lg,12px)] rounded-tl-none bg-[var(--color-bubble-background,#fff)]"
onClick={openImage}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
@@ -49,13 +54,16 @@ export function ImageBubble({
<ChatMediaImage
messageId={messageId}
remoteUrl={imageUrl}
className={styles.image}
errorClassName={styles.errorFallback}
className={imageClassName}
errorClassName="flex size-[var(--chat-media-size,220px)] items-center justify-center bg-[var(--color-bubble-background,#fff)] text-[var(--icon-size-xl,24px)] text-[var(--color-text-secondary,#9e9e9e)]"
width={240}
height={240}
>
{imagePaywalled ? (
<div className={styles.paywallOverlay} aria-hidden="true" />
<div
className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04)_0%,rgba(255,255,255,0.14)_100%)]"
aria-hidden="true"
/>
) : null}
</ChatMediaImage>
</div>