@stacksjs/components 0.2.54 → 0.2.66

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 (68) hide show
  1. package/dist/Accordion-pt8j917s.stx +146 -0
  2. package/dist/AccordionItem-hbhzx63p.stx +37 -0
  3. package/dist/{Checkbox-zgdmy3p5.stx → Checkbox-aqmq4cqv.stx} +3 -1
  4. package/dist/{Dialog-hczpexs4.stx → Dialog-y5rz2vrq.stx} +5 -1
  5. package/dist/{Drawer-xhevp5cw.stx → Drawer-hrz7qcyp.stx} +4 -1
  6. package/dist/Notification-03vfh3k6.stx +117 -0
  7. package/dist/{NumberInput-drnvtqej.stx → NumberInput-g41gmd2c.stx} +2 -1
  8. package/dist/{Pagination-n3d31cns.stx → Pagination-wtgxmzey.stx} +3 -1
  9. package/dist/{PasswordInput-n02x6053.stx → PasswordInput-4rsq1gwb.stx} +2 -1
  10. package/dist/{Progress-yxcntr4x.stx → Progress-9dhakbh4.stx} +2 -1
  11. package/dist/{Radio-cgjv2hb2.stx → Radio-djtb5dpc.stx} +2 -1
  12. package/dist/{Select-28y0pg83.stx → Select-1vnd0brz.stx} +2 -1
  13. package/dist/{Sidebar-73vc78as.stx → Sidebar-0jzasrmt.stx} +116 -22
  14. package/dist/{SidebarHeader-7v174a9c.stx → SidebarHeader-ywpmx14d.stx} +80 -3
  15. package/dist/{SidebarItem-3y3e8g2t.stx → SidebarItem-h53sgqzz.stx} +8 -8
  16. package/dist/{SidebarSection-z5ey6awe.stx → SidebarSection-g195cb4f.stx} +4 -3
  17. package/dist/{Switch-r5t5w04w.stx → Switch-p1bcr9ks.stx} +3 -1
  18. package/dist/TabPanel-5htevsq6.stx +25 -0
  19. package/dist/Tabs-79brys99.stx +202 -0
  20. package/dist/{TextInput-kf084763.stx → TextInput-g0vaz0pe.stx} +4 -1
  21. package/dist/{Textarea-xg4rr2xz.stx → Textarea-96kawv5c.stx} +2 -1
  22. package/dist/{Tooltip-m0tqd72e.stx → Tooltip-y61h04gp.stx} +2 -1
  23. package/dist/index.js +3909 -15
  24. package/dist/ui/accordion/Accordion.stx +9 -1
  25. package/dist/ui/accordion/AccordionItem.stx +7 -0
  26. package/dist/ui/accordion/index.d.ts +30 -1
  27. package/dist/ui/input/NumberInput.stx +1 -1
  28. package/dist/ui/input/TextInput.stx +1 -1
  29. package/dist/ui/notification/Notification.stx +10 -8
  30. package/dist/ui/select/Select.stx +1 -1
  31. package/dist/ui/sidebar/Sidebar.stx +9 -0
  32. package/dist/ui/sidebar/SidebarHeader.stx +7 -0
  33. package/dist/ui/sidebar/SidebarItem.stx +1 -1
  34. package/dist/ui/sidebar/index.d.ts +14 -0
  35. package/dist/ui/tabs/TabPanel.stx +9 -0
  36. package/dist/ui/tabs/Tabs.stx +140 -0
  37. package/dist/ui/tabs/index.d.ts +32 -1
  38. package/dist/ui/textarea/Textarea.stx +1 -1
  39. package/package.json +3 -3
  40. package/src/ui/accordion/Accordion.stx +90 -32
  41. package/src/ui/accordion/AccordionItem.stx +37 -0
  42. package/src/ui/accordion/index.ts +32 -1
  43. package/src/ui/checkbox/Checkbox.stx +3 -1
  44. package/src/ui/dialog/Dialog.stx +5 -1
  45. package/src/ui/drawer/Drawer.stx +4 -1
  46. package/src/ui/input/NumberInput.stx +2 -1
  47. package/src/ui/input/PasswordInput.stx +2 -1
  48. package/src/ui/input/TextInput.stx +4 -1
  49. package/src/ui/notification/Notification.stx +65 -26
  50. package/src/ui/pagination/Pagination.stx +3 -1
  51. package/src/ui/progress/Progress.stx +2 -1
  52. package/src/ui/radio/Radio.stx +2 -1
  53. package/src/ui/select/Select.stx +2 -1
  54. package/src/ui/sidebar/Sidebar.stx +116 -22
  55. package/src/ui/sidebar/SidebarHeader.stx +80 -3
  56. package/src/ui/sidebar/SidebarItem.stx +8 -8
  57. package/src/ui/sidebar/SidebarSection.stx +4 -3
  58. package/src/ui/sidebar/index.ts +30 -0
  59. package/src/ui/switch/Switch.stx +3 -1
  60. package/src/ui/tabs/TabPanel.stx +25 -0
  61. package/src/ui/tabs/Tabs.stx +108 -32
  62. package/src/ui/tabs/index.ts +35 -1
  63. package/src/ui/textarea/Textarea.stx +2 -1
  64. package/src/ui/tooltip/Tooltip.stx +2 -1
  65. package/src/utils/accessibility.ts +1 -1
  66. package/dist/Accordion-19x8q4rj.stx +0 -88
  67. package/dist/Notification-tb9b525s.stx +0 -78
  68. package/dist/Tabs-bzvvn4ke.stx +0 -126
@@ -0,0 +1,146 @@
1
+ <script server>
2
+ // Two modes (see stacksjs/stx#1703):
3
+ // 1. Legacy prop API — pass `items={[{title, content}, …]}` for simple
4
+ // string-content accordions (kept for backward compatibility with
5
+ // existing visual snapshots and consumers).
6
+ // 2. Slot API — drop `<AccordionItem title="…">` children for arbitrary
7
+ // content. Each AccordionItem renders its own header + content panel;
8
+ // this parent attaches click handlers and toggles content visibility.
9
+ export const items = $props.items || []
10
+ export const allowMultiple = $props.allowMultiple || false
11
+ export const defaultOpen = $props.defaultOpen || []
12
+ export const className = $props.className || ''
13
+
14
+ export const accordionClasses = `divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg ${className}`.trim()
15
+ export const headerClasses = `w-full flex items-center justify-between px-4 py-3 text-left font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900`.trim()
16
+ export const iconClasses = `w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200`.trim()
17
+ export const contentClasses = `px-4 py-3 text-gray-700 dark:text-gray-300`.trim()
18
+ export const hasPropItems = Array.isArray(items) && items.length > 0
19
+ </script>
20
+
21
+ <script client>
22
+ const emit = defineEmits()
23
+ const allowMultiple = {{ allowMultiple }}
24
+ const hasPropItems = {{ hasPropItems }}
25
+ const containerRef = useRef()
26
+ const openItems = state({{ defaultOpen }})
27
+
28
+ function findOwnItems() {
29
+ const root = containerRef.value
30
+ if (!root) return []
31
+ return Array.from(root.querySelectorAll('[data-stx-accordion-item]'))
32
+ .filter(item => item.closest('[data-stx-accordion]') === root)
33
+ }
34
+
35
+ onMount(() => {
36
+ if (hasPropItems) return // legacy mode: server-rendered, no wiring needed
37
+ const items = findOwnItems()
38
+ items.forEach((item, idx) => {
39
+ const header = item.querySelector(':scope > [data-stx-accordion-header]')
40
+ if (header && !header.__stx_wired) {
41
+ header.__stx_wired = true
42
+ header.addEventListener('click', () => toggleItem(idx))
43
+ header.addEventListener('keydown', (e) => onHeaderKey(e, idx))
44
+ }
45
+ })
46
+ })
47
+
48
+ // Sync the open state into the DOM: show/hide content panels, update
49
+ // aria-expanded on headers, rotate chevrons.
50
+ effect(() => {
51
+ if (hasPropItems) return
52
+ const items = findOwnItems()
53
+ const open = openItems()
54
+ items.forEach((item, idx) => {
55
+ const isOpenItem = open.includes(idx)
56
+ const header = item.querySelector(':scope > [data-stx-accordion-header]')
57
+ const content = item.querySelector(':scope > [data-stx-accordion-content]')
58
+ const chevron = item.querySelector(':scope > [data-stx-accordion-header] [data-stx-accordion-chevron]')
59
+ if (header) header.setAttribute('aria-expanded', isOpenItem ? 'true' : 'false')
60
+ if (content) {
61
+ if (isOpenItem) content.removeAttribute('hidden')
62
+ else content.setAttribute('hidden', '')
63
+ }
64
+ if (chevron) chevron.style.transform = `rotate(${isOpenItem ? 180 : 0}deg)`
65
+ })
66
+ })
67
+
68
+ function toggleItem(index) {
69
+ const cur = openItems()
70
+ if (allowMultiple) {
71
+ openItems.set(cur.includes(index) ? cur.filter(i => i !== index) : [...cur, index])
72
+ }
73
+ else {
74
+ openItems.set(cur.includes(index) ? [] : [index])
75
+ }
76
+ emit('change', openItems())
77
+ }
78
+
79
+ function isOpen(index) {
80
+ return openItems().includes(index)
81
+ }
82
+
83
+ function onHeaderKey(event, index) {
84
+ const root = containerRef.value
85
+ if (!root) return
86
+ const buttons = root.querySelectorAll('button[data-stx-accordion-header], button[data-accordion-header]')
87
+ if (event.key === 'ArrowDown') {
88
+ event.preventDefault()
89
+ buttons[index + 1]?.focus()
90
+ }
91
+ else if (event.key === 'ArrowUp') {
92
+ event.preventDefault()
93
+ buttons[index - 1]?.focus()
94
+ }
95
+ else if (event.key === 'Home') {
96
+ event.preventDefault()
97
+ buttons[0]?.focus()
98
+ }
99
+ else if (event.key === 'End') {
100
+ event.preventDefault()
101
+ buttons[buttons.length - 1]?.focus()
102
+ }
103
+ }
104
+
105
+ defineExpose({ openItems, toggleItem })
106
+ </script>
107
+
108
+ <div x-ref="containerRef" data-stx-accordion class="{{ accordionClasses }}" role="region">
109
+ @if(hasPropItems)
110
+ @foreach(item in items)
111
+ <div>
112
+ <button
113
+ type="button"
114
+ data-accordion-header
115
+ class="{{ headerClasses }}"
116
+ @click="toggleItem({{ $loop.index }})"
117
+ @keydown="onHeaderKey($event, {{ $loop.index }})"
118
+ :aria-expanded="isOpen({{ $loop.index }}) ? 'true' : 'false'"
119
+ aria-controls="accordion-content-{{ $loop.index }}"
120
+ >
121
+ <span>{{ item.title }}</span>
122
+ <svg
123
+ class="{{ iconClasses }}"
124
+ :style="`transform: rotate(${isOpen({{ $loop.index }}) ? 180 : 0}deg)`"
125
+ fill="none"
126
+ viewBox="0 0 24 24"
127
+ stroke="currentColor"
128
+ >
129
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
130
+ </svg>
131
+ </button>
132
+
133
+ <div
134
+ :show="isOpen({{ $loop.index }})"
135
+ id="accordion-content-{{ $loop.index }}"
136
+ class="{{ contentClasses }}"
137
+ role="region"
138
+ >
139
+ {!! item.content !!}
140
+ </div>
141
+ </div>
142
+ @endforeach
143
+ @else
144
+ <slot />
145
+ @endif
146
+ </div>
@@ -0,0 +1,37 @@
1
+ <script server>
2
+ // Slot wrapper that the parent <Accordion> discovers and wires up on mount.
3
+ // Renders its own header button + content panel; the parent attaches click
4
+ // handlers and toggles the content panel's `hidden` attribute as openItems
5
+ // changes. Hard-coded styling here matches the legacy prop-mode markup so
6
+ // snapshots compare cleanly in both modes. See stacksjs/stx#1703.
7
+ export const title = $props.title || ''
8
+ export const className = $props.className || ''
9
+
10
+ const headerClasses = 'w-full flex items-center justify-between px-4 py-3 text-left font-medium text-gray-900 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
11
+ const iconClasses = 'w-5 h-5 text-gray-500 dark:text-gray-400 transition-transform duration-200'
12
+ const contentClasses = 'px-4 py-3 text-gray-700 dark:text-gray-300'
13
+ </script>
14
+
15
+ <div data-stx-accordion-item data-title="{{ title }}" class="{{ className }}">
16
+ <button
17
+ type="button"
18
+ data-stx-accordion-header
19
+ data-accordion-header
20
+ class="{{ headerClasses }}"
21
+ aria-expanded="false"
22
+ >
23
+ <span>{{ title }}</span>
24
+ <svg
25
+ data-stx-accordion-chevron
26
+ class="{{ iconClasses }}"
27
+ fill="none"
28
+ viewBox="0 0 24 24"
29
+ stroke="currentColor"
30
+ >
31
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
32
+ </svg>
33
+ </button>
34
+ <div data-stx-accordion-content class="{{ contentClasses }}" role="region" hidden>
35
+ <slot />
36
+ </div>
37
+ </div>
@@ -26,7 +26,9 @@ export const inputId = `checkbox-${value || label}`
26
26
  const emit = defineEmits()
27
27
  const isDisabled = {{ disabled }}
28
28
  const isIndeterminate = {{ indeterminate }}
29
- const isChecked = state({{ checked }})
29
+ // Reactive prop binding so the parent can drive `checked` via signals.
30
+ // See stacksjs/stx#1704.
31
+ const isChecked = useReactiveProp('checked', {{ checked }})
30
32
 
31
33
  function onInputChange(event) {
32
34
  if (isDisabled) {
@@ -8,7 +8,11 @@ export const dialogClasses = `${baseClasses} ${className}`.trim()
8
8
 
9
9
  <script client>
10
10
  const emit = defineEmits()
11
- const isOpen = state({{ open }})
11
+ // useReactiveProp wires `:open="parentSignal()"` from the parent into our
12
+ // local `isOpen` signal — when the parent's signal changes, bindAttr
13
+ // updates the root's `open` attribute, our MutationObserver picks it up,
14
+ // and isOpen flips. See stacksjs/stx#1704.
15
+ const isOpen = useReactiveProp('open', {{ open }})
12
16
 
13
17
  function close() {
14
18
  isOpen.set(false)
@@ -32,7 +32,10 @@ export const closeButtonClasses = `absolute top-0 ${closeButtonPosition[position
32
32
 
33
33
  <script client>
34
34
  const emit = defineEmits()
35
- const isOpen = state({{ open }})
35
+ // useReactiveProp lets the parent drive `open` via signals
36
+ // (`:open="drawerOpen()"`) instead of the prop being captured once.
37
+ // See stacksjs/stx#1704.
38
+ const isOpen = useReactiveProp('open', {{ open }})
36
39
 
37
40
  function close() {
38
41
  isOpen.set(false)
@@ -0,0 +1,117 @@
1
+ <script server>
2
+ // Initial defaults captured at render time. The client script wraps these
3
+ // in signals so consumers can mutate them after mount (fetch-then-toast,
4
+ // auto-dismiss, etc.). See stacksjs/stx#1692.
5
+ export const initialShow = $props.show !== false
6
+ export const initialTitle = $props.title || ''
7
+ export const initialMessage = $props.message || ''
8
+ export const initialType = $props.type || 'info'
9
+ export const initialPosition = $props.position || 'top-right'
10
+ export const initialDuration = Number($props.duration ?? 5000)
11
+ export const className = $props.className || ''
12
+ </script>
13
+
14
+ <script client>
15
+ const emit = defineEmits()
16
+
17
+ const isVisible = state({{ initialShow }})
18
+ const title = state({{ initialTitle }})
19
+ const message = state({{ initialMessage }})
20
+ const type = state({{ initialType }})
21
+ const position = state({{ initialPosition }})
22
+ const duration = state({{ initialDuration }})
23
+
24
+ const typeColors = {
25
+ info: 'bg-blue-50 text-blue-900 dark:bg-blue-900 dark:text-blue-100',
26
+ success: 'bg-green-50 text-green-900 dark:bg-green-900 dark:text-green-100',
27
+ warning: 'bg-yellow-50 text-yellow-900 dark:bg-yellow-900 dark:text-yellow-100',
28
+ error: 'bg-red-50 text-red-900 dark:bg-red-900 dark:text-red-100',
29
+ }
30
+
31
+ const positions = {
32
+ 'top-left': 'top-0 left-0',
33
+ 'top-right': 'top-0 right-0',
34
+ 'top-center': 'top-0 left-1/2 -translate-x-1/2',
35
+ 'bottom-left': 'bottom-0 left-0',
36
+ 'bottom-right': 'bottom-0 right-0',
37
+ 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2',
38
+ }
39
+
40
+ const baseClasses = 'fixed z-50 m-4 max-w-md w-full pointer-events-auto rounded-lg p-4 shadow-lg ring-1 ring-black ring-opacity-5 transition-all'
41
+ const extraClassName = {{ JSON.stringify(className) }}
42
+
43
+ const notificationClasses = derived(() => {
44
+ const color = typeColors[type()] || typeColors.info
45
+ const pos = positions[position()] || positions['top-right']
46
+ return `${baseClasses} ${color} ${pos} ${extraClassName}`.trim()
47
+ })
48
+
49
+ let autoHideTimer = null
50
+ function clearAutoHide() {
51
+ if (autoHideTimer) {
52
+ clearTimeout(autoHideTimer)
53
+ autoHideTimer = null
54
+ }
55
+ }
56
+ function scheduleAutoHide() {
57
+ clearAutoHide()
58
+ const d = duration()
59
+ if (d > 0 && isVisible()) {
60
+ autoHideTimer = setTimeout(() => close(), d)
61
+ }
62
+ }
63
+
64
+ function close() {
65
+ isVisible.set(false)
66
+ clearAutoHide()
67
+ emit('close')
68
+ }
69
+
70
+ function open(opts) {
71
+ if (opts && typeof opts === 'object') {
72
+ if (opts.title !== undefined) title.set(opts.title)
73
+ if (opts.message !== undefined) message.set(opts.message)
74
+ if (opts.type !== undefined) type.set(opts.type)
75
+ if (opts.position !== undefined) position.set(opts.position)
76
+ if (opts.duration !== undefined) duration.set(Number(opts.duration))
77
+ }
78
+ isVisible.set(true)
79
+ scheduleAutoHide()
80
+ }
81
+
82
+ onMount(() => {
83
+ if (isVisible()) scheduleAutoHide()
84
+ })
85
+
86
+ defineExpose({
87
+ open,
88
+ close,
89
+ isVisible,
90
+ title,
91
+ message,
92
+ type,
93
+ position,
94
+ duration,
95
+ })
96
+ </script>
97
+
98
+ <div :if="isVisible()" :class="notificationClasses()" role="alert">
99
+ <div class="flex items-start">
100
+ <div class="flex-1">
101
+ <p :if="title()" class="text-sm font-medium" :text="title()"></p>
102
+ <p :if="message()" class="mt-1 text-sm" :class="title() ? 'opacity-90' : ''" :text="message()"></p>
103
+ <slot :if="!title() && !message()" />
104
+ </div>
105
+
106
+ <button
107
+ type="button"
108
+ @click="close()"
109
+ class="ml-4 inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2"
110
+ >
111
+ <span class="sr-only">Close</span>
112
+ <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
113
+ <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
114
+ </svg>
115
+ </button>
116
+ </div>
117
+ </div>
@@ -37,7 +37,8 @@ const emit = defineEmits()
37
37
  const stepSize = {{ step }}
38
38
  const minVal = {{ min === undefined ? 'undefined' : min }}
39
39
  const maxVal = {{ max === undefined ? 'undefined' : max }}
40
- const inputValue = state({{ value }})
40
+ // Reactive prop binding — parent can drive value via signals. See #1704.
41
+ const inputValue = useReactiveProp('value', {{ value }}, { parse: (v) => v == null || v === '' ? '' : String(v) })
41
42
 
42
43
  function onInput(event) {
43
44
  inputValue.set(event.target.value)
@@ -17,7 +17,9 @@ export const iconClasses = `w-5 h-5`.trim()
17
17
  const emit = defineEmits()
18
18
  const totalPages = {{ totalPages }}
19
19
  const siblingCount = {{ siblingCount }}
20
- const page = state({{ currentPage }})
20
+ // Reactive prop binding parent can drive `current-page` via signals
21
+ // (useful for URL-driven pagination via useSearchParams). See #1704.
22
+ const page = useReactiveProp('current-page', {{ currentPage }})
21
23
 
22
24
  function goToPage(target) {
23
25
  if (target >= 1 && target <= totalPages && target !== page()) {
@@ -34,7 +34,8 @@ export const iconClasses = iconSizeClasses[size]
34
34
  <script client>
35
35
  const emit = defineEmits()
36
36
  const showPassword = state(false)
37
- const inputValue = state({{ value }})
37
+ // Reactive prop binding — parent can drive value via signals. See #1704.
38
+ const inputValue = useReactiveProp('value', {{ value }}, { parse: (v) => v == null ? '' : String(v) })
38
39
 
39
40
  function onInput(event) {
40
41
  inputValue.set(event.target.value)
@@ -45,7 +45,8 @@ export const circularBarColor = color === 'primary' ? 'stroke-blue-600 dark:stro
45
45
  const max = {{ max }}
46
46
  const indeterminate = {{ indeterminate }}
47
47
  const circumference = {{ circumference }}
48
- const value = state({{ value }})
48
+ // Reactive prop binding — parent can drive `value` via signals. See #1704.
49
+ const value = useReactiveProp('value', {{ value }})
49
50
 
50
51
  const percentage = derived(() => Math.min(Math.max((value() / max) * 100, 0), 100))
51
52
  const percentLabel = derived(() => `${Math.round(percentage())}%`)
@@ -25,7 +25,8 @@ export const inputId = `radio-${value || label}`
25
25
  const emit = defineEmits()
26
26
  const isDisabled = {{ disabled }}
27
27
  const radioValue = {{ value }}
28
- const isChecked = state({{ checked }})
28
+ // Reactive prop binding — parent can drive `checked` via signals. See #1704.
29
+ const isChecked = useReactiveProp('checked', {{ checked }})
29
30
 
30
31
  function onInputChange(event) {
31
32
  if (isDisabled) {
@@ -27,7 +27,8 @@ export const selectId = `select-${value}`
27
27
 
28
28
  <script client>
29
29
  const emit = defineEmits()
30
- const selectedValue = state({{ value }})
30
+ // Reactive prop binding — parent can drive `value` via signals. See #1704.
31
+ const selectedValue = useReactiveProp('value', {{ value }}, { parse: (v) => v == null ? '' : String(v) })
31
32
 
32
33
  function onChange(event) {
33
34
  selectedValue.set(event.target.value)
@@ -4,10 +4,19 @@ export const collapsed = $props.collapsed ?? false
4
4
  export const collapsible = $props.collapsible ?? true
5
5
  export const width = $props.width || 240
6
6
  export const minWidth = $props.minWidth || 64
7
+ export const collapseMode = $props.collapseMode || 'rail'
7
8
  export const variant = $props.variant || 'tahoe'
8
9
  export const position = $props.position || 'left'
9
10
  export const placement = $props.placement || 'fixed'
10
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
+ export const persistKey = $props.persistKey || ''
16
+ export const shellSelector = $props.shellSelector || ''
17
+ export const widthVar = $props.widthVar || '--stx-sidebar-width'
18
+ export const collapsedClass = $props.collapsedClass || ''
19
+ export const nativeMaterialCollapse = $props.nativeMaterialCollapse ?? (variant === 'desktop' && collapseMode === 'hidden')
11
20
  export const className = $props.className || ''
12
21
 
13
22
  const variantStyles = {
@@ -44,13 +53,13 @@ const variantStyles = {
44
53
  slot: 'px-5 py-5',
45
54
  },
46
55
  desktop: {
47
- bg: 'bg-white/42 dark:bg-black/32',
48
- border: 'border-white/30 dark:border-white/10',
49
- backdrop: 'backdrop-blur-3xl backdrop-saturate-200',
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',
50
59
  tint: false,
51
60
  shimmer: true,
52
- nav: 'px-4 py-4 space-y-1',
53
- slot: 'px-4 py-4',
61
+ nav: 'px-5 py-2 space-y-1',
62
+ slot: 'px-5 py-2',
54
63
  },
55
64
  }
56
65
 
@@ -77,7 +86,9 @@ export const containerClasses = [
77
86
  className,
78
87
  ].filter(Boolean).join(' ')
79
88
 
80
- export const collapseBtnPosition = position === 'left' ? 'right-2' : 'left-2'
89
+ export const collapseBtnPosition = variant === 'desktop'
90
+ ? (position === 'left' ? 'left-[166px]' : 'right-[166px]')
91
+ : (position === 'left' ? 'right-2' : 'left-2')
81
92
  export const navClasses = style.nav || 'px-3 py-2 space-y-1'
82
93
  export const slotClasses = style.slot || 'px-3 py-2'
83
94
  </script>
@@ -88,9 +99,18 @@ const collapsedProp = {{ collapsed }}
88
99
  const collapsibleProp = {{ collapsible }}
89
100
  const widthProp = {{ width }}
90
101
  const minWidthProp = {{ minWidth }}
102
+ const collapseModeProp = {{ collapseMode }}
91
103
  const sectionsProp = {{ sections }}
92
104
  const positionProp = {{ position }}
93
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 }}
94
114
 
95
115
  const collapsed = state(collapsedProp)
96
116
  const isHovered = state(false)
@@ -98,14 +118,45 @@ const hasNativeSidebar = state(false)
98
118
 
99
119
  const showExpanded = derived(() => !collapsed() || isHovered())
100
120
  const sidebarWidth = derived(() => {
121
+ if (collapsed() && collapseModeProp === 'hidden') return '0px'
101
122
  if (collapsed() && !isHovered()) return `${minWidthProp}px`
102
123
  return `${widthProp}px`
103
124
  })
104
125
  const collapseAriaLabel = derived(() => collapsed() ? 'Expand sidebar' : 'Collapse sidebar')
105
- const collapseIconClass = derived(() => collapsed() ? 'i-hugeicons-sidebar-right' : 'i-hugeicons-sidebar-left')
126
+
127
+ function applyCollapseEffects(nextCollapsed) {
128
+ if (persistKeyProp) {
129
+ try {
130
+ window.localStorage?.setItem(persistKeyProp, nextCollapsed ? 'true' : 'false')
131
+ }
132
+ catch {}
133
+ }
134
+
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'
139
+ })
140
+ }
141
+
142
+ document.documentElement.dataset.stxSidebarCollapsed = nextCollapsed ? 'true' : 'false'
143
+ if (collapsedClassProp) {
144
+ document.documentElement.classList.toggle(collapsedClassProp, nextCollapsed)
145
+ }
146
+
147
+ if (nativeMaterialCollapseProp) {
148
+ window.craft?.window?.setWebSidebarCollapsed?.(nextCollapsed).catch?.(() => {})
149
+ }
150
+ }
151
+
152
+ function setCollapsed(nextCollapsed, shouldEmit = true) {
153
+ collapsed.set(nextCollapsed)
154
+ applyCollapseEffects(nextCollapsed)
155
+ if (shouldEmit) emit('collapse', nextCollapsed)
156
+ }
106
157
 
107
158
  function onMouseEnter() {
108
- if (collapsed() && collapsibleProp) isHovered.set(true)
159
+ if (collapsed() && collapsibleProp && collapseModeProp !== 'hidden') isHovered.set(true)
109
160
  }
110
161
 
111
162
  function onMouseLeave() {
@@ -113,8 +164,7 @@ function onMouseLeave() {
113
164
  }
114
165
 
115
166
  function toggleCollapse() {
116
- collapsed.set(!collapsed())
117
- emit('collapse', collapsed())
167
+ setCollapsed(!collapsed())
118
168
  }
119
169
 
120
170
  function onSectionToggle(sectionId) {
@@ -125,22 +175,46 @@ function onItemClick(event) {
125
175
  emit('itemClick', event.detail)
126
176
  }
127
177
 
178
+ function isCraftRuntime() {
179
+ return window.__craftNativeSidebar === true
180
+ || window.craft?.__craft_bridge_loaded === true
181
+ || !!window.craft?.window
182
+ || !!window.webkit?.messageHandlers?.craft
183
+ }
184
+
128
185
  onMount(async () => {
129
- hasNativeSidebar.set(
130
- window.__craftNativeSidebar === true
131
- || window.craft?.hasNativeSidebar?.()
132
- || (window.webkit?.messageHandlers?.craft && document.body?.dataset?.nativeSidebar === 'true'),
133
- )
134
- if (window.craft?.isCraft?.() && window.craft?.components?.createSidebar) {
186
+ if (persistKeyProp) {
187
+ try {
188
+ const saved = window.localStorage?.getItem(persistKeyProp)
189
+ if (saved === 'true' || saved === 'false') collapsed.set(saved === 'true')
190
+ }
191
+ catch {}
192
+ }
193
+
194
+ applyCollapseEffects(collapsed())
195
+
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
+ }
200
+
201
+ hasNativeSidebar.set(isCraftRuntime() && window.craft?.hasNativeSidebar?.() === true)
202
+
203
+ const nativeSidebarApi = window.craft?.nativeUI || window.craft?.components
204
+ if (isCraftRuntime() && nativeSidebarApi?.createSidebar) {
135
205
  try {
136
- await window.craft.components.createSidebar({
206
+ const sidebar = await nativeSidebarApi.createSidebar({
137
207
  position: positionProp,
138
208
  width: widthProp,
139
209
  minWidth: minWidthProp,
210
+ collapseMode: collapseModeProp,
140
211
  collapsible: collapsibleProp,
141
212
  variant: variantProp,
142
213
  material: variantProp === 'desktop' ? 'sidebar' : undefined,
143
- backgroundEffect: variantProp === 'desktop' ? 'shimmer' : undefined,
214
+ backgroundEffect: variantProp === 'desktop' ? 'vibrancy' : undefined,
215
+ materialOpacity: materialOpacityProp,
216
+ materialDarkOpacity: materialDarkOpacityProp,
217
+ materialScheme: materialSchemeProp,
144
218
  allowsVibrancy: variantProp === 'desktop' || variantProp === 'vibrancy' || variantProp === 'tahoe',
145
219
  sections: sectionsProp.map(s => ({
146
220
  id: s.id,
@@ -148,6 +222,7 @@ onMount(async () => {
148
222
  items: s.items?.map(i => ({ id: i.id, label: i.label, icon: i.icon, href: i.href, badge: i.badge })),
149
223
  })),
150
224
  })
225
+ if (sidebar) hasNativeSidebar.set(true)
151
226
  }
152
227
  catch (err) {
153
228
  console.warn('[Sidebar] Failed to create native sidebar, using web fallback:', err)
@@ -161,7 +236,10 @@ defineExpose({ collapsed, toggle: toggleCollapse })
161
236
  <aside
162
237
  :show="!hasNativeSidebar()"
163
238
  class="{{ containerClasses }} sidebar-container"
164
- :style="`width: ${sidebarWidth()}`"
239
+ data-sidebar-variant="{{ variant }}"
240
+ data-sidebar-material-scheme="{{ materialScheme }}"
241
+ :aria-hidden="String(collapsed() && collapseModeProp === 'hidden')"
242
+ :style="`width: ${sidebarWidth()}; --stx-sidebar-material-opacity: ${materialOpacityProp}; --stx-sidebar-material-dark-opacity: ${materialDarkOpacityProp}`"
165
243
  @mouseenter="onMouseEnter()"
166
244
  @mouseleave="onMouseLeave()"
167
245
  role="navigation"
@@ -171,7 +249,7 @@ defineExpose({ collapsed, toggle: toggleCollapse })
171
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>
172
250
  @endif
173
251
  @if(variantShimmer)
174
- <div class="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_18%_12%,rgba(255,255,255,0.46),transparent_28%),linear-gradient(120deg,rgba(255,255,255,0.22),rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18))] dark:bg-[radial-gradient(circle_at_18%_12%,rgba(255,255,255,0.12),transparent_30%),linear-gradient(120deg,rgba(255,255,255,0.10),rgba(255,255,255,0.02)_45%,rgba(255,255,255,0.08))]"></div>
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>
175
253
  @endif
176
254
 
177
255
  @if($slots.header)
@@ -213,13 +291,15 @@ defineExpose({ collapsed, toggle: toggleCollapse })
213
291
 
214
292
  @if(collapsible)
215
293
  <button
216
- :show="showExpanded()"
217
294
  type="button"
218
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"
219
296
  @click="toggleCollapse()"
220
297
  :aria-label="collapseAriaLabel()"
298
+ data-sidebar-collapse
299
+ data-stx-sidebar-collapse-button
221
300
  >
222
- <div :class="`${collapseIconClass()} w-5 h-5`"></div>
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>
223
303
  </button>
224
304
  @endif
225
305
  </aside>
@@ -229,7 +309,21 @@ defineExpose({ collapsed, toggle: toggleCollapse })
229
309
  .overflow-y-auto::-webkit-scrollbar-track { background: transparent; }
230
310
  .overflow-y-auto::-webkit-scrollbar-thumb { background-color: rgba(0, 0, 0, 0.15); border-radius: 3px; }
231
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);
316
+ }
317
+ html.has-native-sidebar [data-stx-native-window-controls] {
318
+ display: none;
319
+ }
320
+ html.has-native-sidebar [data-stx-sidebar-web-chrome] {
321
+ margin-left: 202px;
322
+ }
232
323
  @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));
326
+ }
233
327
  .overflow-y-auto::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.15); }
234
328
  .overflow-y-auto::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, 0.25); }
235
329
  }