feat: creator revenue page overhaul (#4204)

* feat: start on tax compliance

* feat: avarala1099 composable

* fix: shouldShow should be managed on the page itself

* refactor: move show logic to revenue page

* feat: security practices rather than info

* feat: withdraw page lock

* fix: empty modal bug & lint issues

* feat: hide behind feature flag

* Use standard admonition components, make casing consistent

* modal title

* lint

* feat: withdrawal check

* feat: tax cap on withdrawals warning

* feat: start on revenue page overhaul

* feat: segment generation for bar

* feat: tooltips and links

* fix: tooltip border

* feat: finish initial layout, start on withdraw modal

* feat: start on withdrawal limit stage

* feat: shade support for primary colors

* feat: start on withdraw details stage

* fix: convert swatches to hex

* feat: payout method/region dropdown temporarily using multiselect

* feat: fix modal open issues and use teleport dropdowns

* feat: hide transactions section if there are no transactions

* refactor: NavStack surfaces

* feat: new dropdown component

* feat: remove teleport dropdown modal in favour of new combobox component

* fix: lint

* refactor: dashboard sidebar layout

* feat: cleanup

* fix: niche bugs

* fix: ComboBox styling

* feat: first part of qa

* feat: animate flash rather than tooltip

* fix: lint

* feat: qa border gradient

* fix: seg hover flashes

* feat: i18n

* feat: i18n and final QA

* fix: lint

* feat: QA

* fix: lint

* fix: merge conflicts

* fix: intl

* fix: blue hover

* fix: transfers page

* feat: surface variables & gradients

* feat: text vars

* fix: lint

* fix: intl

* feat: stages

* fix: lint

* feat: region selection

* feat: method selection btns

* fix: flex col on transactions

* feat: hook up method selection to ctx

* feat: muralpay kyc stage info

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* feat: progress

* fix: broken tax form stage logic

* polish: tax form stage and method selection stage layout

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* polish: muralpay qa

* refactor: clean up combobox component

* polish: change from critical -> warning admonition in MuralpayDetailsStage

* Temporarily disable Venmo and PayPal methods from frontend

* polish: clean up transaction component & page

* polish: navbar qa, text color-contrast in chips type buttonstyled, mb on rev/index.vue page

* fix: incorrectly using available balance as tax form withdraw limit after tax forms submitted

* wip: counterparties

* Start on counterparties and payment methods API

* polish: combobox component

* polish: fix broken scroll logic using a composable & web:fix

* fix: lint

* polish: various QA fixes

* feat: hook up with backend (wip)

* feat: draft muralpay rails dynamic logic

* polish: modify rails to support backend changes

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* feat: fees & methods endpoint hookup

* chore: remove duplicates fix

* polish: qa changes + figma match

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

* polish: i18n and better document type dropdown -> id input labels

* feat: tremendous

* fix: lint & i18n

* feat: reintroduce tin mismatch logic to index.vue

* polish: qa

* fix: i18n

* feat: remove teleport dropdown menu - combobox should be used

* fix: lint

* fix: jsdoc

* feat: checkbox for reward program terms

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* polish: qa changes

* feat: i18n pass

* feat: deduplicate methods endpoint & fix i18n issues

* chore: deduplicate i18n strings into common-messages.ts

* fix: lint

* fix: i18n

* feat: estimates

* polish: more QA

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* feat: withdraw endpoint impl & internals refactor

* Add more details to Tremendous errors

* feat: completion stage

* Add fees to Mural

* feat: transactions page match figma

* fix: i18n

* polish: QA changes

* polish: qa

* Payout history route and bank details

* polish: autofill and requirements checks

* fix: i18n + lint

* fix: fiat rail fees

* polish: move scroll fade stuff into NewModal rather than just CreatorWithdrawModal

* feat: simplify action btn logic & tax form error

* fix: tax -> Tax form

* Re-add legacy PayPal/Venmo options for US

* feat: mobile responsiveness fixes for modal

* fix: responsiveness issues

* feat: navstack responsiveness

* fix: responsiveness

* move the mural bank details route

* fix: generated state cleanup & bank details input

* fix: lint & i18n

* Add utoipa support to payout endpoints

* address some PR comments

* polish: qa

* add CORS to new utoipa routes

* feat: legacy paypal/venmo stage

* polish: reset amount on back qa

* revert: navstack mr changes

* polish: loading indicator on method selection stage

* fix: paypal modal doesnt reopen after auth

* fix: lint & i18n

* fix: paypal flow

* polish: qa changes

* fix: gitignore

* polish: qa fixes

* fix: payouts_available in payouts.rs

* fix: bug when limit is zero

* polish: qa changes

* fix: qa stuff & muralpay sub-division fix

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* polish: qa & currency support for paypal tremendous

* polish: fx qa

* feat: demo mode flag

* fix: i18n & padding issues

* polish: qa changes

* fix: ml

* Add Mural balance to bank balance info

* polish: show warning for paypal international USD withdrawals + more currencies

* Add more Tremendous currencies support

* fix: colors on balance bars

* fix: empty states

* fix: pl-8 mobile issue

* fix: hide see all

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* fix: empty state + paypal warning

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

* fix: segment brightness

* fix: min & max for muralpay & legacy paypal

* Fix some icon issues

* more issues

* fix user menu

* fix: remove + network

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
Calum H.
2025-11-03 23:15:25 +00:00
committed by GitHub
parent 92698e4bb5
commit 3765a6ded8
108 changed files with 9071 additions and 2664 deletions

View File

@@ -27,13 +27,13 @@
</p>
</div>
<TeleportDropdownMenu
<Combobox
:id="'interval-field'"
v-model="backupIntervalsLabel"
:disabled="!autoBackupEnabled || isSaving"
name="interval"
:options="Object.keys(backupIntervals)"
placeholder="Backup interval"
:options="Object.keys(backupIntervals).map((k) => ({ value: k, label: k }))"
:display-value="backupIntervalsLabel"
/>
<div class="mt-4 flex justify-start gap-4">
@@ -57,12 +57,7 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
} from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'

View File

@@ -60,16 +60,24 @@
</NuxtLink>
</div>
</div>
<TeleportDropdownMenu
<Combobox
v-model="selectedVersion"
name="Project"
:options="filteredVersions"
placeholder="No valid versions found"
:options="
filteredVersions.map((v) => ({
value: v,
label: typeof v === 'object' ? v.version_number : String(v),
}))
"
:display-value="
selectedVersion
? typeof selectedVersion === 'object'
? selectedVersion.version_number
: String(selectedVersion)
: 'No valid versions found'
"
class="!min-w-full"
:disabled="filteredVersions.length === 0"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
</template>
@@ -237,14 +245,7 @@ import {
LockOpenIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
CopyCode,
NewModal,
TeleportDropdownMenu,
} from '@modrinth/ui'
import { Admonition, Avatar, ButtonStyled, Combobox, CopyCode, NewModal } from '@modrinth/ui'
import TagItem from '@modrinth/ui/src/components/base/TagItem.vue'
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
@@ -282,7 +283,7 @@ const versionsError = ref('')
const showBetaAlphaReleases = ref(false)
const unlockFilterAccordion = ref()
const versionFilter = ref(true)
const tags = useTags()
const tags = useGeneratedState()
const noCompatibleVersions = ref(false)
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(

View File

@@ -17,10 +17,11 @@
</div>
<div class="flex w-full flex-col gap-4">
<TeleportDropdownMenu
<Combobox
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions"
:options="versionOptions.map((v) => ({ value: v, label: v }))"
:display-value="selectedVersion || 'Select version...'"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
@@ -68,12 +69,7 @@
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
} from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
@@ -96,7 +92,7 @@ const emit = defineEmits<{
const modal = ref()
const hardReset = ref(false)
const isLoading = ref(false)
const selectedVersion = ref('')
const selectedVersion = ref(props.currentVersion?.version_number || '')
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])

View File

@@ -54,11 +54,12 @@
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<TeleportDropdownMenu
<Combobox
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
:options="mcVersions.map((v) => ({ value: v, label: v }))"
:display-value="selectedMCVersion || 'Select Minecraft version...'"
class="!w-full"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
@@ -108,10 +109,17 @@
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<TeleportDropdownMenu
<Combobox
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
:options="selectedLoaderVersions.map((v) => ({ value: v, label: v }))"
:display-value="
selectedLoaderVersion ||
(selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? 'Select build number...'
: 'Select loader version...')
"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
@@ -201,9 +209,9 @@ import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/asset
import {
BackupWarning,
ButtonStyled,
Combobox,
injectNotificationManager,
NewModal,
TeleportDropdownMenu,
Toggle,
} from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
@@ -433,7 +441,7 @@ onMounted(() => {
fetchLoaderVersions()
})
const tags = useTags()
const tags = useGeneratedState()
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>

View File

@@ -1,458 +0,0 @@
<template>
<div class="relative inline-block h-9 w-full max-w-80">
<button
ref="triggerRef"
type="button"
aria-haspopup="listbox"
:aria-expanded="dropdownVisible"
:aria-controls="listboxId"
:aria-labelledby="listboxId"
class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
@keydown="handleTriggerKeyDown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</button>
<Teleport to="#teleports">
<transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="dropdownVisible"
:id="listboxId"
ref="optionsContainer"
role="listbox"
tabindex="-1"
:aria-activedescendant="activeDescendant"
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown="handleListboxKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
:style="{
position: 'absolute',
top: 0,
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
width: '100%',
height: `${ITEM_HEIGHT}px`,
}"
>
<div
:id="`${listboxId}-option-${item.index}`"
role="option"
:aria-selected="selectedValue === item.option"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
'rounded-t-xl': item.index === 0 && isRenderingUp,
}"
@click="selectOption(item.option, item.index)"
@mousemove="focusedOptionIndex = item.index"
>
{{ displayName(item.option) }}
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const ITEM_HEIGHT = 44
const BUFFER_ITEMS = 5
type OptionValue = string | number | Record<string, any>
interface Props {
options: OptionValue[]
name: string
defaultValue?: OptionValue | null
placeholder?: string | number | null
modelValue?: OptionValue | null
renderUp?: boolean
disabled?: boolean
displayName?: (option: OptionValue) => string
}
const props = withDefaults(defineProps<Props>(), {
defaultValue: null,
placeholder: null,
modelValue: null,
renderUp: false,
disabled: false,
displayName: (option: OptionValue) => String(option),
})
const emit = defineEmits<{
(e: 'input' | 'update:modelValue', value: OptionValue): void
(e: 'change', value: { option: OptionValue; index: number }): void
}>()
const dropdownVisible = ref(false)
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue)
const focusedOptionIndex = ref<number | null>(null)
const optionsContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const isRenderingUp = ref(false)
const virtualListHeight = ref(300)
const isOpen = ref(false)
const openDropdownCount = ref(0)
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`
const triggerRef = ref<HTMLButtonElement | null>(null)
const positionStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
width: '0px',
zIndex: 999,
})
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
const visibleOptions = computed(() => {
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
return Array.from({ length: visibleCount }, (_, i) => {
const index = startIndex + i
if (index >= 0 && index < props.options.length) {
return {
index,
option: props.options[index],
}
}
return null
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
})
const selectedOption = computed(() => {
if (selectedValue.value !== null && selectedValue.value !== undefined) {
return props.displayName(selectedValue.value as OptionValue)
}
return props.placeholder || 'Select an option'
})
const radioValue = computed<OptionValue>({
get() {
return props.modelValue ?? selectedValue.value ?? ''
},
set(newValue: OptionValue) {
emit('update:modelValue', newValue)
selectedValue.value = newValue
},
})
const triggerClasses = computed(() => ({
'!cursor-not-allowed opacity-50 grayscale': props.disabled,
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
}))
const updatePosition = async () => {
if (!triggerRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const margin = 8
const contentHeight = props.options.length * ITEM_HEIGHT
const preferredHeight = Math.min(contentHeight, 300)
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
virtualListHeight.value = isRenderingUp.value
? Math.min(spaceAbove - margin, preferredHeight)
: Math.min(spaceBelow - margin, preferredHeight)
positionStyle.value = {
position: 'fixed',
left: `${triggerRect.left}px`,
width: `${triggerRect.width}px`,
zIndex: 999,
...(isRenderingUp.value
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
}
}
const toggleDropdown = () => {
if (!props.disabled) {
if (dropdownVisible.value) {
closeDropdown()
} else {
openDropdown()
}
}
}
const handleResize = () => {
if (dropdownVisible.value) {
requestAnimationFrame(() => {
updatePosition()
})
}
}
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
const closeAllDropdowns = () => {
const event = new CustomEvent('close-all-dropdowns')
window.dispatchEvent(event)
}
const selectOption = (option: OptionValue, index: number) => {
radioValue.value = option
emit('change', { option, index })
closeDropdown()
}
const focusNextOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = 0
} else {
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
}
scrollToFocused()
}
const focusPreviousOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = props.options.length - 1
} else {
focusedOptionIndex.value =
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
}
scrollToFocused()
}
const scrollToFocused = () => {
if (focusedOptionIndex.value === null) return
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
if (!optionsElement) return
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
const scrollBottom = optionsElement.clientHeight
if (targetScrollTop < optionsElement.scrollTop) {
optionsElement.scrollTop = targetScrollTop
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
}
}
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns()
dropdownVisible.value = true
isOpen.value = true
openDropdownCount.value++
document.body.style.overflow = 'hidden'
await updatePosition()
nextTick(() => {
optionsContainer.value?.focus()
})
}
}
const closeDropdown = () => {
if (isOpen.value) {
dropdownVisible.value = false
isOpen.value = false
openDropdownCount.value--
if (openDropdownCount.value === 0) {
document.body.style.overflow = ''
}
focusedOptionIndex.value = null
triggerRef.value?.focus()
}
}
const handleTriggerKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault()
if (!dropdownVisible.value) {
openDropdown()
focusedOptionIndex.value = event.key === 'ArrowUp' ? props.options.length - 1 : 0
} else if (event.key === 'ArrowDown') {
focusNextOption()
} else {
focusPreviousOption()
}
break
case 'Enter':
case ' ':
event.preventDefault()
if (!dropdownVisible.value) {
openDropdown()
focusedOptionIndex.value = 0
} else if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'Tab':
if (dropdownVisible.value) {
event.preventDefault()
}
break
}
}
const handleListboxKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault()
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'Tab':
event.preventDefault()
break
case 'Home':
event.preventDefault()
focusedOptionIndex.value = 0
scrollToFocused()
break
case 'End':
event.preventDefault()
focusedOptionIndex.value = props.options.length - 1
scrollToFocused()
break
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase()
const index = props.options.findIndex((option) =>
props.displayName(option).toLowerCase().startsWith(char),
)
if (index !== -1) {
focusedOptionIndex.value = index
scrollToFocused()
}
}
break
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleResize, true)
window.addEventListener('click', (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown()
}
})
window.addEventListener('close-all-dropdowns', closeDropdown)
if (selectedValue.value) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize, true)
window.removeEventListener('click', (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown()
}
})
window.removeEventListener('close-all-dropdowns', closeDropdown)
if (isOpen.value) {
openDropdownCount.value--
if (openDropdownCount.value === 0) {
document.body.style.overflow = ''
}
}
})
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue
},
)
watch(dropdownVisible, async (newValue) => {
if (newValue) {
await updatePosition()
scrollTop.value = 0
}
})
const activeDescendant = computed(() =>
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
)
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element
while (currentNode) {
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
return true
}
currentNode = currentNode.parentElement
}
return false
}
</script>