Skip to content

fix(Overlay, DropdownMenuGroup, MobileNavToggle): forward host attributes to the element each owns - #8755

Open
kentonquatman wants to merge 4 commits into
mainfrom
fix/forward-data-attributes
Open

kentonquatman wants to merge 4 commits into
mainfrom
fix/forward-data-attributes

Conversation

@kentonquatman

@kentonquatman kentonquatman commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

User impact

Builders attach data-* attributes to components for test selectors, analytics, and visual-editing or inspection tools. Overlay and DropdownMenuGroup drop them, and MobileNavToggle keeps only data-testid. Each accepts only xstyle, className, style and ref in its props type (plus data-testid on MobileNavToggle), but TypeScript accepts any hyphenated attribute on a JSX element, so <Overlay data-testid="hero"> and <DropdownMenuGroup data-analytics-id="history"> type-check and then never reach the DOM. Nothing warns. A test query or analytics selector finds nothing, and an app that wraps these components and forwards caller props loses them. The only workaround is a ref callback that writes attributes imperatively. The same applies to id, aria-* and DOM handlers, which the types reject outright.

After this change, those attributes and handlers reach the element each component already owns: the Overlay root that carries its ref, theme target and styling props, the role="group" element of DropdownMenuGroup, and the button MobileNavToggle renders.

Problem and solution fit

  • Why it occurs. Both props types are a Pick of the three styling props, and both components destructure a closed set and spread nothing onto their root.
  • Why it harms the task. Attributes the caller set silently disappear, so the hooks tests, analytics and inspection tools depend on are missing from exactly these components while their siblings forward them (SideNavCollapseButton, the other AppShell toggle, already takes the full BaseProps surface).
  • Why that matters. Overlays over media, grouped menu rows and the app's navigation toggle are common places for those hooks, and wrapper components that forward props break here without any signal.

The fix gives each component the host surface other DOM-owning components already have: extend BaseProps for the owned element and spread the remaining props onto it, with component-owned semantics set after the spread.

Expected behavior and authority

  • architecture:public-component-api (current), INV5: for a DOM-owning component, supported DOM, data, ARIA, style, class and event inputs reach the element that owns the contract, and component-owned accessibility and behavior cannot be accidentally overwritten. Its Owning code section: BaseProps defines the shared DOM and styling surface for DOM-owning components. INV7: event handlers compose deliberately, and a consumer can cancel built-in behavior only where the public API promises cancellation.
  • docs/contributing/api-conventions.md (DOM props, styling, and refs), which projects INV5: type BaseProps with the contract element, omit names that collide with component concepts, forward neutral data-*, ARIA, DOM and event props, and keep owned role and accessibility from being overwritten.
  • Each component owns one stable DOM element: Overlay its root container (ref and the overlay theme target land there), DropdownMenuGroup its role="group" element (ref lands there), MobileNavToggle its button (ref, styling and data-testid land there).
  • component:Overlay (current) governs OverlayRegion placement only and leaves Overlay's root surface to source, so INV5 is the governing rule for these props. DropdownMenuGroup has no current component record.
  • component:MobileNav (current) lists architecture:public-component-api INV5 and INV7 among its governing rules. Its FR11 owns what the toggle does (renders only in an enabled AppShell mobile layout, icon-only ghost Button, toggles the drawer, aria-expanded, aria-controls), and all of that is unchanged. Its contract-at-a-glance row ("MobileNavToggle with label and children") is a review projection that already omits the ref, styling props and data-testid the toggle accepts, and no requirement says the toggle drops host attributes, so the record needs no edit.
  • Same defect class and shape as fix(DropdownMenuDivider): forward host attributes to the separator #7645 (DropdownMenuDivider), feat(TabMenu): forward host attributes and DOM handlers to the trigger #7295 (TabMenu), feat(DropdownMenuSubMenu): forward host attributes, DOM handlers and a ref to the trigger row #7318 (DropdownMenuSubMenu) and feat(DropdownMenuItem): forward host attributes and DOM handlers to the row #7196 (DropdownMenuItem).

Smallest restoration

  • OverlayProps extends BaseProps<HTMLDivElement> instead of the three-prop Pick; the remaining props spread onto the root before its ref, theme target and merged styling. The touch tap-to-toggle handlers run first and a caller onClick or onMouseUp runs after them, so a caller preventDefault() cannot cancel the toggle (Overlay promises no cancellation).
  • DropdownMenuGroupProps extends Omit<BaseProps<HTMLDivElement>, 'role'>; the remaining props spread onto the group before its owned role="group". While the group has a title, the heading keeps naming it; an untitled group passes a caller aria-labelledby through instead of clearing it.
  • MobileNavToggleProps extends Omit<BaseProps<HTMLButtonElement>, 'aria-expanded' | 'aria-controls'>; the remaining props spread onto the Button before its own variant, label, icon, ARIA wiring, default data-testid and styling. The drawer toggle runs first and a caller onClick runs after it, as in TabMenu, so a caller cannot cancel it. The accessible name stays label (Button sets aria-label from it after caller props).
  • File headers and consumer docs (EN, dense and zh where present) note the forwarding. One patch changeset covers all three components.
  • No change to rendering, styling, theme targets, reveal behavior, arrow-key order, the data-mode {type: 'section'} path, or when the toggle renders.

Evidence

  • Reproduction before the change: with the three source files reverted to main, eight of the nine new tests fail; no element carries the forwarded attribute or runs the caller handler. The ninth (MobileNavToggle keeps its own aria-expanded and aria-controls) guards the spread order and passes on main because nothing was forwarded.

  • Result after the change: all pass. Attributes reach the Overlay root (the same node as its ref, keeping astryx-overlay), the DropdownMenuGroup element (a caller role is rejected by the type and overridden at runtime; the title still names the group), and the MobileNavToggle button (a caller aria-expanded or aria-controls is overridden, and the drawer still opens when a caller onClick calls preventDefault()).

  • Representative unchanged path: the existing Overlay rendering, position, scrim, controlled visibility, styling, ref and touch-toggle suites; the DropdownMenu, ContextMenu, Breadcrumbs and MoreMenu suites; and the MobileNav, AppShell and TopNav suites, including the toggle's existing ARIA wiring tests.

  • Regression test or other mutation-sensitive proof: each guard was removed in turn and the suite rerun:

    Mutation Fails
    Overlay: drop {...rest} data/ARIA/id forwarding, caller onClick, caller onMouseUp
    Overlay: caller onClick first (cancellable) caller onClick still toggles on tap
    Overlay: caller onClick not composed caller onClick still toggles on tap
    Overlay: caller onMouseUp not composed caller onMouseUp
    DropdownMenuGroup: drop {...rest} host attributes, untitled caller name
    DropdownMenuGroup: caller aria-labelledby dropped when untitled untitled caller name
    DropdownMenuGroup: {...rest} after the owned role host attributes (role becomes presentation)
    MobileNavToggle: drop {...rest} data/ARIA/id forwarding
    MobileNavToggle: {...rest} after the owned ARIA keeps its own aria-expanded and aria-controls
    MobileNavToggle: caller onClick first (cancellable) caller onClick runs after the toggle
    MobileNavToggle: caller onClick not composed caller onClick runs after the toggle

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label. The props types widen to the shared BaseProps surface INV5 and its Owning code section name for DOM-owning components; nothing component-specific is added.

Public delta. OverlayProps changes from Pick<BaseProps<HTMLDivElement>, 'xstyle' | 'className' | 'style'> to BaseProps<HTMLDivElement>. DropdownMenuGroupProps changes from Pick<BaseProps, 'xstyle' | 'className' | 'style'> to Omit<BaseProps<HTMLDivElement>, 'role'>. MobileNavToggleProps changes from Pick<BaseProps, 'xstyle' | 'className' | 'style'> to Omit<BaseProps<HTMLButtonElement>, 'aria-expanded' | 'aria-controls'>. Every existing prop keeps its name, type and default, nothing becomes required, and no export changes. Observable change: host attributes and handlers a caller passes now reach the owned element, with the owned behavior listed above unchanged.

  • Public text and artifacts contain no internal Meta context.

How the components were chosen: a runtime check on current main (render each candidate with a data-* attribute, look for it in the document) and a static pass over every exported component whose props reach BaseProps. Most components that dropped caller attributes in 0.6.x already forward them on main (#7331, #7339, #7645 and others). The ones that still drop them, and why the rest stay out of this PR:

  • Tooltip, Popover, HoverCard: no stable element of their own. The trigger is the caller's child, which takes its own attributes, and the surface mounts only while open, in more than one presentation. Choosing a host for them is an API decision, not a restoration.
  • Theme, LayerProvider: providers that coordinate children; their props do not include BaseProps.
  • MoreMenu: component:MoreMenu (current) FR4 lists exactly what it forwards to DropdownMenu, including data-testid only. Widening that is a change to the record.
  • PowerSearch: component:PowerSearch (current) records the forwarding of data-* other than data-testid as a known gap left to a separate stable decision.

Open PRs that touch the same files without overlapping this fix: #7952 (Overlay.tsx, OverlayRegion), #7535 (DropdownMenuGroup.tsx, row gap) and #7358 (MobileNavToggle.tsx, i18n slices). Any of them may need a trivial rebase after the other lands.

Why this is a patch

Under spec:AST-017 FR1 and CONTRIBUTING's Changeset policy, a change is breaking only when a valid consumer of the latest stable release must change source or configuration to keep a supported contract working, or loses a stable public surface. FR5 makes broader accepted inputs nonbreaking when old usage is unchanged, and FR32 makes a correction breaking only when a valid released consumer relies on the corrected behavior. The concrete ways this could affect an existing consumer:

  • Attributes that were dropped now reach the DOM. In the released types, id, role, tabIndex, hidden, inert and handlers are compile errors on all three components, so no typed callsite passes them. Only hyphenated names (data-*, aria-*) slip through, because TypeScript does not check them against a component's props. The released types and docs never accepted them, and FR3 does not make undocumented markup a contract. Writing aria-hidden or data-* on one of these components while relying on it being ignored is not a supported scenario. A wrapper that spreads a wider object (which TypeScript does not excess-check) could now see an extra id or a second onClick on the owned element. That path bypasses the released types, and the same exposure existed for every precedent below.
  • Spread order. Caller props spread first, so a caller cannot override what the component owns: Overlay's ref, theme class, merged styling and touch toggle; DropdownMenuGroup's role, its title as name, and merged styling; MobileNavToggle's variant, accessible name, aria-expanded, aria-controls, default test id, styling and drawer toggle. The tests and mutations above pin each of these.
  • Types. The change only adds optional members. Nothing is narrowed, renamed, retyped or made required. title and content stay the components' own ReactNode props because BaseProps already omits those HTML names. One compile-time edge remains: a consumer interface that extends one of these props types and declares a member with a native attribute name but an incompatible type (for example its own onChange) would now fail to compile. The repository has consistently treated a Pick to BaseProps widening as additive and released it as a patch: fix(DropdownMenuDivider): forward host attributes to the separator #7645 ([fix]), feat(TabMenu): forward host attributes and DOM handlers to the trigger #7295, feat(DropdownMenuSubMenu): forward host attributes, DOM handlers and a ref to the trigger row #7318 and feat(DropdownMenuItem): forward host attributes and DOM handlers to the row #7196 ([feat]).

#7331 was released as [breaking] for a different reason. Its four components already declared the full BaseProps surface, so typed callers could legally pass hidden, inert, role or handlers that were then ignored, and applying them changed typed, supported callsites. That does not happen here.

Testing

pnpm exec vitest run --project ui packages/core/src/Overlay packages/core/src/DropdownMenu packages/core/src/ContextMenu packages/core/src/Breadcrumbs packages/core/src/MoreMenu packages/core/src/MobileNav packages/core/src/AppShell packages/core/src/TopNav packages/core/src/docPropReferences.test.ts packages/core/src/docPropLiterals.test.ts packages/core/src/naming.test.ts packages/core/src/serverSafeComponents.test.ts
pnpm exec vitest run --project node packages/cli/clients/cli/commands/component.test.mjs packages/cli/clients/cli/lib/component-format.test.mjs packages/cli/clients/cli/commands/search.test.mjs packages/cli/authoring/doctypes packages/core/src/docs-script.test.mjs
tsc --project packages/core/tsconfig.json --noEmit && tsc --project packages/core/tsconfig.docs.json
ASTRYX_STRICT_LINT=1 eslint <changed files>
pnpm check:repo
pnpm check:changesets
pnpm sync:exports:check
pnpm check:knowledge
node scripts/release/changeset-coverage.mjs pr --base origin/main --explain

All pass (819 ui tests in 33 files, 199 node tests in 12 files). DOM attribute and handler forwarding only; no layout, pixel or focus dependence. The touch branch is exercised the way the existing Overlay touch tests do it, by stubbing (hover: none), and the toggle the way the existing MobileNavToggle tests do it, below the AppShell breakpoint.

Before marking ready

  • No knowledge record changes, so the spec-owner-approval gate does not apply. If a reviewer prefers component:MobileNav's contract-at-a-glance row to name the toggle's host attributes explicitly, that is a one-line edit to a current record and needs approval from its owner.

@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 30, 2026 3:37am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 30, 2026, 4:07 AM UTC

@github-actions github-actions Bot added the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Oct 27, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview availability: Storybook and Sandbox are not both ready on the exact-head Vercel preview.

Modified Components

Overlay (@astryxdesign/core)
Metric Before After Delta
Entry File (ESM) 216B 216B 0B
Lines of Code 451 458 -
Complexity Very High Very High (41) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AppShell 58.3KB 58.3KB +12B 11.6KB 11.6KB 0B
core/ContextMenuItem 53.3KB 53.3KB 0B 9.6KB 9.6KB 0B
core/DropdownMenu 73.1KB 73.1KB 0B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.2KB 16.2KB 0B 5.6KB 5.6KB 0B
core/DropdownMenuDivider 6.6KB 6.6KB 0B 3.6KB 3.6KB 0B
core/DropdownMenuGroup 6.1KB 6.1KB +4B 3.3KB 3.3KB 0B
core/DropdownMenuItem 15.5KB 15.5KB 0B 5.2KB 5.2KB 0B
core/DropdownMenuRadioGroup 6.0KB 6.0KB 0B 3.2KB 3.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.2KB 0B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.5KB 58.5KB 0B 9.5KB 9.5KB 0B
core/MobileNav 51.4KB 51.4KB 0B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.6KB 46.7KB +31B 9.4KB 9.4KB 0B
core/Overlay 19.5KB 19.6KB +43B 3.8KB 3.8KB 0B
core/TopNav 52.3KB 52.3KB +12B 10.4KB 10.4KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.9KB 34.9KB 0B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

2 of 10 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788

Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ready to approve: Overlay and DropdownMenuGroup now forward host attributes and DOM handlers as architecture:public-component-api INV5 requires, while their own role, group name, ref and theme target stay component-controlled. The Overlay touch toggle still runs ahead of a caller onClick, so a caller cannot cancel it, which matches INV7. The five new tests fail without the fix and pass with it; once the pull request is marked ready for review, the required code review can proceed.

[Automated review]

@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Oct 28, 2026
…element

Overlay and DropdownMenuGroup each own one stable DOM element but typed
their props as a Pick of the three styling props, so data-*, id, aria-*
and DOM handlers passed to them never reached the page. Both now extend
BaseProps and spread the remaining props onto that element, as other
DOM-owning components do (architecture:public-component-api INV5).

Overlay runs its touch toggle before a caller onClick or onMouseUp, so a
caller handler cannot cancel it. DropdownMenuGroup keeps role="group" and,
while it has a title, keeps that title as its name; an untitled group
takes a caller's aria-labelledby.
MobileNavToggle owns one button but typed its props as a Pick of the three
styling props plus data-testid, so other data-*, id, aria-* and DOM
handlers never reached the page. It now extends BaseProps for the button,
minus the aria-expanded and aria-controls it owns, and spreads the rest
onto the Button before its own props (architecture:public-component-api
INV5). The drawer toggle runs before a caller onClick, so a caller cannot
cancel it (INV7). The changeset now covers all three components.
@kentonquatman
kentonquatman force-pushed the fix/forward-data-attributes branch from 6adfa0d to 82b5b87 Compare October 28, 2026 02:40
@kentonquatman kentonquatman changed the title fix(Overlay, DropdownMenuGroup): forward host attributes to the root element fix(Overlay, DropdownMenuGroup, MobileNavToggle): forward host attributes to the element each owns Oct 28, 2026
@github-actions github-actions Bot added the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Oct 28, 2026
@kentonquatman
kentonquatman marked this pull request as ready for review October 28, 2026 03:08

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ready to approve: Overlay, DropdownMenuGroup and MobileNavToggle now forward host attributes and DOM handlers to the element each owns, as architecture:public-component-api INV5 requires, while their own role, group name, aria-expanded/aria-controls, ref and theme target stay component-controlled. The built-in touch and drawer toggles still run ahead of a caller onClick, which matches INV7, and the new tests fail without the fix (or with the spread or handler order reversed) and pass with it. One optional wording fix: the changeset says a test id passed to any of the three never reached the page, but MobileNavToggle already forwarded data-testid before this change. The required code review can proceed.

[Automated review]

Comment thread .changeset/overlay-dropdown-menu-group-host-props.md Outdated
@github-actions github-actions Bot removed the needs:code-review High-risk change (new package/component/API) — needs human code review before merge label Oct 28, 2026
The release note said a test id passed to any of the three components never reached the page. MobileNavToggle already forwarded data-testid; it gains every other data-*, id, aria-* and handler.
@kentonquatman

Copy link
Copy Markdown
Contributor Author

Visual regression review (run 40622363152): the two changed shots, AppShell Playground in neutral light and dark, don't come from this PR. The committed baseline was captured on 2026-10-15, before #8311 made AppShell stories render edge to edge, and the after is that frame: no 16 px gutter, so the shot is 1024×768 instead of 1024×800. At 1024 px AppShell uses its desktop layout, where MobileNavToggle doesn't render, and the other eight shots (DropdownMenu, MobileNav, Overlay, TopNav) are unchanged. The after is the intended picture; refreshing the AppShell baselines is separate from this PR. The current head only edits the changeset, so this still holds.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ready to approve: Overlay, DropdownMenuGroup and MobileNavToggle now forward host attributes and DOM handlers to the element each owns, as architecture:public-component-api INV5 requires, while their own role, group name, aria-expanded/aria-controls, ref and theme target stay component-controlled. The built-in touch and drawer toggles still run ahead of a caller onClick, which matches INV7, and the new tests fail without the fix (or with the spread or handler order reversed) and pass with it. One behavior worth knowing, not a blocker: a caller onClick on Overlay is skipped for clicks a descendant already prevented (for example a client-side router link in content), because that is how the shared composeEventHandlers composes handlers.

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks ready to approve: Overlay, DropdownMenuGroup and MobileNavToggle now forward host attributes and DOM handlers to the element each one owns, as architecture:public-component-api INV5 requires, while their own role, title name, aria-expanded/aria-controls, ref and built-in toggles stay component-controlled (INV7). The new tests fail without the fix or with the spread or handler order reversed, and pass with it. Two optional follow-ups, neither blocking:

  • A caller onClick on Overlay is skipped for a click a descendant already prevented (a client-routed link in content, for example), because composeEventHandlers stops at defaultPrevented; chainEventHandlers would deliver every click while keeping the touch toggle uncancellable, if that delivery is preferred.
  • The MobileNavToggle doc line could add that the button's accessible name stays label, since a caller aria-label is replaced as component:Button ORD4 requires.

[Automated review]

// The touch toggle runs before the caller's handler: Overlay promises no
// cancellation, so a caller preventDefault() cannot stop it
// (architecture:public-component-api INV7).
onClick={composeEventHandlers(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: composeEventHandlers returns once event.defaultPrevented is set, so this caller onClick is skipped for any click a descendant already prevented (a client-routed link in content, for example), also on desktop where the touch handler is undefined. A wrapping div would still receive those clicks. If DOM-like delivery is preferred, chainEventHandlers(overlay.containerProps.onClick, onClickProp) keeps the toggle first and uncancellable while the caller sees every click.

displayName: 'Mobile Nav Toggle',
description:
'Hamburger button that opens/closes the mobile nav drawer. Reads open state from AppShell context automatically: does NOT accept isOpen or onOpenChange props. Renders nothing above the mobile breakpoint.',
'Hamburger button that opens/closes the mobile nav drawer. Reads open state from AppShell context automatically: does NOT accept isOpen or onOpenChange props. Renders nothing above the mobile breakpoint. Host attributes and DOM handlers (id, data-*, aria-*) reach the button; its aria-expanded and aria-controls stay component-owned, and a caller onClick runs after the toggle.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional wording: a caller aria-label does not reach the button, because Button replaces it with label (component:Button ORD4). Saying the accessible name comes from label, beside aria-expanded and aria-controls, would keep this list exact; the dense line at 308 has the same gap.

This branch was successfully deployed

1 active deployment
Preview — 84e76354 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants