You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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',
|
||||
|
||||
5
packages/ui/src/components/base/BulletDivider.vue
Normal file
5
packages/ui/src/components/base/BulletDivider.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
539
packages/ui/src/components/base/Combobox.vue
Normal file
539
packages/ui/src/components/base/Combobox.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
packages/ui/src/composables/debug-logger.ts
Normal file
28
packages/ui/src/composables/debug-logger.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './debug-logger'
|
||||
export * from './dynamic-font-size'
|
||||
export * from './how-ago'
|
||||
export * from './scroll-indicator'
|
||||
|
||||
181
packages/ui/src/composables/scroll-indicator.ts
Normal file
181
packages/ui/src/composables/scroll-indicator.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user