diff --git a/.changeset/fix-tray-keyboard-inset.md b/.changeset/fix-tray-keyboard-inset.md deleted file mode 100644 index ed4344d0..00000000 --- a/.changeset/fix-tray-keyboard-inset.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@luke-ui/react': patch ---- - -Fix the mobile tray sitting behind the iOS on-screen keyboard. diff --git a/.changeset/spectrum-combobox-tray.md b/.changeset/spectrum-combobox-tray.md deleted file mode 100644 index e3cba3cb..00000000 --- a/.changeset/spectrum-combobox-tray.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@luke-ui/react': minor ---- - -Add a Spectrum-style bottom tray for the combobox popover on small viewports. diff --git a/apps/docs/content/docs/components/forms/combobox-field.mdx b/apps/docs/content/docs/components/forms/combobox-field.mdx index 74c7be4f..486c25b1 100644 --- a/apps/docs/content/docs/components/forms/combobox-field.mdx +++ b/apps/docs/content/docs/components/forms/combobox-field.mdx @@ -113,22 +113,9 @@ over the load-more row. ``` -## Mobile tray - -Below the `small` breakpoint (640px) the popover renders as a bottom tray instead of a positioned -popover, following -[Adobe Spectrum's combobox pattern](https://react-aria.adobe.com/blog/building-a-combobox). The tray -spans the full viewport width, slides up from the bottom edge on open and back down on close, and -keeps clear of the on-screen keyboard using the -[Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API). A scrim -dims the page behind the tray, and a grab-handle affordance marks its top edge. In browsers that -support `calc-size()`, the tray keeps a usable minimum height even when the viewport is cramped, -while still hugging shorter lists instead of leaving empty space. - ## Accessibility -The tray's slide transition (and the desktop popover's fade) is disabled for users who request -`prefers-reduced-motion: reduce`. +The popover's fade transition is disabled for users who request `prefers-reduced-motion: reduce`. ## Primitive diff --git a/apps/docs/content/docs/components/primitives/combobox.mdx b/apps/docs/content/docs/components/primitives/combobox.mdx index 3d64f630..08b8b4aa 100644 --- a/apps/docs/content/docs/components/primitives/combobox.mdx +++ b/apps/docs/content/docs/components/primitives/combobox.mdx @@ -21,10 +21,7 @@ import { ## Anatomy `ComboboxInput` owns the React Aria `ComboBox` state. Render the control, popover, listbox, and -items inside it. Below the `small` breakpoint `ComboboxPopover` renders as a full-width bottom tray -instead of a positioned popover; see -[Mobile tray](/docs/components/forms/combobox-field#mobile-tray) on the `ComboboxField` page for -details. +items inside it. `ComboboxPopover` positions the listbox relative to the control. ```tsx diff --git a/apps/docs/package.json b/apps/docs/package.json index bff795bc..59b70da5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,7 @@ "check:lint": "vp lint . --type-aware", "check:types": "fumadocs-mdx && tsc --noEmit", "clean": "rm -rf .source .tanstack .turbo .output dist src/routeTree.gen.ts", - "dev": "vp dev", + "dev": "vp dev --host", "fix:format": "vp fmt . --write", "fix:lint": "vp lint . --type-aware --fix", "fix:unsafe": "vp lint . --type-aware --fix-dangerously", diff --git a/packages/@luke-ui/react/inspect-combobox.mjs b/packages/@luke-ui/react/inspect-combobox.mjs new file mode 100644 index 00000000..a0e5f300 --- /dev/null +++ b/packages/@luke-ui/react/inspect-combobox.mjs @@ -0,0 +1,61 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); +await page.goto('http://localhost:3001/docs/components/forms/combobox-field', { waitUntil: 'networkidle' }); + +// Basic example +const basicInput = page.getByRole('combobox', { name: 'Country' }).first(); +await basicInput.click(); +await page.waitForTimeout(500); + +const popovers = await page.$$('[role="listbox"]'); +console.log('listbox count after opening first combobox:', popovers.length); + +const info = await page.evaluate(() => { + const listbox = document.querySelector('[role="listbox"]'); + const popover = listbox?.parentElement; + if (!popover) return { error: 'no popover found' }; + + // Walk ancestor chain checking for containing-block-redefining properties + const chain = []; + let el = popover.parentElement; + while (el) { + const cs = getComputedStyle(el); + const relevant = { + tag: el.tagName, + cls: el.className?.toString().slice(0, 60), + transform: cs.transform, + willChange: cs.willChange, + filter: cs.filter, + perspective: cs.perspective, + contain: cs.contain, + backdropFilter: cs.backdropFilter, + position: cs.position, + }; + const flagged = relevant.transform !== 'none' || relevant.willChange !== 'auto' || relevant.filter !== 'none' || relevant.perspective !== 'none' || (relevant.contain !== 'none' && relevant.contain !== '') || relevant.backdropFilter !== 'none'; + if (flagged) chain.push(relevant); + el = el.parentElement; + } + + const rect = popover.getBoundingClientRect(); + const popoverStyle = getComputedStyle(popover); + return { + rect: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, height: rect.height }, + windowInnerHeight: window.innerHeight, + flaggedAncestors: chain, + popoverPosition: popoverStyle.position, + popoverInsetBlockEnd: popoverStyle.insetBlockEnd, + popoverMaxBlockSize: popoverStyle.maxBlockSize, + popoverMinBlockSize: popoverStyle.minBlockSize, + popoverPaddingBlockEnd: popoverStyle.paddingBlockEnd, + listboxHeight: listbox.getBoundingClientRect(), + itemCount: listbox.querySelectorAll('[role="option"]').length, + }; +}); +console.log(JSON.stringify(info, null, 2)); + +await page.keyboard.press('Escape'); +await page.waitForTimeout(300); + +await browser.close(); diff --git a/packages/@luke-ui/react/inspect-grouped.mjs b/packages/@luke-ui/react/inspect-grouped.mjs new file mode 100644 index 00000000..02ef86ce --- /dev/null +++ b/packages/@luke-ui/react/inspect-grouped.mjs @@ -0,0 +1,35 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); +await page.goto('http://localhost:3001/docs/components/forms/combobox-field', { waitUntil: 'networkidle' }); + +// Find all "Country" comboboxes on the page (basic + grouped examples) +const inputs = await page.getByRole('combobox', { name: 'Country' }).all(); +console.log('combobox count on page:', inputs.length); + +for (let i = 0; i < inputs.length; i++) { + await inputs[i].click(); + await page.waitForTimeout(400); + const info = await page.evaluate(() => { + const listbox = document.querySelector('[role="listbox"]'); + const popover = listbox?.parentElement; + if (!popover) return { error: 'no popover' }; + const rect = popover.getBoundingClientRect(); + const lbRect = listbox.getBoundingClientRect(); + return { + popoverRect: { top: rect.top, bottom: rect.bottom, height: rect.height }, + listboxRect: { top: lbRect.top, bottom: lbRect.bottom, height: lbRect.height }, + itemCount: listbox.querySelectorAll('[role="option"], [role="group"]').length, + optionCount: listbox.querySelectorAll('[role="option"]').length, + groupCount: listbox.querySelectorAll('[role="group"]').length, + innerHTML: listbox.innerHTML.slice(0, 500), + }; + }); + console.log(`--- combobox #${i} ---`); + console.log(JSON.stringify(info, null, 2)); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); +} + +await browser.close(); diff --git a/packages/@luke-ui/react/inspect-grouped2.mjs b/packages/@luke-ui/react/inspect-grouped2.mjs new file mode 100644 index 00000000..7810dd80 --- /dev/null +++ b/packages/@luke-ui/react/inspect-grouped2.mjs @@ -0,0 +1,31 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); +page.on('console', msg => console.log('BROWSER LOG:', msg.text())); +page.on('pageerror', err => console.log('PAGE ERROR:', err.message)); +await page.goto('http://localhost:3001/docs/components/forms/combobox-field', { waitUntil: 'networkidle' }); + +const inputs = await page.getByRole('combobox', { name: 'Country' }).all(); +console.log('combobox count:', inputs.length); + +// Just test #1 fresh, no prior interactions +await inputs[1].scrollIntoViewIfNeeded(); +await page.waitForTimeout(200); +await inputs[1].click(); +await page.waitForTimeout(600); + +const focused = await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 200)); +console.log('focused element:', focused); + +const listboxes = await page.$$('[role="listbox"]'); +console.log('listbox count:', listboxes.length); + +const popoverInfo = await page.evaluate(() => { + // Any popover-ish elements in the DOM at all + const all = Array.from(document.querySelectorAll('[popover], [data-rac]')).filter(el => el.getAttribute('role') !== 'option'); + return all.slice(0, 10).map(el => ({ tag: el.tagName, role: el.getAttribute('role'), cls: el.className.toString().slice(0,80), rect: el.getBoundingClientRect() })); +}); +console.log(JSON.stringify(popoverInfo, null, 2)); + +await browser.close(); diff --git a/packages/@luke-ui/react/inspect-grouped3.mjs b/packages/@luke-ui/react/inspect-grouped3.mjs new file mode 100644 index 00000000..b95ed2dd --- /dev/null +++ b/packages/@luke-ui/react/inspect-grouped3.mjs @@ -0,0 +1,39 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); +await page.goto('http://localhost:3001/docs/components/forms/combobox-field', { waitUntil: 'networkidle' }); + +const inputs = await page.getByRole('combobox', { name: 'Country' }).all(); +await inputs[1].scrollIntoViewIfNeeded(); +await page.waitForTimeout(200); +await inputs[1].click(); +await page.waitForTimeout(600); + +const info = await page.evaluate(() => { + const listbox = document.querySelector('[role="listbox"]'); + if (!listbox) return { error: 'still none' }; + const popover = listbox.parentElement; + const rect = popover.getBoundingClientRect(); + const lbRect = listbox.getBoundingClientRect(); + const cs = getComputedStyle(popover); + return { + popoverRect: rect, + listboxRect: lbRect, + optionCount: listbox.querySelectorAll('[role="option"]').length, + groupCount: listbox.querySelectorAll('[role="group"]').length, + display: cs.display, + visibility: cs.visibility, + opacity: cs.opacity, + position: cs.position, + insetBlockEnd: cs.insetBlockEnd, + maxBlockSize: cs.maxBlockSize, + minBlockSize: cs.minBlockSize, + transform: cs.transform, + zIndex: cs.zIndex, + }; +}); +console.log(JSON.stringify(info, null, 2)); + +await page.screenshot({ path: '/tmp/grouped-open.png' }); +await browser.close(); diff --git a/packages/@luke-ui/react/inspect-grouped4.mjs b/packages/@luke-ui/react/inspect-grouped4.mjs new file mode 100644 index 00000000..7047f032 --- /dev/null +++ b/packages/@luke-ui/react/inspect-grouped4.mjs @@ -0,0 +1,39 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 390, height: 844 } }); +page.on('pageerror', err => console.log('PAGE ERROR:', err.message)); +await page.goto('http://localhost:3001/docs/components/forms/combobox-field', { waitUntil: 'networkidle' }); + +const inputs = await page.getByRole('combobox', { name: 'Country' }).all(); +console.log('combobox count:', inputs.length); +await inputs[2].scrollIntoViewIfNeeded(); +await page.waitForTimeout(200); +await inputs[2].click(); +await page.waitForTimeout(600); + +const info = await page.evaluate(() => { + const listbox = document.querySelector('[role="listbox"]'); + if (!listbox) return { error: 'no listbox found' }; + const popover = listbox.parentElement; + const rect = popover.getBoundingClientRect(); + const lbRect = listbox.getBoundingClientRect(); + const cs = getComputedStyle(popover); + const lbCs = getComputedStyle(listbox); + return { + popoverRect: rect, + listboxRect: lbRect, + optionCount: listbox.querySelectorAll('[role="option"]').length, + groupCount: listbox.querySelectorAll('[role="group"]').length, + groupHeadings: Array.from(listbox.querySelectorAll('[role="group"] > *:first-child')).map(e => e.textContent), + display: cs.display, + lbDisplay: lbCs.display, + lbOverflow: lbCs.overflow, + lbMaxBlockSize: lbCs.maxBlockSize, + lbHeight: lbCs.height, + innerHTML: listbox.innerHTML.slice(0, 1000), + }; +}); +console.log(JSON.stringify(info, null, 2)); +await page.screenshot({ path: '/tmp/grouped-open.png' }); +await browser.close(); diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx index dc45df22..85331a9c 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.browser.test.tsx @@ -20,6 +20,23 @@ const countryItems: Array = [ const renderCountryItem = (item: CountryItem) => {item.label}; +/** + * The tray slides in via a `translate` transition (see `combobox.css.ts`), and RAC keeps it + * mounted for the whole transition. Resolves immediately if the tray has already settled + * (`translate: none`), otherwise waits for that transition to end — matching the same event + * `useVisualViewportVars` listens for to correct a mid-transition geometry reading. + */ +async function waitForTraySettled(element: HTMLElement): Promise { + if (window.getComputedStyle(element).translate === 'none') return; + await new Promise((resolve) => { + element.addEventListener('transitionend', function handleTransitionEnd(event) { + if (event.propertyName !== 'translate') return; + element.removeEventListener('transitionend', handleTransitionEnd); + resolve(); + }); + }); +} + const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'visualViewport'); afterEach(() => { @@ -33,6 +50,14 @@ afterEach(() => { }); test('sets the tray viewport height and keyboard inset custom properties from visualViewport', async () => { + // Narrower than the tray media query's 640px breakpoint, so the popover renders as the + // fixed-position bottom tray instead of the desktop absolutely-positioned popover — the + // `getBoundingClientRect()`-based measurement in `useVisualViewportVars` only means + // anything once the tray is actually pinned to the viewport's bottom edge. + await page.viewport(390, 844); + + // `window.innerHeight` reflects the viewport set above, so this simulates the on-screen + // keyboard having shrunk the visual viewport by 300px versus the full layout viewport. const fake = Object.assign(new EventTarget(), { height: window.innerHeight - 300, offsetTop: 0, @@ -56,16 +81,26 @@ test('sets the tray viewport height and keyboard inset custom properties from vi const popover = document.querySelector('[role="listbox"]')?.parentElement; if (!popover) throw new Error('expected the listbox to have a popover parent'); + // Let the tray's own enter transition finish before measuring: mid-transition, the tray is + // still `translate`d towards its resting position, so `getBoundingClientRect()` would read + // an offset that doesn't reflect where the tray actually settles. + await waitForTraySettled(popover); + expect(popover.style.getPropertyValue(comboboxTrayViewportHeightVar)).toBe(`${fake.height}px`); - expect(popover.style.getPropertyValue(comboboxTrayKeyboardInsetVar)).toBe( - `${window.innerHeight - fake.height}px`, - ); + + // Mirrors the hook's own measurement rather than a parallel formula: the tray's real + // rendered bottom edge (pinned by `insetBlockEnd: 0 !important`) versus the fake visual + // viewport's visible bottom. Reading the DOM directly here means the test can't silently + // drift from the implementation the way the previous `window.screen.height`-based formula + // did. + const expectedInset = `${Math.max(0, popover.getBoundingClientRect().bottom - (fake.offsetTop + fake.height))}px`; + expect(popover.style.getPropertyValue(comboboxTrayKeyboardInsetVar)).toBe(expectedInset); fake.height -= 100; fake.dispatchEvent(new Event('resize')); expect(popover.style.getPropertyValue(comboboxTrayViewportHeightVar)).toBe(`${fake.height}px`); - expect(popover.style.getPropertyValue(comboboxTrayKeyboardInsetVar)).toBe( - `${window.innerHeight - fake.height}px`, - ); + + const expectedInset2 = `${Math.max(0, popover.getBoundingClientRect().bottom - (fake.offsetTop + fake.height))}px`; + expect(popover.style.getPropertyValue(comboboxTrayKeyboardInsetVar)).toBe(expectedInset2); }); diff --git a/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts b/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts index 0d7b80c3..a5067598 100644 --- a/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts +++ b/packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts @@ -10,44 +10,81 @@ export function useVisualViewportVars(element: HTMLElement | null): void { if (element === null) return; if (window.visualViewport == null) return; + console.log('[tray-debug] hook mounted', { element, hasVisualViewport: window.visualViewport != null }); + // Reassigned into locals so TypeScript keeps them narrowed to non-null inside `update`, // a nested function declaration whose narrowing TS can't otherwise carry over. const trayElement = element; const visualViewport = window.visualViewport; - // `innerHeight`/`clientHeight` don't reliably report the fixed-position containing - // block's height across browsers: on iOS Safari `innerHeight` tracks the visual - // viewport, so it shrinks with the keyboard and the old inset math collapsed to ~0. - // A probe pinned to all four edges via `inset: 0` measures that containing block - // directly, by construction, regardless of toolbar/keyboard state. - const probe = document.createElement('div'); - probe.style.position = 'fixed'; - probe.style.inset = '0'; - probe.style.visibility = 'hidden'; - probe.style.pointerEvents = 'none'; - document.body.append(probe); - - function update() { + // `combobox.css.ts` pins the tray's `insetBlockEnd` to `0 !important`, so the + // browser has already decided where the fixed containing block's bottom edge + // sits — above the keyboard on browsers where `position: fixed` is clipped to + // the visual viewport, or behind it where fixed elements still extend to the + // full layout viewport/physical screen. Rather than guess which model a given + // browser/OS/version uses, read the tray's own rendered bottom edge and diff it + // against the visual viewport's true visible bottom: a zero delta means the box + // already stops above the keyboard (no compensation needed), a positive delta + // means it doesn't (that delta is exactly the padding needed to lift content + // clear of the keyboard). This measurement is self-correcting either way, with + // no feedback loop: `paddingBlockEnd` (driven by `keyboardInset`) lives inside + // the box, while `insetBlockEnd: 0` pins the outer bottom edge regardless of + // padding, so the rect's bottom edge doesn't shift between calls. + function update(trigger: string) { trayElement.style.setProperty(comboboxTrayViewportHeightVar, `${visualViewport.height}px`); - // Read fresh each call in case the containing block itself changes (e.g. toolbar show/hide). - const probeHeight = probe.getBoundingClientRect().height; - const keyboardInset = Math.max( - 0, - probeHeight - visualViewport.offsetTop - visualViewport.height, - ); + const visibleBottom = visualViewport.offsetTop + visualViewport.height; + const trayRect = trayElement.getBoundingClientRect(); + const trayBottom = trayRect.bottom; + const keyboardInset = Math.max(0, trayBottom - visibleBottom); trayElement.style.setProperty(comboboxTrayKeyboardInsetVar, `${keyboardInset}px`); + + console.log('[tray-debug] update', { + trigger, + visualViewportHeight: visualViewport.height, + visualViewportOffsetTop: visualViewport.offsetTop, + visualViewportWidth: visualViewport.width, + windowInnerHeight: window.innerHeight, + windowInnerWidth: window.innerWidth, + screenHeight: window.screen.height, + trayRectTop: trayRect.top, + trayRectBottom: trayRect.bottom, + trayRectHeight: trayRect.height, + keyboardInset, + visibleBottom, + scrollY: window.scrollY, + }); } - update(); + update('mount'); + + // The tray slides in/out via a `translate` transition (see the `data-entering`/ + // `data-exiting` selectors in `combobox.css.ts`), and RAC keeps it mounted for the + // full transition. The initial `update()` call above can land mid-transition — before + // the tray has slid up to its resting position — so `getBoundingClientRect()` briefly + // reports an offset that doesn't reflect where the tray actually settles. Re-measuring + // once that transition ends corrects any such stale reading. + function handleTransitionEnd(event: TransitionEvent) { + if (event.target !== trayElement) return; + if (event.propertyName !== 'translate') return; + update('transitionend'); + } + trayElement.addEventListener('transitionend', handleTransitionEnd); + // VisualViewport is not an Element, so ResizeObserver cannot observe it. Scroll also reports // changes to offsetTop that do not resize the viewport. - visualViewport.addEventListener('resize', update); - visualViewport.addEventListener('scroll', update); + function handleResize() { + update('resize'); + } + function handleScroll() { + update('scroll'); + } + visualViewport.addEventListener('resize', handleResize); + visualViewport.addEventListener('scroll', handleScroll); return () => { - visualViewport.removeEventListener('resize', update); - visualViewport.removeEventListener('scroll', update); - probe.remove(); + visualViewport.removeEventListener('resize', handleResize); + visualViewport.removeEventListener('scroll', handleScroll); + trayElement.removeEventListener('transitionend', handleTransitionEnd); }; }, [element]); } diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index 3c8c3e70..fc8554ec 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -263,13 +263,18 @@ export const comboboxPopover = recipeInLayer('recipes', { // RAC positions the popover with inline styles (`position`, `top`, `left`, // `max-height`); cascade layers can't out-rank inline styles, only `!important` // can — and these logical properties still beat RAC's physical ones. - insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, + insetBlockEnd: '0 !important', insetBlockStart: 'auto !important' as 'auto', insetInline: '0 !important', + // The visual viewport height minus a top gap keeps the tray from filling the + // entire screen; the keyboard inset and safe-area padding below lift content + // above the keyboard so interactive options are always reachable. maxBlockSize: `calc(var(${comboboxTrayViewportHeightVar}, 100dvh) - ${vars.space.xxlarge}) !important`, minInlineSize: 'auto !important' as 'auto', - // iPhone home indicator safe area. - paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)', + // The tray always extends to the physical bottom of the viewport. The keyboard + // inset or home-indicator safe area becomes padding that lifts interactive + // content above the on-screen keyboard — whichever is larger wins. + paddingBlockEnd: `max(var(${comboboxTrayKeyboardInsetVar}, 0px), env(safe-area-inset-bottom, 0px))`, position: 'fixed !important' as 'fixed', selectors: {