fix(web): filter toggle hover as border, not background (#1460) (#1461)

* fix(web): filter toggle hover as border, not background (#1460)

The Logging/History/Network filter toggles used the same
--mantine-primary-color-light background for both their hover and their
active (toggled-on) state, so clicking a button to turn it off while the
cursor was still over it looked like a no-op — the hover background
masked the removal of the active background.

- Add a shared FilterToggleButton element used by LogControls,
  NetworkControls, and MessageDirectionFilter, replacing the duplicated
  UnstyledButton + inline `bg={active ? … : undefined}` in each.
- Add a `filterToggle` UnstyledButton theme variant + `.filter-toggle`
  rules: hover is now a thin border (reserved via a transparent 1px
  border so it doesn't shift layout); the active state is the filled
  background, keyed off `aria-pressed="true"`.
- This leaves the `.list-item` selectable rows (Prompts/Apps/Tools/
  Resources lists, section headers) untouched — they keep their
  hover-fill since they aren't toggles.
- Add `aria-pressed` to the Logging level toggles (previously missing).

Tests + stories cover the active/hover distinction via aria-pressed for
FilterToggleButton and all three controls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): move filter-toggle border to CSS so :hover actually shows (#1460)

The previous attempt set the reserved `1px solid transparent` border via
the theme's `styles` callback, which Mantine emits as an *inline* style.
Inline styles outrank stylesheet `:hover` rules, so
`.filter-toggle:hover { border-color }` never applied — hovering showed
no border at all (verified in a real browser via Playwright).

Move the border (and border-radius) out of the inline theme styles into
the `.filter-toggle` class in App.css, so `:hover` can override the
transparent border-color. The active fill (`[aria-pressed="true"]`) was
already correct.

Verified with real Playwright page.hover: inactive+hover now shows a
1px --inspector-brand-primary border, active shows the fill, active+hover
shows both, all distinct. Story play functions assert the active fill and
the reserved transparent border (the hover border can't be asserted in the
synthetic-event test runner, so it's documented as Playwright-verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-06-13 19:52:27 -04:00 • committed by GitHub
commit d90cc4c34b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 256 additions and 68 deletions

View file

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { FilterToggleButton } from "./FilterToggleButton";
const meta: Meta<typeof FilterToggleButton> = {
title: "Elements/FilterToggleButton",
component: FilterToggleButton,
args: {
label: "debug",
color: "blue",
onToggle: fn(),
},
};
export default meta;
type Story = StoryObj<typeof FilterToggleButton>;
// Active (on): filled background, aria-pressed=true.
export const Active: Story = {
args: {
active: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole("button", { name: "debug" });
await expect(button).toHaveAttribute("aria-pressed", "true");
// Active renders a filled background (driven by `[aria-pressed="true"]`).
await expect(getComputedStyle(button).backgroundColor).not.toBe(
"rgba(0, 0, 0, 0)",
);
},
};
const TRANSPARENT = "rgba(0, 0, 0, 0)";
// Inactive (off): no fill, and the reserved border is transparent. The visible
// hover border is intentionally NOT asserted here — the Storybook/vitest runner
// dispatches synthetic pointer events that don't engage real CSS `:hover`. The
// border-on-hover (and that it isn't an inline style outranking the `:hover`
// rule — the #1460 regression) was verified with real Playwright `page.hover`.
export const Inactive: Story = {
args: {
active: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = await canvas.findByRole("button", { name: "debug" });
await expect(button).toHaveAttribute("aria-pressed", "false");
// The reserved border is transparent by default and there's no active fill,
// so the inactive button reads as empty until hovered or toggled on.
await expect(getComputedStyle(button).borderTopColor).toBe(TRANSPARENT);
await expect(getComputedStyle(button).backgroundColor).toBe(TRANSPARENT);
},
};
// Clicking an active button invokes onToggle(false).
export const TogglesOff: Story = {
args: {
active: true,
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "debug" }));
await expect(args.onToggle).toHaveBeenCalledWith(false);
},
};

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { FilterToggleButton } from "./FilterToggleButton";
const baseProps = {
label: "debug",
color: "blue",
active: false,
onToggle: vi.fn(),
};
describe("FilterToggleButton", () => {
it("renders the label as the accessible name", () => {
renderWithMantine(<FilterToggleButton {...baseProps} />);
expect(screen.getByRole("button", { name: "debug" })).toBeInTheDocument();
});
it("reflects the active state via aria-pressed=true", () => {
renderWithMantine(<FilterToggleButton {...baseProps} active={true} />);
expect(screen.getByRole("button", { name: "debug" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("reflects the inactive state via aria-pressed=false", () => {
renderWithMantine(<FilterToggleButton {...baseProps} active={false} />);
expect(screen.getByRole("button", { name: "debug" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
it("toggles to true when an inactive button is clicked", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(
<FilterToggleButton {...baseProps} active={false} onToggle={onToggle} />,
);
await user.click(screen.getByRole("button", { name: "debug" }));
expect(onToggle).toHaveBeenCalledWith(true);
});
it("toggles to false when an active button is clicked", async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
renderWithMantine(
<FilterToggleButton {...baseProps} active={true} onToggle={onToggle} />,
);
await user.click(screen.getByRole("button", { name: "debug" }));
expect(onToggle).toHaveBeenCalledWith(false);
});
});

View file

@ -0,0 +1,44 @@
import { Text, UnstyledButton } from "@mantine/core";
export interface FilterToggleButtonProps {
/** Visible label and accessible name for the toggle. */
label: string;
/** Mantine text color for the label (e.g. "blue", "red", "dimmed"). */
color: string;
/** Whether the filter is currently on (rendered as a filled background). */
active: boolean;
/** Receives the next desired active state when the button is clicked. */
onToggle: (active: boolean) => void;
}
const ToggleLabel = Text.withProps({
ta: "center",
fw: 500,
});
/**
* A single full-width filter toggle used by the Logging, History, and Network
* controls. The `filterToggle` theme variant + `.filter-toggle` rules own the
* styling: hover shows a thin border, the active (`aria-pressed`) state shows a
* filled background. Keeping hover as a border (not a fill) means toggling a
* button off while the cursor is still over it is visibly distinct from hover,
* instead of the two states sharing the same background. See issue #1460.
*/
export function FilterToggleButton({
label,
color,
active,
onToggle,
}: FilterToggleButtonProps) {
return (
<UnstyledButton
w="100%"
p="sm"
variant="filterToggle"
aria-pressed={active}
onClick={() => onToggle(!active)}
>
<ToggleLabel c={color}>{label}</ToggleLabel>
</UnstyledButton>
);
}