diff --git a/src/components/composed/PlateMapEditor/PlateMapPlateSelector.tsx b/src/components/composed/PlateMapEditor/PlateMapPlateSelector.tsx index 9c4c0edd..461ac1e9 100644 --- a/src/components/composed/PlateMapEditor/PlateMapPlateSelector.tsx +++ b/src/components/composed/PlateMapEditor/PlateMapPlateSelector.tsx @@ -80,6 +80,9 @@ export function PlateMapPlateSelector({ value={plate.id} disabled={plate.disabled || !onPlateChange} aria-label={plate.label ?? plate.barcode} + // Plate tabs are navigation, not a multi-select: the active + // tab reads from its tint alone, so opt out of the ring. + selectedIndicator="none" className={cn(canRemove && "rounded-r-none border-r-0")} > {plate.label ?? plate.barcode} diff --git a/src/components/ui/toggle-group.stories.tsx b/src/components/ui/toggle-group.stories.tsx index 42acf40c..331ff927 100644 --- a/src/components/ui/toggle-group.stories.tsx +++ b/src/components/ui/toggle-group.stories.tsx @@ -1,6 +1,8 @@ -import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react" +import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, XIcon } from "lucide-react" +import { type ReactNode } from "react" import { expect, within } from "storybook/test" +import { Button } from "./button" import { ToggleGroup, ToggleGroupItem } from "./toggle-group" import type { Meta, StoryObj } from "@storybook/react-vite" @@ -166,4 +168,249 @@ export const Spaced: Story = { expect(canvas.getByRole("button", { name: "Align center" })).toBeInTheDocument() }) }, +} + +/** + * SW-2445: labelled multi-select. Label-only items show the dotted-ring→check + * indicator by default (no `selectedIndicator` prop needed), so even with every + * option selected the items stay countable — each shows a check and the segments + * keep their dividers + container outline, so "all selected" never collapses + * into one solid button. + */ +export const SelectedIndicator: Story = { + name: "Selected indicator (SW-2445)", + parameters: { + zephyr: { testCaseId: "SW-T5647" }, + }, + render: () => ( + + Samples + Controls + Blanks + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step("all three options are selected", async () => { + const items = canvas.getAllByRole("button") + expect(items).toHaveLength(3) + items.forEach((el) => expect(el).toHaveAttribute("data-state", "on")) + }) + + await step("each selected item shows a check indicator", async () => { + canvas.getAllByRole("button").forEach((el) => { + expect(el.querySelector(".lucide-check")).not.toBeNull() + }) + }) + + await step("the segmented items are outlined (per-item borders)", async () => { + const item = canvas.getAllByRole("button")[0] + expect(getComputedStyle(item).borderTopWidth).not.toBe("0px") + }) + }, +} + +function VariationRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ) +} + +/** + * The ways a ToggleGroup is used: single vs multi select, icon-only vs + * label-only (which sets the SW-2445 indicator default), the segmented vs + * spaced layout, the outline variant, and sizes. + */ +export const Variations: Story = { + parameters: { + layout: "padded", + zephyr: { testCaseId: "SW-T5649" }, + }, + render: () => ( +
+ + + + + + + + + + + + + + + + + Board + Table + Timeline + + + + + + Samples + Controls + Blanks + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Plate 1 + + +
+
+
+ + + + Plain + + Wrapped + + + + Icon + label + + + + Forced dot + + + + + + + Board + Table + + + Board + Table + + + Board + Table + + +
+ ), + play: async ({ canvasElement, step }) => { + await step("all variation groups render", async () => { + expect( + canvasElement.querySelectorAll('[data-slot="toggle-group"]').length, + ).toBeGreaterThanOrEqual(6) + }) + + await step("label-only selected items show a check", async () => { + expect(canvasElement.querySelector(".lucide-check")).not.toBeNull() + }) + + await step("the selected tint actually resolves to a paint", async () => { + // Guards the --selected tokens. Tailwind emits NO css for bg-selected / + // text-selected-foreground when those theme keys are missing, and it does + // not warn — so without this assertion the selected state silently + // renders as nothing while every check stays green. + const items = canvasElement.querySelectorAll( + '[data-slot="toggle-group-item"]', + ) + const on = [...items].find((el) => el.dataset.state === "on") + const off = [...items].find((el) => el.dataset.state === "off") + expect(on).toBeDefined() + expect(off).toBeDefined() + + const tint = getComputedStyle(on!).backgroundColor + expect(tint).not.toBe("") + expect(tint).not.toBe("rgba(0, 0, 0, 0)") + expect(tint).not.toBe(getComputedStyle(off!).backgroundColor) + }) + + await step("indicator default follows rendered content, not React children", async () => { + const indicator = (testId: string) => + canvasElement.querySelector( + `[data-testid="${testId}"] [data-slot="toggle-group-indicator"]`, + ) + + // A label wrapped in a is still label-only: it keeps its ring. + const wrapped = indicator("wrapped-label-item") + expect(wrapped).not.toBeNull() + expect(getComputedStyle(wrapped!).display).not.toBe("none") + + // An item that renders an icon drops the ring (the icon carries the state). + const iconLabel = indicator("icon-label-item") + expect(iconLabel).not.toBeNull() + expect(getComputedStyle(iconLabel!).display).toBe("none") + + // An explicit selectedIndicator="dot" overrides the icon auto-hide. + const forced = indicator("forced-dot-item") + expect(forced).not.toBeNull() + expect(getComputedStyle(forced!).display).not.toBe("none") + }) + + await step("a consumer's className still wins over the item border", async () => { + // The segmented border must stay at plain-utility specificity, or a + // group-scoped selector silently beats `border-r-0` passed by a consumer + // and the item no longer sits flush against its adjacent control. + const joined = canvasElement.querySelector( + '[data-testid="joined-item"]', + ) + expect(joined).not.toBeNull() + expect(getComputedStyle(joined!).borderRightWidth).toBe("0px") + }) + }, } \ No newline at end of file diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index bb82e9dd..6197ef8d 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -1,4 +1,5 @@ import { type VariantProps } from "class-variance-authority" +import { CircleDashed, Check } from "lucide-react" import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" import * as React from "react" @@ -58,11 +59,39 @@ function ToggleGroupItem({ children, variant = "default", size = "default", + selectedIndicator, ...props }: React.ComponentProps & - VariantProps) { + VariantProps & { + /** + * Leading indicator for the selectable state (SW-2445). `"dot"` shows a + * faint dotted ring when off that cross-fades to a check when on — a resting + * affordance so an item reads as "selectable" and stays legible when every + * option is selected. + * + * Defaults by content: **label-only** items get `"dot"`; items that render + * an **icon** (icon-only or icon + label) get `"none"` — the icon carries + * the state and there's no room for a ring. The default is decided in CSS + * from the rendered DOM (`:has(svg)`), not from the React children, so a + * label wrapped in a ``, a translation component or a tooltip still + * keeps its ring. Pass the prop to override either way. + */ + selectedIndicator?: "dot" | "none" + }) { const context = React.useContext(ToggleGroupContext) + // Segmented (spacing=0) items carry their own border. Applied as plain + // `border` rather than a `group-data-*:` variant on purpose: a group-scoped + // selector compiles to specificity (0,2,0) and would silently beat a + // consumer's own `border-r-0`/`border-l-0` in `className` (0,1,0). Plain + // utilities stay at (0,1,0), so `className` keeps winning — which is the + // contract every other component here follows. + const segmented = (context.spacing ?? 0) === 0 + const collapseLeadingEdge = + context.orientation === "vertical" + ? "[&:not(:first-child)]:border-t-0" + : "[&:not(:first-child)]:border-l-0" + return ( + {selectedIndicator !== "none" && ( + svg]:[grid-area:1/1]", + // Content-aware default, decided in CSS rather than by inspecting + // React children: hide the ring when the item renders any *other* + // svg (an icon carries the state). Inspecting children would treat + // a ``-wrapped or translated label as "has icon" and silently + // drop the ring — misaligning a group that mixes plain and wrapped + // labels. `display:none` also drops the slot, so no layout shift. + selectedIndicator === undefined && + "group-has-[svg:not([data-slot=toggle-group-indicator]_svg)]/toggle:hidden" + )} + > + + + + )} {children} ) diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx index eb3e2ab6..6dfaa793 100644 --- a/src/components/ui/toggle.tsx +++ b/src/components/ui/toggle.tsx @@ -5,12 +5,13 @@ import * as React from "react" import { cn } from "@/lib/utils" const toggleVariants = cva( - "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:shadow-focus disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:shadow-focus aria-pressed:bg-accent aria-pressed:text-accent-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:shadow-focus disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:shadow-focus aria-pressed:bg-selected aria-pressed:text-selected-foreground aria-pressed:shadow-(--shadow-inset-pressed) data-[state=on]:bg-selected data-[state=on]:text-selected-foreground data-[state=on]:shadow-(--shadow-inset-pressed) [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-transparent", - outline: "border border-input bg-transparent hover:bg-accent", + outline: + "border border-input bg-transparent hover:bg-accent aria-pressed:border-selected-border data-[state=on]:border-selected-border", }, size: { default: "h-8 min-w-8 px-2",