feat(htmx-css): ToggleGroup support + padding/primary parity

Generator (scripts/generate-htmx-css.ts): track `viaVariants` per slot so
slots that compose another component's variant system (e.g. ToggleGroupItem
via toggleVariants) inherit the referenced CVA's base + variant rules under
their own selector. Previously toggle-group-item's CSS contained only its
override classes, shipping with no padding/height/hover/active state.

Toggle (components/ui/toggle.tsx):
  - data-[state=on] now uses bg-primary (orange) instead of bg-accent (grey),
    matching every other "commit" affordance in the palette.
  - Horizontal padding aligned with Button: px-4/px-3/px-6 per size, plus
    has-[>svg]:px-* for icon-only toggles.

ToggleGroup (components/ui/toggle-group.tsx): drop min-w-0 flex-1 shrink-0
from the item override. Items now size to content instead of being clamped
into equal narrow columns where longer labels overflowed the bg box.

Showcase: add ToggleGroup section to the React page (component-matrix.tsx)
and 1:1 HTMX mirror (public/htmx.html) with a new JS bridge branch for
single/multi-select. compare-all.sh extended with the new section; 22/22
pass at ≥99.97%.

Docs: GAPS.md captures the generator gap, overflow root cause, color
rationale, and padding parity with before/after numbers.
This commit is contained in:
2026-04-24 14:43:55 -06:00
parent 928fdd8f75
commit 90930d8f78
9 changed files with 391 additions and 29 deletions

179
GAPS.md Normal file
View File

@@ -0,0 +1,179 @@
# HTMX CSS Generator — Known Gaps & Fixes
Running log of edge cases discovered while consuming `dist/greyhaven.htmx.css`
in framework-agnostic (HTMX / Go `html/template` / etc.) projects. Each entry
captures: the bug, its root cause, and the fix. Keep this file updated when
new gaps surface so consumers have a single place to check.
---
## Fixed
### 2026-04-24 — `toggle-group-item` missing Toggle base styles
**Symptom (consumer-side):** `data-slot="toggle-group-item"` renders as
unstyled inline text — no padding, no height, no border, and `data-state="on"`
produces no visual change.
**Root cause:** In React, `ToggleGroupItem` composes `toggleVariants()` with a
small set of segmented-group overrides:
```tsx
className={cn(
toggleVariants({ variant, size }), // base + variant + size classes
'min-w-0 flex-1 shrink-0 rounded-none ...'
)}
```
The generator's `extractSlot` only captured the first *string-literal* arg of
`cn(...)` — the segmented overrides — and never followed the
`toggleVariants(...)` call to pull in Toggle's base, variant, and size rules.
As a result, the emitted `[data-slot="toggle-group-item"]` had no padding,
hover, `disabled`, or — critically — the `data-[state=on]:bg-accent
data-[state=on]:text-accent-foreground` rule that drives the pressed state.
**Fix (`scripts/generate-htmx-css.ts`):** `SlotExtract` now carries a
`viaVariants: string[]` field. When processing `className={cn(xVariants(...),
'literal')}`, the extractor records every `*Variants` call it sees. The
emitter then merges each referenced CVA's base + variant rules under the
slot's own selector, so a slot that *composes* another component's variant
system inherits its full rule set.
**Generated output before:**
```css
[data-slot="toggle-group-item"] { /* only overrides */
min-w-0 flex-1 ... rounded-none first:rounded-l-md ...
}
```
**Generated output after:**
```css
[data-slot="toggle-group-item"] { /* full Toggle base + item overrides */
inline-flex items-center ... data-[state=on]:bg-accent ...
min-w-0 flex-1 ... rounded-none first:rounded-l-md ...
}
[data-slot="toggle-group-item"][data-variant="outline"] { /* inherited variant */ }
[data-slot="toggle-group-item"][data-size="sm"] { /* inherited size */ }
```
**Consumer impact:** existing HTMX demos do not use `toggle-group-item`, so no
screenshot-diff regression risk. Consumers previously working around this by
using `data-slot="toggle"` on each item (with manual rounded-none / border
overrides) can switch to the clean `toggle-group-item` form.
---
### 2026-04-24 — Toggle active state uses `primary` (orange), not `accent` (grey)
**Symptom:** Design review flagged the active-state grey (`bg-accent`) as too
muted — users expected the brand orange for selected items in a segmented
control, matching every other "selected" affordance in the system (primary
button, active nav link, focus ring).
**Fix:** `components/ui/toggle.tsx``toggleVariants` base class swapped from
`data-[state=on]:bg-accent data-[state=on]:text-accent-foreground` to
`data-[state=on]:bg-primary data-[state=on]:text-primary-foreground`. This
propagates to `ToggleGroupItem` automatically (composes `toggleVariants`) and
to the generated `[data-slot="toggle"]` and `[data-slot="toggle-group-item"]`
rules after `pnpm htmx-css:build && pnpm htmx-demo:build`.
**Rationale:** Greyhaven's palette reserves `accent` for hover hints and
subtle surface shifts; `primary` is the single brand-accent color, used
wherever a choice is committed. Selected-state for Toggle/ToggleGroup now
matches that convention.
### 2026-04-24 — `ToggleGroupItem` overflow: text escapes the bg box
**Symptom:** With segmented labels of uneven length ("System" / "Light" /
"Dark"), the longer label's text rendered outside its button's background
rectangle. Selected-state highlight appeared narrower than the text it was
supposed to cover, and hover state was clipped for longer labels.
**Root cause:** `ToggleGroupItem` layered `'min-w-0 flex-1 shrink-0'` on top
of `toggleVariants`' size-based `min-w-*`. Because tailwind-merge keeps the
later `min-w-0`, each item was allowed to shrink below its content. Combined
with `flex: 1 1 0%` and `w-fit` on the group, items ended up forced to equal
narrow columns sized to `container_width / N` — which for the "System" case
was ~37px, well below the text's ~45px intrinsic width. `whitespace-nowrap`
then let the text bleed out of the button's layout box.
**Fix:** `components/ui/toggle-group.tsx` — dropped `min-w-0 flex-1 shrink-0`
from `ToggleGroupItem`'s override string. Items now size to their content
(respecting the `min-w-*` floor from `toggleVariants`), the group's `w-fit`
sums them up, and padding is symmetric. Unequal-width items are the
intentional result (a segmented "System/Light/Dark" now shows "System" wider
than "Light"/"Dark"); if a consumer specifically wants equal-width columns,
they can re-apply `flex-1 basis-0` on each item via `className`.
**Consumer impact:** no regressions in the existing 21 sections; the new
`toggle-group` section passes at 99.98%.
### 2026-04-24 — Toggle horizontal padding aligned with Button
**Symptom:** Side-by-side, a `Toggle` (or `ToggleGroupItem`) looked cramped
compared to a regular `Button` of the same size — the active-state orange
bg sat tight against the label, while Button had comfortable breathing room
on both sides. Perceived as a visual-rhythm bug across any UI that mixed the
two in the same view.
**Root cause:** `Toggle`'s cva had roughly half the horizontal padding of
`Button` per size:
| size | Button | Toggle (old) |
|---------|---------------|---------------|
| default | `px-4` (16px) | `px-2` (8px) |
| sm | `px-3` (12px) | `px-1.5` (6px)|
| lg | `px-6` (24px) | `px-2.5` (10px)|
**Fix:** `components/ui/toggle.tsx``toggleVariants.size.*` horizontal
padding now matches Button exactly. Also added `has-[>svg]:px-*` for
icon-only toggles, mirroring Button's affordance. `min-w-*` kept as a floor
so very short labels (`A`, `B`) still render as balanced pills.
```ts
size: {
default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
},
```
## Known (not yet fixed)
*(Add entries here as they are discovered. Template:*
```
### YYYY-MM-DD — <slot or behavior>
**Symptom:** ...
**Root cause:** ...
**Workaround:** ...
**Proposed fix:** ...
```
*)*
### Radix primitive-only components (AlertDialogAction, AlertDialogCancel)
**Symptom:** These components have no static `data-slot` in their JSX — they
wrap `<AlertDialogPrimitive.Action>` which sets `data-slot` at runtime. The
AST extractor never sees them, so no CSS is emitted.
**Workaround:** Consumers should add `data-slot="button"` on the
corresponding HTML element. The visual contract matches Button exactly
(`cn(buttonVariants(), className)` in React).
**Proposed fix:** none yet — requires either parsing the Radix primitive
source, or adding explicit `data-slot` attributes upstream.
### Interactive components without a built-in JS bridge
**Symptom:** `data-slot="select-trigger"`, `data-slot="tooltip-content"`,
`data-slot="dialog-content"`, `data-slot="popover-content"` emit the CSS for
their open/closed states, but nothing drives them.
**Workaround:** Consumers must implement open/close + positioning themselves
(Alpine.js, plain JS, HTMX swap). `public/htmx.html` ships a ~60-line vanilla
bridge for checkbox / switch / tabs; select / tooltip / dialog remain
consumer concerns.
**Proposed fix:** ship an opt-in `greyhaven.htmx.js` companion with pointer
positioning (e.g. via Floating UI) and focus management.

View File

@@ -7,6 +7,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
Select,
SelectContent,
@@ -455,6 +456,69 @@ export function ComponentMatrix() {
</div>
</div>
{/* Toggle Group */}
<div id="sub-toggle-group">
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Toggle Group
</h4>
<div className="border border-border rounded-md p-6 bg-card">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4">
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Single, outline</p>
<ToggleGroup type="single" variant="outline" defaultValue="light" aria-label="Theme">
<ToggleGroupItem value="system" aria-label="System">System</ToggleGroupItem>
<ToggleGroupItem value="light" aria-label="Light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Single, default</p>
<ToggleGroup type="single" defaultValue="grid" aria-label="Layout">
<ToggleGroupItem value="list" aria-label="List">List</ToggleGroupItem>
<ToggleGroupItem value="grid" aria-label="Grid">Grid</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Board" disabled>Board</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Multiple</p>
<ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="Text formatting">
<ToggleGroupItem value="bold" aria-label="Bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Italic">Italic</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="Underline">Underline</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
<div className="space-y-4">
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Small</p>
<ToggleGroup type="single" variant="outline" size="sm" defaultValue="light" aria-label="Theme (sm)">
<ToggleGroupItem value="system">System</ToggleGroupItem>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Default</p>
<ToggleGroup type="single" variant="outline" defaultValue="light" aria-label="Theme (default)">
<ToggleGroupItem value="system">System</ToggleGroupItem>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
<div>
<p className="font-sans text-xs text-muted-foreground mb-2">Large</p>
<ToggleGroup type="single" variant="outline" size="lg" defaultValue="light" aria-label="Theme (lg)">
<ToggleGroupItem value="system">System</ToggleGroupItem>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
</div>
</div>
</div>
{/* Tooltips */}
<div>
<h4 className="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">

View File

@@ -60,7 +60,7 @@ function ToggleGroupItem({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
'rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}

View File

@@ -7,7 +7,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
@@ -16,9 +16,9 @@ const toggleVariants = cva(
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
default: 'h-9 px-4 min-w-9 has-[>svg]:px-3',
sm: 'h-8 px-3 min-w-8 has-[>svg]:px-2.5',
lg: 'h-10 px-6 min-w-10 has-[>svg]:px-4',
},
},
defaultVariants: {

View File

@@ -836,20 +836,27 @@
:where([data-slot="toast"]):where([data-variant="destructive"]) { @apply border-destructive bg-destructive text-destructive-foreground; }
/* ── toggle ─────────────────────────────────────────── */
:where([data-slot="toggle"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap; }
:where([data-slot="toggle"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap; }
:where([data-slot="toggle"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="toggle"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="toggle"]):where([data-variant="outline"]) { @apply border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground; }
:where([data-slot="toggle"]):where(:not([data-size])) { @apply h-9 px-2 min-w-9; }
:where([data-slot="toggle"]):where([data-size="default"]) { @apply h-9 px-2 min-w-9; }
:where([data-slot="toggle"]):where([data-size="sm"]) { @apply h-8 px-1.5 min-w-8; }
:where([data-slot="toggle"]):where([data-size="lg"]) { @apply h-10 px-2.5 min-w-10; }
:where([data-slot="toggle"]):where(:not([data-size])) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle"]):where([data-size="default"]) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle"]):where([data-size="sm"]) { @apply h-8 px-3 min-w-8 has-[>svg]:px-2.5; }
:where([data-slot="toggle"]):where([data-size="lg"]) { @apply h-10 px-6 min-w-10 has-[>svg]:px-4; }
/* ── toggle-group ─────────────────────────────────────────── */
:where([data-slot="toggle-group"]) { @apply flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs; }
/* ── toggle-group-item ─────────────────────────────────────────── */
:where([data-slot="toggle-group-item"]) { @apply min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l; }
:where([data-slot="toggle-group-item"]) { @apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l; }
:where([data-slot="toggle-group-item"]):where(:not([data-variant])) { @apply bg-transparent; }
:where([data-slot="toggle-group-item"]):where([data-variant="default"]) { @apply bg-transparent; }
:where([data-slot="toggle-group-item"]):where([data-variant="outline"]) { @apply border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground; }
:where([data-slot="toggle-group-item"]):where(:not([data-size])) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle-group-item"]):where([data-size="default"]) { @apply h-9 px-4 min-w-9 has-[>svg]:px-3; }
:where([data-slot="toggle-group-item"]):where([data-size="sm"]) { @apply h-8 px-3 min-w-8 has-[>svg]:px-2.5; }
:where([data-slot="toggle-group-item"]):where([data-size="lg"]) { @apply h-10 px-6 min-w-10 has-[>svg]:px-4; }
/* ── tooltip-content ─────────────────────────────────────────── */
:where([data-slot="tooltip-content"]) { @apply bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance; }

View File

@@ -28,6 +28,7 @@ SECTIONS=(
"select"
"checkboxes-switches"
"tabs"
"toggle-group"
"tooltips"
"sample-form"
"settings-card"

File diff suppressed because one or more lines are too long

View File

@@ -736,6 +736,69 @@
</div>
</div>
<!-- Toggle Group -->
<div id="sub-toggle-group">
<h4 class="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
Toggle Group
</h4>
<div class="border border-border rounded-md p-6 bg-card">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-4">
<div>
<p class="font-sans text-xs text-muted-foreground mb-2">Single, outline</p>
<div data-slot="toggle-group" data-variant="outline" data-type="single" role="group" aria-label="Theme">
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="off" data-value="system" aria-label="System">System</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="on" data-value="light" aria-label="Light">Light</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="off" data-value="dark" aria-label="Dark">Dark</button>
</div>
</div>
<div>
<p class="font-sans text-xs text-muted-foreground mb-2">Single, default</p>
<div data-slot="toggle-group" data-type="single" role="group" aria-label="Layout">
<button type="button" data-slot="toggle-group-item" data-state="off" data-value="list" aria-label="List">List</button>
<button type="button" data-slot="toggle-group-item" data-state="on" data-value="grid" aria-label="Grid">Grid</button>
<button type="button" data-slot="toggle-group-item" data-state="off" data-value="board" aria-label="Board" disabled data-disabled="">Board</button>
</div>
</div>
<div>
<p class="font-sans text-xs text-muted-foreground mb-2">Multiple</p>
<div data-slot="toggle-group" data-variant="outline" data-type="multiple" role="group" aria-label="Text formatting">
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="on" data-value="bold" aria-label="Bold">Bold</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="off" data-value="italic" aria-label="Italic">Italic</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="off" data-value="underline" aria-label="Underline">Underline</button>
</div>
</div>
</div>
<div class="space-y-4">
<div>
<p class="font-sans text-xs text-muted-foreground mb-2">Small</p>
<div data-slot="toggle-group" data-variant="outline" data-type="single" role="group" aria-label="Theme (sm)">
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-size="sm" data-state="off" data-value="system">System</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-size="sm" data-state="on" data-value="light">Light</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-size="sm" data-state="off" data-value="dark">Dark</button>
</div>
</div>
<div>
<p class="font-sans text-xs text-muted-foreground mb-2">Default</p>
<div data-slot="toggle-group" data-variant="outline" data-type="single" role="group" aria-label="Theme (default)">
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="off" data-value="system">System</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="on" data-value="light">Light</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-state="off" data-value="dark">Dark</button>
</div>
</div>
<div>
<p class="font-sans text-xs text-muted-foreground mb-2">Large</p>
<div data-slot="toggle-group" data-variant="outline" data-type="single" role="group" aria-label="Theme (lg)">
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-size="lg" data-state="off" data-value="system">System</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-size="lg" data-state="on" data-value="light">Light</button>
<button type="button" data-slot="toggle-group-item" data-variant="outline" data-size="lg" data-state="off" data-value="dark">Dark</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tooltips -->
<div id="sub-tooltips">
<h4 class="font-sans text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-4">
@@ -993,6 +1056,19 @@
el.setAttribute('aria-checked', next === 'checked' ? 'true' : 'false');
const thumb = el.querySelector('[data-slot="switch-thumb"]');
if (thumb) thumb.setAttribute('data-state', next);
} else if (slot === 'toggle-group-item') {
const group = el.closest('[data-slot="toggle-group"]');
if (!group) return;
const multi = group.getAttribute('data-type') === 'multiple';
const next = el.getAttribute('data-state') === 'on' ? 'off' : 'on';
if (multi) {
el.setAttribute('data-state', next);
} else {
// Single-select: activate this, deactivate siblings. Don't deselect the active one.
group.querySelectorAll('[data-slot="toggle-group-item"]').forEach((it) => {
it.setAttribute('data-state', it === el ? 'on' : 'off');
});
}
} else if (slot === 'tabs-trigger') {
const list = el.closest('[data-slot="tabs-list"]');
const tabs = el.closest('[data-slot="tabs"]');

View File

@@ -50,6 +50,15 @@ type SlotExtract = {
sourceFile: string
slot: string
classes: string
/**
* Names of CVA functions referenced inside the slot's `cn(...)` call — e.g.
* `cn(toggleVariants({variant,size}), 'rounded-none ...')` on
* ToggleGroupItem records `['toggleVariants']`. Used to emit the
* referenced CVA's base + variant rules under this slot's selector, so
* slots that *compose* another component's variant system (instead of
* declaring their own cva) still inherit its padding/height/states.
*/
viaVariants: string[]
}
type Warning = { sourceFile: string; message: string }
@@ -156,6 +165,7 @@ function extractSlot(
): void {
let slot: string | null = null
let classes: string | null = null
const viaVariants: string[] = []
const attrs = element.attributes.properties
for (const attr of attrs) {
@@ -173,13 +183,22 @@ function extractSlot(
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
classes = (expr as ts.StringLiteral).text
} else if (ts.isCallExpression(expr)) {
// cn(...) or xVariants(...) — we extract the first string-literal arg
// Variant calls are already covered by CVA extraction; cn's first literal
// is the static baseline.
const callName = expr.expression.getText()
if (callName === 'cn' || callName.endsWith('Variants')) {
// First string-literal arg is the static class baseline.
const first = expr.arguments.find((a) => getStringLiteral(a) !== null)
if (first) classes = getStringLiteral(first)
// Any *call* arg whose callee is a `xVariants` identifier means this
// slot inherits from another component's variant system (e.g.
// ToggleGroupItem: `cn(toggleVariants({variant,size}), 'rounded-none...')`).
// Record the names so the emitter can pull in those CVAs' base + variants.
for (const arg of expr.arguments) {
if (!ts.isCallExpression(arg)) continue
const argCallee = arg.expression
if (ts.isIdentifier(argCallee) && argCallee.text.endsWith('Variants')) {
viaVariants.push(argCallee.text)
}
}
}
}
}
@@ -187,13 +206,7 @@ function extractSlot(
}
if (slot) {
if (classes === null) {
// data-slot is present but no static class extractable (maybe dynamic).
// We still record the slot with empty classes so rule ordering is preserved.
slotExtracts.push({ sourceFile, slot, classes: '' })
} else {
slotExtracts.push({ sourceFile, slot, classes })
}
slotExtracts.push({ sourceFile, slot, classes: classes ?? '', viaVariants })
}
}
@@ -313,7 +326,9 @@ function emitCss(): string {
// in the same file uses `data-slot="x"`. If ambiguous, fall back to stripping
// "Variants" and kebab-casing.
const cvaBySlot = new Map<string, CvaExtract>()
const cvaByName = new Map<string, CvaExtract>()
for (const cva of cvaExtracts) {
cvaByName.set(cva.variableName, cva)
const stripped = cva.variableName.replace(/Variants$/, '')
const slot = stripped
.replace(/([a-z])([A-Z])/g, '$1-$2')
@@ -321,13 +336,30 @@ function emitCss(): string {
cvaBySlot.set(slot, cva)
}
// Aggregate viaVariants per slot (a slot may appear in multiple JSX sites).
const slotVia = new Map<string, Set<string>>()
for (const s of slotExtracts) {
if (s.viaVariants.length === 0) continue
if (!slotVia.has(s.slot)) slotVia.set(s.slot, new Set())
for (const v of s.viaVariants) slotVia.get(s.slot)!.add(v)
}
// Emit slots in sorted order for stable output.
const allSlots = Array.from(
new Set([...slotMap.keys(), ...cvaBySlot.keys()]),
).sort()
for (const slot of allSlots) {
const cva = cvaBySlot.get(slot)
const selfCva = cvaBySlot.get(slot)
// Gather CVAs to apply under this slot's selector: its own (if any), plus
// any CVAs referenced via `cn(xVariants(...), ...)` in the JSX (e.g.
// ToggleGroupItem inherits from toggleVariants). Dedup.
const cvas: CvaExtract[] = []
if (selfCva) cvas.push(selfCva)
for (const name of slotVia.get(slot) ?? []) {
const aliasCva = cvaByName.get(name)
if (aliasCva && !cvas.includes(aliasCva)) cvas.push(aliasCva)
}
const staticSets = slotMap.get(slot)
const staticClasses = staticSets ? Array.from(staticSets).join(' ') : ''
@@ -341,19 +373,22 @@ function emitCss(): string {
// (0,1,0) and silently drop user overrides.
const SLOT = `:where([data-slot="${slot}"])`
// Base rule: cva.base + any static classes found on the slot's JSX
// Base rule: every contributing CVA's base + any static classes from JSX.
const basePieces: string[] = []
if (cva?.base) basePieces.push(cva.base)
for (const c of cvas) if (c.base) basePieces.push(c.base)
if (staticClasses) basePieces.push(staticClasses)
const base = uniqueClasses(basePieces.join(' '))
if (base) {
lines.push(` ${SLOT} { @apply ${base}; }`)
}
// CVA variants
if (cva) {
for (const [axis, values] of Object.entries(cva.variants)) {
const defaultValue = cva.defaultVariants[axis]
// Emit variant rules for each contributing CVA, under this slot's selector.
// When a slot inherits (e.g. toggle-group-item via toggleVariants), its
// data-variant/data-size attributes on the DOM drive the inherited CVA's
// rules just like they drive the self-CVA's.
for (const c of cvas) {
for (const [axis, values] of Object.entries(c.variants)) {
const defaultValue = c.defaultVariants[axis]
const axisAttr = `data-${axis}`
for (const [key, classes] of Object.entries(values)) {
if (!classes.trim()) continue