// Copyright (c) Meta Platforms, Inc. and affiliates.
/**
* @file layerHost.ts
* @input Uses the layer's intended inline parent element
* @output Exports resolveLayerPortalTarget, the corrective portal target
* @position Layer utility; used by useLayer to place the popover in the DOM
*
* SYNC: When modified, update:
* - /packages/core/src/Layer/layerHost.test.ts
*/
/**
* Ancestors a layer must not be hosted inside.
*
* Two overlapping reasons, both verified in Chrome:
*
* 1. The HTML parser owns server markup. `
` and the heading-ish elements
* take phrasing content only, so a block element inside one makes the
* parser close the paragraph and reparent the rest — the layer's content
* ends up in the page instead of the popover. A nested `` triggers the
* same tearing through the adoption agency algorithm.
* 2. Interactive ancestors capture the layer's own interactions. A card
* hosted inside an ` ` or `` puts its links and buttons inside
* that control: clicking one navigates the wrapping link, and every
* focusable in the card joins the control's own tab stop.
*
* Inline formatting elements are on the list for a third, milder reason: a
* layer hosted in one inherits its typography (font-size, text-align), so a
* card in a 13px centered paragraph renders 13px and centered.
*/
const UNSAFE_HOSTS = new Set([
// Phrasing-content-only containers
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'dt', 'pre', 'legend', 'data', 'dfn', 'meter', 'output', 'progress', 'option', 'optgroup',
// Structural containers whose direct children are restricted. An inert
// marker is valid script-supporting content in these positions,
// but the eventual div/span layer is not.
'table', 'thead', 'tbody', 'tfoot', 'tr', 'colgroup', 'ul', 'ol', 'menu', 'dl', 'select', 'datalist', 'picture', 'hgroup', 'ruby', 'rt', 'rp',
// Interactive containers
'a', 'button', 'label', 'summary',
// Inline formatting context
'span', 'em', 'strong', 'b', 'i', 'u', 's', 'small', 'mark', 'code', 'kbd', 'samp', 'var', 'sub', 'sup', 'abbr', 'cite', 'q', 'time', 'bdi', 'bdo', 'ins', 'del']);
/**
* Return the corrective portal target for a layer's intended inline parent.
*
* A null result means the inline parent and all of its ancestors are safe, so
* the layer should stay at its JSX position. Otherwise the result is the
* nearest element outside every unsafe ancestor around that position.
*
* Walking up from the actual render position (rather than the trigger, or
* portaling to `document.body`) keeps the two things a layer inherits there:
*
* - **Theme.** Theme scopes live on DOM ancestors. Staying as close as
* possible to the intended render position preserves their component rules
* and custom properties; `document.body` may sit outside a nested scope.
* - **Tab order.** Sequential focus follows DOM order, so a host near the
* trigger keeps the layer's focusables next to it. (`show()` also passes
* the trigger as the popover's invoker `source`, which pins focus order to
* the invoker in browsers that support it.)
*
* The outermost unsafe ancestor matters. A safe div may itself sit inside an
* anchor; stopping at that div would still put the layer's buttons inside the
* link. Walking the whole chain ensures the target is outside both.
*/
export function resolveLayerPortalTarget(inlineParent: HTMLElement | null): HTMLElement | null {
if (!inlineParent) {
return null;
}
let outermostUnsafe: HTMLElement | null = null;
let node: HTMLElement | null = inlineParent;
while (node) {
if (UNSAFE_HOSTS.has(node.tagName.toLowerCase())) {
outermostUnsafe = node;
}
node = node.parentElement;
}
return outermostUnsafe?.parentElement ?? null;
}