endfield-vision 2.2.1 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +5 -5
  2. package/README.ru.md +5 -5
  3. package/dist/components/Autocomplete.d.ts +37 -0
  4. package/dist/components/Autocomplete.d.ts.map +1 -0
  5. package/dist/components/Autocomplete.js +109 -0
  6. package/dist/components/Autocomplete.js.map +1 -0
  7. package/dist/components/Choice.d.ts +2 -1
  8. package/dist/components/Choice.d.ts.map +1 -1
  9. package/dist/components/Choice.js +3 -1
  10. package/dist/components/Choice.js.map +1 -1
  11. package/dist/components/CompletenessBadge.d.ts +21 -8
  12. package/dist/components/CompletenessBadge.d.ts.map +1 -1
  13. package/dist/components/CompletenessBadge.js +48 -15
  14. package/dist/components/CompletenessBadge.js.map +1 -1
  15. package/dist/components/ContextMenu.d.ts +5 -1
  16. package/dist/components/ContextMenu.d.ts.map +1 -1
  17. package/dist/components/ContextMenu.js +13 -7
  18. package/dist/components/ContextMenu.js.map +1 -1
  19. package/dist/components/Select.d.ts +5 -0
  20. package/dist/components/Select.d.ts.map +1 -1
  21. package/dist/components/Select.js +7 -3
  22. package/dist/components/Select.js.map +1 -1
  23. package/dist/components/Thumbnail.d.ts +65 -0
  24. package/dist/components/Thumbnail.d.ts.map +1 -0
  25. package/dist/components/Thumbnail.js +57 -0
  26. package/dist/components/Thumbnail.js.map +1 -0
  27. package/dist/index.d.ts +3 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/lib/i18n-messages.d.ts +5 -0
  32. package/dist/lib/i18n-messages.d.ts.map +1 -1
  33. package/dist/lib/i18n-messages.js +10 -0
  34. package/dist/lib/i18n-messages.js.map +1 -1
  35. package/dist/styles/components/communication.css +161 -0
  36. package/dist/styles/components/records.css +8 -1
  37. package/dist/styles/tokens.css +3 -0
  38. package/package.json +1 -1
  39. package/src/components/Autocomplete.tsx +221 -0
  40. package/src/components/Choice.tsx +5 -2
  41. package/src/components/CompletenessBadge.tsx +102 -46
  42. package/src/components/ContextMenu.tsx +11 -5
  43. package/src/components/Select.tsx +12 -3
  44. package/src/components/Thumbnail.tsx +244 -0
  45. package/src/index.ts +15 -0
  46. package/src/lib/i18n-messages.ts +15 -0
  47. package/src/styles/components/communication.css +161 -0
  48. package/src/styles/components/records.css +8 -1
  49. package/src/styles/tokens.css +3 -0
@@ -5,14 +5,17 @@ import { useRef, useState, type ReactNode } from 'react'
5
5
  import { cx } from '../lib/cx'
6
6
  import { useMessages } from '../lib/i18n'
7
7
  import type { Messages } from '../lib/i18n-messages'
8
+ import { UiLink } from '../lib/link'
9
+ import type { Tone } from './Display'
8
10
  import { Popover } from './Popover'
9
11
  import { Tooltip } from './Tooltip'
10
12
 
11
13
  /*
12
- * Полнота данных сущности: бейдж «Заполнено» / «Не заполнено: N» /
13
- * «Истекает срок» и подсказка со списком незаполненных полей и истекающих
14
- * сроков. С onItemClick бейдж - кнопка: по нажатию - список-поповер, пункт
15
- * переводит к полю (открыть блок на правку, прокрутить к строке).
14
+ * Полнота и состояние данных сущности: бейдж «Заполнено» / «Не заполнено: N» /
15
+ * «Истекает срок» / «Просрочено» / «Блокирует выпуск» и подсказка с группами:
16
+ * блокирует, просрочено, истекает, не заполнено. С onItemClick бейдж - кнопка:
17
+ * по нажатию - список-поповер, пункт переводит к полю (открыть блок на правку,
18
+ * прокрутить к строке). С href бейдж - ссылка (например в карточку записи).
16
19
  */
17
20
 
18
21
  export interface CompletenessField {
@@ -21,7 +24,8 @@ export interface CompletenessField {
21
24
  label: ReactNode
22
25
  }
23
26
 
24
- export interface CompletenessExpiringItem {
27
+ /** Пункт группы: причина блокировки, просроченный или истекающий срок. */
28
+ export interface CompletenessItem {
25
29
  /** Ключ: с ним пункт можно нажать (onItemClick). */
26
30
  key?: string
27
31
  label: ReactNode
@@ -29,32 +33,50 @@ export interface CompletenessExpiringItem {
29
33
  date?: ReactNode
30
34
  }
31
35
 
36
+ /** Истекающий срок - то же, что CompletenessItem. */
37
+ export type CompletenessExpiringItem = CompletenessItem
38
+
32
39
  export type CompletenessSeverity = 'warning' | 'danger'
33
40
  export type CompletenessTone = 'success' | 'warning' | 'danger'
34
41
 
35
42
  /**
36
- * Тон бейджа: есть незаполненные - severity (warning по умолчанию),
37
- * только истекающие сроки - warning, иначе success.
43
+ * Тон бейджа: есть блокирующие причины или просроченные сроки (dangerCount) -
44
+ * danger; есть незаполненные - severity (warning по умолчанию); только
45
+ * истекающие сроки - warning, иначе success.
38
46
  */
39
- export function completenessTone(missingCount: number, expiringCount: number, severity: CompletenessSeverity = 'warning'): CompletenessTone {
47
+ export function completenessTone(
48
+ missingCount: number,
49
+ expiringCount: number,
50
+ severity: CompletenessSeverity = 'warning',
51
+ dangerCount = 0,
52
+ ): CompletenessTone {
53
+ if (dangerCount > 0) return 'danger'
40
54
  if (missingCount > 0) return severity
41
55
  if (expiringCount > 0) return 'warning'
42
56
  return 'success'
43
57
  }
44
58
 
45
59
  export interface CompletenessBadgeProps {
46
- /** Незаполненные поля: строка (она же ключ) или { key, label }. */
47
- missing?: Array<string | CompletenessField>
60
+ /** Причины, по которым запись не допускается к работе: тон danger, первая группа. Строка - она же ключ. */
61
+ blocking?: Array<string | CompletenessItem>
62
+ /** Просроченные сроки: тон danger. Строка - она же ключ. */
63
+ expired?: Array<string | CompletenessItem>
48
64
  /** Истекающие сроки (документы, допуски). */
49
65
  expiring?: CompletenessExpiringItem[]
66
+ /** Незаполненные поля: строка (она же ключ) или { key, label }. */
67
+ missing?: Array<string | CompletenessField>
50
68
  /** Тон при незаполненных полях: warning (не мешает работе) или danger (блокирует). */
51
69
  severity?: CompletenessSeverity
52
70
  /** Подпись, когда всё заполнено. По умолчанию - «Заполнено». */
53
71
  completeLabel?: ReactNode
54
- /** Своя подпись бейджа при незаполненных полях или сроках. */
72
+ /** Своя подпись бейджа поверх вычисленной - и при пустых группах (например «Заблокирован»). */
55
73
  label?: ReactNode
74
+ /** Свой тон бейджа поверх вычисленного - и при пустых группах. */
75
+ tone?: Tone
56
76
  /** Переход к полю: бейдж становится кнопкой со списком. */
57
77
  onItemClick?: (key: string) => void
78
+ /** Бейдж-ссылка (например в карточку записи), группы - в подсказке. Вместе с onItemClick и группами побеждает onItemClick. */
79
+ href?: string
58
80
  size?: 'sm' | 'md'
59
81
  className?: string
60
82
  }
@@ -63,17 +85,17 @@ function toField(f: string | CompletenessField): CompletenessField {
63
85
  return typeof f === 'string' ? { key: f, label: f } : f
64
86
  }
65
87
 
66
- function Details({
67
- missing,
68
- expiring,
69
- t,
70
- onPick,
71
- }: {
72
- missing: CompletenessField[]
73
- expiring: CompletenessExpiringItem[]
74
- t: Messages
75
- onPick?: (key: string) => void
76
- }) {
88
+ function toItem(f: string | CompletenessItem): CompletenessItem {
89
+ return typeof f === 'string' ? { key: f, label: f } : f
90
+ }
91
+
92
+ interface Group {
93
+ id: 'blocking' | 'expired' | 'expiring' | 'missing'
94
+ title: string
95
+ items: CompletenessItem[]
96
+ }
97
+
98
+ function Details({ groups, onPick }: { groups: Group[]; onPick?: (key: string) => void }) {
77
99
  const item = (key: string | undefined, content: ReactNode) =>
78
100
  onPick && key !== undefined ? (
79
101
  <button type="button" className="ev-completeness-item" onClick={() => onPick(key)}>
@@ -84,21 +106,11 @@ function Details({
84
106
  )
85
107
  return (
86
108
  <div className="ev-completeness-details">
87
- {missing.length > 0 ? (
88
- <div className="ev-completeness-group">
89
- <div className="ev-completeness-title">{t.completeness.missingTitle}</div>
109
+ {groups.map((g) => (
110
+ <div key={g.id} className="ev-completeness-group" data-group={g.id}>
111
+ <div className="ev-completeness-title">{g.title}</div>
90
112
  <ul className="ev-completeness-list" role="list">
91
- {missing.map((f) => (
92
- <li key={f.key}>{item(f.key, f.label)}</li>
93
- ))}
94
- </ul>
95
- </div>
96
- ) : null}
97
- {expiring.length > 0 ? (
98
- <div className="ev-completeness-group">
99
- <div className="ev-completeness-title">{t.completeness.expiring}</div>
100
- <ul className="ev-completeness-list" role="list">
101
- {expiring.map((e, i) => (
113
+ {g.items.map((e, i) => (
102
114
  <li key={e.key ?? i}>
103
115
  {item(
104
116
  e.key,
@@ -111,31 +123,63 @@ function Details({
111
123
  ))}
112
124
  </ul>
113
125
  </div>
114
- ) : null}
126
+ ))}
115
127
  </div>
116
128
  )
117
129
  }
118
130
 
131
+ /** Группы подсказки в порядке важности: блокирует, просрочено, истекает, не заполнено. */
132
+ function buildGroups(
133
+ t: Messages,
134
+ blocking: CompletenessItem[],
135
+ expired: CompletenessItem[],
136
+ expiring: CompletenessItem[],
137
+ missing: CompletenessField[],
138
+ ): Group[] {
139
+ const all: Group[] = [
140
+ { id: 'blocking', title: t.completeness.blockingTitle, items: blocking },
141
+ { id: 'expired', title: t.completeness.expired, items: expired },
142
+ { id: 'expiring', title: t.completeness.expiring, items: expiring },
143
+ { id: 'missing', title: t.completeness.missingTitle, items: missing },
144
+ ]
145
+ return all.filter((g) => g.items.length > 0)
146
+ }
147
+
119
148
  export function CompletenessBadge({
120
- missing: missingProp,
149
+ blocking: blockingProp,
150
+ expired: expiredProp,
121
151
  expiring: expiringProp,
152
+ missing: missingProp,
122
153
  severity = 'warning',
123
154
  completeLabel,
124
155
  label: labelProp,
156
+ tone: toneProp,
125
157
  onItemClick,
158
+ href,
126
159
  size = 'md',
127
160
  className,
128
161
  }: CompletenessBadgeProps) {
129
162
  const t = useMessages()
130
163
  const [open, setOpen] = useState(false)
131
164
  const triggerRef = useRef<HTMLButtonElement | null>(null)
132
- const missing = (missingProp ?? []).map(toField)
165
+ const blocking = (blockingProp ?? []).map(toItem)
166
+ const expired = (expiredProp ?? []).map(toItem)
133
167
  const expiring = expiringProp ?? []
134
- const tone = completenessTone(missing.length, expiring.length, severity)
135
- const hasDetails = missing.length > 0 || expiring.length > 0
136
- const label = !hasDetails
137
- ? (completeLabel ?? t.completeness.complete)
138
- : (labelProp ?? (missing.length > 0 ? t.completeness.missing(missing.length) : t.completeness.expiring))
168
+ const missing = (missingProp ?? []).map(toField)
169
+ const groups = buildGroups(t, blocking, expired, expiring, missing)
170
+ const tone = toneProp ?? completenessTone(missing.length, expiring.length, severity, blocking.length + expired.length)
171
+ const hasDetails = groups.length > 0
172
+ const computedLabel =
173
+ blocking.length > 0
174
+ ? t.completeness.blockingTitle
175
+ : expired.length > 0
176
+ ? t.completeness.expired
177
+ : missing.length > 0
178
+ ? t.completeness.missing(missing.length)
179
+ : expiring.length > 0
180
+ ? t.completeness.expiring
181
+ : (completeLabel ?? t.completeness.complete)
182
+ const label = labelProp ?? computedLabel
139
183
 
140
184
  const inner = (
141
185
  <>
@@ -144,6 +188,18 @@ export function CompletenessBadge({
144
188
  </>
145
189
  )
146
190
 
191
+ const details = <Details groups={groups} />
192
+
193
+ // Ссылка: группы - в подсказке у ссылки (она и так фокусируется).
194
+ if (href !== undefined && !(onItemClick && hasDetails)) {
195
+ const link = (
196
+ <UiLink href={href} className={cx('ev-badge ev-completeness ev-completeness-link', className)} data-tone={tone} data-size={size}>
197
+ {inner}
198
+ </UiLink>
199
+ )
200
+ return hasDetails ? <Tooltip content={details}>{link}</Tooltip> : link
201
+ }
202
+
147
203
  if (!hasDetails) {
148
204
  return (
149
205
  <span className={cx('ev-badge ev-completeness', className)} data-tone={tone} data-size={size}>
@@ -179,13 +235,13 @@ export function CompletenessBadge({
179
235
  </button>
180
236
  }
181
237
  >
182
- <Details missing={missing} expiring={expiring} t={t} onPick={pick} />
238
+ <Details groups={groups} onPick={pick} />
183
239
  </Popover>
184
240
  )
185
241
  }
186
242
 
187
243
  return (
188
- <Tooltip content={<Details missing={missing} expiring={expiring} t={t} />}>
244
+ <Tooltip content={details}>
189
245
  <span className={cx('ev-badge ev-completeness', className)} data-tone={tone} data-size={size} tabIndex={0}>
190
246
  {inner}
191
247
  </span>
@@ -112,15 +112,21 @@ export function compactMenuEntries(entries: MenuEntry[]): MenuEntry[] {
112
112
  return out
113
113
  }
114
114
 
115
- /** Склейка групп пунктов через разделитель; пустые группы пропускаются. */
115
+ /**
116
+ * Склейка групп пунктов через разделитель; пустые группы пропускаются.
117
+ * id разделителя - из id соседних пунктов: уникален и при повторной склейке
118
+ * уже склеенных групп.
119
+ */
116
120
  export function joinMenuSections(sections: Array<MenuEntry[] | null | undefined>): MenuEntry[] {
117
121
  const out: MenuEntry[] = []
118
- sections.forEach((section, i) => {
122
+ for (const section of sections) {
119
123
  const items = section ? compactMenuEntries(section) : []
120
- if (items.length === 0) return
121
- if (out.length > 0) out.push({ type: 'separator', id: `ev-ctx-sep-${i}` })
124
+ const prev = out[out.length - 1]
125
+ const next = items[0]
126
+ if (!next) continue
127
+ if (prev) out.push({ type: 'separator', id: `ev-ctx-sep:${prev.id}:${next.id}` })
122
128
  out.push(...items)
123
- })
129
+ }
124
130
  return out
125
131
  }
126
132
 
@@ -13,7 +13,7 @@ import {
13
13
  } from 'react'
14
14
  import { useMessages } from '../lib/i18n'
15
15
  import { cx, normalizeSearch } from '../lib/cx'
16
- import { useEventCallback, useIsoLayoutEffect, useOutsideClick } from '../lib/hooks'
16
+ import { useEventCallback, useIsoLayoutEffect, useMounted, useOutsideClick } from '../lib/hooks'
17
17
  import { Portal, useEscapeLayer, useFloating } from '../lib/overlay'
18
18
  import { useFieldProps } from './Field'
19
19
  import type { ControlSize } from './Input'
@@ -163,6 +163,11 @@ interface CommonSelectProps<V extends string> {
163
163
  dropdownMinWidth?: number
164
164
  /** Серверный поиск: опции приходят снаружи, локальной фильтрации нет. */
165
165
  onSearch?: (query: string) => void
166
+ /**
167
+ * Список раскрыт сразу после монтирования, фокус в поиске (или в списке).
168
+ * Серверная разметка - закрытый список, раскрытие - после гидратации.
169
+ */
170
+ defaultOpen?: boolean
166
171
  loading?: boolean
167
172
  icon?: ReactNode
168
173
  'aria-label'?: string
@@ -183,9 +188,13 @@ export interface SelectProps<V extends string = string> extends CommonSelectProp
183
188
  function useSelectCore<V extends string>(props: CommonSelectProps<V>, initialActive: () => number) {
184
189
  const { options, searchable: searchableProp, onSearch } = props
185
190
  const listId = useId()
186
- const [open, setOpen] = useState(false)
191
+ // Раскрытие при монтировании: состояние сразу открытое, но список показывается
192
+ // только на клиенте - серверная разметка и гидратация не меняются.
193
+ const mounted = useMounted()
194
+ const [openState, setOpen] = useState(() => Boolean(props.defaultOpen && !props.disabled))
195
+ const open = openState && mounted
187
196
  const [query, setQuery] = useState('')
188
- const [activeIndex, setActiveIndex] = useState(-1)
197
+ const [activeIndex, setActiveIndex] = useState(() => (openState ? initialActive() : -1))
189
198
  const triggerRef = useRef<HTMLButtonElement | null>(null)
190
199
  const wrapRef = useRef<HTMLDivElement | null>(null)
191
200
  const popRef = useRef<HTMLDivElement | null>(null)
@@ -0,0 +1,244 @@
1
+ 'use client'
2
+
3
+ import { ImageOff } from 'lucide-react'
4
+ import { useCallback, useState, type CSSProperties, type ReactNode } from 'react'
5
+ import { cx } from '../lib/cx'
6
+ import { useMessages } from '../lib/i18n'
7
+ import { Lightbox, type LightboxImage } from './Lightbox'
8
+
9
+ /*
10
+ * Миниатюра изображения: кадр с заданными пропорциями (обрезка cover или
11
+ * вписывание contain), подпись снизу, значок поверх (например «видео»),
12
+ * состояния загрузки и ошибки. С onClick кадр - кнопка. ThumbnailGrid -
13
+ * сетка превью с переходом в Lightbox по индексу.
14
+ */
15
+
16
+ /** Пропорции кадра: число (ширина / высота) или строка CSS aspect-ratio ('4/3', '16/9'). */
17
+ export type ThumbnailAspect = number | string
18
+
19
+ export interface ThumbnailProps {
20
+ src: string
21
+ /** Описание для скринридера; '' - декоративное изображение. */
22
+ alt: string
23
+ /** Подпись под кадром. */
24
+ caption?: ReactNode
25
+ /** Пропорции кадра (по умолчанию 4/3). */
26
+ aspect?: ThumbnailAspect
27
+ /** cover - обрезать по кадру (по умолчанию), contain - вписать целиком. */
28
+ fit?: 'cover' | 'contain'
29
+ /** Значок в углу кадра: «видео», «3 стр.». */
30
+ badge?: ReactNode
31
+ /** Знак по центру кадра, например иконка воспроизведения у видео. */
32
+ overlay?: ReactNode
33
+ /** Кадр ещё готовится снаружи (загрузка файла, обработка): скелетон поверх. */
34
+ loading?: boolean
35
+ onClick?: () => void
36
+ /** Подпись кнопки, если отличается от alt. */
37
+ 'aria-label'?: string
38
+ /** Ширина миниатюры (по умолчанию 100% контейнера). */
39
+ width?: number | string
40
+ className?: string
41
+ }
42
+
43
+ type LoadState = 'loading' | 'loaded' | 'error'
44
+
45
+ function aspectValue(aspect: ThumbnailAspect): string {
46
+ return typeof aspect === 'number' ? String(aspect) : aspect
47
+ }
48
+
49
+ /**
50
+ * Состояние загрузки по src: смена адреса - снова загрузка, без сброса в эффекте.
51
+ * Ref-callback ловит снимки, загруженные до гидратации (их onLoad React не видит).
52
+ */
53
+ function useImageState(src: string): [LoadState, (next: LoadState) => void, (img: HTMLImageElement | null) => void] {
54
+ const [state, setState] = useState<{ src: string; value: LoadState } | null>(null)
55
+ const value: LoadState = state?.src === src ? state.value : 'loading'
56
+ const settle = useCallback((next: LoadState) => setState({ src, value: next }), [src])
57
+ const watch = useCallback(
58
+ (img: HTMLImageElement | null) => {
59
+ if (img?.complete) settle(img.naturalWidth > 0 ? 'loaded' : 'error')
60
+ },
61
+ [settle],
62
+ )
63
+ return [value, settle, watch]
64
+ }
65
+
66
+ export function Thumbnail({
67
+ src,
68
+ alt,
69
+ caption,
70
+ aspect = '4/3',
71
+ fit = 'cover',
72
+ badge,
73
+ overlay,
74
+ loading = false,
75
+ onClick,
76
+ 'aria-label': ariaLabel,
77
+ width,
78
+ className,
79
+ }: ThumbnailProps) {
80
+ const t = useMessages()
81
+ const [loadState, settle, watchImage] = useImageState(src)
82
+ const state: LoadState = loading ? 'loading' : loadState
83
+ const style = { '--ev-thumb-aspect': aspectValue(aspect), width } as CSSProperties
84
+
85
+ const frame = (
86
+ <>
87
+ {/* eslint-disable-next-line @next/next/no-img-element */}
88
+ <img
89
+ ref={watchImage}
90
+ className="ev-thumb-img"
91
+ src={src}
92
+ alt={alt}
93
+ loading="lazy"
94
+ decoding="async"
95
+ draggable={false}
96
+ onLoad={() => settle('loaded')}
97
+ onError={() => settle('error')}
98
+ />
99
+ {state === 'loading' ? <span className="ev-thumb-skeleton" aria-hidden="true" /> : null}
100
+ {state === 'error' ? (
101
+ <span className="ev-thumb-error">
102
+ <ImageOff size={20} aria-hidden="true" />
103
+ <span>{t.thumbnail.error}</span>
104
+ </span>
105
+ ) : null}
106
+ {overlay && state === 'loaded' ? (
107
+ <span className="ev-thumb-overlay" aria-hidden="true">
108
+ {overlay}
109
+ </span>
110
+ ) : null}
111
+ {badge ? <span className="ev-thumb-badge">{badge}</span> : null}
112
+ </>
113
+ )
114
+
115
+ return (
116
+ <figure className={cx('ev-thumb', className)} data-state={state} data-fit={fit} style={style}>
117
+ {onClick ? (
118
+ <button
119
+ type="button"
120
+ className="ev-thumb-frame"
121
+ aria-label={ariaLabel ?? (alt || undefined)}
122
+ aria-busy={state === 'loading' || undefined}
123
+ onClick={onClick}
124
+ >
125
+ {frame}
126
+ </button>
127
+ ) : (
128
+ <div className="ev-thumb-frame" aria-busy={state === 'loading' || undefined}>
129
+ {frame}
130
+ </div>
131
+ )}
132
+ {caption ? <figcaption className="ev-thumb-caption">{caption}</figcaption> : null}
133
+ </figure>
134
+ )
135
+ }
136
+
137
+ export interface ThumbnailGridItem {
138
+ /** Ключ элемента; по умолчанию - src. */
139
+ key?: string
140
+ /** Превью. */
141
+ src: string
142
+ alt: string
143
+ caption?: ReactNode
144
+ badge?: ReactNode
145
+ overlay?: ReactNode
146
+ loading?: boolean
147
+ /** Полный снимок для Lightbox (по умолчанию src). */
148
+ fullSrc?: string
149
+ /** Имя файла в Lightbox и при скачивании. */
150
+ name?: string
151
+ downloadUrl?: string
152
+ /** Своё действие вместо Lightbox (например видео); такой элемент в Lightbox не попадает. */
153
+ onClick?: () => void
154
+ }
155
+
156
+ export interface ThumbnailGridProps {
157
+ items: ThumbnailGridItem[]
158
+ aspect?: ThumbnailAspect
159
+ fit?: 'cover' | 'contain'
160
+ /** Минимальная ширина колонки, px (по умолчанию 140): колонок столько, сколько помещается. */
161
+ minWidth?: number
162
+ /** Просмотр в Lightbox по нажатию (по умолчанию да). */
163
+ lightbox?: boolean
164
+ /** Нажатие на элемент (вызывается и при открытии Lightbox). */
165
+ onItemClick?: (index: number) => void
166
+ /** Lightbox: с последнего снимка - на первый и обратно. */
167
+ loop?: boolean
168
+ /** Lightbox: кнопка скачивания (по умолчанию да). */
169
+ download?: boolean
170
+ 'aria-label'?: string
171
+ className?: string
172
+ }
173
+
174
+ /** Сетка превью: колонки по ширине контейнера, просмотр в Lightbox. */
175
+ export function ThumbnailGrid({
176
+ items,
177
+ aspect,
178
+ fit,
179
+ minWidth = 140,
180
+ lightbox = true,
181
+ onItemClick,
182
+ loop,
183
+ download,
184
+ 'aria-label': ariaLabel,
185
+ className,
186
+ }: ThumbnailGridProps) {
187
+ const [viewing, setViewing] = useState<number | null>(null)
188
+ // В Lightbox - только элементы без своего действия; номер элемента сетки -> номер снимка.
189
+ const shots: LightboxImage[] = []
190
+ const shotIndex = items.map((it) => {
191
+ if (it.onClick) return -1
192
+ shots.push({ src: it.fullSrc ?? it.src, alt: it.alt, name: it.name, downloadUrl: it.downloadUrl })
193
+ return shots.length - 1
194
+ })
195
+ const clickable = lightbox || Boolean(onItemClick)
196
+
197
+ return (
198
+ <>
199
+ <ul
200
+ role="list"
201
+ aria-label={ariaLabel}
202
+ className={cx('ev-thumb-grid', className)}
203
+ style={{ '--ev-thumb-min': `${minWidth}px` } as CSSProperties}
204
+ >
205
+ {items.map((it, i) => {
206
+ const shot = shotIndex[i] ?? -1
207
+ const open =
208
+ it.onClick ?? (clickable
209
+ ? () => {
210
+ onItemClick?.(i)
211
+ if (lightbox && shot >= 0) setViewing(shot)
212
+ }
213
+ : undefined)
214
+ return (
215
+ <li key={it.key ?? it.src}>
216
+ <Thumbnail
217
+ src={it.src}
218
+ alt={it.alt}
219
+ caption={it.caption}
220
+ badge={it.badge}
221
+ overlay={it.overlay}
222
+ loading={it.loading}
223
+ aspect={aspect}
224
+ fit={fit}
225
+ onClick={open}
226
+ />
227
+ </li>
228
+ )
229
+ })}
230
+ </ul>
231
+ {lightbox && shots.length > 0 ? (
232
+ <Lightbox
233
+ images={shots}
234
+ open={viewing !== null}
235
+ onClose={() => setViewing(null)}
236
+ index={viewing ?? 0}
237
+ onIndexChange={setViewing}
238
+ loop={loop}
239
+ download={download}
240
+ />
241
+ ) : null}
242
+ </>
243
+ )
244
+ }
package/src/index.ts CHANGED
@@ -250,6 +250,12 @@ export { checkedIds, checkedLeafSet, flattenVisibleTree, indexTree, toggleTreeCh
250
250
  export { Slider, type SliderProps, type SliderMark, type SliderRange } from './components/Slider'
251
251
  export { Chip, ChipGroup, type ChipProps, type ChipGroupProps } from './components/Chip'
252
252
  export { TagInput, type TagInputProps } from './components/TagInput'
253
+ export {
254
+ Autocomplete,
255
+ filterAutocomplete,
256
+ type AutocompleteProps,
257
+ type AutocompleteSuggestion,
258
+ } from './components/Autocomplete'
253
259
  export { OtpInput, type OtpInputProps, type OtpMode } from './components/OtpInput'
254
260
  export { InlineEdit, type InlineEditProps } from './components/InlineEdit'
255
261
 
@@ -281,6 +287,7 @@ export {
281
287
  type CompletenessBadgeProps,
282
288
  type CompletenessField,
283
289
  type CompletenessExpiringItem,
290
+ type CompletenessItem,
284
291
  type CompletenessSeverity,
285
292
  type CompletenessTone,
286
293
  } from './components/CompletenessBadge'
@@ -386,4 +393,12 @@ export {
386
393
  type NotificationListProps,
387
394
  } from './components/NotificationCenter'
388
395
  export { Lightbox, type LightboxImage, type LightboxProps } from './components/Lightbox'
396
+ export {
397
+ Thumbnail,
398
+ ThumbnailGrid,
399
+ type ThumbnailAspect,
400
+ type ThumbnailGridItem,
401
+ type ThumbnailGridProps,
402
+ type ThumbnailProps,
403
+ } from './components/Thumbnail'
389
404
  export { ConnectionStatus, type ConnectionState, type ConnectionStatusProps } from './components/ConnectionStatus'
@@ -241,6 +241,11 @@ export interface Messages {
241
241
  missing: (n: number) => string
242
242
  missingTitle: string
243
243
  expiring: string
244
+ expired: string
245
+ blockingTitle: string
246
+ }
247
+ thumbnail: {
248
+ error: string
244
249
  }
245
250
  badgeStack: {
246
251
  more: (n: number) => string
@@ -607,6 +612,11 @@ export const ru: Messages = {
607
612
  missing: (n) => `Не заполнено: ${n}`,
608
613
  missingTitle: 'Не заполнено',
609
614
  expiring: 'Истекает срок',
615
+ expired: 'Просрочено',
616
+ blockingTitle: 'Блокирует выпуск',
617
+ },
618
+ thumbnail: {
619
+ error: 'Не удалось загрузить',
610
620
  },
611
621
  badgeStack: {
612
622
  more: (n) => `Ещё ${n}`,
@@ -959,6 +969,11 @@ export const en: Messages = {
959
969
  missing: (n) => `${n} missing`,
960
970
  missingTitle: 'Missing',
961
971
  expiring: 'Expiring soon',
972
+ expired: 'Expired',
973
+ blockingTitle: 'Blocking',
974
+ },
975
+ thumbnail: {
976
+ error: 'Couldn’t load',
962
977
  },
963
978
  badgeStack: {
964
979
  more: (n) => `${n} more`,