@pgcorp/ui-kit 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/docs/accessibility.md +27 -0
- package/docs/getting-started.md +80 -0
- package/docs/licensing.md +30 -0
- package/docs/public-api.md +39 -0
- package/docs/security.md +21 -0
- package/docs/theming.md +35 -0
- package/docs/versioning.md +23 -0
- package/index.ts +3 -0
- package/package.json +592 -0
- package/src/components/icons/_internal/generatedSputnigUiIconMarkup.ts +971 -0
- package/src/components/icons/sputnigUiIcons.ts +530 -0
- package/src/components/layout/SAppShell.vue +232 -0
- package/src/components/layout/SDockRegion.vue +200 -0
- package/src/components/layout/SResizeHandle.vue +251 -0
- package/src/components/layout/SSideMenuButton.vue +104 -0
- package/src/components/layout/SSideMenuRail.vue +47 -0
- package/src/components/layout/SStack.css +25 -0
- package/src/components/layout/SStack.vue +57 -0
- package/src/components/layout/SStatusBarShell.css +15 -0
- package/src/components/layout/SStatusBarShell.vue +26 -0
- package/src/components/layout/SWorkbenchLayout.vue +53 -0
- package/src/components/layout/SWorkbenchPanelShell.css +161 -0
- package/src/components/layout/SWorkbenchPanelShell.vue +286 -0
- package/src/components/layout/_internal/SAppBrand.css +45 -0
- package/src/components/layout/_internal/SAppBrand.vue +37 -0
- package/src/components/layout/_internal/SAppBrandContent.vue +30 -0
- package/src/components/layout/types.ts +28 -0
- package/src/components/shared/_internal/SDialogSurface.css +127 -0
- package/src/components/shared/_internal/SDialogSurface.vue +117 -0
- package/src/components/shared/_internal/SDisclosureSummary.css +92 -0
- package/src/components/shared/_internal/SDisclosureSummary.vue +121 -0
- package/src/components/shared/_internal/SListboxGroup.css +18 -0
- package/src/components/shared/_internal/SListboxGroup.vue +24 -0
- package/src/components/shared/_internal/SMenuItem.css +112 -0
- package/src/components/shared/_internal/SMenuItem.vue +213 -0
- package/src/components/shared/_internal/SMenuLayer.css +21 -0
- package/src/components/shared/_internal/SMenuLayer.vue +48 -0
- package/src/components/shared/_internal/SMenuSeparator.css +5 -0
- package/src/components/shared/_internal/SMenuSeparator.vue +13 -0
- package/src/components/shared/_internal/SToastItem.css +65 -0
- package/src/components/shared/_internal/SToastItem.vue +58 -0
- package/src/components/shared/_internal/STreeNode.vue +761 -0
- package/src/components/shared/_internal/dialogSurface.ts +105 -0
- package/src/components/shared/_internal/focusTrap.ts +40 -0
- package/src/components/shared/_internal/listboxContext.ts +16 -0
- package/src/components/shared/_internal/markerContract.ts +10 -0
- package/src/components/shared/_internal/menuContext.ts +23 -0
- package/src/components/shared/_internal/selectionContract.ts +239 -0
- package/src/components/shared/_internal/sidebarGroupContext.ts +31 -0
- package/src/components/shared/_internal/tabsContext.ts +57 -0
- package/src/components/shared/_internal/treeRuntime.ts +203 -0
- package/src/components/shared/_internal/useSelectionRoving.ts +162 -0
- package/src/components/shared/codeLanguages.ts +168 -0
- package/src/components/shared/complex/SKanbanCard.css +8 -0
- package/src/components/shared/complex/SKanbanCard.vue +76 -0
- package/src/components/shared/complex/SKanbanLane.vue +82 -0
- package/src/components/shared/complex/STree.css +24 -0
- package/src/components/shared/complex/STree.vue +288 -0
- package/src/components/shared/complex/treeAdapter.ts +210 -0
- package/src/components/shared/complex/types.ts +102 -0
- package/src/components/shared/containers/SCloudChrome.css +103 -0
- package/src/components/shared/containers/SCloudChrome.vue +87 -0
- package/src/components/shared/containers/SCloudChromeEmpty.css +10 -0
- package/src/components/shared/containers/SCloudChromeEmpty.vue +18 -0
- package/src/components/shared/containers/SCloudChromeRow.css +78 -0
- package/src/components/shared/containers/SCloudChromeRow.vue +108 -0
- package/src/components/shared/containers/SCollapsiblePanel.css +57 -0
- package/src/components/shared/containers/SCollapsiblePanel.vue +360 -0
- package/src/components/shared/containers/SDrawer.css +60 -0
- package/src/components/shared/containers/SDrawer.vue +86 -0
- package/src/components/shared/containers/SFormGrid.css +26 -0
- package/src/components/shared/containers/SFormGrid.vue +46 -0
- package/src/components/shared/containers/SFormGridItem.css +18 -0
- package/src/components/shared/containers/SFormGridItem.vue +29 -0
- package/src/components/shared/containers/SLeftSidebar.vue +74 -0
- package/src/components/shared/containers/SModal.css +27 -0
- package/src/components/shared/containers/SModal.vue +105 -0
- package/src/components/shared/containers/SPageHeader.css +66 -0
- package/src/components/shared/containers/SPageHeader.vue +45 -0
- package/src/components/shared/containers/SPanel.css +134 -0
- package/src/components/shared/containers/SPanel.vue +169 -0
- package/src/components/shared/containers/SPopover.css +35 -0
- package/src/components/shared/containers/SPopover.vue +551 -0
- package/src/components/shared/containers/SSidebarGroup.css +12 -0
- package/src/components/shared/containers/SSidebarGroup.vue +443 -0
- package/src/components/shared/containers/SSidebarSection.css +41 -0
- package/src/components/shared/containers/SSidebarSection.vue +268 -0
- package/src/components/shared/containers/disclosureSummary.ts +48 -0
- package/src/components/shared/containers/popover.ts +72 -0
- package/src/components/shared/containers/sidebar.ts +206 -0
- package/src/components/shared/controls/SButton.css +274 -0
- package/src/components/shared/controls/SButton.vue +211 -0
- package/src/components/shared/controls/SCheckbox.css +65 -0
- package/src/components/shared/controls/SCheckbox.vue +130 -0
- package/src/components/shared/controls/SCheckboxGroup.vue +134 -0
- package/src/components/shared/controls/SChoiceCards.css +29 -0
- package/src/components/shared/controls/SChoiceCards.vue +145 -0
- package/src/components/shared/controls/SColorSelect.vue +146 -0
- package/src/components/shared/controls/SComboboxTrigger.css +35 -0
- package/src/components/shared/controls/SComboboxTrigger.vue +122 -0
- package/src/components/shared/controls/SContextMenu.css +11 -0
- package/src/components/shared/controls/SContextMenu.vue +186 -0
- package/src/components/shared/controls/SContextToggleButton.vue +64 -0
- package/src/components/shared/controls/SDragHandle.vue +100 -0
- package/src/components/shared/controls/SDropdownMenu.css +5 -0
- package/src/components/shared/controls/SDropdownMenu.vue +188 -0
- package/src/components/shared/controls/SField.css +79 -0
- package/src/components/shared/controls/SField.vue +89 -0
- package/src/components/shared/controls/SFieldGroup.css +58 -0
- package/src/components/shared/controls/SFieldGroup.vue +117 -0
- package/src/components/shared/controls/SFieldLabel.css +12 -0
- package/src/components/shared/controls/SFieldLabel.vue +51 -0
- package/src/components/shared/controls/SInlineTokenEditor.css +61 -0
- package/src/components/shared/controls/SInlineTokenEditor.vue +1081 -0
- package/src/components/shared/controls/SInputText.css +66 -0
- package/src/components/shared/controls/SInputText.vue +321 -0
- package/src/components/shared/controls/SInteractiveSurface.css +72 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +326 -0
- package/src/components/shared/controls/SLink.css +114 -0
- package/src/components/shared/controls/SLink.ts +85 -0
- package/src/components/shared/controls/SLink.vue +114 -0
- package/src/components/shared/controls/SListbox.css +31 -0
- package/src/components/shared/controls/SListbox.vue +388 -0
- package/src/components/shared/controls/SListboxOption.css +63 -0
- package/src/components/shared/controls/SListboxOption.vue +85 -0
- package/src/components/shared/controls/SMarker.css +19 -0
- package/src/components/shared/controls/SMarker.vue +33 -0
- package/src/components/shared/controls/SMenuSurface.css +20 -0
- package/src/components/shared/controls/SMenuSurface.vue +333 -0
- package/src/components/shared/controls/SSegmentedControl.css +38 -0
- package/src/components/shared/controls/SSegmentedControl.vue +134 -0
- package/src/components/shared/controls/SSelect.css +12 -0
- package/src/components/shared/controls/SSelect.vue +338 -0
- package/src/components/shared/controls/STextarea.css +31 -0
- package/src/components/shared/controls/STextarea.vue +316 -0
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +224 -0
- package/src/components/shared/controls/menu.ts +178 -0
- package/src/components/shared/data-display/SActionCard.css +6 -0
- package/src/components/shared/data-display/SActionCard.vue +49 -0
- package/src/components/shared/data-display/SActionList.css +15 -0
- package/src/components/shared/data-display/SActionList.vue +34 -0
- package/src/components/shared/data-display/SActionListItem.css +114 -0
- package/src/components/shared/data-display/SActionListItem.vue +230 -0
- package/src/components/shared/data-display/SBadge.css +73 -0
- package/src/components/shared/data-display/SBadge.vue +52 -0
- package/src/components/shared/data-display/SCodeBlock.css +56 -0
- package/src/components/shared/data-display/SCodeBlock.vue +188 -0
- package/src/components/shared/data-display/SCodeEditor.css +178 -0
- package/src/components/shared/data-display/SCodeEditor.vue +274 -0
- package/src/components/shared/data-display/SCodeSearchPanel.vue +327 -0
- package/src/components/shared/data-display/SCopyField.vue +77 -0
- package/src/components/shared/data-display/SDocBlock.css +136 -0
- package/src/components/shared/data-display/SDocBlock.vue +358 -0
- package/src/components/shared/data-display/SEmptyState.css +42 -0
- package/src/components/shared/data-display/SEmptyState.vue +79 -0
- package/src/components/shared/data-display/SExpandableText.vue +314 -0
- package/src/components/shared/data-display/SJsonTree.css +6 -0
- package/src/components/shared/data-display/SJsonTree.vue +111 -0
- package/src/components/shared/data-display/SKeyValueGrid.css +37 -0
- package/src/components/shared/data-display/SKeyValueGrid.vue +277 -0
- package/src/components/shared/data-display/SLinkedSystemsList.vue +270 -0
- package/src/components/shared/data-display/SMessage.css +88 -0
- package/src/components/shared/data-display/SMessage.vue +152 -0
- package/src/components/shared/data-display/SMetricCard.css +32 -0
- package/src/components/shared/data-display/SMetricCard.vue +117 -0
- package/src/components/shared/data-display/SProgressBar.css +84 -0
- package/src/components/shared/data-display/SProgressBar.vue +114 -0
- package/src/components/shared/data-display/SProgressIndicator.css +59 -0
- package/src/components/shared/data-display/SProgressIndicator.vue +104 -0
- package/src/components/shared/data-display/SSectionHeader.css +84 -0
- package/src/components/shared/data-display/SSectionHeader.vue +58 -0
- package/src/components/shared/data-display/SStatus.css +90 -0
- package/src/components/shared/data-display/SStatus.vue +79 -0
- package/src/components/shared/data-display/STable.css +83 -0
- package/src/components/shared/data-display/STable.vue +339 -0
- package/src/components/shared/data-display/SToastContainer.css +53 -0
- package/src/components/shared/data-display/SToastContainer.vue +31 -0
- package/src/components/shared/data-display/STooltip.css +114 -0
- package/src/components/shared/data-display/STooltip.vue +550 -0
- package/src/components/shared/data-display/STooltipAnchor.css +21 -0
- package/src/components/shared/data-display/STooltipAnchor.vue +119 -0
- package/src/components/shared/data-display/SVirtualList.css +24 -0
- package/src/components/shared/data-display/SVirtualList.ts +19 -0
- package/src/components/shared/data-display/SVirtualList.vue +361 -0
- package/src/components/shared/data-display/json.ts +136 -0
- package/src/components/shared/data-display/table.ts +76 -0
- package/src/components/shared/database/SDataGrid.css +3 -0
- package/src/components/shared/database/SDataGrid.vue +464 -0
- package/src/components/shared/database/SSqlEditor.vue +363 -0
- package/src/components/shared/database/dataGrid.ts +61 -0
- package/src/components/shared/feedback/SAsyncState.css +26 -0
- package/src/components/shared/feedback/SAsyncState.vue +190 -0
- package/src/components/shared/graph/SGraphViewport.ts +1 -0
- package/src/components/shared/graph/SGraphViewport.vue +858 -0
- package/src/components/shared/graph/_internal/graphViewportContract.ts +362 -0
- package/src/components/shared/markdown.ts +169 -0
- package/src/components/shared/navigation/SBottomNav.vue +692 -0
- package/src/components/shared/navigation/SBreadcrumbs.css +26 -0
- package/src/components/shared/navigation/SBreadcrumbs.vue +155 -0
- package/src/components/shared/navigation/SCatalogNavigator.css +100 -0
- package/src/components/shared/navigation/SCatalogNavigator.vue +308 -0
- package/src/components/shared/navigation/STab.vue +363 -0
- package/src/components/shared/navigation/STabList.vue +442 -0
- package/src/components/shared/navigation/STabPanel.vue +165 -0
- package/src/components/shared/navigation/STabPanels.vue +33 -0
- package/src/components/shared/navigation/STabs.vue +617 -0
- package/src/components/shared/navigation/SWizardSteps.css +69 -0
- package/src/components/shared/navigation/SWizardSteps.vue +171 -0
- package/src/components/shared/navigation/_internal/SCatalogNavigatorGroup.vue +63 -0
- package/src/components/shared/persona/SPersonaProjectPicker.css +36 -0
- package/src/components/shared/persona/SPersonaProjectPicker.vue +361 -0
- package/src/components/shared/persona/SPersonaRunWorkbench.css +109 -0
- package/src/components/shared/persona/SPersonaRunWorkbench.vue +549 -0
- package/src/components/shared/persona/_internal/SPersonaRunConsole.css +40 -0
- package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +298 -0
- package/src/components/shared/persona/_internal/inventory.ts +110 -0
- package/src/components/shared/persona/_internal/labels.ts +79 -0
- package/src/components/shared/persona/presentation.ts +371 -0
- package/src/components/shared/persona/types.ts +256 -0
- package/src/composables/uiPreferencesReset.ts +52 -0
- package/src/composables/useClipboard.ts +37 -0
- package/src/composables/useDialogOverlay.ts +85 -0
- package/src/composables/useDisclosure.ts +40 -0
- package/src/composables/useFloatingPosition.ts +245 -0
- package/src/composables/useNotifier.ts +26 -0
- package/src/composables/useSidebarPanelState.ts +610 -0
- package/src/internal/codeEditorContract.ts +110 -0
- package/src/internal/codeEditorExtensions.ts +27 -0
- package/src/internal/feedbackPresentation.ts +136 -0
- package/src/internal/fieldSurface.ts +59 -0
- package/src/internal/focusNavigation.ts +69 -0
- package/src/internal/inlineTokenEditorContract.ts +136 -0
- package/src/internal/interactiveElement.ts +33 -0
- package/src/internal/layerStack.ts +202 -0
- package/src/internal/linkTarget.ts +256 -0
- package/src/internal/ownedAttrs.ts +464 -0
- package/src/internal/passiveContentContract.ts +129 -0
- package/src/internal/pointerInteractionLease.ts +81 -0
- package/src/internal/runtimeContract.ts +194 -0
- package/src/internal/runtimeTheme.ts +159 -0
- package/src/internal/semanticSizing.ts +60 -0
- package/src/internal/useMenuLayer.ts +129 -0
- package/src/stores/useNotifierStore.ts +69 -0
- package/src/styles/reference.css +4 -0
- package/src/styles/style.css +151 -0
- package/src/styles/tokens.css +836 -0
- package/src/theme.ts +449 -0
- package/src/types/highlight-js-lib.d.ts +76 -0
- package/src/variants.ts +15 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<slot :trigger="triggerBinding" :is-open="isOpen" />
|
|
3
|
+
<teleport to="body">
|
|
4
|
+
<transition
|
|
5
|
+
enter-active-class="transition-opacity duration-[var(--s-motion-fast)]"
|
|
6
|
+
enter-from-class="opacity-0"
|
|
7
|
+
leave-active-class="transition-opacity duration-[var(--s-motion-fast)]"
|
|
8
|
+
leave-from-class="opacity-100"
|
|
9
|
+
leave-to-class="opacity-0"
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
v-if="isOpen"
|
|
13
|
+
:id="tooltipId"
|
|
14
|
+
ref="tooltipRef"
|
|
15
|
+
class="s-tooltip"
|
|
16
|
+
:data-placement="resolvedPlacement"
|
|
17
|
+
:style="floatingStyle"
|
|
18
|
+
role="tooltip"
|
|
19
|
+
>
|
|
20
|
+
<div class="s-tooltip__content">
|
|
21
|
+
<strong v-if="resolvedTitle" class="s-tooltip__title">{{ resolvedTitle }}</strong>
|
|
22
|
+
<p v-if="resolvedDescription" class="s-tooltip__description">{{ resolvedDescription }}</p>
|
|
23
|
+
<div v-if="resolvedText" class="s-tooltip__text">{{ resolvedText }}</div>
|
|
24
|
+
<dl v-if="resolvedDetails.length > 0" class="s-tooltip__details">
|
|
25
|
+
<div v-for="detail in resolvedDetails" :key="detail.id" class="s-tooltip__detail">
|
|
26
|
+
<dt class="s-tooltip__detail-label">{{ detail.label }}</dt>
|
|
27
|
+
<dd
|
|
28
|
+
class="s-tooltip__detail-value"
|
|
29
|
+
:data-presentation="detail.presentation ?? 'text'"
|
|
30
|
+
:data-tone="detail.tone ?? 'neutral'"
|
|
31
|
+
>
|
|
32
|
+
<SBadge
|
|
33
|
+
v-if="detail.presentation === 'badge'"
|
|
34
|
+
size="xs"
|
|
35
|
+
:severity="resolveDetailSeverity(detail.tone)"
|
|
36
|
+
>
|
|
37
|
+
{{ detail.value }}
|
|
38
|
+
</SBadge>
|
|
39
|
+
<template v-else>{{ detail.value }}</template>
|
|
40
|
+
</dd>
|
|
41
|
+
</div>
|
|
42
|
+
</dl>
|
|
43
|
+
<div v-if="resolvedSections.length > 0" class="s-tooltip__sections">
|
|
44
|
+
<section v-for="section in resolvedSections" :key="section.id" class="s-tooltip__section">
|
|
45
|
+
<strong class="s-tooltip__section-title">{{ section.title }}</strong>
|
|
46
|
+
<p v-if="section.description" class="s-tooltip__section-description">{{ section.description }}</p>
|
|
47
|
+
<ul v-if="section.items?.length" class="s-tooltip__section-items">
|
|
48
|
+
<li v-for="item in section.items" :key="item.id">{{ item.label }}</li>
|
|
49
|
+
</ul>
|
|
50
|
+
</section>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="s-tooltip-arrow" :style="arrowStyle"></div>
|
|
54
|
+
</div>
|
|
55
|
+
</transition>
|
|
56
|
+
</teleport>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import {
|
|
61
|
+
computed,
|
|
62
|
+
onBeforeUnmount,
|
|
63
|
+
onMounted,
|
|
64
|
+
ref,
|
|
65
|
+
useId,
|
|
66
|
+
watch,
|
|
67
|
+
watchEffect,
|
|
68
|
+
type VNodeChild,
|
|
69
|
+
type VNodeRef,
|
|
70
|
+
} from 'vue';
|
|
71
|
+
import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs';
|
|
72
|
+
import {
|
|
73
|
+
resolveInteractiveElement,
|
|
74
|
+
type ElementRefTarget,
|
|
75
|
+
} from '../../../internal/interactiveElement';
|
|
76
|
+
import { isFocusableElement } from '../../../internal/focusNavigation';
|
|
77
|
+
import {
|
|
78
|
+
validateBoolean,
|
|
79
|
+
validateDomId,
|
|
80
|
+
validateExactString,
|
|
81
|
+
validateNonEmptyString,
|
|
82
|
+
} from '../../../internal/runtimeContract';
|
|
83
|
+
import { useFloatingPosition, type FloatingPlacement } from '../../../composables/useFloatingPosition';
|
|
84
|
+
import { registerLayer, type LayerRegistration } from '../../../internal/layerStack';
|
|
85
|
+
import SBadge, { type BadgeSeverity } from './SBadge.vue';
|
|
86
|
+
|
|
87
|
+
defineOptions({ inheritAttrs: false });
|
|
88
|
+
|
|
89
|
+
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
|
|
90
|
+
export type TooltipDetailPresentation = 'text' | 'badge' | 'code';
|
|
91
|
+
export type TooltipDetailTone = 'neutral' | 'info' | 'success' | 'warn' | 'danger';
|
|
92
|
+
|
|
93
|
+
export interface TooltipDetail {
|
|
94
|
+
id: string;
|
|
95
|
+
label: string;
|
|
96
|
+
value: string | number;
|
|
97
|
+
presentation?: TooltipDetailPresentation;
|
|
98
|
+
tone?: TooltipDetailTone;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface TooltipSectionItem {
|
|
102
|
+
id: string;
|
|
103
|
+
label: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TooltipSection {
|
|
107
|
+
id: string;
|
|
108
|
+
title: string;
|
|
109
|
+
description?: string;
|
|
110
|
+
items?: readonly TooltipSectionItem[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Декларативные ARIA/ref/event bindings, которые применяет сам trigger leaf.
|
|
115
|
+
* Declarative ARIA/ref/event bindings applied by the trigger leaf itself.
|
|
116
|
+
*/
|
|
117
|
+
export interface STooltipTriggerBinding {
|
|
118
|
+
readonly ref: VNodeRef;
|
|
119
|
+
readonly 'aria-describedby': string | undefined;
|
|
120
|
+
readonly onPointerenter: (event: PointerEvent) => void;
|
|
121
|
+
readonly onPointerleave: (event: PointerEvent) => void;
|
|
122
|
+
readonly onPointerdown: (event: PointerEvent) => void;
|
|
123
|
+
readonly onPointerup: (event: PointerEvent) => void;
|
|
124
|
+
readonly onPointercancel: (event: PointerEvent) => void;
|
|
125
|
+
readonly onFocusin: () => void;
|
|
126
|
+
readonly onFocusout: (event: FocusEvent) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Scoped-slot API явного trigger owner. / Scoped-slot API for the explicit trigger owner. */
|
|
130
|
+
export interface STooltipTriggerSlotProps {
|
|
131
|
+
readonly trigger: STooltipTriggerBinding;
|
|
132
|
+
readonly isOpen: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface Props {
|
|
136
|
+
text?: string;
|
|
137
|
+
title?: string;
|
|
138
|
+
description?: string;
|
|
139
|
+
details?: readonly TooltipDetail[];
|
|
140
|
+
sections?: readonly TooltipSection[];
|
|
141
|
+
position?: TooltipPosition;
|
|
142
|
+
disabled?: boolean;
|
|
143
|
+
showDelay?: number;
|
|
144
|
+
hideDelay?: number;
|
|
145
|
+
triggerDescribedBy?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
149
|
+
text: '',
|
|
150
|
+
title: undefined,
|
|
151
|
+
description: undefined,
|
|
152
|
+
details: () => [],
|
|
153
|
+
sections: () => [],
|
|
154
|
+
position: 'top',
|
|
155
|
+
disabled: false,
|
|
156
|
+
showDelay: 120,
|
|
157
|
+
hideDelay: 80,
|
|
158
|
+
triggerDescribedBy: undefined,
|
|
159
|
+
});
|
|
160
|
+
defineSlots<{
|
|
161
|
+
/** Ровно один leaf обязан применить trigger binding. / Exactly one leaf must apply the trigger binding. */
|
|
162
|
+
default: (scope: STooltipTriggerSlotProps) => VNodeChild;
|
|
163
|
+
}>();
|
|
164
|
+
|
|
165
|
+
const triggerContractError = (message: string): Error => new Error(`STooltip: ${message}`);
|
|
166
|
+
|
|
167
|
+
const isOpen = ref(false);
|
|
168
|
+
const ownedAttrs = useOwnedAttrs({ component: 'STooltip', owner: 'renderless tooltip contract' });
|
|
169
|
+
const layerZIndex = ref('var(--s-layer-floating)');
|
|
170
|
+
let layerRegistration: LayerRegistration | null = null;
|
|
171
|
+
let hideTimer: number | null = null;
|
|
172
|
+
let showTimer: number | null = null;
|
|
173
|
+
let triggerTarget: ElementRefTarget = null;
|
|
174
|
+
let triggerElement: HTMLElement | null = null;
|
|
175
|
+
let pointerInside = false;
|
|
176
|
+
let focusInside = false;
|
|
177
|
+
let touchActive = false;
|
|
178
|
+
const tooltipId = `s-tooltip-${useId()}`;
|
|
179
|
+
|
|
180
|
+
function validateOptionalText(coordinate: string, value: unknown): string | undefined {
|
|
181
|
+
return value === undefined || value === ''
|
|
182
|
+
? undefined
|
|
183
|
+
: validateNonEmptyString('STooltip', coordinate, value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function assertPlainRecord(coordinate: string, value: unknown): Record<string, unknown> {
|
|
187
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) {
|
|
188
|
+
throw triggerContractError(`${coordinate} должен быть plain object. ${coordinate} must be a plain object.`);
|
|
189
|
+
}
|
|
190
|
+
return value as Record<string, unknown>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assertExactKeys(coordinate: string, record: Record<string, unknown>, keys: readonly string[]): void {
|
|
194
|
+
const unexpected = Object.keys(record).filter((key) => !keys.includes(key));
|
|
195
|
+
if (unexpected.length > 0) {
|
|
196
|
+
throw triggerContractError(`${coordinate} содержит неизвестные поля ${unexpected.join(', ')}. ${coordinate} contains unknown fields ${unexpected.join(', ')}.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function assertDenseArray(coordinate: string, value: unknown): readonly unknown[] {
|
|
201
|
+
if (!Array.isArray(value)) {
|
|
202
|
+
throw triggerContractError(`${coordinate} должен быть массивом. ${coordinate} must be an array.`);
|
|
203
|
+
}
|
|
204
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
205
|
+
if (!Object.hasOwn(value, index)) {
|
|
206
|
+
throw triggerContractError(`${coordinate}[${index}] отсутствует в sparse array. ${coordinate}[${index}] is missing from a sparse array.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function registerUniqueId(ids: Map<string, number>, id: string, coordinate: string, index: number): void {
|
|
213
|
+
const firstIndex = ids.get(id);
|
|
214
|
+
if (firstIndex !== undefined) {
|
|
215
|
+
throw triggerContractError(`${coordinate}[${index}].id дублирует ${JSON.stringify(id)} из ${coordinate}[${firstIndex}].id. ${coordinate}[${index}].id duplicates ${JSON.stringify(id)} from ${coordinate}[${firstIndex}].id.`);
|
|
216
|
+
}
|
|
217
|
+
ids.set(id, index);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validateDetails(value: unknown): readonly TooltipDetail[] {
|
|
221
|
+
const source = assertDenseArray('details', value);
|
|
222
|
+
const ids = new Map<string, number>();
|
|
223
|
+
return source.map((candidate, index): TooltipDetail => {
|
|
224
|
+
const coordinate = `details[${index}]`;
|
|
225
|
+
const record = assertPlainRecord(coordinate, candidate);
|
|
226
|
+
assertExactKeys(coordinate, record, ['id', 'label', 'value', 'presentation', 'tone']);
|
|
227
|
+
const id = validateNonEmptyString('STooltip', `${coordinate}.id`, record.id);
|
|
228
|
+
registerUniqueId(ids, id, 'details', index);
|
|
229
|
+
const detailValue = record.value;
|
|
230
|
+
if (!(typeof detailValue === 'string' && detailValue.length > 0) && !(typeof detailValue === 'number' && Number.isFinite(detailValue))) {
|
|
231
|
+
throw triggerContractError(`${coordinate}.value должен быть непустой строкой или конечным числом. ${coordinate}.value must be a non-empty string or finite number.`);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
id,
|
|
235
|
+
label: validateNonEmptyString('STooltip', `${coordinate}.label`, record.label),
|
|
236
|
+
value: detailValue,
|
|
237
|
+
...(record.presentation === undefined ? {} : {
|
|
238
|
+
presentation: validateExactString('STooltip', `${coordinate}.presentation`, record.presentation, ['text', 'badge', 'code'] as const),
|
|
239
|
+
}),
|
|
240
|
+
...(record.tone === undefined ? {} : {
|
|
241
|
+
tone: validateExactString('STooltip', `${coordinate}.tone`, record.tone, ['neutral', 'info', 'success', 'warn', 'danger'] as const),
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validateSections(value: unknown): readonly TooltipSection[] {
|
|
248
|
+
const source = assertDenseArray('sections', value);
|
|
249
|
+
const ids = new Map<string, number>();
|
|
250
|
+
return source.map((candidate, index): TooltipSection => {
|
|
251
|
+
const coordinate = `sections[${index}]`;
|
|
252
|
+
const record = assertPlainRecord(coordinate, candidate);
|
|
253
|
+
assertExactKeys(coordinate, record, ['id', 'title', 'description', 'items']);
|
|
254
|
+
const id = validateNonEmptyString('STooltip', `${coordinate}.id`, record.id);
|
|
255
|
+
registerUniqueId(ids, id, 'sections', index);
|
|
256
|
+
const items = record.items === undefined ? undefined : (() => {
|
|
257
|
+
const itemSource = assertDenseArray(`${coordinate}.items`, record.items);
|
|
258
|
+
const itemIds = new Map<string, number>();
|
|
259
|
+
return itemSource.map((item, itemIndex): TooltipSectionItem => {
|
|
260
|
+
const itemCoordinate = `${coordinate}.items[${itemIndex}]`;
|
|
261
|
+
const itemRecord = assertPlainRecord(itemCoordinate, item);
|
|
262
|
+
assertExactKeys(itemCoordinate, itemRecord, ['id', 'label']);
|
|
263
|
+
const itemId = validateNonEmptyString('STooltip', `${itemCoordinate}.id`, itemRecord.id);
|
|
264
|
+
registerUniqueId(itemIds, itemId, `${coordinate}.items`, itemIndex);
|
|
265
|
+
return {
|
|
266
|
+
id: itemId,
|
|
267
|
+
label: validateNonEmptyString('STooltip', `${itemCoordinate}.label`, itemRecord.label),
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
})();
|
|
271
|
+
return {
|
|
272
|
+
id,
|
|
273
|
+
title: validateNonEmptyString('STooltip', `${coordinate}.title`, record.title),
|
|
274
|
+
...(record.description === undefined ? {} : {
|
|
275
|
+
description: validateNonEmptyString('STooltip', `${coordinate}.description`, record.description),
|
|
276
|
+
}),
|
|
277
|
+
...(items === undefined ? {} : { items }),
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const resolvedText = computed(() => validateOptionalText('text', props.text));
|
|
283
|
+
const resolvedTitle = computed(() => validateOptionalText('title', props.title));
|
|
284
|
+
const resolvedDescription = computed(() => validateOptionalText('description', props.description));
|
|
285
|
+
const resolvedDetails = computed(() => validateDetails(props.details));
|
|
286
|
+
const resolvedSections = computed(() => validateSections(props.sections));
|
|
287
|
+
const hasContent = computed(() => {
|
|
288
|
+
const text = resolvedText.value;
|
|
289
|
+
const title = resolvedTitle.value;
|
|
290
|
+
const description = resolvedDescription.value;
|
|
291
|
+
const details = resolvedDetails.value;
|
|
292
|
+
const sections = resolvedSections.value;
|
|
293
|
+
return Boolean(text || title || description || details.length > 0 || sections.length > 0);
|
|
294
|
+
});
|
|
295
|
+
const detailSeverityByTone: Record<TooltipDetailTone, BadgeSeverity> = {
|
|
296
|
+
neutral: 'secondary',
|
|
297
|
+
info: 'info',
|
|
298
|
+
success: 'success',
|
|
299
|
+
warn: 'warn',
|
|
300
|
+
danger: 'danger',
|
|
301
|
+
};
|
|
302
|
+
const resolveDetailSeverity = (tone: TooltipDetailTone | undefined): BadgeSeverity =>
|
|
303
|
+
detailSeverityByTone[tone ?? 'neutral'];
|
|
304
|
+
const placement = computed<FloatingPlacement>(() => `${props.position}-center`);
|
|
305
|
+
const {
|
|
306
|
+
anchorRef: triggerRef,
|
|
307
|
+
panelRef: tooltipRef,
|
|
308
|
+
floatingStyle,
|
|
309
|
+
arrowStyle,
|
|
310
|
+
resolvedPlacement,
|
|
311
|
+
} = useFloatingPosition({
|
|
312
|
+
active: isOpen,
|
|
313
|
+
placement: () => placement.value,
|
|
314
|
+
offset: () => 6,
|
|
315
|
+
boundaryPadding: () => 6,
|
|
316
|
+
flip: () => true,
|
|
317
|
+
shift: () => true,
|
|
318
|
+
widthMatch: () => 'none',
|
|
319
|
+
zIndex: () => layerZIndex.value,
|
|
320
|
+
scrollStrategy: 'close',
|
|
321
|
+
requestClose: () => closeImmediately(),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const clearHideTimer = () => {
|
|
325
|
+
if (hideTimer !== null) {
|
|
326
|
+
clearTimeout(hideTimer);
|
|
327
|
+
hideTimer = null;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const clearShowTimer = () => {
|
|
332
|
+
if (showTimer !== null) {
|
|
333
|
+
window.clearTimeout(showTimer);
|
|
334
|
+
showTimer = null;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const closeImmediately = () => {
|
|
339
|
+
clearHideTimer();
|
|
340
|
+
clearShowTimer();
|
|
341
|
+
isOpen.value = false;
|
|
342
|
+
layerRegistration?.unregister({ restoreFocus: false });
|
|
343
|
+
layerRegistration = null;
|
|
344
|
+
layerZIndex.value = 'var(--s-layer-floating)';
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const openImmediately = () => {
|
|
348
|
+
if (isOpen.value || !triggerElement) return;
|
|
349
|
+
layerRegistration = registerLayer({
|
|
350
|
+
kind: 'tooltip',
|
|
351
|
+
root: () => tooltipRef.value,
|
|
352
|
+
anchor: () => triggerRef.value,
|
|
353
|
+
blocksParentFocusTrap: false,
|
|
354
|
+
restoreTarget: null,
|
|
355
|
+
requestDismiss: closeImmediately,
|
|
356
|
+
});
|
|
357
|
+
layerZIndex.value = layerRegistration.zIndex;
|
|
358
|
+
isOpen.value = true;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const scheduleShow = () => {
|
|
362
|
+
if (props.disabled || !hasContent.value || !triggerElement) return;
|
|
363
|
+
clearHideTimer();
|
|
364
|
+
clearShowTimer();
|
|
365
|
+
showTimer = window.setTimeout(() => {
|
|
366
|
+
showTimer = null;
|
|
367
|
+
openImmediately();
|
|
368
|
+
}, props.showDelay);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const scheduleHide = () => {
|
|
372
|
+
clearShowTimer();
|
|
373
|
+
clearHideTimer();
|
|
374
|
+
hideTimer = window.setTimeout(() => {
|
|
375
|
+
hideTimer = null;
|
|
376
|
+
closeImmediately();
|
|
377
|
+
}, props.hideDelay);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const hideWhenInactive = () => {
|
|
381
|
+
if (!pointerInside && !focusInside && !touchActive) scheduleHide();
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const handlePointerEnter = (event: PointerEvent) => {
|
|
385
|
+
if (event.pointerType === 'touch') return;
|
|
386
|
+
pointerInside = true;
|
|
387
|
+
scheduleShow();
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const handlePointerLeave = (event: PointerEvent) => {
|
|
391
|
+
if (event.pointerType === 'touch') return;
|
|
392
|
+
pointerInside = false;
|
|
393
|
+
hideWhenInactive();
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
397
|
+
if (event.pointerType !== 'touch') return;
|
|
398
|
+
touchActive = true;
|
|
399
|
+
scheduleShow();
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const handlePointerEnd = (event: PointerEvent) => {
|
|
403
|
+
if (event.pointerType !== 'touch') return;
|
|
404
|
+
touchActive = false;
|
|
405
|
+
hideWhenInactive();
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const handleFocusIn = () => {
|
|
409
|
+
focusInside = true;
|
|
410
|
+
scheduleShow();
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const handleFocusOut = (event: FocusEvent) => {
|
|
414
|
+
if (event.relatedTarget instanceof Node && triggerElement?.contains(event.relatedTarget)) return;
|
|
415
|
+
focusInside = false;
|
|
416
|
+
hideWhenInactive();
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const commitTriggerElement = (element: HTMLElement | null) => {
|
|
420
|
+
if (element !== null && !isFocusableElement(element)) {
|
|
421
|
+
throw triggerContractError(
|
|
422
|
+
'trigger должен владеть canonical focus owner; используйте интерактивный UI-kit leaf. '
|
|
423
|
+
+ 'The trigger must own a canonical focus owner; use an interactive UI-kit leaf.',
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
triggerElement = element;
|
|
427
|
+
triggerRef.value = element;
|
|
428
|
+
pointerInside = false;
|
|
429
|
+
focusInside = false;
|
|
430
|
+
touchActive = false;
|
|
431
|
+
if (element === null) closeImmediately();
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const bindTriggerElement: VNodeRef = (target) => {
|
|
435
|
+
if (target === triggerTarget) return;
|
|
436
|
+
if (target !== null && triggerTarget !== null) {
|
|
437
|
+
throw triggerContractError(
|
|
438
|
+
'trigger binding применён более чем к одному leaf. '
|
|
439
|
+
+ 'The trigger binding was applied to more than one leaf.',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
triggerTarget = target;
|
|
443
|
+
if (target === null || target instanceof HTMLElement) {
|
|
444
|
+
commitTriggerElement(target);
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const validatedTriggerDescribedBy = computed(() => {
|
|
449
|
+
if (props.triggerDescribedBy === undefined) return undefined;
|
|
450
|
+
if (typeof props.triggerDescribedBy !== 'string') {
|
|
451
|
+
throw triggerContractError(
|
|
452
|
+
'triggerDescribedBy должен быть строкой DOM id references. '
|
|
453
|
+
+ 'triggerDescribedBy must be a string of DOM id references.',
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
const references = props.triggerDescribedBy.trim().split(/\s+/u).filter(Boolean);
|
|
457
|
+
if (references.length === 0) {
|
|
458
|
+
throw triggerContractError(
|
|
459
|
+
'triggerDescribedBy должен содержать хотя бы один DOM id. '
|
|
460
|
+
+ 'triggerDescribedBy must contain at least one DOM id.',
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return references.map((reference, index) => (
|
|
464
|
+
validateDomId('STooltip', `triggerDescribedBy[${index}]`, reference)
|
|
465
|
+
)).join(' ');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const triggerBinding = computed<STooltipTriggerBinding>(() => ({
|
|
469
|
+
ref: bindTriggerElement,
|
|
470
|
+
'aria-describedby': mergeIdReferences(
|
|
471
|
+
validatedTriggerDescribedBy.value,
|
|
472
|
+
isOpen.value ? tooltipId : undefined,
|
|
473
|
+
),
|
|
474
|
+
onPointerenter: handlePointerEnter,
|
|
475
|
+
onPointerleave: handlePointerLeave,
|
|
476
|
+
onPointerdown: handlePointerDown,
|
|
477
|
+
onPointerup: handlePointerEnd,
|
|
478
|
+
onPointercancel: handlePointerEnd,
|
|
479
|
+
onFocusin: handleFocusIn,
|
|
480
|
+
onFocusout: handleFocusOut,
|
|
481
|
+
}));
|
|
482
|
+
|
|
483
|
+
const handleWindowBlur = () => {
|
|
484
|
+
if (isOpen.value) {
|
|
485
|
+
closeImmediately();
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const handleVisibilityChange = () => {
|
|
490
|
+
if (document.hidden && isOpen.value) {
|
|
491
|
+
closeImmediately();
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const bindWindowListeners = () => {
|
|
496
|
+
window.addEventListener('blur', handleWindowBlur);
|
|
497
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const unbindWindowListeners = () => {
|
|
501
|
+
window.removeEventListener('blur', handleWindowBlur);
|
|
502
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
watch(isOpen, (open) => {
|
|
506
|
+
if (open) {
|
|
507
|
+
bindWindowListeners();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
unbindWindowListeners();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
watch(() => [props.disabled, hasContent.value] as const, ([disabled, contentAvailable]) => {
|
|
514
|
+
if (disabled || !contentAvailable) closeImmediately();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
watchEffect(() => {
|
|
518
|
+
ownedAttrs.reject('STooltip не рендерит public wrapper; используйте props и default trigger slot. STooltip renders no public wrapper; use props and the default trigger slot.');
|
|
519
|
+
for (const [name, value] of [['showDelay', props.showDelay], ['hideDelay', props.hideDelay]] as const) {
|
|
520
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 60_000) {
|
|
521
|
+
throw triggerContractError(
|
|
522
|
+
`${name} должен быть целым числом миллисекунд от 0 до 60000. `
|
|
523
|
+
+ `${name} must be an integer number of milliseconds from 0 through 60000.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
validateExactString('STooltip', 'position', props.position, ['top', 'bottom', 'left', 'right'] as const);
|
|
528
|
+
validateBoolean('STooltip', 'disabled', props.disabled);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
onMounted(() => {
|
|
532
|
+
if (triggerTarget === null) {
|
|
533
|
+
throw triggerContractError(
|
|
534
|
+
'default slot обязан применить trigger binding ровно к одному leaf. '
|
|
535
|
+
+ 'The default slot must apply the trigger binding to exactly one leaf.',
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
commitTriggerElement(resolveInteractiveElement('STooltip: trigger', triggerTarget));
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
onBeforeUnmount(() => {
|
|
542
|
+
closeImmediately();
|
|
543
|
+
triggerTarget = null;
|
|
544
|
+
triggerElement = null;
|
|
545
|
+
triggerRef.value = null;
|
|
546
|
+
unbindWindowListeners();
|
|
547
|
+
});
|
|
548
|
+
</script>
|
|
549
|
+
|
|
550
|
+
<style lang="postcss" src="./STooltip.css"></style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
@reference "../../../styles/reference.css";
|
|
2
|
+
|
|
3
|
+
.s-tooltip-anchor {
|
|
4
|
+
@apply relative inline-flex min-w-0 max-w-full align-middle;
|
|
5
|
+
border-radius: var(--radius-xs);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.s-tooltip-anchor[data-width='full'] {
|
|
9
|
+
@apply flex w-full;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.s-tooltip-anchor[data-shape='pill'],
|
|
13
|
+
.s-tooltip-anchor[data-shape='circle'] {
|
|
14
|
+
border-radius: var(--s-radius-pill);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.s-tooltip-anchor:focus-visible {
|
|
18
|
+
z-index: var(--s-layer-focused-control);
|
|
19
|
+
outline: var(--s-focus-ring-width) solid var(--s-focus-ring-color);
|
|
20
|
+
outline-offset: var(--s-focus-ring-offset);
|
|
21
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="rootTag"
|
|
4
|
+
ref="anchorRef"
|
|
5
|
+
v-bind="ownedAttrs.bindings()"
|
|
6
|
+
class="s-tooltip-anchor"
|
|
7
|
+
:data-shape="resolvedShape"
|
|
8
|
+
:data-width="resolvedWidth"
|
|
9
|
+
role="group"
|
|
10
|
+
tabindex="0"
|
|
11
|
+
:aria-label="resolvedAccessibleLabel"
|
|
12
|
+
@focusin="emit('focusin', $event)"
|
|
13
|
+
@focusout="emit('focusout', $event)"
|
|
14
|
+
@pointerenter="emit('pointerenter', $event)"
|
|
15
|
+
@pointerleave="emit('pointerleave', $event)"
|
|
16
|
+
@pointerdown="emit('pointerdown', $event)"
|
|
17
|
+
@pointerup="emit('pointerup', $event)"
|
|
18
|
+
@pointercancel="emit('pointercancel', $event)"
|
|
19
|
+
>
|
|
20
|
+
<slot />
|
|
21
|
+
</component>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
import { computed, ref, type VNodeChild } from 'vue'
|
|
26
|
+
import type { InteractiveElementApi } from '../../../internal/interactiveElement'
|
|
27
|
+
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
28
|
+
import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
|
|
29
|
+
import { validateExactString, validateNonEmptyString } from '../../../internal/runtimeContract'
|
|
30
|
+
|
|
31
|
+
defineOptions({
|
|
32
|
+
name: 'STooltipAnchor',
|
|
33
|
+
inheritAttrs: false,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export type STooltipAnchorShape = 'default' | 'pill' | 'circle'
|
|
37
|
+
export type STooltipAnchorTag = 'span' | 'div'
|
|
38
|
+
export type STooltipAnchorWidth = 'auto' | 'full'
|
|
39
|
+
export type STooltipAnchorElement = HTMLSpanElement | HTMLDivElement
|
|
40
|
+
export type STooltipAnchorApi = InteractiveElementApi<STooltipAnchorElement>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Focus owner для tooltip над пассивным содержимым или недоступным control.
|
|
44
|
+
* Focus owner for a tooltip around passive content or an unavailable control.
|
|
45
|
+
*/
|
|
46
|
+
export interface Props {
|
|
47
|
+
/** Доступное имя самостоятельной focus-точки. / Accessible name of the standalone focus target. */
|
|
48
|
+
accessibleLabel: string
|
|
49
|
+
/** Валидный intrinsic root для inline или flow-content composition. / Valid intrinsic root for inline or flow-content composition. */
|
|
50
|
+
as?: STooltipAnchorTag
|
|
51
|
+
/** Геометрия принадлежащего компоненту focus ring. / Geometry of the component-owned focus ring. */
|
|
52
|
+
shape?: STooltipAnchorShape
|
|
53
|
+
/** Строчный или заполняющий layout-контракт. / Inline or fill layout contract. */
|
|
54
|
+
width?: STooltipAnchorWidth
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
58
|
+
as: 'span',
|
|
59
|
+
shape: 'default',
|
|
60
|
+
width: 'auto',
|
|
61
|
+
})
|
|
62
|
+
const emit = defineEmits<{
|
|
63
|
+
focusin: [event: FocusEvent]
|
|
64
|
+
focusout: [event: FocusEvent]
|
|
65
|
+
pointerenter: [event: PointerEvent]
|
|
66
|
+
pointerleave: [event: PointerEvent]
|
|
67
|
+
pointerdown: [event: PointerEvent]
|
|
68
|
+
pointerup: [event: PointerEvent]
|
|
69
|
+
pointercancel: [event: PointerEvent]
|
|
70
|
+
}>()
|
|
71
|
+
const slots = defineSlots<{
|
|
72
|
+
/** Пассивное содержимое или disabled control без собственной tab-stop. / Passive content or a disabled control without its own tab stop. */
|
|
73
|
+
default: () => VNodeChild
|
|
74
|
+
}>()
|
|
75
|
+
|
|
76
|
+
if (!slots.default) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'STooltipAnchor: default slot обязателен. / STooltipAnchor: the default slot is required.',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const anchorRef = ref<STooltipAnchorElement | null>(null)
|
|
83
|
+
const ownedAttrs = useOwnedAttrs({ component: 'STooltipAnchor', owner: 'focusable tooltip anchor' })
|
|
84
|
+
const resolvedAccessibleLabel = computed(() => (
|
|
85
|
+
validateNonEmptyString('STooltipAnchor', 'accessibleLabel', props.accessibleLabel)
|
|
86
|
+
))
|
|
87
|
+
const resolvedAs = computed(() => (
|
|
88
|
+
validateExactString('STooltipAnchor', 'as', props.as, ['span', 'div'] as const)
|
|
89
|
+
))
|
|
90
|
+
const rootTag = computed(() => resolvedAs.value === 'span' ? 'span' : 'div')
|
|
91
|
+
const resolvedShape = computed(() => (
|
|
92
|
+
validateExactString(
|
|
93
|
+
'STooltipAnchor',
|
|
94
|
+
'shape',
|
|
95
|
+
props.shape,
|
|
96
|
+
['default', 'pill', 'circle'] as const,
|
|
97
|
+
)
|
|
98
|
+
))
|
|
99
|
+
const resolvedWidth = computed(() => (
|
|
100
|
+
validateExactString('STooltipAnchor', 'width', props.width, ['auto', 'full'] as const)
|
|
101
|
+
))
|
|
102
|
+
|
|
103
|
+
useInteractiveLeafRegistration({ owner: 'STooltipAnchor' })
|
|
104
|
+
|
|
105
|
+
const getElement = (): STooltipAnchorElement => {
|
|
106
|
+
const element = anchorRef.value
|
|
107
|
+
if (!element) {
|
|
108
|
+
throw new Error('STooltipAnchor: native anchor element is unavailable')
|
|
109
|
+
}
|
|
110
|
+
return element
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
defineExpose<STooltipAnchorApi>({
|
|
114
|
+
getElement,
|
|
115
|
+
focus: (options?: FocusOptions) => getElement().focus(options),
|
|
116
|
+
})
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<style lang="postcss" src="./STooltipAnchor.css" scoped></style>
|