Files
kamidso-front/src/components/ui/SelectMenu.tsx
T

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