feat(ui): searchable MultiSelect (cmdk) + DateRangePicker (react-day-picker)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import * as React from 'react'
|
||||
import { CalendarBlankIcon, XIcon } from '@phosphor-icons/react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from './popover'
|
||||
import { FieldLabel, FieldHint, FieldError } from './field'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* DateRangePicker — single popover trigger c react-day-picker calendar внутри,
|
||||
* выбор start/end в одном UI. Stage 3.8b: замена пары двух DatePicker'ов
|
||||
* (audit page from/to) на компактный range picker.
|
||||
*
|
||||
* <p>react-day-picker загружается lazily через React.lazy — модуль ~80kb gz
|
||||
* чтобы не утяжелять main bundle (DateRangePicker используется только на
|
||||
* audit page + filtering screens, не в каждом form'е).
|
||||
*
|
||||
* <p>Trigger показывает «{from} — {to}», или placeholder если пусто.
|
||||
* Clear button (X) появляется когда есть выбор.
|
||||
*
|
||||
* <p>API:
|
||||
* <ul>
|
||||
* <li>value: {from?: Date, to?: Date} — undefined start/end OK (open range).</li>
|
||||
* <li>onChange: ({from, to}) => void.</li>
|
||||
* <li>locale: ru (default) или en — формат шапки месяца и weekday labels.</li>
|
||||
* </ul>
|
||||
*/
|
||||
|
||||
// Lazy react-day-picker — компонент Suspense'ит calendar UI на open. До open
|
||||
// bundle не подтягивается. Trigger button + state работают eager.
|
||||
const DayPickerLazy = React.lazy(async () => {
|
||||
// Подгружаем компонент И стили (vite bundles css side-effect через `?inline`?
|
||||
// нет — обычный import достаточен, vite collect'ит css в build pipeline).
|
||||
const [{ DayPicker }] = await Promise.all([
|
||||
import('react-day-picker'),
|
||||
import('react-day-picker/style.css'),
|
||||
])
|
||||
return { default: DayPicker }
|
||||
})
|
||||
|
||||
export type DateRangePickerValue = {
|
||||
from?: Date
|
||||
to?: Date
|
||||
}
|
||||
|
||||
export type DateRangePickerProps = {
|
||||
label?: React.ReactNode
|
||||
hint?: React.ReactNode
|
||||
error?: React.ReactNode
|
||||
required?: boolean
|
||||
/** Range value. undefined = пустой пик, partial = open-ended. */
|
||||
value?: DateRangePickerValue
|
||||
onChange?: (value: DateRangePickerValue) => void
|
||||
/** ru (default) или en — для weekday/month labels. */
|
||||
locale?: 'ru' | 'en'
|
||||
/** Min / max bounds. Calendar disables вне диапазона. */
|
||||
fromDate?: Date
|
||||
toDate?: Date
|
||||
/** Placeholder когда range пустой. */
|
||||
placeholder?: string
|
||||
/** Trigger button id. */
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
rootClassName?: string
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
const formatDateLabel = (d: Date | undefined, locale: 'ru' | 'en' = 'ru'): string => {
|
||||
if (!d) return ''
|
||||
return d.toLocaleDateString(locale === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
locale = 'ru',
|
||||
fromDate,
|
||||
toDate,
|
||||
placeholder,
|
||||
id,
|
||||
disabled,
|
||||
rootClassName,
|
||||
triggerClassName,
|
||||
}: DateRangePickerProps) {
|
||||
const autoId = React.useId()
|
||||
const triggerId = id ?? autoId
|
||||
const hintId = hint ? `${triggerId}-hint` : undefined
|
||||
const errorId = error ? `${triggerId}-error` : undefined
|
||||
|
||||
const fromLabel = formatDateLabel(value?.from, locale)
|
||||
const toLabel = formatDateLabel(value?.to, locale)
|
||||
const hasValue = Boolean(value?.from || value?.to)
|
||||
|
||||
const display = hasValue
|
||||
? `${fromLabel || '…'} — ${toLabel || '…'}`
|
||||
: placeholder ?? (locale === 'ru' ? 'Выберите период' : 'Pick a range')
|
||||
|
||||
// react-day-picker DateRange type uses `from`/`to` тоже — direct passthrough.
|
||||
const dpRange: DateRange | undefined = hasValue
|
||||
? { from: value?.from, to: value?.to }
|
||||
: undefined
|
||||
|
||||
const handleSelect = (next: DateRange | undefined) => {
|
||||
if (!next) {
|
||||
onChange?.({})
|
||||
return
|
||||
}
|
||||
onChange?.({ from: next.from, to: next.to })
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
onChange?.({})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', rootClassName)}>
|
||||
{label && (
|
||||
<FieldLabel htmlFor={triggerId} required={required}>
|
||||
{label}
|
||||
</FieldLabel>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
id={triggerId}
|
||||
disabled={disabled}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={[hintId, errorId].filter(Boolean).join(' ') || undefined}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
|
||||
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-pink focus-visible:ring-pink/40',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<CalendarBlankIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||
<span className={cn('truncate', !hasValue && 'text-mute')}>{display}</span>
|
||||
</span>
|
||||
{hasValue && !disabled && (
|
||||
<span
|
||||
role="button"
|
||||
aria-label={locale === 'ru' ? 'Очистить' : 'Clear'}
|
||||
onClick={handleClear}
|
||||
className="shrink-0 rounded-sm p-0.5 text-mute hover:text-ink hover:bg-surface-2"
|
||||
>
|
||||
<XIcon size={12} weight="bold" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-3">
|
||||
<PopoverPrimitive.Close className="hidden" />
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="h-64 w-64 flex items-center justify-center text-mute text-cap">
|
||||
{locale === 'ru' ? 'Загрузка календаря…' : 'Loading calendar…'}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DayPickerLazy
|
||||
mode="range"
|
||||
selected={dpRange}
|
||||
onSelect={handleSelect}
|
||||
numberOfMonths={2}
|
||||
showOutsideDays
|
||||
startMonth={fromDate}
|
||||
endMonth={toDate}
|
||||
weekStartsOn={1}
|
||||
// ISO 8601 weekday order matches RU calendar convention; en тоже
|
||||
// OK (просто Monday first). Если когда-нибудь нужен Sunday-first
|
||||
// — отдельный prop.
|
||||
className="ord-day-picker"
|
||||
/>
|
||||
</React.Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
|
||||
{error && <FieldError id={errorId}>{error}</FieldError>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, CaretDownIcon } from '@phosphor-icons/react'
|
||||
import { CheckIcon, CaretDownIcon, MagnifyingGlassIcon } from '@phosphor-icons/react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from './popover'
|
||||
import { FieldLabel, FieldHint, FieldError } from './field'
|
||||
import { Badge } from './badge'
|
||||
@@ -10,11 +11,15 @@ import { cn } from '@/lib/utils'
|
||||
* MultiSelect — popover с inline checkboxes. Замена @nstart/ui MultiSelect.
|
||||
*
|
||||
* <p>Trigger показывает count selected + первые 2 selected labels.
|
||||
* Popover — список с checkboxes. Click toggle. Поиск НЕ implemented (handoff
|
||||
* MultiSelect не требует) — для search-heavy lists используй cmdk Combobox
|
||||
* (Stage 3.8b TODO).
|
||||
* Popover — список с checkboxes. Click toggle.
|
||||
*
|
||||
* <p>API: options [{value, label}], value string[], onChange (ids: string[]).
|
||||
* <p><b>Searchable mode</b> (opt-in): {@code searchable=true} включает
|
||||
* cmdk Combobox c keyword filter + keyboard navigation (↑/↓/Enter/Esc).
|
||||
* Имеет смысл когда options 10+ или labels длинные. Cmdk фильтрует на
|
||||
* клиенте по substring match label'а — для server-side search будущий
|
||||
* follow-up (async loader prop).
|
||||
*
|
||||
* <p>API: options [{id, label}], value string[], onChange (ids: string[]).
|
||||
*/
|
||||
|
||||
/** nstart-compat: {id, label}. Use `id` key. */
|
||||
@@ -39,6 +44,12 @@ export type MultiSelectProps = {
|
||||
rootClassName?: string
|
||||
/** Compact display (no badges, just count). */
|
||||
compact?: boolean
|
||||
/** Включает cmdk-powered поиск (input + keyboard nav). Default false. */
|
||||
searchable?: boolean
|
||||
/** Placeholder for search input (только если searchable). */
|
||||
searchPlaceholder?: string
|
||||
/** Message when filter yields zero results (только если searchable). */
|
||||
emptyMessage?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
@@ -53,6 +64,9 @@ export function MultiSelect({
|
||||
disabled,
|
||||
rootClassName,
|
||||
compact,
|
||||
searchable,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
}: MultiSelectProps) {
|
||||
const autoId = React.useId()
|
||||
const triggerId = autoId
|
||||
@@ -118,44 +132,108 @@ export function MultiSelect({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-1 min-w-[var(--radix-popover-trigger-width)]">
|
||||
<PopoverPrimitive.Close className="hidden" />
|
||||
<ul role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto">
|
||||
{options.map((opt) => {
|
||||
const checked = valueSet.has((opt.id ?? opt.value ?? ''))
|
||||
return (
|
||||
<li
|
||||
key={(opt.id ?? opt.value ?? '')}
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
tabIndex={0}
|
||||
onClick={() => toggle((opt.id ?? opt.value ?? ''))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggle((opt.id ?? opt.value ?? ''))
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-1.5 rounded-sm cursor-pointer text-body',
|
||||
'hover:bg-surface-2 focus:bg-surface-2 focus:outline-none',
|
||||
checked && 'text-ink',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
{searchable ? (
|
||||
<Command
|
||||
// cmdk использует label option'а как searchable value через
|
||||
// `value={String(label)}` — но options могут иметь React.ReactNode
|
||||
// label'ы (мы поддерживаем JSX в label, см. webhooks). Чтобы
|
||||
// фильтр работал, передаём value явно — toString() от label если
|
||||
// оно strings, иначе id. Custom filter handle ниже.
|
||||
filter={(itemValue, search) => {
|
||||
if (!search) return 1
|
||||
return itemValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2 border-b border-line">
|
||||
<MagnifyingGlassIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder ?? 'Поиск…'}
|
||||
className="flex-1 h-8 bg-transparent border-0 outline-none text-body text-ink placeholder:text-mute"
|
||||
/>
|
||||
</div>
|
||||
<CommandList className="max-h-64 overflow-y-auto p-1">
|
||||
<CommandEmpty className="px-2.5 py-4 text-body text-mute text-center">
|
||||
{emptyMessage ?? 'Ничего не найдено'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((opt) => {
|
||||
const optId = opt.id ?? opt.value ?? ''
|
||||
const checked = valueSet.has(optId)
|
||||
// cmdk использует value для filter + keyboard match.
|
||||
// Передаём labels-as-string + id чтобы оба варианта поиска
|
||||
// работали ("admin" найдёт ADMIN, и "ADMIN" тоже).
|
||||
const searchValue = `${typeof opt.label === 'string' ? opt.label : ''} ${optId}`.trim()
|
||||
return (
|
||||
<CommandItem
|
||||
key={optId}
|
||||
value={searchValue}
|
||||
onSelect={() => toggle(optId)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2.5 py-1.5 rounded-sm cursor-pointer text-body',
|
||||
'aria-selected:bg-surface-2 data-[selected=true]:bg-surface-2',
|
||||
'hover:bg-surface-2 focus:bg-surface-2 outline-none',
|
||||
checked && 'text-ink',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-4 rounded-sm border',
|
||||
checked
|
||||
? 'bg-accent border-accent text-on-accent'
|
||||
: 'border-line-2 bg-surface',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && <CheckIcon size={10} weight="bold" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
) : (
|
||||
<ul role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto">
|
||||
{options.map((opt) => {
|
||||
const checked = valueSet.has((opt.id ?? opt.value ?? ''))
|
||||
return (
|
||||
<li
|
||||
key={(opt.id ?? opt.value ?? '')}
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
tabIndex={0}
|
||||
onClick={() => toggle((opt.id ?? opt.value ?? ''))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggle((opt.id ?? opt.value ?? ''))
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-4 rounded-sm border',
|
||||
checked
|
||||
? 'bg-accent border-accent text-on-accent'
|
||||
: 'border-line-2 bg-surface',
|
||||
'flex items-center gap-2 px-2.5 py-1.5 rounded-sm cursor-pointer text-body',
|
||||
'hover:bg-surface-2 focus:bg-surface-2 focus:outline-none',
|
||||
checked && 'text-ink',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && <CheckIcon size={10} weight="bold" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center size-4 rounded-sm border',
|
||||
checked
|
||||
? 'bg-accent border-accent text-on-accent'
|
||||
: 'border-line-2 bg-surface',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && <CheckIcon size={10} weight="bold" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{opt.label}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
|
||||
|
||||
Reference in New Issue
Block a user