355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { Check, ChevronDown } from 'lucide-react'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
|
|
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
export type SelectMenuOption = {
|
|
value: string
|
|
label: string
|
|
description?: string
|
|
group?: string
|
|
icon?: LucideIcon
|
|
disabled?: boolean
|
|
}
|
|
|
|
type MenuPos = {
|
|
top: number
|
|
left: number
|
|
width: number
|
|
openUp: boolean
|
|
maxHeight: number
|
|
}
|
|
|
|
const PANEL_CLASS =
|
|
'overflow-hidden rounded-2xl border border-line bg-white shadow-[0_12px_40px_-12px_rgba(23,32,51,0.28),0_4px_12px_-4px_rgba(23,32,51,0.12)]'
|
|
|
|
export function SelectMenu({
|
|
value,
|
|
options,
|
|
onChange,
|
|
ariaLabel,
|
|
placeholder = 'انتخاب کنید',
|
|
disabled = false,
|
|
leading,
|
|
trailing,
|
|
footer,
|
|
size = 'md',
|
|
fullWidth = false,
|
|
matchTriggerWidth = true,
|
|
minMenuWidth = 200,
|
|
maxMenuWidth = 360,
|
|
triggerClassName = '',
|
|
invalid = false,
|
|
emptyLabel = 'موردی نیست',
|
|
iconOnly = false,
|
|
selectedValues,
|
|
closeOnSelect = true,
|
|
displayLabel,
|
|
}: {
|
|
value: string
|
|
options: readonly SelectMenuOption[]
|
|
onChange: (value: string) => void
|
|
ariaLabel: string
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
leading?: ReactNode
|
|
trailing?: ReactNode
|
|
footer?: ReactNode
|
|
size?: 'sm' | 'md' | 'chip'
|
|
fullWidth?: boolean
|
|
matchTriggerWidth?: boolean
|
|
minMenuWidth?: number
|
|
maxMenuWidth?: number
|
|
triggerClassName?: string
|
|
invalid?: boolean
|
|
emptyLabel?: string
|
|
iconOnly?: boolean
|
|
/** When set, checkmarks use this set instead of the single `value`. */
|
|
selectedValues?: readonly string[]
|
|
closeOnSelect?: boolean
|
|
/** Override the trigger text (e.g. first attached file while several are checked). */
|
|
displayLabel?: string
|
|
}) {
|
|
const listId = useId()
|
|
const [open, setOpen] = useState(false)
|
|
const [pos, setPos] = useState<MenuPos | null>(null)
|
|
const [highlight, setHighlight] = useState(-1)
|
|
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const selected = useMemo(
|
|
() => options.find((option) => option.value === value) || null,
|
|
[options, value],
|
|
)
|
|
|
|
const enabledIndexes = useMemo(
|
|
() => options.map((option, index) => (option.disabled ? -1 : index)).filter((index) => index >= 0),
|
|
[options],
|
|
)
|
|
|
|
function updatePosition() {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const width = Math.min(
|
|
maxMenuWidth,
|
|
Math.max(matchTriggerWidth ? rect.width : minMenuWidth, minMenuWidth),
|
|
)
|
|
const spaceBelow = window.innerHeight - rect.bottom - 12
|
|
const spaceAbove = rect.top - 12
|
|
const openUp = spaceBelow < 220 && spaceAbove > spaceBelow
|
|
const maxHeight = Math.min(320, Math.max(160, openUp ? spaceAbove : spaceBelow))
|
|
const left = Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)
|
|
setPos({
|
|
top: openUp ? rect.top - 6 : rect.bottom + 6,
|
|
left,
|
|
width,
|
|
openUp,
|
|
maxHeight,
|
|
})
|
|
}
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return
|
|
updatePosition()
|
|
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled)
|
|
setHighlight(selectedIndex >= 0 ? selectedIndex : enabledIndexes[0] ?? -1)
|
|
}, [open, options, value, enabledIndexes])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function onPointerDown(event: MouseEvent) {
|
|
const target = event.target as Node
|
|
if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
setOpen(false)
|
|
triggerRef.current?.focus()
|
|
}
|
|
}
|
|
function onReposition() {
|
|
updatePosition()
|
|
}
|
|
document.addEventListener('mousedown', onPointerDown)
|
|
document.addEventListener('keydown', onKeyDown)
|
|
window.addEventListener('resize', onReposition)
|
|
window.addEventListener('scroll', onReposition, true)
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown)
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
window.removeEventListener('resize', onReposition)
|
|
window.removeEventListener('scroll', onReposition, true)
|
|
}
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open || highlight < 0) return
|
|
const item = menuRef.current?.querySelector<HTMLElement>(`[data-index="${highlight}"]`)
|
|
item?.scrollIntoView({ block: 'nearest' })
|
|
}, [highlight, open])
|
|
|
|
function selectValue(next: string) {
|
|
onChange(next)
|
|
if (!closeOnSelect) return
|
|
setOpen(false)
|
|
triggerRef.current?.focus()
|
|
}
|
|
|
|
function moveHighlight(delta: number) {
|
|
if (enabledIndexes.length === 0) return
|
|
const currentPos = enabledIndexes.indexOf(highlight)
|
|
const start = currentPos >= 0 ? currentPos : delta > 0 ? -1 : 0
|
|
const nextPos = (start + delta + enabledIndexes.length) % enabledIndexes.length
|
|
setHighlight(enabledIndexes[nextPos]!)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
menuRef.current?.focus()
|
|
}, [open, pos])
|
|
|
|
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
|
|
if (disabled) return
|
|
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setOpen(true)
|
|
}
|
|
}
|
|
|
|
function onMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
moveHighlight(1)
|
|
return
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
moveHighlight(-1)
|
|
return
|
|
}
|
|
if (event.key === 'Home') {
|
|
event.preventDefault()
|
|
setHighlight(enabledIndexes[0] ?? -1)
|
|
return
|
|
}
|
|
if (event.key === 'End') {
|
|
event.preventDefault()
|
|
setHighlight(enabledIndexes[enabledIndexes.length - 1] ?? -1)
|
|
return
|
|
}
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
const option = options[highlight]
|
|
if (option && !option.disabled) selectValue(option.value)
|
|
}
|
|
}
|
|
|
|
const isChip = size === 'chip'
|
|
const heightClass = isChip ? 'h-8' : size === 'sm' ? 'h-9' : 'h-10'
|
|
const shapeClass = isChip ? 'gap-1.5 rounded-full pr-2 pl-1.5' : 'gap-2 rounded-xl pr-3 pl-2.5'
|
|
const textClass = isChip ? 'text-[12px] font-medium' : 'text-sm'
|
|
const SelectedIcon = selected?.icon
|
|
const triggerLabel = displayLabel || (value && selected ? selected.label : placeholder)
|
|
const triggerMuted = !displayLabel && !(value && selected)
|
|
|
|
const menu =
|
|
open && pos
|
|
? createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
id={listId}
|
|
role="listbox"
|
|
aria-label={ariaLabel}
|
|
tabIndex={-1}
|
|
onKeyDown={onMenuKeyDown}
|
|
style={{
|
|
top: pos.openUp ? undefined : pos.top,
|
|
bottom: pos.openUp ? window.innerHeight - pos.top : undefined,
|
|
left: pos.left,
|
|
width: pos.width,
|
|
maxHeight: pos.maxHeight,
|
|
}}
|
|
className={`fixed z-[9999] flex flex-col ${PANEL_CLASS}`}
|
|
>
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5">
|
|
{options.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-sm text-ink-muted">{emptyLabel}</p>
|
|
) : (
|
|
options.map((option, index) => {
|
|
const Icon = option.icon
|
|
const isSelected = selectedValues
|
|
? selectedValues.includes(option.value)
|
|
: option.value === value
|
|
const isActive = index === highlight
|
|
const prevGroup = index > 0 ? options[index - 1]?.group : undefined
|
|
const showGroup = Boolean(option.group && option.group !== prevGroup)
|
|
return (
|
|
<div key={option.value || `opt-${index}`}>
|
|
{showGroup ? (
|
|
<p className={`px-2.5 pb-1 text-[11px] font-medium text-ink-subtle ${index === 0 ? 'pt-0.5' : 'pt-2'}`}>
|
|
{option.group}
|
|
</p>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
data-index={index}
|
|
aria-selected={isSelected}
|
|
disabled={option.disabled}
|
|
onMouseEnter={() => {
|
|
if (!option.disabled) setHighlight(index)
|
|
}}
|
|
onClick={() => {
|
|
if (!option.disabled) selectValue(option.value)
|
|
}}
|
|
className={`flex w-full items-start gap-2.5 rounded-xl px-2.5 py-2 text-right transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
isActive ? 'bg-accent-soft' : 'hover:bg-surface-subtle'
|
|
} ${isSelected ? 'text-accent' : 'text-ink'}`}
|
|
>
|
|
{Icon ? (
|
|
<span
|
|
className={`mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
|
|
isSelected ? 'bg-accent text-white' : 'bg-surface-subtle text-ink-muted'
|
|
}`}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
</span>
|
|
) : null}
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">{option.label}</span>
|
|
{option.description ? (
|
|
<span className="mt-0.5 block truncate text-[11px] text-ink-subtle">
|
|
{option.description}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center">
|
|
{isSelected ? <Check className="h-4 w-4 text-accent" /> : null}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
{footer ? (
|
|
<div className="border-t border-line bg-surface-subtle/60 px-2 py-2">{footer}</div>
|
|
) : null}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null
|
|
|
|
return (
|
|
<div className={`relative ${fullWidth ? 'w-full' : 'inline-flex'}`}>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
aria-controls={open ? listId : undefined}
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
if (!disabled) setOpen((prev) => !prev)
|
|
}}
|
|
onKeyDown={onTriggerKeyDown}
|
|
className={`inline-flex ${iconOnly ? 'h-8 w-8 justify-center rounded-full px-0' : `${heightClass} ${shapeClass}`} items-center border ${isChip ? '' : 'bg-white'} ${textClass} transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
fullWidth && !iconOnly ? 'w-full' : ''
|
|
} ${
|
|
invalid
|
|
? 'border-danger/50'
|
|
: open
|
|
? 'border-accent/45 shadow-[0_0_0_3px_rgba(37,99,235,0.08)]'
|
|
: 'border-line hover:border-line-strong'
|
|
} ${triggerClassName}`}
|
|
>
|
|
{leading}
|
|
{SelectedIcon && !leading ? (
|
|
<SelectedIcon className="h-4 w-4 shrink-0 text-ink-muted" />
|
|
) : null}
|
|
{iconOnly ? (
|
|
<span className="sr-only">{triggerLabel || ariaLabel}</span>
|
|
) : (
|
|
<span className={`min-w-0 flex-1 truncate text-right ${triggerMuted ? 'text-ink-muted' : 'text-ink'}`}>
|
|
{triggerLabel}
|
|
</span>
|
|
)}
|
|
{trailing}
|
|
{iconOnly ? null : (
|
|
<ChevronDown
|
|
className={`${isChip ? 'h-3.5 w-3.5' : 'h-4 w-4'} shrink-0 text-ink-muted/80 transition ${open ? 'rotate-180' : ''}`}
|
|
/>
|
|
)}
|
|
</button>
|
|
{menu}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const selectMenuPanelClass = PANEL_CLASS
|