[fullscreen] Create the Fullscreen component#4755
Open
arturbien wants to merge 19 commits into
Open
Conversation
commit: |
Bundle size
PerformanceTotal duration: 1,160.98 ms ▼-369.68 ms(-24.2%) | Renders: 53 (+0) | Paint: 1,785.31 ms ▼-556.48 ms(-23.8%)
…and 7 more — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
Co-authored-by: Cursor <cursoragent@cursor.com>
`useExternalFullscreenTarget` resolved the `target` prop in a layout effect at mount time. When the consumer passed a `RefObject` pointing at an ancestor DOM node, that ref was attached only after the descendant layout effects fired, so the resolution returned `null` and `containerRef.current` stayed empty. The trigger then dispatched `setOpen(true)` (firing `onOpenChange`) but the open-effect bailed because no container was wired up — clicks appeared to do nothing in production builds where StrictMode's double invoke could not mask the issue. Move resolution into `useFullscreenRoot`'s open-effect so it runs at request time, by which point all ancestor refs are guaranteed to be attached. Adds a regression test that reproduces the bug with a ref hung on the parent `<section>` and verifies `aria-controls` is also kept in sync. The experiment page now has obvious visual feedback (`:fullscreen` background and `onOpenChange` plumbing) so the new behaviour is easy to verify by hand. Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the `useEffect`-based pattern in favor of branching inside `<Fullscreen.Trigger>`'s `render` prop. The supported branch spreads the trigger's props onto a `<button>`; the unsupported branch renders a plain non-interactive node WITHOUT spreading props (the original example attached `onClick`, `aria-pressed`, focus management, and the trigger ref to a `<span>`, producing button-like behaviour on an inert element). Also lead with the safer default — the trigger auto-disables when the API is unavailable, so most apps don't need to render alternate UI. Co-authored-by: Cursor <cursoragent@cursor.com>
Contributor
Author
|
@atomiks mind taking a look at my PR? 👀 |
Contributor
|
@arturbien Thanks for contribution. Just a heads up that it might be a while before someone has time to review this, and there is a high chance that it won't be merged. I haven't looked closely at it, that is just the case with any unexpected PR adding a new, unplanned primitive. But we'll see. We're all busy with other work, so please be patient, thanks! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a new
<Fullscreen />composite for presenting elements with the browser Fullscreen API, plus the cross-cutting fixes that make it actually compose with the rest of Base UI.Why
Building fullscreen UX with the raw Fullscreen API on top of Base UI primitives is doable but full of subtle footguns:
Element.requestFullscreen()is gated by transient user activation. Wiring this through React state without losing the activation window — especially for controlled mode and detached/imperative triggers — is tricky to get right.Dialog,Popover,Menu, or any other dismissible UI, opening that popup while in fullscreen and pressing Esc exits fullscreen instead of dismissing the popup. Most apps just ship this broken because there is no documented way to make Esc go to the popup first.<Dialog.Portal>,<Popover.Portal>,<Menu.Portal>, etc.) default todocument.body, which the browser hides while another element is fullscreen. The portal mounts but never paints — the user sees a frozen UI. To combat that, developers have to create acontainercontext and then pass it to every Tooltip, Dropdown, Dialog, Popover that is nested within a component that is fullscreen-able.Today consumers either re-implement this integration in every app (and usually miss one of the above), or pull in a third-party fullscreen wrapper that doesn't compose with Base UI primitives. This PR fixes both ergonomics and the cross-cutting issues.
What's new
Fullscreen.Rootopen/defaultOpen/onOpenChange,disabled,navigationUI, and atargetprop for fullscreening any DOM element.Fullscreen.ContainerrequestFullscreen(). Forwardsdata-fullscreen/data-not-fullscreen/data-supported.Fullscreen.Triggeraria-pressed/aria-controlsanddata-disabledwhen the API is unavailable. Supports detached usage viahandle, just likeDialog.Trigger.Fullscreen.CloseFullscreen.PortalDialog.Portal, supportskeepMountedandcontainer).Fullscreen.Handle/Fullscreen.createHandle()handle.open(),handle.close(),handle.isOpen. Same shape asDialog.createHandle().Fullscreen.request()/Fullscreen.exit()document.documentElement).Why composition with the rest of Base UI is hard — and what we did
Two non-obvious cross-cutting fixes ship with this PR.
1. Esc inside popups dismisses the popup, not fullscreen
Concrete scenario: you fullscreen a component that contains a
<Popover>(or<Dialog>/<Menu>/ etc.); inside fullscreen, the user opens the popover; they press Esc to dismiss. Without this fix the browser exits fullscreen first, and the popover stays "logically open" but the user is jarringly thrown back to the page. The expected behavior is that Esc dismisses the popover and a second Esc exits fullscreen.A new global Esc bridge (
packages/react/src/fullscreen/escapeBridge.ts) uses the Keyboard Lock API to keep Esc out of the browser's fullscreen handling, lets it reach JS handlers (useDismisscallsevent.preventDefault()synchronously on the keydown), and only exits fullscreen ifdefaultPreventedis still false at the next microtask.useDismiss-based primitives —Dialog,AlertDialog,Popover,Menu,ContextMenu,Menubar,NavigationMenu,Select,Combobox,Autocomplete,Drawer,Tooltip,PreviewCard— work with this out of the box.useFullscreenRootand fromFullscreen.request(), so it survivessideEffects: falsetree-shaking.2. Portals re-route automatically into the fullscreen element
useFloatingPortalNode(the shared portal primitive that backs every Base UI portal) now subscribes tofullscreenchangeand reroutes the default container intodocument.fullscreenElementonly when the resolved fallback (parentPortalNode ?? document.body) is outside the fullscreen subtree. An explicitcontainerprop is always respected.Result: dropping a
<Dialog>or<Popover>inside a<Fullscreen.Container>Just Works — no manualcontainerplumbing needed. Page-level fullscreen (document.documentElement) is detected and skipped to avoid moving portals that would render correctly anyway. The change is fully backwards-compatible: in the no-fullscreen case, the fallback chain is identical to before.3. Smaller niceties
data-fullscreen/data-not-fullscreen/data-supported/data-disabledon Root, Container, Trigger, Close so consumers can drive CSS without state plumbing.:fullscreenand::backdropstyling continues to work.supportedstate on every part so consumers can render fallback UI on iOS Safari for iPhone.openfrom any user-gesture handler (including asetTimeoutwithin the activation window) drivesrequestFullscreen()from a layout effect in the same JS task. If the browser rejects, Base UI rolls the controlled state back viaonOpenChange(false, { reason: 'none' })and warns in development.Anatomy
Default:
Mount only when open:
Fullscreen any element on the page (no Container needed):
Testing
packages/react/src/fullscreen/covering controlled/uncontrolled mode, detached triggers, the imperative handle and utilities, the portal lifecycle, the externaltargetprop (including refs to ancestor DOM nodes), the Esc bridge, and the portal-rerouting integration.FloatingPortalcovering every branch of the new fullscreen-aware container resolution (default reroute, no-op when already inside, respects explicitcontainer, migration on enter/exit, mount-while-already-fullscreen)./experiments/fullscreenexercises every feature: controlled state withnavigationUI, detached triggers, imperative handle,Fullscreen.PortalwithkeepMounted, the externaltargetprop, the imperativeFullscreen.request()/Fullscreen.exit()utilities, and a Dialog-inside-Fullscreen scenario for the Esc bridge and portal rerouting.Docs
New
react/components/fullscreenpage covering anatomy, controlled/uncontrolled state, detached triggers, imperative handle, mount-only-when-open, unsupported-browser fallbacks, thenavigationUIhint, and the "Fullscreen any element" pattern. TheUsage guidelinesblock calls out user-gesture requirements, iframeallowfullscreenrequirements, mobile considerations, and documents the new Esc-inside-popups and portal-auto-rerouting behaviors.Checklist
createHandle,request,exit)targetprop on Root