Skip to content

Headless UI v2, and Switch on top of it - Laz 860 - #23898

Open
r1chm8 wants to merge 3 commits into
masterfrom
laz-860
Open

Headless UI v2, and Switch on top of it - Laz 860#23898
r1chm8 wants to merge 3 commits into
masterfrom
laz-860

Conversation

@r1chm8

@r1chm8 r1chm8 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Why now

v2 requires React 18's useId, which is why this was deferred. The workspace is on react: 18.3.1 (verified: the renderer resolves 18.3.1 and React DevTools reports 18.3.1 in
the running app), so the blocker is gone. There was a stale react@17.0.2 plus a Headless UI built against it in node_modules/.pnpm, but the lockfile has zero references to
either — orphaned artifacts, not real dependencies.

Commit 1 — @headlessui/react 1.7.19 → 2.2.10

The migration was far smaller than expected, for two reasons: v2 keeps dot notation as deprecated aliases, so nothing was forced to change to compile, and there is no
Transition usage anywhere
in Vortex, which is normally the bulk of a v1→v2 migration.

  • Dialog.Overlay is the only genuinely removed APIDialogBackdrop. One site, in Modal.tsx.
  • ~20 call sites moved off deprecated dot notation (Menu.Button, Popover.Panel, Listbox.Options, …) to the flat exports. Several of our own components share names with v2's
    exports, so those imports are aliased (PopoverButton as HeadlessPopoverButton).

anchor positioning

DropdownItems, PopoverPanel and ListboxOptions now take placement from anchor={{ gap: 4, to: "bottom end" }}, replacing hand-written absolute positioning. Three things
worth knowing, all confirmed from the compiled source rather than assumed:

  • anchor forces portal: true (_&&(s=!0) in menu.js). Panels move to #headlessui-portal-root at body level, so they escape clipping ancestors — but z-index becomes
    the only thing keeping them above the page.
  • It writes inline Floating UI styles, so the stylesheets' position: absolute; right: 0; margin-top had to go or they fight it. The 4px margin became gap: 4.
  • It also sets an inline max-height: min(var(--anchor-max-height, 100vh), …), and inline beats the stylesheet — so our designed 512px dropdown cap was silently becoming
    856px. Moving it to --anchor-max-height restores the cap and keeps the viewport-awareness.

Also removed the now-obsolete right-0 left-auto / absolute … mt-2.5 overrides at three call sites, since they'd fight the inline styles, and converted Picker's placement
prop to drive anchor rather than CSS classes. I kept the prop rather than deleting it as its TODO suggested — v2 removes the class hack, not the start/end choice the prop
expresses.

Commit 2 — Switch on Headless UI's Checkbox

Not HUI's Switch: ARIA only allows aria-checked to be true/false on role="switch", and HUI controls that attribute (SwitchPropsWeControl omits it from the public
props), so mixed cannot be expressed there. A tri-state master control is the checkbox pattern — which is what this component already was underneath (role="checkbox" +
native indeterminate). HUI's Checkbox takes indeterminate and emits aria-checked": l?"mixed":a?"true":"false", so the tri-state is preserved exactly.

A three-way radio group was considered and rejected: semi-on is never user-selectable (clicking only flips on↔off), so radios would expose a state the user can't legitimately
choose and announce it as "1 of 3".

  • The visually-hidden native input and the useEffect that imperatively set indeterminate are both gone; the component is 67 lines lighter.
  • CSS moved off our hand-derived data-state="on|semi-on|off" onto HUI's own data-checked, data-indeterminate, data-disabled, data-hover, data-active, data-focus
    so hover/active/focus now come from HUI's state and get proper pointer-vs-keyboard semantics.
  • onChange(event)onChange(checked: boolean). Both real consumers (the DisplayOptions wrappers) pass () => void, so were unaffected; five demo call sites that
    destructured event.target.checked were updated.

Testing

primitive result
Menu (Help dropdown, toolbar kebab) portalled, 4px gap, end-aligned, in viewport
Listbox (Picker) portalled, height capped from available space
Popover portalled, z-index preserved
Dialog DialogBackdrop dims correctly behind the panel
Picker placement="left" start-aligned, no leftover classes

Switch verified across all six states — off / on / semi-on, each enabled and disabled — with semi-on reporting aria-checked="mixed". The "select all" master switch goes mixed
→ all on → all off, never landing on mixed.

Switch tests went 6 → 8, adding explicit coverage for role="checkbox" + aria-checked="mixed" and for semi-on holding when checked={false}, since that pairing is the whole
reason for the Checkbox choice.

@r1chm8
r1chm8 requested a review from a team as a code owner August 6, 2026 13:45
@r1chm8
r1chm8 force-pushed the laz-860 branch 2 times, most recently from a228471 to c394df9 Compare August 6, 2026 13:55
r1chm8 and others added 3 commits August 6, 2026 16:22
Bumps @headlessui/react from 1.7.19 to 2.2.10, now that the workspace is on
React 18 and its useId requirement is satisfiable. Dot-notation subcomponents
(Menu.Button and friends) are deprecated in v2, so every call site moves to the
flat exports; Dialog.Overlay is the one API actually removed and becomes
DialogBackdrop.

Dropdown, listbox and popover panels now take their placement from v2's anchor
prop, which positions them with Floating UI and portals them out of any clipping
ancestor, replacing the hand-written absolute positioning in the stylesheets and
the right-0/left-auto overrides at the call sites. Panels flip and shift to stay
on screen, and the dropdown height cap moves to --anchor-max-height so it
survives the inline style anchor sets.

Closes LAZ-860.
Puts the tri-state switch onto a Headless UI primitive without losing the
semi-on state. It's `Checkbox` rather than `Switch` because ARIA only allows
aria-checked to be true or false on role="switch", and Headless UI controls that
attribute, so mixed can't be expressed there — a tri-state master control is the
checkbox pattern, which is what this component already was underneath.

The track and thumb now style themselves off the data attributes Headless UI
sets rather than a data-state we derived ourselves, the visually-hidden native
input is gone, and onChange hands over the new checked value instead of a change
event.
Resizing a toolbar with its overflow menu open makes chromium emit
"ResizeObserver loop completed with undelivered notifications." as an
ErrorEvent whose `error` is null, so errorHandler's fallback chain
resolved it to `evt.message` - a plain string.

Two things then went wrong. The string branch only matched chromium's
pre-92 wording ("loop limit exceeded"), so the modern message fell
through; and the packery check a few lines later read `error.stack`
unguarded, which on a string is undefined. The handler threw
"Cannot read properties of undefined (reading 'includes')", and that
exception - itself uncaught - was what reached the log, reported as a
bug in Vortex rather than the benign warning behind it.

Match the warning by prefix so both wordings are covered, and
preventDefault it so chromium stops printing it on its own account.
Read the stack once as a string for the library/extension sniffing, so
any ErrorEvent without an error object (a cross-origin "Script error."
too, not just this one) can no longer take the handler down.

The ResizeObserver loop itself is unchanged: floating-ui's autoUpdate
and the toolbar's own observer feed each other while the menu is open,
which chromium handles by deferring a frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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