@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 +57 -0
- package/package.json +1 -1
- package/src/components/shared/containers/SPageHeader.css +5 -0
- package/src/components/shared/containers/SPageHeader.vue +1 -1
- package/src/components/shared/controls/SCombobox.vue +47 -5
- package/src/components/shared/data-display/STable.css +39 -0
- package/src/components/shared/data-display/STable.vue +541 -73
- package/src/components/shared/data-display/table.ts +4 -0
- package/src/internal/semanticSizing.ts +2 -2
- package/src/styles/tokens.css +1 -0
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
|
@@ -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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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="
|
|
95
|
+
:colspan="renderedColumnCount"
|
|
63
96
|
/>
|
|
64
97
|
<template v-else>
|
|
65
|
-
<
|
|
98
|
+
<template
|
|
66
99
|
v-for="(row, rowIndex) in validatedRows"
|
|
67
|
-
:key="
|
|
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
|
-
<
|
|
76
|
-
|
|
77
|
-
:
|
|
78
|
-
:
|
|
79
|
-
|
|
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
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
98
|
-
</
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
332
|
-
if (
|
|
333
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
+
|
|
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
|
-
|
|
399
|
-
+
|
|
647
|
+
`${diagnosticOwner()}: getRowKey должен быть обязательным function resolver. `
|
|
648
|
+
+ `/ ${diagnosticOwner()}: getRowKey must be a required function resolver.`,
|
|
400
649
|
)
|
|
401
650
|
}
|
|
402
651
|
|
|
403
|
-
const
|
|
652
|
+
const firstIndexByKey = new Map<STableRowKey, number>()
|
|
404
653
|
const rowKeys = new Map<Row, RowKey>()
|
|
405
|
-
for (const row of props.data) {
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
410
|
-
+ `/ STable: getRowKey returned duplicate key ${describeRowKey(key)}.`,
|
|
660
|
+
`${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
|
|
411
661
|
)
|
|
412
662
|
}
|
|
413
|
-
|
|
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
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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.
|
package/src/styles/tokens.css
CHANGED
|
@@ -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;
|