admin/components/heroui/Select.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

243 lines
7.8 KiB
TypeScript

'use client'
import type { ChangeEvent, Key, ReactElement, ReactNode, Ref } from 'react'
import { Children, isValidElement } from 'react'
import { Description, FieldError, Label, ListBox, Select as HeroSelect } from '@heroui/react'
import { cn } from '@/lib/cn'
type SelectKey = string | number
export interface SelectItemProps {
children?: ReactNode
classNames?: { base?: string }
key?: SelectKey
textValue?: string
}
export function SelectItem(_props: SelectItemProps) {
return null
}
interface SelectProps<T extends object = object> {
'aria-label'?: string
autoFocus?: boolean
children?: ReactNode | ((item: T) => ReactElement<SelectItemProps>)
className?: string
classNames?: { base?: string; label?: string; trigger?: string; value?: string; placeholder?: string }
description?: ReactNode
descriptionClassName?: string
disabledKeys?: Iterable<SelectKey>
endContent?: ReactNode
errorMessage?: ReactNode
fullWidth?: boolean
isDisabled?: boolean
isInvalid?: boolean
items?: Iterable<T>
label?: ReactNode
labelPlacement?: 'inside' | 'outside' | 'outside-left'
listboxProps?: object
name?: string
placeholder?: string
popoverProps?: {
classNames?: { content?: string }
placement?: string
portalContainer?: Element | null
shouldBlockScroll?: boolean
}
radius?: 'none' | 'sm' | 'md' | 'lg' | 'full'
selectedKeys?: 'all' | Iterable<SelectKey>
selectionMode?: 'single' | 'multiple'
size?: 'sm' | 'md' | 'lg'
startContent?: ReactNode
ref?: Ref<HTMLButtonElement>
validationBehavior?: 'native' | 'aria'
variant?: 'flat' | 'bordered' | 'faded' | 'underlined'
onBlur?: () => void
onChange?: (event: ChangeEvent<HTMLSelectElement>) => void
onSelectionChange?: (keys: 'all' | Set<SelectKey>) => void
}
function normalizeKey(key: ReactElement['key'], index: number): string {
if (key == null) return String(index)
const value = String(key)
return value.startsWith('.$') ? value.slice(2) : value
}
function getItems<T extends object>(children: SelectProps<T>['children'], sourceItems?: Iterable<T>) {
const resolvedChildren = typeof children === 'function' ? Array.from(sourceItems ?? [], (item) => children(item)) : children
return Children.toArray(resolvedChildren)
.filter((child): child is ReactElement<SelectItemProps> => isValidElement<SelectItemProps>(child))
.map((child, index) => ({
id: normalizeKey(child.key, index),
label: child.props.children,
textValue:
child.props.textValue ?? (typeof child.props.children === 'string' ? child.props.children : normalizeKey(child.key, index)),
}))
}
export function Select<T extends object = object>({
'aria-label': ariaLabel,
autoFocus,
children,
className,
classNames,
description,
descriptionClassName,
disabledKeys,
endContent,
errorMessage,
fullWidth: _fullWidth,
isDisabled,
isInvalid,
items: sourceItems,
label,
labelPlacement: _labelPlacement,
listboxProps: _listboxProps,
name,
placeholder,
popoverProps,
radius,
selectedKeys,
selectionMode = 'single',
size,
startContent,
ref,
validationBehavior,
variant,
onBlur,
onChange,
onSelectionChange,
}: SelectProps<T>) {
const items = getItems(children, sourceItems)
const selected = selectedKeys === 'all' ? 'all' : new Set(Array.from(selectedKeys ?? [], String))
const rootClassName = cn('flex w-full flex-col gap-1', classNames?.base, className)
const radiusClass =
variant === 'underlined'
? 'rounded-none'
: !radius
? ''
: radius === 'none'
? 'rounded-none'
: radius === 'full'
? 'rounded-full'
: radius === 'lg'
? 'rounded-2xl'
: radius === 'sm'
? 'rounded-md'
: 'rounded-lg'
const variantClass = isInvalid
? 'border-fourth'
: variant === 'underlined'
? 'rounded-none border-x-0 border-t-0'
: variant === 'flat' || variant === 'faded'
? 'border-transparent bg-default-100'
: 'border-divider bg-surface'
const triggerClassName = cn(
'flex w-full items-center gap-2',
!classNames?.trigger &&
cn('border px-3', size === 'sm' ? 'min-h-8' : size === 'lg' ? 'min-h-12' : 'min-h-10', radiusClass, variantClass),
classNames?.trigger
)
if (selectionMode === 'multiple') {
const selectedValues = selected === 'all' ? items.map((item) => item.id) : Array.from(selected)
return (
<div className={rootClassName}>
{label ? <Label className={classNames?.label}>{label}</Label> : null}
<select
multiple
aria-label={ariaLabel}
className={triggerClassName}
disabled={isDisabled}
name={name}
value={selectedValues}
onBlur={onBlur}
onChange={(event) => {
const keys = new Set(Array.from(event.currentTarget.selectedOptions, (option) => option.value))
onSelectionChange?.(keys)
onChange?.({ target: { value: Array.from(keys)[0] ?? '' } } as ChangeEvent<HTMLSelectElement>)
}}
>
{items.map((item) => (
<option
key={item.id}
disabled={disabledKeys ? new Set(Array.from(disabledKeys, String)).has(item.id) : false}
value={item.id}
>
{item.textValue}
</option>
))}
</select>
{description ? <Description className={descriptionClassName}>{description}</Description> : null}
{isInvalid && errorMessage ? <FieldError>{errorMessage}</FieldError> : null}
</div>
)
}
const selectedKey = selected === 'all' ? null : (Array.from(selected)[0] ?? null)
return (
<HeroSelect
aria-label={ariaLabel}
className={rootClassName}
disabledKeys={disabledKeys}
isDisabled={isDisabled}
isInvalid={isInvalid}
name={name}
// HeroUI Select still exposes selectedKey in v3; migrate when API renames land.
// eslint-disable-next-line @typescript-eslint/no-deprecated -- HeroUI controlled key API
selectedKey={selectedKey}
validationBehavior={validationBehavior}
onBlur={onBlur}
// eslint-disable-next-line @typescript-eslint/no-deprecated -- HeroUI selection change API
onSelectionChange={(key: Key | null) => {
const value = key == null ? '' : String(key)
onSelectionChange?.(new Set(value ? [value] : []))
onChange?.({ target: { value } } as ChangeEvent<HTMLSelectElement>)
}}
>
{label ? <Label className={classNames?.label}>{label}</Label> : null}
<HeroSelect.Trigger
ref={ref}
autoFocus={autoFocus}
className={triggerClassName}
>
{startContent}
<HeroSelect.Value className={cn('min-w-0 flex-1 text-start', classNames?.value)}>
{({ defaultChildren, isPlaceholder }) => (
<span className={cn('block truncate', isPlaceholder ? classNames?.placeholder : classNames?.value)}>
{isPlaceholder ? placeholder : defaultChildren}
</span>
)}
</HeroSelect.Value>
{endContent ?? <HeroSelect.Indicator />}
</HeroSelect.Trigger>
{description ? <Description className={descriptionClassName}>{description}</Description> : null}
{isInvalid && errorMessage ? <FieldError>{errorMessage}</FieldError> : null}
<HeroSelect.Popover
className={cn('max-h-[300px] overflow-y-auto', popoverProps?.classNames?.content)}
placement="bottom start"
>
<ListBox>
{items.map((item) => (
<ListBox.Item
key={item.id}
id={item.id}
textValue={item.textValue}
>
<Label>{item.label}</Label>
</ListBox.Item>
))}
</ListBox>
</HeroSelect.Popover>
</HeroSelect>
)
}