Skip to content
Snippets Groups Projects
Unverified Commit d41e45cb authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Add extended color pickers (#22880)

parent 73fac018
No related branches found
No related tags found
No related merge requests found
Showing
with 630 additions and 0 deletions
import React, { useState } from "react";
import { ComponentStory } from "@storybook/react";
import { useArgs } from "@storybook/client-api";
import ColorInput from "./ColorInput";
export default {
title: "Core/ColorInput",
component: ColorInput,
};
const Template: ComponentStory<typeof ColorInput> = args => {
const [{ color }, updateArgs] = useArgs();
const handleChange = (color?: string) => {
updateArgs({ color });
};
return <ColorInput {...args} color={color} onChange={handleChange} />;
};
export const Default = Template.bind({});
import React, {
ChangeEvent,
FocusEvent,
forwardRef,
InputHTMLAttributes,
Ref,
useCallback,
useMemo,
useState,
} from "react";
import Color from "color";
import Input from "metabase/core/components/Input";
export type ColorInputAttributes = Omit<
InputHTMLAttributes<HTMLDivElement>,
"value" | "onChange"
>;
export interface ColorInputProps extends ColorInputAttributes {
color?: string;
fullWidth?: boolean;
onChange?: (value?: string) => void;
}
const ColorInput = forwardRef(function ColorInput(
{ color, onFocus, onBlur, onChange, ...props }: ColorInputProps,
ref: Ref<HTMLDivElement>,
) {
const colorText = useMemo(() => getColorHex(color) ?? "", [color]);
const [inputText, setInputText] = useState(colorText);
const [isFocused, setIsFocused] = useState(false);
const handleFocus = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
setInputText(colorText);
onFocus?.(event);
},
[colorText, onFocus],
);
const handleBlur = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
onBlur?.(event);
},
[onBlur],
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const newText = event.target.value;
setInputText(newText);
onChange?.(getColorHex(newText));
},
[onChange],
);
return (
<Input
{...props}
ref={ref}
value={isFocused ? inputText : colorText}
size="small"
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
/>
);
});
const getColorHex = (color?: string) => {
try {
return color ? Color(color).hex() : undefined;
} catch (e) {
return undefined;
}
};
export default ColorInput;
export { default } from "./ColorInput";
import React from "react";
import { ComponentStory } from "@storybook/react";
import { useArgs } from "@storybook/client-api";
import { color } from "metabase/lib/colors";
import ColorPicker from "./ColorPicker";
export default {
title: "Core/ColorPicker",
component: ColorPicker,
};
const Template: ComponentStory<typeof ColorPicker> = args => {
const [{ color }, updateArgs] = useArgs();
const handleChange = (color: string) => {
updateArgs({ color });
};
return <ColorPicker {...args} color={color} onChange={handleChange} />;
};
export const Default = Template.bind({});
Default.args = {
color: color("brand"),
placeholder: color("brand"),
};
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
export const TriggerContainer = styled.div`
display: flex;
gap: 1rem;
`;
export const ContentContainer = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
width: 16.5rem;
padding: 1rem;
`;
export const SaturationContainer = styled.div`
position: relative;
height: 10rem;
margin-bottom: 1rem;
border-radius: 0.25rem;
overflow: hidden;
`;
export const HueContainer = styled.div`
position: relative;
height: 0.5rem;
border-radius: 0.25rem;
overflow: hidden;
`;
export const ControlsPointer = styled.div`
border: 2px solid ${color("white")};
border-radius: 50%;
pointer-events: none;
`;
export const SaturationPointer = styled(ControlsPointer)`
width: 0.875rem;
height: 0.875rem;
transform: translate(-50%, -50%);
`;
export const HuePointer = styled(ControlsPointer)`
width: 0.625rem;
height: 0.625rem;
transform: translate(-50%, -0.0625rem);
`;
import React, { forwardRef, HTMLAttributes, Ref } from "react";
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
import ColorPickerTrigger from "./ColorPickerTrigger";
import ColorPickerContent from "./ColorPickerContent";
export type ColorPickerAttributes = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
>;
export interface ColorPickerProps extends ColorPickerAttributes {
color: string;
placeholder?: string;
isBordered?: boolean;
isSelected?: boolean;
isGenerated?: boolean;
onChange?: (color: string) => void;
}
const ColorPicker = forwardRef(function ColorPicker(
{
color,
placeholder,
isBordered,
isSelected,
isGenerated,
onChange,
...props
}: ColorPickerProps,
ref: Ref<HTMLDivElement>,
) {
return (
<TippyPopoverWithTrigger
disableContentSandbox
renderTrigger={({ onClick }) => (
<ColorPickerTrigger
{...props}
ref={ref}
color={color}
placeholder={placeholder}
isBordered={isBordered}
isSelected={isSelected}
isGenerated={isGenerated}
onClick={onClick}
onChange={onChange}
/>
)}
popoverContent={<ColorPickerContent color={color} onChange={onChange} />}
/>
);
});
export default ColorPicker;
import React, { useState } from "react";
import Color from "color";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ColorPicker from "./ColorPicker";
const TestColorPicker = () => {
const [color, setColor] = useState("white");
return <ColorPicker color={color} onChange={setColor} />;
};
describe("ColorPicker", () => {
it("should input a color inline", () => {
render(<TestColorPicker />);
const color = Color.rgb(0, 0, 0);
const input = screen.getByRole("textbox");
userEvent.clear(input);
userEvent.type(input, color.hex());
expect(screen.getByLabelText(color.hex())).toBeInTheDocument();
});
it("should input a color in a popover", async () => {
render(<TestColorPicker />);
userEvent.click(screen.getByLabelText("white"));
const color = Color.rgb(0, 0, 0);
const tooltip = await screen.findByRole("tooltip");
const input = within(tooltip).getByRole("textbox");
userEvent.clear(input);
userEvent.type(input, color.hex());
expect(screen.getByLabelText(color.hex())).toBeInTheDocument();
});
});
import React, { forwardRef, HTMLAttributes, Ref, useCallback } from "react";
import { ColorState } from "react-color";
import ColorInput from "metabase/core/components/ColorInput";
import ColorPickerControls from "./ColorPickerControls";
import { ContentContainer } from "./ColorPicker.styled";
export type ColorPickerContentAttributes = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
>;
export interface ColorPickerContentProps extends ColorPickerContentAttributes {
color?: string;
onChange?: (color: string) => void;
}
const ColorPickerContent = forwardRef(function ColorPickerContent(
{ color, onChange, ...props }: ColorPickerContentProps,
ref: Ref<HTMLDivElement>,
) {
const handleColorChange = useCallback(
(color?: string) => color && onChange?.(color),
[onChange],
);
const handleColorStateChange = useCallback(
(state: ColorState) => onChange?.(state.hex),
[onChange],
);
return (
<ContentContainer {...props} ref={ref}>
<ColorPickerControls color={color} onChange={handleColorStateChange} />
<ColorInput color={color} fullWidth onChange={handleColorChange} />
</ContentContainer>
);
});
export default ColorPickerContent;
import React from "react";
import { CustomPicker, CustomPickerInjectedProps } from "react-color";
import { Hue, Saturation } from "react-color/lib/components/common";
import {
HueContainer,
HuePointer,
SaturationContainer,
SaturationPointer,
} from "./ColorPicker.styled";
const saturationStyles = {
color: {
borderTopLeftRadius: "5px",
borderBottomRightRadius: "5px",
},
};
const ColorPickerControls = CustomPicker(function ColorControls(
props: CustomPickerInjectedProps,
) {
return (
<div>
<SaturationContainer>
<Saturation
{...props}
pointer={SaturationPointer}
style={saturationStyles}
/>
</SaturationContainer>
<HueContainer>
<Hue {...props} pointer={HuePointer} />
</HueContainer>
</div>
);
});
export default ColorPickerControls;
import React, { forwardRef, HTMLAttributes, Ref, useCallback } from "react";
import ColorPill from "metabase/core/components/ColorPill";
import ColorInput from "metabase/core/components/ColorInput";
import { TriggerContainer } from "./ColorPicker.styled";
export interface ColorPickerTriggerProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
color: string;
placeholder?: string;
isBordered?: boolean;
isSelected?: boolean;
isGenerated?: boolean;
onChange?: (color: string) => void;
}
const ColorPickerTrigger = forwardRef(function ColorPickerTrigger(
{
color,
placeholder,
isBordered,
isSelected,
isGenerated,
onClick,
onChange,
...props
}: ColorPickerTriggerProps,
ref: Ref<HTMLDivElement>,
) {
const handleChange = useCallback(
(color?: string) => color && onChange?.(color),
[onChange],
);
return (
<TriggerContainer {...props} ref={ref}>
<ColorPill
color={color}
isBordered={isBordered}
isSelected={isSelected}
isGenerated={isGenerated}
onClick={onClick}
/>
<ColorInput
color={color}
placeholder={placeholder}
onChange={handleChange}
/>
</TriggerContainer>
);
});
export default ColorPickerTrigger;
export { default } from "./ColorPicker";
import React from "react";
import { ComponentStory } from "@storybook/react";
import { color } from "metabase/lib/colors";
import ColorPill from "./ColorPill";
export default {
title: "Core/ColorPill",
component: ColorPill,
};
const Template: ComponentStory<typeof ColorPill> = args => {
return <ColorPill {...args} />;
};
export const Default = Template.bind({});
Default.args = {
color: color("brand"),
};
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
export interface ColorPillRootProps {
isBordered?: boolean;
isSelected?: boolean;
isGenerated?: boolean;
}
export const ColorPillRoot = styled.div<ColorPillRootProps>`
display: inline-block;
width: 2rem;
height: 2rem;
padding: ${props => props.isBordered && "0.1875rem"};
border-width: ${props => (props.isBordered ? "0.0625rem" : "0")};
border-color: ${props =>
props.isSelected ? color("border") : "transparent"};
border-style: ${props => (props.isGenerated ? "dashed" : "solid")};
border-radius: 50%;
cursor: pointer;
`;
export interface ColorPillContentProps {
isBordered?: boolean;
}
export const ColorPillContent = styled.div<ColorPillContentProps>`
width: ${props => (props.isBordered ? "1.5rem" : "2rem")};
height: ${props => (props.isBordered ? "1.5rem" : "2rem")};
border-radius: 50%;
`;
import React, { forwardRef, HTMLAttributes, Ref } from "react";
import { ColorPillContent, ColorPillRoot } from "./ColorPill.styled";
export interface ColorPillProps extends HTMLAttributes<HTMLDivElement> {
color: string;
isBordered?: boolean;
isSelected?: boolean;
isGenerated?: boolean;
}
const ColorPill = forwardRef(function ColorPill(
{
color,
isBordered,
isSelected,
isGenerated,
"aria-label": ariaLabel = color,
...props
}: ColorPillProps,
ref: Ref<HTMLDivElement>,
) {
return (
<ColorPillRoot
{...props}
ref={ref}
isBordered={isBordered}
isSelected={isSelected}
isGenerated={isGenerated}
aria-label={ariaLabel}
>
<ColorPillContent
isBordered={isBordered}
style={{ backgroundColor: color }}
/>
</ColorPillRoot>
);
});
export default ColorPill;
export { default } from "./ColorPill";
import React from "react";
import { ComponentStory } from "@storybook/react";
import { useArgs } from "@storybook/client-api";
import { color } from "metabase/lib/colors";
import ColorSelector from "./ColorSelector";
export default {
title: "Core/ColorSelector",
component: ColorSelector,
};
const Template: ComponentStory<typeof ColorSelector> = args => {
const [{ color }, updateArgs] = useArgs();
const handleChange = (color: string) => {
updateArgs({ color });
};
return <ColorSelector {...args} color={color} onChange={handleChange} />;
};
export const Default = Template.bind({});
Default.args = {
color: color("brand"),
colors: [color("brand"), color("summarize"), color("filter")],
};
import styled from "@emotion/styled";
export interface ColorSelectorProps {
colors: string[];
}
export const ColorSelectorRoot = styled.div<ColorSelectorProps>`
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.75rem;
max-width: 21.5rem;
`;
import React, { forwardRef, HTMLAttributes, Ref } from "react";
import ColorPill from "metabase/core/components/ColorPill";
import TippyPopoverWithTrigger from "metabase/components/PopoverWithTrigger/TippyPopoverWithTrigger";
import ColorSelectorContent from "./ColorSelectorContent";
export type ColorSelectorAttributes = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
>;
export interface ColorSelectorProps extends ColorSelectorAttributes {
color: string;
colors: string[];
isBordered?: boolean;
isSelected?: boolean;
onChange?: (color: string) => void;
}
const ColorSelector = forwardRef(function ColorSelector(
{
color,
colors,
isBordered,
isSelected,
onChange,
...props
}: ColorSelectorProps,
ref: Ref<HTMLDivElement>,
) {
return (
<TippyPopoverWithTrigger
renderTrigger={({ onClick }) => (
<ColorPill
{...props}
ref={ref}
color={color}
isBordered={isBordered}
isSelected={isSelected}
onClick={onClick}
/>
)}
popoverContent={
<ColorSelectorContent
color={color}
colors={colors}
onChange={onChange}
/>
}
/>
);
});
export default ColorSelector;
import React from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ColorSelector from "./ColorSelector";
describe("ColorSelector", () => {
it("should select a color in a popover", async () => {
const onChange = jest.fn();
render(
<ColorSelector
color="white"
colors={["blue", "green"]}
onChange={onChange}
/>,
);
userEvent.click(screen.getByLabelText("white"));
const tooltip = await screen.findByRole("tooltip");
userEvent.click(within(tooltip).getByLabelText("blue"));
expect(onChange).toHaveBeenCalledWith("blue");
});
});
import React, { forwardRef, HTMLAttributes, Ref } from "react";
import ColorPill from "metabase/core/components/ColorPill";
import { ColorSelectorRoot } from "./ColorSelector.styled";
export interface ColorSelectorContentProps
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
color?: string;
colors: string[];
onChange?: (color: string) => void;
}
const ColorSelectorContent = forwardRef(function ColorSelector(
{ color, colors, onChange, ...props }: ColorSelectorContentProps,
ref: Ref<HTMLDivElement>,
) {
return (
<ColorSelectorRoot {...props} ref={ref} colors={colors}>
{colors.map((option, index) => (
<ColorPill
key={index}
color={option}
isBordered
isSelected={color === option}
onClick={() => onChange?.(option)}
/>
))}
</ColorSelectorRoot>
);
});
export default ColorSelectorContent;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment