@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 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` · `118 exact exports` · `6 themes` · `MIT`
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 | `>=11.10.0 <12` |
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 variant="primary">
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, menus, selection | `shared/controls/SButton.vue`, `shared/controls/SInputText.vue`, `shared/controls/SSelect.vue` |
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/SCodeBlock.vue`, `shared/data-display/SStatus.vue` |
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.2.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": ">=11.10.0 <12"
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.16",
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
- @apply flex min-h-0 min-w-0 flex-col;
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
- * Вертикальный layout-owner с ограниченной семантической шкалой интервалов.
26
- * Vertical layout owner with a constrained semantic spacing scale.
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 :is="`h${headingLevel}`" class="s-page-header__title">{{ title }}</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
- <button
3
- ref="buttonRef"
4
- v-bind="buttonBindings"
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
- :disabled="disabled || loading"
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
- <slot />
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
- </button>
55
+ </component>
56
56
  </template>
57
57
 
58
58
  <script setup lang="ts">
59
- import { computed, ref, watchEffect } from 'vue'
60
- import type { Component, VNodeChild } from 'vue'
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 native-button ownership surface for composite UI-kit components. / Типизированная ownership-поверхность native button для составных UI-kit-компонентов. */
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
- export interface Props {
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
- type: 'button',
167
+ href: undefined,
168
+ to: undefined,
169
+ target: undefined,
170
+ rel: undefined,
171
+ type: undefined,
147
172
  })
148
- const slots = defineSlots<{
149
- /** Пассивная метка/графика действия; вложенные controls запрещены. / Passive action label/graphics; nested controls are forbidden. */
150
- default?: () => VNodeChild
173
+ const emit = defineEmits<{
174
+ click: [event: MouseEvent]
151
175
  }>()
152
- const buttonRef = ref<HTMLButtonElement | null>(null)
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: buttonRef,
186
+ element: rootRef,
157
187
  disabled: () => props.disabled || props.loading,
158
188
  })
159
189
 
160
190
  useInteractiveLeafRegistration({ owner: 'SButton' })
161
191
 
162
- const buttonBindings = computed(() => {
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
- if (!props.loading) return semanticBindings
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 = semanticBindings['aria-label']
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 && !slots.default) {
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
- ? { ...semanticBindings, 'aria-label': accessibleName }
182
- : semanticBindings
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 || slots.default)
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
- const getElement = (): HTMLButtonElement => {
213
- const element = buttonRef.value
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: native button element is unavailable')
316
+ throw new Error('SButton: interactive element is unavailable')
216
317
  }
217
318
  return element
218
319
  }
@@ -0,0 +1,9 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-combobox-anchor {
4
+ @apply w-full min-w-0;
5
+ }
6
+
7
+ .s-combobox-layer {
8
+ @apply max-w-[calc(100vw-1rem)];
9
+ }