From f665befdee1e8c8e19e24f61ac6275403fdff940 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 26 Oct 2026 08:59:22 -0700 Subject: [PATCH] perf(core): an overlay's eager import no longer carries the lazy BottomSheet's padding and focus code Since Popover loads a BottomSheet with import() for its phone presentation (#8229), esbuild puts a module that both the eager code and the BottomSheet chunk import into one shared chunk, which the entry imports statically and which keeps every export either side uses. Layout/padding.stylex.ts was such a module: usePopover uses paddingStyles and useLayer uses overlayPaddingReset, while BottomSheetPanel uses the container padding variables, so every component that imports Popover eagerly carried 3,975 B of it instead of 1,413. utils/focusReturn.ts was another, reached eagerly through the utils barrel's isFocusDetached re-export while only BottomSheet uses its focusForLastInput (264 B). paddingStyles and overlayPaddingReset now live in their own modules, which padding.stylex.ts re-exports for its public entry and which useLayer and usePopover import directly; isFocusDetached moves to its own module, which the barrel re-exports. --- .changeset/overlay-eager-padding.md | 7 + packages/core/src/Layer/useLayer.tsx | 2 +- packages/core/src/Layout/index.ts | 2 +- .../src/Layout/overlayPaddingReset.stylex.ts | 73 +++++++++ .../src/Layout/overlayPaddingReset.test.tsx | 5 +- packages/core/src/Layout/padding.stylex.ts | 142 ++---------------- .../core/src/Layout/paddingStyles.stylex.ts | 87 +++++++++++ packages/core/src/Popover/Popover.test.tsx | 8 +- packages/core/src/Popover/usePopover.tsx | 2 +- packages/core/src/utils/focusReturn.test.ts | 3 +- packages/core/src/utils/focusReturn.ts | 33 +--- packages/core/src/utils/index.ts | 2 +- packages/core/src/utils/isFocusDetached.ts | 34 +++++ 13 files changed, 227 insertions(+), 173 deletions(-) create mode 100644 .changeset/overlay-eager-padding.md create mode 100644 packages/core/src/Layout/overlayPaddingReset.stylex.ts create mode 100644 packages/core/src/Layout/paddingStyles.stylex.ts create mode 100644 packages/core/src/utils/isFocusDetached.ts diff --git a/.changeset/overlay-eager-padding.md b/.changeset/overlay-eager-padding.md new file mode 100644 index 0000000000000..054ccf1d20529 --- /dev/null +++ b/.changeset/overlay-eager-padding.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[perf] Components that import Popover no longer load BottomSheet's container padding styles and focus helper up front. Popover loads a BottomSheet on demand for its phone presentation, and a bundler that splits code put the modules both of them use into one chunk that loads with the component, keeping every export either side uses: about 3.5 KB minified in Popover, Carousel, DateRangeInput, DropdownMenu, Table and the other components that import Popover or DropdownMenu. `paddingStyles` and `overlayPaddingReset` now have their own modules, still exported from `@astryxdesign/core/Layout/padding.stylex`, and `isFocusDetached` has its own module, still exported from `@astryxdesign/core/utils`. Rendering is unchanged. + +@vjeux diff --git a/packages/core/src/Layer/useLayer.tsx b/packages/core/src/Layer/useLayer.tsx index 1d0fdc37e7932..01bb07aed21f6 100644 --- a/packages/core/src/Layer/useLayer.tsx +++ b/packages/core/src/Layer/useLayer.tsx @@ -49,7 +49,7 @@ import { import {preserveAnimations} from './preserveAnimations'; import {useLayerContext} from './LayerContext'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; -import {overlayPaddingReset} from '../Layout/padding.stylex'; +import {overlayPaddingReset} from '../Layout/overlayPaddingReset.stylex'; const styles = stylex.create({ // Base reset for all layers diff --git a/packages/core/src/Layout/index.ts b/packages/core/src/Layout/index.ts index dcf4fe5c20b8e..0a59d49181dd9 100644 --- a/packages/core/src/Layout/index.ts +++ b/packages/core/src/Layout/index.ts @@ -21,7 +21,7 @@ export type { // Overlay padding-variable reset (applied on overlay roots so the container // padding system does not cross a fixed/top-layer boundary) -export {overlayPaddingReset} from './padding.stylex'; +export {overlayPaddingReset} from './overlayPaddingReset.stylex'; // Edge compensation utility export {edgeCompSlot, EDGE_COMP_ATTR} from './edgeCompensation.stylex'; diff --git a/packages/core/src/Layout/overlayPaddingReset.stylex.ts b/packages/core/src/Layout/overlayPaddingReset.stylex.ts new file mode 100644 index 0000000000000..4723b18d3eee7 --- /dev/null +++ b/packages/core/src/Layout/overlayPaddingReset.stylex.ts @@ -0,0 +1,73 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file overlayPaddingReset.stylex.ts + * @input Uses @stylexjs/stylex + * @output overlayPaddingReset: the container padding boundary at an overlay's root + * @position Layout utility, re-exported by padding.stylex.ts and Layout/index.ts. + * Its own module so an overlay's eager code (useLayer) imports it without + * padding.stylex.ts: a lazily loaded overlay that uses the container padding + * styles there then keeps them out of the eager chunk it shares with this + * module. + */ + +import * as stylex from '@stylexjs/stylex'; + +/** + * Padding-variable reset for overlay roots (Dialog, BottomSheet, Drawer, + * MobileNav, Lightbox, and every layer surface). + * + * ## Why an overlay needs this + * + * The container padding system talks to descendants through inherited custom + * properties: a padded container announces its padding, and children read the + * value either to apply it or to cancel it with a negative margin + * (`Section`, `Divider`, `Layout`, `Table`). + * + * Inheritance follows the DOM, but an overlay leaves its parent's visual box — + * a fixed/top-layer `` is a DOM descendant of the padded page while + * being nowhere near it on screen. It inherits values describing padding that + * is not there, and its content compensates against phantom space: a `Section` + * inside a 640px sheet rendered 672px wide and hung off both edges (#5208). + * + * Two families leak, and they need opposite treatments: + * + * - `--container-padding-*` -> `0px`. Descendants SUBTRACT these (bleed + * margins). The overlay root has no padding of its own to escape, so the + * honest answer is zero. Nested containers that do set padding (a Dialog's + * content wrapper) re-announce their own values below this point, so + * legitimate edge-to-edge bleed inside the overlay is unaffected. + * - `--layout-padding-*` and `--_section-padding-propagated` -> `initial`. + * Descendants ADD these, so zeroing them would strip padding rather than + * restore it. `initial` makes each guaranteed-invalid, so readers fall + * through their own `var(…, fallback)` chain and land on the theme default — + * which is what an overlay at the top of the tree should show. + * + * `initial` is also why propagation moved off `--astryx-section-padding`: that + * name is public theme surface, set at the theme root, and making it invalid + * here would blank the theme's own section padding inside every overlay. + * + * Apply on the overlay's outermost styled element. + * + * @example + * ``` + * + * ``` + */ +export const overlayPaddingReset = stylex.create({ + reset: { + // Subtracted by descendants — the overlay root has no padding to escape. + '--container-padding-inline-start': '0px', + '--container-padding-inline-end': '0px', + '--container-padding-block-start': '0px', + '--container-padding-block-end': '0px', + // Added by descendants — fall through to each reader's own default. + '--layout-padding-outer-x': 'initial', + '--layout-padding-outer-y': 'initial', + '--layout-padding-own-outer-x': 'initial', + '--layout-padding-own-outer-y': 'initial', + '--layout-padding-inner-x': 'initial', + '--layout-padding-inner-y': 'initial', + '--_section-padding-propagated': 'initial', + }, +}); diff --git a/packages/core/src/Layout/overlayPaddingReset.test.tsx b/packages/core/src/Layout/overlayPaddingReset.test.tsx index cc24c878709e7..81d7b2ea4f716 100644 --- a/packages/core/src/Layout/overlayPaddingReset.test.tsx +++ b/packages/core/src/Layout/overlayPaddingReset.test.tsx @@ -187,7 +187,10 @@ describe('overlayPaddingReset', () => { // this; assert it where it is decided instead. (Verified in a browser: a // theme's 20px still reaches a Section inside a Dialog nested under a // 40px page Section.) - const source = readFileSync(join(__dirname, 'padding.stylex.ts'), 'utf8'); + const source = readFileSync( + join(__dirname, 'overlayPaddingReset.stylex.ts'), + 'utf8', + ); const reset = source.slice( source.indexOf('export const overlayPaddingReset'), ); diff --git a/packages/core/src/Layout/padding.stylex.ts b/packages/core/src/Layout/padding.stylex.ts index 857ef734d308b..a61de4ed9c9a2 100644 --- a/packages/core/src/Layout/padding.stylex.ts +++ b/packages/core/src/Layout/padding.stylex.ts @@ -3,7 +3,9 @@ /** * @file padding.stylex.ts * @input Uses @stylexjs/stylex, spacing from theme - * @output StyleX styles for numeric padding prop on layout components + * @output StyleX styles for numeric padding prop on layout components, the + * container padding variables, and (re-exported) paddingStyles and + * overlayPaddingReset * @position Layout utility; used by Card, Section, and Layout sub-components */ @@ -11,6 +13,12 @@ import * as stylex from '@stylexjs/stylex'; import {spacingVars} from '../theme/tokens.stylex'; import type {SpacingStep} from '../utils/types'; +// In their own modules so an overlay's eager code imports them without this +// one, and a lazily loaded overlay that uses the container padding styles +// below keeps those out of the chunk it shares with that code. +export {paddingStyles} from './paddingStyles.stylex'; +export {overlayPaddingReset} from './overlayPaddingReset.stylex'; + /** * Maps numeric SpacingStep values to SpacingToken keys used by the container system. */ @@ -28,79 +36,6 @@ export const spacingStepToToken: Record = { 10: 'spacing10', }; -/** - * Padding styles for all sides using spacing tokens. - * Each key applies uniform padding to all four sides. - */ -export const paddingStyles = stylex.create({ - 0: { - paddingInlineStart: spacingVars['--spacing-0'], - paddingInlineEnd: spacingVars['--spacing-0'], - paddingBlockStart: spacingVars['--spacing-0'], - paddingBlockEnd: spacingVars['--spacing-0'], - }, - 0.5: { - paddingInlineStart: spacingVars['--spacing-0-5'], - paddingInlineEnd: spacingVars['--spacing-0-5'], - paddingBlockStart: spacingVars['--spacing-0-5'], - paddingBlockEnd: spacingVars['--spacing-0-5'], - }, - 1: { - paddingInlineStart: spacingVars['--spacing-1'], - paddingInlineEnd: spacingVars['--spacing-1'], - paddingBlockStart: spacingVars['--spacing-1'], - paddingBlockEnd: spacingVars['--spacing-1'], - }, - 1.5: { - paddingInlineStart: spacingVars['--spacing-1-5'], - paddingInlineEnd: spacingVars['--spacing-1-5'], - paddingBlockStart: spacingVars['--spacing-1-5'], - paddingBlockEnd: spacingVars['--spacing-1-5'], - }, - 2: { - paddingInlineStart: spacingVars['--spacing-2'], - paddingInlineEnd: spacingVars['--spacing-2'], - paddingBlockStart: spacingVars['--spacing-2'], - paddingBlockEnd: spacingVars['--spacing-2'], - }, - 3: { - paddingInlineStart: spacingVars['--spacing-3'], - paddingInlineEnd: spacingVars['--spacing-3'], - paddingBlockStart: spacingVars['--spacing-3'], - paddingBlockEnd: spacingVars['--spacing-3'], - }, - 4: { - paddingInlineStart: spacingVars['--spacing-4'], - paddingInlineEnd: spacingVars['--spacing-4'], - paddingBlockStart: spacingVars['--spacing-4'], - paddingBlockEnd: spacingVars['--spacing-4'], - }, - 5: { - paddingInlineStart: spacingVars['--spacing-5'], - paddingInlineEnd: spacingVars['--spacing-5'], - paddingBlockStart: spacingVars['--spacing-5'], - paddingBlockEnd: spacingVars['--spacing-5'], - }, - 6: { - paddingInlineStart: spacingVars['--spacing-6'], - paddingInlineEnd: spacingVars['--spacing-6'], - paddingBlockStart: spacingVars['--spacing-6'], - paddingBlockEnd: spacingVars['--spacing-6'], - }, - 8: { - paddingInlineStart: spacingVars['--spacing-8'], - paddingInlineEnd: spacingVars['--spacing-8'], - paddingBlockStart: spacingVars['--spacing-8'], - paddingBlockEnd: spacingVars['--spacing-8'], - }, - 10: { - paddingInlineStart: spacingVars['--spacing-10'], - paddingInlineEnd: spacingVars['--spacing-10'], - paddingBlockStart: spacingVars['--spacing-10'], - paddingBlockEnd: spacingVars['--spacing-10'], - }, -}); - /** * Container padding inline CSS variable styles for edge compensation. * Sets --container-padding-inline-start and --container-padding-inline-end so @@ -474,62 +409,3 @@ export const sectionPaddingPropagationStyles = stylex.create({ 8: {'--_section-padding-propagated': spacingVars['--spacing-8']}, 10: {'--_section-padding-propagated': spacingVars['--spacing-10']}, }); - -/** - * Padding-variable reset for overlay roots (Dialog, BottomSheet, Drawer, - * MobileNav, Lightbox, and every layer surface). - * - * ## Why an overlay needs this - * - * The container padding system talks to descendants through inherited custom - * properties: a padded container announces its padding, and children read the - * value either to apply it or to cancel it with a negative margin - * (`Section`, `Divider`, `Layout`, `Table`). - * - * Inheritance follows the DOM, but an overlay leaves its parent's visual box — - * a fixed/top-layer `` is a DOM descendant of the padded page while - * being nowhere near it on screen. It inherits values describing padding that - * is not there, and its content compensates against phantom space: a `Section` - * inside a 640px sheet rendered 672px wide and hung off both edges (#5208). - * - * Two families leak, and they need opposite treatments: - * - * - `--container-padding-*` -> `0px`. Descendants SUBTRACT these (bleed - * margins). The overlay root has no padding of its own to escape, so the - * honest answer is zero. Nested containers that do set padding (a Dialog's - * content wrapper) re-announce their own values below this point, so - * legitimate edge-to-edge bleed inside the overlay is unaffected. - * - `--layout-padding-*` and `--_section-padding-propagated` -> `initial`. - * Descendants ADD these, so zeroing them would strip padding rather than - * restore it. `initial` makes each guaranteed-invalid, so readers fall - * through their own `var(…, fallback)` chain and land on the theme default — - * which is what an overlay at the top of the tree should show. - * - * `initial` is also why propagation moved off `--astryx-section-padding`: that - * name is public theme surface, set at the theme root, and making it invalid - * here would blank the theme's own section padding inside every overlay. - * - * Apply on the overlay's outermost styled element. - * - * @example - * ``` - * - * ``` - */ -export const overlayPaddingReset = stylex.create({ - reset: { - // Subtracted by descendants — the overlay root has no padding to escape. - '--container-padding-inline-start': '0px', - '--container-padding-inline-end': '0px', - '--container-padding-block-start': '0px', - '--container-padding-block-end': '0px', - // Added by descendants — fall through to each reader's own default. - '--layout-padding-outer-x': 'initial', - '--layout-padding-outer-y': 'initial', - '--layout-padding-own-outer-x': 'initial', - '--layout-padding-own-outer-y': 'initial', - '--layout-padding-inner-x': 'initial', - '--layout-padding-inner-y': 'initial', - '--_section-padding-propagated': 'initial', - }, -}); diff --git a/packages/core/src/Layout/paddingStyles.stylex.ts b/packages/core/src/Layout/paddingStyles.stylex.ts new file mode 100644 index 0000000000000..1f034c5f51f09 --- /dev/null +++ b/packages/core/src/Layout/paddingStyles.stylex.ts @@ -0,0 +1,87 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file paddingStyles.stylex.ts + * @input Uses @stylexjs/stylex, spacing from theme + * @output paddingStyles: uniform padding on all four sides by SpacingStep + * @position Layout utility, re-exported by padding.stylex.ts. Its own module so an + * overlay's eager code (usePopover) imports it without padding.stylex.ts: + * a lazily loaded overlay that uses the container padding styles there then + * keeps them out of the eager chunk it shares with this module. + */ + +import * as stylex from '@stylexjs/stylex'; +import {spacingVars} from '../theme/tokens.stylex'; + +/** + * Padding styles for all sides using spacing tokens. + * Each key applies uniform padding to all four sides. + */ +export const paddingStyles = stylex.create({ + 0: { + paddingInlineStart: spacingVars['--spacing-0'], + paddingInlineEnd: spacingVars['--spacing-0'], + paddingBlockStart: spacingVars['--spacing-0'], + paddingBlockEnd: spacingVars['--spacing-0'], + }, + 0.5: { + paddingInlineStart: spacingVars['--spacing-0-5'], + paddingInlineEnd: spacingVars['--spacing-0-5'], + paddingBlockStart: spacingVars['--spacing-0-5'], + paddingBlockEnd: spacingVars['--spacing-0-5'], + }, + 1: { + paddingInlineStart: spacingVars['--spacing-1'], + paddingInlineEnd: spacingVars['--spacing-1'], + paddingBlockStart: spacingVars['--spacing-1'], + paddingBlockEnd: spacingVars['--spacing-1'], + }, + 1.5: { + paddingInlineStart: spacingVars['--spacing-1-5'], + paddingInlineEnd: spacingVars['--spacing-1-5'], + paddingBlockStart: spacingVars['--spacing-1-5'], + paddingBlockEnd: spacingVars['--spacing-1-5'], + }, + 2: { + paddingInlineStart: spacingVars['--spacing-2'], + paddingInlineEnd: spacingVars['--spacing-2'], + paddingBlockStart: spacingVars['--spacing-2'], + paddingBlockEnd: spacingVars['--spacing-2'], + }, + 3: { + paddingInlineStart: spacingVars['--spacing-3'], + paddingInlineEnd: spacingVars['--spacing-3'], + paddingBlockStart: spacingVars['--spacing-3'], + paddingBlockEnd: spacingVars['--spacing-3'], + }, + 4: { + paddingInlineStart: spacingVars['--spacing-4'], + paddingInlineEnd: spacingVars['--spacing-4'], + paddingBlockStart: spacingVars['--spacing-4'], + paddingBlockEnd: spacingVars['--spacing-4'], + }, + 5: { + paddingInlineStart: spacingVars['--spacing-5'], + paddingInlineEnd: spacingVars['--spacing-5'], + paddingBlockStart: spacingVars['--spacing-5'], + paddingBlockEnd: spacingVars['--spacing-5'], + }, + 6: { + paddingInlineStart: spacingVars['--spacing-6'], + paddingInlineEnd: spacingVars['--spacing-6'], + paddingBlockStart: spacingVars['--spacing-6'], + paddingBlockEnd: spacingVars['--spacing-6'], + }, + 8: { + paddingInlineStart: spacingVars['--spacing-8'], + paddingInlineEnd: spacingVars['--spacing-8'], + paddingBlockStart: spacingVars['--spacing-8'], + paddingBlockEnd: spacingVars['--spacing-8'], + }, + 10: { + paddingInlineStart: spacingVars['--spacing-10'], + paddingInlineEnd: spacingVars['--spacing-10'], + paddingBlockStart: spacingVars['--spacing-10'], + paddingBlockEnd: spacingVars['--spacing-10'], + }, +}); diff --git a/packages/core/src/Popover/Popover.test.tsx b/packages/core/src/Popover/Popover.test.tsx index d5c559785153d..eb7efcfdb5f31 100644 --- a/packages/core/src/Popover/Popover.test.tsx +++ b/packages/core/src/Popover/Popover.test.tsx @@ -1225,12 +1225,10 @@ describe('Popover', () => { describe('surface padding', () => { // StyleX's dev runtime names the style object a class came from - // ("padding__paddingStyles.3"), which is the padding rung the surface - // was handed. + // ("paddingStyles__paddingStyles.3", after its module), which is the + // padding rung the surface was handed. function paddingRungOf(surface: Element | null): string | null { - const match = surface?.className.match( - /padding__paddingStyles\.([\d.]+)/, - ); + const match = surface?.className.match(/__paddingStyles\.([\d.]+)/); return match ? match[1] : null; } diff --git a/packages/core/src/Popover/usePopover.tsx b/packages/core/src/Popover/usePopover.tsx index 42c9519d0ec56..6e53d31bdaa85 100644 --- a/packages/core/src/Popover/usePopover.tsx +++ b/packages/core/src/Popover/usePopover.tsx @@ -35,7 +35,7 @@ import {useDevWarning} from '../hooks/useDevWarning'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {mergeProps} from '../utils/mergeProps'; import {themeProps} from '../utils/themeProps'; -import {paddingStyles} from '../Layout/padding.stylex'; +import {paddingStyles} from '../Layout/paddingStyles.stylex'; import type {SpacingStep} from '../utils/types'; import {stableClassName} from '../naming'; diff --git a/packages/core/src/utils/focusReturn.test.ts b/packages/core/src/utils/focusReturn.test.ts index 2dec110ded8aa..fe457d03a0ad8 100644 --- a/packages/core/src/utils/focusReturn.test.ts +++ b/packages/core/src/utils/focusReturn.test.ts @@ -1,7 +1,8 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. import {describe, it, expect, afterEach, beforeEach, vi} from 'vitest'; -import {focusForLastInput, isFocusDetached} from './focusReturn'; +import {focusForLastInput} from './focusReturn'; +import {isFocusDetached} from './isFocusDetached'; import { __resetInteractionModalityForTest, __startInteractionModalityTrackingForTest, diff --git a/packages/core/src/utils/focusReturn.ts b/packages/core/src/utils/focusReturn.ts index 67e286d31fd1f..1d029f85befdc 100644 --- a/packages/core/src/utils/focusReturn.ts +++ b/packages/core/src/utils/focusReturn.ts @@ -2,39 +2,14 @@ /** * @file focusReturn.ts - * @input The document's current active element and the shared interaction - * modality. - * @output Whether focus is currently "detached" — on nothing in particular — - * and a focus move whose ring follows the last input. - * @position Utility; used by overlay triggers (e.g. DateInput, DateTimeInput) - * that return focus to their control when the overlay closes, but only when - * the dismiss did not already move focus somewhere the user chose, and by - * overlays (Dialog, DropdownMenu) that move focus programmatically. + * @input The shared interaction modality. + * @output A focus move whose ring follows the last input. + * @position Utility; used by overlays (BottomSheet, Dialog, DropdownMenu) that + * move focus programmatically. isFocusDetached lives in isFocusDetached.ts. */ import {getInteractionModality} from './interactionModality'; -/** - * True when focus rests on nothing in particular — no active element, or the - * document body / root element. - * - * An overlay (popover, calendar, dropdown) that restores focus to its trigger - * on close should do so ONLY when this is true. A native `popover="auto"` - * light-dismiss fires its close synchronously with the pointer event that - * moved focus, so by the time the close handler runs, focus has already - * landed on whatever the user clicked — another field, a button, anywhere. - * Restoring focus unconditionally would yank it back and fight that click. - * The one case the browser leaves unresolved is Escape or a click on - * non-focusable empty space, which drops focus to the body; that is exactly - * when the trigger should reclaim it. - */ -export function isFocusDetached(doc: Document = document): boolean { - const active = doc.activeElement; - return ( - active == null || active === doc.body || active === doc.documentElement - ); -} - /** * Move focus programmatically so its ring follows the last input * (`architecture:interaction-modality` INV1). After pointer input the browser's diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index c07928f4b70b8..5ca9917676636 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -76,7 +76,7 @@ export {characterCount, firstCharacter, truncateCharacters} from './characters'; export {mergeProps} from './mergeProps'; export {mergeRefs} from './mergeRefs'; -export {isFocusDetached} from './focusReturn'; +export {isFocusDetached} from './isFocusDetached'; export {composeEventHandlers} from './composeEventHandlers'; export {themeProps, themeDataAttributes} from './themeProps'; export type { diff --git a/packages/core/src/utils/isFocusDetached.ts b/packages/core/src/utils/isFocusDetached.ts new file mode 100644 index 0000000000000..060101ee6c2d8 --- /dev/null +++ b/packages/core/src/utils/isFocusDetached.ts @@ -0,0 +1,34 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file isFocusDetached.ts + * @input The document's current active element + * @output Whether focus is currently "detached": on nothing in particular + * @position Utility, exported from utils/index.ts; used by overlay triggers + * (e.g. DateInput, DateTimeInput) that return focus to their control when the + * overlay closes, but only when the dismiss did not already move focus + * somewhere the user chose. Apart from focusReturn.ts so the utils barrel + * does not reach focusForLastInput: an overlay loaded with import() that + * uses it then keeps it out of the chunk it shares with eager code. + */ + +/** + * True when focus rests on nothing in particular — no active element, or the + * document body / root element. + * + * An overlay (popover, calendar, dropdown) that restores focus to its trigger + * on close should do so ONLY when this is true. A native `popover="auto"` + * light-dismiss fires its close synchronously with the pointer event that + * moved focus, so by the time the close handler runs, focus has already + * landed on whatever the user clicked — another field, a button, anywhere. + * Restoring focus unconditionally would yank it back and fight that click. + * The one case the browser leaves unresolved is Escape or a click on + * non-focusable empty space, which drops focus to the body; that is exactly + * when the trigger should reclaim it. + */ +export function isFocusDetached(doc: Document = document): boolean { + const active = doc.activeElement; + return ( + active == null || active === doc.body || active === doc.documentElement + ); +}