@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.
Files changed (125) hide show
  1. package/dist/{chunk-STGWQFYT.cjs → chunk-3SNDISEN.cjs} +92 -30
  2. package/dist/chunk-3SNDISEN.cjs.map +1 -0
  3. package/dist/{chunk-RL344I7V.cjs → chunk-3U4IZF7H.cjs} +7 -7
  4. package/dist/{chunk-RL344I7V.cjs.map → chunk-3U4IZF7H.cjs.map} +1 -1
  5. package/dist/{chunk-7Q52HP45.js → chunk-4FNDOTW2.js} +139 -77
  6. package/dist/chunk-4FNDOTW2.js.map +1 -0
  7. package/dist/{chunk-O5W26SCL.js → chunk-6AB36PRE.js} +2 -2
  8. package/dist/{chunk-IK5AR6QG.js → chunk-B2BERJ4M.js} +6 -6
  9. package/dist/{chunk-SOALFYU6.cjs → chunk-BKYV2ALJ.cjs} +582 -591
  10. package/dist/chunk-BKYV2ALJ.cjs.map +1 -0
  11. package/dist/{chunk-KPGKNUJF.cjs → chunk-BRHCGXOH.cjs} +40 -40
  12. package/dist/{chunk-KPGKNUJF.cjs.map → chunk-BRHCGXOH.cjs.map} +1 -1
  13. package/dist/{chunk-NO2KT42M.js → chunk-DGIF3YDV.js} +2 -2
  14. package/dist/{chunk-LQWYXIKB.js → chunk-FXZO4EKN.js} +5 -5
  15. package/dist/{chunk-CUN4CJUN.cjs → chunk-G2G7HLEM.cjs} +3 -3
  16. package/dist/{chunk-CUN4CJUN.cjs.map → chunk-G2G7HLEM.cjs.map} +1 -1
  17. package/dist/{chunk-NEMUCNCG.cjs → chunk-H4ISMWHG.cjs} +73 -73
  18. package/dist/{chunk-NEMUCNCG.cjs.map → chunk-H4ISMWHG.cjs.map} +1 -1
  19. package/dist/{chunk-L6OPVKVR.js → chunk-HXRULNWA.js} +5 -5
  20. package/dist/{chunk-RPGFIUN3.cjs → chunk-MF5CVIAC.cjs} +32 -32
  21. package/dist/{chunk-RPGFIUN3.cjs.map → chunk-MF5CVIAC.cjs.map} +1 -1
  22. package/dist/{chunk-J6M2G6TD.js → chunk-MHNBDSWH.js} +6 -6
  23. package/dist/{chunk-E4J5KKHH.js → chunk-NJSBYUJZ.js} +3 -3
  24. package/dist/{chunk-YRKPMYI3.cjs → chunk-RALT6D4H.cjs} +15 -15
  25. package/dist/{chunk-YRKPMYI3.cjs.map → chunk-RALT6D4H.cjs.map} +1 -1
  26. package/dist/{chunk-ZMFQWMM7.js → chunk-SAJM3PDE.js} +2 -2
  27. package/dist/{chunk-V3EZQWHQ.cjs → chunk-SMLAYAIW.cjs} +5 -5
  28. package/dist/{chunk-V3EZQWHQ.cjs.map → chunk-SMLAYAIW.cjs.map} +1 -1
  29. package/dist/{chunk-5HOII3WB.js → chunk-SOLI7V4I.js} +4 -4
  30. package/dist/{chunk-WGOATV3P.cjs → chunk-SZ27SV6T.cjs} +73 -73
  31. package/dist/{chunk-WGOATV3P.cjs.map → chunk-SZ27SV6T.cjs.map} +1 -1
  32. package/dist/{chunk-ZF5O3KUQ.js → chunk-TYCWPT33.js} +3 -3
  33. package/dist/{chunk-F4KE5AZO.cjs → chunk-VTTPNZX3.cjs} +40 -40
  34. package/dist/{chunk-F4KE5AZO.cjs.map → chunk-VTTPNZX3.cjs.map} +1 -1
  35. package/dist/{chunk-T6ZUBXPA.cjs → chunk-VXM2GCKS.cjs} +12 -12
  36. package/dist/{chunk-T6ZUBXPA.cjs.map → chunk-VXM2GCKS.cjs.map} +1 -1
  37. package/dist/{chunk-KZPVN7AZ.js → chunk-XBC5AES6.js} +2 -2
  38. package/dist/{chunk-KRLBVBYU.cjs → chunk-XVPWVG6N.cjs} +18 -18
  39. package/dist/{chunk-KRLBVBYU.cjs.map → chunk-XVPWVG6N.cjs.map} +1 -1
  40. package/dist/{chunk-IVM47EGI.js → chunk-YVJ2XBFU.js} +389 -398
  41. package/dist/chunk-YVJ2XBFU.js.map +1 -0
  42. package/dist/{chunk-VUW4G6SU.js → chunk-Z4RKJOFH.js} +3 -3
  43. package/dist/{chunk-A3RW7M6B.cjs → chunk-ZZWFS7CD.cjs} +29 -29
  44. package/dist/{chunk-A3RW7M6B.cjs.map → chunk-ZZWFS7CD.cjs.map} +1 -1
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/index.cjs +3 -3
  58. package/dist/components/features/index.js +2 -2
  59. package/dist/components/help-center-pages/index.cjs +22 -22
  60. package/dist/components/help-center-pages/index.js +13 -13
  61. package/dist/components/index.cjs +137 -135
  62. package/dist/components/index.cjs.map +1 -1
  63. package/dist/components/index.js +14 -12
  64. package/dist/components/index.js.map +1 -1
  65. package/dist/components/navigation/header.d.ts.map +1 -1
  66. package/dist/components/navigation/index.cjs +5 -3
  67. package/dist/components/navigation/index.cjs.map +1 -1
  68. package/dist/components/navigation/index.d.ts +1 -1
  69. package/dist/components/navigation/index.d.ts.map +1 -1
  70. package/dist/components/navigation/index.js +4 -2
  71. package/dist/components/navigation/mobile-burger-menu.d.ts.map +1 -1
  72. package/dist/components/navigation/mobile-nav-panel.d.ts +2 -0
  73. package/dist/components/navigation/mobile-nav-panel.d.ts.map +1 -1
  74. package/dist/components/navigation/sliding-sidebar.d.ts.map +1 -1
  75. package/dist/components/onboarding-guides/index.cjs +7 -7
  76. package/dist/components/onboarding-guides/index.js +6 -6
  77. package/dist/components/related-content/index.cjs +5 -5
  78. package/dist/components/related-content/index.js +4 -4
  79. package/dist/components/tickets/index.cjs +6 -6
  80. package/dist/components/tickets/index.js +5 -5
  81. package/dist/components/ui/index.cjs +3 -3
  82. package/dist/components/ui/index.js +2 -2
  83. package/dist/components/ui/modal-v2.d.ts.map +1 -1
  84. package/dist/components/ui/modal.d.ts.map +1 -1
  85. package/dist/hooks/index.cjs +6 -2
  86. package/dist/hooks/index.cjs.map +1 -1
  87. package/dist/hooks/index.js +5 -1
  88. package/dist/hooks/ui/index.d.ts +2 -0
  89. package/dist/hooks/ui/index.d.ts.map +1 -1
  90. package/dist/hooks/ui/use-focus-trap.d.ts +32 -0
  91. package/dist/hooks/ui/use-focus-trap.d.ts.map +1 -0
  92. package/dist/hooks/ui/use-header-height.d.ts +10 -1
  93. package/dist/hooks/ui/use-header-height.d.ts.map +1 -1
  94. package/dist/index.cjs +9 -3
  95. package/dist/index.cjs.map +1 -1
  96. package/dist/index.js +8 -2
  97. package/package.json +1 -1
  98. package/src/components/announcement-bar.tsx +1 -1
  99. package/src/components/navigation/header.tsx +5 -0
  100. package/src/components/navigation/index.ts +1 -1
  101. package/src/components/navigation/mobile-burger-menu.tsx +16 -29
  102. package/src/components/navigation/mobile-nav-panel.tsx +32 -35
  103. package/src/components/navigation/sliding-sidebar.tsx +29 -9
  104. package/src/components/ui/modal-v2.tsx +7 -6
  105. package/src/components/ui/modal.tsx +7 -9
  106. package/src/hooks/ui/index.ts +6 -0
  107. package/src/hooks/ui/use-focus-trap.ts +110 -0
  108. package/src/hooks/ui/use-header-height.ts +17 -2
  109. package/src/stories/MobileNavPanel.stories.tsx +150 -0
  110. package/dist/chunk-7Q52HP45.js.map +0 -1
  111. package/dist/chunk-IVM47EGI.js.map +0 -1
  112. package/dist/chunk-SOALFYU6.cjs.map +0 -1
  113. package/dist/chunk-STGWQFYT.cjs.map +0 -1
  114. /package/dist/{chunk-O5W26SCL.js.map → chunk-6AB36PRE.js.map} +0 -0
  115. /package/dist/{chunk-IK5AR6QG.js.map → chunk-B2BERJ4M.js.map} +0 -0
  116. /package/dist/{chunk-NO2KT42M.js.map → chunk-DGIF3YDV.js.map} +0 -0
  117. /package/dist/{chunk-LQWYXIKB.js.map → chunk-FXZO4EKN.js.map} +0 -0
  118. /package/dist/{chunk-L6OPVKVR.js.map → chunk-HXRULNWA.js.map} +0 -0
  119. /package/dist/{chunk-J6M2G6TD.js.map → chunk-MHNBDSWH.js.map} +0 -0
  120. /package/dist/{chunk-E4J5KKHH.js.map → chunk-NJSBYUJZ.js.map} +0 -0
  121. /package/dist/{chunk-ZMFQWMM7.js.map → chunk-SAJM3PDE.js.map} +0 -0
  122. /package/dist/{chunk-5HOII3WB.js.map → chunk-SOLI7V4I.js.map} +0 -0
  123. /package/dist/{chunk-ZF5O3KUQ.js.map → chunk-TYCWPT33.js.map} +0 -0
  124. /package/dist/{chunk-KZPVN7AZ.js.map → chunk-XBC5AES6.js.map} +0 -0
  125. /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, useEffect } from 'react'
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
- // Prevent body scroll when menu is open
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
- // Handle escape key
64
- useEffect(() => {
65
- const handleKeyDown = (e: KeyboardEvent) => {
66
- if (e.key === 'Escape' && isOpen) {
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, { useEffect, useRef } from '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
- // Prevent body scroll when menu is open - using the original working approach
18
- useEffect(() => {
19
- if (isOpen) {
20
- document.body.style.overflow = 'hidden'
21
- } else {
22
- document.body.style.overflow = 'unset'
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] bg-black/50"
90
+ className={cn("fixed inset-0 z-[9998]", OVERLAY_BACKDROP_CLASS)}
103
91
  onClick={config.onClose}
104
92
  />
105
93
 
106
- {/* Navigation Panel - modal style like the original working version */}
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 - matching original working version
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
- // Height constraints with proper mobile spacing - increased top position
116
- "top-20 max-h-[calc(100vh-130px)] md:max-h-[calc(100vh-88px)]",
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 bg-black/50 z-[45]"
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
- // Handle Escape key and scroll blocking
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
 
@@ -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(defaultHeight = 64): number {
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
  }