endfield-vision 2.1.0 → 2.2.0
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 +31 -9
- package/README.ru.md +31 -9
- package/dist/components/CommandPalette.d.ts +24 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +22 -10
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/DataTable.d.ts +38 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/DataTable.js +135 -25
- package/dist/components/DataTable.js.map +1 -1
- package/dist/components/Modal.d.ts +26 -0
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/Modal.js +92 -0
- package/dist/components/Modal.js.map +1 -1
- package/dist/components/Toast.d.ts +3 -0
- package/dist/components/Toast.d.ts.map +1 -1
- package/dist/components/Toast.js +5 -1
- package/dist/components/Toast.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/accent-preset.d.ts +40 -0
- package/dist/lib/accent-preset.d.ts.map +1 -0
- package/dist/lib/accent-preset.js +71 -0
- package/dist/lib/accent-preset.js.map +1 -0
- package/dist/lib/i18n-messages.d.ts +7 -0
- package/dist/lib/i18n-messages.d.ts.map +1 -1
- package/dist/lib/i18n-messages.js +10 -2
- package/dist/lib/i18n-messages.js.map +1 -1
- package/dist/lib/theme-script.d.ts +10 -4
- package/dist/lib/theme-script.d.ts.map +1 -1
- package/dist/lib/theme-script.js +8 -3
- package/dist/lib/theme-script.js.map +1 -1
- package/dist/lib/theme.d.ts +10 -6
- package/dist/lib/theme.d.ts.map +1 -1
- package/dist/lib/theme.js +1 -1
- package/dist/lib/theme.js.map +1 -1
- package/dist/styles/accents.css +6 -6
- package/dist/styles/components/data.css +120 -0
- package/dist/styles/components/navigation.css +6 -0
- package/dist/styles/components/overlay.css +43 -0
- package/dist/styles/shell.css +2 -0
- package/dist/styles/tokens.css +27 -27
- package/package.json +1 -1
- package/src/components/CommandPalette.tsx +68 -10
- package/src/components/DataTable.tsx +258 -41
- package/src/components/Modal.tsx +148 -1
- package/src/components/Toast.tsx +21 -1
- package/src/index.ts +4 -0
- package/src/lib/accent-preset.tsx +109 -0
- package/src/lib/i18n-messages.ts +17 -3
- package/src/lib/theme-script.tsx +19 -5
- package/src/lib/theme.ts +13 -8
- package/src/styles/accents.css +6 -6
- package/src/styles/components/data.css +120 -0
- package/src/styles/components/navigation.css +6 -0
- package/src/styles/components/overlay.css +43 -0
- package/src/styles/shell.css +2 -0
- package/src/styles/tokens.css +27 -27
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, SlidersHorizontal } from 'lucide-react'
|
|
4
|
-
import { useEffect, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
|
4
|
+
import { Fragment, useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode, type RefObject } from 'react'
|
|
5
5
|
import { useMessages } from '../lib/i18n'
|
|
6
6
|
import { cx, EMPTY_VALUE } from '../lib/cx'
|
|
7
|
-
import { useMediaQuery } from '../lib/hooks'
|
|
7
|
+
import { useControllable, useMediaQuery } from '../lib/hooks'
|
|
8
8
|
import { Button } from './Button'
|
|
9
9
|
import { Checkbox } from './Choice'
|
|
10
|
-
import { Skeleton } from './Display'
|
|
10
|
+
import { Skeleton, type Tone } from './Display'
|
|
11
11
|
import { SearchInput } from './Input'
|
|
12
12
|
import { EmptyState, ErrorState } from './Page'
|
|
13
13
|
import { Select } from './Select'
|
|
14
14
|
|
|
15
15
|
/*
|
|
16
16
|
* Таблица данных: колонки конфигом, сортируемые заголовки (сортирует
|
|
17
|
-
* вызывающий - обычно сервер), выбор строк, клик по строке,
|
|
18
|
-
*
|
|
19
|
-
* экране), пустое состояние и ошибка.
|
|
17
|
+
* вызывающий - обычно сервер), выбор строк, клик по строке, раскрывающиеся
|
|
18
|
+
* строки, итоговая строка, скелетон при первой загрузке, приглушение при
|
|
19
|
+
* перезапросе (старые строки остаются на экране), пустое состояние и ошибка.
|
|
20
|
+
* На узком экране - карточки.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
export type SortDir = 'asc' | 'desc'
|
|
@@ -30,6 +31,11 @@ export interface Column<T> {
|
|
|
30
31
|
header: ReactNode
|
|
31
32
|
/** Содержимое ячейки. Пустое значение (null, undefined, '') - приглушённый дефис. */
|
|
32
33
|
cell: (row: T, index: number) => ReactNode
|
|
34
|
+
/**
|
|
35
|
+
* Ячейка итоговой строки (<tfoot>): значение или функция от строк таблицы.
|
|
36
|
+
* Строка итогов появляется, если она задана хотя бы у одной колонки.
|
|
37
|
+
*/
|
|
38
|
+
footerCell?: ReactNode | ((rows: T[]) => ReactNode)
|
|
33
39
|
/** Колонка сортируется; ключ сортировки - sortKey или key. */
|
|
34
40
|
sortable?: boolean
|
|
35
41
|
sortKey?: string
|
|
@@ -49,6 +55,9 @@ export interface Column<T> {
|
|
|
49
55
|
className?: string
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/** Атрибуты строки от приложения: только data-* и aria-*. */
|
|
59
|
+
export type RowAttributes = Partial<Record<`data-${string}` | `aria-${string}`, string | number | boolean | undefined>>
|
|
60
|
+
|
|
52
61
|
export interface DataTableProps<T> {
|
|
53
62
|
columns: Column<T>[]
|
|
54
63
|
rows: T[] | undefined
|
|
@@ -75,9 +84,31 @@ export interface DataTableProps<T> {
|
|
|
75
84
|
onSelectedChange?: (keys: string[]) => void
|
|
76
85
|
/** Приглушить строку (заблокированный, архивный). */
|
|
77
86
|
rowMuted?: (row: T) => boolean
|
|
87
|
+
/** Тон строки - тонированный фон: просрочено, новое, требует внимания. */
|
|
88
|
+
rowTone?: (row: T) => Tone | undefined
|
|
89
|
+
/**
|
|
90
|
+
* Свои атрибуты строки (<tr> или карточки): data-* и aria-*, например
|
|
91
|
+
* метки контекстного меню. Служебные атрибуты таблицы важнее.
|
|
92
|
+
*/
|
|
93
|
+
rowAttributes?: (row: T) => RowAttributes | undefined
|
|
94
|
+
rowClassName?: (row: T) => string | undefined
|
|
95
|
+
/** Содержимое раскрытой строки: под ней, на всю ширину таблицы. */
|
|
96
|
+
renderExpanded?: (row: T) => ReactNode
|
|
97
|
+
/** Можно ли раскрыть строку (по умолчанию - любую, если задан renderExpanded). */
|
|
98
|
+
rowExpandable?: (row: T) => boolean
|
|
99
|
+
/** Раскрытые строки по ключам (управляемо). */
|
|
100
|
+
expanded?: string[]
|
|
101
|
+
defaultExpanded?: string[]
|
|
102
|
+
onExpandedChange?: (keys: string[]) => void
|
|
78
103
|
skeletonRows?: number
|
|
79
104
|
/** Высота области прокрутки: шапка липнет к её верху. */
|
|
80
105
|
maxHeight?: number | string
|
|
106
|
+
/**
|
|
107
|
+
* Шапка липнет к верху страницы (или ближайшей области прокрутки) без maxHeight.
|
|
108
|
+
* true - под шапкой приложения (--ev-table-sticky-top, в AppShell - высота шапки),
|
|
109
|
+
* число - отступ сверху в px.
|
|
110
|
+
*/
|
|
111
|
+
stickyHeader?: boolean | number
|
|
81
112
|
dense?: boolean
|
|
82
113
|
/** Подвал: пагинация, итоги. */
|
|
83
114
|
footer?: ReactNode
|
|
@@ -95,6 +126,16 @@ function renderCell(v: ReactNode): ReactNode {
|
|
|
95
126
|
return isEmpty(v) ? <span className="ev-empty-value">{EMPTY_VALUE}</span> : v
|
|
96
127
|
}
|
|
97
128
|
|
|
129
|
+
/** Атрибуты строки от приложения: всё, кроме data-* и aria-*, отбрасывается. */
|
|
130
|
+
export function pickRowAttributes(attrs: RowAttributes | undefined): RowAttributes {
|
|
131
|
+
const out: RowAttributes = {}
|
|
132
|
+
if (!attrs) return out
|
|
133
|
+
for (const [k, v] of Object.entries(attrs) as [keyof RowAttributes, RowAttributes[keyof RowAttributes]][]) {
|
|
134
|
+
if (v !== undefined && /^(data|aria)-[\w-]+$/.test(k)) out[k] = v
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
98
139
|
/**
|
|
99
140
|
* Клик по кнопке, ссылке или полю внутри строки не открывает строку.
|
|
100
141
|
* События из порталов (окно, меню, список, открытые из ячейки) всплывают
|
|
@@ -117,6 +158,65 @@ export function nextSort(current: SortState | null | undefined, key: string, cle
|
|
|
117
158
|
return clearable ? null : { key, dir: 'asc' }
|
|
118
159
|
}
|
|
119
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Сдвиг шапки, липнущей к странице: насколько верх таблицы ушёл выше границы
|
|
163
|
+
* (верх области прокрутки плюс отступ), но не дальше конца таблицы.
|
|
164
|
+
*/
|
|
165
|
+
export function stickyHeadShift(boundary: number, tableTop: number, tableHeight: number, headHeight: number, footHeight = 0): number {
|
|
166
|
+
const max = Math.max(0, tableHeight - headHeight - footHeight)
|
|
167
|
+
return Math.min(max, Math.max(0, boundary - tableTop))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function scrollParent(el: HTMLElement): HTMLElement | null {
|
|
171
|
+
for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {
|
|
172
|
+
const oy = getComputedStyle(p).overflowY
|
|
173
|
+
if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') return p
|
|
174
|
+
}
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Шапка таблицы у верха страницы. Обёртка таблицы прокручивается по
|
|
180
|
+
* горизонтали, поэтому position: sticky внутри неё к странице не липнет:
|
|
181
|
+
* шапку сдвигает transform по прокрутке (кадр за кадром, без перерисовки React).
|
|
182
|
+
*/
|
|
183
|
+
function usePageStickyHead(ref: RefObject<HTMLTableElement | null>, enabled: boolean, offset: number | undefined) {
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const table = ref.current
|
|
186
|
+
const head = table?.tHead
|
|
187
|
+
if (!enabled || !table || !head) return
|
|
188
|
+
const scroller = scrollParent(table.closest<HTMLElement>('.ev-table-wrap') ?? table)
|
|
189
|
+
let frame = 0
|
|
190
|
+
const update = () => {
|
|
191
|
+
frame = 0
|
|
192
|
+
const top = offset ?? (scroller ? 0 : parseFloat(getComputedStyle(table).getPropertyValue('--ev-table-sticky-top')) || 0)
|
|
193
|
+
const boundary = (scroller ? scroller.getBoundingClientRect().top : 0) + top
|
|
194
|
+
const shift = stickyHeadShift(boundary, table.getBoundingClientRect().top, table.offsetHeight, head.offsetHeight, table.tFoot?.offsetHeight)
|
|
195
|
+
table.style.setProperty('--ev-thead-shift', `${shift}px`)
|
|
196
|
+
if (shift > 0) table.dataset.stuck = ''
|
|
197
|
+
else delete table.dataset.stuck
|
|
198
|
+
}
|
|
199
|
+
const schedule = () => {
|
|
200
|
+
if (!frame) frame = requestAnimationFrame(update)
|
|
201
|
+
}
|
|
202
|
+
update()
|
|
203
|
+
const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)
|
|
204
|
+
ro?.observe(table)
|
|
205
|
+
window.addEventListener('scroll', schedule, { capture: true, passive: true })
|
|
206
|
+
window.addEventListener('resize', schedule)
|
|
207
|
+
return () => {
|
|
208
|
+
cancelAnimationFrame(frame)
|
|
209
|
+
ro?.disconnect()
|
|
210
|
+
window.removeEventListener('scroll', schedule, { capture: true })
|
|
211
|
+
window.removeEventListener('resize', schedule)
|
|
212
|
+
table.style.removeProperty('--ev-thead-shift')
|
|
213
|
+
delete table.dataset.stuck
|
|
214
|
+
}
|
|
215
|
+
}, [ref, enabled, offset])
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const NO_KEYS: string[] = []
|
|
219
|
+
|
|
120
220
|
export function DataTable<T>({
|
|
121
221
|
columns,
|
|
122
222
|
rows,
|
|
@@ -134,8 +234,17 @@ export function DataTable<T>({
|
|
|
134
234
|
selected,
|
|
135
235
|
onSelectedChange,
|
|
136
236
|
rowMuted,
|
|
237
|
+
rowTone,
|
|
238
|
+
rowAttributes,
|
|
239
|
+
rowClassName,
|
|
240
|
+
renderExpanded,
|
|
241
|
+
rowExpandable,
|
|
242
|
+
expanded: expandedProp,
|
|
243
|
+
defaultExpanded = NO_KEYS,
|
|
244
|
+
onExpandedChange,
|
|
137
245
|
skeletonRows = 6,
|
|
138
246
|
maxHeight,
|
|
247
|
+
stickyHeader = false,
|
|
139
248
|
dense = false,
|
|
140
249
|
footer,
|
|
141
250
|
mobile = 'cards',
|
|
@@ -143,6 +252,8 @@ export function DataTable<T>({
|
|
|
143
252
|
...aria
|
|
144
253
|
}: DataTableProps<T>) {
|
|
145
254
|
const t = useMessages()
|
|
255
|
+
const uid = useId()
|
|
256
|
+
const tableRef = useRef<HTMLTableElement | null>(null)
|
|
146
257
|
const empty = emptyProp ?? t.table.empty
|
|
147
258
|
const narrow = useMediaQuery('(max-width: 720px)')
|
|
148
259
|
const asCards = narrow && mobile === 'cards'
|
|
@@ -152,6 +263,14 @@ export function DataTable<T>({
|
|
|
152
263
|
const pageKeys = list.map(rowKey)
|
|
153
264
|
const allOnPage = pageKeys.length > 0 && pageKeys.every((k) => selSet.has(k))
|
|
154
265
|
const someOnPage = pageKeys.some((k) => selSet.has(k))
|
|
266
|
+
const [expanded, setExpanded] = useControllable(expandedProp, defaultExpanded, onExpandedChange)
|
|
267
|
+
const expSet = new Set(expanded)
|
|
268
|
+
const withExpand = Boolean(renderExpanded)
|
|
269
|
+
const canExpand = (row: T) => withExpand && (rowExpandable?.(row) ?? true)
|
|
270
|
+
const withTotals = columns.some((c) => c.footerCell !== undefined)
|
|
271
|
+
const colCount = columns.length + (selectable ? 1 : 0) + (withExpand ? 1 : 0)
|
|
272
|
+
const pageSticky = stickyHeader !== false && maxHeight === undefined
|
|
273
|
+
usePageStickyHead(tableRef, pageSticky && !asCards, typeof stickyHeader === 'number' ? stickyHeader : undefined)
|
|
155
274
|
|
|
156
275
|
const toggleAll = () => {
|
|
157
276
|
if (!onSelectedChange) return
|
|
@@ -162,6 +281,9 @@ export function DataTable<T>({
|
|
|
162
281
|
if (!onSelectedChange) return
|
|
163
282
|
onSelectedChange(selSet.has(k) ? (selected ?? []).filter((x) => x !== k) : [...(selected ?? []), k])
|
|
164
283
|
}
|
|
284
|
+
const toggleExpanded = (k: string) => {
|
|
285
|
+
setExpanded(expSet.has(k) ? expanded.filter((x) => x !== k) : [...expanded, k])
|
|
286
|
+
}
|
|
165
287
|
|
|
166
288
|
const cycleSort = (col: Column<T>) => {
|
|
167
289
|
onSortChange?.(nextSort(sort, col.sortKey ?? col.key, sortClearable))
|
|
@@ -170,23 +292,55 @@ export function DataTable<T>({
|
|
|
170
292
|
const showSkeleton = loading && list.length === 0
|
|
171
293
|
const showError = !showSkeleton && error && list.length === 0
|
|
172
294
|
const showEmpty = !showSkeleton && !showError && list.length === 0
|
|
295
|
+
const totalOf = (c: Column<T>): ReactNode => (typeof c.footerCell === 'function' ? c.footerCell(list) : c.footerCell)
|
|
296
|
+
|
|
297
|
+
/** Атрибуты строки: свои от приложения, поверх - служебные (клик, выбор, тон). */
|
|
298
|
+
const rowProps = (row: T, k: string) => {
|
|
299
|
+
// Без onRowClick клик по раскрываемой строке раскрывает её.
|
|
300
|
+
const activate = onRowClick ? () => onRowClick(row) : canExpand(row) ? () => toggleExpanded(k) : undefined
|
|
301
|
+
return {
|
|
302
|
+
...pickRowAttributes(rowAttributes?.(row)),
|
|
303
|
+
className: rowClassName?.(row) || undefined,
|
|
304
|
+
'data-tone': rowTone?.(row) || undefined,
|
|
305
|
+
'data-muted': rowMuted?.(row) || undefined,
|
|
306
|
+
'data-selected': selSet.has(k) || undefined,
|
|
307
|
+
'data-expanded': (withExpand && expSet.has(k)) || undefined,
|
|
308
|
+
...(activate
|
|
309
|
+
? {
|
|
310
|
+
tabIndex: 0,
|
|
311
|
+
'data-clickable': true,
|
|
312
|
+
onClick: (e: MouseEvent) => {
|
|
313
|
+
if (!isInteractiveTarget(e)) activate()
|
|
314
|
+
},
|
|
315
|
+
onKeyDown: (e: KeyboardEvent) => {
|
|
316
|
+
if (e.key === 'Enter' && !isInteractiveTarget(e)) {
|
|
317
|
+
e.preventDefault()
|
|
318
|
+
activate()
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
: {}),
|
|
323
|
+
}
|
|
324
|
+
}
|
|
173
325
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
326
|
+
// id содержимого раскрытой строки - по позиции: ключ строки может содержать пробелы.
|
|
327
|
+
const detailId = (i: number) => `${uid}-x${i}`
|
|
328
|
+
const expandButton = (row: T, k: string, i: number) => {
|
|
329
|
+
if (!canExpand(row)) return null
|
|
330
|
+
const open = expSet.has(k)
|
|
331
|
+
return (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
className="ev-table-expand-btn"
|
|
335
|
+
aria-expanded={open}
|
|
336
|
+
aria-controls={open ? detailId(i) : undefined}
|
|
337
|
+
aria-label={open ? t.table.collapseRow : t.table.expandRow}
|
|
338
|
+
onClick={() => toggleExpanded(k)}
|
|
339
|
+
>
|
|
340
|
+
<ChevronRight size={15} aria-hidden="true" />
|
|
341
|
+
</button>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
190
344
|
|
|
191
345
|
const stateBlock = showError ? (
|
|
192
346
|
<ErrorState compact message={error?.message} onRetry={onRetry} />
|
|
@@ -197,6 +351,8 @@ export function DataTable<T>({
|
|
|
197
351
|
if (asCards) {
|
|
198
352
|
const primary = columns.find((c) => c.primary) ?? columns[0]
|
|
199
353
|
const rest = columns.filter((c) => c !== primary && !c.hideOnMobile)
|
|
354
|
+
const restTotals = withTotals ? rest.filter((c) => !isEmpty(totalOf(c))) : []
|
|
355
|
+
const primaryTotal = withTotals && primary ? totalOf(primary) : null
|
|
200
356
|
return (
|
|
201
357
|
<div className={cx('ev-table-cards', className)} data-fetching={fetching || undefined} aria-busy={loading || fetching || undefined}>
|
|
202
358
|
{selectable && list.length > 0 ? (
|
|
@@ -216,8 +372,9 @@ export function DataTable<T>({
|
|
|
216
372
|
{stateBlock ? <div className="ev-table-state">{stateBlock}</div> : null}
|
|
217
373
|
{list.map((row, i) => {
|
|
218
374
|
const k = rowKey(row)
|
|
375
|
+
const { className: own, ...props } = rowProps(row, k)
|
|
219
376
|
return (
|
|
220
|
-
<div key={k} className=
|
|
377
|
+
<div key={k} className={cx('ev-table-card', own)} {...props}>
|
|
221
378
|
<div className="ev-table-card-head">
|
|
222
379
|
{selectable ? (
|
|
223
380
|
<span data-row-stop="">
|
|
@@ -225,6 +382,7 @@ export function DataTable<T>({
|
|
|
225
382
|
</span>
|
|
226
383
|
) : null}
|
|
227
384
|
<div className="ev-table-card-title">{primary ? renderCell(primary.cell(row, i)) : null}</div>
|
|
385
|
+
{expandButton(row, k, i)}
|
|
228
386
|
</div>
|
|
229
387
|
{rest.length > 0 ? (
|
|
230
388
|
<dl className="ev-table-card-fields">
|
|
@@ -236,9 +394,31 @@ export function DataTable<T>({
|
|
|
236
394
|
))}
|
|
237
395
|
</dl>
|
|
238
396
|
) : null}
|
|
397
|
+
{renderExpanded && expSet.has(k) && canExpand(row) ? (
|
|
398
|
+
<div className="ev-table-card-detail" id={detailId(i)}>
|
|
399
|
+
{renderExpanded(row)}
|
|
400
|
+
</div>
|
|
401
|
+
) : null}
|
|
239
402
|
</div>
|
|
240
403
|
)
|
|
241
404
|
})}
|
|
405
|
+
{withTotals && list.length > 0 ? (
|
|
406
|
+
<div className="ev-table-card ev-table-card-total">
|
|
407
|
+
<div className="ev-table-card-head">
|
|
408
|
+
<div className="ev-table-card-title">{isEmpty(primaryTotal) ? t.table.total : primaryTotal}</div>
|
|
409
|
+
</div>
|
|
410
|
+
{restTotals.length > 0 ? (
|
|
411
|
+
<dl className="ev-table-card-fields">
|
|
412
|
+
{restTotals.map((c) => (
|
|
413
|
+
<div key={c.key} className="ev-table-card-field">
|
|
414
|
+
<dt>{c.mobileLabel ?? c.header}</dt>
|
|
415
|
+
<dd className={cx(c.numeric && 'ev-num')}>{totalOf(c)}</dd>
|
|
416
|
+
</div>
|
|
417
|
+
))}
|
|
418
|
+
</dl>
|
|
419
|
+
) : null}
|
|
420
|
+
</div>
|
|
421
|
+
) : null}
|
|
242
422
|
{footer ? <div className="ev-table-foot">{footer}</div> : null}
|
|
243
423
|
</div>
|
|
244
424
|
)
|
|
@@ -247,9 +427,17 @@ export function DataTable<T>({
|
|
|
247
427
|
return (
|
|
248
428
|
<div className={cx('ev-table-wrap', className)} data-fetching={fetching || undefined}>
|
|
249
429
|
<div className="ev-table-scroll" style={maxHeight !== undefined ? { maxHeight } : undefined}>
|
|
250
|
-
<table
|
|
430
|
+
<table
|
|
431
|
+
ref={tableRef}
|
|
432
|
+
className="ev-table"
|
|
433
|
+
data-dense={dense || undefined}
|
|
434
|
+
data-sticky-page={pageSticky || undefined}
|
|
435
|
+
aria-label={aria['aria-label']}
|
|
436
|
+
aria-busy={loading || fetching || undefined}
|
|
437
|
+
>
|
|
251
438
|
<thead>
|
|
252
439
|
<tr>
|
|
440
|
+
{withExpand ? <th className="ev-table-expand" aria-hidden="true" /> : null}
|
|
253
441
|
{selectable ? (
|
|
254
442
|
<th className="ev-table-select" scope="col">
|
|
255
443
|
<Checkbox
|
|
@@ -298,6 +486,7 @@ export function DataTable<T>({
|
|
|
298
486
|
{showSkeleton
|
|
299
487
|
? Array.from({ length: skeletonRows }, (_, i) => (
|
|
300
488
|
<tr key={`sk${i}`} className="ev-table-skeleton-row">
|
|
489
|
+
{withExpand ? <td className="ev-table-expand" /> : null}
|
|
301
490
|
{selectable ? (
|
|
302
491
|
<td className="ev-table-select">
|
|
303
492
|
<Skeleton width={16} height={16} />
|
|
@@ -313,32 +502,60 @@ export function DataTable<T>({
|
|
|
313
502
|
: null}
|
|
314
503
|
{stateBlock ? (
|
|
315
504
|
<tr className="ev-table-state-row">
|
|
316
|
-
<td colSpan={
|
|
505
|
+
<td colSpan={colCount}>{stateBlock}</td>
|
|
317
506
|
</tr>
|
|
318
507
|
) : null}
|
|
319
508
|
{list.map((row, i) => {
|
|
320
509
|
const k = rowKey(row)
|
|
510
|
+
const open = renderExpanded && expSet.has(k) && canExpand(row)
|
|
321
511
|
return (
|
|
322
|
-
<
|
|
323
|
-
{
|
|
324
|
-
|
|
325
|
-
<
|
|
326
|
-
|
|
512
|
+
<Fragment key={k}>
|
|
513
|
+
<tr {...rowProps(row, k)}>
|
|
514
|
+
{withExpand ? (
|
|
515
|
+
<td className="ev-table-expand" data-row-stop="">
|
|
516
|
+
{expandButton(row, k, i)}
|
|
517
|
+
</td>
|
|
518
|
+
) : null}
|
|
519
|
+
{selectable ? (
|
|
520
|
+
<td className="ev-table-select" data-row-stop="">
|
|
521
|
+
<Checkbox checked={selSet.has(k)} onChange={() => toggleOne(k)} aria-label={t.table.selectRow} />
|
|
522
|
+
</td>
|
|
523
|
+
) : null}
|
|
524
|
+
{columns.map((c) => (
|
|
525
|
+
<td
|
|
526
|
+
key={c.key}
|
|
527
|
+
data-align={c.align ?? (c.numeric ? 'right' : 'left')}
|
|
528
|
+
data-wrap={c.wrap || undefined}
|
|
529
|
+
className={cx(c.numeric && 'ev-num', c.className)}
|
|
530
|
+
>
|
|
531
|
+
{renderCell(c.cell(row, i))}
|
|
532
|
+
</td>
|
|
533
|
+
))}
|
|
534
|
+
</tr>
|
|
535
|
+
{open ? (
|
|
536
|
+
<tr className="ev-table-detail-row" id={detailId(i)}>
|
|
537
|
+
<td colSpan={colCount}>
|
|
538
|
+
<div className="ev-table-detail">{renderExpanded(row)}</div>
|
|
539
|
+
</td>
|
|
540
|
+
</tr>
|
|
327
541
|
) : null}
|
|
328
|
-
|
|
329
|
-
<td
|
|
330
|
-
key={c.key}
|
|
331
|
-
data-align={c.align ?? (c.numeric ? 'right' : 'left')}
|
|
332
|
-
data-wrap={c.wrap || undefined}
|
|
333
|
-
className={cx(c.numeric && 'ev-num', c.className)}
|
|
334
|
-
>
|
|
335
|
-
{renderCell(c.cell(row, i))}
|
|
336
|
-
</td>
|
|
337
|
-
))}
|
|
338
|
-
</tr>
|
|
542
|
+
</Fragment>
|
|
339
543
|
)
|
|
340
544
|
})}
|
|
341
545
|
</tbody>
|
|
546
|
+
{withTotals && list.length > 0 ? (
|
|
547
|
+
<tfoot>
|
|
548
|
+
<tr>
|
|
549
|
+
{withExpand ? <td className="ev-table-expand" /> : null}
|
|
550
|
+
{selectable ? <td className="ev-table-select" /> : null}
|
|
551
|
+
{columns.map((c) => (
|
|
552
|
+
<td key={c.key} data-align={c.align ?? (c.numeric ? 'right' : 'left')} className={cx(c.numeric && 'ev-num', c.className)}>
|
|
553
|
+
{totalOf(c)}
|
|
554
|
+
</td>
|
|
555
|
+
))}
|
|
556
|
+
</tr>
|
|
557
|
+
</tfoot>
|
|
558
|
+
) : null}
|
|
342
559
|
</table>
|
|
343
560
|
</div>
|
|
344
561
|
{footer ? <div className="ev-table-foot">{footer}</div> : null}
|
package/src/components/Modal.tsx
CHANGED
|
@@ -17,11 +17,13 @@ import { cx } from '../lib/cx'
|
|
|
17
17
|
import { useIsoLayoutEffect } from '../lib/hooks'
|
|
18
18
|
import { Portal, useEscapeLayer, useFocusTrap, useScrollLock } from '../lib/overlay'
|
|
19
19
|
import { Button, type ButtonVariant } from './Button'
|
|
20
|
+
import { Field } from './Field'
|
|
21
|
+
import { Input, Textarea } from './Input'
|
|
20
22
|
import { toast } from './Toast'
|
|
21
23
|
|
|
22
24
|
/*
|
|
23
25
|
* Окна. Modal - декларативный компонент; useModals() - императивный API
|
|
24
|
-
* (open / confirm / alert) поверх него. Ловушка фокуса, возврат фокуса на
|
|
26
|
+
* (open / confirm / alert / prompt) поверх него. Ловушка фокуса, возврат фокуса на
|
|
25
27
|
* инициатора, Escape закрывает только верхнее окно, подложка не закрывает
|
|
26
28
|
* окно во время сохранения. На узком экране окно - на весь экран.
|
|
27
29
|
*/
|
|
@@ -281,6 +283,31 @@ export interface ConfirmOptions {
|
|
|
281
283
|
onOk?: () => unknown
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
export interface PromptOptions {
|
|
287
|
+
title: ReactNode
|
|
288
|
+
message?: ReactNode
|
|
289
|
+
subtitle?: ReactNode
|
|
290
|
+
/** Подпись поля. */
|
|
291
|
+
label?: ReactNode
|
|
292
|
+
placeholder?: string
|
|
293
|
+
defaultValue?: string
|
|
294
|
+
/** Подсказка под полем. */
|
|
295
|
+
hint?: ReactNode
|
|
296
|
+
/** Многострочное поле (причина, комментарий): Enter - перенос строки, Ctrl/Cmd+Enter - подтвердить. */
|
|
297
|
+
multiline?: boolean
|
|
298
|
+
/** Пустое значение не принимается (по умолчанию да). */
|
|
299
|
+
required?: boolean
|
|
300
|
+
maxLength?: number
|
|
301
|
+
okLabel?: string
|
|
302
|
+
cancelLabel?: string
|
|
303
|
+
okVariant?: 'primary' | 'danger'
|
|
304
|
+
size?: ModalSize
|
|
305
|
+
/** Проверка значения (без пробелов по краям): текст ошибки под полем или пусто. */
|
|
306
|
+
validate?: (value: string) => ReactNode
|
|
307
|
+
/** Выполнить действие внутри окна: false - не закрывать. Ошибка остаётся в окне. */
|
|
308
|
+
onOk?: (value: string) => unknown
|
|
309
|
+
}
|
|
310
|
+
|
|
284
311
|
export interface AlertOptions {
|
|
285
312
|
title: ReactNode
|
|
286
313
|
message?: ReactNode
|
|
@@ -292,6 +319,8 @@ export interface ModalsApi {
|
|
|
292
319
|
open: <R = unknown>(opts: ModalOptions<R>) => ModalHandle<R>
|
|
293
320
|
confirm: (opts: ConfirmOptions) => Promise<boolean>
|
|
294
321
|
alert: (opts: AlertOptions) => Promise<void>
|
|
322
|
+
/** Окно с полем ввода. Значение без пробелов по краям; null - окно закрыли без ответа. */
|
|
323
|
+
prompt: (opts: PromptOptions) => Promise<string | null>
|
|
295
324
|
close: (id: string, result?: unknown) => void
|
|
296
325
|
closeAll: () => void
|
|
297
326
|
}
|
|
@@ -315,6 +344,7 @@ export const modals: ModalsApi = {
|
|
|
315
344
|
},
|
|
316
345
|
confirm: (o) => (globalApi ? globalApi.confirm(o) : Promise.resolve(false)),
|
|
317
346
|
alert: (o) => (globalApi ? globalApi.alert(o) : Promise.resolve()),
|
|
347
|
+
prompt: (o) => (globalApi ? globalApi.prompt(o) : Promise.resolve(null)),
|
|
318
348
|
close: (id, r) => globalApi?.close(id, r),
|
|
319
349
|
closeAll: () => globalApi?.closeAll(),
|
|
320
350
|
}
|
|
@@ -434,10 +464,55 @@ export function ModalsProvider({ children }: { children: ReactNode }) {
|
|
|
434
464
|
footer: { buttons: [{ label: o.okLabel ?? tRef.current.common.ok, variant: 'primary', autoFocus: true }] },
|
|
435
465
|
}).result.then(() => undefined)
|
|
436
466
|
|
|
467
|
+
const prompt = (o: PromptOptions): Promise<string | null> => {
|
|
468
|
+
const submitRef: PromptSubmitRef = { current: () => undefined }
|
|
469
|
+
// Enter в поле во время сохранения не запускает действие второй раз.
|
|
470
|
+
let pending = false
|
|
471
|
+
const accept = async (ctx: ModalButtonContext<string>, value: string) => {
|
|
472
|
+
if (!o.onOk) {
|
|
473
|
+
ctx.close(value)
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
if (pending) return
|
|
477
|
+
pending = true
|
|
478
|
+
ctx.setBusy(true)
|
|
479
|
+
try {
|
|
480
|
+
const r = await o.onOk(value)
|
|
481
|
+
if (r !== false) ctx.close(value)
|
|
482
|
+
} catch (err) {
|
|
483
|
+
toast.error(tRef.current.modal.actionFailed, { description: errorMessage(err) })
|
|
484
|
+
} finally {
|
|
485
|
+
pending = false
|
|
486
|
+
ctx.setBusy(false)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const h = open<string>({
|
|
490
|
+
title: o.title,
|
|
491
|
+
subtitle: o.subtitle,
|
|
492
|
+
size: o.size ?? 'sm',
|
|
493
|
+
body: (ctx) => <PromptBody options={o} submitRef={submitRef} onAccept={(v) => void accept(ctx, v)} />,
|
|
494
|
+
footer: {
|
|
495
|
+
buttons: [
|
|
496
|
+
{ label: o.cancelLabel ?? tRef.current.common.cancel, variant: 'ghost' },
|
|
497
|
+
{
|
|
498
|
+
label: o.okLabel ?? tRef.current.common.ok,
|
|
499
|
+
variant: o.okVariant ?? 'primary',
|
|
500
|
+
// Фокус всё равно получает поле (оно раньше в разметке); флаг - для спиннера занятости.
|
|
501
|
+
autoFocus: true,
|
|
502
|
+
closeOnClick: false,
|
|
503
|
+
onClick: () => submitRef.current(),
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
},
|
|
507
|
+
})
|
|
508
|
+
return h.result.then((r) => (typeof r === 'string' ? r : null))
|
|
509
|
+
}
|
|
510
|
+
|
|
437
511
|
return {
|
|
438
512
|
open,
|
|
439
513
|
confirm,
|
|
440
514
|
alert,
|
|
515
|
+
prompt,
|
|
441
516
|
close,
|
|
442
517
|
closeAll: () => {
|
|
443
518
|
const list = listRef.current
|
|
@@ -468,6 +543,78 @@ export function ModalsProvider({ children }: { children: ReactNode }) {
|
|
|
468
543
|
)
|
|
469
544
|
}
|
|
470
545
|
|
|
546
|
+
interface PromptSubmitRef {
|
|
547
|
+
current: () => void
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Тело prompt: поле со своей проверкой. Кнопка подтверждения в подвале вызывает submitRef.current. */
|
|
551
|
+
function PromptBody({
|
|
552
|
+
options: o,
|
|
553
|
+
submitRef,
|
|
554
|
+
onAccept,
|
|
555
|
+
}: {
|
|
556
|
+
options: PromptOptions
|
|
557
|
+
submitRef: PromptSubmitRef
|
|
558
|
+
onAccept: (value: string) => void
|
|
559
|
+
}) {
|
|
560
|
+
const t = useMessages()
|
|
561
|
+
const [value, setValue] = useState(o.defaultValue ?? '')
|
|
562
|
+
const [error, setError] = useState<ReactNode>(null)
|
|
563
|
+
const required = o.required ?? true
|
|
564
|
+
const submit = () => {
|
|
565
|
+
const v = value.trim()
|
|
566
|
+
const problem = required && !v ? t.modal.required : o.validate?.(v)
|
|
567
|
+
if (problem) setError(problem)
|
|
568
|
+
else onAccept(v)
|
|
569
|
+
}
|
|
570
|
+
useIsoLayoutEffect(() => {
|
|
571
|
+
submitRef.current = submit
|
|
572
|
+
})
|
|
573
|
+
const change = (next: string) => {
|
|
574
|
+
setValue(next)
|
|
575
|
+
if (error) setError(null)
|
|
576
|
+
}
|
|
577
|
+
const common = {
|
|
578
|
+
value,
|
|
579
|
+
placeholder: o.placeholder,
|
|
580
|
+
maxLength: o.maxLength,
|
|
581
|
+
'data-autofocus': '',
|
|
582
|
+
'aria-label': o.label ? undefined : typeof o.title === 'string' ? o.title : undefined,
|
|
583
|
+
}
|
|
584
|
+
return (
|
|
585
|
+
<div className="ev-modal-prompt">
|
|
586
|
+
{o.message ? <div className="ev-modal-message">{o.message}</div> : null}
|
|
587
|
+
<Field label={o.label} hint={o.hint} error={error} required={required && Boolean(o.label)}>
|
|
588
|
+
{o.multiline ? (
|
|
589
|
+
<Textarea
|
|
590
|
+
{...common}
|
|
591
|
+
autoResize
|
|
592
|
+
showCount={Boolean(o.maxLength)}
|
|
593
|
+
onChange={(e) => change(e.target.value)}
|
|
594
|
+
onKeyDown={(e) => {
|
|
595
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.nativeEvent.isComposing) {
|
|
596
|
+
e.preventDefault()
|
|
597
|
+
submit()
|
|
598
|
+
}
|
|
599
|
+
}}
|
|
600
|
+
/>
|
|
601
|
+
) : (
|
|
602
|
+
<Input
|
|
603
|
+
{...common}
|
|
604
|
+
onChange={(e) => change(e.target.value)}
|
|
605
|
+
onKeyDown={(e) => {
|
|
606
|
+
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
|
607
|
+
e.preventDefault()
|
|
608
|
+
submit()
|
|
609
|
+
}
|
|
610
|
+
}}
|
|
611
|
+
/>
|
|
612
|
+
)}
|
|
613
|
+
</Field>
|
|
614
|
+
</div>
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
|
|
471
618
|
function ModalEntry({
|
|
472
619
|
entry,
|
|
473
620
|
close,
|