diff --git a/frontend/src/components/reader-settings.test.tsx b/frontend/src/components/reader-settings.test.tsx new file mode 100644 index 0000000..a208e6b --- /dev/null +++ b/frontend/src/components/reader-settings.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect } from "vitest"; +import { beforeEach, describe, it } from "vitest"; +import { ReaderSettings } from "@/components/reader-settings"; +import { ThemeProvider } from "@/components/theme"; +import { DEFAULT_PREFS } from "@/lib/readerPrefs"; + +beforeEach(() => { + localStorage.clear(); +}); + +function renderSettings(props = {}) { + return render( + + {}, ariaLabel: "页码跳转" }} + position="3/12 · 25%" + {...props} + /> + , + ); +} + +describe("ReaderSettings", () => { + it("常驻行渲染:滑条 aria-label 与位置文本", () => { + renderSettings(); + expect(screen.getByRole("slider", { name: "页码跳转" })).toBeInTheDocument(); + expect(screen.getByText("3/12 · 25%")).toBeInTheDocument(); + }); + + it("主题卡点击 → localStorage 的 themeMode 变手动值", () => { + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "夜色主题" })); + const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}"); + expect(stored.themeMode).toBe("night"); + }); + + it("「跟随全局」按钮存在且点击回 auto", () => { + renderSettings(); + const follow = screen.getByRole("button", { name: "跟随全局" }); + expect(follow).toBeInTheDocument(); + expect(follow).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "米色主题" })); + expect(screen.getByRole("button", { name: "跟随全局" })).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(screen.getByRole("button", { name: "跟随全局" })); + const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}"); + expect(stored.themeMode).toBe("auto"); + }); + + it("showFont=false 时无 A−/A+", () => { + renderSettings({ showFont: false }); + expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument(); + }); + + it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => { + renderSettings({ extra:
额外面板
}); + const toggle = screen.getByRole("button", { name: "更多阅读设置" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByTestId("extra")).not.toBeInTheDocument(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByTestId("extra")).toBeInTheDocument(); + }); + + it("showFont 时 A+ 点击 → text.sizeIdx 增加", () => { + renderSettings({ showFont: true }); + fireEvent.click(screen.getByRole("button", { name: "加大字号" })); + const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}"); + expect(stored.text.sizeIdx).toBe(DEFAULT_PREFS.text.sizeIdx + 1); + }); +}); diff --git a/frontend/src/components/reader-settings.tsx b/frontend/src/components/reader-settings.tsx new file mode 100644 index 0000000..a80044b --- /dev/null +++ b/frontend/src/components/reader-settings.tsx @@ -0,0 +1,116 @@ +import { ChevronDown, ChevronUp } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { useGlobalDark } from "@/components/theme"; +import { RD_THEMES, RD_THEME_LABEL, FONT_SIZES, resolveRdTheme, useReaderPrefs, type RdTheme } from "@/lib/readerPrefs"; + +export interface ReaderSettingsProps { + /** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数 */ + slider: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string }; + /** 位置文本,如 "3/12 · 25%" */ + position?: string; + /** 左半区按钮(导航/上一章/下一章等),调用方给 */ + left?: ReactNode; + /** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */ + showFont?: boolean; + /** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */ + extra?: ReactNode; +} + +/** 主题色卡样式(纸 #faf8f4 / 米 #f0e6d2 / 夜 #171512,与 --rd-bg 三值一致) */ +const SWATCH: Record = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" }; + +/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */ +export function ReaderSettings({ slider, position, left, showFont = false, extra }: ReaderSettingsProps) { + const pr = useReaderPrefs(); + const globalDark = useGlobalDark(); + const rd = resolveRdTheme(pr.prefs.themeMode, globalDark); + const [open, setOpen] = useState(false); + + return ( +
+
+ {left &&
{left}
} + {position && ( + + {position} + + )} +
+ {RD_THEMES.map((t) => ( + +
+
+ {showFont && ( + <> + + + + )} + {extra && ( + + )} +
+
+ slider.onChange(v)} + aria-label={slider.ariaLabel} + /> + {extra && open &&
{extra}
} +
+ ); +} diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx index 49d4c7d..82be525 100644 --- a/frontend/src/components/ui/slider.tsx +++ b/frontend/src/components/ui/slider.tsx @@ -14,6 +14,8 @@ function Slider({ () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), [value, defaultValue, min, max], ); + // radix 的可访问 slider 角色在 Thumb 上,aria-label 提到 Root 传不进去——改下沉到每个 Thumb。 + const { "aria-label": ariaLabel, ...rootProps } = props; return ( ))}