diff --git a/app/components/ColorButton.tsx b/app/components/ColorButton.tsx new file mode 100644 index 0000000000..1e59be2b9a --- /dev/null +++ b/app/components/ColorButton.tsx @@ -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 & { + /** 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 + ) => ( + + + + ) +); + +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")}; + } +`; diff --git a/app/components/IconPicker/components/ColorPicker.tsx b/app/components/IconPicker/components/ColorPicker.tsx index 115cce12ae..2d08d0654b 100644 --- a/app/components/IconPicker/components/ColorPicker.tsx +++ b/app/components/IconPicker/components/ColorPicker.tsx @@ -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 ? ( - - - { + setSelectedColor(color); + debouncedOnSelect(color); + }; + + return ( + + + - - ) : ( - - - - {panel === Panel.Builtin ? "#" : } - - - {panel === Panel.Builtin ? ( - - ) : ( - - )} - + ); }; +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; }) => ( - + {colorPalette.map((color) => ( onClick(color)} - > - - - ))} - -); - -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) => { - 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 ( - - - HEX - - - - ); -}; + ))} + {children} + +); 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; diff --git a/app/components/InputColor.tsx b/app/components/InputColor.tsx index 1444d777e7..429e9c3b88 100644 --- a/app/components/InputColor.tsx +++ b/app/components/InputColor.tsx @@ -27,13 +27,14 @@ const InputColor: React.FC = ({ value, onChange, ...rest }: Props) => ( maxLength={7} {...rest} /> - + ); const PositionedSwatchButton = styled(SwatchButton)` + border: 1px solid ${(props) => props.theme.inputBorder}; position: absolute; - bottom: 20px; + bottom: 21px; right: 6px; `; diff --git a/app/components/SwatchButton.tsx b/app/components/SwatchButton.tsx index 835180fcda..ba80a8a37b 100644 --- a/app/components/SwatchButton.tsx +++ b/app/components/SwatchButton.tsx @@ -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 = ({ color, + active = false, + size = 24, onChange, className, pickerInModal = true, }) => { const { t } = useTranslation(); + const isMobile = useMobile(); + + const pickerTrigger = ( + + ); + + const pickerContent = ( + + {t("Loading")}… + + } + > + onChange(c.hex)} + /> + + ); + + if (isMobile) { + return ( + + {pickerTrigger} + + {pickerContent} + + + ); + } return ( - - - + {pickerTrigger} - - {t("Loading")}… - - } - > - onChange(c.hex)} - /> - + {pickerContent} ); }; -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; diff --git a/app/components/primitives/components/Overlay.tsx b/app/components/primitives/components/Overlay.tsx index c1b28700be..4112c9ce2a 100644 --- a/app/components/primitives/components/Overlay.tsx +++ b/app/components/primitives/components/Overlay.tsx @@ -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;