project-nomad/admin/inertia/components/maps/IconSelectorPopover.tsx

168 lines
5.2 KiB
TypeScript

import { useMemo, useState } from 'react'
import * as TablerIcons from '@tabler/icons-react'
import type { IconProps } from '@tabler/icons-react'
import type { ComponentType } from 'react'
import * as FontAwesomeIcons from 'react-icons/fa'
import type { IconType } from 'react-icons'
const PAGE_SIZE = 48
type IconSelectorPopoverProps = {
selectedIcon?: string | null
onSelect: (iconName: string) => void
onClose: () => void
}
const normalizeIconNameForSort = (name: string) => {
return name
.replace(/^Icon/, '') // Tabler: IconHome -> Home
.replace(/^Fa/, '') // FontAwesome: FaHome -> Home
.toLowerCase()
}
type IconEntry = {
name: string
label: string
library: 'tabler' | 'fa'
Icon: ComponentType<IconProps> | IconType
}
const tablerIconEntries: IconEntry[] = Object.entries(TablerIcons)
.filter(([name, value]) => {
return (
name.startsWith('Icon') &&
name !== 'Icon' &&
value !== null &&
(typeof value === 'function' || typeof value === 'object')
)
})
.map(([name, Icon]) => ({
name: `tabler:${name}`,
label: name,
library: 'tabler' as const,
Icon: Icon as ComponentType<IconProps>,
}))
const fontAwesomeIconEntries: IconEntry[] = Object.entries(FontAwesomeIcons)
.filter(([name, value]) => {
return (
name.startsWith('Fa') &&
value !== null &&
(typeof value === 'function' || typeof value === 'object')
)
})
.map(([name, Icon]) => ({
name: `fa:${name}`,
label: name,
library: 'fa' as const,
Icon: Icon as IconType,
}))
const iconEntries: IconEntry[] = [
...tablerIconEntries,
...fontAwesomeIconEntries,
].sort((a, b) => a.label.localeCompare(b.label))
export default function IconSelectorPopover({
selectedIcon,
onSelect,
onClose,
}: IconSelectorPopoverProps) {
const [query, setQuery] = useState('')
const [page, setPage] = useState(0)
const filteredIcons = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
return iconEntries.filter((entry) => {
const normalizedLabel = normalizeIconNameForSort(entry.label)
return (
entry.label.toLowerCase().includes(normalizedQuery) ||
normalizedLabel.includes(normalizedQuery) ||
entry.library.toLowerCase().includes(normalizedQuery)
)
})
}, [query])
const pageCount = Math.max(1, Math.ceil(filteredIcons.length / PAGE_SIZE))
const pagedIcons = filteredIcons.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE)
return (
<div
className="absolute left-0 top-7 z-50 w-72 rounded-md border border-border-subtle bg-surface-primary p-2 shadow-lg"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<input
autoFocus
type="search"
placeholder="Search icons..."
value={query}
onChange={(e) => {
setQuery(e.target.value)
setPage(0)
}}
className="mb-2 block w-full rounded border border-border-default bg-surface-primary px-2 py-1 text-sm text-text-primary placeholder:text-text-muted focus:border-desert-green focus:outline-none"
/>
<div className="max-h-56 overflow-y-auto themed-scrollbar">
<div className="grid grid-cols-6 gap-1">
{pagedIcons.map(({ name, label, library, Icon }) => (
<button
key={name}
type="button"
title={`${label} (${library})`}
aria-label={label}
onClick={() => {
onSelect(name)
onClose()
}}
className={`flex h-8 w-8 items-center justify-center rounded transition-colors hover:bg-surface-secondary ${
selectedIcon === name ? 'bg-desert-green text-white' : 'text-text-secondary'
}`}
>
<Icon size={18} />
</button>
))}
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setPage((prev) => Math.max(0, prev - 1))}
disabled={page === 0}
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530] disabled:opacity-40"
>
Prev
</button>
<span className="text-xs text-text-muted">
{filteredIcons.length === 0 ? 'No icons' : `${page + 1} / ${pageCount}`}
</span>
<button
type="button"
onClick={() => setPage((prev) => Math.min(pageCount - 1, prev + 1))}
disabled={page >= pageCount - 1}
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530] disabled:opacity-40"
>
Next
</button>
</div>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded bg-[#424420] px-2 py-1 text-xs text-white hover:bg-[#525530]"
>
Close
</button>
</div>
</div>
)
}