Merge branch 'feat/searchable-multiselect-and-date-range' into 'main'

feat(ui): searchable MultiSelect (cmdk) + DateRangePicker (react-day-picker)

See merge request 2-6/2-6-4/terravault/ordinis!142
This commit is contained in:
Александр Зимин
2026-05-12 13:55:35 +00:00
9 changed files with 405 additions and 69 deletions
+2
View File
@@ -31,6 +31,7 @@
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"d3-geo": "^3.1.1",
"i18next": "^26.0.3",
@@ -39,6 +40,7 @@
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
"react-day-picker": "^10.0.0",
"react-dom": "^19.0.0",
"react-force-graph-3d": "^1.29.1",
"react-hook-form": "^7.54.2",
+46
View File
@@ -68,6 +68,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
d3-force:
specifier: ^3.0.0
version: 3.0.0
@@ -92,6 +95,9 @@ importers:
react:
specifier: ^19.0.0
version: 19.2.5
react-day-picker:
specifier: ^10.0.0
version: 10.0.0(react@19.2.5)
react-dom:
specifier: ^19.0.0
version: 19.2.5(react@19.2.5)
@@ -378,6 +384,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
@@ -1959,6 +1968,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@@ -2121,6 +2136,9 @@ packages:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -3246,6 +3264,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-day-picker@10.0.0:
resolution: {integrity: sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@@ -4149,6 +4173,8 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@date-fns/tz@1.4.1': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@esbuild/aix-ppc64@0.21.5':
@@ -5597,6 +5623,18 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@@ -5758,6 +5796,8 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
date-fns@4.1.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -6731,6 +6771,12 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-day-picker@10.0.0(react@19.2.5):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
react: 19.2.5
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
+4
View File
@@ -161,6 +161,7 @@ i18n
'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все',
'webhooks.field.eventTypeFilter': 'Типы событий',
'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Пусто = все события.',
'webhooks.field.eventTypeSearch': 'Поиск типа события',
'webhooks.field.description': 'Описание',
'webhooks.field.createdBy': 'Создатель',
'webhooks.error.nameUrlRequired': 'Имя и URL обязательны',
@@ -234,6 +235,7 @@ i18n
'audit.filter.businessKey': 'Бизнес-ключ',
'audit.filter.from': 'С',
'audit.filter.to': 'По',
'audit.filter.range': 'Период',
'audit.filter.reset': 'Сбросить',
'audit.filter.apply': 'Применить',
'audit.filter.active': 'Активные фильтры',
@@ -846,6 +848,7 @@ i18n
'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all',
'webhooks.field.eventTypeFilter': 'Event types',
'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Empty = all events.',
'webhooks.field.eventTypeSearch': 'Search event type',
'webhooks.field.description': 'Description',
'webhooks.field.createdBy': 'Created by',
'webhooks.error.nameUrlRequired': 'Name and URL are required',
@@ -917,6 +920,7 @@ i18n
'audit.filter.businessKey': 'Business key',
'audit.filter.from': 'From',
'audit.filter.to': 'To',
'audit.filter.range': 'Date range',
'audit.filter.reset': 'Reset',
'audit.filter.apply': 'Apply',
'audit.filter.active': 'Active filters',
+24 -29
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import {
Badge,
Button,
DatePicker,
DateRangePicker,
EmptyState,
IconButton,
LoadingBlock,
@@ -380,34 +380,29 @@ function FilterPanel({
onChange={(e) => set('businessKey', e.target.value || undefined)}
onKeyDown={handleEnter}
/>
<DatePicker
label={t('audit.filter.from')}
value={fromDate}
onChange={(d) =>
set(
'from',
d
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}T00:00:00${localTzOffset(d)}`
: undefined,
)
}
/>
<DatePicker
label={t('audit.filter.to')}
value={toDate}
onChange={(d) =>
set(
'to',
d
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate(),
).padStart(2, '0')}T23:59:59${localTzOffset(d)}`
: undefined,
)
}
/>
{/* DateRangePicker заменяет пару отдельных DatePicker'ов один
* popover с двумя месяцами, выбор start/end в одном UI. ISO sтроки
* c local TZ offset формируются здесь, чтобы backend получал
* правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
<div className="md:col-span-2">
<DateRangePicker
label={t('audit.filter.range', { defaultValue: 'Период' })}
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
onChange={({ from, to }) => {
const fromIso = from
? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(
from.getDate(),
).padStart(2, '0')}T00:00:00${localTzOffset(from)}`
: undefined
const toIso = to
? `${to.getFullYear()}-${String(to.getMonth() + 1).padStart(2, '0')}-${String(
to.getDate(),
).padStart(2, '0')}T23:59:59${localTzOffset(to)}`
: undefined
onChange({ ...filters, from: fromIso, to: toIso })
}}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-line">
<Button type="button" variant="secondary" onClick={onReset}>
@@ -331,6 +331,10 @@ function CreateWebhookDialog({ open, onClose, onSuccess }: CreateDialogProps) {
value={eventTypeFilter}
onChange={(ids) => setEventTypeFilter(ids as string[])}
hint={t('webhooks.field.eventTypeFilterHint')}
searchable
searchPlaceholder={t('webhooks.field.eventTypeSearch', {
defaultValue: 'Поиск типа события',
})}
/>
<TextArea
label={t('webhooks.field.description')}
@@ -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>}
+5
View File
@@ -57,6 +57,11 @@ export {
} from './components/field'
export { FormActions, type FormActionsProps } from './components/form-actions'
export { DatePicker, type DatePickerProps } from './components/date-picker'
export {
DateRangePicker,
type DateRangePickerProps,
type DateRangePickerValue,
} from './components/date-range-picker'
export {
MultiSelect,
type MultiSelectOption,
+8
View File
@@ -138,6 +138,14 @@ export default defineConfig({
return 'vendor-monaco'
}
// react-day-picker — ~80kb gz. DateRangePicker лениво загружает
// через React.lazy (используется только на audit/filtering pages).
// Isolated chunk → on-demand fetch без trip в main bundle.
// date-fns тоже сюда — day-picker dep, не используется elsewhere.
if (id.match(/node_modules\/(react-day-picker|date-fns)\//)) {
return 'vendor-day-picker'
}
// HOTFIX: graph3d isolated chunk был причиной runtime "Super
// expression must either be null or a function" (cyclic chunk
// dependency vendor-misc ↔ vendor-graph3d ломает порядок