Skip to content

CORE-2003: Migrate composite UI components (Tooltip, Toast, Tabs, etc.) - #128

Open
OpenStaxClaude wants to merge 12 commits into
mainfrom
migrate-composite-ui-components
Open

CORE-2003: Migrate composite UI components (Tooltip, Toast, Tabs, etc.)#128
OpenStaxClaude wants to merge 12 commits into
mainfrom
migrate-composite-ui-components

Conversation

@OpenStaxClaude

@OpenStaxClaude OpenStaxClaude commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrated composite UI components from styled-components to plain CSS with CSS variables as part of the migration away from styled-components.

Components migrated:

  • ✅ Tooltip (including TooltipGroup and CustomTooltip)
  • ✅ Toast
  • ✅ ToastContainer (including BodyPortalToastContainer)
  • ✅ Tabs (including button-bar variant)
  • ✅ ButtonBar
  • ✅ DropdownMenu (already migrated, included for completeness)

Migration approach:

  • Converted styled-components to className-based styling
  • Created separate CSS files for each component
  • Bound theme values to CSS variables for consistency
  • Used classnames library for conditional styling
  • Maintained all existing functionality, variants, and props

CSS Variables used:

Each component now uses CSS custom properties that reference the palette:

  • Tooltip: --tooltip-bg, --tooltip-color, --tooltip-border-color
  • Toast: --toast-success-*, --toast-neutral-*, --toast-failure-*
  • ToastContainer: --toast-container-z-index
  • Tabs: --tabs-border-color, --tabs-active-border-color, --tabs-button-*
  • ButtonBar: --button-bar-border-color, --button-bar-selected-bg, --button-bar-hover-bg

API compatibility

I searched the openstax org for every export this PR removes, so the calls below are based on actual usage rather than guesswork.

Kept (would have broken consumers):

  • StyledTooltip / StyledTrigger are still exported, now as plain-CSS components, both @deprecated in favour of Tooltip / TooltipGroup. openstax/assignments uses UI.StyledTrigger in LicenseNotice.tsx.

Removed (no consumers outside this repo):

  • The tabListBaseCss / tabBaseCss / buttonBarWrapperCss / buttonBarItemCss fragments from Tabs. Documented in CHANGELOG.md under the existing [Unreleased] → BREAKING CHANGES section, following the CORE-1999 Button migration format.

Public style props accept CSS custom properties:

  • Tabs, ButtonBar, Tooltip, StyledTooltip and StyledTrigger type style as CSSPropertiesWithVariables rather than React.CSSProperties, so callers can override the documented --tabs-* / --button-bar-* / --tooltip-* variables without a cast. Toast and ToastContainer expose no style prop, so they're unaffected. This is a widening — existing style usage is unchanged.

Three regressions found and fixed during review:

  • Tabs narrowed react-aria's className / style / children render-callback unions to static values, so callbacks were rejected at compile time and a style callback spread to {} at runtime. It now resolves callbacks against the render props before merging.
  • Tooltip set className="tooltip" after {...props}, discarding a caller's className. styled-components used to merge it, and openstax/assignments relies on that via styled(UI.TooltipGroup). Both now merge.
  • The StyledTooltip / StyledTrigger compat wrappers didn't forward refs, which the styled-components they replaced did (styled-components has forwarded refs since v4; this repo is on 5.3.5). Both are now forwardRef, matching the NavBarButton / NavBarPopover pattern from CORE-2004: Migrate NavBar components to plain CSS #130.

Each has a regression test that fails against the previous implementation.

Notes for reviewers

  • Rebased onto main (2026-08-26).
  • The forced-colors selected-state styles from fix selected tab colors in forced colors mode #135 are carried into both Tabs.css and ButtonBar.css. On main ButtonBar picked those up implicitly by interpolating buttonBarItemCss; now that the CSS is split per component they are declared in both files.
  • Style objects use CSSPropertiesWithVariables (added to src/types on main by CORE-2004: Migrate NavBar components to plain CSS #130) instead of casting to React.CSSProperties. BodyPortal requires this type on main, so ToastContainer would not typecheck without it.
  • Tooltip's props are narrowed so icon/ariaLabel are only accepted by TooltipGroup, which owns the trigger button.
  • CustomTooltip merges a caller-supplied className/style rather than dropping them.
  • Thanks for the palette.darkerGreen fix in 2035082 — the startsWith('#') workaround in Toast.tsx is gone now that the palette value is correct.

Testing

npm run lint, npx tsc --noEmit, npm run test (39 suites / 250 tests), npm run build, and npm run ladle-build all pass locally.

All components have existing Ladle stories that demonstrate:

  • Different size variants (small, medium, large)
  • Different visual variants (button-bar, success/neutral/failure)
  • Interactive states (hover, selected)
  • Different placements (for Tooltip)

Related

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Migrates several composite UI components from styled-components to plain CSS files, using CSS custom properties (bound to the theme palette) plus classnames for variant/state styling to support the ongoing styled-components removal.

Changes:

  • Replaced styled-components implementations for Tooltip, Toast, ToastContainer, Tabs, and ButtonBar with className + imported CSS files.
  • Introduced per-component CSS variables (set via inline style) to keep theme/palette values configurable.
  • Added new CSS stylesheets for the migrated components and updated component structure accordingly.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/components/Tooltip.tsx Switched Tooltip/TooltipGroup/CustomTooltip to class-based styling with CSS variables.
src/components/Tooltip.css New stylesheet implementing Tooltip layout/placement/trigger styles.
src/components/ToastContainer.tsx Replaced styled toast container wrappers with classnames + CSS variable for z-index.
src/components/ToastContainer.css New stylesheet for toast container layout and fixed positioning behavior.
src/components/Toast.tsx Replaced styled toast with classnames + CSS variables for variant colors and animation delay.
src/components/Toast.css New stylesheet for toast base layout, variants, and dismissal animation.
src/components/Tabs.tsx Replaced styled Tabs wrapper with classnames + CSS variables for borders/backgrounds.
src/components/Tabs.css New stylesheet for default and button-bar variants and size variants.
src/components/ButtonBar.tsx Replaced styled wrapper with a plain div + classnames + CSS variables.
src/components/ButtonBar.css New stylesheet for button bar layout, sizes, and selected/hover states.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/components/Tooltip.tsx Outdated
Comment thread src/components/Tooltip.tsx
Comment thread src/components/ButtonBar.tsx Outdated
Comment thread src/components/ButtonBar.css
Comment thread src/components/ButtonBar.css
RoyEJohnson

This comment was marked as outdated.

RoyEJohnson

This comment was marked as resolved.

@OpenStaxClaude

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

@OpenStaxClaude

Copy link
Copy Markdown
Contributor Author

@RoyEJohnson Copilot's forwardRef comment is addressed in dcde66a — details in the thread. Both checks are green on dcde66a, including Jira now that 70af7d9 fixed the secret names.

One unrelated thing I ran into and want to flag rather than quietly leave. While verifying I hit an intermittent failure in ProfileMenu › matches snapshot with user initials, which this PR doesn't touch. It reproduces reliably with a cold jest cache running the full suite, and never when ProfileMenu is run on its own — so it's an ordering/parallelism interaction. The diff is always react-aria focus state:

-       data-focus-visible="true"
-       data-focused="true"

I checked out unmodified origin/main in a throwaway worktree and it fails there too, 4/4 cold-cache runs (231 tests, i.e. without my additions). So it's pre-existing on main, not something this branch introduced. Warm-cache runs pass, which is why CI stays green.

I've left it alone since it's outside this PR's scope, but it's a real latent flake — happy to open a ticket if you want it chased.

@RoyEJohnson RoyEJohnson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Open a separate bug-fix ticket for the testing issue.

@OpenStaxClaude

Copy link
Copy Markdown
Contributor Author

@RoyEJohnson Filed as CORE-2715: Fix flaky ProfileMenu snapshot test in ui-components — Bug, component REX, Maintenance, Low priority, in Backlog.

It carries the reproduction command, the exact failing diff, and the evidence it's pre-existing. I re-verified against current main (5fdc635) before filing: 3/3 cold-cache full-suite runs fail there, so it's independent of this branch. The acceptance criteria explicitly rule out "fixes" that just re-record the snapshot or skip the test, since either would hide whatever is non-deterministic. Triage hypotheses about react-aria's input-modality state are in a collapsed block, labelled as unconfirmed rather than presented as a diagnosis.

Two things to flag:

  • The API rejected my relates to link to CORE-2003 with 401 No Link Issue Permission, so I recorded the relationship in a ticket comment instead. Worth adding the real link if you want it.
  • This branch is now 1 commit behind main after CORE-2007: Migrate ManageCookies component (createGlobalStyle) #131 landed. GitHub reports it MERGEABLE and the new commit only touches ManageCookies and checks.yml, neither of which this branch goes near — so I've left it rather than force-pushing a rebase you didn't ask for. Happy to rebase if you'd prefer it current.

Nothing else outstanding: both review threads are resolved and build and Jira are green on dcde66a.

OpenStaxClaude and others added 10 commits August 28, 2026 10:04
Migrated Tooltip, Toast, ToastContainer, Tabs, DropdownMenu, and ButtonBar
components from styled-components to plain CSS with CSS variables.

Changes:
- Converted styled-components to className-based styling
- Created separate CSS files for each component
- Bound theme values to CSS variables
- Used classnames library for conditional styling
- Maintained all existing functionality and variants

Related to CORE-2003

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Update Toast.spec.tsx.snap
Co-Authored-By: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Fix ButtonBar to accept standard HTML div attributes

Extends ButtonBarProps with React.HTMLAttributes<HTMLDivElement> to allow
passing standard div attributes like className, style, id, etc.

Also properly merges custom className and style props with component defaults.

Addresses code review comment about breaking TS API change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Restore data-selected styling

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
BodyPortal never uses style, so remove the style parameter from where ToastContainer calls it.
Have Tabs merge style parameters

Address Copilot code review comments

1. Fix Tabs style prop merge order
   - Changed from {...style, ...additionalStyle} to {...additionalStyle, ...style}
   - Allows consumers to override CSS variables via the style prop

2. Add style prop support to BodyPortal
   - Added style to BodyPortalProps type
   - Apply style to portal element in useLayoutEffect
   - Clean up styles on unmount
   - Pass style from BodyPortalToastContainer to BodyPortal
   - Fixes z-index CSS variable binding for toast containers

3. Remove unused icon prop from Tooltip
   - The icon prop is only used in TooltipGroup, not in Tooltip itself
   - Removed from Tooltip destructuring to avoid unused variable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Fix BodyPortal to avoid teardown/reinsert on style changes

Split the useLayoutEffect into two separate effects:
1. One for portal creation/destruction and non-style props (doesn't depend on style)
2. One for style updates only (depends on style but doesn't remove/reinsert the portal)

This prevents unnecessary DOM churn when only CSS variables/styles change,
which could cause focus loss or UI flicker in portal-based components like
ToastContainer.

Also added comprehensive tests for:
- CSS variable application
- Regular CSS property application
- Style updates without portal removal
- Style cleanup on unmount
- Handling of null/undefined style values

Addresses code review comments about performance and test coverage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Address Copilot review comments for style handling and prop forwarding

Fix three issues identified in code review:

1. BodyPortal: Convert camelCase to kebab-case for style cleanup
   - React style objects use camelCase (e.g., backgroundColor)
   - CSS removeProperty expects kebab-case (e.g., background-color)
   - Added conversion to prevent style property leaks on unmount

2. BodyPortalToastContainer: Memoize style object
   - Fresh style object on each render caused unnecessary portal remounts
   - Wrapped style object in useMemo to prevent BodyPortal useLayoutEffect
     from triggering on every toast list change

3. Tooltip: Prevent invalid props from reaching AriaTooltip
   - icon and ariaLabel are trigger-only props (used by TooltipGroup)
   - Now explicitly destructured to avoid spreading them to AriaTooltip
   - Prevents invalid DOM attributes and unexpected react-aria behavior

All fixes maintain backward compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Handle CSS custom property copying

Use variable for animation time

Extract zIndexStyle

Co-Authored-By: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Use CSSPropertiesWithVariables (added to src/types on main) instead of
  casting CSS-variable style objects to React.CSSProperties. BodyPortal now
  requires this type, so ToastContainer would not typecheck otherwise.
- Carry over the forced-colors selected-tab styles from #135 into Tabs.css.
- Narrow Tooltip's props so icon/ariaLabel are only accepted by TooltipGroup,
  which owns the trigger button (Copilot review feedback).
- CustomTooltip: merge caller className/style instead of dropping them.
- Refresh Tabs/ButtonBar snapshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ButtonBar reused buttonBarItemCss from Tabs on main, so it also picked up
the forced-colors fix from #135. Preserve that after the CSS split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tabs: TabsProps intersected RAC.TabsProps with static className/style/children,
which collapses react-aria's `T | ((values) => T)` unions to `T` and rejects
render callbacks at compile time. Spreading a style callback also produced an
empty object at runtime. Stop narrowing the type and resolve each callback
against the render props before merging the variant classes and CSS variables.
RAC merges its own defaultStyle for us, so we only merge the caller's.

Tooltip: StyledTooltip and StyledTrigger were public API via the wildcard
export in src/index.ts. openstax/assignments imports UI.StyledTrigger, so
removing them breaks that build. Re-added as plain-CSS components, deprecated
in favour of Tooltip/TooltipGroup.

Tooltip also set className="tooltip" after {...props}, dropping a caller's
className. styled-components used to merge it, and assignments relies on that
via styled(UI.TooltipGroup). Both now merge.

Also drops the palette.darkerGreen workaround now that 2035082 fixed the
value, and documents the removed Tabs css fragments in CHANGELOG.md. A search
of the openstax org found no consumers of those fragments outside this repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
styled-components has forwarded refs since v4, so the styled(AriaTooltip) and
styled(Button) exports these replaced passed a ref through to the underlying
element. The plain-function replacements dropped that: a consumer attaching a
ref got a type error ("Property 'ref' does not exist") and no DOM node at
runtime.

Wrapped both in forwardRef following the pattern the other migrated react-aria
wrappers use (NavBarButton, NavBarPopover), including displayName. Ref types
come from React.ElementRef<typeof ...> so they track react-aria.

Adds tests covering both; each fails to compile against the previous version.

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

This comment was marked as resolved.

RoyEJohnson

This comment was marked as resolved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/components/Tooltip.spec.tsx:12

  • This test suite overwrites ReactDOM.createPortal in beforeAll but never restores it, which can leak the mock into other test files and cause hard-to-debug failures. Capture the original implementation and restore it in afterAll.
describe('Tooltip', () => {
  beforeAll(() => {
    ReactDOM.createPortal = jest.fn((element) => element) as any;
  })

Comment thread src/components/Tabs.spec.tsx Outdated
These components are themed through CSS variables, but their style props were
typed React.CSSProperties, so overriding a documented variable required a cast.
Widened to CSSPropertiesWithVariables on Tabs, ButtonBar and Tooltip (which
covers StyledTooltip and StyledTrigger via ClassNameAndStyle).

Copilot flagged ButtonBar and Tooltip; Tabs has the same problem via
RAC.TabsProps and is included so the three read consistently.

Tabs needed Omit<RAC.TabsProps, 'style'> rather than a plain intersection:
intersecting a narrower style would collapse react-aria's
`T | ((values) => T)` union and break the render-callback form, which is the
bug fixed earlier in this PR. The existing callback tests cover that.

Adds a test per component overriding a documented variable with no cast; each
fails to compile against the previous types with "does not exist in type
Properties<...>".

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

This comment was marked as resolved.

Minor formatting and documentation clariification

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.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.

3 participants