Skip to content

v2.0.0: migrate to Base UI, slim the bundle, add a Playwright test harness#153

Open
librowski wants to merge 26 commits into
mainfrom
base-ui-migration
Open

v2.0.0: migrate to Base UI, slim the bundle, add a Playwright test harness#153
librowski wants to merge 26 commits into
mainfrom
base-ui-migration

Conversation

@librowski

Copy link
Copy Markdown
Collaborator

Why

@mui/base is abandoned at 5.0.0-beta.62; Base UI (@base-ui/react) is its successor by the same team. On top of the forced migration, this PR fixes the library's two long-standing structural problems: a 166 KB-gzip single-file bundle (importing one Button cost consumers ~105 KB gzip) and zero test coverage.

What

Migration (10 components). Button, Switch, Input, TextArea, Modal, Menu, Select, Snackbar, Tooltip and DatePicker rebuilt on @base-ui/react@1.4 compound components. Public APIs preserved via internal mapping (Floating UI placementside/align, onCloseonOpenChange, tooltip delays via context). DatePicker rebuilt on react-day-picker + date-fns; TextArea on react-textarea-autosize; Snackbar's wrapper replaced with plain status ARIA.

Dependencies. Removed: @mui/base, @mui/material, @emotion/*, @mantine/core, @mantine/dates, @floating-ui/react. Externalized as peers: @base-ui/react@^1.4, @phosphor-icons/react@^2. Remaining bundled deps: clsx, date-fns, react-day-picker, react-textarea-autosize.

Build. Multi-entry library: per-component subpath exports (@synergycodes/overflow-ui/button …) with per-entry CSS; the barrel import still works. Compat shims keep dist/overflow-ui.js and a concatenated dist/index.css alive for consumers with hardcoded paths. Every emitted stylesheet establishes the @layer ui.base, ui.component order up front. Bundle: 166 KB gzip → ~40 KB gzip total, and consumers only load the chunks they import.

Tests (new — repo had none). Playwright harness over a component showcase: 26 visual snapshots (baseline captured before the migration started) + 20 interaction tests (keyboard nav, focus traps and restore, dismissal, selection flows). scripts/check-built-css.ts from main runs as part of build.

Review fixes. A 7-angle review of the branch surfaced 11 verified bugs, all fixed and test-pinned, including: inverted CSS layer cascade in dist/index.css; multiple-mode DatePicker losing the selection at exactly 2 picked dates; Menu offset direction flipped for *-end placements; click-to-deselect wiping the DatePicker value; dead selected/disabled list-item styling; the dropped Input error prop; Menu/Select animations attached to the wrong element; Switch being completely keyboard-inaccessible; controlled tooltips reacting to hover.

Breaking changes (v2.0.0-beta.0)

Change Impact
Peer dependencies Consumers must install @base-ui/react@^1.4 and @phosphor-icons/react@^2
Menu.onOpenChange Now (open: boolean, event?: Event) — was (event, open) with a misleadingly-typed React event
Select API trim multiple, listboxOpen, onListboxOpenChange, getSerializedValue (inherited from MUI's UseSelectParameters) are gone; the popup now renders in a portal
DatePicker Restyled to the design system (not pixel-identical to Mantine); --ax-public-date-picker-header-background removed; range onChange fires null until the range is complete; single-day ranges rejected (Mantine default parity)
Dist layout Entry is dist/index.js + per-entry CSS; legacy dist/overflow-ui.js / dist/index.css remain as generated shims

Known gaps

  • The Playwright suite runs locally only — needs a CI workflow with a pinned (Docker) image and a one-time re-baseline there.
  • Base UI 1.4.1 has an internal race in exit-animation detection (getAnimations() checked at rAF, before the browser creates the CSS transition) — Menu/Select closes are often instant despite correct CSS; enter animations work reliably.
  • The compat shims need a sunset milestone.

Verification

pnpm --filter @synergycodes/overflow-ui build (incl. built-CSS guard), lint, typecheck (ui + website), and the full Playwright suite — 46/46 passing.

librowski added 26 commits June 10, 2026 02:31
Setup Playwright visual regression testing as a gate for the upcoming
@mui/base -> @base-ui/react migration. 25 baseline screenshots cover all
components currently using @mui/base plus surrounding components.

- Expand preview-page from a single Button to a structured showcase with
  data-testid markers per section. Floating UI (Modal, Menu, Select,
  Tooltip, Snackbar) renders in dedicated showcases via ?show=... query
  param so screenshots can capture each open state.
- Add @base-ui/react@^1.4 and react-textarea-autosize as direct deps so
  individual component PRs can pull from them.
- Add @playwright/test, playwright.config.ts, and tests/visual/preview.spec.ts.
  Tests run against vite preview build on port 4173.
- Add scripts: preview:build, test:visual, test:visual:update.
- Add about/migration-base-ui.md with the full plan (migration + slimming
  @mui/material/@emotion + DatePicker rebuild epic).

Baseline bundle (for delta tracking): dist/overflow-ui.js 606.45 kB
(gzip 155.24 kB), dist/index.css 81.88 kB (gzip 10.59 kB).
Migrate three low-risk components off @mui/base. Visual regression (25
snapshots) is identical to baseline.

- BaseButton: drop prepareForSlot wrapper. The wrapper was only needed
  because @mui/base required slot-roots to be prepared this way; Base UI
  uses render-props instead, so the BaseButton no longer needs slot
  semantics. Replace <Button from '@mui/base/Button'> with native <button>
  since our usage didn't depend on any @mui/base/Button-specific
  features.
- Switch: replace <SwitchBase slotProps={...}> with Switch.Root +
  Switch.Thumb compound. Base UI 1.x has no Switch.Track, so trackChildren
  is rendered manually. Wraps the Root in an outer <span> with display:
  contents render-prop trick to keep the existing CSS selectors working
  (the hidden <input> stays a sibling of Root, so :has(input:checked) on
  the wrapper still applies). Public API of BaseSwitchProps preserved.
- Input: drop slotProps. Base UI Input is a single component with no
  startAdornment/endAdornment built in, so render adornments manually as
  a wrapper <div>. Public API unchanged. types.ts no longer extends
  @mui/base InputProps - uses native InputHTMLAttributes.

Library bundle delta: 606.45 kB -> 615.06 kB (gzip 155.24 -> 158.97).
Slight increase because @mui/base is still installed for the remaining
components; will reverse once the migration is complete.
…emotion

Migrate TextArea and Modal off @mui/base, simultaneously drop
@mui/material (only Fade was used) and the entire @emotion family
(peer deps of @mui/material with zero direct usage in src).

- TextArea: replace @mui/base/TextareaAutosize with the standalone
  react-textarea-autosize (same author, drop-in API). One intentional
  visual delta: react-textarea-autosize correctly honors minRows={2}
  whereas @mui/base ignored it on first render - baseline updated for
  the textarea section to reflect the corrected behavior.
- Modal: rewrite with @base-ui/react Dialog (Root + Portal + Backdrop
  + Popup compound). Drop the @mui/material Fade transition entirely
  - Base UI handles enter/exit via data-starting-style / data-ending-style
  data-attributes, but we omit the fade transition for now since
  visual tests run with animations disabled. Existing CSS classes
  (modal-base, backdrop, modal) work unchanged with the new DOM shape.
  Dialog.Title and Dialog.Description rendered as <span> via render
  prop to preserve the previous DOM (defaults are <h2>/<p>).
- Drop dependencies: @mui/material, @emotion/styled, @emotion/react.
  No remaining direct or transitive usage.

Library bundle delta: 615.06 kB -> 577.88 kB (gzip 158.97 -> 148.24,
-10.73 kB gzip). 24/25 visual snapshots identical to baseline; textarea
baseline updated as documented above.
Migrate the two high-risk components off @mui/base. All 25 visual
snapshots identical to baseline.

- Menu: rewrite from <Dropdown><MenuButton/><MenuBase/></Dropdown> to
  <Menu.Root><Menu.Trigger render={children}/><Menu.Portal>
  <Menu.Positioner side align sideOffset alignOffset><Menu.Popup>
  <Menu.Item/></Menu.Popup></Menu.Positioner></Menu.Portal></Menu.Root>.
  Floating-ui Placement is mapped to Base UI side+align via a small
  helper. OffsetOptions is mapped to sideOffset/alignOffset. Public
  API (items, size, placement, open, onOpenChange, offset, children)
  unchanged. The slotProps escape hatch was dropped - no consumer in
  the monorepo used it.
- create-trigger-button.tsx removed entirely. Base UI Menu.Trigger
  natively merges its props and ref into the element passed via
  render={children}, so the cloneElement+forwardRef helper became
  redundant.
- Select: full compound rewrite with Select.Root + Trigger (rendering
  our SelectButton) + Value (render-prop child) + Portal + Positioner
  + Popup + Item. Drop UseSelectParameters base type in favor of an
  explicit SelectBaseProps interface. SelectButton simplified to a
  forwardRef'd <button> taking native props (no more
  SelectRootSlotProps with ownerState). SelectValue takes the raw
  value rather than a SelectOption from @mui/base.
- select-button.module.css: replace .base--expanded selector with
  [data-popup-open] (Base UI Trigger's open-state data attribute).
- select.module.css: width: 100% on .popup -> width: var(--anchor-width)
  since the Positioner is now portaled to body and the old percentage
  was relative to a position:relative container.
- list-box.module.css: replace .base--open visibility hack with
  [data-open] attribute selector to match Base UI's open-state
  signaling.

Library bundle delta: 577.88 kB -> 667.49 kB (gzip 148.24 -> 176.27).
Temporary increase because @mui/base is still pulled in for Snackbar
(the only remaining consumer) - that goes away in the next phase.
Final phase. @mui/base no longer in dependencies; the migration off
the deprecated package is complete.

- Snackbar: drop the @mui/base/Snackbar wrapper. The wrapper was
  little more than an open-gate around <div> with no functional
  contribution to the visible component (no autohide, no transitions,
  no portal). Replace with a plain <div role="status"
  aria-live="polite"> that preserves the same DOM shape and
  accessibility semantics. No public API change.
- Drop dependency: @mui/base. grep -r '@mui/base' packages/ui/src
  returns nothing.

Library bundle delta: 667.49 kB -> 628.27 kB (gzip 176.27 -> 165.94).
Compared to the pre-migration baseline (606.45 kB / 155.24 kB gzip)
the bundle is +21.82 kB / +10.70 kB gzip - @base-ui/react is heavier
than the @mui/base subset we used because we now ship full Floating
UI for Menu/Select/Dialog. Net slimming will come from the separate
DatePicker epic which removes the Mantine block (~105 kB gzip).
Four parallel research-agent reports plus synthesis covering:

- bundle composition (rollup-plugin-visualizer breakdown by package)
- DatePicker rebuild alternatives (react-day-picker vs react-aria etc.)
- Externalization strategies (peer deps scenarios A/B/C)
- Code-splitting / subpath exports (vite multi-entry feasibility)

Key findings (see about/research-final-recommendations.md for the full
synthesis):

- Floating UI exists in three independent copies in the bundle:
  @floating-ui/react@0.26 (direct, our Tooltip + Mantine), the 1.7.x
  stack pulled in transitively via @base-ui/react, and a vendored
  copy inside @base-ui/react/esm/floating-ui-react/. ~50 kB realnych
  gzip total.
- Mantine costs 53 kB gzip realnie (measured by physical rebuild with
  vs without Mantine). DatePicker rebuild on react-day-picker would
  net 5-20 kB gzip, not 80 kB as initially estimated.
- Tree-shaking effectively does NOT work for the current single-entry
  monolithic bundle. A consumer importing only Button still pulls
  ~105 kB gzip; multi-entry subpath exports would drop that to
  40-60 kB gzip.
- @base-ui/react accounts for ~125 kB gzip and is "honest cost" with
  proper tree-shaking; further reduction in the library bundle
  requires externalization as a peer dependency (scenario B major
  v2.0.0 bump).

Tooling additions:
- rollup-plugin-visualizer in devDependencies
- packages/ui/scripts.build:stats produces dist/bundle-stats.html and
  dist/bundle-stats.json. Gated on BUNDLE_STATS=1 env var so the
  default build stays fast.
…ng-ui/react

Migrate Tooltip off the custom @floating-ui/react implementation onto
Base UI's compound Tooltip primitives. Drop @floating-ui/react from
direct dependencies. All 25 visual snapshots identical to baseline.

- tooltip.tsx: wrap Tooltip.Root with delay=500ms / closeDelay=0ms to
  preserve the previous timing. Two internal contexts bridge the API
  shape: placement->side/align via TooltipPlacementContext, and
  delay/closeDelay (Base UI puts these on Trigger, not Root) via
  TooltipDelayContext.
- tooltip-trigger.tsx: use Base UI Tooltip.Trigger via render prop.
  Without asChild it still renders <div>{children}</div> with
  data-state="open|closed" so the existing DOM shape stays the same.
  closeOnClick={false} preserves the prior behavior (clicking the
  trigger did not close the tooltip).
- tooltip-content.tsx: replace the custom FloatingPortal/FloatingArrow
  with Tooltip.Portal -> Positioner -> Popup -> Arrow. Same CSS classes
  applied on the Popup so the visual output is unchanged. Arrow is a
  small inline 10x4 SVG whose color follows the popup background via
  currentColor + the new .arrow-default/.arrow-blue rules.
- use-tooltip.tsx: deleted - Base UI handles the floating-ui plumbing
  internally.
- TooltipOptions moved from use-tooltip.tsx to tooltip.tsx; the
  Placement union is locally defined (replaces import from
  @floating-ui/react).
- menu.tsx: Placement and OffsetOptions are now locally defined types
  (structurally compatible with floating-ui's, so no consumer breaks).
- Drop @floating-ui/react from packages/ui/dependencies. Mantine still
  pulls it transitively, so it does not fully leave node_modules until
  the DatePicker rebuild.

Library bundle delta: 628.27 kB -> 609.78 kB (gzip 165.94 -> 160.58,
-5.36 kB gzip).
…ntine/*

Replace the Mantine DatePickerInput with a react-day-picker calendar
composed inside a Base UI Popover. Drop @mantine/core and @mantine/dates
along with their entire transitive ecosystem (@mantine/hooks, dayjs,
react-remove-scroll, etc.). All 25 visual snapshots pass.

- date-picker.tsx: full rewrite. Popover.Root (controlled open) wraps a
  custom <button> trigger (preserving the existing CSS classes for
  size, font, error states) with sideOffset=4 between trigger and
  calendar. Three branches for type='default'|'range'|'multiple' map
  onto react-day-picker's discriminated mode prop. Custom Chevron
  using @phosphor-icons/react CaretLeft/CaretRight. dayjsTokenToDateFns
  helper preserves backwards compatibility for the default
  valueFormat='DD/MM/YYYY'.
- types.ts: drop the Mantine prop dependency. New DatePickerProps is
  a minimal subset (inputSize, disabled, readOnly, onChange, minDate,
  maxDate, id, className, aria-*) covering the Mantine props anyone
  in the monorepo actually used. The big "...rest" Mantine surface
  (firstDayOfWeek, excludeDate, clearable etc.) is intentionally
  removed - confirmed unused by grep across the repo.
- data-picker-mantine.css: rewritten in place against react-day-picker's
  DOM (rdp-* classes + data-selected/today/outside/disabled attributes).
  Same --ax-public-date-picker-* design tokens. Filename kept because
  packages/website/docs/authored/ui/date-picker/date-picker-docs.tsx
  literally references this path; an inline comment marks the legacy
  name.
- date-picker.module.css: trigger now has display: flex with
  justify-content: center / text-align: center so the rendered date
  stays centered (matches the Mantine baseline 1:1).
- Drop dependencies: @mantine/core, @mantine/dates. With them gone,
  @floating-ui/react/* (their transitive Popper) also leaves
  node_modules.

Library bundle delta: 609.78 kB -> 481.99 kB (gzip 160.58 -> 127.55,
-33.03 kB gzip). Cumulative saving versus the pre-D baseline
(628.27 / 165.94 gzip): -146.28 kB raw / -38.39 kB gzip.

Note: the visual snapshot exercises only the closed-trigger state of
the DatePicker. The open calendar layout differs from the Mantine
original (different DOM, intentional design). A separate showcase for
the opened calendar can be added later if we want regression coverage
on the dropdown itself.
Refactor the library from a single ESM bundle into 21 entries (1 barrel
+ 20 per-component) with subpath exports, so consumers can either
import { Button } from '@synergycodes/overflow-ui/button' or keep using
the barrel. Backwards compatible.

- Add per-component src/components/<name>/index.ts re-exporting what
  src/index.ts used to pull from individual files. The barrel itself
  is rewritten to re-export from these new index files instead of
  reaching into component internals.
- vite.config.mts: lib.entry is now a record of 21 entries. Per-entry
  output via entryFileNames '[name].js'; shared modules go to
  chunks/[name]-[hash].js; CSS goes to assets/. vite-plugin-lib-inject-css
  v2 splits CSS per entry, vite-plugin-dts honors entryRoot and emits
  one .d.ts tree under dist/components/<name>/.
- package.json:
  - main / module / types now point at ./dist/index.js / ./dist/index.d.ts
    (renamed from overflow-ui.js).
  - exports map: '.' (barrel) plus one subpath per component, plus
    './tokens.css'. Each subpath has its own types path under
    ./dist/components/<name>/index.d.ts.
- All 25 visual snapshots pass — preview-page still imports from the
  barrel and gets the same output.

Bundle measurements with a test consumer (lib-only delta after
subtracting React):
- 1 component (subpath or barrel — both work): ~33 kB JS gzip + ~3 kB CSS
- 5 components (subpath): ~48 kB JS gzip + ~5 kB CSS
- The barrel tree-shakes equally well now, but subpath imports document
  intent and protect against bundler regressions.

The library "size" no longer maps to a single number because consumers
only pay for what they import. For comparison, the old monolithic
single-bundle was 481.99 kB raw / 127.55 kB gzip and was paid in full
by every consumer.
…/Tooltip

Restore the soft fade-in/scale-up enter/exit animations that we lost
when migrating off MUI. Base UI exposes [data-starting-style] and
[data-ending-style] data attributes during the open/close transition;
adding CSS transitions targeting those attributes is enough.

- modal.module.css: 200ms opacity + scale(0.96) transition on
  .modal-base, opacity transition on .backdrop.
- list-box.module.css: 150ms opacity + scaleY(0.96) transition on
  .popup, used by Menu and Select. transform-origin: top so it grows
  out of the trigger.
- tooltip.module.css: 120ms opacity + scale(0.96) transition on the
  popup container.

Visual regression remains green - playwright runs with
animations: 'disabled', so snapshots capture the steady state and
the new transitions don't affect them. The animations are visible
when actually using the components.
…act as peer deps

Move the two largest dependencies to peerDependencies and externalize
them in the Vite config. Bump to 2.0.0-beta.0 to mark this as a major
release. All 25 visual snapshots pass.

- @base-ui/react and @phosphor-icons/react are now peer dependencies
  with broad version ranges (^1.0.0 and ^2.0.0). Consumers must install
  them directly.
- vite.config.mts external is now a function that excludes both
  packages plus their subpath imports (@base-ui/react/menu,
  @phosphor-icons/react/X, etc.).
- README updated with the new install command and a note showing the
  per-component subpath import pattern.
- version: 1.0.0-beta.26 -> 2.0.0-beta.0

Bundle delta:
- Total dist/*.js + dist/chunks/*.js raw: ~300 kB (was ~480 kB before
  externalization)
- Heaviest single chunk: dist/chunks/date-picker-*.js at 107 kB raw /
  26 kB gzip (react-day-picker + date-fns + our wrapper). Without that
  chunk, every component is now under 4 kB raw / 2 kB gzip.

The Base UI and Phosphor weight that used to be inlined into the
library bundle is now paid by the consumer once via npm install -
deduplicated across the rest of their app and tree-shaken by their
bundler. For a typical consumer that imports a handful of components,
the total cost (library + peer deps) is meaningfully smaller than the
inlined v1 bundle.

Migration note for v1.x consumers:

  pnpm add @base-ui/react @phosphor-icons/react

Then existing imports work unchanged.
After the Phase 8 multi-entry refactor, two file paths that v1
consumers (e.g. workflow-builder via its LOCAL_OVERFLOW_UI dev alias
and overflow-ui-css CSS alias) hard-coded against the old layout
disappeared:

- dist/overflow-ui.js (renamed to dist/index.js)
- dist/index.css (split per-entry into dist/assets/<name>.css)

Add a small post-build plugin to vite.config.mts that recreates both:

- dist/overflow-ui.js: a one-line re-export of dist/index.js, plus a
  matching dist/overflow-ui.d.ts. Enough to keep direct path imports
  and aliases working.
- dist/index.css: a concatenation of every per-entry CSS asset under
  dist/assets/. The new multi-entry build still emits per-entry CSS
  for tree-shaking; this combined file exists alongside it for
  consumers that want a single stylesheet (no exports map change).

These shims do not change the public API and add zero runtime cost
for new consumers using subpath imports. They simply avoid forcing
existing consumers to update hardcoded paths in their tooling.
Bring the open-calendar look back to 1:1 parity with the previous Mantine
DatePickerInput. Adds a dedicated visual regression showcase for the
opened state and tightens the CSS so it matches Mantine pixel for pixel.

- preview-page: add ?show=date-picker-open showcase rendering DatePicker
  with a default value; the visual test clicks the trigger to capture
  the calendar.
- tests/visual: register date-picker-open in the floating-showcases list
  so visual regression now covers both the closed trigger and the open
  calendar.
- date-picker.tsx: weekStartsOn={1} on every <DayPicker> mode (default,
  range, multiple) so the week order matches Mantine (Mo first instead
  of Su first).
- data-picker-mantine.css:
  - .rdp-month uses CSS Grid (auto 1fr auto / 2 rows) so the previous
    button, caption and next button share one row, with the day grid
    spanning all columns below. This fixes the previous bug where the
    chevrons rendered in the middle of the day grid because of broken
    absolute positioning.
  - .rdp-month gets a subtle header background (--ax-ui-bg-tertiary, the
    Mantine-equivalent gray-300), and each day row (.rdp-week) overrides
    that with the popup background so only the caption + weekday rows
    look "headed".
  - day cells widened (1.875rem -> 2rem) and day buttons widened
    (1.375rem -> 1.5rem) to match the Mantine sizing.

Visual regression: 26/26 pass (added the date-picker-open snapshot).
The library is written against Base UI 1.4 APIs (Tooltip delay props,
eventDetails in onOpenChange). A consumer installing 1.0 would satisfy
^1.0.0 but break at runtime.
Replace the (event, open) callback shape — which casted Base UI's
native event to a React synthetic event just to preserve the v1
signature — with (open: boolean, event?: Event). Consistent with
Tooltip's onOpenChange (open first) and truthfully typed.

No call sites in the repo used the old signature.
Replace data-picker-mantine.css (global stylesheet emulating the old
Mantine look, .mantine-Popover-dropdown hook included) with calendar
styles in the date-picker CSS module, scoped under a local .calendar
class. Simplifications:

- drop the dead --rdp-* variable block (react-day-picker's stylesheet
  is not imported, so those vars affected nothing)
- drop the banded header background and its .rdp-week override hack
  (uniform popup background instead); --ax-public-date-picker-header-
  background is gone
- range selection uses plain full-cell backgrounds with rounded outer
  corners instead of 50% linear-gradient seams

Behavior fixes surfaced by the new interaction tests:

- pass defaultMonth so the calendar opens at the month of the current
  value instead of always today's month
- close the popover after a single-mode date pick (range/multiple stay
  open for further picks)

date-picker-open.png snapshot updated intentionally; the closed
trigger snapshot is unchanged. Website docs + generated metadata
(css-variables, path-types, props) regenerated.
The Base UI Switch root was rendered as a span with display: contents,
which is not focusable in Chromium — keyboard users could not reach or
toggle the switch at all, and the focus ring CSS keyed off
:has(input:focus-visible) never fired because Base UI focuses the root,
not the hidden form input (which it renders as a *sibling* of the root,
outside the styled container).

Make the styled container span the Switch root itself (a real box,
focusable via Base UI's tabindex) with nativeButton={false} so Base UI
attaches Space/Enter handling to it. State selectors move from
:has(input:checked)/:has(input:disabled) to Base UI's [data-checked]/
[data-disabled] attributes in switch, switch-size and icon-switch CSS;
focus ring is now a plain :focus-visible on the root.

Caught by the new Playwright interaction tests; the switch visual
snapshot is byte-identical.
Visual snapshots prove the migration looks right; these prove it
behaves right. New ?show=interactive preview showcase renders stateful,
fully-wired components (nothing forced open) and interactions.spec.ts
drives them by mouse and keyboard:

- Modal: open/close via trigger, Escape (with focus restore), backdrop
  click, X and footer buttons; focus trap (no interactive element
  outside the dialog reachable while open — Base UI traps via inert,
  so sentinel/body stops are allowed)
- Menu: click and full keyboard flow (Enter opens + highlights first
  item, arrows, Enter activates), Escape restores trigger focus
- Select: mouse selection and full keyboard flow, value reported via
  onChange
- Switch: click toggle and keyboard Space toggle
- Tooltip: opens on hover after delay and on keyboard focus
  (focus-visible)
- DatePicker: pick-a-day updates trigger and closes; Escape closes
  without changing the value

These tests caught the Switch focusability bug and both DatePicker
behavior gaps fixed in the previous commits.
Only the barrel entry imported src/styles/layers.css, so the
'@layer ui.base, ui.component;' ordering statement was absent from
per-component CSS assets and landed mid-file in the alphabetically
concatenated dist/index.css — after assets had already used both
layers, fixing the order as [ui.component, ui.base] and inverting the
cascade (ui.base rules beat ui.component). The website shadow-dom-css
plugin loads dist/index.css and was directly affected; subpath
consumers could hit the same inversion depending on import order.

Prepend the layer-order statement to every emitted CSS asset in
generateBundle — re-stating an established order is a no-op, so the
repetition is harmless and makes each asset self-sufficient.
Floating UI's crossAxis is a physical offset while Base UI's
alignOffset is logical (it inverts for align='end', matching Floating
UI's alignmentAxis — both proven from the installed @floating-ui/core
source, where alignOffset feeds alignmentAxis and end alignment
negates it). The previous mapping passed crossAxis straight through,
flipping the offset direction on every *-end placement, including the
component default 'bottom-end'. It also gave crossAxis precedence over
alignmentAxis, the reverse of Floating UI.

Negate crossAxis for end alignment, let alignmentAxis win, and drop
the dead trailing branch plus the type-erasing cast.
Found by code review; each fix is pinned by a new interaction test on
new range/multiple interactive showcases.

- single: add `required` so re-clicking the selected day confirms
  instead of firing onChange(null) and wiping the value
  (react-day-picker deselects by default; Mantine's allowDeselect was
  off).
- range: the first click used to report a 'complete' [d, d] range
  (addToRange with min=0 sets to=from), contradicting the documented
  contract. min={1} makes the first click partial, and an internal
  rangeDraft state renders the in-progress highlight that the public
  [Date, Date] | null type cannot represent. Completing the range
  fires onChange once and closes the popover (Mantine parity);
  closing mid-pick abandons the draft.
- multiple: a selection of exactly 2 dates was misclassified by
  isDateTuple (any 2-element Date array matches), passing
  selected=undefined to DayPicker so the third pick erased the first
  two. Guard by mode, not by shape.
…tributes

list-item.module.css still keyed selected/disabled state on the
MUI-era base--selected/base--disabled classes, which Base UI
Menu.Item/Select.Item never apply — the rules were dead, so a Select
with a chosen value showed no selected highlight and disabled menu
items kept the hover background and pointer cursor.

Key the rules on Base UI's [data-selected]/[data-disabled] instead,
and add [data-highlighted] beside :focus-visible so keyboard
navigation has a visible cursor. The selected rule comes after the
highlight rule so a selected item keeps its selected colors while
highlighted (the outline alone marks the cursor).

The menu-open/select-open showcases now include a disabled item and a
preselected value, so these states are pinned by visual snapshots
(both updated intentionally).
The @mui/base Input applied base--error/base--disabled classes to its
root, which input-root.module.css still targets — after the migration
the plain root div never received them, so the rules were dead and
the public `error` prop (previously inherited from InputBaseProps)
disappeared from the API entirely.

Reintroduce error?: boolean on InputProps and apply the state classes
on the root (same convention TextArea already uses), restoring the
error border/background and the root-level disabled color that
adornments inherit. The input showcase gains an error variant so both
states are snapshot-pinned (input.png updated intentionally).
The Phase 9 enter/exit transition rules lived under .popup, which
menu.tsx and select.tsx apply to the Base UI *Positioner* — but Base UI
only sets data-starting-style/data-ending-style on the *Popup*
element, so the rules never fired and dropdowns opened with no
animation. The :not([data-open]) display:none rule on the positioner
additionally guaranteed any exit transition would be cut off.

Move the visual box (background, radius, shadow) and the transition
rules to .list-box (the Popup element — the same pattern
Tooltip.Popup/Dialog.Popup already use), leaving the positioner with
only margin and z-index. Verified at runtime: the popup opacity ramps
0 to 1 over 150ms on open.

Note: the exit transition is declared correctly but Base UI 1.4.1's
unmount gating (useAnimationsFinished checking getAnimations() at rAF
time) races with the browser creating the CSS transition at style
recalc — the close is often instant. Forcing a style flush (e.g. any
getComputedStyle call between data-ending-style and the rAF) makes it
animate, which confirms the race is in Base UI, not in this CSS.
Snapshots are unaffected (the suite runs with animations disabled).
… props

Two regressions vs the Floating UI implementation, both pinned by new
interaction tests:

- Controlled tooltips reacted to hover/focus: the old hook passed
  enabled: controlledOpen == null to useHover/useFocus, so a parent
  controlling `open` had sole authority (dismissal stayed active).
  Base UI routes every interaction through onOpenChange, so the
  wrapper now filters the trigger-hover/trigger-focus/focus-out
  reasons when `open` is controlled — Escape still propagates.

- TooltipTrigger spread child/user props AFTER Base UI's trigger
  props, so a child with its own onMouseEnter/onFocus silently
  replaced the tooltip's interaction handlers (the old code composed
  via getReferenceProps). Use Base UI's mergeProps, which composes
  event handlers and merges className/style.

The interactive showcase gains a controlled-tooltip section and the
asChild trigger records its own hover, so both behaviors are covered:
hover must both fire the child handler and open the tooltip; a
controlled tooltip must ignore a 900ms hover yet follow its open prop
and close on Escape.
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