@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,202 @@
|
|
|
1
|
+
import { isFocusableElement } from './focusNavigation'
|
|
2
|
+
|
|
3
|
+
export type LayerKind = 'dialog' | 'listbox' | 'menu' | 'popover' | 'tooltip'
|
|
4
|
+
export type LayerDismissReason = 'escape' | 'outside-contextmenu' | 'outside-pointer'
|
|
5
|
+
|
|
6
|
+
export interface LayerRegistrationOptions {
|
|
7
|
+
kind: LayerKind
|
|
8
|
+
root: () => HTMLElement | null
|
|
9
|
+
anchor?: () => HTMLElement | null
|
|
10
|
+
branches?: () => readonly HTMLElement[]
|
|
11
|
+
modal?: boolean
|
|
12
|
+
restoreTarget?: HTMLElement | null
|
|
13
|
+
dismissOnEscape?: () => boolean
|
|
14
|
+
dismissOnOutsidePointer?: () => boolean
|
|
15
|
+
blocksParentFocusTrap?: boolean
|
|
16
|
+
trapFocus?: (event: KeyboardEvent) => void
|
|
17
|
+
requestDismiss: (reason: LayerDismissReason, event: Event) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LayerRegistration {
|
|
21
|
+
readonly id: symbol
|
|
22
|
+
readonly zIndex: string
|
|
23
|
+
readonly focusScope: () => ParentNode
|
|
24
|
+
unregister: (options?: { restoreFocus?: boolean }) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface LayerEntry extends LayerRegistrationOptions {
|
|
28
|
+
id: symbol
|
|
29
|
+
parentId: symbol | null
|
|
30
|
+
restoreTarget: HTMLElement | null
|
|
31
|
+
tier: 'floating' | 'overlay'
|
|
32
|
+
zIndex: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const layers: LayerEntry[] = []
|
|
36
|
+
let listenersAttached = false
|
|
37
|
+
let bodyLockCount = 0
|
|
38
|
+
let preservedBodyOverflow = ''
|
|
39
|
+
|
|
40
|
+
const entryContains = (entry: LayerEntry, target: Node): boolean => {
|
|
41
|
+
if (entry.root()?.contains(target) || entry.anchor?.()?.contains(target)) return true
|
|
42
|
+
return entry.branches?.().some((branch) => branch.contains(target)) ?? false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const findParentId = (options: LayerRegistrationOptions): symbol | null => {
|
|
46
|
+
const reference = options.anchor?.()
|
|
47
|
+
?? options.restoreTarget
|
|
48
|
+
?? (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
|
|
49
|
+
? document.activeElement
|
|
50
|
+
: null)
|
|
51
|
+
if (!reference) return null
|
|
52
|
+
return [...layers].reverse().find((entry) => entryContains(entry, reference))?.id ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const topmostLayer = (): LayerEntry | undefined => layers[layers.length - 1]
|
|
56
|
+
|
|
57
|
+
const resolveFocusScope = (entry: LayerEntry): ParentNode => {
|
|
58
|
+
let parentId = entry.parentId
|
|
59
|
+
while (parentId) {
|
|
60
|
+
const parent = layers.find((candidate) => candidate.id === parentId)
|
|
61
|
+
if (!parent) break
|
|
62
|
+
if (parent.modal || parent.trapFocus) {
|
|
63
|
+
return parent.root() ?? document
|
|
64
|
+
}
|
|
65
|
+
parentId = parent.parentId
|
|
66
|
+
}
|
|
67
|
+
return document
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const handleGlobalKeydown = (event: KeyboardEvent): void => {
|
|
71
|
+
const topmost = topmostLayer()
|
|
72
|
+
if (!topmost) return
|
|
73
|
+
|
|
74
|
+
if (event.key === 'Escape') {
|
|
75
|
+
if (topmost.dismissOnEscape?.() ?? true) {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
event.stopPropagation()
|
|
78
|
+
topmost.requestDismiss('escape', event)
|
|
79
|
+
}
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (event.key !== 'Tab') return
|
|
84
|
+
let trapIndex = -1
|
|
85
|
+
for (let index = layers.length - 1; index >= 0; index -= 1) {
|
|
86
|
+
if (layers[index]?.trapFocus) {
|
|
87
|
+
trapIndex = index
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (trapIndex < 0) return
|
|
92
|
+
const blockedByChild = layers
|
|
93
|
+
.slice(trapIndex + 1)
|
|
94
|
+
.some((entry) => entry.blocksParentFocusTrap ?? true)
|
|
95
|
+
if (!blockedByChild) layers[trapIndex]?.trapFocus?.(event)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const handleGlobalOutsideEvent = (event: Event, reason: LayerDismissReason): void => {
|
|
99
|
+
const topmost = topmostLayer()
|
|
100
|
+
if (!topmost) return
|
|
101
|
+
const target = event.target
|
|
102
|
+
if (target instanceof Node && entryContains(topmost, target)) return
|
|
103
|
+
if (topmost.dismissOnOutsidePointer?.() ?? true) topmost.requestDismiss(reason, event)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleGlobalPointerDown = (event: PointerEvent): void => {
|
|
107
|
+
handleGlobalOutsideEvent(event, 'outside-pointer')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const handleGlobalContextMenu = (event: MouseEvent): void => {
|
|
111
|
+
handleGlobalOutsideEvent(event, 'outside-contextmenu')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const attachListeners = (): void => {
|
|
115
|
+
if (listenersAttached || typeof window === 'undefined' || typeof document === 'undefined') return
|
|
116
|
+
listenersAttached = true
|
|
117
|
+
window.addEventListener('keydown', handleGlobalKeydown, true)
|
|
118
|
+
document.addEventListener('pointerdown', handleGlobalPointerDown, true)
|
|
119
|
+
document.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const detachListeners = (): void => {
|
|
123
|
+
if (!listenersAttached || layers.length > 0 || typeof window === 'undefined' || typeof document === 'undefined') return
|
|
124
|
+
listenersAttached = false
|
|
125
|
+
window.removeEventListener('keydown', handleGlobalKeydown, true)
|
|
126
|
+
document.removeEventListener('pointerdown', handleGlobalPointerDown, true)
|
|
127
|
+
document.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const lockBody = (): void => {
|
|
131
|
+
if (typeof document === 'undefined') return
|
|
132
|
+
if (bodyLockCount === 0) {
|
|
133
|
+
preservedBodyOverflow = document.body.style.overflow
|
|
134
|
+
document.body.style.overflow = 'hidden'
|
|
135
|
+
}
|
|
136
|
+
bodyLockCount += 1
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const unlockBody = (): void => {
|
|
140
|
+
if (typeof document === 'undefined') return
|
|
141
|
+
bodyLockCount = Math.max(0, bodyLockCount - 1)
|
|
142
|
+
if (bodyLockCount === 0) document.body.style.overflow = preservedBodyOverflow
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const scheduleFocusRestore = (entry: LayerEntry): void => {
|
|
146
|
+
queueMicrotask(() => {
|
|
147
|
+
if (entry.restoreTarget?.isConnected && isFocusableElement(entry.restoreTarget)) {
|
|
148
|
+
entry.restoreTarget.focus()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
topmostLayer()?.root()?.focus()
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Регистрирует одну открытую overlay-поверхность в общем dismiss/focus/body-lock стеке.
|
|
157
|
+
* Registers one open overlay surface in the shared dismiss/focus/body-lock stack.
|
|
158
|
+
*/
|
|
159
|
+
export function registerLayer(options: LayerRegistrationOptions): LayerRegistration {
|
|
160
|
+
const previousTopmost = topmostLayer()
|
|
161
|
+
const tier = options.modal || previousTopmost?.tier === 'overlay' ? 'overlay' : 'floating'
|
|
162
|
+
const zIndex = previousTopmost && previousTopmost.tier === tier
|
|
163
|
+
? `calc(${previousTopmost.zIndex} + var(--s-layer-overlay-step))`
|
|
164
|
+
: tier === 'overlay'
|
|
165
|
+
? 'var(--s-layer-overlay-backdrop)'
|
|
166
|
+
: 'var(--s-layer-floating)'
|
|
167
|
+
const entry: LayerEntry = {
|
|
168
|
+
...options,
|
|
169
|
+
id: Symbol(options.kind),
|
|
170
|
+
parentId: findParentId(options),
|
|
171
|
+
restoreTarget: options.restoreTarget ?? null,
|
|
172
|
+
tier,
|
|
173
|
+
zIndex,
|
|
174
|
+
}
|
|
175
|
+
let registered = true
|
|
176
|
+
layers.push(entry)
|
|
177
|
+
if (entry.modal) lockBody()
|
|
178
|
+
attachListeners()
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
id: entry.id,
|
|
182
|
+
zIndex: entry.zIndex,
|
|
183
|
+
focusScope: () => resolveFocusScope(entry),
|
|
184
|
+
unregister: ({ restoreFocus = false } = {}) => {
|
|
185
|
+
if (!registered) return
|
|
186
|
+
registered = false
|
|
187
|
+
const index = layers.findIndex((candidate) => candidate.id === entry.id)
|
|
188
|
+
const wasTopmost = index === layers.length - 1
|
|
189
|
+
if (index >= 0) {
|
|
190
|
+
const removedRoot = entry.root()
|
|
191
|
+
for (const layer of layers.slice(index + 1)) {
|
|
192
|
+
if (layer.parentId === entry.id) layer.parentId = entry.parentId
|
|
193
|
+
if (removedRoot?.contains(layer.restoreTarget)) layer.restoreTarget = entry.restoreTarget
|
|
194
|
+
}
|
|
195
|
+
layers.splice(index, 1)
|
|
196
|
+
}
|
|
197
|
+
if (entry.modal) unlockBody()
|
|
198
|
+
detachListeners()
|
|
199
|
+
if (restoreFocus && wasTopmost) scheduleFocusRestore(entry)
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LINK_BROWSING_TARGETS,
|
|
3
|
+
type LinkBrowsingTarget,
|
|
4
|
+
type LinkHistoryStateValue,
|
|
5
|
+
type LinkRouteScalar,
|
|
6
|
+
type LinkRouterTarget,
|
|
7
|
+
} from '../components/shared/controls/SLink'
|
|
8
|
+
|
|
9
|
+
/** Runtime input accepts unknown fields so every public owner validates the actual payload. */
|
|
10
|
+
export interface LinkTargetInput {
|
|
11
|
+
readonly href?: unknown
|
|
12
|
+
readonly to?: unknown
|
|
13
|
+
readonly target?: unknown
|
|
14
|
+
readonly rel?: unknown
|
|
15
|
+
readonly owner: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ResolvedLinkTarget =
|
|
19
|
+
| { readonly kind: 'anchor'; readonly bindings: { readonly href: string; readonly target: LinkBrowsingTarget; readonly rel?: string } }
|
|
20
|
+
| { readonly kind: 'router'; readonly bindings: { readonly to: LinkRouterTarget; readonly target: LinkBrowsingTarget; readonly rel?: string } }
|
|
21
|
+
|
|
22
|
+
const ROUTE_KEYS = new Set(['name', 'path', 'params', 'query', 'hash', 'replace', 'force', 'state'])
|
|
23
|
+
|
|
24
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
26
|
+
const prototype = Object.getPrototypeOf(value)
|
|
27
|
+
return prototype === Object.prototype || prototype === null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasOwn(value: object, key: string): boolean {
|
|
31
|
+
return Object.prototype.hasOwnProperty.call(value, key)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isRouteScalar(value: unknown): value is LinkRouteScalar {
|
|
35
|
+
return value === null
|
|
36
|
+
|| value === undefined
|
|
37
|
+
|| typeof value === 'string'
|
|
38
|
+
|| (typeof value === 'number' && Number.isFinite(value))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function enumerableDataEntries(
|
|
42
|
+
owner: string,
|
|
43
|
+
coordinate: string,
|
|
44
|
+
value: Record<string, unknown>,
|
|
45
|
+
): Array<readonly [string, unknown]> {
|
|
46
|
+
const entries: Array<readonly [string, unknown]> = []
|
|
47
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
48
|
+
if (typeof key !== 'string') {
|
|
49
|
+
throw new TypeError(`${owner}: ${coordinate} не допускает symbol keys / ${coordinate} does not allow symbol keys`)
|
|
50
|
+
}
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
52
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
53
|
+
throw new TypeError(
|
|
54
|
+
`${owner}: ${coordinate}.${key} должен быть enumerable data property `
|
|
55
|
+
+ `/ ${coordinate}.${key} must be an enumerable data property`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
entries.push([key, descriptor.value])
|
|
59
|
+
}
|
|
60
|
+
return entries
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateDenseScalarArray(
|
|
64
|
+
owner: string,
|
|
65
|
+
coordinate: string,
|
|
66
|
+
value: readonly unknown[],
|
|
67
|
+
allowNullish: boolean,
|
|
68
|
+
): boolean {
|
|
69
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
70
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
|
|
71
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) return false
|
|
72
|
+
if (!isRouteScalar(descriptor.value)) return false
|
|
73
|
+
if (!allowNullish && (descriptor.value === null || descriptor.value === undefined)) return false
|
|
74
|
+
}
|
|
75
|
+
return Reflect.ownKeys(value).every((key) => key === 'length' || (
|
|
76
|
+
typeof key === 'string'
|
|
77
|
+
&& /^(0|[1-9]\d*)$/u.test(key)
|
|
78
|
+
&& Number(key) < value.length
|
|
79
|
+
))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateRouteDictionary(
|
|
83
|
+
owner: string,
|
|
84
|
+
coordinate: string,
|
|
85
|
+
value: unknown,
|
|
86
|
+
allowNullishArrayValues: boolean,
|
|
87
|
+
): void {
|
|
88
|
+
if (!isPlainObject(value)) {
|
|
89
|
+
throw new TypeError(`${owner}: ${coordinate} должен быть plain object / ${coordinate} must be a plain object`)
|
|
90
|
+
}
|
|
91
|
+
for (const [key, candidate] of enumerableDataEntries(owner, coordinate, value)) {
|
|
92
|
+
const validArray = Array.isArray(candidate)
|
|
93
|
+
&& validateDenseScalarArray(owner, `${coordinate}.${key}`, candidate, allowNullishArrayValues)
|
|
94
|
+
if (!isRouteScalar(candidate) && !validArray) {
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
`${owner}: ${coordinate}.${key} должен быть scalar или массивом scalar `
|
|
97
|
+
+ `/ ${coordinate}.${key} must be a scalar or scalar array`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateHistoryStateValue(
|
|
104
|
+
owner: string,
|
|
105
|
+
coordinate: string,
|
|
106
|
+
value: unknown,
|
|
107
|
+
ancestors: Set<object>,
|
|
108
|
+
): asserts value is LinkHistoryStateValue {
|
|
109
|
+
if (value === null || value === undefined || ['string', 'number', 'boolean'].includes(typeof value)) return
|
|
110
|
+
if (typeof value !== 'object') {
|
|
111
|
+
throw new TypeError(`${owner}: ${coordinate} содержит неподдерживаемое state value / ${coordinate} contains an unsupported state value`)
|
|
112
|
+
}
|
|
113
|
+
if (ancestors.has(value)) {
|
|
114
|
+
throw new TypeError(`${owner}: ${coordinate} содержит cycle / ${coordinate} contains a cycle`)
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
117
|
+
throw new TypeError(`${owner}: ${coordinate} должен содержать только plain objects/arrays / ${coordinate} must contain only plain objects/arrays`)
|
|
118
|
+
}
|
|
119
|
+
ancestors.add(value)
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
if (!Reflect.ownKeys(value).every((key) => key === 'length' || (
|
|
122
|
+
typeof key === 'string'
|
|
123
|
+
&& /^(0|[1-9]\d*)$/u.test(key)
|
|
124
|
+
&& Number(key) < value.length
|
|
125
|
+
))) {
|
|
126
|
+
throw new TypeError(`${owner}: ${coordinate} не должен быть sparse / ${coordinate} must not be sparse`)
|
|
127
|
+
}
|
|
128
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
129
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
|
|
130
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
131
|
+
throw new TypeError(`${owner}: ${coordinate} не должен быть sparse/accessor / ${coordinate} must not be sparse/accessor`)
|
|
132
|
+
}
|
|
133
|
+
validateHistoryStateValue(owner, `${coordinate}[${index}]`, descriptor.value, ancestors)
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
for (const [key, item] of enumerableDataEntries(owner, coordinate, value)) {
|
|
137
|
+
validateHistoryStateValue(owner, `${coordinate}.${key}`, item, ancestors)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
ancestors.delete(value)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isRouterTarget(owner: string, value: unknown): value is LinkRouterTarget {
|
|
144
|
+
if (typeof value === 'string') {
|
|
145
|
+
if (value.trim().length > 0 && value === value.trim()) return true
|
|
146
|
+
throw new TypeError(
|
|
147
|
+
`${owner}: to должен быть непустой строкой или route object `
|
|
148
|
+
+ '/ to must be a non-empty string or route object',
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (!isPlainObject(value)) {
|
|
152
|
+
throw new TypeError(`${owner}: to должен быть plain route object / to must be a plain route object`)
|
|
153
|
+
}
|
|
154
|
+
const routeEntries = enumerableDataEntries(owner, 'to', value)
|
|
155
|
+
for (const [key] of routeEntries) {
|
|
156
|
+
if (!ROUTE_KEYS.has(key)) {
|
|
157
|
+
throw new TypeError(
|
|
158
|
+
`${owner}: to route object содержит неподдерживаемое поле ${JSON.stringify(key)} `
|
|
159
|
+
+ `/ to route object contains unsupported field ${JSON.stringify(key)}`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const hasName = hasOwn(value, 'name')
|
|
164
|
+
const hasPath = hasOwn(value, 'path')
|
|
165
|
+
if (hasName === hasPath) {
|
|
166
|
+
throw new TypeError(
|
|
167
|
+
`${owner}: to route object должен содержать ровно один explicit name/path `
|
|
168
|
+
+ '/ to route object must contain exactly one explicit name/path',
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
if (hasName && !(
|
|
172
|
+
typeof value.name === 'symbol'
|
|
173
|
+
|| (typeof value.name === 'string' && value.name.trim().length > 0)
|
|
174
|
+
)) {
|
|
175
|
+
throw new TypeError(`${owner}: to.name должен быть непустой строкой или symbol / to.name must be a non-empty string or symbol`)
|
|
176
|
+
}
|
|
177
|
+
if (hasPath && (typeof value.path !== 'string' || value.path.trim().length === 0)) {
|
|
178
|
+
throw new TypeError(`${owner}: to.path должен быть непустой строкой / to.path must be a non-empty string`)
|
|
179
|
+
}
|
|
180
|
+
if (hasPath && value.params !== undefined) {
|
|
181
|
+
throw new TypeError(`${owner}: to.params допустим только для named route / to.params is only supported for a named route`)
|
|
182
|
+
}
|
|
183
|
+
if (value.params !== undefined) validateRouteDictionary(owner, 'to.params', value.params, false)
|
|
184
|
+
if (value.query !== undefined) validateRouteDictionary(owner, 'to.query', value.query, true)
|
|
185
|
+
if (value.hash !== undefined && typeof value.hash !== 'string') {
|
|
186
|
+
throw new TypeError(`${owner}: to.hash должен быть строкой / to.hash must be a string`)
|
|
187
|
+
}
|
|
188
|
+
for (const key of ['replace', 'force'] as const) {
|
|
189
|
+
if (value[key] !== undefined && typeof value[key] !== 'boolean') {
|
|
190
|
+
throw new TypeError(`${owner}: to.${key} должен быть boolean / to.${key} must be a boolean`)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (value.state !== undefined) {
|
|
194
|
+
if (!isPlainObject(value.state)) {
|
|
195
|
+
throw new TypeError(`${owner}: to.state должен быть plain object / to.state must be a plain object`)
|
|
196
|
+
}
|
|
197
|
+
validateHistoryStateValue(owner, 'to.state', value.state, new Set<object>())
|
|
198
|
+
}
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isBrowsingTarget(value: unknown): value is LinkBrowsingTarget {
|
|
203
|
+
for (const target of LINK_BROWSING_TARGETS) {
|
|
204
|
+
if (value === target) return true
|
|
205
|
+
}
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveBrowsingTarget(owner: string, value: unknown): LinkBrowsingTarget {
|
|
210
|
+
if (value === undefined) return '_self'
|
|
211
|
+
if (isBrowsingTarget(value)) return value
|
|
212
|
+
throw new TypeError(
|
|
213
|
+
`${owner}: target должен быть одним из ${LINK_BROWSING_TARGETS.join(', ')} `
|
|
214
|
+
+ `/ target must be one of ${LINK_BROWSING_TARGETS.join(', ')}`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolveRel(owner: string, value: unknown, target: LinkBrowsingTarget): string | undefined {
|
|
219
|
+
if (value === undefined) return target === '_blank' ? 'noopener noreferrer' : undefined
|
|
220
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
221
|
+
throw new TypeError(`${owner}: rel должен быть непустой строкой / rel must be a non-empty string`)
|
|
222
|
+
}
|
|
223
|
+
if (target !== '_blank') return value
|
|
224
|
+
|
|
225
|
+
const tokens = value.trim().split(/\s+/u)
|
|
226
|
+
const comparableTokens = new Set(tokens.map((token) => token.toLowerCase()))
|
|
227
|
+
if (!comparableTokens.has('noopener')) tokens.push('noopener')
|
|
228
|
+
if (!comparableTokens.has('noreferrer')) tokens.push('noreferrer')
|
|
229
|
+
return tokens.join(' ')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Validates the sole link-target boundary and secures new browsing contexts. */
|
|
233
|
+
export function resolveLinkTarget(input: LinkTargetInput): ResolvedLinkTarget {
|
|
234
|
+
const hasHref = input.href !== undefined
|
|
235
|
+
const hasTo = input.to !== undefined
|
|
236
|
+
if (hasHref === hasTo) {
|
|
237
|
+
throw new TypeError(`${input.owner}: передайте ровно один target — href или to`)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const target = resolveBrowsingTarget(input.owner, input.target)
|
|
241
|
+
const rel = resolveRel(input.owner, input.rel, target)
|
|
242
|
+
if (hasHref) {
|
|
243
|
+
if (
|
|
244
|
+
typeof input.href !== 'string'
|
|
245
|
+
|| input.href.trim().length === 0
|
|
246
|
+
|| input.href !== input.href.trim()
|
|
247
|
+
) {
|
|
248
|
+
throw new TypeError(`${input.owner}: href должен быть непустой строкой / href must be a non-empty string`)
|
|
249
|
+
}
|
|
250
|
+
return { kind: 'anchor', bindings: { href: input.href, target, ...(rel === undefined ? {} : { rel }) } }
|
|
251
|
+
}
|
|
252
|
+
if (isRouterTarget(input.owner, input.to)) {
|
|
253
|
+
return { kind: 'router', bindings: { to: input.to, target, ...(rel === undefined ? {} : { rel }) } }
|
|
254
|
+
}
|
|
255
|
+
throw new TypeError(`${input.owner}: недостижимый router target / unreachable router target`)
|
|
256
|
+
}
|