Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {useDOMRef} from '../utils/useDOMRef';
import {useStyleProps} from '../utils/styleProps';

export interface SpectrumColorSwatchPickerProps
extends ValueBase<string | Color, Color>, StyleProps {
extends ValueBase<string | Color | null, Color | null>, StyleProps {
/** The ColorSwatches within the ColorSwatchPicker. */
children: ReactNode;
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/ColorSwatchPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {useDOMRef} from './useDOMRef';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface ColorSwatchPickerProps
extends ValueBase<string | Color, Color>, StyleProps, SlotProps {
extends ValueBase<string | Color | null, Color | null>, StyleProps, SlotProps {
/** The ColorSwatches within the ColorSwatchPicker. */
children: ReactNode;
/**
Expand Down
6 changes: 3 additions & 3 deletions packages/dev/s2-docs/pages/react-aria/ColorSwatchPicker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ Use the `value` or `defaultValue` prop to set the selected color, and `onChange`
```tsx render
"use client";
import {ColorSwatchPicker, ColorSwatchPickerItem} from 'vanilla-starter/ColorSwatchPicker';
import {parseColor} from 'react-aria-components/ColorSwatchPicker';
import {parseColor, type Color} from 'react-aria-components/ColorSwatchPicker';
import {useState} from 'react';

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


return (
<>
Expand All @@ -69,7 +69,7 @@ function Example() {
<ColorSwatchPickerItem color="#088" />
<ColorSwatchPickerItem color="#008" />
</ColorSwatchPicker>
<pre style={{fontSize: 12}}>Selected color: {value.toString('rgb')}</pre>
<pre style={{fontSize: 12}}>Selected color: {value ? value.toString('rgb') : 'none'}</pre>
</>
);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/dev/s2-docs/pages/s2/ColorSwatchPicker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ Use the `value` or `defaultValue` prop to set the selected color, and `onChange`

```tsx render
"use client";
import {ColorSwatchPicker, ColorSwatch, parseColor} from '@react-spectrum/s2/ColorSwatchPicker';
import {ColorSwatchPicker, ColorSwatch, parseColor, type Color} from '@react-spectrum/s2/ColorSwatchPicker';
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
import {useState} from 'react';

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

return (
<>
Expand All @@ -51,7 +51,7 @@ function Example() {
<ColorSwatch color="#8b5cf6" />
<ColorSwatch color="#ec4899" />
</ColorSwatchPicker>
<pre className={style({font: 'body'})}>Selected color: {value.toString('rgb')}</pre>
<pre className={style({font: 'body'})}>Selected color: {value ? value.toString('rgb') : 'none'}</pre>
</>
);
}
Expand Down
8 changes: 4 additions & 4 deletions packages/react-aria-components/src/ColorSwatchPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ import React, {
useEffect,
useMemo
} from 'react';
import {useColorPickerState} from 'react-stately/useColorPickerState';
import {useColorSwatchPickerState} from 'react-stately/useColorSwatchPickerState';
import {useLocale} from 'react-aria/I18nProvider';
import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter';

export interface ColorSwatchPickerRenderProps extends Omit<ListBoxRenderProps, 'isDropTarget'> {}
export interface ColorSwatchPickerProps
extends
ValueBase<string | Color, Color>,
ValueBase<string | Color | null, Color | null>,
AriaLabelingProps,
StyleRenderProps<ColorSwatchPickerRenderProps>,
GlobalDOMAttributes<HTMLDivElement> {
Expand Down Expand Up @@ -68,7 +68,7 @@ export const ColorSwatchPicker = forwardRef(function ColorSwatchPicker(
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?: Key β†’ Key | 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)

let state = useColorSwatchPickerState(props);
let colorMap = useMemo(() => new Map(), []);
let formatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components');

Expand All @@ -84,7 +84,7 @@ export const ColorSwatchPicker = forwardRef(function ColorSwatchPicker(
}
layout={props.layout || 'grid'}
selectionMode="single"
selectedKeys={[state.color.toString('hexa')]}
selectedKeys={state.color ? [state.color.toString('hexa')] : []}
onSelectionChange={keys => {
// single select, 'all' cannot occur. appease typescript.
if (keys !== 'all') {
Expand Down
77 changes: 77 additions & 0 deletions packages/react-aria-components/test/ColorSwatchPicker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,83 @@ describe('ColorSwatchPicker', function () {
expect(options[2]).toHaveAttribute('aria-selected', 'true');
});

it('has no selection by default', async function () {
let {getByRole} = render(
<ColorSwatchPicker>
<ColorSwatchPickerItem color="#000">
<ColorSwatch />
</ColorSwatchPickerItem>
<ColorSwatchPickerItem color="#f00">
<ColorSwatch />
</ColorSwatchPickerItem>
</ColorSwatchPicker>
);

let listbox = getByRole('listbox');
let options = within(listbox).getAllByRole('option');
expect(options[0]).toHaveAttribute('aria-selected', 'false');
expect(options[1]).toHaveAttribute('aria-selected', 'false');
});

it('supports selecting black, and does not allow deselecting via click', async function () {
let onChange = jest.fn();
let {getByRole} = render(
<ColorSwatchPicker onChange={onChange}>
<ColorSwatchPickerItem color="#000">
<ColorSwatch />
</ColorSwatchPickerItem>
<ColorSwatchPickerItem color="#f00">
<ColorSwatch />
</ColorSwatchPickerItem>
</ColorSwatchPicker>
);

let listbox = getByRole('listbox');
let options = within(listbox).getAllByRole('option');

await user.click(options[0]);
expect(onChange).toHaveBeenLastCalledWith(parseColor('#000000'));
expect(options[0]).toHaveAttribute('aria-selected', 'true');

// Clicking the already-selected swatch again should not deselect it,
// matching how selection normally works elsewhere.
await user.click(options[0]);
expect(onChange).toHaveBeenCalledTimes(1);
expect(options[0]).toHaveAttribute('aria-selected', 'true');
});

it('supports clearing the selection via a controlled null value', async function () {
let {getByRole, rerender} = render(
<ColorSwatchPicker value="#000">
<ColorSwatchPickerItem color="#000">
<ColorSwatch />
</ColorSwatchPickerItem>
<ColorSwatchPickerItem color="#f00">
<ColorSwatch />
</ColorSwatchPickerItem>
</ColorSwatchPicker>
);

let listbox = getByRole('listbox');
let options = within(listbox).getAllByRole('option');
expect(options[0]).toHaveAttribute('aria-selected', 'true');

rerender(
<ColorSwatchPicker value={null}>
<ColorSwatchPickerItem color="#000">
<ColorSwatch />
</ColorSwatchPickerItem>
<ColorSwatchPickerItem color="#f00">
<ColorSwatch />
</ColorSwatchPickerItem>
</ColorSwatchPicker>
);

options = within(listbox).getAllByRole('option');
expect(options[0]).toHaveAttribute('aria-selected', 'false');
expect(options[1]).toHaveAttribute('aria-selected', 'false');
});

it('handles keyboard input', async function () {
let onChange = jest.fn();
let {getByRole} = render(
Expand Down
5 changes: 5 additions & 0 deletions packages/react-stately/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export type {
} from '../src/color/useColorChannelFieldState';
export type {ColorFieldProps, ColorFieldState} from '../src/color/useColorFieldState';
export type {ColorPickerProps, ColorPickerState} from '../src/color/useColorPickerState';
export type {
ColorSwatchPickerStateProps,
ColorSwatchPickerState
} from '../src/color/useColorSwatchPickerState';
export type {
ColorSliderState,
ColorSliderStateOptions,
Expand Down Expand Up @@ -179,6 +183,7 @@ export {useColorAreaState} from '../src/color/useColorAreaState';
export {useColorChannelFieldState} from '../src/color/useColorChannelFieldState';
export {useColorFieldState} from '../src/color/useColorFieldState';
export {useColorPickerState} from '../src/color/useColorPickerState';
export {useColorSwatchPickerState} from '../src/color/useColorSwatchPickerState';
export {useColorSliderState} from '../src/color/useColorSliderState';
export {useColorWheelState} from '../src/color/useColorWheelState';
export {useComboBoxState} from '../src/combobox/useComboBoxState';
Expand Down
18 changes: 18 additions & 0 deletions packages/react-stately/exports/useColorSwatchPickerState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* Copyright 2024 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

export type {
ColorSwatchPickerStateProps,
ColorSwatchPickerState
} from '../src/color/useColorSwatchPickerState';

export {useColorSwatchPickerState} from '../src/color/useColorSwatchPickerState';
31 changes: 31 additions & 0 deletions packages/react-stately/src/color/useColorSwatchPickerState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {Color} from './types';
import {useColor} from './useColor';
import {useControlledState} from '../utils/useControlledState';
import {ValueBase} from '@react-types/shared';

export interface ColorSwatchPickerStateProps extends ValueBase<
string | Color | null,
Color | null
> {}

export interface ColorSwatchPickerState {
/** The current color value of the color swatch picker. */
color: Color | null;
/** Sets the current color value of the color swatch picker. */
setColor(color: Color | null): void;
}

export function useColorSwatchPickerState(
props: ColorSwatchPickerStateProps
): ColorSwatchPickerState {
let value = useColor(props.value);
let defaultValue = useColor(props.defaultValue) ?? null;
let [color, setColor] = useControlledState<Color | null>(value, defaultValue, props.onChange);

return {
color,
setColor(color) {
setColor(color);
}
};
}