From 8e3fbe46e1546878eca57ab08083c76a183a6a88 Mon Sep 17 00:00:00 2001 From: Boram Yi Date: Fri, 28 Aug 2026 18:08:36 -0400 Subject: [PATCH 1/4] feat: SW-2563 unified selected state for Toggle & ToggleGroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the solid-primary selected fill with one consistent state language: a soft tint (--selected) + primary text + pressed inset, per-item borders so a segmented group is always outlined (fixes the default variant having none) with dividers that keep it countable when all are selected, and a content-aware dotted-ring→check indicator on labelled items (label-only on by default; icon-only/icon+label off; ToggleGroupItem selectedIndicator overrides). - tokens: --selected / --selected-foreground / --selected-border / --shadow-inset-pressed (derived from --primary/--foreground), exposed as bg-selected / text-selected-foreground / border-selected-border. - Toggle on-state now tint + pressed (was bg-accent). - ToggleGroup: drop the SW-2292 solid override; per-item borders + collapse for all variants so the border and rounded corners share one element and the selected fill always aligns. - stories + play tests for the indicator and the variations. Button/ButtonGroup selection is handled separately (SW-2445). Co-Authored-By: Claude Opus 4.8 --- src/components/ui/toggle-group.stories.tsx | 153 +++++++++++++++++++++ src/components/ui/toggle-group.tsx | 48 ++++++- src/components/ui/toggle.tsx | 5 +- src/index.tailwind.css | 11 ++ 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/src/components/ui/toggle-group.stories.tsx b/src/components/ui/toggle-group.stories.tsx index 5f5eeb85..0672e7fc 100644 --- a/src/components/ui/toggle-group.stories.tsx +++ b/src/components/ui/toggle-group.stories.tsx @@ -1,4 +1,5 @@ import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react" +import { type ReactNode } from "react" import { expect, within } from "storybook/test" import { ToggleGroup, ToggleGroupItem } from "./toggle-group" @@ -166,4 +167,156 @@ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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() + }) + }, } \ No newline at end of file diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index bb82e9dd..d0508d1b 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,35 @@ 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 (text, no icon) get `"dot"`; + * **icon-only** and **icon + label** items get `"none"` (the icon carries + * the state and there's no room for a ring). Pass the prop to override. + */ + selectedIndicator?: "dot" | "none" + }) { const context = React.useContext(ToggleGroupContext) + // Content-aware default: only plain-text (label-only) items show the ring. + const childArray = React.Children.toArray(children) + const hasIcon = childArray.some((child) => React.isValidElement(child)) + const hasText = childArray.some( + (child) => + (typeof child === "string" && child.trim() !== "") || + typeof child === "number", + ) + const showIndicator = + (selectedIndicator ?? (hasText && !hasIcon ? "dot" : "none")) === "dot" + return ( + {showIndicator && ( + + + + + )} {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", diff --git a/src/index.tailwind.css b/src/index.tailwind.css index e54ed6c3..e362cb3f 100644 --- a/src/index.tailwind.css +++ b/src/index.tailwind.css @@ -143,6 +143,10 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + /* SW-2445 selected-state utilities: bg-selected / text-selected-foreground / border-selected-border */ + --color-selected: var(--selected); + --color-selected-foreground: var(--selected-foreground); + --color-selected-border: var(--selected-border); /* Focus halo — neutral elevation-style shadow (SW-2015 ALT1); pairs with focus-visible:border-ring. Usage: focus-visible:shadow-focus */ --shadow-focus: var(--focus-shadow); @@ -347,6 +351,13 @@ --input: oklch(0.79 0.0229 250.7); /* #B0BCC9 — SW-1920 input border */ --ring: oklch(0.5590 0.1657 269.30); /* #4E6AD4 — SW-2015 focus ring, decoupled from primary */ --focus-shadow: 0 1px 1px 0 rgba(0,0,0,0.18), 0 1px 2px 1px rgba(0,0,0,0.08); + /* Selected/active state (SW-2445) — toggle, toggle-group items, aria-pressed + buttons. Derived from --primary/--foreground so it tracks the theme without + a separate .dark definition (var() resolves in the element's scope). */ + --selected: color-mix(in oklab, var(--primary) 12%, transparent); + --selected-foreground: var(--primary); + --selected-border: color-mix(in oklab, var(--primary) 40%, transparent); + --shadow-inset-pressed: inset 0 1px 2px color-mix(in oklab, var(--foreground) 14%, transparent); /* Charts — CVD-friendly categorical palette (mode-independent; slots 1-8 recommended, 9-12 sparing) */ --chart-1: oklch(0.4465 0.1784 269.18); /* #2F45B5 — TS Blue 500 */ --chart-2: oklch(0.7676 0.1635 60.41); /* #FD972F — Yellow Flax */ From ad07996c6b88bff6b252525447b619358d13607d Mon Sep 17 00:00:00 2001 From: Jenn Medellin Date: Thu, 3 Sep 2026 09:02:14 -0500 Subject: [PATCH 2/4] fix: SW-2563 keep segmented border overridable by consumer className MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The segmented border was applied as group-data-[spacing=0]/toggle-group:border, which compiles to specificity (0,2,0). A consumer passing border-r-0 through className lands at (0,1,0) and silently loses, so an item can no longer sit flush against an adjacent control. PlateMapPlateSelector relies on exactly that: each plate tab passes "rounded-r-none border-r-0" so it butts against its remove button. On main the border came from toggleVariants as plain .border, tying at (0,1,0) and losing to the later .border-r-0 — the override worked. This restores that. Derives the segmented/orientation classes in JS and emits plain utilities, so className keeps winning. Adds a Variations row covering the joined-control composition with an assertion on the computed border width. Co-Authored-By: Claude Opus 5 --- src/components/ui/toggle-group.stories.tsx | 37 +++++++++++++++++++++- src/components/ui/toggle-group.tsx | 15 ++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/components/ui/toggle-group.stories.tsx b/src/components/ui/toggle-group.stories.tsx index 0672e7fc..62aca6ad 100644 --- a/src/components/ui/toggle-group.stories.tsx +++ b/src/components/ui/toggle-group.stories.tsx @@ -1,7 +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" @@ -292,6 +293,29 @@ export const Variations: Story = { + + +
+ + Plate 1 + + +
+
+
+ Board @@ -318,5 +342,16 @@ export const Variations: Story = { await step("label-only selected items show a check", async () => { expect(canvasElement.querySelector(".lucide-check")).not.toBeNull() }) + + 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 d0508d1b..b070bcfe 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -88,6 +88,18 @@ function ToggleGroupItem({ const showIndicator = (selectedIndicator ?? (hasText && !hasIcon ? "dot" : "none")) === "dot" + // 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 ( Date: Thu, 3 Sep 2026 09:10:12 -0500 Subject: [PATCH 3/4] chore: SW-2563 take the --selected tokens from #195 instead of duplicating The tokens now live in #195, which needs them to render anything at all. Keeping a second copy here means whichever PR merges second hits a conflict on identical lines, so drop ours and consume what #195 lands on main. This makes the PR depend on #195 merging first. Adds a play-test assertion on the resolved selected background so that dependency fails loudly: without the tokens Tailwind emits no css for bg-selected and does not warn, so the selected state would otherwise render as nothing with CI green. Expect this branch to be red until #195 is on main. Co-Authored-By: Claude Opus 5 --- src/components/ui/toggle-group.stories.tsx | 19 +++++++++++++++++++ src/index.tailwind.css | 11 ----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/ui/toggle-group.stories.tsx b/src/components/ui/toggle-group.stories.tsx index 62aca6ad..61572f7c 100644 --- a/src/components/ui/toggle-group.stories.tsx +++ b/src/components/ui/toggle-group.stories.tsx @@ -343,6 +343,25 @@ export const Variations: Story = { 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("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 diff --git a/src/index.tailwind.css b/src/index.tailwind.css index e362cb3f..e54ed6c3 100644 --- a/src/index.tailwind.css +++ b/src/index.tailwind.css @@ -143,10 +143,6 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - /* SW-2445 selected-state utilities: bg-selected / text-selected-foreground / border-selected-border */ - --color-selected: var(--selected); - --color-selected-foreground: var(--selected-foreground); - --color-selected-border: var(--selected-border); /* Focus halo — neutral elevation-style shadow (SW-2015 ALT1); pairs with focus-visible:border-ring. Usage: focus-visible:shadow-focus */ --shadow-focus: var(--focus-shadow); @@ -351,13 +347,6 @@ --input: oklch(0.79 0.0229 250.7); /* #B0BCC9 — SW-1920 input border */ --ring: oklch(0.5590 0.1657 269.30); /* #4E6AD4 — SW-2015 focus ring, decoupled from primary */ --focus-shadow: 0 1px 1px 0 rgba(0,0,0,0.18), 0 1px 2px 1px rgba(0,0,0,0.08); - /* Selected/active state (SW-2445) — toggle, toggle-group items, aria-pressed - buttons. Derived from --primary/--foreground so it tracks the theme without - a separate .dark definition (var() resolves in the element's scope). */ - --selected: color-mix(in oklab, var(--primary) 12%, transparent); - --selected-foreground: var(--primary); - --selected-border: color-mix(in oklab, var(--primary) 40%, transparent); - --shadow-inset-pressed: inset 0 1px 2px color-mix(in oklab, var(--foreground) 14%, transparent); /* Charts — CVD-friendly categorical palette (mode-independent; slots 1-8 recommended, 9-12 sparing) */ --chart-1: oklch(0.4465 0.1784 269.18); /* #2F45B5 — TS Blue 500 */ --chart-2: oklch(0.7676 0.1635 60.41); /* #FD972F — Yellow Flax */ From c6b84c14772e7d43260c225215e99935bd5cbcd8 Mon Sep 17 00:00:00 2001 From: Jesus Medellin Date: Thu, 10 Sep 2026 09:49:41 -0500 Subject: [PATCH 4/4] fix: SW-2563 decide the indicator default in CSS, not from React children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dotted-ring default was derived by inspecting React children: any element child counted as "has icon", so a label wrapped in a , a translation component or a tooltip silently lost its ring. A group mixing plain and wrapped labels would then reserve the indicator slot on some items and not others, misaligning the labels. The indicator is now always rendered when the prop is unset and hides itself via `group-has-[svg:not( svg)]/toggle:hidden` — i.e. the decision is made from the rendered DOM (does the item paint another svg?) rather than from the children's shape. `display:none` drops the slot too, so there is still no layout shift. `selectedIndicator="dot" | "none"` keep overriding as before. PlateMapPlateSelector opts out explicitly (`selectedIndicator="none"`): its tabs previously had no ring only because their label happens to be span-wrapped. The Variations story gains a row + assertions covering wrapped label (ring kept), icon + label (ring hidden) and a forced "dot". Co-Authored-By: Claude Fable 5.1 --- .../PlateMapEditor/PlateMapPlateSelector.tsx | 3 ++ src/components/ui/toggle-group.stories.tsx | 40 +++++++++++++++++++ src/components/ui/toggle-group.tsx | 35 ++++++++-------- 3 files changed, 62 insertions(+), 16 deletions(-) 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 87e85002..331ff927 100644 --- a/src/components/ui/toggle-group.stories.tsx +++ b/src/components/ui/toggle-group.stories.tsx @@ -299,6 +299,7 @@ export const Variations: Story = { Plate 1 @@ -316,6 +317,23 @@ export const Variations: Story = { + + + Plain + + Wrapped + + + + Icon + label + + + + Forced dot + + + + Board @@ -362,6 +380,28 @@ export const Variations: Story = { 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 diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index b070bcfe..6197ef8d 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -69,25 +69,17 @@ function ToggleGroupItem({ * affordance so an item reads as "selectable" and stays legible when every * option is selected. * - * Defaults by content: **label-only** items (text, no icon) get `"dot"`; - * **icon-only** and **icon + label** items get `"none"` (the icon carries - * the state and there's no room for a ring). Pass the prop to override. + * 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) - // Content-aware default: only plain-text (label-only) items show the ring. - const childArray = React.Children.toArray(children) - const hasIcon = childArray.some((child) => React.isValidElement(child)) - const hasText = childArray.some( - (child) => - (typeof child === "string" && child.trim() !== "") || - typeof child === "number", - ) - const showIndicator = - (selectedIndicator ?? (hasText && !hasIcon ? "dot" : "none")) === "dot" - // 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 @@ -123,10 +115,21 @@ function ToggleGroupItem({ )} {...props} > - {showIndicator && ( + {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" + )} >