@ossy/app 3.2.0 → 3.4.0

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.
@@ -8,14 +8,18 @@ import {
8
8
  } from '@ossy/design-system'
9
9
  import { useRouter } from '@ossy/router-react'
10
10
  import { languageCode } from './languageCode.js'
11
+ import { compactShellControlStyle } from './mobileShellFocus.js'
11
12
 
12
13
  /**
13
14
  * Language switcher for app shell header — cycle when two locales, dropdown when more.
14
15
  */
15
- export function LanguageSelect () {
16
+ export function LanguageSelect ({ compact = false }) {
16
17
  const router = useRouter()
17
18
  const { t } = useLocale()
18
19
  const { language, supportedLanguages, getHref } = router
20
+ const controlStyle = compact
21
+ ? compactShellControlStyle()
22
+ : { flexShrink: 0 }
19
23
 
20
24
  if (!supportedLanguages?.length || supportedLanguages.length <= 1) {
21
25
  return null
@@ -32,7 +36,7 @@ export function LanguageSelect () {
32
36
  prefix="select"
33
37
  href={getHref({ language: other })}
34
38
  aria-label={pickerLabel}
35
- style={{ flexShrink: 0 }}
39
+ style={controlStyle}
36
40
  >
37
41
  {languageCode(language)}
38
42
  </Button>
@@ -46,7 +50,7 @@ export function LanguageSelect () {
46
50
  prefix="select"
47
51
  variant="link"
48
52
  aria-label={pickerLabel}
49
- style={{ flexShrink: 0 }}
53
+ style={controlStyle}
50
54
  >
51
55
  {languageCode(language)}
52
56
  </Button>
@@ -0,0 +1,180 @@
1
+ import React, { useEffect, useId, useRef } from 'react'
2
+ import { Button, Overlay, Slot, useLocale, View } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { metadata as OpenMobileShellNav } from '../shell-registry/open-mobile-shell-nav.action.js'
5
+ import { metadata as CloseMobileShellNav } from '../shell-registry/close-mobile-shell-nav.action.js'
6
+ import {
7
+ TAB_TRAP_FALLBACK,
8
+ compactShellControlStyle,
9
+ listFocusable,
10
+ mobileShellDrawerSafeAreaStyle,
11
+ resolveTabTrapTarget,
12
+ setAppShellBackgroundInert,
13
+ setMobileShellScrollLock,
14
+ } from './mobileShellFocus.js'
15
+
16
+ /**
17
+ * Compact-viewport primary nav: menu control + left drawer hosting `app:sidebar`.
18
+ * Layouts own open state so custom header slots still get a working mobile navbar.
19
+ */
20
+ export function MobileShellNav ({
21
+ open = false,
22
+ onOpenChange = () => {},
23
+ }) {
24
+ const router = useRouter()
25
+ const { t } = useLocale()
26
+ const panelId = useId()
27
+ const triggerRef = useRef(null)
28
+ const closeRef = useRef(null)
29
+ const panelRef = useRef(null)
30
+ const wasOpenRef = useRef(false)
31
+
32
+ useEffect(() => {
33
+ onOpenChange(false)
34
+ }, [router.href, onOpenChange])
35
+
36
+ useEffect(() => {
37
+ if (!open) return undefined
38
+ const onKeyDown = (event) => {
39
+ if (event.key === 'Escape') {
40
+ onOpenChange(false)
41
+ return
42
+ }
43
+ if (event.key !== 'Tab') return
44
+
45
+ const panel = panelRef.current
46
+ const focusable = listFocusable(panel)
47
+ const next = resolveTabTrapTarget({
48
+ focusable,
49
+ activeElement: document.activeElement,
50
+ containsActive: Boolean(panel?.contains?.(document.activeElement)),
51
+ shiftKey: event.shiftKey,
52
+ })
53
+
54
+ if (next === TAB_TRAP_FALLBACK) {
55
+ event.preventDefault()
56
+ closeRef.current?.focus?.()
57
+ return
58
+ }
59
+
60
+ if (next) {
61
+ event.preventDefault()
62
+ next.focus?.()
63
+ }
64
+ }
65
+ window.addEventListener('keydown', onKeyDown)
66
+ return () => window.removeEventListener('keydown', onKeyDown)
67
+ }, [open, onOpenChange])
68
+
69
+ useEffect(() => {
70
+ if (!open || typeof document === 'undefined') return undefined
71
+ const lock = setMobileShellScrollLock(document, true)
72
+ setAppShellBackgroundInert(document, true)
73
+ return () => {
74
+ setMobileShellScrollLock(document, false, {
75
+ previousOverflow: lock?.previousOverflow ?? '',
76
+ })
77
+ setAppShellBackgroundInert(document, false)
78
+ }
79
+ }, [open])
80
+
81
+ // Move focus into the drawer on open; restore it to the menu control on close.
82
+ useEffect(() => {
83
+ if (open) {
84
+ wasOpenRef.current = true
85
+ closeRef.current?.focus?.()
86
+ return undefined
87
+ }
88
+ if (wasOpenRef.current) {
89
+ wasOpenRef.current = false
90
+ // Shell inert is cleared in the scroll-lock effect cleanup before this runs.
91
+ triggerRef.current?.focus?.()
92
+ }
93
+ return undefined
94
+ }, [open])
95
+
96
+ const openLabel = t('app.shell.header.openNav') || 'Open navigation'
97
+ const closeLabel = t('app.shell.header.closeNav') || 'Close navigation'
98
+ const navLabel = t('app.shell.header.nav') || 'Primary navigation'
99
+ const touchTargetStyle = compactShellControlStyle()
100
+
101
+ return (
102
+ <>
103
+ <Button
104
+ {...OpenMobileShellNav}
105
+ ref={triggerRef}
106
+ type="button"
107
+ variant="link"
108
+ label={undefined}
109
+ prefix={OpenMobileShellNav.prefix ?? 'menu'}
110
+ aria-label={open ? closeLabel : openLabel}
111
+ aria-expanded={open}
112
+ aria-haspopup="dialog"
113
+ aria-controls={panelId}
114
+ data-ossy-mobile-shell-nav-trigger
115
+ onClick={() => onOpenChange(!open)}
116
+ style={touchTargetStyle}
117
+ />
118
+ <Overlay
119
+ isVisible={open}
120
+ onClose={() => onOpenChange(false)}
121
+ data-ossy-mobile-shell-nav-overlay
122
+ >
123
+ <View
124
+ ref={panelRef}
125
+ id={panelId}
126
+ role="dialog"
127
+ aria-modal="true"
128
+ aria-label={navLabel}
129
+ data-ossy-mobile-shell-nav
130
+ surface="primary"
131
+ style={mobileShellDrawerSafeAreaStyle({
132
+ position: 'absolute',
133
+ top: 0,
134
+ left: 0,
135
+ bottom: 0,
136
+ width: 'min(20rem, 86vw)',
137
+ maxWidth: '100%',
138
+ height: '100%',
139
+ boxSizing: 'border-box',
140
+ overflow: 'auto',
141
+ boxShadow: '4px 0 24px color-mix(in srgb, var(--text-default-color, #000) 22%, transparent)',
142
+ display: 'flex',
143
+ flexDirection: 'column',
144
+ // Theme primary is glass; over Overlay's dark scrim it reads as a muddy top bar.
145
+ // Use solid color.primary so drawer chrome stays opaque and readable.
146
+ background: 'var(--color-primary)',
147
+ backdropFilter: 'none',
148
+ WebkitBackdropFilter: 'none',
149
+ })}
150
+ >
151
+ <View
152
+ layout="row"
153
+ style={{
154
+ justifyContent: 'flex-end',
155
+ alignItems: 'center',
156
+ flexShrink: 0,
157
+ padding: 'var(--space-s)',
158
+ }}
159
+ >
160
+ <Button
161
+ {...CloseMobileShellNav}
162
+ ref={closeRef}
163
+ type="button"
164
+ variant="link"
165
+ label={undefined}
166
+ prefix={CloseMobileShellNav.prefix ?? 'close'}
167
+ aria-label={closeLabel}
168
+ data-ossy-mobile-shell-nav-close
169
+ onClick={() => onOpenChange(false)}
170
+ style={touchTargetStyle}
171
+ />
172
+ </View>
173
+ <View style={{ flex: '1 1 auto', minHeight: 0, overflow: 'auto' }}>
174
+ <Slot view="app:sidebar" presentation="drawer" />
175
+ </View>
176
+ </View>
177
+ </Overlay>
178
+ </>
179
+ )
180
+ }
@@ -0,0 +1,45 @@
1
+ import { useEffect } from 'react'
2
+ import { useRouter } from '@ossy/router-react'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { GetWorkspace } from '@ossy/workspaces'
5
+ import { CreatePageView } from '@ossy/resources'
6
+
7
+ /** Survives Strict Mode remounts so the same navigation is not double-counted. */
8
+ let lastRecordedKey = null
9
+ let lastRecordedAt = 0
10
+ const DEDUPE_MS = 1500
11
+
12
+ /**
13
+ * Emit a page-view resource on client navigations so analytics can aggregate traffic.
14
+ * No UI — mount once under the app shell / router.
15
+ */
16
+ export function PageViewTracker () {
17
+ const router = useRouter()
18
+ const sdk = useSdk()
19
+ const { data: workspace } = sdk.read(GetWorkspace)
20
+
21
+ useEffect(() => {
22
+ if (typeof window === 'undefined') return
23
+ if (!workspace?.id) return
24
+
25
+ const path = window.location.pathname || '/'
26
+ const section = window.location.hash
27
+ ? window.location.hash.replace('#', '')
28
+ : undefined
29
+ const key = `${workspace.id}:${path}:${section || ''}:${router.language || ''}`
30
+ const now = Date.now()
31
+ if (lastRecordedKey === key && now - lastRecordedAt < DEDUPE_MS) return
32
+ lastRecordedKey = key
33
+ lastRecordedAt = now
34
+
35
+ sdk.invoke(CreatePageView, {
36
+ path,
37
+ section,
38
+ language: router.language,
39
+ referrer: typeof document !== 'undefined' ? (document.referrer || undefined) : undefined,
40
+ eventAt: now,
41
+ }).catch(() => {})
42
+ }, [sdk, router.href, router.language, workspace?.id])
43
+
44
+ return null
45
+ }
@@ -0,0 +1,49 @@
1
+ import React from 'react'
2
+ import { View, Slot } from '@ossy/design-system'
3
+ import { MobileShellNav } from './MobileShellNav.jsx'
4
+ import {
5
+ shouldShowMobileShellNav,
6
+ shouldShowShellHeaderRow,
7
+ } from './shellChrome.js'
8
+
9
+ /**
10
+ * Shared header chrome for default/workspace layouts.
11
+ * Owns the compact mobile navbar so custom `app:header` slots still get a menu.
12
+ */
13
+ export function ShellHeaderRow ({
14
+ compact = false,
15
+ showSidebar = false,
16
+ showHeader = false,
17
+ mobileNavOpen = false,
18
+ onMobileNavOpenChange = () => {},
19
+ }) {
20
+ if (!shouldShowShellHeaderRow({ compact, showSidebar, showHeader })) {
21
+ return null
22
+ }
23
+
24
+ const showMobileNav = shouldShowMobileShellNav({ compact, showSidebar })
25
+
26
+ return (
27
+ <View
28
+ data-region="header"
29
+ layout="row"
30
+ style={{
31
+ alignItems: 'center',
32
+ gap: compact ? 'var(--space-xs)' : 'var(--space-s)',
33
+ minWidth: 0,
34
+ }}
35
+ >
36
+ {showMobileNav && (
37
+ <MobileShellNav
38
+ open={mobileNavOpen}
39
+ onOpenChange={onMobileNavOpenChange}
40
+ />
41
+ )}
42
+ {showHeader && (
43
+ <View style={{ flex: '1 1 auto', minWidth: 0 }}>
44
+ <Slot view="app:header" />
45
+ </View>
46
+ )}
47
+ </View>
48
+ )
49
+ }
@@ -8,6 +8,7 @@ import {
8
8
  useTheme,
9
9
  } from '@ossy/design-system'
10
10
  import { patchUserAppSettings } from './patchUserAppSettings.js'
11
+ import { compactShellControlStyle } from './mobileShellFocus.js'
11
12
 
12
13
  const THEME_ICON_BY_NAME = {
13
14
  light: 'sun',
@@ -36,6 +37,9 @@ function themeIcon (name) {
36
37
  export function ThemeSelect ({ compact = false }) {
37
38
  const { themes, activeTheme, setTheme } = useTheme()
38
39
  const { t } = useLocale()
40
+ const controlStyle = compact
41
+ ? compactShellControlStyle()
42
+ : { flexShrink: 0 }
39
43
 
40
44
  const saveTheme = useCallback((themeName) => {
41
45
  setTheme(themeName)
@@ -63,7 +67,7 @@ export function ThemeSelect ({ compact = false }) {
63
67
  prefix={themeIcon(activeTheme)}
64
68
  onClick={cycleTheme}
65
69
  aria-label={ariaLabel}
66
- style={{ flexShrink: 0 }}
70
+ style={controlStyle}
67
71
  >
68
72
  {compact ? null : activeLabel}
69
73
  </Button>
@@ -77,7 +81,7 @@ export function ThemeSelect ({ compact = false }) {
77
81
  prefix={compact ? themeIcon(activeTheme) : 'select'}
78
82
  variant="link"
79
83
  aria-label={ariaLabel}
80
- style={{ flexShrink: 0 }}
84
+ style={controlStyle}
81
85
  >
82
86
  {compact ? null : activeLabel}
83
87
  </Button>
@@ -5,9 +5,36 @@ export * from './ThemeEditor.jsx'
5
5
  export { patchUserAppSettings } from './patchUserAppSettings.js'
6
6
  export { useShellWorkspace } from './useShellWorkspace.js'
7
7
  export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
8
+ export { PageViewTracker } from './PageViewTracker.jsx'
8
9
  export { resolveEndpoints } from './resolveEndpoints.js'
9
10
  export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
10
- export { useCompactShellLayout } from './useCompactShellLayout.js'
11
+ export {
12
+ useCompactShellLayout,
13
+ COMPACT_SHELL_MEDIA_QUERY,
14
+ } from './useCompactShellLayout.js'
15
+ export {
16
+ shouldShowMobileShellNav,
17
+ shouldShowShellHeaderRow,
18
+ } from './shellChrome.js'
19
+ export {
20
+ isDrawerPresentation,
21
+ resolveSidebarBorderRadius,
22
+ resolveSidebarSmall,
23
+ shouldShowSidebarCollapseControl,
24
+ } from './sidebarChrome.js'
25
+ export { MobileShellNav } from './MobileShellNav.jsx'
26
+ export { ShellHeaderRow } from './ShellHeaderRow.jsx'
27
+ export {
28
+ compactAppShellStyle,
29
+ mobileShellSafeAreaStyle,
30
+ mobileShellDrawerSafeAreaStyle,
31
+ compactShellControlStyle,
32
+ setAppShellBackgroundInert,
33
+ setMobileShellScrollLock,
34
+ MOBILE_SHELL_SCROLL_LOCK_ATTR,
35
+ } from './mobileShellFocus.js'
36
+ export { metadata as OpenMobileShellNav } from '../shell-registry/open-mobile-shell-nav.action.js'
37
+ export { metadata as CloseMobileShellNav } from '../shell-registry/close-mobile-shell-nav.action.js'
11
38
  export { buildSidebarNav } from './buildSidebarNav.js'
12
39
  export { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
13
40
  export { shellSlotUnset, shellSlotViewId, coerceShellSlotSpec } from '../../runtime/merge-shell-slots.js'
@@ -0,0 +1,196 @@
1
+ /** Minimum touch target for compact menu / close controls (WCAG 2.5.5). */
2
+ export const MOBILE_SHELL_TOUCH_TARGET_PX = 44
3
+
4
+ /** Selector for the layout shell that sits behind the mobile nav drawer portal. */
5
+ export const APP_SHELL_SELECTOR = '[data-ossy-app-shell]'
6
+
7
+ /** Body attribute while the compact drawer locks document scroll (flow hook). */
8
+ export const MOBILE_SHELL_SCROLL_LOCK_ATTR = 'data-ossy-mobile-shell-scroll-lock'
9
+
10
+ /**
11
+ * Notches / home-indicator insets via `env(safe-area-inset-*)`.
12
+ * Requires document viewport `viewport-fit=cover` (see `DOCUMENT_VIEWPORT_CONTENT`).
13
+ *
14
+ * @param {Record<string, unknown>} [extra]
15
+ * @returns {Record<string, unknown>}
16
+ */
17
+ export function mobileShellSafeAreaStyle (extra = {}) {
18
+ return {
19
+ paddingTop: 'env(safe-area-inset-top, 0px)',
20
+ paddingRight: 'env(safe-area-inset-right, 0px)',
21
+ paddingBottom: 'env(safe-area-inset-bottom, 0px)',
22
+ paddingLeft: 'env(safe-area-inset-left, 0px)',
23
+ ...extra,
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Notches / home-indicator insets for the compact nav drawer panel.
29
+ * Applied on the dialog root so close + sidebar content clear the safe area.
30
+ *
31
+ * @param {Record<string, unknown>} [extra]
32
+ * @returns {Record<string, unknown>}
33
+ */
34
+ export function mobileShellDrawerSafeAreaStyle (extra = {}) {
35
+ return mobileShellSafeAreaStyle(extra)
36
+ }
37
+
38
+ /**
39
+ * Outer padding for compact default/workspace shells (edge-to-edge except safe area).
40
+ * Desktop shells keep `var(--space-m)`; compact uses safe-area insets only.
41
+ *
42
+ * @param {Record<string, unknown>} [extra]
43
+ * @returns {Record<string, unknown>}
44
+ */
45
+ export function compactAppShellStyle (extra = {}) {
46
+ return mobileShellSafeAreaStyle({
47
+ boxSizing: 'border-box',
48
+ height: '100%',
49
+ minWidth: 0,
50
+ ...extra,
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Inline style for compact shell icon/link controls (menu, close, header chrome).
56
+ * Keeps hit areas ≥ {@link MOBILE_SHELL_TOUCH_TARGET_PX}.
57
+ *
58
+ * @param {Record<string, unknown>} [extra]
59
+ * @returns {Record<string, unknown>}
60
+ */
61
+ export function compactShellControlStyle (extra = {}) {
62
+ return {
63
+ flexShrink: 0,
64
+ minWidth: MOBILE_SHELL_TOUCH_TARGET_PX,
65
+ minHeight: MOBILE_SHELL_TOUCH_TARGET_PX,
66
+ boxSizing: 'border-box',
67
+ ...extra,
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Mark (or clear) the app shell behind an open mobile drawer so AT / Tab ignore it.
73
+ * The drawer itself is portaled outside the shell (`Overlay`), so inert + aria-hidden
74
+ * on `[data-ossy-app-shell]` does not hide the dialog.
75
+ *
76
+ * @param {ParentNode | null | undefined} root Document or test container
77
+ * @param {boolean} enabled
78
+ * @returns {Element | null} The shell element when found
79
+ */
80
+ export function setAppShellBackgroundInert (root, enabled) {
81
+ const shell = root?.querySelector?.(APP_SHELL_SELECTOR) ?? null
82
+ if (!shell) return null
83
+
84
+ if (enabled) {
85
+ shell.setAttribute('aria-hidden', 'true')
86
+ if ('inert' in shell) {
87
+ shell.inert = true
88
+ } else {
89
+ shell.setAttribute('inert', '')
90
+ }
91
+ } else {
92
+ shell.removeAttribute('aria-hidden')
93
+ if ('inert' in shell) {
94
+ shell.inert = false
95
+ }
96
+ shell.removeAttribute('inert')
97
+ }
98
+
99
+ return shell
100
+ }
101
+
102
+ /**
103
+ * Lock (or restore) document body scroll while the compact mobile drawer is open.
104
+ * Sets {@link MOBILE_SHELL_SCROLL_LOCK_ATTR} so flows can assert scroll-lock without
105
+ * reading computed styles.
106
+ *
107
+ * @param {Document | { body?: HTMLElement | null } | null | undefined} doc
108
+ * @param {boolean} enabled
109
+ * @param {{ previousOverflow?: string }} [opts] Pass the overflow returned when enabling
110
+ * so cleanup restores the prior inline value.
111
+ * @returns {{ body: HTMLElement, previousOverflow: string } | null}
112
+ */
113
+ export function setMobileShellScrollLock (doc, enabled, opts = {}) {
114
+ const body = doc?.body ?? null
115
+ if (!body) return null
116
+
117
+ if (enabled) {
118
+ const previousOverflow = body.style?.overflow ?? ''
119
+ if (body.style) body.style.overflow = 'hidden'
120
+ body.setAttribute?.(MOBILE_SHELL_SCROLL_LOCK_ATTR, '')
121
+ return { body, previousOverflow }
122
+ }
123
+
124
+ if (body.style) {
125
+ body.style.overflow = opts.previousOverflow ?? ''
126
+ }
127
+ body.removeAttribute?.(MOBILE_SHELL_SCROLL_LOCK_ATTR)
128
+ return { body, previousOverflow: opts.previousOverflow ?? '' }
129
+ }
130
+
131
+ export const FOCUSABLE_SELECTOR = [
132
+ 'a[href]',
133
+ 'button:not([disabled])',
134
+ 'input:not([disabled]):not([type="hidden"])',
135
+ 'select:not([disabled])',
136
+ 'textarea:not([disabled])',
137
+ '[tabindex]:not([tabindex="-1"])',
138
+ ].join(',')
139
+
140
+ /** Sentinel: trap has no focusable nodes; caller should focus its fallback control. */
141
+ export const TAB_TRAP_FALLBACK = Object.freeze({ fallback: true })
142
+
143
+ /**
144
+ * List Tab-reachable elements inside a drawer root.
145
+ * Skips `aria-hidden` nodes and elements that are not visible.
146
+ *
147
+ * @param {{ querySelectorAll?: Function } | null | undefined} root
148
+ * @returns {Element[]}
149
+ */
150
+ export function listFocusable (root) {
151
+ if (!root?.querySelectorAll) return []
152
+ return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter((el) => {
153
+ if (el.getAttribute?.('aria-hidden') === 'true') return false
154
+ if (typeof el.checkVisibility === 'function') {
155
+ return el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
156
+ }
157
+ const active = typeof document !== 'undefined' ? document.activeElement : null
158
+ return el.offsetParent !== null || el === active
159
+ })
160
+ }
161
+
162
+ /**
163
+ * Resolve the next focus target for Tab / Shift+Tab inside an open drawer.
164
+ *
165
+ * @param {{
166
+ * focusable: unknown[],
167
+ * activeElement: unknown,
168
+ * containsActive: boolean,
169
+ * shiftKey: boolean,
170
+ * }} opts
171
+ * @returns {null | typeof TAB_TRAP_FALLBACK | unknown}
172
+ * - `TAB_TRAP_FALLBACK` when there are no focusable nodes
173
+ * - an element to focus (and preventDefault) when wrapping
174
+ * - `null` to leave default Tab behavior alone
175
+ */
176
+ export function resolveTabTrapTarget ({
177
+ focusable,
178
+ activeElement,
179
+ containsActive,
180
+ shiftKey,
181
+ }) {
182
+ if (!Array.isArray(focusable) || focusable.length === 0) {
183
+ return TAB_TRAP_FALLBACK
184
+ }
185
+
186
+ const first = focusable[0]
187
+ const last = focusable[focusable.length - 1]
188
+
189
+ if (shiftKey) {
190
+ if (!containsActive || activeElement === first) return last
191
+ return null
192
+ }
193
+
194
+ if (!containsActive || activeElement === last) return first
195
+ return null
196
+ }
@@ -0,0 +1,24 @@
1
+ /** Matches Cloud theme compact typography (`theme.media['(max-width: 900px)']`). */
2
+ export const COMPACT_SHELL_MEDIA_QUERY = '(max-width: 900px)'
3
+
4
+ /**
5
+ * Whether layouts should render the header row on the current viewport.
6
+ * Compact chrome hides the in-grid sidebar, so a menu control must remain
7
+ * reachable even when the `app:header` slot is intentionally unset.
8
+ *
9
+ * @param {{ compact: boolean, showSidebar: boolean, showHeader: boolean }} opts
10
+ * @returns {boolean}
11
+ */
12
+ export function shouldShowShellHeaderRow ({ compact, showSidebar, showHeader }) {
13
+ return Boolean(showHeader || (compact && showSidebar))
14
+ }
15
+
16
+ /**
17
+ * Whether the mobile navbar (menu + drawer) should render.
18
+ *
19
+ * @param {{ compact: boolean, showSidebar: boolean }} opts
20
+ * @returns {boolean}
21
+ */
22
+ export function shouldShowMobileShellNav ({ compact, showSidebar }) {
23
+ return Boolean(compact && showSidebar)
24
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pure chrome rules for default / primary sidebars when hosted in-grid vs
3
+ * inside the compact mobile nav drawer (`presentation="drawer"`).
4
+ */
5
+
6
+ /**
7
+ * @param {unknown} presentation
8
+ * @returns {boolean}
9
+ */
10
+ export function isDrawerPresentation (presentation) {
11
+ return presentation === 'drawer'
12
+ }
13
+
14
+ /**
15
+ * Drawer chrome must stay expanded (labeled); otherwise honor compact /
16
+ * collapsed / explicit `small`.
17
+ *
18
+ * @param {{
19
+ * presentation?: unknown,
20
+ * compactViewport?: boolean,
21
+ * sidebarCollapsed?: boolean,
22
+ * smallProp?: boolean,
23
+ * }} opts
24
+ * @returns {boolean}
25
+ */
26
+ export function resolveSidebarSmall ({
27
+ presentation,
28
+ compactViewport = false,
29
+ sidebarCollapsed = false,
30
+ smallProp = false,
31
+ }) {
32
+ if (isDrawerPresentation(presentation)) return false
33
+ return Boolean(compactViewport || sidebarCollapsed || smallProp)
34
+ }
35
+
36
+ /**
37
+ * Collapse control is desktop in-grid only — never in the mobile drawer or
38
+ * on compact viewports (drawer owns open/close).
39
+ *
40
+ * @param {{ presentation?: unknown, compactViewport?: boolean }} opts
41
+ * @returns {boolean}
42
+ */
43
+ export function shouldShowSidebarCollapseControl ({
44
+ presentation,
45
+ compactViewport = false,
46
+ }) {
47
+ return !isDrawerPresentation(presentation) && !compactViewport
48
+ }
49
+
50
+ /**
51
+ * Square panel in the drawer; rounded rail when in-grid.
52
+ *
53
+ * @param {unknown} presentation
54
+ * @returns {0 | string}
55
+ */
56
+ export function resolveSidebarBorderRadius (presentation) {
57
+ return isDrawerPresentation(presentation)
58
+ ? 0
59
+ : 'var(--space-l) var(--space-s) var(--space-s) var(--space-l)'
60
+ }
@@ -1,18 +1,18 @@
1
1
  import { useSyncExternalStore } from 'react'
2
+ import { COMPACT_SHELL_MEDIA_QUERY } from './shellChrome.js'
2
3
 
3
- /** Viewports at or below this width use compact chrome (icon rail, tighter padding). */
4
- const COMPACT_MEDIA_QUERY = '(max-width: 900px)'
4
+ export { COMPACT_SHELL_MEDIA_QUERY }
5
5
 
6
6
  function subscribe (onStoreChange) {
7
7
  if (typeof window === 'undefined') return () => {}
8
- const mq = window.matchMedia(COMPACT_MEDIA_QUERY)
8
+ const mq = window.matchMedia(COMPACT_SHELL_MEDIA_QUERY)
9
9
  mq.addEventListener('change', onStoreChange)
10
10
  return () => mq.removeEventListener('change', onStoreChange)
11
11
  }
12
12
 
13
13
  function getSnapshot () {
14
14
  if (typeof window === 'undefined') return false
15
- return window.matchMedia(COMPACT_MEDIA_QUERY).matches
15
+ return window.matchMedia(COMPACT_SHELL_MEDIA_QUERY).matches
16
16
  }
17
17
 
18
18
  function getServerSnapshot () {