@pgcorp/ui-kit 0.3.1 → 0.5.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 CHANGED
@@ -113,6 +113,63 @@ TypeScript показывают доступные subpaths при импорт
113
113
  The complete machine-readable catalog is declared in `package.json#exports`.
114
114
  IDEs and TypeScript expose the available subpaths during import.
115
115
 
116
+ ### Реестры и свободный ввод / Registries and custom values
117
+
118
+ `STable` владеет служебными колонками выбора и раскрытия. Consumer передаёт
119
+ controlled keys и доменный контент, не копирует checkbox, disclosure или CSS:
120
+
121
+ `STable` owns selection and disclosure columns. Consumers provide controlled
122
+ keys and domain content instead of copying checkboxes, disclosures, or CSS:
123
+
124
+ ```vue
125
+ <STable
126
+ v-model:selected-row-keys="selectedRowKeys"
127
+ v-model:expanded-row-keys="expandedRowKeys"
128
+ :columns="columns"
129
+ :data="rows"
130
+ :get-row-key="(row) => row.id"
131
+ :get-row-label="(row) => row.name"
132
+ selection-mode="multiple"
133
+ aria-label="Поставщики"
134
+ @row-contextmenu="openRowMenu"
135
+ >
136
+ <template #row-details="{ row }">
137
+ <SupplierDetails :supplier="row" />
138
+ </template>
139
+ </STable>
140
+ ```
141
+
142
+ Для служебных столбцов доступны `headerPresentation: 'assistive'`,
143
+ `width: '2xs'` и `width: 'content'`. `SCombobox` принимает новое строковое
144
+ значение через `allow-custom-value`; `SPageHeader` сочетает `appearance="bare"`
145
+ с `title-presentation="assistive"` для доступного заголовка без визуальной полосы.
146
+
147
+ Utility columns support `headerPresentation: 'assistive'`, `width: '2xs'`, and
148
+ `width: 'content'`. `SCombobox` accepts a new string value with
149
+ `allow-custom-value`; `SPageHeader` combines `appearance="bare"` with
150
+ `title-presentation="assistive"` for an accessible zero-chrome heading.
151
+
152
+ Составная сортировка использует controlled `v-model:sorts`: consumer задаёт
153
+ `sort-mode="multiple"` и передаёт ordered `STableSort[]`. Клик по
154
+ заголовку добавляет критерий, меняет направление или удаляет его; номер возле
155
+ заголовка показывает приоритет. Таблица не переставляет строки самостоятельно.
156
+
157
+ Multi-column sorting uses controlled `v-model:sorts`: the consumer sets
158
+ `sort-mode="multiple"` and provides an ordered `STableSort[]`. Header activation
159
+ adds a descriptor, changes its direction, or removes it; the visible number is
160
+ the priority. The table never reorders rows on its own.
161
+
162
+ ```vue
163
+ <STable
164
+ v-model:sorts="sorts"
165
+ :columns="columns"
166
+ :data="sortedRows"
167
+ :get-row-key="(row) => row.id"
168
+ sort-mode="multiple"
169
+ aria-label="Позиции прайс-листа"
170
+ />
171
+ ```
172
+
116
173
  ## Темы и стили / Themes and styles
117
174
 
118
175
  Для обычного приложения подключайте полный style entrypoint один раз:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgcorp/ui-kit",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Typed Vue 3 design system with accessible components, semantic themes, and workbench patterns.",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -19,6 +19,11 @@
19
19
  @apply px-0 pt-0;
20
20
  }
21
21
 
22
+ .s-page-header[data-appearance='bare'] {
23
+ @apply border-0 bg-transparent p-0 shadow-none dark:bg-transparent;
24
+ border-radius: 0;
25
+ }
26
+
22
27
  .s-page-header__copy {
23
28
  @apply min-w-0;
24
29
  }
@@ -8,7 +8,7 @@ withDefaults(defineProps<{
8
8
  description?: string
9
9
  eyebrow?: string
10
10
  headingLevel?: 1 | 2 | 3
11
- appearance?: 'plain' | 'panel'
11
+ appearance?: 'bare' | 'plain' | 'panel'
12
12
  size?: 'compact' | 'default'
13
13
  actionsLayout?: 'responsive' | 'inline' | 'stacked'
14
14
  titlePresentation?: 'visible' | 'assistive'
@@ -62,7 +62,7 @@ export type SComboboxOptionGroup<TKey extends PublicSelectionKey = PublicSelecti
62
62
  export type SComboboxEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
63
63
  export type SComboboxFilterMode = 'local' | 'manual'
64
64
 
65
- export interface Props<TKey extends PublicSelectionKey = PublicSelectionKey> {
65
+ interface SharedProps<TKey extends PublicSelectionKey> {
66
66
  modelValue: TKey | null
67
67
  search: string
68
68
  options: readonly SComboboxEntry<TKey>[]
@@ -79,6 +79,18 @@ export interface Props<TKey extends PublicSelectionKey = PublicSelectionKey> {
79
79
  id?: string
80
80
  size?: 'sm' | 'md' | 'lg'
81
81
  }
82
+
83
+ /**
84
+ * Custom values require a string-compatible model because the editable text is the committed identity.
85
+ * Свободные значения требуют string-compatible model, потому что введённый текст становится identity.
86
+ */
87
+ export type Props<TKey extends PublicSelectionKey = PublicSelectionKey> = SharedProps<TKey> & {
88
+ allowCustomValue?: string extends TKey ? boolean : false
89
+ }
90
+
91
+ type ComponentProps<TKey extends PublicSelectionKey> = SharedProps<TKey> & {
92
+ allowCustomValue?: boolean
93
+ }
82
94
  </script>
83
95
 
84
96
  <script setup lang="ts" generic="TKey extends SelectionKey = string">
@@ -106,7 +118,7 @@ import SListbox, { type SListboxMoveIntent } from './SListbox.vue'
106
118
 
107
119
  defineOptions({ inheritAttrs: false })
108
120
 
109
- const props = withDefaults(defineProps<Props<TKey>>(), {
121
+ const props = withDefaults(defineProps<ComponentProps<TKey>>(), {
110
122
  filterMode: 'local',
111
123
  label: undefined,
112
124
  placeholder: 'Начните вводить для поиска',
@@ -119,6 +131,7 @@ const props = withDefaults(defineProps<Props<TKey>>(), {
119
131
  emptyLabel: 'Нет доступных вариантов',
120
132
  id: undefined,
121
133
  size: 'md',
134
+ allowCustomValue: false,
122
135
  })
123
136
  const emit = defineEmits<{
124
137
  'update:modelValue': [value: TKey | null]
@@ -156,13 +169,18 @@ const contract = computed(() => ({
156
169
  invalid: validateBoolean('SCombobox', 'invalid', props.invalid),
157
170
  emptyLabel: validateNonEmptyString('SCombobox', 'emptyLabel', props.emptyLabel),
158
171
  size: validateExactString('SCombobox', 'size', props.size, ['sm', 'md', 'lg'] as const),
172
+ allowCustomValue: validateBoolean('SCombobox', 'allowCustomValue', props.allowCustomValue),
159
173
  label: validateOptionalSelectionString('SCombobox', 'label', props.label),
160
174
  helpText: validateOptionalSelectionString('SCombobox', 'helpText', props.helpText),
161
175
  errorMessage: validateOptionalSelectionString('SCombobox', 'errorMessage', props.errorMessage),
162
176
  }))
163
177
  const inventory = computed(() => {
164
178
  const resolved = resolveSelectionInventory<TKey>('SCombobox', props.options)
165
- validateSelectionModel('SCombobox', 'single', props.modelValue, resolved)
179
+ if (contract.value.allowCustomValue && typeof props.modelValue === 'string') {
180
+ selectionKeyToken('SCombobox', props.modelValue, 'modelValue')
181
+ } else {
182
+ validateSelectionModel('SCombobox', 'single', props.modelValue, resolved)
183
+ }
166
184
  return resolved
167
185
  })
168
186
 
@@ -231,6 +249,29 @@ const { anchorRef, panelRef, floatingStyle } = useFloatingPosition({
231
249
 
232
250
  type CloseReason = 'selection' | 'escape' | 'tab' | 'outside'
233
251
 
252
+ function customValueFromSearch(): TKey | null {
253
+ if (!contract.value.allowCustomValue) return null
254
+ const value = validatedSearch.value.trim()
255
+ if (value.length === 0) return null
256
+
257
+ if (props.modelValue !== null) {
258
+ const currentToken = selectionKeyToken('SCombobox', props.modelValue, 'modelValue')
259
+ const currentOption = inventory.value.optionsByToken.get(currentToken)
260
+ if (currentOption?.option.label.trim() === value) return null
261
+ if (typeof props.modelValue === 'string' && props.modelValue === value) return null
262
+ }
263
+
264
+ return value as TKey
265
+ }
266
+
267
+ function commitCustomValue(): boolean {
268
+ const value = customValueFromSearch()
269
+ if (value === null) return false
270
+ emit('update:modelValue', value)
271
+ emit('update:search', String(value))
272
+ return true
273
+ }
274
+
234
275
  function inputElement(): HTMLInputElement {
235
276
  const api = inputRef.value
236
277
  if (!api) throw new Error('SCombobox: SInputText API is unavailable')
@@ -239,7 +280,7 @@ function inputElement(): HTMLInputElement {
239
280
 
240
281
  function close(reason: CloseReason): void {
241
282
  if (!isOpen.value) return
242
- void reason
283
+ if (reason === 'tab' || reason === 'outside') commitCustomValue()
243
284
  isOpen.value = false
244
285
  activeValue.value = null
245
286
  layerRegistration?.unregister({ restoreFocus: false })
@@ -305,7 +346,8 @@ async function onInputKeydown(event: KeyboardEvent): Promise<void> {
305
346
  }
306
347
  if (event.key === 'Enter' && isOpen.value) {
307
348
  event.preventDefault()
308
- listboxRef.value?.selectActive()
349
+ if (activeValue.value !== null) listboxRef.value?.selectActive()
350
+ else if (commitCustomValue()) close('selection')
309
351
  return
310
352
  }
311
353
  if (event.key === 'Escape' && isOpen.value) {
@@ -34,12 +34,15 @@
34
34
  }
35
35
 
36
36
  .s-table-header th[data-width="auto"] { inline-size: auto; }
37
+ .s-table-header th[data-width="content"] { inline-size: 1%; white-space: nowrap; }
38
+ .s-table-header th[data-width="2xs"] { inline-size: var(--s-table-column-inline-size-2xs); }
37
39
  .s-table-header th[data-width="xs"] { inline-size: var(--s-table-column-inline-size-xs); }
38
40
  .s-table-header th[data-width="sm"] { inline-size: var(--s-table-column-inline-size-sm); }
39
41
  .s-table-header th[data-width="md"] { inline-size: var(--s-table-column-inline-size-md); }
40
42
  .s-table-header th[data-width="lg"] { inline-size: var(--s-table-column-inline-size-lg); }
41
43
  .s-table-header th[data-width="xl"] { inline-size: var(--s-table-column-inline-size-xl); }
42
44
  .s-table-header th[data-width="fill"] { inline-size: 100%; }
45
+ .s-table-header th[data-min-width="2xs"] { min-inline-size: var(--s-table-column-inline-size-2xs); }
43
46
  .s-table-header th[data-min-width="xs"] { min-inline-size: var(--s-table-column-inline-size-xs); }
44
47
  .s-table-header th[data-min-width="sm"] { min-inline-size: var(--s-table-column-inline-size-sm); }
45
48
  .s-table-header th[data-min-width="md"] { min-inline-size: var(--s-table-column-inline-size-md); }
@@ -58,6 +61,10 @@
58
61
  @apply bg-surface-50/50 dark:bg-surface-800/20;
59
62
  }
60
63
 
64
+ .s-table-body tr[data-selected="true"] td {
65
+ @apply bg-primary-50 dark:bg-primary-950/30;
66
+ }
67
+
61
68
  .s-table-body td {
62
69
  @apply px-4 py-3 align-middle text-surface-700 dark:text-surface-300;
63
70
  height: var(--s-density-row-comfortable);
@@ -66,6 +73,38 @@
66
73
  .s-table[data-density="compact"] th { @apply px-3 py-2; }
67
74
  .s-table[data-density="compact"] td { @apply px-2 py-1.5; height: var(--s-density-row-compact); }
68
75
 
76
+ .s-table th.s-table__control-cell,
77
+ .s-table td.s-table__control-cell {
78
+ @apply px-2 text-center;
79
+ inline-size: var(--s-table-column-inline-size-2xs);
80
+ min-inline-size: var(--s-table-column-inline-size-2xs);
81
+ }
82
+
83
+ .s-table__control-cell > * {
84
+ @apply mx-auto;
85
+ }
86
+
87
+ .s-table__header-content[data-header-presentation="assistive"],
88
+ .s-table__assistive {
89
+ @apply sr-only;
90
+ }
91
+
92
+ .s-table-body tr[data-row-details] {
93
+ @apply bg-surface-50/40 dark:bg-surface-900/40;
94
+ }
95
+
96
+ .s-table-body tr[data-row-details]:hover {
97
+ @apply bg-surface-50/40 dark:bg-surface-900/40;
98
+ }
99
+
100
+ .s-table-body tr[data-row-details] > td {
101
+ @apply h-auto p-0;
102
+ }
103
+
104
+ .s-table__row-details {
105
+ @apply px-4 py-3 text-surface-700 dark:text-surface-300;
106
+ }
107
+
69
108
  .s-table [data-align="center"] { text-align: center; }
70
109
  .s-table [data-align="right"] { text-align: right; }
71
110
 
@@ -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"
@@ -36,17 +58,28 @@
36
58
  width="full"
37
59
  :content-align="column.align === 'right' ? 'end' : column.align === 'center' ? 'center' : 'start'"
38
60
  :trailing-icon="sortIcon(column)"
61
+ :badge="sortPriority(column) ?? undefined"
62
+ badge-severity="primary"
39
63
  @click="requestSort(column)"
40
64
  >
41
- <slot name="column" :column="column">
42
- <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
43
- </slot>
65
+ <span
66
+ class="s-table__header-content"
67
+ :data-header-presentation="column.headerPresentation ?? 'visible'"
68
+ >
69
+ <slot name="column" :column="column">
70
+ <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
71
+ </slot>
72
+ </span>
44
73
  </SButton>
45
- <template v-else>
74
+ <span
75
+ v-else
76
+ class="s-table__header-content"
77
+ :data-header-presentation="column.headerPresentation ?? 'visible'"
78
+ >
46
79
  <slot name="column" :column="column">
47
80
  <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
48
81
  </slot>
49
- </template>
82
+ </span>
50
83
  </th>
51
84
  </tr>
52
85
  </thead>
@@ -59,44 +92,90 @@
59
92
  ? { status: 'error', message: errorMessage }
60
93
  : { status: 'empty', message: emptyMessage }"
61
94
  presentation="table-row"
62
- :colspan="validatedColumns.length || 1"
95
+ :colspan="renderedColumnCount"
63
96
  />
64
97
  <template v-else>
65
- <tr
98
+ <template
66
99
  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)"
100
+ :key="rowDomToken(row)"
74
101
  >
75
- <td
76
- v-for="column in validatedColumns"
77
- :key="column.field"
78
- :data-align="column.align ?? 'left'"
79
- :data-state="resolveCellState(row, column)"
102
+ <tr
103
+ :data-state="resolveRowState(row)"
104
+ :data-selected="rowIsSelected(row) || undefined"
105
+ :aria-selected="rowAriaSelected(row)"
106
+ tabindex="0"
107
+ @click="onRowClick($event, row, rowIndex)"
108
+ @contextmenu="onRowContextMenu($event, row, rowIndex)"
109
+ @keydown.enter="onRowKeydown($event, row, rowIndex)"
110
+ @keydown.space="onRowKeydown($event, row, rowIndex)"
80
111
  >
81
- <slot
82
- :name="`cell-${column.field}`"
83
- :value="resolveCellValue(row, column)"
84
- :row="row"
85
- :column="column"
86
- :row-index="rowIndex"
112
+ <td
113
+ v-if="validatedSelectionMode === 'multiple'"
114
+ class="s-table__control-cell"
115
+ >
116
+ <SCheckbox
117
+ :model-value="rowIsSelected(row)"
118
+ :disabled="!rowIsSelectable(row)"
119
+ :aria-label="rowSelectionLabel(row, rowIndex)"
120
+ @update:model-value="updateRowSelection(row, rowIndex, $event)"
121
+ />
122
+ </td>
123
+ <td v-if="hasRowDetails" class="s-table__control-cell">
124
+ <SButton
125
+ v-if="rowIsExpandable(row)"
126
+ :aria-label="rowExpansionLabel(row, rowIndex)"
127
+ appearance="text"
128
+ severity="secondary"
129
+ size="xs"
130
+ density="compact"
131
+ shape="square"
132
+ icon-only
133
+ :disclosure="{
134
+ expanded: rowIsExpanded(row),
135
+ controls: rowDetailsId(row),
136
+ iconPlacement: 'leading',
137
+ }"
138
+ @click="toggleRowExpansion(row, rowIndex)"
139
+ />
140
+ </td>
141
+ <td
142
+ v-for="column in validatedColumns"
143
+ :key="column.field"
144
+ :data-align="column.align ?? 'left'"
145
+ :data-state="resolveCellState(row, column)"
87
146
  >
88
147
  <slot
89
- name="cell"
148
+ :name="`cell-${column.field}`"
90
149
  :value="resolveCellValue(row, column)"
91
150
  :row="row"
92
151
  :column="column"
93
152
  :row-index="rowIndex"
94
153
  >
95
- {{ resolveCellValue(row, column) }}
154
+ <slot
155
+ name="cell"
156
+ :value="resolveCellValue(row, column)"
157
+ :row="row"
158
+ :column="column"
159
+ :row-index="rowIndex"
160
+ >
161
+ {{ resolveCellValue(row, column) }}
162
+ </slot>
96
163
  </slot>
97
- </slot>
98
- </td>
99
- </tr>
164
+ </td>
165
+ </tr>
166
+ <tr v-if="rowIsExpandable(row) && rowIsExpanded(row)" data-row-details>
167
+ <td :colspan="renderedColumnCount">
168
+ <div
169
+ :id="rowDetailsId(row)"
170
+ class="s-table__row-details"
171
+ role="region"
172
+ :aria-label="`Детали: ${resolveRowLabel(row, rowIndex)}`"
173
+ >
174
+ <slot name="row-details" :row="row" :row-index="rowIndex" />
175
+ </div>
176
+ </td>
177
+ </tr>
178
+ </template>
100
179
  </template>
101
180
  </tbody>
102
181
  </table>
@@ -109,11 +188,14 @@ import type {
109
188
  STableCellState,
110
189
  STableColumn,
111
190
  STableColumnContent,
191
+ STableColumnHeaderPresentation,
112
192
  STableDensity,
113
193
  STableMinWidth,
114
194
  STableRowKey,
115
195
  STableRowState,
196
+ STableSelectionMode,
116
197
  STableSort,
198
+ STableSortMode,
117
199
  STableSurface,
118
200
  } from './table'
119
201
 
@@ -122,12 +204,15 @@ export type {
122
204
  STableCellState,
123
205
  STableColumn,
124
206
  STableColumnContent,
207
+ STableColumnHeaderPresentation,
125
208
  STableDensity,
126
209
  STableMinWidth,
127
210
  STableRowKey,
128
211
  STableRowState,
212
+ STableSelectionMode,
129
213
  STableSort,
130
214
  STableSortDirection,
215
+ STableSortMode,
131
216
  STableSurface,
132
217
  } from './table'
133
218
 
@@ -145,7 +230,15 @@ export interface STableProps<
145
230
  errorMessage?: string
146
231
  emptyMessage?: string
147
232
  selectedRowKey?: RowKey
233
+ selectionMode?: STableSelectionMode
234
+ selectedRowKeys?: readonly RowKey[]
235
+ isRowSelectable?: (row: Row, index: number) => boolean
236
+ expandedRowKeys?: readonly RowKey[]
237
+ isRowExpandable?: (row: Row, index: number) => boolean
238
+ getRowLabel?: (row: Row, index: number) => string
239
+ sortMode?: STableSortMode
148
240
  sort?: STableSort<Field> | null
241
+ sorts?: readonly STableSort<Field>[]
149
242
  maxHeight?: ScrollableViewportMaxBlockSize
150
243
  density?: STableDensity
151
244
  surface?: STableSurface
@@ -162,7 +255,7 @@ export interface STableProps<
162
255
  </script>
163
256
 
164
257
  <script setup lang="ts" generic="Row extends object = Record<string, unknown>, RowKey extends STableRowKey = STableRowKey, Field extends string = Extract<keyof Row, string>">
165
- import { computed, useSlots } from 'vue'
258
+ import { computed, useId, useSlots } from 'vue'
166
259
  import { ChevronDown, ChevronsUpDown, ChevronUp } from '../../icons/sputnigUiIcons'
167
260
  import { hasOwn } from '../../../internal/es2020'
168
261
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
@@ -180,6 +273,7 @@ import {
180
273
  } from '../../../internal/runtimeContract'
181
274
  import SAsyncState from '../feedback/SAsyncState.vue'
182
275
  import SButton from '../controls/SButton.vue'
276
+ import SCheckbox from '../controls/SCheckbox.vue'
183
277
  defineOptions({ inheritAttrs: false })
184
278
 
185
279
  const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
@@ -188,7 +282,15 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
188
282
  errorMessage: '',
189
283
  emptyMessage: 'Нет данных для отображения',
190
284
  selectedRowKey: undefined,
285
+ selectionMode: 'none',
286
+ selectedRowKeys: () => [],
287
+ isRowSelectable: undefined,
288
+ expandedRowKeys: () => [],
289
+ isRowExpandable: undefined,
290
+ getRowLabel: undefined,
291
+ sortMode: 'single',
191
292
  sort: null,
293
+ sorts: () => [],
192
294
  maxHeight: 'none',
193
295
  density: 'default',
194
296
  surface: 'bordered',
@@ -201,8 +303,14 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
201
303
  })
202
304
  const emit = defineEmits<{
203
305
  'row-click': [row: Row, index: number]
306
+ 'row-contextmenu': [row: Row, index: number, event: MouseEvent]
307
+ 'update:selectedRowKeys': [value: readonly RowKey[]]
308
+ 'update:expandedRowKeys': [value: readonly RowKey[]]
309
+ 'row-expand': [row: Row, index: number, expanded: boolean]
204
310
  'update:sort': [value: STableSort<Field> | null]
205
311
  sort: [value: STableSort<Field> | null]
312
+ 'update:sorts': [value: readonly STableSort<Field>[]]
313
+ 'multi-sort': [value: readonly STableSort<Field>[]]
206
314
  }>()
207
315
  const slots = useSlots()
208
316
 
@@ -213,9 +321,15 @@ const TABLE_ALIGNMENTS = ['left', 'center', 'right'] as const
213
321
  const TABLE_ROW_STATES = ['default', 'inserted', 'updated', 'deleted'] as const
214
322
  const TABLE_CELL_STATES = ['default', 'pending'] as const
215
323
  const TABLE_COLUMN_CONTENT = ['field', 'value', 'slot'] as const
324
+ const TABLE_HEADER_PRESENTATIONS = ['visible', 'assistive'] as const
325
+ const TABLE_SELECTION_MODES = ['none', 'multiple'] as const
326
+ const TABLE_SORT_MODES = ['single', 'multiple'] as const
327
+ const TABLE_SORT_DIRECTIONS = ['ascending', 'descending'] as const
216
328
 
217
329
  const ownedAttrs = useOwnedAttrs({ component: 'STable', owner: 'table scroll surface' })
218
330
  useInteractiveLeafRegistration({ owner: 'STable' })
331
+ const tableInstanceId = `s-table-${useId()}`
332
+ const hasRowDetails = typeof slots['row-details'] === 'function'
219
333
 
220
334
  function eventBelongsToNestedControl(event: Event): boolean {
221
335
  const owner = event.currentTarget
@@ -228,8 +342,14 @@ function onRowClick(event: MouseEvent, row: Row, rowIndex: number): void {
228
342
  if (!eventBelongsToNestedControl(event)) emit('row-click', row, rowIndex)
229
343
  }
230
344
 
345
+ function onRowContextMenu(event: MouseEvent, row: Row, rowIndex: number): void {
346
+ if (!eventBelongsToNestedControl(event)) emit('row-contextmenu', row, rowIndex, event)
347
+ }
348
+
231
349
  function onRowKeydown(event: KeyboardEvent, row: Row, rowIndex: number): void {
232
- if (event.target === event.currentTarget) emit('row-click', row, rowIndex)
350
+ if (event.target !== event.currentTarget) return
351
+ event.preventDefault()
352
+ emit('row-click', row, rowIndex)
233
353
  }
234
354
  const validatedLoading = computed(() => validateBoolean('STable', 'loading', props.loading))
235
355
  const resolvedAccessibleName = computed(() => {
@@ -275,12 +395,30 @@ const validatedMaxHeight = computed(() => validateScrollableViewportMaxBlockSize
275
395
  'maxHeight',
276
396
  props.maxHeight,
277
397
  ))
398
+ const validatedSelectionMode = computed(() => validateExactString(
399
+ 'STable',
400
+ 'selectionMode',
401
+ props.selectionMode,
402
+ TABLE_SELECTION_MODES,
403
+ ))
404
+ const validatedSortMode = computed(() => validateExactString(
405
+ 'STable',
406
+ 'sortMode',
407
+ props.sortMode,
408
+ TABLE_SORT_MODES,
409
+ ))
278
410
 
279
411
  const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
280
412
  const fields = new Set<string>()
281
413
  for (const column of props.columns) {
282
414
  const field = validateNonEmptyString('STable', 'columns[].field', column.field)
283
415
  validateNonEmptyString('STable', `column ${JSON.stringify(field)}.header`, column.header)
416
+ const headerPresentation: STableColumnHeaderPresentation = validateExactString(
417
+ 'STable',
418
+ `column ${JSON.stringify(field)}.headerPresentation`,
419
+ column.headerPresentation ?? 'visible',
420
+ TABLE_HEADER_PRESENTATIONS,
421
+ )
284
422
  if (fields.has(field)) {
285
423
  throw new TypeError(
286
424
  `STable: columns содержит duplicate field ${JSON.stringify(field)}. `
@@ -294,6 +432,12 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
294
432
  if (column.sortable !== undefined) {
295
433
  validateBoolean('STable', `column ${JSON.stringify(field)}.sortable`, column.sortable)
296
434
  }
435
+ if (headerPresentation === 'assistive' && column.sortable) {
436
+ throw new TypeError(
437
+ `STable: assistive header column ${JSON.stringify(field)} не может быть sortable без visible action. `
438
+ + `/ STable: assistive header column ${JSON.stringify(field)} cannot be sortable without a visible action.`,
439
+ )
440
+ }
297
441
  if (column.width !== undefined) {
298
442
  validateTableColumnInlineSize('STable', `column ${JSON.stringify(field)}.width`, column.width)
299
443
  }
@@ -328,9 +472,15 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
328
472
  return props.columns
329
473
  })
330
474
 
331
- const validatedSort = computed<STableSort<Field> | null>(() => {
332
- if (props.sort === null || props.sort === undefined) return null
333
- const key = validateNonEmptyString('STable', 'sort.key', props.sort.key) as Field
475
+ function validateSortEntry(value: unknown, coordinate: string): STableSort<Field> {
476
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
477
+ throw new TypeError(
478
+ `STable: ${coordinate} должен быть STableSort object. `
479
+ + `/ STable: ${coordinate} must be an STableSort object.`,
480
+ )
481
+ }
482
+ const entry = value as Record<string, unknown>
483
+ const key = validateNonEmptyString('STable', `${coordinate}.key`, entry.key) as Field
334
484
  const column = validatedColumns.value.find((candidate) => candidate.field === key)
335
485
  if (!column?.sortable) {
336
486
  throw new TypeError(`STable: sort key ${JSON.stringify(key)} должен ссылаться на sortable column. / STable: sort key ${JSON.stringify(key)} must reference a sortable column.`)
@@ -339,25 +489,100 @@ const validatedSort = computed<STableSort<Field> | null>(() => {
339
489
  key,
340
490
  direction: validateExactString(
341
491
  'STable',
342
- 'sort.direction',
343
- props.sort.direction,
344
- ['ascending', 'descending'] as const,
492
+ `${coordinate}.direction`,
493
+ entry.direction,
494
+ TABLE_SORT_DIRECTIONS,
345
495
  ),
346
496
  }
497
+ }
498
+
499
+ const validatedSingleSort = computed<STableSort<Field> | null>(() => {
500
+ if (validatedSortMode.value === 'multiple') {
501
+ if (props.sort !== null && props.sort !== undefined) {
502
+ throw new TypeError(
503
+ 'STable: sortMode="multiple" несовместим с sort; используйте v-model:sorts. '
504
+ + '/ STable: sortMode="multiple" is incompatible with sort; use v-model:sorts.',
505
+ )
506
+ }
507
+ return null
508
+ }
509
+ if (props.sort === null || props.sort === undefined) return null
510
+ return validateSortEntry(props.sort, 'sort')
511
+ })
512
+
513
+ const validatedMultipleSort = computed<readonly STableSort<Field>[]>(() => {
514
+ const sorts: unknown = props.sorts
515
+ if (!Array.isArray(sorts)) {
516
+ throw new TypeError(
517
+ 'STable: sorts должен быть ordered STableSort array. '
518
+ + '/ STable: sorts must be an ordered STableSort array.',
519
+ )
520
+ }
521
+ if (validatedSortMode.value === 'single') {
522
+ if (sorts.length > 0) {
523
+ throw new TypeError(
524
+ 'STable: sortMode="single" несовместим с непустым sorts; используйте v-model:sort. '
525
+ + '/ STable: sortMode="single" is incompatible with non-empty sorts; use v-model:sort.',
526
+ )
527
+ }
528
+ return []
529
+ }
530
+ const keys = new Set<Field>()
531
+ return sorts.map((entry, index) => {
532
+ const validated = validateSortEntry(entry, `sorts[${index}]`)
533
+ if (keys.has(validated.key)) {
534
+ throw new TypeError(
535
+ `STable: sorts содержит duplicate key ${JSON.stringify(validated.key)}. `
536
+ + `/ STable: sorts contains duplicate key ${JSON.stringify(validated.key)}.`,
537
+ )
538
+ }
539
+ keys.add(validated.key)
540
+ return validated
541
+ })
542
+ })
543
+
544
+ const activeSorts = computed<readonly STableSort<Field>[]>(() => {
545
+ const singleSort = validatedSingleSort.value
546
+ const multipleSort = validatedMultipleSort.value
547
+ if (validatedSortMode.value === 'multiple') return multipleSort
548
+ return singleSort === null ? [] : [singleSort]
347
549
  })
348
550
 
551
+ function sortIndex(column: STableColumn<Row, Field>): number {
552
+ return activeSorts.value.findIndex((candidate) => candidate.key === column.field)
553
+ }
554
+
555
+ function sortPriority(column: STableColumn<Row, Field>): number | null {
556
+ if (validatedSortMode.value !== 'multiple') return null
557
+ const index = sortIndex(column)
558
+ return index === -1 ? null : index + 1
559
+ }
560
+
349
561
  const columnAriaSort = (column: STableColumn<Row, Field>): 'none' | 'ascending' | 'descending' | undefined => {
350
562
  if (!column.sortable) return undefined
351
- return validatedSort.value?.key === column.field ? validatedSort.value.direction : 'none'
563
+ if (validatedSortMode.value === 'multiple') {
564
+ const primary = activeSorts.value[0]
565
+ return primary?.key === column.field ? primary.direction : undefined
566
+ }
567
+ const current = activeSorts.value[0]
568
+ return current?.key === column.field ? current.direction : 'none'
352
569
  }
353
570
 
354
571
  const sortIcon = (column: STableColumn<Row, Field>) => {
355
- const direction = columnAriaSort(column)
572
+ const direction = activeSorts.value.find((candidate) => candidate.key === column.field)?.direction
356
573
  return direction === 'ascending' ? ChevronUp : direction === 'descending' ? ChevronDown : ChevronsUpDown
357
574
  }
358
575
 
359
576
  const sortActionLabel = (column: STableColumn<Row, Field>): string => {
360
- const direction = columnAriaSort(column)
577
+ const index = sortIndex(column)
578
+ const current = index === -1 ? undefined : activeSorts.value[index]
579
+ if (validatedSortMode.value === 'multiple') {
580
+ if (!current) return `${column.header}: добавить в сортировку по возрастанию`
581
+ const state = current.direction === 'ascending' ? 'по возрастанию' : 'по убыванию'
582
+ const action = current.direction === 'ascending' ? 'сортировать по убыванию' : 'убрать из сортировки'
583
+ return `${column.header}: приоритет ${index + 1} из ${activeSorts.value.length}, ${state}; ${action}`
584
+ }
585
+ const direction = current?.direction
361
586
  if (direction === 'ascending') return `${column.header}: сортировать по убыванию`
362
587
  if (direction === 'descending') return `${column.header}: сбросить сортировку`
363
588
  return `${column.header}: сортировать по возрастанию`
@@ -365,62 +590,207 @@ const sortActionLabel = (column: STableColumn<Row, Field>): string => {
365
590
 
366
591
  function requestSort(column: STableColumn<Row, Field>): void {
367
592
  if (!column.sortable) return
368
- const current = validatedSort.value
369
593
  const key = column.field as Field
370
- const next: STableSort<Field> | null = current?.key !== key
371
- ? { key, direction: 'ascending' }
372
- : current.direction === 'ascending'
373
- ? { key, direction: 'descending' }
374
- : null
375
- emit('update:sort', next)
376
- emit('sort', next)
594
+ if (validatedSortMode.value === 'multiple') {
595
+ const current = activeSorts.value
596
+ const index = sortIndex(column)
597
+ let next: readonly STableSort<Field>[]
598
+ if (index === -1) next = [...current, { key, direction: 'ascending' }]
599
+ else if (current[index]!.direction === 'ascending') {
600
+ next = current.map((entry, candidateIndex) => candidateIndex === index
601
+ ? { key: entry.key, direction: 'descending' }
602
+ : entry)
603
+ } else next = current.filter((_entry, candidateIndex) => candidateIndex !== index)
604
+ emit('update:sorts', next)
605
+ emit('multi-sort', next)
606
+ } else {
607
+ const current = activeSorts.value[0]
608
+ const next: STableSort<Field> | null = current?.key !== key
609
+ ? { key, direction: 'ascending' }
610
+ : current.direction === 'ascending'
611
+ ? { key, direction: 'descending' }
612
+ : null
613
+ emit('update:sort', next)
614
+ emit('sort', next)
615
+ }
377
616
  }
378
617
 
379
618
  function describeRowKey(key: STableRowKey): string {
380
619
  return typeof key === 'string' ? JSON.stringify(key) : String(key)
381
620
  }
382
621
 
383
- function validateRowKey(value: unknown): RowKey {
622
+ function diagnosticOwner(): string {
623
+ return resolvedAccessibleName.value.ariaLabel === undefined
624
+ ? `STable [aria-labelledby=${JSON.stringify(resolvedAccessibleName.value.ariaLabelledby)}]`
625
+ : `STable ${JSON.stringify(resolvedAccessibleName.value.ariaLabel)}`
626
+ }
627
+
628
+ function describeThrownError(error: unknown): string {
629
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error)
630
+ }
631
+
632
+ function validateRowKey(value: unknown, coordinate: string): RowKey {
384
633
  if (
385
634
  (typeof value === 'string' && value.trim().length > 0)
386
635
  || (typeof value === 'number' && Number.isFinite(value))
387
636
  ) return value as RowKey
388
637
 
389
638
  throw new TypeError(
390
- `STable: getRowKey должен вернуть non-empty string | finite number. `
391
- + '/ STable: getRowKey must return a non-empty string | finite number.',
639
+ `${diagnosticOwner()}: ${coordinate} должен вернуть non-empty string | finite number. `
640
+ + `/ ${diagnosticOwner()}: ${coordinate} must return a non-empty string | finite number.`,
392
641
  )
393
642
  }
394
643
 
395
644
  const resolvedRowKeys = computed<ReadonlyMap<Row, RowKey>>(() => {
396
645
  if (typeof props.getRowKey !== 'function') {
397
646
  throw new TypeError(
398
- 'STable: getRowKey должен быть обязательным function resolver. '
399
- + '/ STable: getRowKey must be a required function resolver.',
647
+ `${diagnosticOwner()}: getRowKey должен быть обязательным function resolver. `
648
+ + `/ ${diagnosticOwner()}: getRowKey must be a required function resolver.`,
400
649
  )
401
650
  }
402
651
 
403
- const keys = new Set<STableRowKey>()
652
+ const firstIndexByKey = new Map<STableRowKey, number>()
404
653
  const rowKeys = new Map<Row, RowKey>()
405
- for (const row of props.data) {
406
- const key = validateRowKey(props.getRowKey(row))
407
- if (keys.has(key)) {
654
+ for (const [rowIndex, row] of props.data.entries()) {
655
+ let value: unknown
656
+ try {
657
+ value = props.getRowKey(row)
658
+ } catch (error: unknown) {
408
659
  throw new TypeError(
409
- `STable: getRowKey вернул duplicate key ${describeRowKey(key)}. `
410
- + `/ STable: getRowKey returned duplicate key ${describeRowKey(key)}.`,
660
+ `${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
411
661
  )
412
662
  }
413
- keys.add(key)
663
+ const key = validateRowKey(value, `getRowKey at data[${rowIndex}]`)
664
+ const firstIndex = firstIndexByKey.get(key)
665
+ if (firstIndex !== undefined) {
666
+ throw new TypeError(
667
+ `${diagnosticOwner()}: getRowKey вернул duplicate key ${describeRowKey(key)} at data[${rowIndex}]; `
668
+ + `first returned at data[${firstIndex}]. / ${diagnosticOwner()}: getRowKey returned duplicate key `
669
+ + `${describeRowKey(key)} at data[${rowIndex}]; first returned at data[${firstIndex}].`,
670
+ )
671
+ }
672
+ firstIndexByKey.set(key, rowIndex)
414
673
  rowKeys.set(row, key)
415
674
  }
416
675
  return rowKeys
417
676
  })
418
677
 
678
+ function validateControlledKeys(coordinate: string, value: unknown): readonly RowKey[] {
679
+ if (!Array.isArray(value)) {
680
+ throw new TypeError(
681
+ `${diagnosticOwner()}: ${coordinate} должен быть readonly array. `
682
+ + `/ ${diagnosticOwner()}: ${coordinate} must be a readonly array.`,
683
+ )
684
+ }
685
+ const validated: RowKey[] = []
686
+ const seen = new Set<STableRowKey>()
687
+ for (const [index, candidate] of value.entries()) {
688
+ const key = validateRowKey(candidate, `${coordinate}[${index}]`)
689
+ if (seen.has(key)) {
690
+ throw new TypeError(
691
+ `${diagnosticOwner()}: ${coordinate} содержит duplicate key ${describeRowKey(key)}. `
692
+ + `/ ${diagnosticOwner()}: ${coordinate} contains duplicate key ${describeRowKey(key)}.`,
693
+ )
694
+ }
695
+ seen.add(key)
696
+ validated.push(key)
697
+ }
698
+ return validated
699
+ }
700
+
701
+ const validatedSelectedRowKey = computed<RowKey | undefined>(() => {
702
+ if (props.selectedRowKey === undefined) return undefined
703
+ if (validatedSelectionMode.value === 'multiple') {
704
+ throw new TypeError(
705
+ `${diagnosticOwner()}: selectedRowKey несовместим с selectionMode="multiple"; используйте selectedRowKeys. `
706
+ + `/ ${diagnosticOwner()}: selectedRowKey is incompatible with selectionMode="multiple"; use selectedRowKeys.`,
707
+ )
708
+ }
709
+ const selectedKey = validateRowKey(props.selectedRowKey, 'selectedRowKey')
710
+ const rowKeys = new Set(resolvedRowKeys.value.values())
711
+ if (!rowKeys.has(selectedKey)) {
712
+ throw new TypeError(
713
+ `${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} отсутствует в resolved row keys. `
714
+ + `/ ${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} is absent from resolved row keys.`,
715
+ )
716
+ }
717
+ return selectedKey
718
+ })
719
+
720
+ const validatedSelectedRowKeys = computed<readonly RowKey[]>(() => {
721
+ const keys = validateControlledKeys('selectedRowKeys', props.selectedRowKeys)
722
+ if (validatedSelectionMode.value === 'none' && keys.length > 0) {
723
+ throw new TypeError(
724
+ `${diagnosticOwner()}: selectedRowKeys требует selectionMode="multiple". `
725
+ + `/ ${diagnosticOwner()}: selectedRowKeys requires selectionMode="multiple".`,
726
+ )
727
+ }
728
+ if (props.isRowSelectable !== undefined && validatedSelectionMode.value !== 'multiple') {
729
+ throw new TypeError(
730
+ `${diagnosticOwner()}: isRowSelectable требует selectionMode="multiple". `
731
+ + `/ ${diagnosticOwner()}: isRowSelectable requires selectionMode="multiple".`,
732
+ )
733
+ }
734
+ if (props.isRowSelectable !== undefined && typeof props.isRowSelectable !== 'function') {
735
+ throw new TypeError(`${diagnosticOwner()}: isRowSelectable must be a function.`)
736
+ }
737
+ return keys
738
+ })
739
+
740
+ const rowSelectableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
741
+ const result = new Map<Row, boolean>()
742
+ for (const [index, row] of props.data.entries()) {
743
+ const selectable = validatedSelectionMode.value === 'multiple'
744
+ ? validateBoolean('STable', `isRowSelectable at data[${index}]`, props.isRowSelectable?.(row, index) ?? true)
745
+ : false
746
+ result.set(row, selectable)
747
+ }
748
+ return result
749
+ })
750
+
751
+ const validatedExpandedRowKeys = computed<readonly RowKey[]>(() => {
752
+ const keys = validateControlledKeys('expandedRowKeys', props.expandedRowKeys)
753
+ if (!hasRowDetails && (keys.length > 0 || props.isRowExpandable !== undefined)) {
754
+ throw new TypeError(
755
+ `${diagnosticOwner()}: expandedRowKeys/isRowExpandable требует slot row-details. `
756
+ + `/ ${diagnosticOwner()}: expandedRowKeys/isRowExpandable requires the row-details slot.`,
757
+ )
758
+ }
759
+ if (props.isRowExpandable !== undefined && typeof props.isRowExpandable !== 'function') {
760
+ throw new TypeError(`${diagnosticOwner()}: isRowExpandable must be a function.`)
761
+ }
762
+ return keys
763
+ })
764
+
765
+ const rowExpandableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
766
+ const result = new Map<Row, boolean>()
767
+ for (const [index, row] of props.data.entries()) {
768
+ const expandable = hasRowDetails
769
+ ? validateBoolean('STable', `isRowExpandable at data[${index}]`, props.isRowExpandable?.(row, index) ?? true)
770
+ : false
771
+ result.set(row, expandable)
772
+ }
773
+ const expanded = new Set(validatedExpandedRowKeys.value)
774
+ for (const row of props.data) {
775
+ if (expanded.has(rowKey(row)) && !result.get(row)) {
776
+ throw new TypeError(
777
+ `${diagnosticOwner()}: expandedRowKeys содержит current row, для которой isRowExpandable вернул false. `
778
+ + `/ ${diagnosticOwner()}: expandedRowKeys contains a current row for which isRowExpandable returned false.`,
779
+ )
780
+ }
781
+ }
782
+ return result
783
+ })
784
+
419
785
  const validatedRows = computed<readonly Row[]>(() => {
420
786
  const columns = validatedColumns.value
421
- void validatedSort.value
787
+ void activeSorts.value
422
788
  void resolvedRowKeys.value
423
789
  void validatedSelectedRowKey.value
790
+ void validatedSelectedRowKeys.value
791
+ void rowSelectableByRow.value
792
+ void validatedExpandedRowKeys.value
793
+ void rowExpandableByRow.value
424
794
  for (const row of props.data) {
425
795
  for (const column of columns) {
426
796
  const content = column.content ?? (column.value === undefined ? 'field' : 'value')
@@ -435,18 +805,116 @@ const validatedRows = computed<readonly Row[]>(() => {
435
805
  return props.data
436
806
  })
437
807
 
438
- const validatedSelectedRowKey = computed<RowKey | undefined>(() => {
439
- if (props.selectedRowKey === undefined) return undefined
440
- const selectedKey = validateRowKey(props.selectedRowKey)
441
- const rowKeys = new Set(resolvedRowKeys.value.values())
442
- if (!rowKeys.has(selectedKey)) {
443
- throw new TypeError(
444
- `STable: selectedRowKey ${describeRowKey(selectedKey)} отсутствует в resolved row keys. `
445
- + `/ STable: selectedRowKey ${describeRowKey(selectedKey)} is absent from resolved row keys.`,
446
- )
808
+ function rowKey(row: Row): RowKey {
809
+ const key = resolvedRowKeys.value.get(row)
810
+ if (key === undefined) throw new Error(`${diagnosticOwner()}: row identity is unavailable`)
811
+ return key
812
+ }
813
+
814
+ function rowKeyToken(key: RowKey): string {
815
+ return `${typeof key === 'number' ? 'number' : 'string'}:${String(key)}`
816
+ }
817
+
818
+ function rowDomToken(row: Row): string {
819
+ return rowKeyToken(rowKey(row))
820
+ }
821
+
822
+ function rowDetailsId(row: Row): string {
823
+ return `${tableInstanceId}-details-${encodeURIComponent(rowDomToken(row))}`
824
+ }
825
+
826
+ function resolveRowLabel(row: Row, index: number): string {
827
+ if (props.getRowLabel === undefined) return `Строка ${index + 1}`
828
+ if (typeof props.getRowLabel !== 'function') {
829
+ throw new TypeError(`${diagnosticOwner()}: getRowLabel must be a function.`)
447
830
  }
448
- return selectedKey
449
- })
831
+ return validateNonEmptyString('STable', `getRowLabel at data[${index}]`, props.getRowLabel(row, index))
832
+ }
833
+
834
+ const selectedKeySet = computed(() => new Set(validatedSelectedRowKeys.value))
835
+ const expandedKeySet = computed(() => new Set(validatedExpandedRowKeys.value))
836
+ const selectableRows = computed(() => props.data.filter((row) => rowSelectableByRow.value.get(row) === true))
837
+ const selectedCurrentRowCount = computed(() => selectableRows.value.filter(
838
+ (row) => selectedKeySet.value.has(rowKey(row)),
839
+ ).length)
840
+ const someCurrentRowsSelected = computed(() => selectedCurrentRowCount.value > 0)
841
+ const allCurrentRowsSelected = computed(() => (
842
+ selectableRows.value.length > 0 && selectedCurrentRowCount.value === selectableRows.value.length
843
+ ))
844
+ const renderedColumnCount = computed(() => Math.max(
845
+ validatedColumns.value.length
846
+ + (validatedSelectionMode.value === 'multiple' ? 1 : 0)
847
+ + (hasRowDetails ? 1 : 0),
848
+ 1,
849
+ ))
850
+
851
+ function rowIsSelectable(row: Row): boolean {
852
+ return rowSelectableByRow.value.get(row) === true
853
+ }
854
+
855
+ function rowIsSelected(row: Row): boolean {
856
+ return validatedSelectionMode.value === 'multiple' && selectedKeySet.value.has(rowKey(row))
857
+ }
858
+
859
+ function rowAriaSelected(row: Row): 'true' | 'false' | undefined {
860
+ if (validatedSelectionMode.value === 'multiple') return rowIsSelected(row) ? 'true' : 'false'
861
+ return rowKey(row) === validatedSelectedRowKey.value ? 'true' : undefined
862
+ }
863
+
864
+ function rowSelectionLabel(row: Row, index: number): string {
865
+ const label = resolveRowLabel(row, index)
866
+ if (!rowIsSelectable(row)) return `Выбор недоступен: ${label}`
867
+ return rowIsSelected(row) ? `Снять выбор: ${label}` : `Выбрать: ${label}`
868
+ }
869
+
870
+ function updateRowSelection(row: Row, index: number, selected: boolean): void {
871
+ void index
872
+ if (!rowIsSelectable(row)) return
873
+ const key = rowKey(row)
874
+ const current = validatedSelectedRowKeys.value
875
+ if (selected && !selectedKeySet.value.has(key)) emit('update:selectedRowKeys', [...current, key])
876
+ if (!selected && selectedKeySet.value.has(key)) {
877
+ emit('update:selectedRowKeys', current.filter((candidate) => candidate !== key))
878
+ }
879
+ }
880
+
881
+ function updateAllRowsSelection(selected: boolean): void {
882
+ const current = validatedSelectedRowKeys.value
883
+ const currentSelectableKeys = new Set(selectableRows.value.map((row) => rowKey(row)))
884
+ if (!selected) {
885
+ emit('update:selectedRowKeys', current.filter((key) => !currentSelectableKeys.has(key)))
886
+ return
887
+ }
888
+ const next = [...current]
889
+ const nextKeys = new Set(current)
890
+ for (const key of currentSelectableKeys) {
891
+ if (!nextKeys.has(key)) next.push(key)
892
+ }
893
+ emit('update:selectedRowKeys', next)
894
+ }
895
+
896
+ function rowIsExpandable(row: Row): boolean {
897
+ return rowExpandableByRow.value.get(row) === true
898
+ }
899
+
900
+ function rowIsExpanded(row: Row): boolean {
901
+ return expandedKeySet.value.has(rowKey(row))
902
+ }
903
+
904
+ function rowExpansionLabel(row: Row, index: number): string {
905
+ return `${rowIsExpanded(row) ? 'Свернуть' : 'Раскрыть'}: ${resolveRowLabel(row, index)}`
906
+ }
907
+
908
+ function toggleRowExpansion(row: Row, index: number): void {
909
+ if (!rowIsExpandable(row)) return
910
+ const key = rowKey(row)
911
+ const expanded = !expandedKeySet.value.has(key)
912
+ const next = expanded
913
+ ? [...validatedExpandedRowKeys.value, key]
914
+ : validatedExpandedRowKeys.value.filter((candidate) => candidate !== key)
915
+ emit('update:expandedRowKeys', next)
916
+ emit('row-expand', row, index, expanded)
917
+ }
450
918
 
451
919
  const resolveCellValue = (row: Row, column: STableColumn<Row, Field>): unknown => column.value
452
920
  ? column.value(row)
@@ -11,7 +11,10 @@ export type STableSurface = 'bordered' | 'plain'
11
11
  export type STableMinWidth = 'content' | 'md' | 'lg'
12
12
  export type STableDensity = 'default' | 'compact'
13
13
  export type STableColumnContent = 'field' | 'value' | 'slot'
14
+ export type STableColumnHeaderPresentation = 'visible' | 'assistive'
15
+ export type STableSelectionMode = 'none' | 'multiple'
14
16
  export type STableSortDirection = 'ascending' | 'descending'
17
+ export type STableSortMode = 'single' | 'multiple'
15
18
 
16
19
  /** Controlled sort state; consumer owns data ordering. / Управляемое состояние сортировки; порядок данных принадлежит consumer. */
17
20
  export interface STableSort<Field extends string = string> {
@@ -22,6 +25,7 @@ export interface STableSort<Field extends string = string> {
22
25
  interface STableColumnBase<Field extends string> {
23
26
  field: Field
24
27
  header: string
28
+ headerPresentation?: STableColumnHeaderPresentation
25
29
  width?: TableColumnInlineSize
26
30
  minWidth?: TableColumnMinInlineSize
27
31
  align?: STableAlignment
@@ -2,8 +2,8 @@ import { validateExactString } from './runtimeContract'
2
2
 
3
3
  const SCROLLABLE_VIEWPORT_MAX_BLOCK_SIZES = ['none', 'sm', 'md', 'lg'] as const
4
4
  const EXPANDED_CONTENT_MAX_BLOCK_SIZES = ['sm', 'md', 'lg'] as const
5
- const TABLE_COLUMN_INLINE_SIZES = ['auto', 'xs', 'sm', 'md', 'lg', 'xl', 'fill'] as const
6
- const TABLE_COLUMN_MIN_INLINE_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
5
+ const TABLE_COLUMN_INLINE_SIZES = ['auto', 'content', '2xs', 'xs', 'sm', 'md', 'lg', 'xl', 'fill'] as const
6
+ const TABLE_COLUMN_MIN_INLINE_SIZES = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl'] as const
7
7
 
8
8
  /**
9
9
  * Максимальный block-size прокручиваемого viewport; none сохраняет естественный flow.
@@ -212,6 +212,7 @@
212
212
  --s-expanded-content-max-block-size-lg: 28rem;
213
213
  --s-table-inline-size-md: 32rem;
214
214
  --s-table-inline-size-lg: 42rem;
215
+ --s-table-column-inline-size-2xs: 3rem;
215
216
  --s-table-column-inline-size-xs: 6rem;
216
217
  --s-table-column-inline-size-sm: 9rem;
217
218
  --s-table-column-inline-size-md: 12rem;