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
14 changes: 13 additions & 1 deletion site/src/components/api/ApiPageContents.astro
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ const { sections, related } = Astro.props as Props
}
settleArrival()
}
// Chrome on iOS shrinks innerHeight under the keyboard; while a field has focus keep the tallest height seen at this width.
let tallest = 0
let tallestWidth = 0
const viewportHeight = () => {
const editing = document.activeElement?.matches('textarea, select, [contenteditable]:not([contenteditable="false"]), input:not([type="button"], [type="checkbox"], [type="radio"], [type="submit"], [type="reset"], [type="range"], [type="color"], [type="file"])')
if (innerWidth !== tallestWidth || !editing) { tallestWidth = innerWidth; tallest = innerHeight } else tallest = Math.max(tallest, innerHeight)
return tallest
}
const measureMembers = (readingTop) => {
if (!memberHeading || !members) return
memberHeight = Math.ceil(memberHeading.getBoundingClientRect().height)
Expand All @@ -98,7 +106,7 @@ const { sections, related } = Astro.props as Props
const fontSize = Number.parseFloat(getComputedStyle(root).fontSize) || 16
// Keep a reading area beside the stacked bars, scaled to text size and viewport.
stickyMembers = memberHeight > 0 &&
innerHeight - readingTop - memberHeight - groupHeight >= Math.max(fontSize * 6, innerHeight / 3)
viewportHeight() - readingTop - memberHeight - groupHeight >= Math.max(fontSize * 6, viewportHeight() / 3)
members.toggleAttribute('data-api-sticky-members', stickyMembers)
setLength(members, '--api-member-heading-height', memberHeight)
const last = groupHeadings.at(-1)
Expand Down Expand Up @@ -229,6 +237,10 @@ const { sections, related } = Astro.props as Props
schedule()
}, { signal })
window.addEventListener('resize', schedule, { signal })
window.visualViewport?.addEventListener('resize', schedule, { signal })
// Focus lands before the keyboard shrinks the frame: record the full height first. activeElement is still the old field during focusout, so recompute two frames later.
document.addEventListener('focusin', () => { viewportHeight() }, { signal })
document.addEventListener('focusout', () => requestAnimationFrame(schedule), { signal })
this.observer = new ResizeObserver(schedule)
if (header) this.observer.observe(header)
if (row) this.observer.observe(row)
Expand Down
5 changes: 3 additions & 2 deletions site/src/components/api/api-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* holds the tree's Tab stop.
*/
import { waitForAppearance, layoutBeforeAppearance } from '../../lib/page-readiness'
import { onViewportChange, stableViewportHeight } from '../../lib/stable-viewport'
import { wordBreak } from '../../lib/word-break'
import { kindMark, markClass } from '../../lib/api-labels'
import { apiTreeHref, searchApi, type ApiTreeBucket as JsonBucket, type ApiTreeJson as TreeJson } from '../../lib/api-search'
Expand Down Expand Up @@ -690,7 +691,7 @@ function fitSidebar(nav: HTMLElement, signal: AbortSignal) {
const stickyHeader = header && ['sticky', 'fixed'].includes(getComputedStyle(header).position)
const top = stickyHeader ? Math.max(0, header.getBoundingClientRect().bottom) : 0
const bounds = column.getBoundingClientRect()
const height = Math.max(0, Math.min(window.innerHeight, bounds.bottom) - Math.max(top, bounds.top))
const height = Math.max(0, Math.min(stableViewportHeight(), bounds.bottom) - Math.max(top, bounds.top))
column.style.setProperty('--api-nav-top', `${top}px`)
nav.style.setProperty('--api-nav-height', `${height}px`)
}
Expand All @@ -699,7 +700,7 @@ function fitSidebar(nav: HTMLElement, signal: AbortSignal) {
observer.observe(column)
if (header) observer.observe(header)
window.addEventListener('scroll', schedule, { passive: true, signal })
window.addEventListener('resize', schedule, { signal })
onViewportChange(schedule, signal)
desktop.addEventListener('change', schedule, { signal })
signal.addEventListener('abort', () => { observer.disconnect(); window.cancelAnimationFrame(frame) }, { once: true })
layoutBeforeAppearance(update)
Expand Down
30 changes: 30 additions & 0 deletions site/src/lib/stable-viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const EDITABLE = 'textarea, select, [contenteditable]:not([contenteditable="false"]), input:not([type="button"], [type="checkbox"], [type="radio"], [type="submit"], [type="reset"], [type="range"], [type="color"], [type="file"])'
let width = 0
let tallest = 0

/**
* The layout viewport's height with any on-screen keyboard ignored. Chrome on
* iOS shrinks innerHeight while the keyboard is up, even a floating one, so
* while an editable element has focus this keeps the tallest height seen at
* the current width. Any other time it is innerHeight, and a new width or
* orientation starts over.
*/
export function stableViewportHeight(): number {
const editing = document.activeElement?.matches(EDITABLE) ?? false
if (window.innerWidth !== width || !editing) {
width = window.innerWidth
tallest = window.innerHeight
} else tallest = Math.max(tallest, window.innerHeight)
return tallest
}

/** Run `work` when the keyboard may have opened or closed: now, and a frame later. */
export function onViewportChange(work: () => void, signal: AbortSignal) {
const later = () => window.requestAnimationFrame(() => window.requestAnimationFrame(work))
window.addEventListener('resize', work, { signal })
window.visualViewport?.addEventListener('resize', work, { signal })
// Focus lands before the keyboard shrinks the frame, so record the full height first.
document.addEventListener('focusin', () => { stableViewportHeight(); work() }, { signal })
// activeElement is still the old element during focusout.
document.addEventListener('focusout', later, { signal })
}
83 changes: 83 additions & 0 deletions site/test/stable-viewport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs'
import { Window } from 'happy-dom'
import { afterEach, expect, it, vi } from 'vitest'

const component = readFileSync(new URL('../src/components/api/ApiPageContents.astro', import.meta.url), 'utf8')
const script = component.match(/<script is:inline[^>]*>([\s\S]*?)<\/script>/)![1]
const css = readFileSync(new URL('../src/styles/api.css', import.meta.url), 'utf8')
let win: Window
afterEach(async () => { await win?.happyDOM.abort(); vi.unstubAllGlobals(); vi.restoreAllMocks() })

function frames(target: Window) {
const queue = new Map<number, FrameRequestCallback>()
let next = 0
Object.defineProperties(target, {
requestAnimationFrame: { value: (callback: FrameRequestCallback) => { queue.set(++next, callback); return next } },
cancelAnimationFrame: { value: (id: number) => { queue.delete(id) } },
})
const flush = () => { for (let round = 0; round < 3; round++) for (const [id, callback] of [...queue]) { queue.delete(id); callback(0) } }
return flush
}

/** Chrome on iOS resizes its frame, and so innerHeight, while the keyboard is up. */
function keyboard(height: number) {
win.happyDOM.setWindowSize({ width: win.innerWidth, height })
win.dispatchEvent(new win.Event('resize'))
}

it('keeps the sidebar height while the keyboard shrinks the viewport, and recovers on blur', async () => {
win = new Window({ url: 'https://libtmux.org/en/elixir/latest/reference/libtmux-selection/' })
win.happyDOM.setWindowSize({ width: 1210, height: 696 })
for (const [name, value] of Object.entries({ window: win, document: win.document, location: win.location, CustomEvent: win.CustomEvent, getComputedStyle: win.getComputedStyle.bind(win) })) vi.stubGlobal(name, value)
const flush = frames(win)
Object.defineProperty(win, 'matchMedia', { value: () => ({ matches: true, addEventListener() {} }) })
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
win.document.body.innerHTML = `<div class="api-nav-column"><nav class="api-nav" data-base="/b/" data-src="/t.json" data-version="v1"><ul role="tree"></ul></nav><input id="filter"></div>`
vi.spyOn(win.document.querySelector('.api-nav-column')!, 'getBoundingClientRect').mockImplementation(() => new win.DOMRect(0, 0, 256, 5000))
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ port: 'p', buckets: [], members: {} }) })))
vi.resetModules()
const { initApiTree } = await import('../src/components/api/api-tree')
initApiTree()
flush()
const nav = win.document.querySelector('.api-nav') as unknown as HTMLElement
const filter = win.document.getElementById('filter') as unknown as HTMLInputElement
expect(nav.style.getPropertyValue('--api-nav-height')).toBe('696px')
filter.focus()
keyboard(342)
flush()
expect(nav.style.getPropertyValue('--api-nav-height')).toBe('696px')
filter.blur()
keyboard(696)
flush()
expect(nav.style.getPropertyValue('--api-nav-height')).toBe('696px')
keyboard(500)
flush()
expect(nav.style.getPropertyValue('--api-nav-height')).toBe('500px')
})

it('keeps member bars sticky while the keyboard shrinks the viewport', () => {
win = new Window({ url: 'https://libtmux.org/en/elixir/latest/reference/libtmux-selection/' })
win.happyDOM.setWindowSize({ width: 1210, height: 696 })
win.document.head.innerHTML = `<style>${css}</style>`
win.document.body.innerHTML = `<header class="site-header"></header><main class="api-reference-layout api-reference-layout--symbol"><div class="api-reference-content">
<nav class="api-section-links"><a href="#api-members" data-api-section-link>Members</a></nav>
<section class="api-members"><h2 id="api-members">Members</h2><div class="api-member-group"><h3 id="g">Common</h3></div></section>
<input id="filter"></div><api-page-contents></api-page-contents></main>`
const rect = (selector: string, height: number, top: number) => vi.spyOn(win.document.querySelector(selector)!, 'getBoundingClientRect').mockImplementation(() => new win.DOMRect(0, top, 350, height))
rect('.site-header', 53, 0); rect('.api-section-links', 48, 53); rect('.api-members > h2', 48, 300); rect('.api-member-group > h3', 45, 370); rect('.api-member-group', 400, 370)
const flush = frames(win)
Object.defineProperty(win, 'ResizeObserver', { value: class { observe() {} disconnect() {} } })
win.eval(script)
flush()
const members = win.document.querySelector('.api-members')!
expect(members.hasAttribute('data-api-sticky-members')).toBe(true)
;(win.document.getElementById('filter') as unknown as HTMLInputElement).focus()
keyboard(200)
flush()
expect(members.hasAttribute('data-api-sticky-members')).toBe(true)
;(win.document.getElementById('filter') as unknown as HTMLInputElement).blur()
flush()
keyboard(200)
flush()
expect(members.hasAttribute('data-api-sticky-members')).toBe(false)
})
Loading