@stacksjs/components 0.2.91 → 0.2.93

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,476 @@
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)
175
+ // ---------------------------------------------------------------------------
176
+ // Keyboard — matches macOS source lists: ↑/↓ move the selection over visible
177
+ // rows, ←/→ collapse/expand, Return opens.
178
+ // ---------------------------------------------------------------------------
179
+
180
+ function visibleItems() {
181
+ return [...pane.querySelectorAll('[data-sidebar-row]:not([data-collapsed]) [data-sidebar-item]:not([data-disabled])')]
156
182
  }
157
183
 
158
- function onMouseEnter() {
159
- if (collapsed() && collapsibleProp && collapseModeProp !== 'hidden') isHovered.set(true)
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
+ }
160
214
  }
161
215
 
162
- function onMouseLeave() {
163
- isHovered.set(false)
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 })
164
242
  }
165
243
 
166
- function toggleCollapse() {
167
- setCollapsed(!collapsed())
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?.(() => {})
168
262
  }
169
263
 
170
- function onSectionToggle(sectionId) {
171
- emit('sectionToggle', sectionId)
264
+ function setCollapsed(nextCollapsed) {
265
+ collapsed.set(nextCollapsed)
266
+ applyCollapseEffects(nextCollapsed)
267
+ emit('collapse', nextCollapsed)
172
268
  }
173
269
 
174
- function onItemClick(event) {
175
- emit('itemClick', event.detail)
270
+ function toggleCollapse() {
271
+ if (collapsibleProp) setCollapsed(!collapsed())
176
272
  }
177
273
 
178
- function isCraftRuntime() {
179
- return window.__craftNativeSidebar === true
180
- || window.craft?.__craft_bridge_loaded === true
181
- || !!window.craft?.window
182
- || !!window.webkit?.messageHandlers?.craft
274
+ // ---------------------------------------------------------------------------
275
+ // Appearance — native sidebars follow the OS light/dark appearance. Crosswind
276
+ // dark: utilities are class-based, so mirror prefers-color-scheme onto the
277
+ // root element unless the app manages its own theme (data-theme attribute).
278
+ // ---------------------------------------------------------------------------
279
+
280
+ function followSystemAppearance() {
281
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
282
+ function sync() {
283
+ if (document.documentElement.dataset.theme) return
284
+ document.documentElement.classList.toggle('dark', media.matches)
285
+ }
286
+ sync()
287
+ media.addEventListener('change', sync)
183
288
  }
184
289
 
185
- onMount(async () => {
290
+ onMount(() => {
291
+ pane = document.getElementById(sidebarUidProp)
292
+ if (!pane) return
293
+
294
+ followSystemAppearance()
295
+
186
296
  if (persistKeyProp) {
187
297
  try {
188
298
  const saved = window.localStorage?.getItem(persistKeyProp)
189
299
  if (saved === 'true' || saved === 'false') collapsed.set(saved === 'true')
190
- }
191
- catch {}
300
+ } catch {}
192
301
  }
193
-
194
302
  applyCollapseEffects(collapsed())
195
303
 
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
- }
304
+ pane.addEventListener('click', onClick)
305
+ pane.addEventListener('keydown', onKeyDown)
200
306
 
201
- hasNativeSidebar.set(isCraftRuntime() && window.craft?.hasNativeSidebar?.() === true)
307
+ const scrollArea = pane.querySelector('[data-sidebar-scroll]')
308
+ const thumb = pane.querySelector('[data-sidebar-scroll-thumb]')
309
+ if (scrollArea && thumb) attachOverlayScrollbar(scrollArea, thumb)
202
310
 
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
- }
311
+ // Ensure the initial active row carries the theme's highlight classes.
312
+ const active = pane.querySelector('[data-sidebar-item][data-active="true"]')
313
+ if (active) active.classList.add(...activeClassList())
314
+ syncRowVisibility()
231
315
  })
232
316
 
233
317
  defineExpose({ collapsed, toggle: toggleCollapse })
234
318
  </script>
235
319
 
236
320
  <aside
237
- :show="!hasNativeSidebar()"
238
- class="{{ containerClasses }} sidebar-container"
239
- data-sidebar-variant="{{ variant }}"
240
- data-sidebar-material-scheme="{{ materialScheme }}"
321
+ id="{{ sidebarUid }}"
322
+ data-stx-sidebar
323
+ data-sidebar-theme="{{ theme }}"
324
+ data-active-class="{{ activeClasses }}"
325
+ class="{{ paneClasses }}"
326
+ :style="`width: ${sidebarWidth()}`"
241
327
  :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
328
  role="navigation"
246
- aria-label="Sidebar navigation"
329
+ aria-label="Sidebar"
330
+ tabindex="0"
247
331
  >
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>
332
+ @if(tintClasses)
333
+ <div class="absolute inset-0 pointer-events-none {{ tintClasses }}"></div>
250
334
  @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>
335
+ @if(hasShimmerRim)
336
+ <div class="stx-sidebar-rim" aria-hidden="true"></div>
253
337
  @endif
254
338
 
255
339
  @if($slots.header)
256
- <div class="flex-shrink-0 relative z-10">
340
+ <div class="relative z-10 flex-shrink-0">
257
341
  <slot name="header" />
258
342
  </div>
259
343
  @endif
260
344
 
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 }}">
345
+ <div class="relative z-10 min-h-0 flex-1">
346
+ <div data-sidebar-scroll class="{{ scrollAreaClasses }} h-full stx-sidebar-scroll">
347
+ @if(sectionList.length > 0)
348
+ <nav class="flex flex-col">
349
+ @foreach(sectionList as section)
350
+ <SidebarSection
351
+ :id="section.id"
352
+ :label="section.label"
353
+ :items="section.items"
354
+ :collapsible="section.collapsible"
355
+ :collapsed="section.collapsed"
356
+ :theme="theme"
357
+ />
358
+ @endforeach
359
+ </nav>
360
+ @else
281
361
  <slot />
282
- </div>
283
- @endif
362
+ @endif
363
+ </div>
364
+ <div data-sidebar-scroll-thumb class="stx-sidebar-scroll-thumb" aria-hidden="true"></div>
284
365
  </div>
285
366
 
286
367
  @if($slots.footer)
287
- <div class="flex-shrink-0 relative z-10">
368
+ <div class="relative z-10 flex-shrink-0">
288
369
  <slot name="footer" />
289
370
  </div>
290
371
  @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
372
  </aside>
306
373
 
307
374
  <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);
375
+ [data-stx-sidebar] {
376
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif;
377
+ -webkit-font-smoothing: antialiased;
378
+ outline: none;
316
379
  }
317
- html.has-native-sidebar [data-stx-native-window-controls] {
318
- display: none;
380
+
381
+ /* Liquid Glass edge highlight: a hairline rim that catches light along the
382
+ pane's edges, with a slow sheen drifting across it. */
383
+ .stx-sidebar-rim {
384
+ position: absolute;
385
+ inset: 0;
386
+ border-radius: inherit;
387
+ pointer-events: none;
388
+ z-index: 5;
389
+ box-shadow:
390
+ inset 0 1px 0 rgba(255, 255, 255, 0.50),
391
+ inset 1px 0 0 rgba(255, 255, 255, 0.26),
392
+ inset -1px 0 0 rgba(255, 255, 255, 0.10),
393
+ inset 0 -1px 0 rgba(255, 255, 255, 0.14);
394
+ }
395
+ .stx-sidebar-rim::after {
396
+ content: '';
397
+ position: absolute;
398
+ inset: 0;
399
+ border-radius: inherit;
400
+ padding: 1.5px;
401
+ background: linear-gradient(
402
+ 115deg,
403
+ rgba(255, 255, 255, 0.85) 0%,
404
+ rgba(255, 255, 255, 0.00) 28%,
405
+ rgba(255, 255, 255, 0.18) 52%,
406
+ rgba(255, 255, 255, 0.00) 74%,
407
+ rgba(255, 255, 255, 0.65) 100%
408
+ );
409
+ background-size: 250% 250%;
410
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
411
+ -webkit-mask-composite: xor;
412
+ mask-composite: exclude;
413
+ animation: stx-sidebar-shimmer 10s ease-in-out infinite alternate;
319
414
  }
320
- html.has-native-sidebar [data-stx-sidebar-web-chrome] {
321
- margin-left: 202px;
415
+ @keyframes stx-sidebar-shimmer {
416
+ from { background-position: 0% 0%; }
417
+ to { background-position: 100% 100%; }
322
418
  }
323
419
  @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));
420
+ .stx-sidebar-rim {
421
+ box-shadow:
422
+ inset 0 1px 0 rgba(255, 255, 255, 0.18),
423
+ inset 1px 0 0 rgba(255, 255, 255, 0.10),
424
+ inset -1px 0 0 rgba(255, 255, 255, 0.05),
425
+ inset 0 -1px 0 rgba(255, 255, 255, 0.06);
326
426
  }
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); }
427
+ .stx-sidebar-rim::after { opacity: 0.35; }
428
+ }
429
+ @media (prefers-reduced-motion: reduce) {
430
+ .stx-sidebar-rim::after { animation: none; }
431
+ }
432
+
433
+ /* Rows collapse like AppKit outlines: fixed row height animates to zero. */
434
+ [data-sidebar-row] {
435
+ height: 32px; /* 30px row + 2px gap */
436
+ padding-bottom: 2px;
437
+ transition: height 0.22s ease, opacity 0.16s ease;
438
+ }
439
+ [data-sidebar-row][data-collapsed] {
440
+ height: 0;
441
+ opacity: 0;
442
+ overflow: hidden;
443
+ pointer-events: none;
444
+ }
445
+
446
+ /* Disclosure chevron rotates from ▸ to ▾ */
447
+ [data-sidebar-row][data-expanded="true"] [data-sidebar-disclosure] > * {
448
+ transform: rotate(90deg);
449
+ }
450
+ [data-sidebar-section][data-expanded="false"] [data-sidebar-section-chevron] {
451
+ transform: rotate(-90deg);
452
+ }
453
+
454
+ /* Native scrollbar hidden; the floating thumb below takes its place. */
455
+ .stx-sidebar-scroll {
456
+ scrollbar-width: none;
457
+ }
458
+ .stx-sidebar-scroll::-webkit-scrollbar {
459
+ display: none;
460
+ }
461
+ .stx-sidebar-scroll-thumb {
462
+ position: absolute;
463
+ top: 0;
464
+ right: 3px;
465
+ width: 7px;
466
+ border-radius: 4px;
467
+ background: rgba(0, 0, 0, 0.35);
468
+ opacity: 0;
469
+ transition: opacity 0.3s ease;
470
+ pointer-events: none;
471
+ z-index: 20;
472
+ }
473
+ @media (prefers-color-scheme: dark) {
474
+ .stx-sidebar-scroll-thumb { background: rgba(255, 255, 255, 0.35); }
329
475
  }
330
476
  </style>