ReviewOS

stacks/stx

components: `<Tabs>` and `<Accordion>` take content as a string prop — blocks every real-world use

#1703
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

TL;DR

@stacksjs/components's <Tabs> and <Accordion> define their content via a server-side string prop, not slot/children. That makes them unusable for any tab or accordion whose content is a component tree (which is essentially every real-world tab/accordion). After a sweep across four downstream apps (bench-review, paweldregan, trailbuddy, training), neither component is used anywhere — and the API friction is why.

What the API looks like today

packages/components/src/ui/tabs/Tabs.stx:

export interface Tab {
  label: string
  content: string   // ← string only
  icon?: string
}

export interface TabsProps {
  tabs: Tab[]
  ...
}

packages/components/src/ui/accordion/Accordion.stx:

export interface AccordionItem {
  title: string
  content: string   // ← same
}

export interface AccordionProps {
  items: AccordionItem[]
  ...
}

The component bodies iterate tabs / items server-side via @foreach and interpolate as text. No <slot>, no per-item children.

Where this falls down

Every tab/accordion I audited in the training app contains component trees. A representative case from resources/views/settings.stx:

<div :if="activeSection === 'drivers'">
  <DriversTable :drivers="drivers()" />
  <ConnectDriverModal />
  ...
</div>
<div :if="activeSection === 'notifications'">
  <NotificationsTable :rows="notifications()" />
  ...
</div>

You can't express this with <Tabs tabs="[{label, content: ???}]" />. The team ended up with 40 manual :if="activeSection === '…'" switch-blocks across 4 files because the component doesn't fit. Same story for Accordion — 20 manual :show="openSections[…]" toggles in coach-sidebar.stx because the existing component can't hold an <EventCard> or <AthleteRow>.

Adoption signal

Across bench-review, paweldregan, trailbuddy, and training combined: zero <Tabs> usages, zero <Accordion> usages. The components ship, they're documented, but nobody can use them.

Proposed fix

Move to a slot-based API where each tab/accordion item is a child element with a label attribute:

<Tabs>
  <TabPanel label="Drivers">
    <DriversTable :drivers="drivers()" />
  </TabPanel>
  <TabPanel label="Notifications">
    <NotificationsTable />
  </TabPanel>
</Tabs>

<Accordion>
  <AccordionItem title="Profile">
    <ProfileEditor :user="user()" />
  </AccordionItem>
  <AccordionItem title="Zones">
    <ZonesEditor />
  </AccordionItem>
</Accordion>

The container component still owns activeTab / openItems state and panel switching; the children just provide labels + arbitrary content.

This matches how Vue/React/Svelte tabs work, and (more importantly) matches how <Dialog> in the same package already works (slot-based with <DialogPanel> children).

Why this matters

The CLAUDE.md § "Reach for stx primitives before vanilla JS" guidance points agents at @stacksjs/components first. When the components can't actually express the common case, agents either (a) reinvent the pattern inline, or (b) churn through a refactor before discovering the API mismatch. Both happened in the latest audit.

🤖 Filed via Claude Code

Sign in to comment on this issue.