Merge pull request #47 from refactoringhq/task/icon-picker-sezioni
feat: expand icon picker to 290 Phosphor icons with search and scroll
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from './TypeCustomizePopover'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import { useSectionVisibility } from '../hooks/useSectionVisibility'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TypeCustomizePopover, resolveIcon, ICON_OPTIONS } from './TypeCustomizePopover'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import { resolveIcon, ICON_OPTIONS } from '../utils/iconRegistry'
|
||||
|
||||
describe('resolveIcon', () => {
|
||||
it('returns the correct icon component for known name', () => {
|
||||
const Icon = resolveIcon('wrench')
|
||||
expect(Icon).toBeDefined()
|
||||
expect(Icon).not.toBe(ICON_OPTIONS[0].Icon) // should not be fallback (file-text)
|
||||
// wrench should not be the default fallback (file-text)
|
||||
const fileTextIcon = resolveIcon('file-text')
|
||||
expect(Icon).not.toBe(fileTextIcon)
|
||||
})
|
||||
|
||||
it('returns FileText fallback for null', () => {
|
||||
const Icon = resolveIcon(null)
|
||||
// FileText is the default
|
||||
expect(Icon).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -21,6 +23,23 @@ describe('resolveIcon', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ICON_OPTIONS', () => {
|
||||
it('contains 200+ icons', () => {
|
||||
expect(ICON_OPTIONS.length).toBeGreaterThanOrEqual(200)
|
||||
})
|
||||
|
||||
it('has unique names', () => {
|
||||
const names = ICON_OPTIONS.map((o) => o.name)
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
})
|
||||
|
||||
it('uses kebab-case names', () => {
|
||||
for (const option of ICON_OPTIONS) {
|
||||
expect(option.name).toMatch(/^[a-z][a-z0-9-]*$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('TypeCustomizePopover', () => {
|
||||
const onChangeIcon = vi.fn()
|
||||
const onChangeColor = vi.fn()
|
||||
@@ -45,6 +64,57 @@ describe('TypeCustomizePopover', () => {
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters icons by search query', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'book' } })
|
||||
|
||||
// Should show book-related icons
|
||||
expect(screen.getByTitle('book')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('book-open')).toBeInTheDocument()
|
||||
// Should not show unrelated icons
|
||||
expect(screen.queryByTitle('wrench')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no icons match search', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } })
|
||||
|
||||
expect(screen.getByText('No icons found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChangeColor when a color is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
@@ -56,8 +126,7 @@ describe('TypeCustomizePopover', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Click the first color button (by title)
|
||||
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange/i)
|
||||
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
|
||||
fireEvent.click(colorButtons[0])
|
||||
|
||||
expect(onChangeColor).toHaveBeenCalled()
|
||||
@@ -74,9 +143,7 @@ describe('TypeCustomizePopover', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Click the wrench icon
|
||||
fireEvent.click(screen.getByTitle('wrench'))
|
||||
|
||||
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
|
||||
})
|
||||
|
||||
@@ -95,7 +162,7 @@ describe('TypeCustomizePopover', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders all icon options', () => {
|
||||
it('renders all color options including teal and pink', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
@@ -106,9 +173,7 @@ describe('TypeCustomizePopover', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Should have buttons for each icon option
|
||||
for (const option of ICON_OPTIONS.slice(0, 5)) {
|
||||
expect(screen.getByTitle(option.name)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByTitle('Teal')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Pink')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,63 +1,13 @@
|
||||
import { useState, type ComponentType } from 'react'
|
||||
import {
|
||||
FileText, Wrench, Flask, Target, ArrowsClockwise, Users, CalendarBlank,
|
||||
Tag, StackSimple, BookOpen, CookingPot, Heart, Star, House, Lightbulb,
|
||||
Briefcase, Gear, Cube, Leaf, MusicNote, Camera, Airplane, GameController,
|
||||
PaintBrush, ShoppingCart, GraduationCap, Trophy, ChatCircle, Notebook,
|
||||
MapPin, Code, Barbell, PawPrint, Pill, Knife,
|
||||
type IconProps,
|
||||
} from '@phosphor-icons/react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react'
|
||||
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
|
||||
import { ACCENT_COLORS } from '../utils/typeColors'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Curated Phosphor icons (normal weight) for type customization */
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- constant co-located with component
|
||||
export const ICON_OPTIONS: { name: string; Icon: ComponentType<IconProps> }[] = [
|
||||
{ name: 'file-text', Icon: FileText },
|
||||
{ name: 'wrench', Icon: Wrench },
|
||||
{ name: 'flask', Icon: Flask },
|
||||
{ name: 'target', Icon: Target },
|
||||
{ name: 'arrows-clockwise', Icon: ArrowsClockwise },
|
||||
{ name: 'users', Icon: Users },
|
||||
{ name: 'calendar-blank', Icon: CalendarBlank },
|
||||
{ name: 'tag', Icon: Tag },
|
||||
{ name: 'stack-simple', Icon: StackSimple },
|
||||
{ name: 'book-open', Icon: BookOpen },
|
||||
{ name: 'cooking-pot', Icon: CookingPot },
|
||||
{ name: 'heart', Icon: Heart },
|
||||
{ name: 'star', Icon: Star },
|
||||
{ name: 'house', Icon: House },
|
||||
{ name: 'lightbulb', Icon: Lightbulb },
|
||||
{ name: 'briefcase', Icon: Briefcase },
|
||||
{ name: 'gear', Icon: Gear },
|
||||
{ name: 'cube', Icon: Cube },
|
||||
{ name: 'leaf', Icon: Leaf },
|
||||
{ name: 'music-note', Icon: MusicNote },
|
||||
{ name: 'camera', Icon: Camera },
|
||||
{ name: 'airplane', Icon: Airplane },
|
||||
{ name: 'game-controller', Icon: GameController },
|
||||
{ name: 'paint-brush', Icon: PaintBrush },
|
||||
{ name: 'shopping-cart', Icon: ShoppingCart },
|
||||
{ name: 'graduation-cap', Icon: GraduationCap },
|
||||
{ name: 'trophy', Icon: Trophy },
|
||||
{ name: 'chat-circle', Icon: ChatCircle },
|
||||
{ name: 'notebook', Icon: Notebook },
|
||||
{ name: 'map-pin', Icon: MapPin },
|
||||
{ name: 'code', Icon: Code },
|
||||
{ name: 'barbell', Icon: Barbell },
|
||||
{ name: 'paw-print', Icon: PawPrint },
|
||||
{ name: 'pill', Icon: Pill },
|
||||
{ name: 'knife', Icon: Knife },
|
||||
]
|
||||
|
||||
const ICON_MAP: Record<string, ComponentType<IconProps>> = Object.fromEntries(
|
||||
ICON_OPTIONS.map((o) => [o.name, o.Icon]),
|
||||
)
|
||||
|
||||
/** Resolves a Phosphor icon name to its component, with fallback to FileText */
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function resolveIcon(name: string | null): ComponentType<IconProps> {
|
||||
return (name && ICON_MAP[name]) || FileText
|
||||
function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
|
||||
if (!query) return icons
|
||||
const lower = query.toLowerCase()
|
||||
return icons.filter((o) => o.name.includes(lower))
|
||||
}
|
||||
|
||||
interface TypeCustomizePopoverProps {
|
||||
@@ -77,6 +27,9 @@ export function TypeCustomizePopover({
|
||||
}: TypeCustomizePopoverProps) {
|
||||
const [selectedColor, setSelectedColor] = useState(currentColor)
|
||||
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
|
||||
|
||||
const handleColorClick = (key: string) => {
|
||||
setSelectedColor(key)
|
||||
@@ -91,13 +44,13 @@ export function TypeCustomizePopover({
|
||||
return (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
|
||||
style={{ width: 264, padding: 12 }}
|
||||
style={{ width: 280, padding: 12 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color section */}
|
||||
<div className="font-mono-overline mb-2 text-muted-foreground">COLOR</div>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
@@ -105,7 +58,7 @@ export function TypeCustomizePopover({
|
||||
"flex items-center justify-center rounded-full border-2 cursor-pointer transition-all",
|
||||
selectedColor === c.key ? "border-foreground scale-110" : "border-transparent hover:scale-105",
|
||||
)}
|
||||
style={{ width: 28, height: 28, backgroundColor: c.css, border: selectedColor === c.key ? '2px solid var(--foreground)' : '2px solid transparent' }}
|
||||
style={{ width: 24, height: 24, backgroundColor: c.css, border: selectedColor === c.key ? '2px solid var(--foreground)' : '2px solid transparent' }}
|
||||
onClick={() => handleColorClick(c.key)}
|
||||
title={c.label}
|
||||
/>
|
||||
@@ -114,23 +67,46 @@ export function TypeCustomizePopover({
|
||||
|
||||
{/* Icon section */}
|
||||
<div className="font-mono-overline mb-2 text-muted-foreground">ICON</div>
|
||||
<div className="flex flex-wrap gap-1" style={{ maxHeight: 200, overflowY: 'auto' }}>
|
||||
{ICON_OPTIONS.map(({ name, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded cursor-pointer transition-colors",
|
||||
selectedIcon === name
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
style={{ width: 30, height: 30 }}
|
||||
onClick={() => handleIconClick(name)}
|
||||
title={name}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative mb-2">
|
||||
<MagnifyingGlass
|
||||
size={14}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className="w-full rounded border border-border bg-background pl-7 pr-2 py-1 text-[12px] text-foreground placeholder:text-muted-foreground outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icon grid */}
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
|
||||
{filteredIcons.length === 0 ? (
|
||||
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
|
||||
No icons found
|
||||
</div>
|
||||
) : (
|
||||
filteredIcons.map(({ name, Icon }) => (
|
||||
<button
|
||||
key={name}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded cursor-pointer transition-colors",
|
||||
selectedIcon === name
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
style={{ width: 30, height: 30 }}
|
||||
onClick={() => handleIconClick(name)}
|
||||
title={name}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Done button */}
|
||||
|
||||
Reference in New Issue
Block a user