@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.
- package/README.md +5 -5
- package/package.json +15 -3
- package/src/components/layout/SStack.css +9 -3
- package/src/components/layout/SStack.vue +10 -2
- package/src/components/shared/containers/SPageHeader.css +11 -0
- package/src/components/shared/containers/SPageHeader.vue +7 -1
- package/src/components/shared/controls/SButton.css +13 -0
- package/src/components/shared/controls/SButton.vue +130 -29
- package/src/components/shared/controls/SCombobox.css +9 -0
- package/src/components/shared/controls/SCombobox.vue +322 -0
- package/src/components/shared/controls/SInputText.vue +15 -0
- package/src/components/shared/controls/SListbox.vue +99 -1
- package/src/components/shared/controls/SListboxOption.vue +15 -2
- package/src/components/shared/controls/SSwitch.css +48 -0
- package/src/components/shared/controls/SSwitch.vue +109 -0
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +6 -3
- package/src/components/shared/data-display/SChip.css +59 -0
- package/src/components/shared/data-display/SChip.vue +92 -0
- package/src/components/shared/data-display/STable.vue +107 -3
- package/src/components/shared/data-display/table.ts +9 -0
- package/src/components/shared/navigation/SBottomNav.vue +3 -0
- package/src/internal/ownedAttrs.ts +24 -2
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
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,
|