@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
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
**A typed PGCorp design system for Vue 3, spanning foundational controls and
|
|
7
7
|
complete workbench interfaces.**
|
|
8
8
|
|
|
9
|
-
`Vue 3` · `TypeScript` · `
|
|
9
|
+
`Vue 3` · `TypeScript` · `122 exact exports` · `6 themes` · `MIT`
|
|
10
10
|
|
|
11
11
|
## Что входит / What is included
|
|
12
12
|
|
|
@@ -32,7 +32,7 @@ npm install @pgcorp/ui-kit vue pinia vue-router
|
|
|
32
32
|
| Требование / Requirement | Версия / Version |
|
|
33
33
|
| --- | --- |
|
|
34
34
|
| Node.js | `>=20.19.0` |
|
|
35
|
-
| npm | `>=
|
|
35
|
+
| npm | `>=10.9.2 <12` |
|
|
36
36
|
| Vue | `^3.5.0` |
|
|
37
37
|
| Pinia | `^3.0.4` |
|
|
38
38
|
| Vue Router | `^5.0.0` |
|
|
@@ -82,7 +82,7 @@ import SButton from '@pgcorp/ui-kit/shared/controls/SButton.vue'
|
|
|
82
82
|
content-layout="stack"
|
|
83
83
|
content-gap="content"
|
|
84
84
|
>
|
|
85
|
-
<SButton
|
|
85
|
+
<SButton appearance="solid" severity="primary">
|
|
86
86
|
Сохранить
|
|
87
87
|
</SButton>
|
|
88
88
|
</SPanel>
|
|
@@ -100,9 +100,9 @@ monorepo alias or copied UI-kit files.
|
|
|
100
100
|
| Группа / Group | Назначение / Purpose | Примеры exports / Example exports |
|
|
101
101
|
| --- | --- | --- |
|
|
102
102
|
| Layout | App shell, dock regions, stacks, workbench layout | `layout/SAppShell.vue`, `layout/SStack.vue`, `layout/SWorkbenchLayout.vue` |
|
|
103
|
-
| Controls | Buttons, fields, inputs, selects,
|
|
103
|
+
| Controls | Buttons, fields, inputs, switches, selects, comboboxes, menus | `shared/controls/SButton.vue`, `shared/controls/SSwitch.vue`, `shared/controls/SCombobox.vue` |
|
|
104
104
|
| Containers | Panels, modal, drawer, popover, sidebars | `shared/containers/SPanel.vue`, `shared/containers/SModal.vue`, `shared/containers/SPopover.vue` |
|
|
105
|
-
| Data display | Tables, status, progress, code, JSON, tooltips | `shared/data-display/STable.vue`, `shared/data-display/
|
|
105
|
+
| Data display | Tables, chips, status, progress, code, JSON, tooltips | `shared/data-display/STable.vue`, `shared/data-display/SChip.vue`, `shared/data-display/SStatus.vue` |
|
|
106
106
|
| Navigation | Tabs, breadcrumbs, catalog navigation, wizard | `shared/navigation/STabs.vue`, `shared/navigation/SBreadcrumbs.vue`, `shared/navigation/SWizardSteps.vue` |
|
|
107
107
|
| Complex surfaces | Tree, Kanban, data grid, SQL editor, graph | `shared/complex/STree.vue`, `shared/database/SDataGrid.vue`, `shared/graph/SGraphViewport.vue` |
|
|
108
108
|
| Runtime | Themes, variants, icons, composables, stores | `theme`, `variants`, `icons`, `composables/useNotifier`, `stores/useNotifierStore` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgcorp/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Typed Vue 3 design system with accessible components, semantic themes, and workbench patterns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
40
|
"node": ">=20.19.0",
|
|
41
|
-
"npm": ">=
|
|
41
|
+
"npm": ">=10.9.2 <12"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public",
|
|
@@ -207,6 +207,9 @@
|
|
|
207
207
|
"shared/controls/SColorSelect.vue": [
|
|
208
208
|
"src/components/shared/controls/SColorSelect.vue"
|
|
209
209
|
],
|
|
210
|
+
"shared/controls/SCombobox.vue": [
|
|
211
|
+
"src/components/shared/controls/SCombobox.vue"
|
|
212
|
+
],
|
|
210
213
|
"shared/controls/SContextMenu.vue": [
|
|
211
214
|
"src/components/shared/controls/SContextMenu.vue"
|
|
212
215
|
],
|
|
@@ -261,6 +264,9 @@
|
|
|
261
264
|
"shared/controls/SSelect.vue": [
|
|
262
265
|
"src/components/shared/controls/SSelect.vue"
|
|
263
266
|
],
|
|
267
|
+
"shared/controls/SSwitch.vue": [
|
|
268
|
+
"src/components/shared/controls/SSwitch.vue"
|
|
269
|
+
],
|
|
264
270
|
"shared/controls/STextarea.vue": [
|
|
265
271
|
"src/components/shared/controls/STextarea.vue"
|
|
266
272
|
],
|
|
@@ -279,6 +285,9 @@
|
|
|
279
285
|
"shared/data-display/SBadge.vue": [
|
|
280
286
|
"src/components/shared/data-display/SBadge.vue"
|
|
281
287
|
],
|
|
288
|
+
"shared/data-display/SChip.vue": [
|
|
289
|
+
"src/components/shared/data-display/SChip.vue"
|
|
290
|
+
],
|
|
282
291
|
"shared/data-display/SCodeBlock.vue": [
|
|
283
292
|
"src/components/shared/data-display/SCodeBlock.vue"
|
|
284
293
|
],
|
|
@@ -468,6 +477,7 @@
|
|
|
468
477
|
"./shared/controls/SCheckboxGroup.vue": "./src/components/shared/controls/SCheckboxGroup.vue",
|
|
469
478
|
"./shared/controls/SChoiceCards.vue": "./src/components/shared/controls/SChoiceCards.vue",
|
|
470
479
|
"./shared/controls/SColorSelect.vue": "./src/components/shared/controls/SColorSelect.vue",
|
|
480
|
+
"./shared/controls/SCombobox.vue": "./src/components/shared/controls/SCombobox.vue",
|
|
471
481
|
"./shared/controls/SContextMenu.vue": "./src/components/shared/controls/SContextMenu.vue",
|
|
472
482
|
"./shared/controls/SContextToggleButton.vue": "./src/components/shared/controls/SContextToggleButton.vue",
|
|
473
483
|
"./shared/controls/SDragHandle.vue": "./src/components/shared/controls/SDragHandle.vue",
|
|
@@ -486,12 +496,14 @@
|
|
|
486
496
|
"./shared/controls/SRange.vue": "./src/components/shared/controls/SRange.vue",
|
|
487
497
|
"./shared/controls/SSegmentedControl.vue": "./src/components/shared/controls/SSegmentedControl.vue",
|
|
488
498
|
"./shared/controls/SSelect.vue": "./src/components/shared/controls/SSelect.vue",
|
|
499
|
+
"./shared/controls/SSwitch.vue": "./src/components/shared/controls/SSwitch.vue",
|
|
489
500
|
"./shared/controls/STextarea.vue": "./src/components/shared/controls/STextarea.vue",
|
|
490
501
|
"./shared/data-display/json": "./src/components/shared/data-display/json.ts",
|
|
491
502
|
"./shared/data-display/SActionCard.vue": "./src/components/shared/data-display/SActionCard.vue",
|
|
492
503
|
"./shared/data-display/SActionList.vue": "./src/components/shared/data-display/SActionList.vue",
|
|
493
504
|
"./shared/data-display/SActionListItem.vue": "./src/components/shared/data-display/SActionListItem.vue",
|
|
494
505
|
"./shared/data-display/SBadge.vue": "./src/components/shared/data-display/SBadge.vue",
|
|
506
|
+
"./shared/data-display/SChip.vue": "./src/components/shared/data-display/SChip.vue",
|
|
495
507
|
"./shared/data-display/SCodeBlock.vue": "./src/components/shared/data-display/SCodeBlock.vue",
|
|
496
508
|
"./shared/data-display/SCodeEditor.vue": "./src/components/shared/data-display/SCodeEditor.vue",
|
|
497
509
|
"./shared/data-display/SCodeSearchPanel.vue": "./src/components/shared/data-display/SCodeSearchPanel.vue",
|
|
@@ -582,7 +594,7 @@
|
|
|
582
594
|
"eslint-plugin-vue": "^10.8.0",
|
|
583
595
|
"jsdom": "^28.1.0",
|
|
584
596
|
"pinia": "^3.0.4",
|
|
585
|
-
"postcss": "8.5.
|
|
597
|
+
"postcss": "8.5.19",
|
|
586
598
|
"postcss-selector-parser": "7.1.4",
|
|
587
599
|
"storybook": "10.5.3",
|
|
588
600
|
"typescript": "^5.9.3",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
@reference "../../styles/reference.css";
|
|
2
2
|
|
|
3
|
-
.s-stack {
|
|
4
|
-
|
|
5
|
-
}
|
|
3
|
+
.s-stack { @apply flex min-h-0 min-w-0; }
|
|
4
|
+
|
|
5
|
+
.s-stack[data-direction='column'] { @apply flex-col; }
|
|
6
|
+
.s-stack[data-direction='row'] { @apply flex-row; }
|
|
6
7
|
|
|
7
8
|
.s-stack[data-fill='container'] {
|
|
8
9
|
@apply h-full flex-1;
|
|
@@ -13,6 +14,11 @@
|
|
|
13
14
|
.s-stack[data-align='end'] { @apply items-end; }
|
|
14
15
|
.s-stack[data-align='stretch'] { @apply items-stretch; }
|
|
15
16
|
|
|
17
|
+
.s-stack[data-justify='start'] { @apply justify-start; }
|
|
18
|
+
.s-stack[data-justify='center'] { @apply justify-center; }
|
|
19
|
+
.s-stack[data-justify='end'] { @apply justify-end; }
|
|
20
|
+
.s-stack[data-justify='between'] { @apply justify-between; }
|
|
21
|
+
|
|
16
22
|
.s-stack[data-gap='tight'] { gap: 0.25rem; }
|
|
17
23
|
.s-stack[data-gap='control'] { gap: var(--s-space-control-gap); }
|
|
18
24
|
.s-stack[data-gap='content'] { gap: var(--s-space-content-gap); }
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
v-bind="ownedAttrs.bindings()"
|
|
5
5
|
class="s-stack"
|
|
6
6
|
:data-gap="gap"
|
|
7
|
+
:data-direction="direction"
|
|
7
8
|
:data-padding-end="paddingEnd"
|
|
8
9
|
:data-fill="fill"
|
|
9
10
|
:data-align="align"
|
|
11
|
+
:data-justify="justify"
|
|
10
12
|
>
|
|
11
13
|
<slot />
|
|
12
14
|
</component>
|
|
@@ -22,12 +24,14 @@ export type SStackGap = 'none' | 'tight' | 'control' | 'content' | 'section' | '
|
|
|
22
24
|
export type SStackPadding = 'none' | 'control' | 'content' | 'section' | 'panel'
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
+
* Flex layout-owner с ограниченной семантической шкалой интервалов.
|
|
28
|
+
* Flex layout owner with a constrained semantic spacing scale.
|
|
27
29
|
*/
|
|
28
30
|
export interface Props {
|
|
29
31
|
/** Семантический HTML-container без интерактивной роли. / Semantic non-interactive HTML container. */
|
|
30
32
|
as?: 'div' | 'section' | 'article' | 'ul' | 'ol'
|
|
33
|
+
/** Направление основной оси. / Main-axis direction. */
|
|
34
|
+
direction?: 'column' | 'row'
|
|
31
35
|
/** Интервал между непосредственными участниками. / Gap between direct participants. */
|
|
32
36
|
gap?: SStackGap
|
|
33
37
|
/** Нижний внутренний отступ для scroll-safe composition. / End padding for scroll-safe composition. */
|
|
@@ -36,14 +40,18 @@ export interface Props {
|
|
|
36
40
|
fill?: 'content' | 'container'
|
|
37
41
|
/** Выравнивание участников по поперечной оси. / Cross-axis participant alignment. */
|
|
38
42
|
align?: 'stretch' | 'start' | 'center' | 'end'
|
|
43
|
+
/** Распределение участников по основной оси. / Main-axis participant distribution. */
|
|
44
|
+
justify?: 'start' | 'center' | 'end' | 'between'
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
withDefaults(defineProps<Props>(), {
|
|
42
48
|
as: 'div',
|
|
49
|
+
direction: 'column',
|
|
43
50
|
gap: 'content',
|
|
44
51
|
paddingEnd: 'none',
|
|
45
52
|
fill: 'content',
|
|
46
53
|
align: 'stretch',
|
|
54
|
+
justify: 'start',
|
|
47
55
|
})
|
|
48
56
|
|
|
49
57
|
const ownedAttrs = useOwnedAttrs({ component: 'SStack', owner: 'semantic stack root' })
|
|
@@ -31,6 +31,17 @@
|
|
|
31
31
|
@apply m-0 text-2xl font-semibold leading-tight text-surface-900 dark:text-surface-50;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
.s-page-header__title[data-presentation='assistive'] {
|
|
35
|
+
position: absolute;
|
|
36
|
+
width: 1px;
|
|
37
|
+
height: 1px;
|
|
38
|
+
padding: 0;
|
|
39
|
+
margin: -1px;
|
|
40
|
+
clip-path: inset(50%);
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
border: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
.s-page-header[data-size='compact'] .s-page-header__title {
|
|
35
46
|
@apply text-xl;
|
|
36
47
|
}
|
|
@@ -11,6 +11,7 @@ withDefaults(defineProps<{
|
|
|
11
11
|
appearance?: 'plain' | 'panel'
|
|
12
12
|
size?: 'compact' | 'default'
|
|
13
13
|
actionsLayout?: 'responsive' | 'inline' | 'stacked'
|
|
14
|
+
titlePresentation?: 'visible' | 'assistive'
|
|
14
15
|
}>(), {
|
|
15
16
|
description: undefined,
|
|
16
17
|
eyebrow: undefined,
|
|
@@ -18,6 +19,7 @@ withDefaults(defineProps<{
|
|
|
18
19
|
appearance: 'panel',
|
|
19
20
|
size: 'default',
|
|
20
21
|
actionsLayout: 'responsive',
|
|
22
|
+
titlePresentation: 'visible',
|
|
21
23
|
})
|
|
22
24
|
|
|
23
25
|
const ownedAttrs = useOwnedAttrs({ component: 'SPageHeader', owner: 'page header root' })
|
|
@@ -33,7 +35,11 @@ const ownedAttrs = useOwnedAttrs({ component: 'SPageHeader', owner: 'page header
|
|
|
33
35
|
>
|
|
34
36
|
<div class="s-page-header__copy">
|
|
35
37
|
<div v-if="eyebrow" class="s-page-header__eyebrow">{{ eyebrow }}</div>
|
|
36
|
-
<component
|
|
38
|
+
<component
|
|
39
|
+
:is="`h${headingLevel}`"
|
|
40
|
+
class="s-page-header__title"
|
|
41
|
+
:data-presentation="titlePresentation"
|
|
42
|
+
>{{ title }}</component>
|
|
37
43
|
<p v-if="description" class="s-page-header__description">{{ description }}</p>
|
|
38
44
|
</div>
|
|
39
45
|
<div v-if="$slots.actions" class="s-page-header__actions">
|
|
@@ -146,6 +146,10 @@
|
|
|
146
146
|
background: var(--s-button-solid-background-hover);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
.s-button[aria-disabled='true'] {
|
|
150
|
+
pointer-events: none;
|
|
151
|
+
}
|
|
152
|
+
|
|
149
153
|
.s-button[data-appearance='solid']:active:not(:disabled) {
|
|
150
154
|
border-color: var(--s-button-solid-background-active);
|
|
151
155
|
background: var(--s-button-solid-background-active);
|
|
@@ -219,6 +223,14 @@
|
|
|
219
223
|
cursor: not-allowed;
|
|
220
224
|
}
|
|
221
225
|
|
|
226
|
+
.s-button[aria-disabled='true'] {
|
|
227
|
+
border-color: transparent;
|
|
228
|
+
background: var(--s-button-disabled-background);
|
|
229
|
+
box-shadow: none;
|
|
230
|
+
color: var(--s-button-disabled-foreground);
|
|
231
|
+
cursor: not-allowed;
|
|
232
|
+
}
|
|
233
|
+
|
|
222
234
|
.s-button[data-appearance='ghost']:disabled,
|
|
223
235
|
.s-button[data-appearance='text']:disabled {
|
|
224
236
|
background: transparent;
|
|
@@ -246,6 +258,7 @@
|
|
|
246
258
|
.s-button[data-width='fit'] { width: fit-content; }
|
|
247
259
|
.s-button[data-width='full'] .s-button__content { width: 100%; }
|
|
248
260
|
.s-button[data-content-align='start'] .s-button__content { justify-content: flex-start; text-align: left; }
|
|
261
|
+
.s-button[data-content-align='end'] .s-button__content { justify-content: flex-end; text-align: right; }
|
|
249
262
|
.s-button[data-content-align='between'] .s-button__content { justify-content: space-between; text-align: left; }
|
|
250
263
|
|
|
251
264
|
.s-button[data-shape='pill'],
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
<component
|
|
3
|
+
:is="rootComponent"
|
|
4
|
+
:ref="setRootRef"
|
|
5
|
+
v-bind="rootBindings"
|
|
5
6
|
class="s-button"
|
|
6
7
|
:data-severity="severity"
|
|
7
8
|
:data-size="size"
|
|
@@ -17,8 +18,7 @@
|
|
|
17
18
|
:data-loading="loading || undefined"
|
|
18
19
|
:aria-pressed="pressed"
|
|
19
20
|
:aria-busy="busy || loading || undefined"
|
|
20
|
-
|
|
21
|
-
:type="type"
|
|
21
|
+
@click="handleClick"
|
|
22
22
|
>
|
|
23
23
|
<span v-if="loading" class="s-button__loader" aria-hidden="true">
|
|
24
24
|
<SProgressIndicator
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<component :is="leadingIcon" v-else-if="leadingIcon" class="s-button__icon s-button__icon--leading" aria-hidden="true" />
|
|
38
38
|
<span v-if="label" class="s-button__label">{{ label }}</span>
|
|
39
39
|
<PassiveContentBoundary v-if="renderDefaultSlot" :contract="passiveContentContract">
|
|
40
|
-
<
|
|
40
|
+
<component :is="defaultContentComponent" />
|
|
41
41
|
</PassiveContentBoundary>
|
|
42
42
|
<span v-if="badge !== undefined" class="s-button__badge-slot">
|
|
43
43
|
<SBadge :severity="badgeSeverity" size="xs">{{ badge }}</SBadge>
|
|
@@ -52,14 +52,16 @@
|
|
|
52
52
|
><ChevronRight /></span>
|
|
53
53
|
<component :is="trailingIcon" v-else-if="trailingIcon" class="s-button__icon s-button__icon--trailing" aria-hidden="true" />
|
|
54
54
|
</span>
|
|
55
|
-
</
|
|
55
|
+
</component>
|
|
56
56
|
</template>
|
|
57
57
|
|
|
58
58
|
<script setup lang="ts">
|
|
59
|
-
import { computed, ref, watchEffect } from 'vue'
|
|
60
|
-
import type { Component,
|
|
59
|
+
import { computed, getCurrentInstance, ref, watchEffect } from 'vue'
|
|
60
|
+
import type { Component, ComponentPublicInstance } from 'vue'
|
|
61
|
+
import { RouterLink } from 'vue-router'
|
|
61
62
|
import { ChevronRight } from '../../icons/sputnigUiIcons'
|
|
62
63
|
import type { InteractiveElementApi } from '../../../internal/interactiveElement'
|
|
64
|
+
import { resolveLinkTarget } from '../../../internal/linkTarget'
|
|
63
65
|
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
64
66
|
import {
|
|
65
67
|
PassiveContentBoundary,
|
|
@@ -69,6 +71,10 @@ import {
|
|
|
69
71
|
import { useTreeActionRegistration } from '../_internal/treeRuntime'
|
|
70
72
|
import SBadge, { type BadgeSeverity } from '../data-display/SBadge.vue'
|
|
71
73
|
import SProgressIndicator, { type SProgressIndicatorSize } from '../data-display/SProgressIndicator.vue'
|
|
74
|
+
import type {
|
|
75
|
+
HrefLinkDestination,
|
|
76
|
+
RouterLinkDestination,
|
|
77
|
+
} from './SLink'
|
|
72
78
|
|
|
73
79
|
defineOptions({ inheritAttrs: false })
|
|
74
80
|
|
|
@@ -79,7 +85,7 @@ export type SButtonDensity = 'default' | 'compact'
|
|
|
79
85
|
export type SButtonSpacing = 'default' | 'tight'
|
|
80
86
|
export type SButtonHoverSurface = 'self' | 'parent'
|
|
81
87
|
export type SButtonWidth = 'auto' | 'fit' | 'full'
|
|
82
|
-
export type SButtonContentAlign = 'center' | 'start' | 'between'
|
|
88
|
+
export type SButtonContentAlign = 'center' | 'start' | 'end' | 'between'
|
|
83
89
|
export type SButtonAppearance = 'solid' | 'soft' | 'outline' | 'ghost' | 'text'
|
|
84
90
|
export type SButtonShape = 'default' | 'pill' | 'square' | 'circle' | 'row'
|
|
85
91
|
export type SButtonNativeType = 'button' | 'reset' | 'submit'
|
|
@@ -89,10 +95,10 @@ export interface SButtonDisclosure {
|
|
|
89
95
|
iconPlacement?: 'leading' | 'trailing' | 'none'
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
/** Typed
|
|
93
|
-
export type SButtonApi = InteractiveElementApi<HTMLButtonElement>
|
|
98
|
+
/** Typed interactive owner API for action and navigation buttons. / Типизированная API-поверхность action/navigation button. */
|
|
99
|
+
export type SButtonApi = InteractiveElementApi<HTMLButtonElement | HTMLAnchorElement>
|
|
94
100
|
|
|
95
|
-
|
|
101
|
+
interface SButtonPresentationProps {
|
|
96
102
|
label?: string
|
|
97
103
|
severity?: SButtonSeverity
|
|
98
104
|
size?: SButtonSize
|
|
@@ -116,9 +122,24 @@ export interface Props {
|
|
|
116
122
|
pressed?: boolean | 'mixed'
|
|
117
123
|
busy?: boolean
|
|
118
124
|
disclosure?: SButtonDisclosure
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type SButtonActionProps = SButtonPresentationProps & {
|
|
128
|
+
href?: never
|
|
129
|
+
to?: never
|
|
130
|
+
target?: never
|
|
131
|
+
rel?: never
|
|
119
132
|
type?: SButtonNativeType
|
|
120
133
|
}
|
|
121
134
|
|
|
135
|
+
export type SButtonNavigationProps = SButtonPresentationProps & (
|
|
136
|
+
| (HrefLinkDestination & { type?: never })
|
|
137
|
+
| (RouterLinkDestination & { type?: never })
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
/** Strict action/navigation XOR with a shared button presentation. */
|
|
141
|
+
export type Props = SButtonActionProps | SButtonNavigationProps
|
|
142
|
+
|
|
122
143
|
const props = withDefaults(defineProps<Props>(), {
|
|
123
144
|
label: undefined,
|
|
124
145
|
severity: 'primary',
|
|
@@ -143,23 +164,57 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
143
164
|
pressed: undefined,
|
|
144
165
|
busy: false,
|
|
145
166
|
disclosure: undefined,
|
|
146
|
-
|
|
167
|
+
href: undefined,
|
|
168
|
+
to: undefined,
|
|
169
|
+
target: undefined,
|
|
170
|
+
rel: undefined,
|
|
171
|
+
type: undefined,
|
|
147
172
|
})
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
default?: () => VNodeChild
|
|
173
|
+
const emit = defineEmits<{
|
|
174
|
+
click: [event: MouseEvent]
|
|
151
175
|
}>()
|
|
152
|
-
const
|
|
176
|
+
const componentInstance = getCurrentInstance()
|
|
177
|
+
if (!componentInstance) throw new Error('SButton: component instance is unavailable')
|
|
178
|
+
const buttonSlots = componentInstance.slots
|
|
179
|
+
const defaultContentComponent = (): ReturnType<NonNullable<typeof buttonSlots.default>> => (
|
|
180
|
+
buttonSlots.default?.() ?? []
|
|
181
|
+
)
|
|
182
|
+
const rootRef = ref<HTMLButtonElement | HTMLAnchorElement | null>(null)
|
|
153
183
|
const ownedAttrs = useOwnedAttrs({ component: 'SButton', owner: 'native button' })
|
|
154
184
|
const isRegisteredTreeAction = useTreeActionRegistration({
|
|
155
185
|
owner: 'SButton',
|
|
156
|
-
element:
|
|
186
|
+
element: rootRef,
|
|
157
187
|
disabled: () => props.disabled || props.loading,
|
|
158
188
|
})
|
|
159
189
|
|
|
160
190
|
useInteractiveLeafRegistration({ owner: 'SButton' })
|
|
161
191
|
|
|
162
|
-
const
|
|
192
|
+
const navigationTarget = computed(() => {
|
|
193
|
+
const hasHref = props.href !== undefined
|
|
194
|
+
const hasTo = props.to !== undefined
|
|
195
|
+
if (hasHref && hasTo) {
|
|
196
|
+
throw new TypeError('SButton: передайте не более одного navigation target — href или to')
|
|
197
|
+
}
|
|
198
|
+
if (!hasHref && !hasTo) return null
|
|
199
|
+
if (props.type !== undefined) {
|
|
200
|
+
throw new TypeError('SButton: navigation target несовместим с native button type')
|
|
201
|
+
}
|
|
202
|
+
return resolveLinkTarget({
|
|
203
|
+
href: props.href,
|
|
204
|
+
to: props.to,
|
|
205
|
+
target: props.target,
|
|
206
|
+
rel: props.rel,
|
|
207
|
+
owner: 'SButton',
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
const navigationUnavailable = computed(() => props.disabled || props.loading)
|
|
211
|
+
const rootComponent = computed(() => {
|
|
212
|
+
const target = navigationTarget.value
|
|
213
|
+
if (!target || navigationUnavailable.value) return target ? 'a' : 'button'
|
|
214
|
+
return target.kind === 'router' ? RouterLink : 'a'
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const rootBindings = computed(() => {
|
|
163
218
|
const bindings = ownedAttrs.bindings()
|
|
164
219
|
const managedBindings = isRegisteredTreeAction ? { ...bindings, tabindex: -1 } : bindings
|
|
165
220
|
const semanticBindings = props.disclosure
|
|
@@ -169,17 +224,31 @@ const buttonBindings = computed(() => {
|
|
|
169
224
|
'aria-controls': props.disclosure.controls,
|
|
170
225
|
}
|
|
171
226
|
: managedBindings
|
|
172
|
-
|
|
227
|
+
const target = navigationTarget.value
|
|
228
|
+
const elementBindings = target === null
|
|
229
|
+
? {
|
|
230
|
+
...semanticBindings,
|
|
231
|
+
disabled: props.disabled || props.loading || undefined,
|
|
232
|
+
type: props.type ?? 'button',
|
|
233
|
+
}
|
|
234
|
+
: navigationUnavailable.value
|
|
235
|
+
? {
|
|
236
|
+
...semanticBindings,
|
|
237
|
+
'aria-disabled': true,
|
|
238
|
+
tabindex: -1,
|
|
239
|
+
}
|
|
240
|
+
: { ...semanticBindings, ...target.bindings }
|
|
241
|
+
if (!props.loading) return elementBindings
|
|
173
242
|
|
|
174
|
-
const externalAccessibleName =
|
|
243
|
+
const externalAccessibleName = elementBindings['aria-label']
|
|
175
244
|
const accessibleName = [props.loadingLabel, props.label, externalAccessibleName]
|
|
176
245
|
.find((candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0)
|
|
177
|
-
if (!accessibleName && !
|
|
246
|
+
if (!accessibleName && !buttonSlots.default) {
|
|
178
247
|
throw new Error('SButton: loading action requires label, loadingLabel, or aria-label')
|
|
179
248
|
}
|
|
180
249
|
return accessibleName
|
|
181
|
-
? { ...
|
|
182
|
-
:
|
|
250
|
+
? { ...elementBindings, 'aria-label': accessibleName }
|
|
251
|
+
: elementBindings
|
|
183
252
|
})
|
|
184
253
|
|
|
185
254
|
const passiveContentContract: PassiveContentContract = {
|
|
@@ -189,6 +258,13 @@ const passiveContentContract: PassiveContentContract = {
|
|
|
189
258
|
}
|
|
190
259
|
|
|
191
260
|
watchEffect(() => {
|
|
261
|
+
const target = navigationTarget.value
|
|
262
|
+
if (target && props.pressed !== undefined) {
|
|
263
|
+
throw new Error('SButton: navigation target несовместим с pressed toggle semantics')
|
|
264
|
+
}
|
|
265
|
+
if (target && props.disclosure !== undefined) {
|
|
266
|
+
throw new Error('SButton: navigation target несовместим с disclosure trigger semantics')
|
|
267
|
+
}
|
|
192
268
|
if (props.disclosure && props.disclosure.controls.trim().length === 0) {
|
|
193
269
|
throw new Error('SButton: disclosure.controls must be a non-empty id')
|
|
194
270
|
}
|
|
@@ -202,17 +278,42 @@ watchEffect(() => {
|
|
|
202
278
|
})
|
|
203
279
|
|
|
204
280
|
const isIconOnly = computed(() => props.iconOnly || (
|
|
205
|
-
props.label === '' && Boolean(props.leadingIcon || props.trailingIcon ||
|
|
281
|
+
props.label === '' && Boolean(props.leadingIcon || props.trailingIcon || buttonSlots.default)
|
|
206
282
|
))
|
|
207
283
|
const renderDefaultSlot = computed(() => props.label === undefined || (
|
|
208
284
|
props.label === '' && !props.leadingIcon && !props.trailingIcon
|
|
209
285
|
))
|
|
210
286
|
const progressIndicatorSize = computed<SProgressIndicatorSize>(() => props.size === 'lg' ? 'sm' : 'xs')
|
|
211
287
|
|
|
212
|
-
|
|
213
|
-
|
|
288
|
+
function setRootRef(target: Element | ComponentPublicInstance | null): void {
|
|
289
|
+
if (target === null) {
|
|
290
|
+
rootRef.value = null
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement) {
|
|
294
|
+
rootRef.value = target
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const element: unknown = Reflect.get(target, '$el')
|
|
298
|
+
if (!(element instanceof HTMLAnchorElement)) {
|
|
299
|
+
throw new TypeError('SButton: RouterLink root must resolve to an HTMLAnchorElement')
|
|
300
|
+
}
|
|
301
|
+
rootRef.value = element
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function handleClick(event: MouseEvent): void {
|
|
305
|
+
if (navigationTarget.value && navigationUnavailable.value) {
|
|
306
|
+
event.preventDefault()
|
|
307
|
+
event.stopImmediatePropagation()
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
emit('click', event)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const getElement = (): HTMLButtonElement | HTMLAnchorElement => {
|
|
314
|
+
const element = rootRef.value
|
|
214
315
|
if (!element) {
|
|
215
|
-
throw new Error('SButton:
|
|
316
|
+
throw new Error('SButton: interactive element is unavailable')
|
|
216
317
|
}
|
|
217
318
|
return element
|
|
218
319
|
}
|