Skip to content

Commit 67025b6

Browse files
feat(components): add slot API to Tabs + Accordion (#1703)
The old Tabs/Accordion APIs took `tabs: Tab[]` / `items: AccordionItem[]` where each item's `content` was a HTML string. That blocked every real-world use — tab/accordion content is almost always a component tree (a calendar grid, a form, a table). Across all four downstream apps the components saw zero adoption. Two new components — `<TabPanel label="…">` and `<AccordionItem title="…">` — act as slot wrappers. Each renders a `data-stx-*` marker the parent discovers on mount, so: <Tabs defaultTab="0" variant="pills"> <TabPanel label="Drivers"> <DriversTable :drivers="drivers()" /> </TabPanel> <TabPanel label="Notifications"> <NotificationsTable /> </TabPanel> </Tabs> <Accordion allowMultiple> <AccordionItem title="Profile"> <ProfileEditor :user="user()" /> </AccordionItem> <AccordionItem title="Zones"> <ZonesEditor /> </AccordionItem> </Accordion> now both work. Tabs walks `[data-stx-tab-panel]` descendants (with a `closest('[data-stx-tabs]') === root` guard against nested-Tabs contamination), builds the tab list from each panel's `data-label`, and toggles `hidden` as the active tab changes. Accordion walks `[data-stx-accordion-item]` descendants, wires per-item click + keydown handlers (with a `__stx_wired` idempotency guard for HMR re-runs), and syncs `aria-expanded` + chevron rotation as openItems changes. The legacy `tabs` / `items` prop APIs still work — the components branch on `hasPropTabs` / `hasPropItems` at template-eval time so existing visual snapshots stay byte-identical. Both are marked optional in the TS interfaces. 11 new tests in `test/integration/tabs-accordion-slot.test.ts` cover the discovery markers, both render paths, nested scoping, allowMultiple behavior, and aria/chevron sync. Existing component tests still pass (50 pass, 0 fail in the non-visual suites). Closes #1703 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 752c900 commit 67025b6

7 files changed

Lines changed: 448 additions & 66 deletions

File tree

Lines changed: 90 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
<script server>
2+
// Two modes (see stacksjs/stx#1703):
3+
// 1. Legacy prop API — pass `items={[{title, content}, …]}` for simple
4+
// string-content accordions (kept for backward compatibility with
5+
// existing visual snapshots and consumers).
6+
// 2. Slot API — drop `<AccordionItem title="…">` children for arbitrary
7+
// content. Each AccordionItem renders its own header + content panel;
8+
// this parent attaches click handlers and toggles content visibility.
29
export const items = $props.items || []
310
export const allowMultiple = $props.allowMultiple || false
411
export const defaultOpen = $props.defaultOpen || []
@@ -8,13 +15,56 @@ export const accordionClasses = `divide-y divide-gray-200 dark:divide-gray-700 b
815
export const headerClasses = `w-full flex items-center justify-between px-4 py-3 text-left font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900`.trim()
916
export const iconClasses = `w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200`.trim()
1017
export const contentClasses = `px-4 py-3 text-gray-700 dark:text-gray-300`.trim()
18+
export const hasPropItems = Array.isArray(items) && items.length > 0
1119
</script>
1220

1321
<script client>
1422
const emit = defineEmits()
1523
const allowMultiple = {{ allowMultiple }}
24+
const hasPropItems = {{ hasPropItems }}
25+
const containerRef = useRef()
1626
const openItems = state({{ defaultOpen }})
1727

28+
function findOwnItems() {
29+
const root = containerRef.value
30+
if (!root) return []
31+
return Array.from(root.querySelectorAll('[data-stx-accordion-item]'))
32+
.filter(item => item.closest('[data-stx-accordion]') === root)
33+
}
34+
35+
onMount(() => {
36+
if (hasPropItems) return // legacy mode: server-rendered, no wiring needed
37+
const items = findOwnItems()
38+
items.forEach((item, idx) => {
39+
const header = item.querySelector(':scope > [data-stx-accordion-header]')
40+
if (header && !header.__stx_wired) {
41+
header.__stx_wired = true
42+
header.addEventListener('click', () => toggleItem(idx))
43+
header.addEventListener('keydown', (e) => onHeaderKey(e, idx))
44+
}
45+
})
46+
})
47+
48+
// Sync the open state into the DOM: show/hide content panels, update
49+
// aria-expanded on headers, rotate chevrons.
50+
effect(() => {
51+
if (hasPropItems) return
52+
const items = findOwnItems()
53+
const open = openItems()
54+
items.forEach((item, idx) => {
55+
const isOpenItem = open.includes(idx)
56+
const header = item.querySelector(':scope > [data-stx-accordion-header]')
57+
const content = item.querySelector(':scope > [data-stx-accordion-content]')
58+
const chevron = item.querySelector(':scope > [data-stx-accordion-header] [data-stx-accordion-chevron]')
59+
if (header) header.setAttribute('aria-expanded', isOpenItem ? 'true' : 'false')
60+
if (content) {
61+
if (isOpenItem) content.removeAttribute('hidden')
62+
else content.setAttribute('hidden', '')
63+
}
64+
if (chevron) chevron.style.transform = `rotate(${isOpenItem ? 180 : 0}deg)`
65+
})
66+
})
67+
1868
function toggleItem(index) {
1969
const cur = openItems()
2070
if (allowMultiple) {
@@ -31,7 +81,9 @@ function isOpen(index) {
3181
}
3282

3383
function onHeaderKey(event, index) {
34-
const buttons = event.currentTarget.closest('[role="region"]').querySelectorAll('button[data-accordion-header]')
84+
const root = containerRef.value
85+
if (!root) return
86+
const buttons = root.querySelectorAll('button[data-stx-accordion-header], button[data-accordion-header]')
3587
if (event.key === 'ArrowDown') {
3688
event.preventDefault()
3789
buttons[index + 1]?.focus()
@@ -49,40 +101,46 @@ function onHeaderKey(event, index) {
49101
buttons[buttons.length - 1]?.focus()
50102
}
51103
}
104+
105+
defineExpose({ openItems, toggleItem })
52106
</script>
53107

54-
<div class="{{ accordionClasses }}" role="region">
55-
@foreach(item in items)
56-
<div>
57-
<button
58-
type="button"
59-
data-accordion-header
60-
class="{{ headerClasses }}"
61-
@click="toggleItem({{ $loop.index }})"
62-
@keydown="onHeaderKey($event, {{ $loop.index }})"
63-
:aria-expanded="isOpen({{ $loop.index }}) ? 'true' : 'false'"
64-
aria-controls="accordion-content-{{ $loop.index }}"
65-
>
66-
<span>{{ item.title }}</span>
67-
<svg
68-
class="{{ iconClasses }}"
69-
:style="`transform: rotate(${isOpen({{ $loop.index }}) ? 180 : 0}deg)`"
70-
fill="none"
71-
viewBox="0 0 24 24"
72-
stroke="currentColor"
108+
<div x-ref="containerRef" data-stx-accordion class="{{ accordionClasses }}" role="region">
109+
@if(hasPropItems)
110+
@foreach(item in items)
111+
<div>
112+
<button
113+
type="button"
114+
data-accordion-header
115+
class="{{ headerClasses }}"
116+
@click="toggleItem({{ $loop.index }})"
117+
@keydown="onHeaderKey($event, {{ $loop.index }})"
118+
:aria-expanded="isOpen({{ $loop.index }}) ? 'true' : 'false'"
119+
aria-controls="accordion-content-{{ $loop.index }}"
73120
>
74-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
75-
</svg>
76-
</button>
121+
<span>{{ item.title }}</span>
122+
<svg
123+
class="{{ iconClasses }}"
124+
:style="`transform: rotate(${isOpen({{ $loop.index }}) ? 180 : 0}deg)`"
125+
fill="none"
126+
viewBox="0 0 24 24"
127+
stroke="currentColor"
128+
>
129+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
130+
</svg>
131+
</button>
77132

78-
<div
79-
:show="isOpen({{ $loop.index }})"
80-
id="accordion-content-{{ $loop.index }}"
81-
class="{{ contentClasses }}"
82-
role="region"
83-
>
84-
{!! item.content !!}
133+
<div
134+
:show="isOpen({{ $loop.index }})"
135+
id="accordion-content-{{ $loop.index }}"
136+
class="{{ contentClasses }}"
137+
role="region"
138+
>
139+
{!! item.content !!}
140+
</div>
85141
</div>
86-
</div>
87-
@endforeach
142+
@endforeach
143+
@else
144+
<slot />
145+
@endif
88146
</div>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script server>
2+
// Slot wrapper that the parent <Accordion> discovers and wires up on mount.
3+
// Renders its own header button + content panel; the parent attaches click
4+
// handlers and toggles the content panel's `hidden` attribute as openItems
5+
// changes. Hard-coded styling here matches the legacy prop-mode markup so
6+
// snapshots compare cleanly in both modes. See stacksjs/stx#1703.
7+
export const title = $props.title || ''
8+
export const className = $props.className || ''
9+
10+
const headerClasses = 'w-full flex items-center justify-between px-4 py-3 text-left font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
11+
const iconClasses = 'w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200'
12+
const contentClasses = 'px-4 py-3 text-gray-700 dark:text-gray-300'
13+
</script>
14+
15+
<div data-stx-accordion-item data-title="{{ title }}" class="{{ className }}">
16+
<button
17+
type="button"
18+
data-stx-accordion-header
19+
data-accordion-header
20+
class="{{ headerClasses }}"
21+
aria-expanded="false"
22+
>
23+
<span>{{ title }}</span>
24+
<svg
25+
data-stx-accordion-chevron
26+
class="{{ iconClasses }}"
27+
fill="none"
28+
viewBox="0 0 24 24"
29+
stroke="currentColor"
30+
>
31+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
32+
</svg>
33+
</button>
34+
<div data-stx-accordion-content class="{{ contentClasses }}" role="region" hidden>
35+
<slot />
36+
</div>
37+
</div>
Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
11
export { default as Accordion } from './Accordion.stx'
2+
export { default as AccordionItem } from './AccordionItem.stx'
23

4+
/**
5+
* Legacy prop API — pass `items` as an array of `{ title, content }` where
6+
* `content` is a HTML string. Kept for backward compatibility; prefer the
7+
* slot API below for any accordion whose content needs to be a component tree.
8+
*
9+
* See stacksjs/stx#1703.
10+
*/
311
export interface AccordionItem {
412
title: string
513
content: string
614
}
715

816
export interface AccordionProps {
9-
items: AccordionItem[]
17+
/** Legacy: array of item definitions with string content. */
18+
items?: AccordionItem[]
1019
allowMultiple?: boolean
1120
defaultOpen?: number[]
1221
onChange?: (openItems: number[]) => void
1322
className?: string
1423
}
24+
25+
/**
26+
* Slot API — wrap each section's content in `<AccordionItem title="…">`.
27+
* Each `<AccordionItem>` renders its own header + content panel; the parent
28+
* `<Accordion>` attaches click handlers and toggles the content visibility.
29+
*
30+
* @example
31+
* ```html
32+
* <Accordion allowMultiple>
33+
* <AccordionItem title="Profile">
34+
* <ProfileEditor :user="user()" />
35+
* </AccordionItem>
36+
* <AccordionItem title="Zones">
37+
* <ZonesEditor />
38+
* </AccordionItem>
39+
* </Accordion>
40+
* ```
41+
*/
42+
export interface AccordionItemProps {
43+
title: string
44+
className?: string
45+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script server>
2+
// Slot wrapper that the parent <Tabs> discovers via DOM walk. The parent
3+
// reads `data-label` (and optional `data-icon`) to build the tab list and
4+
// toggles the `hidden` attribute as the active tab changes. Hidden by
5+
// default — the parent flips the active one visible after mount. See
6+
// stacksjs/stx#1703.
7+
export const label = $props.label || ''
8+
export const icon = $props.icon || ''
9+
export const className = $props.className || ''
10+
11+
const baseClasses = 'p-4 focus:outline-none'
12+
export const panelClasses = `${baseClasses} ${className}`.trim()
13+
</script>
14+
15+
<div
16+
data-stx-tab-panel
17+
data-label="{{ label }}"
18+
data-icon="{{ icon }}"
19+
class="{{ panelClasses }}"
20+
role="tabpanel"
21+
tabindex="0"
22+
hidden
23+
>
24+
<slot />
25+
</div>

0 commit comments

Comments
 (0)