@pgcorp/ui-kit 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +135 -8
  2. package/docs/accessibility.md +12 -0
  3. package/docs/getting-started.md +32 -2
  4. package/docs/public-api.md +19 -2
  5. package/docs/theming.md +16 -0
  6. package/package.json +18 -3
  7. package/src/components/shared/codeLanguages.ts +2 -107
  8. package/src/components/shared/containers/SPanel.css +32 -1
  9. package/src/components/shared/containers/SPanel.vue +4 -0
  10. package/src/components/shared/controls/SCheckbox.vue +2 -2
  11. package/src/components/shared/controls/SInteractiveSurface.css +4 -0
  12. package/src/components/shared/controls/SInteractiveSurface.vue +1 -1
  13. package/src/components/shared/controls/SListbox.vue +4 -4
  14. package/src/components/shared/controls/SSwitch.vue +2 -2
  15. package/src/components/shared/data-display/SChart.css +149 -0
  16. package/src/components/shared/data-display/SChart.vue +493 -0
  17. package/src/components/shared/data-display/SChip.css +19 -0
  18. package/src/components/shared/data-display/SChip.vue +30 -6
  19. package/src/components/shared/data-display/SCodeBlock.css +68 -0
  20. package/src/components/shared/data-display/SCodeBlock.vue +102 -16
  21. package/src/components/shared/data-display/SMetricCard.css +1 -0
  22. package/src/components/shared/data-display/SMetricCard.vue +1 -0
  23. package/src/components/shared/data-display/STable.vue +120 -145
  24. package/src/components/shared/data-display/chart.ts +54 -0
  25. package/src/components/shared/navigation/STabList.vue +0 -3
  26. package/src/internal/chartGeometry.ts +748 -0
  27. package/src/internal/codeLanguageIdentity.ts +89 -0
  28. package/src/internal/lazyCodeEditor.ts +1 -0
  29. package/src/internal/linkTarget.ts +1 -3
  30. package/src/internal/ownedAttrs.ts +1 -0
  31. package/src/internal/useBinaryInput.ts +9 -2
  32. package/src/styles/tailwind.css +3 -0
  33. package/src/styles/tokens.css +33 -0
@@ -5,9 +5,10 @@
5
5
  :class="{
6
6
  's-code-block--radius-none': validatedSurfaceRadius === 'none',
7
7
  's-code-block--radius-xl': validatedSurfaceRadius === 'xl',
8
- 's-code-block--interactive': !validatedReadOnly,
8
+ 's-code-block--interactive': validatedPresentation === 'editor' && !validatedReadOnly,
9
9
  's-code-block--viewport': !['content', 'document'].includes(validatedViewport),
10
10
  }"
11
+ :data-presentation="validatedPresentation"
11
12
  >
12
13
  <div v-if="validatedShowHeader" class="s-code-block-header">
13
14
  <div class="s-code-block-info">
@@ -29,7 +30,8 @@
29
30
  </div>
30
31
  </div>
31
32
  <div class="s-code-block-editor-region">
32
- <SCodeEditor
33
+ <AsyncSCodeEditor
34
+ v-if="validatedPresentation === 'editor'"
33
35
  ref="codeEditor"
34
36
  :model-value="props.code"
35
37
  :language="validatedLanguage"
@@ -45,9 +47,24 @@
45
47
  @selection-applied="emit('selection-applied', $event)"
46
48
  @ready="onReady"
47
49
  />
50
+ <pre
51
+ v-else
52
+ :ref="setPlainSurface"
53
+ class="s-code-block-plain"
54
+ :data-viewport="validatedViewport"
55
+ :data-line-numbers="validatedShowLineNumbers ? 'visible' : 'hidden'"
56
+ :aria-label="props.ariaLabel"
57
+ :aria-labelledby="props.ariaLabelledby"
58
+ :style="{ tabSize: validatedTabSize }"
59
+ tabindex="0"
60
+ ><code v-if="!validatedShowLineNumbers">{{ validatedCode }}</code><code v-else class="s-code-block-plain__lines"><span
61
+ v-for="(line, index) in plainLines"
62
+ :key="index"
63
+ class="s-code-block-plain__line"
64
+ ><span class="s-code-block-plain__line-number" aria-hidden="true">{{ index + 1 }}</span><span class="s-code-block-plain__line-content">{{ line || '\u200b' }}</span></span></code></pre>
48
65
  </div>
49
66
  <SAsyncState
50
- v-if="validatedErrorMessage !== undefined || !isReady"
67
+ v-if="validatedErrorMessage !== undefined || (validatedPresentation === 'editor' && !isReady)"
51
68
  :state="validatedErrorMessage === undefined
52
69
  ? { status: 'loading', message: 'Загрузка кода...' }
53
70
  : { status: 'error', message: validatedErrorMessage }"
@@ -57,21 +74,21 @@
57
74
  </template>
58
75
 
59
76
  <script setup lang="ts">
60
- import { computed, ref } from 'vue';
77
+ import { computed, defineAsyncComponent, onMounted, ref, watch, type ComponentPublicInstance } from 'vue';
61
78
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
62
- import { validateBoolean, validateExactString, validateNonEmptyString } from '../../../internal/runtimeContract';
79
+ import { validateBoolean, validateBoundedInteger, validateExactString, validateNonEmptyString } from '../../../internal/runtimeContract';
63
80
  import { validateCodeEditorLanguage, validateCodeEditorViewport } from '../../../internal/codeEditorContract';
64
- import { resolveCodeLanguageKey } from '../codeLanguages';
81
+ import { resolveCodeLanguageKey } from '../../../internal/codeLanguageIdentity';
65
82
  import { useNotifier } from '../../../composables/useNotifier';
66
83
  import { useClipboard } from '../../../composables/useClipboard';
67
84
  import SButton from '../controls/SButton.vue';
68
- import SCodeEditor, {
69
- type SCodeEditorCapabilities,
70
- type SCodeEditorCursorActivity,
71
- type SCodeEditorLanguage,
72
- type SCodeEditorReadyPayload,
73
- type SCodeEditorSelectionAppliedPayload,
74
- type SCodeEditorViewport,
85
+ import type {
86
+ SCodeEditorCapabilities,
87
+ SCodeEditorCursorActivity,
88
+ SCodeEditorLanguage,
89
+ SCodeEditorReadyPayload,
90
+ SCodeEditorSelectionAppliedPayload,
91
+ SCodeEditorViewport,
75
92
  } from './SCodeEditor.vue';
76
93
  import { Copy } from '../../icons/sputnigUiIcons';
77
94
  import SAsyncState from '../feedback/SAsyncState.vue';
@@ -86,6 +103,7 @@ type AccessibleName =
86
103
  export type Props = AccessibleName & {
87
104
  code: string;
88
105
  language: SCodeEditorLanguage;
106
+ presentation?: 'editor' | 'plain';
89
107
  showHeader?: boolean;
90
108
  showLineNumbers?: boolean;
91
109
  readOnly?: boolean;
@@ -100,6 +118,7 @@ export type Props = AccessibleName & {
100
118
  };
101
119
 
102
120
  const props = withDefaults(defineProps<Props>(), {
121
+ presentation: 'editor',
103
122
  showHeader: true,
104
123
  showLineNumbers: true,
105
124
  readOnly: true,
@@ -114,7 +133,9 @@ const props = withDefaults(defineProps<Props>(), {
114
133
  });
115
134
 
116
135
  const isReady = ref(false);
117
- const codeEditor = ref<InstanceType<typeof SCodeEditor> | null>(null);
136
+ const codeEditor = ref<InstanceType<typeof import('./SCodeEditor.vue')['default']> | null>(null);
137
+ const plainSurface = ref<HTMLElement | null>(null);
138
+ const AsyncSCodeEditor = defineAsyncComponent(() => import('../../../internal/lazyCodeEditor'));
118
139
  const ownedAttrs = useOwnedAttrs({ component: 'SCodeBlock', owner: 'code block root' });
119
140
  const emit = defineEmits<{
120
141
  (e: 'cursor-activity', payload: { pos: number; lineFrom: number; lineTo: number; lineText: string; cursorCol: number }): void
@@ -124,19 +145,29 @@ const emit = defineEmits<{
124
145
  }>();
125
146
 
126
147
  const validatedShowHeader = computed(() => validateBoolean('SCodeBlock', 'showHeader', props.showHeader));
148
+ const validatedShowLineNumbers = computed(() => validateBoolean('SCodeBlock', 'showLineNumbers', props.showLineNumbers));
127
149
  const validatedReadOnly = computed(() => validateBoolean('SCodeBlock', 'readOnly', props.readOnly));
150
+ const validatedPresentation = computed(() => validateExactString(
151
+ 'SCodeBlock', 'presentation', props.presentation, ['editor', 'plain'] as const,
152
+ ));
128
153
  const validatedSurfaceRadius = computed(() => validateExactString(
129
154
  'SCodeBlock', 'surfaceRadius', props.surfaceRadius, ['default', 'none', 'xl'] as const,
130
155
  ));
131
156
  const validatedViewport = computed(() => validateCodeEditorViewport('SCodeBlock', props.viewport));
132
157
  const validatedLanguage = computed(() => validateCodeEditorLanguage('SCodeBlock', props.language));
158
+ const validatedCode = computed(() => {
159
+ if (typeof props.code !== 'string') throw new TypeError('SCodeBlock: code должен быть string. / SCodeBlock: code must be a string.');
160
+ return props.code;
161
+ });
162
+ const validatedTabSize = computed(() => validateBoundedInteger('SCodeBlock', 'tabSize', props.tabSize, { minimum: 1, maximum: 16 }));
133
163
  const resolvedLanguageId = computed(() => resolveCodeLanguageKey(validatedLanguage.value));
164
+ const plainLines = computed(() => validatedCode.value.split('\n'));
134
165
  const validatedErrorMessage = computed(() => props.errorMessage === undefined
135
166
  ? undefined
136
167
  : validateNonEmptyString('SCodeBlock', 'errorMessage', props.errorMessage));
137
168
  const editorCapabilities = computed<SCodeEditorCapabilities>(() => ({
138
- lineNumbers: props.showLineNumbers,
139
- indentation: { tabSize: props.tabSize, keyBehavior: props.tabKeyBehavior },
169
+ lineNumbers: validatedShowLineNumbers.value,
170
+ indentation: { tabSize: validatedTabSize.value, keyBehavior: props.tabKeyBehavior },
140
171
  ...(props.interceptKeys === undefined ? {} : { keyInterceptor: props.interceptKeys }),
141
172
  cursorActivity: 'always',
142
173
  }));
@@ -154,13 +185,68 @@ const onReady = (payload: SCodeEditorReadyPayload): void => {
154
185
  emit('ready', payload);
155
186
  };
156
187
 
188
+ let mounted = false;
189
+ const emitPlainReady = (): void => {
190
+ isReady.value = true;
191
+ emit('ready', {
192
+ documentLength: validatedCode.value.length,
193
+ viewport: validatedViewport.value,
194
+ variant: 'code',
195
+ locked: true,
196
+ });
197
+ };
198
+
199
+ const syncPresentation = (): void => {
200
+ if (!mounted) return;
201
+ if (validatedPresentation.value === 'plain') emitPlainReady();
202
+ else isReady.value = false;
203
+ };
204
+
205
+ onMounted(() => {
206
+ mounted = true;
207
+ syncPresentation();
208
+ });
209
+ watch(validatedPresentation, syncPresentation);
210
+
211
+ watch(
212
+ [validatedPresentation, validatedReadOnly],
213
+ ([presentation, readOnly]) => {
214
+ if (presentation === 'plain' && !readOnly) {
215
+ throw new TypeError('SCodeBlock: presentation="plain" поддерживает только readOnly=true. / SCodeBlock: presentation="plain" supports readOnly=true only.');
216
+ }
217
+ if (presentation === 'plain' && props.interceptKeys !== undefined) {
218
+ throw new TypeError('SCodeBlock: presentation="plain" не принимает interceptKeys. / SCodeBlock: presentation="plain" does not accept interceptKeys.');
219
+ }
220
+ if (presentation === 'plain' && props.nextSelectionPos !== null) {
221
+ throw new TypeError('SCodeBlock: presentation="plain" не принимает nextSelectionPos. / SCodeBlock: presentation="plain" does not accept nextSelectionPos.');
222
+ }
223
+ },
224
+ { immediate: true },
225
+ );
226
+
227
+ const setPlainSurface = (element: Element | ComponentPublicInstance | null): void => {
228
+ if (element !== null && !(element instanceof HTMLElement)) {
229
+ throw new TypeError('SCodeBlock: plain surface ref должен быть HTMLElement. / SCodeBlock: plain surface ref must be an HTMLElement.');
230
+ }
231
+ plainSurface.value = element;
232
+ };
233
+
157
234
  const applySelection = (position: number): void => {
235
+ if (validatedPresentation.value === 'plain') {
236
+ throw new Error('SCodeBlock: applySelection недоступен в presentation="plain". / SCodeBlock: applySelection is unavailable in presentation="plain".');
237
+ }
158
238
  const editor = codeEditor.value;
159
239
  if (!editor) throw new Error('SCodeBlock: editor не готов; дождитесь ready. / SCodeBlock: editor is not ready; wait for ready.');
160
240
  editor.applySelection(position);
161
241
  };
162
242
 
163
243
  const focus = (): void => {
244
+ if (validatedPresentation.value === 'plain') {
245
+ const surface = plainSurface.value;
246
+ if (!surface) throw new Error('SCodeBlock: plain surface не готов; дождитесь ready. / SCodeBlock: plain surface is not ready; wait for ready.');
247
+ surface.focus();
248
+ return;
249
+ }
164
250
  const editor = codeEditor.value;
165
251
  if (!editor) throw new Error('SCodeBlock: editor не готов; дождитесь ready. / SCodeBlock: editor is not ready; wait for ready.');
166
252
  editor.focus();
@@ -30,3 +30,4 @@
30
30
  }
31
31
  .s-metric-card__progress { @apply mt-auto space-y-2; }
32
32
  .s-metric-card__progress-label { @apply text-xs text-surface-500; }
33
+ .s-metric-card__chart { @apply mt-auto min-w-0 w-full overflow-hidden; }
@@ -108,6 +108,7 @@ watchEffect(() => {
108
108
  <SProgressBar :accessibility="progressAccessibility" :value="resolvedProgressValue" :severity="progressSeverity" />
109
109
  <div class="s-metric-card__progress-label">{{ resolvedProgressLabel }}</div>
110
110
  </div>
111
+ <div v-if="$slots.chart" class="s-metric-card__chart"><slot name="chart" /></div>
111
112
  <slot />
112
113
  </div>
113
114
  <template v-if="$slots.actions" #actions><slot name="actions" /></template>
@@ -96,34 +96,34 @@
96
96
  />
97
97
  <template v-else>
98
98
  <template
99
- v-for="(row, rowIndex) in validatedRows"
100
- :key="rowDomToken(row)"
99
+ v-for="entry in validatedRows"
100
+ :key="rowKeyToken(entry.key)"
101
101
  >
102
102
  <tr
103
- :data-state="resolveRowState(row)"
104
- :data-selected="rowIsSelected(row) || undefined"
105
- :aria-selected="rowAriaSelected(row)"
103
+ :data-state="resolveRowState(entry.row)"
104
+ :data-selected="rowIsSelected(entry) || undefined"
105
+ :aria-selected="rowAriaSelected(entry)"
106
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)"
107
+ @click="onRowClick($event, entry.row, entry.index)"
108
+ @contextmenu="onRowContextMenu($event, entry.row, entry.index)"
109
+ @keydown.enter="onRowKeydown($event, entry.row, entry.index)"
110
+ @keydown.space="onRowKeydown($event, entry.row, entry.index)"
111
111
  >
112
112
  <td
113
113
  v-if="validatedSelectionMode === 'multiple'"
114
114
  class="s-table__control-cell"
115
115
  >
116
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)"
117
+ :model-value="rowIsSelected(entry)"
118
+ :disabled="!entry.selectable"
119
+ :aria-label="rowSelectionLabel(entry)"
120
+ @update:model-value="updateRowSelection(entry, $event)"
121
121
  />
122
122
  </td>
123
123
  <td v-if="hasRowDetails" class="s-table__control-cell">
124
124
  <SButton
125
- v-if="rowIsExpandable(row)"
126
- :aria-label="rowExpansionLabel(row, rowIndex)"
125
+ v-if="entry.expandable"
126
+ :aria-label="rowExpansionLabel(entry)"
127
127
  appearance="text"
128
128
  severity="secondary"
129
129
  size="xs"
@@ -131,47 +131,47 @@
131
131
  shape="square"
132
132
  icon-only
133
133
  :disclosure="{
134
- expanded: rowIsExpanded(row),
135
- controls: rowDetailsId(row),
134
+ expanded: rowIsExpanded(entry),
135
+ controls: rowDetailsId(entry.key),
136
136
  iconPlacement: 'leading',
137
137
  }"
138
- @click="toggleRowExpansion(row, rowIndex)"
138
+ @click="toggleRowExpansion(entry)"
139
139
  />
140
140
  </td>
141
141
  <td
142
142
  v-for="column in validatedColumns"
143
143
  :key="column.field"
144
144
  :data-align="column.align ?? 'left'"
145
- :data-state="resolveCellState(row, column)"
145
+ :data-state="resolveCellState(entry.row, column)"
146
146
  >
147
147
  <slot
148
148
  :name="`cell-${column.field}`"
149
- :value="resolveCellValue(row, column)"
150
- :row="row"
149
+ :value="resolveCellValue(entry.row, column)"
150
+ :row="entry.row"
151
151
  :column="column"
152
- :row-index="rowIndex"
152
+ :row-index="entry.index"
153
153
  >
154
154
  <slot
155
155
  name="cell"
156
- :value="resolveCellValue(row, column)"
157
- :row="row"
156
+ :value="resolveCellValue(entry.row, column)"
157
+ :row="entry.row"
158
158
  :column="column"
159
- :row-index="rowIndex"
159
+ :row-index="entry.index"
160
160
  >
161
- {{ resolveCellValue(row, column) }}
161
+ {{ resolveCellValue(entry.row, column) }}
162
162
  </slot>
163
163
  </slot>
164
164
  </td>
165
165
  </tr>
166
- <tr v-if="rowIsExpandable(row) && rowIsExpanded(row)" data-row-details>
166
+ <tr v-if="entry.expandable && rowIsExpanded(entry)" data-row-details>
167
167
  <td :colspan="renderedColumnCount">
168
168
  <div
169
- :id="rowDetailsId(row)"
169
+ :id="rowDetailsId(entry.key)"
170
170
  class="s-table__row-details"
171
171
  role="region"
172
- :aria-label="`Детали: ${resolveRowLabel(row, rowIndex)}`"
172
+ :aria-label="`Детали: ${resolveRowLabel(entry.row, entry.index)}`"
173
173
  >
174
- <slot name="row-details" :row="row" :row-index="rowIndex" />
174
+ <slot name="row-details" :row="entry.row" :row-index="entry.index" />
175
175
  </div>
176
176
  </td>
177
177
  </tr>
@@ -664,7 +664,7 @@ function validateRowKey(value: unknown, coordinate: string): RowKey {
664
664
  )
665
665
  }
666
666
 
667
- const resolvedRowKeys = computed<ReadonlyMap<Row, RowKey>>(() => {
667
+ function resolveRowKey(row: Row, rowIndex: number): RowKey {
668
668
  if (typeof props.getRowKey !== 'function') {
669
669
  throw new TypeError(
670
670
  `${diagnosticOwner()}: getRowKey должен быть обязательным function resolver. `
@@ -672,31 +672,16 @@ const resolvedRowKeys = computed<ReadonlyMap<Row, RowKey>>(() => {
672
672
  )
673
673
  }
674
674
 
675
- const firstIndexByKey = new Map<STableRowKey, number>()
676
- const rowKeys = new Map<Row, RowKey>()
677
- for (const [rowIndex, row] of props.data.entries()) {
678
- let value: unknown
679
- try {
680
- value = props.getRowKey(row)
681
- } catch (error: unknown) {
682
- throw new TypeError(
683
- `${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
684
- )
685
- }
686
- const key = validateRowKey(value, `getRowKey at data[${rowIndex}]`)
687
- const firstIndex = firstIndexByKey.get(key)
688
- if (firstIndex !== undefined) {
689
- throw new TypeError(
690
- `${diagnosticOwner()}: getRowKey вернул duplicate key ${describeRowKey(key)} at data[${rowIndex}]; `
691
- + `first returned at data[${firstIndex}]. / ${diagnosticOwner()}: getRowKey returned duplicate key `
692
- + `${describeRowKey(key)} at data[${rowIndex}]; first returned at data[${firstIndex}].`,
693
- )
694
- }
695
- firstIndexByKey.set(key, rowIndex)
696
- rowKeys.set(row, key)
675
+ let value: unknown
676
+ try {
677
+ value = props.getRowKey(row)
678
+ } catch (error: unknown) {
679
+ throw new TypeError(
680
+ `${diagnosticOwner()}: getRowKey failed at data[${rowIndex}]: ${describeThrownError(error)}.`,
681
+ )
697
682
  }
698
- return rowKeys
699
- })
683
+ return validateRowKey(value, `getRowKey at data[${rowIndex}]`)
684
+ }
700
685
 
701
686
  function validateControlledKeys(coordinate: string, value: unknown): readonly RowKey[] {
702
687
  if (!Array.isArray(value)) {
@@ -729,15 +714,7 @@ const validatedSelectedRowKey = computed<RowKey | undefined>(() => {
729
714
  + `/ ${diagnosticOwner()}: selectedRowKey is incompatible with selectionMode="multiple"; use selectedRowKeys.`,
730
715
  )
731
716
  }
732
- const selectedKey = validateRowKey(props.selectedRowKey, 'selectedRowKey')
733
- const rowKeys = new Set(resolvedRowKeys.value.values())
734
- if (!rowKeys.has(selectedKey)) {
735
- throw new TypeError(
736
- `${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} отсутствует в resolved row keys. `
737
- + `/ ${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} is absent from resolved row keys.`,
738
- )
739
- }
740
- return selectedKey
717
+ return validateRowKey(props.selectedRowKey, 'selectedRowKey')
741
718
  })
742
719
 
743
720
  const validatedSelectedRowKeys = computed<readonly RowKey[]>(() => {
@@ -760,17 +737,6 @@ const validatedSelectedRowKeys = computed<readonly RowKey[]>(() => {
760
737
  return keys
761
738
  })
762
739
 
763
- const rowSelectableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
764
- const result = new Map<Row, boolean>()
765
- for (const [index, row] of props.data.entries()) {
766
- const selectable = validatedSelectionMode.value === 'multiple'
767
- ? validateBoolean('STable', `isRowSelectable at data[${index}]`, props.isRowSelectable?.(row, index) ?? true)
768
- : false
769
- result.set(row, selectable)
770
- }
771
- return result
772
- })
773
-
774
740
  const validatedExpandedRowKeys = computed<readonly RowKey[]>(() => {
775
741
  const keys = validateControlledKeys('expandedRowKeys', props.expandedRowKeys)
776
742
  if (!hasRowDetails && (keys.length > 0 || props.isRowExpandable !== undefined)) {
@@ -785,36 +751,32 @@ const validatedExpandedRowKeys = computed<readonly RowKey[]>(() => {
785
751
  return keys
786
752
  })
787
753
 
788
- const rowExpandableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
789
- const result = new Map<Row, boolean>()
790
- for (const [index, row] of props.data.entries()) {
791
- const expandable = hasRowDetails
792
- ? validateBoolean('STable', `isRowExpandable at data[${index}]`, props.isRowExpandable?.(row, index) ?? true)
793
- : false
794
- result.set(row, expandable)
795
- }
796
- const expanded = new Set(validatedExpandedRowKeys.value)
797
- for (const row of props.data) {
798
- if (expanded.has(rowKey(row)) && !result.get(row)) {
799
- throw new TypeError(
800
- `${diagnosticOwner()}: expandedRowKeys содержит current row, для которой isRowExpandable вернул false. `
801
- + `/ ${diagnosticOwner()}: expandedRowKeys contains a current row for which isRowExpandable returned false.`,
802
- )
803
- }
804
- }
805
- return result
806
- })
754
+ interface ResolvedRowEntry {
755
+ readonly row: Row
756
+ readonly key: RowKey
757
+ readonly index: number
758
+ readonly selectable: boolean
759
+ readonly expandable: boolean
760
+ }
807
761
 
808
- const validatedRows = computed<readonly Row[]>(() => {
762
+ const resolvedRowEntries = computed<readonly ResolvedRowEntry[]>(() => {
809
763
  const columns = validatedColumns.value
810
764
  void activeSorts.value
811
- void resolvedRowKeys.value
812
- void validatedSelectedRowKey.value
813
765
  void validatedSelectedRowKeys.value
814
- void rowSelectableByRow.value
815
766
  void validatedExpandedRowKeys.value
816
- void rowExpandableByRow.value
817
- for (const row of props.data) {
767
+ const firstIndexByKey = new Map<STableRowKey, number>()
768
+ const entries: ResolvedRowEntry[] = []
769
+ for (const [index, row] of props.data.entries()) {
770
+ const key = resolveRowKey(row, index)
771
+ const firstIndex = firstIndexByKey.get(key)
772
+ if (firstIndex !== undefined) {
773
+ throw new TypeError(
774
+ `${diagnosticOwner()}: getRowKey вернул duplicate key ${describeRowKey(key)} at data[${index}]; `
775
+ + `first returned at data[${firstIndex}]. / ${diagnosticOwner()}: getRowKey returned duplicate key `
776
+ + `${describeRowKey(key)} at data[${index}]; first returned at data[${firstIndex}].`,
777
+ )
778
+ }
779
+ firstIndexByKey.set(key, index)
818
780
  for (const column of columns) {
819
781
  const content = column.content ?? (column.value === undefined ? 'field' : 'value')
820
782
  if (content === 'field' && !hasOwn(row, column.field)) {
@@ -824,26 +786,48 @@ const validatedRows = computed<readonly Row[]>(() => {
824
786
  )
825
787
  }
826
788
  }
827
- }
828
- return props.data
789
+ entries.push({
790
+ row,
791
+ key,
792
+ index,
793
+ selectable: validatedSelectionMode.value === 'multiple'
794
+ ? validateBoolean('STable', `isRowSelectable at data[${index}]`, props.isRowSelectable?.(row, index) ?? true)
795
+ : false,
796
+ expandable: hasRowDetails
797
+ ? validateBoolean('STable', `isRowExpandable at data[${index}]`, props.isRowExpandable?.(row, index) ?? true)
798
+ : false,
799
+ })
800
+ }
801
+ return entries
829
802
  })
830
803
 
831
- function rowKey(row: Row): RowKey {
832
- const key = resolvedRowKeys.value.get(row)
833
- if (key === undefined) throw new Error(`${diagnosticOwner()}: row identity is unavailable`)
834
- return key
835
- }
804
+ const validatedRows = computed<readonly ResolvedRowEntry[]>(() => {
805
+ const entries = resolvedRowEntries.value
806
+ const selectedKey = validatedSelectedRowKey.value
807
+ if (selectedKey !== undefined && !entries.some((entry) => entry.key === selectedKey)) {
808
+ throw new TypeError(
809
+ `${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} отсутствует в resolved row keys. `
810
+ + `/ ${diagnosticOwner()}: selectedRowKey ${describeRowKey(selectedKey)} is absent from resolved row keys.`,
811
+ )
812
+ }
813
+ const expanded = new Set(validatedExpandedRowKeys.value)
814
+ for (const entry of entries) {
815
+ if (expanded.has(entry.key) && !entry.expandable) {
816
+ throw new TypeError(
817
+ `${diagnosticOwner()}: expandedRowKeys содержит current row, для которой isRowExpandable вернул false. `
818
+ + `/ ${diagnosticOwner()}: expandedRowKeys contains a current row for which isRowExpandable returned false.`,
819
+ )
820
+ }
821
+ }
822
+ return entries
823
+ })
836
824
 
837
825
  function rowKeyToken(key: RowKey): string {
838
826
  return `${typeof key === 'number' ? 'number' : 'string'}:${String(key)}`
839
827
  }
840
828
 
841
- function rowDomToken(row: Row): string {
842
- return rowKeyToken(rowKey(row))
843
- }
844
-
845
- function rowDetailsId(row: Row): string {
846
- return `${tableInstanceId}-details-${encodeURIComponent(rowDomToken(row))}`
829
+ function rowDetailsId(key: RowKey): string {
830
+ return `${tableInstanceId}-details-${encodeURIComponent(rowKeyToken(key))}`
847
831
  }
848
832
 
849
833
  function resolveRowLabel(row: Row, index: number): string {
@@ -856,9 +840,9 @@ function resolveRowLabel(row: Row, index: number): string {
856
840
 
857
841
  const selectedKeySet = computed(() => new Set(validatedSelectedRowKeys.value))
858
842
  const expandedKeySet = computed(() => new Set(validatedExpandedRowKeys.value))
859
- const selectableRows = computed(() => props.data.filter((row) => rowSelectableByRow.value.get(row) === true))
843
+ const selectableRows = computed(() => validatedRows.value.filter((entry) => entry.selectable))
860
844
  const selectedCurrentRowCount = computed(() => selectableRows.value.filter(
861
- (row) => selectedKeySet.value.has(rowKey(row)),
845
+ (entry) => selectedKeySet.value.has(entry.key),
862
846
  ).length)
863
847
  const someCurrentRowsSelected = computed(() => selectedCurrentRowCount.value > 0)
864
848
  const allCurrentRowsSelected = computed(() => (
@@ -871,29 +855,24 @@ const renderedColumnCount = computed(() => Math.max(
871
855
  1,
872
856
  ))
873
857
 
874
- function rowIsSelectable(row: Row): boolean {
875
- return rowSelectableByRow.value.get(row) === true
858
+ function rowIsSelected(entry: ResolvedRowEntry): boolean {
859
+ return validatedSelectionMode.value === 'multiple' && selectedKeySet.value.has(entry.key)
876
860
  }
877
861
 
878
- function rowIsSelected(row: Row): boolean {
879
- return validatedSelectionMode.value === 'multiple' && selectedKeySet.value.has(rowKey(row))
862
+ function rowAriaSelected(entry: ResolvedRowEntry): 'true' | 'false' | undefined {
863
+ if (validatedSelectionMode.value === 'multiple') return rowIsSelected(entry) ? 'true' : 'false'
864
+ return entry.key === validatedSelectedRowKey.value ? 'true' : undefined
880
865
  }
881
866
 
882
- function rowAriaSelected(row: Row): 'true' | 'false' | undefined {
883
- if (validatedSelectionMode.value === 'multiple') return rowIsSelected(row) ? 'true' : 'false'
884
- return rowKey(row) === validatedSelectedRowKey.value ? 'true' : undefined
867
+ function rowSelectionLabel(entry: ResolvedRowEntry): string {
868
+ const label = resolveRowLabel(entry.row, entry.index)
869
+ if (!entry.selectable) return `Выбор недоступен: ${label}`
870
+ return rowIsSelected(entry) ? `Снять выбор: ${label}` : `Выбрать: ${label}`
885
871
  }
886
872
 
887
- function rowSelectionLabel(row: Row, index: number): string {
888
- const label = resolveRowLabel(row, index)
889
- if (!rowIsSelectable(row)) return `Выбор недоступен: ${label}`
890
- return rowIsSelected(row) ? `Снять выбор: ${label}` : `Выбрать: ${label}`
891
- }
892
-
893
- function updateRowSelection(row: Row, index: number, selected: boolean): void {
894
- void index
895
- if (!rowIsSelectable(row)) return
896
- const key = rowKey(row)
873
+ function updateRowSelection(entry: ResolvedRowEntry, selected: boolean): void {
874
+ if (!entry.selectable) return
875
+ const key = entry.key
897
876
  const current = validatedSelectedRowKeys.value
898
877
  if (selected && !selectedKeySet.value.has(key)) emit('update:selectedRowKeys', [...current, key])
899
878
  if (!selected && selectedKeySet.value.has(key)) {
@@ -903,7 +882,7 @@ function updateRowSelection(row: Row, index: number, selected: boolean): void {
903
882
 
904
883
  function updateAllRowsSelection(selected: boolean): void {
905
884
  const current = validatedSelectedRowKeys.value
906
- const currentSelectableKeys = new Set(selectableRows.value.map((row) => rowKey(row)))
885
+ const currentSelectableKeys = new Set(selectableRows.value.map((entry) => entry.key))
907
886
  if (!selected) {
908
887
  emit('update:selectedRowKeys', current.filter((key) => !currentSelectableKeys.has(key)))
909
888
  return
@@ -916,27 +895,23 @@ function updateAllRowsSelection(selected: boolean): void {
916
895
  emit('update:selectedRowKeys', next)
917
896
  }
918
897
 
919
- function rowIsExpandable(row: Row): boolean {
920
- return rowExpandableByRow.value.get(row) === true
921
- }
922
-
923
- function rowIsExpanded(row: Row): boolean {
924
- return expandedKeySet.value.has(rowKey(row))
898
+ function rowIsExpanded(entry: ResolvedRowEntry): boolean {
899
+ return expandedKeySet.value.has(entry.key)
925
900
  }
926
901
 
927
- function rowExpansionLabel(row: Row, index: number): string {
928
- return `${rowIsExpanded(row) ? 'Свернуть' : 'Раскрыть'}: ${resolveRowLabel(row, index)}`
902
+ function rowExpansionLabel(entry: ResolvedRowEntry): string {
903
+ return `${rowIsExpanded(entry) ? 'Свернуть' : 'Раскрыть'}: ${resolveRowLabel(entry.row, entry.index)}`
929
904
  }
930
905
 
931
- function toggleRowExpansion(row: Row, index: number): void {
932
- if (!rowIsExpandable(row)) return
933
- const key = rowKey(row)
906
+ function toggleRowExpansion(entry: ResolvedRowEntry): void {
907
+ if (!entry.expandable) return
908
+ const key = entry.key
934
909
  const expanded = !expandedKeySet.value.has(key)
935
910
  const next = expanded
936
911
  ? [...validatedExpandedRowKeys.value, key]
937
912
  : validatedExpandedRowKeys.value.filter((candidate) => candidate !== key)
938
913
  emit('update:expandedRowKeys', next)
939
- emit('row-expand', row, index, expanded)
914
+ emit('row-expand', entry.row, entry.index, expanded)
940
915
  }
941
916
 
942
917
  const resolveCellValue = (row: Row, column: STableColumn<Row, Field>): unknown => column.value