@stacksjs/components 0.2.90 → 0.2.92

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.
@@ -1,330 +1,458 @@
1
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? }] }]
2
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.
3
17
  export const collapsed = $props.collapsed ?? false
4
18
  export const collapsible = $props.collapsible ?? true
5
- export const width = $props.width || 240
19
+ export const collapseMode = $props.collapseMode || (theme === 'workspace' || theme === 'desktop' ? 'rail' : 'hidden')
6
20
  export const minWidth = $props.minWidth || 64
7
- export const collapseMode = $props.collapseMode || 'rail'
8
- export const variant = $props.variant || 'tahoe'
9
- export const position = $props.position || 'left'
10
- export const placement = $props.placement || 'fixed'
11
- export const bordered = $props.bordered ?? true
12
- export const materialOpacity = $props.materialOpacity ?? (variant === 'desktop' ? 0.78 : 0.7)
13
- export const materialDarkOpacity = $props.materialDarkOpacity ?? (variant === 'desktop' ? 0.8 : materialOpacity)
14
- export const materialScheme = $props.materialScheme || 'system'
15
21
  export const persistKey = $props.persistKey || ''
16
22
  export const shellSelector = $props.shellSelector || ''
17
23
  export const widthVar = $props.widthVar || '--stx-sidebar-width'
18
24
  export const collapsedClass = $props.collapsedClass || ''
19
- export const nativeMaterialCollapse = $props.nativeMaterialCollapse ?? (variant === 'desktop' && collapseMode === 'hidden')
20
25
  export const className = $props.className || ''
21
26
 
22
- const variantStyles = {
23
- tahoe: {
24
- bg: 'bg-white/70 dark:bg-black/50',
25
- border: 'border-black/5 dark:border-white/10',
26
- backdrop: 'backdrop-blur-2xl backdrop-saturate-180',
27
- tint: true,
28
- },
29
- vibrancy: {
30
- bg: 'bg-white/50 dark:bg-black/40',
31
- border: 'border-white/20 dark:border-white/10',
32
- backdrop: 'backdrop-blur-3xl backdrop-saturate-200',
33
- tint: false,
34
- },
35
- solid: {
36
- bg: 'bg-stone-100 dark:bg-neutral-900',
37
- border: 'border-stone-200 dark:border-neutral-800',
38
- backdrop: '',
39
- tint: false,
40
- },
41
- transparent: {
42
- bg: 'bg-transparent',
43
- border: 'border-transparent',
44
- backdrop: '',
45
- tint: false,
46
- },
47
- workspace: {
48
- bg: 'bg-[#f4f4f3] dark:bg-neutral-950',
49
- border: 'border-transparent',
50
- backdrop: '',
51
- tint: false,
52
- nav: 'px-5 py-5 space-y-1',
53
- slot: 'px-5 py-5',
54
- },
55
- desktop: {
56
- bg: 'bg-[#f3f3f1]/48 dark:bg-neutral-950/32',
57
- border: 'border-white/15 dark:border-white/10',
58
- backdrop: 'backdrop-blur-3xl backdrop-saturate-150',
59
- tint: false,
60
- shimmer: true,
61
- nav: 'px-5 py-2 space-y-1',
62
- slot: 'px-5 py-2',
63
- },
64
- }
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 hasShimmerRim = themeStyle.layers.shimmerRim === true
33
+ export const tintClasses = themeStyle.layers.tint || ''
34
+ export const scrollAreaClasses = themeStyle.scrollArea
65
35
 
66
- const style = variantStyles[variant] || variantStyles.tahoe
67
- export const variantTint = style.tint
68
- export const variantShimmer = style.shimmer
69
36
  const borderSide = position === 'left' ? 'border-r' : 'border-l'
70
- const borderClasses = bordered ? `${borderSide} ${style.border}` : ''
71
- const positionClass = position === 'left' ? 'left-0' : 'right-0'
37
+ const borderClasses = bordered ? `${borderSide} border-black/5 dark:border-white/10` : ''
72
38
  const placementClasses = {
73
- fixed: `fixed inset-y-0 z-50 ${positionClass}`,
39
+ fixed: `fixed inset-y-0 z-50 ${position === 'left' ? 'left-0' : 'right-0'}`,
74
40
  sticky: 'sticky top-0 h-screen',
75
41
  static: 'relative h-full',
76
42
  }
77
43
 
78
- export const containerClasses = [
44
+ export const paneClasses = [
79
45
  placementClasses[placement] || placementClasses.fixed,
80
- 'flex flex-col',
81
- 'transition-all duration-300 ease-out',
82
- 'overflow-hidden',
83
- style.bg,
84
- style.backdrop,
46
+ 'flex flex-col overflow-hidden',
47
+ 'transition-[width] duration-300 ease-out',
48
+ themeStyle.pane,
85
49
  borderClasses,
86
50
  className,
87
51
  ].filter(Boolean).join(' ')
88
52
 
89
- export const collapseBtnPosition = variant === 'desktop'
90
- ? (position === 'left' ? 'left-[166px]' : 'right-[166px]')
91
- : (position === 'left' ? 'right-2' : 'left-2')
92
- export const navClasses = style.nav || 'px-3 py-2 space-y-1'
93
- export const slotClasses = style.slot || 'px-3 py-2'
53
+ // Normalized ahead of the loop so template bindings stay simple accessors.
54
+ export const sectionList = sections.map(section => ({
55
+ id: section.id,
56
+ label: section.label || '',
57
+ items: section.items || [],
58
+ collapsible: section.collapsible !== false,
59
+ collapsed: section.collapsed === true,
60
+ }))
61
+
62
+ // State classes the client controller swaps on rows. Kept as data attributes
63
+ // so all behavior lives in one place (the controller below) and themes stay
64
+ // pure data.
65
+ export const activeClasses = themeStyle.item.active
94
66
  </script>
95
67
 
96
68
  <script client>
97
69
  const emit = defineEmits()
70
+ const sidebarUidProp = '{{ sidebarUid }}'
98
71
  const collapsedProp = {{ collapsed }}
99
72
  const collapsibleProp = {{ collapsible }}
73
+ const collapseModeProp = '{{ collapseMode }}'
100
74
  const widthProp = {{ width }}
101
75
  const minWidthProp = {{ minWidth }}
102
- const collapseModeProp = {{ collapseMode }}
103
- const sectionsProp = {{ sections }}
104
- const positionProp = {{ position }}
105
- const variantProp = {{ variant }}
106
- const materialOpacityProp = {{ materialOpacity }}
107
- const materialDarkOpacityProp = {{ materialDarkOpacity }}
108
- const materialSchemeProp = {{ materialScheme }}
109
- const persistKeyProp = {{ persistKey }}
110
- const shellSelectorProp = {{ shellSelector }}
111
- const widthVarProp = {{ widthVar }}
112
- const collapsedClassProp = {{ collapsedClass }}
113
- const nativeMaterialCollapseProp = {{ nativeMaterialCollapse }}
76
+ const persistKeyProp = '{{ persistKey }}'
77
+ const shellSelectorProp = '{{ shellSelector }}'
78
+ const widthVarProp = '{{ widthVar }}'
79
+ const collapsedClassProp = '{{ collapsedClass }}'
114
80
 
115
81
  const collapsed = state(collapsedProp)
116
- const isHovered = state(false)
117
- const hasNativeSidebar = state(false)
118
-
119
- const showExpanded = derived(() => !collapsed() || isHovered())
120
82
  const sidebarWidth = derived(() => {
121
- if (collapsed() && collapseModeProp === 'hidden') return '0px'
122
- if (collapsed() && !isHovered()) return `${minWidthProp}px`
83
+ if (collapsed()) return collapseModeProp === 'hidden' ? '0px' : `${minWidthProp}px`
123
84
  return `${widthProp}px`
124
85
  })
125
- const collapseAriaLabel = derived(() => collapsed() ? 'Expand sidebar' : 'Collapse sidebar')
126
86
 
127
- function applyCollapseEffects(nextCollapsed) {
128
- if (persistKeyProp) {
129
- try {
130
- window.localStorage?.setItem(persistKeyProp, nextCollapsed ? 'true' : 'false')
131
- }
132
- catch {}
133
- }
87
+ let pane = null
134
88
 
135
- if (shellSelectorProp) {
136
- document.querySelectorAll(shellSelectorProp).forEach((shell) => {
137
- shell.style?.setProperty?.(widthVarProp, nextCollapsed && collapseModeProp === 'hidden' ? '0px' : `${widthProp}px`)
138
- if (shell.dataset) shell.dataset.sidebarCollapsed = nextCollapsed ? 'true' : 'false'
89
+ // ---------------------------------------------------------------------------
90
+ // Selection — one row carries data-active="true"; the highlight classes come
91
+ // from the theme (stored on the pane as data-active-class).
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function activeClassList() {
95
+ return (pane?.dataset.activeClass || '').split(' ').filter(Boolean)
96
+ }
97
+
98
+ function setActiveRow(button) {
99
+ const classes = activeClassList()
100
+ pane.querySelectorAll('[data-sidebar-item][data-active="true"]').forEach((el) => {
101
+ el.removeAttribute('data-active')
102
+ el.classList.remove(...classes)
103
+ })
104
+ button.setAttribute('data-active', 'true')
105
+ button.classList.add(...classes)
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Disclosure — rows know their ancestors (data-parents="a/b"). Collapsing a
110
+ // row (or section) marks every descendant with data-collapsed, which the
111
+ // scoped CSS animates to zero height, exactly like AppKit's outline collapse.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function syncRowVisibility() {
115
+ const collapsedIds = new Set()
116
+ pane.querySelectorAll('[data-sidebar-row][data-expanded="false"]').forEach((row) => {
117
+ collapsedIds.add(row.dataset.itemId)
118
+ })
119
+ pane.querySelectorAll('[data-sidebar-section][data-expanded="false"]').forEach((section) => {
120
+ section.querySelectorAll('[data-sidebar-row]').forEach((row) => {
121
+ row.setAttribute('data-collapsed', '')
139
122
  })
123
+ })
124
+ pane.querySelectorAll('[data-sidebar-section][data-expanded="true"] [data-sidebar-row]').forEach((row) => {
125
+ const parents = (row.dataset.parents || '').split('/').filter(Boolean)
126
+ const hidden = parents.some(id => collapsedIds.has(id))
127
+ if (hidden) row.setAttribute('data-collapsed', '')
128
+ else row.removeAttribute('data-collapsed')
129
+ })
130
+ }
131
+
132
+ function toggleRowDisclosure(row) {
133
+ const expanded = row.getAttribute('data-expanded') === 'true'
134
+ row.setAttribute('data-expanded', String(!expanded))
135
+ syncRowVisibility()
136
+ emit('itemToggle', { id: row.dataset.itemId, expanded: !expanded })
137
+ }
138
+
139
+ function toggleSection(section) {
140
+ const expanded = section.getAttribute('data-expanded') !== 'false'
141
+ section.setAttribute('data-expanded', String(!expanded))
142
+ syncRowVisibility()
143
+ emit('sectionToggle', section.dataset.sectionId)
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Pointer input (event delegation — items and sections stay presentational)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ function onClick(event) {
151
+ const chevron = event.target.closest('[data-sidebar-disclosure]')
152
+ if (chevron) {
153
+ event.preventDefault()
154
+ toggleRowDisclosure(chevron.closest('[data-sidebar-row]'))
155
+ return
140
156
  }
141
157
 
142
- document.documentElement.dataset.stxSidebarCollapsed = nextCollapsed ? 'true' : 'false'
143
- if (collapsedClassProp) {
144
- document.documentElement.classList.toggle(collapsedClassProp, nextCollapsed)
158
+ const sectionHeader = event.target.closest('[data-sidebar-section-header]')
159
+ if (sectionHeader) {
160
+ toggleSection(sectionHeader.closest('[data-sidebar-section]'))
161
+ return
145
162
  }
146
163
 
147
- if (nativeMaterialCollapseProp) {
148
- window.craft?.window?.setWebSidebarCollapsed?.(nextCollapsed).catch?.(() => {})
164
+ const item = event.target.closest('[data-sidebar-item]')
165
+ if (item && !item.hasAttribute('data-disabled')) {
166
+ setActiveRow(item)
167
+ const row = item.closest('[data-sidebar-row]')
168
+ emit('itemClick', {
169
+ item: { id: row.dataset.itemId, label: item.dataset.label || '', href: item.getAttribute('href') || '' },
170
+ event,
171
+ })
149
172
  }
150
173
  }
151
174
 
152
- function setCollapsed(nextCollapsed, shouldEmit = true) {
153
- collapsed.set(nextCollapsed)
154
- applyCollapseEffects(nextCollapsed)
155
- if (shouldEmit) emit('collapse', nextCollapsed)
156
- }
175
+ // ---------------------------------------------------------------------------
176
+ // Keyboard — matches macOS source lists: ↑/↓ move the selection over visible
177
+ // rows, ←/→ collapse/expand, Return opens.
178
+ // ---------------------------------------------------------------------------
157
179
 
158
- function onMouseEnter() {
159
- if (collapsed() && collapsibleProp && collapseModeProp !== 'hidden') isHovered.set(true)
180
+ function visibleItems() {
181
+ return [...pane.querySelectorAll('[data-sidebar-row]:not([data-collapsed]) [data-sidebar-item]:not([data-disabled])')]
160
182
  }
161
183
 
162
- function onMouseLeave() {
163
- isHovered.set(false)
184
+ function onKeyDown(event) {
185
+ const items = visibleItems()
186
+ if (items.length === 0) return
187
+ const current = pane.querySelector('[data-sidebar-item][data-active="true"]')
188
+ const index = items.indexOf(current)
189
+
190
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
191
+ event.preventDefault()
192
+ const next = event.key === 'ArrowDown'
193
+ ? items[Math.min(index + 1, items.length - 1)]
194
+ : items[Math.max(index - 1, 0)]
195
+ if (next && next !== current) {
196
+ setActiveRow(next)
197
+ next.scrollIntoView({ block: 'nearest' })
198
+ emit('itemClick', { item: { id: next.closest('[data-sidebar-row]').dataset.itemId, label: next.dataset.label || '', href: next.getAttribute('href') || '' }, event })
199
+ }
200
+ }
201
+ else if ((event.key === 'ArrowRight' || event.key === 'ArrowLeft') && current) {
202
+ const row = current.closest('[data-sidebar-row]')
203
+ if (row.querySelector('[data-sidebar-disclosure]')) {
204
+ const expanded = row.getAttribute('data-expanded') === 'true'
205
+ if (event.key === 'ArrowRight' ? !expanded : expanded) {
206
+ event.preventDefault()
207
+ toggleRowDisclosure(row)
208
+ }
209
+ }
210
+ }
211
+ else if (event.key === 'Enter' && current) {
212
+ current.click()
213
+ }
164
214
  }
165
215
 
166
- function toggleCollapse() {
167
- setCollapsed(!collapsed())
216
+ // ---------------------------------------------------------------------------
217
+ // Overlay scrollbar — a floating thumb that appears while scrolling and fades
218
+ // away, like macOS overlay scrollbars. The native scrollbar is hidden so rows
219
+ // keep their exact insets.
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function attachOverlayScrollbar(scrollArea, thumb) {
223
+ let fadeTimer
224
+
225
+ function update() {
226
+ const { scrollTop, scrollHeight, clientHeight } = scrollArea
227
+ if (scrollHeight <= clientHeight) {
228
+ thumb.style.opacity = '0'
229
+ return
230
+ }
231
+ const trackHeight = clientHeight - 8
232
+ const height = Math.max(24, (clientHeight / scrollHeight) * trackHeight)
233
+ const top = 4 + (scrollTop / (scrollHeight - clientHeight)) * (trackHeight - height)
234
+ thumb.style.height = `${height}px`
235
+ thumb.style.transform = `translateY(${top}px)`
236
+ thumb.style.opacity = '1'
237
+ clearTimeout(fadeTimer)
238
+ fadeTimer = setTimeout(() => { thumb.style.opacity = '0' }, 900)
239
+ }
240
+
241
+ scrollArea.addEventListener('scroll', update, { passive: true })
168
242
  }
169
243
 
170
- function onSectionToggle(sectionId) {
171
- emit('sectionToggle', sectionId)
244
+ // ---------------------------------------------------------------------------
245
+ // Collapse (sidebar-wide) — persisted, synced to the app shell and to Craft's
246
+ // native web-sidebar material when present.
247
+ // ---------------------------------------------------------------------------
248
+
249
+ function applyCollapseEffects(nextCollapsed) {
250
+ if (persistKeyProp) {
251
+ try { window.localStorage?.setItem(persistKeyProp, String(nextCollapsed)) } catch {}
252
+ }
253
+ if (shellSelectorProp) {
254
+ document.querySelectorAll(shellSelectorProp).forEach((shell) => {
255
+ shell.style?.setProperty?.(widthVarProp, nextCollapsed && collapseModeProp === 'hidden' ? '0px' : `${widthProp}px`)
256
+ if (shell.dataset) shell.dataset.sidebarCollapsed = String(nextCollapsed)
257
+ })
258
+ }
259
+ document.documentElement.dataset.stxSidebarCollapsed = String(nextCollapsed)
260
+ if (collapsedClassProp) document.documentElement.classList.toggle(collapsedClassProp, nextCollapsed)
261
+ window.craft?.window?.setWebSidebarCollapsed?.(nextCollapsed).catch?.(() => {})
172
262
  }
173
263
 
174
- function onItemClick(event) {
175
- emit('itemClick', event.detail)
264
+ function setCollapsed(nextCollapsed) {
265
+ collapsed.set(nextCollapsed)
266
+ applyCollapseEffects(nextCollapsed)
267
+ emit('collapse', nextCollapsed)
176
268
  }
177
269
 
178
- function isCraftRuntime() {
179
- return window.__craftNativeSidebar === true
180
- || window.craft?.__craft_bridge_loaded === true
181
- || !!window.craft?.window
182
- || !!window.webkit?.messageHandlers?.craft
270
+ function toggleCollapse() {
271
+ if (collapsibleProp) setCollapsed(!collapsed())
183
272
  }
184
273
 
185
- onMount(async () => {
274
+ onMount(() => {
275
+ pane = document.getElementById(sidebarUidProp)
276
+ if (!pane) return
277
+
186
278
  if (persistKeyProp) {
187
279
  try {
188
280
  const saved = window.localStorage?.getItem(persistKeyProp)
189
281
  if (saved === 'true' || saved === 'false') collapsed.set(saved === 'true')
190
- }
191
- catch {}
282
+ } catch {}
192
283
  }
193
-
194
284
  applyCollapseEffects(collapsed())
195
285
 
196
- if (variantProp === 'desktop' && isCraftRuntime()) {
197
- document.documentElement.classList.add('has-stx-sidebar-material')
198
- if (document.body?.dataset) document.body.dataset.sidebarMaterial = 'desktop'
199
- }
286
+ pane.addEventListener('click', onClick)
287
+ pane.addEventListener('keydown', onKeyDown)
200
288
 
201
- hasNativeSidebar.set(isCraftRuntime() && window.craft?.hasNativeSidebar?.() === true)
289
+ const scrollArea = pane.querySelector('[data-sidebar-scroll]')
290
+ const thumb = pane.querySelector('[data-sidebar-scroll-thumb]')
291
+ if (scrollArea && thumb) attachOverlayScrollbar(scrollArea, thumb)
202
292
 
203
- const nativeSidebarApi = window.craft?.nativeUI || window.craft?.components
204
- if (isCraftRuntime() && nativeSidebarApi?.createSidebar) {
205
- try {
206
- const sidebar = await nativeSidebarApi.createSidebar({
207
- position: positionProp,
208
- width: widthProp,
209
- minWidth: minWidthProp,
210
- collapseMode: collapseModeProp,
211
- collapsible: collapsibleProp,
212
- variant: variantProp,
213
- material: variantProp === 'desktop' ? 'sidebar' : undefined,
214
- backgroundEffect: variantProp === 'desktop' ? 'vibrancy' : undefined,
215
- materialOpacity: materialOpacityProp,
216
- materialDarkOpacity: materialDarkOpacityProp,
217
- materialScheme: materialSchemeProp,
218
- allowsVibrancy: variantProp === 'desktop' || variantProp === 'vibrancy' || variantProp === 'tahoe',
219
- sections: sectionsProp.map(s => ({
220
- id: s.id,
221
- label: s.label,
222
- items: s.items?.map(i => ({ id: i.id, label: i.label, icon: i.icon, href: i.href, badge: i.badge })),
223
- })),
224
- })
225
- if (sidebar) hasNativeSidebar.set(true)
226
- }
227
- catch (err) {
228
- console.warn('[Sidebar] Failed to create native sidebar, using web fallback:', err)
229
- }
230
- }
293
+ // Ensure the initial active row carries the theme's highlight classes.
294
+ const active = pane.querySelector('[data-sidebar-item][data-active="true"]')
295
+ if (active) active.classList.add(...activeClassList())
296
+ syncRowVisibility()
231
297
  })
232
298
 
233
299
  defineExpose({ collapsed, toggle: toggleCollapse })
234
300
  </script>
235
301
 
236
302
  <aside
237
- :show="!hasNativeSidebar()"
238
- class="{{ containerClasses }} sidebar-container"
239
- data-sidebar-variant="{{ variant }}"
240
- data-sidebar-material-scheme="{{ materialScheme }}"
303
+ id="{{ sidebarUid }}"
304
+ data-stx-sidebar
305
+ data-sidebar-theme="{{ theme }}"
306
+ data-active-class="{{ activeClasses }}"
307
+ class="{{ paneClasses }}"
308
+ :style="`width: ${sidebarWidth()}`"
241
309
  :aria-hidden="String(collapsed() && collapseModeProp === 'hidden')"
242
- :style="`width: ${sidebarWidth()}; --stx-sidebar-material-opacity: ${materialOpacityProp}; --stx-sidebar-material-dark-opacity: ${materialDarkOpacityProp}`"
243
- @mouseenter="onMouseEnter()"
244
- @mouseleave="onMouseLeave()"
245
310
  role="navigation"
246
- aria-label="Sidebar navigation"
311
+ aria-label="Sidebar"
312
+ tabindex="0"
247
313
  >
248
- @if(variantTint)
249
- <div class="absolute inset-0 pointer-events-none bg-gradient-to-b from-amber-50/30 via-orange-50/20 to-stone-100/30 dark:from-neutral-800/20 dark:via-neutral-900/10 dark:to-neutral-800/20 mix-blend-overlay"></div>
314
+ @if(tintClasses)
315
+ <div class="absolute inset-0 pointer-events-none {{ tintClasses }}"></div>
250
316
  @endif
251
- @if(variantShimmer)
252
- <div class="absolute inset-0 pointer-events-none bg-[linear-gradient(112deg,rgba(255,255,255,0.20)_0%,rgba(255,255,255,0.04)_45%,rgba(226,226,222,0.16)_100%)] dark:bg-[linear-gradient(112deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.02)_46%,rgba(255,255,255,0.06)_100%)]"></div>
317
+ @if(hasShimmerRim)
318
+ <div class="stx-sidebar-rim" aria-hidden="true"></div>
253
319
  @endif
254
320
 
255
321
  @if($slots.header)
256
- <div class="flex-shrink-0 relative z-10">
322
+ <div class="relative z-10 flex-shrink-0">
257
323
  <slot name="header" />
258
324
  </div>
259
325
  @endif
260
326
 
261
- <div class="flex-1 overflow-y-auto overflow-x-hidden relative z-10">
262
- @if(sections.length > 0)
263
- <nav class="{{ navClasses }}">
264
- @foreach(section in sections)
265
- <SidebarSection
266
- :id="section.id"
267
- :label="section.label"
268
- :icon="section.icon"
269
- :items="section.items"
270
- :expanded="section.expanded !== false"
271
- :collapsible="section.collapsible !== false"
272
- :showLabel="showExpanded()"
273
- :variant="variant"
274
- @toggle="onSectionToggle(section.id)"
275
- @itemClick="onItemClick($event)"
276
- />
277
- @endforeach
278
- </nav>
279
- @else
280
- <div class="{{ slotClasses }}">
327
+ <div class="relative z-10 min-h-0 flex-1">
328
+ <div data-sidebar-scroll class="{{ scrollAreaClasses }} h-full stx-sidebar-scroll">
329
+ @if(sectionList.length > 0)
330
+ <nav class="flex flex-col">
331
+ @foreach(sectionList as section)
332
+ <SidebarSection
333
+ :id="section.id"
334
+ :label="section.label"
335
+ :items="section.items"
336
+ :collapsible="section.collapsible"
337
+ :collapsed="section.collapsed"
338
+ :theme="theme"
339
+ />
340
+ @endforeach
341
+ </nav>
342
+ @else
281
343
  <slot />
282
- </div>
283
- @endif
344
+ @endif
345
+ </div>
346
+ <div data-sidebar-scroll-thumb class="stx-sidebar-scroll-thumb" aria-hidden="true"></div>
284
347
  </div>
285
348
 
286
349
  @if($slots.footer)
287
- <div class="flex-shrink-0 relative z-10">
350
+ <div class="relative z-10 flex-shrink-0">
288
351
  <slot name="footer" />
289
352
  </div>
290
353
  @endif
291
-
292
- @if(collapsible)
293
- <button
294
- type="button"
295
- class="absolute top-4 {{ collapseBtnPosition }} p-1.5 rounded-lg text-stone-400 hover:text-stone-600 hover:bg-black/5 dark:hover:text-neutral-300 dark:hover:bg-white/10 transition-colors z-20"
296
- @click="toggleCollapse()"
297
- :aria-label="collapseAriaLabel()"
298
- data-sidebar-collapse
299
- data-stx-sidebar-collapse-button
300
- >
301
- <div :show="!collapsed()" class="i-hugeicons-sidebar-left w-5 h-5"></div>
302
- <div :show="collapsed()" class="i-hugeicons-sidebar-right w-5 h-5"></div>
303
- </button>
304
- @endif
305
354
  </aside>
306
355
 
307
356
  <style scoped>
308
- .overflow-y-auto::-webkit-scrollbar { width: 6px; }
309
- .overflow-y-auto::-webkit-scrollbar-track { background: transparent; }
310
- .overflow-y-auto::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.15); border-radius: 3px; }
311
- .overflow-y-auto::-webkit-scrollbar-thumb:hover { background-color: rgba(0, 0, 0, 0.25); }
312
- .sidebar-container[data-sidebar-variant="desktop"] {
313
- background-color: rgba(246, 246, 244, var(--stx-sidebar-material-opacity, 0.78));
314
- backdrop-filter: blur(24px) saturate(1.08);
315
- -webkit-backdrop-filter: blur(24px) saturate(1.08);
357
+ [data-stx-sidebar] {
358
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
359
+ -webkit-font-smoothing: antialiased;
360
+ outline: none;
316
361
  }
317
- html.has-native-sidebar [data-stx-native-window-controls] {
318
- display: none;
362
+
363
+ /* Liquid Glass edge highlight: a hairline rim that catches light along the
364
+ pane's edges, with a slow sheen drifting across it. */
365
+ .stx-sidebar-rim {
366
+ position: absolute;
367
+ inset: 0;
368
+ border-radius: inherit;
369
+ pointer-events: none;
370
+ z-index: 5;
371
+ box-shadow:
372
+ inset 0 1px 0 rgba(255, 255, 255, 0.50),
373
+ inset 1px 0 0 rgba(255, 255, 255, 0.26),
374
+ inset -1px 0 0 rgba(255, 255, 255, 0.10),
375
+ inset 0 -1px 0 rgba(255, 255, 255, 0.14);
376
+ }
377
+ .stx-sidebar-rim::after {
378
+ content: '';
379
+ position: absolute;
380
+ inset: 0;
381
+ border-radius: inherit;
382
+ padding: 1.5px;
383
+ background: linear-gradient(
384
+ 115deg,
385
+ rgba(255, 255, 255, 0.85) 0%,
386
+ rgba(255, 255, 255, 0.00) 28%,
387
+ rgba(255, 255, 255, 0.18) 52%,
388
+ rgba(255, 255, 255, 0.00) 74%,
389
+ rgba(255, 255, 255, 0.65) 100%
390
+ );
391
+ background-size: 250% 250%;
392
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
393
+ -webkit-mask-composite: xor;
394
+ mask-composite: exclude;
395
+ animation: stx-sidebar-shimmer 10s ease-in-out infinite alternate;
319
396
  }
320
- html.has-native-sidebar [data-stx-sidebar-web-chrome] {
321
- margin-left: 202px;
397
+ @keyframes stx-sidebar-shimmer {
398
+ from { background-position: 0% 0%; }
399
+ to { background-position: 100% 100%; }
322
400
  }
323
401
  @media (prefers-color-scheme: dark) {
324
- .sidebar-container[data-sidebar-variant="desktop"]:not([data-sidebar-material-scheme="light"]) {
325
- background-color: rgba(24, 24, 23, var(--stx-sidebar-material-dark-opacity, 0.8));
402
+ .stx-sidebar-rim {
403
+ box-shadow:
404
+ inset 0 1px 0 rgba(255, 255, 255, 0.18),
405
+ inset 1px 0 0 rgba(255, 255, 255, 0.10),
406
+ inset -1px 0 0 rgba(255, 255, 255, 0.05),
407
+ inset 0 -1px 0 rgba(255, 255, 255, 0.06);
326
408
  }
327
- .overflow-y-auto::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.15); }
328
- .overflow-y-auto::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, 0.25); }
409
+ .stx-sidebar-rim::after { opacity: 0.35; }
410
+ }
411
+ @media (prefers-reduced-motion: reduce) {
412
+ .stx-sidebar-rim::after { animation: none; }
413
+ }
414
+
415
+ /* Rows collapse like AppKit outlines: fixed row height animates to zero. */
416
+ [data-sidebar-row] {
417
+ height: 32px; /* 30px row + 2px gap */
418
+ padding-bottom: 2px;
419
+ transition: height 0.22s ease, opacity 0.16s ease;
420
+ }
421
+ [data-sidebar-row][data-collapsed] {
422
+ height: 0;
423
+ opacity: 0;
424
+ overflow: hidden;
425
+ pointer-events: none;
426
+ }
427
+
428
+ /* Disclosure chevron rotates from ▸ to ▾ */
429
+ [data-sidebar-row][data-expanded="true"] [data-sidebar-disclosure] > * {
430
+ transform: rotate(90deg);
431
+ }
432
+ [data-sidebar-section][data-expanded="false"] [data-sidebar-section-chevron] {
433
+ transform: rotate(-90deg);
434
+ }
435
+
436
+ /* Native scrollbar hidden; the floating thumb below takes its place. */
437
+ .stx-sidebar-scroll {
438
+ scrollbar-width: none;
439
+ }
440
+ .stx-sidebar-scroll::-webkit-scrollbar {
441
+ display: none;
442
+ }
443
+ .stx-sidebar-scroll-thumb {
444
+ position: absolute;
445
+ top: 0;
446
+ right: 3px;
447
+ width: 7px;
448
+ border-radius: 4px;
449
+ background: rgba(0, 0, 0, 0.35);
450
+ opacity: 0;
451
+ transition: opacity 0.3s ease;
452
+ pointer-events: none;
453
+ z-index: 20;
454
+ }
455
+ @media (prefers-color-scheme: dark) {
456
+ .stx-sidebar-scroll-thumb { background: rgba(255, 255, 255, 0.35); }
329
457
  }
330
458
  </style>