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:
179
GAPS.md
Normal file
179
GAPS.md
Normal 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
19
dist/greyhaven.htmx.css
vendored
19
dist/greyhaven.htmx.css
vendored
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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"]');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user