feat:icon colors (#2928)

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Ruzenie
2026-04-27 21:12:55 +08:00
committed by GitHub
parent 6a8dd480cb
commit 118004b3d3
84 changed files with 1992 additions and 736 deletions
+75 -9
View File
@@ -1,18 +1,43 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
import ReactColorColorful from "@uiw/react-color-colorful";
import { useMemo } from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { cn } from "@/utils/style";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Separator } from "../ui/separator";
const presetColors = [
"rgba(0, 0, 0, 1)",
"rgba(231, 0, 11, 1)",
"rgba(245, 73, 0, 1)",
"rgba(225, 113, 0, 1)",
"rgba(208, 135, 0, 1)",
"rgba(94, 165, 0, 1)",
"rgba(0, 166, 62, 1)",
"rgba(0, 153, 102, 1)",
"rgba(0, 146, 184, 1)",
"rgba(0, 132, 209, 1)",
"rgba(21, 93, 252, 1)",
"rgba(79, 57, 246, 1)",
"rgba(127, 34, 254, 1)",
"rgba(200, 0, 222, 1)",
"rgba(230, 0, 118, 1)",
"rgba(69, 85, 108, 1)",
] as const;
type ColorPickerProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
trigger?: React.ReactNode;
children?: React.ReactNode;
};
export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps) {
export function ColorPicker({ value, defaultValue, onChange, trigger, children }: ColorPickerProps) {
const [currentValue, setCurrentValue] = useControlledState<string>({
value,
defaultValue,
@@ -28,15 +53,56 @@ export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps)
return (
<Popover>
<PopoverTrigger>
<div
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground transition-opacity hover:opacity-60"
style={{ backgroundColor: currentValue }}
/>
</PopoverTrigger>
{trigger ?? (
<PopoverTrigger>
<div
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
style={{ backgroundColor: currentValue }}
/>
</PopoverTrigger>
)}
<PopoverContent className="max-w-fit rounded-md p-2">
<ReactColorColorful color={color} onChange={onColorChange} />
<PopoverContent align="start" className="min-w-xs">
{children && (
<>
{children}
<Separator />
</>
)}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
<Trans>Presets</Trans>
</span>
<div className="grid grid-cols-8 gap-3 rounded bg-muted p-3">
{presetColors.map((color) => (
<button
key={color}
type="button"
title={color}
style={{ backgroundColor: color }}
aria-label={t`Use color ${color}`}
aria-pressed={currentValue === color}
onClick={() => setCurrentValue(color)}
className={cn(
"size-5 shrink-0 cursor-pointer rounded-full transition-all hover:scale-105 focus-visible:outline-hidden",
currentValue === color && "border border-foreground/60",
)}
/>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
<Trans>Custom</Trans>
</span>
<div className="rounded bg-muted p-3 *:w-full! [&_.w-color-alpha]:mt-4! [&_.w-color-alpha]:h-4! [&_.w-color-alpha>div]:rounded-full! [&_.w-color-hue]:mt-4! [&_.w-color-hue]:h-4! [&_.w-color-hue]:rounded-full! [&_.w-color-saturation]:h-36! [&_.w-color-saturation]:rounded-[calc(var(--radius-lg)-0.25rem)]!">
<ReactColorColorful color={color} onChange={onColorChange} />
</div>
</div>
</PopoverContent>
</Popover>
);
@@ -176,26 +176,3 @@
text-underline-offset: 0.15rem;
}
}
.color_picker {
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-muted);
padding: 0.5rem;
> :global(*) {
width: 100% !important;
}
:global(.w-color-saturation) {
height: 8.5rem !important;
border-radius: calc(var(--radius-lg) - 0.25rem) !important;
}
:global(.w-color-hue),
:global(.w-color-alpha) {
height: 0.75rem !important;
margin-top: 0.5rem !important;
border-radius: 9999px !important;
}
}
+53 -112
View File
@@ -52,8 +52,6 @@ import {
useEditorState,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
import ReactColorColorful from "@uiw/react-color-colorful";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
@@ -69,37 +67,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
import { usePrompt } from "@/hooks/use-prompt";
import { isRTL } from "@/utils/locale";
import { sanitizeHtml } from "@/utils/sanitize";
import { cn } from "@/utils/style";
import { Toggle } from "../ui/toggle";
import { ColorPicker } from "./color-picker";
import styles from "./rich-input.module.css";
const defaultTextColor = "rgba(0, 0, 0, 1)";
const richTextColorOptions = [
"rgba(0, 0, 0, 1)",
"rgba(231, 0, 11, 1)",
"rgba(245, 73, 0, 1)",
"rgba(225, 113, 0, 1)",
"rgba(208, 135, 0, 1)",
"rgba(94, 165, 0, 1)",
"rgba(0, 166, 62, 1)",
"rgba(0, 153, 102, 1)",
"rgba(0, 146, 184, 1)",
"rgba(0, 132, 209, 1)",
"rgba(21, 93, 252, 1)",
"rgba(79, 57, 246, 1)",
"rgba(127, 34, 254, 1)",
"rgba(200, 0, 222, 1)",
"rgba(230, 0, 118, 1)",
"rgba(69, 85, 108, 1)",
] as const;
const extensions = [
StarterKit.configure({
heading: {
@@ -461,103 +440,65 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<HighlighterCircleIcon className="size-3.5" />
</Toggle>
<Popover>
<PopoverTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
title={t`Text Color`}
disabled={!state.canTextColor}
>
<span className="flex flex-col items-center leading-none">
<span className="text-xs font-semibold">A</span>
<span
className="mt-0.5 h-0.5 w-3 rounded-full"
style={{ backgroundColor: state.textColor ?? "currentColor" }}
/>
</span>
</Button>
}
/>
<PopoverContent sideOffset={8} className="w-80 gap-3 rounded-xl p-3 shadow-xl">
<PopoverHeader className="flex-row items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm font-semibold shadow-xs"
style={{ color: state.textColor ?? "currentColor" }}
<ColorPicker
defaultValue={defaultTextColor}
value={state.textColor ?? undefined}
onChange={state.setTextColor}
trigger={
<PopoverTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
title={t`Text Color`}
disabled={!state.canTextColor}
>
A
</span>
<div className="flex flex-col gap-0.5">
<PopoverTitle>
<Trans>Text Color</Trans>
</PopoverTitle>
<span className="text-xs text-muted-foreground">
<Trans comment="Preset or custom shade refer to the color picker">
Choose a preset or custom shade.
</Trans>
<span className="flex flex-col items-center leading-none">
<span className="text-xs font-semibold">A</span>
<span
className="mt-0.5 h-0.5 w-3 rounded-full"
style={{ backgroundColor: state.textColor ?? "currentColor" }}
/>
</span>
</div>
</div>
<Button
size="xs"
variant="ghost"
className="shrink-0"
onClick={state.unsetTextColor}
disabled={!state.textColor}
</Button>
}
/>
}
>
<PopoverHeader className="flex-row items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<span
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm font-semibold shadow-xs"
style={{ color: state.textColor ?? "currentColor" }}
>
<Trans comment="Clear the text color">Clear</Trans>
</Button>
</PopoverHeader>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
<Trans>Presets</Trans>
A
</span>
<div className="grid grid-cols-8 gap-2 rounded-lg bg-muted/40 p-2">
{richTextColorOptions.map((color) => (
<button
key={color}
type="button"
className={cn(
"size-7 rounded-full border border-border shadow-xs ring-offset-background transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden",
state.textColor === color && "ring-2 ring-ring ring-offset-2",
)}
style={{ backgroundColor: color }}
title={color}
aria-label={t`Use color ${color}`}
aria-pressed={state.textColor === color}
onClick={() => state.setTextColor(color)}
/>
))}
<div className="flex flex-col gap-0.5">
<PopoverTitle>
<Trans>Text Color</Trans>
</PopoverTitle>
<span className="text-xs text-muted-foreground">
<Trans comment="Preset or custom shade refer to the color picker">
Choose a preset or custom shade.
</Trans>
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-muted-foreground">
<Trans>Custom</Trans>
</span>
<div className={styles.color_picker}>
<ReactColorColorful
color={rgbaStringToHsva(state.textColor ?? defaultTextColor)}
onChange={(color: ColorResult) => {
state.setTextColor(hsvaToRgbaString(color.hsva));
}}
/>
</div>
</div>
</PopoverContent>
</Popover>
<Button
size="xs"
variant="ghost"
className="shrink-0"
onClick={state.unsetTextColor}
disabled={!state.textColor}
>
<Trans comment="Clear the text color">Clear</Trans>
</Button>
</PopoverHeader>
</ColorPicker>
<div className="mx-1 h-5 w-px bg-border" />
@@ -13,7 +13,7 @@ export function InterestsItem({ className, ...item }: InterestsItemProps) {
<div className={cn("interests-item", className)}>
{/* Header */}
<div className="section-item-header interests-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon" />
<PageIcon icon={item.icon} color={item.iconColor} className="section-item-icon interests-item-icon" />
<strong className="section-item-title interests-item-name">{item.name}</strong>
</div>
@@ -15,7 +15,7 @@ export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
<div className={cn("profiles-item", className)}>
{/* Header */}
<div className="section-item-header profiles-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon" />
<PageIcon icon={item.icon} color={item.iconColor} className="section-item-icon profiles-item-icon" />
<LinkedTitle
title={item.network}
website={item.website}
@@ -14,7 +14,7 @@ export function SkillsItem({ className, ...item }: SkillsItemProps) {
<div className={cn("skills-item", className)}>
{/* Header */}
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon" />
<PageIcon icon={item.icon} color={item.iconColor} className="section-item-icon skills-item-icon" />
<strong className="section-item-title skills-item-name">{item.name}</strong>
</div>
+2 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/utils/style";
import type { ExtendedIconProps } from "../preview";
export function PageIcon({ icon, className }: { icon: string; className?: string }) {
export function PageIcon({ icon, color, className }: { icon: string; color?: string; className?: string }) {
const iconContext = use<ExtendedIconProps>(IconContext);
if (!icon || iconContext.hidden) return null;
@@ -13,7 +13,7 @@ export function PageIcon({ icon, className }: { icon: string; className?: string
return (
<i
className={cn("ph shrink-0", `ph-${icon}`, className)}
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
style={{ fontSize: `${iconContext.size}px`, color: color || iconContext.color }}
/>
);
}
+11 -1
View File
@@ -115,6 +115,7 @@ describe("useResumeStore — updateResumeData", () => {
id: "s1",
hidden: false,
icon: "",
iconColor: "",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
@@ -246,7 +247,16 @@ describe("useResumeStore — edge cases", () => {
it("can remove all items from a section", () => {
const resume = makeResume();
resume.data.sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
{
id: "s1",
hidden: false,
icon: "",
iconColor: "",
name: "JS",
proficiency: "",
level: 0,
keywords: [],
},
];
useResumeStore.getState().initialize(resume);
+1 -1
View File
@@ -99,7 +99,7 @@ function ComboboxContent({
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-60 origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"group/combobox-content relative max-h-(--available-height) max-w-fit min-w-(--anchor-width) origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
+1 -1
View File
@@ -44,7 +44,7 @@ function ContextMenuContent({
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"z-50 max-h-(--available-height) max-w-fit min-w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
+2 -1
View File
@@ -37,7 +37,8 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
"z-50 max-h-(--available-height) max-w-fit min-w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:duration-0 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 motion-reduce:data-open:animate-none data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:data-closed:animate-none",
className,
)}
{...props}