@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 +16 -0
- package/package.json +7 -7
- package/runtime/document-viewport.js +7 -0
- package/runtime/page-runtime.js +4 -0
- package/src/en.translations.json +4 -1
- package/src/shell/HeaderAuthActions.jsx +7 -3
- package/src/shell/LanguageSelect.jsx +7 -3
- package/src/shell/MobileShellNav.jsx +180 -0
- package/src/shell/ShellHeaderRow.jsx +49 -0
- package/src/shell/ThemeSelect.jsx +6 -2
- package/src/shell/index.js +27 -1
- package/src/shell/mobileShellFocus.js +196 -0
- package/src/shell/shellChrome.js +24 -0
- package/src/shell/sidebarChrome.js +60 -0
- package/src/shell/useCompactShellLayout.js +4 -4
- package/src/shell-registry/close-mobile-shell-nav.action.js +6 -0
- package/src/shell-registry/default.layout.jsx +30 -12
- package/src/shell-registry/header-default.component.jsx +1 -1
- package/src/shell-registry/open-mobile-shell-nav.action.js +6 -0
- package/src/shell-registry/sidebar-default.component.jsx +25 -6
- package/src/shell-registry/workspace.layout.jsx +30 -12
- package/src/sv.translations.json +4 -1
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.
|
|
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.
|
|
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.
|
|
59
|
-
"@ossy/resources": "^3.
|
|
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.
|
|
63
|
+
"@ossy/sdk": "^3.3.0",
|
|
64
64
|
"@ossy/sdk-react": "^3.0.9",
|
|
65
|
-
"@ossy/themes": "^3.
|
|
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": "
|
|
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'
|
package/runtime/page-runtime.js
CHANGED
|
@@ -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)),
|
package/src/en.translations.json
CHANGED
|
@@ -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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
84
|
+
style={controlStyle}
|
|
81
85
|
>
|
|
82
86
|
{compact ? null : activeLabel}
|
|
83
87
|
</Button>
|
package/src/shell/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
15
|
+
return window.matchMedia(COMPACT_SHELL_MEDIA_QUERY).matches
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function getServerSnapshot () {
|
|
@@ -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 {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
{!
|
|
72
|
+
{showSidebar && !compact && (
|
|
59
73
|
<Slot region="sidebar-primary" view="app:sidebar" />
|
|
60
74
|
)}
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
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>
|
|
@@ -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 =
|
|
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:
|
|
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 &&
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
{!
|
|
75
|
+
{showSidebar && !compact && (
|
|
62
76
|
<Slot region="sidebar-primary" view="app:sidebar" />
|
|
63
77
|
)}
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
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"
|
package/src/sv.translations.json
CHANGED
|
@@ -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
|
}
|