feat(ui): searchable MultiSelect (cmdk) + DateRangePicker (react-day-picker)
This commit is contained in:
@@ -31,6 +31,7 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^10.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-force-graph-3d": "^1.29.1",
|
"react-force-graph-3d": "^1.29.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
|||||||
Generated
+46
@@ -68,6 +68,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
d3-force:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
@@ -92,6 +95,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.5
|
version: 19.2.5
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(react@19.2.5)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.5(react@19.2.5)
|
version: 19.2.5(react@19.2.5)
|
||||||
@@ -378,6 +384,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
'@date-fns/tz@1.4.1':
|
||||||
|
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0':
|
'@dimforge/rapier3d-compat@0.12.0':
|
||||||
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
|
||||||
|
|
||||||
@@ -1959,6 +1968,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@1.9.3:
|
||||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||||
|
|
||||||
@@ -2121,6 +2136,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
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:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -3246,6 +3264,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||||
hasBin: true
|
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:
|
react-dom@19.2.5:
|
||||||
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4149,6 +4173,8 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/css-tokenizer@4.0.0': {}
|
'@csstools/css-tokenizer@4.0.0': {}
|
||||||
|
|
||||||
|
'@date-fns/tz@1.4.1': {}
|
||||||
|
|
||||||
'@dimforge/rapier3d-compat@0.12.0': {}
|
'@dimforge/rapier3d-compat@0.12.0': {}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
@@ -5597,6 +5623,18 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
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:
|
color-convert@1.9.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.3
|
color-name: 1.1.3
|
||||||
@@ -5758,6 +5796,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@noble/hashes'
|
- '@noble/hashes'
|
||||||
|
|
||||||
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -6731,6 +6771,12 @@ snapshots:
|
|||||||
minimist: 1.2.8
|
minimist: 1.2.8
|
||||||
strip-json-comments: 2.0.1
|
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):
|
react-dom@19.2.5(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import Ajv, { type ErrorObject } from 'ajv'
|
import Ajv, { type ErrorObject } from 'ajv'
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
parseFormDate,
|
parseFormDate,
|
||||||
} from '@/lib/dates'
|
} from '@/lib/dates'
|
||||||
import { pickLocalized } from '@/lib/locales'
|
import { pickLocalized } from '@/lib/locales'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
businessKey: string
|
businessKey: string
|
||||||
@@ -361,21 +362,112 @@ export const SchemaDrivenForm = ({
|
|||||||
// strip сверху (sticky), он же выполняет функцию quick-jump.
|
// strip сверху (sticky), он же выполняет функцию quick-jump.
|
||||||
const formContainerRef = useRef<HTMLFormElement>(null)
|
const formContainerRef = useRef<HTMLFormElement>(null)
|
||||||
|
|
||||||
|
// Scroll-spy: IntersectionObserver следит за section anchors, highlight'ит
|
||||||
|
// chip активной секции (= видимая ближе всего к верху scroll viewport'а).
|
||||||
|
// rootMargin '-44px 0px -60% 0px' создаёт «detection band» в верхней 40%
|
||||||
|
// viewport'а: header sticky ~44px сверху, section считается «активной»
|
||||||
|
// если её top попадает в этот диапазон. Это даёт ощущение «active = что
|
||||||
|
// юзер сейчас читает», а не «что в самом верху». threshold 0 — fires при
|
||||||
|
// любом пересечении границы, дешевле чем массив тысяч threshold'ов.
|
||||||
|
//
|
||||||
|
// Сохраняем Map<id, isIntersecting> и считаем active как первую секцию
|
||||||
|
// в sectionOrder у которой isIntersecting=true. Это устойчивее чем
|
||||||
|
// «entries[0].target.id» — observer callback приходит batched и порядок
|
||||||
|
// entries не гарантирован.
|
||||||
|
const [activeSection, setActiveSection] = useState<SectionId | null>(null)
|
||||||
|
const intersectingRef = useRef<Map<SectionId, boolean>>(new Map())
|
||||||
|
|
||||||
|
const handleIntersect = useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
const map = intersectingRef.current
|
||||||
|
for (const entry of entries) {
|
||||||
|
const id = (entry.target as HTMLElement).dataset.sectionId as SectionId | undefined
|
||||||
|
if (!id) continue
|
||||||
|
map.set(id, entry.isIntersecting)
|
||||||
|
}
|
||||||
|
// Pick first in sectionOrder that is currently intersecting. Fallback —
|
||||||
|
// оставляем предыдущий active (избегаем «мигания» в null когда юзер
|
||||||
|
// между секциями в gap-промежутке).
|
||||||
|
for (const id of sectionOrder) {
|
||||||
|
if (map.get(id)) {
|
||||||
|
setActiveSection(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sectionOrder],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAllSections) {
|
||||||
|
setActiveSection(null)
|
||||||
|
intersectingRef.current.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const form = formContainerRef.current
|
||||||
|
if (!form) return
|
||||||
|
// root: ближайший scrollable ancestor — drawer body. null = viewport,
|
||||||
|
// но для embedded drawer'а правильнее scoped root. Ищем через closest
|
||||||
|
// overflow-y-auto. Если не найдено — fallback на null (window scroll).
|
||||||
|
let root: Element | null = form.parentElement
|
||||||
|
while (root && root !== document.body) {
|
||||||
|
const style = window.getComputedStyle(root)
|
||||||
|
if (
|
||||||
|
style.overflowY === 'auto' ||
|
||||||
|
style.overflowY === 'scroll' ||
|
||||||
|
style.overflowY === 'overlay'
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
root = root.parentElement
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(handleIntersect, {
|
||||||
|
root: root && root !== document.body ? root : null,
|
||||||
|
// Top 44px reserved для chip strip sticky. Bottom -60% — секция
|
||||||
|
// считается active пока её top в верхних 40% viewport'а.
|
||||||
|
rootMargin: '-44px 0px -60% 0px',
|
||||||
|
threshold: 0,
|
||||||
|
})
|
||||||
|
// Observe все section anchor divs (data-section-id). Querying inside
|
||||||
|
// form ref scope чтобы не зацепить чужие элементы.
|
||||||
|
const anchors = form.querySelectorAll<HTMLElement>('[data-section-id]')
|
||||||
|
anchors.forEach((el) => observer.observe(el))
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
intersectingRef.current.clear()
|
||||||
|
}
|
||||||
|
// sectionOrder в deps чтобы при изменении schema'ы observer'ы пересоздались
|
||||||
|
// для новых anchor'ов.
|
||||||
|
}, [showAllSections, handleIntersect, sectionOrder])
|
||||||
|
|
||||||
const sectionsChipStrip = showAllSections ? (
|
const sectionsChipStrip = showAllSections ? (
|
||||||
<div className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10">
|
<div
|
||||||
{visibleTabs.map((tab) => (
|
className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10"
|
||||||
<button
|
role="navigation"
|
||||||
key={tab.id}
|
aria-label={t('drawer.sectionsNav', { defaultValue: 'Секции формы' })}
|
||||||
type="button"
|
>
|
||||||
onClick={() => scrollToSection(tab.id)}
|
{visibleTabs.map((tab) => {
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm bg-surface-2 border border-line hover:bg-accent-bg hover:border-accent text-cap text-ink-2 hover:text-accent transition-colors"
|
const isActive = activeSection === tab.id
|
||||||
>
|
return (
|
||||||
{tab.label}
|
<button
|
||||||
<span className="text-mono text-mute">
|
key={tab.id}
|
||||||
{filteredBuckets[tab.id]?.length ?? 0}
|
type="button"
|
||||||
</span>
|
onClick={() => scrollToSection(tab.id)}
|
||||||
</button>
|
aria-current={isActive ? 'location' : undefined}
|
||||||
))}
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-cap transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-bg border-accent text-accent'
|
||||||
|
: 'bg-surface-2 border-line text-ink-2 hover:bg-accent-bg/40 hover:border-accent/60 hover:text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span className={cn('text-mono', isActive ? 'text-accent/80' : 'text-mute')}>
|
||||||
|
{filteredBuckets[tab.id]?.length ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
@@ -411,6 +503,7 @@ export const SchemaDrivenForm = ({
|
|||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
id={`form-section-${formId ?? 'default'}-${id}`}
|
id={`form-section-${formId ?? 'default'}-${id}`}
|
||||||
|
data-section-id={id}
|
||||||
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
|
className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
|
||||||
>
|
>
|
||||||
{showAllSections && (isIdentity || fields.length > 0) && (
|
{showAllSections && (isIdentity || fields.length > 0) && (
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ import { cn } from '@/lib/utils'
|
|||||||
* localStorage key {@code ord-drawer-wide}.</li>
|
* localStorage key {@code ord-drawer-wide}.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Phase 1 (current): drawer shell + width toggle + sticky chrome.
|
* <p>Status (v2.12+):
|
||||||
* Phase 2 (TODO Stage 3.5b): section anchor chips, wide-mode ToC rail
|
* <ul>
|
||||||
* (180px), search field, "только заполненные" toggle. Требует {@code x-section}
|
* <li>✅ Drawer shell + width toggle + sticky chrome (Phase 1).</li>
|
||||||
* metadata в schemaJson — пока секционирование делает SchemaDrivenForm своими
|
* <li>✅ Toolbar: search полей + «только заполненные» (props.toolbar).</li>
|
||||||
* 3 tabs.
|
* <li>✅ Section anchor chips + scroll-spy active highlight — рендерит
|
||||||
|
* SchemaDrivenForm в sections layout (sticky chip strip с
|
||||||
|
* IntersectionObserver).</li>
|
||||||
|
* <li>❌ Wide-mode left ToC rail — отклонено по user feedback
|
||||||
|
* («в редакторе записей левая навигация лишняя»); chip strip
|
||||||
|
* выполняет ту же функцию quick-jump компактнее.</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Drives form submit через атрибут {@code form={formId}} на footer Save
|
* <p>Drives form submit через атрибут {@code form={formId}} на footer Save
|
||||||
* button. SchemaDrivenForm должен получить тот же {@code formId} prop и
|
* button. SchemaDrivenForm должен получить тот же {@code formId} prop и
|
||||||
@@ -226,9 +232,9 @@ export function RecordDrawer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === BODY (scrolling) ===
|
{/* === BODY (scrolling) ===
|
||||||
* Phase B остатки (deferred — требует x-section schema metadata):
|
* SchemaDrivenForm в sections layout рендерит свой sticky chip
|
||||||
* - section anchor chips strip над form sections
|
* strip + IntersectionObserver scroll-spy. Drawer body — просто
|
||||||
* - wide-mode left ToC rail (180px) в CSS grid 180px/1fr layout */}
|
* overflow-y-auto контейнер, scroll root для observer'а. */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
|
||||||
{/* === FOOTER (sticky bottom) === */}
|
{/* === FOOTER (sticky bottom) === */}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ i18n
|
|||||||
'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все',
|
'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все',
|
||||||
'webhooks.field.eventTypeFilter': 'Типы событий',
|
'webhooks.field.eventTypeFilter': 'Типы событий',
|
||||||
'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Пусто = все события.',
|
'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Пусто = все события.',
|
||||||
|
'webhooks.field.eventTypeSearch': 'Поиск типа события',
|
||||||
'webhooks.field.description': 'Описание',
|
'webhooks.field.description': 'Описание',
|
||||||
'webhooks.field.createdBy': 'Создатель',
|
'webhooks.field.createdBy': 'Создатель',
|
||||||
'webhooks.error.nameUrlRequired': 'Имя и URL обязательны',
|
'webhooks.error.nameUrlRequired': 'Имя и URL обязательны',
|
||||||
@@ -234,6 +235,7 @@ i18n
|
|||||||
'audit.filter.businessKey': 'Бизнес-ключ',
|
'audit.filter.businessKey': 'Бизнес-ключ',
|
||||||
'audit.filter.from': 'С',
|
'audit.filter.from': 'С',
|
||||||
'audit.filter.to': 'По',
|
'audit.filter.to': 'По',
|
||||||
|
'audit.filter.range': 'Период',
|
||||||
'audit.filter.reset': 'Сбросить',
|
'audit.filter.reset': 'Сбросить',
|
||||||
'audit.filter.apply': 'Применить',
|
'audit.filter.apply': 'Применить',
|
||||||
'audit.filter.active': 'Активные фильтры',
|
'audit.filter.active': 'Активные фильтры',
|
||||||
@@ -381,6 +383,9 @@ i18n
|
|||||||
'drawer.close': 'Закрыть',
|
'drawer.close': 'Закрыть',
|
||||||
'drawer.captionEdit': 'редактирование записи · {{dict}}',
|
'drawer.captionEdit': 'редактирование записи · {{dict}}',
|
||||||
'drawer.captionCreate': 'новая запись · {{dict}}',
|
'drawer.captionCreate': 'новая запись · {{dict}}',
|
||||||
|
'drawer.sectionsNav': 'Секции формы',
|
||||||
|
'drawer.searchPlaceholder': 'Поиск полей',
|
||||||
|
'drawer.onlyFilled': 'только заполненные',
|
||||||
'scope.PUBLIC': 'PUBLIC — публичный',
|
'scope.PUBLIC': 'PUBLIC — публичный',
|
||||||
'scope.INTERNAL': 'INTERNAL — внутренний',
|
'scope.INTERNAL': 'INTERNAL — внутренний',
|
||||||
'scope.RESTRICTED': 'RESTRICTED — ограниченный',
|
'scope.RESTRICTED': 'RESTRICTED — ограниченный',
|
||||||
@@ -843,6 +848,7 @@ i18n
|
|||||||
'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all',
|
'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all',
|
||||||
'webhooks.field.eventTypeFilter': 'Event types',
|
'webhooks.field.eventTypeFilter': 'Event types',
|
||||||
'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Empty = all events.',
|
'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Empty = all events.',
|
||||||
|
'webhooks.field.eventTypeSearch': 'Search event type',
|
||||||
'webhooks.field.description': 'Description',
|
'webhooks.field.description': 'Description',
|
||||||
'webhooks.field.createdBy': 'Created by',
|
'webhooks.field.createdBy': 'Created by',
|
||||||
'webhooks.error.nameUrlRequired': 'Name and URL are required',
|
'webhooks.error.nameUrlRequired': 'Name and URL are required',
|
||||||
@@ -914,6 +920,7 @@ i18n
|
|||||||
'audit.filter.businessKey': 'Business key',
|
'audit.filter.businessKey': 'Business key',
|
||||||
'audit.filter.from': 'From',
|
'audit.filter.from': 'From',
|
||||||
'audit.filter.to': 'To',
|
'audit.filter.to': 'To',
|
||||||
|
'audit.filter.range': 'Date range',
|
||||||
'audit.filter.reset': 'Reset',
|
'audit.filter.reset': 'Reset',
|
||||||
'audit.filter.apply': 'Apply',
|
'audit.filter.apply': 'Apply',
|
||||||
'audit.filter.active': 'Active filters',
|
'audit.filter.active': 'Active filters',
|
||||||
@@ -1052,6 +1059,9 @@ i18n
|
|||||||
'drawer.close': 'Close',
|
'drawer.close': 'Close',
|
||||||
'drawer.captionEdit': 'edit record · {{dict}}',
|
'drawer.captionEdit': 'edit record · {{dict}}',
|
||||||
'drawer.captionCreate': 'new record · {{dict}}',
|
'drawer.captionCreate': 'new record · {{dict}}',
|
||||||
|
'drawer.sectionsNav': 'Form sections',
|
||||||
|
'drawer.searchPlaceholder': 'Search fields',
|
||||||
|
'drawer.onlyFilled': 'only filled',
|
||||||
'form.error.required': 'Required field',
|
'form.error.required': 'Required field',
|
||||||
'scope.PUBLIC': 'PUBLIC',
|
'scope.PUBLIC': 'PUBLIC',
|
||||||
'scope.INTERNAL': 'INTERNAL',
|
'scope.INTERNAL': 'INTERNAL',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
DatePicker,
|
DateRangePicker,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingBlock,
|
LoadingBlock,
|
||||||
@@ -380,34 +380,29 @@ function FilterPanel({
|
|||||||
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
onChange={(e) => set('businessKey', e.target.value || undefined)}
|
||||||
onKeyDown={handleEnter}
|
onKeyDown={handleEnter}
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
{/* DateRangePicker заменяет пару отдельных DatePicker'ов — один
|
||||||
label={t('audit.filter.from')}
|
* popover с двумя месяцами, выбор start/end в одном UI. ISO sтроки
|
||||||
value={fromDate}
|
* c local TZ offset формируются здесь, чтобы backend получал
|
||||||
onChange={(d) =>
|
* правильный bounds (T00:00:00 для from, T23:59:59 для to). */}
|
||||||
set(
|
<div className="md:col-span-2">
|
||||||
'from',
|
<DateRangePicker
|
||||||
d
|
label={t('audit.filter.range', { defaultValue: 'Период' })}
|
||||||
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
value={{ from: fromDate ?? undefined, to: toDate ?? undefined }}
|
||||||
d.getDate(),
|
onChange={({ from, to }) => {
|
||||||
).padStart(2, '0')}T00:00:00${localTzOffset(d)}`
|
const fromIso = from
|
||||||
: undefined,
|
? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
)
|
from.getDate(),
|
||||||
}
|
).padStart(2, '0')}T00:00:00${localTzOffset(from)}`
|
||||||
/>
|
: undefined
|
||||||
<DatePicker
|
const toIso = to
|
||||||
label={t('audit.filter.to')}
|
? `${to.getFullYear()}-${String(to.getMonth() + 1).padStart(2, '0')}-${String(
|
||||||
value={toDate}
|
to.getDate(),
|
||||||
onChange={(d) =>
|
).padStart(2, '0')}T23:59:59${localTzOffset(to)}`
|
||||||
set(
|
: undefined
|
||||||
'to',
|
onChange({ ...filters, from: fromIso, to: toIso })
|
||||||
d
|
}}
|
||||||
? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
/>
|
||||||
d.getDate(),
|
</div>
|
||||||
).padStart(2, '0')}T23:59:59${localTzOffset(d)}`
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2 border-t border-line">
|
<div className="flex justify-end gap-2 pt-2 border-t border-line">
|
||||||
<Button type="button" variant="secondary" onClick={onReset}>
|
<Button type="button" variant="secondary" onClick={onReset}>
|
||||||
|
|||||||
@@ -331,6 +331,10 @@ function CreateWebhookDialog({ open, onClose, onSuccess }: CreateDialogProps) {
|
|||||||
value={eventTypeFilter}
|
value={eventTypeFilter}
|
||||||
onChange={(ids) => setEventTypeFilter(ids as string[])}
|
onChange={(ids) => setEventTypeFilter(ids as string[])}
|
||||||
hint={t('webhooks.field.eventTypeFilterHint')}
|
hint={t('webhooks.field.eventTypeFilterHint')}
|
||||||
|
searchable
|
||||||
|
searchPlaceholder={t('webhooks.field.eventTypeSearch', {
|
||||||
|
defaultValue: 'Поиск типа события',
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
<TextArea
|
<TextArea
|
||||||
label={t('webhooks.field.description')}
|
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 * 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 * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk'
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from './popover'
|
import { Popover, PopoverTrigger, PopoverContent } from './popover'
|
||||||
import { FieldLabel, FieldHint, FieldError } from './field'
|
import { FieldLabel, FieldHint, FieldError } from './field'
|
||||||
import { Badge } from './badge'
|
import { Badge } from './badge'
|
||||||
@@ -10,11 +11,15 @@ import { cn } from '@/lib/utils'
|
|||||||
* MultiSelect — popover с inline checkboxes. Замена @nstart/ui MultiSelect.
|
* MultiSelect — popover с inline checkboxes. Замена @nstart/ui MultiSelect.
|
||||||
*
|
*
|
||||||
* <p>Trigger показывает count selected + первые 2 selected labels.
|
* <p>Trigger показывает count selected + первые 2 selected labels.
|
||||||
* Popover — список с checkboxes. Click toggle. Поиск НЕ implemented (handoff
|
* Popover — список с checkboxes. Click toggle.
|
||||||
* MultiSelect не требует) — для search-heavy lists используй cmdk Combobox
|
|
||||||
* (Stage 3.8b TODO).
|
|
||||||
*
|
*
|
||||||
* <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. */
|
/** nstart-compat: {id, label}. Use `id` key. */
|
||||||
@@ -39,6 +44,12 @@ export type MultiSelectProps = {
|
|||||||
rootClassName?: string
|
rootClassName?: string
|
||||||
/** Compact display (no badges, just count). */
|
/** Compact display (no badges, just count). */
|
||||||
compact?: boolean
|
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({
|
export function MultiSelect({
|
||||||
@@ -53,6 +64,9 @@ export function MultiSelect({
|
|||||||
disabled,
|
disabled,
|
||||||
rootClassName,
|
rootClassName,
|
||||||
compact,
|
compact,
|
||||||
|
searchable,
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyMessage,
|
||||||
}: MultiSelectProps) {
|
}: MultiSelectProps) {
|
||||||
const autoId = React.useId()
|
const autoId = React.useId()
|
||||||
const triggerId = autoId
|
const triggerId = autoId
|
||||||
@@ -118,44 +132,108 @@ export function MultiSelect({
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" className="p-1 min-w-[var(--radix-popover-trigger-width)]">
|
<PopoverContent align="start" className="p-1 min-w-[var(--radix-popover-trigger-width)]">
|
||||||
<PopoverPrimitive.Close className="hidden" />
|
<PopoverPrimitive.Close className="hidden" />
|
||||||
<ul role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto">
|
{searchable ? (
|
||||||
{options.map((opt) => {
|
<Command
|
||||||
const checked = valueSet.has((opt.id ?? opt.value ?? ''))
|
// cmdk использует label option'а как searchable value через
|
||||||
return (
|
// `value={String(label)}` — но options могут иметь React.ReactNode
|
||||||
<li
|
// label'ы (мы поддерживаем JSX в label, см. webhooks). Чтобы
|
||||||
key={(opt.id ?? opt.value ?? '')}
|
// фильтр работал, передаём value явно — toString() от label если
|
||||||
role="option"
|
// оно strings, иначе id. Custom filter handle ниже.
|
||||||
aria-selected={checked}
|
filter={(itemValue, search) => {
|
||||||
tabIndex={0}
|
if (!search) return 1
|
||||||
onClick={() => toggle((opt.id ?? opt.value ?? ''))}
|
return itemValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0
|
||||||
onKeyDown={(e) => {
|
}}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
className="flex flex-col"
|
||||||
e.preventDefault()
|
>
|
||||||
toggle((opt.id ?? opt.value ?? ''))
|
<div className="flex items-center gap-2 px-2 border-b border-line">
|
||||||
}
|
<MagnifyingGlassIcon size={14} weight="regular" className="shrink-0 text-mute" />
|
||||||
}}
|
<CommandInput
|
||||||
className={cn(
|
placeholder={searchPlaceholder ?? 'Поиск…'}
|
||||||
'flex items-center gap-2 px-2.5 py-1.5 rounded-sm cursor-pointer text-body',
|
className="flex-1 h-8 bg-transparent border-0 outline-none text-body text-ink placeholder:text-mute"
|
||||||
'hover:bg-surface-2 focus:bg-surface-2 focus:outline-none',
|
/>
|
||||||
checked && 'text-ink',
|
</div>
|
||||||
)}
|
<CommandList className="max-h-64 overflow-y-auto p-1">
|
||||||
>
|
<CommandEmpty className="px-2.5 py-4 text-body text-mute text-center">
|
||||||
<span
|
{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(
|
className={cn(
|
||||||
'inline-flex items-center justify-center size-4 rounded-sm border',
|
'flex items-center gap-2 px-2.5 py-1.5 rounded-sm cursor-pointer text-body',
|
||||||
checked
|
'hover:bg-surface-2 focus:bg-surface-2 focus:outline-none',
|
||||||
? 'bg-accent border-accent text-on-accent'
|
checked && 'text-ink',
|
||||||
: 'border-line-2 bg-surface',
|
|
||||||
)}
|
)}
|
||||||
aria-hidden="true"
|
|
||||||
>
|
>
|
||||||
{checked && <CheckIcon size={10} weight="bold" />}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
<span className="flex-1 truncate">{opt.label}</span>
|
'inline-flex items-center justify-center size-4 rounded-sm border',
|
||||||
</li>
|
checked
|
||||||
)
|
? 'bg-accent border-accent text-on-accent'
|
||||||
})}
|
: 'border-line-2 bg-surface',
|
||||||
</ul>
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{checked && <CheckIcon size={10} weight="bold" />}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{opt.label}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
|
{hint && !error && <FieldHint id={hintId}>{hint}</FieldHint>}
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export {
|
|||||||
} from './components/field'
|
} from './components/field'
|
||||||
export { FormActions, type FormActionsProps } from './components/form-actions'
|
export { FormActions, type FormActionsProps } from './components/form-actions'
|
||||||
export { DatePicker, type DatePickerProps } from './components/date-picker'
|
export { DatePicker, type DatePickerProps } from './components/date-picker'
|
||||||
|
export {
|
||||||
|
DateRangePicker,
|
||||||
|
type DateRangePickerProps,
|
||||||
|
type DateRangePickerValue,
|
||||||
|
} from './components/date-range-picker'
|
||||||
export {
|
export {
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
type MultiSelectOption,
|
type MultiSelectOption,
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ export default defineConfig({
|
|||||||
return 'vendor-monaco'
|
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
|
// HOTFIX: graph3d isolated chunk был причиной runtime "Super
|
||||||
// expression must either be null or a function" (cyclic chunk
|
// expression must either be null or a function" (cyclic chunk
|
||||||
// dependency vendor-misc ↔ vendor-graph3d ломает порядок
|
// dependency vendor-misc ↔ vendor-graph3d ломает порядок
|
||||||
|
|||||||
Reference in New Issue
Block a user