Skip to content

fix: allow ColorSwatchPicker to have no selection#10331

Open
nabsei wants to merge 3 commits into
adobe:mainfrom
nabsei:fix/color-swatch-picker-null-selection
Open

fix: allow ColorSwatchPicker to have no selection#10331
nabsei wants to merge 3 commits into
adobe:mainfrom
nabsei:fix/color-swatch-picker-null-selection

Conversation

@nabsei

@nabsei nabsei commented Jul 15, 2026

Copy link
Copy Markdown

Closes #7918

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests for this change.
  • Filled out test instructions.
  • Updated documentation (no doc content described the old always-selected behavior, so nothing needed updating).
  • Looked at the Accessibility Practices for this feature - Aria Practices (no change to the underlying ListBox/aria-selected semantics, just when a selection exists at all).

📝 Test Instructions:

ColorSwatchPicker always initialized its selection to black (#000000) via useColorPickerState, and disallowEmptySelection meant a black swatch could never be deselected. If your swatch list happened to include black, it was permanently stuck as "selected" with no way to represent "nothing chosen."

ColorSwatchPicker now manages its own controlled/uncontrolled state directly instead of going through useColorPickerState — which is shared with the much more complex ColorPicker and shouldn't be changed to support a nullable color, since ColorPicker's slider/wheel components assume state.color is always a real Color. value/defaultValue/onChange on ColorSwatchPicker can now be null, and selectedKeys is empty when nothing is selected.

The two Spectrum wrapper components (@adobe/react-spectrum and @react-spectrum/s2) only had their prop types widened (ValueBase<string | Color, Color>ValueBase<string | Color | null, Color | null>) to stay consistent — no behavior change in those files, they just spread props through.

To verify manually: render a ColorSwatchPicker with a swatch colored #000 and no value/defaultValue — no swatch should appear selected initially, and clicking the black swatch should select it and clicking it again should deselect it (previously impossible).

🧢 Your Project:

N/A — found while investigating this issue.


Parts of this fix (root-cause analysis and implementation) were produced with the help of Claude Code — credited via the Co-authored-by trailer on the commit. I reviewed and understood the change before opening this PR.

Verification performed: full monorepo type-check (yarn check-types) passes, oxlint/oxfmt clean on all touched files, and the full ColorSwatchPicker test suites in both react-aria-components and @adobe/react-spectrum pass (11/11), including two new tests covering no-selection-by-default and deselecting black specifically.

ColorSwatchPicker always defaulted to black (#000000) via
useColorPickerState, and disallowEmptySelection meant a black swatch
could never be deselected — if the swatch list happened to contain
black, it was permanently stuck as selected.

ColorSwatchPicker now manages its own controlled/uncontrolled state
directly (instead of going through useColorPickerState, which is
shared with the more complex ColorPicker and shouldn't be changed to
support a nullable color), so value/defaultValue/onChange can be null,
and selectedKeys is empty when nothing is selected. The two Spectrum
wrapper components (@adobe/react-spectrum and @react-spectrum/s2) just
had their prop types widened to match, no behavior change there.

Closes adobe#7918

Co-authored-by: Claude <noreply@anthropic.com>
The value/onChange examples used plain useState(parseColor(...)),
whose setter no longer matched the widened onChange type. Type the
state as Color | null and guard the display text, matching the
existing ColorField doc example's pattern.
@nabsei

nabsei commented Jul 15, 2026

Copy link
Copy Markdown
Author

Fixed — the typecheck-docs failure was real: the value/onChange doc examples used plain useState(parseColor(...)), whose setter no longer matched the widened onChange type after this change. Typed the state as Color | null and guarded the display text, matching the existing ColorField doc example's pattern. Verified locally with the same node scripts/extractExamplesS2.mjs && yarn tsgo --project dist/docs-examples/tsconfig.json steps CI runs — passes clean now.

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I guess the other option would be to have a way inside the Color class of representing "no color"
I don't know if that'd truly be no color, or just a fully transparent color, or something like that


function Example() {
let [value, setValue] = useState(parseColor('#A00'));
let [value, setValue] = useState<Color | null>(parseColor('#A00'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just highlighting for the rest of the team. This now requires additional typescript and checks against null in code as seen in this file.

We went through some similar steps back in Select
https://github.com/adobe/react-spectrum/pull/2676/changes
https://github.com/adobe/react-spectrum/pull/7888/changes#diff-1de776587d3b665172f7a4e80f44244c69d868bd3ac9c8d8b6e3f6235beaa1ac

ref: ForwardedRef<HTMLDivElement>
) {
[props, ref] = useContextProps(props, ref, ColorSwatchPickerContext);
let state = useColorPickerState(props);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

changes should be made in the stately hook, we should still be using the hook here. was there a reason you dropped it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I looked at the precedent for Select (#2676) — that PR only changed a type declaration (selectedKey?: KeyKey | null in selection.d.ts), the underlying selection hook already tolerated null at runtime. useColorPickerState is different: its code actively rejects null (if (color != null) { setColor(...) }) and hardcodes #000000 as the default, so widening its type alone wouldn't be enough — its actual logic would need to change, and it's shared with ColorPicker, whose slider/wheel components assume state.color" is always a real Color. That's why I kept the state local to ColorSwatchPicker` instead. Happy to revisit if you think the risk is worth it for consistency.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's a good point. For now lets create a new stately hook we can use here. Make it look the same as the original hook so that it's easier to compare against (no need for all the memos)

@snowystinger

Copy link
Copy Markdown
Member

@nabsei

nabsei commented Jul 15, 2026

Copy link
Copy Markdown
Author

Thanks for the feedback! I did consider that route, but I think it'd carry more risk than benefit here: Color is consumed pervasively across ColorPicker, ColorField, ColorArea, ColorWheel, and ColorSlider, so introducing a sentinel "no color" value inside the class would mean every consumer calling .toString(), .getChannelValue(), etc. would need to guard against it — a much wider blast radius than keeping "no selection" as a plain null at the component boundary.

This also matches the existing precedent in ColorField, which already types its state as Color | null rather than special-casing inside Color itself. Happy to go a different direction if you'd still prefer it, just wanted to explain the reasoning.

expect(options[1]).toHaveAttribute('aria-selected', 'false');
});

it('supports deselecting a swatch, including black', async function () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Given how Selects normally work, I don't think we should be allowing clicking on the same swatch to deselect, can probably just set disallowEmptySelection on the ListBox. Doing reset through a form or button should still work with null

ref: ForwardedRef<HTMLDivElement>
) {
[props, ref] = useContextProps(props, ref, ColorSwatchPickerContext);
let state = useColorPickerState(props);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's a good point. For now lets create a new stately hook we can use here. Make it look the same as the original hook so that it's easier to compare against (no need for all the memos)

() => (typeof defaultValueProp === 'string' ? parseColor(defaultValueProp) : defaultValueProp),
[defaultValueProp]
);
let [color, setColor] = useControlledState<Color | null>(value, defaultValue ?? null, onChange);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I just noticed this changes the default color as well, will be easier to see little differences like that when it's in a hook that mirrors the original

Per review:
- Move the controlled/uncontrolled color state into a new
  useColorSwatchPickerState stately hook, mirroring
  useColorPickerState's structure (no inline memos in the component),
  so the two are easy to diff against each other.
- Restore disallowEmptySelection: once a swatch is selected, clicking
  it again no longer deselects it, matching how selection works
  elsewhere. Clearing the selection is only done by passing a
  controlled null value (e.g. from a form reset).
@nabsei

nabsei commented Jul 16, 2026

Copy link
Copy Markdown
Author

Done — pushed a new commit addressing all three points:

  • Moved the state into a new useColorSwatchPickerState stately hook (in packages/react-stately/src/color/), structured to mirror useColorPickerState as closely as possible (no inline memos in the component anymore).
  • Restored disallowEmptySelection — clicking an already-selected swatch no longer deselects it. Clearing the selection is now only possible via a controlled null value.
  • Updated the tests to match: replaced the click-to-deselect test with one confirming clicking the selected swatch again is a no-op, and added a new test for clearing via controlled value={null}.

Let me know if the new hook's shape looks right!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ColorSwatchPicker always selects #000 when in "unselected" state.

2 participants