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>
|
Before Width: | Height: | Size: 989 B After Width: | Height: | Size: 989 B |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 967 B After Width: | Height: | Size: 967 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
13
packages/assets/external/color/paypal.svg
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.056000232696533 3 37.35095977783203 45">
|
||||
<g xmlns="http://www.w3.org/2000/svg" clip-path="url(#a)">
|
||||
<path fill="#002991"
|
||||
d="M38.914 13.35c0 5.574-5.144 12.15-12.927 12.15H18.49l-.368 2.322L16.373 39H7.056l5.605-36h15.095c5.083 0 9.082 2.833 10.555 6.77a9.687 9.687 0 0 1 .603 3.58z">
|
||||
</path>
|
||||
<path fill="#60CDFF"
|
||||
d="M44.284 23.7A12.894 12.894 0 0 1 31.53 34.5h-5.206L24.157 48H14.89l1.483-9 1.75-11.178.367-2.322h7.497c7.773 0 12.927-6.576 12.927-12.15 3.825 1.974 6.055 5.963 5.37 10.35z">
|
||||
</path>
|
||||
<path fill="#008CFF"
|
||||
d="M38.914 13.35C37.31 12.511 35.365 12 33.248 12h-12.64L18.49 25.5h7.497c7.773 0 12.927-6.576 12.927-12.15z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
|
Before Width: | Height: | Size: 839 B After Width: | Height: | Size: 839 B |
10
packages/assets/external/color/usdc.svg
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="86977684-12db-4850-8f30-233a7c267d11" viewBox="0 0 2000 2000">
|
||||
<path d="M1000 2000c554.17 0 1000-445.83 1000-1000S1554.17 0 1000 0 0 445.83 0 1000s445.83 1000 1000 1000z"
|
||||
fill="#2775ca" />
|
||||
<path
|
||||
d="M1275 1158.33c0-145.83-87.5-195.83-262.5-216.66-125-16.67-150-50-150-108.34s41.67-95.83 125-95.83c75 0 116.67 25 137.5 87.5 4.17 12.5 16.67 20.83 29.17 20.83h66.66c16.67 0 29.17-12.5 29.17-29.16v-4.17c-16.67-91.67-91.67-162.5-187.5-170.83v-100c0-16.67-12.5-29.17-33.33-33.34h-62.5c-16.67 0-29.17 12.5-33.34 33.34v95.83c-125 16.67-204.16 100-204.16 204.17 0 137.5 83.33 191.66 258.33 212.5 116.67 20.83 154.17 45.83 154.17 112.5s-58.34 112.5-137.5 112.5c-108.34 0-145.84-45.84-158.34-108.34-4.16-16.66-16.66-25-29.16-25h-70.84c-16.66 0-29.16 12.5-29.16 29.17v4.17c16.66 104.16 83.33 179.16 220.83 200v100c0 16.66 12.5 29.16 33.33 33.33h62.5c16.67 0 29.17-12.5 33.34-33.33v-100c125-20.84 208.33-108.34 208.33-220.84z"
|
||||
fill="#fff" />
|
||||
<path
|
||||
d="M787.5 1595.83c-325-116.66-491.67-479.16-370.83-800 62.5-175 200-308.33 370.83-370.83 16.67-8.33 25-20.83 25-41.67V325c0-16.67-8.33-29.17-25-33.33-4.17 0-12.5 0-16.67 4.16-395.83 125-612.5 545.84-487.5 941.67 75 233.33 254.17 412.5 487.5 487.5 16.67 8.33 33.34 0 37.5-16.67 4.17-4.16 4.17-8.33 4.17-16.66v-58.34c0-12.5-12.5-29.16-25-37.5zM1229.17 295.83c-16.67-8.33-33.34 0-37.5 16.67-4.17 4.17-4.17 8.33-4.17 16.67v58.33c0 16.67 12.5 33.33 25 41.67 325 116.66 491.67 479.16 370.83 800-62.5 175-200 308.33-370.83 370.83-16.67 8.33-25 20.83-25 41.67V1700c0 16.67 8.33 29.17 25 33.33 4.17 0 12.5 0 16.67-4.16 395.83-125 612.5-545.84 487.5-941.67-75-237.5-258.34-416.67-487.5-491.67z"
|
||||
fill="#fff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
8
packages/assets/external/color/venmo.svg
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<g transform="matrix(.124031 0 0 .124031 -.000001 56.062016)">
|
||||
<rect y="-452" rx="61" height="516" width="516" fill="#3396cd" />
|
||||
<path
|
||||
d="M385.16-347c11.1 18.3 16.08 37.17 16.08 61 0 76-64.87 174.7-117.52 244H163.5l-48.2-288.35 105.3-10 25.6 205.17C270-174 299.43-235 299.43-276.56c0-22.77-3.9-38.25-10-51z"
|
||||
fill="#fff" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
6
packages/assets/external/paypal.svg
vendored
@@ -1 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PayPal</title><path fill="currentColor" d="M7.016 19.198h-4.2a.562.562 0 0 1-.555-.65L5.093.584A.692.692 0 0 1 5.776 0h7.222c3.417 0 5.904 2.488 5.846 5.5-.006.25-.027.5-.066.747A6.794 6.794 0 0 1 12.071 12H8.743a.69.69 0 0 0-.682.583l-.325 2.056-.013.083-.692 4.39-.015.087zM19.79 6.142c-.01.087-.01.175-.023.261a7.76 7.76 0 0 1-7.695 6.598H9.007l-.283 1.795-.013.083-.692 4.39-.134.843-.014.088H6.86l-.497 3.15a.562.562 0 0 0 .555.65h3.612c.34 0 .63-.249.683-.585l.952-6.031a.692.692 0 0 1 .683-.584h2.126a6.793 6.793 0 0 0 6.707-5.752c.306-1.95-.466-3.744-1.89-4.906z"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>PayPal</title>
|
||||
<path fill="currentColor"
|
||||
d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 658 B After Width: | Height: | Size: 481 B |
5
packages/assets/external/polygon.svg
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Polygon</title>
|
||||
<path fill="currentColor"
|
||||
d="m17.82 16.342 5.692-3.287A.98.98 0 0 0 24 12.21V5.635a.98.98 0 0 0-.488-.846l-5.693-3.286a.98.98 0 0 0-.977 0L11.15 4.789a.98.98 0 0 0-.489.846v11.747L6.67 19.686l-3.992-2.304v-4.61l3.992-2.304 2.633 1.52V8.896L7.158 7.658a.98.98 0 0 0-.977 0L.488 10.945a.98.98 0 0 0-.488.846v6.573a.98.98 0 0 0 .488.847l5.693 3.286a.981.981 0 0 0 .977 0l5.692-3.286a.98.98 0 0 0 .489-.846V6.618l.072-.041 3.92-2.263 3.99 2.305v4.609l-3.99 2.304-2.63-1.517v3.092l2.14 1.236a.981.981 0 0 0 .978 0v-.001Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
6
packages/assets/external/tumblr.svg
vendored
@@ -1 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tumblr</title><path fill="currentColor" d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Tumblr</title>
|
||||
<path fill="currentColor"
|
||||
d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 414 B After Width: | Height: | Size: 422 B |
5
packages/assets/external/venmo.svg
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M56.4346 0C60.6127 0.000251497 63.9998 3.38727 64 7.56543V56.4346C63.9997 60.6127 60.6127 63.9997 56.4346 64H7.56543C3.38727 63.9998 0.0002515 60.6127 0 56.4346V7.56543C0.000249178 3.38727 3.38727 0.000249181 7.56543 0H56.4346ZM35.8984 15.4346C36.655 17.0159 37.1386 18.9358 37.1387 21.7598C37.1387 26.9145 33.4881 34.481 30.5361 39.2959L27.3613 13.8486L14.3008 15.0889L20.2793 50.8525H35.1904C41.7206 42.2572 49.7666 30.0151 49.7666 20.5889C49.7665 17.6334 49.1482 15.2931 47.7715 13.0234L35.8984 15.4346Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
5
packages/assets/external/visa.svg
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M24.2987 22.0321L15.92 42.0214H10.4533L6.33067 26.0667C6.08 25.0854 5.864 24.7254 5.10133 24.3121C3.85867 23.6374 1.80533 23.0054 0 22.6107L0.122667 22.0321H8.92267C9.49773 22.0315 10.0541 22.2365 10.4912 22.6101C10.9284 22.9837 11.2176 23.5013 11.3067 24.0694L13.4853 35.6374L18.8667 22.0321H24.2987ZM45.72 35.4961C45.7413 30.2187 38.424 29.9281 38.4747 27.5707C38.4907 26.8534 39.1733 26.0907 40.6667 25.8961C42.4168 25.7299 44.1793 26.0394 45.768 26.7921L46.6747 22.5521C45.1279 21.9706 43.4898 21.6699 41.8373 21.6641C36.7253 21.6641 33.128 24.3841 33.096 28.2747C33.064 31.1521 35.664 32.7547 37.624 33.7147C39.64 34.6934 40.3173 35.3227 40.3067 36.1974C40.2933 37.5414 38.7013 38.1307 37.2133 38.1547C34.6133 38.1947 33.1067 37.4534 31.9013 36.8934L30.9653 41.2721C32.1733 41.8267 34.4027 42.3121 36.7147 42.3334C42.1467 42.3334 45.7013 39.6507 45.72 35.4961ZM59.216 42.0214H64L59.8267 22.0321H55.4107C54.9388 22.0277 54.4766 22.1652 54.0838 22.4267C53.6911 22.6882 53.3859 23.0617 53.208 23.4987L45.4507 42.0214H50.88L51.96 39.0347H58.5947L59.216 42.0214ZM53.448 34.9387L56.168 27.4321L57.736 34.9387H53.448ZM31.688 22.0321L27.4133 42.0214H22.24L26.52 22.0321H31.688Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -6,8 +6,13 @@ import _AlignLeftIcon from './icons/align-left.svg?component'
|
||||
import _ArchiveIcon from './icons/archive.svg?component'
|
||||
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
|
||||
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
|
||||
import _ArrowDownIcon from './icons/arrow-down.svg?component'
|
||||
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
|
||||
import _ArrowUpIcon from './icons/arrow-up.svg?component'
|
||||
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
|
||||
import _AsteriskIcon from './icons/asterisk.svg?component'
|
||||
import _BadgeCheckIcon from './icons/badge-check.svg?component'
|
||||
import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
|
||||
import _BanIcon from './icons/ban.svg?component'
|
||||
import _BellIcon from './icons/bell.svg?component'
|
||||
import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||
@@ -72,12 +77,14 @@ import _FolderSearchIcon from './icons/folder-search.svg?component'
|
||||
import _GameIcon from './icons/game.svg?component'
|
||||
import _GapIcon from './icons/gap.svg?component'
|
||||
import _GaugeIcon from './icons/gauge.svg?component'
|
||||
import _GiftIcon from './icons/gift.svg?component'
|
||||
import _GitGraphIcon from './icons/git-graph.svg?component'
|
||||
import _GlassesIcon from './icons/glasses.svg?component'
|
||||
import _GlobeIcon from './icons/globe.svg?component'
|
||||
import _GridIcon from './icons/grid.svg?component'
|
||||
import _HamburgerIcon from './icons/hamburger.svg?component'
|
||||
import _HammerIcon from './icons/hammer.svg?component'
|
||||
import _HandHelpingIcon from './icons/hand-helping.svg?component'
|
||||
import _HashIcon from './icons/hash.svg?component'
|
||||
import _Heading1Icon from './icons/heading-1.svg?component'
|
||||
import _Heading2Icon from './icons/heading-2.svg?component'
|
||||
@@ -94,6 +101,7 @@ import _IssuesIcon from './icons/issues.svg?component'
|
||||
import _ItalicIcon from './icons/italic.svg?component'
|
||||
import _KeyIcon from './icons/key.svg?component'
|
||||
import _KeyboardIcon from './icons/keyboard.svg?component'
|
||||
import _LandmarkIcon from './icons/landmark.svg?component'
|
||||
import _LanguagesIcon from './icons/languages.svg?component'
|
||||
import _LeftArrowIcon from './icons/left-arrow.svg?component'
|
||||
import _LibraryIcon from './icons/library.svg?component'
|
||||
@@ -104,6 +112,7 @@ import _ListBulletedIcon from './icons/list-bulleted.svg?component'
|
||||
import _ListEndIcon from './icons/list-end.svg?component'
|
||||
import _ListOrderedIcon from './icons/list-ordered.svg?component'
|
||||
import _LoaderIcon from './icons/loader.svg?component'
|
||||
import _LoaderCircleIcon from './icons/loader-circle.svg?component'
|
||||
import _LockIcon from './icons/lock.svg?component'
|
||||
import _LockOpenIcon from './icons/lock-open.svg?component'
|
||||
import _LogInIcon from './icons/log-in.svg?component'
|
||||
@@ -210,8 +219,13 @@ export const AlignLeftIcon = _AlignLeftIcon
|
||||
export const ArchiveIcon = _ArchiveIcon
|
||||
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
|
||||
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
|
||||
export const ArrowDownIcon = _ArrowDownIcon
|
||||
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
|
||||
export const ArrowUpRightIcon = _ArrowUpRightIcon
|
||||
export const ArrowUpIcon = _ArrowUpIcon
|
||||
export const AsteriskIcon = _AsteriskIcon
|
||||
export const BadgeCheckIcon = _BadgeCheckIcon
|
||||
export const BadgeDollarSignIcon = _BadgeDollarSignIcon
|
||||
export const BanIcon = _BanIcon
|
||||
export const BellRingIcon = _BellRingIcon
|
||||
export const BellIcon = _BellIcon
|
||||
@@ -276,12 +290,14 @@ export const FolderSearchIcon = _FolderSearchIcon
|
||||
export const GameIcon = _GameIcon
|
||||
export const GapIcon = _GapIcon
|
||||
export const GaugeIcon = _GaugeIcon
|
||||
export const GiftIcon = _GiftIcon
|
||||
export const GitGraphIcon = _GitGraphIcon
|
||||
export const GlassesIcon = _GlassesIcon
|
||||
export const GlobeIcon = _GlobeIcon
|
||||
export const GridIcon = _GridIcon
|
||||
export const HamburgerIcon = _HamburgerIcon
|
||||
export const HammerIcon = _HammerIcon
|
||||
export const HandHelpingIcon = _HandHelpingIcon
|
||||
export const HashIcon = _HashIcon
|
||||
export const Heading1Icon = _Heading1Icon
|
||||
export const Heading2Icon = _Heading2Icon
|
||||
@@ -298,6 +314,7 @@ export const IssuesIcon = _IssuesIcon
|
||||
export const ItalicIcon = _ItalicIcon
|
||||
export const KeyIcon = _KeyIcon
|
||||
export const KeyboardIcon = _KeyboardIcon
|
||||
export const LandmarkIcon = _LandmarkIcon
|
||||
export const LanguagesIcon = _LanguagesIcon
|
||||
export const LeftArrowIcon = _LeftArrowIcon
|
||||
export const LibraryIcon = _LibraryIcon
|
||||
@@ -307,6 +324,7 @@ export const ListBulletedIcon = _ListBulletedIcon
|
||||
export const ListEndIcon = _ListEndIcon
|
||||
export const ListOrderedIcon = _ListOrderedIcon
|
||||
export const ListIcon = _ListIcon
|
||||
export const LoaderCircleIcon = _LoaderCircleIcon
|
||||
export const LoaderIcon = _LoaderIcon
|
||||
export const LockOpenIcon = _LockOpenIcon
|
||||
export const LockIcon = _LockIcon
|
||||
|
||||
5
packages/assets/icons/arrow-down.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-icon lucide-arrow-down">
|
||||
<path d="M12 5v14" />
|
||||
<path d="m19 12-7 7-7-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 275 B |
1
packages/assets/icons/arrow-left-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-right-icon lucide-arrow-left-right"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>
|
||||
|
After Width: | Height: | Size: 344 B |
15
packages/assets/icons/arrow-up-right.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"
|
||||
>
|
||||
<path d="M7 7h10v10" />
|
||||
<path d="M7 17 17 7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 314 B |
15
packages/assets/icons/arrow-up.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-arrow-up-icon lucide-arrow-up"
|
||||
>
|
||||
<path d="m5 12 7-7 7 7" />
|
||||
<path d="M12 19V5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
7
packages/assets/icons/badge-dollar-sign.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-dollar-sign-icon lucide-badge-dollar-sign">
|
||||
<path
|
||||
d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" />
|
||||
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
|
||||
<path d="M12 18V6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
1
packages/assets/icons/gift.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gift-icon lucide-gift"><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/></svg>
|
||||
|
After Width: | Height: | Size: 441 B |
6
packages/assets/icons/hand-helping.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-helping-icon lucide-hand-helping">
|
||||
<path d="M11 12h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 14" />
|
||||
<path d="m7 18 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9" />
|
||||
<path d="m2 13 6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
9
packages/assets/icons/landmark.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-landmark-icon lucide-landmark">
|
||||
<path d="M10 18v-7" />
|
||||
<path d="M11.12 2.198a2 2 0 0 1 1.76.006l7.866 3.847c.476.233.31.949-.22.949H3.474c-.53 0-.695-.716-.22-.949z" />
|
||||
<path d="M14 18v-7" />
|
||||
<path d="M18 18v-7" />
|
||||
<path d="M3 22h18" />
|
||||
<path d="M6 18v-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
1
packages/assets/icons/loader-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle-icon lucide-loader-circle"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -27,6 +27,15 @@ import _WavingRinthbot from './branding/rinthbot/waving.webp'
|
||||
import _AppleIcon from './external/apple.svg?component'
|
||||
import _BlueskyIcon from './external/bluesky.svg?component'
|
||||
import _BuyMeACoffeeIcon from './external/bmac.svg?component'
|
||||
import _DiscordColorIcon from './external/color/discord.svg?component'
|
||||
import _GitHubColorIcon from './external/color/github.svg?component'
|
||||
import _GitLabColorIcon from './external/color/gitlab.svg?component'
|
||||
import _GoogleColorIcon from './external/color/google.svg?component'
|
||||
import _MicrosoftColorIcon from './external/color/microsoft.svg?component'
|
||||
import _PayPalColorIcon from './external/color/paypal.svg?component'
|
||||
import _SteamColorIcon from './external/color/steam.svg?component'
|
||||
import _USDCColorIcon from './external/color/usdc.svg?component'
|
||||
import _VenmoColorIcon from './external/color/venmo.svg?component'
|
||||
import _CurseForgeIcon from './external/curseforge.svg?component'
|
||||
import _DiscordIcon from './external/discord.svg?component'
|
||||
import _FacebookIcon from './external/facebook.svg?component'
|
||||
@@ -37,20 +46,17 @@ import _MastodonIcon from './external/mastodon.svg?component'
|
||||
import _OpenCollectiveIcon from './external/opencollective.svg?component'
|
||||
import _PatreonIcon from './external/patreon.svg?component'
|
||||
import _PayPalIcon from './external/paypal.svg?component'
|
||||
import _PolygonIcon from './external/polygon.svg?component'
|
||||
import _RedditIcon from './external/reddit.svg?component'
|
||||
import _ReelsIcon from './external/reels.svg?component'
|
||||
import _SnapchatIcon from './external/snapchat.svg?component'
|
||||
import _SSODiscordIcon from './external/sso/discord.svg?component'
|
||||
import _SSOGitHubIcon from './external/sso/github.svg?component'
|
||||
import _SSOGitLabIcon from './external/sso/gitlab.svg?component'
|
||||
import _SSOGoogleIcon from './external/sso/google.svg?component'
|
||||
import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component'
|
||||
import _SSOSteamIcon from './external/sso/steam.svg?component'
|
||||
import _ThreadsIcon from './external/threads.svg?component'
|
||||
import _TikTokIcon from './external/tiktok.svg?component'
|
||||
import _TumblrIcon from './external/tumblr.svg?component'
|
||||
import _TwitchIcon from './external/twitch.svg?component'
|
||||
import _TwitterIcon from './external/twitter.svg?component'
|
||||
import _VenmoIcon from './external/venmo.svg?component'
|
||||
import _VisaIcon from './external/visa.svg?component'
|
||||
import _WindowsIcon from './external/windows.svg?component'
|
||||
import _YouTubeIcon from './external/youtube.svg?component'
|
||||
import _YouTubeGaming from './external/youtubegaming.svg?component'
|
||||
@@ -70,12 +76,14 @@ export const SleepingRinthbot = _SleepingRinthbot
|
||||
export const SobbingRinthbot = _SobbingRinthbot
|
||||
export const ThinkingRinthbot = _ThinkingRinthbot
|
||||
export const WavingRinthbot = _WavingRinthbot
|
||||
export const SSODiscordIcon = _SSODiscordIcon
|
||||
export const SSOGitHubIcon = _SSOGitHubIcon
|
||||
export const SSOGitLabIcon = _SSOGitLabIcon
|
||||
export const SSOGoogleIcon = _SSOGoogleIcon
|
||||
export const SSOMicrosoftIcon = _SSOMicrosoftIcon
|
||||
export const SSOSteamIcon = _SSOSteamIcon
|
||||
export const PayPalColorIcon = _PayPalColorIcon
|
||||
export const VenmoColorIcon = _VenmoColorIcon
|
||||
export const DiscordColorIcon = _DiscordColorIcon
|
||||
export const GitHubColorIcon = _GitHubColorIcon
|
||||
export const GitLabColorIcon = _GitLabColorIcon
|
||||
export const GoogleColorIcon = _GoogleColorIcon
|
||||
export const MicrosoftColorIcon = _MicrosoftColorIcon
|
||||
export const SteamColorIcon = _SteamColorIcon
|
||||
export const AppleIcon = _AppleIcon
|
||||
export const BlueskyIcon = _BlueskyIcon
|
||||
export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
|
||||
@@ -101,6 +109,10 @@ export const WindowsIcon = _WindowsIcon
|
||||
export const YouTubeIcon = _YouTubeIcon
|
||||
export const YouTubeGaming = _YouTubeGaming
|
||||
export const YouTubeShortsIcon = _YouTubeShortsIcon
|
||||
export const VenmoIcon = _VenmoIcon
|
||||
export const PolygonIcon = _PolygonIcon
|
||||
export const USDCColorIcon = _USDCColorIcon
|
||||
export const VisaIcon = _VisaIcon
|
||||
|
||||
export * from './generated-icons'
|
||||
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -599,6 +599,81 @@ export interface DelphiReport {
|
||||
content?: string
|
||||
}
|
||||
|
||||
export type PayoutId = string
|
||||
export type UserId = string
|
||||
export type PayoutStatus =
|
||||
| 'success'
|
||||
| 'in-transit'
|
||||
| 'cancelled'
|
||||
| 'cancelling'
|
||||
| 'failed'
|
||||
| 'unknown'
|
||||
export type PayoutMethodType = 'venmo' | 'paypal' | 'tremendous' | 'muralpay' | 'unknown'
|
||||
|
||||
export interface Payout {
|
||||
id: PayoutId
|
||||
user_id: UserId
|
||||
status: PayoutStatus
|
||||
created: string // ISO 8601
|
||||
amount: number
|
||||
fee: number | null
|
||||
method: PayoutMethodType | null
|
||||
method_address: string | null
|
||||
platform_id: string | null
|
||||
}
|
||||
|
||||
export type PayoutList = Payout[]
|
||||
|
||||
// Revenue event types for transaction history
|
||||
export interface IncomeEvent {
|
||||
type: 'payout_available'
|
||||
created: string // ISO 8601
|
||||
payout_source: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface WithdrawalEvent {
|
||||
type: 'withdrawal'
|
||||
id: string
|
||||
status: PayoutStatus
|
||||
created: string // ISO 8601
|
||||
amount: number
|
||||
fee: number | null
|
||||
method_type: PayoutMethodType | null
|
||||
method_address: string | null
|
||||
}
|
||||
|
||||
export type RevenueEvent = IncomeEvent | WithdrawalEvent
|
||||
|
||||
export type RevenueEventList = RevenueEvent[]
|
||||
|
||||
export interface PayoutMethodFee {
|
||||
percentage: number
|
||||
min: number
|
||||
max: number | null
|
||||
}
|
||||
|
||||
export type PayoutInterval =
|
||||
| {
|
||||
type: 'standard'
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
| {
|
||||
type: 'fixed'
|
||||
values: number[]
|
||||
}
|
||||
|
||||
export interface PayoutMethod {
|
||||
id: string
|
||||
type: PayoutMethodType
|
||||
name: string
|
||||
supported_countries: string[]
|
||||
image_url: string | null
|
||||
interval: PayoutInterval
|
||||
fee: PayoutMethodFee
|
||||
}
|
||||
|
||||
export type AffiliateLink = {
|
||||
id: string
|
||||
created_at: string
|
||||
|
||||