@mkbabb/glass-ui 0.2.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/README.md +172 -0
- package/dist/glass-ui.css +1 -0
- package/dist/glass-ui.js +10019 -0
- package/dist/index.d.ts +6619 -0
- package/package.json +65 -0
- package/src/components/custom/aurora/Aurora.vue +34 -0
- package/src/components/custom/aurora/composables/color.ts +122 -0
- package/src/components/custom/aurora/composables/useAurora.ts +355 -0
- package/src/components/custom/aurora/index.ts +8 -0
- package/src/components/custom/confirm-dialog/ConfirmDialog.vue +88 -0
- package/src/components/custom/confirm-dialog/index.ts +1 -0
- package/src/components/custom/controls/DarkModeToggle.vue +96 -0
- package/src/components/custom/controls/index.ts +1 -0
- package/src/components/custom/dock/DockLayerGroup.vue +21 -0
- package/src/components/custom/dock/DockPopover.vue +263 -0
- package/src/components/custom/dock/GlassDock.vue +276 -0
- package/src/components/custom/dock/composables/index.ts +16 -0
- package/src/components/custom/dock/composables/isTeleportedTarget.ts +19 -0
- package/src/components/custom/dock/composables/useDockActionBar.ts +33 -0
- package/src/components/custom/dock/composables/useDockState.ts +301 -0
- package/src/components/custom/dock/composables/useDockTransition.ts +146 -0
- package/src/components/custom/dock/composables/useLayerTransition.ts +135 -0
- package/src/components/custom/dock/composables/usePopupMutex.ts +83 -0
- package/src/components/custom/dock/index.ts +9 -0
- package/src/components/custom/expandable-container/ExpandableContainer.vue +64 -0
- package/src/components/custom/expandable-container/index.ts +1 -0
- package/src/components/custom/glass-panel/GlassPanel.vue +98 -0
- package/src/components/custom/glass-panel/index.ts +2 -0
- package/src/components/custom/icon-tooltip/IconTooltip.vue +20 -0
- package/src/components/custom/icon-tooltip/index.ts +1 -0
- package/src/components/custom/index.ts +15 -0
- package/src/components/custom/infinite-scroll/InfiniteScroll.vue +55 -0
- package/src/components/custom/infinite-scroll/composables/index.ts +2 -0
- package/src/components/custom/infinite-scroll/composables/types.ts +23 -0
- package/src/components/custom/infinite-scroll/composables/useInfiniteScroll.ts +73 -0
- package/src/components/custom/infinite-scroll/index.ts +1 -0
- package/src/components/custom/labeled-field/LabeledInput.vue +29 -0
- package/src/components/custom/labeled-field/LabeledSelect.vue +59 -0
- package/src/components/custom/labeled-field/LabeledSlider.vue +32 -0
- package/src/components/custom/labeled-field/LabeledSwitch.vue +27 -0
- package/src/components/custom/labeled-field/index.ts +4 -0
- package/src/components/custom/metaballs/MetaballCanvas.vue +23 -0
- package/src/components/custom/metaballs/index.ts +4 -0
- package/src/components/custom/metaballs/shaders.ts +63 -0
- package/src/components/custom/metaballs/types.ts +29 -0
- package/src/components/custom/metaballs/useMetaballs.ts +252 -0
- package/src/components/custom/search/FuzzySearch.vue +589 -0
- package/src/components/custom/search/SearchBar.vue +44 -0
- package/src/components/custom/search/composables/fuzzySearchIndex.ts +224 -0
- package/src/components/custom/search/composables/index.ts +5 -0
- package/src/components/custom/search/composables/types.ts +34 -0
- package/src/components/custom/search/composables/useFuzzySearch.ts +115 -0
- package/src/components/custom/search/index.ts +7 -0
- package/src/components/custom/sidebar/ProgressiveSidebar.vue +256 -0
- package/src/components/custom/sidebar/composables/index.ts +6 -0
- package/src/components/custom/sidebar/composables/useScrollTracker.ts +242 -0
- package/src/components/custom/sidebar/composables/useSidebarFollow.ts +247 -0
- package/src/components/custom/sidebar/composables/useSidebarState.ts +72 -0
- package/src/components/custom/sidebar/composables/useTreeIndex.ts +152 -0
- package/src/components/custom/sidebar/index.ts +15 -0
- package/src/components/custom/sidebar/types.ts +50 -0
- package/src/components/custom/tabs/BouncyTabs.vue +39 -0
- package/src/components/custom/tabs/BouncyToggle.vue +352 -0
- package/src/components/custom/tabs/UnderlineTabs.vue +115 -0
- package/src/components/custom/tabs/index.ts +5 -0
- package/src/components/custom/timeline/GlassTimeline.vue +174 -0
- package/src/components/custom/timeline/index.ts +1 -0
- package/src/components/custom/typewriter/TypewriterText.vue +239 -0
- package/src/components/custom/typewriter/composables/index.ts +1 -0
- package/src/components/custom/typewriter/composables/useTypewriter.ts +413 -0
- package/src/components/custom/typewriter/index.ts +7 -0
- package/src/components/custom/typewriter/types.ts +159 -0
- package/src/components/custom/typewriter/utils/keyboard.ts +213 -0
- package/src/components/custom/typewriter/utils/pausePatterns.ts +55 -0
- package/src/components/custom/typewriter/utils/timing.ts +104 -0
- package/src/components/custom/typewriter/utils/typoStateMachine.ts +197 -0
- package/src/components/index.ts +2 -0
- package/src/components/ui/accordion/Accordion.vue +19 -0
- package/src/components/ui/accordion/AccordionContent.vue +24 -0
- package/src/components/ui/accordion/AccordionItem.vue +24 -0
- package/src/components/ui/accordion/AccordionTrigger.vue +39 -0
- package/src/components/ui/accordion/index.ts +4 -0
- package/src/components/ui/alert/Alert.vue +20 -0
- package/src/components/ui/alert/AlertDescription.vue +17 -0
- package/src/components/ui/alert/AlertTitle.vue +17 -0
- package/src/components/ui/alert/index.ts +23 -0
- package/src/components/ui/avatar/Avatar.vue +21 -0
- package/src/components/ui/avatar/AvatarFallback.vue +11 -0
- package/src/components/ui/avatar/AvatarImage.vue +9 -0
- package/src/components/ui/avatar/index.ts +24 -0
- package/src/components/ui/badge/Badge.vue +16 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +26 -0
- package/src/components/ui/button/index.ts +43 -0
- package/src/components/ui/card/Card.vue +28 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +21 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/carousel/Carousel.vue +53 -0
- package/src/components/ui/carousel/CarouselContent.vue +35 -0
- package/src/components/ui/carousel/CarouselItem.vue +24 -0
- package/src/components/ui/carousel/CarouselNext.vue +40 -0
- package/src/components/ui/carousel/CarouselPrevious.vue +40 -0
- package/src/components/ui/carousel/index.ts +10 -0
- package/src/components/ui/carousel/interface.ts +26 -0
- package/src/components/ui/carousel/useCarousel.ts +56 -0
- package/src/components/ui/checkbox/Checkbox.vue +33 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/collapsible/Collapsible.vue +15 -0
- package/src/components/ui/collapsible/CollapsibleContent.vue +11 -0
- package/src/components/ui/collapsible/CollapsibleTrigger.vue +11 -0
- package/src/components/ui/collapsible/index.ts +3 -0
- package/src/components/ui/combobox/Combobox.vue +17 -0
- package/src/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/src/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/src/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/src/components/ui/combobox/ComboboxInput.vue +41 -0
- package/src/components/ui/combobox/ComboboxItem.vue +24 -0
- package/src/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/src/components/ui/combobox/ComboboxList.vue +29 -0
- package/src/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/src/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/src/components/ui/combobox/index.ts +12 -0
- package/src/components/ui/command/Command.vue +30 -0
- package/src/components/ui/command/CommandDialog.vue +21 -0
- package/src/components/ui/command/CommandEmpty.vue +20 -0
- package/src/components/ui/command/CommandGroup.vue +29 -0
- package/src/components/ui/command/CommandInput.vue +33 -0
- package/src/components/ui/command/CommandItem.vue +26 -0
- package/src/components/ui/command/CommandList.vue +27 -0
- package/src/components/ui/command/CommandSeparator.vue +23 -0
- package/src/components/ui/command/CommandShortcut.vue +14 -0
- package/src/components/ui/command/index.ts +9 -0
- package/src/components/ui/context-menu/ContextMenu.vue +15 -0
- package/src/components/ui/context-menu/ContextMenuCheckboxItem.vue +40 -0
- package/src/components/ui/context-menu/ContextMenuContent.vue +36 -0
- package/src/components/ui/context-menu/ContextMenuGroup.vue +11 -0
- package/src/components/ui/context-menu/ContextMenuItem.vue +34 -0
- package/src/components/ui/context-menu/ContextMenuLabel.vue +25 -0
- package/src/components/ui/context-menu/ContextMenuPortal.vue +11 -0
- package/src/components/ui/context-menu/ContextMenuRadioGroup.vue +19 -0
- package/src/components/ui/context-menu/ContextMenuRadioItem.vue +40 -0
- package/src/components/ui/context-menu/ContextMenuSeparator.vue +20 -0
- package/src/components/ui/context-menu/ContextMenuShortcut.vue +14 -0
- package/src/components/ui/context-menu/ContextMenuSub.vue +19 -0
- package/src/components/ui/context-menu/ContextMenuSubContent.vue +35 -0
- package/src/components/ui/context-menu/ContextMenuSubTrigger.vue +34 -0
- package/src/components/ui/context-menu/ContextMenuTrigger.vue +13 -0
- package/src/components/ui/context-menu/index.ts +14 -0
- package/src/components/ui/data-table/DataTable.vue +167 -0
- package/src/components/ui/data-table/DataTablePagination.vue +112 -0
- package/src/components/ui/data-table/index.ts +3 -0
- package/src/components/ui/data-table/types.ts +48 -0
- package/src/components/ui/dialog/Dialog.vue +14 -0
- package/src/components/ui/dialog/DialogClose.vue +11 -0
- package/src/components/ui/dialog/DialogContent.vue +61 -0
- package/src/components/ui/dialog/DialogDescription.vue +24 -0
- package/src/components/ui/dialog/DialogFooter.vue +19 -0
- package/src/components/ui/dialog/DialogHeader.vue +16 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +65 -0
- package/src/components/ui/dialog/DialogTitle.vue +29 -0
- package/src/components/ui/dialog/DialogTrigger.vue +11 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/drawer/Drawer.vue +19 -0
- package/src/components/ui/drawer/DrawerContent.vue +28 -0
- package/src/components/ui/drawer/DrawerDescription.vue +20 -0
- package/src/components/ui/drawer/DrawerFooter.vue +14 -0
- package/src/components/ui/drawer/DrawerHeader.vue +14 -0
- package/src/components/ui/drawer/DrawerOverlay.vue +18 -0
- package/src/components/ui/drawer/DrawerTitle.vue +20 -0
- package/src/components/ui/drawer/index.ts +8 -0
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +40 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +44 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +11 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +28 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +24 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +40 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +36 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +33 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +13 -0
- package/src/components/ui/dropdown-menu/index.ts +16 -0
- package/src/components/ui/hover-card/HoverCard.vue +14 -0
- package/src/components/ui/hover-card/HoverCardContent.vue +41 -0
- package/src/components/ui/hover-card/HoverCardTrigger.vue +11 -0
- package/src/components/ui/hover-card/index.ts +3 -0
- package/src/components/ui/index.ts +41 -0
- package/src/components/ui/input/Input.vue +24 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/label/Label.vue +27 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/multi-select/MultiSelect.vue +141 -0
- package/src/components/ui/multi-select/index.ts +7 -0
- package/src/components/ui/notification/Notification.vue +85 -0
- package/src/components/ui/notification/index.ts +1 -0
- package/src/components/ui/number-field/NumberField.vue +23 -0
- package/src/components/ui/number-field/NumberFieldContent.vue +14 -0
- package/src/components/ui/number-field/NumberFieldDecrement.vue +25 -0
- package/src/components/ui/number-field/NumberFieldIncrement.vue +25 -0
- package/src/components/ui/number-field/NumberFieldInput.vue +8 -0
- package/src/components/ui/number-field/index.ts +5 -0
- package/src/components/ui/popover/Popover.vue +15 -0
- package/src/components/ui/popover/PopoverContent.vue +61 -0
- package/src/components/ui/popover/PopoverTrigger.vue +11 -0
- package/src/components/ui/popover/index.ts +3 -0
- package/src/components/ui/progress/Progress.vue +39 -0
- package/src/components/ui/progress/index.ts +1 -0
- package/src/components/ui/radio-group/RadioGroup.vue +25 -0
- package/src/components/ui/radio-group/RadioGroupItem.vue +39 -0
- package/src/components/ui/radio-group/index.ts +2 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +29 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +30 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/scroll-pane/ScrollPane.vue +25 -0
- package/src/components/ui/scroll-pane/ScrollPaneHeader.vue +75 -0
- package/src/components/ui/scroll-pane/index.ts +2 -0
- package/src/components/ui/select/Select.vue +15 -0
- package/src/components/ui/select/SelectContent.vue +57 -0
- package/src/components/ui/select/SelectGroup.vue +19 -0
- package/src/components/ui/select/SelectItem.vue +47 -0
- package/src/components/ui/select/SelectItemText.vue +11 -0
- package/src/components/ui/select/SelectLabel.vue +13 -0
- package/src/components/ui/select/SelectScrollDownButton.vue +24 -0
- package/src/components/ui/select/SelectScrollUpButton.vue +24 -0
- package/src/components/ui/select/SelectSeparator.vue +17 -0
- package/src/components/ui/select/SelectTrigger.vue +45 -0
- package/src/components/ui/select/SelectValue.vue +11 -0
- package/src/components/ui/select/index.ts +11 -0
- package/src/components/ui/separator/Separator.vue +35 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/sheet/Sheet.vue +14 -0
- package/src/components/ui/sheet/SheetClose.vue +11 -0
- package/src/components/ui/sheet/SheetContent.vue +56 -0
- package/src/components/ui/sheet/SheetDescription.vue +22 -0
- package/src/components/ui/sheet/SheetFooter.vue +19 -0
- package/src/components/ui/sheet/SheetHeader.vue +16 -0
- package/src/components/ui/sheet/SheetTitle.vue +22 -0
- package/src/components/ui/sheet/SheetTrigger.vue +11 -0
- package/src/components/ui/sheet/index.ts +31 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/slider/Slider.vue +66 -0
- package/src/components/ui/slider/index.ts +1 -0
- package/src/components/ui/switch/Switch.vue +37 -0
- package/src/components/ui/switch/index.ts +1 -0
- package/src/components/ui/table/Table.vue +16 -0
- package/src/components/ui/table/TableBody.vue +14 -0
- package/src/components/ui/table/TableCaption.vue +14 -0
- package/src/components/ui/table/TableCell.vue +14 -0
- package/src/components/ui/table/TableEmpty.vue +39 -0
- package/src/components/ui/table/TableFooter.vue +16 -0
- package/src/components/ui/table/TableHead.vue +21 -0
- package/src/components/ui/table/TableHeader.vue +14 -0
- package/src/components/ui/table/TableRow.vue +21 -0
- package/src/components/ui/table/index.ts +9 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +22 -0
- package/src/components/ui/tabs/TabsIndicator.vue +22 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +5 -0
- package/src/components/ui/tags-input/TagsInput.vue +22 -0
- package/src/components/ui/tags-input/TagsInputInput.vue +19 -0
- package/src/components/ui/tags-input/TagsInputItem.vue +22 -0
- package/src/components/ui/tags-input/TagsInputItemDelete.vue +24 -0
- package/src/components/ui/tags-input/TagsInputItemText.vue +19 -0
- package/src/components/ui/tags-input/index.ts +5 -0
- package/src/components/ui/textarea/Textarea.vue +24 -0
- package/src/components/ui/textarea/index.ts +1 -0
- package/src/components/ui/toast/Toast.vue +57 -0
- package/src/components/ui/toast/ToastAction.vue +30 -0
- package/src/components/ui/toast/ToastClose.vue +31 -0
- package/src/components/ui/toast/ToastDescription.vue +25 -0
- package/src/components/ui/toast/ToastTitle.vue +25 -0
- package/src/components/ui/toast/Toaster.vue +31 -0
- package/src/components/ui/toast/index.ts +8 -0
- package/src/components/ui/toast/use-toast.ts +136 -0
- package/src/components/ui/toggle/Toggle.vue +35 -0
- package/src/components/ui/toggle/index.ts +27 -0
- package/src/components/ui/toggle-group/ToggleGroup.vue +34 -0
- package/src/components/ui/toggle-group/ToggleGroupItem.vue +35 -0
- package/src/components/ui/toggle-group/index.ts +2 -0
- package/src/components/ui/tooltip/Tooltip.vue +14 -0
- package/src/components/ui/tooltip/TooltipContent.vue +31 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +11 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +11 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/glass/index.ts +8 -0
- package/src/composables/glass/useGlassRenderer.ts +252 -0
- package/src/composables/glass/webgl/frostShader.ts +221 -0
- package/src/composables/glass/webgpu/glassShader.wgsl +173 -0
- package/src/composables/index.ts +32 -0
- package/src/composables/infinite-scroll/index.ts +2 -0
- package/src/composables/infinite-scroll/types.ts +25 -0
- package/src/composables/infinite-scroll/useInfiniteScroll.ts +101 -0
- package/src/composables/interaction/index.ts +5 -0
- package/src/composables/interaction/useHeightTransition.ts +82 -0
- package/src/composables/interaction/useHoverPopover.ts +64 -0
- package/src/composables/interaction/useHoverToggle.ts +103 -0
- package/src/composables/interaction/useLeaveTimer.ts +17 -0
- package/src/composables/interaction/useTouchGate.ts +207 -0
- package/src/composables/pagination/index.ts +2 -0
- package/src/composables/pagination/useOffsetPagination.ts +70 -0
- package/src/composables/prng.ts +32 -0
- package/src/composables/useCharSplit.ts +31 -0
- package/src/composables/useClipboard.ts +46 -0
- package/src/composables/useGlobalDark.ts +61 -0
- package/src/composables/useKeyboardShortcuts.ts +205 -0
- package/src/composables/useWatercolorBlob.ts +136 -0
- package/src/composables/virtual/index.ts +22 -0
- package/src/composables/virtual/useVirtualSectionWindow.ts +338 -0
- package/src/composables/virtual/useWindowedStore.ts +86 -0
- package/src/composables/virtual/virtualSectionLayout.ts +212 -0
- package/src/index.ts +9 -0
- package/src/styles/animations.css +233 -0
- package/src/styles/cards.css +66 -0
- package/src/styles/dock.css +221 -0
- package/src/styles/floating-panel.css +49 -0
- package/src/styles/glass.css +266 -0
- package/src/styles/index.css +26 -0
- package/src/styles/scroll-pane.css +10 -0
- package/src/styles/theme.css +138 -0
- package/src/styles/tokens.css +333 -0
- package/src/styles/transitions.css +226 -0
- package/src/styles/typography.css +277 -0
- package/src/styles/utilities.css +697 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic dock action bar system.
|
|
3
|
+
*
|
|
4
|
+
* Any view can provide a DockActionBar — a list of actions with icons,
|
|
5
|
+
* titles, and callbacks. The dock renders them as dock-icon-btn buttons.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component, InjectionKey, Ref } from "vue";
|
|
9
|
+
|
|
10
|
+
export interface DockAction {
|
|
11
|
+
key: string;
|
|
12
|
+
icon: Component;
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
rotateOnClick?: boolean;
|
|
16
|
+
iconClass?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
handler: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DockActionBar {
|
|
22
|
+
/** The label shown next to the Tools toggle button */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Icon for the Tools toggle */
|
|
25
|
+
icon: Component;
|
|
26
|
+
/** Accent color for the action bar */
|
|
27
|
+
accentColor?: string;
|
|
28
|
+
/** The actions to display in the dock */
|
|
29
|
+
actions: Ref<DockAction[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DOCK_ACTION_BAR_KEY: InjectionKey<Ref<DockActionBar | null>> =
|
|
33
|
+
Symbol("dockActionBar");
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { ref, watch, onUnmounted, provide, inject } from "vue";
|
|
2
|
+
import type { Ref } from "vue";
|
|
3
|
+
import { isTeleportedTarget } from "./isTeleportedTarget";
|
|
4
|
+
|
|
5
|
+
export interface UseDockStateOptions {
|
|
6
|
+
/** Delay before auto-collapse after mouse leaves (ms) */
|
|
7
|
+
collapseDelay?: number;
|
|
8
|
+
/** Root element ref — used for contains() checks */
|
|
9
|
+
rootEl: Ref<HTMLElement | null>;
|
|
10
|
+
/** Disable collapse behavior and keep the dock expanded. */
|
|
11
|
+
alwaysExpanded?: Ref<boolean> | boolean;
|
|
12
|
+
/** Called on every state transition */
|
|
13
|
+
onStateChange?: (newState: DockState, oldState: DockState) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type DockState = "collapsed" | "hover" | "pinned";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dock state machine with three states and ref-counted child holds.
|
|
20
|
+
*
|
|
21
|
+
* ```
|
|
22
|
+
* State Machine:
|
|
23
|
+
* COLLAPSED ──(mouseenter/focusin)──→ HOVER
|
|
24
|
+
* HOVER ──(mouseleave + timeout)──→ COLLAPSED
|
|
25
|
+
* HOVER ──(clickCollapsed)──→ PINNED
|
|
26
|
+
* PINNED ──(clickOutside)──→ COLLAPSED
|
|
27
|
+
* Any ──(alwaysExpanded=true)──→ PINNED
|
|
28
|
+
*
|
|
29
|
+
* keepOpen/release ref-counting prevents TIMER-BASED collapse
|
|
30
|
+
* but NOT explicit click-away dismissal (onPointerDownOutside).
|
|
31
|
+
*
|
|
32
|
+
* Teleported targets (reka-ui portals, floating panels, dock popovers)
|
|
33
|
+
* are treated as "inside the dock" for mouse/focus/click-away purposes.
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function useDockState(options: UseDockStateOptions) {
|
|
37
|
+
const { collapseDelay = 2500, rootEl, alwaysExpanded = false, onStateChange } = options;
|
|
38
|
+
|
|
39
|
+
const getAlwaysExpanded = () =>
|
|
40
|
+
typeof alwaysExpanded === "boolean" ? alwaysExpanded : alwaysExpanded.value;
|
|
41
|
+
|
|
42
|
+
const initiallyExpanded = getAlwaysExpanded();
|
|
43
|
+
const state = ref<DockState>(initiallyExpanded ? "pinned" : "collapsed");
|
|
44
|
+
const expanded = ref(initiallyExpanded);
|
|
45
|
+
const isPinned = ref(initiallyExpanded);
|
|
46
|
+
|
|
47
|
+
let collapseTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
let keepOpenCount = 0;
|
|
49
|
+
let removeClickAway: (() => void) | null = null;
|
|
50
|
+
let isCollapsing = false;
|
|
51
|
+
|
|
52
|
+
// Suppress events briefly after mount to avoid phantom triggers
|
|
53
|
+
let ignoreEvents = true;
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
ignoreEvents = false;
|
|
56
|
+
}, 600);
|
|
57
|
+
|
|
58
|
+
let prevState: DockState = state.value;
|
|
59
|
+
function syncDerived() {
|
|
60
|
+
if (getAlwaysExpanded()) {
|
|
61
|
+
state.value = "pinned";
|
|
62
|
+
}
|
|
63
|
+
expanded.value = state.value !== "collapsed";
|
|
64
|
+
isPinned.value = state.value === "pinned";
|
|
65
|
+
if (onStateChange && prevState !== state.value) {
|
|
66
|
+
onStateChange(state.value, prevState);
|
|
67
|
+
}
|
|
68
|
+
prevState = state.value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearTimer() {
|
|
72
|
+
if (collapseTimer) {
|
|
73
|
+
clearTimeout(collapseTimer);
|
|
74
|
+
collapseTimer = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function scheduleCollapse() {
|
|
79
|
+
if (getAlwaysExpanded()) return;
|
|
80
|
+
if (keepOpenCount > 0) return;
|
|
81
|
+
clearTimer();
|
|
82
|
+
collapseTimer = setTimeout(() => {
|
|
83
|
+
dismissOpenOverlays();
|
|
84
|
+
state.value = "collapsed";
|
|
85
|
+
syncDerived();
|
|
86
|
+
}, collapseDelay);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function dismissOpenOverlays() {
|
|
90
|
+
// Close any open reka-ui portals by programmatically clicking the document body.
|
|
91
|
+
// This triggers reka-ui's native "dismiss on outside click" without interfering
|
|
92
|
+
// with keyboard shortcut listeners (Escape would stop animations, etc.).
|
|
93
|
+
const active = document.querySelector(
|
|
94
|
+
'[data-reka-menu-content][data-state="open"], ' +
|
|
95
|
+
'[data-reka-popper-content-wrapper]'
|
|
96
|
+
);
|
|
97
|
+
if (active) {
|
|
98
|
+
// Pointer event outside the portal triggers reka-ui's onPointerDownOutside
|
|
99
|
+
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, composed: true }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collapse() {
|
|
104
|
+
if (getAlwaysExpanded()) {
|
|
105
|
+
state.value = "pinned";
|
|
106
|
+
syncDerived();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (isCollapsing) return;
|
|
110
|
+
isCollapsing = true;
|
|
111
|
+
clearTimer();
|
|
112
|
+
dismissOpenOverlays();
|
|
113
|
+
state.value = "collapsed";
|
|
114
|
+
syncDerived();
|
|
115
|
+
isCollapsing = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function expand() {
|
|
119
|
+
if (getAlwaysExpanded()) {
|
|
120
|
+
state.value = "pinned";
|
|
121
|
+
syncDerived();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (ignoreEvents) return;
|
|
125
|
+
clearTimer();
|
|
126
|
+
state.value = "hover";
|
|
127
|
+
syncDerived();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Mouse handlers ---
|
|
131
|
+
|
|
132
|
+
function onMouseEnter() {
|
|
133
|
+
if (getAlwaysExpanded()) return;
|
|
134
|
+
if (ignoreEvents) return;
|
|
135
|
+
clearTimer();
|
|
136
|
+
if (state.value === "collapsed") {
|
|
137
|
+
state.value = "hover";
|
|
138
|
+
syncDerived();
|
|
139
|
+
}
|
|
140
|
+
// If pinned, no-op (stays pinned)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onMouseLeave(e?: MouseEvent) {
|
|
144
|
+
if (getAlwaysExpanded()) return;
|
|
145
|
+
if (state.value === "hover") {
|
|
146
|
+
// Something is explicitly holding the dock open (dropdown, edit, etc.)
|
|
147
|
+
if (keepOpenCount > 0) return;
|
|
148
|
+
// Mouse moved to a descendant or teleported child (dropdown, popover)
|
|
149
|
+
if (e) {
|
|
150
|
+
const root = rootEl.value;
|
|
151
|
+
if (
|
|
152
|
+
root &&
|
|
153
|
+
e.relatedTarget instanceof Node &&
|
|
154
|
+
root.contains(e.relatedTarget)
|
|
155
|
+
)
|
|
156
|
+
return;
|
|
157
|
+
if (isTeleportedTarget(e.relatedTarget)) return;
|
|
158
|
+
}
|
|
159
|
+
scheduleCollapse();
|
|
160
|
+
}
|
|
161
|
+
// If pinned, no-op (stays pinned)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Focus handlers ---
|
|
165
|
+
|
|
166
|
+
function onFocusIn() {
|
|
167
|
+
if (getAlwaysExpanded()) return;
|
|
168
|
+
if (ignoreEvents) return;
|
|
169
|
+
clearTimer();
|
|
170
|
+
if (state.value === "collapsed") {
|
|
171
|
+
state.value = "hover";
|
|
172
|
+
syncDerived();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onFocusOut(e: FocusEvent) {
|
|
177
|
+
if (getAlwaysExpanded()) return;
|
|
178
|
+
if (state.value !== "hover") return;
|
|
179
|
+
if (keepOpenCount > 0) return;
|
|
180
|
+
const root = e.currentTarget as HTMLElement;
|
|
181
|
+
if (e.relatedTarget && root.contains(e.relatedTarget as Node)) return;
|
|
182
|
+
// Focus moved to a teleported element (dropdown content, select, popover)
|
|
183
|
+
if (isTeleportedTarget(e.relatedTarget)) return;
|
|
184
|
+
scheduleCollapse();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Click on collapsed layer → PINNED ---
|
|
188
|
+
|
|
189
|
+
function onClickCollapsed() {
|
|
190
|
+
if (getAlwaysExpanded()) {
|
|
191
|
+
state.value = "pinned";
|
|
192
|
+
syncDerived();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
clearTimer();
|
|
196
|
+
state.value = "pinned";
|
|
197
|
+
syncDerived();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- keepOpen / release (ref-counted child holds) ---
|
|
201
|
+
|
|
202
|
+
function keepOpen() {
|
|
203
|
+
keepOpenCount++;
|
|
204
|
+
clearTimer();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function release() {
|
|
208
|
+
keepOpenCount = Math.max(0, keepOpenCount - 1);
|
|
209
|
+
if (keepOpenCount === 0 && state.value === "hover") {
|
|
210
|
+
// Grace period: don't collapse immediately after a child releases
|
|
211
|
+
// (e.g., dialog dismissed via Escape). Give the user time to re-engage.
|
|
212
|
+
clearTimer();
|
|
213
|
+
collapseTimer = setTimeout(() => {
|
|
214
|
+
if (keepOpenCount === 0 && state.value === "hover") {
|
|
215
|
+
scheduleCollapse();
|
|
216
|
+
}
|
|
217
|
+
}, Math.min(collapseDelay, 800));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Provide for descendant components
|
|
222
|
+
provide("dockKeepOpen", keepOpen);
|
|
223
|
+
provide("dockRelease", release);
|
|
224
|
+
provide("dockExpanded", expanded);
|
|
225
|
+
|
|
226
|
+
// --- Click-away listener ---
|
|
227
|
+
|
|
228
|
+
function onPointerDownOutside(e: PointerEvent) {
|
|
229
|
+
if (getAlwaysExpanded()) return;
|
|
230
|
+
const root = rootEl.value;
|
|
231
|
+
if (!root || root.contains(e.target as Node)) return;
|
|
232
|
+
if (isTeleportedTarget(e.target)) return;
|
|
233
|
+
|
|
234
|
+
// Click outside → always collapse, even if keepOpenCount > 0
|
|
235
|
+
// (keepOpenCount prevents timer-based collapse, not explicit dismissal)
|
|
236
|
+
collapse();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
watch(expanded, (isExpanded) => {
|
|
240
|
+
if (isExpanded) {
|
|
241
|
+
// Defer attachment past the current event's propagation.
|
|
242
|
+
// nextTick alone isn't enough — the opening pointerdown can still
|
|
243
|
+
// reach a capture-phase listener attached in the same microtask.
|
|
244
|
+
// requestAnimationFrame ensures we wait until the next frame.
|
|
245
|
+
requestAnimationFrame(() => {
|
|
246
|
+
document.addEventListener(
|
|
247
|
+
"pointerdown",
|
|
248
|
+
onPointerDownOutside,
|
|
249
|
+
true,
|
|
250
|
+
);
|
|
251
|
+
removeClickAway = () => {
|
|
252
|
+
document.removeEventListener(
|
|
253
|
+
"pointerdown",
|
|
254
|
+
onPointerDownOutside,
|
|
255
|
+
true,
|
|
256
|
+
);
|
|
257
|
+
removeClickAway = null;
|
|
258
|
+
};
|
|
259
|
+
});
|
|
260
|
+
} else {
|
|
261
|
+
removeClickAway?.();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (typeof alwaysExpanded !== "boolean") {
|
|
266
|
+
watch(alwaysExpanded, (forceOpen) => {
|
|
267
|
+
if (forceOpen) {
|
|
268
|
+
clearTimer();
|
|
269
|
+
state.value = "pinned";
|
|
270
|
+
syncDerived();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
state.value = "collapsed";
|
|
274
|
+
syncDerived();
|
|
275
|
+
}, { immediate: true });
|
|
276
|
+
} else if (alwaysExpanded) {
|
|
277
|
+
state.value = "pinned";
|
|
278
|
+
syncDerived();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Cleanup
|
|
282
|
+
onUnmounted(() => {
|
|
283
|
+
clearTimer();
|
|
284
|
+
removeClickAway?.();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
state,
|
|
289
|
+
expanded,
|
|
290
|
+
isPinned,
|
|
291
|
+
onMouseEnter,
|
|
292
|
+
onMouseLeave,
|
|
293
|
+
onFocusIn,
|
|
294
|
+
onFocusOut,
|
|
295
|
+
onClickCollapsed,
|
|
296
|
+
keepOpen,
|
|
297
|
+
release,
|
|
298
|
+
expand,
|
|
299
|
+
collapse,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ref, watch, nextTick, onUnmounted } from "vue";
|
|
2
|
+
import type { Ref } from "vue";
|
|
3
|
+
|
|
4
|
+
export interface UseDockTransitionOptions {
|
|
5
|
+
/** The logical expanded state (from useDockState) */
|
|
6
|
+
expanded: Ref<boolean>;
|
|
7
|
+
/** The dock root element ref — width is animated on this element */
|
|
8
|
+
rootEl: Ref<HTMLElement | null>;
|
|
9
|
+
/** Fade duration in ms before the layer swap (default: 60) */
|
|
10
|
+
fadeMs?: number;
|
|
11
|
+
/** When true, skip all width-pinning transitions (dock is always open) */
|
|
12
|
+
alwaysExpanded?: Ref<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Orchestrates a deferred layer-swap transition for dock components.
|
|
17
|
+
*
|
|
18
|
+
* Both expand and collapse follow the same sequence:
|
|
19
|
+
* fade out → swap layer → animate width → fade in
|
|
20
|
+
*
|
|
21
|
+
* Width animation is driven through a reactive ref (`transitionWidth`)
|
|
22
|
+
* so it cooperates with Vue's reactive :style bindings.
|
|
23
|
+
*/
|
|
24
|
+
export function useDockTransition(options: UseDockTransitionOptions) {
|
|
25
|
+
const { expanded, rootEl, fadeMs = 60, alwaysExpanded } = options;
|
|
26
|
+
|
|
27
|
+
const visualExpanded = ref(expanded.value);
|
|
28
|
+
const isTransitioning = ref(false);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Reactive width applied during transitions via :style.
|
|
32
|
+
* `null` means "no override — use natural sizing".
|
|
33
|
+
*/
|
|
34
|
+
const transitionWidth = ref<string | null>(null);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* When true, CSS transitions on the dock are suppressed.
|
|
38
|
+
* Used during the measure phase so width changes don't animate.
|
|
39
|
+
*/
|
|
40
|
+
const suppressTransition = ref(false);
|
|
41
|
+
|
|
42
|
+
let fadeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
43
|
+
|
|
44
|
+
function clearFadeTimer() {
|
|
45
|
+
if (fadeTimer) {
|
|
46
|
+
clearTimeout(fadeTimer);
|
|
47
|
+
fadeTimer = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let transitionId = 0;
|
|
52
|
+
|
|
53
|
+
watch(expanded, () => {
|
|
54
|
+
if (alwaysExpanded?.value) {
|
|
55
|
+
visualExpanded.value = expanded.value;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const el = rootEl.value;
|
|
60
|
+
if (!el) return;
|
|
61
|
+
|
|
62
|
+
clearFadeTimer();
|
|
63
|
+
const id = ++transitionId;
|
|
64
|
+
|
|
65
|
+
// Capture current width before any changes
|
|
66
|
+
const from = el.getBoundingClientRect().width;
|
|
67
|
+
|
|
68
|
+
// Pin width so the layer swap can't cause a resize jump
|
|
69
|
+
suppressTransition.value = true;
|
|
70
|
+
transitionWidth.value = `${from}px`;
|
|
71
|
+
|
|
72
|
+
// Phase 1: fade out (visualExpanded still shows OLD layer)
|
|
73
|
+
isTransitioning.value = true;
|
|
74
|
+
|
|
75
|
+
// Phase 2: after fade completes, swap layer and animate width
|
|
76
|
+
fadeTimer = setTimeout(() => {
|
|
77
|
+
fadeTimer = null;
|
|
78
|
+
if (id !== transitionId) return;
|
|
79
|
+
|
|
80
|
+
// Swap layers while content is invisible
|
|
81
|
+
visualExpanded.value = expanded.value;
|
|
82
|
+
|
|
83
|
+
// Wait for Vue to flush the layer swap, then measure target width
|
|
84
|
+
nextTick(() => {
|
|
85
|
+
if (id !== transitionId) return;
|
|
86
|
+
|
|
87
|
+
// Release width to measure natural target (still no transitions)
|
|
88
|
+
transitionWidth.value = null;
|
|
89
|
+
|
|
90
|
+
nextTick(() => {
|
|
91
|
+
if (id !== transitionId) return;
|
|
92
|
+
|
|
93
|
+
const to = el.getBoundingClientRect().width;
|
|
94
|
+
|
|
95
|
+
// If from ≈ to, no animation needed
|
|
96
|
+
if (Math.abs(from - to) < 1) {
|
|
97
|
+
suppressTransition.value = false;
|
|
98
|
+
transitionWidth.value = null;
|
|
99
|
+
isTransitioning.value = false;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pin back to old width (still suppressed)
|
|
104
|
+
transitionWidth.value = `${from}px`;
|
|
105
|
+
|
|
106
|
+
// Let Vue flush the pinned width, then enable transitions and animate
|
|
107
|
+
nextTick(() => {
|
|
108
|
+
if (id !== transitionId) return;
|
|
109
|
+
|
|
110
|
+
// Force the browser to commit the from-width
|
|
111
|
+
el.offsetWidth;
|
|
112
|
+
|
|
113
|
+
// Re-enable CSS transitions and set target width
|
|
114
|
+
suppressTransition.value = false;
|
|
115
|
+
|
|
116
|
+
// rAF ensures the browser has painted the from-width
|
|
117
|
+
// before we set the to-width, guaranteeing the transition fires
|
|
118
|
+
requestAnimationFrame(() => {
|
|
119
|
+
if (id !== transitionId) return;
|
|
120
|
+
transitionWidth.value = `${to}px`;
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}, fadeMs);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** Call from @transitionend on the root element. */
|
|
129
|
+
function onTransitionEnd(e: TransitionEvent) {
|
|
130
|
+
if (e.target !== rootEl.value) return;
|
|
131
|
+
if (e.propertyName === "width") {
|
|
132
|
+
transitionWidth.value = null;
|
|
133
|
+
isTransitioning.value = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
onUnmounted(clearFadeTimer);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
visualExpanded,
|
|
141
|
+
isTransitioning,
|
|
142
|
+
transitionWidth,
|
|
143
|
+
suppressTransition,
|
|
144
|
+
onTransitionEnd,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ref, watch, nextTick, onUnmounted } from "vue";
|
|
2
|
+
import type { Ref } from "vue";
|
|
3
|
+
|
|
4
|
+
export interface UseLayerTransitionOptions {
|
|
5
|
+
/** The container element (must have `.dock-layer-grid` class) */
|
|
6
|
+
containerEl: Ref<HTMLElement | null>;
|
|
7
|
+
/** The currently active layer id */
|
|
8
|
+
activeLayer: Ref<string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UseLayerTransitionReturn {
|
|
12
|
+
/** Returns class array + inert for a given layer id */
|
|
13
|
+
layerProps(id: string): { class: string[]; inert: true | undefined };
|
|
14
|
+
/** Attach to @transitionend on the container */
|
|
15
|
+
onTransitionEnd(e: TransitionEvent): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Coordinates simultaneous crossfade + FLIP width animation for
|
|
20
|
+
* grid-stacked layer containers. Reusable at any nesting level.
|
|
21
|
+
*
|
|
22
|
+
* Algorithm on activeLayer change:
|
|
23
|
+
* 1. Capture current container width
|
|
24
|
+
* 2. Pin container to that width
|
|
25
|
+
* 3. Swap classes: old layer → leaving (absolute, fading out),
|
|
26
|
+
* new layer → active (relative, fading in)
|
|
27
|
+
* 4. nextTick: measure new natural width, re-pin to old
|
|
28
|
+
* 5. Animate to new width via CSS transition
|
|
29
|
+
* 6. On transitionend(width), clear inline width
|
|
30
|
+
*/
|
|
31
|
+
export function useLayerTransition(
|
|
32
|
+
options: UseLayerTransitionOptions,
|
|
33
|
+
): UseLayerTransitionReturn {
|
|
34
|
+
const { containerEl, activeLayer } = options;
|
|
35
|
+
|
|
36
|
+
const currentLayer = ref(activeLayer.value);
|
|
37
|
+
const leavingLayer = ref<string | null>(null);
|
|
38
|
+
let transitionId = 0;
|
|
39
|
+
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
|
|
41
|
+
function clearCleanup() {
|
|
42
|
+
if (cleanupTimer) {
|
|
43
|
+
clearTimeout(cleanupTimer);
|
|
44
|
+
cleanupTimer = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
watch(activeLayer, (newLayer, oldLayer) => {
|
|
49
|
+
if (newLayer === oldLayer) return;
|
|
50
|
+
|
|
51
|
+
const el = containerEl.value;
|
|
52
|
+
if (!el) {
|
|
53
|
+
currentLayer.value = newLayer;
|
|
54
|
+
leavingLayer.value = null;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
clearCleanup();
|
|
59
|
+
const id = ++transitionId;
|
|
60
|
+
|
|
61
|
+
// 1. Capture current width
|
|
62
|
+
const fromWidth = el.getBoundingClientRect().width;
|
|
63
|
+
|
|
64
|
+
// 2. Pin width (prevents snap during class swap)
|
|
65
|
+
el.style.width = `${fromWidth}px`;
|
|
66
|
+
|
|
67
|
+
// 3. Swap: mark old as leaving, new as active
|
|
68
|
+
leavingLayer.value = oldLayer;
|
|
69
|
+
currentLayer.value = newLayer;
|
|
70
|
+
|
|
71
|
+
// 4. Measure new natural width on next tick
|
|
72
|
+
nextTick(() => {
|
|
73
|
+
if (id !== transitionId) return;
|
|
74
|
+
if (!el) return;
|
|
75
|
+
|
|
76
|
+
// Temporarily unpin to measure
|
|
77
|
+
el.style.transition = "none";
|
|
78
|
+
el.style.width = "";
|
|
79
|
+
const toWidth = el.getBoundingClientRect().width;
|
|
80
|
+
|
|
81
|
+
// Re-pin to old width
|
|
82
|
+
el.style.width = `${fromWidth}px`;
|
|
83
|
+
// Force reflow so the browser registers the old width
|
|
84
|
+
void el.offsetWidth;
|
|
85
|
+
// Restore CSS transitions
|
|
86
|
+
el.style.transition = "";
|
|
87
|
+
|
|
88
|
+
// 5. Animate to new width
|
|
89
|
+
requestAnimationFrame(() => {
|
|
90
|
+
if (id !== transitionId) return;
|
|
91
|
+
el.style.width = `${toWidth}px`;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Safety: clear inline width after max duration in case transitionend
|
|
95
|
+
// doesn't fire (e.g. width didn't actually change)
|
|
96
|
+
cleanupTimer = setTimeout(() => {
|
|
97
|
+
if (id !== transitionId) return;
|
|
98
|
+
el.style.width = "";
|
|
99
|
+
leavingLayer.value = null;
|
|
100
|
+
}, 400);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function onTransitionEnd(e: TransitionEvent) {
|
|
105
|
+
const el = containerEl.value;
|
|
106
|
+
if (!el) return;
|
|
107
|
+
if (e.target !== el) return;
|
|
108
|
+
if (e.propertyName !== "width") return;
|
|
109
|
+
|
|
110
|
+
clearCleanup();
|
|
111
|
+
el.style.width = "";
|
|
112
|
+
leavingLayer.value = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function layerProps(id: string): {
|
|
116
|
+
class: string[];
|
|
117
|
+
inert: true | undefined;
|
|
118
|
+
} {
|
|
119
|
+
const isActive = currentLayer.value === id;
|
|
120
|
+
const isLeaving = leavingLayer.value === id;
|
|
121
|
+
|
|
122
|
+
const classes = ["dock-layer-item"];
|
|
123
|
+
if (isActive) classes.push("dock-layer-active");
|
|
124
|
+
if (isLeaving) classes.push("dock-layer-leaving");
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
class: classes,
|
|
128
|
+
inert: isActive ? undefined : true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onUnmounted(clearCleanup);
|
|
133
|
+
|
|
134
|
+
return { layerProps, onTransitionEnd };
|
|
135
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ref, computed, onUnmounted } from "vue";
|
|
2
|
+
import type { Ref, WritableComputedRef } from "vue";
|
|
3
|
+
|
|
4
|
+
export interface UsePopupMutexOptions {
|
|
5
|
+
/** Delay when swapping between popups to prevent jarring transitions (ms, default: 180) */
|
|
6
|
+
swapDelay?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UsePopupMutexReturn<K extends string> {
|
|
10
|
+
/** Currently open popup key, or null */
|
|
11
|
+
current: Ref<K | null>;
|
|
12
|
+
/** Whether any popup is open or mid-swap */
|
|
13
|
+
isAnyOpen: Ref<boolean>;
|
|
14
|
+
/** Create a writable computed get/set for a specific popup key */
|
|
15
|
+
popupModel(key: K): WritableComputedRef<boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Mutex for dock popups / dropdowns: only one open at a time,
|
|
20
|
+
* with a brief delay when swapping to prevent jarring transitions.
|
|
21
|
+
*/
|
|
22
|
+
export function usePopupMutex<K extends string>(
|
|
23
|
+
options?: UsePopupMutexOptions,
|
|
24
|
+
): UsePopupMutexReturn<K> {
|
|
25
|
+
const { swapDelay = 180 } = options ?? {};
|
|
26
|
+
|
|
27
|
+
const current = ref<K | null>(null) as Ref<K | null>;
|
|
28
|
+
const pending = ref<K | null>(null) as Ref<K | null>;
|
|
29
|
+
let swapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
|
|
31
|
+
function clearSwapTimer() {
|
|
32
|
+
if (swapTimer) {
|
|
33
|
+
clearTimeout(swapTimer);
|
|
34
|
+
swapTimer = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function update(key: K, open: boolean) {
|
|
39
|
+
if (open) {
|
|
40
|
+
if (current.value === key) return;
|
|
41
|
+
clearSwapTimer();
|
|
42
|
+
|
|
43
|
+
// Swapping: close current, delay, then open new
|
|
44
|
+
if (current.value && current.value !== key) {
|
|
45
|
+
pending.value = key;
|
|
46
|
+
current.value = null;
|
|
47
|
+
swapTimer = setTimeout(() => {
|
|
48
|
+
current.value = pending.value;
|
|
49
|
+
pending.value = null;
|
|
50
|
+
swapTimer = null;
|
|
51
|
+
}, swapDelay);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pending.value = null;
|
|
56
|
+
current.value = key;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Closing
|
|
61
|
+
if (pending.value === key) {
|
|
62
|
+
pending.value = null;
|
|
63
|
+
}
|
|
64
|
+
if (current.value === key) {
|
|
65
|
+
current.value = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isAnyOpen = computed(
|
|
70
|
+
() => current.value !== null || pending.value !== null,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
function popupModel(key: K): WritableComputedRef<boolean> {
|
|
74
|
+
return computed({
|
|
75
|
+
get: () => current.value === key,
|
|
76
|
+
set: (open: boolean) => update(key, open),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onUnmounted(clearSwapTimer);
|
|
81
|
+
|
|
82
|
+
return { current, isAnyOpen, popupModel };
|
|
83
|
+
}
|