Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions docs/src/components/CustomHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,21 @@ const base = import.meta.env.BASE_URL;
.hamburger-icon::after { top: 6px; }

.tablet-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
/* Use fixed positioning so the dropdown anchors to the viewport, not the
(potentially overflowed) header parent. This prevents the menu from
appearing off-screen on Android Chrome when large user fonts are active.
Position is calculated by JS below; defaults are safe fallbacks.
See: https://github.com/github/gh-aw/issues/29545 */
position: fixed;
top: 3.5rem;
right: 1rem;
background: var(--sl-color-bg-nav, #161b22);
border: 1px solid rgba(48, 54, 61, 0.8);
border-radius: 8px;
padding: 0.5rem 0;
min-width: 180px;
z-index: 100;
max-width: calc(100vw - 2rem);
z-index: 1000;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

Expand All @@ -141,13 +147,42 @@ const base = import.meta.env.BASE_URL;

if (!hamburgerBtn || !tabletDropdown) return;

/** Position the dropdown below and flush-right with the button,
* clamped inside the viewport. This is recalculated on each open
* so it stays correct after font-size or layout changes (e.g. Android
* Chrome large-font mode). See: https://github.com/github/gh-aw/issues/29545 */
function positionDropdown() {
const VIEWPORT_EDGE_PADDING = 8; // px gap from viewport edges
// Read min-width from the computed style so this stays in sync with CSS.
const cssMinWidth = parseFloat(getComputedStyle(tabletDropdown).minWidth) || 0;

const rect = hamburgerBtn.getBoundingClientRect();
const dropdownWidth = Math.max(cssMinWidth, tabletDropdown.scrollWidth);
const viewportWidth = window.innerWidth;

// Align right edge of dropdown with right edge of button, then clamp.
let right = viewportWidth - rect.right;
right = Math.max(right, VIEWPORT_EDGE_PADDING);

tabletDropdown.style.top = `${rect.bottom + VIEWPORT_EDGE_PADDING}px`;
tabletDropdown.style.right = `${right}px`;
tabletDropdown.style.left = 'auto';

// If the computed left position would be off-screen, switch to left-align.
const computedLeft = viewportWidth - right - dropdownWidth;
if (computedLeft < VIEWPORT_EDGE_PADDING) {
tabletDropdown.style.left = `${Math.max(rect.left, VIEWPORT_EDGE_PADDING)}px`;
tabletDropdown.style.right = 'auto';
}
}

hamburgerBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = hamburgerBtn.getAttribute('aria-expanded') === 'true';
hamburgerBtn.setAttribute('aria-expanded', String(!isOpen));
tabletDropdown.hidden = isOpen;
// Move focus to first link when opening for keyboard accessibility
if (!isOpen) {
positionDropdown();
const firstLink = tabletDropdown.querySelector<HTMLAnchorElement>('.dropdown-link');
firstLink?.focus();
}
Expand Down
19 changes: 17 additions & 2 deletions docs/src/styles/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@

/* CSS Variables - Theme Colors */
:root {
/* Prevent Android Chrome from automatically inflating text sizes when the user
has set a large system font. Without this, OS-level font scaling can push
header items out of position and cause the navigation dropdown to render
off-screen. See: https://github.com/github/gh-aw/issues/29545 */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;

--sl-font: "Mona Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--sl-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;

Expand Down Expand Up @@ -1551,8 +1558,16 @@ table img[src*="badge.svg"] {
box-sizing: border-box;
}

/* Ensure body and main containers don't overflow */
body, html {
/* Ensure body and main containers don't overflow.
NOTE: overflow-x is intentionally set ONLY on body, not on html.
Setting overflow-x:hidden on the html element breaks position:fixed overlays
(e.g. the mobile sidebar drawer) on Android Chrome with large user fonts.
See: https://github.com/github/gh-aw/issues/29545 */
html {
max-width: 100vw;
}

body {
overflow-x: hidden;
max-width: 100vw;
}
Expand Down
41 changes: 41 additions & 0 deletions docs/tests/mobile-responsive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,45 @@ test.describe('Mobile and Responsive Layout', () => {
});
});
}

// Regression test for https://github.com/github/gh-aw/issues/29545
// Verify the navigation dropdown is fully within the viewport when large
// user fonts cause header elements to shift on Android Chrome.
test('hamburger dropdown stays within viewport with large user fonts', async ({ browser }) => {
const VIEWPORT_WIDTH = 393;
const context = await browser.newContext({
// Simulate Android Chrome with the user's accessibility font size set to
// "Large" — typically 1.3× the default, so override the page root font-size.
viewport: { width: VIEWPORT_WIDTH, height: 852 },
javaScriptEnabled: true,
});
const page = await context.newPage();

await page.goto('/gh-aw/introduction/overview/');
await page.waitForLoadState('networkidle');

// Simulate large OS-level font scaling by overriding the root font size.
// Done after navigation so the document exists and the style tag can attach.
await page.addStyleTag({ content: 'html { font-size: 20px !important; }' });

// The hamburger wrapper should be visible on a narrow mobile viewport.
const hamburgerBtn = page.locator('.hamburger-btn');
await expect(hamburgerBtn).toBeVisible();

// Click the hamburger to open the dropdown.
await hamburgerBtn.click();

const dropdown = page.locator('.tablet-dropdown');
await expect(dropdown).toBeVisible();

// The dropdown must be fully within the viewport horizontally.
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
if (dropdownBox) {
expect(dropdownBox.x).toBeGreaterThanOrEqual(0);
expect(dropdownBox.x + dropdownBox.width).toBeLessThanOrEqual(VIEWPORT_WIDTH + 1); // 1px tolerance
}

await context.close();
});
});