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