feat: UI color picker available in icon picker (#10696)

* wip

* fix: Mobile treatment

* Debounce SwatchButton onSelect
This commit is contained in:
Tom Moor
2025-11-23 16:47:28 +01:00
committed by GitHub
parent 2abc1e97ae
commit 1e36b459dc
5 changed files with 168 additions and 205 deletions
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react";
import styled from "styled-components";
import NudeButton from "./NudeButton";
import { hover, s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
};
export const ColorButton = React.forwardRef(
(
{ color, active = false, size = 24, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => (
<ColorButtonInternal
$active={active}
$size={size}
{...rest}
style={{ "--color": color, ...rest.style } as React.CSSProperties}
ref={ref}
>
<Selected />
</ColorButtonInternal>
)
);
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButtonInternal = styled(NudeButton)<{
$active: boolean;
$size: number;
}>`
display: inline-flex;
justify-content: center;
align-items: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
background: var(
--color,
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
);
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: 0px 0px 3px 3px var(--color);
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
@@ -1,17 +1,11 @@
import { BackIcon } from "outline-icons";
import * as React from "react";
import debounce from "lodash/debounce";
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import { validateColorHex } from "@shared/utils/color";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
enum Panel {
Builtin,
Hex,
}
import { SwatchButton } from "~/components/SwatchButton";
import { ColorButton } from "~/components/ColorButton";
type Props = {
width: number;
@@ -19,128 +13,77 @@ type Props = {
onSelect: (color: string) => void;
};
const ColorPicker = ({ width, activeColor, onSelect }: Props) => {
const [localValue, setLocalValue] = React.useState(activeColor);
const ColorPicker = ({ activeColor, onSelect }: Props) => {
const [selectedColor, setSelectedColor] = React.useState(activeColor);
const isBuiltInColor = colorPalette.includes(selectedColor);
const color = isBuiltInColor ? undefined : selectedColor;
const [panel, setPanel] = React.useState(
colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex
const debouncedOnSelect = React.useMemo(
() =>
debounce((color: string) => {
onSelect(color);
}, 250),
[onSelect]
);
const handleSwitcherClick = React.useCallback(() => {
setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin);
}, [panel, setPanel]);
const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding
React.useEffect(
() => () => {
debouncedOnSelect.cancel();
},
[debouncedOnSelect]
);
React.useEffect(() => {
setLocalValue(activeColor);
setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex);
setSelectedColor(activeColor);
}, [activeColor]);
return isLargeMobile ? (
<Container justify="space-between">
<LargeMobileBuiltinColors activeColor={activeColor} onClick={onSelect} />
<LargeMobileCustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
const handleSelect = (color: string) => {
setSelectedColor(color);
debouncedOnSelect(color);
};
return (
<BuiltinColors activeColor={selectedColor} onClick={handleSelect}>
<Divider />
<SwatchButton
color={color}
active={!isBuiltInColor}
onChange={handleSelect}
pickerInModal
/>
</Container>
) : (
<Container gap={12}>
<PanelSwitcher align="center">
<SwitcherButton panel={panel} onClick={handleSwitcherClick}>
{panel === Panel.Builtin ? "#" : <BackIcon />}
</SwitcherButton>
</PanelSwitcher>
{panel === Panel.Builtin ? (
<BuiltinColors activeColor={activeColor} onClick={onSelect} />
) : (
<CustomColor
value={localValue}
setLocalValue={setLocalValue}
onValidHex={onSelect}
/>
)}
</Container>
</BuiltinColors>
);
};
const Divider = styled.div`
width: 1px;
height: 24px;
background-color: ${s("inputBorder")};
`;
const BuiltinColors = ({
activeColor,
onClick,
className,
children,
}: {
activeColor: string;
onClick: (color: string) => void;
className?: string;
children?: React.ReactNode;
}) => (
<Flex className={className} justify="space-between" align="center" auto>
<Container className={className} justify="space-between" align="center" auto>
{colorPalette.map((color) => (
<ColorButton
key={color}
$color={color}
$active={color === activeColor}
color={color}
active={color === activeColor}
onClick={() => onClick(color)}
>
<Selected />
</ColorButton>
))}
</Flex>
);
const CustomColor = ({
value,
setLocalValue,
onValidHex,
className,
}: {
value: string;
setLocalValue: (value: string) => void;
onValidHex: (color: string) => void;
className?: string;
}) => {
const hasHexChars = React.useCallback(
(color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color),
[]
);
const handleInputChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const val = ev.target.value;
if (val === "" || val === "#") {
setLocalValue("#");
return;
}
const uppercasedVal = val.toUpperCase();
if (hasHexChars(uppercasedVal)) {
setLocalValue(uppercasedVal);
}
if (validateColorHex(uppercasedVal)) {
onValidHex(uppercasedVal);
}
},
[setLocalValue, hasHexChars, onValidHex]
);
return (
<Flex className={className} align="center" gap={8}>
<Text type="tertiary" size="small">
HEX
</Text>
<CustomColorInput
maxLength={7}
value={value}
onChange={handleInputChange}
autoFocus
/>
</Flex>
);
};
))}
{children}
</Container>
);
const Container = styled(Flex)`
height: 48px;
@@ -148,71 +91,4 @@ const Container = styled(Flex)`
border-bottom: 1px solid ${s("inputBorder")};
`;
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>`
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: ${({ $color }) => $color};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`};
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
const PanelSwitcher = styled(Flex)`
width: 40px;
border-right: 1px solid ${s("inputBorder")};
`;
const SwitcherButton = styled(NudeButton)<{ panel: Panel }>`
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 14px;
border: 1px solid ${s("inputBorder")};
transition: all 100ms ease-in-out;
&: ${hover} {
border-color: ${s("inputBorderFocused")};
}
`;
const LargeMobileBuiltinColors = styled(BuiltinColors)`
max-width: 400px;
padding-right: 8px;
`;
const LargeMobileCustomColor = styled(CustomColor)`
padding-left: 8px;
border-left: 1px solid ${s("inputBorder")};
width: 120px;
`;
const CustomColorInput = styled.input.attrs(() => ({
type: "text",
autocomplete: "off",
}))`
font-size: 14px;
color: ${s("textSecondary")};
background: transparent;
border: 0;
outline: 0;
`;
export default ColorPicker;
+3 -2
View File
@@ -27,13 +27,14 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => (
maxLength={7}
{...rest}
/>
<PositionedSwatchButton color={value} onChange={onChange} />
<PositionedSwatchButton color={value} onChange={onChange} size={22} />
</Relative>
);
const PositionedSwatchButton = styled(SwatchButton)`
border: 1px solid ${(props) => props.theme.inputBorder};
position: absolute;
bottom: 20px;
bottom: 21px;
right: 6px;
`;
+51 -28
View File
@@ -3,17 +3,23 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useMobile from "~/hooks/useMobile";
import DelayedMount from "./DelayedMount";
import NudeButton from "./NudeButton";
import { Drawer, DrawerContent, DrawerTrigger } from "./primitives/Drawer";
import { Popover, PopoverTrigger, PopoverContent } from "./primitives/Popover";
import Text from "./Text";
import { ColorButton } from "./ColorButton";
/**
* Props for the SwatchButton component.
*/
type SwatchButtonProps = {
/** The current color value in hex format */
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the swatch button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
/** Callback function invoked when the color is changed */
onChange: (color: string) => void;
/** Additional CSS class name to apply to the button */
@@ -24,51 +30,67 @@ type SwatchButtonProps = {
export const SwatchButton: React.FC<SwatchButtonProps> = ({
color,
active = false,
size = 24,
onChange,
className,
pickerInModal = true,
}) => {
const { t } = useTranslation();
const isMobile = useMobile();
const pickerTrigger = (
<ColorButton
aria-label={t("Select a color")}
className={className}
color={color}
active={active}
size={size}
/>
);
const pickerContent = (
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={color}
onChange={(c) => onChange(c.hex)}
/>
</React.Suspense>
);
if (isMobile) {
return (
<Drawer>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerContent aria-label={t("Select a color")}>
{pickerContent}
</DrawerContent>
</Drawer>
);
}
return (
<Popover modal={pickerInModal}>
<PopoverTrigger>
<StyledSwatchButton
aria-label={t("Select a color")}
className={className}
style={{ background: color }}
/>
</PopoverTrigger>
<PopoverTrigger>{pickerTrigger}</PopoverTrigger>
<StyledContent
side="bottom"
align="end"
aria-label={t("Select a color")}
shrink
>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={color}
onChange={(c) => onChange(c.hex)}
/>
</React.Suspense>
{pickerContent}
</StyledContent>
</Popover>
);
};
const StyledSwatchButton = styled(NudeButton)`
background: ${s("menuBackground")};
border: 1px solid ${s("inputBorder")};
border-radius: 50%;
`;
const StyledContent = styled(PopoverContent)`
width: auto;
padding: 8px;
@@ -84,6 +106,7 @@ const StyledColorPicker = styled(ColorPicker)`
border: 0 !important;
border-radius: 0 !important;
user-select: none;
width: auto !important;
input {
user-select: text;
@@ -5,7 +5,7 @@ export const Overlay = styled.div`
position: fixed;
inset: 0;
background: ${s("backdrop")};
z-index: ${depths.overlay};
z-index: ${depths.menu};
transition: opacity 50ms ease-in-out;
opacity: 0;