@stacksjs/components 0.2.111 → 0.2.113

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-gbd5ctkp.stx} +84 -3
  2. package/dist/{SidebarItem-gtrcw4ky.stx → SidebarItem-9rvbwhxr.stx} +11 -7
  3. package/dist/index.js +14 -11
  4. package/dist/ui/sidebar/themes.d.ts +25 -3
  5. package/package.json +5 -4
  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 +557 -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 +277 -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,557 @@
1
+ <script server>
2
+ import { resolveSidebarTheme } from './themes'
3
+
4
+ // Data: [{ id, label, collapsible?, collapsed?, items: [{ id, label, icon,
5
+ // iconColor, image, href, count, active?, disabled?, expanded?, children? }] }]
6
+ export const sections = $props.sections || []
7
+
8
+ // `theme` is the new name; `variant` is accepted for backwards compatibility.
9
+ export const theme = $props.theme || $props.variant || 'macos'
10
+ export const width = $props.width || 250
11
+ export const position = $props.position || 'left'
12
+ export const placement = $props.placement || 'fixed'
13
+ export const bordered = $props.bordered ?? false
14
+
15
+ // Collapsing. macOS sidebars hide entirely (toolbar button / drag); legacy
16
+ // themes may collapse to a compact rail instead.
17
+ export const collapsed = $props.collapsed ?? false
18
+ export const collapsible = $props.collapsible ?? true
19
+ export const collapseMode = $props.collapseMode || (theme === 'workspace' || theme === 'desktop' ? 'rail' : 'hidden')
20
+ export const minWidth = $props.minWidth || 64
21
+ export const persistKey = $props.persistKey || ''
22
+ export const shellSelector = $props.shellSelector || ''
23
+ export const widthVar = $props.widthVar || '--stx-sidebar-width'
24
+ export const collapsedClass = $props.collapsedClass || ''
25
+ export const className = $props.className || ''
26
+
27
+ // Unique id so the client controller can find this pane even with several
28
+ // sidebars on the page.
29
+ export const sidebarUid = $props.id || `stx-sidebar-${Math.random().toString(36).slice(2, 8)}`
30
+
31
+ const themeStyle = resolveSidebarTheme(theme)
32
+ export const selectionModel = themeStyle.selection
33
+ export const hasShimmerRim = themeStyle.layers.shimmerRim === true
34
+ export const tintClasses = themeStyle.layers.tint || ''
35
+ export const scrollAreaClasses = themeStyle.scrollArea
36
+
37
+ const borderSide = position === 'left' ? 'border-r' : 'border-l'
38
+ const borderClasses = bordered ? `${borderSide} border-black/5 dark:border-white/10` : ''
39
+ const placementClasses = {
40
+ fixed: `fixed inset-y-0 z-50 ${position === 'left' ? 'left-0' : 'right-0'}`,
41
+ sticky: 'sticky top-0 h-screen',
42
+ static: 'relative h-full',
43
+ }
44
+
45
+ export const paneClasses = [
46
+ placementClasses[placement] || placementClasses.fixed,
47
+ 'flex flex-col overflow-hidden',
48
+ 'transition-[width] duration-300 ease-out',
49
+ themeStyle.pane,
50
+ borderClasses,
51
+ className,
52
+ ].filter(Boolean).join(' ')
53
+
54
+ // Normalized ahead of the loop so template bindings stay simple accessors.
55
+ export const sectionList = sections.map(section => ({
56
+ id: section.id,
57
+ label: section.label || '',
58
+ items: section.items || [],
59
+ collapsible: section.collapsible !== false,
60
+ collapsed: section.collapsed === true,
61
+ }))
62
+
63
+ // State classes the client controller swaps on rows. Kept as data attributes
64
+ // so all behavior lives in one place (the controller below) and themes stay
65
+ // pure data.
66
+ export const activeClasses = themeStyle.item.active
67
+ </script>
68
+
69
+ <script client>
70
+ const emit = defineEmits()
71
+ const sidebarUidProp = '{{ sidebarUid }}'
72
+ const collapsedProp = {{ collapsed }}
73
+ const collapsibleProp = {{ collapsible }}
74
+ const collapseModeProp = '{{ collapseMode }}'
75
+ const widthProp = {{ width }}
76
+ const minWidthProp = {{ minWidth }}
77
+ const persistKeyProp = '{{ persistKey }}'
78
+ const shellSelectorProp = '{{ shellSelector }}'
79
+ const widthVarProp = '{{ widthVar }}'
80
+ const collapsedClassProp = '{{ collapsedClass }}'
81
+
82
+ const collapsed = state(collapsedProp)
83
+ const sidebarWidth = derived(() => {
84
+ if (collapsed()) return collapseModeProp === 'hidden' ? '0px' : `${minWidthProp}px`
85
+ return `${widthProp}px`
86
+ })
87
+
88
+ let pane = null
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Selection — one row carries data-active="true"; the highlight classes come
92
+ // from the theme (stored on the pane as data-active-class).
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function activeClassList() {
96
+ return (pane?.dataset.activeClass || '').split(' ').filter(Boolean)
97
+ }
98
+
99
+ function setActiveRow(button) {
100
+ const classes = activeClassList()
101
+ pane.querySelectorAll('[data-sidebar-item][data-active="true"]').forEach((el) => {
102
+ el.removeAttribute('data-active')
103
+ el.classList.remove(...classes)
104
+ })
105
+ button.setAttribute('data-active', 'true')
106
+ button.classList.add(...classes)
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Disclosure — rows know their ancestors (data-parents="a/b"). Collapsing a
111
+ // row (or section) marks every descendant with data-collapsed, which the
112
+ // scoped CSS animates to zero height, exactly like AppKit's outline collapse.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function syncRowVisibility() {
116
+ const collapsedIds = new Set()
117
+ pane.querySelectorAll('[data-sidebar-row][data-expanded="false"]').forEach((row) => {
118
+ collapsedIds.add(row.dataset.itemId)
119
+ })
120
+ pane.querySelectorAll('[data-sidebar-section][data-expanded="false"]').forEach((section) => {
121
+ section.querySelectorAll('[data-sidebar-row]').forEach((row) => {
122
+ row.setAttribute('data-collapsed', '')
123
+ })
124
+ })
125
+ pane.querySelectorAll('[data-sidebar-section][data-expanded="true"] [data-sidebar-row]').forEach((row) => {
126
+ const parents = (row.dataset.parents || '').split('/').filter(Boolean)
127
+ const hidden = parents.some(id => collapsedIds.has(id))
128
+ if (hidden) row.setAttribute('data-collapsed', '')
129
+ else row.removeAttribute('data-collapsed')
130
+ })
131
+ }
132
+
133
+ function toggleRowDisclosure(row) {
134
+ const expanded = row.getAttribute('data-expanded') === 'true'
135
+ row.setAttribute('data-expanded', String(!expanded))
136
+ syncRowVisibility()
137
+ emit('itemToggle', { id: row.dataset.itemId, expanded: !expanded })
138
+ }
139
+
140
+ function toggleSection(section) {
141
+ const expanded = section.getAttribute('data-expanded') !== 'false'
142
+ section.setAttribute('data-expanded', String(!expanded))
143
+ syncRowVisibility()
144
+ emit('sectionToggle', section.dataset.sectionId)
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Pointer input (event delegation — items and sections stay presentational)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function onClick(event) {
152
+ const chevron = event.target.closest('[data-sidebar-disclosure]')
153
+ if (chevron) {
154
+ event.preventDefault()
155
+ toggleRowDisclosure(chevron.closest('[data-sidebar-row]'))
156
+ return
157
+ }
158
+
159
+ const sectionHeader = event.target.closest('[data-sidebar-section-header]')
160
+ if (sectionHeader) {
161
+ toggleSection(sectionHeader.closest('[data-sidebar-section]'))
162
+ return
163
+ }
164
+
165
+ const item = event.target.closest('[data-sidebar-item]')
166
+ if (item && !item.hasAttribute('data-disabled')) {
167
+ setActiveRow(item)
168
+ const row = item.closest('[data-sidebar-row]')
169
+ emit('itemClick', {
170
+ item: { id: row.dataset.itemId, label: item.dataset.label || '', href: item.getAttribute('href') || '' },
171
+ event,
172
+ })
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Keyboard — matches macOS source lists: ↑/↓ move the selection over visible
178
+ // rows, ←/→ collapse/expand, Return opens.
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function visibleItems() {
182
+ return [...pane.querySelectorAll('[data-sidebar-row]:not([data-collapsed]) [data-sidebar-item]:not([data-disabled])')]
183
+ }
184
+
185
+ function onKeyDown(event) {
186
+ const items = visibleItems()
187
+ if (items.length === 0) return
188
+ const current = pane.querySelector('[data-sidebar-item][data-active="true"]')
189
+ const index = items.indexOf(current)
190
+
191
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
192
+ event.preventDefault()
193
+ const next = event.key === 'ArrowDown'
194
+ ? items[Math.min(index + 1, items.length - 1)]
195
+ : items[Math.max(index - 1, 0)]
196
+ if (next && next !== current) {
197
+ setActiveRow(next)
198
+ next.scrollIntoView({ block: 'nearest' })
199
+ emit('itemClick', { item: { id: next.closest('[data-sidebar-row]').dataset.itemId, label: next.dataset.label || '', href: next.getAttribute('href') || '' }, event })
200
+ }
201
+ }
202
+ else if ((event.key === 'ArrowRight' || event.key === 'ArrowLeft') && current) {
203
+ const row = current.closest('[data-sidebar-row]')
204
+ if (row.querySelector('[data-sidebar-disclosure]')) {
205
+ const expanded = row.getAttribute('data-expanded') === 'true'
206
+ if (event.key === 'ArrowRight' ? !expanded : expanded) {
207
+ event.preventDefault()
208
+ toggleRowDisclosure(row)
209
+ }
210
+ }
211
+ }
212
+ else if (event.key === 'Enter' && current) {
213
+ current.click()
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Overlay scrollbar — a floating thumb that appears while scrolling and fades
219
+ // away, like macOS overlay scrollbars. The native scrollbar is hidden so rows
220
+ // keep their exact insets.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function attachOverlayScrollbar(scrollArea, thumb) {
224
+ let fadeTimer
225
+
226
+ function update() {
227
+ const { scrollTop, scrollHeight, clientHeight } = scrollArea
228
+ if (scrollHeight <= clientHeight) {
229
+ thumb.style.opacity = '0'
230
+ return
231
+ }
232
+ const trackHeight = clientHeight - 8
233
+ const height = Math.max(24, (clientHeight / scrollHeight) * trackHeight)
234
+ const top = 4 + (scrollTop / (scrollHeight - clientHeight)) * (trackHeight - height)
235
+ thumb.style.height = `${height}px`
236
+ thumb.style.transform = `translateY(${top}px)`
237
+ thumb.style.opacity = '1'
238
+ clearTimeout(fadeTimer)
239
+ fadeTimer = setTimeout(() => { thumb.style.opacity = '0' }, 900)
240
+ }
241
+
242
+ scrollArea.addEventListener('scroll', update, { passive: true })
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Collapse (sidebar-wide) — persisted, synced to the app shell and to Craft's
247
+ // native web-sidebar material when present.
248
+ // ---------------------------------------------------------------------------
249
+
250
+ function applyCollapseEffects(nextCollapsed) {
251
+ if (persistKeyProp) {
252
+ try { window.localStorage?.setItem(persistKeyProp, String(nextCollapsed)) } catch {}
253
+ }
254
+ if (shellSelectorProp) {
255
+ document.querySelectorAll(shellSelectorProp).forEach((shell) => {
256
+ shell.style?.setProperty?.(widthVarProp, nextCollapsed && collapseModeProp === 'hidden' ? '0px' : `${widthProp}px`)
257
+ if (shell.dataset) shell.dataset.sidebarCollapsed = String(nextCollapsed)
258
+ })
259
+ }
260
+ document.documentElement.dataset.stxSidebarCollapsed = String(nextCollapsed)
261
+ if (collapsedClassProp) document.documentElement.classList.toggle(collapsedClassProp, nextCollapsed)
262
+ window.craft?.window?.setWebSidebarCollapsed?.(nextCollapsed).catch?.(() => {})
263
+ }
264
+
265
+ function setCollapsed(nextCollapsed) {
266
+ collapsed.set(nextCollapsed)
267
+ applyCollapseEffects(nextCollapsed)
268
+ emit('collapse', nextCollapsed)
269
+ }
270
+
271
+ function toggleCollapse() {
272
+ if (collapsibleProp) setCollapsed(!collapsed())
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Appearance — native sidebars follow the OS light/dark appearance. Crosswind
277
+ // dark: utilities are class-based, so mirror prefers-color-scheme onto the
278
+ // root element unless the app manages its own theme (data-theme attribute).
279
+ // ---------------------------------------------------------------------------
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Key-window tracking — AppKit dims a source-list selection from the accent
283
+ // to a neutral fill the moment the window resigns key. `data-window-key` on
284
+ // the pane drives the two selection rule sets in the scoped CSS above.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function trackKeyWindow() {
288
+ // Driven by real focus/blur events only — never by an initial
289
+ // `document.hasFocus()` reading. A freshly loaded page, a background tab
290
+ // that has not been clicked yet, and an embedded webview that does not
291
+ // report focus at all all answer `false`, which would open the sidebar in
292
+ // the dimmed non-key state and leave it there. Starting from the key state
293
+ // and reacting to an actual blur keeps the default correct everywhere and
294
+ // still dims exactly when AppKit would.
295
+ window.addEventListener('focus', () => pane.setAttribute('data-window-key', 'true'))
296
+ window.addEventListener('blur', () => pane.setAttribute('data-window-key', 'false'))
297
+ }
298
+
299
+ function followSystemAppearance() {
300
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
301
+ function sync() {
302
+ if (document.documentElement.dataset.theme) return
303
+ document.documentElement.classList.toggle('dark', media.matches)
304
+ }
305
+ sync()
306
+ media.addEventListener('change', sync)
307
+ }
308
+
309
+ onMount(() => {
310
+ pane = document.getElementById(sidebarUidProp)
311
+ if (!pane) return
312
+
313
+ followSystemAppearance()
314
+ trackKeyWindow()
315
+
316
+ if (persistKeyProp) {
317
+ try {
318
+ const saved = window.localStorage?.getItem(persistKeyProp)
319
+ if (saved === 'true' || saved === 'false') collapsed.set(saved === 'true')
320
+ } catch {}
321
+ }
322
+ applyCollapseEffects(collapsed())
323
+
324
+ pane.addEventListener('click', onClick)
325
+ pane.addEventListener('keydown', onKeyDown)
326
+
327
+ const scrollArea = pane.querySelector('[data-sidebar-scroll]')
328
+ const thumb = pane.querySelector('[data-sidebar-scroll-thumb]')
329
+ if (scrollArea && thumb) attachOverlayScrollbar(scrollArea, thumb)
330
+
331
+ // Ensure the initial active row carries the theme's highlight classes.
332
+ const active = pane.querySelector('[data-sidebar-item][data-active="true"]')
333
+ if (active) active.classList.add(...activeClassList())
334
+ syncRowVisibility()
335
+ })
336
+
337
+ defineExpose({ collapsed, toggle: toggleCollapse })
338
+ </script>
339
+
340
+ <aside
341
+ id="{{ sidebarUid }}"
342
+ data-stx-sidebar
343
+ data-sidebar-theme="{{ theme }}"
344
+ data-sidebar-selection="{{ selectionModel }}"
345
+ data-window-key="true"
346
+ data-active-class="{{ activeClasses }}"
347
+ class="{{ paneClasses }}"
348
+ :style="`width: ${sidebarWidth()}`"
349
+ :aria-hidden="String(collapsed() && collapseModeProp === 'hidden')"
350
+ role="navigation"
351
+ aria-label="Sidebar"
352
+ tabindex="0"
353
+ >
354
+ @if(tintClasses)
355
+ <div class="absolute inset-0 pointer-events-none {{ tintClasses }}"></div>
356
+ @endif
357
+ @if(hasShimmerRim)
358
+ <div class="stx-sidebar-rim" aria-hidden="true"></div>
359
+ @endif
360
+
361
+ @if($slots.header)
362
+ <div class="relative z-10 flex-shrink-0">
363
+ <slot name="header" />
364
+ </div>
365
+ @endif
366
+
367
+ <div class="relative z-10 min-h-0 flex-1">
368
+ <div data-sidebar-scroll class="{{ scrollAreaClasses }} h-full stx-sidebar-scroll">
369
+ @if(sectionList.length > 0)
370
+ <nav class="flex flex-col">
371
+ @foreach(sectionList as section)
372
+ <SidebarSection
373
+ :id="section.id"
374
+ :label="section.label"
375
+ :items="section.items"
376
+ :collapsible="section.collapsible"
377
+ :collapsed="section.collapsed"
378
+ :theme="theme"
379
+ />
380
+ @endforeach
381
+ </nav>
382
+ @else
383
+ <slot />
384
+ @endif
385
+ </div>
386
+ <div data-sidebar-scroll-thumb class="stx-sidebar-scroll-thumb" aria-hidden="true"></div>
387
+ </div>
388
+
389
+ @if($slots.footer)
390
+ <div class="relative z-10 flex-shrink-0">
391
+ <slot name="footer" />
392
+ </div>
393
+ @endif
394
+ </aside>
395
+
396
+ <style scoped>
397
+ [data-stx-sidebar] {
398
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
399
+ -webkit-font-smoothing: antialiased;
400
+ outline: none;
401
+ }
402
+
403
+ /* Liquid Glass edge highlight: a hairline rim that catches light along the
404
+ pane's edges, with a slow sheen drifting across it. */
405
+ .stx-sidebar-rim {
406
+ position: absolute;
407
+ inset: 0;
408
+ border-radius: inherit;
409
+ pointer-events: none;
410
+ z-index: 5;
411
+ box-shadow:
412
+ inset 0 1px 0 rgba(255, 255, 255, 0.50),
413
+ inset 1px 0 0 rgba(255, 255, 255, 0.26),
414
+ inset -1px 0 0 rgba(255, 255, 255, 0.10),
415
+ inset 0 -1px 0 rgba(255, 255, 255, 0.14);
416
+ }
417
+ .stx-sidebar-rim::after {
418
+ content: '';
419
+ position: absolute;
420
+ inset: 0;
421
+ border-radius: inherit;
422
+ padding: 1.5px;
423
+ background: linear-gradient(
424
+ 115deg,
425
+ rgba(255, 255, 255, 0.85) 0%,
426
+ rgba(255, 255, 255, 0.00) 28%,
427
+ rgba(255, 255, 255, 0.18) 52%,
428
+ rgba(255, 255, 255, 0.00) 74%,
429
+ rgba(255, 255, 255, 0.65) 100%
430
+ );
431
+ background-size: 250% 250%;
432
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
433
+ -webkit-mask-composite: xor;
434
+ mask-composite: exclude;
435
+ animation: stx-sidebar-shimmer 10s ease-in-out infinite alternate;
436
+ }
437
+ @keyframes stx-sidebar-shimmer {
438
+ from { background-position: 0% 0%; }
439
+ to { background-position: 100% 100%; }
440
+ }
441
+ @media (prefers-color-scheme: dark) {
442
+ .stx-sidebar-rim {
443
+ box-shadow:
444
+ inset 0 1px 0 rgba(255, 255, 255, 0.18),
445
+ inset 1px 0 0 rgba(255, 255, 255, 0.10),
446
+ inset -1px 0 0 rgba(255, 255, 255, 0.05),
447
+ inset 0 -1px 0 rgba(255, 255, 255, 0.06);
448
+ }
449
+ .stx-sidebar-rim::after { opacity: 0.35; }
450
+ }
451
+ @media (prefers-reduced-motion: reduce) {
452
+ .stx-sidebar-rim::after { animation: none; }
453
+ }
454
+
455
+ /* ---------------------------------------------------------------------------
456
+ * Selection, `accent` model (macOS and its derivatives).
457
+ *
458
+ * AppKit paints a source-list selection in two states. In a key window the
459
+ * row takes the system accent and its label, icon, count and disclosure
460
+ * chevron all turn white; the label also goes semibold. When the window
461
+ * resigns key the fill drops to a neutral gray and the label stays dark.
462
+ * Measured from Mail.app: key fill is the accent, non-key fill sits about
463
+ * 5.5% black over the pane, and the icons desaturate to secondary gray.
464
+ *
465
+ * These are attribute rules rather than utility classes because the row's
466
+ * children have to recolor together, and because the controller toggles
467
+ * `data-active` on the client — CSS keyed on the attribute keeps the
468
+ * previously selected row's own icon tint intact when selection moves.
469
+ * ------------------------------------------------------------------------- */
470
+ [data-sidebar-selection="accent"] {
471
+ --stx-sidebar-accent: #007aff;
472
+ --stx-sidebar-selection-idle: rgba(0, 0, 0, 0.055);
473
+ --stx-sidebar-on-selection: #fff;
474
+ --stx-sidebar-icon-idle: #8e8e93;
475
+ }
476
+ @media (prefers-color-scheme: dark) {
477
+ [data-sidebar-selection="accent"] {
478
+ --stx-sidebar-accent: #0a84ff;
479
+ --stx-sidebar-selection-idle: rgba(255, 255, 255, 0.11);
480
+ --stx-sidebar-icon-idle: #98989d;
481
+ }
482
+ }
483
+
484
+ /* Per-item tint, published by SidebarItem as a custom property. */
485
+ [data-sidebar-selection="accent"] [data-sidebar-icon] {
486
+ color: var(--stx-sidebar-icon, currentColor);
487
+ }
488
+
489
+ [data-sidebar-selection="accent"][data-window-key="true"] [data-sidebar-item][data-active="true"] {
490
+ background: var(--stx-sidebar-accent);
491
+ color: var(--stx-sidebar-on-selection);
492
+ font-weight: 590;
493
+ }
494
+ [data-sidebar-selection="accent"][data-window-key="true"] [data-sidebar-item][data-active="true"] [data-sidebar-icon],
495
+ [data-sidebar-selection="accent"][data-window-key="true"] [data-sidebar-item][data-active="true"] [data-sidebar-count],
496
+ [data-sidebar-selection="accent"][data-window-key="true"] [data-sidebar-item][data-active="true"] [data-sidebar-chevron] {
497
+ color: var(--stx-sidebar-on-selection);
498
+ }
499
+ /* Hover must not lighten the selected row out from under the accent. */
500
+ [data-sidebar-selection="accent"][data-window-key="true"] [data-sidebar-item][data-active="true"]:hover {
501
+ background: var(--stx-sidebar-accent);
502
+ }
503
+
504
+ /* Window resigned key: neutral fill, label stays dark, icons desaturate. */
505
+ [data-sidebar-selection="accent"][data-window-key="false"] [data-sidebar-item][data-active="true"] {
506
+ background: var(--stx-sidebar-selection-idle);
507
+ font-weight: 590;
508
+ }
509
+ [data-sidebar-selection="accent"][data-window-key="false"] [data-sidebar-icon] {
510
+ color: var(--stx-sidebar-icon-idle);
511
+ }
512
+
513
+ /* Rows collapse like AppKit outlines: fixed row height animates to zero.
514
+ The 32px pitch is the row itself — AppKit leaves no gap, so a run of
515
+ selected or hovered rows reads as one continuous block. */
516
+ [data-sidebar-row] {
517
+ height: 32px;
518
+ transition: height 0.22s ease, opacity 0.16s ease;
519
+ }
520
+ [data-sidebar-row][data-collapsed] {
521
+ height: 0;
522
+ opacity: 0;
523
+ overflow: hidden;
524
+ pointer-events: none;
525
+ }
526
+
527
+ /* Disclosure chevron rotates from ▸ to ▾ */
528
+ [data-sidebar-row][data-expanded="true"] [data-sidebar-disclosure] > * {
529
+ transform: rotate(90deg);
530
+ }
531
+ [data-sidebar-section][data-expanded="false"] [data-sidebar-section-chevron] {
532
+ transform: rotate(-90deg);
533
+ }
534
+
535
+ /* Native scrollbar hidden; the floating thumb below takes its place. */
536
+ .stx-sidebar-scroll {
537
+ scrollbar-width: none;
538
+ }
539
+ .stx-sidebar-scroll::-webkit-scrollbar {
540
+ display: none;
541
+ }
542
+ .stx-sidebar-scroll-thumb {
543
+ position: absolute;
544
+ top: 0;
545
+ right: 3px;
546
+ width: 7px;
547
+ border-radius: 4px;
548
+ background: rgba(0, 0, 0, 0.35);
549
+ opacity: 0;
550
+ transition: opacity 0.3s ease;
551
+ pointer-events: none;
552
+ z-index: 20;
553
+ }
554
+ @media (prefers-color-scheme: dark) {
555
+ .stx-sidebar-scroll-thumb { background: rgba(255, 255, 255, 0.35); }
556
+ }
557
+ </style>
@@ -0,0 +1,71 @@
1
+ <script server>
2
+ // The bottom strip of a macOS sidebar — e.g. the account row in Music
3
+ // ("Chris Breuer" with an avatar) or custom actions.
4
+ export const theme = $props.theme || $props.variant || 'macos'
5
+ export const avatar = $props.avatar || ''
6
+ export const name = $props.name || ''
7
+ export const detail = $props.detail || ''
8
+ export const actions = $props.actions || [] // [{ id, icon, label }]
9
+
10
+ export const isMacos = theme === 'macos' || theme === 'tahoe' || theme === 'macos-tahoe' || theme === 'macos-latest'
11
+
12
+ export const profileClasses = [
13
+ 'flex w-full items-center gap-[8px]',
14
+ 'h-[44px] rounded-[10px] px-[6px]',
15
+ 'text-[13px] font-medium text-black dark:text-white',
16
+ 'transition-colors duration-150',
17
+ 'hover:bg-black/4 active:bg-black/8',
18
+ 'dark:hover:bg-white/6 dark:active:bg-white/12',
19
+ ].join(' ')
20
+
21
+ export const actionClasses = [
22
+ 'grid h-[28px] w-[28px] place-items-center rounded-[7px]',
23
+ 'text-[#3c3c43]/70 dark:text-[#ebebf5]/70',
24
+ 'transition-colors duration-150',
25
+ 'hover:bg-black/5 dark:hover:bg-white/10',
26
+ ].join(' ')
27
+ </script>
28
+
29
+ <script client>
30
+ const emit = defineEmits()
31
+
32
+ function onProfileClick() {
33
+ emit('profileClick')
34
+ }
35
+
36
+ function onAction(event) {
37
+ emit('action', event.target.closest('[data-sidebar-action]')?.dataset.sidebarAction)
38
+ }
39
+ </script>
40
+
41
+ @if(isMacos)
42
+ <div class="shrink-0 px-[10px] pb-[8px] pt-[4px]">
43
+ @if(name)
44
+ <button type="button" class="{{ profileClasses }}" @click="onProfileClick()">
45
+ @if(avatar)
46
+ <img src="{{ avatar }}" alt="" class="h-[30px] w-[30px] rounded-full object-cover shadow-sm" />
47
+ @else
48
+ <span class="grid h-[30px] w-[30px] place-items-center rounded-full bg-black/10 text-[13px] font-semibold dark:bg-white/15">{{ name.charAt(0) }}</span>
49
+ @endif
50
+ <span class="min-w-0 flex-1 truncate text-left">{{ name }}</span>
51
+ @if(detail)
52
+ <span class="shrink-0 text-[12px] text-[#3c3c43]/60 dark:text-[#ebebf5]/60">{{ detail }}</span>
53
+ @endif
54
+ </button>
55
+ @endif
56
+ @if(actions.length > 0)
57
+ <div class="flex items-center gap-[4px] pt-[4px]">
58
+ @foreach(actions as action)
59
+ <button type="button" class="{{ actionClasses }}" aria-label="{{ action.label }}" data-sidebar-action="{{ action.id }}" @click="onAction($event)">
60
+ <span class="{{ action.icon }} h-[16px] w-[16px]" aria-hidden="true"></span>
61
+ </button>
62
+ @endforeach
63
+ </div>
64
+ @endif
65
+ <slot />
66
+ </div>
67
+ @else
68
+ <div class="shrink-0 border-t border-black/5 p-3 dark:border-white/10">
69
+ <slot />
70
+ </div>
71
+ @endif