fix(ui): rebuild SettingSwitch on Obsidian native toggle vars (#2651)

The switch mixed CSS units: the track width used Tailwind's default
`w-10` (2.5rem, font-relative) while height, thumb, and travel were
fixed px via Obsidian's --size-4-* tokens. Obsidian's interface
font-size setting drives the rem root, so changing it stretched the
width alone and the thumb stopped landing at the edge (the reported
break).

Source all geometry from Obsidian's native small-toggle variables
(--toggle-s-width/-thumb-width/-thumb-height/-border-width,
--toggle-radius, --toggle-thumb-radius) so the switch is 100% px and
pixel-matches Obsidian's own toggles; the thumb travel is derived from
those sizes rather than a hardcoded distance. Public API, ARIA, and
keyboard behavior unchanged. Adds a React Testing Library behavior test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-07-03 05:02:23 +08:00 committed by GitHub
parent 55c9a2aa0c
commit 703529ac67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 3 deletions

View file

@ -0,0 +1,67 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { SettingSwitch } from "./setting-switch";
describe("SettingSwitch", () => {
it("renders an element with role=switch", () => {
render(<SettingSwitch />);
expect(screen.getByRole("switch")).not.toBeNull();
});
it("reflects the checked state via aria-checked and data-state", () => {
const { rerender } = render(<SettingSwitch checked />);
const on = screen.getByRole("switch");
expect(on.getAttribute("aria-checked")).toBe("true");
expect(on.getAttribute("data-state")).toBe("checked");
rerender(<SettingSwitch checked={false} />);
const off = screen.getByRole("switch");
expect(off.getAttribute("aria-checked")).toBe("false");
expect(off.getAttribute("data-state")).toBe("unchecked");
});
it("calls onCheckedChange with the negation of checked when clicked", () => {
const onCheckedChange = jest.fn();
const { rerender } = render(
<SettingSwitch checked={false} onCheckedChange={onCheckedChange} />
);
fireEvent.click(screen.getByRole("switch"));
expect(onCheckedChange).toHaveBeenLastCalledWith(true);
rerender(<SettingSwitch checked onCheckedChange={onCheckedChange} />);
fireEvent.click(screen.getByRole("switch"));
expect(onCheckedChange).toHaveBeenLastCalledWith(false);
});
it("toggles on Enter and on Space", () => {
const onCheckedChange = jest.fn();
render(<SettingSwitch checked={false} onCheckedChange={onCheckedChange} />);
const sw = screen.getByRole("switch");
fireEvent.keyDown(sw, { key: "Enter" });
expect(onCheckedChange).toHaveBeenCalledTimes(1);
fireEvent.keyDown(sw, { key: " " });
expect(onCheckedChange).toHaveBeenCalledTimes(2);
expect(onCheckedChange).toHaveBeenNthCalledWith(1, true);
expect(onCheckedChange).toHaveBeenNthCalledWith(2, true);
fireEvent.keyDown(sw, { key: "a" });
expect(onCheckedChange).toHaveBeenCalledTimes(2);
});
it("does not toggle and is not focusable when disabled", () => {
const onCheckedChange = jest.fn();
render(<SettingSwitch checked={false} disabled onCheckedChange={onCheckedChange} />);
const sw = screen.getByRole("switch");
fireEvent.click(sw);
fireEvent.keyDown(sw, { key: "Enter" });
fireEvent.keyDown(sw, { key: " " });
expect(onCheckedChange).not.toHaveBeenCalled();
expect(sw.getAttribute("aria-disabled")).toBe("true");
expect(sw.tabIndex).toBe(-1);
});
});

View file

@ -33,7 +33,7 @@ const SettingSwitch = React.forwardRef<HTMLDivElement, SettingSwitchProps>(
ref={ref}
tabIndex={disabled ? -1 : 0}
className={cn(
"tw-relative tw-inline-flex tw-h-5.5 tw-w-10 tw-shrink-0 tw-cursor-pointer tw-items-center tw-rounded-full tw-transition-colors",
"tw-relative tw-inline-flex tw-h-[calc(var(--toggle-s-thumb-height)_+_var(--toggle-s-border-width)*2)] tw-w-[var(--toggle-s-width)] tw-shrink-0 tw-cursor-pointer tw-items-center tw-rounded-[var(--toggle-radius)] tw-transition-colors",
"focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-ring focus-visible:tw-ring-offset-2",
checked ? "tw-bg-interactive-accent" : "tw-bg-[--background-modifier-border-hover]",
disabled && "tw-cursor-not-allowed tw-opacity-50",
@ -45,8 +45,10 @@ const SettingSwitch = React.forwardRef<HTMLDivElement, SettingSwitchProps>(
>
<div
className={cn(
"tw-pointer-events-none tw-block tw-size-4 tw-rounded-full tw-bg-toggle-thumb tw-shadow-lg tw-ring-0 tw-transition-transform",
checked ? "tw-translate-x-5.5" : "tw-translate-x-0.5"
"tw-pointer-events-none tw-block tw-h-[var(--toggle-s-thumb-height)] tw-w-[var(--toggle-s-thumb-width)] tw-rounded-[var(--toggle-thumb-radius)] tw-bg-toggle-thumb tw-shadow-sm tw-ring-0 tw-transition-transform",
checked
? "tw-translate-x-[calc(var(--toggle-s-width)_-_var(--toggle-s-thumb-width)_-_var(--toggle-s-border-width))]"
: "tw-translate-x-[var(--toggle-s-border-width)]"
)}
/>
</div>