refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
@@ -5,38 +5,48 @@
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-if="dismissible"
|
||||
circular
|
||||
type="highlight-colored-text"
|
||||
:color="buttonColors[type]"
|
||||
>
|
||||
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'flex gap-2 items-start',
|
||||
(header || $slots.header) && 'flex-col',
|
||||
dismissible && 'pr-8',
|
||||
]"
|
||||
>
|
||||
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component
|
||||
:is="getSeverityIcon(type)"
|
||||
:class="['h-6 w-6 flex-none', iconClasses[type]]"
|
||||
/>
|
||||
</slot>
|
||||
<div v-if="header || $slots.header" class="font-semibold text-base">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<div class="flex items-start gap-2">
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-1 gap-2',
|
||||
header || $slots.header ? 'flex-col items-start' : 'items-center',
|
||||
(dismissible || $slots['top-right-actions']) && 'pr-8',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 items-start"
|
||||
:class="header || $slots.header ? 'w-full' : 'contents'"
|
||||
>
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component
|
||||
:is="getSeverityIcon(type)"
|
||||
:class="['h-6 w-6 flex-none', iconClasses[type]]"
|
||||
/>
|
||||
</slot>
|
||||
<div v-if="header || $slots.header" class="font-semibold text-base">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'">
|
||||
<slot>{{ body }}</slot>
|
||||
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2">
|
||||
<slot name="top-right-actions" />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else-if="dismissible"
|
||||
circular
|
||||
type="highlight-colored-text"
|
||||
:color="buttonColors[type]"
|
||||
>
|
||||
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="$slots.progress">
|
||||
<slot name="progress" />
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions">
|
||||
<slot name="actions" />
|
||||
|
||||
318
packages/ui/src/components/base/NavTabs.vue
Normal file
318
packages/ui/src/components/base/NavTabs.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
:class="{ 'shadow-sm': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<RouterLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
:replace="replace"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@click="emit('tabClick', index, link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Animated slider background -->
|
||||
<div
|
||||
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
|
||||
:class="[
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
{ 'navtabs-transition': transitionsEnabled },
|
||||
]"
|
||||
:style="sliderStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
shown?: boolean
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
replace?: boolean
|
||||
links: Tab[]
|
||||
query?: string
|
||||
mode?: 'navigation' | 'local'
|
||||
activeIndex?: number
|
||||
}>(),
|
||||
{
|
||||
mode: 'navigation',
|
||||
query: undefined,
|
||||
activeIndex: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
tabClick: [index: number, tab: Tab]
|
||||
}>()
|
||||
|
||||
// DOM refs
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const tabLinkElements = ref<HTMLElement[]>()
|
||||
|
||||
// Slider pos state
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
|
||||
// active tab state
|
||||
const currentActiveIndex = ref(-1)
|
||||
const subpageSelected = ref(false)
|
||||
|
||||
// SSR state
|
||||
const sliderReady = ref(false)
|
||||
const transitionsEnabled = ref(false)
|
||||
|
||||
// Stagger delays for the trailing edges of the slider animation
|
||||
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
|
||||
|
||||
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
|
||||
|
||||
const sliderStyle = computed(() => ({
|
||||
left: `${sliderLeft.value}px`,
|
||||
top: `${sliderTop.value}px`,
|
||||
right: `${sliderRight.value}px`,
|
||||
bottom: `${sliderBottom.value}px`,
|
||||
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
|
||||
}))
|
||||
|
||||
const leftDelay = computed(() => sliderDelays.value.left)
|
||||
const rightDelay = computed(() => sliderDelays.value.right)
|
||||
const topDelay = computed(() => sliderDelays.value.top)
|
||||
const bottomDelay = computed(() => sliderDelays.value.bottom)
|
||||
|
||||
const isActiveAndNotSubpage = computed(
|
||||
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
||||
)
|
||||
|
||||
function getSSRFallbackClasses(index: number) {
|
||||
if (sliderReady.value) return {}
|
||||
if (currentActiveIndex.value !== index) return {}
|
||||
|
||||
return {
|
||||
'rounded-full': true,
|
||||
'bg-button-bgSelected': !subpageSelected.value,
|
||||
'bg-button-bg': subpageSelected.value,
|
||||
}
|
||||
}
|
||||
|
||||
function getIconClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-secondary': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function getLabelClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-contrast': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function computeActiveIndex(): { index: number; isSubpage: boolean } {
|
||||
if (props.mode === 'local' && props.activeIndex !== undefined) {
|
||||
return {
|
||||
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
|
||||
isSubpage: false,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
const decodedPath = decodeURIComponent(route.path)
|
||||
const decodedHref = decodeURIComponent(link.href.split('?')[0])
|
||||
|
||||
if (props.query) {
|
||||
const queryValue = route.query[props.query]
|
||||
if (queryValue === link.href || (!queryValue && !link.href)) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (decodedPath === decodedHref) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
|
||||
const isSubpageMatch =
|
||||
(decodedPath.startsWith(decodedHref) &&
|
||||
(decodedPath.length === decodedHref.length || decodedPath[decodedHref.length] === '/')) ||
|
||||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
|
||||
|
||||
if (isSubpageMatch) {
|
||||
return { index: i, isSubpage: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { index: -1, isSubpage: false }
|
||||
}
|
||||
|
||||
function getTabElement(index: number): HTMLElement | null {
|
||||
if (index === -1) return null
|
||||
|
||||
const container = scrollContainer.value as HTMLElement | undefined
|
||||
if (!container) return null
|
||||
|
||||
const tabs = container.querySelectorAll('.button-animation')
|
||||
const element = tabs[index] as HTMLElement | undefined
|
||||
|
||||
if (!element) return null
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
function positionSlider() {
|
||||
const el = getTabElement(currentActiveIndex.value)
|
||||
if (!el?.offsetParent) return
|
||||
|
||||
const parent = el.offsetParent as HTMLElement
|
||||
const newPosition = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||
|
||||
if (isInitialPosition) {
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
|
||||
sliderReady.value = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
} else {
|
||||
animateSliderTo(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
function animateSliderTo(newPosition: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}) {
|
||||
const STAGGER_DELAY = '200ms'
|
||||
|
||||
sliderDelays.value = {
|
||||
left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
|
||||
right: newPosition.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
|
||||
top: newPosition.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
|
||||
bottom: newPosition.top < sliderTop.value ? STAGGER_DELAY : '0ms',
|
||||
}
|
||||
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
}
|
||||
|
||||
async function updateActiveTab() {
|
||||
await nextTick()
|
||||
const { index, isSubpage } = computeActiveIndex()
|
||||
currentActiveIndex.value = index
|
||||
subpageSelected.value = isSubpage
|
||||
|
||||
if (index !== -1) {
|
||||
positionSlider()
|
||||
} else {
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const initialActive = computeActiveIndex()
|
||||
currentActiveIndex.value = initialActive.index
|
||||
subpageSelected.value = initialActive.isSubpage
|
||||
|
||||
onMounted(updateActiveTab)
|
||||
|
||||
watch(
|
||||
() => [route.path, route.query],
|
||||
() => {
|
||||
if (props.mode === 'navigation') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.activeIndex,
|
||||
() => {
|
||||
if (props.mode === 'local') {
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.links,
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateActiveTab()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
|
||||
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
|
||||
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
|
||||
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,6 @@
|
||||
:placement="placement"
|
||||
:class="dropdownClass"
|
||||
@apply-hide="focusTrigger"
|
||||
@apply-show="focusMenuChild"
|
||||
>
|
||||
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
|
||||
<slot></slot>
|
||||
@@ -52,14 +51,6 @@ defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
function focusMenuChild() {
|
||||
setTimeout(() => {
|
||||
if (menu.value && menu.value.children && menu.value.children.length > 0) {
|
||||
menu.value.children[0].focus()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hideAndFocusTrigger(hide) {
|
||||
hide()
|
||||
focusTrigger()
|
||||
|
||||
438
packages/ui/src/components/base/TeleportOverflowMenu.vue
Normal file
438
packages/ui/src/components/base/TeleportOverflowMenu.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div data-pyro-telepopover-wrapper class="relative">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:class="btnClass"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
v-tooltip="option.tooltip"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
</slot>
|
||||
</button>
|
||||
<AutoLink
|
||||
v-else-if="typeof option.action === 'string'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
</slot>
|
||||
</AutoLink>
|
||||
<span v-else>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
icon?: Component
|
||||
action?: (() => void) | string
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider?: boolean
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
type Item = Option | Divider
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Item[]
|
||||
hoverable?: boolean
|
||||
btnClass?: string | string[] | Record<string, boolean>
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
btnClass: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: Option]
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const selectedIndex = ref(-1)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isMouseDown = ref(false)
|
||||
const typeAheadBuffer = ref('')
|
||||
const typeAheadTimeout = ref<number | null>(null)
|
||||
const menuItemsRef = ref<HTMLElement[]>([])
|
||||
|
||||
const hoveringTrigger = useElementHover(triggerRef)
|
||||
const hoveringMenu = useElementHover(menuRef)
|
||||
|
||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
||||
|
||||
const menuStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
||||
|
||||
const calculateMenuPosition = () => {
|
||||
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const menuRect = menuRef.value.getBoundingClientRect()
|
||||
const menuWidth = menuRect.width
|
||||
const menuHeight = menuRect.height
|
||||
const margin = 8
|
||||
|
||||
let top: number
|
||||
let left: number
|
||||
|
||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||
top = triggerRect.bottom + margin
|
||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||
top = triggerRect.top - menuHeight - margin
|
||||
} else {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||
}
|
||||
|
||||
if (triggerRect.right - menuWidth >= margin) {
|
||||
left = triggerRect.right - menuWidth
|
||||
} else if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||
left = triggerRect.left
|
||||
} else {
|
||||
left = Math.max(margin, window.innerWidth - menuWidth - margin)
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu()
|
||||
} else {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
disableBodyScroll()
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
focusFirstMenuItem()
|
||||
})
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = -1
|
||||
enableBodyScroll()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
const selectOption = (option: Option) => {
|
||||
emit('select', option)
|
||||
if (typeof option.action === 'function') {
|
||||
option.action()
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
isMouseDown.value = true
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.hoverable) {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.hoverable) {
|
||||
setTimeout(() => {
|
||||
if (!hovering.value) {
|
||||
closeMenu()
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isOpen.value || !isMouseDown.value) return
|
||||
|
||||
const menuRect = menuRef.value?.getBoundingClientRect()
|
||||
if (!menuRect) return
|
||||
|
||||
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
||||
if (!menuItems) return
|
||||
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
||||
if (
|
||||
event.clientX >= itemRect.left &&
|
||||
event.clientX <= itemRect.right &&
|
||||
event.clientY >= itemRect.top &&
|
||||
event.clientY <= itemRect.bottom
|
||||
) {
|
||||
selectedIndex.value = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (option: Option, index: number) => {
|
||||
if (option.disabled) return
|
||||
selectedIndex.value = index
|
||||
selectOption(option)
|
||||
}
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
|
||||
const disableBodyScroll = () => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const enableBodyScroll = () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0]?.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (selectedIndex.value >= 0) {
|
||||
const option = filteredOptions.value[selectedIndex.value]
|
||||
if (isDivider(option)) break
|
||||
selectOption(option)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeMenu()
|
||||
triggerRef.value?.focus?.()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
if (event.shiftKey) {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase()
|
||||
const matchIndex = filteredOptions.value.findIndex(
|
||||
(option) =>
|
||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
)
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
typeAheadTimeout.value = setTimeout(() => {
|
||||
typeAheadBuffer.value = ''
|
||||
}, 1000) as unknown as number
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeOrScroll = () => {
|
||||
if (isOpen.value) {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
}
|
||||
}
|
||||
|
||||
const throttle = <T extends unknown[]>(
|
||||
func: (...args: T) => void,
|
||||
limit: number,
|
||||
): ((...args: T) => void) => {
|
||||
let inThrottle: boolean
|
||||
return function (...args: T) {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
||||
|
||||
onMounted(() => {
|
||||
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
enableBodyScroll()
|
||||
})
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
menuRef.value?.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(menuRef, (event) => {
|
||||
if (!triggerRef.value?.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -45,6 +45,7 @@ export type { MultiSelectOption } from './MultiSelect.vue'
|
||||
export { default as MultiSelect } from './MultiSelect.vue'
|
||||
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
|
||||
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
|
||||
export { default as NavTabs } from './NavTabs.vue'
|
||||
export { default as OptionGroup } from './OptionGroup.vue'
|
||||
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
||||
export { default as OverflowMenu } from './OverflowMenu.vue'
|
||||
|
||||
Reference in New Issue
Block a user