@flamingo-stack/openframe-frontend-core 0.0.409 → 0.0.410-snapshot.20260711165901
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/dist/{chunk-STGWQFYT.cjs → chunk-3SNDISEN.cjs} +92 -30
- package/dist/chunk-3SNDISEN.cjs.map +1 -0
- package/dist/{chunk-RL344I7V.cjs → chunk-3U4IZF7H.cjs} +7 -7
- package/dist/{chunk-RL344I7V.cjs.map → chunk-3U4IZF7H.cjs.map} +1 -1
- package/dist/{chunk-7Q52HP45.js → chunk-4FNDOTW2.js} +139 -77
- package/dist/chunk-4FNDOTW2.js.map +1 -0
- package/dist/{chunk-O5W26SCL.js → chunk-6AB36PRE.js} +2 -2
- package/dist/{chunk-IK5AR6QG.js → chunk-B2BERJ4M.js} +6 -6
- package/dist/{chunk-SOALFYU6.cjs → chunk-BKYV2ALJ.cjs} +582 -591
- package/dist/chunk-BKYV2ALJ.cjs.map +1 -0
- package/dist/{chunk-KPGKNUJF.cjs → chunk-BRHCGXOH.cjs} +40 -40
- package/dist/{chunk-KPGKNUJF.cjs.map → chunk-BRHCGXOH.cjs.map} +1 -1
- package/dist/{chunk-NO2KT42M.js → chunk-DGIF3YDV.js} +2 -2
- package/dist/{chunk-LQWYXIKB.js → chunk-FXZO4EKN.js} +5 -5
- package/dist/{chunk-CUN4CJUN.cjs → chunk-G2G7HLEM.cjs} +3 -3
- package/dist/{chunk-CUN4CJUN.cjs.map → chunk-G2G7HLEM.cjs.map} +1 -1
- package/dist/{chunk-NEMUCNCG.cjs → chunk-H4ISMWHG.cjs} +73 -73
- package/dist/{chunk-NEMUCNCG.cjs.map → chunk-H4ISMWHG.cjs.map} +1 -1
- package/dist/{chunk-L6OPVKVR.js → chunk-HXRULNWA.js} +5 -5
- package/dist/{chunk-RPGFIUN3.cjs → chunk-MF5CVIAC.cjs} +32 -32
- package/dist/{chunk-RPGFIUN3.cjs.map → chunk-MF5CVIAC.cjs.map} +1 -1
- package/dist/{chunk-J6M2G6TD.js → chunk-MHNBDSWH.js} +6 -6
- package/dist/{chunk-E4J5KKHH.js → chunk-NJSBYUJZ.js} +3 -3
- package/dist/{chunk-YRKPMYI3.cjs → chunk-RALT6D4H.cjs} +15 -15
- package/dist/{chunk-YRKPMYI3.cjs.map → chunk-RALT6D4H.cjs.map} +1 -1
- package/dist/{chunk-ZMFQWMM7.js → chunk-SAJM3PDE.js} +2 -2
- package/dist/{chunk-V3EZQWHQ.cjs → chunk-SMLAYAIW.cjs} +5 -5
- package/dist/{chunk-V3EZQWHQ.cjs.map → chunk-SMLAYAIW.cjs.map} +1 -1
- package/dist/{chunk-5HOII3WB.js → chunk-SOLI7V4I.js} +4 -4
- package/dist/{chunk-WGOATV3P.cjs → chunk-SZ27SV6T.cjs} +73 -73
- package/dist/{chunk-WGOATV3P.cjs.map → chunk-SZ27SV6T.cjs.map} +1 -1
- package/dist/{chunk-ZF5O3KUQ.js → chunk-TYCWPT33.js} +3 -3
- package/dist/{chunk-F4KE5AZO.cjs → chunk-VTTPNZX3.cjs} +40 -40
- package/dist/{chunk-F4KE5AZO.cjs.map → chunk-VTTPNZX3.cjs.map} +1 -1
- package/dist/{chunk-T6ZUBXPA.cjs → chunk-VXM2GCKS.cjs} +12 -12
- package/dist/{chunk-T6ZUBXPA.cjs.map → chunk-VXM2GCKS.cjs.map} +1 -1
- package/dist/{chunk-KZPVN7AZ.js → chunk-XBC5AES6.js} +2 -2
- package/dist/{chunk-KRLBVBYU.cjs → chunk-XVPWVG6N.cjs} +18 -18
- package/dist/{chunk-KRLBVBYU.cjs.map → chunk-XVPWVG6N.cjs.map} +1 -1
- package/dist/{chunk-IVM47EGI.js → chunk-YVJ2XBFU.js} +389 -398
- package/dist/chunk-YVJ2XBFU.js.map +1 -0
- package/dist/{chunk-VUW4G6SU.js → chunk-Z4RKJOFH.js} +3 -3
- package/dist/{chunk-A3RW7M6B.cjs → chunk-ZZWFS7CD.cjs} +29 -29
- package/dist/{chunk-A3RW7M6B.cjs.map → chunk-ZZWFS7CD.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +137 -135
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +14 -12
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/header.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +5 -3
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +1 -1
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +4 -2
- package/dist/components/navigation/mobile-burger-menu.d.ts.map +1 -1
- package/dist/components/navigation/mobile-nav-panel.d.ts +2 -0
- package/dist/components/navigation/mobile-nav-panel.d.ts.map +1 -1
- package/dist/components/navigation/sliding-sidebar.d.ts.map +1 -1
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/modal-v2.d.ts.map +1 -1
- package/dist/components/ui/modal.d.ts.map +1 -1
- package/dist/hooks/index.cjs +6 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +5 -1
- package/dist/hooks/ui/index.d.ts +2 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-focus-trap.d.ts +32 -0
- package/dist/hooks/ui/use-focus-trap.d.ts.map +1 -0
- package/dist/hooks/ui/use-header-height.d.ts +10 -1
- package/dist/hooks/ui/use-header-height.d.ts.map +1 -1
- package/dist/index.cjs +9 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +8 -2
- package/package.json +1 -1
- package/src/components/announcement-bar.tsx +1 -1
- package/src/components/navigation/header.tsx +5 -0
- package/src/components/navigation/index.ts +1 -1
- package/src/components/navigation/mobile-burger-menu.tsx +16 -29
- package/src/components/navigation/mobile-nav-panel.tsx +32 -35
- package/src/components/navigation/sliding-sidebar.tsx +29 -9
- package/src/components/ui/modal-v2.tsx +7 -6
- package/src/components/ui/modal.tsx +7 -9
- package/src/hooks/ui/index.ts +6 -0
- package/src/hooks/ui/use-focus-trap.ts +110 -0
- package/src/hooks/ui/use-header-height.ts +17 -2
- package/src/stories/MobileNavPanel.stories.tsx +150 -0
- package/dist/chunk-7Q52HP45.js.map +0 -1
- package/dist/chunk-IVM47EGI.js.map +0 -1
- package/dist/chunk-SOALFYU6.cjs.map +0 -1
- package/dist/chunk-STGWQFYT.cjs.map +0 -1
- /package/dist/{chunk-O5W26SCL.js.map → chunk-6AB36PRE.js.map} +0 -0
- /package/dist/{chunk-IK5AR6QG.js.map → chunk-B2BERJ4M.js.map} +0 -0
- /package/dist/{chunk-NO2KT42M.js.map → chunk-DGIF3YDV.js.map} +0 -0
- /package/dist/{chunk-LQWYXIKB.js.map → chunk-FXZO4EKN.js.map} +0 -0
- /package/dist/{chunk-L6OPVKVR.js.map → chunk-HXRULNWA.js.map} +0 -0
- /package/dist/{chunk-J6M2G6TD.js.map → chunk-MHNBDSWH.js.map} +0 -0
- /package/dist/{chunk-E4J5KKHH.js.map → chunk-NJSBYUJZ.js.map} +0 -0
- /package/dist/{chunk-ZMFQWMM7.js.map → chunk-SAJM3PDE.js.map} +0 -0
- /package/dist/{chunk-5HOII3WB.js.map → chunk-SOLI7V4I.js.map} +0 -0
- /package/dist/{chunk-ZF5O3KUQ.js.map → chunk-TYCWPT33.js.map} +0 -0
- /package/dist/{chunk-KZPVN7AZ.js.map → chunk-XBC5AES6.js.map} +0 -0
- /package/dist/{chunk-VUW4G6SU.js.map → chunk-Z4RKJOFH.js.map} +0 -0
|
@@ -10,7 +10,7 @@ export type { ClientOnlyHeaderProps } from './client-only-header'
|
|
|
10
10
|
export { HeaderSkeleton } from './header-skeleton'
|
|
11
11
|
export type { HeaderSkeletonProps } from './header-skeleton'
|
|
12
12
|
|
|
13
|
-
export { MobileNavPanel } from './mobile-nav-panel'
|
|
13
|
+
export { MobileNavPanel, MOBILE_NAV_PANEL_ID } from './mobile-nav-panel'
|
|
14
14
|
export type { MobileNavPanelProps } from './mobile-nav-panel'
|
|
15
15
|
|
|
16
16
|
export { SlidingSidebar } from './sliding-sidebar'
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React, { useCallback,
|
|
3
|
+
import React, { useCallback, useRef } from 'react'
|
|
4
|
+
import { usePreventScroll } from '@react-aria/overlays'
|
|
4
5
|
import { NavigationSidebarConfig, NavigationSidebarItem } from '../../types/navigation'
|
|
5
6
|
import { cn } from '../../utils'
|
|
7
|
+
import { useFocusTrap } from '../../hooks/ui/use-focus-trap'
|
|
6
8
|
import { Logout02Icon, PenEditIcon, UserSearchIcon } from '../icons-v2-generated'
|
|
7
9
|
import { Button, SquareAvatar } from '../ui'
|
|
8
10
|
import { OVERLAY_BACKDROP_CLASS } from '../ui/drawer'
|
|
@@ -47,32 +49,12 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
47
49
|
onLogout,
|
|
48
50
|
disabled = false,
|
|
49
51
|
}: MobileBurgerMenuProps) {
|
|
50
|
-
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
if (isOpen) {
|
|
53
|
-
document.body.style.overflow = 'hidden'
|
|
54
|
-
} else {
|
|
55
|
-
document.body.style.overflow = 'unset'
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return () => {
|
|
59
|
-
document.body.style.overflow = 'unset'
|
|
60
|
-
}
|
|
61
|
-
}, [isOpen])
|
|
52
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
62
53
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
onClose()
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (isOpen) {
|
|
72
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
73
|
-
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
74
|
-
}
|
|
75
|
-
}, [isOpen, onClose])
|
|
54
|
+
// Shared ref-counted, iOS-aware scroll lock (react-aria) while open.
|
|
55
|
+
usePreventScroll({ isDisabled: !isOpen })
|
|
56
|
+
// Initial focus, Tab containment, Escape-to-close, guarded focus restore.
|
|
57
|
+
useFocusTrap(panelRef, isOpen, { onEscape: onClose })
|
|
76
58
|
|
|
77
59
|
const handleItemClick = useCallback((item: NavigationSidebarItem) => {
|
|
78
60
|
if (item.onClick) {
|
|
@@ -165,7 +147,7 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
165
147
|
className={cn(
|
|
166
148
|
"absolute inset-0 z-[100] md:hidden",
|
|
167
149
|
OVERLAY_BACKDROP_CLASS,
|
|
168
|
-
"transition-all duration-300",
|
|
150
|
+
"transition-all duration-300 motion-reduce:transition-none",
|
|
169
151
|
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
170
152
|
)}
|
|
171
153
|
style={{ top: HEADER_HEIGHT }}
|
|
@@ -176,11 +158,12 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
176
158
|
{/* Menu Panel - positioned below header with slide-down animation. `absolute`
|
|
177
159
|
for the same reason as the backdrop above (anchors below the topBar). */}
|
|
178
160
|
<div
|
|
161
|
+
ref={panelRef}
|
|
179
162
|
className={cn(
|
|
180
163
|
"absolute left-0 right-0 z-[101] md:hidden",
|
|
181
164
|
"flex flex-col",
|
|
182
165
|
"bg-ods-bg border-b border-ods-border",
|
|
183
|
-
"transition-all duration-300 ease-out",
|
|
166
|
+
"transition-all duration-300 ease-out motion-reduce:transition-none",
|
|
184
167
|
"overflow-hidden",
|
|
185
168
|
isOpen
|
|
186
169
|
? "opacity-100 translate-y-0"
|
|
@@ -193,9 +176,13 @@ export const MobileBurgerMenu = React.memo(function MobileBurgerMenu({
|
|
|
193
176
|
role="dialog"
|
|
194
177
|
aria-modal="true"
|
|
195
178
|
aria-label="Mobile navigation menu"
|
|
179
|
+
tabIndex={-1}
|
|
180
|
+
// Stays mounted while hidden (opacity-0) — inert keeps its items out
|
|
181
|
+
// of the tab order when closed.
|
|
182
|
+
inert={!isOpen || undefined}
|
|
196
183
|
>
|
|
197
184
|
{/* Scrollable Content */}
|
|
198
|
-
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 flex flex-col gap-4">
|
|
185
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain p-4 flex flex-col gap-4">
|
|
199
186
|
{/* User Card */}
|
|
200
187
|
{user && (
|
|
201
188
|
<div
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React, {
|
|
3
|
+
import React, { useRef } from 'react'
|
|
4
|
+
import { usePreventScroll } from '@react-aria/overlays'
|
|
4
5
|
import { cn } from '../../utils'
|
|
5
6
|
import { MobileNavConfig, NavigationItem } from '../../types/navigation'
|
|
6
7
|
import { Button } from '../ui/button'
|
|
8
|
+
import { OVERLAY_BACKDROP_CLASS } from '../ui/drawer'
|
|
9
|
+
import { useFocusTrap } from '../../hooks/ui/use-focus-trap'
|
|
10
|
+
import { useHeaderHeight } from '../../hooks/ui/use-header-height'
|
|
7
11
|
import { X } from 'lucide-react'
|
|
8
12
|
|
|
13
|
+
/** DOM id of the panel — referenced by the header hamburger's `aria-controls`. */
|
|
14
|
+
export const MOBILE_NAV_PANEL_ID = 'mobile-nav-panel'
|
|
15
|
+
|
|
9
16
|
export interface MobileNavPanelProps {
|
|
10
17
|
isOpen: boolean
|
|
11
18
|
config: MobileNavConfig
|
|
@@ -14,32 +21,13 @@ export interface MobileNavPanelProps {
|
|
|
14
21
|
export function MobileNavPanel({ isOpen, config }: MobileNavPanelProps) {
|
|
15
22
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
16
23
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return () => {
|
|
26
|
-
document.body.style.overflow = 'unset'
|
|
27
|
-
}
|
|
28
|
-
}, [isOpen])
|
|
29
|
-
|
|
30
|
-
// Handle escape key
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
33
|
-
if (e.key === 'Escape' && isOpen) {
|
|
34
|
-
config.onClose?.()
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (isOpen) {
|
|
39
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
40
|
-
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
41
|
-
}
|
|
42
|
-
}, [isOpen, config])
|
|
24
|
+
// Shared ref-counted, iOS-aware scroll lock (react-aria) — one counter with
|
|
25
|
+
// the modals and chat overlays, restores prior styles on release.
|
|
26
|
+
usePreventScroll({ isDisabled: !isOpen })
|
|
27
|
+
// Initial focus, Tab containment, Escape-to-close, guarded focus restore.
|
|
28
|
+
useFocusTrap(panelRef, isOpen, { onEscape: config.onClose })
|
|
29
|
+
// The panel is layout-mounted on every page — observers only while open.
|
|
30
|
+
const headerHeight = useHeaderHeight(64, { enabled: isOpen })
|
|
43
31
|
|
|
44
32
|
if (!isOpen) return null
|
|
45
33
|
|
|
@@ -99,21 +87,30 @@ export function MobileNavPanel({ isOpen, config }: MobileNavPanelProps) {
|
|
|
99
87
|
<>
|
|
100
88
|
{/* Backdrop - closes nav when clicked outside */}
|
|
101
89
|
<div
|
|
102
|
-
className="fixed inset-0 z-[9998]
|
|
90
|
+
className={cn("fixed inset-0 z-[9998]", OVERLAY_BACKDROP_CLASS)}
|
|
103
91
|
onClick={config.onClose}
|
|
104
92
|
/>
|
|
105
93
|
|
|
106
|
-
{/* Navigation Panel -
|
|
94
|
+
{/* Navigation Panel - top-anchored floating card */}
|
|
107
95
|
<div
|
|
108
96
|
ref={panelRef}
|
|
97
|
+
id={MOBILE_NAV_PANEL_ID}
|
|
98
|
+
role="dialog"
|
|
99
|
+
aria-modal="true"
|
|
100
|
+
aria-label="Navigation menu"
|
|
101
|
+
tabIndex={-1}
|
|
102
|
+
style={{ '--mobile-nav-top': `${headerHeight + 16}px` } as React.CSSProperties}
|
|
109
103
|
className={cn(
|
|
110
104
|
"fixed z-[9999] rounded-lg shadow-xl",
|
|
111
105
|
config.className ? "" : "bg-ods-card border border-ods-border",
|
|
112
|
-
// Responsive positioning and sizing
|
|
106
|
+
// Responsive positioning and sizing
|
|
113
107
|
"right-2 left-2 md:left-auto",
|
|
114
108
|
"md:right-6 md:w-[400px] md:max-w-[calc(100vw-3rem)]",
|
|
115
|
-
//
|
|
116
|
-
|
|
109
|
+
// Small-viewport (svh) sizing anchored below the measured header —
|
|
110
|
+
// 100vh overflowed under mobile browser chrome, leaving the bottom
|
|
111
|
+
// entries and footer unreachable while the body was scroll-locked.
|
|
112
|
+
"top-[var(--mobile-nav-top)]",
|
|
113
|
+
"max-h-[calc(100svh-var(--mobile-nav-top)-50px)] md:max-h-[calc(100svh-var(--mobile-nav-top)-8px)]",
|
|
117
114
|
"flex flex-col",
|
|
118
115
|
config.className || ""
|
|
119
116
|
)}
|
|
@@ -132,7 +129,7 @@ export function MobileNavPanel({ isOpen, config }: MobileNavPanelProps) {
|
|
|
132
129
|
</div>
|
|
133
130
|
|
|
134
131
|
{/* Scrollable content area */}
|
|
135
|
-
<div className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2">
|
|
132
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-2 py-2">
|
|
136
133
|
<div className="flex flex-col space-y-1">
|
|
137
134
|
{config.sections.map((section, index) => (
|
|
138
135
|
<div key={index}>
|
|
@@ -149,9 +146,9 @@ export function MobileNavPanel({ isOpen, config }: MobileNavPanelProps) {
|
|
|
149
146
|
</div>
|
|
150
147
|
</div>
|
|
151
148
|
|
|
152
|
-
{/* Footer with action button - fixed at bottom */}
|
|
149
|
+
{/* Footer with action button - fixed at bottom, clears the iOS home indicator */}
|
|
153
150
|
{config.footer && (
|
|
154
|
-
<div className="border-t border-ods-border p-4 flex-shrink-0">
|
|
151
|
+
<div className="border-t border-ods-border p-4 pb-[max(1rem,env(safe-area-inset-bottom))] flex-shrink-0">
|
|
155
152
|
{config.footer}
|
|
156
153
|
</div>
|
|
157
154
|
)}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect } from 'react'
|
|
4
|
-
import { motion, AnimatePresence } from 'framer-motion'
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react'
|
|
4
|
+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'
|
|
5
|
+
import { usePreventScroll } from '@react-aria/overlays'
|
|
5
6
|
import { cn } from '../../utils'
|
|
6
7
|
import { useHeaderHeight } from '../../hooks/ui'
|
|
8
|
+
import { useFocusTrap } from '../../hooks/ui/use-focus-trap'
|
|
7
9
|
import { SlidingSidebarConfig, NavigationItem } from '../../types/navigation'
|
|
8
10
|
import { Button } from '../ui/button'
|
|
11
|
+
import { OVERLAY_BACKDROP_CLASS } from '../ui/drawer'
|
|
9
12
|
|
|
10
13
|
export interface SlidingSidebarProps {
|
|
11
14
|
config: SlidingSidebarConfig
|
|
@@ -15,6 +18,15 @@ export function SlidingSidebar({ config }: SlidingSidebarProps) {
|
|
|
15
18
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
|
|
16
19
|
const [mounted, setMounted] = useState(false)
|
|
17
20
|
const headerHeight = useHeaderHeight()
|
|
21
|
+
const asideRef = useRef<HTMLElement>(null)
|
|
22
|
+
const prefersReducedMotion = useReducedMotion()
|
|
23
|
+
|
|
24
|
+
// Shared ref-counted scroll lock (react-aria) while the drawer is open.
|
|
25
|
+
usePreventScroll({ isDisabled: !config.isOpen })
|
|
26
|
+
// Escape + initial focus + guarded restore. `contain: false`: the z-50
|
|
27
|
+
// header stays visible and clickable ABOVE the open drawer (see the z-tier
|
|
28
|
+
// comment below), so this is a NON-modal dialog — Tab may leave freely.
|
|
29
|
+
useFocusTrap(asideRef, config.isOpen, { onEscape: config.onClose, contain: false })
|
|
18
30
|
|
|
19
31
|
useEffect(() => { setMounted(true) }, [])
|
|
20
32
|
|
|
@@ -170,7 +182,7 @@ export function SlidingSidebar({ config }: SlidingSidebarProps) {
|
|
|
170
182
|
duration: 0.3,
|
|
171
183
|
ease: [0.4, 0, 0.2, 1]
|
|
172
184
|
}}
|
|
173
|
-
className="fixed inset-0
|
|
185
|
+
className={cn("fixed inset-0 z-[45]", OVERLAY_BACKDROP_CLASS)}
|
|
174
186
|
onClick={() => config.onClose()}
|
|
175
187
|
/>
|
|
176
188
|
)}
|
|
@@ -178,18 +190,26 @@ export function SlidingSidebar({ config }: SlidingSidebarProps) {
|
|
|
178
190
|
|
|
179
191
|
{/* Sliding Sidebar */}
|
|
180
192
|
<motion.aside
|
|
193
|
+
ref={asideRef}
|
|
181
194
|
initial={false}
|
|
182
195
|
animate={{
|
|
183
196
|
x: config.isOpen ? 0 : (config.position === 'right' ? "100%" : "-100%")
|
|
184
197
|
}}
|
|
185
|
-
transition={{
|
|
186
|
-
type: "spring",
|
|
187
|
-
damping: 25,
|
|
198
|
+
transition={prefersReducedMotion ? { duration: 0 } : {
|
|
199
|
+
type: "spring",
|
|
200
|
+
damping: 25,
|
|
188
201
|
stiffness: 300,
|
|
189
202
|
mass: 0.8,
|
|
190
203
|
restDelta: 0.01,
|
|
191
204
|
velocity: config.isOpen ? 5 : -5
|
|
192
205
|
}}
|
|
206
|
+
tabIndex={-1}
|
|
207
|
+
// Stays mounted translated off-screen — inert keeps its items out of
|
|
208
|
+
// the tab order while "closed". Non-modal dialog: no aria-modal (the
|
|
209
|
+
// header above remains reachable by design).
|
|
210
|
+
inert={!config.isOpen || undefined}
|
|
211
|
+
role={config.isOpen ? 'dialog' : undefined}
|
|
212
|
+
aria-label={config.isOpen ? 'Navigation sidebar' : undefined}
|
|
193
213
|
className={cn(
|
|
194
214
|
"fixed top-0 bottom-0 z-[46] w-72 bg-ods-card border-ods-border flex flex-col shadow-xl",
|
|
195
215
|
config.position === 'right' ? "right-0 border-l" : "left-0 border-r",
|
|
@@ -200,13 +220,13 @@ export function SlidingSidebar({ config }: SlidingSidebarProps) {
|
|
|
200
220
|
<div className="flex-shrink-0" style={{ height: `${headerHeight}px` }} />
|
|
201
221
|
|
|
202
222
|
{/* Navigation */}
|
|
203
|
-
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
|
|
223
|
+
<nav className="flex-1 p-4 space-y-2 overflow-y-auto overscroll-contain">
|
|
204
224
|
{config.items.map(item => renderMenuItem(item))}
|
|
205
225
|
</nav>
|
|
206
226
|
|
|
207
|
-
{/* Footer */}
|
|
227
|
+
{/* Footer - bottom padding clears the iOS home indicator */}
|
|
208
228
|
{config.footer && (
|
|
209
|
-
<div className="p-4 border-t border-ods-border">
|
|
229
|
+
<div className="p-4 pb-[max(1rem,env(safe-area-inset-bottom))] border-t border-ods-border">
|
|
210
230
|
{config.footer}
|
|
211
231
|
</div>
|
|
212
232
|
)}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { useEffect, useState } from "react"
|
|
5
|
+
import { usePreventScroll } from "@react-aria/overlays"
|
|
5
6
|
import { XmarkIcon } from "../icons-v2-generated"
|
|
6
7
|
import { cn } from "../../utils/cn"
|
|
7
8
|
|
|
@@ -52,6 +53,11 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
52
53
|
return () => clearTimeout(timeout)
|
|
53
54
|
}, [isOpen])
|
|
54
55
|
|
|
56
|
+
// Shared ref-counted scroll lock (react-aria) — restores prior styles on
|
|
57
|
+
// release instead of clobbering to 'unset'.
|
|
58
|
+
usePreventScroll({ isDisabled: !isOpen })
|
|
59
|
+
|
|
60
|
+
// Escape key (document-level: top-of-stack semantics for modals)
|
|
55
61
|
useEffect(() => {
|
|
56
62
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
57
63
|
if (event.key === 'Escape') {
|
|
@@ -60,13 +66,8 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
if (isOpen) {
|
|
63
|
-
document.body.style.overflow = 'hidden'
|
|
64
69
|
document.addEventListener('keydown', handleKeyDown)
|
|
65
|
-
|
|
66
|
-
return () => {
|
|
67
|
-
document.body.style.overflow = 'unset'
|
|
68
|
-
document.removeEventListener('keydown', handleKeyDown)
|
|
69
|
-
}
|
|
70
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
70
71
|
}
|
|
71
72
|
}, [isOpen, onClose])
|
|
72
73
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { useEffect } from "react"
|
|
5
|
+
import { usePreventScroll } from "@react-aria/overlays"
|
|
5
6
|
import { cn } from "../../utils/cn"
|
|
6
7
|
|
|
7
8
|
interface ModalProps {
|
|
@@ -34,7 +35,11 @@ interface ModalFooterProps {
|
|
|
34
35
|
/** @deprecated Use ModalV2 from './modal-v2' instead. */
|
|
35
36
|
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
36
37
|
({ isOpen, onClose, children, className }, ref) => {
|
|
37
|
-
//
|
|
38
|
+
// Shared ref-counted scroll lock (react-aria) — restores prior styles on
|
|
39
|
+
// release instead of clobbering to 'unset'.
|
|
40
|
+
usePreventScroll({ isDisabled: !isOpen })
|
|
41
|
+
|
|
42
|
+
// Handle Escape key (document-level: top-of-stack semantics for modals)
|
|
38
43
|
useEffect(() => {
|
|
39
44
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
40
45
|
if (event.key === 'Escape') {
|
|
@@ -43,15 +48,8 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
if (isOpen) {
|
|
46
|
-
// Block background scrolling
|
|
47
|
-
document.body.style.overflow = 'hidden'
|
|
48
51
|
document.addEventListener('keydown', handleKeyDown)
|
|
49
|
-
|
|
50
|
-
return () => {
|
|
51
|
-
// Restore scrolling
|
|
52
|
-
document.body.style.overflow = 'unset'
|
|
53
|
-
document.removeEventListener('keydown', handleKeyDown)
|
|
54
|
-
}
|
|
52
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
55
53
|
}
|
|
56
54
|
}, [isOpen, onClose])
|
|
57
55
|
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from './use-auto-limit-tags'
|
|
3
3
|
export * from './use-debounce'
|
|
4
4
|
export * from './use-deferred-error'
|
|
5
|
+
export * from './use-focus-trap'
|
|
5
6
|
export * from './use-header-height'
|
|
6
7
|
export * from './use-horizontal-scrollbar'
|
|
7
8
|
export * from './use-image-edge-color'
|
|
@@ -14,3 +15,8 @@ export * from './use-search'
|
|
|
14
15
|
export * from './use-table-pagination'
|
|
15
16
|
export * from './use-throttle'
|
|
16
17
|
export * from './use-window-size'
|
|
18
|
+
|
|
19
|
+
// Shared ref-counted, iOS-aware body scroll lock (react-aria). Re-exported so
|
|
20
|
+
// hub/app consumers have one import path; lib internals may import the
|
|
21
|
+
// package directly (npm dedupes to one module instance either way).
|
|
22
|
+
export { usePreventScroll } from '@react-aria/overlays'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type RefObject } from 'react'
|
|
4
|
+
|
|
5
|
+
const FOCUSABLE_SELECTOR = [
|
|
6
|
+
'a[href]',
|
|
7
|
+
'button:not([disabled])',
|
|
8
|
+
'input:not([disabled])',
|
|
9
|
+
'select:not([disabled])',
|
|
10
|
+
'textarea:not([disabled])',
|
|
11
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
12
|
+
].join(', ')
|
|
13
|
+
|
|
14
|
+
export interface UseFocusTrapOptions {
|
|
15
|
+
/** Called when Escape is pressed while focus is inside the container.
|
|
16
|
+
* When omitted, Escape passes through untouched so document-level
|
|
17
|
+
* listeners still fire. */
|
|
18
|
+
onEscape?: () => void
|
|
19
|
+
/**
|
|
20
|
+
* When false, Tab/Shift+Tab are NOT cycled within the container — for
|
|
21
|
+
* non-modal dialogs (e.g. SlidingSidebar, whose z-50 header stays clickable
|
|
22
|
+
* above the open drawer). Escape / initial focus / guarded restore still apply.
|
|
23
|
+
* @default true
|
|
24
|
+
*/
|
|
25
|
+
contain?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Focus management for overlay surfaces (mobile nav panel, sliding sidebar,
|
|
30
|
+
* burger menu, modals): initial focus on activate, container-scoped Tab
|
|
31
|
+
* cycling + Escape, and guarded focus restore on deactivate.
|
|
32
|
+
*
|
|
33
|
+
* CONTRACT: the container element MUST have `tabIndex={-1}`. After a
|
|
34
|
+
* click/tap on a non-focusable area inside the surface, browsers move focus
|
|
35
|
+
* to the nearest focusable ancestor — with the container script-focusable
|
|
36
|
+
* that ancestor is the container itself, so this keydown listener keeps
|
|
37
|
+
* hearing Escape/Tab. Without it, focus falls to `body` and the trap goes
|
|
38
|
+
* deaf.
|
|
39
|
+
*
|
|
40
|
+
* The listener being container-scoped makes it stacking-safe: it only fires
|
|
41
|
+
* while focus is inside the surface, so a modal open above never leaks its
|
|
42
|
+
* Escape into the surface below.
|
|
43
|
+
*/
|
|
44
|
+
export function useFocusTrap(
|
|
45
|
+
containerRef: RefObject<HTMLElement | null>,
|
|
46
|
+
active: boolean,
|
|
47
|
+
{ onEscape, contain = true }: UseFocusTrapOptions = {}
|
|
48
|
+
): void {
|
|
49
|
+
// Latest-ref so unstable inline callbacks never tear the trap down and
|
|
50
|
+
// re-yank focus — same stable-callback discipline as `useCloseOnNavigation`.
|
|
51
|
+
const onEscapeRef = useRef(onEscape)
|
|
52
|
+
onEscapeRef.current = onEscape
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!active) return
|
|
56
|
+
const container = containerRef.current
|
|
57
|
+
if (!container) return
|
|
58
|
+
|
|
59
|
+
const previouslyFocused = document.activeElement as HTMLElement | null
|
|
60
|
+
|
|
61
|
+
const focusables = () =>
|
|
62
|
+
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
|
63
|
+
.filter((el) => el.getClientRects().length > 0)
|
|
64
|
+
|
|
65
|
+
const first = focusables()[0]
|
|
66
|
+
;(first ?? container).focus({ preventScroll: true })
|
|
67
|
+
|
|
68
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
69
|
+
if (e.key === 'Escape') {
|
|
70
|
+
// No preventDefault/stopPropagation — pass-through by design.
|
|
71
|
+
onEscapeRef.current?.()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (e.key !== 'Tab' || !contain) return
|
|
75
|
+
const items = focusables()
|
|
76
|
+
if (items.length === 0) {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
container.focus({ preventScroll: true })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
const firstEl = items[0]
|
|
82
|
+
const lastEl = items[items.length - 1]
|
|
83
|
+
const current = document.activeElement
|
|
84
|
+
if (e.shiftKey && (current === firstEl || current === container)) {
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
lastEl.focus({ preventScroll: true })
|
|
87
|
+
} else if (!e.shiftKey && current === lastEl) {
|
|
88
|
+
e.preventDefault()
|
|
89
|
+
firstEl.focus({ preventScroll: true })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
container.addEventListener('keydown', handleKeyDown)
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
container.removeEventListener('keydown', handleKeyDown)
|
|
97
|
+
// Guarded restore: only when focus is still inside the surface (or fell
|
|
98
|
+
// to body because the surface unmounted) — never steal focus from a
|
|
99
|
+
// dialog that opened meanwhile (e.g. the nav-footer → SSO modal flow).
|
|
100
|
+
const current = document.activeElement
|
|
101
|
+
if (
|
|
102
|
+
previouslyFocused &&
|
|
103
|
+
previouslyFocused.isConnected &&
|
|
104
|
+
(current === null || current === document.body || container.contains(current))
|
|
105
|
+
) {
|
|
106
|
+
previouslyFocused.focus({ preventScroll: true })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, [containerRef, active, contain])
|
|
110
|
+
}
|
|
@@ -8,10 +8,25 @@ import { useEffect, useState } from 'react'
|
|
|
8
8
|
* Useful for offsetting fixed/absolute-positioned panels so they don't
|
|
9
9
|
* overlap the header.
|
|
10
10
|
*/
|
|
11
|
-
export function useHeaderHeight(
|
|
11
|
+
export function useHeaderHeight(
|
|
12
|
+
defaultHeight = 64,
|
|
13
|
+
options: {
|
|
14
|
+
/**
|
|
15
|
+
* When false, no observers are attached and the default height is
|
|
16
|
+
* returned — for consumers that stay mounted on every page but only need
|
|
17
|
+
* the measurement while an overlay is open (avoids an always-on
|
|
18
|
+
* document-wide MutationObserver + forced reflow).
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
21
|
+
enabled?: boolean
|
|
22
|
+
} = {}
|
|
23
|
+
): number {
|
|
24
|
+
const { enabled = true } = options
|
|
12
25
|
const [height, setHeight] = useState(defaultHeight)
|
|
13
26
|
|
|
14
27
|
useEffect(() => {
|
|
28
|
+
if (!enabled) return
|
|
29
|
+
|
|
15
30
|
const measure = () => {
|
|
16
31
|
let total = 0
|
|
17
32
|
const header = document.querySelector('header')
|
|
@@ -49,7 +64,7 @@ export function useHeaderHeight(defaultHeight = 64): number {
|
|
|
49
64
|
resizeObserver.disconnect()
|
|
50
65
|
mutationObserver.disconnect()
|
|
51
66
|
}
|
|
52
|
-
}, [defaultHeight])
|
|
67
|
+
}, [defaultHeight, enabled])
|
|
53
68
|
|
|
54
69
|
return height
|
|
55
70
|
}
|