endfield-vision 2.1.1 → 2.2.1

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 (60) hide show
  1. package/README.md +29 -7
  2. package/README.ru.md +29 -7
  3. package/dist/components/CommandPalette.d.ts +24 -1
  4. package/dist/components/CommandPalette.d.ts.map +1 -1
  5. package/dist/components/CommandPalette.js +22 -10
  6. package/dist/components/CommandPalette.js.map +1 -1
  7. package/dist/components/DataTable.d.ts +38 -1
  8. package/dist/components/DataTable.d.ts.map +1 -1
  9. package/dist/components/DataTable.js +135 -25
  10. package/dist/components/DataTable.js.map +1 -1
  11. package/dist/components/Modal.d.ts +26 -0
  12. package/dist/components/Modal.d.ts.map +1 -1
  13. package/dist/components/Modal.js +92 -0
  14. package/dist/components/Modal.js.map +1 -1
  15. package/dist/components/NotificationCenter.d.ts.map +1 -1
  16. package/dist/components/NotificationCenter.js +2 -2
  17. package/dist/components/NotificationCenter.js.map +1 -1
  18. package/dist/components/Toast.d.ts +3 -0
  19. package/dist/components/Toast.d.ts.map +1 -1
  20. package/dist/components/Toast.js +5 -1
  21. package/dist/components/Toast.js.map +1 -1
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/accent-preset.d.ts +40 -0
  27. package/dist/lib/accent-preset.d.ts.map +1 -0
  28. package/dist/lib/accent-preset.js +71 -0
  29. package/dist/lib/accent-preset.js.map +1 -0
  30. package/dist/lib/i18n-messages.d.ts +7 -0
  31. package/dist/lib/i18n-messages.d.ts.map +1 -1
  32. package/dist/lib/i18n-messages.js +10 -2
  33. package/dist/lib/i18n-messages.js.map +1 -1
  34. package/dist/lib/theme-script.d.ts +10 -4
  35. package/dist/lib/theme-script.d.ts.map +1 -1
  36. package/dist/lib/theme-script.js +8 -3
  37. package/dist/lib/theme-script.js.map +1 -1
  38. package/dist/lib/theme.d.ts +10 -6
  39. package/dist/lib/theme.d.ts.map +1 -1
  40. package/dist/lib/theme.js +1 -1
  41. package/dist/lib/theme.js.map +1 -1
  42. package/dist/styles/components/data.css +120 -0
  43. package/dist/styles/components/navigation.css +6 -0
  44. package/dist/styles/components/overlay.css +43 -0
  45. package/dist/styles/shell.css +2 -0
  46. package/package.json +1 -1
  47. package/src/components/CommandPalette.tsx +68 -10
  48. package/src/components/DataTable.tsx +258 -41
  49. package/src/components/Modal.tsx +148 -1
  50. package/src/components/NotificationCenter.tsx +2 -1
  51. package/src/components/Toast.tsx +21 -1
  52. package/src/index.ts +4 -0
  53. package/src/lib/accent-preset.tsx +109 -0
  54. package/src/lib/i18n-messages.ts +17 -3
  55. package/src/lib/theme-script.tsx +19 -5
  56. package/src/lib/theme.ts +13 -8
  57. package/src/styles/components/data.css +120 -0
  58. package/src/styles/components/navigation.css +6 -0
  59. package/src/styles/components/overlay.css +43 -0
  60. package/src/styles/shell.css +2 -0
@@ -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
- const rowProps = (row: T) =>
175
- onRowClick
176
- ? {
177
- tabIndex: 0,
178
- 'data-clickable': true,
179
- onClick: (e: MouseEvent) => {
180
- if (!isInteractiveTarget(e)) onRowClick(row)
181
- },
182
- onKeyDown: (e: KeyboardEvent) => {
183
- if (e.key === 'Enter' && !isInteractiveTarget(e)) {
184
- e.preventDefault()
185
- onRowClick(row)
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="ev-table-card" data-muted={rowMuted?.(row) || undefined} data-selected={selSet.has(k) || undefined} {...rowProps(row)}>
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 className="ev-table" data-dense={dense || undefined} aria-label={aria['aria-label']} aria-busy={loading || fetching || undefined}>
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={columns.length + (selectable ? 1 : 0)}>{stateBlock}</td>
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
- <tr key={k} data-muted={rowMuted?.(row) || undefined} data-selected={selSet.has(k) || undefined} {...rowProps(row)}>
323
- {selectable ? (
324
- <td className="ev-table-select" data-row-stop="">
325
- <Checkbox checked={selSet.has(k)} onChange={() => toggleOne(k)} aria-label={t.table.selectRow} />
326
- </td>
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
- {columns.map((c) => (
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}
@@ -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,
@@ -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 ? (