From d41e45cb068fe56c9480019a391f4efb67ebfb66 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Tue, 24 May 2022 16:23:01 +0300 Subject: [PATCH] Add extended color pickers (#22880) --- .../ColorInput/ColorInput.stories.tsx | 21 +++++ .../core/components/ColorInput/ColorInput.tsx | 80 +++++++++++++++++++ .../core/components/ColorInput/index.ts | 1 + .../ColorPicker/ColorPicker.stories.tsx | 26 ++++++ .../ColorPicker/ColorPicker.styled.tsx | 48 +++++++++++ .../components/ColorPicker/ColorPicker.tsx | 53 ++++++++++++ .../ColorPicker/ColorPicker.unit.spec.tsx | 36 +++++++++ .../ColorPicker/ColorPickerContent.tsx | 39 +++++++++ .../ColorPicker/ColorPickerControls.tsx | 37 +++++++++ .../ColorPicker/ColorPickerTrigger.tsx | 52 ++++++++++++ .../core/components/ColorPicker/index.ts | 1 + .../ColorPill/ColorPill.stories.tsx | 18 +++++ .../components/ColorPill/ColorPill.styled.tsx | 31 +++++++ .../core/components/ColorPill/ColorPill.tsx | 39 +++++++++ .../core/components/ColorPill/index.ts | 1 + .../ColorSelector/ColorSelector.stories.tsx | 26 ++++++ .../ColorSelector/ColorSelector.styled.tsx | 13 +++ .../ColorSelector/ColorSelector.tsx | 53 ++++++++++++ .../ColorSelector/ColorSelector.unit.spec.tsx | 24 ++++++ .../ColorSelector/ColorSelectorContent.tsx | 31 +++++++ .../core/components/ColorSelector/index.ts | 1 + .../core/components/DateInput/DateInput.tsx | 2 +- .../core/components/Input/Input.styled.tsx | 9 +++ .../metabase/core/components/Input/Input.tsx | 11 ++- .../metabase/core/components/Input/types.ts | 1 + .../components/NumericInput/NumericInput.tsx | 2 +- 26 files changed, 653 insertions(+), 3 deletions(-) create mode 100644 frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx create mode 100644 frontend/src/metabase/core/components/ColorInput/ColorInput.tsx create mode 100644 frontend/src/metabase/core/components/ColorInput/index.ts create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPicker.styled.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPicker.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPicker.unit.spec.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPickerContent.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPickerControls.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/ColorPickerTrigger.tsx create mode 100644 frontend/src/metabase/core/components/ColorPicker/index.ts create mode 100644 frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx create mode 100644 frontend/src/metabase/core/components/ColorPill/ColorPill.styled.tsx create mode 100644 frontend/src/metabase/core/components/ColorPill/ColorPill.tsx create mode 100644 frontend/src/metabase/core/components/ColorPill/index.ts create mode 100644 frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx create mode 100644 frontend/src/metabase/core/components/ColorSelector/ColorSelector.styled.tsx create mode 100644 frontend/src/metabase/core/components/ColorSelector/ColorSelector.tsx create mode 100644 frontend/src/metabase/core/components/ColorSelector/ColorSelector.unit.spec.tsx create mode 100644 frontend/src/metabase/core/components/ColorSelector/ColorSelectorContent.tsx create mode 100644 frontend/src/metabase/core/components/ColorSelector/index.ts create mode 100644 frontend/src/metabase/core/components/Input/types.ts diff --git a/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx b/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx new file mode 100644 index 00000000000..3721c495fd1 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorInput/ColorInput.stories.tsx @@ -0,0 +1,21 @@ +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({}); diff --git a/frontend/src/metabase/core/components/ColorInput/ColorInput.tsx b/frontend/src/metabase/core/components/ColorInput/ColorInput.tsx new file mode 100644 index 00000000000..18bf37631c7 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorInput/ColorInput.tsx @@ -0,0 +1,80 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorInput/index.ts b/frontend/src/metabase/core/components/ColorInput/index.ts new file mode 100644 index 00000000000..ea57087f74b --- /dev/null +++ b/frontend/src/metabase/core/components/ColorInput/index.ts @@ -0,0 +1 @@ +export { default } from "./ColorInput"; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx new file mode 100644 index 00000000000..6fb56139a2e --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.stories.tsx @@ -0,0 +1,26 @@ +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"), +}; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPicker.styled.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.styled.tsx new file mode 100644 index 00000000000..4f1a4d96c8e --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.styled.tsx @@ -0,0 +1,48 @@ +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); +`; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPicker.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 00000000000..1ac0bf6c18d --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,53 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPicker.unit.spec.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.unit.spec.tsx new file mode 100644 index 00000000000..f52e6094beb --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPicker.unit.spec.tsx @@ -0,0 +1,36 @@ +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(); + }); +}); diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPickerContent.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPickerContent.tsx new file mode 100644 index 00000000000..4b7adcce72a --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPickerContent.tsx @@ -0,0 +1,39 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPickerControls.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPickerControls.tsx new file mode 100644 index 00000000000..aa278416668 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPickerControls.tsx @@ -0,0 +1,37 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorPicker/ColorPickerTrigger.tsx b/frontend/src/metabase/core/components/ColorPicker/ColorPickerTrigger.tsx new file mode 100644 index 00000000000..d27b72c6703 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/ColorPickerTrigger.tsx @@ -0,0 +1,52 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorPicker/index.ts b/frontend/src/metabase/core/components/ColorPicker/index.ts new file mode 100644 index 00000000000..1bc9069e2d1 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPicker/index.ts @@ -0,0 +1 @@ +export { default } from "./ColorPicker"; diff --git a/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx b/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx new file mode 100644 index 00000000000..e71db4ce7fc --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPill/ColorPill.stories.tsx @@ -0,0 +1,18 @@ +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"), +}; diff --git a/frontend/src/metabase/core/components/ColorPill/ColorPill.styled.tsx b/frontend/src/metabase/core/components/ColorPill/ColorPill.styled.tsx new file mode 100644 index 00000000000..e9d3926e31d --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPill/ColorPill.styled.tsx @@ -0,0 +1,31 @@ +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%; +`; diff --git a/frontend/src/metabase/core/components/ColorPill/ColorPill.tsx b/frontend/src/metabase/core/components/ColorPill/ColorPill.tsx new file mode 100644 index 00000000000..c61ca191526 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPill/ColorPill.tsx @@ -0,0 +1,39 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorPill/index.ts b/frontend/src/metabase/core/components/ColorPill/index.ts new file mode 100644 index 00000000000..b162f4cef30 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorPill/index.ts @@ -0,0 +1 @@ +export { default } from "./ColorPill"; diff --git a/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx new file mode 100644 index 00000000000..657798b68b0 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.stories.tsx @@ -0,0 +1,26 @@ +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")], +}; diff --git a/frontend/src/metabase/core/components/ColorSelector/ColorSelector.styled.tsx b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.styled.tsx new file mode 100644 index 00000000000..28f5d84991a --- /dev/null +++ b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.styled.tsx @@ -0,0 +1,13 @@ +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; +`; diff --git a/frontend/src/metabase/core/components/ColorSelector/ColorSelector.tsx b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.tsx new file mode 100644 index 00000000000..2b6610dee56 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.tsx @@ -0,0 +1,53 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorSelector/ColorSelector.unit.spec.tsx b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.unit.spec.tsx new file mode 100644 index 00000000000..42da1d9e9ed --- /dev/null +++ b/frontend/src/metabase/core/components/ColorSelector/ColorSelector.unit.spec.tsx @@ -0,0 +1,24 @@ +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"); + }); +}); diff --git a/frontend/src/metabase/core/components/ColorSelector/ColorSelectorContent.tsx b/frontend/src/metabase/core/components/ColorSelector/ColorSelectorContent.tsx new file mode 100644 index 00000000000..345bf7464dc --- /dev/null +++ b/frontend/src/metabase/core/components/ColorSelector/ColorSelectorContent.tsx @@ -0,0 +1,31 @@ +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; diff --git a/frontend/src/metabase/core/components/ColorSelector/index.ts b/frontend/src/metabase/core/components/ColorSelector/index.ts new file mode 100644 index 00000000000..ac4599c4ea8 --- /dev/null +++ b/frontend/src/metabase/core/components/ColorSelector/index.ts @@ -0,0 +1 @@ +export { default } from "./ColorSelector"; diff --git a/frontend/src/metabase/core/components/DateInput/DateInput.tsx b/frontend/src/metabase/core/components/DateInput/DateInput.tsx index e836f3bd923..68fba21b949 100644 --- a/frontend/src/metabase/core/components/DateInput/DateInput.tsx +++ b/frontend/src/metabase/core/components/DateInput/DateInput.tsx @@ -19,7 +19,7 @@ const TIME_FORMAT_24 = "HH:mm"; export type DateInputAttributes = Omit< InputHTMLAttributes<HTMLDivElement>, - "value" | "onChange" + "size" | "value" | "onChange" >; export interface DateInputProps extends DateInputAttributes { diff --git a/frontend/src/metabase/core/components/Input/Input.styled.tsx b/frontend/src/metabase/core/components/Input/Input.styled.tsx index 3896e2fd096..086aa27f3c5 100644 --- a/frontend/src/metabase/core/components/Input/Input.styled.tsx +++ b/frontend/src/metabase/core/components/Input/Input.styled.tsx @@ -2,8 +2,10 @@ import styled from "@emotion/styled"; import { css } from "@emotion/react"; import { color, darken } from "metabase/lib/colors"; import IconButtonWrapper from "metabase/components/IconButtonWrapper"; +import { InputSize } from "./types"; export interface InputProps { + fieldSize?: InputSize; hasError?: boolean; fullWidth?: boolean; hasLeftIcon?: boolean; @@ -57,6 +59,13 @@ export const InputField = styled.input<InputProps>` css` padding-right: 2.25rem; `}; + + ${props => + props.fieldSize === "small" && + css` + font-size: 0.875rem; + padding: 0.4375rem 0.625rem; + `}; `; export const InputButton = styled(IconButtonWrapper)` diff --git a/frontend/src/metabase/core/components/Input/Input.tsx b/frontend/src/metabase/core/components/Input/Input.tsx index b6e0257de30..86eb0595ed3 100644 --- a/frontend/src/metabase/core/components/Input/Input.tsx +++ b/frontend/src/metabase/core/components/Input/Input.tsx @@ -13,9 +13,16 @@ import { InputRightButton, InputRoot, } from "./Input.styled"; +import { InputSize } from "./types"; -export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { +export type InputAttributes = Omit< + InputHTMLAttributes<HTMLInputElement>, + "size" +>; + +export interface InputProps extends InputAttributes { inputRef?: Ref<HTMLInputElement>; + size?: InputSize; error?: boolean; fullWidth?: boolean; leftIcon?: string; @@ -31,6 +38,7 @@ const Input = forwardRef(function Input( className, style, inputRef, + size = "medium", error, fullWidth, leftIcon, @@ -53,6 +61,7 @@ const Input = forwardRef(function Input( <InputField {...props} ref={inputRef} + fieldSize={size} hasError={error} fullWidth={fullWidth} hasRightIcon={Boolean(rightIcon)} diff --git a/frontend/src/metabase/core/components/Input/types.ts b/frontend/src/metabase/core/components/Input/types.ts new file mode 100644 index 00000000000..cb47f8b08c3 --- /dev/null +++ b/frontend/src/metabase/core/components/Input/types.ts @@ -0,0 +1 @@ +export type InputSize = "small" | "medium"; diff --git a/frontend/src/metabase/core/components/NumericInput/NumericInput.tsx b/frontend/src/metabase/core/components/NumericInput/NumericInput.tsx index 7dd247fa749..78e12db5d9a 100644 --- a/frontend/src/metabase/core/components/NumericInput/NumericInput.tsx +++ b/frontend/src/metabase/core/components/NumericInput/NumericInput.tsx @@ -12,7 +12,7 @@ import Input from "metabase/core/components/Input"; export type NumericInputAttributes = Omit< InputHTMLAttributes<HTMLDivElement>, - "value" | "onChange" + "value" | "size" | "onChange" >; export interface NumericInputProps extends NumericInputAttributes { -- GitLab