Skip to content

feat: SW-2563 unified selected state for Toggle & ToggleGroup - #196

Merged
boramyi-ts merged 5 commits into
mainfrom
SW-2563-toggle-group-selected-state
Sep 10, 2026
Merged

feat: SW-2563 unified selected state for Toggle & ToggleGroup#196
boramyi-ts merged 5 commits into
mainfrom
SW-2563-toggle-group-selected-state

Conversation

@boramyi-ts

Copy link
Copy Markdown
Contributor

SW-2563 — A unified “selected” state for Toggle & ToggleGroup

The usability problem

In a ToggleGroup, users couldn't reliably tell what was selected — or that items were selectable:

  • “All selected” looked like one button. A selected item was a solid bg-primary fill, read only by contrast with unselected neighbours; select every option and the segments merged into one seamless primary pill.
  • Selected looked like a call-to-action — the same solid primary as a primary button, so “on” read as “click me”.
  • No resting affordance — an unselected item gave no hint it was selectable.
  • The default variant had no outline, so unselected items floated as bare labels with no container.

Before → after

Before — solid fill; labelled default-variant has no outline; all-selected merges:

before

After — soft tint + primary text + a dotted-ring→check indicator on labelled items, per-item borders (always outlined) with dividers, so every selection is legible and countable:

after

What changed (Toggle / ToggleGroup only)

  • Tokens (src/index.tailwind.css): --selected, --selected-foreground, --selected-border, --shadow-inset-pressed — derived from --primary / --foreground so they track light/dark with no separate .dark block — exposed as bg-selected / text-selected-foreground / border-selected-border.
  • Toggle: on-state is the tint + pressed inset (was bg-accent); outline gains a selected border.
  • ToggleGroup: dropped the SW-2292 solid-primary override (the base tint now applies). Every segmented item is bordered with edge-collapse (not only variant="outline"), so the group is always outlined, stays countable when all are selected, and — because the border and rounded corners live on the same element — the selected fill always aligns at every size. New ToggleGroupItem prop selectedIndicator ("dot" | "none") renders a faint dotted ring that cross-fades to a check, in a fixed-width slot (no layout shift). It defaults by content: label-only items get the ring; icon-only and icon+label items get none (the icon carries it, no double icons) — the prop overrides.

Tests

  • All existing Toggle / ToggleGroup play tests pass.
  • New stories “Selected indicator” and “Variations” with play tests (selection reads, per-item borders, indicator appears). Zephyr: SW-T5647, SW-T5649.
  • yarn typecheck, yarn lint, yarn build clean (dist ships the var-based bg-selected utilities).

Scope

Toggle/ToggleGroup only — Button / ButtonGroup selection (aria-pressed) is handled separately (SW-2445). The shared --selected tokens live here.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@boramyi-ts
boramyi-ts requested review from a team as code owners August 28, 2026 22:09
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ts-lib-ui-kit-storybook Ready Ready Preview Sep 10, 2026 2:56pm UTC

Request Review

boramyi-ts added a commit that referenced this pull request Aug 28, 2026
…W-2563

Revert the Toggle/ToggleGroup changes and the shared --selected tokens to main
— those now live in SW-2563 (#196). This PR keeps only the Button aria-pressed
selected state (scoped to outline/ghost) and the ButtonGroup variations story.
Depends on #196 landing first for the --selected tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
boramyi-ts added a commit that referenced this pull request Sep 1, 2026
Include the --selected / --selected-foreground / --selected-border /
--shadow-inset-pressed tokens so the button aria-pressed selected state renders
without depending on SW-2563 (#196) merging first. These tokens are identical
to #196's; whichever PR merges second will have a trivial index.tailwind.css
conflict (keep one copy).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
54321jenn-ts added a commit that referenced this pull request Sep 3, 2026
The aria-pressed treatment on Button referenced bg-selected /
text-selected-foreground / --shadow-inset-pressed, but those tokens only
existed in #196. Tailwind silently emits no CSS for a utility whose theme
key is missing, so merging this before #196 would have shipped a selected
state that renders nothing, with CI green.

Moves the four token lines from #196 into this PR (verbatim, so #196
stays in sync) and adds assertions that fail if they ever go missing.

Also covers the vertical + separator case: the horizontal/vertical
variants strip the preceding child's border when a separator follows, so
the separator is the only thing drawing that divider. The story now
exercises it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@54321jenn-ts

Copy link
Copy Markdown
Collaborator

Reviewed this and pushed one fix (ad07996). The design work here is strong — the "all selected collapses into one pill" problem is real and the dotted-ring→check solution is a genuinely good answer. Notes below in plain terms.


1. Fixed: buttons couldn't sit flush against each other any more 🔴

In plain terms: this PR gave every segmented toggle item its own outline — good, that's the point. But it applied that outline in a way that outranks any instruction a screen sends in. So when a screen said "remove the right edge of this one so it joins seamlessly onto the button beside it", that instruction got ignored.

Where it showed: the plate tabs in PlateMapEditor. Each tab has a little ✕ delete button welded onto its right side. The tab is supposed to drop its right edge so the two read as one control. With this change, the edge came back — so you'd get a doubled, slightly darker seam between the tab and its ✕.

Think of it as a specificity argument: the new rule was shouting louder than the screen's own instruction, so the screen lost.

The fix: same visual result, same outline on every segmented item — just applied at normal volume, so a screen's own styling wins again, the way it does everywhere else in the kit. Nothing about the intended look changes.

I also added a row to the Variations story showing this joined pattern (a tab welded to a ✕ button), with a test that fails if the edge ever comes back. It's worth seeing in the story anyway — it's a real composition people use.


2. Worth knowing: the ring appears based on a guess about your content 🟡

The dotted-ring indicator turns itself on for "label-only" items and off for items with icons. Sensible default. But the check for "does this have an icon" is really "does this contain anything other than bare text".

What that means in practice: if a label is wrapped in anything — a <span>, a translation component, a tooltip — the item is treated as having an icon and the ring silently disappears.

The risk isn't one item losing its ring; it's a group where some items have plain text and others have wrapped text. Then some items reserve space for the ring and some don't, so the labels don't line up. Slightly ragged, and hard to trace back to the cause.

PlateMapPlateSelector already wraps its labels in a <span>. There it happens to give the right answer (tabs shouldn't have rings) — but by luck rather than intent.

Not blocking, and I haven't changed it. Two ways to firm it up if you want: detect actual icon elements rather than "any element", or document that wrapping a label opts out of the ring. Flagging it because it'll be confusing the first time someone hits it.


3. Nobody has seen this running 🟡

The Vercel preview for this PR is marked Ignored, so no live Storybook was ever built for it. That means:

  • There's no preview link to click through
  • The Storybook E2E test job never ran — it only triggers after a successful preview deploy, so it's showing as skipped
  • The before/after images in the description are screenshots committed to a separate branch, not the live build

The 9 green checks are real, but they're lint, typecheck, build, and the component tests — not a visual pass. Someone should get a preview deployed and actually look at this before merging, especially the plate tabs.

For what it's worth, the component tests in here are good — they check real rendered borders and count items, rather than just asserting that props got passed through.


4. Heads-up: this reverses an earlier decision 📌

The previous look — selected items filled solid primary blue — was a deliberate call (SW-2292). This replaces it with the soft tint. The reasoning in the description is sound and I agree with it, but whoever made that original call should get a nod rather than finding out at merge.


Consistent with #195

Two things I checked line up with the Button work:

  • Hover does nothing on a selected item — the selected tint outranks the hover colour, same as Button. Already reviewed and accepted there.
  • The pressed-in inset on selected items is the same shared token, also reviewed and kept as-is on feat: SW-2445 Button/ButtonGroup selected state (aria-pressed) #195. Consistent across Button, Toggle and ToggleGroup by choice.

One merge chore

The four --selected token lines in src/index.tailwind.css now live in #195 as well. That was deliberate — without them #195's selected state renders nothing at all, silently, with CI green.

So these two PRs will conflict on those lines. Drop them from this PR and keep #195's. The values are identical, so nothing here changes. Merge order no longer matters either way.

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues found

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🟢 Lines 96.84% (🎯 83%)
🟰 ±0%
23597 / 24365
🟢 Statements 96.84% (🎯 83%)
🟰 ±0%
23597 / 24365
🟢 Functions 95.08% (🎯 74%)
🟰 ±0%
1084 / 1140
🟢 Branches 90.65% (🎯 81%)
⬆️ +0.08%
4639 / 5117
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/components/ui/toggle-group.tsx 100%
🟰 ±0%
82.6%
⬆️ +52.60%
100%
🟰 ±0%
100%
🟰 ±0%
src/components/ui/toggle.tsx 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #1153 for commit ad07996 by the Vitest Coverage Report Action

…ating

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 <noreply@anthropic.com>
@54321jenn-ts

Copy link
Copy Markdown
Collaborator

Two updates — and this PR's checks will now go red on purpose. Please read before reacting to the failure.

Removed the duplicated --selected tokens (77abbe8)

Those four token lines were in both this PR and #195, so whichever merged second would have hit a conflict on identical lines. Removed ours; this PR now consumes what #195 lands on main. That's the conflict taken off your plate.

The trade-off: this PR now depends on #195 merging first. It genuinely cannot work without it — the toggles reference a colour that no longer exists on this branch.

So I made that dependency loud instead of silent. Tailwind emits no CSS at all for bg-selected when the token is missing, and it doesn't warn. Without a guard, this branch would render toggles with no selected tint while every check stayed green and the preview looked plausible — the exact trap #195 was in. So I added a play-test assertion that reads the selected item's actual rendered background and fails if it isn't a real colour distinct from an unselected sibling.

Expect red until #195 is on main. That's the dependency working as intended, not a broken branch. Once #195 merges, rebase and it goes green with no code change. If you'd rather not carry a red PR in the meantime, say so and I'll put the tokens back — the conflict returns, but it's a two-minute resolution.

The border fix is verified ✅

The earlier fix (ad07996) passed in real Chromium:

✓ storybook (chromium) src/components/ui/toggle-group.stories.tsx (8 tests) 2297ms
  Test Files  170 passed (170)

That includes the new assertion checking a plate-tab-style item actually keeps its right border removed when a consumer asks for it.

You now have a preview 🎉

Your Vercel deploy had been marked Ignored, so this PR never had a live Storybook and Storybook E2E Tests had never run. Pushing triggered a real deploy — E2E ran and passed for the first time. Worth clicking through, particularly the plate tabs, since nothing here had been looked at visually before.

Still open for you

The dotted-ring indicator defaulthasIcon is really "has any non-text child", so a label wrapped in a <span> or a translation component silently loses its ring. A group mixing plain and wrapped labels will have misaligned items. Not changed; it needs your call on whether to detect real icons or document the behaviour.

@boramyi-ts

Copy link
Copy Markdown
Contributor Author

Status check — what's needed

No unresolved review threads. check (title), CodeQL, Endor, Vercel, and publish are green. Two red checks remain — Storybook E2E Tests and build (coverage) — but both are stale:

  • The branch is 42 commits behind main and last ran CI on Sep 3. That run also hit infra install errors (gyp ERR! not found: make while building lz4), and the failure was a single toggle-group play test.
  • Re-ran the suite locally against the current branch: toggle-group.stories.tsx passes 8/8 (incl. the automatic axe check). The coverage baseline has also moved 42 commits, so the build coverage gate is comparing against a stale number.

What to do: merge latest main into the branch and push to re-run both checks against a current baseline. That almost certainly clears both; if anything real remains it'll surface cleanly. After green, it needs a code-owner review (currently REVIEW_REQUIRED).

Coordinate before merging:

@boramyi-ts

Copy link
Copy Markdown
Contributor Author

Correction — the Storybook E2E failure is real, not stale

I tried refreshing with main and dug in: the failing check is legit, not a stale/flaky run. My earlier note was based on a stale local branch ref that still had the tokens — apologies.

Root cause: the Variations play test asserts the selected tint is a real paint —

expect(tint).not.toBe("rgba(0, 0, 0, 0)")

but commit 77abbe8 ("take the --selected tokens from #195 instead of duplicating") removed the --selected / --selected-foreground / --selected-border definitions from src/index.tailwind.css. Those tokens are not on main (they live in #195, which isn't merged), so bg-selected resolves to transparent and the assertion fails. Merging main in doesn't help — it doesn't carry the tokens.

So this PR can't go green on its own. Two ways forward:

  1. Merge feat: SW-2445 Button/ButtonGroup selected state (aria-pressed) #195 first (it defines the tokens), then this branch — matches 77abbe8's dedup intent. Recommended.
  2. Or re-add the four token lines here to make feat: SW-2563 unified selected state for Toggle & ToggleGroup #196 self-contained, and drop them from feat: SW-2445 Button/ButtonGroup selected state (aria-pressed) #195 to avoid the duplicate.

Either way it's a merge-order decision between #195 and #196, not a CI refresh. I did not push anything — the branch is untouched.

54321jenn-ts and others added 2 commits September 10, 2026 09:46
…dren

The dotted-ring default was derived by inspecting React children: any
element child counted as "has icon", so a label wrapped in a <span>, 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(<indicator> 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 <noreply@anthropic.com>

@54321jenn-ts 54321jenn-ts left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM but merge after #195

@boramyi-ts
boramyi-ts merged commit 47f203a into main Sep 10, 2026
8 of 10 checks passed
@boramyi-ts
boramyi-ts deleted the SW-2563-toggle-group-selected-state branch September 10, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants