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