Skip to content

⚠️ Add middleware/focus props and reference registration to Floating - #3182

Draft
jandrade wants to merge 3 commits into
feature/floating-uifrom
WB-2119.1
Draft

⚠️ Add middleware/focus props and reference registration to Floating#3182
jandrade wants to merge 3 commits into
feature/floating-uifrom
WB-2119.1

Conversation

@jandrade

@jandrade jandrade commented Aug 25, 2026

Copy link
Copy Markdown
Member

⚠️ Add middleware/focus props and reference registration to Floating

Adds the props that consumers like Popover need from Floating, and changes
how Floating resolves its reference (anchor) element so that a trigger no
longer has to forward refs — and so that no wrapper element is added to the DOM.

New props: returnFocus, closeOnFocusOut, onPlacementChange,
shiftPadding, rootBoundary.

New export: useFloatingReference (and the FloatingReferenceContext it
reads). A trigger that can't receive a ref (a plain function component) can
attach the returned ref callback to the element it renders, and that element
becomes the reference element. Triggers that can receive a ref (host elements,
forwardRef and class components) keep getting the reference ref injected
directly, so existing usage is unchanged.

Each Floating provides its reference setter to its own trigger only
never to the floating content — so multiple simultaneously open (or nested)
floating elements resolve independent reference elements.

Also removes the floating element's max-inline-size (previously 472px,
carried over from Tooltip). That cap forced every Floating consumer into a
tooltip-sized bubble; the floating element now sizes to its content, and
consumers that need a width cap set one on their own content (or via the
styles.floating prop). Popover is unaffected — PopoverContentCore already
caps its content at 288px.

Impact: @khanacademy/wonder-blocks-floating is not yet released (0.0.1)
and currently has no production consumers, so this is additive for external
users. Within this stack, WB-2119.2 (the Popover refactor) depends on
useFloatingReference and should be rebased on this once it lands.

Issue: WB-2119

Test plan:

Automated: pnpm typecheck, pnpm lint, pnpm jest packages/wonder-blocks-floating
and packages/wonder-blocks-popover all pass, plus Storybook story tests
(with a11y) for every Floating story and 8 Popover stories.

Manual verification (positioning and focus behavior can't be fully asserted in
jsdom):

  1. Run pnpm start and open Packages / Floating → Custom Trigger Component
    (http://localhost:6061/?path=/story/packages-floating--custom-trigger-component).
    • Click the trigger. The floating element should appear anchored to the
      custom component (which uses useFloatingReference, not a forwarded ref).
    • Confirm the trigger has no wrapper element around it in the DOM
      inspector.
    • Press Esc and click outside — both should dismiss it, confirming the
      props Floating injects still reach the trigger.
    • Confirm focus returns to the trigger after closing.
  2. Open Packages / Floating → Placements and confirm all 12 placements still
    anchor correctly against IconButton triggers, in both LTR and RTL (toggle
    the direction global).
  3. Open Packages / Floating → Middlewares and scroll the container; confirm
    shift/flip/hide still behave as before with a View trigger.
  4. Confirm the floating element sizes to its content now that the 472px cap is
    gone: check a long-text story (and a Popover story, which should be
    unchanged at 288px) in both LTR and RTL.

Review plan:

Please review these risky changes

  1. ⚠️ floating.tsx
  2. ⚠️ floating-reference-context.ts
  3. ⚠️ accepts-ref.ts
  4. ⚠️ floating.tsx (styles) — removes the 472px max-inline-size.

Common patterns:

1 File: New optional props are declared with JSDoc (so they land in the
Storybook props table), defaulted in the destructured signature, and threaded
into the floating-ui config. Five props follow this shape.

+    /**
+     * The boundary that the floating element should be kept within by the
+     * `flip` and `shift` middleware.
+     * @default "viewport"
+     */
+    rootBoundary?: "viewport" | "document";

-    shiftProp ? shift({padding: SHIFT_PADDING, crossAxis: true}) : undefined,
+    shiftProp
+        ? shift({padding: shiftPadding, crossAxis: true, rootBoundary})
+        : undefined,

3 Files: Logic that doesn't belong in the component was extracted into
src/util/ with a colocated test file, then imported by floating.tsx.

-    const trigger = React.useMemo(() => {
-        return React.cloneElement(children, {
-            ref: refs.setReference,
-            ...getReferenceProps(),
-        });
-    }, [children, refs.setReference, getReferenceProps]);
+    const trigger = React.useMemo(() => {
+        return React.cloneElement(children, {
+            ...(acceptsRef(children) ? {ref: setReference} : undefined),
+            ...getReferenceProps(),
+        });
+    }, [children, setReference, getReferenceProps]);

@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fa574b4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@khanacademy/wonder-blocks-floating Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

import {Arrow, type ArrowStyles} from "./floating-arrow";
import {Portal} from "./floating-portal";
import {rtlMirror} from "../util/rtl-mirror-middleware";
import {acceptsRef} from "../util/accepts-ref";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

⚠️ Medium risk — this changes how the reference element is resolved for every consumer of Floating.

The reference ref is now injected conditionally (only when the trigger can receive one), and five new props are threaded into the floating-ui middleware and FloatingFocusManager config.

Worth scrutinising: a trigger that neither accepts a ref nor calls useFloatingReference now fails silently — no reference element resolves, so open && elements.reference stays false and the floating element simply never renders. Previously React logged a "Function components cannot be given refs" warning in that situation. I left out a dev-mode warning because the check is racy on the first commit (the ref callback and the context registration can both land after the first render); happy to add a deferred check in an effect if you'd prefer the louder failure.

@@ -0,0 +1,48 @@
import * as React from "react";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

⚠️ Medium risk — new public API (useFloatingReference / FloatingReferenceContext).

The key line to check is where the provider is placed in floating.tsx: it wraps only the trigger, never content. That's what keeps multiple simultaneously open — and nested — floating elements from claiming each other's reference element, since a trigger rendered inside an outer floating element resolves to its own nearest provider.

There are no ids and no document-wide queries involved, so there is nothing to collide across instances or across React roots. Covered by tests for the multi-instance, nested, and "content can't see the setter" cases in floating.test.tsx.

@@ -0,0 +1,31 @@
import * as React from "react";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

⚠️ Medium risk — new functionality that inspects React internals.

acceptsRef decides whether a ref can be injected by checking $$typeof against Symbol.for("react.forward_ref") / Symbol.for("react.memo") and prototype.isReactComponent. These symbols are stable across React versions, but it is internals-adjacent, so worth confirming the covered cases are the ones we care about.

Unit tests cover host elements, forwardRef, memo(forwardRef(...)), class components, plain function components, and memo(function).

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (b8317d2) and published all packages with changesets to npm.

You can install the packages in frontend by running:

./dev/tools/deploy_wonder_blocks.js --tag="PR3182"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR3182

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Size Change: +321 B (+0.24%)

Total Size: 135 kB

📦 View Changed
Filename Size Change
packages/wonder-blocks-floating/dist/es/index.js 3.1 kB +321 B (+11.55%) ⚠️
ℹ️ View Unchanged
Filename Size
packages/eslint-plugin-wonder-blocks/dist/es/index.js 7.11 kB
packages/wonder-blocks-accordion/dist/es/index.js 3.02 kB
packages/wonder-blocks-announcer/dist/es/index.js 2.43 kB
packages/wonder-blocks-badge/dist/es/index.js 2.03 kB
packages/wonder-blocks-banner/dist/es/index.js 2.01 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.93 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 798 B
packages/wonder-blocks-button/dist/es/index.js 4.28 kB
packages/wonder-blocks-card/dist/es/index.js 1.09 kB
packages/wonder-blocks-cell/dist/es/index.js 2.19 kB
packages/wonder-blocks-clickable/dist/es/index.js 2.61 kB
packages/wonder-blocks-core/dist/es/index.js 2.6 kB
packages/wonder-blocks-data/dist/es/index.js 5.51 kB
packages/wonder-blocks-date-picker/dist/es/index.js 8.06 kB
packages/wonder-blocks-dropdown/dist/es/index.js 20.6 kB
packages/wonder-blocks-form/dist/es/index.js 6.39 kB
packages/wonder-blocks-grid/dist/es/index.js 1.25 kB
packages/wonder-blocks-icon-button/dist/es/index.js 4.06 kB
packages/wonder-blocks-icon/dist/es/index.js 1.89 kB
packages/wonder-blocks-labeled-field/dist/es/index.js 3.47 kB
packages/wonder-blocks-layout/dist/es/index.js 1.69 kB
packages/wonder-blocks-link/dist/es/index.js 1.54 kB
packages/wonder-blocks-modal/dist/es/index.js 7.36 kB
packages/wonder-blocks-pill/dist/es/index.js 1.32 kB
packages/wonder-blocks-popover/dist/es/index.js 4.58 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.49 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.12 kB
packages/wonder-blocks-styles/dist/es/index.js 464 B
packages/wonder-blocks-switch/dist/es/index.js 1.6 kB
packages/wonder-blocks-tabs/dist/es/index.js 5.62 kB
packages/wonder-blocks-testing-core/dist/es/index.js 4.12 kB
packages/wonder-blocks-testing/dist/es/index.js 978 B
packages/wonder-blocks-theming/dist/es/index.js 384 B
packages/wonder-blocks-timing/dist/es/index.js 1.53 kB
packages/wonder-blocks-tokens/dist/es/index.js 6.5 kB
packages/wonder-blocks-toolbar/dist/es/index.js 906 B
packages/wonder-blocks-tooltip/dist/es/index.js 6.19 kB
packages/wonder-blocks-typography/dist/es/index.js 1.04 kB

compressed-size-action

@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-acfdgexvsm.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 546
Tests with visual changes 0
Total stories 880
Inherited (not captured) snapshots [TurboSnap] 0
Tests on the build 546

@@ -331,8 +422,6 @@ const styles = StyleSheet.create({
background: semanticColor.core.background.base.default,
border: `solid ${border.width.thin} ${semanticColor.core.border.neutral.subtle}`,
borderRadius: border.radius.radius_040,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

⚠️ Drops the max-inline-size: 472px cap (carried over from Tooltip). The floating element now sizes to its content, so consumers that want a width limit set one on their own content or via styles.floating. Popover is unaffected — PopoverContentCore already caps at 288px — but any future consumer relying on the implicit tooltip width will now render wider.

Juan Andrade added 3 commits August 27, 2026 10:33
Add new props to support more consumers (e.g. Popover):

- `returnFocus`: whether/where focus is returned when the floating element
  closes.
- `closeOnFocusOut`: whether the floating element closes when focus moves
  outside of it.
- `onPlacementChange`: called with the resolved placement (after middleware
  such as `flip` runs).
- `shiftPadding`: padding used by the `shift` middleware.
- `rootBoundary`: the boundary used by the `flip` and `shift` middleware.

Also add a `useFloatingReference` hook (and the `FloatingReferenceContext` it
reads) so a trigger that can't receive a ref (a plain function component) can
register its own DOM element as the reference element, without having to
forward refs and without Floating rendering a wrapper element around the
trigger. Each instance only shares its reference setter with its own trigger,
so multiple open (or nested) floating elements stay independent.

Triggers that can receive a ref (host elements, forwardRef and class
components) keep getting the reference ref injected directly.
The 472px cap was carried over from Tooltip. It forced every Floating
consumer into a tooltip-sized bubble, so the floating element now sizes to
its content and consumers cap their own width (or use styles.floating).
… ref

Floating no longer injects a ref into the trigger. It injects the
data-wb-floating-reference attribute (unique per instance) along with the
interaction props and resolves the reference element with a DOM lookup, so a
trigger of any component type works as long as it spreads the props it is
given, which it has to do anyway for the interaction and ARIA props.

This removes the acceptsRef helper (which sniffed React internals to decide
whether a trigger could receive a ref) and the FloatingReferenceContext /
useFloatingReference escape hatch (a trigger picks the element to anchor to by
choosing where it spreads the props). Warns in development when the trigger's
element can't be found.
jandrade pushed a commit that referenced this pull request Aug 28, 2026
Migrate `Popover` off PopperJS and the `wonder-blocks-tooltip` dependency to
the new `Floating` component (floating-ui). `Floating` now owns positioning,
portaling, the tail/arrow, dismiss (Esc/outside click), and focus management.

This is stacked on #3182, which adds the `Floating` props this migration needs
(`returnFocus`, `closeOnFocusOut`, `onPlacementChange`, `shiftPadding`,
`rootBoundary`). Those `Floating` changes are not part of this diff.

Popover keeps its public API. `rootBoundary`/`viewportPadding` are remapped onto
floating-ui's `flip`/`shift` middleware; `autoUpdate` and `initialFocusDelay`
are now deprecated no-ops. Focus management is now floating-ui's non-modal model
(focus flows via focus guards) rather than the previous custom circular
navigation. The popover "bubble" chrome (background/border/shadow/radius) now
comes from `Floating`; `PopoverContentCore` no longer renders its own chrome.
The popover uses `strategy="fixed"` so it positions correctly inside clipping/
scrolling ancestors.

`Floating` resolves the reference (anchor) element from the DOM rather than via
a ref. `PopoverAnchor` is left completely untouched here: passing the reference
attribute through to the trigger, and removing the ref plumbing that is no
longer needed, are both done in WB-2119.3. That means the popover does not
position yet on this commit alone -- the Popover jest suite only goes green once
WB-2119.3 is applied on top.

The superseded internal helpers (`focus-manager`, `initial-focus`,
`popover-event-listener`) and the popper-specific parts of `popover-dialog` are
removed. `PopoverDialog` is now a small functional dialog wrapper.

The `Placement` and `RootBoundary` types are relocated off the
`wonder-blocks-tooltip` and `@popperjs/core` dependencies into a local
`util/types.ts` (both unions are unchanged).

Issue: WB-2119

Automated: `pnpm typecheck`, `pnpm lint`, and `pnpm build` all pass. The Popover
jest suite is expected to fail on this commit alone (see above) and goes green
with WB-2119.3 applied on top. Focus-flow behaviors
that jsdom can't faithfully run against floating-ui's focus guards are covered
by the Floating package's unit tests in #3182.

Manual (Storybook — Popover and PopoverContentCore stories):

1. Verify the popover renders with a single border + shadow (no double chrome)
   across placements, and the arrow/tail connects to the bubble.
2. Verify placement flip near viewport edges, and `rootBoundary="document"` /
   `viewportPadding` stories still position correctly.
3. Verify dismiss (Esc + outside click), initial focus, return focus,
   `closedFocusId`, controlled/uncontrolled open, and `portal` on/off.
4. Verify RTL placement (left/right) mirrors correctly.
5. Confirm standalone `PopoverContentCore` stories still look acceptable now
   that its chrome is provided by the parent (Floating) rather than itself.

Please review these risky changes

1. 🚨 `packages/wonder-blocks-popover/src/components/popover.tsx`: Full rewrite
   of the user-visible Popover to adopt Floating. Changes the positioning
   engine, focus management, and dismiss behavior.
2. ⚠️ `packages/wonder-blocks-popover/src/components/popover-content-core.tsx`:
   Removes the bubble chrome (border/background/shadow/radius); chrome is now
   provided by Floating. Affects the appearance of standalone
   `PopoverContentCore`.
3. ⚠️ `packages/wonder-blocks-popover/src/components/popover-dialog.tsx`:
   Reworked from a class component into a functional dialog wrapper; drops the
   tail and the `wonder-blocks-tooltip` dependency.
jandrade pushed a commit that referenced this pull request Aug 28, 2026
Migrate `Popover` off PopperJS and the `wonder-blocks-tooltip` dependency to
the new `Floating` component (floating-ui). `Floating` now owns positioning,
portaling, the tail/arrow, dismiss (Esc/outside click), and focus management.

This is stacked on #3182, which adds the `Floating` props this migration needs
(`returnFocus`, `closeOnFocusOut`, `onPlacementChange`, `shiftPadding`,
`rootBoundary`). Those `Floating` changes are not part of this diff.

Popover keeps its public API. `rootBoundary`/`viewportPadding` are remapped onto
floating-ui's `flip`/`shift` middleware; `autoUpdate` and `initialFocusDelay`
are now deprecated no-ops. Focus management is now floating-ui's non-modal model
(focus flows via focus guards) rather than the previous custom circular
navigation. The popover "bubble" chrome (background/border/shadow/radius) now
comes from `Floating`; `PopoverContentCore` no longer renders its own chrome.
The popover uses `strategy="fixed"` so it positions correctly inside clipping/
scrolling ancestors.

`PopoverAnchor` spreads the `FloatingReferenceAttributeName` attribute it is
given onto the trigger, so `Floating` can resolve the reference (anchor) element
from the DOM. The existing ref plumbing is left in place here and is cleaned up
separately in WB-2119.3.

The superseded internal helpers (`focus-manager`, `initial-focus`,
`popover-event-listener`) and the popper-specific parts of `popover-dialog` are
removed. `PopoverDialog` is now a small functional dialog wrapper.

The `Placement` and `RootBoundary` types are relocated off the
`wonder-blocks-tooltip` and `@popperjs/core` dependencies into a local
`util/types.ts` (both unions are unchanged).

Issue: WB-2119

Automated: `pnpm typecheck`, `pnpm lint`, and `pnpm build` all pass. The Popover
jest suite passes (tests updated for the new focus model). Focus-flow behaviors
that jsdom can't faithfully run against floating-ui's focus guards are covered
by the Floating package's unit tests in #3182.

Manual (Storybook — Popover and PopoverContentCore stories):

1. Verify the popover renders with a single border + shadow (no double chrome)
   across placements, and the arrow/tail connects to the bubble.
2. Verify placement flip near viewport edges, and `rootBoundary="document"` /
   `viewportPadding` stories still position correctly.
3. Verify dismiss (Esc + outside click), initial focus, return focus,
   `closedFocusId`, controlled/uncontrolled open, and `portal` on/off.
4. Verify RTL placement (left/right) mirrors correctly.
5. Confirm standalone `PopoverContentCore` stories still look acceptable now
   that its chrome is provided by the parent (Floating) rather than itself.

Please review these risky changes

1. 🚨 `packages/wonder-blocks-popover/src/components/popover.tsx`: Full rewrite
   of the user-visible Popover to adopt Floating. Changes the positioning
   engine, focus management, and dismiss behavior.
2. ⚠️ `packages/wonder-blocks-popover/src/components/popover-content-core.tsx`:
   Removes the bubble chrome (border/background/shadow/radius); chrome is now
   provided by Floating. Affects the appearance of standalone
   `PopoverContentCore`.
3. ⚠️ `packages/wonder-blocks-popover/src/components/popover-dialog.tsx`:
   Reworked from a class component into a functional dialog wrapper; drops the
   tail and the `wonder-blocks-tooltip` dependency.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant