@pgcorp/ui-kit 0.3.1 → 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.
package/README.md CHANGED
@@ -113,6 +113,42 @@ 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
+
116
152
  ## Темы и стили / Themes and styles
117
153
 
118
154
  Для обычного приложения подключайте полный 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.4.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"
@@ -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>
@@ -109,10 +186,12 @@ import type {
109
186
  STableCellState,
110
187
  STableColumn,
111
188
  STableColumnContent,
189
+ STableColumnHeaderPresentation,
112
190
  STableDensity,
113
191
  STableMinWidth,
114
192
  STableRowKey,
115
193
  STableRowState,
194
+ STableSelectionMode,
116
195
  STableSort,
117
196
  STableSurface,
118
197
  } from './table'
@@ -122,10 +201,12 @@ export type {
122
201
  STableCellState,
123
202
  STableColumn,
124
203
  STableColumnContent,
204
+ STableColumnHeaderPresentation,
125
205
  STableDensity,
126
206
  STableMinWidth,
127
207
  STableRowKey,
128
208
  STableRowState,
209
+ STableSelectionMode,
129
210
  STableSort,
130
211
  STableSortDirection,
131
212
  STableSurface,
@@ -145,6 +226,12 @@ export interface STableProps<
145
226
  errorMessage?: string
146
227
  emptyMessage?: string
147
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
148
235
  sort?: STableSort<Field> | null
149
236
  maxHeight?: ScrollableViewportMaxBlockSize
150
237
  density?: STableDensity
@@ -162,7 +249,7 @@ export interface STableProps<
162
249
  </script>
163
250
 
164
251
  <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'
252
+ import { computed, useId, useSlots } from 'vue'
166
253
  import { ChevronDown, ChevronsUpDown, ChevronUp } from '../../icons/sputnigUiIcons'
167
254
  import { hasOwn } from '../../../internal/es2020'
168
255
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
@@ -180,6 +267,7 @@ import {
180
267
  } from '../../../internal/runtimeContract'
181
268
  import SAsyncState from '../feedback/SAsyncState.vue'
182
269
  import SButton from '../controls/SButton.vue'
270
+ import SCheckbox from '../controls/SCheckbox.vue'
183
271
  defineOptions({ inheritAttrs: false })
184
272
 
185
273
  const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
@@ -188,6 +276,12 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
188
276
  errorMessage: '',
189
277
  emptyMessage: 'Нет данных для отображения',
190
278
  selectedRowKey: undefined,
279
+ selectionMode: 'none',
280
+ selectedRowKeys: () => [],
281
+ isRowSelectable: undefined,
282
+ expandedRowKeys: () => [],
283
+ isRowExpandable: undefined,
284
+ getRowLabel: undefined,
191
285
  sort: null,
192
286
  maxHeight: 'none',
193
287
  density: 'default',
@@ -201,6 +295,10 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
201
295
  })
202
296
  const emit = defineEmits<{
203
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]
204
302
  'update:sort': [value: STableSort<Field> | null]
205
303
  sort: [value: STableSort<Field> | null]
206
304
  }>()
@@ -213,9 +311,13 @@ const TABLE_ALIGNMENTS = ['left', 'center', 'right'] as const
213
311
  const TABLE_ROW_STATES = ['default', 'inserted', 'updated', 'deleted'] as const
214
312
  const TABLE_CELL_STATES = ['default', 'pending'] as const
215
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
216
316
 
217
317
  const ownedAttrs = useOwnedAttrs({ component: 'STable', owner: 'table scroll surface' })
218
318
  useInteractiveLeafRegistration({ owner: 'STable' })
319
+ const tableInstanceId = `s-table-${useId()}`
320
+ const hasRowDetails = typeof slots['row-details'] === 'function'
219
321
 
220
322
  function eventBelongsToNestedControl(event: Event): boolean {
221
323
  const owner = event.currentTarget
@@ -228,8 +330,14 @@ function onRowClick(event: MouseEvent, row: Row, rowIndex: number): void {
228
330
  if (!eventBelongsToNestedControl(event)) emit('row-click', row, rowIndex)
229
331
  }
230
332
 
333
+ function onRowContextMenu(event: MouseEvent, row: Row, rowIndex: number): void {
334
+ if (!eventBelongsToNestedControl(event)) emit('row-contextmenu', row, rowIndex, event)
335
+ }
336
+
231
337
  function onRowKeydown(event: KeyboardEvent, row: Row, rowIndex: number): void {
232
- 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)
233
341
  }
234
342
  const validatedLoading = computed(() => validateBoolean('STable', 'loading', props.loading))
235
343
  const resolvedAccessibleName = computed(() => {
@@ -275,12 +383,24 @@ const validatedMaxHeight = computed(() => validateScrollableViewportMaxBlockSize
275
383
  'maxHeight',
276
384
  props.maxHeight,
277
385
  ))
386
+ const validatedSelectionMode = computed(() => validateExactString(
387
+ 'STable',
388
+ 'selectionMode',
389
+ props.selectionMode,
390
+ TABLE_SELECTION_MODES,
391
+ ))
278
392
 
279
393
  const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
280
394
  const fields = new Set<string>()
281
395
  for (const column of props.columns) {
282
396
  const field = validateNonEmptyString('STable', 'columns[].field', column.field)
283
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
+ )
284
404
  if (fields.has(field)) {
285
405
  throw new TypeError(
286
406
  `STable: columns содержит duplicate field ${JSON.stringify(field)}. `
@@ -294,6 +414,12 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
294
414
  if (column.sortable !== undefined) {
295
415
  validateBoolean('STable', `column ${JSON.stringify(field)}.sortable`, column.sortable)
296
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
+ }
297
423
  if (column.width !== undefined) {
298
424
  validateTableColumnInlineSize('STable', `column ${JSON.stringify(field)}.width`, column.width)
299
425
  }
@@ -380,47 +506,178 @@ function describeRowKey(key: STableRowKey): string {
380
506
  return typeof key === 'string' ? JSON.stringify(key) : String(key)
381
507
  }
382
508
 
383
- 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 {
384
520
  if (
385
521
  (typeof value === 'string' && value.trim().length > 0)
386
522
  || (typeof value === 'number' && Number.isFinite(value))
387
523
  ) return value as RowKey
388
524
 
389
525
  throw new TypeError(
390
- `STable: getRowKey должен вернуть non-empty string | finite number. `
391
- + '/ 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.`,
392
528
  )
393
529
  }
394
530
 
395
531
  const resolvedRowKeys = computed<ReadonlyMap<Row, RowKey>>(() => {
396
532
  if (typeof props.getRowKey !== 'function') {
397
533
  throw new TypeError(
398
- 'STable: getRowKey должен быть обязательным function resolver. '
399
- + '/ STable: getRowKey must be a required function resolver.',
534
+ `${diagnosticOwner()}: getRowKey должен быть обязательным function resolver. `
535
+ + `/ ${diagnosticOwner()}: getRowKey must be a required function resolver.`,
400
536
  )
401
537
  }
402
538
 
403
- const keys = new Set<STableRowKey>()
539
+ const firstIndexByKey = new Map<STableRowKey, number>()
404
540
  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)) {
541
+ for (const [rowIndex, row] of props.data.entries()) {
542
+ let value: unknown
543
+ try {
544
+ value = props.getRowKey(row)
545
+ } catch (error: unknown) {
408
546
  throw new TypeError(
409
- `STable: getRowKey вернул duplicate key ${describeRowKey(key)}. `
410
- + `/ STable: getRowKey returned duplicate key ${describeRowKey(key)}.`,
547
+ `${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
411
548
  )
412
549
  }
413
- 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)
414
560
  rowKeys.set(row, key)
415
561
  }
416
562
  return rowKeys
417
563
  })
418
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
+
419
672
  const validatedRows = computed<readonly Row[]>(() => {
420
673
  const columns = validatedColumns.value
421
674
  void validatedSort.value
422
675
  void resolvedRowKeys.value
423
676
  void validatedSelectedRowKey.value
677
+ void validatedSelectedRowKeys.value
678
+ void rowSelectableByRow.value
679
+ void validatedExpandedRowKeys.value
680
+ void rowExpandableByRow.value
424
681
  for (const row of props.data) {
425
682
  for (const column of columns) {
426
683
  const content = column.content ?? (column.value === undefined ? 'field' : 'value')
@@ -435,18 +692,116 @@ const validatedRows = computed<readonly Row[]>(() => {
435
692
  return props.data
436
693
  })
437
694
 
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
- )
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.`)
447
717
  }
448
- return selectedKey
449
- })
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
+ }
450
805
 
451
806
  const resolveCellValue = (row: Row, column: STableColumn<Row, Field>): unknown => column.value
452
807
  ? column.value(row)
@@ -11,6 +11,8 @@ 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'
15
17
 
16
18
  /** Controlled sort state; consumer owns data ordering. / Управляемое состояние сортировки; порядок данных принадлежит consumer. */
@@ -22,6 +24,7 @@ export interface STableSort<Field extends string = string> {
22
24
  interface STableColumnBase<Field extends string> {
23
25
  field: Field
24
26
  header: string
27
+ headerPresentation?: STableColumnHeaderPresentation
25
28
  width?: TableColumnInlineSize
26
29
  minWidth?: TableColumnMinInlineSize
27
30
  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;