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:
@@ -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