Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
243 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|