refactor(app): migrate small components to tailwind
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Checkbox } from "../checkbox";
|
||||||
import { LoadingIndicator } from "../loading-indicator";
|
import { LoadingIndicator } from "../loading-indicator";
|
||||||
import { PageLoadingFallback } from "../page-loading-fallback";
|
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("text-[rgba(255,255,255,0.72)]");
|
||||||
expect(html).toContain("Loading chat...");
|
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 type { ReactNode } from "react";
|
||||||
|
|
||||||
import styles from "./checkbox.module.css";
|
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (next: boolean) => void;
|
onChange: (next: boolean) => void;
|
||||||
@@ -30,6 +28,9 @@ export function Checkbox({
|
|||||||
className,
|
className,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}: CheckboxProps) {
|
}: 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 = (
|
const button = (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -38,11 +39,11 @@ export function Checkbox({
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={[styles.checkbox, disabled ? styles.disabled : ""]
|
className={checkboxClassName}
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,10 +57,17 @@ export function Checkbox({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<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}
|
{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>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ export * from "./mobile-shell";
|
|||||||
export * from "./page-loading-fallback";
|
export * from "./page-loading-fallback";
|
||||||
export * from "./page-scaffold";
|
export * from "./page-scaffold";
|
||||||
export * from "./responsive-mobile-shell";
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { ChatSendButton } from "../chat-send-button";
|
import { ChatSendButton } from "../chat-send-button";
|
||||||
|
import { ImageBubble } from "../image-bubble";
|
||||||
import { MessageAvatar } from "../message-avatar";
|
import { MessageAvatar } from "../message-avatar";
|
||||||
import { PrivateMessageCard } from "../private-message-card";
|
import { PrivateMessageCard } from "../private-message-card";
|
||||||
import { TextBubble } from "../text-bubble";
|
import { TextBubble } from "../text-bubble";
|
||||||
@@ -90,4 +91,29 @@ describe("chat Tailwind components", () => {
|
|||||||
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]");
|
expect(emptyHtml).toContain("text-[rgba(255,255,255,0.88)]");
|
||||||
expect(disabledHtml).toContain("disabled");
|
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 { ChatMediaImage } from "./chat-media-image";
|
||||||
import styles from "./image-bubble.module.css";
|
|
||||||
|
|
||||||
export interface ImageBubbleProps {
|
export interface ImageBubbleProps {
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -25,6 +24,12 @@ export function ImageBubble({
|
|||||||
onOpenImage,
|
onOpenImage,
|
||||||
}: ImageBubbleProps) {
|
}: ImageBubbleProps) {
|
||||||
const canOpen = Boolean(messageId && onOpenImage);
|
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 = () => {
|
const openImage = () => {
|
||||||
if (!messageId || !onOpenImage) return;
|
if (!messageId || !onOpenImage) return;
|
||||||
@@ -33,7 +38,7 @@ export function ImageBubble({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={openImage}
|
||||||
role={canOpen ? "button" : undefined}
|
role={canOpen ? "button" : undefined}
|
||||||
tabIndex={canOpen ? 0 : undefined}
|
tabIndex={canOpen ? 0 : undefined}
|
||||||
@@ -49,13 +54,16 @@ export function ImageBubble({
|
|||||||
<ChatMediaImage
|
<ChatMediaImage
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
remoteUrl={imageUrl}
|
remoteUrl={imageUrl}
|
||||||
className={styles.image}
|
className={imageClassName}
|
||||||
errorClassName={styles.errorFallback}
|
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}
|
width={240}
|
||||||
height={240}
|
height={240}
|
||||||
>
|
>
|
||||||
{imagePaywalled ? (
|
{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}
|
) : null}
|
||||||
</ChatMediaImage>
|
</ChatMediaImage>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user