@pgcorp/ui-kit 0.2.0 → 0.3.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.
@@ -0,0 +1,48 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-switch-control {
4
+ @apply relative inline-flex shrink-0 items-center;
5
+ width: 2.5rem;
6
+ height: 1.5rem;
7
+ }
8
+
9
+ .s-switch-input {
10
+ @apply absolute inset-0 z-10 m-0 cursor-pointer opacity-0;
11
+ }
12
+
13
+ .s-switch-track {
14
+ @apply flex h-5 w-9 items-center rounded-full border border-surface-300 bg-surface-200 p-0.5 transition-colors;
15
+ @apply dark:border-surface-600 dark:bg-surface-700;
16
+ transition-duration: var(--s-motion-standard);
17
+ }
18
+
19
+ .s-switch-thumb {
20
+ @apply block h-3.5 w-3.5 rounded-full bg-surface-0 shadow-sm transition-transform;
21
+ @apply dark:bg-surface-100;
22
+ transition-duration: var(--s-motion-standard);
23
+ }
24
+
25
+ .s-switch-input:checked + .s-switch-track {
26
+ @apply border-primary-500 bg-primary-500 dark:border-primary-400 dark:bg-primary-400;
27
+ }
28
+
29
+ .s-switch-input:checked + .s-switch-track > .s-switch-thumb {
30
+ transform: translateX(1rem);
31
+ }
32
+
33
+ .s-switch-input:not(:disabled):hover + .s-switch-track {
34
+ @apply border-primary-500 dark:border-primary-400;
35
+ }
36
+
37
+ .s-switch-input:focus-visible + .s-switch-track {
38
+ outline: var(--s-focus-ring-width) solid var(--s-focus-ring-strong-color);
39
+ outline-offset: var(--s-focus-ring-offset);
40
+ }
41
+
42
+ .s-switch-input:disabled {
43
+ @apply cursor-not-allowed;
44
+ }
45
+
46
+ .s-switch-input:disabled + .s-switch-track {
47
+ @apply border-surface-200 bg-surface-100 opacity-70 dark:border-surface-700 dark:bg-surface-800;
48
+ }
@@ -0,0 +1,109 @@
1
+ <template>
2
+ <SField
3
+ :control-id="inputId"
4
+ :label="label"
5
+ :required="required"
6
+ :help-text="helpText"
7
+ :error-message="errorMessage"
8
+ :invalid="invalid"
9
+ :disabled="disabled"
10
+ :readonly="readonly"
11
+ layout="inline"
12
+ >
13
+ <template #default="field">
14
+ <span class="s-switch-control">
15
+ <input
16
+ v-bind="ownedAttrs.bindings()"
17
+ :id="field.controlId"
18
+ type="checkbox"
19
+ role="switch"
20
+ class="s-switch-input"
21
+ :checked="checkedValue"
22
+ :disabled="field.disabled"
23
+ :required="field.required"
24
+ :aria-checked="checkedValue"
25
+ :aria-invalid="field.invalid || undefined"
26
+ :aria-readonly="field.readonly || undefined"
27
+ :aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
28
+ @click.stop="onClick"
29
+ @change="onChange"
30
+ />
31
+ <span class="s-switch-track" aria-hidden="true">
32
+ <span class="s-switch-thumb" />
33
+ </span>
34
+ </span>
35
+ </template>
36
+ </SField>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { computed, useId } from 'vue'
41
+ import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
42
+ import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
43
+ import { resolveOptionalDomId } from '../../../internal/runtimeContract'
44
+ import SField from './SField.vue'
45
+
46
+ defineOptions({ inheritAttrs: false })
47
+
48
+ export interface Props {
49
+ modelValue: boolean
50
+ label?: string
51
+ disabled?: boolean
52
+ readonly?: boolean
53
+ required?: boolean
54
+ invalid?: boolean
55
+ helpText?: string
56
+ errorMessage?: string
57
+ id?: string
58
+ }
59
+
60
+ const props = withDefaults(defineProps<Props>(), {
61
+ label: undefined,
62
+ disabled: false,
63
+ readonly: false,
64
+ required: false,
65
+ invalid: false,
66
+ helpText: undefined,
67
+ errorMessage: undefined,
68
+ id: undefined,
69
+ })
70
+ const emit = defineEmits<{
71
+ 'update:modelValue': [value: boolean]
72
+ click: [event: MouseEvent]
73
+ }>()
74
+ const ownedAttrs = useOwnedAttrs({ component: 'SSwitch', owner: 'native switch input' })
75
+ useInteractiveLeafRegistration({ owner: 'SSwitch' })
76
+
77
+ const generatedInputId = useId()
78
+ const inputId = computed(() => resolveOptionalDomId(
79
+ 'SSwitch',
80
+ 'id',
81
+ props.id,
82
+ `s-switch-${generatedInputId}`,
83
+ ))
84
+ const checkedValue = computed(() => {
85
+ if (typeof props.modelValue !== 'boolean') {
86
+ throw new TypeError('SSwitch: modelValue must be boolean')
87
+ }
88
+ return props.modelValue
89
+ })
90
+
91
+ function onChange(event: Event): void {
92
+ if (props.readonly) return
93
+ const target = event.currentTarget
94
+ if (!(target instanceof HTMLInputElement)) {
95
+ throw new TypeError('SSwitch: change event owner must be the native switch input')
96
+ }
97
+ emit('update:modelValue', target.checked)
98
+ }
99
+
100
+ function onClick(event: MouseEvent): void {
101
+ if (props.readonly) {
102
+ event.preventDefault()
103
+ return
104
+ }
105
+ emit('click', event)
106
+ }
107
+ </script>
108
+
109
+ <style lang="postcss" src="./SSwitch.css" scoped></style>
@@ -4,6 +4,7 @@ import {
4
4
  createTextVNode,
5
5
  defineComponent,
6
6
  h,
7
+ type Component,
7
8
  type ComponentPublicInstance,
8
9
  type PropType,
9
10
  } from 'vue'
@@ -14,7 +15,9 @@ import type { InlineTokenEditorSegmentContract } from '../../../../internal/inli
14
15
  import type { OwnedAttributeBindings } from '../../../../internal/ownedAttrs'
15
16
  import { useInteractiveLeafRegistration } from '../../../../internal/passiveContentContract'
16
17
  import { validateBoundedInteger } from '../../../../internal/runtimeContract'
17
- import SButton, { type Props as SButtonProps } from '../SButton.vue'
18
+ import SButton, { type SButtonActionProps } from '../SButton.vue'
19
+
20
+ const actionButtonComponent: Component = SButton
18
21
 
19
22
  /** Текст визуального caret helper. / Visual caret-helper text. */
20
23
  export const INLINE_TOKEN_EDITOR_VISUAL_CARET_TEXT = '\u200B'
@@ -35,7 +38,7 @@ export type InlineTokenElementRef = (
35
38
  refs: Record<string, unknown>,
36
39
  ) => void
37
40
 
38
- interface InlineTokenActionButtonBindings extends SButtonProps {
41
+ interface InlineTokenActionButtonBindings extends SButtonActionProps {
39
42
  readonly 'aria-label': string
40
43
  readonly title?: string
41
44
  readonly onClick: (event: MouseEvent) => void
@@ -161,7 +164,7 @@ export default defineComponent({
161
164
  onClick: (event) => emitTokenAction(event, action, segment),
162
165
  onKeydown: stopKeyPropagation,
163
166
  }
164
- return h(SButton, { ...bindings, key: action })
167
+ return h(actionButtonComponent, { ...bindings, key: action })
165
168
  }
166
169
 
167
170
  return () => h('div', {
@@ -0,0 +1,59 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-chip {
4
+ @apply inline-flex min-w-0 items-center rounded-full font-medium;
5
+ }
6
+
7
+ .s-chip[data-size='md'] {
8
+ @apply min-h-7 gap-1.5 px-2.5 text-xs;
9
+ }
10
+
11
+ .s-chip[data-size='md'][data-closable='true'] {
12
+ @apply pr-1;
13
+ }
14
+
15
+ .s-chip[data-size='sm'] {
16
+ @apply min-h-6 gap-1 px-2 text-[11px] leading-4;
17
+ }
18
+
19
+ .s-chip[data-size='sm'][data-closable='true'] {
20
+ @apply pr-0.5;
21
+ }
22
+
23
+ .s-chip__icon {
24
+ @apply inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center;
25
+ }
26
+
27
+ .s-chip__icon-graphic {
28
+ @apply h-full w-full;
29
+ }
30
+
31
+ .s-chip__label {
32
+ @apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
33
+ }
34
+
35
+ .s-chip[data-disabled='true'] {
36
+ @apply opacity-60;
37
+ }
38
+
39
+ .s-chip[data-severity='primary'] {
40
+ @apply bg-primary-100 text-primary-800 dark:bg-primary-950 dark:text-primary-50;
41
+ }
42
+ .s-chip[data-severity='secondary'] {
43
+ @apply bg-surface-100 text-surface-800 dark:bg-surface-800 dark:text-surface-50;
44
+ }
45
+ .s-chip[data-severity='success'] {
46
+ @apply bg-success-100 text-success-800 dark:bg-success-950 dark:text-success-50;
47
+ }
48
+ .s-chip[data-severity='info'] {
49
+ @apply bg-info-100 text-info-800 dark:bg-info-950 dark:text-info-50;
50
+ }
51
+ .s-chip[data-severity='warn'] {
52
+ @apply bg-warn-100 text-warn-800 dark:bg-warn-950 dark:text-warn-50;
53
+ }
54
+ .s-chip[data-severity='danger'] {
55
+ @apply bg-danger-100 text-danger-800 dark:bg-danger-950 dark:text-danger-50;
56
+ }
57
+ .s-chip[data-severity='contrast'] {
58
+ @apply bg-surface-800 text-surface-100 dark:bg-surface-200 dark:text-surface-800;
59
+ }
@@ -0,0 +1,92 @@
1
+ <template>
2
+ <span
3
+ v-bind="ownedAttrs.bindings()"
4
+ class="s-chip"
5
+ :data-severity="validatedSeverity"
6
+ :data-size="validatedSize"
7
+ :data-closable="closable || undefined"
8
+ :data-disabled="disabled || undefined"
9
+ >
10
+ <span v-if="leadingIcon" class="s-chip__icon" aria-hidden="true">
11
+ <component :is="leadingIcon" class="s-chip__icon-graphic" />
12
+ </span>
13
+ <span class="s-chip__label">{{ validatedLabel }}</span>
14
+ <SButton
15
+ v-if="closable"
16
+ label=""
17
+ appearance="ghost"
18
+ severity="secondary"
19
+ size="xs"
20
+ density="compact"
21
+ spacing="tight"
22
+ shape="circle"
23
+ icon-only
24
+ :leading-icon="X"
25
+ :disabled="disabled"
26
+ :aria-label="resolvedCloseLabel"
27
+ :title="resolvedCloseLabel"
28
+ @click.stop="emit('close', $event)"
29
+ />
30
+ </span>
31
+ </template>
32
+
33
+ <script setup lang="ts">
34
+ import { computed, type Component } from 'vue'
35
+ import { X } from '../../icons/sputnigUiIcons'
36
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
37
+ import {
38
+ validateBoolean,
39
+ validateExactString,
40
+ validateNonEmptyString,
41
+ } from '../../../internal/runtimeContract'
42
+ import SButton from '../controls/SButton.vue'
43
+ import type { BadgeSeverity } from './SBadge.vue'
44
+
45
+ defineOptions({ inheritAttrs: false })
46
+
47
+ export type SChipSeverity = BadgeSeverity
48
+ export type SChipSize = 'sm' | 'md'
49
+
50
+ export interface Props {
51
+ label: string
52
+ severity?: SChipSeverity
53
+ size?: SChipSize
54
+ leadingIcon?: Component
55
+ closable?: boolean
56
+ closeLabel?: string
57
+ disabled?: boolean
58
+ }
59
+
60
+ const props = withDefaults(defineProps<Props>(), {
61
+ severity: 'secondary',
62
+ size: 'md',
63
+ leadingIcon: undefined,
64
+ closable: false,
65
+ closeLabel: undefined,
66
+ disabled: false,
67
+ })
68
+ const emit = defineEmits<{
69
+ close: [event: MouseEvent]
70
+ }>()
71
+ const ownedAttrs = useOwnedAttrs({ component: 'SChip', owner: 'chip root' })
72
+
73
+ const validatedLabel = computed(() => validateNonEmptyString('SChip', 'label', props.label))
74
+ const validatedSeverity = computed(() => validateExactString(
75
+ 'SChip',
76
+ 'severity',
77
+ props.severity,
78
+ ['primary', 'secondary', 'success', 'info', 'warn', 'danger', 'contrast'] as const,
79
+ ))
80
+ const validatedSize = computed(() => validateExactString(
81
+ 'SChip', 'size', props.size, ['sm', 'md'] as const,
82
+ ))
83
+ const resolvedCloseLabel = computed(() => {
84
+ validateBoolean('SChip', 'closable', props.closable)
85
+ validateBoolean('SChip', 'disabled', props.disabled)
86
+ return props.closeLabel === undefined
87
+ ? `Удалить «${validatedLabel.value}»`
88
+ : validateNonEmptyString('SChip', 'closeLabel', props.closeLabel)
89
+ })
90
+ </script>
91
+
92
+ <style lang="postcss" src="./SChip.css" scoped></style>
@@ -23,10 +23,30 @@
23
23
  :data-width="column.width"
24
24
  :data-min-width="column.minWidth"
25
25
  :data-align="column.align ?? 'left'"
26
+ :data-sortable="column.sortable || undefined"
27
+ :aria-sort="columnAriaSort(column)"
26
28
  >
27
- <slot name="column" :column="column">
28
- <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
29
- </slot>
29
+ <SButton
30
+ v-if="column.sortable"
31
+ :aria-label="sortActionLabel(column)"
32
+ appearance="text"
33
+ severity="secondary"
34
+ size="xs"
35
+ density="compact"
36
+ width="full"
37
+ :content-align="column.align === 'right' ? 'end' : column.align === 'center' ? 'center' : 'start'"
38
+ :trailing-icon="sortIcon(column)"
39
+ @click="requestSort(column)"
40
+ >
41
+ <slot name="column" :column="column">
42
+ <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
43
+ </slot>
44
+ </SButton>
45
+ <template v-else>
46
+ <slot name="column" :column="column">
47
+ <slot :name="`header-${column.field}`" :column="column">{{ column.header }}</slot>
48
+ </slot>
49
+ </template>
30
50
  </th>
31
51
  </tr>
32
52
  </thead>
@@ -47,6 +67,10 @@
47
67
  :key="resolvedRowKeys.get(row)"
48
68
  :data-state="resolveRowState(row)"
49
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)"
50
74
  >
51
75
  <td
52
76
  v-for="column in validatedColumns"
@@ -90,13 +114,17 @@ export type {
90
114
  STableProps,
91
115
  STableRowKey,
92
116
  STableRowState,
117
+ STableSort,
118
+ STableSortDirection,
93
119
  STableSurface,
94
120
  } from './table'
95
121
  </script>
96
122
 
97
123
  <script setup lang="ts" generic="Row extends object = Record<string, unknown>, RowKey extends STableRowKey = STableRowKey, Field extends string = Extract<keyof Row, string>">
98
124
  import { computed, useSlots } from 'vue'
125
+ import { ChevronDown, ChevronsUpDown, ChevronUp } from '../../icons/sputnigUiIcons'
99
126
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
127
+ import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
100
128
  import {
101
129
  validateScrollableViewportMaxBlockSize,
102
130
  validateTableColumnInlineSize,
@@ -109,6 +137,7 @@ import {
109
137
  validateNonEmptyString,
110
138
  } from '../../../internal/runtimeContract'
111
139
  import SAsyncState from '../feedback/SAsyncState.vue'
140
+ import SButton from '../controls/SButton.vue'
112
141
  import type {
113
142
  STableCellState,
114
143
  STableColumn,
@@ -116,6 +145,7 @@ import type {
116
145
  STableProps,
117
146
  STableRowKey,
118
147
  STableRowState,
148
+ STableSort,
119
149
  } from './table'
120
150
 
121
151
  defineOptions({ inheritAttrs: false })
@@ -126,6 +156,7 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
126
156
  errorMessage: '',
127
157
  emptyMessage: 'Нет данных для отображения',
128
158
  selectedRowKey: undefined,
159
+ sort: null,
129
160
  maxHeight: 'none',
130
161
  density: 'default',
131
162
  surface: 'bordered',
@@ -136,6 +167,11 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
136
167
  ariaLabelledby: undefined,
137
168
  ariaDescribedby: undefined,
138
169
  })
170
+ const emit = defineEmits<{
171
+ 'row-click': [row: Row, index: number]
172
+ 'update:sort': [value: STableSort<Field> | null]
173
+ sort: [value: STableSort<Field> | null]
174
+ }>()
139
175
  const slots = useSlots()
140
176
 
141
177
  const TABLE_DENSITIES = ['default', 'compact'] as const
@@ -147,6 +183,22 @@ const TABLE_CELL_STATES = ['default', 'pending'] as const
147
183
  const TABLE_COLUMN_CONTENT = ['field', 'value', 'slot'] as const
148
184
 
149
185
  const ownedAttrs = useOwnedAttrs({ component: 'STable', owner: 'table scroll surface' })
186
+ useInteractiveLeafRegistration({ owner: 'STable' })
187
+
188
+ function eventBelongsToNestedControl(event: Event): boolean {
189
+ const owner = event.currentTarget
190
+ return event.composedPath().some((candidate) => candidate !== owner && candidate instanceof HTMLElement && (
191
+ candidate.matches('button, a, input, select, textarea, [role="button"], [role="link"]')
192
+ ))
193
+ }
194
+
195
+ function onRowClick(event: MouseEvent, row: Row, rowIndex: number): void {
196
+ if (!eventBelongsToNestedControl(event)) emit('row-click', row, rowIndex)
197
+ }
198
+
199
+ function onRowKeydown(event: KeyboardEvent, row: Row, rowIndex: number): void {
200
+ if (event.target === event.currentTarget) emit('row-click', row, rowIndex)
201
+ }
150
202
  const validatedLoading = computed(() => validateBoolean('STable', 'loading', props.loading))
151
203
  const resolvedAccessibleName = computed(() => {
152
204
  if (props.ariaLabel !== undefined && props.ariaLabelledby !== undefined) {
@@ -207,6 +259,9 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
207
259
  if (column.align !== undefined) {
208
260
  validateExactString('STable', `column ${JSON.stringify(field)}.align`, column.align, TABLE_ALIGNMENTS)
209
261
  }
262
+ if (column.sortable !== undefined) {
263
+ validateBoolean('STable', `column ${JSON.stringify(field)}.sortable`, column.sortable)
264
+ }
210
265
  if (column.width !== undefined) {
211
266
  validateTableColumnInlineSize('STable', `column ${JSON.stringify(field)}.width`, column.width)
212
267
  }
@@ -241,6 +296,54 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
241
296
  return props.columns
242
297
  })
243
298
 
299
+ const validatedSort = computed<STableSort<Field> | null>(() => {
300
+ if (props.sort === null || props.sort === undefined) return null
301
+ const key = validateNonEmptyString('STable', 'sort.key', props.sort.key) as Field
302
+ const column = validatedColumns.value.find((candidate) => candidate.field === key)
303
+ if (!column?.sortable) {
304
+ throw new TypeError(`STable: sort key ${JSON.stringify(key)} должен ссылаться на sortable column. / STable: sort key ${JSON.stringify(key)} must reference a sortable column.`)
305
+ }
306
+ return {
307
+ key,
308
+ direction: validateExactString(
309
+ 'STable',
310
+ 'sort.direction',
311
+ props.sort.direction,
312
+ ['ascending', 'descending'] as const,
313
+ ),
314
+ }
315
+ })
316
+
317
+ const columnAriaSort = (column: STableColumn<Row, Field>): 'none' | 'ascending' | 'descending' | undefined => {
318
+ if (!column.sortable) return undefined
319
+ return validatedSort.value?.key === column.field ? validatedSort.value.direction : 'none'
320
+ }
321
+
322
+ const sortIcon = (column: STableColumn<Row, Field>) => {
323
+ const direction = columnAriaSort(column)
324
+ return direction === 'ascending' ? ChevronUp : direction === 'descending' ? ChevronDown : ChevronsUpDown
325
+ }
326
+
327
+ const sortActionLabel = (column: STableColumn<Row, Field>): string => {
328
+ const direction = columnAriaSort(column)
329
+ if (direction === 'ascending') return `${column.header}: сортировать по убыванию`
330
+ if (direction === 'descending') return `${column.header}: сбросить сортировку`
331
+ return `${column.header}: сортировать по возрастанию`
332
+ }
333
+
334
+ function requestSort(column: STableColumn<Row, Field>): void {
335
+ if (!column.sortable) return
336
+ const current = validatedSort.value
337
+ const key = column.field as Field
338
+ const next: STableSort<Field> | null = current?.key !== key
339
+ ? { key, direction: 'ascending' }
340
+ : current.direction === 'ascending'
341
+ ? { key, direction: 'descending' }
342
+ : null
343
+ emit('update:sort', next)
344
+ emit('sort', next)
345
+ }
346
+
244
347
  function describeRowKey(key: STableRowKey): string {
245
348
  return typeof key === 'string' ? JSON.stringify(key) : String(key)
246
349
  }
@@ -283,6 +386,7 @@ const resolvedRowKeys = computed<ReadonlyMap<Row, RowKey>>(() => {
283
386
 
284
387
  const validatedRows = computed<readonly Row[]>(() => {
285
388
  const columns = validatedColumns.value
389
+ void validatedSort.value
286
390
  void resolvedRowKeys.value
287
391
  void validatedSelectedRowKey.value
288
392
  for (const row of props.data) {
@@ -12,6 +12,13 @@ export type STableSurface = 'bordered' | 'plain'
12
12
  export type STableMinWidth = 'content' | 'md' | 'lg'
13
13
  export type STableDensity = 'default' | 'compact'
14
14
  export type STableColumnContent = 'field' | 'value' | 'slot'
15
+ export type STableSortDirection = 'ascending' | 'descending'
16
+
17
+ /** Controlled sort state; consumer owns data ordering. / Управляемое состояние сортировки; порядок данных принадлежит consumer. */
18
+ export interface STableSort<Field extends string = string> {
19
+ key: Field
20
+ direction: STableSortDirection
21
+ }
15
22
 
16
23
  interface STableColumnBase<Field extends string> {
17
24
  field: Field
@@ -19,6 +26,7 @@ interface STableColumnBase<Field extends string> {
19
26
  width?: TableColumnInlineSize
20
27
  minWidth?: TableColumnMinInlineSize
21
28
  align?: STableAlignment
29
+ sortable?: boolean
22
30
  }
23
31
 
24
32
  type STableFieldColumn<Field extends string> = STableColumnBase<Field> & {
@@ -61,6 +69,7 @@ export interface STableProps<
61
69
  errorMessage?: string
62
70
  emptyMessage?: string
63
71
  selectedRowKey?: RowKey
72
+ sort?: STableSort<Field> | null
64
73
  maxHeight?: ScrollableViewportMaxBlockSize
65
74
  density?: STableDensity
66
75
  surface?: STableSurface
@@ -270,6 +270,9 @@ const resolveOverflowTrigger = (item: BottomNavOverflowItem): HTMLButtonElement
270
270
  throw new Error(`SBottomNav: overflow trigger '${item.id}' is unavailable`);
271
271
  }
272
272
  const trigger = api.getElement();
273
+ if (!(trigger instanceof HTMLButtonElement)) {
274
+ throw new TypeError(`SBottomNav: overflow trigger '${item.id}' must be an action button`);
275
+ }
273
276
  const expectedId = overflowTriggerId(item);
274
277
  if (trigger.id !== expectedId) {
275
278
  throw new Error(
@@ -126,6 +126,13 @@ export const ownedAttributeSchemas = Object.freeze({
126
126
  onPointerdown: 'listener',
127
127
  tabindex: 'string-or-number',
128
128
  }),
129
+ SSwitch: defineOwnedAttributeSchema({
130
+ 'aria-label': 'string',
131
+ 'aria-describedby': 'string',
132
+ onClick: 'listener',
133
+ onPointerdown: 'listener',
134
+ tabindex: 'string-or-number',
135
+ }),
129
136
  SCheckboxGroup: defineOwnedAttributeSchema({
130
137
  'aria-describedby': 'string',
131
138
  'data-testid': 'data',
@@ -137,6 +144,10 @@ export const ownedAttributeSchemas = Object.freeze({
137
144
  SColorSelect: defineOwnedAttributeSchema({
138
145
  'aria-label': 'string',
139
146
  }),
147
+ SCombobox: defineOwnedAttributeSchema({
148
+ 'aria-describedby': 'string',
149
+ 'aria-label': 'string',
150
+ }),
140
151
  SDragHandle: defineOwnedAttributeSchema({
141
152
  'data-testid': 'data',
142
153
  }),
@@ -158,7 +169,12 @@ export const ownedAttributeSchemas = Object.freeze({
158
169
  title: 'string',
159
170
  }),
160
171
  SInputText: defineOwnedAttributeSchema({
172
+ 'aria-activedescendant': 'string',
173
+ 'aria-autocomplete': 'string',
174
+ 'aria-controls': 'string',
161
175
  'aria-describedby': 'string',
176
+ 'aria-expanded': 'aria-boolean',
177
+ 'aria-haspopup': 'aria-haspopup',
162
178
  'aria-invalid': 'aria-boolean',
163
179
  'aria-label': 'string',
164
180
  'data-testid': 'data',
@@ -168,10 +184,12 @@ export const ownedAttributeSchemas = Object.freeze({
168
184
  max: 'string-or-number',
169
185
  min: 'string-or-number',
170
186
  onBlur: 'listener',
187
+ onClick: 'listener',
171
188
  onFocus: 'listener',
172
189
  onKeydown: 'listener',
173
190
  onKeyup: 'listener',
174
191
  pattern: 'string',
192
+ role: 'string',
175
193
  step: 'string-or-number',
176
194
  title: 'string',
177
195
  }),
@@ -237,6 +255,10 @@ export const ownedAttributeSchemas = Object.freeze({
237
255
  'data-testid': 'data',
238
256
  title: 'string',
239
257
  }),
258
+ SChip: defineOwnedAttributeSchema({
259
+ 'aria-label': 'string',
260
+ title: 'string',
261
+ }),
240
262
  SCodeBlock: defineOwnedAttributeSchema({
241
263
  'aria-label': 'string',
242
264
  'data-testid': 'data',
@@ -338,7 +360,7 @@ type OwnedAttributeValue<Name extends string, Kind extends OwnedAttributeValueKi
338
360
 
339
361
  export type OwnedAttributeBindings<Component extends OwnedAttributeComponent> = Readonly<Partial<{
340
362
  [Name in OwnedAttributeName<Component>]: OwnedAttributeValue<Name, OwnedAttributeKind<Component, Name>>
341
- }>>
363
+ }> & Partial<Record<`data-${string}`, string | number | boolean>>>
342
364
 
343
365
  export interface OwnedAttrsOptions<Component extends OwnedAttributeComponent> {
344
366
  /** Имя public-компонента для диагностики контракта. / Public component name used in contract diagnostics. */
@@ -420,7 +442,7 @@ export function useOwnedAttrs<Component extends OwnedAttributeComponent>(
420
442
  `raw "${name}" запрещён; атрибут "${name}" не объявлен в exact owned-attrs schema; используйте типизированные props/tokens UI-kit или consumer layout wrapper. Raw "${name}" is forbidden and is not declared by the exact owned-attrs schema; use typed UI-kit props/tokens or a consumer layout wrapper`,
421
443
  )
422
444
  }
423
- const kind = schema[name]
445
+ const kind = schema[name] ?? (name.startsWith('data-') && name.length > 5 ? 'data' : undefined)
424
446
  if (!kind) {
425
447
  throw attributeBoundaryError(
426
448
  options.component,