Skip to content

Commit bb59dde

Browse files
feat: Add ColorPicker (#1861)
## 📝 Changes - Add `ColorPicker` - Add `ColorPickerInputField` - Updates `react-aria*` packages <img width="515" height="367" alt="image" src="https://github.com/user-attachments/assets/fee65870-61fe-411b-98ab-fac9d9f896ba" /> ## ✅ Checklist Easy UI has certain UX standards that must be met. In general, non-trivial changes should meet the following criteria: - [x] Visuals match Design Specs in Figma - [x] Stories accompany any component changes - [x] Code is in accordance with our style guide - [x] Design tokens are utilized - [x] Unit tests accompany any component changes - [x] TSDoc is written for any API surface area - [x] Console is free from warnings - [x] No accessibility violations are reported - [x] Cross-browser check is performed (Chrome, Safari, Firefox) - [x] Changeset is added ~Strikethrough~ any items that are not applicable to this pull request.
1 parent 45fba5b commit bb59dde

23 files changed

+2958
-2014
lines changed

.changeset/full-ears-drum.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@easypost/easy-ui": minor
3+
---
4+
5+
feat: Add ColorPicker

easy-ui-react/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,19 +31,19 @@
3131
"dependencies": {
3232
"@easypost/easy-ui-icons": "1.0.0-alpha.50",
3333
"@easypost/easy-ui-tokens": "1.0.0-alpha.17",
34-
"@react-aria/toast": "^3.0.5",
35-
"@react-aria/utils": "^3.29.1",
36-
"@react-stately/toast": "^3.1.1",
37-
"@react-types/shared": "^3.30.0",
34+
"@react-aria/toast": "^3.0.9",
35+
"@react-aria/utils": "^3.32.0",
36+
"@react-stately/toast": "^3.1.2",
37+
"@react-types/shared": "^3.32.1",
3838
"@types/react": "^18.3.1",
3939
"@types/react-dom": "^18.3.1",
4040
"lodash": "^4.17.21",
4141
"overlayscrollbars": "^2.3.0",
4242
"overlayscrollbars-react": "^0.5.6",
43-
"react-aria": "^3.41.1",
44-
"react-aria-components": "^1.7.1",
43+
"react-aria": "^3.45.0",
44+
"react-aria-components": "^1.14.0",
4545
"react-is": "^18.3.1",
46-
"react-stately": "^3.39.0",
46+
"react-stately": "^3.43.0",
4747
"react-syntax-highlighter": "^15.6.1",
4848
"react-transition-group": "^4.4.5",
4949
"use-clipboard-copy": "^0.2.0"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
@use "../styles/common" as *;
2+
3+
.ColorArea {
4+
width: 100%;
5+
aspect-ratio: 1 / 1;
6+
border-radius: design-token("shape.border_radius.md");
7+
flex-shrink: 0;
8+
9+
&[data-disabled] {
10+
opacity: 0.5;
11+
}
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from "react";
2+
import {
3+
ColorArea as AriaColorArea,
4+
ColorAreaProps,
5+
} from "react-aria-components";
6+
import { ColorThumb } from "./ColorThumb";
7+
8+
import styles from "./ColorArea.module.scss";
9+
10+
export function ColorArea(props: ColorAreaProps) {
11+
return (
12+
<AriaColorArea {...props} className={styles.ColorArea}>
13+
<ColorThumb />
14+
</AriaColorArea>
15+
);
16+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React from "react";
2+
import { ArgTypes, Canvas, Meta, Controls } from "@storybook/addon-docs/blocks";
3+
import { ColorPicker } from "./ColorPicker";
4+
import * as ColorPickerStories from "./ColorPicker.stories";
5+
6+
<Meta of={ColorPickerStories} />
7+
8+
# ColorPicker
9+
10+
A `<ColorPicker />` allows users to select a color with a color picker.
11+
12+
<Canvas of={ColorPickerStories.Default} />
13+
14+
<Controls of={ColorPickerStories.Default} />
15+
16+
## Rendering
17+
18+
A `<ColorPicker >` can be attached to any pressable element through `<ColorPicker.Trigger />`.
19+
20+
```tsx
21+
<ColorPicker>
22+
<ColorPicker.Trigger>
23+
<Button>Pick a color</Button>
24+
</ColorPicker.Trigger>
25+
</ColorPicker>
26+
```
27+
28+
## Retrieving a Selected Color
29+
30+
### Controlled Component
31+
32+
You can retrieve the selected color through `value` and `onChange` props by making the component controlled.
33+
34+
```tsx
35+
import { ColorPicker, Color } from "@easypost/easy-ui/ColorPicker";
36+
37+
function ControlledColorPicker() {
38+
const [color, setColor] = React.useState<Color | null>(null);
39+
40+
// Use the selected color
41+
console.log(color.toString("css"));
42+
43+
return (
44+
<ColorPicker value={color} onChange={setColor}>
45+
<ColorPicker.Trigger>
46+
<Button>Pick a color</Button>
47+
</ColorPicker.Trigger>
48+
</ColorPicker>
49+
);
50+
}
51+
```
52+
53+
### useColorPickerState Hook
54+
55+
You can also retrieve the selected color using the `useColorPickerState` hook.
56+
57+
```tsx
58+
import { useColorPickerState } from "@easypost/easy-ui/ColorPicker";
59+
60+
function SelectedColor() {
61+
const { color } = useColorPickerState();
62+
return (
63+
<Text variant="subtitle1">
64+
Selected Color: {color ? color.toString("css") : "None"}
65+
</Text>
66+
);
67+
}
68+
69+
function AppColorPicker() {
70+
<ColorPicker>
71+
<SelectedColor />
72+
<ColorPicker.Trigger>
73+
<Button>Pick a color</Button>
74+
</ColorPicker.Trigger>
75+
</ColorPicker>;
76+
}
77+
```
78+
79+
### Render Prop
80+
81+
You can also retrieve the selected color using the render prop pattern.
82+
83+
```tsx
84+
<ColorPicker>
85+
{({ color }) => (
86+
<VerticalStack inlineAlign="start" gap="2">
87+
<Text variant="subtitle1">
88+
Selected Color: {color ? color.toString("css") : "None"}
89+
</Text>
90+
<ColorPicker.Trigger>
91+
<Button>Pick a color</Button>
92+
</ColorPicker.Trigger>
93+
</VerticalStack>
94+
)}
95+
</ColorPicker>
96+
```
97+
98+
## Properties
99+
100+
<ArgTypes of={ColorPicker} exclude={["children"]} />
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@use "../styles/common" as *;
2+
3+
.popover {
4+
min-width: 220px;
5+
max-height: inherit;
6+
overflow: auto;
7+
8+
display: flex;
9+
flex-direction: column;
10+
gap: design-token("space.2");
11+
12+
padding: design-token("space.2");
13+
background: design-token("color.neutral.000");
14+
box-shadow: design-token("shadow.level.2");
15+
border-radius: design-token("shape.border_radius.lg");
16+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Meta, StoryObj } from "@storybook/react-vite";
2+
import React from "react";
3+
import { Button } from "../Button";
4+
import { Text } from "../Text";
5+
import { VerticalStack } from "../VerticalStack";
6+
import { ColorPicker, useColorPickerState } from "./ColorPicker";
7+
8+
type Story = StoryObj<typeof ColorPicker>;
9+
10+
const meta: Meta<typeof ColorPicker> = {
11+
title: "Components/ColorPicker/ColorPicker",
12+
component: ColorPicker,
13+
args: {},
14+
parameters: {
15+
controls: {
16+
exclude: ["children"],
17+
},
18+
},
19+
};
20+
21+
export default meta;
22+
23+
export const Default: Story = {
24+
render: (args) => (
25+
<ColorPicker {...args}>
26+
<VerticalStack inlineAlign="start" gap="2">
27+
<SelectedColorText />
28+
<ColorPicker.Trigger>
29+
<Button>Pick a color</Button>
30+
</ColorPicker.Trigger>
31+
</VerticalStack>
32+
</ColorPicker>
33+
),
34+
args: {
35+
defaultValue: "#00ff00",
36+
},
37+
};
38+
39+
function SelectedColorText() {
40+
const { color } = useColorPickerState();
41+
return (
42+
<Text variant="subtitle1">
43+
Selected Color: {color ? color.toString("css") : "None"}
44+
</Text>
45+
);
46+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { screen } from "@testing-library/react";
2+
import React from "react";
3+
import { vi } from "vitest";
4+
import { Button } from "../Button";
5+
import { render, userClick, userKeyboard, userTab } from "../utilities/test";
6+
import { ColorPicker, useColorPickerState } from "./ColorPicker";
7+
8+
describe("<ColorPicker />", () => {
9+
beforeEach(() => {
10+
vi.useFakeTimers();
11+
});
12+
13+
afterEach(() => {
14+
vi.useRealTimers();
15+
});
16+
17+
it("should render", () => {
18+
render(
19+
<ColorPicker>
20+
<ColorPicker.Trigger>
21+
<Button>Pick a color</Button>
22+
</ColorPicker.Trigger>
23+
</ColorPicker>,
24+
);
25+
expect(screen.getByText("Pick a color")).toBeInTheDocument();
26+
});
27+
28+
it("should open", async () => {
29+
const { user } = render(
30+
<ColorPicker>
31+
<ColorPicker.Trigger>
32+
<Button>Pick a color</Button>
33+
</ColorPicker.Trigger>
34+
</ColorPicker>,
35+
);
36+
await userClick(user, screen.getByText("Pick a color"));
37+
expect(screen.getAllByLabelText("Color picker").length).toBeGreaterThan(0);
38+
expect(screen.getByLabelText("Hue slider")).toBeInTheDocument();
39+
});
40+
41+
it("should select a color", async () => {
42+
const handleChange = vi.fn();
43+
const { user } = render(
44+
<ColorPicker defaultValue="#ff0000" onChange={handleChange}>
45+
<ColorPicker.Trigger>
46+
<Button>Pick a color</Button>
47+
</ColorPicker.Trigger>
48+
</ColorPicker>,
49+
);
50+
await userClick(user, screen.getByText("Pick a color"));
51+
expect(screen.getAllByLabelText("Color picker").length).toBeGreaterThan(0);
52+
await userTab(user);
53+
await userKeyboard(user, "{ArrowRight}{ArrowUp}{ArrowLeft}{ArrowDown}");
54+
expect(handleChange).toHaveBeenCalled();
55+
});
56+
57+
it("should support context hook", async () => {
58+
function SelectedColor() {
59+
const { color } = useColorPickerState();
60+
return <span>{color ? color.toString("css") : "None"}</span>;
61+
}
62+
63+
const handleChange = vi.fn();
64+
const { user } = render(
65+
<ColorPicker defaultValue="#ff0000" onChange={handleChange}>
66+
<SelectedColor />
67+
<ColorPicker.Trigger>
68+
<Button>Pick a color</Button>
69+
</ColorPicker.Trigger>
70+
</ColorPicker>,
71+
);
72+
73+
await userClick(user, screen.getByText("Pick a color"));
74+
expect(screen.getAllByLabelText("Color picker").length).toBeGreaterThan(0);
75+
await userTab(user);
76+
await userKeyboard(user, "{ArrowRight}{ArrowUp}{ArrowLeft}{ArrowDown}");
77+
expect(screen.getByText("hsla(0, 100%, 49.5%, 1)")).toBeInTheDocument();
78+
});
79+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React, { useContext } from "react";
2+
import {
3+
ColorPicker as AriaColorPicker,
4+
ColorPickerProps as AriaColorPickerProps,
5+
Color as AriaColor,
6+
ColorPickerStateContext,
7+
DialogTrigger,
8+
Popover,
9+
} from "react-aria-components";
10+
import { ColorArea } from "./ColorArea";
11+
import { ColorSlider } from "./ColorSlider";
12+
13+
import styles from "./ColorPicker.module.scss";
14+
15+
export type Color = AriaColor;
16+
17+
export type ColorPickerProps = {
18+
/** The current value (controlled). */
19+
value?: AriaColorPickerProps["value"];
20+
/** The default value (uncontrolled). */
21+
defaultValue?: AriaColorPickerProps["defaultValue"];
22+
/** Handler that is called when the value changes. */
23+
onChange?: AriaColorPickerProps["onChange"];
24+
/** The children of the component. A function may be provided to alter the children based on component state. */
25+
children: AriaColorPickerProps["children"];
26+
};
27+
28+
/**
29+
* A `<ColorPicker />` allows users to select a color with a color picker.
30+
*
31+
* @example
32+
* <ColorPicker>
33+
* <ColorPickerTrigger>
34+
* <Button>Pick a color</Button>
35+
* </ColorPickerTrigger>
36+
* </ColorPicker>
37+
*/
38+
export function ColorPicker(props: ColorPickerProps) {
39+
return <AriaColorPicker {...props} />;
40+
}
41+
42+
function ColorPickerTrigger({ children }: { children: React.ReactNode }) {
43+
return (
44+
<DialogTrigger>
45+
{children}
46+
<Popover placement="bottom start" className={styles.popover}>
47+
<ColorArea
48+
colorSpace="hsb"
49+
xChannel="saturation"
50+
yChannel="brightness"
51+
/>
52+
<ColorSlider colorSpace="hsb" channel="hue" />
53+
</Popover>
54+
</DialogTrigger>
55+
);
56+
}
57+
58+
/**
59+
* Attaches a `<ColorPicker />` to a pressiable trigger element.
60+
*/
61+
ColorPicker.Trigger = ColorPickerTrigger;
62+
63+
/**
64+
* Returns the internal state of the nearest `<ColorPicker />` component.
65+
*/
66+
export function useColorPickerState() {
67+
const colorPickerStateContext = useContext(ColorPickerStateContext);
68+
if (!colorPickerStateContext) {
69+
throw new Error(
70+
"useColorPickerState must be used within a ColorPicker component",
71+
);
72+
}
73+
return colorPickerStateContext;
74+
}

0 commit comments

Comments
 (0)