@pgcorp/ui-kit 0.3.0 → 0.4.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.
Files changed (39) hide show
  1. package/README.md +36 -0
  2. package/docs/public-api.md +8 -0
  3. package/package.json +1 -1
  4. package/src/components/layout/SAppShell.vue +3 -2
  5. package/src/components/shared/complex/STree.vue +29 -4
  6. package/src/components/shared/complex/treeAdapter.ts +2 -1
  7. package/src/components/shared/complex/types.ts +1 -12
  8. package/src/components/shared/containers/SLeftSidebar.vue +7 -2
  9. package/src/components/shared/containers/SPageHeader.css +5 -0
  10. package/src/components/shared/containers/SPageHeader.vue +1 -1
  11. package/src/components/shared/containers/SPopover.vue +2 -1
  12. package/src/components/shared/containers/SSidebarGroup.vue +10 -5
  13. package/src/components/shared/containers/sidebar.ts +2 -1
  14. package/src/components/shared/controls/SButton.vue +10 -5
  15. package/src/components/shared/controls/SCombobox.vue +47 -5
  16. package/src/components/shared/controls/SInteractiveSurface.vue +2 -1
  17. package/src/components/shared/controls/SLink.ts +1 -17
  18. package/src/components/shared/controls/SLink.vue +26 -2
  19. package/src/components/shared/data-display/SDocBlock.vue +10 -10
  20. package/src/components/shared/data-display/SLinkedSystemsList.vue +3 -3
  21. package/src/components/shared/data-display/STable.css +39 -0
  22. package/src/components/shared/data-display/STable.vue +454 -67
  23. package/src/components/shared/data-display/STooltip.vue +2 -1
  24. package/src/components/shared/data-display/SVirtualList.ts +1 -16
  25. package/src/components/shared/data-display/SVirtualList.vue +18 -2
  26. package/src/components/shared/data-display/json.ts +1 -1
  27. package/src/components/shared/data-display/table.ts +4 -29
  28. package/src/components/shared/database/SDataGrid.vue +29 -8
  29. package/src/components/shared/database/SSqlEditor.vue +3 -2
  30. package/src/components/shared/database/dataGrid.ts +1 -17
  31. package/src/components/shared/navigation/STabs.vue +2 -1
  32. package/src/composables/uiPreferencesReset.ts +2 -1
  33. package/src/composables/useClipboard.ts +2 -1
  34. package/src/composables/useSidebarPanelState.ts +3 -2
  35. package/src/internal/es2020.ts +50 -0
  36. package/src/internal/inlineTokenEditorContract.ts +4 -3
  37. package/src/internal/pointerInteractionLease.ts +2 -1
  38. package/src/internal/semanticSizing.ts +2 -2
  39. package/src/styles/tokens.css +1 -0
@@ -16,6 +16,28 @@
16
16
  >
17
17
  <thead class="s-table-header">
18
18
  <tr>
19
+ <th
20
+ v-if="validatedSelectionMode === 'multiple'"
21
+ class="s-table__control-cell"
22
+ scope="col"
23
+ data-width="2xs"
24
+ >
25
+ <SCheckbox
26
+ :model-value="allCurrentRowsSelected"
27
+ :indeterminate="someCurrentRowsSelected && !allCurrentRowsSelected"
28
+ :disabled="selectableRows.length === 0"
29
+ :aria-label="allCurrentRowsSelected ? 'Снять выбор со всех строк' : 'Выбрать все строки'"
30
+ @update:model-value="updateAllRowsSelection"
31
+ />
32
+ </th>
33
+ <th
34
+ v-if="hasRowDetails"
35
+ class="s-table__control-cell"
36
+ scope="col"
37
+ data-width="2xs"
38
+ >
39
+ <span class="s-table__assistive">Детали строки</span>
40
+ </th>
19
41
  <th
20
42
  v-for="column in validatedColumns"
21
43
  :key="column.field"
@@ -38,15 +60,24 @@
38
60
  :trailing-icon="sortIcon(column)"
39
61
  @click="requestSort(column)"
40
62
  >
41
- <slot name="column" :column="column">
42
- <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
43
- </slot>
63
+ <span
64
+ class="s-table__header-content"
65
+ :data-header-presentation="column.headerPresentation ?? 'visible'"
66
+ >
67
+ <slot name="column" :column="column">
68
+ <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
69
+ </slot>
70
+ </span>
44
71
  </SButton>
45
- <template v-else>
72
+ <span
73
+ v-else
74
+ class="s-table__header-content"
75
+ :data-header-presentation="column.headerPresentation ?? 'visible'"
76
+ >
46
77
  <slot name="column" :column="column">
47
78
  <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
48
79
  </slot>
49
- </template>
80
+ </span>
50
81
  </th>
51
82
  </tr>
52
83
  </thead>
@@ -59,44 +90,90 @@
59
90
  ? { status: 'error', message: errorMessage }
60
91
  : { status: 'empty', message: emptyMessage }"
61
92
  presentation="table-row"
62
- :colspan="validatedColumns.length || 1"
93
+ :colspan="renderedColumnCount"
63
94
  />
64
95
  <template v-else>
65
- <tr
96
+ <template
66
97
  v-for="(row, rowIndex) in validatedRows"
67
- :key="resolvedRowKeys.get(row)"
68
- :data-state="resolveRowState(row)"
69
- :aria-selected="resolvedRowKeys.get(row) === validatedSelectedRowKey ? 'true' : undefined"
70
- tabindex="0"
71
- @click="onRowClick($event, row, rowIndex)"
72
- @keydown.enter="onRowKeydown($event, row, rowIndex)"
73
- @keydown.space.prevent="onRowKeydown($event, row, rowIndex)"
98
+ :key="rowDomToken(row)"
74
99
  >
75
- <td
76
- v-for="column in validatedColumns"
77
- :key="column.field"
78
- :data-align="column.align ?? 'left'"
79
- :data-state="resolveCellState(row, column)"
100
+ <tr
101
+ :data-state="resolveRowState(row)"
102
+ :data-selected="rowIsSelected(row) || undefined"
103
+ :aria-selected="rowAriaSelected(row)"
104
+ tabindex="0"
105
+ @click="onRowClick($event, row, rowIndex)"
106
+ @contextmenu="onRowContextMenu($event, row, rowIndex)"
107
+ @keydown.enter="onRowKeydown($event, row, rowIndex)"
108
+ @keydown.space="onRowKeydown($event, row, rowIndex)"
80
109
  >
81
- <slot
82
- :name="`cell-${column.field}`"
83
- :value="resolveCellValue(row, column)"
84
- :row="row"
85
- :column="column"
86
- :row-index="rowIndex"
110
+ <td
111
+ v-if="validatedSelectionMode === 'multiple'"
112
+ class="s-table__control-cell"
113
+ >
114
+ <SCheckbox
115
+ :model-value="rowIsSelected(row)"
116
+ :disabled="!rowIsSelectable(row)"
117
+ :aria-label="rowSelectionLabel(row, rowIndex)"
118
+ @update:model-value="updateRowSelection(row, rowIndex, $event)"
119
+ />
120
+ </td>
121
+ <td v-if="hasRowDetails" class="s-table__control-cell">
122
+ <SButton
123
+ v-if="rowIsExpandable(row)"
124
+ :aria-label="rowExpansionLabel(row, rowIndex)"
125
+ appearance="text"
126
+ severity="secondary"
127
+ size="xs"
128
+ density="compact"
129
+ shape="square"
130
+ icon-only
131
+ :disclosure="{
132
+ expanded: rowIsExpanded(row),
133
+ controls: rowDetailsId(row),
134
+ iconPlacement: 'leading',
135
+ }"
136
+ @click="toggleRowExpansion(row, rowIndex)"
137
+ />
138
+ </td>
139
+ <td
140
+ v-for="column in validatedColumns"
141
+ :key="column.field"
142
+ :data-align="column.align ?? 'left'"
143
+ :data-state="resolveCellState(row, column)"
87
144
  >
88
145
  <slot
89
- name="cell"
146
+ :name="`cell-${column.field}`"
90
147
  :value="resolveCellValue(row, column)"
91
148
  :row="row"
92
149
  :column="column"
93
150
  :row-index="rowIndex"
94
151
  >
95
- {{ resolveCellValue(row, column) }}
152
+ <slot
153
+ name="cell"
154
+ :value="resolveCellValue(row, column)"
155
+ :row="row"
156
+ :column="column"
157
+ :row-index="rowIndex"
158
+ >
159
+ {{ resolveCellValue(row, column) }}
160
+ </slot>
96
161
  </slot>
97
- </slot>
98
- </td>
99
- </tr>
162
+ </td>
163
+ </tr>
164
+ <tr v-if="rowIsExpandable(row) && rowIsExpanded(row)" data-row-details>
165
+ <td :colspan="renderedColumnCount">
166
+ <div
167
+ :id="rowDetailsId(row)"
168
+ class="s-table__row-details"
169
+ role="region"
170
+ :aria-label="`Детали: ${resolveRowLabel(row, rowIndex)}`"
171
+ >
172
+ <slot name="row-details" :row="row" :row-index="rowIndex" />
173
+ </div>
174
+ </td>
175
+ </tr>
176
+ </template>
100
177
  </template>
101
178
  </tbody>
102
179
  </table>
@@ -104,25 +181,77 @@
104
181
  </template>
105
182
 
106
183
  <script lang="ts">
184
+ import type { ScrollableViewportMaxBlockSize } from '../../../internal/semanticSizing'
185
+ import type {
186
+ STableCellState,
187
+ STableColumn,
188
+ STableColumnContent,
189
+ STableColumnHeaderPresentation,
190
+ STableDensity,
191
+ STableMinWidth,
192
+ STableRowKey,
193
+ STableRowState,
194
+ STableSelectionMode,
195
+ STableSort,
196
+ STableSurface,
197
+ } from './table'
198
+
107
199
  export type {
108
200
  STableAlignment,
109
201
  STableCellState,
110
202
  STableColumn,
111
203
  STableColumnContent,
204
+ STableColumnHeaderPresentation,
112
205
  STableDensity,
113
206
  STableMinWidth,
114
- STableProps,
115
207
  STableRowKey,
116
208
  STableRowState,
209
+ STableSelectionMode,
117
210
  STableSort,
118
211
  STableSortDirection,
119
212
  STableSurface,
120
213
  } from './table'
214
+
215
+ /** Публичный generic-контракт таблицы принадлежит source-SFC runtime. / The public generic table contract is owned by the source-SFC runtime. */
216
+ export interface STableProps<
217
+ Row extends object,
218
+ RowKey extends STableRowKey,
219
+ Field extends string,
220
+ > {
221
+ columns: readonly STableColumn<Row, Field>[]
222
+ data: readonly Row[]
223
+ getRowKey: (row: Row) => RowKey
224
+ loading?: boolean
225
+ loadingMessage?: string
226
+ errorMessage?: string
227
+ emptyMessage?: string
228
+ selectedRowKey?: RowKey
229
+ selectionMode?: STableSelectionMode
230
+ selectedRowKeys?: readonly RowKey[]
231
+ isRowSelectable?: (row: Row, index: number) => boolean
232
+ expandedRowKeys?: readonly RowKey[]
233
+ isRowExpandable?: (row: Row, index: number) => boolean
234
+ getRowLabel?: (row: Row, index: number) => string
235
+ sort?: STableSort<Field> | null
236
+ maxHeight?: ScrollableViewportMaxBlockSize
237
+ density?: STableDensity
238
+ surface?: STableSurface
239
+ minWidth?: STableMinWidth
240
+ rowState?: (row: Row) => STableRowState
241
+ cellState?: (row: Row, column: STableColumn<Row, Field>) => STableCellState
242
+ /** Accessible name без visible owner. / Accessible name when there is no visible owner. */
243
+ ariaLabel?: string
244
+ /** DOM id visible owner, mutually exclusive with ariaLabel. / DOM id of the visible owner, mutually exclusive with ariaLabel. */
245
+ ariaLabelledby?: string
246
+ /** DOM id supplemental status/diagnostic owner. / DOM id of a supplemental status/diagnostic owner. */
247
+ ariaDescribedby?: string
248
+ }
121
249
  </script>
122
250
 
123
251
  <script setup lang="ts" generic="Row extends object = Record<string, unknown>, RowKey extends STableRowKey = STableRowKey, Field extends string = Extract<keyof Row, string>">
124
- import { computed, useSlots } from 'vue'
252
+ import { computed, useId, useSlots } from 'vue'
125
253
  import { ChevronDown, ChevronsUpDown, ChevronUp } from '../../icons/sputnigUiIcons'
254
+ import { hasOwn } from '../../../internal/es2020'
126
255
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
127
256
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
128
257
  import {
@@ -138,16 +267,7 @@ import {
138
267
  } from '../../../internal/runtimeContract'
139
268
  import SAsyncState from '../feedback/SAsyncState.vue'
140
269
  import SButton from '../controls/SButton.vue'
141
- import type {
142
- STableCellState,
143
- STableColumn,
144
- STableColumnContent,
145
- STableProps,
146
- STableRowKey,
147
- STableRowState,
148
- STableSort,
149
- } from './table'
150
-
270
+ import SCheckbox from '../controls/SCheckbox.vue'
151
271
  defineOptions({ inheritAttrs: false })
152
272
 
153
273
  const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
@@ -156,6 +276,12 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
156
276
  errorMessage: '',
157
277
  emptyMessage: 'Нет данных для отображения',
158
278
  selectedRowKey: undefined,
279
+ selectionMode: 'none',
280
+ selectedRowKeys: () => [],
281
+ isRowSelectable: undefined,
282
+ expandedRowKeys: () => [],
283
+ isRowExpandable: undefined,
284
+ getRowLabel: undefined,
159
285
  sort: null,
160
286
  maxHeight: 'none',
161
287
  density: 'default',
@@ -169,6 +295,10 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
169
295
  })
170
296
  const emit = defineEmits<{
171
297
  'row-click': [row: Row, index: number]
298
+ 'row-contextmenu': [row: Row, index: number, event: MouseEvent]
299
+ 'update:selectedRowKeys': [value: readonly RowKey[]]
300
+ 'update:expandedRowKeys': [value: readonly RowKey[]]
301
+ 'row-expand': [row: Row, index: number, expanded: boolean]
172
302
  'update:sort': [value: STableSort<Field> | null]
173
303
  sort: [value: STableSort<Field> | null]
174
304
  }>()
@@ -181,9 +311,13 @@ const TABLE_ALIGNMENTS = ['left', 'center', 'right'] as const
181
311
  const TABLE_ROW_STATES = ['default', 'inserted', 'updated', 'deleted'] as const
182
312
  const TABLE_CELL_STATES = ['default', 'pending'] as const
183
313
  const TABLE_COLUMN_CONTENT = ['field', 'value', 'slot'] as const
314
+ const TABLE_HEADER_PRESENTATIONS = ['visible', 'assistive'] as const
315
+ const TABLE_SELECTION_MODES = ['none', 'multiple'] as const
184
316
 
185
317
  const ownedAttrs = useOwnedAttrs({ component: 'STable', owner: 'table scroll surface' })
186
318
  useInteractiveLeafRegistration({ owner: 'STable' })
319
+ const tableInstanceId = `s-table-${useId()}`
320
+ const hasRowDetails = typeof slots['row-details'] === 'function'
187
321
 
188
322
  function eventBelongsToNestedControl(event: Event): boolean {
189
323
  const owner = event.currentTarget
@@ -196,8 +330,14 @@ function onRowClick(event: MouseEvent, row: Row, rowIndex: number): void {
196
330
  if (!eventBelongsToNestedControl(event)) emit('row-click', row, rowIndex)
197
331
  }
198
332
 
333
+ function onRowContextMenu(event: MouseEvent, row: Row, rowIndex: number): void {
334
+ if (!eventBelongsToNestedControl(event)) emit('row-contextmenu', row, rowIndex, event)
335
+ }
336
+
199
337
  function onRowKeydown(event: KeyboardEvent, row: Row, rowIndex: number): void {
200
- if (event.target === event.currentTarget) emit('row-click', row, rowIndex)
338
+ if (event.target !== event.currentTarget) return
339
+ event.preventDefault()
340
+ emit('row-click', row, rowIndex)
201
341
  }
202
342
  const validatedLoading = computed(() => validateBoolean('STable', 'loading', props.loading))
203
343
  const resolvedAccessibleName = computed(() => {
@@ -243,12 +383,24 @@ const validatedMaxHeight = computed(() => validateScrollableViewportMaxBlockSize
243
383
  'maxHeight',
244
384
  props.maxHeight,
245
385
  ))
386
+ const validatedSelectionMode = computed(() => validateExactString(
387
+ 'STable',
388
+ 'selectionMode',
389
+ props.selectionMode,
390
+ TABLE_SELECTION_MODES,
391
+ ))
246
392
 
247
393
  const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
248
394
  const fields = new Set<string>()
249
395
  for (const column of props.columns) {
250
396
  const field = validateNonEmptyString('STable', 'columns[].field', column.field)
251
397
  validateNonEmptyString('STable', `column ${JSON.stringify(field)}.header`, column.header)
398
+ const headerPresentation: STableColumnHeaderPresentation = validateExactString(
399
+ 'STable',
400
+ `column ${JSON.stringify(field)}.headerPresentation`,
401
+ column.headerPresentation ?? 'visible',
402
+ TABLE_HEADER_PRESENTATIONS,
403
+ )
252
404
  if (fields.has(field)) {
253
405
  throw new TypeError(
254
406
  `STable: columns содержит duplicate field ${JSON.stringify(field)}. `
@@ -262,6 +414,12 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
262
414
  if (column.sortable !== undefined) {
263
415
  validateBoolean('STable', `column ${JSON.stringify(field)}.sortable`, column.sortable)
264
416
  }
417
+ if (headerPresentation === 'assistive' && column.sortable) {
418
+ throw new TypeError(
419
+ `STable: assistive header column ${JSON.stringify(field)} не может быть sortable без visible action. `
420
+ + `/ STable: assistive header column ${JSON.stringify(field)} cannot be sortable without a visible action.`,
421
+ )
422
+ }
265
423
  if (column.width !== undefined) {
266
424
  validateTableColumnInlineSize('STable', `column ${JSON.stringify(field)}.width`, column.width)
267
425
  }
@@ -348,51 +506,182 @@ function describeRowKey(key: STableRowKey): string {
348
506
  return typeof key === 'string' ? JSON.stringify(key) : String(key)
349
507
  }
350
508
 
351
- function validateRowKey(value: unknown): RowKey {
509
+ function diagnosticOwner(): string {
510
+ return resolvedAccessibleName.value.ariaLabel === undefined
511
+ ? `STable [aria-labelledby=${JSON.stringify(resolvedAccessibleName.value.ariaLabelledby)}]`
512
+ : `STable ${JSON.stringify(resolvedAccessibleName.value.ariaLabel)}`
513
+ }
514
+
515
+ function describeThrownError(error: unknown): string {
516
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error)
517
+ }
518
+
519
+ function validateRowKey(value: unknown, coordinate: string): RowKey {
352
520
  if (
353
521
  (typeof value === 'string' && value.trim().length > 0)
354
522
  || (typeof value === 'number' && Number.isFinite(value))
355
523
  ) return value as RowKey
356
524
 
357
525
  throw new TypeError(
358
- `STable: getRowKey должен вернуть non-empty string | finite number. `
359
- + '/ STable: getRowKey must return a non-empty string | finite number.',
526
+ `${diagnosticOwner()}: ${coordinate} должен вернуть non-empty string | finite number. `
527
+ + `/ ${diagnosticOwner()}: ${coordinate} must return a non-empty string | finite number.`,
360
528
  )
361
529
  }
362
530
 
363
531
  const resolvedRowKeys = computed<ReadonlyMap<Row, RowKey>>(() => {
364
532
  if (typeof props.getRowKey !== 'function') {
365
533
  throw new TypeError(
366
- 'STable: getRowKey должен быть обязательным function resolver. '
367
- + '/ STable: getRowKey must be a required function resolver.',
534
+ `${diagnosticOwner()}: getRowKey должен быть обязательным function resolver. `
535
+ + `/ ${diagnosticOwner()}: getRowKey must be a required function resolver.`,
368
536
  )
369
537
  }
370
538
 
371
- const keys = new Set<STableRowKey>()
539
+ const firstIndexByKey = new Map<STableRowKey, number>()
372
540
  const rowKeys = new Map<Row, RowKey>()
373
- for (const row of props.data) {
374
- const key = validateRowKey(props.getRowKey(row))
375
- if (keys.has(key)) {
541
+ for (const [rowIndex, row] of props.data.entries()) {
542
+ let value: unknown
543
+ try {
544
+ value = props.getRowKey(row)
545
+ } catch (error: unknown) {
376
546
  throw new TypeError(
377
- `STable: getRowKey вернул duplicate key ${describeRowKey(key)}. `
378
- + `/ STable: getRowKey returned duplicate key ${describeRowKey(key)}.`,
547
+ `${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
379
548
  )
380
549
  }
381
- keys.add(key)
550
+ const key = validateRowKey(value, `getRowKey at data[${rowIndex}]`)
551
+ const firstIndex = firstIndexByKey.get(key)
552
+ if (firstIndex !== undefined) {
553
+ throw new TypeError(
554
+ `${diagnosticOwner()}: getRowKey вернул duplicate key ${describeRowKey(key)} at data[${rowIndex}]; `
555
+ + `first returned at data[${firstIndex}]. / ${diagnosticOwner()}: getRowKey returned duplicate key `
556
+ + `${describeRowKey(key)} at data[${rowIndex}]; first returned at data[${firstIndex}].`,
557
+ )
558
+ }
559
+ firstIndexByKey.set(key, rowIndex)
382
560
  rowKeys.set(row, key)
383
561
  }
384
562
  return rowKeys
385
563
  })
386
564
 
565
+ function validateControlledKeys(coordinate: string, value: unknown): readonly RowKey[] {
566
+ if (!Array.isArray(value)) {
567
+ throw new TypeError(
568
+ `${diagnosticOwner()}: ${coordinate} должен быть readonly array. `
569
+ + `/ ${diagnosticOwner()}: ${coordinate} must be a readonly array.`,
570
+ )
571
+ }
572
+ const validated: RowKey[] = []
573
+ const seen = new Set<STableRowKey>()
574
+ for (const [index, candidate] of value.entries()) {
575
+ const key = validateRowKey(candidate, `${coordinate}[${index}]`)
576
+ if (seen.has(key)) {
577
+ throw new TypeError(
578
+ `${diagnosticOwner()}: ${coordinate} содержит duplicate key ${describeRowKey(key)}. `
579
+ + `/ ${diagnosticOwner()}: ${coordinate} contains duplicate key ${describeRowKey(key)}.`,
580
+ )
581
+ }
582
+ seen.add(key)
583
+ validated.push(key)
584
+ }
585
+ return validated
586
+ }
587
+
588
+ const validatedSelectedRowKey = computed<RowKey | undefined>(() => {
589
+ if (props.selectedRowKey === undefined) return undefined
590
+ if (validatedSelectionMode.value === 'multiple') {
591
+ throw new TypeError(
592
+ `${diagnosticOwner()}: selectedRowKey несовместим с selectionMode="multiple"; используйте selectedRowKeys. `
593
+ + `/ ${diagnosticOwner()}: selectedRowKey is incompatible with selectionMode="multiple"; use selectedRowKeys.`,
594
+ )
595
+ }
596
+ const selectedKey = validateRowKey(props.selectedRowKey, 'selectedRowKey')
597
+ const rowKeys = new Set(resolvedRowKeys.value.values())
598
+ if (!rowKeys.has(selectedKey)) {
599
+ throw new TypeError(
600
+ `${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} отсутствует в resolved row keys. `
601
+ + `/ ${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} is absent from resolved row keys.`,
602
+ )
603
+ }
604
+ return selectedKey
605
+ })
606
+
607
+ const validatedSelectedRowKeys = computed<readonly RowKey[]>(() => {
608
+ const keys = validateControlledKeys('selectedRowKeys', props.selectedRowKeys)
609
+ if (validatedSelectionMode.value === 'none' && keys.length > 0) {
610
+ throw new TypeError(
611
+ `${diagnosticOwner()}: selectedRowKeys требует selectionMode="multiple". `
612
+ + `/ ${diagnosticOwner()}: selectedRowKeys requires selectionMode="multiple".`,
613
+ )
614
+ }
615
+ if (props.isRowSelectable !== undefined && validatedSelectionMode.value !== 'multiple') {
616
+ throw new TypeError(
617
+ `${diagnosticOwner()}: isRowSelectable требует selectionMode="multiple". `
618
+ + `/ ${diagnosticOwner()}: isRowSelectable requires selectionMode="multiple".`,
619
+ )
620
+ }
621
+ if (props.isRowSelectable !== undefined && typeof props.isRowSelectable !== 'function') {
622
+ throw new TypeError(`${diagnosticOwner()}: isRowSelectable must be a function.`)
623
+ }
624
+ return keys
625
+ })
626
+
627
+ const rowSelectableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
628
+ const result = new Map<Row, boolean>()
629
+ for (const [index, row] of props.data.entries()) {
630
+ const selectable = validatedSelectionMode.value === 'multiple'
631
+ ? validateBoolean('STable', `isRowSelectable at data[${index}]`, props.isRowSelectable?.(row, index) ?? true)
632
+ : false
633
+ result.set(row, selectable)
634
+ }
635
+ return result
636
+ })
637
+
638
+ const validatedExpandedRowKeys = computed<readonly RowKey[]>(() => {
639
+ const keys = validateControlledKeys('expandedRowKeys', props.expandedRowKeys)
640
+ if (!hasRowDetails && (keys.length > 0 || props.isRowExpandable !== undefined)) {
641
+ throw new TypeError(
642
+ `${diagnosticOwner()}: expandedRowKeys/isRowExpandable требует slot row-details. `
643
+ + `/ ${diagnosticOwner()}: expandedRowKeys/isRowExpandable requires the row-details slot.`,
644
+ )
645
+ }
646
+ if (props.isRowExpandable !== undefined && typeof props.isRowExpandable !== 'function') {
647
+ throw new TypeError(`${diagnosticOwner()}: isRowExpandable must be a function.`)
648
+ }
649
+ return keys
650
+ })
651
+
652
+ const rowExpandableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
653
+ const result = new Map<Row, boolean>()
654
+ for (const [index, row] of props.data.entries()) {
655
+ const expandable = hasRowDetails
656
+ ? validateBoolean('STable', `isRowExpandable at data[${index}]`, props.isRowExpandable?.(row, index) ?? true)
657
+ : false
658
+ result.set(row, expandable)
659
+ }
660
+ const expanded = new Set(validatedExpandedRowKeys.value)
661
+ for (const row of props.data) {
662
+ if (expanded.has(rowKey(row)) && !result.get(row)) {
663
+ throw new TypeError(
664
+ `${diagnosticOwner()}: expandedRowKeys содержит current row, для которой isRowExpandable вернул false. `
665
+ + `/ ${diagnosticOwner()}: expandedRowKeys contains a current row for which isRowExpandable returned false.`,
666
+ )
667
+ }
668
+ }
669
+ return result
670
+ })
671
+
387
672
  const validatedRows = computed<readonly Row[]>(() => {
388
673
  const columns = validatedColumns.value
389
674
  void validatedSort.value
390
675
  void resolvedRowKeys.value
391
676
  void validatedSelectedRowKey.value
677
+ void validatedSelectedRowKeys.value
678
+ void rowSelectableByRow.value
679
+ void validatedExpandedRowKeys.value
680
+ void rowExpandableByRow.value
392
681
  for (const row of props.data) {
393
682
  for (const column of columns) {
394
683
  const content = column.content ?? (column.value === undefined ? 'field' : 'value')
395
- if (content === 'field' && !Object.hasOwn(row, column.field)) {
684
+ if (content === 'field' && !hasOwn(row, column.field)) {
396
685
  throw new TypeError(
397
686
  `STable: column ${JSON.stringify(column.field)} не известен row; добавьте typed field или value resolver. `
398
687
  + `/ STable: column ${JSON.stringify(column.field)} is unknown to row; add a typed field or value resolver.`,
@@ -403,18 +692,116 @@ const validatedRows = computed<readonly Row[]>(() => {
403
692
  return props.data
404
693
  })
405
694
 
406
- const validatedSelectedRowKey = computed<RowKey | undefined>(() => {
407
- if (props.selectedRowKey === undefined) return undefined
408
- const selectedKey = validateRowKey(props.selectedRowKey)
409
- const rowKeys = new Set(resolvedRowKeys.value.values())
410
- if (!rowKeys.has(selectedKey)) {
411
- throw new TypeError(
412
- `STable: selectedRowKey ${describeRowKey(selectedKey)} отсутствует в resolved row keys. `
413
- + `/ STable: selectedRowKey ${describeRowKey(selectedKey)} is absent from resolved row keys.`,
414
- )
695
+ function rowKey(row: Row): RowKey {
696
+ const key = resolvedRowKeys.value.get(row)
697
+ if (key === undefined) throw new Error(`${diagnosticOwner()}: row identity is unavailable`)
698
+ return key
699
+ }
700
+
701
+ function rowKeyToken(key: RowKey): string {
702
+ return `${typeof key === 'number' ? 'number' : 'string'}:${String(key)}`
703
+ }
704
+
705
+ function rowDomToken(row: Row): string {
706
+ return rowKeyToken(rowKey(row))
707
+ }
708
+
709
+ function rowDetailsId(row: Row): string {
710
+ return `${tableInstanceId}-details-${encodeURIComponent(rowDomToken(row))}`
711
+ }
712
+
713
+ function resolveRowLabel(row: Row, index: number): string {
714
+ if (props.getRowLabel === undefined) return `Строка ${index + 1}`
715
+ if (typeof props.getRowLabel !== 'function') {
716
+ throw new TypeError(`${diagnosticOwner()}: getRowLabel must be a function.`)
415
717
  }
416
- return selectedKey
417
- })
718
+ return validateNonEmptyString('STable', `getRowLabel at data[${index}]`, props.getRowLabel(row, index))
719
+ }
720
+
721
+ const selectedKeySet = computed(() => new Set(validatedSelectedRowKeys.value))
722
+ const expandedKeySet = computed(() => new Set(validatedExpandedRowKeys.value))
723
+ const selectableRows = computed(() => props.data.filter((row) => rowSelectableByRow.value.get(row) === true))
724
+ const selectedCurrentRowCount = computed(() => selectableRows.value.filter(
725
+ (row) => selectedKeySet.value.has(rowKey(row)),
726
+ ).length)
727
+ const someCurrentRowsSelected = computed(() => selectedCurrentRowCount.value > 0)
728
+ const allCurrentRowsSelected = computed(() => (
729
+ selectableRows.value.length > 0 && selectedCurrentRowCount.value === selectableRows.value.length
730
+ ))
731
+ const renderedColumnCount = computed(() => Math.max(
732
+ validatedColumns.value.length
733
+ + (validatedSelectionMode.value === 'multiple' ? 1 : 0)
734
+ + (hasRowDetails ? 1 : 0),
735
+ 1,
736
+ ))
737
+
738
+ function rowIsSelectable(row: Row): boolean {
739
+ return rowSelectableByRow.value.get(row) === true
740
+ }
741
+
742
+ function rowIsSelected(row: Row): boolean {
743
+ return validatedSelectionMode.value === 'multiple' && selectedKeySet.value.has(rowKey(row))
744
+ }
745
+
746
+ function rowAriaSelected(row: Row): 'true' | 'false' | undefined {
747
+ if (validatedSelectionMode.value === 'multiple') return rowIsSelected(row) ? 'true' : 'false'
748
+ return rowKey(row) === validatedSelectedRowKey.value ? 'true' : undefined
749
+ }
750
+
751
+ function rowSelectionLabel(row: Row, index: number): string {
752
+ const label = resolveRowLabel(row, index)
753
+ if (!rowIsSelectable(row)) return `Выбор недоступен: ${label}`
754
+ return rowIsSelected(row) ? `Снять выбор: ${label}` : `Выбрать: ${label}`
755
+ }
756
+
757
+ function updateRowSelection(row: Row, index: number, selected: boolean): void {
758
+ void index
759
+ if (!rowIsSelectable(row)) return
760
+ const key = rowKey(row)
761
+ const current = validatedSelectedRowKeys.value
762
+ if (selected && !selectedKeySet.value.has(key)) emit('update:selectedRowKeys', [...current, key])
763
+ if (!selected && selectedKeySet.value.has(key)) {
764
+ emit('update:selectedRowKeys', current.filter((candidate) => candidate !== key))
765
+ }
766
+ }
767
+
768
+ function updateAllRowsSelection(selected: boolean): void {
769
+ const current = validatedSelectedRowKeys.value
770
+ const currentSelectableKeys = new Set(selectableRows.value.map((row) => rowKey(row)))
771
+ if (!selected) {
772
+ emit('update:selectedRowKeys', current.filter((key) => !currentSelectableKeys.has(key)))
773
+ return
774
+ }
775
+ const next = [...current]
776
+ const nextKeys = new Set(current)
777
+ for (const key of currentSelectableKeys) {
778
+ if (!nextKeys.has(key)) next.push(key)
779
+ }
780
+ emit('update:selectedRowKeys', next)
781
+ }
782
+
783
+ function rowIsExpandable(row: Row): boolean {
784
+ return rowExpandableByRow.value.get(row) === true
785
+ }
786
+
787
+ function rowIsExpanded(row: Row): boolean {
788
+ return expandedKeySet.value.has(rowKey(row))
789
+ }
790
+
791
+ function rowExpansionLabel(row: Row, index: number): string {
792
+ return `${rowIsExpanded(row) ? 'Свернуть' : 'Раскрыть'}: ${resolveRowLabel(row, index)}`
793
+ }
794
+
795
+ function toggleRowExpansion(row: Row, index: number): void {
796
+ if (!rowIsExpandable(row)) return
797
+ const key = rowKey(row)
798
+ const expanded = !expandedKeySet.value.has(key)
799
+ const next = expanded
800
+ ? [...validatedExpandedRowKeys.value, key]
801
+ : validatedExpandedRowKeys.value.filter((candidate) => candidate !== key)
802
+ emit('update:expandedRowKeys', next)
803
+ emit('row-expand', row, index, expanded)
804
+ }
418
805
 
419
806
  const resolveCellValue = (row: Row, column: STableColumn<Row, Field>): unknown => column.value
420
807
  ? column.value(row)