@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,26 @@
|
|
|
1
|
+
@reference "../../../styles/reference.css";
|
|
2
|
+
|
|
3
|
+
.s-breadcrumbs {
|
|
4
|
+
@apply min-w-0 overflow-x-auto border-y border-surface-200 py-2 dark:border-surface-700;
|
|
5
|
+
scrollbar-width: thin;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.s-breadcrumbs__list {
|
|
9
|
+
@apply flex min-w-max list-none items-center gap-1 p-0 m-0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.s-breadcrumbs__item {
|
|
13
|
+
@apply flex items-center gap-1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.s-breadcrumbs__current {
|
|
17
|
+
@apply block shrink-0 rounded px-2 py-1 text-xs font-medium;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.s-breadcrumbs__current {
|
|
21
|
+
@apply text-surface-900 dark:text-surface-50;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.s-breadcrumbs__divider {
|
|
25
|
+
@apply h-3 w-3 shrink-0 text-surface-400;
|
|
26
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { resolveLinkTarget } from '../../../internal/linkTarget'
|
|
5
|
+
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
6
|
+
import { ChevronRight } from '../../icons/sputnigUiIcons'
|
|
7
|
+
import SButton from '../controls/SButton.vue'
|
|
8
|
+
import SLink from '../controls/SLink.vue'
|
|
9
|
+
import type { LinkDestination } from '../controls/SLink'
|
|
10
|
+
|
|
11
|
+
defineOptions({ inheritAttrs: false })
|
|
12
|
+
|
|
13
|
+
interface BreadcrumbItemBase {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly label: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Обычное navigation destination с точным href/to XOR. / Ordinary navigation destination with exact href/to XOR. */
|
|
19
|
+
export type BreadcrumbDestinationItem = BreadcrumbItemBase & LinkDestination & {
|
|
20
|
+
readonly kind: 'destination'
|
|
21
|
+
readonly disabled?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Команда в цепочке, не имитирующая ссылку. / A breadcrumb command that does not impersonate a link. */
|
|
25
|
+
export interface BreadcrumbActionItem extends BreadcrumbItemBase {
|
|
26
|
+
readonly kind: 'action'
|
|
27
|
+
readonly disabled?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Текущая пассивная страница. / The current passive page. */
|
|
31
|
+
export interface BreadcrumbCurrentItem extends BreadcrumbItemBase {
|
|
32
|
+
readonly kind: 'current'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Точный semantic contract сегмента. / Exact semantic breadcrumb-item contract. */
|
|
36
|
+
export type BreadcrumbItem = BreadcrumbDestinationItem | BreadcrumbActionItem | BreadcrumbCurrentItem
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(defineProps<{
|
|
39
|
+
items: readonly BreadcrumbItem[]
|
|
40
|
+
ariaLabel?: string
|
|
41
|
+
}>(), {
|
|
42
|
+
ariaLabel: 'Навигационная цепочка',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
action: [id: string, event: MouseEvent]
|
|
47
|
+
navigate: [item: BreadcrumbDestinationItem, event: MouseEvent]
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const ownedAttrs = useOwnedAttrs({ component: 'SBreadcrumbs', owner: 'breadcrumb navigation root' })
|
|
51
|
+
const EXACT_KEYS = {
|
|
52
|
+
action: new Set(['kind', 'id', 'label', 'disabled']),
|
|
53
|
+
current: new Set(['kind', 'id', 'label']),
|
|
54
|
+
destination: new Set(['kind', 'id', 'label', 'disabled', 'href', 'to', 'target', 'rel']),
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
function assertRecord(value: unknown, coordinate: string): asserts value is Record<string, unknown> {
|
|
58
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) return
|
|
59
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate} must be an object`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function validateContract(): void {
|
|
63
|
+
if (typeof props.ariaLabel !== 'string' || !props.ariaLabel.trim()) {
|
|
64
|
+
throw new TypeError('SBreadcrumbs: ariaLabel must be a non-empty string')
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(props.items) || props.items.length === 0) {
|
|
67
|
+
throw new TypeError('SBreadcrumbs: items must be a non-empty array')
|
|
68
|
+
}
|
|
69
|
+
const ids = new Set<string>()
|
|
70
|
+
let currentIndex = -1
|
|
71
|
+
for (const [index, candidate] of props.items.entries()) {
|
|
72
|
+
const coordinate = `items[${index}]`
|
|
73
|
+
assertRecord(candidate, coordinate)
|
|
74
|
+
const kind = candidate.kind
|
|
75
|
+
if (kind !== 'action' && kind !== 'current' && kind !== 'destination') {
|
|
76
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate}.kind must be action, current, or destination`)
|
|
77
|
+
}
|
|
78
|
+
for (const key of Reflect.ownKeys(candidate)) {
|
|
79
|
+
if (typeof key !== 'string' || !EXACT_KEYS[kind].has(key)) {
|
|
80
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate} has unsupported field '${String(key)}'`)
|
|
81
|
+
}
|
|
82
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key)
|
|
83
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
84
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate}.${key} must be an enumerable data property`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (typeof candidate.id !== 'string' || !candidate.id.trim()) {
|
|
88
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate}.id must be a non-empty string`)
|
|
89
|
+
}
|
|
90
|
+
if (ids.has(candidate.id)) throw new TypeError(`SBreadcrumbs: duplicate item id '${candidate.id}'`)
|
|
91
|
+
ids.add(candidate.id)
|
|
92
|
+
if (typeof candidate.label !== 'string' || !candidate.label.trim()) {
|
|
93
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate}.label must be a non-empty string`)
|
|
94
|
+
}
|
|
95
|
+
if (candidate.disabled !== undefined && typeof candidate.disabled !== 'boolean') {
|
|
96
|
+
throw new TypeError(`SBreadcrumbs: ${coordinate}.disabled must be a boolean`)
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'destination') {
|
|
99
|
+
resolveLinkTarget({
|
|
100
|
+
owner: `SBreadcrumbs: ${coordinate}`,
|
|
101
|
+
href: candidate.href,
|
|
102
|
+
to: candidate.to,
|
|
103
|
+
target: candidate.target,
|
|
104
|
+
rel: candidate.rel,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
if (kind === 'current') {
|
|
108
|
+
if (currentIndex >= 0) throw new TypeError('SBreadcrumbs: items must contain exactly one current item')
|
|
109
|
+
currentIndex = index
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (currentIndex !== props.items.length - 1) {
|
|
113
|
+
throw new TypeError('SBreadcrumbs: the sole current item must be last')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
watch(() => [props.items, props.ariaLabel] as const, validateContract, { immediate: true, deep: true })
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<template>
|
|
121
|
+
<nav v-bind="ownedAttrs.bindings()" class="s-breadcrumbs" :aria-label="ariaLabel">
|
|
122
|
+
<ol class="s-breadcrumbs__list">
|
|
123
|
+
<li v-for="(item, index) in items" :key="item.id" class="s-breadcrumbs__item">
|
|
124
|
+
<ChevronRight v-if="index > 0" class="s-breadcrumbs__divider" aria-hidden="true" />
|
|
125
|
+
<span v-if="item.kind === 'current'" class="s-breadcrumbs__current" aria-current="page">
|
|
126
|
+
{{ item.label }}
|
|
127
|
+
</span>
|
|
128
|
+
<SLink
|
|
129
|
+
v-else-if="item.kind === 'destination'"
|
|
130
|
+
:href="item.href"
|
|
131
|
+
:to="item.to"
|
|
132
|
+
:target="item.target"
|
|
133
|
+
:rel="item.rel"
|
|
134
|
+
variant="subtle"
|
|
135
|
+
size="sm"
|
|
136
|
+
:disabled="item.disabled"
|
|
137
|
+
@click="emit('navigate', item, $event)"
|
|
138
|
+
>
|
|
139
|
+
{{ item.label }}
|
|
140
|
+
</SLink>
|
|
141
|
+
<SButton
|
|
142
|
+
v-else
|
|
143
|
+
:label="item.label"
|
|
144
|
+
:disabled="item.disabled"
|
|
145
|
+
size="xs"
|
|
146
|
+
severity="secondary"
|
|
147
|
+
appearance="ghost"
|
|
148
|
+
@click="emit('action', item.id, $event)"
|
|
149
|
+
/>
|
|
150
|
+
</li>
|
|
151
|
+
</ol>
|
|
152
|
+
</nav>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<style lang="postcss" src="./SBreadcrumbs.css" scoped></style>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
.s-catalog-navigator {
|
|
2
|
+
container-type: inline-size;
|
|
3
|
+
display: flex;
|
|
4
|
+
min-height: 0;
|
|
5
|
+
flex: 1 1 auto;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 0.625rem;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.s-catalog-navigator__toolbar {
|
|
11
|
+
display: grid;
|
|
12
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
13
|
+
gap: 0.5rem;
|
|
14
|
+
align-items: end;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.s-catalog-navigator__toolbar > :first-child {
|
|
18
|
+
grid-column: 1 / -1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.s-catalog-navigator__summary {
|
|
22
|
+
color: var(--color-text-muted);
|
|
23
|
+
font-size: 0.75rem;
|
|
24
|
+
line-height: 1rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.s-catalog-navigator__viewport {
|
|
28
|
+
min-height: 10rem;
|
|
29
|
+
flex: 1 1 auto;
|
|
30
|
+
overflow-y: auto;
|
|
31
|
+
overscroll-behavior: contain;
|
|
32
|
+
border-block: 1px solid var(--color-border-subtle);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.s-catalog-navigator__row {
|
|
36
|
+
display: flex;
|
|
37
|
+
width: 100%;
|
|
38
|
+
min-width: 0;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 0.5rem;
|
|
41
|
+
border-bottom: 1px solid var(--color-border-subtle);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.s-catalog-navigator__row:hover {
|
|
45
|
+
background: var(--color-surface-muted);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.s-catalog-navigator__row--selected {
|
|
49
|
+
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
50
|
+
box-shadow: inset 3px 0 0 var(--color-primary);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.s-catalog-navigator__row--selected .s-catalog-navigator__subtitle {
|
|
54
|
+
color: var(--color-text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.s-catalog-navigator__row-main {
|
|
58
|
+
display: grid;
|
|
59
|
+
min-width: 0;
|
|
60
|
+
flex: 1 1 auto;
|
|
61
|
+
gap: 0.125rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.s-catalog-navigator__subtitle {
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
text-overflow: ellipsis;
|
|
67
|
+
white-space: nowrap;
|
|
68
|
+
padding-inline: 0.75rem;
|
|
69
|
+
color: var(--color-text-muted);
|
|
70
|
+
font-size: 0.75rem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.s-catalog-navigator__badges {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex: 0 0 auto;
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
justify-content: flex-end;
|
|
78
|
+
gap: 0.25rem;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.s-catalog-navigator__empty {
|
|
82
|
+
padding: 1.5rem 0.75rem;
|
|
83
|
+
color: var(--color-text-muted);
|
|
84
|
+
font-size: 0.875rem;
|
|
85
|
+
text-align: center;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@container (max-width: 26rem) {
|
|
89
|
+
.s-catalog-navigator__toolbar {
|
|
90
|
+
grid-template-columns: 1fr;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.s-catalog-navigator__toolbar > :first-child {
|
|
94
|
+
grid-column: auto;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.s-catalog-navigator__badges {
|
|
98
|
+
justify-content: flex-start;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section v-bind="ownedAttrs.bindings()" class="s-catalog-navigator" :aria-label="ariaLabel">
|
|
3
|
+
<div class="s-catalog-navigator__toolbar">
|
|
4
|
+
<SInputText
|
|
5
|
+
v-model="query"
|
|
6
|
+
size="sm"
|
|
7
|
+
:placeholder="searchPlaceholder"
|
|
8
|
+
:aria-label="searchPlaceholder"
|
|
9
|
+
/>
|
|
10
|
+
<SSelect
|
|
11
|
+
v-model="selectedGroup"
|
|
12
|
+
size="sm"
|
|
13
|
+
:options="groupOptions"
|
|
14
|
+
:label="groupFilterLabel"
|
|
15
|
+
/>
|
|
16
|
+
<SSelect
|
|
17
|
+
v-model="sortMode"
|
|
18
|
+
size="sm"
|
|
19
|
+
:options="sortOptions"
|
|
20
|
+
label="Сортировка"
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="s-catalog-navigator__summary" aria-live="polite">
|
|
25
|
+
Найдено: {{ filteredItemCount }} из {{ items.length }}
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="s-catalog-navigator__viewport" role="region" :aria-label="`${ariaLabel}: элементы`">
|
|
29
|
+
<div v-if="groupedItems.length === 0" class="s-catalog-navigator__empty">
|
|
30
|
+
{{ emptyText }}
|
|
31
|
+
</div>
|
|
32
|
+
<template v-else>
|
|
33
|
+
<SCatalogNavigatorGroup
|
|
34
|
+
v-for="group in groupedItems"
|
|
35
|
+
:key="group.key"
|
|
36
|
+
:id="groupDisclosureId(group.key)"
|
|
37
|
+
:label="group.label"
|
|
38
|
+
:count="group.items.length"
|
|
39
|
+
>
|
|
40
|
+
<div
|
|
41
|
+
v-for="item in group.items"
|
|
42
|
+
:key="item.id"
|
|
43
|
+
class="s-catalog-navigator__row"
|
|
44
|
+
role="listitem"
|
|
45
|
+
:class="{ 's-catalog-navigator__row--selected': item.id === validatedSelectedId }"
|
|
46
|
+
:aria-current="item.id === validatedSelectedId ? 'true' : undefined"
|
|
47
|
+
>
|
|
48
|
+
<div class="s-catalog-navigator__row-main">
|
|
49
|
+
<SButton
|
|
50
|
+
:label="item.title"
|
|
51
|
+
size="sm"
|
|
52
|
+
severity="secondary"
|
|
53
|
+
appearance="ghost"
|
|
54
|
+
shape="row"
|
|
55
|
+
width="full"
|
|
56
|
+
content-align="start"
|
|
57
|
+
:disabled="item.disabled"
|
|
58
|
+
:aria-current="item.id === validatedSelectedId || undefined"
|
|
59
|
+
@click="selectItem(item)"
|
|
60
|
+
/>
|
|
61
|
+
<span v-if="item.subtitle" class="s-catalog-navigator__subtitle">{{ item.subtitle }}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<span v-if="item.badges?.length" class="s-catalog-navigator__badges">
|
|
64
|
+
<SBadge v-for="badge in item.badges" :key="badge" severity="secondary" size="xs">
|
|
65
|
+
{{ badge }}
|
|
66
|
+
</SBadge>
|
|
67
|
+
</span>
|
|
68
|
+
<slot name="item-actions" :item="item" />
|
|
69
|
+
</div>
|
|
70
|
+
</SCatalogNavigatorGroup>
|
|
71
|
+
</template>
|
|
72
|
+
</div>
|
|
73
|
+
</section>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<script setup lang="ts">
|
|
77
|
+
import { computed, ref, useId, watch } from 'vue';
|
|
78
|
+
import SInputText from '../controls/SInputText.vue';
|
|
79
|
+
import SSelect from '../controls/SSelect.vue';
|
|
80
|
+
import SButton from '../controls/SButton.vue';
|
|
81
|
+
import SBadge from '../data-display/SBadge.vue';
|
|
82
|
+
import SCatalogNavigatorGroup from './_internal/SCatalogNavigatorGroup.vue';
|
|
83
|
+
import { useOwnedAttrs } from '../../../internal/ownedAttrs';
|
|
84
|
+
|
|
85
|
+
defineOptions({ inheritAttrs: false });
|
|
86
|
+
|
|
87
|
+
export interface CatalogNavigatorItem {
|
|
88
|
+
id: string;
|
|
89
|
+
title: string;
|
|
90
|
+
subtitle?: string;
|
|
91
|
+
group: string;
|
|
92
|
+
groupLabel: string;
|
|
93
|
+
keywords?: string[];
|
|
94
|
+
badges?: string[];
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const props = withDefaults(defineProps<{
|
|
99
|
+
items: CatalogNavigatorItem[];
|
|
100
|
+
selectedId?: string | null;
|
|
101
|
+
ariaLabel?: string;
|
|
102
|
+
searchPlaceholder?: string;
|
|
103
|
+
groupFilterLabel?: string;
|
|
104
|
+
emptyText?: string;
|
|
105
|
+
}>(), {
|
|
106
|
+
selectedId: null,
|
|
107
|
+
ariaLabel: 'Каталог',
|
|
108
|
+
searchPlaceholder: 'Поиск',
|
|
109
|
+
groupFilterLabel: 'Группа',
|
|
110
|
+
emptyText: 'Ничего не найдено.',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const emit = defineEmits<{
|
|
114
|
+
(event: 'select', id: string): void;
|
|
115
|
+
}>();
|
|
116
|
+
const ownedAttrs = useOwnedAttrs({ component: 'SCatalogNavigator', owner: 'catalog navigation section' });
|
|
117
|
+
|
|
118
|
+
const query = ref('');
|
|
119
|
+
const ALL_GROUP_FILTER = 'all';
|
|
120
|
+
const selectedGroup = ref(ALL_GROUP_FILTER);
|
|
121
|
+
const sortMode = ref('title-asc');
|
|
122
|
+
const componentId = useId();
|
|
123
|
+
const groupIdentityTokens = new Map<string, number>();
|
|
124
|
+
let nextGroupIdentityToken = 0;
|
|
125
|
+
|
|
126
|
+
interface CatalogGroupInventory {
|
|
127
|
+
key: string;
|
|
128
|
+
label: string;
|
|
129
|
+
filterValue: string;
|
|
130
|
+
disclosureId: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ITEM_KEYS = new Set([
|
|
134
|
+
'id', 'title', 'subtitle', 'group', 'groupLabel', 'keywords', 'badges', 'disabled',
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
function validateNonEmptyString(value: unknown, coordinate: string): string {
|
|
138
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
139
|
+
throw new TypeError(`SCatalogNavigator: ${coordinate} must be a non-empty string`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateOptionalStringList(value: unknown, coordinate: string): string[] | undefined {
|
|
145
|
+
if (value === undefined) return undefined;
|
|
146
|
+
if (!Array.isArray(value)) {
|
|
147
|
+
throw new TypeError(`SCatalogNavigator: ${coordinate} must be an array of non-empty strings`);
|
|
148
|
+
}
|
|
149
|
+
const result = value.map((item, index) => validateNonEmptyString(item, `${coordinate}[${index}]`));
|
|
150
|
+
if (new Set(result).size !== result.length) {
|
|
151
|
+
throw new TypeError(`SCatalogNavigator: ${coordinate} must not contain duplicate values`);
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function groupIdentityToken(key: string): number {
|
|
157
|
+
const current = groupIdentityTokens.get(key);
|
|
158
|
+
if (current !== undefined) return current;
|
|
159
|
+
const token = nextGroupIdentityToken;
|
|
160
|
+
nextGroupIdentityToken += 1;
|
|
161
|
+
groupIdentityTokens.set(key, token);
|
|
162
|
+
return token;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const catalogInventory = computed(() => {
|
|
166
|
+
if (!Array.isArray(props.items)) {
|
|
167
|
+
throw new TypeError('SCatalogNavigator: items must be an array');
|
|
168
|
+
}
|
|
169
|
+
const ids = new Set<string>();
|
|
170
|
+
const groupsByKey = new Map<string, CatalogGroupInventory>();
|
|
171
|
+
const validatedItems = props.items.map((candidate, index): CatalogNavigatorItem => {
|
|
172
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
|
|
173
|
+
throw new TypeError(`SCatalogNavigator: items[${index}] must be an object`);
|
|
174
|
+
}
|
|
175
|
+
for (const key of Reflect.ownKeys(candidate)) {
|
|
176
|
+
if (typeof key !== 'string' || !ITEM_KEYS.has(key)) {
|
|
177
|
+
throw new TypeError(`SCatalogNavigator: items[${index}] has unsupported field ${String(key)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const id = validateNonEmptyString(candidate.id, `items[${index}].id`);
|
|
181
|
+
if (ids.has(id)) {
|
|
182
|
+
throw new TypeError(`SCatalogNavigator: duplicate item id ${JSON.stringify(id)}`);
|
|
183
|
+
}
|
|
184
|
+
ids.add(id);
|
|
185
|
+
validateNonEmptyString(candidate.title, `items[${index}].title`);
|
|
186
|
+
const group = validateNonEmptyString(candidate.group, `items[${index}].group`);
|
|
187
|
+
const groupLabel = validateNonEmptyString(candidate.groupLabel, `items[${index}].groupLabel`);
|
|
188
|
+
const existingGroup = groupsByKey.get(group);
|
|
189
|
+
if (existingGroup && existingGroup.label !== groupLabel) {
|
|
190
|
+
throw new TypeError(
|
|
191
|
+
`SCatalogNavigator: group ${JSON.stringify(group)} has conflicting labels ${JSON.stringify(existingGroup.label)} and ${JSON.stringify(groupLabel)}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (!existingGroup) {
|
|
195
|
+
const token = groupIdentityToken(group);
|
|
196
|
+
groupsByKey.set(group, {
|
|
197
|
+
key: group,
|
|
198
|
+
label: groupLabel,
|
|
199
|
+
filterValue: `group-${token}`,
|
|
200
|
+
disclosureId: `${componentId}-catalog-group-${token}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (candidate.disabled !== undefined && typeof candidate.disabled !== 'boolean') {
|
|
204
|
+
throw new TypeError(`SCatalogNavigator: items[${index}].disabled must be a boolean`);
|
|
205
|
+
}
|
|
206
|
+
if (candidate.subtitle !== undefined) {
|
|
207
|
+
validateNonEmptyString(candidate.subtitle, `items[${index}].subtitle`);
|
|
208
|
+
}
|
|
209
|
+
validateOptionalStringList(candidate.keywords, `items[${index}].keywords`);
|
|
210
|
+
validateOptionalStringList(candidate.badges, `items[${index}].badges`);
|
|
211
|
+
return candidate;
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
items: validatedItems,
|
|
215
|
+
ids,
|
|
216
|
+
groups: [...groupsByKey.values()],
|
|
217
|
+
groupsByKey,
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const validatedSelectedId = computed(() => {
|
|
222
|
+
if (props.selectedId === null) return null;
|
|
223
|
+
const id = validateNonEmptyString(props.selectedId, 'selectedId');
|
|
224
|
+
if (!catalogInventory.value.ids.has(id)) {
|
|
225
|
+
throw new RangeError(`SCatalogNavigator: selectedId ${JSON.stringify(id)} is outside items inventory`);
|
|
226
|
+
}
|
|
227
|
+
return id;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
function selectItem(item: CatalogNavigatorItem): void {
|
|
231
|
+
if (!item.disabled) emit('select', item.id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function groupDisclosureId(groupKey: string): string {
|
|
235
|
+
const group = catalogInventory.value.groupsByKey.get(groupKey);
|
|
236
|
+
if (!group) {
|
|
237
|
+
throw new RangeError(`SCatalogNavigator: group ${JSON.stringify(groupKey)} is outside inventory`);
|
|
238
|
+
}
|
|
239
|
+
return group.disclosureId;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const groupOptions = computed(() => [
|
|
243
|
+
{ value: ALL_GROUP_FILTER, label: 'Все группы' },
|
|
244
|
+
...[...catalogInventory.value.groups]
|
|
245
|
+
.sort((left, right) => left.label.localeCompare(right.label, 'ru'))
|
|
246
|
+
.map((group) => ({ value: group.filterValue, label: group.label })),
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
const sortOptions = [
|
|
250
|
+
{ value: 'title-asc', label: 'По названию' },
|
|
251
|
+
{ value: 'title-desc', label: 'По названию, обратно' },
|
|
252
|
+
{ value: 'id-asc', label: 'По ID' },
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
const normalizedQuery = computed(() => query.value.trim().toLocaleLowerCase('ru'));
|
|
256
|
+
|
|
257
|
+
const selectedGroupKey = computed(() => {
|
|
258
|
+
if (selectedGroup.value === ALL_GROUP_FILTER) return null;
|
|
259
|
+
const group = catalogInventory.value.groups.find((candidate) => candidate.filterValue === selectedGroup.value);
|
|
260
|
+
if (!group) {
|
|
261
|
+
throw new RangeError(`SCatalogNavigator: selected group filter ${JSON.stringify(selectedGroup.value)} is outside inventory`);
|
|
262
|
+
}
|
|
263
|
+
return group.key;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
watch(
|
|
267
|
+
() => catalogInventory.value.groups.map((group) => group.filterValue).join('\n'),
|
|
268
|
+
() => {
|
|
269
|
+
if (
|
|
270
|
+
selectedGroup.value !== ALL_GROUP_FILTER
|
|
271
|
+
&& !catalogInventory.value.groups.some((group) => group.filterValue === selectedGroup.value)
|
|
272
|
+
) {
|
|
273
|
+
selectedGroup.value = ALL_GROUP_FILTER;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
{ flush: 'sync' },
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const visibleItems = computed(() => catalogInventory.value.items
|
|
280
|
+
.filter((item) => selectedGroupKey.value === null || item.group === selectedGroupKey.value)
|
|
281
|
+
.filter((item) => {
|
|
282
|
+
if (!normalizedQuery.value) return true;
|
|
283
|
+
const haystack = [item.title, item.subtitle, item.id, ...(item.keywords ?? [])]
|
|
284
|
+
.filter((value): value is string => typeof value === 'string')
|
|
285
|
+
.join(' ')
|
|
286
|
+
.toLocaleLowerCase('ru');
|
|
287
|
+
return haystack.includes(normalizedQuery.value);
|
|
288
|
+
})
|
|
289
|
+
.sort((left, right) => {
|
|
290
|
+
if (sortMode.value === 'id-asc') return left.id.localeCompare(right.id, 'ru');
|
|
291
|
+
const direction = sortMode.value === 'title-desc' ? -1 : 1;
|
|
292
|
+
return direction * left.title.localeCompare(right.title, 'ru');
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
const groupedItems = computed(() => {
|
|
296
|
+
const groups = new Map<string, { key: string; label: string; items: CatalogNavigatorItem[] }>();
|
|
297
|
+
visibleItems.value.forEach((item) => {
|
|
298
|
+
const group = groups.get(item.group) ?? { key: item.group, label: item.groupLabel, items: [] };
|
|
299
|
+
group.items.push(item);
|
|
300
|
+
groups.set(item.group, group);
|
|
301
|
+
});
|
|
302
|
+
return Array.from(groups.values()).sort((left, right) => left.label.localeCompare(right.label, 'ru'));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const filteredItemCount = computed(() => visibleItems.value.length);
|
|
306
|
+
</script>
|
|
307
|
+
|
|
308
|
+
<style lang="postcss" src="./SCatalogNavigator.css" scoped></style>
|