Skip to content

Commit 224b833

Browse files
hyochanclaude
andcommitted
fix(docs): harden mobile sidebar drawer for iOS Safari
Several rounds of "z-index 9001 should beat the top nav's 100" still left the docs Menu button un-tappable, the top nav hamburger overlapping the docs sidebar toggle, and (the final straw) a closed drawer still absorbing taps on its child links because `visibility: hidden; opacity: 0` alone doesn't stop iOS Safari from delivering touches to a fixed subtree. This squashes the iterative fixes into one coherent change: - Hide the top nav hamburger on /docs routes so the docs sidebar toggle is the only "open menu" affordance there. - Push the toggle below the sticky top nav (top: 70px, scrolled: 64px) and portal it to document.body so it sits outside any ancestor's stacking context. - Unmount the toggle while the drawer is open — leaving it in the DOM with `.hidden { pointer-events: none }` still let iOS Safari swallow taps meant for the drawer's "APIs" / open child rows underneath. - Slide the closed drawer off-screen (translateX(-100%)) and disable pointer-events on it so child links stop firing while it looks closed. - Bump baseline-browser-mapping to ^2.10.22 to silence the "data is over two months old" warning from browserslist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ab49572 commit 224b833

5 files changed

Lines changed: 163 additions & 100 deletions

File tree

bun.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@typescript-eslint/parser": "^8.39.1",
3737
"@vitejs/plugin-react": "^4.3.1",
3838
"babel-plugin-react-compiler": "^19.1.0-rc.2",
39+
"baseline-browser-mapping": "^2.10.22",
3940
"eslint": "^9.21.0",
4041
"eslint-plugin-react-hooks": "^5.1.0",
4142
"eslint-plugin-react-refresh": "^0.4.7",

packages/docs/src/components/Navigation.tsx

Lines changed: 98 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Link, NavLink } from 'react-router-dom';
1+
import { Link, NavLink, useLocation } from 'react-router-dom';
22
import { useState, useEffect } from 'react';
33
import { DarkModeToggle } from './DarkModeToggle';
44
import { Menu, X } from 'lucide-react';
@@ -8,11 +8,26 @@ import { IAPKIT_URL, LOGO_PATH, trackIapKitClick } from '../lib/config';
88

99
function Navigation() {
1010
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
11+
const location = useLocation();
12+
// Docs pages have their own sticky "Menu" toggle for the docs sidebar.
13+
// The top nav's mobile hamburger ☰ sits in the same vertical area on
14+
// mobile and was visually-and-tap competing with that toggle — users
15+
// reported tapping the docs Menu button but having the top nav menu
16+
// open instead. Hide the top hamburger on docs routes so the docs
17+
// sidebar toggle is the only "open menu" affordance there.
18+
const isDocsRoute = location.pathname.startsWith('/docs');
1119

1220
const closeMobileMenu = () => {
1321
setIsMobileMenuOpen(false);
1422
};
1523

24+
// Force-close the top nav menu whenever the user navigates onto a
25+
// docs route (covers the "I had the top menu open, then tapped a
26+
// docs link" path).
27+
useEffect(() => {
28+
if (isDocsRoute) setIsMobileMenuOpen(false);
29+
}, [isDocsRoute]);
30+
1631
useEffect(() => {
1732
const handleKeyDown = (e: KeyboardEvent) => {
1833
// Cmd+K or Ctrl+K to open search
@@ -118,84 +133,90 @@ function Navigation() {
118133
<FaGithub size={20} />
119134
</a>
120135

121-
{/* Mobile Menu Button */}
122-
<button
123-
className="mobile-menu-button"
124-
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
125-
aria-label="Toggle mobile menu"
126-
>
127-
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
128-
</button>
136+
{/* Mobile Menu Button — hidden on /docs routes so it can't
137+
compete with the docs sidebar's own Menu toggle. */}
138+
{!isDocsRoute && (
139+
<button
140+
className="mobile-menu-button"
141+
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
142+
aria-label="Toggle mobile menu"
143+
>
144+
{isMobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
145+
</button>
146+
)}
129147
</div>
130148

131-
{/* Mobile Menu Dropdown */}
132-
<div className={`mobile-menu ${isMobileMenuOpen ? 'open' : ''}`}>
133-
<ul className="mobile-nav-list">
134-
<li>
135-
<NavLink
136-
to="/introduction"
137-
className={({ isActive }) => (isActive ? 'active' : '')}
138-
onClick={closeMobileMenu}
139-
>
140-
Introduction
141-
</NavLink>
142-
</li>
143-
144-
<li>
145-
<NavLink
146-
to="/docs"
147-
className={({ isActive }) => (isActive ? 'active' : '')}
148-
onClick={closeMobileMenu}
149-
>
150-
Docs
151-
</NavLink>
152-
</li>
153-
154-
<li>
155-
<NavLink
156-
to="/languages"
157-
className={({ isActive }) => (isActive ? 'active' : '')}
158-
onClick={closeMobileMenu}
159-
>
160-
Languages
161-
</NavLink>
162-
</li>
163-
164-
<li>
165-
<NavLink
166-
to="/tutorials"
167-
className={({ isActive }) => (isActive ? 'active' : '')}
168-
onClick={closeMobileMenu}
169-
>
170-
Tutorials
171-
</NavLink>
172-
</li>
173-
174-
<li>
175-
<NavLink
176-
to="/sponsors"
177-
className={({ isActive }) => (isActive ? 'active' : '')}
178-
onClick={closeMobileMenu}
179-
>
180-
Sponsors
181-
</NavLink>
182-
</li>
183-
184-
<li>
185-
<a
186-
href={IAPKIT_URL}
187-
target="_blank"
188-
rel="noopener noreferrer"
189-
onClick={() => {
190-
trackIapKitClick();
191-
closeMobileMenu();
192-
}}
193-
>
194-
IAPKit
195-
</a>
196-
</li>
197-
</ul>
198-
</div>
149+
{/* Mobile Menu Dropdown — also hidden on /docs to remove its
150+
invisible-but-present 0-height absolute box from the DOM. */}
151+
{!isDocsRoute && (
152+
<div className={`mobile-menu ${isMobileMenuOpen ? 'open' : ''}`}>
153+
<ul className="mobile-nav-list">
154+
<li>
155+
<NavLink
156+
to="/introduction"
157+
className={({ isActive }) => (isActive ? 'active' : '')}
158+
onClick={closeMobileMenu}
159+
>
160+
Introduction
161+
</NavLink>
162+
</li>
163+
164+
<li>
165+
<NavLink
166+
to="/docs"
167+
className={({ isActive }) => (isActive ? 'active' : '')}
168+
onClick={closeMobileMenu}
169+
>
170+
Docs
171+
</NavLink>
172+
</li>
173+
174+
<li>
175+
<NavLink
176+
to="/languages"
177+
className={({ isActive }) => (isActive ? 'active' : '')}
178+
onClick={closeMobileMenu}
179+
>
180+
Languages
181+
</NavLink>
182+
</li>
183+
184+
<li>
185+
<NavLink
186+
to="/tutorials"
187+
className={({ isActive }) => (isActive ? 'active' : '')}
188+
onClick={closeMobileMenu}
189+
>
190+
Tutorials
191+
</NavLink>
192+
</li>
193+
194+
<li>
195+
<NavLink
196+
to="/sponsors"
197+
className={({ isActive }) => (isActive ? 'active' : '')}
198+
onClick={closeMobileMenu}
199+
>
200+
Sponsors
201+
</NavLink>
202+
</li>
203+
204+
<li>
205+
<a
206+
href={IAPKIT_URL}
207+
target="_blank"
208+
rel="noopener noreferrer"
209+
onClick={() => {
210+
trackIapKitClick();
211+
closeMobileMenu();
212+
}}
213+
>
214+
IAPKit
215+
</a>
216+
</li>
217+
</ul>
218+
</div>
219+
)}
199220
</div>
200221
</nav>
201222
);

packages/docs/src/pages/docs/index.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect } from 'react';
2+
import { createPortal } from 'react-dom';
23
import {
34
Route,
45
Routes,
@@ -151,29 +152,49 @@ function Docs() {
151152
return () => window.removeEventListener('scroll', handleScroll);
152153
}, []);
153154

155+
// Portal the sidebar toggle to document.body so it sits OUTSIDE any
156+
// ancestor's stacking context, AND fully unmount it while the drawer
157+
// is open. Earlier rounds left the toggle in the DOM with
158+
// `.hidden { opacity: 0; pointer-events: none }` while the drawer was
159+
// open, which on iOS Safari still let the toggle absorb taps that
160+
// landed on the drawer's first menu item ("APIs" header sits at
161+
// y≈88-120px, exactly where the fixed toggle at top: 70px lives).
162+
// Removing the element from the DOM entirely guarantees the drawer
163+
// items underneath get every tap they should.
164+
const sidebarToggle = isSidebarOpen
165+
? null
166+
: createPortal(
167+
<button
168+
type="button"
169+
className={`docs-sidebar-toggle ${isScrolled ? 'scrolled' : ''}`}
170+
onClick={(event) => {
171+
event.stopPropagation();
172+
setIsSidebarOpen(true);
173+
}}
174+
aria-label="Toggle sidebar"
175+
>
176+
<svg
177+
width="20"
178+
height="20"
179+
viewBox="0 0 20 20"
180+
fill="none"
181+
xmlns="http://www.w3.org/2000/svg"
182+
>
183+
<path
184+
d="M3 5h14M3 10h14M3 15h14"
185+
stroke="currentColor"
186+
strokeWidth="2"
187+
strokeLinecap="round"
188+
/>
189+
</svg>
190+
<span>Menu</span>
191+
</button>,
192+
document.body
193+
);
194+
154195
return (
155196
<div className="docs-container">
156-
<button
157-
className={`docs-sidebar-toggle ${isSidebarOpen ? 'hidden' : ''} ${isScrolled ? 'scrolled' : ''}`}
158-
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
159-
aria-label="Toggle sidebar"
160-
>
161-
<svg
162-
width="20"
163-
height="20"
164-
viewBox="0 0 20 20"
165-
fill="none"
166-
xmlns="http://www.w3.org/2000/svg"
167-
>
168-
<path
169-
d="M3 5h14M3 10h14M3 15h14"
170-
stroke="currentColor"
171-
strokeWidth="2"
172-
strokeLinecap="round"
173-
/>
174-
</svg>
175-
<span>Menu</span>
176-
</button>
197+
{sidebarToggle}
177198

178199
{isSidebarOpen && (
179200
<div className="sidebar-overlay" onClick={closeSidebar}></div>

packages/docs/src/styles/documentation.css

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,8 +896,14 @@
896896
pointer-events: auto;
897897
}
898898

899+
/* When the user scrolls, keep the toggle clear of the sticky top nav
900+
(56px tall) instead of jumping it up to `top: 1rem` which lands the
901+
button INSIDE the nav's bounding box. Even with z-index 9001 vs the
902+
nav's 100, taps in the overlap zone were flaky on mobile WebKit —
903+
keeping the toggle below the nav avoids the geometry collision
904+
entirely. */
899905
.docs-sidebar-toggle.scrolled {
900-
top: 1rem;
906+
top: 64px;
901907
}
902908

903909
.docs-sidebar-toggle svg {
@@ -976,13 +982,22 @@
976982
background: var(--bg-secondary);
977983
z-index: 9000;
978984
transition:
985+
transform 0.25s ease,
979986
opacity 0.2s ease,
980987
visibility 0.2s ease;
981988
border-right: 1px solid var(--border-color);
982989
padding: var(--spacing-xl) 0;
983990
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
991+
/* Closed state: slide fully off-screen, hide visually, AND disable
992+
pointer events. iOS Safari has been observed to still deliver taps
993+
to children of a `visibility: hidden; opacity: 0` fixed element,
994+
so we belt-and-braces it: translateX moves the geometry out of the
995+
hit-test area, and pointer-events: none turns off hit-testing for
996+
this entire subtree even if the transform isn't honored. */
997+
transform: translateX(-100%);
984998
opacity: 0;
985999
visibility: hidden;
1000+
pointer-events: none;
9861001
overflow-y: auto;
9871002
box-sizing: border-box;
9881003
}
@@ -994,8 +1009,10 @@
9941009
}
9951010

9961011
.docs-sidebar.open {
1012+
transform: translateX(0);
9971013
opacity: 1;
9981014
visibility: visible;
1015+
pointer-events: auto;
9991016
}
10001017

10011018
.docs-content {

0 commit comments

Comments
 (0)