@pgcorp/ui-kit 0.6.0 → 0.7.1

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.
Files changed (32) hide show
  1. package/README.md +57 -0
  2. package/docs/public-api.md +6 -0
  3. package/docs/theming.md +58 -0
  4. package/package.json +5 -1
  5. package/src/components/shared/_internal/SToastItem.css +8 -8
  6. package/src/components/shared/_internal/SToastItem.vue +8 -1
  7. package/src/components/shared/controls/SButton.css +6 -6
  8. package/src/components/shared/controls/SButton.vue +9 -1
  9. package/src/components/shared/controls/SInteractiveSurface.css +3 -3
  10. package/src/components/shared/controls/SInteractiveSurface.vue +9 -2
  11. package/src/components/shared/controls/SMarker.css +16 -16
  12. package/src/components/shared/controls/SMarker.vue +27 -1
  13. package/src/components/shared/data-display/SActionListItem.css +2 -2
  14. package/src/components/shared/data-display/SActionListItem.vue +8 -1
  15. package/src/components/shared/data-display/SMessage.css +8 -8
  16. package/src/components/shared/data-display/SMessage.vue +8 -1
  17. package/src/components/shared/data-display/SProgressBar.css +12 -12
  18. package/src/components/shared/data-display/SProgressBar.vue +12 -6
  19. package/src/components/shared/data-display/SProgressIndicator.css +5 -5
  20. package/src/components/shared/data-display/SProgressIndicator.vue +9 -1
  21. package/src/components/shared/data-display/SStatus.css +10 -10
  22. package/src/components/shared/data-display/SStatus.vue +9 -1
  23. package/src/components/shared/data-display/STable.vue +27 -4
  24. package/src/components/shared/data-display/STooltip.css +4 -4
  25. package/src/components/shared/data-display/STooltip.vue +159 -29
  26. package/src/components/shared/data-display/STooltipTarget.css +26 -0
  27. package/src/components/shared/data-display/STooltipTarget.vue +94 -0
  28. package/src/components/shared/navigation/STabs.vue +11 -3
  29. package/src/internal/ownedAttrs.ts +3 -0
  30. package/src/internal/pillSurface.ts +4 -3
  31. package/src/internal/semanticTone.ts +23 -0
  32. package/src/styles/style.css +7 -7
@@ -22,22 +22,22 @@
22
22
  }
23
23
 
24
24
  /* Severity Colors */
25
- .s-progress-bar-value[data-severity='primary'] {
25
+ .s-progress-bar-value[data-s-tone='primary'] {
26
26
  background-color: var(--s-progress-bar-fill-primary);
27
27
  }
28
- .s-progress-bar-value[data-severity='success'] {
28
+ .s-progress-bar-value[data-s-tone='success'] {
29
29
  background-color: var(--s-progress-bar-fill-success);
30
30
  }
31
- .s-progress-bar-value[data-severity='info'] {
31
+ .s-progress-bar-value[data-s-tone='info'] {
32
32
  background-color: var(--s-progress-bar-fill-info);
33
33
  }
34
- .s-progress-bar-value[data-severity='warn'] {
34
+ .s-progress-bar-value[data-s-tone='warn'] {
35
35
  background-color: var(--s-progress-bar-fill-warn);
36
36
  }
37
- .s-progress-bar-value[data-severity='danger'] {
37
+ .s-progress-bar-value[data-s-tone='danger'] {
38
38
  background-color: var(--s-progress-bar-fill-danger);
39
39
  }
40
- .s-progress-bar-value[data-severity='neutral'] {
40
+ .s-progress-bar-value[data-s-tone='neutral'] {
41
41
  background-color: var(--s-progress-bar-fill-neutral);
42
42
  }
43
43
 
@@ -64,21 +64,21 @@
64
64
  }
65
65
 
66
66
  /* Indeterminate Severity Colors */
67
- .s-progress-bar-value--indeterminate[data-severity='primary']::before {
67
+ .s-progress-bar-value--indeterminate[data-s-tone='primary']::before {
68
68
  background-color: var(--s-progress-bar-fill-primary);
69
69
  }
70
- .s-progress-bar-value--indeterminate[data-severity='success']::before {
70
+ .s-progress-bar-value--indeterminate[data-s-tone='success']::before {
71
71
  background-color: var(--s-progress-bar-fill-success);
72
72
  }
73
- .s-progress-bar-value--indeterminate[data-severity='info']::before {
73
+ .s-progress-bar-value--indeterminate[data-s-tone='info']::before {
74
74
  background-color: var(--s-progress-bar-fill-info);
75
75
  }
76
- .s-progress-bar-value--indeterminate[data-severity='warn']::before {
76
+ .s-progress-bar-value--indeterminate[data-s-tone='warn']::before {
77
77
  background-color: var(--s-progress-bar-fill-warn);
78
78
  }
79
- .s-progress-bar-value--indeterminate[data-severity='danger']::before {
79
+ .s-progress-bar-value--indeterminate[data-s-tone='danger']::before {
80
80
  background-color: var(--s-progress-bar-fill-danger);
81
81
  }
82
- .s-progress-bar-value--indeterminate[data-severity='neutral']::before {
82
+ .s-progress-bar-value--indeterminate[data-s-tone='neutral']::before {
83
83
  background-color: var(--s-progress-bar-indeterminate-neutral-fill);
84
84
  }
@@ -14,8 +14,8 @@
14
14
  >
15
15
  <div
16
16
  v-if="mode === 'determinate'"
17
+ :data-s-tone="toneBinding['data-s-tone']"
17
18
  class="s-progress-bar-value"
18
- :data-severity="severity"
19
19
  :style="{ width: `${value}%` }"
20
20
  >
21
21
  <span v-if="showValue" class="s-progress-bar-label">
@@ -24,19 +24,21 @@
24
24
  </div>
25
25
  <div
26
26
  v-else
27
+ :data-s-tone="toneBinding['data-s-tone']"
27
28
  class="s-progress-bar-value--indeterminate"
28
- :data-severity="severity"
29
29
  ></div>
30
30
  </div>
31
31
  </template>
32
32
 
33
33
  <script setup lang="ts">
34
- import { watchEffect } from 'vue';
34
+ import { computed, watchEffect } from 'vue';
35
35
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
36
+ import { resolveSemanticToneBinding } from '../../../internal/semanticTone';
36
37
 
37
38
  defineOptions({ inheritAttrs: false });
38
39
 
39
40
  export type Severity = 'primary' | 'success' | 'info' | 'warn' | 'danger' | 'neutral';
41
+ const PROGRESS_SEVERITIES = ['primary', 'success', 'info', 'warn', 'danger', 'neutral'] as const satisfies readonly Severity[];
40
42
  export type ProgressBarMode = 'determinate' | 'indeterminate';
41
43
  export type SProgressBarAccessibility =
42
44
  | { readonly mode: 'decorative' }
@@ -62,6 +64,12 @@ const props = withDefaults(defineProps<Props>(), {
62
64
  });
63
65
 
64
66
  const ownedAttrs = useOwnedAttrs({ component: 'SProgressBar', owner: 'progressbar root' });
67
+ const toneBinding = computed(() => resolveSemanticToneBinding(
68
+ 'SProgressBar',
69
+ 'severity',
70
+ props.severity,
71
+ PROGRESS_SEVERITIES,
72
+ ));
65
73
 
66
74
  watchEffect(() => {
67
75
  if (!Number.isFinite(props.value) || props.value < 0 || props.value > 100) {
@@ -70,9 +78,7 @@ watchEffect(() => {
70
78
  if (!['determinate', 'indeterminate'].includes(props.mode)) {
71
79
  throw new TypeError(`SProgressBar: неизвестный mode "${String(props.mode)}"`);
72
80
  }
73
- if (!['primary', 'success', 'info', 'warn', 'danger', 'neutral'].includes(props.severity)) {
74
- throw new TypeError(`SProgressBar: неизвестный severity "${String(props.severity)}"`);
75
- }
81
+ void toneBinding.value;
76
82
  if (!['xs', 'sm', 'md'].includes(props.size)) {
77
83
  throw new TypeError(`SProgressBar: неизвестный size "${String(props.size)}"`);
78
84
  }
@@ -23,11 +23,11 @@
23
23
  .s-progress-indicator[data-size='md'] { --s-progress-indicator-size: var(--s-icon-size-md); }
24
24
  .s-progress-indicator[data-size='lg'] { --s-progress-indicator-size: var(--s-icon-size-lg); }
25
25
 
26
- .s-progress-indicator[data-tone='neutral'] { color: var(--s-progress-indicator-foreground-neutral); }
27
- .s-progress-indicator[data-tone='info'] { color: var(--s-progress-indicator-foreground-info); }
28
- .s-progress-indicator[data-tone='success'] { color: var(--s-progress-indicator-foreground-success); }
29
- .s-progress-indicator[data-tone='warn'] { color: var(--s-progress-indicator-foreground-warn); }
30
- .s-progress-indicator[data-tone='danger'] { color: var(--s-progress-indicator-foreground-danger); }
26
+ .s-progress-indicator[data-s-tone='neutral'] { color: var(--s-progress-indicator-foreground-neutral); }
27
+ .s-progress-indicator[data-s-tone='info'] { color: var(--s-progress-indicator-foreground-info); }
28
+ .s-progress-indicator[data-s-tone='success'] { color: var(--s-progress-indicator-foreground-success); }
29
+ .s-progress-indicator[data-s-tone='warn'] { color: var(--s-progress-indicator-foreground-warn); }
30
+ .s-progress-indicator[data-s-tone='danger'] { color: var(--s-progress-indicator-foreground-danger); }
31
31
 
32
32
  .s-progress-indicator__graphic {
33
33
  width: 100%;
@@ -1,9 +1,9 @@
1
1
  <template>
2
2
  <span
3
3
  v-bind="ownedAttrs.bindings()"
4
+ :data-s-tone="toneBinding['data-s-tone']"
4
5
  class="s-progress-indicator"
5
6
  :data-size="size"
6
- :data-tone="tone"
7
7
  :data-motion="presentation.motion"
8
8
  :data-show-label="validatedShowLabel || undefined"
9
9
  :role="liveAttributes.role"
@@ -30,12 +30,14 @@ import { computed, watchEffect } from 'vue'
30
30
 
31
31
  import { resolveFeedbackIndicator, resolveFeedbackLive, type FeedbackLiveMode } from '../../../internal/feedbackPresentation'
32
32
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
33
+ import { resolveSemanticToneBinding } from '../../../internal/semanticTone'
33
34
  import { validateBoolean } from '../../../internal/runtimeContract'
34
35
 
35
36
  defineOptions({ inheritAttrs: false })
36
37
 
37
38
  export type SProgressIndicatorSize = 'xs' | 'sm' | 'md' | 'lg'
38
39
  export type SProgressIndicatorTone = 'current' | 'neutral' | 'info' | 'success' | 'warn' | 'danger'
40
+ const PROGRESS_INDICATOR_TONES = ['current', 'neutral', 'info', 'success', 'warn', 'danger'] as const satisfies readonly SProgressIndicatorTone[]
39
41
 
40
42
  /**
41
43
  * Декоративный progress принадлежит labelled parent; standalone status требует собственного имени.
@@ -64,6 +66,12 @@ const props = withDefaults(defineProps<Props>(), {
64
66
  })
65
67
 
66
68
  const ownedAttrs = useOwnedAttrs({ component: 'SProgressIndicator', owner: 'progress indicator root' })
69
+ const toneBinding = computed(() => resolveSemanticToneBinding(
70
+ 'SProgressIndicator',
71
+ 'tone',
72
+ props.tone,
73
+ PROGRESS_INDICATOR_TONES,
74
+ ))
67
75
  const presentation = resolveFeedbackIndicator('progress')
68
76
  const validatedShowLabel = computed(() => validateBoolean('SProgressIndicator', 'showLabel', props.showLabel))
69
77
  const feedbackTone = computed(() => props.tone === 'current' ? 'info' : props.tone)
@@ -49,42 +49,42 @@
49
49
  @apply sr-only;
50
50
  }
51
51
 
52
- .s-status[data-tone='neutral'] {
52
+ .s-status[data-s-tone='neutral'] {
53
53
  @apply text-surface-500 dark:text-surface-400;
54
54
  }
55
55
 
56
- .s-status[data-tone='info'] {
56
+ .s-status[data-s-tone='info'] {
57
57
  @apply text-info-700 dark:text-info-300;
58
58
  }
59
59
 
60
- .s-status[data-tone='success'] {
60
+ .s-status[data-s-tone='success'] {
61
61
  @apply text-success-700 dark:text-success-300;
62
62
  }
63
63
 
64
- .s-status[data-tone='warn'] {
64
+ .s-status[data-s-tone='warn'] {
65
65
  @apply text-warn-700 dark:text-warn-300;
66
66
  }
67
67
 
68
- .s-status[data-tone='danger'] {
68
+ .s-status[data-s-tone='danger'] {
69
69
  @apply text-danger-700 dark:text-danger-300;
70
70
  }
71
71
 
72
- .s-status[data-appearance='soft'][data-tone='neutral'] {
72
+ .s-status[data-appearance='soft'][data-s-tone='neutral'] {
73
73
  @apply border-surface-200 bg-surface-100 dark:border-surface-700 dark:bg-surface-800;
74
74
  }
75
75
 
76
- .s-status[data-appearance='soft'][data-tone='info'] {
76
+ .s-status[data-appearance='soft'][data-s-tone='info'] {
77
77
  @apply border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950;
78
78
  }
79
79
 
80
- .s-status[data-appearance='soft'][data-tone='success'] {
80
+ .s-status[data-appearance='soft'][data-s-tone='success'] {
81
81
  @apply border-success-200 bg-success-50 dark:border-success-800 dark:bg-success-950;
82
82
  }
83
83
 
84
- .s-status[data-appearance='soft'][data-tone='warn'] {
84
+ .s-status[data-appearance='soft'][data-s-tone='warn'] {
85
85
  @apply border-warn-200 bg-warn-50 dark:border-warn-800 dark:bg-warn-950;
86
86
  }
87
87
 
88
- .s-status[data-appearance='soft'][data-tone='danger'] {
88
+ .s-status[data-appearance='soft'][data-s-tone='danger'] {
89
89
  @apply border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950;
90
90
  }
@@ -1,8 +1,8 @@
1
1
  <template>
2
2
  <span
3
3
  v-bind="ownedAttrs.bindings()"
4
+ :data-s-tone="toneBinding['data-s-tone']"
4
5
  class="s-status"
5
- :data-tone="tone"
6
6
  :data-appearance="appearance"
7
7
  :data-size="size"
8
8
  :data-label-visibility="labelVisibility"
@@ -22,6 +22,7 @@
22
22
  <script setup lang="ts">
23
23
  import { computed, markRaw, toRaw, type Component } from 'vue'
24
24
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
25
+ import { resolveSemanticToneBinding } from '../../../internal/semanticTone'
25
26
 
26
27
  import {
27
28
  resolveFeedbackLive,
@@ -33,6 +34,7 @@ import {
33
34
  defineOptions({ inheritAttrs: false })
34
35
 
35
36
  export type StatusTone = FeedbackTone
37
+ const STATUS_TONES = ['neutral', 'info', 'success', 'warn', 'danger'] as const satisfies readonly StatusTone[]
36
38
  export type StatusAppearance = 'text' | 'soft'
37
39
  export type StatusSize = 'xs' | 'sm' | 'md'
38
40
  export type StatusLiveMode = Exclude<FeedbackLiveMode, 'auto'>
@@ -66,6 +68,12 @@ const props = withDefaults(defineProps<{
66
68
 
67
69
  const defaultIcon = computed<Component>(() => resolveFeedbackPresentation(props.tone).icon)
68
70
  const ownedAttrs = useOwnedAttrs({ component: 'SStatus', owner: 'status root' })
71
+ const toneBinding = computed(() => resolveSemanticToneBinding(
72
+ 'SStatus',
73
+ 'tone',
74
+ props.tone,
75
+ STATUS_TONES,
76
+ ))
69
77
  const liveAttributes = computed(() => resolveFeedbackLive(props.tone, props.live))
70
78
 
71
79
  const resolvedIcon = computed<Component | null>(() => {
@@ -255,7 +255,7 @@ export interface STableProps<
255
255
  </script>
256
256
 
257
257
  <script setup lang="ts" generic="Row extends object = Record<string, unknown>, RowKey extends STableRowKey = STableRowKey, Field extends string = Extract<keyof Row, string>">
258
- import { computed, useId, useSlots } from 'vue'
258
+ import { computed, useId, useSlots, watch } from 'vue'
259
259
  import { ChevronDown, ChevronsUpDown, ChevronUp } from '../../icons/sputnigUiIcons'
260
260
  import { hasOwn } from '../../../internal/es2020'
261
261
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
@@ -548,6 +548,23 @@ const activeSorts = computed<readonly STableSort<Field>[]>(() => {
548
548
  return singleSort === null ? [] : [singleSort]
549
549
  })
550
550
 
551
+ const NO_PENDING_SORT = Symbol('STable.no-pending-sort')
552
+ let pendingSingleSort: STableSort<Field> | null | typeof NO_PENDING_SORT = NO_PENDING_SORT
553
+ let pendingMultipleSort: readonly STableSort<Field>[] | typeof NO_PENDING_SORT = NO_PENDING_SORT
554
+
555
+ const clearPendingSortIntent = (): void => {
556
+ pendingSingleSort = NO_PENDING_SORT
557
+ pendingMultipleSort = NO_PENDING_SORT
558
+ }
559
+
560
+ watch(validatedSortMode, clearPendingSortIntent, { flush: 'sync' })
561
+ watch(validatedSingleSort, () => {
562
+ pendingSingleSort = NO_PENDING_SORT
563
+ }, { deep: true, flush: 'sync' })
564
+ watch(validatedMultipleSort, () => {
565
+ pendingMultipleSort = NO_PENDING_SORT
566
+ }, { deep: true, flush: 'sync' })
567
+
551
568
  function sortIndex(column: STableColumn<Row, Field>): number {
552
569
  return activeSorts.value.findIndex((candidate) => candidate.key === column.field)
553
570
  }
@@ -592,8 +609,10 @@ function requestSort(column: STableColumn<Row, Field>): void {
592
609
  if (!column.sortable) return
593
610
  const key = column.field as Field
594
611
  if (validatedSortMode.value === 'multiple') {
595
- const current = activeSorts.value
596
- const index = sortIndex(column)
612
+ const current = pendingMultipleSort === NO_PENDING_SORT
613
+ ? activeSorts.value
614
+ : pendingMultipleSort
615
+ const index = current.findIndex((candidate) => candidate.key === key)
597
616
  let next: readonly STableSort<Field>[]
598
617
  if (index === -1) next = [...current, { key, direction: 'ascending' }]
599
618
  else if (current[index]!.direction === 'ascending') {
@@ -601,15 +620,19 @@ function requestSort(column: STableColumn<Row, Field>): void {
601
620
  ? { key: entry.key, direction: 'descending' }
602
621
  : entry)
603
622
  } else next = current.filter((_entry, candidateIndex) => candidateIndex !== index)
623
+ pendingMultipleSort = next.map((entry) => ({ ...entry }))
604
624
  emit('update:sorts', next)
605
625
  emit('multi-sort', next)
606
626
  } else {
607
- const current = activeSorts.value[0]
627
+ const current = pendingSingleSort === NO_PENDING_SORT
628
+ ? activeSorts.value[0]
629
+ : pendingSingleSort ?? undefined
608
630
  const next: STableSort<Field> | null = current?.key !== key
609
631
  ? { key, direction: 'ascending' }
610
632
  : current.direction === 'ascending'
611
633
  ? { key, direction: 'descending' }
612
634
  : null
635
+ pendingSingleSort = next === null ? null : { ...next }
613
636
  emit('update:sort', next)
614
637
  emit('sort', next)
615
638
  }
@@ -54,19 +54,19 @@
54
54
  @apply font-mono;
55
55
  }
56
56
 
57
- .s-tooltip__detail-value[data-tone='info'] {
57
+ .s-tooltip__detail-value[data-s-tone='info'] {
58
58
  @apply text-info-700 dark:text-info-300;
59
59
  }
60
60
 
61
- .s-tooltip__detail-value[data-tone='success'] {
61
+ .s-tooltip__detail-value[data-s-tone='success'] {
62
62
  @apply text-success-700 dark:text-success-300;
63
63
  }
64
64
 
65
- .s-tooltip__detail-value[data-tone='warn'] {
65
+ .s-tooltip__detail-value[data-s-tone='warn'] {
66
66
  @apply text-warn-700 dark:text-warn-300;
67
67
  }
68
68
 
69
- .s-tooltip__detail-value[data-tone='danger'] {
69
+ .s-tooltip__detail-value[data-s-tone='danger'] {
70
70
  @apply text-danger-700 dark:text-danger-300;
71
71
  }
72
72
 
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <slot :trigger="triggerBinding" :is-open="isOpen" />
2
+ <slot
3
+ :trigger="triggerBinding"
4
+ :focus-owner="focusOwnerBinding"
5
+ :pointer-target="pointerTargetBinding"
6
+ :is-open="isOpen"
7
+ />
3
8
  <teleport to="body">
4
9
  <transition
5
10
  enter-active-class="transition-opacity duration-[var(--s-motion-fast)]"
@@ -27,7 +32,7 @@
27
32
  <dd
28
33
  class="s-tooltip__detail-value"
29
34
  :data-presentation="detail.presentation ?? 'text'"
30
- :data-tone="detail.tone ?? 'neutral'"
35
+ :data-s-tone="resolveDetailToneBinding(detail.tone)['data-s-tone']"
31
36
  >
32
37
  <SBadge
33
38
  v-if="detail.presentation === 'badge'"
@@ -70,6 +75,7 @@ import {
70
75
  } from 'vue';
71
76
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs';
72
77
  import { hasOwn } from '../../../internal/es2020';
78
+ import { resolveSemanticToneBinding } from '../../../internal/semanticTone';
73
79
  import {
74
80
  resolveInteractiveElement,
75
81
  type ElementRefTarget,
@@ -127,9 +133,26 @@ export interface STooltipTriggerBinding {
127
133
  readonly onFocusout: (event: FocusEvent) => void;
128
134
  }
129
135
 
136
+ /** Focus/ARIA owner для split trigger без pointer geometry. / Focus and ARIA owner for a split trigger without pointer geometry. */
137
+ export interface STooltipFocusOwnerBinding {
138
+ readonly ref: VNodeRef;
139
+ readonly 'aria-describedby': string | undefined;
140
+ readonly onFocusin: () => void;
141
+ readonly onFocusout: (event: FocusEvent) => void;
142
+ }
143
+
144
+ /** Пассивная pointer/geometry target той же интерактивной композиции. / Passive pointer and geometry target in the same interactive composition. */
145
+ export interface STooltipPointerTargetBinding {
146
+ readonly ref: VNodeRef;
147
+ readonly onPointerenter: (event: PointerEvent) => void;
148
+ readonly onPointerleave: (event: PointerEvent) => void;
149
+ }
150
+
130
151
  /** Scoped-slot API явного trigger owner. / Scoped-slot API for the explicit trigger owner. */
131
152
  export interface STooltipTriggerSlotProps {
132
153
  readonly trigger: STooltipTriggerBinding;
154
+ readonly focusOwner: STooltipFocusOwnerBinding;
155
+ readonly pointerTarget: STooltipPointerTargetBinding;
133
156
  readonly isOpen: boolean;
134
157
  }
135
158
 
@@ -171,8 +194,11 @@ const layerZIndex = ref('var(--s-layer-floating)');
171
194
  let layerRegistration: LayerRegistration | null = null;
172
195
  let hideTimer: number | null = null;
173
196
  let showTimer: number | null = null;
174
- let triggerTarget: ElementRefTarget = null;
175
- let triggerElement: HTMLElement | null = null;
197
+ let combinedTarget: ElementRefTarget = null;
198
+ let focusOwnerTarget: ElementRefTarget = null;
199
+ let pointerTarget: ElementRefTarget = null;
200
+ let focusOwnerElement: HTMLElement | null = null;
201
+ let pointerTargetElement: HTMLElement | null = null;
176
202
  let pointerInside = false;
177
203
  let focusInside = false;
178
204
  let touchActive = false;
@@ -302,9 +328,15 @@ const detailSeverityByTone: Record<TooltipDetailTone, BadgeSeverity> = {
302
328
  };
303
329
  const resolveDetailSeverity = (tone: TooltipDetailTone | undefined): BadgeSeverity =>
304
330
  detailSeverityByTone[tone ?? 'neutral'];
331
+ const resolveDetailToneBinding = (tone: TooltipDetailTone | undefined) => resolveSemanticToneBinding(
332
+ 'STooltip',
333
+ 'detail.tone',
334
+ tone ?? 'neutral',
335
+ ['neutral', 'info', 'success', 'warn', 'danger'] as const,
336
+ );
305
337
  const placement = computed<FloatingPlacement>(() => `${props.position}-center`);
306
338
  const {
307
- anchorRef: triggerRef,
339
+ anchorRef: positionAnchorRef,
308
340
  panelRef: tooltipRef,
309
341
  floatingStyle,
310
342
  arrowStyle,
@@ -346,11 +378,11 @@ const closeImmediately = () => {
346
378
  };
347
379
 
348
380
  const openImmediately = () => {
349
- if (isOpen.value || !triggerElement) return;
381
+ if (isOpen.value || !focusOwnerElement || !pointerTargetElement) return;
350
382
  layerRegistration = registerLayer({
351
383
  kind: 'tooltip',
352
384
  root: () => tooltipRef.value,
353
- anchor: () => triggerRef.value,
385
+ anchor: () => positionAnchorRef.value,
354
386
  blocksParentFocusTrap: false,
355
387
  restoreTarget: null,
356
388
  requestDismiss: closeImmediately,
@@ -360,7 +392,7 @@ const openImmediately = () => {
360
392
  };
361
393
 
362
394
  const scheduleShow = () => {
363
- if (props.disabled || !hasContent.value || !triggerElement) return;
395
+ if (props.disabled || !hasContent.value || !focusOwnerElement || !pointerTargetElement) return;
364
396
  clearHideTimer();
365
397
  clearShowTimer();
366
398
  showTimer = window.setTimeout(() => {
@@ -412,37 +444,100 @@ const handleFocusIn = () => {
412
444
  };
413
445
 
414
446
  const handleFocusOut = (event: FocusEvent) => {
415
- if (event.relatedTarget instanceof Node && triggerElement?.contains(event.relatedTarget)) return;
447
+ if (event.relatedTarget instanceof Node && focusOwnerElement?.contains(event.relatedTarget)) return;
416
448
  focusInside = false;
417
449
  hideWhenInactive();
418
450
  };
419
451
 
420
- const commitTriggerElement = (element: HTMLElement | null) => {
452
+ const validateSplitComposition = (): void => {
453
+ if (!focusOwnerElement || !pointerTargetElement || combinedTarget !== null) return;
454
+ if (focusOwnerElement === pointerTargetElement) {
455
+ throw triggerContractError(
456
+ 'focusOwner и pointerTarget должны быть разными владельцами одной интерактивной композиции. '
457
+ + 'focusOwner and pointerTarget must be distinct owners in one interactive composition.',
458
+ );
459
+ }
460
+ };
461
+
462
+ const commitFocusOwnerElement = (element: HTMLElement | null) => {
421
463
  if (element !== null && !isFocusableElement(element)) {
422
464
  throw triggerContractError(
423
- 'trigger должен владеть canonical focus owner; используйте интерактивный UI-kit leaf. '
424
- + 'The trigger must own a canonical focus owner; use an interactive UI-kit leaf.',
465
+ 'trigger/focusOwner должен владеть canonical focus owner; используйте интерактивный UI-kit leaf. '
466
+ + 'trigger/focusOwner must own a canonical focus owner; use an interactive UI-kit leaf.',
425
467
  );
426
468
  }
427
- triggerElement = element;
428
- triggerRef.value = element;
469
+ focusOwnerElement = element;
429
470
  pointerInside = false;
430
471
  focusInside = false;
431
472
  touchActive = false;
432
473
  if (element === null) closeImmediately();
474
+ validateSplitComposition();
433
475
  };
434
476
 
435
- const bindTriggerElement: VNodeRef = (target) => {
436
- if (target === triggerTarget) return;
437
- if (target !== null && triggerTarget !== null) {
477
+ const commitPointerTargetElement = (element: HTMLElement | null) => {
478
+ if (element !== null && combinedTarget === null && isFocusableElement(element)) {
438
479
  throw triggerContractError(
439
- 'trigger binding применён более чем к одному leaf. '
440
- + 'The trigger binding was applied to more than one leaf.',
480
+ 'pointerTarget должен быть пассивным и не может создавать дополнительный tab stop. '
481
+ + 'pointerTarget must be passive and cannot create an additional tab stop.',
441
482
  );
442
483
  }
443
- triggerTarget = target;
484
+ pointerTargetElement = element;
485
+ positionAnchorRef.value = element;
486
+ pointerInside = false;
487
+ touchActive = false;
488
+ if (element === null) closeImmediately();
489
+ validateSplitComposition();
490
+ };
491
+
492
+ const assertBindingMode = (binding: 'combined' | 'focusOwner' | 'pointerTarget'): void => {
493
+ if (binding === 'combined' && (focusOwnerTarget !== null || pointerTarget !== null)) {
494
+ throw triggerContractError('combined trigger нельзя смешивать со split trigger. combined trigger cannot be mixed with a split trigger.');
495
+ }
496
+ if (binding !== 'combined' && combinedTarget !== null) {
497
+ throw triggerContractError('split trigger нельзя смешивать с combined trigger. A split trigger cannot be mixed with a combined trigger.');
498
+ }
499
+ };
500
+
501
+ const bindCombinedElement: VNodeRef = (target) => {
502
+ if (target === combinedTarget) return;
503
+ if (target !== null) {
504
+ assertBindingMode('combined');
505
+ if (combinedTarget !== null) {
506
+ throw triggerContractError('trigger binding применён более чем к одному leaf. The trigger binding was applied to more than one leaf.');
507
+ }
508
+ }
509
+ combinedTarget = target;
444
510
  if (target === null || target instanceof HTMLElement) {
445
- commitTriggerElement(target);
511
+ commitFocusOwnerElement(target);
512
+ commitPointerTargetElement(target);
513
+ }
514
+ };
515
+
516
+ const bindFocusOwnerElement: VNodeRef = (target) => {
517
+ if (target === focusOwnerTarget) return;
518
+ if (target !== null) {
519
+ assertBindingMode('focusOwner');
520
+ if (focusOwnerTarget !== null) {
521
+ throw triggerContractError('focusOwner binding применён более чем к одному leaf. focusOwner binding was applied to more than one leaf.');
522
+ }
523
+ }
524
+ focusOwnerTarget = target;
525
+ if (target === null || target instanceof HTMLElement) {
526
+ commitFocusOwnerElement(target);
527
+ }
528
+ };
529
+
530
+ const bindPointerTargetElement: VNodeRef = (target) => {
531
+ if (target === pointerTarget) return;
532
+ if (target !== null) {
533
+ assertBindingMode('pointerTarget');
534
+ if (pointerTarget !== null) {
535
+ throw triggerContractError('pointerTarget binding применён более чем к одному leaf. pointerTarget binding was applied to more than one leaf.');
536
+ }
537
+ }
538
+ pointerTarget = target;
539
+ if (target === null || target instanceof HTMLElement) {
540
+ commitPointerTargetElement(target);
446
541
  }
447
542
  };
448
543
 
@@ -467,7 +562,7 @@ const validatedTriggerDescribedBy = computed(() => {
467
562
  });
468
563
 
469
564
  const triggerBinding = computed<STooltipTriggerBinding>(() => ({
470
- ref: bindTriggerElement,
565
+ ref: bindCombinedElement,
471
566
  'aria-describedby': mergeIdReferences(
472
567
  validatedTriggerDescribedBy.value,
473
568
  isOpen.value ? tooltipId : undefined,
@@ -481,6 +576,22 @@ const triggerBinding = computed<STooltipTriggerBinding>(() => ({
481
576
  onFocusout: handleFocusOut,
482
577
  }));
483
578
 
579
+ const focusOwnerBinding = computed<STooltipFocusOwnerBinding>(() => ({
580
+ ref: bindFocusOwnerElement,
581
+ 'aria-describedby': mergeIdReferences(
582
+ validatedTriggerDescribedBy.value,
583
+ isOpen.value ? tooltipId : undefined,
584
+ ),
585
+ onFocusin: handleFocusIn,
586
+ onFocusout: handleFocusOut,
587
+ }));
588
+
589
+ const pointerTargetBinding = computed<STooltipPointerTargetBinding>(() => ({
590
+ ref: bindPointerTargetElement,
591
+ onPointerenter: handlePointerEnter,
592
+ onPointerleave: handlePointerLeave,
593
+ }));
594
+
484
595
  const handleWindowBlur = () => {
485
596
  if (isOpen.value) {
486
597
  closeImmediately();
@@ -530,20 +641,39 @@ watchEffect(() => {
530
641
  });
531
642
 
532
643
  onMounted(() => {
533
- if (triggerTarget === null) {
644
+ if (combinedTarget !== null && (focusOwnerTarget !== null || pointerTarget !== null)) {
645
+ throw triggerContractError('combined trigger нельзя смешивать со split trigger. combined trigger cannot be mixed with a split trigger.');
646
+ }
647
+ if (combinedTarget === null && (focusOwnerTarget === null || pointerTarget === null)) {
648
+ throw triggerContractError(
649
+ 'default slot обязан применить combined trigger либо обе половины split trigger: focusOwner и pointerTarget. '
650
+ + 'The default slot must apply the combined trigger or both split trigger bindings: focusOwner and pointerTarget.',
651
+ );
652
+ }
653
+ if (combinedTarget !== null) {
654
+ const element = resolveInteractiveElement('STooltip: trigger', combinedTarget);
655
+ commitFocusOwnerElement(element);
656
+ commitPointerTargetElement(element);
657
+ return;
658
+ }
659
+ if (focusOwnerTarget === null || pointerTarget === null) {
534
660
  throw triggerContractError(
535
- 'default slot обязан применить trigger binding ровно к одному leaf. '
536
- + 'The default slot must apply the trigger binding to exactly one leaf.',
661
+ 'split trigger требует focusOwner и pointerTarget. '
662
+ + 'A split trigger requires focusOwner and pointerTarget.',
537
663
  );
538
664
  }
539
- commitTriggerElement(resolveInteractiveElement('STooltip: trigger', triggerTarget));
665
+ commitFocusOwnerElement(resolveInteractiveElement('STooltip: focusOwner', focusOwnerTarget));
666
+ commitPointerTargetElement(resolveInteractiveElement('STooltip: pointerTarget', pointerTarget));
540
667
  });
541
668
 
542
669
  onBeforeUnmount(() => {
543
670
  closeImmediately();
544
- triggerTarget = null;
545
- triggerElement = null;
546
- triggerRef.value = null;
671
+ combinedTarget = null;
672
+ focusOwnerTarget = null;
673
+ pointerTarget = null;
674
+ focusOwnerElement = null;
675
+ pointerTargetElement = null;
676
+ positionAnchorRef.value = null;
547
677
  unbindWindowListeners();
548
678
  });
549
679
  </script>