@ossy/app 3.2.0 → 3.3.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.
package/README.md CHANGED
@@ -170,6 +170,22 @@ export default function ServiceForm() { … }
170
170
 
171
171
  See [`docs/component-primitive.md`](./docs/component-primitive.md) and [design-system/docs/SLOTS.md](../design-system/docs/SLOTS.md).
172
172
 
173
+ ## Compact / mobile shell
174
+
175
+ Default and workspace layouts use `@ossy/app/shell` helpers for viewports ≤900px:
176
+
177
+ - `useCompactShellLayout` / `COMPACT_SHELL_MEDIA_QUERY` — match Cloud theme compact typography
178
+ - `ShellHeaderRow` + `MobileShellNav` — menu control + overlay drawer hosting `app:sidebar` with `presentation="drawer"`
179
+ - `OpenMobileShellNav` / `CloseMobileShellNav` action POJOs on the menu/close controls (`data-action`) for flow coverage with `{ viewport }` / `{ press: 'Escape' }` / `{ press: 'Tab' }` / `{ press: 'Shift+Tab' }`
180
+ - Header row stays when `app:header` is unset so sidebar-only pages keep primary nav
181
+ - Drawer traps Tab focus via a `View` panel ref (flow asserts wrap with `Shift+Tab` / `Tab`), restores the menu trigger on close, and keeps menu/close hit areas ≥44px
182
+ - While open, `[data-ossy-app-shell]` is `inert` + `aria-hidden`; body gets `data-ossy-mobile-shell-scroll-lock`; the drawer panel respects `env(safe-area-inset-*)`
183
+ - Compact shells pad with the same safe-area insets (not zero) so the header menu clears notches; document head uses `viewport-fit=cover`
184
+ - Compact header chrome (language / theme / auth) uses the same ≥44px hit areas via `compactShellControlStyle`
185
+ - Tab-trap helpers live in `mobileShellFocus.js` (`listFocusable`, `resolveTabTrapTarget`, `compactShellControlStyle`, `compactAppShellStyle`, `setAppShellBackgroundInert`, `setMobileShellScrollLock`, `mobileShellDrawerSafeAreaStyle`) with unit coverage in `__tests__/mobileShellFocus.test.js`
186
+
187
+ See [SHELL-SPEC §4.5](../../docs/concepts/SHELL-SPEC.md).
188
+
173
189
  ## Port configuration
174
190
 
175
191
  The server listens on port **3000** by default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -50,19 +50,19 @@
50
50
  "@babel/preset-react": "^7.26.3",
51
51
  "@babel/register": "^7.25.9",
52
52
  "@ossy/authentication": "^3.2.0",
53
- "@ossy/design-system": "^3.2.0",
53
+ "@ossy/design-system": "^3.3.0",
54
54
  "@ossy/locale": "^3.0.9",
55
55
  "@ossy/manifest": "^3.0.9",
56
56
  "@ossy/package-catalog": "^3.0.9",
57
57
  "@ossy/pages": "^3.0.9",
58
- "@ossy/platform": "^3.2.0",
59
- "@ossy/resources": "^3.2.0",
58
+ "@ossy/platform": "^3.3.0",
59
+ "@ossy/resources": "^3.3.0",
60
60
  "@ossy/router": "^3.0.9",
61
61
  "@ossy/router-react": "^3.0.9",
62
62
  "@ossy/schema": "^3.0.9",
63
- "@ossy/sdk": "^3.2.0",
63
+ "@ossy/sdk": "^3.3.0",
64
64
  "@ossy/sdk-react": "^3.0.9",
65
- "@ossy/themes": "^3.2.0",
65
+ "@ossy/themes": "^3.3.0",
66
66
  "@ossy/workspaces": "^3.2.0",
67
67
  "@rollup/plugin-alias": "^6.0.0",
68
68
  "@rollup/plugin-babel": "^7.1.0",
@@ -97,5 +97,5 @@
97
97
  "README.md",
98
98
  "tsconfig.json"
99
99
  ],
100
- "gitHead": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
100
+ "gitHead": "19e8bae4c1e9df79270218f28c0c9391f4f9871c"
101
101
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Document viewport for mobile / iPad (and notched devices).
3
+ * `viewport-fit=cover` lets `env(safe-area-inset-*)` resolve so compact shell chrome
4
+ * and the mobile nav drawer can clear the status bar / home indicator.
5
+ */
6
+ export const DOCUMENT_VIEWPORT_CONTENT =
7
+ 'width=device-width, initial-scale=1, viewport-fit=cover'
@@ -3,6 +3,9 @@ import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
3
3
  import { Slot } from '@ossy/design-system'
4
4
  import { App } from '../src/shell/App.jsx'
5
5
  import { resolvePageSlots, CONTENT_SLOT_NAME } from './resolve-app-slots.js'
6
+ import { DOCUMENT_VIEWPORT_CONTENT } from './document-viewport.js'
7
+
8
+ export { DOCUMENT_VIEWPORT_CONTENT } from './document-viewport.js'
6
9
 
7
10
  /**
8
11
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -93,6 +96,7 @@ function buildTree ({ Layout, metadata, props }) {
93
96
  'head',
94
97
  null,
95
98
  createElement('meta', { charSet: 'utf-8' }),
99
+ createElement('meta', { name: 'viewport', content: DOCUMENT_VIEWPORT_CONTENT }),
96
100
  createElement('title', null, resolveDocumentTitle(metadata, props)),
97
101
  ),
98
102
  createElement('body', { style: { height: '100%', margin: 0 } }, createElement(App, props, contentEl)),
@@ -4,5 +4,8 @@
4
4
  "app.shell.header.languagePicker": "Language",
5
5
  "app.shell.footer.languages": "Languages",
6
6
  "app.shell.header.themeSwitch": "{theme} theme. Switch theme.",
7
- "app.shell.header.profile": "Profile"
7
+ "app.shell.header.profile": "Profile",
8
+ "app.shell.header.openNav": "Open navigation",
9
+ "app.shell.header.closeNav": "Close navigation",
10
+ "app.shell.header.nav": "Primary navigation"
8
11
  }
@@ -3,6 +3,7 @@ import { Button, useLocale } from '@ossy/design-system'
3
3
  import { useRouter } from '@ossy/router-react'
4
4
  import { OpenSignIn, OpenSignUp } from '@ossy/authentication'
5
5
  import { useApp } from './AppContext.js'
6
+ import { compactShellControlStyle } from './mobileShellFocus.js'
6
7
 
7
8
  /**
8
9
  * Profile link when authenticated; sign-in / sign-up when not.
@@ -11,6 +12,9 @@ export function HeaderAuthActions ({ compact = false }) {
11
12
  const app = useApp()
12
13
  const router = useRouter()
13
14
  const { t } = useLocale()
15
+ const controlStyle = compact
16
+ ? compactShellControlStyle()
17
+ : { flexShrink: 0 }
14
18
 
15
19
  if (app?.isAuthenticated) {
16
20
  return (
@@ -19,7 +23,7 @@ export function HeaderAuthActions ({ compact = false }) {
19
23
  suffix="profile"
20
24
  href={router.getHref('@profile')}
21
25
  aria-label={t('app.shell.header.profile') || 'Profile'}
22
- style={{ flexShrink: 0 }}
26
+ style={controlStyle}
23
27
  >
24
28
  {compact ? null : (t('app.shell.header.profile') || 'Profile')}
25
29
  </Button>
@@ -34,7 +38,7 @@ export function HeaderAuthActions ({ compact = false }) {
34
38
  href={router.getHref('@sign-in')}
35
39
  label={compact ? undefined : OpenSignIn.label}
36
40
  aria-label={t(OpenSignIn.label)}
37
- style={{ flexShrink: 0 }}
41
+ style={controlStyle}
38
42
  />
39
43
  <Button
40
44
  variant="cta"
@@ -42,7 +46,7 @@ export function HeaderAuthActions ({ compact = false }) {
42
46
  href={router.getHref('@sign-up')}
43
47
  label={compact ? undefined : OpenSignUp.label}
44
48
  aria-label={t(OpenSignUp.label)}
45
- style={{ flexShrink: 0 }}
49
+ style={controlStyle}
46
50
  />
47
51
  </>
48
52
  )
@@ -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,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>
@@ -7,7 +7,33 @@ export { useShellWorkspace } from './useShellWorkspace.js'
7
7
  export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
8
8
  export { resolveEndpoints } from './resolveEndpoints.js'
9
9
  export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
10
- export { useCompactShellLayout } from './useCompactShellLayout.js'
10
+ export {
11
+ useCompactShellLayout,
12
+ COMPACT_SHELL_MEDIA_QUERY,
13
+ } from './useCompactShellLayout.js'
14
+ export {
15
+ shouldShowMobileShellNav,
16
+ shouldShowShellHeaderRow,
17
+ } from './shellChrome.js'
18
+ export {
19
+ isDrawerPresentation,
20
+ resolveSidebarBorderRadius,
21
+ resolveSidebarSmall,
22
+ shouldShowSidebarCollapseControl,
23
+ } from './sidebarChrome.js'
24
+ export { MobileShellNav } from './MobileShellNav.jsx'
25
+ export { ShellHeaderRow } from './ShellHeaderRow.jsx'
26
+ export {
27
+ compactAppShellStyle,
28
+ mobileShellSafeAreaStyle,
29
+ mobileShellDrawerSafeAreaStyle,
30
+ compactShellControlStyle,
31
+ setAppShellBackgroundInert,
32
+ setMobileShellScrollLock,
33
+ MOBILE_SHELL_SCROLL_LOCK_ATTR,
34
+ } from './mobileShellFocus.js'
35
+ export { metadata as OpenMobileShellNav } from '../shell-registry/open-mobile-shell-nav.action.js'
36
+ export { metadata as CloseMobileShellNav } from '../shell-registry/close-mobile-shell-nav.action.js'
11
37
  export { buildSidebarNav } from './buildSidebarNav.js'
12
38
  export { resolvePackageHomePageId } from './resolvePackageHomePageId.js'
13
39
  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 () {
@@ -0,0 +1,6 @@
1
+ export const metadata = {
2
+ id: '@ossy/app/actions/close-mobile-shell-nav',
3
+ access: 'public',
4
+ prefix: 'close',
5
+ label: 'app.shell.header.closeNav',
6
+ }
@@ -1,10 +1,15 @@
1
- import React from 'react'
1
+ import React, { useEffect, useState } from 'react'
2
2
  import {
3
3
  PageSection,
4
4
  View,
5
5
  Slot,
6
6
  } from '@ossy/design-system'
7
- import { useCompactShellLayout, shellSlotUnset } from '@ossy/app/shell'
7
+ import {
8
+ useCompactShellLayout,
9
+ shellSlotUnset,
10
+ ShellHeaderRow,
11
+ compactAppShellStyle,
12
+ } from '@ossy/app/shell'
8
13
 
9
14
  export const metadata = { id: '@ossy/app/layout/default' }
10
15
 
@@ -20,6 +25,13 @@ export default function DefaultLayout ({
20
25
  maxWidth = 'xl',
21
26
  }) {
22
27
  const compact = useCompactShellLayout()
28
+ const [mobileNavOpen, setMobileNavOpen] = useState(false)
29
+ const showSidebar = !shellSlotUnset(shellSlots['app:sidebar'])
30
+ const showHeader = !shellSlotUnset(shellSlots['app:header'])
31
+
32
+ useEffect(() => {
33
+ if (!compact) setMobileNavOpen(false)
34
+ }, [compact])
23
35
 
24
36
  return (
25
37
  <>
@@ -27,12 +39,14 @@ export default function DefaultLayout ({
27
39
  <View
28
40
  surface="base"
29
41
  data-ossy-app-shell
30
- style={{
31
- boxSizing: 'border-box',
32
- height: '100%',
33
- minWidth: 0,
34
- padding: compact ? 0 : 'var(--space-m)',
35
- }}
42
+ style={compact
43
+ ? compactAppShellStyle()
44
+ : {
45
+ boxSizing: 'border-box',
46
+ height: '100%',
47
+ minWidth: 0,
48
+ padding: 'var(--space-m)',
49
+ }}
36
50
  >
37
51
  <PageSection
38
52
  maxWidth={maxWidth}
@@ -55,13 +69,17 @@ export default function DefaultLayout ({
55
69
  ...(compact ? { gap: '4px' } : {}),
56
70
  }}
57
71
  >
58
- {!shellSlotUnset(shellSlots['app:sidebar']) && (
72
+ {showSidebar && !compact && (
59
73
  <Slot region="sidebar-primary" view="app:sidebar" />
60
74
  )}
61
75
 
62
- {!shellSlotUnset(shellSlots['app:header']) && (
63
- <Slot region="header" view="app:header" />
64
- )}
76
+ <ShellHeaderRow
77
+ compact={compact}
78
+ showSidebar={showSidebar}
79
+ showHeader={showHeader}
80
+ mobileNavOpen={mobileNavOpen}
81
+ onMobileNavOpenChange={setMobileNavOpen}
82
+ />
65
83
 
66
84
  <View
67
85
  data-region="content"
@@ -40,7 +40,7 @@ export default function DefaultHeader ({
40
40
  gap: compact ? 'var(--space-xs)' : 'var(--space-s)',
41
41
  }}
42
42
  >
43
- <LanguageSelect />
43
+ <LanguageSelect compact={compact} />
44
44
  <ThemeSelect compact={compact} />
45
45
  <HeaderAuthActions compact={compact} />
46
46
  </View>
@@ -0,0 +1,6 @@
1
+ export const metadata = {
2
+ id: '@ossy/app/actions/open-mobile-shell-nav',
3
+ access: 'public',
4
+ prefix: 'menu',
5
+ label: 'app.shell.header.openNav',
6
+ }
@@ -2,6 +2,12 @@ import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
2
2
  import { Button, View, Slot, useLocale } from '@ossy/design-system'
3
3
  import { useRouter } from '@ossy/router-react'
4
4
  import { useApp, buildSidebarNav, patchUserAppSettings, useCompactShellLayout, useShellWorkspace } from '@ossy/app/shell'
5
+ import {
6
+ isDrawerPresentation,
7
+ resolveSidebarBorderRadius,
8
+ resolveSidebarSmall,
9
+ shouldShowSidebarCollapseControl,
10
+ } from '../shell/sidebarChrome.js'
5
11
 
6
12
  export const metadata = { id: '@ossy/app/component/sidebar/default' }
7
13
 
@@ -47,6 +53,7 @@ function navLabelForItem (item, showLabels, t) {
47
53
 
48
54
  export default function DefaultSidebar ({
49
55
  small: smallProp = false,
56
+ presentation,
50
57
  ...rest
51
58
  }) {
52
59
  const [hoverRailExpanded, setHoverRailExpanded] = useState(false)
@@ -56,6 +63,7 @@ export default function DefaultSidebar ({
56
63
  const compactViewport = useCompactShellLayout()
57
64
  const initialSidebarCollapsed = app?.sidebarPrimaryCollapsed === true
58
65
  const [sidebarCollapsed, setSidebarCollapsed] = useState(initialSidebarCollapsed)
66
+ const isDrawer = isDrawerPresentation(presentation)
59
67
 
60
68
  const onToggleCollapsed = useCallback(() => {
61
69
  setSidebarCollapsed((collapsed) => {
@@ -65,16 +73,25 @@ export default function DefaultSidebar ({
65
73
  })
66
74
  }, [])
67
75
 
68
- const small = compactViewport || sidebarCollapsed || smallProp
76
+ const small = resolveSidebarSmall({
77
+ presentation,
78
+ compactViewport,
79
+ sidebarCollapsed,
80
+ smallProp,
81
+ })
69
82
  const { workspace } = useShellWorkspace()
70
83
  const router = useRouter()
71
84
  const { t } = useLocale()
72
85
 
73
- const desktopHoverRail = small && !compactViewport
86
+ const desktopHoverRail = small && !compactViewport && !isDrawer
74
87
  const showLabels = !small || (desktopHoverRail && hoverRailExpanded)
75
88
  const iconOnly = !showLabels
76
89
  const hoverExpandedDesktop = desktopHoverRail && hoverRailExpanded
77
90
  const narrowChrome = small
91
+ const showCollapseControl = shouldShowSidebarCollapseControl({
92
+ presentation,
93
+ compactViewport,
94
+ })
78
95
 
79
96
  const { shellItems, packageItems } = useMemo(
80
97
  () => buildSidebarNav({
@@ -186,6 +203,7 @@ export default function DefaultSidebar ({
186
203
  prefix={item.prefix}
187
204
  title={item.title}
188
205
  aria-current={item.selected ? 'page' : undefined}
206
+ data-ossy-sidebar-nav-item={item.key ?? item.href}
189
207
  style={{
190
208
  justifyContent: iconOnly ? 'center' : 'flex-start',
191
209
  padding: hoverExpandedDesktop ? '8px 24px' : (narrowChrome ? 'var(--space-s)' : '8px 24px'),
@@ -200,17 +218,18 @@ export default function DefaultSidebar ({
200
218
  {...(!desktopHoverRail ? rest : {})}
201
219
  surface="primary"
202
220
  gap={narrowChrome ? 's' : 'm'}
221
+ data-ossy-sidebar-presentation={isDrawer ? 'drawer' : undefined}
203
222
  style={{
204
223
  height: '100%',
205
- width: desktopHoverRail ? '100%' : 'auto',
224
+ width: isDrawer || desktopHoverRail ? '100%' : 'auto',
206
225
  minWidth: small ? 0 : undefined,
207
- maxHeight: small ? '100%' : undefined,
226
+ maxHeight: small || isDrawer ? '100%' : undefined,
208
227
  padding: hoverExpandedDesktop
209
228
  ? 'var(--space-m) var(--space-l) var(--space-m) var(--space-m)'
210
229
  : narrowChrome
211
230
  ? 'var(--space-s) var(--space-xs)'
212
231
  : 'var(--space-m) var(--space-l) var(--space-m) var(--space-m)',
213
- borderRadius: 'var(--space-l) var(--space-s) var(--space-s) var(--space-l)',
232
+ borderRadius: resolveSidebarBorderRadius(presentation),
214
233
  flexShrink: 0,
215
234
  boxSizing: 'border-box',
216
235
  }}
@@ -233,7 +252,7 @@ export default function DefaultSidebar ({
233
252
  {menuNear.map(renderButton)}
234
253
  </View>
235
254
 
236
- {onToggleCollapsed && !compactViewport && (
255
+ {onToggleCollapsed && showCollapseControl && (
237
256
  <View style={{ flexShrink: 0, marginTop: 'var(--space-s)' }}>
238
257
  <Button
239
258
  variant="link"
@@ -1,10 +1,15 @@
1
- import React from 'react'
1
+ import React, { useEffect, useState } from 'react'
2
2
  import {
3
3
  PageSection,
4
4
  View,
5
5
  Slot,
6
6
  } from '@ossy/design-system'
7
- import { useCompactShellLayout, shellSlotUnset } from '@ossy/app/shell'
7
+ import {
8
+ useCompactShellLayout,
9
+ shellSlotUnset,
10
+ ShellHeaderRow,
11
+ compactAppShellStyle,
12
+ } from '@ossy/app/shell'
8
13
 
9
14
  export const metadata = { id: '@ossy/app/layout/workspace' }
10
15
 
@@ -23,6 +28,13 @@ export default function WorkspaceLayout ({
23
28
  maxWidth = 'xl',
24
29
  }) {
25
30
  const compact = useCompactShellLayout()
31
+ const [mobileNavOpen, setMobileNavOpen] = useState(false)
32
+ const showSidebar = !shellSlotUnset(shellSlots['app:sidebar'])
33
+ const showHeader = !shellSlotUnset(shellSlots['app:header'])
34
+
35
+ useEffect(() => {
36
+ if (!compact) setMobileNavOpen(false)
37
+ }, [compact])
26
38
 
27
39
  return (
28
40
  <>
@@ -30,12 +42,14 @@ export default function WorkspaceLayout ({
30
42
  <View
31
43
  surface="base"
32
44
  data-ossy-app-shell
33
- style={{
34
- boxSizing: 'border-box',
35
- height: '100%',
36
- minWidth: 0,
37
- padding: compact ? 0 : 'var(--space-m)',
38
- }}
45
+ style={compact
46
+ ? compactAppShellStyle()
47
+ : {
48
+ boxSizing: 'border-box',
49
+ height: '100%',
50
+ minWidth: 0,
51
+ padding: 'var(--space-m)',
52
+ }}
39
53
  >
40
54
  <PageSection
41
55
  maxWidth={maxWidth}
@@ -58,13 +72,17 @@ export default function WorkspaceLayout ({
58
72
  ...(compact ? { gap: '4px' } : {}),
59
73
  }}
60
74
  >
61
- {!shellSlotUnset(shellSlots['app:sidebar']) && (
75
+ {showSidebar && !compact && (
62
76
  <Slot region="sidebar-primary" view="app:sidebar" />
63
77
  )}
64
78
 
65
- {!shellSlotUnset(shellSlots['app:header']) && (
66
- <Slot region="header" view="app:header" />
67
- )}
79
+ <ShellHeaderRow
80
+ compact={compact}
81
+ showSidebar={showSidebar}
82
+ showHeader={showHeader}
83
+ mobileNavOpen={mobileNavOpen}
84
+ onMobileNavOpenChange={setMobileNavOpen}
85
+ />
68
86
 
69
87
  <View
70
88
  data-region="content"
@@ -4,5 +4,8 @@
4
4
  "app.shell.header.languagePicker": "Språk",
5
5
  "app.shell.footer.languages": "Språk",
6
6
  "app.shell.header.themeSwitch": "{theme}-tema. Byt tema.",
7
- "app.shell.header.profile": "Profil"
7
+ "app.shell.header.profile": "Profil",
8
+ "app.shell.header.openNav": "Öppna navigation",
9
+ "app.shell.header.closeNav": "Stäng navigation",
10
+ "app.shell.header.nav": "Huvudnavigation"
8
11
  }