[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add Spectrum-style mobile tray for the combobox popover (#89)

authored by

Luke Bennett and committed by
GitHub
(Jul 11, 2026, 8:28 PM +1000) d72743bc 9679c934

+251 -5
+5
.changeset/spectrum-combobox-tray.md
··· 1 + --- 2 + '@luke-ui/react': minor 3 + --- 4 + 5 + Add a Spectrum-style bottom tray for the combobox popover on small viewports.
+66
packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx
··· 63 63 await expect.element(page.elementLocator(document.body)).toMatchScreenshot('combobox-field-open'); 64 64 }); 65 65 66 + test('mobile tray', async () => { 67 + renderVisual( 68 + <Stack> 69 + <ComboboxField 70 + defaultItems={countryItems} 71 + description="Select where the user is located." 72 + label="Country" 73 + name="country" 74 + placeholder="Select a country..." 75 + > 76 + {renderCountryItem} 77 + </ComboboxField> 78 + </Stack>, 79 + ); 80 + 81 + await page.viewport(390, 700); 82 + try { 83 + await userEvent.click(page.getByRole('combobox', { name: 'Country' })); 84 + await expect.element(page.getByRole('option', { name: 'Australia' })).toBeInTheDocument(); 85 + 86 + // Below the `small` breakpoint the popover renders as a bottom tray; screenshot 87 + // document.body (the popover portals there) to capture it pinned to the viewport edge. 88 + await expect 89 + .element(page.elementLocator(document.body)) 90 + .toMatchScreenshot('combobox-field-tray'); 91 + } finally { 92 + // Restore the viewport fixed by the visual project config (vitest.config.ts) so later 93 + // tests in this file/run aren't affected. 94 + await page.viewport(1024, 800); 95 + } 96 + }); 97 + 98 + // Guards the calc-size `fit-content` goldilocks behavior: a short, 2-item tray must hug its 99 + // content height, not stretch to fill the 12em minimum reserved for taller lists. 100 + test('mobile tray short list', async () => { 101 + renderVisual( 102 + <Stack> 103 + <ComboboxField 104 + defaultItems={countryItems.slice(0, 2)} 105 + description="Select where the user is located." 106 + label="Country" 107 + name="country" 108 + placeholder="Select a country..." 109 + > 110 + {renderCountryItem} 111 + </ComboboxField> 112 + </Stack>, 113 + ); 114 + 115 + await page.viewport(390, 700); 116 + try { 117 + await userEvent.click(page.getByRole('combobox', { name: 'Country' })); 118 + await expect.element(page.getByRole('option', { name: 'Australia' })).toBeInTheDocument(); 119 + 120 + // Below the `small` breakpoint the popover renders as a bottom tray; screenshot 121 + // document.body (the popover portals there) to capture it pinned to the viewport edge. 122 + await expect 123 + .element(page.elementLocator(document.body)) 124 + .toMatchScreenshot('combobox-field-tray-short'); 125 + } finally { 126 + // Restore the viewport fixed by the visual project config (vitest.config.ts) so later 127 + // tests in this file/run aren't affected. 128 + await page.viewport(1024, 800); 129 + } 130 + }); 131 + 66 132 test('sizes', async () => { 67 133 const locator = renderVisual( 68 134 <Stack>
+106
packages/@luke-ui/react/src/recipes/combobox.css.ts
··· 1 1 import type { RecipeVariants } from '@vanilla-extract/recipes'; 2 2 import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js'; 3 3 import { vars } from '../styles/vars.css.js'; 4 + import { dimensionToRemString } from '../tokens/converters.js'; 5 + import { breakpointValues } from '../tokens/values.js'; 4 6 import { descendantDisabledSelector, inputChromeStyles, inputStates } from './input-states.css.js'; 7 + 8 + /** Custom property mirroring `visualViewport.height`, set by `useVisualViewportVars`. */ 9 + export const comboboxTrayViewportHeightVar = '--luke-ui-visual-viewport-height'; 10 + 11 + /** Custom property mirroring the on-screen keyboard's height, set by `useVisualViewportVars`. */ 12 + export const comboboxTrayKeyboardInsetVar = '--luke-ui-keyboard-inset'; 13 + 14 + // Below this width the popover renders as a bottom tray instead of a positioned popover, matching 15 + // Adobe Spectrum's combobox pattern (https://react-aria.adobe.com/blog/building-a-combobox). 16 + const trayMediaQuery = `(width < ${dimensionToRemString(breakpointValues.small)})`; 5 17 6 18 /** 7 19 * The combobox control is a group wrapping an input and a trigger button, and ··· 214 226 backgroundColor: vars.backgroundColor.default, 215 227 borderRadius: vars.borderRadius.large, 216 228 boxShadow: vars.boxShadow.medium, 229 + display: 'flex', 230 + flexDirection: 'column', 217 231 inlineSize: 'var(--trigger-width)', 218 232 minInlineSize: 'var(--trigger-width)', 219 233 overflow: 'hidden', 234 + // A subtle fade for the desktop popover; the tray media query below swaps this for a 235 + // slide. RAC keeps the element mounted during data-entering/data-exiting so the 236 + // transition has time to run. 237 + transition: [ 238 + `opacity ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 239 + `translate ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 240 + `box-shadow ${vars.motion.duration.fast} ${vars.motion.easing.standard}`, 241 + ].join(', '), 242 + 243 + selectors: { 244 + '&[data-entering]': { 245 + opacity: 0, 246 + }, 247 + '&[data-exiting]': { 248 + opacity: 0, 249 + }, 250 + }, 251 + 252 + '@media': { 253 + [trayMediaQuery]: { 254 + borderBlockStart: `${vars.borderWidth.thin} solid ${vars.border.default}`, 255 + borderEndEndRadius: 0, 256 + borderEndStartRadius: 0, 257 + borderStartEndRadius: vars.borderRadius.xlarge, 258 + borderStartStartRadius: vars.borderRadius.xlarge, 259 + boxShadow: `${vars.boxShadow.large}, 0 0 0 100vmax rgba(0, 0, 0, 0.2)`, 260 + // RAC's `menuWidth`-driven inline `width` style, and the base recipe's own 261 + // `var(--trigger-width)`, both need to lose to the full-width tray. 262 + inlineSize: 'auto !important' as 'auto', 263 + // RAC positions the popover with inline styles (`position`, `top`, `left`, 264 + // `max-height`); cascade layers can't out-rank inline styles, only `!important` 265 + // can — and these logical properties still beat RAC's physical ones. 266 + insetBlockEnd: `var(${comboboxTrayKeyboardInsetVar}, 0px) !important`, 267 + insetBlockStart: 'auto !important' as 'auto', 268 + insetInline: '0 !important', 269 + maxBlockSize: `calc(var(${comboboxTrayViewportHeightVar}, 100dvh) - ${vars.space.xxlarge}) !important`, 270 + minInlineSize: 'auto !important' as 'auto', 271 + // iPhone home indicator safe area. 272 + paddingBlockEnd: 'env(safe-area-inset-bottom, 0px)', 273 + position: 'fixed !important' as 'fixed', 274 + 275 + selectors: { 276 + '&::before': { 277 + alignSelf: 'center', 278 + backgroundColor: vars.border.default, 279 + blockSize: '4px', 280 + borderRadius: vars.borderRadius.full, 281 + content: '', 282 + flexShrink: 0, 283 + inlineSize: '36px', 284 + marginBlockEnd: vars.space.xxsmall, 285 + marginBlockStart: vars.space.xsmall, 286 + }, 287 + '&[data-entering]': { 288 + boxShadow: `${vars.boxShadow.large}, 0 0 0 100vmax transparent`, 289 + opacity: 1, 290 + translate: '0 100%', 291 + }, 292 + '&[data-exiting]': { 293 + boxShadow: `${vars.boxShadow.large}, 0 0 0 100vmax transparent`, 294 + opacity: 1, 295 + translate: '0 100%', 296 + }, 297 + }, 298 + }, 299 + // The global reduced-motion reset lives in the `reset` layer, so it can't beat this 300 + // recipe's transition. Declared after the tray query so it also disables the slide, 301 + // not just the desktop fade — exit is instant instead of animating. 302 + '(prefers-reduced-motion: reduce)': { 303 + transition: 'none', 304 + }, 305 + }, 306 + 307 + // Prevents the popover/tray being squeezed unusably small when React Aria's inline 308 + // `max-height` gets tiny in cramped viewports, without adding empty space for short 309 + // lists: at least 12em when content is taller, exactly content height when shorter. 310 + // `min-block-size` beats even an inline `max-height` per CSS min/max resolution, so no 311 + // `!important` needed. `calc-size()` is Chromium-only, hence the `@supports` gate — other 312 + // browsers keep today's fixed-height behavior. https://jakearchibald.com/2026/goldilocks-select-height/ 313 + '@supports': { 314 + '(min-block-size: calc-size(fit-content, size))': { 315 + minBlockSize: 'calc-size(fit-content, min(size, 12em))', 316 + }, 317 + }, 220 318 }, 221 319 }); 222 320 223 321 export const comboboxListBox = recipeInLayer('recipes', { 224 322 base: { 225 323 boxSizing: 'border-box', 324 + flex: 1, 226 325 inlineSize: '100%', 227 326 listStyle: 'none', 228 327 margin: 0, 229 328 maxBlockSize: '18.75rem', 329 + minBlockSize: 0, 230 330 outline: 'none', 231 331 overflow: 'auto', 232 332 padding: vars.space.xsmall, 333 + 334 + '@media': { 335 + [trayMediaQuery]: { 336 + maxBlockSize: 'none', 337 + }, 338 + }, 233 339 }, 234 340 }); 235 341
+17
apps/docs/content/docs/components/forms/combobox-field.mdx
··· 113 113 </ComboboxField> 114 114 ``` 115 115 116 + ## Mobile tray 117 + 118 + Below the `small` breakpoint (640px) the popover renders as a bottom tray instead of a positioned 119 + popover, following 120 + [Adobe Spectrum's combobox pattern](https://react-aria.adobe.com/blog/building-a-combobox). The tray 121 + spans the full viewport width, slides up from the bottom edge on open and back down on close, and 122 + keeps clear of the on-screen keyboard using the 123 + [Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API). A scrim 124 + dims the page behind the tray, and a grab-handle affordance marks its top edge. In browsers that 125 + support `calc-size()`, the tray keeps a usable minimum height even when the viewport is cramped, 126 + while still hugging shorter lists instead of leaving empty space. 127 + 128 + ## Accessibility 129 + 130 + The tray's slide transition (and the desktop popover's fade) is disabled for users who request 131 + `prefers-reduced-motion: reduce`. 132 + 116 133 ## Primitive 117 134 118 135 Use the [combobox primitives](/docs/components/primitives/combobox) when you need a custom combobox
+4 -1
apps/docs/content/docs/components/primitives/combobox.mdx
··· 21 21 ## Anatomy 22 22 23 23 `ComboboxInput` owns the React Aria `ComboBox` state. Render the control, popover, listbox, and 24 - items inside it. 24 + items inside it. Below the `small` breakpoint `ComboboxPopover` renders as a full-width bottom tray 25 + instead of a positioned popover; see 26 + [Mobile tray](/docs/components/forms/combobox-field#mobile-tray) on the `ComboboxField` page for 27 + details. 25 28 26 29 ```tsx 27 30 <ComboboxInput defaultItems={countries} aria-label="Country">
+15 -4
packages/@luke-ui/react/src/combobox-field/primitive/popover.tsx
··· 1 - import type { JSX } from 'react'; 1 + import { mergeRefs } from '@react-aria/utils'; 2 + import type { JSX, Ref } from 'react'; 3 + import { useState } from 'react'; 2 4 import type { PopoverProps as RacPopoverProps } from 'react-aria-components/ComboBox'; 3 5 import { Popover as RacPopover } from 'react-aria-components/ComboBox'; 4 6 import { composeRenderProps } from 'react-aria-components/composeRenderProps'; 5 7 import * as styles from '../../recipes/combobox.css.js'; 6 8 import { themeRootClassName } from '../../theme/index.js'; 7 9 import { cx } from '../../utils/index.js'; 10 + import { useVisualViewportVars } from './use-visual-viewport-vars.js'; 8 11 9 12 /** 10 13 * Props for the styled combobox popover. 11 14 * 12 15 * @tier primitive 13 16 */ 14 - export interface ComboboxPopoverProps extends Omit<RacPopoverProps, 'UNSTABLE_portalContainer'> {} 17 + export interface ComboboxPopoverProps extends Omit<RacPopoverProps, 'UNSTABLE_portalContainer'> { 18 + /** Forwarded to the popover's DOM element. */ 19 + ref?: Ref<HTMLElement>; 20 + } 15 21 16 22 /** Popover surface used for listbox content. */ 17 23 export function ComboboxPopover(props: ComboboxPopoverProps): JSX.Element { 24 + const { ref, ...restProps } = props; 25 + const [element, setElement] = useState<HTMLElement | null>(null); 26 + useVisualViewportVars(element); 27 + 18 28 return ( 19 29 <RacPopover 20 - {...props} 21 - className={composeRenderProps(props.className, (className) => { 30 + {...restProps} 31 + className={composeRenderProps(restProps.className, (className) => { 22 32 return cx(themeRootClassName, styles.comboboxPopover(), className); 23 33 })} 34 + ref={mergeRefs(ref, (node: HTMLElement | null) => setElement(node))} 24 35 /> 25 36 ); 26 37 }
+38
packages/@luke-ui/react/src/combobox-field/primitive/use-visual-viewport-vars.ts
··· 1 + import { useEffect } from 'react'; 2 + import { 3 + comboboxTrayKeyboardInsetVar, 4 + comboboxTrayViewportHeightVar, 5 + } from '../../recipes/combobox.css.js'; 6 + 7 + /** Sets the visible viewport height and keyboard inset on the mobile tray. */ 8 + export function useVisualViewportVars(element: HTMLElement | null): void { 9 + useEffect(() => { 10 + if (element === null) return; 11 + if (window.visualViewport == null) return; 12 + 13 + // Reassigned into locals so TypeScript keeps them narrowed to non-null inside `update`, 14 + // a nested function declaration whose narrowing TS can't otherwise carry over. 15 + const trayElement = element; 16 + const visualViewport = window.visualViewport; 17 + 18 + function update() { 19 + trayElement.style.setProperty(comboboxTrayViewportHeightVar, `${visualViewport.height}px`); 20 + const keyboardInset = Math.max( 21 + 0, 22 + window.innerHeight - visualViewport.height - visualViewport.offsetTop, 23 + ); 24 + trayElement.style.setProperty(comboboxTrayKeyboardInsetVar, `${keyboardInset}px`); 25 + } 26 + 27 + update(); 28 + // VisualViewport is not an Element, so ResizeObserver cannot observe it. Scroll also reports 29 + // changes to offsetTop that do not resize the viewport. 30 + visualViewport.addEventListener('resize', update); 31 + visualViewport.addEventListener('scroll', update); 32 + 33 + return () => { 34 + visualViewport.removeEventListener('resize', update); 35 + visualViewport.removeEventListener('scroll', update); 36 + }; 37 + }, [element]); 38 + }
packages/@luke-ui/react/src/combobox-field/__screenshots__/combobox-field.visual.test.tsx/combobox-field-tray-chromium-linux.png

This is a binary file and will not be displayed.

packages/@luke-ui/react/src/combobox-field/__screenshots__/combobox-field.visual.test.tsx/combobox-field-tray-short-chromium-linux.png

This is a binary file and will not be displayed.