@stacksjs/components 0.2.110 → 0.2.112

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 (190) hide show
  1. package/dist/{Sidebar-4hn7vw4n.stx → Sidebar-bg47fqmq.stx} +100 -3
  2. package/dist/{SidebarItem-gtrcw4ky.stx → SidebarItem-9rvbwhxr.stx} +11 -7
  3. package/dist/index.js +15 -12
  4. package/dist/ui/sidebar/themes.d.ts +26 -3
  5. package/package.json +4 -3
  6. package/src/components/CodeBlock.stx +54 -0
  7. package/src/components/Footer.stx +66 -0
  8. package/src/components/Hero.stx +25 -0
  9. package/src/components/Installation.stx +37 -0
  10. package/src/components/index.ts +4 -0
  11. package/src/composables/index.ts +4 -0
  12. package/src/composables/useCopyCode.ts +73 -0
  13. package/src/composables/useDarkMode.ts +100 -0
  14. package/src/composables/useSEO.ts +230 -0
  15. package/src/composables/utilities/index.ts +7 -0
  16. package/src/composables/utilities/useClickOutside.ts +72 -0
  17. package/src/composables/utilities/useFocusTrap.ts +159 -0
  18. package/src/composables/utilities/useKeyboard.ts +124 -0
  19. package/src/composables/utilities/useLocalStorage.ts +132 -0
  20. package/src/composables/utilities/useMediaQuery.ts +118 -0
  21. package/src/index.ts +99 -0
  22. package/src/stx.d.ts +4 -0
  23. package/src/ui/accordion/Accordion.stx +146 -0
  24. package/src/ui/accordion/AccordionItem.stx +37 -0
  25. package/src/ui/accordion/index.ts +47 -0
  26. package/src/ui/audio/Audio.stx +50 -0
  27. package/src/ui/audio/README.md +41 -0
  28. package/src/ui/audio/index.ts +13 -0
  29. package/src/ui/auth/Login.stx +161 -0
  30. package/src/ui/auth/README.md +142 -0
  31. package/src/ui/auth/Signup.stx +159 -0
  32. package/src/ui/auth/TwoFactorChallenge.stx +111 -0
  33. package/src/ui/auth/index.ts +32 -0
  34. package/src/ui/avatar/Avatar.stx +77 -0
  35. package/src/ui/avatar/index.ts +12 -0
  36. package/src/ui/badge/Badge.stx +62 -0
  37. package/src/ui/badge/index.ts +10 -0
  38. package/src/ui/breadcrumb/Breadcrumb.stx +79 -0
  39. package/src/ui/breadcrumb/index.ts +15 -0
  40. package/src/ui/button/Button.stx +74 -0
  41. package/src/ui/button/README.md +80 -0
  42. package/src/ui/button/index.ts +85 -0
  43. package/src/ui/calendar/Calendar.stx +189 -0
  44. package/src/ui/calendar/README.md +254 -0
  45. package/src/ui/calendar/index.ts +14 -0
  46. package/src/ui/card/Card.stx +68 -0
  47. package/src/ui/card/index.ts +13 -0
  48. package/src/ui/checkbox/Checkbox.stx +86 -0
  49. package/src/ui/checkbox/index.ts +16 -0
  50. package/src/ui/combobox/Combobox.stx +96 -0
  51. package/src/ui/combobox/ComboboxButton.stx +25 -0
  52. package/src/ui/combobox/ComboboxInput.stx +32 -0
  53. package/src/ui/combobox/ComboboxOption.stx +49 -0
  54. package/src/ui/combobox/ComboboxOptions.stx +16 -0
  55. package/src/ui/combobox/README.md +150 -0
  56. package/src/ui/combobox/index.ts +40 -0
  57. package/src/ui/command-palette/CommandPalette.stx +81 -0
  58. package/src/ui/command-palette/CommandPaletteItem.stx +27 -0
  59. package/src/ui/command-palette/README.md +42 -0
  60. package/src/ui/command-palette/index.ts +15 -0
  61. package/src/ui/dialog/Dialog.stx +41 -0
  62. package/src/ui/dialog/DialogBackdrop.stx +9 -0
  63. package/src/ui/dialog/DialogDescription.stx +11 -0
  64. package/src/ui/dialog/DialogPanel.stx +16 -0
  65. package/src/ui/dialog/DialogTitle.stx +11 -0
  66. package/src/ui/dialog/README.md +121 -0
  67. package/src/ui/dialog/index.ts +32 -0
  68. package/src/ui/drawer/Drawer.stx +120 -0
  69. package/src/ui/drawer/README.md +90 -0
  70. package/src/ui/drawer/index.ts +9 -0
  71. package/src/ui/dropdown/Dropdown.stx +82 -0
  72. package/src/ui/dropdown/DropdownButton.stx +29 -0
  73. package/src/ui/dropdown/DropdownItem.stx +35 -0
  74. package/src/ui/dropdown/DropdownItems.stx +18 -0
  75. package/src/ui/dropdown/README.md +114 -0
  76. package/src/ui/dropdown/index.ts +29 -0
  77. package/src/ui/form/Form.stx +95 -0
  78. package/src/ui/form/index.ts +67 -0
  79. package/src/ui/heatmap/Heatmap.stx +294 -0
  80. package/src/ui/image/Image.stx +165 -0
  81. package/src/ui/image/README.md +164 -0
  82. package/src/ui/image/index.ts +22 -0
  83. package/src/ui/input/EmailInput.stx +30 -0
  84. package/src/ui/input/NumberInput.stx +126 -0
  85. package/src/ui/input/PasswordInput.stx +143 -0
  86. package/src/ui/input/SearchInput.stx +44 -0
  87. package/src/ui/input/TextInput.stx +132 -0
  88. package/src/ui/input/index.ts +180 -0
  89. package/src/ui/listbox/Listbox.stx +101 -0
  90. package/src/ui/listbox/ListboxButton.stx +28 -0
  91. package/src/ui/listbox/ListboxLabel.stx +11 -0
  92. package/src/ui/listbox/ListboxOption.stx +49 -0
  93. package/src/ui/listbox/ListboxOptions.stx +16 -0
  94. package/src/ui/listbox/README.md +135 -0
  95. package/src/ui/listbox/index.ts +39 -0
  96. package/src/ui/navigator/Navigator.stx +113 -0
  97. package/src/ui/navigator/README.md +352 -0
  98. package/src/ui/navigator/index.ts +20 -0
  99. package/src/ui/notification/Notification.stx +117 -0
  100. package/src/ui/notification/README.md +114 -0
  101. package/src/ui/notification/index.ts +12 -0
  102. package/src/ui/pagination/Pagination.stx +150 -0
  103. package/src/ui/pagination/index.ts +11 -0
  104. package/src/ui/payment/Checkout.stx +31 -0
  105. package/src/ui/payment/DefaultPaymentMethod.stx +62 -0
  106. package/src/ui/payment/PaymentMethods.stx +104 -0
  107. package/src/ui/payment/README.md +345 -0
  108. package/src/ui/payment/SubscriptionCheckout.stx +323 -0
  109. package/src/ui/payment/index.ts +59 -0
  110. package/src/ui/popover/Popover.stx +60 -0
  111. package/src/ui/popover/PopoverButton.stx +29 -0
  112. package/src/ui/popover/PopoverPanel.stx +23 -0
  113. package/src/ui/popover/README.md +91 -0
  114. package/src/ui/popover/index.ts +22 -0
  115. package/src/ui/portal/Portal.stx +57 -0
  116. package/src/ui/portal/index.ts +6 -0
  117. package/src/ui/progress/Progress.stx +131 -0
  118. package/src/ui/progress/index.ts +12 -0
  119. package/src/ui/radio/Radio.stx +77 -0
  120. package/src/ui/radio/index.ts +15 -0
  121. package/src/ui/radio-group/README.md +139 -0
  122. package/src/ui/radio-group/RadioGroup.stx +60 -0
  123. package/src/ui/radio-group/RadioGroupDescription.stx +11 -0
  124. package/src/ui/radio-group/RadioGroupLabel.stx +11 -0
  125. package/src/ui/radio-group/RadioGroupOption.stx +58 -0
  126. package/src/ui/radio-group/index.ts +30 -0
  127. package/src/ui/select/README.md +180 -0
  128. package/src/ui/select/Select.stx +82 -0
  129. package/src/ui/select/index.ts +21 -0
  130. package/src/ui/sidebar/Sidebar.stx +573 -0
  131. package/src/ui/sidebar/SidebarFooter.stx +71 -0
  132. package/src/ui/sidebar/SidebarHeader.stx +103 -0
  133. package/src/ui/sidebar/SidebarItem.stx +90 -0
  134. package/src/ui/sidebar/SidebarSection.stx +83 -0
  135. package/src/ui/sidebar/index.ts +146 -0
  136. package/src/ui/sidebar/themes.ts +288 -0
  137. package/src/ui/skeleton/Skeleton.stx +48 -0
  138. package/src/ui/skeleton/index.ts +11 -0
  139. package/src/ui/spinner/Spinner.stx +109 -0
  140. package/src/ui/spinner/index.ts +9 -0
  141. package/src/ui/stepper/README.md +36 -0
  142. package/src/ui/stepper/Stepper.stx +17 -0
  143. package/src/ui/stepper/StepperStep.stx +57 -0
  144. package/src/ui/stepper/index.ts +19 -0
  145. package/src/ui/storage/README.md +193 -0
  146. package/src/ui/storage/index.ts +347 -0
  147. package/src/ui/switch/README.md +114 -0
  148. package/src/ui/switch/Switch.stx +72 -0
  149. package/src/ui/switch/index.ts +10 -0
  150. package/src/ui/table/README.md +39 -0
  151. package/src/ui/table/Table.stx +14 -0
  152. package/src/ui/table/TableBody.stx +10 -0
  153. package/src/ui/table/TableCell.stx +10 -0
  154. package/src/ui/table/TableHead.stx +10 -0
  155. package/src/ui/table/TableHeader.stx +20 -0
  156. package/src/ui/table/TableRow.stx +11 -0
  157. package/src/ui/table/index.ts +35 -0
  158. package/src/ui/tabs/TabPanel.stx +25 -0
  159. package/src/ui/tabs/Tabs.stx +202 -0
  160. package/src/ui/tabs/index.ts +50 -0
  161. package/src/ui/teleport/Teleport.stx +65 -0
  162. package/src/ui/teleport/index.ts +7 -0
  163. package/src/ui/textarea/Textarea.stx +108 -0
  164. package/src/ui/textarea/index.ts +20 -0
  165. package/src/ui/tooltip/Tooltip.stx +71 -0
  166. package/src/ui/tooltip/index.ts +10 -0
  167. package/src/ui/transition/README.md +30 -0
  168. package/src/ui/transition/Transition.stx +102 -0
  169. package/src/ui/transition/index.ts +25 -0
  170. package/src/ui/video/README.md +47 -0
  171. package/src/ui/video/Video.stx +45 -0
  172. package/src/ui/video/index.ts +16 -0
  173. package/src/ui/virtual-list/VirtualList.stx +67 -0
  174. package/src/ui/virtual-list/index.ts +10 -0
  175. package/src/ui/virtual-table/VirtualTable.stx +103 -0
  176. package/src/ui/virtual-table/index.ts +21 -0
  177. package/src/utils/PROP_VALIDATION.md +458 -0
  178. package/src/utils/THEME_SYSTEM.md +702 -0
  179. package/src/utils/accessibility.ts +841 -0
  180. package/src/utils/animation/easing.ts +368 -0
  181. package/src/utils/animation/helpers.ts +223 -0
  182. package/src/utils/animation/index.ts +5 -0
  183. package/src/utils/animation/keyframes.ts +230 -0
  184. package/src/utils/error-handling.ts +475 -0
  185. package/src/utils/highlighter.ts +93 -0
  186. package/src/utils/i18n.ts +634 -0
  187. package/src/utils/keep-alive.ts +819 -0
  188. package/src/utils/lazy-loading.ts +530 -0
  189. package/src/utils/prop-validation.ts +478 -0
  190. package/src/utils/theme.ts +626 -0
@@ -0,0 +1,72 @@
1
+ <script server>
2
+ export const checked = $props.checked || false
3
+ export const disabled = $props.disabled || false
4
+ export const size = $props.size || 'md'
5
+ export const className = $props.className || ''
6
+ export const label = $props.label || ''
7
+
8
+ const sizeConfig = {
9
+ sm: { track: 'h-5 w-9', thumb: 'h-4 w-4', translate: 'translate-x-4' },
10
+ md: { track: 'h-[38px] w-[74px]', thumb: 'h-[34px] w-[34px]', translate: 'translate-x-9' },
11
+ lg: { track: 'h-12 w-24', thumb: 'h-11 w-11', translate: 'translate-x-12' },
12
+ }
13
+
14
+ export const config = sizeConfig[size]
15
+
16
+ export const trackBaseClasses = 'relative inline-flex shrink-0 cursor-pointer border-2 border-transparent rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-300 dark:focus-visible:ring-offset-neutral-700'
17
+ export const trackDisabledClasses = disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : ''
18
+ export const thumbBaseClasses = 'pointer-events-none inline-block transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out'
19
+ </script>
20
+
21
+ <script client>
22
+ const emit = defineEmits()
23
+ const isDisabled = {{ disabled }}
24
+ const config = {{ config }}
25
+ const trackBase = {{ trackBaseClasses }}
26
+ const trackDisabled = {{ trackDisabledClasses }}
27
+ const thumbBase = {{ thumbBaseClasses }}
28
+ const extraClass = {{ className }}
29
+ // Reactive prop binding so the parent can drive `checked` via signals.
30
+ // See stacksjs/stx#1704.
31
+ const isChecked = useReactiveProp('checked', {{ checked }})
32
+
33
+ function toggle() {
34
+ if (isDisabled) return
35
+ isChecked.set(!isChecked())
36
+ emit('change', isChecked())
37
+ }
38
+
39
+ function trackClasses() {
40
+ const stateClass = isChecked()
41
+ ? 'bg-blue-500 dark:bg-blue-600'
42
+ : 'bg-neutral-400 dark:bg-neutral-600'
43
+ return `${trackBase} ${stateClass} ${trackDisabled} ${config.track} ${extraClass}`.trim()
44
+ }
45
+
46
+ function thumbClasses() {
47
+ const pos = isChecked() ? config.translate : 'translate-x-0'
48
+ return `${thumbBase} ${pos} ${config.thumb}`.trim()
49
+ }
50
+
51
+ defineExpose({ isChecked, toggle })
52
+ </script>
53
+
54
+ <label class="inline-flex items-center gap-3 cursor-pointer">
55
+ @if(label)
56
+ <span class="text-sm font-medium text-neutral-700 dark:text-neutral-300">
57
+ {{ label }}
58
+ </span>
59
+ @endif
60
+
61
+ <button
62
+ type="button"
63
+ role="switch"
64
+ :aria-checked="isChecked() ? 'true' : 'false'"
65
+ :class="trackClasses()"
66
+ @if(disabled)disabled aria-disabled="true"@endif
67
+ @click="toggle()"
68
+ >
69
+ <span class="sr-only">@if(label){{ label }}@else Toggle switch@endif</span>
70
+ <span aria-hidden="true" :class="thumbClasses()"></span>
71
+ </button>
72
+ </label>
@@ -0,0 +1,10 @@
1
+ export { default as Switch } from './Switch.stx'
2
+
3
+ export interface SwitchProps {
4
+ checked?: boolean
5
+ disabled?: boolean
6
+ size?: 'sm' | 'md' | 'lg'
7
+ className?: string
8
+ onChange?: (checked: boolean) => void
9
+ label?: string
10
+ }
@@ -0,0 +1,39 @@
1
+ # Table Component
2
+
3
+ Responsive table component with sorting and styling options.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @stacksjs/components
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```stx
14
+ <Table>
15
+ <TableHead>
16
+ <TableRow>
17
+ <TableHeader>Name</TableHeader>
18
+ <TableHeader>Email</TableHeader>
19
+ <TableHeader>Role</TableHeader>
20
+ </TableRow>
21
+ </TableHead>
22
+
23
+ <TableBody>
24
+ <TableRow>
25
+ <TableCell>John Doe</TableCell>
26
+ <TableCell>john@example.com</TableCell>
27
+ <TableCell>Admin</TableCell>
28
+ </TableRow>
29
+ </TableBody>
30
+ </Table>
31
+ ```
32
+
33
+ ## Features
34
+
35
+ - Responsive design
36
+ - Sortable columns
37
+ - Striped rows
38
+ - Hover effects
39
+ - Dark mode support
@@ -0,0 +1,14 @@
1
+ <script server>
2
+ export const className = $props.className || ''
3
+ export const striped = $props.striped || false
4
+ export const hoverable = $props.hoverable !== false
5
+
6
+ const baseClasses = 'min-w-full divide-y divide-neutral-200 dark:divide-neutral-700'
7
+ const tableClasses = `${baseClasses} ${className}`.trim()
8
+ </script>
9
+
10
+ <div class="overflow-x-auto">
11
+ <table class="{{ tableClasses }}">
12
+ <slot :striped="striped" :hoverable="hoverable" />
13
+ </table>
14
+ </div>
@@ -0,0 +1,10 @@
1
+ <script server>
2
+ export const className = $props.className || ''
3
+
4
+ const baseClasses = 'bg-white divide-y divide-neutral-200 dark:bg-neutral-900 dark:divide-neutral-700'
5
+ const bodyClasses = `${baseClasses} ${className}`.trim()
6
+ </script>
7
+
8
+ <tbody class="{{ bodyClasses }}">
9
+ <slot />
10
+ </tbody>
@@ -0,0 +1,10 @@
1
+ <script server>
2
+ export const className = $props.className || ''
3
+
4
+ const baseClasses = 'px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100'
5
+ const cellClasses = `${baseClasses} ${className}`.trim()
6
+ </script>
7
+
8
+ <td class="{{ cellClasses }}">
9
+ <slot />
10
+ </td>
@@ -0,0 +1,10 @@
1
+ <script server>
2
+ export const className = $props.className || ''
3
+
4
+ const baseClasses = 'bg-neutral-50 dark:bg-neutral-800'
5
+ const headClasses = `${baseClasses} ${className}`.trim()
6
+ </script>
7
+
8
+ <thead class="{{ headClasses }}">
9
+ <slot />
10
+ </thead>
@@ -0,0 +1,20 @@
1
+ <script server>
2
+ export const className = $props.className || ''
3
+ export const sortable = $props.sortable || false
4
+
5
+ const baseClasses = 'px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider dark:text-neutral-400'
6
+ const sortableClasses = sortable ? 'cursor-pointer hover:text-neutral-700 dark:hover:text-neutral-200' : ''
7
+ export const headerClasses = `${baseClasses} ${sortableClasses} ${className}`.trim()
8
+ </script>
9
+
10
+ <script client>
11
+ const emit = defineEmits()
12
+
13
+ function onClick() {
14
+ emit('click')
15
+ }
16
+ </script>
17
+
18
+ <th class="{{ headerClasses }}" @if(sortable)@click="onClick()"@endif>
19
+ <slot />
20
+ </th>
@@ -0,0 +1,11 @@
1
+ <script server>
2
+ export const className = $props.className || ''
3
+ export const hoverable = $props.hoverable !== false
4
+
5
+ const hoverClasses = hoverable ? 'hover:bg-neutral-50 dark:hover:bg-neutral-800' : ''
6
+ const rowClasses = `${hoverClasses} ${className}`.trim()
7
+ </script>
8
+
9
+ <tr class="{{ rowClasses }}">
10
+ <slot />
11
+ </tr>
@@ -0,0 +1,35 @@
1
+ export { default as Table } from './Table.stx'
2
+ export { default as TableBody } from './TableBody.stx'
3
+ export { default as TableCell } from './TableCell.stx'
4
+ export { default as TableHead } from './TableHead.stx'
5
+ export { default as TableHeader } from './TableHeader.stx'
6
+ export { default as TableRow } from './TableRow.stx'
7
+
8
+ export interface TableProps {
9
+ className?: string
10
+ striped?: boolean
11
+ hoverable?: boolean
12
+ }
13
+
14
+ export interface TableHeadProps {
15
+ className?: string
16
+ }
17
+
18
+ export interface TableBodyProps {
19
+ className?: string
20
+ }
21
+
22
+ export interface TableRowProps {
23
+ className?: string
24
+ hoverable?: boolean
25
+ }
26
+
27
+ export interface TableHeaderProps {
28
+ className?: string
29
+ sortable?: boolean
30
+ onClick?: () => void
31
+ }
32
+
33
+ export interface TableCellProps {
34
+ className?: string
35
+ }
@@ -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,0 +1,202 @@
1
+ <script server>
2
+ // Two modes (see stacksjs/stx#1703):
3
+ // 1. Legacy prop API — pass `tabs={[{label, content, icon}, …]}` for
4
+ // simple string-content tabs (kept for backward compatibility with
5
+ // existing visual snapshots and consumers).
6
+ // 2. Slot API — drop `<TabPanel label="…">` children for arbitrary
7
+ // content (components, forms, tables, etc.). The parent enumerates
8
+ // [data-stx-tab-panel] descendants on mount and builds the tab list
9
+ // dynamically.
10
+ export const tabs = $props.tabs || []
11
+ export const defaultTab = $props.defaultTab || 0
12
+ export const orientation = $props.orientation || 'horizontal'
13
+ export const variant = $props.variant || 'line'
14
+ export const className = $props.className || ''
15
+
16
+ const orientationClasses = {
17
+ horizontal: 'flex-col',
18
+ vertical: 'flex-row',
19
+ }
20
+
21
+ const tabListOrientationClasses = {
22
+ horizontal: 'flex-row border-b',
23
+ vertical: 'flex-col border-r',
24
+ }
25
+
26
+ const variantClasses = {
27
+ line: 'border-gray-200 dark:border-gray-700',
28
+ pills: 'gap-2 border-0 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg',
29
+ enclosed: 'border-gray-200 dark:border-gray-700',
30
+ }
31
+
32
+ export const containerClasses = `flex ${orientationClasses[orientation]} ${className}`.trim()
33
+ export const tabListClasses = `flex ${tabListOrientationClasses[orientation]} ${variantClasses[variant]}`.trim()
34
+ export const tabPanelClasses = `p-4 focus:outline-none`.trim()
35
+ // True when the parent passes a non-empty `tabs` array — switches to the
36
+ // legacy render path so existing snapshots stay byte-identical.
37
+ export const hasPropTabs = Array.isArray(tabs) && tabs.length > 0
38
+ </script>
39
+
40
+ <script client>
41
+ const emit = defineEmits()
42
+ const tabs = {{ tabs }}
43
+ const variant = {{ variant }}
44
+ const orientation = {{ orientation }}
45
+ const hasPropTabs = {{ hasPropTabs }}
46
+ const containerRef = useRef()
47
+ // In slot mode, `discoveredTabs` is populated from child <TabPanel> data
48
+ // attributes on mount. In prop mode, it mirrors the `tabs` prop so the
49
+ // rendering loop works the same in both modes.
50
+ const discoveredTabs = state(hasPropTabs ? tabs : [])
51
+ const activeTab = state({{ defaultTab }})
52
+
53
+ // Walk only direct descendants of THIS container (not nested Tabs').
54
+ function findOwnPanels() {
55
+ const root = containerRef.value
56
+ if (!root) return []
57
+ // Closest ancestor with data-stx-tabs must be this container's root —
58
+ // protects against nested <Tabs> contamination.
59
+ return Array.from(root.querySelectorAll('[data-stx-tab-panel]'))
60
+ .filter(p => p.closest('[data-stx-tabs]') === root)
61
+ }
62
+
63
+ onMount(() => {
64
+ if (hasPropTabs) return // legacy mode: prop array already in discoveredTabs
65
+ const panels = findOwnPanels()
66
+ const list = panels.map(p => ({
67
+ label: p.dataset.label || '',
68
+ icon: p.dataset.icon || '',
69
+ }))
70
+ discoveredTabs.set(list)
71
+ })
72
+
73
+ // In slot mode, toggle each panel's `hidden` attribute as activeTab changes.
74
+ // Prop mode is handled inline by the @foreach loop's :show binding below.
75
+ effect(() => {
76
+ if (hasPropTabs) return
77
+ const panels = findOwnPanels()
78
+ const idx = activeTab()
79
+ panels.forEach((p, i) => {
80
+ if (i === idx) p.removeAttribute('hidden')
81
+ else p.setAttribute('hidden', '')
82
+ })
83
+ })
84
+
85
+ function selectTab(index) {
86
+ activeTab.set(index)
87
+ const list = discoveredTabs()
88
+ emit('change', { index, tab: list[index] })
89
+ }
90
+
91
+ function isActive(index) {
92
+ return activeTab() === index
93
+ }
94
+
95
+ function getTabClasses(index) {
96
+ const isActiveTab = isActive(index)
97
+ 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'
98
+ if (variant === 'line') {
99
+ const border = orientation === 'horizontal' ? 'border-b-2' : 'border-r-2'
100
+ 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'}`
101
+ }
102
+ if (variant === 'pills') {
103
+ 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'}`
104
+ }
105
+ if (variant === 'enclosed') {
106
+ const borders = orientation === 'horizontal' ? 'border-t border-l border-r rounded-t-md' : 'border-t border-l border-b rounded-l-md'
107
+ 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'}`
108
+ }
109
+ return base
110
+ }
111
+
112
+ function onTabKey(event, index) {
113
+ const tabCount = discoveredTabs().length
114
+ let next = index
115
+ if (orientation === 'horizontal') {
116
+ if (event.key === 'ArrowRight') { event.preventDefault(); next = (index + 1) % tabCount }
117
+ else if (event.key === 'ArrowLeft') { event.preventDefault(); next = (index - 1 + tabCount) % tabCount }
118
+ }
119
+ else {
120
+ if (event.key === 'ArrowDown') { event.preventDefault(); next = (index + 1) % tabCount }
121
+ else if (event.key === 'ArrowUp') { event.preventDefault(); next = (index - 1 + tabCount) % tabCount }
122
+ }
123
+ if (event.key === 'Home') { event.preventDefault(); next = 0 }
124
+ else if (event.key === 'End') { event.preventDefault(); next = tabCount - 1 }
125
+ if (next !== index) {
126
+ selectTab(next)
127
+ document.getElementById(`tab-${next}`)?.focus()
128
+ }
129
+ }
130
+
131
+ defineExpose({ activeTab, selectTab })
132
+ </script>
133
+
134
+ <div x-ref="containerRef" data-stx-tabs class="{{ containerClasses }}">
135
+ <div
136
+ class="{{ tabListClasses }}"
137
+ role="tablist"
138
+ aria-orientation="{{ orientation }}"
139
+ >
140
+ @if(hasPropTabs)
141
+ {{-- Legacy mode: server-side iterate the prop tabs array. Buttons --}}
142
+ {{-- get static class names so existing visual snapshots stay --}}
143
+ {{-- byte-identical. --}}
144
+ @foreach(tab in tabs)
145
+ <button
146
+ type="button"
147
+ :class="getTabClasses({{ $loop.index }})"
148
+ role="tab"
149
+ :aria-selected="isActive({{ $loop.index }}) ? 'true' : 'false'"
150
+ aria-controls="tab-panel-{{ $loop.index }}"
151
+ id="tab-{{ $loop.index }}"
152
+ :tabindex="isActive({{ $loop.index }}) ? '0' : '-1'"
153
+ @click="selectTab({{ $loop.index }})"
154
+ @keydown="onTabKey($event, {{ $loop.index }})"
155
+ >
156
+ @if(tab.icon)
157
+ <span class="inline-flex items-center gap-2">
158
+ <span x-html="{!! JSON.stringify(tab.icon) !!}"></span>
159
+ <span>{{ tab.label }}</span>
160
+ </span>
161
+ @else
162
+ {{ tab.label }}
163
+ @endif
164
+ </button>
165
+ @endforeach
166
+ @else
167
+ {{-- Slot mode: render the tab list dynamically from --}}
168
+ {{-- discoveredTabs() which is populated in onMount. --}}
169
+ <button
170
+ :for="(tab, idx) in discoveredTabs()"
171
+ :key="idx"
172
+ type="button"
173
+ x-class="getTabClasses(idx)"
174
+ role="tab"
175
+ :aria-selected="isActive(idx) ? 'true' : 'false'"
176
+ :tabindex="isActive(idx) ? 0 : -1"
177
+ @click="selectTab(idx)"
178
+ @keydown="onTabKey($event, idx)"
179
+ >
180
+ <span :if="tab.icon" class="inline-flex items-center gap-2" x-html="tab.icon"></span>
181
+ <span :text="tab.label"></span>
182
+ </button>
183
+ @endif
184
+ </div>
185
+
186
+ @if(hasPropTabs)
187
+ @foreach(tab in tabs)
188
+ <div
189
+ :show="isActive({{ $loop.index }})"
190
+ id="tab-panel-{{ $loop.index }}"
191
+ class="{{ tabPanelClasses }}"
192
+ role="tabpanel"
193
+ aria-labelledby="tab-{{ $loop.index }}"
194
+ tabindex="0"
195
+ >
196
+ {!! tab.content !!}
197
+ </div>
198
+ @endforeach
199
+ @else
200
+ <slot />
201
+ @endif
202
+ </div>
@@ -0,0 +1,50 @@
1
+ export { default as TabPanel } from './TabPanel.stx'
2
+ export { default as Tabs } from './Tabs.stx'
3
+
4
+ /**
5
+ * Legacy prop API — pass `tabs` as an array of `{ label, content, icon? }`
6
+ * where `content` is a HTML string. Kept for backward compatibility; prefer
7
+ * the slot API below for any tab whose content needs to be a component tree.
8
+ *
9
+ * See stacksjs/stx#1703.
10
+ */
11
+ export interface Tab {
12
+ label: string
13
+ content: string
14
+ icon?: string
15
+ }
16
+
17
+ export interface TabsProps {
18
+ /** Legacy: array of tab definitions with string content. */
19
+ tabs?: Tab[]
20
+ defaultTab?: number
21
+ orientation?: 'horizontal' | 'vertical'
22
+ variant?: 'line' | 'pills' | 'enclosed'
23
+ onChange?: (index: number, tab: Tab) => void
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * Slot API — wrap each tab's content in a `<TabPanel label="…">`. The parent
29
+ * `<Tabs>` discovers panels on mount via `[data-stx-tab-panel]` data
30
+ * attributes, builds the tab list dynamically, and toggles each panel's
31
+ * `hidden` attribute as the active tab changes.
32
+ *
33
+ * @example
34
+ * ```html
35
+ * <Tabs defaultTab="0" variant="pills">
36
+ * <TabPanel label="Drivers">
37
+ * <DriversTable :drivers="drivers()" />
38
+ * </TabPanel>
39
+ * <TabPanel label="Notifications" icon="bell">
40
+ * <NotificationsTable />
41
+ * </TabPanel>
42
+ * </Tabs>
43
+ * ```
44
+ */
45
+ export interface TabPanelProps {
46
+ label: string
47
+ /** Optional icon HTML (rendered via `x-html`) for the tab button. */
48
+ icon?: string
49
+ className?: string
50
+ }
@@ -0,0 +1,65 @@
1
+ <script server>
2
+ export const to = $props.to || 'body'
3
+ export const disabled = $props.disabled || false
4
+ export const defer = $props.defer || false
5
+
6
+ export const teleportId = `teleport-${Math.random().toString(36).slice(2, 11)}`
7
+ </script>
8
+
9
+ <script client>
10
+ const to = {{ to }}
11
+ const isDisabled = {{ disabled }}
12
+ const shouldDefer = {{ defer }}
13
+ const teleportId = {{ teleportId }}
14
+
15
+ let teleportContainer = null
16
+
17
+ function teleport() {
18
+ if (isDisabled) return
19
+ const targetElement = typeof to === 'string' ? document.querySelector(to) : to
20
+ if (!targetElement) {
21
+ console.warn(`Teleport target "${to}" not found`)
22
+ return
23
+ }
24
+ const sourceElement = document.getElementById(teleportId)
25
+ if (!sourceElement) return
26
+ if (!teleportContainer) {
27
+ teleportContainer = document.createElement('div')
28
+ teleportContainer.setAttribute('data-teleport-id', teleportId)
29
+ }
30
+ while (sourceElement.firstChild) {
31
+ teleportContainer.appendChild(sourceElement.firstChild)
32
+ }
33
+ targetElement.appendChild(teleportContainer)
34
+ }
35
+
36
+ function returnContent() {
37
+ if (!teleportContainer || !teleportContainer.parentNode) return
38
+ const sourceElement = document.getElementById(teleportId)
39
+ if (!sourceElement) return
40
+ while (teleportContainer.firstChild) {
41
+ sourceElement.appendChild(teleportContainer.firstChild)
42
+ }
43
+ teleportContainer.parentNode.removeChild(teleportContainer)
44
+ teleportContainer = null
45
+ }
46
+
47
+ onMount(() => {
48
+ if (shouldDefer) setTimeout(teleport, 0)
49
+ else teleport()
50
+ window.addEventListener('beforeunload', returnContent)
51
+ })
52
+
53
+ onDestroy(() => {
54
+ returnContent()
55
+ window.removeEventListener('beforeunload', returnContent)
56
+ })
57
+ </script>
58
+
59
+ @if(disabled)
60
+ <slot />
61
+ @else
62
+ <div id="{{ teleportId }}" data-teleport-source="true">
63
+ <slot />
64
+ </div>
65
+ @endif
@@ -0,0 +1,7 @@
1
+ export { default as Teleport } from './Teleport.stx'
2
+
3
+ export interface TeleportProps {
4
+ to?: string | HTMLElement
5
+ disabled?: boolean
6
+ defer?: boolean
7
+ }