endfield-vision 2.2.0 → 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.
- package/README.md +5 -5
- package/README.ru.md +5 -5
- package/dist/components/Autocomplete.d.ts +37 -0
- package/dist/components/Autocomplete.d.ts.map +1 -0
- package/dist/components/Autocomplete.js +109 -0
- package/dist/components/Autocomplete.js.map +1 -0
- package/dist/components/Choice.d.ts +2 -1
- package/dist/components/Choice.d.ts.map +1 -1
- package/dist/components/Choice.js +3 -1
- package/dist/components/Choice.js.map +1 -1
- package/dist/components/CompletenessBadge.d.ts +21 -8
- package/dist/components/CompletenessBadge.d.ts.map +1 -1
- package/dist/components/CompletenessBadge.js +48 -15
- package/dist/components/CompletenessBadge.js.map +1 -1
- package/dist/components/ContextMenu.d.ts +5 -1
- package/dist/components/ContextMenu.d.ts.map +1 -1
- package/dist/components/ContextMenu.js +13 -7
- package/dist/components/ContextMenu.js.map +1 -1
- package/dist/components/NotificationCenter.d.ts.map +1 -1
- package/dist/components/NotificationCenter.js +2 -2
- package/dist/components/NotificationCenter.js.map +1 -1
- package/dist/components/Select.d.ts +5 -0
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/Select.js +7 -3
- package/dist/components/Select.js.map +1 -1
- package/dist/components/Thumbnail.d.ts +65 -0
- package/dist/components/Thumbnail.d.ts.map +1 -0
- package/dist/components/Thumbnail.js +57 -0
- package/dist/components/Thumbnail.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/i18n-messages.d.ts +5 -0
- package/dist/lib/i18n-messages.d.ts.map +1 -1
- package/dist/lib/i18n-messages.js +10 -0
- package/dist/lib/i18n-messages.js.map +1 -1
- package/dist/styles/components/communication.css +161 -0
- package/dist/styles/components/records.css +8 -1
- package/dist/styles/tokens.css +3 -0
- package/package.json +1 -1
- package/src/components/Autocomplete.tsx +221 -0
- package/src/components/Choice.tsx +5 -2
- package/src/components/CompletenessBadge.tsx +102 -46
- package/src/components/ContextMenu.tsx +11 -5
- package/src/components/NotificationCenter.tsx +2 -1
- package/src/components/Select.tsx +12 -3
- package/src/components/Thumbnail.tsx +244 -0
- package/src/index.ts +15 -0
- package/src/lib/i18n-messages.ts +15 -0
- package/src/styles/components/communication.css +161 -0
- package/src/styles/components/records.css +8 -1
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
* Тон бейджа: есть
|
|
37
|
-
*
|
|
43
|
+
* Тон бейджа: есть блокирующие причины или просроченные сроки (dangerCount) -
|
|
44
|
+
* danger; есть незаполненные - severity (warning по умолчанию); только
|
|
45
|
+
* истекающие сроки - warning, иначе success.
|
|
38
46
|
*/
|
|
39
|
-
export function 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
|
-
/**
|
|
47
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
{
|
|
88
|
-
<div className="ev-completeness-group">
|
|
89
|
-
<div className="ev-completeness-title">{
|
|
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
|
-
{
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
|
165
|
+
const blocking = (blockingProp ?? []).map(toItem)
|
|
166
|
+
const expired = (expiredProp ?? []).map(toItem)
|
|
133
167
|
const expiring = expiringProp ?? []
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
238
|
+
<Details groups={groups} onPick={pick} />
|
|
183
239
|
</Popover>
|
|
184
240
|
)
|
|
185
241
|
}
|
|
186
242
|
|
|
187
243
|
return (
|
|
188
|
-
<Tooltip content={
|
|
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
|
-
|
|
122
|
+
for (const section of sections) {
|
|
119
123
|
const items = section ? compactMenuEntries(section) : []
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
|
@@ -283,6 +283,7 @@ function BellButton({
|
|
|
283
283
|
count,
|
|
284
284
|
icon,
|
|
285
285
|
ref,
|
|
286
|
+
className,
|
|
286
287
|
...rest
|
|
287
288
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
288
289
|
label: string
|
|
@@ -297,11 +298,11 @@ function BellButton({
|
|
|
297
298
|
<button
|
|
298
299
|
ref={ref}
|
|
299
300
|
type="button"
|
|
300
|
-
className="ev-btn ev-btn-iconic ev-notify-bell"
|
|
301
301
|
data-variant="ghost"
|
|
302
302
|
data-size="md"
|
|
303
303
|
aria-label={aria}
|
|
304
304
|
{...rest}
|
|
305
|
+
className={cx('ev-btn ev-btn-iconic ev-notify-bell', className)}
|
|
305
306
|
>
|
|
306
307
|
<span className="ev-btn-icon">{icon ?? <Bell size={18} />}</span>
|
|
307
308
|
{count > 0 ? (
|
|
@@ -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
|
-
|
|
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'
|
package/src/lib/i18n-messages.ts
CHANGED
|
@@ -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`,
|