Explore extending disclosure style recipes in the nav recipes
This issue is the implementation specification for letting the nav and list style recipes extend the disclosure style recipes. The exploration is finished: every design decision below was settled by the maintainer, and the whole contract was prototyped green on main at 2fc48a8. Further contract changes are made by editing this body.
Problem
While fixing the nav end bar offset that the disclosure body padding caused (#7554), the fix had to publish the space under the button as a --disclosure-body-offset custom property on the disclosure root, set from the nav disclosure's style attribute, because a pbs-* utility added to navDisclosureContentBody and the pbs-* utility that disclosureContentBody already emits land on the same element, and only Tailwind's emitted order decides which one wins. That race is a symptom of how the nav and disclosure recipes are combined today.
The nav recipes in packages/ariakit-ui/src/styles/nav.ts (navDisclosure, navButton, navDisclosureContentBody) do not extend the disclosure recipes in packages/ariakit-ui/src/styles/disclosure.ts (disclosure, disclosureButton, disclosureContentBody). Instead, the React components in packages/ariakit-ui/src/components/nav.ariakit.react.tsx render the Disclosure framework components and spread the nav recipe's jsx() output over them. The recipes were kept separate so the nav components could reuse the disclosure components instead of re-implementing their behavior in nav. The list components in list.ariakit.react.tsx do the same over the same framework components, and NavButton does it over Button.
Two cv outputs meet on one element through splitProps, which claims the incoming className and style and appends them to the framework recipe's own output. Conflicts between the two recipes are then resolved by the stylesheet cascade, not by clava's extend order, which applies a base once and before the child. The current code pays for that in several places:
navButtonmarks its gap utility important because the disclosure button spends its own--gapchannel on the same property:gap-[calc(var(--nav-row-gap,--spacing(3))+var(--px)-var(--py))]!.navDisclosurewrites--disclosure-gapand, since #7554,--disclosure-body-offsetthrough the style attribute, because that is the only way to beat the disclosure root's own classes, as its comments say.listDisclosuredoes the same for--disclosure-psand--disclosure-icon.navDisclosureContentBodyextendsframeBasefor the body's radius and padding channels, whiledisclosureContentBodyextendsedgeand owns the padding utilities (p-(--ak-frame-padding),ps-(--disclosure-body-ps),pbs-*). One element's geometry is split across two recipes that cannot see each other's output.- While prototyping the end bar fix, a
pbs-[max(...)]utility on the nav body beat the disclosure body'spbs-[calc(...)]utility only because Tailwind sorts same-property arbitrary candidates by comparing the raw candidate strings. Renaming the value tocalc(max(...))made it lose. NavDisclosureContentBodyforces$layer="transparent",NavDisclosureButtonforces$gap="none",NavDisclosuresets$contentPadding,$rounded,$p, and$layeras parameter defaults,NavButtonforces$rounded="lg"and$lightnessOffset={false}, andListDisclosureforces$rounded="unset"and$p="unset". Defaults for disclosure variants live in the React components instead of indefaultVariants, because those variants belong to a recipe the wrapper's recipe does not extend.- Each wrapper's props type joins two variant surfaces, for example
NavDisclosureButtonProps extends DisclosureButtonProps, VariantProps<typeof navButton>, and each wrapper runs its ownsplitPropsbefore the framework component runs another one.
The obstacle to writing extend: [disclosureContentBody] in the nav recipe today is that Disclosure, DisclosureButton, DisclosureContent, and DisclosureContentBody each apply their own recipe internally. A nav recipe that extended the disclosure recipe would emit the disclosure contribution a second time on the same element, and the nav component's splitProps would claim the shared variant props before the framework component sees them.
Goals
- One recipe per element: the nav and list disclosure root, button, and body, and the standalone nav button, each get their classes and style from a single
cvthat extends the corresponding base recipe. - No
!flags or style-attribute overrides whose only purpose is to beat the base recipe's class on the same element. - Defaults for base variants (
$contentPadding,$rounded,$p,$layer,$gap,$lightnessOffset) expressed asdefaultVariantsof the extending recipe rather than as React parameter defaults or forced props. - Variant contracts inherited through
extend, so each wrapper's props type is theVariantPropsof one recipe. - The disclosure behavior stays implemented once, in the framework components. The nav and the list customize only style and their own composition.
Non-goals
- Changing what the nav, the list, or the disclosure look like. Every style attribute and every computed style must stay the same, with the one exception listed under "What must not change".
- Replacing clava or Tailwind, or adding a class-merging step that rewrites class strings at render time.
- Loosening clava's
variantKeysandpropKeystypes. - Adding the prop to framework components other than the four named below.
Contract
1. Four framework components accept a generic recipe prop
Disclosure, DisclosureButton, DisclosureContentBody (in disclosure.ariakit.react.tsx) and Button (in button.ariakit.react.tsx) accept an optional prop named recipe. DisclosureContent does not get it, because no wrapper adds a recipe to it.
- The constraint uses built-in types only:
R extends Pick<typeof base, "getVariants"> = typeof base. This is the shape clava uses for its ownVariantPropsconstraint. There is no helper module and no custom helper type. - The component runs one
splitPropsand onejsx()against the recipe it was given, or its own recipe when none was given. - The component reads base variants through one inline cast, with this comment, in each of the four components.
- The four props types become generic
typealiases, because an interface cannot extendVariantProps<R>whileRis a type parameter (TS2312). Their non-variant members move to exported own-props interfaces (DisclosureOwnProps,DisclosureButtonOwnProps) so that every existing JSDoc comment survives unchanged. - Everything else in the components stays as it is: the store, the provider,
data-open, thesplitto$splitmapping, the slots, the label and description wiring, the indicator, and the$disabledderivation inButton.
// disclosure.ariakit.react.tsx
export interface DisclosureOwnProps {
/** Custom button element or props to render a `DisclosureButton`. */
button?: React.ReactNode | DisclosureButtonProps;
/** Custom content element or props to render a `DisclosureContent`. */
content?: React.ReactElement | DisclosureContentProps;
decoration?: React.ReactNode;
split?: boolean;
}
export type DisclosureProps<
R extends Pick<typeof disclosure, "getVariants"> = typeof disclosure,
> = Omit<ak.RoleProps<"div">, "content"> &
Pick<ak.DisclosureProviderProps, "open" | "setOpen" | "defaultOpen"> &
VariantProps<R> &
DisclosureOwnProps & {
/** The recipe applied to the root in place of `disclosure`. It must extend it. */
recipe?: R;
};
export function Disclosure<
R extends Pick<typeof disclosure, "getVariants"> = typeof disclosure,
>({ recipe, open, setOpen, defaultOpen, split, button, content, decoration, ...props }: DisclosureProps<R>) {
// The recipe accepts every base variant, which is all the component reads.
const styles = (recipe ?? disclosure) as typeof disclosure;
const [variantProps, rest] = splitProps(props, styles);
// ...unchanged, with `styles` wherever `disclosure` was applied...
}2. The nav and list recipes extend the base recipes
styles/nav.ts:
navDisclosure:extend: [disclosure]. Itsstyleblock is removed.defaultVariantsare$contentPadding: true,$rounded: "lg",$p: 2,$layer: "transparent", plus the two channel variants from section 3. The comments that explained the React parameter defaults move onto the matching defaults.- New
navDisclosureButton = cv({ extend: [disclosureButton], class: "whitespace-normal" }). It has no gap utility and no!: the inherited$gap: "auto"already spends--disclosure-gap, which the nav root sets. navButton(standalone rows throughButton):extend: [button],defaultVariants: { $rounded: "lg", $lightnessOffset: false, $gap: "none" }, and its own gap utility without the!, the waynavLinkalready does it. Its header comment changes, because it is no longer shared with the disclosure button.navDisclosureContentBody:extend: [disclosureContentBody, frameBase], with$layer: "transparent"added to itsdefaultVariantsand the comment moved from the React component. Its classes stay.
styles/list.ts:
listDisclosure:extend: [disclosure, listRow]. The order is significant and needs a comment:listRowcomes last, so its$rounded: "xl"and$p: "var(--list-item-padding)"defaults win over the disclosure's. That replaces the$rounded="unset"and$p="unset"props. Itsstyleblock is removed.defaultVariantsare$layer: "transparent"(restated, becauselistRowturns the layer off and the disclosure root needs the transparent one) plus the two channel variants from section 3.listDisclosureButton:extend: [disclosureButton].listDisclosureContentBody:extend: [disclosureContentBody]. Their classes stay.
3. Channels become variants of disclosure
Every custom property that a nav or list root sets through a recipe style entry only to beat a class of the disclosure root becomes a function variant of disclosure that writes the same custom property through style, the way $iconSize already does. The rendered style attribute does not change. What changes is that the base recipe owns the knob, types it, and a caller can override it with a prop.
Variant on disclosure |
Writes | Set in defaultVariants of |
Value |
|---|---|---|---|
$slotGap(value?: string | number) |
--disclosure-gap |
navDisclosure |
"var(--nav-row-gap, calc(var(--spacing) * 3))" |
$bodyOffset(value?: string | number) |
--disclosure-body-offset |
navDisclosure |
"var(--nav-gap, calc(var(--spacing) * 1))" |
$indent(value?: string | number) |
--disclosure-ps |
listDisclosure |
"calc(var(--py) + (var(--px) - var(--py) + 1lh) * var(--list-guide))" |
$leadingIcon(value?: boolean) |
--disclosure-icon as "1" or "0" |
listDisclosure |
false |
$slotGap(value?: string | number) {
if (value == null) return;
return { style: { "--disclosure-gap": getSpacingValue(value) } };
},
$leadingIcon(value?: boolean) {
if (value == null) return;
return { style: { "--disclosure-icon": value ? "1" : "0" } };
},- Unset, a variant emits nothing, so the class defaults on the root keep working:
[--disclosure-gap:max(...)],[--disclosure-body-offset:0px],[--disclosure-ps:initial], and the:has()detection of a leading slot. - Declare the four variants after
$iconSize, in the order of the table. That keeps the key order of the emitted style attributes identical to today's. $leadingIconstays a style entry on purpose: the flag has to beat a:has()rule that no class can beat by order.- The comments in
disclosure.tsthat say a nav or a list sets a channel "through the style attribute" are updated to name the variant. - The names were chosen to avoid two hazards found in the final prototype. A root variant named
$gapwould share its name, with another type, with the$gapmap variant ofbuttonandcontroland with the$gapofnav, which makes a recipe that extends bothdisclosureandbuttonill-typed. A variant named$pswould read like padding on the root besidetextFrame's$px, but it indents the button's start padding and the body.
4. The wrappers become thin
NavDisclosure, NavDisclosureButton, NavDisclosureContentBody, NavButton, ListDisclosure, ListDisclosureButton, and ListDisclosureContentBody keep their public names, their shorthands (button="..."), and their own behavior: the nav context (NavDisclosureRoot stays), the NavButtonContent label wrapping, the indicator defaults, guide, the list marker and label composition, the checked and progress props, and the decoration guide. They lose their splitProps call, their .jsx() spread, their parameter defaults, and their forced variant props. NavDisclosureContent and ListDisclosureContent are unchanged, because they add no recipe.
- A wrapper's props type stays an
interfaceover the framework props withrecipeomitted:Omit<DisclosureProps<typeof navDisclosure>, "recipe">. On the button and body types, theOmitis what lets the root narrow itsbuttonandcontentslots. On the root it hides the fixed recipe. - The spread order carries the contract and gets one comment per wrapper:
recipefirst, the caller's props second, the composed slots last. - The
NavDisclosurecomment that explained why its defaults were parameters and not props is removed.defaultVariantssolves that case structurally: passing every variant asundefinedproduces the same class and style as passing nothing.
export interface NavDisclosureProps
extends Omit<DisclosureProps<typeof navDisclosure>, "recipe"> {
button?: React.ReactNode | NavDisclosureButtonProps;
content?: React.ReactElement | NavDisclosureContentProps;
}
export function NavDisclosure({ button, content, ...props }: NavDisclosureProps) {
return (
<Disclosure
// The order carries the contract: the recipe first, the caller's props
// second, the composed slots last.
recipe={navDisclosure}
{...props}
button={createOptionalRender(NavDisclosureButton, button)}
content={createRender(NavDisclosureContent, content)}
render={<NavDisclosureRoot render={props.render} />}
/>
);
}
export function ListDisclosure({ button, content, decoration, ...props }: ListDisclosureProps) {
return (
<Disclosure
recipe={listDisclosure}
{...props}
decoration={<>{decoration}<ListItemGuide /></>}
button={createOptionalRender(ListDisclosureButton, button)}
content={createRender(ListDisclosureContent, content)}
/>
);
}
export function NavButton(props: NavButtonProps) {
return <Button recipe={navButton} {...props} />;
}Examples
// A caller overrides a nav default: one class for $rounded lands, the nav's
// default is not emitted.
<NavDisclosure $rounded="md" button="Docs">...</NavDisclosure>
// A caller moves a channel on one row. The variant writes the custom property
// on the root, and the button and body follow it.
<NavDisclosure $slotGap={4} $bodyOffset={2} button="Docs">...</NavDisclosure>
<ListDisclosure $leadingIcon button="Item">...</ListDisclosure>
// A consumer builds a sidebar disclosure over the same behavior.
const sidebarDisclosure = cv({
extend: [disclosure],
variants: { $tone: { quiet: "opacity-80", loud: "font-semibold" } },
defaultVariants: { $p: 3, $rounded: "xl", $slotGap: "var(--sidebar-gap)" },
});
export function SidebarDisclosure(
props: Omit<DisclosureProps<typeof sidebarDisclosure>, "recipe">,
) {
return <Disclosure recipe={sidebarDisclosure} {...props} />;
}
<SidebarDisclosure $tone="quiet" />; // the new variant is typed
<SidebarDisclosure $nope />; // type errorWhat must not change
Measured in the final prototype against a baseline taken on the same commit, and expected from the implementation:
- The style attribute of every affected element is identical, character for character and in key order. The nav root keeps
--frame-padding,--disclosure-gap,--disclosure-body-offset. The list root keeps--frame-padding,--disclosure-ps,--disclosure-icon: 0. - No class token appears twice on any element. Today the nav disclosure button repeats
w-full,justify-start,overflow-clip,text-start, and the list root repeatsak-frame. - No
!gap utility remains. On the nav disclosure button, the inherited[--gap:calc(var(--disclosure-gap)+var(--px)-var(--py))] gap-(--gap)replaces it. On the standalone nav button, the same utility without!is the only gap utility, because$gap: "none"emits nothing. - Chrome computed styles of the root, button, content, and body of a nav row and of a list row are identical (padding, radius, background, gap, and the
--disclosure-*channels), with one exception. The nav disclosure button's row gap goes from about 18px to 4px, because the old!shorthand set both axes and the inherited$gapY: "auto"now applies. The button is a non-wrapping flex row, so nothing moves on screen, and the plain disclosure button and the list button already compute 4px. Note it in the recipe comment. - The class order inside the class attribute changes:
extendplaces a wrapper's own classes before the base's variant output instead of last. CSS ignores it. Do not write rules that assume the wrapper's class comes last.
Known limits
- The
Pickconstraint means "does not contradict the base", not "extends it". It rejects a recipe with no variant name in common with the base, and accepts one that shares a name, including the recipe of another disclosure part:<Disclosure recipe={navDisclosureContentBody} />compiles, and a missing$splitis then dropped silently at runtime. The wrappers in this repository are the only callers today. The exact check, a type that walks the recipe'sextendlist and leaves clava's key arrays exact, is tracked in ariakit/clava#538. Adopting it later changes each constraint and nothing else, because its accepting branch is this samePick. - The cast types the recipe as the base, so
splitPropstypes a wrapper's extra variant keys into the rest props, while at runtime the recipe's own keys move them out. The runtime is the correct one, and the rest props are spread onto an element that tolerates the type. - Two class-against-class competitions exist today and stay, because they are not about the nav:
justify-centerfromcontrolbesidejustify-starton every disclosure button, andp-(--ak-frame-padding)besideak-frameplusak-frame-p-(--frame-padding)on the nav body. On the list body, the variant-gatedin-[.list]:pbs-[...]still wins over the body's ownpbs-*by Tailwind's order, as today. - Four framework props types change from
interfacetotype. Declaration merging on them is no longer possible, and nothing in the repository does it. The generated public reference is not affected: the jsdoc loader readspackages/ariakit-reactandpackages/ariakit-solidonly, and the legacy extractor has no@ariakit/uientry. Only the nav and the list consume the four types.
Test obligations
- Types: whole-repository
pnpm tsc, which emits declarations. A type test or probe that an unknown variant on a wrapper is rejected, thatrecipeon a wrapper is rejected, that a consumer recipe's new variant is typed on the framework component, and thatcreateOptionalRender(NavDisclosureButton, button),createRender(NavDisclosureContent, content), andcreateRender(DisclosureContentBody, body, { $prose })still infer. - happy-dom, in
app/src/sandbox/ariakit-ui-nav/test.tsandapp/src/sandbox/ariakit-ui-list/test.ts: the disclosure root, button, and bod
Source: ariakit/ariakit