@pgcorp/ui-kit 0.1.2 → 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 +19 -3
- package/src/components/layout/SSideMenuButton.vue +5 -4
- package/src/components/layout/SSideMenuRail.vue +3 -6
- package/src/components/layout/SStack.css +9 -3
- package/src/components/layout/SStack.vue +10 -2
- package/src/components/shared/_internal/STreeNode.vue +10 -4
- package/src/components/shared/_internal/sidebarGroupContext.ts +3 -1
- package/src/components/shared/_internal/tabsContext.ts +1 -0
- package/src/components/shared/containers/SLeftSidebar.vue +2 -2
- package/src/components/shared/containers/SPageHeader.css +11 -0
- package/src/components/shared/containers/SPageHeader.vue +7 -1
- package/src/components/shared/containers/SPanel.css +7 -1
- package/src/components/shared/containers/SPanel.vue +5 -1
- package/src/components/shared/containers/SSidebarGroup.css +1 -1
- package/src/components/shared/containers/SSidebarGroup.vue +113 -94
- package/src/components/shared/containers/SSidebarSection.css +16 -4
- package/src/components/shared/containers/SSidebarSection.vue +15 -16
- package/src/components/shared/controls/SButton.css +39 -0
- package/src/components/shared/controls/SButton.vue +146 -29
- package/src/components/shared/controls/SCombobox.css +9 -0
- package/src/components/shared/controls/SCombobox.vue +322 -0
- package/src/components/shared/controls/SContextToggleButton.vue +1 -1
- package/src/components/shared/controls/SInputText.vue +15 -0
- package/src/components/shared/controls/SInteractiveSurface.css +4 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +4 -0
- package/src/components/shared/controls/SListbox.vue +99 -1
- package/src/components/shared/controls/SListboxOption.vue +15 -2
- package/src/components/shared/controls/SRange.css +35 -0
- package/src/components/shared/controls/SRange.vue +129 -0
- 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/SCodeBlock.vue +1 -1
- package/src/components/shared/data-display/SCodeEditor.css +15 -0
- package/src/components/shared/data-display/SCodeEditor.vue +14 -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/components/shared/navigation/STab.vue +31 -6
- package/src/components/shared/navigation/STabs.vue +8 -2
- package/src/internal/codeEditorContract.ts +1 -1
- package/src/internal/ownedAttrs.ts +31 -2
|
@@ -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>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
's-code-block--radius-none': validatedSurfaceRadius === 'none',
|
|
7
7
|
's-code-block--radius-xl': validatedSurfaceRadius === 'xl',
|
|
8
8
|
's-code-block--interactive': !validatedReadOnly,
|
|
9
|
-
's-code-block--viewport':
|
|
9
|
+
's-code-block--viewport': !['content', 'document'].includes(validatedViewport),
|
|
10
10
|
}"
|
|
11
11
|
>
|
|
12
12
|
<div v-if="validatedShowHeader" class="s-code-block-header">
|
|
@@ -11,6 +11,21 @@
|
|
|
11
11
|
max-height: var(--s-code-editor-viewport-content-max);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
.s-code-editor[data-viewport='document'] {
|
|
15
|
+
overflow: visible;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.s-code-editor[data-viewport='document'] :deep(.cm-editor) {
|
|
19
|
+
height: auto;
|
|
20
|
+
max-height: none;
|
|
21
|
+
overflow: visible;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.s-code-editor[data-viewport='document'][data-variant] :deep(.cm-scroller) {
|
|
25
|
+
height: auto;
|
|
26
|
+
overflow: visible;
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
.s-code-editor[data-viewport='compact'] {
|
|
15
30
|
height: var(--s-code-editor-viewport-compact);
|
|
16
31
|
}
|
|
@@ -179,6 +179,20 @@ const resolvedExtensions = computed<Extension[]>(() => {
|
|
|
179
179
|
const capabilities = resolvedCapabilities.value
|
|
180
180
|
const language = resolvedLanguage.value
|
|
181
181
|
const result: Extension[] = [...(trustedChannel?.extensions.value ?? [])]
|
|
182
|
+
if (validatedViewport.value === 'document') {
|
|
183
|
+
result.push(EditorView.theme({
|
|
184
|
+
'&.cm-editor': {
|
|
185
|
+
height: 'auto',
|
|
186
|
+
maxHeight: 'none',
|
|
187
|
+
overflow: 'visible',
|
|
188
|
+
},
|
|
189
|
+
'.cm-scroller': {
|
|
190
|
+
height: 'auto',
|
|
191
|
+
overflow: 'visible',
|
|
192
|
+
},
|
|
193
|
+
}))
|
|
194
|
+
result.push(EditorView.lineWrapping)
|
|
195
|
+
}
|
|
182
196
|
const languageExtension = getCodeMirrorLanguageExtension(language)
|
|
183
197
|
if (languageExtension) result.push(languageExtension)
|
|
184
198
|
if (runtimeTheme.value.mode === 'dark') result.push(oneDark)
|
|
@@ -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(
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
class="s-tab-container"
|
|
12
12
|
:class="{ 's-tab-container--selected': state.active }"
|
|
13
13
|
:data-close-mode="ctx.closeButtonMode.value"
|
|
14
|
+
:data-close-visibility="ctx.closeButtonVisibility.value"
|
|
14
15
|
:data-disabled="state.disabled || undefined"
|
|
15
16
|
:data-editing="editing || undefined"
|
|
16
17
|
:style="containerStyle"
|
|
@@ -192,7 +193,7 @@ const hasContextMenu = computed(() => Boolean(props.contextMenuItems?.length))
|
|
|
192
193
|
const containerStyle = computed(() => {
|
|
193
194
|
const siblingSpace = hasSiblingActions.value ? siblingActionsWidth.value : 0
|
|
194
195
|
const basePaddingInline = 'var(--s-tab-base-padding-inline)'
|
|
195
|
-
const paddingInlineEnd = ctx.closeButtonMode.value === '
|
|
196
|
+
const paddingInlineEnd = ctx.closeButtonMode.value === 'reserve' && siblingSpace > 0
|
|
196
197
|
? `calc(${basePaddingInline} + ${siblingSpace}px + var(--s-tab-sibling-actions-gap))`
|
|
197
198
|
: basePaddingInline
|
|
198
199
|
return {
|
|
@@ -250,13 +251,19 @@ onBeforeUnmount(() => {
|
|
|
250
251
|
min-width: var(--s-tab-min-width);
|
|
251
252
|
max-width: var(--s-tab-max-width);
|
|
252
253
|
height: var(--s-tab-row-height);
|
|
253
|
-
border-bottom: var(--s-tab-indicator-width) solid transparent;
|
|
254
254
|
border-radius: var(--s-tab-radius);
|
|
255
255
|
overflow: hidden;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
.s-tab-container
|
|
259
|
-
|
|
258
|
+
.s-tab-container::after {
|
|
259
|
+
@apply pointer-events-none absolute inset-x-0 bottom-0;
|
|
260
|
+
height: var(--s-tab-indicator-width);
|
|
261
|
+
background-color: transparent;
|
|
262
|
+
content: '';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.s-tab-container--selected::after {
|
|
266
|
+
background-color: var(--s-tab-selected-border-color);
|
|
260
267
|
}
|
|
261
268
|
|
|
262
269
|
.s-tab__trigger {
|
|
@@ -265,8 +272,7 @@ onBeforeUnmount(() => {
|
|
|
265
272
|
width: 100%;
|
|
266
273
|
min-height: var(--s-tab-trigger-min-height);
|
|
267
274
|
margin: 0;
|
|
268
|
-
padding-block
|
|
269
|
-
padding-block-end: var(--s-tab-padding-block);
|
|
275
|
+
padding-block: var(--s-tab-padding-block);
|
|
270
276
|
padding-inline-start: var(--s-tab-padding-inline-start);
|
|
271
277
|
padding-inline-end: var(--s-tab-padding-inline-end);
|
|
272
278
|
border-radius: inherit;
|
|
@@ -348,6 +354,25 @@ onBeforeUnmount(() => {
|
|
|
348
354
|
z-index: var(--s-layer-sticky);
|
|
349
355
|
}
|
|
350
356
|
|
|
357
|
+
.s-tab-container[data-close-visibility='active-hover']:not(.s-tab-container--selected):not(:hover):not(:focus-within) .s-tab__sibling-actions {
|
|
358
|
+
visibility: hidden;
|
|
359
|
+
opacity: 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.s-tab-container[data-close-visibility='hover']:not(:hover):not(:focus-within) .s-tab__sibling-actions {
|
|
363
|
+
visibility: hidden;
|
|
364
|
+
opacity: 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.s-tab-container:is(
|
|
368
|
+
[data-close-visibility='active-hover'],
|
|
369
|
+
[data-close-visibility='hover']
|
|
370
|
+
) .s-tab__sibling-actions {
|
|
371
|
+
transition:
|
|
372
|
+
opacity var(--s-motion-fast) ease,
|
|
373
|
+
visibility var(--s-motion-fast) ease;
|
|
374
|
+
}
|
|
375
|
+
|
|
351
376
|
.s-tab-container[data-close-mode='overlay'] .s-tab__sibling-actions {
|
|
352
377
|
@apply absolute inset-y-0 right-0;
|
|
353
378
|
}
|