Icon picker glow-up (#13177)

* Icon panel glow-up

* Move colorPalette to shared/constants
This commit is contained in:
Tom Moor
2026-07-28 08:47:09 -04:00
committed by GitHub
parent b377c2cf96
commit c5f9646337
20 changed files with 303 additions and 62 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Button from "~/components/Button";
+8 -3
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import styled from "styled-components";
import styled, { css } from "styled-components";
import NudeButton from "./NudeButton";
import { hover, s } from "@shared/styles";
@@ -29,6 +29,9 @@ export const ColorButton = React.forwardRef(
)
);
/** Highlight for the hovered or selected swatch, falls back for the gradient. */
const ring = css`2px solid var(--color, ${s("textTertiary")})`;
const Selected = styled.span`
width: 10px;
height: 5px;
@@ -52,9 +55,11 @@ const ColorButtonInternal = styled(NudeButton)<{
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
);
outline-offset: 1px;
outline: ${({ $active }) => ($active ? ring : "none")};
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: 0px 0px 3px 3px var(--color);
outline: ${ring} !important;
}
& ${Selected} {
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { toError } from "@shared/utils/error";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
@@ -8,8 +8,9 @@ import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import type { DataNode, EmojiNode } from "./GridTemplate";
import type { DataNode, EmojiNode, IconNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import { IconPreview, PREVIEW_HEIGHT } from "./IconPreview";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
@@ -136,6 +137,20 @@ const EmojiPanel = ({
});
}, [emojis, getFrequentCustomIcons]);
const [activeEmoji, setActiveEmoji] = React.useState<EmojiNode>();
const [hasMoreBelow, setHasMoreBelow] = React.useState(false);
const handleEmojiActive = React.useCallback((icon: IconNode) => {
if (icon.type !== IconType.SVG) {
setActiveEmoji(icon);
}
}, []);
// Drop the preview when the grid contents change out from under it.
React.useEffect(() => {
setActiveEmoji(undefined);
}, [query, skinTone]);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
@@ -180,9 +195,11 @@ const EmojiPanel = ({
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
height={height - 48 - PREVIEW_HEIGHT}
data={templateData}
onIconSelect={handleEmojiSelection}
onIconActive={handleEmojiActive}
onOverflowChange={setHasMoreBelow}
empty={
<IconButton
onClick={handleUploadClick}
@@ -192,10 +209,20 @@ const EmojiPanel = ({
</IconButton>
}
/>
<IconPreview
icon={activeEmoji ?? firstIcon(templateData)}
showFade={hasMoreBelow}
/>
</Flex>
);
};
/** The first icon shown in the grid, previewed until the user hovers another. */
const firstIcon = (data: DataNode[]): EmojiNode | undefined => {
const icon = data.find((node) => node.icons.length)?.icons[0];
return icon && icon.type !== IconType.SVG ? icon : undefined;
};
const getSearchResults = ({
query,
skinTone,
@@ -218,6 +245,7 @@ const getSearchResults = ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
name: emoji.name,
})),
];
@@ -251,6 +279,7 @@ const getAllEmojis = ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
name: emoji.name,
})),
...freqCustomEmojis,
];
@@ -269,6 +298,7 @@ const getAllEmojis = ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
name: emoji.name,
})),
};
};
+35 -13
View File
@@ -9,23 +9,45 @@ type Props = {
data: React.ReactNode[][];
columns: number;
itemWidth: number;
onOverflowChange?: (hasMoreBelow: boolean) => void;
};
const Grid = (
{ width, height, data, columns, itemWidth }: Props,
{ width, height, data, columns, itemWidth, onOverflowChange }: Props,
ref: React.Ref<HTMLDivElement>
) => (
<Container
outerRef={ref}
width={width}
height={height}
itemCount={data.length}
itemSize={itemWidth}
itemData={{ data, columns }}
>
{Row}
</Container>
);
) => {
const offsetRef = React.useRef(0);
const maxOffset = Math.max(0, data.length * itemWidth - height);
const notify = React.useCallback(() => {
onOverflowChange?.(offsetRef.current < maxOffset - 1);
}, [onOverflowChange, maxOffset]);
// The amount of scrollable content changes as rows are added or removed.
React.useEffect(notify, [notify]);
const handleScroll = React.useCallback(
({ scrollOffset }: { scrollOffset: number }) => {
offsetRef.current = scrollOffset;
notify();
},
[notify]
);
return (
<Container
outerRef={ref}
width={width}
height={height}
itemCount={data.length}
itemSize={itemWidth}
itemData={{ data, columns }}
onScroll={handleScroll}
>
{Row}
</Container>
);
};
type RowProps = {
data: React.ReactNode[][];
@@ -21,7 +21,7 @@ const BUTTON_SIZE_MOBILE = 40;
const ICON_SIZE_DESKTOP = 24;
const ICON_SIZE_MOBILE = 32;
type OutlineNode = {
export type OutlineNode = {
type: IconType.SVG;
name: string;
color: string;
@@ -36,9 +36,11 @@ export type EmojiNode = {
name?: string;
};
export type IconNode = OutlineNode | EmojiNode;
export type DataNode = {
category: keyof typeof TRANSLATED_CATEGORIES;
icons: (OutlineNode | EmojiNode)[];
icons: IconNode[];
};
type Props = {
@@ -52,10 +54,22 @@ type Props = {
empty?: React.ReactNode;
/** Callback when an icon is selected */
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
/** Callback when an icon is hovered or focused */
onIconActive?: (icon: IconNode) => void;
/** Callback when whether the grid has content below the fold changes */
onOverflowChange?: (hasMoreBelow: boolean) => void;
};
const GridTemplate = (
{ width, height, data, empty, onIconSelect }: Props,
{
width,
height,
data,
empty,
onIconSelect,
onIconActive,
onOverflowChange,
}: Props,
ref: React.Ref<HTMLDivElement>
) => {
const isMobile = useMobile();
@@ -85,11 +99,15 @@ const GridTemplate = (
}
const items = node.icons.map((item) => {
const handleActive = () => onIconActive?.(item);
if (item.type === IconType.SVG) {
return (
<IconButton
key={item.name}
onClick={() => onIconSelect({ id: item.name, value: item.name })}
onMouseEnter={handleActive}
onFocus={handleActive}
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
>
<Icon
@@ -107,6 +125,8 @@ const GridTemplate = (
<IconButton
key={item.id}
onClick={() => onIconSelect({ id: item.id, value: item.value })}
onMouseEnter={handleActive}
onFocus={handleActive}
>
<Emoji
width={iconSize}
@@ -136,6 +156,7 @@ const GridTemplate = (
data={gridItems}
columns={itemsPerRow}
itemWidth={buttonSize}
onOverflowChange={onOverflowChange}
/>
);
};
@@ -1,11 +1,11 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import Flex from "~/components/Flex";
import { colorPalette } from "@shared/constants";
import { SwatchButton } from "~/components/SwatchButton";
import { ColorButton } from "~/components/ColorButton";
const SWATCH_SIZE = 20;
type Props = {
width: number;
activeColor: string;
@@ -27,12 +27,12 @@ const IconColorPicker = ({ activeColor, onSelect }: Props) => {
};
return (
<Container justify="space-between" align="center" auto>
<Container>
<PresetColors activeColor={selectedColor} onClick={handleSelect} />
<Divider />
<SwatchButton
color={color}
active={!isBuiltInColor}
size={SWATCH_SIZE}
onChange={handleSelect}
pickerInModal
/>
@@ -40,12 +40,6 @@ const IconColorPicker = ({ activeColor, onSelect }: Props) => {
);
};
const Divider = styled.div`
width: 1px;
height: 24px;
background-color: ${s("inputBorder")};
`;
const PresetColors = ({
activeColor,
onClick,
@@ -59,16 +53,22 @@ const PresetColors = ({
key={color}
color={color}
active={color === activeColor}
size={SWATCH_SIZE}
onClick={() => onClick(color)}
/>
))}
</>
);
const Container = styled(Flex)`
// One column per preset color plus the custom swatch, matching the horizontal
// rhythm of the icon grid below.
const Container = styled.div`
display: grid;
grid-template-columns: repeat(${colorPalette.length + 1}, 1fr);
align-items: center;
justify-items: center;
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid ${s("inputBorder")};
`;
export default IconColorPicker;
@@ -7,8 +7,9 @@ import Flex from "~/components/Flex";
import InputSearch from "~/components/InputSearch";
import { DisplayCategory } from "../utils";
import IconColorPicker from "./IconColorPicker";
import type { DataNode } from "./GridTemplate";
import type { DataNode, IconNode } from "./GridTemplate";
import GridTemplate from "./GridTemplate";
import { IconPreview, PREVIEW_HEIGHT } from "./IconPreview";
import { useIconState } from "../useIconState";
const IconNames = Object.keys(IconLibrary.mapping);
@@ -18,7 +19,7 @@ const TotalIcons = IconNames.length;
* This is needed as a constant for react-window.
* Calculated from the heights of TabPanel, ColorPicker and InputSearch.
*/
const GRID_HEIGHT = 314;
const GRID_HEIGHT = 314 - PREVIEW_HEIGHT;
type Props = {
panelWidth: number;
@@ -75,6 +76,24 @@ const IconPanel = ({
[onIconChange, incrementIconCount]
);
const [activeIcon, setActiveIcon] = React.useState<string>();
const [hasMoreBelow, setHasMoreBelow] = React.useState(false);
const handleIconActive = React.useCallback((icon: IconNode) => {
if (icon.type === IconType.SVG) {
setActiveIcon(icon.name);
}
}, []);
React.useEffect(() => {
setActiveIcon(undefined);
}, [query]);
// Preview the first icon shown in the grid until the user hovers another.
const previewIcon =
activeIcon ??
(isSearch ? filteredIcons[0] : (freqIcons[0] ?? filteredIcons[0]));
const baseIcons: DataNode = {
category,
icons: filteredIcons.map((name, index) => ({
@@ -133,6 +152,22 @@ const IconPanel = ({
height={GRID_HEIGHT}
data={templateData}
onIconSelect={handleIconSelection}
onIconActive={handleIconActive}
onOverflowChange={setHasMoreBelow}
/>
<IconPreview
showFade={hasMoreBelow}
icon={
previewIcon
? {
type: IconType.SVG,
name: previewIcon,
color,
initial,
delay: 0,
}
: undefined
}
/>
</Flex>
);
@@ -0,0 +1,117 @@
import { startCase } from "es-toolkit/compat";
import styled from "styled-components";
import { s } from "@shared/styles";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { CustomEmoji } from "@shared/components/CustomEmoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import type { IconNode } from "./GridTemplate";
/** Height of the preview bar, used to size the grid above it. */
export const PREVIEW_HEIGHT = 56;
const GLYPH_SIZE = 28;
type Props = {
/** The icon or emoji to preview, nothing is rendered when empty */
icon?: IconNode;
/** Whether the grid above has content below the fold, fades the last row */
showFade?: boolean;
};
/**
* Displays a large preview of the hovered icon or emoji alongside its name and
* shortcode.
*/
export const IconPreview = ({ icon, showFade }: Props) => (
<Container align="center" gap={12} $showFade={showFade}>
{icon && (
<>
<Glyph justify="center" align="center">
{icon.type === IconType.SVG ? (
<Icon
as={IconLibrary.getComponent(icon.name)}
color={icon.color}
size={GLYPH_SIZE}
>
{icon.initial}
</Icon>
) : (
<Emoji size={GLYPH_SIZE}>
{icon.type === IconType.Custom ? (
<CustomEmoji value={icon.value} title={icon.name} />
) : (
icon.value
)}
</Emoji>
)}
</Glyph>
<Labels column>
<Name weight="bold" ellipsis>
{displayName(icon)}
</Name>
<Text type="tertiary" size="xsmall" monospace ellipsis>
:{shortcode(icon)}:
</Text>
</Labels>
</>
)}
</Container>
);
const displayName = (icon: IconNode) =>
icon.type === IconType.SVG
? startCase(icon.name)
: startCase(icon.name ?? "");
const shortcode = (icon: IconNode) => {
if (icon.type === IconType.SVG) {
return icon.name;
}
return icon.type === IconType.Custom ? (icon.name ?? icon.value) : icon.id;
};
const Container = styled(Flex)<{ $showFade?: boolean }>`
position: relative;
flex-shrink: 0;
height: ${PREVIEW_HEIGHT}px;
padding: 0 16px;
background: ${s("menuBackground")};
// Offsets the bottom padding of the containing popover.
margin-bottom: -6px;
// Fades out the last row of the grid while there's more to scroll.
&:before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 24px;
pointer-events: none;
opacity: ${({ $showFade }) => ($showFade ? 1 : 0)};
transition: opacity 150ms ease-in-out;
background: linear-gradient(to bottom, transparent, ${s("menuBackground")});
}
`;
const Name = styled(Text)`
font-size: 15px;
`;
const Glyph = styled(Flex)`
width: ${GLYPH_SIZE}px;
height: ${GLYPH_SIZE}px;
flex-shrink: 0;
`;
const Labels = styled(Flex)`
min-width: 0;
`;
const Icon = styled.svg`
flex-shrink: 0;
`;
+1 -1
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import type Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
+12 -3
View File
@@ -20,6 +20,12 @@ const EmojiPanel = createLazyComponent(
() => import("~/components/IconPicker/components/EmojiPanel")
);
/** Fits nine 32px emoji per row, with 12px of padding on either side. */
const PANEL_WIDTH = 332;
/** Leaves room for two more 32px rows than the panel's minimum. */
const PANEL_HEIGHT = 364;
type Props = {
/** Callback when an emoji is selected by the user. */
onSelect: (emoji: string) => Promise<void>;
@@ -37,7 +43,7 @@ const ReactionPicker: React.FC<Props> = ({ onSelect, className, size }) => {
const [query, setQuery] = React.useState("");
const popoverWidth = isMobile ? windowWidth : 300;
const popoverWidth = isMobile ? windowWidth : PANEL_WIDTH;
// In mobile, popover is absolutely positioned to leave 8px on both sides.
const panelWidth = isMobile ? windowWidth - 16 : popoverWidth;
@@ -79,7 +85,7 @@ const ReactionPicker: React.FC<Props> = ({ onSelect, className, size }) => {
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel.Component
height={300}
height={PANEL_HEIGHT}
panelWidth={panelWidth}
query={query}
onEmojiChange={handleEmojiSelect}
@@ -96,7 +102,10 @@ const ReactionPicker: React.FC<Props> = ({ onSelect, className, size }) => {
const Placeholder = React.memo(
() => (
<VStack spacing={6} style={{ height: "300px", padding: "6px 12px" }}>
<VStack
spacing={6}
style={{ height: `${PANEL_HEIGHT}px`, padding: "6px 12px" }}
>
<HStack>
<PlaceholderText height={32} minWidth={90} />
<PlaceholderText height={32} width={32} />
+1 -1
View File
@@ -9,7 +9,7 @@ import Heading from "~/components/Heading";
import ContentEditable from "~/components/ContentEditable";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type Collection from "~/models/Collection";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import usePolicy from "~/hooks/usePolicy";
import { observer } from "mobx-react";
import lazyWithRetry from "~/utils/lazyWithRetry";
+1 -1
View File
@@ -6,7 +6,7 @@ import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import Text from "@shared/components/Text";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import Comment from "~/models/Comment";
import type Document from "~/models/Document";
import type Template from "~/models/Template";
@@ -10,7 +10,7 @@ import { s, depths, hover } from "@shared/styles";
import { cloneDeep } from "es-toolkit/compat";
import type { ProsemirrorData } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import Editor from "~/components/Editor";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import type { Props as EditorProps } from "~/components/Editor";
@@ -1,7 +1,7 @@
import { FetchError } from "node-fetch";
import { Op, QueryTypes } from "sequelize";
import { toError } from "@shared/utils/error";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import WebhookDisabledEmail from "@server/emails/templates/WebhookDisabledEmail";
import env from "@server/env";
import Logger from "@server/logging/Logger";
+1 -1
View File
@@ -23,7 +23,7 @@ import {
ImportTaskState,
MentionType,
} from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import { colorPalette } from "@shared/constants";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { CollectionValidation } from "@shared/validations";
import { createContext } from "@server/context";
+1 -1
View File
@@ -5,7 +5,7 @@ import breakpoint from "styled-components-breakpoint";
import useStores from "../hooks/useStores";
import { IconType } from "../types";
import { IconLibrary } from "../utils/IconLibrary";
import { colorPalette } from "../utils/collections";
import { colorPalette } from "../constants";
import { determineIconType } from "../utils/icon";
import EmojiIcon from "./EmojiIcon";
import Flex from "./Flex";
+15
View File
@@ -10,6 +10,21 @@ import {
export const MAX_AVATAR_DISPLAY = 6;
/** Preset colors offered when choosing an icon color. */
export const colorPalette = [
"#4E5C6E",
"#0366D6",
"#2BC2FF",
"#9E5CF7",
"#FF825C",
"#FF5C80",
"#FFBE0B",
"#42DED1",
"#00D084",
"#FF4DFA",
"#2F362F",
];
export const Pagination = {
defaultLimit: 25,
defaultOffset: 0,
-13
View File
@@ -40,16 +40,3 @@ export const sortNavigationNodes = (
return { ...node, children: sortedChildren };
});
};
export const colorPalette = [
"#4E5C6E",
"#0366D6",
"#9E5CF7",
"#FF825C",
"#FF5C80",
"#FFBE0B",
"#42DED1",
"#00D084",
"#FF4DFA",
"#2F362F",
];