refactor(app): migrate small components to tailwind
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user