diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb355c..754c722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `FloatingToggle`: single-cell floating card with an `aria-pressed` icon button, the on/off counterpart of the `floating` `SegmentedControl`, for placing beside `ZoomControls`. + +### Changed + +- `floatingControlCellStateClassName` in `floating-control.ts` now holds the selected/unselected cell styles shared by `SegmentedControl` and `FloatingToggle`. + ## [0.23.0] - 2026-11-12 ### Added diff --git a/packages/ui/src/FloatingToggle.test.tsx b/packages/ui/src/FloatingToggle.test.tsx new file mode 100644 index 0000000..421ec83 --- /dev/null +++ b/packages/ui/src/FloatingToggle.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { FloatingToggle } from "./FloatingToggle"; + +describe("FloatingToggle", () => { + it("exposes the pressed state", () => { + render( + {}}> + + , + ); + expect(screen.getByRole("button", { name: "Night mode" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("requests the opposite state on click", () => { + const onPressedChange = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Night mode" })); + expect(onPressedChange).toHaveBeenCalledWith(true); + }); + + it("does not fire when disabled", () => { + const onPressedChange = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Night mode" })); + expect(onPressedChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/FloatingToggle.tsx b/packages/ui/src/FloatingToggle.tsx new file mode 100644 index 0000000..b294f6b --- /dev/null +++ b/packages/ui/src/FloatingToggle.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { ReactNode } from "react"; +import { cn } from "./class-name"; +import { + floatingControlCardClassName, + floatingControlCellClassName, + floatingControlCellStateClassName, +} from "./floating-control"; + +export interface FloatingToggleProps { + pressed: boolean; + onPressedChange: (pressed: boolean) => void; + /** Icon shown in the cell. */ + children: ReactNode; + "aria-label": string; + disabled?: boolean; + title?: string; + className?: string; +} + +/** + * Single-cell floating card holding an on/off icon button: the toggle counterpart of the + * `floating` `SegmentedControl`. Unpositioned: the caller places it over its canvas. + */ +export function FloatingToggle({ + pressed, + onPressedChange, + children, + "aria-label": ariaLabel, + disabled, + title, + className, +}: FloatingToggleProps) { + return ( +
+ +
+ ); +} diff --git a/packages/ui/src/SegmentedControl.tsx b/packages/ui/src/SegmentedControl.tsx index 8f92cee..4780819 100644 --- a/packages/ui/src/SegmentedControl.tsx +++ b/packages/ui/src/SegmentedControl.tsx @@ -2,7 +2,11 @@ import { ReactNode } from "react"; import { cn } from "./class-name"; -import { floatingControlCardClassName, floatingControlCellClassName } from "./floating-control"; +import { + floatingControlCardClassName, + floatingControlCellClassName, + floatingControlCellStateClassName, +} from "./floating-control"; export interface SegmentedControlOption { value: T; @@ -62,17 +66,11 @@ export function SegmentedControl({ floating ? cn("inline-flex items-center justify-center rounded-md", floatingControlCellClassName) : "rounded px-3 py-1 text-sm [@media(pointer:coarse)]:min-h-[var(--layout-touch-target)] [@media(pointer:coarse)]:px-4", - selected - ? cn( - "text-[var(--color-text)]", - floating - ? "bg-[var(--color-surface-muted)] ring-1 ring-inset ring-[var(--color-border)]" - : "bg-[var(--color-surface)] font-medium shadow-sm", - ) - : cn( - "text-[var(--color-text-muted)] enabled:hover:text-[var(--color-text)]", - floating && "enabled:hover:bg-[var(--color-surface-muted)]", - ), + floating + ? floatingControlCellStateClassName(selected) + : selected + ? "bg-[var(--color-surface)] font-medium text-[var(--color-text)] shadow-sm" + : "text-[var(--color-text-muted)] enabled:hover:text-[var(--color-text)]", )} > {option.label} diff --git a/packages/ui/src/floating-control.ts b/packages/ui/src/floating-control.ts index aba0c5b..32df2a8 100644 --- a/packages/ui/src/floating-control.ts +++ b/packages/ui/src/floating-control.ts @@ -7,3 +7,10 @@ export const floatingControlCardClassName = export const floatingControlCellClassName = "h-8 w-8 [@media(pointer:coarse)]:h-[var(--layout-touch-target)] [@media(pointer:coarse)]:w-[var(--layout-touch-target)]"; + +/** Selected / unselected look of a floating cell, shared by every pressed-state control. */ +export function floatingControlCellStateClassName(selected: boolean): string { + return selected + ? "text-[var(--color-text)] bg-[var(--color-surface-muted)] ring-1 ring-inset ring-[var(--color-border)]" + : "text-[var(--color-text-muted)] enabled:hover:text-[var(--color-text)] enabled:hover:bg-[var(--color-surface-muted)]"; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 423d0fe..0196fbf 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -6,6 +6,7 @@ export * from "./Button"; export * from "./IconTextButton"; export * from "./IconButton"; export * from "./Avatar"; +export * from "./FloatingToggle"; export * from "./SegmentedControl"; export * from "./PanelSection"; export * from "./ZoomControls";