v2.0.0: migrate to Base UI, slim the bundle, add a Playwright test harness#153
Open
librowski wants to merge 26 commits into
Open
v2.0.0: migrate to Base UI, slim the bundle, add a Playwright test harness#153librowski wants to merge 26 commits into
librowski wants to merge 26 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
@mui/baseis abandoned at5.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 oneButtoncost 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.4compound components. Public APIs preserved via internal mapping (Floating UIplacement→side/align,onClose→onOpenChange, tooltip delays via context). DatePicker rebuilt onreact-day-picker+date-fns; TextArea onreact-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 keepdist/overflow-ui.jsand a concatenateddist/index.cssalive for consumers with hardcoded paths. Every emitted stylesheet establishes the@layer ui.base, ui.componentorder 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.tsfrom main runs as part ofbuild.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*-endplacements; click-to-deselect wiping the DatePicker value; dead selected/disabled list-item styling; the dropped Inputerrorprop; 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)
@base-ui/react@^1.4and@phosphor-icons/react@^2Menu.onOpenChange(open: boolean, event?: Event)— was(event, open)with a misleadingly-typed React eventSelectAPI trimmultiple,listboxOpen,onListboxOpenChange,getSerializedValue(inherited from MUI'sUseSelectParameters) are gone; the popup now renders in a portal--ax-public-date-picker-header-backgroundremoved; rangeonChangefiresnulluntil the range is complete; single-day ranges rejected (Mantine default parity)dist/index.js+ per-entry CSS; legacydist/overflow-ui.js/dist/index.cssremain as generated shimsKnown gaps
getAnimations()checked at rAF, before the browser creates the CSS transition) — Menu/Select closes are often instant despite correct CSS; enter animations work reliably.Verification
pnpm --filter @synergycodes/overflow-ui build(incl. built-CSS guard),lint,typecheck(ui + website), and the full Playwright suite — 46/46 passing.