@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 +36 -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 +410 -55
- package/src/components/shared/data-display/table.ts +3 -0
- package/src/internal/semanticSizing.ts +2 -2
- package/src/styles/tokens.css +1 -0
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
|
@@ -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"
|
|
@@ -38,15 +60,24 @@
|
|
|
38
60
|
:trailing-icon="sortIcon(column)"
|
|
39
61
|
@click="requestSort(column)"
|
|
40
62
|
>
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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="
|
|
93
|
+
:colspan="renderedColumnCount"
|
|
63
94
|
/>
|
|
64
95
|
<template v-else>
|
|
65
|
-
<
|
|
96
|
+
<template
|
|
66
97
|
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)"
|
|
98
|
+
:key="rowDomToken(row)"
|
|
74
99
|
>
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
:
|
|
78
|
-
:
|
|
79
|
-
|
|
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
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
98
|
-
</
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
391
|
-
+
|
|
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
|
-
|
|
399
|
-
+
|
|
534
|
+
`${diagnosticOwner()}: getRowKey должен быть обязательным function resolver. `
|
|
535
|
+
+ `/ ${diagnosticOwner()}: getRowKey must be a required function resolver.`,
|
|
400
536
|
)
|
|
401
537
|
}
|
|
402
538
|
|
|
403
|
-
const
|
|
539
|
+
const firstIndexByKey = new Map<STableRowKey, number>()
|
|
404
540
|
const rowKeys = new Map<Row, RowKey>()
|
|
405
|
-
for (const row of props.data) {
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
410
|
-
+ `/ STable: getRowKey returned duplicate key ${describeRowKey(key)}.`,
|
|
547
|
+
`${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
|
|
411
548
|
)
|
|
412
549
|
}
|
|
413
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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.
|
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;
|