CODAP-1150: migrate color picker from Chakra UI to React Aria#2492
CODAP-1150: migrate color picker from Chakra UI to React Aria#2492
Conversation
…DAP-1150) Replace Chakra PopoverContent/PopoverBody/PopoverArrow/Button/ButtonGroup/Flex with plain HTML and React Aria RadioGroup/Radio for the color swatch grid. Remove all positioning logic (React Aria Popover handles positioning in a later task). Delete the SCSS :export block and its TypeScript declaration file. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove unused isPaletteOpen prop and with-color-picker CSS class. Fix deprecated grid-gap to gap. Fix invalid justify-content: right to flex-end. Part of CODAP-1150. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… DialogTrigger (CODAP-1150) Replace Chakra Popover/PopoverTrigger/Portal with React Aria DialogTrigger + Button + Popover + Dialog. Remove useOutsidePointerDown (React Aria handles outside-click dismissal). Replace string-based openPopover state with boolean isOpen. Use isAcceptingRef pattern to distinguish accept-close from dismiss-close. Update tests to work with real React Aria components instead of Chakra mocks, and add tests for Escape-to-reject, accept-without-reject, and disabled behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ialogTrigger (CODAP-1150) Replace Chakra Popover/PopoverTrigger/PopoverAnchor/Portal with React Aria DialogTrigger/Popover/Dialog for the case card inline color editor. Replace Chakra forwardRef/useMergeRefs with React equivalents. Keep useOutsidePointerDown for submit-on-outside-click of the entire editor. Use isCancellingRef pattern to distinguish Escape (cancel) from outside-click (submit) on palette close. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ia DialogTrigger (CODAP-1150) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Aria (CODAP-1150) Replace Chakra Popover/PopoverTrigger/Portal/useDisclosure with React Aria Popover + Dialog in controlled mode. Use triggerRef approach (rather than DialogTrigger) since InspectorButton already wraps a React Aria Button in a TooltipTrigger. Add isAcceptingRef pattern to distinguish accept-close from dismiss-close. Remove removed ColorPickerPalette props (initialColor, buttonRef, placement). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Part of CODAP-1150. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 tests covering RadioGroup rendering, swatch selection, arrow key navigation, non-standard color swatch, More/Less toggle, Accept/Reject callbacks, color picker expand/collapse, and light swatch class. Part of CODAP-1150. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2492 +/- ##
===========================================
+ Coverage 70.23% 86.33% +16.09%
===========================================
Files 768 769 +1
Lines 42980 43031 +51
Branches 10280 10686 +406
===========================================
+ Hits 30189 37152 +6963
+ Misses 12785 5864 -6921
- Partials 6 15 +9
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
codap-v3
|
||||||||||||||||||||||||||||
| Project |
codap-v3
|
| Branch Review |
CODAP-1150-color-picker-react-aria
|
| Run status |
|
| Run duration | 02m 06s |
| Commit |
|
| Committer | Kirk Swenson |
| View all properties for this run ↗︎ | |
| Test results | |
|---|---|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
0
|
|
|
4
|
| View all changes introduced in this branch ↗︎ | |
Add Current Status section documenting 3 known bugs (gray border, tab cycling, arrow key losing custom color), implementation deviations from plan (FormatTextColorButton, useOutsidePointerDown kept in ColorTextEditor, React Aria Radio structure), and updated File Map. Part of CODAP-1150. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
d8ec856 to
1d080b0
Compare
There was a problem hiding this comment.
Pull request overview
Migrates CODAP v3 color picker UI from Chakra UI popovers/buttons to React Aria Components to improve accessible focus management, keyboard navigation, and ARIA semantics across inspector, text toolbar, and table/card editors.
Changes:
- Replaces Chakra popovers with React Aria
DialogTrigger/Popover/Dialogand rewrites the swatch grid using React AriaListBox/ListBoxItem. - Introduces
useColorPickerPopoverOffset()to keep the expanded palette within the viewport. - Adds/updates unit tests and Cypress selectors to match the new DOM/ARIA behavior, plus new translation strings for accessible labels.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| v3/src/utilities/translation/lang/en-US.json5 | Adds dialog label + named swatch labels for accessible announcements. |
| v3/src/components/text/format-text-color-button.tsx | Migrates text toolbar color popover to React Aria and wires palette + offset hook. |
| v3/src/components/data-display/inspector/point-color-setting.tsx | Migrates inspector swatch popover to React Aria and adds commit/reject handling. |
| v3/src/components/data-display/inspector/point-color-setting.test.tsx | Updates tests to target React Aria behavior instead of Chakra mocks. |
| v3/src/components/common/use-color-picker-popover-offset.ts | New hook to compute/maintain vertical offset when palette expands. |
| v3/src/components/common/color-picker-palette.tsx | Rewrites palette UI with React Aria ListBox, adds non-standard swatch support. |
| v3/src/components/common/color-picker-palette.test.tsx | New test suite validating swatches, expansion, accept/reject, and non-standard swatch behavior. |
| v3/src/components/common/color-picker-palette.scss.d.ts | Removes no-longer-needed SCSS exports typings. |
| v3/src/components/common/color-picker-palette.scss | Updates palette layout/styling (8×2 grid, 22px swatches, new buttons). |
| v3/src/components/case-tile-common/color-text-editor.tsx | Migrates case card color editor popover to React Aria; preserves outside-click submit behavior. |
| v3/src/components/case-table/color-cell-text-editor.tsx | Migrates case table color editor popover to React Aria and wires offset hook. |
| v3/doc/specs/2026-03-19-codap-1150-color-picker-plan.md | Adds implementation plan/spec and known bugs documentation. |
| v3/doc/specs/2026-03-19-codap-1150-color-picker-a11y-design.md | Adds accessibility design notes and migration approach. |
| v3/cypress/support/elements/color-picker-palette.ts | Updates Cypress selectors for React Aria data-selected and new structure. |
| v3/cypress/e2e/graph.spec.ts | Adjusts E2E interactions to close popovers via Escape; adds viewport overflow test for expanded picker. |
| v3/cypress/e2e/graph-legend.spec.ts | Updates E2E assertions to use data-selected and closes popover via Escape. |
Comments suppressed due to low confidence (1)
v3/src/components/case-tile-common/color-text-editor.tsx:98
handleInputColorChange()callssetInputValue()and then callshandleUpdateValue(), which callssetInputValue()again. This double state update is redundant and can cause extra renders. Consider havinghandleInputColorChange()delegate entirely tohandleUpdateValue(event.target.value)(or removing thesetInputValue()from one of the two functions).
function handleInputColorChange(event: ChangeEvent<HTMLInputElement>) {
setInputValue(event.target.value)
handleUpdateValue(event.target.value)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const handleAcceptColor = useCallback((color: string) => { | ||
| updateValue(color) | ||
| initialColorRef.current = color | ||
| closePopover() | ||
| }, [closePopover, updateValue]) | ||
| isAcceptingRef.current = true | ||
| setIsOpen(false) | ||
| }, [updateValue]) |
There was a problem hiding this comment.
handleAcceptColor() closes the dialog via setIsOpen(false), which bypasses handleOpenChange(false) where you reset the popover offset (and where reject-vs-accept logic lives). This can leave a non-zero popoverOffset applied the next time the picker opens (especially after expanding via “More”). Consider centralizing all close paths through handleOpenChange(false) (or calling resetPopoverOffset() here) so offset cleanup is guaranteed.
| const handleReject = useCallback(() => { | ||
| handleRejectColor() | ||
| setIsOpen(false) | ||
| }, [handleRejectColor]) |
There was a problem hiding this comment.
handleReject() closes via setIsOpen(false) instead of going through handleOpenChange(false), so resetPopoverOffset() won't run and the reject path may leave state (e.g. offset) dirty for the next open. Also, reject currently calls onColorChange(initialColorRef.current) but does not restore local inputValue, so the next time the popover opens it may render the palette using a stale inputValue that no longer matches the reverted swatchBackgroundColor. Recommend resetting inputValue back to initialColorRef.current (or syncing it from swatchBackgroundColor on open/close) and ensuring all dismiss/close paths run the same cleanup.
There was a problem hiding this comment.
Addressed in cad8e93.
handleAcceptColor and handleReject now route through handleOpenChange(false). Also reset inputValue on close to prevent stale values on next open.
| const handleAccept = useCallback((color: string) => { | ||
| handleSetColor(color) | ||
| onClose() | ||
| } | ||
| isAcceptingRef.current = true | ||
| setIsOpen(false) | ||
| }, [handleSetColor]) | ||
|
|
||
| function handleReject() { | ||
| const handleReject = useCallback(() => { | ||
| handleSetColor(initialColorRef.current) | ||
| onClose() | ||
| } | ||
| setIsOpen(false) | ||
| }, [handleSetColor]) |
There was a problem hiding this comment.
handleAccept() / handleReject() close the popover by calling setIsOpen(false), but the offset cleanup (resetPopoverOffset()) only runs inside handleOpenChange(false). With controlled isOpen, onOpenChange is typically only invoked for user-initiated state changes, so closing programmatically can leave a non-zero popoverOffset applied on the next open. Consider routing all closes through the same cleanup path (e.g. call handleOpenChange(false) or call resetPopoverOffset() directly in accept/reject).
There was a problem hiding this comment.
Addressed in cad8e93.
handleAccept and handleReject now route through handleOpenChange(false).
…color picker Remove the nested <Dialog> inside <Popover> in all four color picker consumers. The nested [role=dialog] caused React Aria's Popover to disable its own focus management (FocusScope containment and auto-focus), leaving focus in the parent component instead of the popover portal. Fix keyboard event leaking from the portal-rendered color picker to RDG's EditCell handler. React synthetic events bubble through the component tree (not the DOM tree), so Enter/Arrow/Tab events from the popover reached RDG and triggered cell navigation. Two complementary fixes: (1) stop propagation for Enter/Arrow keys in ColorPickerPalette's capture handler, and (2) guard RDG's handleCellKeyDown to skip events when focus is outside the gridcell (Tab must propagate so FocusScope can trap it). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use explicit grid-template-rows: repeat(3, 22px) so the grid always reserves space for a third row. This keeps the More/Less button in a stable position regardless of whether a custom (17th) swatch is present. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix FormatTextColorButton to accept color on click-outside (not reject). Replace isAcceptingRef pattern with simpler model: close = accept, Escape = reject. This matches the expected behavior where previewing colors does not require explicit Set Color to commit. Scope arrow key stopPropagation to the swatch grid area only, so react-colorful saturation/hue sliders still receive arrow key events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
kswenson
left a comment
There was a problem hiding this comment.
👍 Looks good -- I encountered some focus management issues in a couple places, some keyboard handling issues in the case table, and some other issues that I went ahead and fixed. See commit messages for details, but one of the issues was that Claude/we determined that the pattern of Dialog within Popover was preventing focus trapping from working correctly in some cases, so the Dialogs have been removed. According to Claude, Popover functions like a dialog and having both of them is redundant. You may want to review the changes in turn. I've appended the rest of a review developed in conjunction with Claude Code 🤖, and an accesslint accessibility audit. Feel free to address or ignore any suggestions found there.
PR Review Summary
Changes: 14 files, +898 / -462 lines
What it does
Migrates the color picker UI from Chakra UI components to React Aria for accessible focus management, keyboard navigation, and ARIA semantics. This is part of a broader effort to move CODAP v3 off Chakra UI. The migration touches 5 consumer components (PointColorSetting, ColorTextEditor, ColorCellTextEditor, FormatTextColorButton, and ColorPickerPalette itself) plus Cypress helpers and tests.
The approach
ColorPickerPalette is rebuilt from scratch: the old Chakra PopoverContent/Button/ButtonGroup/Flex structure is replaced with a React Aria ListBox (single-selection, grid layout) for the swatch grid, plain HTML buttons for More/Less and Cancel/Set Color, and a clean CSS grid layout (8×2 instead of 4×4). The ListBox gives keyboard navigation with independent focus and selection for free — arrow keys move focus without changing the selected color, and click/Enter selects.
Consumer components (PointColorSetting, ColorTextEditor, ColorCellTextEditor, FormatTextColorButton) each replace Chakra Popover/PopoverTrigger/Portal with React Aria DialogTrigger/Popover/Dialog. The old manual positioning logic (~30 lines of adjustPosition() with SCSS :export variables) is deleted entirely. React Aria handles most positioning, with shouldFlip={false} and a new shared useColorPickerPopoverOffset hook to handle the case where a tile is near the viewport bottom.
FormatTextColorButton uses a triggerRef approach (rather than DialogTrigger) because its trigger button is already wrapped in a React Aria TooltipTrigger via InspectorButton.
Key patterns:
isAcceptingRef/isCancellingRefpatterns distinguish accept-close from dismiss-close (Escape/outside-click)nonStandardIdRefkeeps the 17th (custom color) swatch's ListBoxItem ID stable across rendersuseColorPickerPopoverOffsetdynamically shifts the popover upward when the expanded picker would overflow the viewport
Assessment
This is a well-structured, thorough migration. The approach is sound — React Aria's ListBox is the right primitive for a single-selection swatch grid, and the consumer-side DialogTrigger/Popover/Dialog pattern is consistent and clean across all 4 consumers. Good test coverage: 22 unit tests for ColorPickerPalette, 11 for PointColorSetting, and a new Cypress test for viewport containment.
The accessibility improvements are substantial: human-readable color names on every swatch, aria-label on dialogs and buttons, aria-expanded on the More/Less toggle, proper focus management via ListBox, and WCAG-compliant swatch border contrast (charcoal-dark-1 instead of charcoal-light-1).
Issues
-
Magic number in
useColorPickerPopoverOffset(color-picker-palette.tsx:28): TheexpandedAddition = 280is a hardcoded estimate of expanded picker height. If the color picker's layout changes, this will silently break. A comment noting what this approximates would help, though measuring after render would be the ideal approach. -
handlePaletteOpenChangestale closure inColorTextEditor(color-text-editor.tsx:83-95): The callback closes overinputValueandisPaletteOpenwhich change frequently. The deps array correctly lists them, but this means a new callback is created on every keystroke in the color text input. Since this is passed as a prop toDialogTrigger, it could cause unnecessary re-renders of the popover. This is a minor perf concern, not a bug. -
Deleted Cypress test for "clicks outside to close" (
graph-legend.spec.ts:668-671): The old test for "Hides color picker when user clicks outside" was removed. React Aria handles outside-click dismissal, so the behavior should still work — but removing the test means there's no regression coverage for it. Consider whether this scenario is covered elsewhere. -
Cypress viewport containment test is fragile (
graph.spec.ts:532-545): The test drags the graph toinnerHeight - 150and then checks popover bounds. This depends on the exact graph tile height and popover size. If either changes, the test may pass trivially (popover doesn't actually overflow) or fail spuriously.
None of these are blockers. Issues 1 and 3 are worth noting; 2 and 4 are informational.
Accessibility Audit
Summary: 1 Critical, 3 High, 4 Medium issues. The migration to React Aria provides a strong accessibility foundation — proper ARIA roles via ListBox, translated color name labels, focus-visible styling, and aria-selected state. The issues below are areas where the implementation could go further.
Critical
A1. FormatTextColorButton: Popover without DialogTrigger may break focus return (format-text-color-button.tsx:72-91)
- WCAG 2.4.3, 2.1.2 — Uses standalone
<Popover>withtriggerRefinstead of<DialogTrigger>(unlike the other 3 consumers). Focus may be lost to<body>when the popover closes. ThepreventFocusLosspointer handler (e.preventDefault()) for Slate editor focus preservation may also interfere with the popover's focus management. - FIXED in review commit
7a3c5f2df: Removed the nested<Dialog>wrapper from all four consumers. The<Dialog>was causing the Popover to setisDialog = false, which disabled both FocusScope containment andfocusSafelyauto-focus. With<Dialog>removed, the Popover manages focus correctly —shouldContainFocus: true, auto-focus on mount, and focus restoration on close.
High
A2. Non-standard (17th) swatch uses raw hex as aria-label (color-picker-palette.tsx:131)
- WCAG 1.1.1, 4.1.2 — Custom color swatch gets
aria-label="#3a7fb2", announced as gibberish by screen readers. Standard swatches correctly use names like "Red", "Blue". Suggest:"Custom color: #3a7fb2"via a translation string. - Not addressed — remains as a future improvement.
A3. "More"/"Less" button lacks accessible context (color-picker-palette.tsx:140-148)
- WCAG 4.1.2 — Button has
aria-expanded(good) but generic text "more"/"less". Noaria-controlslinking to the expanded region. Screen reader users hear "more, button, expanded" with no indication of what it controls. - Not addressed — remains as a future improvement.
A4. Expanded color picker sliders lack keyboard instructions (color-picker-palette.tsx:152-153)
- WCAG 3.3.2 — The
react-colorfulsaturation picker requires 2D arrow key navigation, a non-obvious interaction. No visible or programmatic instructions. Consider adding a hex input field for keyboard users. - Not addressed — a react-colorful library limitation. Would require upstream changes or a custom wrapper.
Medium
A5. Cancel/Set Color visual distinction — borderline pass since text labels differentiate them, but visual hierarchy relies partly on color.
A6. Escape key double-dismiss risk (color-picker-palette.tsx:101-106) — capture-phase Escape handler may conflict with React Aria's own Escape handling, potentially running rejection logic twice.
- Likely mitigated in review commit
7a3c5f2df: ThehandleKeyDownCapturecallse.stopPropagation()on Escape before callingonReject(), which should prevent React Aria from also processing the Escape event.
A7. White swatch minimal visual distinction — 1px charcoal-dark-1 border on white background passes WCAG (7.57:1) but is thin.
A8. Asymmetric grid when 17th swatch present — 8-column grid with 17 items creates a lone item in the third row; keyboard navigation may feel odd.
- Mitigated in review commit
592789044: The grid now usesgrid-template-rows: repeat(3, 22px)to reserve space for a third row, so the layout is consistent with or without the 17th swatch.
Positive Findings
- All 16 palette colors have translated human-readable
aria-labelvalues - Popover now properly manages focus (FocusScope containment + auto-focus) after
<Dialog>removal ListBoxwithlayout="grid"provides proper ARIA roles, arrow keys, selection management[data-focus-visible]styling only shows for keyboard focus (not mouse)- All color contrast ratios pass WCAG AA
autoFocuson ListBox gives keyboard users immediate interaction targetdata-selected/aria-selectedproperly communicates selected state
Summary
ListBox(single selection, grid layout) for keyboard navigation with independent focus and selectionadjustPosition()positioning logic. React Aria handles positioning withshouldFlip={false}and a new shareduseColorPickerPopoverOffsethook for viewport-aware popover positioning.ColorPickerPaletteunit tests, 11PointColorSettingunit tests, and 1 Cypress test for expanded popover viewport containmentaria-labelon Popovers, human-readable color swatch names,aria-expandedon More/Less button, missing labels on case card editor, WCAG-compliant swatch border contrastListBoxmigration (data-selected, popover dismiss via Escape)<Dialog>wrapper from inside<Popover>in all consumers — the nested[role=dialog]caused React Aria's Popover to disable its own focus management (FocusScope containment and auto-focus)Files migrated:
color-picker-palette.tsx,point-color-setting.tsx,color-text-editor.tsx,color-cell-text-editor.tsx,format-text-color-button.tsxNew files:
use-color-picker-popover-offset.tsTest plan
🤖 Generated with Claude Code