@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,333 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="surfaceRef"
|
|
4
|
+
v-bind="ownedAttrs.bindings()"
|
|
5
|
+
class="s-menu-surface"
|
|
6
|
+
:data-density="density"
|
|
7
|
+
:data-orientation="orientation"
|
|
8
|
+
:data-menu-depth="depth"
|
|
9
|
+
role="menu"
|
|
10
|
+
:aria-label="label"
|
|
11
|
+
:aria-orientation="orientation"
|
|
12
|
+
@keydown="handleKeydown"
|
|
13
|
+
@focusout="handleFocusout"
|
|
14
|
+
>
|
|
15
|
+
<template v-for="item in items" :key="item.id">
|
|
16
|
+
<SMenuSeparator v-if="item.kind === 'separator'" :entry-id="item.id" />
|
|
17
|
+
<SMenuItem
|
|
18
|
+
v-else
|
|
19
|
+
:entry="item"
|
|
20
|
+
:submenu-open="activeSubmenu?.id === item.id"
|
|
21
|
+
@select="selectItem(item, $event)"
|
|
22
|
+
@open-submenu="openSubmenu(item.id, true)"
|
|
23
|
+
@pointer-intent="handlePointerIntent(item)"
|
|
24
|
+
/>
|
|
25
|
+
</template>
|
|
26
|
+
<teleport to="body">
|
|
27
|
+
<SMenuLayer
|
|
28
|
+
v-if="activeSubmenu"
|
|
29
|
+
ref="submenuLayerRef"
|
|
30
|
+
kind="submenu"
|
|
31
|
+
:landmark-label="`${label}: ${activeSubmenu.label}`"
|
|
32
|
+
:position="submenuPosition"
|
|
33
|
+
:z-index="submenuZIndex"
|
|
34
|
+
>
|
|
35
|
+
<SMenuSurface
|
|
36
|
+
ref="submenuSurfaceRef"
|
|
37
|
+
:label="`${label}: ${activeSubmenu.label}`"
|
|
38
|
+
:items="activeSubmenu.children"
|
|
39
|
+
:density="density"
|
|
40
|
+
orientation="vertical"
|
|
41
|
+
:autofocus="submenuAutofocus"
|
|
42
|
+
@escape="closeSubmenu('escape')"
|
|
43
|
+
@submenu-back="closeSubmenu('escape')"
|
|
44
|
+
@dismiss-tree="emit('dismiss-tree')"
|
|
45
|
+
@select="forwardSelection"
|
|
46
|
+
@tab="emit('tab', $event)"
|
|
47
|
+
/>
|
|
48
|
+
</SMenuLayer>
|
|
49
|
+
</teleport>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { computed, inject, nextTick, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue'
|
|
55
|
+
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
56
|
+
import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
|
|
57
|
+
import { useMenuLayer } from '../../../internal/useMenuLayer'
|
|
58
|
+
import { menuContextKey, menuDepthKey } from '../_internal/menuContext'
|
|
59
|
+
import type { MenuDensity, MenuItemRegistration } from '../_internal/menuContext'
|
|
60
|
+
import SMenuItem from '../_internal/SMenuItem.vue'
|
|
61
|
+
import SMenuLayer from '../_internal/SMenuLayer.vue'
|
|
62
|
+
import type { SMenuLayerApi } from '../_internal/SMenuLayer.vue'
|
|
63
|
+
import SMenuSeparator from '../_internal/SMenuSeparator.vue'
|
|
64
|
+
import {
|
|
65
|
+
validateSMenuEntries,
|
|
66
|
+
type SMenuActivationEvent,
|
|
67
|
+
type SMenuEntry,
|
|
68
|
+
type SMenuInteractiveEntry,
|
|
69
|
+
type SMenuSelectableEntry,
|
|
70
|
+
type SMenuSubmenu,
|
|
71
|
+
} from './menu'
|
|
72
|
+
|
|
73
|
+
export type SMenuDensity = MenuDensity
|
|
74
|
+
|
|
75
|
+
defineOptions({ inheritAttrs: false })
|
|
76
|
+
export interface Props {
|
|
77
|
+
label: string
|
|
78
|
+
items: readonly SMenuEntry[]
|
|
79
|
+
density?: SMenuDensity
|
|
80
|
+
orientation?: 'vertical' | 'horizontal'
|
|
81
|
+
autofocus?: boolean
|
|
82
|
+
initialFocus?: 'first' | 'last'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Типизированная focus-поверхность для menu owners без DOM queries. / Typed focus surface for menu owners without DOM queries. */
|
|
86
|
+
export interface SMenuSurfaceApi {
|
|
87
|
+
focusFirst: () => void
|
|
88
|
+
focusLast: () => void
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
92
|
+
density: 'comfortable',
|
|
93
|
+
orientation: 'vertical',
|
|
94
|
+
autofocus: false,
|
|
95
|
+
initialFocus: 'first',
|
|
96
|
+
})
|
|
97
|
+
const emit = defineEmits<{
|
|
98
|
+
escape: []
|
|
99
|
+
'focus-exit': [event: FocusEvent]
|
|
100
|
+
select: [item: SMenuSelectableEntry, event: SMenuActivationEvent]
|
|
101
|
+
tab: [event: KeyboardEvent]
|
|
102
|
+
'submenu-back': []
|
|
103
|
+
'dismiss-tree': []
|
|
104
|
+
}>()
|
|
105
|
+
const ownedAttrs = useOwnedAttrs({ component: 'SMenuSurface', owner: 'menu root' })
|
|
106
|
+
useInteractiveLeafRegistration({ owner: 'SMenuSurface' })
|
|
107
|
+
|
|
108
|
+
const parentDepth = inject(menuDepthKey, -1)
|
|
109
|
+
const depth = parentDepth + 1
|
|
110
|
+
provide(menuDepthKey, depth)
|
|
111
|
+
const surfaceRef = ref<HTMLElement | null>(null)
|
|
112
|
+
const registrations = new Map<string, MenuItemRegistration>()
|
|
113
|
+
provide(menuContextKey, {
|
|
114
|
+
density: toRef(props, 'density'),
|
|
115
|
+
register: (registration) => {
|
|
116
|
+
if (registrations.has(registration.entryId)) {
|
|
117
|
+
throw new Error(`SMenuSurface: duplicate mounted entry id '${registration.entryId}'`)
|
|
118
|
+
}
|
|
119
|
+
registrations.set(registration.entryId, registration)
|
|
120
|
+
return () => registrations.delete(registration.entryId)
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const activeSubmenuId = ref<string | null>(null)
|
|
125
|
+
const submenuAutofocus = ref(true)
|
|
126
|
+
const submenuLayerRef = ref<SMenuLayerApi | null>(null)
|
|
127
|
+
const submenuSurfaceRef = ref<SMenuSurfaceApi | null>(null)
|
|
128
|
+
const submenuLayerElement = computed(() => submenuLayerRef.value?.getElement() ?? null)
|
|
129
|
+
const {
|
|
130
|
+
position: submenuPosition,
|
|
131
|
+
zIndex: submenuZIndex,
|
|
132
|
+
open: openSubmenuLayer,
|
|
133
|
+
close: closeSubmenuLayer,
|
|
134
|
+
} = useMenuLayer(submenuLayerElement, {
|
|
135
|
+
onDismiss: (reason) => {
|
|
136
|
+
activeSubmenuId.value = null
|
|
137
|
+
if (reason === 'outside') emit('dismiss-tree')
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
const activeSubmenu = computed<SMenuSubmenu | null>(() => {
|
|
141
|
+
const item = props.items.find((entry) => entry.id === activeSubmenuId.value)
|
|
142
|
+
return item?.kind === 'submenu' ? item : null
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
let typeaheadBuffer = ''
|
|
146
|
+
let typeaheadTimer: ReturnType<typeof setTimeout> | null = null
|
|
147
|
+
|
|
148
|
+
const orderedRegistrations = (): MenuItemRegistration[] => [...registrations.values()]
|
|
149
|
+
.filter((item) => item.element().isConnected)
|
|
150
|
+
.sort((left, right) => {
|
|
151
|
+
const relation = left.element().compareDocumentPosition(right.element())
|
|
152
|
+
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
|
|
153
|
+
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
|
|
154
|
+
return 0
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const focusItem = (items: MenuItemRegistration[], index: number): void => {
|
|
158
|
+
for (const item of items) item.setTabStop(false)
|
|
159
|
+
const item = items[index]
|
|
160
|
+
if (!item) return
|
|
161
|
+
item.setTabStop(true)
|
|
162
|
+
item.focus()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const focusFirst = (): void => {
|
|
166
|
+
const items = orderedRegistrations()
|
|
167
|
+
if (items.length > 0) focusItem(items, 0)
|
|
168
|
+
}
|
|
169
|
+
const focusLast = (): void => {
|
|
170
|
+
const items = orderedRegistrations()
|
|
171
|
+
if (items.length > 0) focusItem(items, items.length - 1)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const validateContract = (): void => {
|
|
175
|
+
if (typeof props.label !== 'string' || !props.label.trim()) {
|
|
176
|
+
throw new TypeError('SMenuSurface: label must be a non-empty string')
|
|
177
|
+
}
|
|
178
|
+
validateSMenuEntries(props.items)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const moveFocus = (direction: 1 | -1): void => {
|
|
182
|
+
const items = orderedRegistrations()
|
|
183
|
+
if (items.length === 0) return
|
|
184
|
+
const currentIndex = items.findIndex((item) => item.isFocused())
|
|
185
|
+
const origin = currentIndex < 0 ? (direction === 1 ? -1 : 0) : currentIndex
|
|
186
|
+
focusItem(items, (origin + direction + items.length) % items.length)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const focusByTypeahead = (key: string): void => {
|
|
190
|
+
const normalizedKey = key.toLocaleLowerCase()
|
|
191
|
+
typeaheadBuffer += normalizedKey
|
|
192
|
+
if (typeaheadTimer) clearTimeout(typeaheadTimer)
|
|
193
|
+
typeaheadTimer = setTimeout(() => {
|
|
194
|
+
typeaheadBuffer = ''
|
|
195
|
+
typeaheadTimer = null
|
|
196
|
+
}, 500)
|
|
197
|
+
|
|
198
|
+
const search = [...typeaheadBuffer].every((character) => character === normalizedKey)
|
|
199
|
+
? normalizedKey
|
|
200
|
+
: typeaheadBuffer
|
|
201
|
+
const items = orderedRegistrations()
|
|
202
|
+
if (items.length === 0) return
|
|
203
|
+
const currentIndex = items.findIndex((item) => item.isFocused())
|
|
204
|
+
const ordered = [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)]
|
|
205
|
+
const match = ordered.find((item) => item.label().trim().toLocaleLowerCase().startsWith(search))
|
|
206
|
+
if (match) focusItem(items, items.indexOf(match))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function openSubmenu(entryId: string, autofocus: boolean): Promise<void> {
|
|
210
|
+
const entry = props.items.find((candidate) => candidate.id === entryId)
|
|
211
|
+
const registration = registrations.get(entryId)
|
|
212
|
+
if (entry?.kind !== 'submenu' || entry.disabled || !registration) return
|
|
213
|
+
if (activeSubmenuId.value === entryId) {
|
|
214
|
+
if (autofocus) await nextTick(() => submenuSurfaceRef.value?.focusFirst())
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
if (activeSubmenuId.value) closeSubmenuLayer('outside')
|
|
218
|
+
submenuAutofocus.value = autofocus
|
|
219
|
+
activeSubmenuId.value = entryId
|
|
220
|
+
await openSubmenuLayer(
|
|
221
|
+
{ type: 'element', element: registration.element(), align: 'left', placement: 'inline' },
|
|
222
|
+
registration.element(),
|
|
223
|
+
)
|
|
224
|
+
if (autofocus) await nextTick(() => submenuSurfaceRef.value?.focusFirst())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function closeSubmenu(reason: 'escape' | 'outside'): void {
|
|
228
|
+
if (!activeSubmenuId.value) return
|
|
229
|
+
closeSubmenuLayer(reason)
|
|
230
|
+
activeSubmenuId.value = null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handlePointerIntent(item: SMenuInteractiveEntry): void {
|
|
234
|
+
if (item.kind === 'submenu' && !item.disabled) {
|
|
235
|
+
void openSubmenu(item.id, false)
|
|
236
|
+
} else if (activeSubmenuId.value) closeSubmenu('outside')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function selectItem(item: SMenuInteractiveEntry, event: SMenuActivationEvent): void {
|
|
240
|
+
if (item.kind === 'submenu' || item.disabled) return
|
|
241
|
+
emit('select', item, event)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function forwardSelection(item: SMenuSelectableEntry, event: SMenuActivationEvent): void {
|
|
245
|
+
emit('select', item, event)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function focusedRegistration(): MenuItemRegistration | undefined {
|
|
249
|
+
return orderedRegistrations().find((item) => item.isFocused())
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const handleKeydown = (event: KeyboardEvent): void => {
|
|
253
|
+
const eventTarget = event.target
|
|
254
|
+
if (
|
|
255
|
+
event.currentTarget !== eventTarget
|
|
256
|
+
&& !orderedRegistrations().some((registration) => registration.ownsEventTarget(eventTarget))
|
|
257
|
+
) return
|
|
258
|
+
if (event.key === 'Tab') {
|
|
259
|
+
emit('tab', event)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (event.key === 'Escape') {
|
|
263
|
+
event.preventDefault()
|
|
264
|
+
emit('escape')
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
const nextKey = props.orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
|
|
268
|
+
const previousKey = props.orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
|
|
269
|
+
if (props.orientation === 'vertical' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {
|
|
270
|
+
const current = focusedRegistration()
|
|
271
|
+
event.preventDefault()
|
|
272
|
+
if (event.key === 'ArrowRight' && current?.hasSubmenu()) void openSubmenu(current.entryId, true)
|
|
273
|
+
else if (event.key === 'ArrowLeft' && depth > 0) emit('submenu-back')
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
if (event.key === nextKey || event.key === previousKey) {
|
|
277
|
+
event.preventDefault()
|
|
278
|
+
moveFocus(event.key === nextKey ? 1 : -1)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (props.orientation === 'horizontal' && event.key === 'ArrowDown') {
|
|
282
|
+
const current = focusedRegistration()
|
|
283
|
+
if (current?.hasSubmenu()) {
|
|
284
|
+
event.preventDefault()
|
|
285
|
+
void openSubmenu(current.entryId, true)
|
|
286
|
+
}
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
290
|
+
event.preventDefault()
|
|
291
|
+
if (event.key === 'Home') focusFirst()
|
|
292
|
+
else focusLast()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
296
|
+
const current = focusedRegistration()
|
|
297
|
+
if (current) {
|
|
298
|
+
current.activate(event)
|
|
299
|
+
}
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
303
|
+
focusByTypeahead(event.key)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const handleFocusout = (event: FocusEvent): void => {
|
|
308
|
+
const nextTarget = event.relatedTarget
|
|
309
|
+
if (nextTarget instanceof Node && (
|
|
310
|
+
surfaceRef.value?.contains(nextTarget) || submenuLayerElement.value?.contains(nextTarget)
|
|
311
|
+
)) return
|
|
312
|
+
emit('focus-exit', event)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
onMounted(() => {
|
|
316
|
+
if (props.autofocus) nextTick(props.initialFocus === 'last' ? focusLast : focusFirst)
|
|
317
|
+
})
|
|
318
|
+
watch(
|
|
319
|
+
() => [props.label, props.items] as const,
|
|
320
|
+
() => {
|
|
321
|
+
validateContract()
|
|
322
|
+
if (activeSubmenuId.value && !activeSubmenu.value) closeSubmenu('outside')
|
|
323
|
+
},
|
|
324
|
+
{ immediate: true, deep: true },
|
|
325
|
+
)
|
|
326
|
+
onBeforeUnmount(() => {
|
|
327
|
+
if (typeaheadTimer) clearTimeout(typeaheadTimer)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
defineExpose<SMenuSurfaceApi>({ focusFirst, focusLast })
|
|
331
|
+
</script>
|
|
332
|
+
|
|
333
|
+
<style lang="postcss" src="./SMenuSurface.css" scoped></style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
@reference "../../../styles/reference.css";
|
|
2
|
+
|
|
3
|
+
.s-segmented-control__group {
|
|
4
|
+
@apply inline-grid min-w-0 grid-flow-col auto-cols-fr rounded-md border border-surface-300 bg-surface-100 p-0.5 shadow-sm dark:border-surface-700 dark:bg-surface-900;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.s-segmented-control__item {
|
|
8
|
+
@apply min-w-0 rounded-md border border-transparent bg-transparent px-5 py-2.5 text-sm font-semibold text-surface-600 hover:bg-surface-200 disabled:cursor-not-allowed disabled:opacity-50 dark:text-surface-300 dark:hover:bg-surface-800;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.s-segmented-control__item:focus-visible {
|
|
12
|
+
outline: var(--s-focus-ring-width) solid var(--s-focus-ring-color);
|
|
13
|
+
outline-offset: var(--s-focus-ring-offset);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.s-segmented-control__item[data-size='sm'] {
|
|
17
|
+
@apply px-2.5 py-1.5 text-xs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.s-segmented-control__item[aria-checked='true'] {
|
|
21
|
+
@apply bg-primary-50 text-primary-700 dark:bg-primary-950/40 dark:text-primary-300;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.s-segmented-control__group[data-invalid='true'] {
|
|
25
|
+
@apply border-danger-500 dark:border-danger-400;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.s-segmented-control__group[data-readonly='true'] .s-segmented-control__item {
|
|
29
|
+
@apply cursor-default hover:bg-transparent dark:hover:bg-transparent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.s-segmented-control__group[data-readonly='true'] .s-segmented-control__item[aria-checked='true'] {
|
|
33
|
+
@apply hover:bg-primary-50 dark:hover:bg-primary-950/40;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.s-segmented-control__group[data-option-wrap='nowrap'] .s-segmented-control__item {
|
|
37
|
+
white-space: nowrap;
|
|
38
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<SFieldGroup
|
|
3
|
+
v-bind="ownedAttrs.bindings()"
|
|
4
|
+
:id="id"
|
|
5
|
+
:label="label"
|
|
6
|
+
:help-text="helpText"
|
|
7
|
+
:error-message="errorMessage"
|
|
8
|
+
:required="required"
|
|
9
|
+
:invalid="invalid"
|
|
10
|
+
:disabled="disabled"
|
|
11
|
+
:readonly="readonly"
|
|
12
|
+
>
|
|
13
|
+
<template #default="group">
|
|
14
|
+
<div
|
|
15
|
+
class="s-segmented-control__group"
|
|
16
|
+
:data-size="size"
|
|
17
|
+
:data-option-wrap="optionWrap"
|
|
18
|
+
:data-invalid="group.invalid || undefined"
|
|
19
|
+
:data-readonly="group.readonly || undefined"
|
|
20
|
+
role="radiogroup"
|
|
21
|
+
:aria-labelledby="group.labelId"
|
|
22
|
+
:aria-describedby="group.describedBy"
|
|
23
|
+
:aria-invalid="group.invalid || undefined"
|
|
24
|
+
:aria-required="group.required || undefined"
|
|
25
|
+
:aria-readonly="group.readonly || undefined"
|
|
26
|
+
>
|
|
27
|
+
<button
|
|
28
|
+
v-for="option in options"
|
|
29
|
+
:key="optionToken(option)"
|
|
30
|
+
:ref="(element) => roving.register(option.value, resolveNativeElement(element))"
|
|
31
|
+
class="s-segmented-control__item"
|
|
32
|
+
:data-size="size"
|
|
33
|
+
type="button"
|
|
34
|
+
:aria-checked="option.value === modelValue"
|
|
35
|
+
:disabled="group.disabled || option.disabled"
|
|
36
|
+
role="radio"
|
|
37
|
+
:tabindex="option.value === focusableValue ? 0 : -1"
|
|
38
|
+
:title="option.title"
|
|
39
|
+
@click="selectOption(option)"
|
|
40
|
+
@keydown="roving.handleKeydown($event, option.value)"
|
|
41
|
+
>{{ option.label }}</button>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
</SFieldGroup>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script lang="ts">
|
|
48
|
+
import type { SelectionKey as PublicSelectionKey } from '../_internal/useSelectionRoving'
|
|
49
|
+
|
|
50
|
+
/** Одна option сегментированного выбора. / One segmented selection option. */
|
|
51
|
+
export type SegmentedControlOption<T extends PublicSelectionKey = string> = {
|
|
52
|
+
value: T
|
|
53
|
+
label: string
|
|
54
|
+
title?: string
|
|
55
|
+
disabled?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Публичный field/radiogroup-контракт сегментированного выбора. / Public segmented field/radiogroup contract. */
|
|
59
|
+
export interface Props<T extends PublicSelectionKey = string> {
|
|
60
|
+
modelValue: T
|
|
61
|
+
options: readonly SegmentedControlOption<T>[]
|
|
62
|
+
label: string
|
|
63
|
+
id?: string
|
|
64
|
+
helpText?: string
|
|
65
|
+
errorMessage?: string
|
|
66
|
+
required?: boolean
|
|
67
|
+
invalid?: boolean
|
|
68
|
+
disabled?: boolean
|
|
69
|
+
readonly?: boolean
|
|
70
|
+
size?: 'sm' | 'md'
|
|
71
|
+
optionWrap?: 'wrap' | 'nowrap'
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts" generic="T extends SelectionKey = string">
|
|
76
|
+
import { computed } from 'vue'
|
|
77
|
+
import type { ComponentPublicInstance } from 'vue'
|
|
78
|
+
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
79
|
+
import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
|
|
80
|
+
import { selectionKeyToken, useSelectionRoving } from '../_internal/useSelectionRoving'
|
|
81
|
+
import type { SelectionKey } from '../_internal/useSelectionRoving'
|
|
82
|
+
import SFieldGroup from './SFieldGroup.vue'
|
|
83
|
+
|
|
84
|
+
defineOptions({ inheritAttrs: false })
|
|
85
|
+
|
|
86
|
+
const props = withDefaults(defineProps<Props<T>>(), {
|
|
87
|
+
id: undefined,
|
|
88
|
+
helpText: undefined,
|
|
89
|
+
errorMessage: undefined,
|
|
90
|
+
required: false,
|
|
91
|
+
invalid: false,
|
|
92
|
+
disabled: false,
|
|
93
|
+
readonly: false,
|
|
94
|
+
size: 'md',
|
|
95
|
+
optionWrap: 'wrap',
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const emit = defineEmits<{
|
|
99
|
+
'update:modelValue': [value: T]
|
|
100
|
+
}>()
|
|
101
|
+
const ownedAttrs = useOwnedAttrs({ component: 'SSegmentedControl', owner: 'field group root' })
|
|
102
|
+
useInteractiveLeafRegistration({ owner: 'SSegmentedControl' })
|
|
103
|
+
|
|
104
|
+
function selectOption(option: SegmentedControlOption<T>): void {
|
|
105
|
+
if (props.disabled || props.readonly || option.disabled) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
emit('update:modelValue', option.value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const rovingOptions = computed(() => props.options.map((option) => ({
|
|
112
|
+
value: option.value,
|
|
113
|
+
disabled: props.disabled || option.disabled,
|
|
114
|
+
})))
|
|
115
|
+
const roving = useSelectionRoving({
|
|
116
|
+
options: rovingOptions,
|
|
117
|
+
selectedValue: computed(() => props.modelValue),
|
|
118
|
+
select: (value) => {
|
|
119
|
+
if (!props.disabled && !props.readonly) emit('update:modelValue', value)
|
|
120
|
+
},
|
|
121
|
+
component: 'SSegmentedControl',
|
|
122
|
+
})
|
|
123
|
+
const focusableValue = roving.focusableValue
|
|
124
|
+
const optionToken = (option: SegmentedControlOption<T>): string => selectionKeyToken(
|
|
125
|
+
'SSegmentedControl',
|
|
126
|
+
option.value,
|
|
127
|
+
'option.value',
|
|
128
|
+
)
|
|
129
|
+
const resolveNativeElement = (element: Element | ComponentPublicInstance | null): HTMLElement | null => (
|
|
130
|
+
element instanceof HTMLElement ? element : null
|
|
131
|
+
)
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<style lang="postcss" src="./SSegmentedControl.css" scoped></style>
|