mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: UI color picker available in icon picker (#10696)
* wip * fix: Mobile treatment * Debounce SwatchButton onSelect
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user