@stacksjs/components 0.2.118 → 0.2.120

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,12 +1,25 @@
1
1
  <script server>
2
+ import { normalizeSpaces, spaceIndexOf } from './spaces'
2
3
  import { resolveSidebarTheme } from './themes'
3
4
 
4
5
  // Data: [{ id, label, collapsible?, collapsed?, items: [{ id, label, icon,
5
6
  // iconColor, image, href, count, active?, disabled?, expanded?, children? }] }]
6
7
  export const sections = $props.sections || []
7
8
 
9
+ // Arc-style spaces: [{ id, label, icon, tint, pinned, sections, action, clear }].
10
+ // Passing them turns the sidebar into a swipeable stack of scenes instead of
11
+ // one list. `theme` then defaults to `arc`, because spaces are that theme's
12
+ // idea — a macOS source list has no equivalent.
13
+ export const spaces = $props.spaces || []
14
+ export const hasSpaces = spaces.length > 0
15
+ export const activeSpace = $props.space || $props.activeSpace || ''
16
+ export const showSpaceSwitcher = $props.showSpaceSwitcher ?? true
17
+ export const showSpaceAdd = $props.showSpaceAdd ?? false
18
+ export const spaceAddLabel = $props.spaceAddLabel || 'New space'
19
+ export const spacePersistKey = $props.spacePersistKey || ''
20
+
8
21
  // `theme` is the new name; `variant` is accepted for backwards compatibility.
9
- export const theme = $props.theme || $props.variant || 'macos'
22
+ export const theme = $props.theme || $props.variant || (hasSpaces ? 'arc' : 'macos')
10
23
  export const width = $props.width || 250
11
24
  export const position = $props.position || 'left'
12
25
  export const placement = $props.placement || 'fixed'
@@ -24,6 +37,14 @@ export const widthVar = $props.widthVar || '--stx-sidebar-width'
24
37
  export const collapsedClass = $props.collapsedClass || ''
25
38
  export const className = $props.className || ''
26
39
 
40
+ // Mirror the OS light/dark preference onto the root element's `dark` class.
41
+ // A native sidebar follows the system appearance, and Crosswind's `dark:`
42
+ // variants are class-based, so something has to write that class — but it is
43
+ // the document's business, not a navigation pane's. Left on by default so a
44
+ // sidebar dropped into a page with no theme controller still behaves; turn it
45
+ // off in any app that owns its own light/dark switch.
46
+ export const followSystemAppearance = $props.followSystemAppearance ?? true
47
+
27
48
  // Unique id so the client controller can find this pane even with several
28
49
  // sidebars on the page.
29
50
  export const sidebarUid = $props.id || `stx-sidebar-${Math.random().toString(36).slice(2, 8)}`
@@ -51,6 +72,19 @@ export const paneClasses = [
51
72
  className,
52
73
  ].filter(Boolean).join(' ')
53
74
 
75
+ // The pane carries its own width and, for spaces, the active space's palette.
76
+ // Both are server-rendered rather than bound reactively so the sidebar is the
77
+ // right size and the right color in the very first paint, before any client
78
+ // script runs. The controller keeps `width` in step afterwards.
79
+ const initialWidth = collapsed ? (collapseMode === 'hidden' ? 0 : minWidth) : width
80
+ const normalizedSpaces = hasSpaces ? normalizeSpaces(spaces) : []
81
+ const initialSpace = normalizedSpaces[spaceIndexOf(normalizedSpaces, activeSpace)] || null
82
+
83
+ export const paneStyle = [
84
+ `width: ${initialWidth}px`,
85
+ initialSpace ? initialSpace.style : '',
86
+ ].filter(Boolean).join('; ')
87
+
54
88
  // Normalized ahead of the loop so template bindings stay simple accessors.
55
89
  export const sectionList = sections.map(section => ({
56
90
  id: section.id,
@@ -70,16 +104,17 @@ export const activeClasses = themeStyle.item.active
70
104
  import { findActiveSidebarHref } from './route-selection'
71
105
 
72
106
  const emit = defineEmits()
73
- const sidebarUidProp = '{{ sidebarUid }}'
107
+ const componentRoot = window.__STX_CURRENT_ELEMENT__
74
108
  const collapsedProp = {{ collapsed }}
75
109
  const collapsibleProp = {{ collapsible }}
76
- const collapseModeProp = '{{ collapseMode }}'
110
+ const collapseModeProp = {{ collapseMode }}
77
111
  const widthProp = {{ width }}
78
112
  const minWidthProp = {{ minWidth }}
79
- const persistKeyProp = '{{ persistKey }}'
80
- const shellSelectorProp = '{{ shellSelector }}'
81
- const widthVarProp = '{{ widthVar }}'
82
- const collapsedClassProp = '{{ collapsedClass }}'
113
+ const persistKeyProp = {{ persistKey }}
114
+ const shellSelectorProp = {{ shellSelector }}
115
+ const widthVarProp = {{ widthVar }}
116
+ const collapsedClassProp = {{ collapsedClass }}
117
+ const followSystemAppearanceProp = {{ followSystemAppearance }}
83
118
 
84
119
  const collapsed = state(collapsedProp)
85
120
  const sidebarWidth = derived(() => {
@@ -162,7 +197,10 @@ function toggleRowDisclosure(row) {
162
197
 
163
198
  function toggleSection(section) {
164
199
  const expanded = section.getAttribute('data-expanded') !== 'false'
165
- section.setAttribute('data-expanded', String(!expanded))
200
+ const nextExpanded = !expanded
201
+ section.setAttribute('data-expanded', String(nextExpanded))
202
+ section.querySelector('[data-sidebar-section-header]')
203
+ ?.setAttribute('aria-expanded', String(nextExpanded))
166
204
  syncRowVisibility()
167
205
  emit('sectionToggle', section.dataset.sectionId)
168
206
  }
@@ -320,6 +358,20 @@ function trackKeyWindow() {
320
358
  }
321
359
 
322
360
  function followSystemAppearance() {
361
+ // Writing the `dark` class puts a navigation component in charge of the
362
+ // whole document's color scheme, which is only defensible while nothing
363
+ // else wants the job. Two ways to say it does:
364
+ //
365
+ // `follow-system-appearance="false"` — typed, discoverable, and the one to
366
+ // reach for. An app with its own light/dark control wants this.
367
+ // `data-theme` on the root — the runtime signal, for apps that decide at
368
+ // load time. Re-checked on every sync, so claiming it later also works.
369
+ //
370
+ // Before the prop existed only the second was available, which meant an app
371
+ // that set `.dark` itself watched the sidebar quietly undo it on mount, with
372
+ // nothing in the component's API to suggest why.
373
+ if (!followSystemAppearanceProp) return
374
+
323
375
  const media = window.matchMedia('(prefers-color-scheme: dark)')
324
376
  function sync() {
325
377
  if (document.documentElement.dataset.theme) return
@@ -330,7 +382,7 @@ function followSystemAppearance() {
330
382
  }
331
383
 
332
384
  onMount(() => {
333
- pane = document.getElementById(sidebarUidProp)
385
+ pane = componentRoot?.querySelector('[data-stx-sidebar]')
334
386
  if (!pane) return
335
387
 
336
388
  followSystemAppearance()
@@ -344,6 +396,13 @@ onMount(() => {
344
396
  }
345
397
  applyCollapseEffects(collapsed())
346
398
 
399
+ // Width is server-rendered onto the pane so it is correct in the first
400
+ // paint; from here on the effect keeps it in step with the collapse state.
401
+ // (A reactive `:style` binding would have owned the whole attribute, which
402
+ // would mean no width until hydration and would wipe the space palette
403
+ // rendered alongside it.)
404
+ effect(() => { pane.style.width = sidebarWidth() })
405
+
347
406
  pane.addEventListener('click', onClick)
348
407
  pane.addEventListener('keydown', onKeyDown)
349
408
  window.addEventListener('stx:navigate', onRouteChange)
@@ -372,13 +431,14 @@ defineExpose({ collapsed, toggle: toggleCollapse, syncRouteSelection })
372
431
  data-window-key="true"
373
432
  data-active-class="{{ activeClasses }}"
374
433
  class="{{ paneClasses }}"
375
- :style="`width: ${sidebarWidth()}`"
434
+ style="{{ paneStyle }}"
435
+ @if(hasSpaces)data-sidebar-spaces="true"@endif
376
436
  :aria-hidden="String(collapsed() && collapseModeProp === 'hidden')"
377
437
  role="navigation"
378
438
  aria-label="Sidebar"
379
439
  tabindex="0"
380
440
  >
381
- @if(tintClasses)
441
+ @if(tintClasses && !hasSpaces)
382
442
  <div class="absolute inset-0 pointer-events-none {{ tintClasses }}"></div>
383
443
  @endif
384
444
  @if(hasShimmerRim)
@@ -391,27 +451,41 @@ defineExpose({ collapsed, toggle: toggleCollapse, syncRouteSelection })
391
451
  </div>
392
452
  @endif
393
453
 
394
- <div class="relative z-10 min-h-0 flex-1">
395
- <div data-sidebar-scroll class="{{ scrollAreaClasses }} h-full stx-sidebar-scroll">
396
- @if(sectionList.length > 0)
397
- <nav class="flex flex-col">
398
- @foreach(sectionList as section)
399
- <SidebarSection
400
- :id="section.id"
401
- :label="section.label"
402
- :items="section.items"
403
- :collapsible="section.collapsible"
404
- :collapsed="section.collapsed"
405
- :theme="theme"
406
- />
407
- @endforeach
408
- </nav>
409
- @else
410
- <slot />
411
- @endif
454
+ @if(hasSpaces)
455
+ <div class="stx-sidebar-spaces-slot relative z-10 flex min-h-0 flex-1 flex-col">
456
+ <SidebarSpaces
457
+ :spaces="spaces"
458
+ :space="activeSpace"
459
+ :theme="theme"
460
+ :showSwitcher="showSpaceSwitcher"
461
+ :showAdd="showSpaceAdd"
462
+ :addLabel="spaceAddLabel"
463
+ :persistKey="spacePersistKey"
464
+ />
412
465
  </div>
413
- <div data-sidebar-scroll-thumb class="stx-sidebar-scroll-thumb" aria-hidden="true"></div>
414
- </div>
466
+ @else
467
+ <div class="relative z-10 min-h-0 flex-1">
468
+ <div data-sidebar-scroll class="{{ scrollAreaClasses }} h-full stx-sidebar-scroll">
469
+ @if(sectionList.length > 0)
470
+ <nav class="flex flex-col">
471
+ @foreach(sectionList as section)
472
+ <SidebarSection
473
+ :id="section.id"
474
+ :label="section.label"
475
+ :items="section.items"
476
+ :collapsible="section.collapsible"
477
+ :collapsed="section.collapsed"
478
+ :theme="theme"
479
+ />
480
+ @endforeach
481
+ </nav>
482
+ @else
483
+ <slot />
484
+ @endif
485
+ </div>
486
+ <div data-sidebar-scroll-thumb class="stx-sidebar-scroll-thumb" aria-hidden="true"></div>
487
+ </div>
488
+ @endif
415
489
 
416
490
  @if($slots.footer)
417
491
  <div class="relative z-10 flex-shrink-0">
@@ -537,13 +611,91 @@ defineExpose({ collapsed, toggle: toggleCollapse, syncRouteSelection })
537
611
  color: var(--stx-sidebar-icon-idle);
538
612
  }
539
613
 
614
+ /* ---------------------------------------------------------------------------
615
+ * Spaces — the `arc` theme's swipeable scenes.
616
+ *
617
+ * The panel takes the active space's color, and switching spaces *crossfades*
618
+ * it rather than cutting. That crossfade is why the four properties below are
619
+ * registered with `@property`: an unregistered custom property is a token the
620
+ * browser cannot interpolate, so `transition` on it does nothing. Typed as
621
+ * `<color>`, they animate like any other color.
622
+ *
623
+ * Each space publishes both appearances as inline variables (SidebarSpaces
624
+ * writes them on swipe, the server writes the first one), and these rules pick
625
+ * between them. Keeping the choice in CSS is what lets a dark-mode space
626
+ * render correctly server-side, before any script runs.
627
+ * ------------------------------------------------------------------------- */
628
+ @property --stx-space-from { syntax: "<color>"; inherits: true; initial-value: #f2f0ec; }
629
+ @property --stx-space-to { syntax: "<color>"; inherits: true; initial-value: #e6e3dd; }
630
+ @property --stx-space-ink { syntax: "<color>"; inherits: true; initial-value: #2c2a28; }
631
+ @property --stx-space-accent { syntax: "<color>"; inherits: true; initial-value: #5b5b63; }
632
+
633
+ /* The doubled attribute is for specificity, not for matching: the theme's own
634
+ `dark:bg-…` / `dark:text-…` utilities compile to `.dark .x`, which ties with
635
+ a single attribute plus the scope marker. A tie would leave the outcome to
636
+ stylesheet order — true today, but not something the space color should
637
+ depend on. */
638
+
639
+ [data-sidebar-spaces="true"][data-stx-sidebar] {
640
+ --stx-space-from: var(--stx-space-light-from, #f2f0ec);
641
+ --stx-space-to: var(--stx-space-light-to, #e6e3dd);
642
+ --stx-space-ink: var(--stx-space-light-ink, #2c2a28);
643
+ --stx-space-accent: var(--stx-space-light-accent, #5b5b63);
644
+ /* Two layers: a sheen falling off over the top third, then the space color.
645
+ The sheen is what keeps a saturated space from reading as a flat swatch. */
646
+ background:
647
+ linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
648
+ linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
649
+ color: var(--stx-space-ink);
650
+ /* Width is listed here because this rule outranks the pane's
651
+ `transition-[width]` utility class and would otherwise drop it. */
652
+ transition:
653
+ --stx-space-from 420ms ease,
654
+ --stx-space-to 420ms ease,
655
+ --stx-space-ink 420ms ease,
656
+ --stx-space-accent 420ms ease,
657
+ width 300ms ease-out;
658
+ }
659
+
660
+ @media (prefers-color-scheme: dark) {
661
+ [data-sidebar-spaces="true"][data-stx-sidebar] {
662
+ --stx-space-from: var(--stx-space-dark-from, #17171b);
663
+ --stx-space-to: var(--stx-space-dark-to, #0e0e11);
664
+ --stx-space-ink: var(--stx-space-dark-ink, #ededf0);
665
+ --stx-space-accent: var(--stx-space-dark-accent, #b9b9c2);
666
+ background:
667
+ linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 30%),
668
+ linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
669
+ }
670
+ }
671
+
672
+ @media (prefers-reduced-motion: reduce) {
673
+ [data-sidebar-spaces="true"][data-stx-sidebar] { transition: none; }
674
+ }
675
+
676
+ /* A component carrying a client script renders inside a scope wrapper, and
677
+ that wrapper is a plain block we cannot put classes on from here — so the
678
+ spaces root's own `flex-1` sizes itself against the wrapper instead of
679
+ against this column, and the switcher rail floats up under the last row
680
+ rather than sitting on the floor of the pane. Collapsing the wrapper out of
681
+ layout makes the spaces root the flex item, which is what it was written to
682
+ be. The selector deliberately spares the root itself, for the case where
683
+ there is no wrapper at all. */
684
+ .stx-sidebar-spaces-slot > *:not([data-stx-sidebar-spaces]):not(script):not(style) {
685
+ display: contents;
686
+ }
687
+
540
688
  /* Rows collapse like AppKit outlines: fixed row height animates to zero.
541
689
  The 32px pitch is the row itself — AppKit leaves no gap, so a run of
542
- selected or hovered rows reads as one continuous block. */
690
+ selected or hovered rows reads as one continuous block. Arc's rows are
691
+ discrete cards with their own gutter, so the theme overrides the pitch. */
543
692
  [data-sidebar-row] {
544
- height: 32px;
693
+ height: var(--stx-sidebar-row-h, 32px);
545
694
  transition: height 0.22s ease, opacity 0.16s ease;
546
695
  }
696
+ [data-sidebar-theme="arc"] {
697
+ --stx-sidebar-row-h: 30px;
698
+ }
547
699
  [data-sidebar-row][data-collapsed] {
548
700
  height: 0;
549
701
  opacity: 0;
@@ -24,6 +24,26 @@ export const actionClasses = [
24
24
  'transition-colors duration-150',
25
25
  'hover:bg-black/5 dark:hover:bg-white/10',
26
26
  ].join(' ')
27
+
28
+ // Arc keeps the footer to a quiet strip of ghost icons, tuned to the warm
29
+ // panel rather than the macOS neutral.
30
+ export const isArc = theme === 'arc'
31
+
32
+ // Like the rest of the Arc chrome, these take their color from the panel so
33
+ // they follow a space's ink instead of staying a fixed dark on top of it.
34
+ export const arcActionClasses = [
35
+ 'grid h-[28px] w-[28px] place-items-center rounded-[7px]',
36
+ 'opacity-50 transition-[background-color,opacity] duration-150',
37
+ 'hover:bg-black/[0.05] hover:opacity-100 dark:hover:bg-white/10',
38
+ ].join(' ')
39
+
40
+ export const arcProfileClasses = [
41
+ 'flex w-full items-center gap-[8px]',
42
+ 'h-[38px] rounded-[8px] px-[6px]',
43
+ 'text-[13px] font-medium',
44
+ 'transition-colors duration-150',
45
+ 'hover:bg-black/[0.05] dark:hover:bg-white/8',
46
+ ].join(' ')
27
47
  </script>
28
48
 
29
49
  <script client>
@@ -64,6 +84,32 @@ function onAction(event) {
64
84
  @endif
65
85
  <slot />
66
86
  </div>
87
+ @elseif(isArc)
88
+ <div class="shrink-0 px-[10px] pb-[10px] pt-[4px]">
89
+ @if(name)
90
+ <button type="button" class="{{ arcProfileClasses }}" @click="onProfileClick()">
91
+ @if(avatar)
92
+ <img src="{{ avatar }}" alt="" class="h-[24px] w-[24px] rounded-full object-cover" />
93
+ @else
94
+ <span class="grid h-[24px] w-[24px] place-items-center rounded-full bg-black/8 text-[12px] font-semibold dark:bg-white/12">{{ name.charAt(0) }}</span>
95
+ @endif
96
+ <span class="min-w-0 flex-1 truncate text-left">{{ name }}</span>
97
+ @if(detail)
98
+ <span class="shrink-0 text-[12px] opacity-45">{{ detail }}</span>
99
+ @endif
100
+ </button>
101
+ @endif
102
+ @if(actions.length > 0)
103
+ <div class="flex items-center gap-[2px]">
104
+ @foreach(actions as action)
105
+ <button type="button" class="{{ arcActionClasses }}" aria-label="{{ action.label }}" data-sidebar-action="{{ action.id }}" @click="onAction($event)">
106
+ <span class="{{ action.icon }} h-[15px] w-[15px]" aria-hidden="true"></span>
107
+ </button>
108
+ @endforeach
109
+ </div>
110
+ @endif
111
+ <slot />
112
+ </div>
67
113
  @else
68
114
  <div class="shrink-0 border-t border-black/5 p-3 dark:border-white/10">
69
115
  <slot />
@@ -3,7 +3,6 @@
3
3
  // the left, floating "Liquid Glass" toolbar buttons on the right, and the
4
4
  // whole strip acts as a window drag region inside a Craft window.
5
5
  export const theme = $props.theme || $props.variant || 'macos'
6
- export const showWindowControls = $props.showWindowControls ?? true
7
6
  export const actions = $props.actions || [] // [{ id, icon, label }]
8
7
  export const title = $props.title || ''
9
8
  export const subtitle = $props.subtitle || ''
@@ -28,6 +27,45 @@ export const toolbarButtonClasses = [
28
27
  'hover:bg-white/80 dark:hover:bg-white/20',
29
28
  'active:bg-white/95 dark:active:bg-white/25',
30
29
  ].join(' ')
30
+
31
+ // Arc's header is quieter than macOS'. No floating glass buttons — just a
32
+ // recessed search field and flat ghost icons, sized to sit on the panel
33
+ // without competing with the row cards below it.
34
+ export const isArc = theme === 'arc'
35
+
36
+ // Window chrome. A macOS sidebar owns the traffic lights, so they are on by
37
+ // default there. Arc's sidebar can go either way — a browser window puts the
38
+ // lights and the back/forward controls in the sidebar, while an app that only
39
+ // borrows the look wants a bare strip — so on this theme both are opt-in.
40
+ export const showWindowControls = $props.showWindowControls ?? !isArc
41
+ export const showNavigation = $props.showNavigation ?? false
42
+
43
+ // Back / forward / reload, as in Arc's URL strip. Flat and ghosted, not glass.
44
+ export const navButtons = [
45
+ { id: 'back', icon: 'i-f7-arrow-left', label: 'Back' },
46
+ { id: 'forward', icon: 'i-f7-arrow-right', label: 'Forward' },
47
+ { id: 'reload', icon: 'i-f7-arrow-clockwise', label: 'Reload' },
48
+ ]
49
+
50
+ // Arc's chrome takes its color from the panel rather than declaring its own.
51
+ // On a plain warm panel that resolves to the theme's ink; inside a space it
52
+ // follows `--stx-space-ink` and crossfades along with the panel on every
53
+ // swipe. A hardcoded `text-[#2c2a28]` would stay dark over a saturated space
54
+ // and would not move when the space changed.
55
+ export const arcSearchClasses = [
56
+ 'h-[30px] w-full rounded-[8px] border-0',
57
+ 'bg-black/[0.045] dark:bg-white/[0.07]',
58
+ 'pl-[28px] pr-[8px]',
59
+ 'text-[13px] text-current',
60
+ 'placeholder:text-current placeholder:opacity-40',
61
+ 'focus:outline-none focus:ring-2 focus:ring-black/10 dark:focus:ring-white/15',
62
+ ].join(' ')
63
+
64
+ export const arcActionClasses = [
65
+ 'grid h-[28px] w-[28px] place-items-center rounded-[7px]',
66
+ 'opacity-55 transition-[background-color,opacity] duration-150',
67
+ 'hover:bg-black/[0.05] hover:opacity-100 dark:hover:bg-white/10',
68
+ ].join(' ')
31
69
  </script>
32
70
 
33
71
  <script client>
@@ -85,6 +123,61 @@ function onWindowControl(action) {
85
123
  </div>
86
124
  </div>
87
125
  @endif
126
+ @elseif(isArc)
127
+ <div class="flex flex-col gap-[8px] px-[10px] pb-[8px] pt-[12px]">
128
+ @if(showWindowControls || showNavigation)
129
+ <div class="flex items-center px-[2px] [-webkit-app-region:drag]">
130
+ @if(showWindowControls)
131
+ <div class="flex items-center gap-[7px] [-webkit-app-region:no-drag]">
132
+ <button type="button" class="{{ trafficLightClasses }} bg-[#ff5f57]" aria-label="Close window" @click="onWindowControl('close')"></button>
133
+ <button type="button" class="{{ trafficLightClasses }} bg-[#febc2e]" aria-label="Minimize window" @click="onWindowControl('minimize')"></button>
134
+ <button type="button" class="{{ trafficLightClasses }} bg-[#28c840]" aria-label="Zoom window" @click="onWindowControl('zoom')"></button>
135
+ </div>
136
+ @endif
137
+ <span class="flex-1"></span>
138
+ @if(showNavigation)
139
+ <div class="flex items-center gap-[2px] [-webkit-app-region:no-drag]">
140
+ @foreach(navButtons as nav)
141
+ <button type="button" class="{{ arcActionClasses }}" aria-label="{{ nav.label }}" data-sidebar-action="{{ nav.id }}" @click="onAction($event)">
142
+ <span class="{{ nav.icon }} h-[14px] w-[14px]" aria-hidden="true"></span>
143
+ </button>
144
+ @endforeach
145
+ </div>
146
+ @endif
147
+ </div>
148
+ @endif
149
+
150
+ @if(title || logo || actions.length > 0)
151
+ <div class="flex items-center gap-[8px]">
152
+ @if(logo)
153
+ <img src="{{ logo }}" alt="{{ title }}" class="h-[20px] w-[20px] rounded-[6px] object-cover" />
154
+ @endif
155
+ @if(title)
156
+ <span class="min-w-0 flex-1 truncate text-[13px] font-medium">{{ title }}</span>
157
+ @else
158
+ <span class="flex-1"></span>
159
+ @endif
160
+ @foreach(actions as action)
161
+ <button type="button" class="{{ arcActionClasses }}" aria-label="{{ action.label }}" data-sidebar-action="{{ action.id }}" @click="onAction($event)">
162
+ <span class="{{ action.icon }} h-[15px] w-[15px]" aria-hidden="true"></span>
163
+ </button>
164
+ @endforeach
165
+ <slot name="actions" />
166
+ </div>
167
+ @endif
168
+
169
+ @if(showSearch)
170
+ <div class="relative">
171
+ <span class="i-f7-search absolute left-[9px] top-1/2 h-[13px] w-[13px] -translate-y-1/2 opacity-40" aria-hidden="true"></span>
172
+ <input
173
+ type="text"
174
+ placeholder="{{ searchPlaceholder }}"
175
+ class="{{ arcSearchClasses }}"
176
+ @input="onSearchInput($event)"
177
+ />
178
+ </div>
179
+ @endif
180
+ </div>
88
181
  @else
89
182
  <div class="flex h-13 items-center gap-3 border-b border-black/5 px-4 dark:border-white/10">
90
183
  @if(logo)
@@ -0,0 +1,88 @@
1
+ <script server>
2
+ import { resolveIconColor } from './themes'
3
+
4
+ // Arc's favorites grid — the block of square tiles above a space's rows.
5
+ //
6
+ // These are deliberately NOT sidebar rows. They carry no `data-sidebar-item`,
7
+ // so the Sidebar controller's selection, arrow-key navigation and disclosure
8
+ // logic skip them entirely: a pinned tile is a shortcut, not a place in the
9
+ // list. Their own click handler emits `pinnedClick` and that is the whole
10
+ // contract.
11
+ export const items = ($props.items || []).map(item => ({
12
+ id: item.id,
13
+ label: item.label || '',
14
+ icon: item.icon || '',
15
+ image: item.image || '',
16
+ href: item.href || '',
17
+ iconStyle: resolveIconColor(item.iconColor) ? `color: ${resolveIconColor(item.iconColor)}` : '',
18
+ }))
19
+
20
+ export const columns = $props.columns || 4
21
+
22
+ // Tiles are translucent white so they pick up whichever space color sits
23
+ // behind them, rather than fighting it with a fixed fill.
24
+ export const tileClasses = [
25
+ 'grid h-[46px] place-items-center rounded-[10px]',
26
+ 'bg-white/45 dark:bg-white/8',
27
+ 'transition-[background-color,transform] duration-150 ease-out',
28
+ 'hover:bg-white/72 dark:hover:bg-white/14',
29
+ 'active:scale-[0.96]',
30
+ ].join(' ')
31
+ </script>
32
+
33
+ <script client>
34
+ const emit = defineEmits()
35
+
36
+ function onPinnedClick(event) {
37
+ const tile = event.target.closest('[data-space-pinned-item]')
38
+ if (!tile) return
39
+ emit('pinnedClick', { id: tile.dataset.pinnedId, href: tile.getAttribute('href') || '', event })
40
+ }
41
+ </script>
42
+
43
+ @if(items.length > 0)
44
+ <div
45
+ data-space-pinned
46
+ class="grid gap-[6px] px-[8px] pb-[10px] pt-[2px]"
47
+ style="grid-template-columns: repeat({{ columns }}, minmax(0, 1fr))"
48
+ role="list"
49
+ aria-label="Pinned"
50
+ @click="onPinnedClick($event)"
51
+ >
52
+ @foreach(items as item)
53
+ @if(item.href)
54
+ <a
55
+ href="{{ item.href }}"
56
+ data-space-pinned-item
57
+ data-pinned-id="{{ item.id }}"
58
+ class="{{ tileClasses }}"
59
+ role="listitem"
60
+ title="{{ item.label }}"
61
+ aria-label="{{ item.label }}"
62
+ >
63
+ @if(item.image)
64
+ <img src="{{ item.image }}" alt="" class="h-[22px] w-[22px] rounded-[5px] object-cover" />
65
+ @else
66
+ <span class="{{ item.icon }} h-[20px] w-[20px]" @if(item.iconStyle)style="{{ item.iconStyle }}"@endif aria-hidden="true"></span>
67
+ @endif
68
+ </a>
69
+ @else
70
+ <button
71
+ type="button"
72
+ data-space-pinned-item
73
+ data-pinned-id="{{ item.id }}"
74
+ class="{{ tileClasses }}"
75
+ role="listitem"
76
+ title="{{ item.label }}"
77
+ aria-label="{{ item.label }}"
78
+ >
79
+ @if(item.image)
80
+ <img src="{{ item.image }}" alt="" class="h-[22px] w-[22px] rounded-[5px] object-cover" />
81
+ @else
82
+ <span class="{{ item.icon }} h-[20px] w-[20px]" @if(item.iconStyle)style="{{ item.iconStyle }}"@endif aria-hidden="true"></span>
83
+ @endif
84
+ </button>
85
+ @endif
86
+ @endforeach
87
+ </div>
88
+ @endif