@stacksjs/components 0.2.63 → 0.2.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/Accordion-pt8j917s.stx +146 -0
  2. package/dist/AccordionItem-hbhzx63p.stx +37 -0
  3. package/dist/{Checkbox-zgdmy3p5.stx → Checkbox-aqmq4cqv.stx} +3 -1
  4. package/dist/{Dialog-hczpexs4.stx → Dialog-y5rz2vrq.stx} +5 -1
  5. package/dist/{Drawer-xhevp5cw.stx → Drawer-hrz7qcyp.stx} +4 -1
  6. package/dist/Notification-03vfh3k6.stx +117 -0
  7. package/dist/{NumberInput-drnvtqej.stx → NumberInput-g41gmd2c.stx} +2 -1
  8. package/dist/{Pagination-n3d31cns.stx → Pagination-wtgxmzey.stx} +3 -1
  9. package/dist/{PasswordInput-n02x6053.stx → PasswordInput-4rsq1gwb.stx} +2 -1
  10. package/dist/{Progress-yxcntr4x.stx → Progress-9dhakbh4.stx} +2 -1
  11. package/dist/{Radio-cgjv2hb2.stx → Radio-djtb5dpc.stx} +2 -1
  12. package/dist/{Select-28y0pg83.stx → Select-1vnd0brz.stx} +2 -1
  13. package/dist/{SidebarSection-npt9h265.stx → SidebarSection-g195cb4f.stx} +2 -1
  14. package/dist/{Switch-r5t5w04w.stx → Switch-p1bcr9ks.stx} +3 -1
  15. package/dist/TabPanel-5htevsq6.stx +25 -0
  16. package/dist/Tabs-79brys99.stx +202 -0
  17. package/dist/{TextInput-kf084763.stx → TextInput-g0vaz0pe.stx} +4 -1
  18. package/dist/{Textarea-xg4rr2xz.stx → Textarea-96kawv5c.stx} +2 -1
  19. package/dist/{Tooltip-m0tqd72e.stx → Tooltip-y61h04gp.stx} +2 -1
  20. package/dist/index.js +3909 -15
  21. package/dist/ui/accordion/Accordion.stx +9 -1
  22. package/dist/ui/accordion/AccordionItem.stx +7 -0
  23. package/dist/ui/accordion/index.d.ts +30 -1
  24. package/dist/ui/input/NumberInput.stx +1 -1
  25. package/dist/ui/input/TextInput.stx +1 -1
  26. package/dist/ui/notification/Notification.stx +10 -8
  27. package/dist/ui/select/Select.stx +1 -1
  28. package/dist/ui/tabs/TabPanel.stx +9 -0
  29. package/dist/ui/tabs/Tabs.stx +140 -0
  30. package/dist/ui/tabs/index.d.ts +32 -1
  31. package/dist/ui/textarea/Textarea.stx +1 -1
  32. package/package.json +3 -3
  33. package/src/ui/accordion/Accordion.stx +90 -32
  34. package/src/ui/accordion/AccordionItem.stx +37 -0
  35. package/src/ui/accordion/index.ts +32 -1
  36. package/src/ui/checkbox/Checkbox.stx +3 -1
  37. package/src/ui/dialog/Dialog.stx +5 -1
  38. package/src/ui/drawer/Drawer.stx +4 -1
  39. package/src/ui/input/NumberInput.stx +2 -1
  40. package/src/ui/input/PasswordInput.stx +2 -1
  41. package/src/ui/input/TextInput.stx +4 -1
  42. package/src/ui/notification/Notification.stx +65 -26
  43. package/src/ui/pagination/Pagination.stx +3 -1
  44. package/src/ui/progress/Progress.stx +2 -1
  45. package/src/ui/radio/Radio.stx +2 -1
  46. package/src/ui/select/Select.stx +2 -1
  47. package/src/ui/sidebar/SidebarSection.stx +2 -1
  48. package/src/ui/switch/Switch.stx +3 -1
  49. package/src/ui/tabs/TabPanel.stx +25 -0
  50. package/src/ui/tabs/Tabs.stx +108 -32
  51. package/src/ui/tabs/index.ts +35 -1
  52. package/src/ui/textarea/Textarea.stx +2 -1
  53. package/src/ui/tooltip/Tooltip.stx +2 -1
  54. package/dist/Accordion-19x8q4rj.stx +0 -88
  55. package/dist/Notification-tb9b525s.stx +0 -78
  56. package/dist/Tabs-bzvvn4ke.stx +0 -126
@@ -1,3 +1,10 @@
1
+ // Two modes (see stacksjs/stx#1703):
2
+ // 1. Legacy prop API — pass `items={[{title, content}, …]}` for simple
3
+ // string-content accordions (kept for backward compatibility with
4
+ // existing visual snapshots and consumers).
5
+ // 2. Slot API — drop `<AccordionItem title="…">` children for arbitrary
6
+ // content. Each AccordionItem renders its own header + content panel;
7
+ // this parent attaches click handlers and toggles content visibility.
1
8
  export declare const items: unknown;
2
9
  export declare const allowMultiple: unknown;
3
10
  export declare const defaultOpen: unknown;
@@ -5,4 +12,5 @@ export declare const className: unknown;
5
12
  export declare const accordionClasses: unknown;
6
13
  export declare const headerClasses: unknown;
7
14
  export declare const iconClasses: unknown;
8
- export declare const contentClasses: () => unknown;
15
+ export declare const contentClasses: unknown;
16
+ export declare const hasPropItems: (...args: any[]) => any;
@@ -0,0 +1,7 @@
1
+ // Slot wrapper that the parent <Accordion> discovers and wires up on mount.
2
+ // Renders its own header button + content panel; the parent attaches click
3
+ // handlers and toggles the content panel's `hidden` attribute as openItems
4
+ // changes. Hard-coded styling here matches the legacy prop-mode markup so
5
+ // snapshots compare cleanly in both modes. See stacksjs/stx#1703.
6
+ export declare const title: unknown;
7
+ export declare const className: unknown;
@@ -1,12 +1,41 @@
1
+ /**
2
+ * Legacy prop API — pass `items` as an array of `{ title, content }` where
3
+ * `content` is a HTML string. Kept for backward compatibility; prefer the
4
+ * slot API below for any accordion whose content needs to be a component tree.
5
+ *
6
+ * See stacksjs/stx#1703.
7
+ */
1
8
  export declare interface AccordionItem {
2
9
  title: string
3
10
  content: string
4
11
  }
5
12
  export declare interface AccordionProps {
6
- items: AccordionItem[]
13
+ items?: AccordionItem[]
7
14
  allowMultiple?: boolean
8
15
  defaultOpen?: number[]
9
16
  onChange?: (openItems: number[]) => void
10
17
  className?: string
11
18
  }
19
+ /**
20
+ * Slot API — wrap each section's content in `<AccordionItem title="…">`.
21
+ * Each `<AccordionItem>` renders its own header + content panel; the parent
22
+ * `<Accordion>` attaches click handlers and toggles the content visibility.
23
+ *
24
+ * @example
25
+ * ```html
26
+ * <Accordion allowMultiple>
27
+ * <AccordionItem title="Profile">
28
+ * <ProfileEditor :user="user()" />
29
+ * </AccordionItem>
30
+ * <AccordionItem title="Zones">
31
+ * <ZonesEditor />
32
+ * </AccordionItem>
33
+ * </Accordion>
34
+ * ```
35
+ */
36
+ export declare interface AccordionItemProps {
37
+ title: string
38
+ className?: string
39
+ }
12
40
  export { default as Accordion } from './Accordion.stx';
41
+ export { default as AccordionItem } from './AccordionItem.stx';
@@ -15,4 +15,4 @@ export declare const className: unknown;
15
15
  export declare const inputClassName: unknown;
16
16
  export declare const inputClasses: unknown;
17
17
  export declare const labelClasses: unknown;
18
- export declare const helperClasses: unknown;
18
+ export declare const helperClasses: () => unknown;
@@ -18,4 +18,4 @@ export declare const inputClasses: unknown;
18
18
  export declare const labelClasses: unknown;
19
19
  export declare const helperClasses: unknown;
20
20
  export declare const iconClasses: unknown;
21
- export declare const inputId: () => unknown;
21
+ export declare const inputId: (...args: any[]) => any;
@@ -1,8 +1,10 @@
1
- export declare const show: unknown;
2
- export declare const title: unknown;
3
- export declare const message: unknown;
4
- export declare const type: unknown;
5
- export declare const position: unknown;
6
- export declare const duration: unknown;
7
- export declare const className: unknown;
8
- export declare const notificationClasses: () => unknown;
1
+ // Initial defaults captured at render time. The client script wraps these
2
+ // in signals so consumers can mutate them after mount (fetch-then-toast,
3
+ // auto-dismiss, etc.). See stacksjs/stx#1692.
4
+ export declare const initialShow: unknown;
5
+ export declare const initialTitle: unknown;
6
+ export declare const initialMessage: unknown;
7
+ export declare const initialType: unknown;
8
+ export declare const initialPosition: unknown;
9
+ export declare const initialDuration: unknown;
10
+ export declare const className: (...args: any[]) => any;
@@ -11,4 +11,4 @@ export declare const className: unknown;
11
11
  export declare const selectClasses: unknown;
12
12
  export declare const labelClasses: unknown;
13
13
  export declare const helperClasses: unknown;
14
- export declare const selectId: unknown;
14
+ export declare const selectId: () => unknown;
@@ -0,0 +1,9 @@
1
+ // Slot wrapper that the parent <Tabs> discovers via DOM walk. The parent
2
+ // reads `data-label` (and optional `data-icon`) to build the tab list and
3
+ // toggles the `hidden` attribute as the active tab changes. Hidden by
4
+ // default — the parent flips the active one visible after mount. See
5
+ // stacksjs/stx#1703.
6
+ export declare const label: unknown;
7
+ export declare const icon: unknown;
8
+ export declare const className: unknown;
9
+ export declare const panelClasses: unknown;
@@ -1,3 +1,11 @@
1
+ // Two modes (see stacksjs/stx#1703):
2
+ // 1. Legacy prop API — pass `tabs={[{label, content, icon}, …]}` for
3
+ // simple string-content tabs (kept for backward compatibility with
4
+ // existing visual snapshots and consumers).
5
+ // 2. Slot API — drop `<TabPanel label="…">` children for arbitrary
6
+ // content (components, forms, tables, etc.). The parent enumerates
7
+ // [data-stx-tab-panel] descendants on mount and builds the tab list
8
+ // dynamically.
1
9
  export declare const tabs: unknown;
2
10
  export declare const defaultTab: unknown;
3
11
  export declare const orientation: unknown;
@@ -6,3 +14,135 @@ export declare const className: unknown;
6
14
  export declare const containerClasses: unknown;
7
15
  export declare const tabListClasses: unknown;
8
16
  export declare const tabPanelClasses: unknown;
17
+ // True when the parent passes a non-empty `tabs` array — switches to the
18
+ // legacy render path so existing snapshots stay byte-identical.
19
+ export declare const hasPropTabs: activeTab changes.
20
+ // Prop mode is handled inline by the @foreach loop's :show binding below.
21
+ effect(() => {
22
+ if (hasPropTabs) return
23
+ const panels = findOwnPanels()
24
+ const idx = activeTab()
25
+ panels.forEach((p, i) => {
26
+ if (i === idx) p.removeAttribute('hidden')
27
+ else p.setAttribute('hidden', '')
28
+ })
29
+ })
30
+
31
+ function selectTab(index) {
32
+ activeTab.set(index)
33
+ const list = discoveredTabs()
34
+ emit('change', { index, tab: list[index] })
35
+ }
36
+
37
+ function isActive(index) {
38
+ return activeTab() === index
39
+ }
40
+
41
+ function getTabClasses(index) {
42
+ const isActiveTab = isActive(index)
43
+ const base = 'px-4 py-2 font-medium text-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
44
+ if (variant === 'line') {
45
+ const border = orientation === 'horizontal' ? 'border-b-2' : 'border-r-2'
46
+ return `${base} ${border} ${isActiveTab ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600'}`
47
+ }
48
+ if (variant === 'pills') {
49
+ return `${base} rounded-md ${isActiveTab ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'}`
50
+ }
51
+ if (variant === 'enclosed') {
52
+ const borders = orientation === 'horizontal' ? 'border-t border-l border-r rounded-t-md' : 'border-t border-l border-b rounded-l-md'
53
+ return `${base} ${borders} ${isActiveTab ? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 -mb-px' : 'bg-gray-50 dark:bg-gray-900 border-transparent hover:border-gray-200 dark:hover:border-gray-700'}`
54
+ }
55
+ return base
56
+ }
57
+
58
+ function onTabKey(event, index) {
59
+ const tabCount = discoveredTabs().length
60
+ let next = index
61
+ if (orientation === 'horizontal') {
62
+ if (event.key === 'ArrowRight') { event.preventDefault(); next = (index + 1) % tabCount }
63
+ else if (event.key === 'ArrowLeft') { event.preventDefault(); next = (index - 1 + tabCount) % tabCount }
64
+ }
65
+ else {
66
+ if (event.key === 'ArrowDown') { event.preventDefault(); next = (index + 1) % tabCount }
67
+ else if (event.key === 'ArrowUp') { event.preventDefault(); next = (index - 1 + tabCount) % tabCount }
68
+ }
69
+ if (event.key === 'Home') { event.preventDefault(); next = 0 }
70
+ else if (event.key === 'End') { event.preventDefault(); next = tabCount - 1 }
71
+ if (next !== index) {
72
+ selectTab(next)
73
+ document.getElementById(`tab-${next}`)?.focus()
74
+ }
75
+ }
76
+
77
+ defineExpose({ activeTab, selectTab })
78
+ </script>
79
+
80
+ <div x-ref="containerRef" data-stx-tabs class="{{ containerClasses }}">
81
+ <div
82
+ class="{{ tabListClasses }}"
83
+ role="tablist"
84
+ aria-orientation="{{ orientation }}"
85
+ >
86
+ @if(hasPropTabs)
87
+ {{-- Legacy mode: server-side iterate the prop tabs array. Buttons --}}
88
+ {{-- get static class names so existing visual snapshots stay --}}
89
+ {{-- byte-identical. --}}
90
+ @foreach(tab in tabs)
91
+ <button
92
+ type="button"
93
+ :class="getTabClasses({{ $loop.index }})"
94
+ role="tab"
95
+ :aria-selected="isActive({{ $loop.index }}) ? 'true' : 'false'"
96
+ aria-controls="tab-panel-{{ $loop.index }}"
97
+ id="tab-{{ $loop.index }}"
98
+ :tabindex="isActive({{ $loop.index }}) ? '0' : '-1'"
99
+ @click="selectTab({{ $loop.index }})"
100
+ @keydown="onTabKey($event, {{ $loop.index }})"
101
+ >
102
+ @if(tab.icon)
103
+ <span class="inline-flex items-center gap-2">
104
+ <span x-html="{!! JSON.stringify(tab.icon) !!}"></span>
105
+ <span>{{ tab.label }}</span>
106
+ </span>
107
+ @else
108
+ {{ tab.label }}
109
+ @endif
110
+ </button>
111
+ @endforeach
112
+ @else
113
+ {{-- Slot mode: render the tab list dynamically from --}}
114
+ {{-- discoveredTabs() which is populated in onMount. --}}
115
+ <button
116
+ :for="(tab, idx) in discoveredTabs()"
117
+ :key="idx"
118
+ type="button"
119
+ x-class="getTabClasses(idx)"
120
+ role="tab"
121
+ :aria-selected="isActive(idx) ? 'true' : 'false'"
122
+ :tabindex="isActive(idx) ? 0 : -1"
123
+ @click="selectTab(idx)"
124
+ @keydown="onTabKey($event, idx)"
125
+ >
126
+ <span :if="tab.icon" class="inline-flex items-center gap-2" x-html="tab.icon"></span>
127
+ <span :text="tab.label"></span>
128
+ </button>
129
+ @endif
130
+ </div>
131
+
132
+ @if(hasPropTabs)
133
+ @foreach(tab in tabs)
134
+ <div
135
+ :show="isActive({{ $loop.index }})"
136
+ id="tab-panel-{{ $loop.index }}"
137
+ class="{{ tabPanelClasses }}"
138
+ role="tabpanel"
139
+ aria-labelledby="tab-{{ $loop.index }}"
140
+ tabindex="0"
141
+ >
142
+ {!! tab.content !!}
143
+ </div>
144
+ @endforeach
145
+ @else
146
+ <slot />
147
+ @endif
148
+ </div>;
@@ -1,14 +1,45 @@
1
+ /**
2
+ * Legacy prop API — pass `tabs` as an array of `{ label, content, icon? }`
3
+ * where `content` is a HTML string. Kept for backward compatibility; prefer
4
+ * the slot API below for any tab whose content needs to be a component tree.
5
+ *
6
+ * See stacksjs/stx#1703.
7
+ */
1
8
  export declare interface Tab {
2
9
  label: string
3
10
  content: string
4
11
  icon?: string
5
12
  }
6
13
  export declare interface TabsProps {
7
- tabs: Tab[]
14
+ tabs?: Tab[]
8
15
  defaultTab?: number
9
16
  orientation?: 'horizontal' | 'vertical'
10
17
  variant?: 'line' | 'pills' | 'enclosed'
11
18
  onChange?: (index: number, tab: Tab) => void
12
19
  className?: string
13
20
  }
21
+ /**
22
+ * Slot API — wrap each tab's content in a `<TabPanel label="…">`. The parent
23
+ * `<Tabs>` discovers panels on mount via `[data-stx-tab-panel]` data
24
+ * attributes, builds the tab list dynamically, and toggles each panel's
25
+ * `hidden` attribute as the active tab changes.
26
+ *
27
+ * @example
28
+ * ```html
29
+ * <Tabs defaultTab="0" variant="pills">
30
+ * <TabPanel label="Drivers">
31
+ * <DriversTable :drivers="drivers()" />
32
+ * </TabPanel>
33
+ * <TabPanel label="Notifications" icon="bell">
34
+ * <NotificationsTable />
35
+ * </TabPanel>
36
+ * </Tabs>
37
+ * ```
38
+ */
39
+ export declare interface TabPanelProps {
40
+ label: string
41
+ icon?: string
42
+ className?: string
43
+ }
44
+ export { default as TabPanel } from './TabPanel.stx';
14
45
  export { default as Tabs } from './Tabs.stx';
@@ -15,4 +15,4 @@ export declare const resize: unknown;
15
15
  export declare const className: unknown;
16
16
  export declare const textareaClasses: unknown;
17
17
  export declare const labelClasses: unknown;
18
- export declare const helperClasses: () => unknown;
18
+ export declare const helperClasses: (...args: any[]) => any;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/components",
3
3
  "type": "module",
4
- "version": "0.2.63",
4
+ "version": "0.2.66",
5
5
  "description": "A modern component library built with STX, featuring headwind utility classes and TypeScript syntax highlighting.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -58,8 +58,8 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@cwcss/crosswind": "^0.2.0",
61
- "@stacksjs/stx": "0.2.63",
62
- "bun-plugin-stx": "0.2.63",
61
+ "@stacksjs/stx": "0.2.66",
62
+ "bun-plugin-stx": "0.2.66",
63
63
  "ts-syntax-highlighter": "^0.2.1"
64
64
  },
65
65
  "devDependencies": {
@@ -1,4 +1,11 @@
1
1
  <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.
2
9
  export const items = $props.items || []
3
10
  export const allowMultiple = $props.allowMultiple || false
4
11
  export const defaultOpen = $props.defaultOpen || []
@@ -8,13 +15,56 @@ export const accordionClasses = `divide-y divide-gray-200 dark:divide-gray-700 b
8
15
  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()
9
16
  export const iconClasses = `w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200`.trim()
10
17
  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
11
19
  </script>
12
20
 
13
21
  <script client>
14
22
  const emit = defineEmits()
15
23
  const allowMultiple = {{ allowMultiple }}
24
+ const hasPropItems = {{ hasPropItems }}
25
+ const containerRef = useRef()
16
26
  const openItems = state({{ defaultOpen }})
17
27
 
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
+
18
68
  function toggleItem(index) {
19
69
  const cur = openItems()
20
70
  if (allowMultiple) {
@@ -31,7 +81,9 @@ function isOpen(index) {
31
81
  }
32
82
 
33
83
  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]')
35
87
  if (event.key === 'ArrowDown') {
36
88
  event.preventDefault()
37
89
  buttons[index + 1]?.focus()
@@ -49,40 +101,46 @@ function onHeaderKey(event, index) {
49
101
  buttons[buttons.length - 1]?.focus()
50
102
  }
51
103
  }
104
+
105
+ defineExpose({ openItems, toggleItem })
52
106
  </script>
53
107
 
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 }}"
73
120
  >
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>
77
132
 
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>
85
141
  </div>
86
- </div>
87
- @endforeach
142
+ @endforeach
143
+ @else
144
+ <slot />
145
+ @endif
88
146
  </div>
@@ -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>
@@ -1,14 +1,45 @@
1
1
  export { default as Accordion } from './Accordion.stx'
2
+ export { default as AccordionItem } from './AccordionItem.stx'
2
3
 
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
+ */
3
11
  export interface AccordionItem {
4
12
  title: string
5
13
  content: string
6
14
  }
7
15
 
8
16
  export interface AccordionProps {
9
- items: AccordionItem[]
17
+ /** Legacy: array of item definitions with string content. */
18
+ items?: AccordionItem[]
10
19
  allowMultiple?: boolean
11
20
  defaultOpen?: number[]
12
21
  onChange?: (openItems: number[]) => void
13
22
  className?: string
14
23
  }
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
+ }
@@ -26,7 +26,9 @@ export const inputId = `checkbox-${value || label}`
26
26
  const emit = defineEmits()
27
27
  const isDisabled = {{ disabled }}
28
28
  const isIndeterminate = {{ indeterminate }}
29
- const isChecked = state({{ checked }})
29
+ // Reactive prop binding so the parent can drive `checked` via signals.
30
+ // See stacksjs/stx#1704.
31
+ const isChecked = useReactiveProp('checked', {{ checked }})
30
32
 
31
33
  function onInputChange(event) {
32
34
  if (isDisabled) {
@@ -8,7 +8,11 @@ export const dialogClasses = `${baseClasses} ${className}`.trim()
8
8
 
9
9
  <script client>
10
10
  const emit = defineEmits()
11
- const isOpen = state({{ open }})
11
+ // useReactiveProp wires `:open="parentSignal()"` from the parent into our
12
+ // local `isOpen` signal — when the parent's signal changes, bindAttr
13
+ // updates the root's `open` attribute, our MutationObserver picks it up,
14
+ // and isOpen flips. See stacksjs/stx#1704.
15
+ const isOpen = useReactiveProp('open', {{ open }})
12
16
 
13
17
  function close() {
14
18
  isOpen.set(false)
@@ -32,7 +32,10 @@ export const closeButtonClasses = `absolute top-0 ${closeButtonPosition[position
32
32
 
33
33
  <script client>
34
34
  const emit = defineEmits()
35
- const isOpen = state({{ open }})
35
+ // useReactiveProp lets the parent drive `open` via signals
36
+ // (`:open="drawerOpen()"`) instead of the prop being captured once.
37
+ // See stacksjs/stx#1704.
38
+ const isOpen = useReactiveProp('open', {{ open }})
36
39
 
37
40
  function close() {
38
41
  isOpen.set(false)
@@ -37,7 +37,8 @@ const emit = defineEmits()
37
37
  const stepSize = {{ step }}
38
38
  const minVal = {{ min === undefined ? 'undefined' : min }}
39
39
  const maxVal = {{ max === undefined ? 'undefined' : max }}
40
- const inputValue = state({{ value }})
40
+ // Reactive prop binding — parent can drive value via signals. See #1704.
41
+ const inputValue = useReactiveProp('value', {{ value }}, { parse: (v) => v == null || v === '' ? '' : String(v) })
41
42
 
42
43
  function onInput(event) {
43
44
  inputValue.set(event.target.value)
@@ -34,7 +34,8 @@ export const iconClasses = iconSizeClasses[size]
34
34
  <script client>
35
35
  const emit = defineEmits()
36
36
  const showPassword = state(false)
37
- const inputValue = state({{ value }})
37
+ // Reactive prop binding — parent can drive value via signals. See #1704.
38
+ const inputValue = useReactiveProp('value', {{ value }}, { parse: (v) => v == null ? '' : String(v) })
38
39
 
39
40
  function onInput(event) {
40
41
  inputValue.set(event.target.value)
@@ -39,7 +39,10 @@ export const inputId = `input-${label}`
39
39
 
40
40
  <script client>
41
41
  const emit = defineEmits()
42
- const inputValue = state({{ value }})
42
+ // Reactive prop binding so the parent can drive `value` via signals.
43
+ // Parent listens to @input to push user changes back into its signal.
44
+ // See stacksjs/stx#1704.
45
+ const inputValue = useReactiveProp('value', {{ value }}, { parse: (v) => v == null ? '' : String(v) })
43
46
 
44
47
  function onInput(event) {
45
48
  inputValue.set(event.target.value)