1
0

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

@@ -1,23 +1,24 @@
<template>
<div
:class="[
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
'flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
typeClasses[type],
]"
>
<component
:is="icons[type]"
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold flex justify-between gap-4">
<slot name="header">{{ header }}</slot>
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<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="icons[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-sm sm:text-base">
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
</div>
</div>
<div class="ml-auto w-fit">
<div v-if="showActionsUnderneath || $slots.actions">
<slot name="actions" />
</div>
</div>
@@ -26,20 +27,20 @@
<script setup lang="ts">
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
defineProps({
type: {
type: String as () => 'info' | 'warning' | 'critical',
default: 'info',
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical'
header?: string
body?: string
showActionsUnderneath?: boolean
}>(),
{
type: 'info',
header: '',
body: '',
showActionsUnderneath: false,
},
header: {
type: String,
default: '',
},
body: {
type: String,
default: '',
},
})
)
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',

View File

@@ -0,0 +1,5 @@
<template>
<div
class="min-w-1.5 min-h-1.5 max-h-1.5 max-w-1.5 mx-0.5 rounded-full bg-surface-5 inline-block my-auto align-middle"
></div>
</template>

View File

@@ -6,7 +6,7 @@ const props = withDefaults(
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
size?: 'standard' | 'large' | 'small'
circular?: boolean
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
colorFill?: 'auto' | 'background' | 'text' | 'none'
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
@@ -172,12 +172,16 @@ const colorVariables = computed(() => {
: 'var(--color-button-bg)',
text: 'var(--color-contrast)',
icon:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand)'
: 'var(--color-contrast)',
props.type === 'chip'
? 'var(--color-contrast)'
: props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand)'
: 'var(--color-contrast)',
}
const hoverColors = JSON.parse(JSON.stringify(colors))
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : 'none'
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
}
let colors = {
@@ -197,6 +201,14 @@ const colorVariables = computed(() => {
hoverColors,
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
)
} else if (props.type === 'chip') {
// Chip type uses highlight-colored-text styling when colored
if (colorVar.value && highlightedColorVar.value) {
colors.bg = highlightedColorVar.value
colors.text = colorVar.value
hoverColors.bg = highlightedColorVar.value
hoverColors.text = colorVar.value
}
} else {
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
hoverColors = setColorFill(
@@ -205,7 +217,8 @@ const colorVariables = computed(() => {
)
}
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
const boxShadow = props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : 'none'
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
})
const fontSize = computed(() => {
@@ -219,7 +232,7 @@ const fontSize = computed(() => {
<template>
<div
class="btn-wrapper"
:class="[{ outline: type === 'outlined' }, fontSize]"
:class="[{ outline: type === 'outlined', chip: type === 'chip' }, fontSize]"
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
>
<slot />
@@ -242,10 +255,12 @@ const fontSize = computed(() => {
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
color 0.25s ease-in-out;
color 0.25s ease-in-out,
filter 0.25s ease-in-out;
svg:first-child {
color: var(--_icon, var(--_text));
@@ -267,7 +282,7 @@ const fontSize = computed(() => {
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
&:hover svg:first-child,
&:focus-visible svg:first-child {
@@ -276,6 +291,20 @@ const fontSize = computed(() => {
}
}
.btn-wrapper:not(.chip) :deep(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip) :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip)
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95;
}
}
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,

View File

@@ -0,0 +1,539 @@
<template>
<div ref="containerRef" class="relative inline-block w-full">
<span
ref="triggerRef"
role="button"
tabindex="0"
class="max-h-[36px] relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
:class="[
triggerClasses,
{
'z-[9999]': isOpen,
'rounded-b-none': shouldRoundBottomCorners,
'rounded-t-none': shouldRoundTopCorners,
'cursor-not-allowed opacity-50': disabled,
},
]"
:aria-expanded="isOpen"
:aria-haspopup="listbox ? 'listbox' : 'menu'"
:aria-disabled="disabled || undefined"
@click="handleTriggerClick"
@keydown="handleTriggerKeydown"
>
<div class="flex items-center gap-2">
<slot name="prefix"></slot>
<span class="text-primary font-semibold leading-tight">
<slot name="selected">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
class="size-5 shrink-0 transition-transform duration-300"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</span>
<Teleport to="#teleports">
<div
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 !border-solid border-0 shadow-2xl"
:class="[
shouldRoundBottomCorners
? 'rounded-t-none !border-t-[1px] !border-t-surface-5'
: 'rounded-b-none !border-b-[1px] !border-b-surface-5',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
>
<div v-if="searchable" class="p-4">
<div class="iconified-input w-full border-surface-5 border-[1px] border-solid rounded-xl">
<SearchIcon aria-hidden="true" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class=""
@keydown.stop="handleSearchKeydown"
/>
</div>
</div>
<div v-if="searchable && filteredOptions.length > 0" class="h-px bg-surface-5"></div>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</slot>
</component>
</template>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
No results found
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="T">
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import {
type Component,
computed,
nextTick,
onMounted,
onUnmounted,
ref,
useSlots,
watch,
} from 'vue'
export interface DropdownOption<T> {
value: T
label: string
icon?: Component
disabled?: boolean
class?: string
type?: 'button' | 'link' | 'divider'
href?: string
target?: string
action?: () => void
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
opt: DropdownOption<T> | { type: 'divider' },
): opt is DropdownOption<T> {
return 'value' in opt
}
function isDivider<T>(opt: DropdownOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
return opt.type === 'divider'
}
const props = withDefaults(
defineProps<{
modelValue?: T
options: (DropdownOption<T> | { type: 'divider' })[]
placeholder?: string
disabled?: boolean
searchable?: boolean
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
maxHeight?: number
displayValue?: string
extraPosition?: 'top' | 'bottom'
triggerClass?: string
forceDirection?: 'up' | 'down'
}>(),
{
placeholder: 'Select an option',
disabled: false,
searchable: false,
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
},
)
const emit = defineEmits<{
'update:modelValue': [value: T]
select: [option: DropdownOption<T>]
open: []
close: []
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const dropdownStyle = ref({
top: '0px',
left: '0px',
width: '0px',
})
const openDirection = ref<'down' | 'up'>('down')
const triggerClasses = computed(() => {
const classes = [props.triggerClass]
if (isOpen.value) {
if (props.extraPosition === 'bottom' && slots?.extra) {
classes.push('!rounded-b-none')
} else if (props.extraPosition === 'top' && slots?.extra) {
classes.push('!rounded-t-none')
}
}
return classes
})
const selectedOption = computed<DropdownOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is DropdownOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
)
})
const triggerText = computed(() => {
if (props.displayValue !== undefined) return props.displayValue
if (selectedOption.value) return selectedOption.value.label
return props.placeholder
})
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
key: isDivider(opt) ? `divider-${index}` : `option-${opt.value}`,
}))
})
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable) {
return optionsWithKeys.value
}
const query = searchQuery.value.toLowerCase()
return optionsWithKeys.value.filter((opt) => {
if (isDivider(opt)) return false
return opt.label.toLowerCase().includes(query)
})
})
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: DropdownOption<T> & { key: string }, index: number) {
return [
item.class,
{
'bg-surface-5':
(props.listbox && item.value === props.modelValue) ||
(focusedIndex.value === index && !(props.listbox && item.value === props.modelValue)),
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
}
function setOptionRef(el: HTMLElement | null, index: number) {
optionRefs.value[index] = el
}
function setInitialFocus() {
focusedIndex.value = props.listbox
? props.options.findIndex((opt) => isDropdownOption(opt) && opt.value === props.modelValue)
: -1
if (focusedIndex.value >= 0 && optionRefs.value[focusedIndex.value]) {
optionRefs.value[focusedIndex.value]?.scrollIntoView({ block: 'center' })
}
}
function focusSearchInput() {
if (props.searchable && searchInputRef.value) {
searchInputRef.value.focus()
}
}
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportHeight: number,
): 'up' | 'down' {
if (props.forceDirection) {
return props.forceDirection
}
const hasSpaceBelow =
triggerRect.bottom + dropdownRect.height + DROPDOWN_VIEWPORT_MARGIN <= viewportHeight
const hasSpaceAbove = triggerRect.top - dropdownRect.height - DROPDOWN_VIEWPORT_MARGIN > 0
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
function calculateVerticalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
direction: 'up' | 'down',
): number {
return direction === 'up' ? triggerRect.top - dropdownRect.height : triggerRect.bottom
}
function calculateHorizontalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportWidth: number,
): number {
let left = triggerRect.left
if (left + dropdownRect.width > viewportWidth - DROPDOWN_VIEWPORT_MARGIN) {
left = Math.max(
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - dropdownRect.width - DROPDOWN_VIEWPORT_MARGIN,
)
}
return left
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
const direction = determineOpenDirection(triggerRect, dropdownRect, viewportHeight)
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction)
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewportWidth)
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`,
width: `${triggerRect.width}px`,
}
openDirection.value = direction
}
async function openDropdown() {
if (props.disabled || isOpen.value) return
isOpen.value = true
searchQuery.value = ''
emit('open')
await nextTick()
await updateDropdownPosition()
setInitialFocus()
focusSearchInput()
}
function closeDropdown() {
if (!isOpen.value) return
isOpen.value = false
searchQuery.value = ''
focusedIndex.value = -1
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
}
function handleTriggerClick() {
if (isOpen.value) {
closeDropdown()
} else {
openDropdown()
}
}
function handleOptionClick(option: DropdownOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
focusedIndex.value = index
if (option.action) {
option.action()
}
if (props.listbox && option.value !== undefined) {
emit('update:modelValue', option.value)
}
emit('select', option)
if (option.type !== 'link') {
closeDropdown()
}
}
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
const length = filteredOptions.value.length
let index = currentIndex
let option
do {
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
option = filteredOptions.value[index]
} while (isDivider(option) || option.disabled)
return index
}
function focusOption(index: number) {
if (index < 0 || index >= filteredOptions.value.length) return
const option = filteredOptions.value[index]
if (isDivider(option) || option.disabled) return
focusedIndex.value = index
optionRefs.value[index]?.focus()
optionRefs.value[index]?.scrollIntoView({ block: 'nearest' })
}
function focusNextOption() {
const nextIndex = findNextFocusableOption(focusedIndex.value, 'next')
focusOption(nextIndex)
}
function focusPreviousOption() {
const prevIndex = findNextFocusableOption(focusedIndex.value, 'previous')
focusOption(prevIndex)
}
function handleTriggerKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault()
openDropdown()
break
case 'ArrowUp':
event.preventDefault()
openDropdown()
break
}
}
function handleDropdownKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (!isDivider(option)) {
handleOptionClick(option, focusedIndex.value)
}
}
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
focusNextOption()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusPreviousOption()
}
}
function handleWindowResize() {
if (isOpen.value) {
updateDropdownPosition()
}
}
onClickOutside(
dropdownRef,
() => {
closeDropdown()
},
{ ignore: [triggerRef] },
)
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
})
watch(isOpen, (value) => {
if (value) {
updateDropdownPosition()
}
})
watch(filteredOptions, () => {
if (isOpen.value) {
updateDropdownPosition()
}
})
</script>

View File

@@ -1,441 +0,0 @@
<template>
<div
ref="dropdown"
data-pyro-dropdown
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="relative inline-block h-9 w-full max-w-80"
@focus="onFocus"
@blur="onBlur"
@mousedown.prevent
@keydown="handleKeyDown"
>
<div
data-pyro-dropdown-trigger
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</div>
<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"
ref="optionsContainer"
data-pyro-dropdown-options
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown.stop="handleDropdownKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
data-pyro-dropdown-options-virtual-scroller
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
data-pyro-dropdown-option
:style="{
position: 'absolute',
top: 0,
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
width: '100%',
height: `${ITEM_HEIGHT}px`,
}"
>
<div
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
role="option"
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
}"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)"
@mouseover="focusedOptionIndex = item.index"
@focus="focusedOptionIndex = item.index"
>
<input
:id="`${name}-${item.index}`"
v-model="radioValue"
type="radio"
:value="item.option"
:name="name"
class="hidden"
/>
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
{{ displayName(item.option) }}
</label>
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="OptionValue extends string | number | Record<string, any>">
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
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 focusedOptionRef = ref<HTMLElement | null>(null)
const dropdown = ref<HTMLElement | null>(null)
const optionsContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const isRenderingUp = ref(false)
const virtualListHeight = ref(300)
const lastFocusedElement = ref<HTMLElement | null>(null)
const positionStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
width: '0px',
zIndex: 999,
})
const handleOptionRef = (el: HTMLElement | null, index: number) => {
if (focusedOptionIndex.value === index) {
focusedOptionRef.value = el
}
}
const onFocus = async () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
lastFocusedElement.value = document.activeElement as HTMLElement
dropdownVisible.value = true
await updatePosition()
nextTick(() => {
dropdown.value?.focus()
})
}
}
const onBlur = (event: FocusEvent) => {
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
closeDropdown()
}
}
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element
while (currentNode) {
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
return true
}
currentNode = currentNode.parentElement
}
return false
}
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 (!dropdown.value) return
await nextTick()
const triggerRect = dropdown.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 openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns()
dropdownVisible.value = true
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
lastFocusedElement.value = document.activeElement as HTMLElement
await updatePosition()
requestAnimationFrame(() => {
updatePosition()
})
}
}
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 handleKeyDown = (event: KeyboardEvent) => {
if (!dropdownVisible.value) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
lastFocusedElement.value = document.activeElement as HTMLElement
toggleDropdown()
}
} else {
handleDropdownKeyDown(event)
}
}
const handleDropdownKeyDown = (event: KeyboardEvent) => {
event.stopPropagation()
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
event.preventDefault()
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'Escape':
event.preventDefault()
event.stopPropagation()
closeDropdown()
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
const closeDropdown = () => {
dropdownVisible.value = false
focusedOptionIndex.value = null
if (lastFocusedElement.value) {
lastFocusedElement.value.focus()
lastFocusedElement.value = null
}
}
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()
nextTick(() => {
focusedOptionRef.value?.focus()
})
}
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()
nextTick(() => {
focusedOptionRef.value?.focus()
})
}
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
}
}
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)
})
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)
lastFocusedElement.value = null
})
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue
},
)
watch(dropdownVisible, async (newValue) => {
if (newValue) {
await updatePosition()
scrollTop.value = 0
}
})
</script>

View File

@@ -6,6 +6,7 @@ export { default as AutoBrandIcon } from './base/AutoBrandIcon.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
export { default as BulletDivider } from './base/BulletDivider.vue'
export { default as Button } from './base/Button.vue'
export { default as ButtonStyled } from './base/ButtonStyled.vue'
export { default as Card } from './base/Card.vue'
@@ -13,6 +14,7 @@ export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
export { default as Combobox } from './base/Combobox.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'
@@ -50,7 +52,6 @@ export { default as Slider } from './base/Slider.vue'
export { default as SmartClickable } from './base/SmartClickable.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as TagItem } from './base/TagItem.vue'
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
export { default as Timeline } from './base/Timeline.vue'
export { default as Toggle } from './base/Toggle.vue'
export { default as UnsavedChangesPopup } from './base/UnsavedChangesPopup.vue'

View File

@@ -38,9 +38,66 @@
</button>
</ButtonStyled>
</div>
<div class="overflow-y-auto p-6">
<ButtonStyled
v-if="props.mergeHeader && closable"
class="absolute top-4 right-4 z-10"
circular
>
<button v-tooltip="'Close'" aria-label="Close" @click="hide">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<div v-if="scrollable" class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-24 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="scrollContainer"
:class="[
'overflow-y-auto p-6 !pb-1 sm:pb-6',
{ 'pt-12': props.mergeHeader && closable },
]"
:style="{ maxHeight: maxContentHeight }"
@scroll="checkScrollState"
>
<slot> You just lost the game.</slot>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-24 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<div v-else :class="['overflow-y-auto p-6', { 'pt-12': props.mergeHeader && closable }]">
<slot> You just lost the game.</slot>
</div>
<div v-if="$slots.actions" class="p-6 pt-0">
<slot name="actions" />
</div>
</div>
</div>
</div>
@@ -51,6 +108,7 @@
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import ButtonStyled from '../base/ButtonStyled.vue'
const props = withDefaults(
@@ -65,6 +123,9 @@ const props = withDefaults(
hideHeader?: boolean
onHide?: () => void
onShow?: () => void
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
}>(),
{
type: true,
@@ -77,12 +138,19 @@ const props = withDefaults(
hideHeader: false,
onHide: () => {},
onShow: () => {},
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
maxContentHeight: '70vh',
},
)
const open = ref(false)
const visible = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
// make modal opening not shift page when there's a vertical scrollbar
function addBodyPadding() {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth
@@ -127,6 +195,7 @@ function hide() {
defineExpose({
show,
hide,
checkScrollState,
})
const mouseX = ref(-1)

View File

@@ -0,0 +1,28 @@
function getCallerLocation(): string {
try {
const stack = new Error().stack
if (!stack) return ''
const lines = stack.split('\n')
const callerLine = lines[3]
if (!callerLine) return ''
const match = callerLine.match(/(https?:\/\/.+?|file:\/\/.+?|\/.*?):(\d+):\d+/)
if (!match) return ''
const [, fullPath, line] = match
const fileName = fullPath.split('/').pop()?.split('?')[0] || fullPath
return `${fileName}:${line}`
} catch {
return ''
}
}
export function useDebugLogger(namespace: string) {
// eslint-disable-next-line
return (...args: any[]) => {
const location = getCallerLocation()
const prefix = location ? `[${namespace}] [${location}]` : `[${namespace}]`
console.debug(prefix, ...args)
}
}

View File

@@ -1,2 +1,4 @@
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './how-ago'
export * from './scroll-indicator'

View File

@@ -0,0 +1,181 @@
import { nextTick, onUnmounted, type Ref, ref, watchEffect } from 'vue'
import { useDebugLogger } from './debug-logger'
export interface ScrollIndicatorOptions {
watchContent?: boolean
debounceMs?: number
tolerance?: number
debug?: boolean
}
export interface ScrollIndicator {
showTopFade: Ref<boolean>
showBottomFade: Ref<boolean>
checkScrollState: () => void
forceCheck: () => void
}
export function useScrollIndicator(
containerRef: Ref<HTMLElement | null>,
options: ScrollIndicatorOptions = {},
): ScrollIndicator {
const { watchContent = true, debounceMs = 10, tolerance = 1, debug = false } = options
const showTopFade = ref(false)
const showBottomFade = ref(false)
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
let rafId: number | null = null
let debounceTimer: number | null = null
const log = useDebugLogger('ScrollIndicator')
const checkScrollStateInternal = () => {
const container = containerRef.value
if (!container) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Container not found, hiding fades')
return
}
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
const { scrollTop, scrollHeight, clientHeight } = container
const isScrollable = scrollHeight > clientHeight + tolerance
if (debug) {
log('Checking scroll state', {
scrollTop,
scrollHeight,
clientHeight,
isScrollable,
})
}
if (!isScrollable) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Content fits, no fades needed')
} else {
showTopFade.value = scrollTop > tolerance
showBottomFade.value = scrollTop < scrollHeight - clientHeight - tolerance
if (debug) {
log('Fades updated', {
showTop: showTopFade.value,
showBottom: showBottomFade.value,
})
}
}
})
}
const checkScrollState = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = window.setTimeout(() => {
checkScrollStateInternal()
}, debounceMs)
}
const forceCheck = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
checkScrollStateInternal()
}
watchEffect((onCleanup) => {
const container = containerRef.value
if (!container) {
if (debug) log('No container, skipping setup')
return
}
if (debug) log('Setting up observers for container', container)
nextTick(() => {
forceCheck()
})
resizeObserver = new ResizeObserver(() => {
if (debug) log('ResizeObserver triggered')
checkScrollState()
})
resizeObserver.observe(container)
if (watchContent) {
mutationObserver = new MutationObserver(() => {
if (debug) log('MutationObserver triggered')
checkScrollState()
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
attributes: false,
})
}
const handleScroll = () => {
if (debug) log('Scroll event triggered')
checkScrollState()
}
container.addEventListener('scroll', handleScroll, { passive: true })
const handleResize = () => {
if (debug) log('Window resize triggered')
checkScrollState()
}
window.addEventListener('resize', handleResize, { passive: true })
onCleanup(() => {
if (debug) log('Cleaning up observers and listeners')
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
resizeObserver?.disconnect()
resizeObserver = null
mutationObserver?.disconnect()
mutationObserver = null
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
})
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (rafId) {
cancelAnimationFrame(rafId)
}
})
return {
showTopFade,
showBottomFade,
checkScrollState,
forceCheck,
}
}

View File

@@ -86,6 +86,9 @@
"button.follow": {
"defaultMessage": "Follow"
},
"button.max": {
"defaultMessage": "Max"
},
"button.more-options": {
"defaultMessage": "More options"
},
@@ -158,6 +161,84 @@
"collection.label.private": {
"defaultMessage": "Private"
},
"form.label.address-line": {
"defaultMessage": "Address line"
},
"form.label.address-line-2": {
"defaultMessage": "Address line 2 (optional)"
},
"form.label.amount": {
"defaultMessage": "Amount"
},
"form.label.bank-name": {
"defaultMessage": "Bank name"
},
"form.label.business-name": {
"defaultMessage": "Business name"
},
"form.label.city": {
"defaultMessage": "City"
},
"form.label.country": {
"defaultMessage": "Country"
},
"form.label.date-of-birth": {
"defaultMessage": "Date of birth"
},
"form.label.email": {
"defaultMessage": "Email"
},
"form.label.first-name": {
"defaultMessage": "First name"
},
"form.label.last-name": {
"defaultMessage": "Last name"
},
"form.label.postal-code": {
"defaultMessage": "Postal code/ZIP code"
},
"form.label.state-province": {
"defaultMessage": "State/province"
},
"form.placeholder.address": {
"defaultMessage": "Enter address"
},
"form.placeholder.address-2": {
"defaultMessage": "Apartment, suite, etc."
},
"form.placeholder.amount": {
"defaultMessage": "Enter amount"
},
"form.placeholder.bank-name": {
"defaultMessage": "Enter bank name"
},
"form.placeholder.bank-name-dropdown": {
"defaultMessage": "Select bank name"
},
"form.placeholder.business-name": {
"defaultMessage": "Enter business name"
},
"form.placeholder.city": {
"defaultMessage": "Enter city"
},
"form.placeholder.country": {
"defaultMessage": "Select country"
},
"form.placeholder.email": {
"defaultMessage": "Enter email address"
},
"form.placeholder.first-name": {
"defaultMessage": "Enter first name"
},
"form.placeholder.last-name": {
"defaultMessage": "Enter last name"
},
"form.placeholder.postal-code": {
"defaultMessage": "Enter postal code"
},
"form.placeholder.state": {
"defaultMessage": "Enter state/province"
},
"icon-select.edit": {
"defaultMessage": "Edit icon"
},
@@ -197,6 +278,9 @@
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Unknown game mode"
},
"label.available": {
"defaultMessage": "{amount} available."
},
"label.changes-saved": {
"defaultMessage": "Changes saved"
},
@@ -245,6 +329,9 @@
"label.rejected": {
"defaultMessage": "Rejected"
},
"label.rewards-program-terms-agreement": {
"defaultMessage": "I agree to the <terms-link>Rewards Program Terms</terms-link>"
},
"label.saved": {
"defaultMessage": "Saved"
},
@@ -392,6 +479,9 @@
"omorphia.component.purchase_modal.payment_method_type.paypal": {
"defaultMessage": "PayPal"
},
"omorphia.component.purchase_modal.payment_method_type.paypal_international": {
"defaultMessage": "PayPal International"
},
"omorphia.component.purchase_modal.payment_method_type.unionpay": {
"defaultMessage": "UnionPay"
},
@@ -401,6 +491,27 @@
"omorphia.component.purchase_modal.payment_method_type.visa": {
"defaultMessage": "Visa"
},
"payment-method.charity": {
"defaultMessage": "Charity"
},
"payment-method.charity-plural": {
"defaultMessage": "Charities"
},
"payment-method.gift-card": {
"defaultMessage": "Gift card"
},
"payment-method.gift-card-plural": {
"defaultMessage": "Gift cards"
},
"payment-method.venmo": {
"defaultMessage": "Venmo"
},
"payment-method.virtual-visa": {
"defaultMessage": "Virtual Visa"
},
"payment-method.virtual-visa-plural": {
"defaultMessage": "Virtual Visas"
},
"project-type.all": {
"defaultMessage": "All"
},

View File

@@ -281,6 +281,131 @@ export const commonMessages = defineMessages({
id: 'label.visit-your-profile',
defaultMessage: 'Visit your profile',
},
maxButton: {
id: 'button.max',
defaultMessage: 'Max',
},
})
export const formFieldLabels = defineMessages({
email: {
id: 'form.label.email',
defaultMessage: 'Email',
},
firstName: {
id: 'form.label.first-name',
defaultMessage: 'First name',
},
lastName: {
id: 'form.label.last-name',
defaultMessage: 'Last name',
},
dateOfBirth: {
id: 'form.label.date-of-birth',
defaultMessage: 'Date of birth',
},
businessName: {
id: 'form.label.business-name',
defaultMessage: 'Business name',
},
addressLine: {
id: 'form.label.address-line',
defaultMessage: 'Address line',
},
addressLine2: {
id: 'form.label.address-line-2',
defaultMessage: 'Address line 2 (optional)',
},
city: {
id: 'form.label.city',
defaultMessage: 'City',
},
stateProvince: {
id: 'form.label.state-province',
defaultMessage: 'State/province',
},
postalCode: {
id: 'form.label.postal-code',
defaultMessage: 'Postal code/ZIP code',
},
country: {
id: 'form.label.country',
defaultMessage: 'Country',
},
bankName: {
id: 'form.label.bank-name',
defaultMessage: 'Bank name',
},
amount: {
id: 'form.label.amount',
defaultMessage: 'Amount',
},
})
export const formFieldPlaceholders = defineMessages({
emailPlaceholder: {
id: 'form.placeholder.email',
defaultMessage: 'Enter email address',
},
firstNamePlaceholder: {
id: 'form.placeholder.first-name',
defaultMessage: 'Enter first name',
},
lastNamePlaceholder: {
id: 'form.placeholder.last-name',
defaultMessage: 'Enter last name',
},
businessNamePlaceholder: {
id: 'form.placeholder.business-name',
defaultMessage: 'Enter business name',
},
addressPlaceholder: {
id: 'form.placeholder.address',
defaultMessage: 'Enter address',
},
address2Placeholder: {
id: 'form.placeholder.address-2',
defaultMessage: 'Apartment, suite, etc.',
},
cityPlaceholder: {
id: 'form.placeholder.city',
defaultMessage: 'Enter city',
},
statePlaceholder: {
id: 'form.placeholder.state',
defaultMessage: 'Enter state/province',
},
postalCodePlaceholder: {
id: 'form.placeholder.postal-code',
defaultMessage: 'Enter postal code',
},
countryPlaceholder: {
id: 'form.placeholder.country',
defaultMessage: 'Select country',
},
bankNamePlaceholder: {
id: 'form.placeholder.bank-name',
defaultMessage: 'Enter bank name',
},
bankNamePlaceholderDropdown: {
id: 'form.placeholder.bank-name-dropdown',
defaultMessage: 'Select bank name',
},
amountPlaceholder: {
id: 'form.placeholder.amount',
defaultMessage: 'Enter amount',
},
})
export const financialMessages = defineMessages({
available: {
id: 'label.available',
defaultMessage: '{amount} available.',
},
rewardsProgramTermsAgreement: {
id: 'label.rewards-program-terms-agreement',
defaultMessage: 'I agree to the <terms-link>Rewards Program Terms</terms-link>',
},
})
export const commonProjectTypeCategoryMessages = defineMessages({
@@ -499,6 +624,14 @@ export const paymentMethodMessages = defineMessages({
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
paypalInternational: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal_international',
defaultMessage: 'PayPal International',
},
paypalUS: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
unionpay: {
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
defaultMessage: 'UnionPay',
@@ -511,4 +644,32 @@ export const paymentMethodMessages = defineMessages({
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
defaultMessage: 'Visa',
},
venmo: {
id: 'payment-method.venmo',
defaultMessage: 'Venmo',
},
virtualVisa: {
id: 'payment-method.virtual-visa',
defaultMessage: 'Virtual Visa',
},
virtualVisaPlural: {
id: 'payment-method.virtual-visa-plural',
defaultMessage: 'Virtual Visas',
},
giftCard: {
id: 'payment-method.gift-card',
defaultMessage: 'Gift card',
},
giftCardPlural: {
id: 'payment-method.gift-card-plural',
defaultMessage: 'Gift cards',
},
charity: {
id: 'payment-method.charity',
defaultMessage: 'Charity',
},
charityPlural: {
id: 'payment-method.charity-plural',
defaultMessage: 'Charities',
},
})