@getdom/studio 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.md +109 -0
- package/README.md +44 -0
- package/SKILL.md +83 -0
- package/package.json +73 -0
- package/public/llms.txt +57 -0
- package/public/skills.sh +11 -0
- package/src/pages/_layout/componentCatalog.generated.js +4514 -0
- package/src/pages/_layout/componentInspector.js +60 -0
- package/src/pages/_layout/componentManager.js +192 -0
- package/src/pages/_layout/componentRuntimeRecords.js +49 -0
- package/src/pages/_layout/inspector/componentRegistry.js +639 -0
- package/src/pages/_layout/inspector/serialize.js +134 -0
- package/src/pages/_layout/inspector/useInspector.js +293 -0
- package/src/pages/components/accordion/DomAccordion.vue +39 -0
- package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
- package/src/pages/components/button/DomButton.vue +132 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
- package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
- package/src/pages/components/dialog/DomDialog.vue +271 -0
- package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
- package/src/pages/components/dialog/DomDialogStack.vue +289 -0
- package/src/pages/components/dialog/useDialogs.js +253 -0
- package/src/pages/components/drawer/DomDrawer.vue +159 -0
- package/src/pages/components/dropdown/DomDropdown.vue +261 -0
- package/src/pages/components/icon-button/DomIconButton.vue +155 -0
- package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
- package/src/pages/components/menu/DomMenu.vue +359 -0
- package/src/pages/components/menu/DomMenuItem.vue +233 -0
- package/src/pages/components/menu/DomMenuLabel.vue +19 -0
- package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
- package/src/pages/components/menu/menuContext.js +11 -0
- package/src/pages/components/menu/menuPlacement.js +26 -0
- package/src/pages/components/menu/submenu.js +58 -0
- package/src/pages/components/pagination/DomPagination.vue +265 -0
- package/src/pages/components/playground/DomRenderer.vue +48 -0
- package/src/pages/components/popover/DomPopover.vue +244 -0
- package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
- package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
- package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
- package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
- package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
- package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
- package/src/pages/components/stepper/DomStepper.vue +386 -0
- package/src/pages/components/tabs/DomTabs.vue +149 -0
- package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
- package/src/pages/components/toast/DomToastItem.vue +52 -0
- package/src/pages/components/toast/DomToastStack.vue +768 -0
- package/src/pages/components/toast/useToasts.js +237 -0
- package/src/pages/components/toolbar/DomToolbar.vue +158 -0
- package/src/pages/components/tooltip/DomTooltip.vue +69 -0
- package/src/pages/components/tooltip/vDomTooltip.js +106 -0
- package/src/pages/components/tree-view/DomTreeView.vue +961 -0
- package/src/pages/components/tree-view/treeSchemas.js +129 -0
- package/src/pages/components/tree-view/treeUtils.js +228 -0
- package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
- package/src/pages/data/_shared/resourceRecord.js +29 -0
- package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
- package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
- package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
- package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
- package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
- package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
- package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
- package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
- package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
- package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
- package/src/pages/data/kanban/DomKanban.vue +1703 -0
- package/src/pages/data/kanban/kanbanApi.js +80 -0
- package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
- package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
- package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
- package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
- package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
- package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
- package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
- package/src/pages/forms/_shared/options.js +61 -0
- package/src/pages/forms/_shared/useOptionMenu.js +59 -0
- package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
- package/src/pages/forms/calendar/DomCalendar.vue +442 -0
- package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
- package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
- package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
- package/src/pages/forms/color-input/DomColorInput.vue +118 -0
- package/src/pages/forms/combobox/DomCombobox.vue +237 -0
- package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
- package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
- package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
- package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
- package/src/pages/forms/field/DomField.vue +267 -0
- package/src/pages/forms/field/fieldLayout.js +1 -0
- package/src/pages/forms/field/fieldProps.js +114 -0
- package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
- package/src/pages/forms/field/useField.js +343 -0
- package/src/pages/forms/field/validators.js +364 -0
- package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
- package/src/pages/forms/form/DomForm.vue +985 -0
- package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
- package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
- package/src/pages/forms/form/adapters/shared.js +53 -0
- package/src/pages/forms/form/adapters/zod.js +235 -0
- package/src/pages/forms/form/formApi.js +38 -0
- package/src/pages/forms/form/formDefinition.js +392 -0
- package/src/pages/forms/form/schemaAdapter.js +54 -0
- package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
- package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
- package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
- package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
- package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
- package/src/pages/forms/listbox/DomListbox.vue +105 -0
- package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
- package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
- package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
- package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
- package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
- package/src/pages/forms/password-strength/passwordStrength.js +92 -0
- package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
- package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
- package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
- package/src/pages/forms/select/DomSelect.vue +479 -0
- package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
- package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
- package/src/pages/forms/text-input/DomTextInput.vue +48 -0
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
- package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
- package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
- package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
- package/src/pages/forms/time-selector/timeUtils.js +194 -0
- package/src/pages/forms/toggle/DomToggle.vue +158 -0
- package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
- package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
- package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
- package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
- package/src/pages/lib/css/color.js +245 -0
- package/src/pages/lib/headless/accordion.d.ts +7 -0
- package/src/pages/lib/headless/accordion.js +246 -0
- package/src/pages/lib/headless/autocomplete.d.ts +4 -0
- package/src/pages/lib/headless/autocomplete.js +33 -0
- package/src/pages/lib/headless/base.d.ts +25 -0
- package/src/pages/lib/headless/base.js +127 -0
- package/src/pages/lib/headless/checkbox.d.ts +19 -0
- package/src/pages/lib/headless/checkbox.js +99 -0
- package/src/pages/lib/headless/combobox.d.ts +7 -0
- package/src/pages/lib/headless/combobox.js +488 -0
- package/src/pages/lib/headless/dialog.d.ts +23 -0
- package/src/pages/lib/headless/dialog.js +384 -0
- package/src/pages/lib/headless/drawer.d.ts +21 -0
- package/src/pages/lib/headless/drawer.js +682 -0
- package/src/pages/lib/headless/dropdown.d.ts +19 -0
- package/src/pages/lib/headless/dropdown.js +412 -0
- package/src/pages/lib/headless/floating-effect.js +118 -0
- package/src/pages/lib/headless/floating.d.ts +34 -0
- package/src/pages/lib/headless/floating.js +382 -0
- package/src/pages/lib/headless/index.d.ts +19 -0
- package/src/pages/lib/headless/index.js +19 -0
- package/src/pages/lib/headless/listbox.d.ts +19 -0
- package/src/pages/lib/headless/listbox.js +126 -0
- package/src/pages/lib/headless/menu.d.ts +18 -0
- package/src/pages/lib/headless/menu.js +117 -0
- package/src/pages/lib/headless/popover-panel.d.ts +18 -0
- package/src/pages/lib/headless/popover-panel.js +309 -0
- package/src/pages/lib/headless/popover.d.ts +19 -0
- package/src/pages/lib/headless/popover.js +391 -0
- package/src/pages/lib/headless/radio-group.d.ts +19 -0
- package/src/pages/lib/headless/radio-group.js +100 -0
- package/src/pages/lib/headless/tabs.d.ts +18 -0
- package/src/pages/lib/headless/tabs.js +119 -0
- package/src/pages/lib/headless/toast.d.ts +13 -0
- package/src/pages/lib/headless/toast.js +65 -0
- package/src/pages/lib/headless/toggle.d.ts +19 -0
- package/src/pages/lib/headless/toggle.js +162 -0
- package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
- package/src/pages/lib/headless/tooltip-controller.js +549 -0
- package/src/pages/lib/headless/tooltip.d.ts +19 -0
- package/src/pages/lib/headless/tooltip.js +153 -0
- package/src/pages/lib/headless/transitions.d.ts +5 -0
- package/src/pages/lib/headless/transitions.js +89 -0
- package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
- package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
- package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
- package/src/pages/lib/vue/codeHighlighter.js +132 -0
- package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
- package/src/pages/lib/vue/index.d.ts +6806 -0
- package/src/pages/lib/vue/index.js +218 -0
- package/src/pages/lib/vue/useTheme.js +116 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
- package/src/pages/mobile/shell/DomAppShell.vue +51 -0
- package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
- package/src/pages/mobile/stack/DomAppStack.vue +711 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
- package/src/pages/visual/alert/DomAlert.vue +363 -0
- package/src/pages/visual/avatar/DomAvatar.vue +110 -0
- package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
- package/src/pages/visual/badge/DomBadge.vue +105 -0
- package/src/pages/visual/card/DomCard.vue +65 -0
- package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
- package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
- package/src/pages/visual/progress/DomProgress.vue +212 -0
- package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
- package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
- package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
- package/src/pages/visual/spinner/DomSpinner.vue +122 -0
- package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
- package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
- package/src/pages/visual/text-diff/textDiff.js +300 -0
- package/src/style.css +446 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
nextTick,
|
|
5
|
+
onBeforeUnmount,
|
|
6
|
+
onMounted,
|
|
7
|
+
ref,
|
|
8
|
+
watch,
|
|
9
|
+
} from 'vue';
|
|
10
|
+
import { provideScrollMenuContext } from './scrollMenuContext.js';
|
|
11
|
+
|
|
12
|
+
defineOptions({
|
|
13
|
+
name: 'DomScrollMenu',
|
|
14
|
+
__doc: {
|
|
15
|
+
name: 'Scroll menu',
|
|
16
|
+
tag: '<DomScrollMenu>',
|
|
17
|
+
description: 'A smooth-scrolling table of contents that keeps real fragment links highlighted as their target headings move through a page or scroll container.',
|
|
18
|
+
slots: [
|
|
19
|
+
{ name: '(default)', payload: '{ activeHref }', description: 'DomScrollMenuItem rows or equivalent custom content.' },
|
|
20
|
+
{ name: 'label', payload: '{ label }', description: 'Custom visible navigation label.' },
|
|
21
|
+
],
|
|
22
|
+
events: [
|
|
23
|
+
{ name: 'update:modelValue', payload: 'href', description: 'Emitted when scrolling or navigation changes the active fragment link.' },
|
|
24
|
+
{ name: 'change', payload: '{ href, target }', description: 'Emitted with the active fragment and its target element.' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const props = defineProps({
|
|
30
|
+
modelValue: {
|
|
31
|
+
type: String,
|
|
32
|
+
default: '',
|
|
33
|
+
_edit: { description: 'Active fragment href. Use v-model when the surrounding page needs the current section.' },
|
|
34
|
+
},
|
|
35
|
+
as: {
|
|
36
|
+
type: String,
|
|
37
|
+
default: 'nav',
|
|
38
|
+
_edit: { description: 'Root element rendered around the scroll menu.' },
|
|
39
|
+
},
|
|
40
|
+
listAs: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: 'ul',
|
|
43
|
+
_edit: { description: 'Element rendered around the menu items.' },
|
|
44
|
+
},
|
|
45
|
+
label: {
|
|
46
|
+
type: String,
|
|
47
|
+
default: 'On this page',
|
|
48
|
+
_edit: { description: 'Accessible navigation label and default visible heading.' },
|
|
49
|
+
},
|
|
50
|
+
showLabel: {
|
|
51
|
+
type: Boolean,
|
|
52
|
+
default: true,
|
|
53
|
+
_edit: { description: 'Show the visual navigation label above the item list.' },
|
|
54
|
+
},
|
|
55
|
+
scrollRoot: {
|
|
56
|
+
type: [String, Object],
|
|
57
|
+
default: null,
|
|
58
|
+
_edit: { description: 'Optional scroll-container element or CSS selector. By default the nearest scrollable ancestor of the first target is used.' },
|
|
59
|
+
},
|
|
60
|
+
scrollBehavior: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: 'smooth',
|
|
63
|
+
validator: (value) => ['auto', 'smooth'].includes(value),
|
|
64
|
+
_edit: { description: 'Fragment navigation behavior. Smooth scrolling respects the user’s reduced-motion preference.' },
|
|
65
|
+
},
|
|
66
|
+
rootMargin: {
|
|
67
|
+
type: String,
|
|
68
|
+
default: '0px 0px -70% 0px',
|
|
69
|
+
_edit: { description: 'IntersectionObserver root margin defining the activation band. Use a negative top value to account for sticky headers.' },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const emit = defineEmits(['update:modelValue', 'change']);
|
|
74
|
+
const menuRoot = ref(null);
|
|
75
|
+
const internalActiveHref = ref('');
|
|
76
|
+
const registrationVersion = ref(0);
|
|
77
|
+
const registeredItems = new Map();
|
|
78
|
+
const visibleTargets = new Map();
|
|
79
|
+
let intersectionObserver = null;
|
|
80
|
+
let observationRun = 0;
|
|
81
|
+
let mounted = false;
|
|
82
|
+
let scrollBehaviorRoot = null;
|
|
83
|
+
let previousScrollBehavior = '';
|
|
84
|
+
let scrollBehaviorRestoreTimer = null;
|
|
85
|
+
|
|
86
|
+
const activeHref = computed(() => props.modelValue || internalActiveHref.value);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns a nested item's current fragment href.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} item Registered item contract.
|
|
92
|
+
* @returns {string} Current fragment href.
|
|
93
|
+
*/
|
|
94
|
+
function itemHref(item) {
|
|
95
|
+
return String(item.getHref?.() || '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reports whether a registered item is currently disabled.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} item Registered item contract.
|
|
102
|
+
* @returns {boolean} Whether the item is disabled.
|
|
103
|
+
*/
|
|
104
|
+
function itemDisabled(item) {
|
|
105
|
+
return Boolean(item.isDisabled?.());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolves a fragment href to its target element without CSS-selector escaping.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} href Fragment href such as #installation.
|
|
112
|
+
* @returns {HTMLElement|null} Matching document element.
|
|
113
|
+
*/
|
|
114
|
+
function targetForHref(href) {
|
|
115
|
+
if (typeof document === 'undefined' || !href.startsWith('#')) return null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
return document.getElementById(decodeURIComponent(href.slice(1)));
|
|
119
|
+
} catch {
|
|
120
|
+
return document.getElementById(href.slice(1));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Builds the ordered set of enabled menu items whose targets currently exist.
|
|
126
|
+
*
|
|
127
|
+
* @returns {Array<{ href: string, target: HTMLElement }>} Resolved scroll targets.
|
|
128
|
+
*/
|
|
129
|
+
function resolvedTargets() {
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const targets = [];
|
|
132
|
+
|
|
133
|
+
for (const item of registeredItems.values()) {
|
|
134
|
+
const href = itemHref(item);
|
|
135
|
+
if (!href || seen.has(href) || itemDisabled(item)) continue;
|
|
136
|
+
const target = targetForHref(href);
|
|
137
|
+
if (!target) continue;
|
|
138
|
+
seen.add(href);
|
|
139
|
+
targets.push({ href, target });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return targets;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Reports whether an element can provide vertical scrolling for its descendants.
|
|
147
|
+
*
|
|
148
|
+
* @param {HTMLElement} element Candidate scroll container.
|
|
149
|
+
* @returns {boolean} Whether the element is a vertical scroll container.
|
|
150
|
+
*/
|
|
151
|
+
function isScrollableElement(element) {
|
|
152
|
+
if (typeof window === 'undefined') return false;
|
|
153
|
+
const overflowY = window.getComputedStyle(element).overflowY;
|
|
154
|
+
return /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Finds the nearest vertical scroll container for a target element.
|
|
159
|
+
*
|
|
160
|
+
* @param {HTMLElement|null} target Observed heading or section.
|
|
161
|
+
* @returns {HTMLElement|null} Scroll container, or null for the viewport.
|
|
162
|
+
*/
|
|
163
|
+
function nearestScrollRoot(target) {
|
|
164
|
+
let element = target?.parentElement || null;
|
|
165
|
+
|
|
166
|
+
while (element && element !== document.documentElement) {
|
|
167
|
+
if (isScrollableElement(element)) return element;
|
|
168
|
+
element = element.parentElement;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolves an explicit scroll root or discovers one from the first target.
|
|
176
|
+
*
|
|
177
|
+
* @param {Array<{ href: string, target: HTMLElement }>} targets Resolved target records.
|
|
178
|
+
* @returns {HTMLElement|null} IntersectionObserver root, or null for the viewport.
|
|
179
|
+
*/
|
|
180
|
+
function resolveScrollRoot(targets) {
|
|
181
|
+
if (typeof document === 'undefined') return null;
|
|
182
|
+
if (typeof props.scrollRoot === 'string') {
|
|
183
|
+
return document.querySelector(props.scrollRoot);
|
|
184
|
+
}
|
|
185
|
+
if (props.scrollRoot instanceof Element) return props.scrollRoot;
|
|
186
|
+
return nearestScrollRoot(targets[0]?.target || null);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Commits a new active fragment and tells v-model consumers about the change.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} href Active fragment href.
|
|
193
|
+
* @returns {void}
|
|
194
|
+
*/
|
|
195
|
+
function setActiveHref(href) {
|
|
196
|
+
if (!href || activeHref.value === href) return;
|
|
197
|
+
internalActiveHref.value = href;
|
|
198
|
+
const target = targetForHref(href);
|
|
199
|
+
emit('update:modelValue', href);
|
|
200
|
+
emit('change', { href, target });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Selects the closest visible heading inside the observer activation band.
|
|
205
|
+
*
|
|
206
|
+
* @param {IntersectionObserverEntry[]} entries Changed intersection records.
|
|
207
|
+
* @param {IntersectionObserver} observer Active observer.
|
|
208
|
+
* @returns {void}
|
|
209
|
+
*/
|
|
210
|
+
function handleIntersections(entries, observer) {
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
visibleTargets.set(entry.target, entry.isIntersecting);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rootTop = observer.root?.getBoundingClientRect().top || 0;
|
|
216
|
+
const candidate = resolvedTargets()
|
|
217
|
+
.filter(({ target }) => visibleTargets.get(target))
|
|
218
|
+
.sort((left, right) => (
|
|
219
|
+
Math.abs(left.target.getBoundingClientRect().top - rootTop)
|
|
220
|
+
- Math.abs(right.target.getBoundingClientRect().top - rootTop)
|
|
221
|
+
))[0];
|
|
222
|
+
|
|
223
|
+
if (candidate) setActiveHref(candidate.href);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Disconnects the active observer and clears its visible-target state.
|
|
228
|
+
*
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
function stopObservation() {
|
|
232
|
+
intersectionObserver?.disconnect();
|
|
233
|
+
intersectionObserver = null;
|
|
234
|
+
visibleTargets.clear();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Starts observing the current set of fragment targets.
|
|
239
|
+
*
|
|
240
|
+
* @returns {void}
|
|
241
|
+
*/
|
|
242
|
+
function startObservation() {
|
|
243
|
+
stopObservation();
|
|
244
|
+
const targets = resolvedTargets();
|
|
245
|
+
if (!targets.length) return;
|
|
246
|
+
|
|
247
|
+
const hashTarget = targets.find(({ href }) => href === window.location.hash);
|
|
248
|
+
setActiveHref(hashTarget?.href || activeHref.value || targets[0].href);
|
|
249
|
+
|
|
250
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
251
|
+
const root = resolveScrollRoot(targets);
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
intersectionObserver = new IntersectionObserver(handleIntersections, {
|
|
255
|
+
root,
|
|
256
|
+
rootMargin: props.rootMargin,
|
|
257
|
+
threshold: 0,
|
|
258
|
+
});
|
|
259
|
+
} catch {
|
|
260
|
+
intersectionObserver = new IntersectionObserver(handleIntersections, {
|
|
261
|
+
root,
|
|
262
|
+
rootMargin: '0px 0px -70% 0px',
|
|
263
|
+
threshold: 0,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const { target } of targets) intersectionObserver.observe(target);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Refreshes observation after Vue has applied item or target changes.
|
|
272
|
+
*
|
|
273
|
+
* @returns {Promise<void>}
|
|
274
|
+
*/
|
|
275
|
+
async function refreshObservation() {
|
|
276
|
+
const run = ++observationRun;
|
|
277
|
+
await nextTick();
|
|
278
|
+
if (!mounted || run !== observationRun) return;
|
|
279
|
+
startObservation();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Registers a nested DomScrollMenuItem with this scrollspy.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ token: symbol, getHref: Function, isDisabled: Function }} item Nested item contract.
|
|
286
|
+
* @returns {Function} Cleanup callback that unregisters the item.
|
|
287
|
+
*/
|
|
288
|
+
function registerItem(item) {
|
|
289
|
+
registeredItems.set(item.token, item);
|
|
290
|
+
registrationVersion.value += 1;
|
|
291
|
+
|
|
292
|
+
return () => {
|
|
293
|
+
registeredItems.delete(item.token);
|
|
294
|
+
registrationVersion.value += 1;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Requests target resolution again after an item prop changes.
|
|
300
|
+
*
|
|
301
|
+
* @returns {void}
|
|
302
|
+
*/
|
|
303
|
+
function refreshItems() {
|
|
304
|
+
registrationVersion.value += 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reports whether a fragment link is the current scrollspy selection.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} href Fragment href.
|
|
311
|
+
* @returns {boolean} Whether the link is active.
|
|
312
|
+
*/
|
|
313
|
+
function isItemActive(href) {
|
|
314
|
+
return activeHref.value === href;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Restores the scroll root's inline behavior after native fragment navigation starts.
|
|
319
|
+
*
|
|
320
|
+
* @returns {void}
|
|
321
|
+
*/
|
|
322
|
+
function restoreScrollBehavior() {
|
|
323
|
+
if (scrollBehaviorRestoreTimer !== null) {
|
|
324
|
+
window.clearTimeout(scrollBehaviorRestoreTimer);
|
|
325
|
+
scrollBehaviorRestoreTimer = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (scrollBehaviorRoot?.style.scrollBehavior === 'smooth') {
|
|
329
|
+
scrollBehaviorRoot.style.scrollBehavior = previousScrollBehavior;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
scrollBehaviorRoot = null;
|
|
333
|
+
previousScrollBehavior = '';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Temporarily enables native smooth scrolling on the resolved scroll root.
|
|
338
|
+
*
|
|
339
|
+
* @param {string} href Fragment href.
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
342
|
+
function prepareScrollBehavior(href) {
|
|
343
|
+
restoreScrollBehavior();
|
|
344
|
+
if (props.scrollBehavior !== 'smooth') return;
|
|
345
|
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
|
346
|
+
|
|
347
|
+
const target = targetForHref(href);
|
|
348
|
+
if (!target) return;
|
|
349
|
+
const root = resolveScrollRoot([{ href, target }]) || document.documentElement;
|
|
350
|
+
if (!root?.style) return;
|
|
351
|
+
|
|
352
|
+
scrollBehaviorRoot = root;
|
|
353
|
+
previousScrollBehavior = root.style.scrollBehavior;
|
|
354
|
+
root.style.scrollBehavior = 'smooth';
|
|
355
|
+
scrollBehaviorRestoreTimer = window.setTimeout(restoreScrollBehavior, 1000);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Selects an item and prepares its scroll root for native anchor navigation.
|
|
360
|
+
*
|
|
361
|
+
* @param {string} href Fragment href.
|
|
362
|
+
* @returns {void}
|
|
363
|
+
*/
|
|
364
|
+
function activateItem(href) {
|
|
365
|
+
setActiveHref(href);
|
|
366
|
+
prepareScrollBehavior(href);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Syncs selection when browser history changes the page fragment.
|
|
371
|
+
*
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
function handleHashChange() {
|
|
375
|
+
const href = window.location.hash;
|
|
376
|
+
const ownsHref = resolvedTargets().some((item) => item.href === href);
|
|
377
|
+
if (ownsHref) setActiveHref(href);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
provideScrollMenuContext({
|
|
381
|
+
activateItem,
|
|
382
|
+
isItemActive,
|
|
383
|
+
refreshItems,
|
|
384
|
+
registerItem,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
watch(registrationVersion, refreshObservation);
|
|
388
|
+
watch(() => [props.scrollRoot, props.rootMargin], refreshObservation);
|
|
389
|
+
watch(() => props.scrollBehavior, restoreScrollBehavior);
|
|
390
|
+
watch(() => props.modelValue, (value) => {
|
|
391
|
+
if (value) internalActiveHref.value = value;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
onMounted(() => {
|
|
395
|
+
mounted = true;
|
|
396
|
+
window.addEventListener('hashchange', handleHashChange);
|
|
397
|
+
refreshObservation();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
onBeforeUnmount(() => {
|
|
401
|
+
mounted = false;
|
|
402
|
+
observationRun += 1;
|
|
403
|
+
window.removeEventListener('hashchange', handleHashChange);
|
|
404
|
+
restoreScrollBehavior();
|
|
405
|
+
stopObservation();
|
|
406
|
+
});
|
|
407
|
+
</script>
|
|
408
|
+
|
|
409
|
+
<template>
|
|
410
|
+
<component
|
|
411
|
+
:is="as"
|
|
412
|
+
ref="menuRoot"
|
|
413
|
+
:aria-label="label"
|
|
414
|
+
:role="as === 'nav' ? undefined : 'navigation'"
|
|
415
|
+
class="min-w-0"
|
|
416
|
+
>
|
|
417
|
+
<div
|
|
418
|
+
v-if="showLabel"
|
|
419
|
+
class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-muted-fg"
|
|
420
|
+
>
|
|
421
|
+
<slot name="label" :label="label">{{ label }}</slot>
|
|
422
|
+
</div>
|
|
423
|
+
<component
|
|
424
|
+
:is="listAs"
|
|
425
|
+
class="border-l border-border"
|
|
426
|
+
>
|
|
427
|
+
<slot :active-href="activeHref" />
|
|
428
|
+
</component>
|
|
429
|
+
</component>
|
|
430
|
+
</template>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
onBeforeUnmount,
|
|
5
|
+
onMounted,
|
|
6
|
+
onUpdated,
|
|
7
|
+
ref,
|
|
8
|
+
watch,
|
|
9
|
+
} from 'vue';
|
|
10
|
+
import { useScrollMenuContext } from './scrollMenuContext.js';
|
|
11
|
+
|
|
12
|
+
defineOptions({
|
|
13
|
+
name: 'DomScrollMenuItem',
|
|
14
|
+
__doc: {
|
|
15
|
+
name: 'Scroll menu item',
|
|
16
|
+
tag: '<DomScrollMenuItem>',
|
|
17
|
+
description: 'A fragment-link wrapper that registers either its href prop or a slotted anchor with the parent DomScrollMenu scrollspy.',
|
|
18
|
+
slots: [
|
|
19
|
+
{ name: '(default)', payload: '{ active, href }', description: 'Link contents when href is set, or one custom fragment anchor when href is omitted.' },
|
|
20
|
+
],
|
|
21
|
+
events: [
|
|
22
|
+
{ name: 'navigate', payload: '{ href, active }', description: 'Emitted when the fragment link is activated with an unmodified primary click.' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const props = defineProps({
|
|
28
|
+
href: {
|
|
29
|
+
type: String,
|
|
30
|
+
default: '',
|
|
31
|
+
validator: (value) => !value || (value.startsWith('#') && value.length > 1),
|
|
32
|
+
_edit: { description: 'Optional fragment href shorthand, including the leading #. Omit it when the slot provides its own anchor.' },
|
|
33
|
+
},
|
|
34
|
+
as: {
|
|
35
|
+
type: String,
|
|
36
|
+
default: 'li',
|
|
37
|
+
_edit: { description: 'Element wrapping the anchor. Use li with the default DomScrollMenu list.' },
|
|
38
|
+
},
|
|
39
|
+
depth: {
|
|
40
|
+
type: Number,
|
|
41
|
+
default: 0,
|
|
42
|
+
_edit: { description: 'Visual nesting depth for subordinate page headings.' },
|
|
43
|
+
},
|
|
44
|
+
disabled: {
|
|
45
|
+
type: Boolean,
|
|
46
|
+
default: false,
|
|
47
|
+
_edit: { description: 'Remove the fragment link from navigation and scrollspy selection.' },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const emit = defineEmits(['navigate']);
|
|
52
|
+
const context = useScrollMenuContext();
|
|
53
|
+
const itemToken = Symbol('DomScrollMenuItem');
|
|
54
|
+
const itemRoot = ref(null);
|
|
55
|
+
const slottedHref = ref('');
|
|
56
|
+
let slottedAnchor = null;
|
|
57
|
+
const depthClasses = {
|
|
58
|
+
0: '[&>a]:pl-4',
|
|
59
|
+
1: '[&>a]:pl-7',
|
|
60
|
+
2: '[&>a]:pl-10',
|
|
61
|
+
3: '[&>a]:pl-13',
|
|
62
|
+
};
|
|
63
|
+
const resolvedHref = computed(() => props.href || slottedHref.value);
|
|
64
|
+
const active = computed(() => Boolean(context?.isItemActive(resolvedHref.value)));
|
|
65
|
+
const itemClasses = computed(() => [
|
|
66
|
+
'min-w-0',
|
|
67
|
+
'[&>a]:-ml-px [&>a]:block [&>a]:border-l [&>a]:py-1.5 [&>a]:pr-3 [&>a]:text-sm [&>a]:leading-5 [&>a]:outline-none [&>a]:transition',
|
|
68
|
+
'[&>a:focus-visible]:rounded-r-md [&>a:focus-visible]:ring-2 [&>a:focus-visible]:ring-ring/50',
|
|
69
|
+
depthClasses[Math.min(Math.max(props.depth, 0), 3)] || depthClasses[0],
|
|
70
|
+
active.value
|
|
71
|
+
? '[&>a]:border-primary [&>a]:font-semibold [&>a]:text-canvas-fg'
|
|
72
|
+
: '[&>a]:border-transparent [&>a]:font-normal [&>a]:text-muted-fg [&>a:hover]:border-border [&>a:hover]:text-canvas-fg',
|
|
73
|
+
props.disabled && '[&>a]:cursor-not-allowed [&>a]:opacity-50',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the item's current fragment href for parent registration.
|
|
78
|
+
*
|
|
79
|
+
* @returns {string} Fragment href.
|
|
80
|
+
*/
|
|
81
|
+
function getHref() {
|
|
82
|
+
return resolvedHref.value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns the item's current disabled state for parent registration.
|
|
87
|
+
*
|
|
88
|
+
* @returns {boolean} Whether the item is disabled.
|
|
89
|
+
*/
|
|
90
|
+
function isDisabled() {
|
|
91
|
+
return props.disabled;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Removes scroll-menu-owned state from a previously discovered slotted anchor.
|
|
96
|
+
*
|
|
97
|
+
* @param {HTMLAnchorElement|null} anchor Previously managed anchor.
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
function clearSlottedAnchorState(anchor) {
|
|
101
|
+
if (!anchor) return;
|
|
102
|
+
if (anchor.getAttribute('aria-current') === 'location') anchor.removeAttribute('aria-current');
|
|
103
|
+
anchor.removeAttribute('aria-disabled');
|
|
104
|
+
anchor.removeAttribute('data-active');
|
|
105
|
+
anchor.removeAttribute('data-dom-scroll-menu-link');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Applies current-location and disabled semantics to the discovered slot anchor.
|
|
110
|
+
*
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
function syncSlottedAnchorState() {
|
|
114
|
+
if (!slottedAnchor) return;
|
|
115
|
+
slottedAnchor.toggleAttribute('data-active', active.value);
|
|
116
|
+
slottedAnchor.setAttribute('data-dom-scroll-menu-link', '');
|
|
117
|
+
|
|
118
|
+
if (active.value) slottedAnchor.setAttribute('aria-current', 'location');
|
|
119
|
+
else if (slottedAnchor.getAttribute('aria-current') === 'location') slottedAnchor.removeAttribute('aria-current');
|
|
120
|
+
|
|
121
|
+
if (props.disabled) slottedAnchor.setAttribute('aria-disabled', 'true');
|
|
122
|
+
else slottedAnchor.removeAttribute('aria-disabled');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Discovers the first fragment anchor rendered directly through the default slot.
|
|
127
|
+
*
|
|
128
|
+
* @returns {void}
|
|
129
|
+
*/
|
|
130
|
+
function discoverSlottedAnchor() {
|
|
131
|
+
if (props.href || !itemRoot.value) {
|
|
132
|
+
clearSlottedAnchorState(slottedAnchor);
|
|
133
|
+
slottedAnchor = null;
|
|
134
|
+
if (slottedHref.value) slottedHref.value = '';
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const nextAnchor = itemRoot.value.querySelector('a[href^="#"]');
|
|
139
|
+
if (nextAnchor !== slottedAnchor) {
|
|
140
|
+
clearSlottedAnchorState(slottedAnchor);
|
|
141
|
+
slottedAnchor = nextAnchor;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const nextHref = slottedAnchor?.getAttribute('href') || '';
|
|
145
|
+
if (nextHref !== slottedHref.value) {
|
|
146
|
+
slottedHref.value = nextHref;
|
|
147
|
+
context?.refreshItems();
|
|
148
|
+
}
|
|
149
|
+
syncSlottedAnchorState();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Handles selection while leaving scrolling and URL updates to the real anchor.
|
|
154
|
+
*
|
|
155
|
+
* @param {MouseEvent} event Anchor click event.
|
|
156
|
+
* @returns {void}
|
|
157
|
+
*/
|
|
158
|
+
function handleClick(event) {
|
|
159
|
+
const anchor = event.target.closest?.('a[href^="#"]');
|
|
160
|
+
if (!anchor || !itemRoot.value?.contains(anchor)) return;
|
|
161
|
+
if (props.disabled) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
166
|
+
const href = anchor.getAttribute('href') || resolvedHref.value;
|
|
167
|
+
context?.activateItem(href);
|
|
168
|
+
emit('navigate', { href, active: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const unregister = context?.registerItem({
|
|
172
|
+
token: itemToken,
|
|
173
|
+
getHref,
|
|
174
|
+
isDisabled,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
watch(() => [props.href, props.disabled], () => {
|
|
178
|
+
discoverSlottedAnchor();
|
|
179
|
+
context?.refreshItems();
|
|
180
|
+
});
|
|
181
|
+
watch(active, syncSlottedAnchorState);
|
|
182
|
+
|
|
183
|
+
onMounted(discoverSlottedAnchor);
|
|
184
|
+
onUpdated(discoverSlottedAnchor);
|
|
185
|
+
|
|
186
|
+
onBeforeUnmount(() => {
|
|
187
|
+
clearSlottedAnchorState(slottedAnchor);
|
|
188
|
+
unregister?.();
|
|
189
|
+
});
|
|
190
|
+
</script>
|
|
191
|
+
|
|
192
|
+
<template>
|
|
193
|
+
<component
|
|
194
|
+
:is="as"
|
|
195
|
+
ref="itemRoot"
|
|
196
|
+
:data-active="active ? '' : undefined"
|
|
197
|
+
:class="itemClasses"
|
|
198
|
+
@click="handleClick"
|
|
199
|
+
>
|
|
200
|
+
<a
|
|
201
|
+
v-if="href"
|
|
202
|
+
:href="disabled ? undefined : href"
|
|
203
|
+
:aria-current="active ? 'location' : undefined"
|
|
204
|
+
:aria-disabled="disabled ? 'true' : undefined"
|
|
205
|
+
:tabindex="disabled ? -1 : undefined"
|
|
206
|
+
:data-active="active ? '' : undefined"
|
|
207
|
+
>
|
|
208
|
+
<slot :active="active" :href="resolvedHref" />
|
|
209
|
+
</a>
|
|
210
|
+
<slot v-else :active="active" :href="resolvedHref" />
|
|
211
|
+
</component>
|
|
212
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { inject, provide } from 'vue';
|
|
2
|
+
|
|
3
|
+
export const scrollMenuContextKey = Symbol('DomScrollMenuContext');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provides the parent scroll-menu contract to nested items.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} context Scroll-menu registration and selection contract.
|
|
9
|
+
* @returns {void}
|
|
10
|
+
*/
|
|
11
|
+
export function provideScrollMenuContext(context) {
|
|
12
|
+
provide(scrollMenuContextKey, context);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reads the nearest parent scroll-menu contract.
|
|
17
|
+
*
|
|
18
|
+
* @returns {object|null} Parent scroll-menu contract when available.
|
|
19
|
+
*/
|
|
20
|
+
export function useScrollMenuContext() {
|
|
21
|
+
return inject(scrollMenuContextKey, null);
|
|
22
|
+
}
|