@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,194 @@
|
|
|
1
|
+
export interface BoundedNumberDomain {
|
|
2
|
+
minimum: number
|
|
3
|
+
maximum: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const ASCII_WHITESPACE_PATTERN = /[\t\n\f\r ]/u
|
|
7
|
+
|
|
8
|
+
function describeRuntimeValue(value: unknown): string {
|
|
9
|
+
if (typeof value === 'string') return JSON.stringify(value)
|
|
10
|
+
if (typeof value === 'number') return `number(${String(value)})`
|
|
11
|
+
if (typeof value === 'bigint') return `bigint(${String(value)})`
|
|
12
|
+
if (typeof value === 'boolean') return `boolean(${String(value)})`
|
|
13
|
+
if (typeof value === 'undefined') return 'undefined'
|
|
14
|
+
if (typeof value === 'symbol') return `symbol(${value.description ?? ''})`
|
|
15
|
+
if (typeof value === 'function') return 'function'
|
|
16
|
+
if (value === null) return 'null'
|
|
17
|
+
return Array.isArray(value) ? 'array' : 'object'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertBoundedDomain(
|
|
21
|
+
owner: string,
|
|
22
|
+
coordinate: string,
|
|
23
|
+
domain: BoundedNumberDomain,
|
|
24
|
+
integer: boolean,
|
|
25
|
+
): void {
|
|
26
|
+
const validNumbers = Number.isFinite(domain.minimum) && Number.isFinite(domain.maximum)
|
|
27
|
+
const validIntegerBounds = !integer
|
|
28
|
+
|| (Number.isInteger(domain.minimum) && Number.isInteger(domain.maximum))
|
|
29
|
+
if (validNumbers && validIntegerBounds && domain.minimum <= domain.maximum) return
|
|
30
|
+
|
|
31
|
+
const russianKind = integer ? 'целыми числами' : 'конечными числами'
|
|
32
|
+
const englishKind = integer ? 'integers' : 'finite numbers'
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
`runtimeContract: границы domain для ${owner}.${coordinate} должны быть ${russianKind}, `
|
|
35
|
+
+ `где minimum <= maximum; получено minimum=${describeRuntimeValue(domain.minimum)}, `
|
|
36
|
+
+ `maximum=${describeRuntimeValue(domain.maximum)}. / runtimeContract: domain bounds for `
|
|
37
|
+
+ `${owner}.${coordinate} must be ${englishKind} with minimum <= maximum; received `
|
|
38
|
+
+ `minimum=${describeRuntimeValue(domain.minimum)}, maximum=${describeRuntimeValue(domain.maximum)}.`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertExactStringDomain(
|
|
43
|
+
owner: string,
|
|
44
|
+
coordinate: string,
|
|
45
|
+
allowed: readonly string[],
|
|
46
|
+
): void {
|
|
47
|
+
const uniqueValues = new Set(allowed)
|
|
48
|
+
if (
|
|
49
|
+
allowed.length > 0
|
|
50
|
+
&& uniqueValues.size === allowed.length
|
|
51
|
+
&& allowed.every((value) => value.trim().length > 0)
|
|
52
|
+
) return
|
|
53
|
+
|
|
54
|
+
throw new TypeError(
|
|
55
|
+
`runtimeContract: domain для ${owner}.${coordinate} должен содержать непустой уникальный список строк. `
|
|
56
|
+
+ `/ runtimeContract: domain for ${owner}.${coordinate} must contain a non-empty unique string list.`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Проверяет непустую строку без trim/coercion и возвращает исходное значение.
|
|
62
|
+
* Validates a non-empty string without trimming/coercion and returns the original value.
|
|
63
|
+
*/
|
|
64
|
+
export function validateNonEmptyString(
|
|
65
|
+
owner: string,
|
|
66
|
+
coordinate: string,
|
|
67
|
+
value: unknown,
|
|
68
|
+
): string {
|
|
69
|
+
if (typeof value === 'string' && value.trim().length > 0) return value
|
|
70
|
+
const received = describeRuntimeValue(value)
|
|
71
|
+
throw new TypeError(
|
|
72
|
+
`${owner}: ${coordinate} должен быть непустой строкой; получено ${received}. `
|
|
73
|
+
+ `/ ${owner}: ${coordinate} must be a non-empty string; received ${received}.`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Проверяет boolean без truthy/falsy coercion.
|
|
79
|
+
* Validates a boolean without truthy/falsy coercion.
|
|
80
|
+
*/
|
|
81
|
+
export function validateBoolean(owner: string, coordinate: string, value: unknown): boolean {
|
|
82
|
+
if (typeof value === 'boolean') return value
|
|
83
|
+
const received = describeRuntimeValue(value)
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
`${owner}: ${coordinate} должен быть boolean; получено ${received}. `
|
|
86
|
+
+ `/ ${owner}: ${coordinate} must be a boolean; received ${received}.`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Проверяет DOM id по HTML-контракту: непустая строка без ASCII whitespace.
|
|
92
|
+
* Validates an HTML DOM id: a non-empty string without ASCII whitespace.
|
|
93
|
+
*/
|
|
94
|
+
export function validateDomId(owner: string, coordinate: string, value: unknown): string {
|
|
95
|
+
if (typeof value === 'string' && value.length > 0 && !ASCII_WHITESPACE_PATTERN.test(value)) {
|
|
96
|
+
return value
|
|
97
|
+
}
|
|
98
|
+
const received = describeRuntimeValue(value)
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`${owner}: ${coordinate} должен быть непустым DOM id без ASCII-пробелов; получено ${received}. `
|
|
101
|
+
+ `/ ${owner}: ${coordinate} must be a non-empty DOM id without ASCII whitespace; `
|
|
102
|
+
+ `received ${received}.`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Возвращает generated DOM id только при отсутствии явного значения и валидирует обе ветки.
|
|
108
|
+
* Returns a generated DOM id only when the explicit value is absent and validates both branches.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveOptionalDomId(
|
|
111
|
+
owner: string,
|
|
112
|
+
coordinate: string,
|
|
113
|
+
value: unknown,
|
|
114
|
+
generatedValue: unknown,
|
|
115
|
+
): string {
|
|
116
|
+
return value === undefined
|
|
117
|
+
? validateDomId(owner, `generated ${coordinate}`, generatedValue)
|
|
118
|
+
: validateDomId(owner, coordinate, value)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Проверяет exact string enum и сохраняет его literal union в return type.
|
|
123
|
+
* Validates an exact string enum and preserves its literal union in the return type.
|
|
124
|
+
*/
|
|
125
|
+
export function validateExactString<const Allowed extends readonly string[]>(
|
|
126
|
+
owner: string,
|
|
127
|
+
coordinate: string,
|
|
128
|
+
value: unknown,
|
|
129
|
+
allowed: Allowed,
|
|
130
|
+
): Allowed[number] {
|
|
131
|
+
assertExactStringDomain(owner, coordinate, allowed)
|
|
132
|
+
if (typeof value === 'string' && allowed.includes(value)) return value as Allowed[number]
|
|
133
|
+
|
|
134
|
+
const expected = `[${allowed.map((candidate) => JSON.stringify(candidate)).join(', ')}]`
|
|
135
|
+
const received = describeRuntimeValue(value)
|
|
136
|
+
throw new TypeError(
|
|
137
|
+
`${owner}: ${coordinate} должен быть одним из ${expected}; получено ${received}. `
|
|
138
|
+
+ `/ ${owner}: ${coordinate} must be one of ${expected}; received ${received}.`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Проверяет конечное число в inclusive domain без coercion.
|
|
144
|
+
* Validates a finite number in an inclusive domain without coercion.
|
|
145
|
+
*/
|
|
146
|
+
export function validateBoundedNumber(
|
|
147
|
+
owner: string,
|
|
148
|
+
coordinate: string,
|
|
149
|
+
value: unknown,
|
|
150
|
+
domain: BoundedNumberDomain,
|
|
151
|
+
): number {
|
|
152
|
+
assertBoundedDomain(owner, coordinate, domain, false)
|
|
153
|
+
if (
|
|
154
|
+
typeof value === 'number'
|
|
155
|
+
&& Number.isFinite(value)
|
|
156
|
+
&& value >= domain.minimum
|
|
157
|
+
&& value <= domain.maximum
|
|
158
|
+
) return value
|
|
159
|
+
|
|
160
|
+
const received = describeRuntimeValue(value)
|
|
161
|
+
throw new TypeError(
|
|
162
|
+
`${owner}: ${coordinate} должен быть конечным числом в диапазоне `
|
|
163
|
+
+ `[${domain.minimum}, ${domain.maximum}]; получено ${received}. / ${owner}: ${coordinate} `
|
|
164
|
+
+ `must be a finite number in the inclusive range [${domain.minimum}, ${domain.maximum}]; `
|
|
165
|
+
+ `received ${received}.`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Проверяет целое число в inclusive domain без coercion.
|
|
171
|
+
* Validates an integer in an inclusive domain without coercion.
|
|
172
|
+
*/
|
|
173
|
+
export function validateBoundedInteger(
|
|
174
|
+
owner: string,
|
|
175
|
+
coordinate: string,
|
|
176
|
+
value: unknown,
|
|
177
|
+
domain: BoundedNumberDomain,
|
|
178
|
+
): number {
|
|
179
|
+
assertBoundedDomain(owner, coordinate, domain, true)
|
|
180
|
+
if (
|
|
181
|
+
typeof value === 'number'
|
|
182
|
+
&& Number.isInteger(value)
|
|
183
|
+
&& value >= domain.minimum
|
|
184
|
+
&& value <= domain.maximum
|
|
185
|
+
) return value
|
|
186
|
+
|
|
187
|
+
const received = describeRuntimeValue(value)
|
|
188
|
+
throw new TypeError(
|
|
189
|
+
`${owner}: ${coordinate} должен быть целым числом в диапазоне `
|
|
190
|
+
+ `[${domain.minimum}, ${domain.maximum}]; получено ${received}. / ${owner}: ${coordinate} `
|
|
191
|
+
+ `must be an integer in the inclusive range [${domain.minimum}, ${domain.maximum}]; `
|
|
192
|
+
+ `received ${received}.`,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted, readonly, ref, type DeepReadonly, type Ref } from 'vue'
|
|
2
|
+
import {
|
|
3
|
+
isThemePreference,
|
|
4
|
+
themeVariants,
|
|
5
|
+
type ThemeMode,
|
|
6
|
+
type ThemePreference,
|
|
7
|
+
type ThemeVariant,
|
|
8
|
+
} from '../theme'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Текущее декларативное состояние темы документа.
|
|
12
|
+
* Current declarative document-theme state.
|
|
13
|
+
*/
|
|
14
|
+
export interface RuntimeThemeState {
|
|
15
|
+
/** Точное значение `data-theme`; `null` только при отсутствии явной темы. / Exact `data-theme`; `null` only when no explicit theme is applied. */
|
|
16
|
+
readonly dataTheme: ThemePreference | null
|
|
17
|
+
/** Нормализованный режим для runtime adapter. / Normalized mode for a runtime adapter. */
|
|
18
|
+
readonly mode: ThemeMode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type RuntimeThemeListener = (state: RuntimeThemeState) => void
|
|
22
|
+
|
|
23
|
+
interface DocumentThemeRuntime {
|
|
24
|
+
readonly document: Document
|
|
25
|
+
readonly listeners: Set<RuntimeThemeListener>
|
|
26
|
+
observer: MutationObserver | null
|
|
27
|
+
state: RuntimeThemeState
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const runtimes = new WeakMap<Document, DocumentThemeRuntime>()
|
|
31
|
+
|
|
32
|
+
const themeVariantByDataTheme = new Map<ThemePreference, ThemeVariant>(
|
|
33
|
+
themeVariants.map((variant): [ThemePreference, ThemeVariant] => [variant.dataTheme, variant]),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const statesEqual = (left: RuntimeThemeState, right: RuntimeThemeState): boolean => (
|
|
37
|
+
left.dataTheme === right.dataTheme && left.mode === right.mode
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Читает theme contract документа без подписки и DOM-мутаций.
|
|
42
|
+
* Reads the document theme contract without subscribing or mutating DOM.
|
|
43
|
+
*/
|
|
44
|
+
export function readRuntimeTheme(targetDocument?: Document): RuntimeThemeState {
|
|
45
|
+
if (targetDocument === undefined) return { dataTheme: null, mode: 'light' }
|
|
46
|
+
const root = targetDocument.documentElement
|
|
47
|
+
const rawDataTheme = root.getAttribute('data-theme')
|
|
48
|
+
if (rawDataTheme === null) {
|
|
49
|
+
if (root.classList.contains('dark')) {
|
|
50
|
+
throw new Error('UI-kit runtime theme: class="dark" требует canonical data-theme.')
|
|
51
|
+
}
|
|
52
|
+
return { dataTheme: null, mode: 'light' }
|
|
53
|
+
}
|
|
54
|
+
const dataTheme = rawDataTheme
|
|
55
|
+
if (!isThemePreference(dataTheme)) {
|
|
56
|
+
throw new TypeError(`UI-kit runtime theme: неизвестный data-theme "${rawDataTheme}".`)
|
|
57
|
+
}
|
|
58
|
+
const variant = themeVariantByDataTheme.get(dataTheme)
|
|
59
|
+
if (variant === undefined) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`UI-kit runtime theme: data-theme="${dataTheme}" отсутствует в canonical theme registry.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
const mode = variant.mode
|
|
65
|
+
const hasDarkClass = root.classList.contains('dark')
|
|
66
|
+
if (mode === 'dark' && !hasDarkClass) {
|
|
67
|
+
throw new Error(`UI-kit runtime theme: data-theme="${dataTheme}" требует class="dark".`)
|
|
68
|
+
}
|
|
69
|
+
if (mode === 'light' && hasDarkClass) {
|
|
70
|
+
throw new Error(`UI-kit runtime theme: data-theme="${dataTheme}" конфликтует с class="dark".`)
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
dataTheme,
|
|
74
|
+
mode,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const refreshRuntime = (runtime: DocumentThemeRuntime): void => {
|
|
79
|
+
const nextState = readRuntimeTheme(runtime.document)
|
|
80
|
+
if (statesEqual(runtime.state, nextState)) return
|
|
81
|
+
runtime.state = nextState
|
|
82
|
+
for (const listener of runtime.listeners) listener(nextState)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const createRuntime = (targetDocument: Document): DocumentThemeRuntime => ({
|
|
86
|
+
document: targetDocument,
|
|
87
|
+
listeners: new Set(),
|
|
88
|
+
observer: null,
|
|
89
|
+
state: readRuntimeTheme(targetDocument),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const startRuntime = (runtime: DocumentThemeRuntime): void => {
|
|
93
|
+
if (runtime.observer || typeof MutationObserver === 'undefined') return
|
|
94
|
+
runtime.observer = new MutationObserver(() => refreshRuntime(runtime))
|
|
95
|
+
runtime.observer.observe(runtime.document.documentElement, {
|
|
96
|
+
attributes: true,
|
|
97
|
+
attributeFilter: ['class', 'data-theme'],
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stopRuntime = (runtime: DocumentThemeRuntime): void => {
|
|
102
|
+
if (runtime.observer !== null) runtime.observer.disconnect()
|
|
103
|
+
runtime.observer = null
|
|
104
|
+
runtimes.delete(runtime.document)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Подписывает runtime-интеграцию на единый per-document theme owner.
|
|
109
|
+
* Subscribes a runtime integration to the single per-document theme owner.
|
|
110
|
+
*/
|
|
111
|
+
export function subscribeRuntimeTheme(
|
|
112
|
+
listener: RuntimeThemeListener,
|
|
113
|
+
targetDocument?: Document,
|
|
114
|
+
): () => void {
|
|
115
|
+
if (targetDocument === undefined) {
|
|
116
|
+
listener(readRuntimeTheme())
|
|
117
|
+
return () => undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let runtime = runtimes.get(targetDocument)
|
|
121
|
+
if (runtime === undefined) {
|
|
122
|
+
runtime = createRuntime(targetDocument)
|
|
123
|
+
runtimes.set(targetDocument, runtime)
|
|
124
|
+
}
|
|
125
|
+
const subscribedRuntime = runtime
|
|
126
|
+
subscribedRuntime.listeners.add(listener)
|
|
127
|
+
listener(subscribedRuntime.state)
|
|
128
|
+
startRuntime(subscribedRuntime)
|
|
129
|
+
|
|
130
|
+
let subscribed = true
|
|
131
|
+
return () => {
|
|
132
|
+
if (!subscribed) return
|
|
133
|
+
subscribed = false
|
|
134
|
+
subscribedRuntime.listeners.delete(listener)
|
|
135
|
+
if (subscribedRuntime.listeners.size === 0) stopRuntime(subscribedRuntime)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Vue lifecycle adapter для единого runtime theme owner.
|
|
141
|
+
* Vue lifecycle adapter for the canonical runtime theme owner.
|
|
142
|
+
*/
|
|
143
|
+
export function useRuntimeTheme(): DeepReadonly<Ref<RuntimeThemeState>> {
|
|
144
|
+
const targetDocument = typeof document === 'undefined' ? undefined : document
|
|
145
|
+
const state = ref<RuntimeThemeState>(readRuntimeTheme(targetDocument))
|
|
146
|
+
let unsubscribe: (() => void) | null = null
|
|
147
|
+
|
|
148
|
+
onMounted(() => {
|
|
149
|
+
unsubscribe = subscribeRuntimeTheme((nextState) => {
|
|
150
|
+
state.value = nextState
|
|
151
|
+
}, targetDocument)
|
|
152
|
+
})
|
|
153
|
+
onBeforeUnmount(() => {
|
|
154
|
+
if (unsubscribe !== null) unsubscribe()
|
|
155
|
+
unsubscribe = null
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return readonly(state)
|
|
159
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { validateExactString } from './runtimeContract'
|
|
2
|
+
|
|
3
|
+
const SCROLLABLE_VIEWPORT_MAX_BLOCK_SIZES = ['none', 'sm', 'md', 'lg'] as const
|
|
4
|
+
const EXPANDED_CONTENT_MAX_BLOCK_SIZES = ['sm', 'md', 'lg'] as const
|
|
5
|
+
const TABLE_COLUMN_INLINE_SIZES = ['auto', 'xs', 'sm', 'md', 'lg', 'xl', 'fill'] as const
|
|
6
|
+
const TABLE_COLUMN_MIN_INLINE_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Максимальный block-size прокручиваемого viewport; none сохраняет естественный flow.
|
|
10
|
+
* Maximum block size for a scroll viewport; none preserves natural flow.
|
|
11
|
+
*/
|
|
12
|
+
export type ScrollableViewportMaxBlockSize = typeof SCROLLABLE_VIEWPORT_MAX_BLOCK_SIZES[number]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Максимальный block-size раскрытого текстового content region.
|
|
16
|
+
* Maximum block size for an expanded textual content region.
|
|
17
|
+
*/
|
|
18
|
+
export type ExpandedContentMaxBlockSize = typeof EXPANDED_CONTENT_MAX_BLOCK_SIZES[number]
|
|
19
|
+
|
|
20
|
+
/** Семантическая ширина столбца таблицы. / Semantic table-column inline size. */
|
|
21
|
+
export type TableColumnInlineSize = typeof TABLE_COLUMN_INLINE_SIZES[number]
|
|
22
|
+
|
|
23
|
+
/** Семантическая минимальная ширина столбца таблицы. / Semantic table-column minimum inline size. */
|
|
24
|
+
export type TableColumnMinInlineSize = typeof TABLE_COLUMN_MIN_INLINE_SIZES[number]
|
|
25
|
+
|
|
26
|
+
/** Проверяет semantic scroll viewport size без CSS-length escape. / Validates a semantic scroll viewport size without a CSS-length escape. */
|
|
27
|
+
export function validateScrollableViewportMaxBlockSize(
|
|
28
|
+
owner: string,
|
|
29
|
+
coordinate: string,
|
|
30
|
+
value: unknown,
|
|
31
|
+
): ScrollableViewportMaxBlockSize {
|
|
32
|
+
return validateExactString(owner, coordinate, value, SCROLLABLE_VIEWPORT_MAX_BLOCK_SIZES)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Проверяет semantic expanded-content size без CSS-length escape. / Validates a semantic expanded-content size without a CSS-length escape. */
|
|
36
|
+
export function validateExpandedContentMaxBlockSize(
|
|
37
|
+
owner: string,
|
|
38
|
+
coordinate: string,
|
|
39
|
+
value: unknown,
|
|
40
|
+
): ExpandedContentMaxBlockSize {
|
|
41
|
+
return validateExactString(owner, coordinate, value, EXPANDED_CONTENT_MAX_BLOCK_SIZES)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Проверяет semantic column width без CSS-length escape. / Validates semantic column width without a CSS-length escape. */
|
|
45
|
+
export function validateTableColumnInlineSize(
|
|
46
|
+
owner: string,
|
|
47
|
+
coordinate: string,
|
|
48
|
+
value: unknown,
|
|
49
|
+
): TableColumnInlineSize {
|
|
50
|
+
return validateExactString(owner, coordinate, value, TABLE_COLUMN_INLINE_SIZES)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Проверяет semantic minimum column width без CSS-length escape. / Validates semantic minimum column width without a CSS-length escape. */
|
|
54
|
+
export function validateTableColumnMinInlineSize(
|
|
55
|
+
owner: string,
|
|
56
|
+
coordinate: string,
|
|
57
|
+
value: unknown,
|
|
58
|
+
): TableColumnMinInlineSize {
|
|
59
|
+
return validateExactString(owner, coordinate, value, TABLE_COLUMN_MIN_INLINE_SIZES)
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { nextTick, onBeforeUnmount, ref, type Ref } from 'vue'
|
|
2
|
+
import { useFloatingPosition } from '../composables/useFloatingPosition'
|
|
3
|
+
import { findFocusableOwner, findRelativeTabTarget } from './focusNavigation'
|
|
4
|
+
import { registerLayer, type LayerRegistration } from './layerStack'
|
|
5
|
+
|
|
6
|
+
export type MenuAnchor =
|
|
7
|
+
| { type: 'element'; element: HTMLElement; align: 'left' | 'right'; placement?: 'bottom' | 'inline' }
|
|
8
|
+
| { type: 'point'; x: number; y: number }
|
|
9
|
+
|
|
10
|
+
export type MenuCloseReason = 'escape' | 'outside' | 'programmatic' | 'selection' | 'trigger'
|
|
11
|
+
|
|
12
|
+
const VIEWPORT_PADDING = 5
|
|
13
|
+
|
|
14
|
+
const pointRect = (x: number, y: number): DOMRect => ({
|
|
15
|
+
x,
|
|
16
|
+
y,
|
|
17
|
+
left: x,
|
|
18
|
+
right: x,
|
|
19
|
+
top: y,
|
|
20
|
+
bottom: y,
|
|
21
|
+
width: 0,
|
|
22
|
+
height: 0,
|
|
23
|
+
toJSON: () => ({ x, y }),
|
|
24
|
+
}) as DOMRect
|
|
25
|
+
|
|
26
|
+
/** Управляет общей lifecycle/focus границей menu поверх canonical floating geometry. / Owns menu lifecycle and focus over canonical floating geometry. */
|
|
27
|
+
export function useMenuLayer(
|
|
28
|
+
layerRef: Ref<HTMLElement | null>,
|
|
29
|
+
options: { onDismiss?: (reason: 'escape' | 'outside') => void } = {},
|
|
30
|
+
) {
|
|
31
|
+
const isOpen = ref(false)
|
|
32
|
+
const zIndex = ref('var(--s-layer-floating)')
|
|
33
|
+
let anchor: MenuAnchor | null = null
|
|
34
|
+
let focusAnchor: HTMLElement | null = null
|
|
35
|
+
let registration: LayerRegistration | null = null
|
|
36
|
+
|
|
37
|
+
function close(reason: MenuCloseReason = 'programmatic'): void {
|
|
38
|
+
if (!isOpen.value && !registration) return
|
|
39
|
+
isOpen.value = false
|
|
40
|
+
registration?.unregister({ restoreFocus: reason !== 'outside' })
|
|
41
|
+
registration = null
|
|
42
|
+
anchor = null
|
|
43
|
+
focusAnchor = null
|
|
44
|
+
zIndex.value = 'var(--s-layer-floating)'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function closeForTab(event: KeyboardEvent): void {
|
|
48
|
+
const reference = focusAnchor
|
|
49
|
+
if (!reference) {
|
|
50
|
+
close('outside')
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const scope = registration?.focusScope() ?? document
|
|
54
|
+
const target = findRelativeTabTarget(reference, scope, layerRef.value, event.shiftKey)
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
close('outside')
|
|
57
|
+
if (target) nextTick(() => target.focus())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const anchorElement = (): HTMLElement | null => anchor?.type === 'element' ? anchor.element : null
|
|
61
|
+
const anchorRect = (): DOMRect | null => {
|
|
62
|
+
if (!anchor) return null
|
|
63
|
+
if (anchor.type === 'point') return pointRect(anchor.x, anchor.y)
|
|
64
|
+
if (!anchor.element.isConnected) {
|
|
65
|
+
close('outside')
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
return anchor.element.getBoundingClientRect()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
coordinates: position,
|
|
73
|
+
updatePosition: positionLayer,
|
|
74
|
+
} = useFloatingPosition({
|
|
75
|
+
active: isOpen,
|
|
76
|
+
placement: () => anchor?.type === 'element' && anchor.placement === 'inline'
|
|
77
|
+
? (anchor.align === 'right' ? 'left-start' : 'right-start')
|
|
78
|
+
: anchor?.type === 'element' && anchor.align === 'right' ? 'bottom-end' : 'bottom-start',
|
|
79
|
+
anchorElement,
|
|
80
|
+
anchorRect,
|
|
81
|
+
panelRef: layerRef,
|
|
82
|
+
offset: () => 0,
|
|
83
|
+
boundaryPadding: () => VIEWPORT_PADDING,
|
|
84
|
+
flip: () => true,
|
|
85
|
+
shift: () => true,
|
|
86
|
+
widthMatch: () => 'none',
|
|
87
|
+
zIndex: () => zIndex.value,
|
|
88
|
+
scrollStrategy: 'reposition',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
async function open(nextAnchor: MenuAnchor, focusTarget?: HTMLElement | null): Promise<void> {
|
|
92
|
+
if (isOpen.value) close('outside')
|
|
93
|
+
if (nextAnchor.type === 'element' && findFocusableOwner(nextAnchor.element) !== nextAnchor.element) {
|
|
94
|
+
throw new Error('useMenuLayer: element anchor must be the canonical focusable owner')
|
|
95
|
+
}
|
|
96
|
+
anchor = nextAnchor
|
|
97
|
+
const restoreTarget = findFocusableOwner(focusTarget ?? null)
|
|
98
|
+
?? findFocusableOwner(document.activeElement)
|
|
99
|
+
focusAnchor = nextAnchor.type === 'element' ? nextAnchor.element : restoreTarget
|
|
100
|
+
isOpen.value = true
|
|
101
|
+
registration = registerLayer({
|
|
102
|
+
kind: 'menu',
|
|
103
|
+
root: () => layerRef.value,
|
|
104
|
+
anchor: () => nextAnchor.type === 'element' ? nextAnchor.element : restoreTarget,
|
|
105
|
+
restoreTarget,
|
|
106
|
+
requestDismiss: (reason) => {
|
|
107
|
+
const closeReason = reason === 'escape' ? 'escape' : 'outside'
|
|
108
|
+
close(closeReason)
|
|
109
|
+
options.onDismiss?.(closeReason)
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
zIndex.value = registration.zIndex
|
|
113
|
+
await nextTick()
|
|
114
|
+
if (!isOpen.value) return
|
|
115
|
+
if (!layerRef.value) {
|
|
116
|
+
close('outside')
|
|
117
|
+
throw new Error('useMenuLayer: layer element is unavailable after opening')
|
|
118
|
+
}
|
|
119
|
+
positionLayer()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toggle(nextAnchor: MenuAnchor, focusTarget?: HTMLElement | null): void {
|
|
123
|
+
if (isOpen.value) close('trigger')
|
|
124
|
+
else void open(nextAnchor, focusTarget)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onBeforeUnmount(() => close('outside'))
|
|
128
|
+
return { isOpen, position, zIndex, open, close, closeForTab, toggle, positionLayer }
|
|
129
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
3
|
+
import type { FeedbackSeverity } from '../internal/feedbackPresentation'
|
|
4
|
+
|
|
5
|
+
/** Severity уведомления использует канонический feedback-контракт. / Notification severity uses the canonical feedback contract. */
|
|
6
|
+
export type Severity = FeedbackSeverity
|
|
7
|
+
|
|
8
|
+
export interface Notification {
|
|
9
|
+
id: string
|
|
10
|
+
severity: Severity
|
|
11
|
+
title?: string
|
|
12
|
+
message: string
|
|
13
|
+
duration?: number
|
|
14
|
+
createdAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type NotificationInput = Omit<Notification, 'id' | 'createdAt'>
|
|
18
|
+
|
|
19
|
+
interface NotifierState {
|
|
20
|
+
notifications: Notification[]
|
|
21
|
+
history: Notification[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_NOTIFICATION_DURATION_MS = 2800
|
|
25
|
+
const removalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
26
|
+
|
|
27
|
+
function stopRemovalTimer(id: string): void {
|
|
28
|
+
const timer = removalTimers.get(id)
|
|
29
|
+
if (!timer) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
clearTimeout(timer)
|
|
33
|
+
removalTimers.delete(id)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const useNotifierStore = defineStore('notifier', {
|
|
37
|
+
state: (): NotifierState => ({
|
|
38
|
+
notifications: [],
|
|
39
|
+
history: [],
|
|
40
|
+
}),
|
|
41
|
+
actions: {
|
|
42
|
+
add(notification: NotificationInput) {
|
|
43
|
+
const id = uuidv4()
|
|
44
|
+
const newNotification: Notification = {
|
|
45
|
+
id,
|
|
46
|
+
createdAt: new Date().toISOString(),
|
|
47
|
+
duration: DEFAULT_NOTIFICATION_DURATION_MS,
|
|
48
|
+
...notification,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.history.unshift(newNotification)
|
|
52
|
+
this.notifications.push(newNotification)
|
|
53
|
+
|
|
54
|
+
if (newNotification.duration) {
|
|
55
|
+
const removalTimer = setTimeout(() => {
|
|
56
|
+
this.remove(id)
|
|
57
|
+
}, newNotification.duration)
|
|
58
|
+
removalTimers.set(id, removalTimer)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
remove(id: string) {
|
|
62
|
+
stopRemovalTimer(id)
|
|
63
|
+
this.notifications = this.notifications.filter((n) => n.id !== id)
|
|
64
|
+
},
|
|
65
|
+
clearHistory() {
|
|
66
|
+
this.history = []
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
})
|