+ ),
+ 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",