Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
>
<span className="truncate">{plate.label ?? plate.barcode}</span>
Expand Down
249 changes: 248 additions & 1 deletion src/components/ui/toggle-group.stories.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -166,4 +168,249 @@
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: () => (
<ToggleGroup
type="multiple"
variant="outline"
defaultValue={["samples", "controls", "blanks"]}
>
<ToggleGroupItem value="samples">Samples</ToggleGroupItem>
<ToggleGroupItem value="controls">Controls</ToggleGroupItem>
<ToggleGroupItem value="blanks">Blanks</ToggleGroupItem>
</ToggleGroup>
),
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 (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-wrap items-center gap-4">{children}</div>
</div>
)
}

/**
* 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: () => (
<div className="flex flex-col gap-6">
<VariationRow label="Single select — icon-only (no ring)">
<ToggleGroup type="single" defaultValue="center" aria-label="Align">
<ToggleGroupItem value="left" aria-label="Left">
<AlignLeftIcon />
</ToggleGroupItem>
<ToggleGroupItem value="center" aria-label="Center">
<AlignCenterIcon />
</ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="Right">
<AlignRightIcon />
</ToggleGroupItem>
</ToggleGroup>
</VariationRow>

<VariationRow label="Single select — label-only (ring on by default)">
<ToggleGroup type="single" defaultValue="board">
<ToggleGroupItem value="board">Board</ToggleGroupItem>
<ToggleGroupItem value="table">Table</ToggleGroupItem>
<ToggleGroupItem value="timeline">Timeline</ToggleGroupItem>
</ToggleGroup>
</VariationRow>

<VariationRow label="Multi select — all selected stays countable">
<ToggleGroup type="multiple" defaultValue={["samples", "controls", "blanks"]}>
<ToggleGroupItem value="samples">Samples</ToggleGroupItem>
<ToggleGroupItem value="controls">Controls</ToggleGroupItem>
<ToggleGroupItem value="blanks">Blanks</ToggleGroupItem>
</ToggleGroup>
</VariationRow>

<VariationRow label="Outline variant + spaced (spacing=4)">
<ToggleGroup type="single" variant="outline" defaultValue="center" aria-label="Align">
<ToggleGroupItem value="left" aria-label="Left">
<AlignLeftIcon />
</ToggleGroupItem>
<ToggleGroupItem value="center" aria-label="Center">
<AlignCenterIcon />
</ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="Right">
<AlignRightIcon />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" variant="outline" spacing={4} defaultValue="center" aria-label="Align spaced">
<ToggleGroupItem value="left" aria-label="Left">
<AlignLeftIcon />
</ToggleGroupItem>
<ToggleGroupItem value="center" aria-label="Center">
<AlignCenterIcon />
</ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="Right">
<AlignRightIcon />
</ToggleGroupItem>
</ToggleGroup>
</VariationRow>

<VariationRow label="Joined to an adjacent control (consumer border override)">
<ToggleGroup type="single" variant="outline" size="sm" defaultValue="plate-1">
<div className="inline-flex items-stretch">
<ToggleGroupItem
value="plate-1"
data-testid="joined-item"
selectedIndicator="none"
className="rounded-r-none border-r-0"
>
<span className="truncate">Plate 1</span>
</ToggleGroupItem>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Remove Plate 1"
className="rounded-l-none"
>
<XIcon />
</Button>
</div>
</ToggleGroup>
</VariationRow>

<VariationRow label="Indicator default is content-aware (wrapped label keeps ring, icon drops it)">
<ToggleGroup type="multiple" defaultValue={["wrapped"]}>
<ToggleGroupItem value="plain">Plain</ToggleGroupItem>
<ToggleGroupItem value="wrapped" data-testid="wrapped-label-item">
<span>Wrapped</span>
</ToggleGroupItem>
<ToggleGroupItem value="icon-label" data-testid="icon-label-item">
<AlignLeftIcon />
Icon + label
</ToggleGroupItem>
<ToggleGroupItem value="forced" data-testid="forced-dot-item" selectedIndicator="dot">
<AlignRightIcon />
Forced dot
</ToggleGroupItem>
</ToggleGroup>
</VariationRow>

<VariationRow label="Sizes — sm / default / lg">
<ToggleGroup type="single" size="sm" defaultValue="board">
<ToggleGroupItem value="board">Board</ToggleGroupItem>
<ToggleGroupItem value="table">Table</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" size="default" defaultValue="board">
<ToggleGroupItem value="board">Board</ToggleGroupItem>
<ToggleGroupItem value="table">Table</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup type="single" size="lg" defaultValue="board">
<ToggleGroupItem value="board">Board</ToggleGroupItem>
<ToggleGroupItem value="table">Table</ToggleGroupItem>
</ToggleGroup>
</VariationRow>
</div>
),
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<HTMLElement>(
'[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)")

Check failure on line 379 in src/components/ui/toggle-group.stories.tsx

View workflow job for this annotation

GitHub Actions / build

src/components/ui/toggle-group.stories.tsx > Variations

AssertionError: Click to debug the error directly in Storybook: http://localhost:6006/?path=/story/components-actions-toggle-group--variations&addonPanel=storybook/interactions/panel expected 'rgba(0, 0, 0, 0)' not to be 'rgba(0, 0, 0, 0)' // Object.is equality ❯ toBe src/components/ui/toggle-group.stories.tsx:379:23 ❯ node_modules/storybook/dist/_browser-chunks/chunk-5WKL7XVT.js?v=6033e529:1071:19 ❯ node_modules/storybook/dist/_browser-chunks/chunk-5WKL7XVT.js?v=6033e529:1071:19 ❯ node_modules/storybook/dist/_browser-chunks/chunk-5WKL7XVT.js?v=6033e529:1071:19
expect(tint).not.toBe(getComputedStyle(off!).backgroundColor)
})

await step("indicator default follows rendered content, not React children", async () => {
const indicator = (testId: string) =>
canvasElement.querySelector<HTMLElement>(
`[data-testid="${testId}"] [data-slot="toggle-group-indicator"]`,
)

// A label wrapped in a <span> 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<HTMLElement>(
'[data-testid="joined-item"]',
)
expect(joined).not.toBeNull()
expect(getComputedStyle(joined!).borderRightWidth).toBe("0px")
})
},
}
64 changes: 58 additions & 6 deletions src/components/ui/toggle-group.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -58,31 +59,82 @@ function ToggleGroupItem({
children,
variant = "default",
size = "default",
selectedIndicator,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
VariantProps<typeof toggleVariants> & {
/**
* 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 `<span>`, 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 (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
// SW-2292: selected group items use a high-contrast primary fill (the
// shared toggle `on` state is the subtle bg-accent). twMerge drops the
// inherited accent so this wins; standalone Toggle is unaffected.
"aria-pressed:bg-primary aria-pressed:text-primary-foreground data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
// SW-2445: every segmented item is bordered (not only variant=outline),
// so the group always reads as outlined and stays countable when all are
// selected. Non-first items drop their shared (leading) edge so adjacent
// borders collapse to a single divider. Border + rounded corners live on
// the same element, so the selected fill always aligns with the outline.
segmented && "border border-input",
segmented && collapseLeadingEdge,
className
)}
{...props}
>
{selectedIndicator !== "none" && (
<span
aria-hidden
data-slot="toggle-group-indicator"
className={cn(
"grid size-4 shrink-0 place-items-center [&>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 `<span>`-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"
)}
>
<CircleDashed className="size-3.5 text-muted-foreground/50 transition-opacity group-data-[state=on]/toggle:opacity-0" />
<Check className="size-3.5 opacity-0 transition-opacity group-data-[state=on]/toggle:opacity-100" />
</span>
)}
{children}
</ToggleGroupPrimitive.Item>
)
Expand Down
5 changes: 3 additions & 2 deletions src/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading