refactor(components): migrate shared styles to tailwind

This commit is contained in:
2026-07-11 13:25:58 +08:00
parent 04af0e25f1
commit bbe1825bb3
7 changed files with 89 additions and 119 deletions
@@ -0,0 +1,54 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { BackButton } from "../back-button";
import { CharacterAvatar } from "../character-avatar";
import { UserMessageAvatar } from "../user-message-avatar";
describe("shared Tailwind components", () => {
it("renders BackButton link and action variants with Tailwind classes", () => {
const linkHtml = renderToStaticMarkup(
<BackButton href="/chat" variant="soft" ariaLabel="Back to chat" />,
);
const actionHtml = renderToStaticMarkup(
<BackButton onClick={() => undefined} variant="ghost" />,
);
expect(linkHtml).toContain('href="/chat"');
expect(linkHtml).toContain('aria-label="Back to chat"');
expect(linkHtml).toContain("inline-flex");
expect(linkHtml).toContain("size-[var(--responsive-icon-button-size,42px)]");
expect(linkHtml).toContain("hover:bg-white");
expect(actionHtml).toContain("<button");
expect(actionHtml).toContain("size-8");
expect(actionHtml).toContain("hover:opacity-70");
});
it("renders CharacterAvatar with dynamic sizing and image utilities", () => {
const html = renderToStaticMarkup(
<CharacterAvatar size={48} imageSize={96} className="custom-avatar" />,
);
expect(html).toContain("inline-flex");
expect(html).toContain("custom-avatar");
expect(html).toContain("width:48px");
expect(html).toContain("height:48px");
expect(html).toContain("size-full object-cover");
expect(html).toContain("%2Fimages%2Fchat%2Fpic-chat-elio.png");
});
it("renders UserMessageAvatar custom and fallback images", () => {
const userHtml = renderToStaticMarkup(
<UserMessageAvatar avatarUrl="/avatar.png" size="3rem" />,
);
const guestHtml = renderToStaticMarkup(<UserMessageAvatar />);
expect(userHtml).toContain('aria-label="User avatar"');
expect(userHtml).toContain("width:3rem");
expect(userHtml).toContain("height:3rem");
expect(userHtml).toContain("size-full object-cover");
expect(userHtml).toContain("%2Favatar.png");
expect(guestHtml).toContain('aria-label="Guest avatar"');
expect(guestHtml).toContain("%2Fimages%2Fchat%2Fpic-chat-guest.png");
});
});
@@ -1,74 +0,0 @@
.backButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--color-auth-text-primary);
cursor: pointer;
border: 0;
border-radius: 999px;
text-decoration: none;
transition:
background 0.15s ease,
box-shadow 0.18s ease,
transform 0.05s ease;
}
.floating {
position: absolute;
top: calc(var(--spacing-xl, 20px) + var(--app-safe-top, 0px));
left: calc(var(--spacing-xl, 20px) + var(--app-safe-left, 0px));
z-index: 2;
width: var(--back-button-size);
height: var(--back-button-size);
padding: 0 2px 0 0;
background: var(--color-auth-surface);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.soft {
width: var(--responsive-icon-button-size, 42px);
height: var(--responsive-icon-button-size, 42px);
color: #21191d;
border: 1px solid rgba(25, 19, 22, 0.08);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 10px 22px rgba(50, 30, 40, 0.08);
}
.ghost {
width: 32px;
height: 32px;
color: #000000;
background: transparent;
}
.floating:hover {
background: rgba(0, 0, 0, 0.04);
}
.soft:hover {
background: #ffffff;
box-shadow: 0 14px 26px rgba(50, 30, 40, 0.12);
transform: translateY(-1px);
}
.ghost:hover {
opacity: 0.7;
}
.floating:active {
transform: scale(0.96);
}
.soft:active {
transform: translateY(1px);
}
.ghost:active {
transform: scale(0.96);
}
.backButton:focus-visible {
outline: 2px solid #f657a0;
outline-offset: 3px;
}
+14 -4
View File
@@ -3,8 +3,6 @@
import { ChevronLeft } from "lucide-react";
import Link, { type LinkProps } from "next/link";
import styles from "./back-button.module.css";
interface BackButtonBaseProps {
className?: string;
ariaLabel?: string;
@@ -24,6 +22,18 @@ type BackButtonActionProps = BackButtonBaseProps & {
export type BackButtonProps = BackButtonLinkProps | BackButtonActionProps;
const BASE_CLASS_NAME =
"inline-flex cursor-pointer items-center justify-center rounded-full border-0 p-0 text-[var(--color-auth-text-primary)] no-underline transition-[background,box-shadow,transform] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]";
const VARIANT_CLASS_NAMES = {
floating:
"absolute left-[calc(var(--spacing-xl,20px)+var(--app-safe-left,0px))] top-[calc(var(--spacing-xl,20px)+var(--app-safe-top,0px))] z-[2] size-[var(--back-button-size)] bg-[var(--color-auth-surface)] pr-[2px] shadow-[0_2px_8px_rgba(0,0,0,0.1)] hover:bg-[rgba(0,0,0,0.04)] active:scale-[0.96]",
soft:
"size-[var(--responsive-icon-button-size,42px)] border border-[rgba(25,19,22,0.08)] bg-[rgba(255,255,255,0.82)] text-[#21191d] shadow-[0_10px_22px_rgba(50,30,40,0.08)] hover:-translate-y-px hover:bg-white hover:shadow-[0_14px_26px_rgba(50,30,40,0.12)] active:translate-y-px",
ghost:
"size-8 bg-transparent text-black hover:opacity-70 active:scale-[0.96]",
} satisfies Record<NonNullable<BackButtonBaseProps["variant"]>, string>;
export function BackButton({
href,
onClick,
@@ -33,8 +43,8 @@ export function BackButton({
variant = "floating",
}: BackButtonProps) {
const buttonClassName = [
styles.backButton,
styles[variant],
BASE_CLASS_NAME,
VARIANT_CLASS_NAMES[variant],
className,
]
.filter(Boolean)
@@ -1,15 +0,0 @@
.avatar {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 9999px;
background: var(--color-avatar-border, #fbf3f5);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
+7 -3
View File
@@ -3,8 +3,6 @@
import Image from "next/image";
import type { CSSProperties } from "react";
import styles from "./character-avatar.module.css";
export interface CharacterAvatarProps {
src?: string;
alt?: string;
@@ -31,7 +29,12 @@ export function CharacterAvatar({
return (
<span
className={[styles.avatar, className].filter(Boolean).join(" ")}
className={[
"inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--color-avatar-border,#fbf3f5)]",
className,
]
.filter(Boolean)
.join(" ")}
style={style}
>
<Image
@@ -40,6 +43,7 @@ export function CharacterAvatar({
width={resolvedImageSize}
height={resolvedImageSize}
priority={priority}
className="size-full object-cover"
/>
</span>
);
@@ -1,19 +0,0 @@
.avatar {
flex: 0 0 auto;
width: var(--chat-avatar-size, 43px);
height: var(--chat-avatar-size, 43px);
border-radius: 9999px;
overflow: hidden;
border: 2px solid var(--color-avatar-border, #fbf3f5);
box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
background: var(--color-avatar-border, #fbf3f5);
display: flex;
align-items: center;
justify-content: center;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
+14 -4
View File
@@ -2,8 +2,6 @@
import Image from "next/image";
import styles from "./user-message-avatar.module.css";
export interface UserMessageAvatarProps {
avatarUrl?: string | null;
className?: string;
@@ -15,7 +13,12 @@ export function UserMessageAvatar({
className,
size = 43,
}: UserMessageAvatarProps) {
const avatarClassName = [styles.avatar, className].filter(Boolean).join(" ");
const avatarClassName = [
"flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-[var(--color-avatar-border,#fbf3f5)] bg-[var(--color-avatar-border,#fbf3f5)] shadow-[var(--shadow-input-box,0_1px_2px_rgba(0,0,0,0.1))]",
className,
]
.filter(Boolean)
.join(" ");
const avatarStyle = { width: size, height: size };
const imageSize = typeof size === "number" ? size : 64;
@@ -26,7 +29,13 @@ export function UserMessageAvatar({
style={avatarStyle}
aria-label="User avatar"
>
<Image src={avatarUrl} alt="" width={imageSize} height={imageSize} />
<Image
src={avatarUrl}
alt=""
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
</div>
);
}
@@ -42,6 +51,7 @@ export function UserMessageAvatar({
alt="Guest"
width={imageSize}
height={imageSize}
className="size-full object-cover"
/>
</div>
);