You've already forked AstralRinth
forked from didirus/AstralRinth
* fix Signed-off-by: Evan Song <theevansong@gmail.com> * fix Signed-off-by: Evan Song <theevansong@gmail.com> * refactor(fileitem): optimize Signed-off-by: Evan Song <theevansong@gmail.com> * chore(fileitem): fixed width timestamp Signed-off-by: Evan Song <theevansong@gmail.com> * fix(fileitem): allow editing json5/jsonc Signed-off-by: Evan Song <theevansong@gmail.com> * feat: motd pt 1, auto backups scaffolding, editing navbar changes * feat: fancy sidebar animations * fix: files * fix: files pt2 * fix: faulty name validation disallowing spaces in file names Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: fileitem props Signed-off-by: Evan Song <theevansong@gmail.com> * fix: upload files not refreshing files list Signed-off-by: Evan Song <theevansong@gmail.com> * fix(imgviewer): handle invalid/empty images Signed-off-by: Evan Song <theevansong@gmail.com> * fix: return of the sticky files header Signed-off-by: Evan Song <theevansong@gmail.com> * chore: prevent servericon from shrinking Signed-off-by: Evan Song <theevansong@gmail.com> * fix: wtf were we thinking with this anyway Signed-off-by: Evan Song <theevansong@gmail.com> * fix: further mobile optimization Signed-off-by: Evan Song <theevansong@gmail.com> * chore: propagate margin Signed-off-by: Evan Song <theevansong@gmail.com> * chore: truncation fixes Signed-off-by: Evan Song <theevansong@gmail.com> * fix: track navbar with sentinel Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * fix(files): a11y Signed-off-by: Evan Song <theevansong@gmail.com> * chore: improve inspector styles Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * feat: console preformance improvements, decrease blur * feat(mobile): new server header * fix: linting * fix: useless z indeces Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust file filter names Signed-off-by: Evan Song <theevansong@gmail.com> * feat(files): true breadcrumbs Signed-off-by: Evan Song <theevansong@gmail.com> * fix(marketing): make custom responsive * fix(marketing): mobile file manager card * feat: trackable navtabs Signed-off-by: Evan Song <theevansong@gmail.com> * fix: oh no Signed-off-by: Evan Song <theevansong@gmail.com> * fix: smartly truncate Signed-off-by: Evan Song <theevansong@gmail.com> * fix(terminal): z-indexes * fix: autofocus more inputs Signed-off-by: Evan Song <theevansong@gmail.com> * fix: color Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust copy Signed-off-by: Evan Song <theevansong@gmail.com> * chore: backup modal usability improvements Signed-off-by: Evan Song <theevansong@gmail.com> * fix: padding Signed-off-by: Evan Song <theevansong@gmail.com> * chore: title Signed-off-by: Evan Song <theevansong@gmail.com> * fix(content): update banner mobile support * fix: server listing icons Signed-off-by: Evan Song <theevansong@gmail.com> * fix: ignore clicks in server listing for labels Signed-off-by: Evan Song <theevansong@gmail.com> * feat(mobile): backup card * fix(backups): make plural conditional * fix: debounce file item selectitem Signed-off-by: Evan Song <theevansong@gmail.com> * fix: lint Signed-off-by: Evan Song <theevansong@gmail.com> * stuff Signed-off-by: Evan Song <theevansong@gmail.com> * fix: temp sidebar fix until i can be smart * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: explictly set button type in file modals Signed-off-by: Evan Song <theevansong@gmail.com> * fix: properly sort backups Signed-off-by: Evan Song <theevansong@gmail.com> * feat: add getautobackup method to pyroservers Signed-off-by: Evan Song <theevansong@gmail.com> * choer: update autobackup params Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update autobackup methods (REALLY GUYS) Signed-off-by: Evan Song <theevansong@gmail.com> * feat: implement autobackups Signed-off-by: Evan Song <theevansong@gmail.com> * feat: implement backup-while-running preference Signed-off-by: Evan Song <theevansong@gmail.com> * feat: make server labels a component * feat: implement 'All details' modal * fix(mobile): server manage page * feat(files): mobile compatible * fix(info labels): wrap * chore(inspector): clean Signed-off-by: Evan Song <theevansong@gmail.com> * fix(backup settings): swap + and - * fix(manage): new -> plans instead of modal * feat: more small mobile fixes * fix(auto backup modal): manual input validation * fix(file browse navbar): home margin * feat(purchase modal): mobile support * fix(marketing): faded line alignments * feat: add servers to mobile nav * feat(network): dns record fixes * feat: make all settings work on mobile * fix(loader settings): modpack mobile * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * feat(marketing): add 'Manage your servers' button * fix(marketing): only check servers if logged in * fix(network): allocation edit & delete button * fix(backups): use UiServersTeleportOverflowMenu * chore: linting * chore: but here comes the sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * feat(marketing): make buttons consistent * lint Signed-off-by: Evan Song <theevansong@gmail.com> * fix(loader): prevent multiline version names in dropdown Signed-off-by: Evan Song <theevansong@gmail.com> * lint Signed-off-by: Evan Song <theevansong@gmail.com> * fix: copy Signed-off-by: Evan Song <theevansong@gmail.com> * fix: sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * fix: linting * chore: rename dumbass preference key Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: rewrite power action buttons Signed-off-by: Evan Song <theevansong@gmail.com> * fix: robust download logic Signed-off-by: Evan Song <theevansong@gmail.com> * fix(loader mobile): modpack dropdown width * fix: sentence case * fix(save & 'working on it'): look good on mobile * fix(TeleportDropdown): width * fix(inspecting error): mobile * fix: show action button dropdown when installing * fix(navtabs): temp fix for mobile scrolling issue * fix(install error): mobile compatible * chore: just remove tracking Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * fix: cleanup * fix: broken svg clr in checkbox when using experimental styles Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust vanilla icon Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust loader props Signed-off-by: Evan Song <theevansong@gmail.com> * revert changes to serversidebar Signed-off-by: Evan Song <theevansong@gmail.com> * fix: server properties flicker Signed-off-by: Evan Song <theevansong@gmail.com> * fix(backups): plural * fix: cases where the telepoverflow would clash with viewport edge Signed-off-by: Evan Song <theevansong@gmail.com> * feat(backups): auto-backups label * fix(network): titlecase * feat(fileitem): new rename icon * fix(properties): wiki proper noun * fix: disable motd for the time being * chore: adjust wording for power conifmration Signed-off-by: Evan Song <theevansong@gmail.com> * feat: "external" to billing Signed-off-by: Evan Song <theevansong@gmail.com> * fix: icon Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add EULA checkbox * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * me and bro deciding which case rules to enforce Signed-off-by: Evan Song <theevansong@gmail.com> * feat(sftp): copy address & username, launch tooltip * feat(files): better move * chore: attempt to mitigate excessive stack depth type Signed-off-by: Evan Song <theevansong@gmail.com> * fix(loader): prevent versions 1.2.4 and below * feat(dns table): placeholder improvements * feat(pyroServer): error handling * fix: intrinsic size on loader icon Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust wording Signed-off-by: Evan Song <theevansong@gmail.com> * fix: sentence case Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust wording Signed-off-by: Evan Song <theevansong@gmail.com> * fix: types Signed-off-by: Evan Song <theevansong@gmail.com> * fix: "implemented" key in preference Signed-off-by: Evan Song <theevansong@gmail.com> * feat(connection lost): redesign * feat(connection error): make icon orange * fix: cleanup * chore(connection lost): redesign pt 2 Signed-off-by: Evan Song <theevansong@gmail.com> * fix: OOOOHHH MY GOD Signed-off-by: Evan Song <theevansong@gmail.com> * feat: implement capacity api on marketing Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update createdat backup type Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: all of backups Signed-off-by: Evan Song <theevansong@gmail.com> * chore: update backup types Signed-off-by: Evan Song <theevansong@gmail.com> * refactor: backups pt 2 Signed-off-by: Evan Song <theevansong@gmail.com> * fix: comically small icons Signed-off-by: Evan Song <theevansong@gmail.com> * chore: align designs Signed-off-by: Evan Song <theevansong@gmail.com> * chore: hide ram graph if ram as bytes enabled Signed-off-by: Evan Song <theevansong@gmail.com> * base add content page * Fix conflict * feat(content): mobile-compatible header, sticky * fix(marketing): md instead of sm for custom * fix: compiler macro warning Signed-off-by: Evan Song <theevansong@gmail.com> * again Signed-off-by: Evan Song <theevansong@gmail.com> * fix: loader type error Signed-off-by: Evan Song <theevansong@gmail.com> * fix: default uptime seconds prop Signed-off-by: Evan Song <theevansong@gmail.com> * fix: hydration errors on server listing Signed-off-by: Evan Song <theevansong@gmail.com> * feat: move custom URL to general Signed-off-by: Evan Song <theevansong@gmail.com> * feat: indiviudally checkj capacities Signed-off-by: Evan Song <theevansong@gmail.com> * fix: falsey Signed-off-by: Evan Song <theevansong@gmail.com> * fix: missing prop Signed-off-by: Evan Song <theevansong@gmail.com> * chore: Derive On That Thang Signed-off-by: Evan Song <theevansong@gmail.com> * chore: adjust gap Signed-off-by: Evan Song <theevansong@gmail.com> * fix: add default name for backups * fix: the backup number should PROBABLY be computed lol * fix(backups): truncate text, mobile fixes * fix(loader): modpack mobile fix * feat(plans): add vcpus * fix(backup modal): blank by default, maxlength * fix(subdomain): separate length & valid chars * feat: mrpack installs functionality (untested), forbidden handling, backups grammar * feat(content): make responsive on mobile * fix: disable plan buttons separately * fix(backup modal): update name max length * fix(purchase): wrapping on eula, eula link * fix: move skeleton * fix(server mobile header): truncation * fix(server header): proper alignment * Finish content page fixes * fix: who up rinthing Signed-off-by: Evan Song <theevansong@gmail.com> * wip Signed-off-by: Evan Song <theevansong@gmail.com> * fix(staging & email banner): z-index * feat: make eula tickbox more visible * fix: move "powered by pyro" below buttons on hero * fix: oops sorry ellie, also updated the main screenshot * feat: update content screenshot * fix: content page card should hide image on lg * feat: hide total storage for now * fix: terminal card now uses terminal icon * fix(marketing): make medium plan card border solid * feat: modloader card, move pyro BACK below buttons, beta release pill * fix: spinning logo should be behind hero * feat: surgically remove the hero's massive forehead * feat(marketing): mobile UI screenshot * fix(hero): z-index goes over mobile nav * fix: consistent borders, files breakpoints * chore: update turbo * chore: adjust hero sizing Signed-off-by: Evan Song <theevansong@gmail.com> * chore: mention region restrictions * chore: double check if we are at capcity Signed-off-by: Evan Song <theevansong@gmail.com> * fix: measure twice cut once Signed-off-by: Evan Song <theevansong@gmail.com> * chore: bro cut twice and measured once 💀 Signed-off-by: Evan Song <theevansong@gmail.com> * fix(marketing): login first * fix: out of capacity text when logged out * fix(slider): reset some values for frontend * feat: wip hero section Signed-off-by: Evan Song <theevansong@gmail.com> * New navigation to support the new products (#2879) * Nav * oops extra file * feat: mrpack uploading with existing modpack, fix: choose modpack duplicate * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * feat: update features section Signed-off-by: Evan Song <theevansong@gmail.com> * Nav adjustments * fix: server manager empty state clashing with loading state Signed-off-by: Evan Song <theevansong@gmail.com> * chore: query param hard Signed-off-by: Evan Song <theevansong@gmail.com> * fix: do not count uptime if crashed Signed-off-by: Evan Song <theevansong@gmail.com> * fix: grammar Signed-off-by: Evan Song <theevansong@gmail.com> * hide hero img on lg breakpoints * Make plugins a plug * chore: prep for buffered text selection terminal Signed-off-by: Evan Song <theevansong@gmail.com> * fix: marketing responsive stuff, n fixes * fix hoverable prop * fix: edit mod spacing * fix: type error for display name in dropdown Signed-off-by: Evan Song <theevansong@gmail.com> * feat: custom plans * fix: no more console.log * fix: properly linked prop label Signed-off-by: Evan Song <theevansong@gmail.com> * fix(install hero mobile): padding * fix: prevent x overflow on servers page Signed-off-by: Evan Song <theevansong@gmail.com> * fix lint oh ym fucking god yal Signed-off-by: Evan Song <theevansong@gmail.com> * Migrate modpack install to search * fix(custom plan): warning icon variable * fix: loading probally and modal loader things * fix(marketing): login icon colours * fix(marketing): responsiveness * fix(marketing): responsiveness v2 * fix: sync button for icon tm * fix(marketing): responsiveness v3 * fix: hero image Signed-off-by: Evan Song <theevansong@gmail.com> * chore: clean Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * chore: switch to cdn links Signed-off-by: Evan Song <theevansong@gmail.com> * Remove prod override --------- Signed-off-by: Evan Song <theevansong@gmail.com> Co-authored-by: Evan Song <theevansong@gmail.com> Co-authored-by: TheWander02 <48934424+thewander02@users.noreply.github.com> Co-authored-by: he3als <65787561+he3als@users.noreply.github.com> Co-authored-by: Evan Song <52982404+ferothefox@users.noreply.github.com> Co-authored-by: Lio <git@lio.cat> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: not-nullptr <needhelpwithrift@gmail.com> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: Prospector <prospectordev@gmail.com> Co-authored-by: sticks <tanner@teamhydra.dev>
445 lines
13 KiB
Vue
445 lines
13 KiB
Vue
<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">
|
|
import { DropdownIcon } from "@modrinth/assets";
|
|
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
|
import type { CSSProperties } from "vue";
|
|
|
|
const ITEM_HEIGHT = 44;
|
|
const BUFFER_ITEMS = 5;
|
|
|
|
type OptionValue = string | number | Record<string, any>;
|
|
|
|
interface Props {
|
|
options: OptionValue[];
|
|
name: string;
|
|
defaultValue?: OptionValue | null;
|
|
placeholder?: string | number | null;
|
|
modelValue?: OptionValue | null;
|
|
renderUp?: boolean;
|
|
disabled?: boolean;
|
|
displayName?: (option: OptionValue) => string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
defaultValue: null,
|
|
placeholder: null,
|
|
modelValue: null,
|
|
renderUp: false,
|
|
disabled: false,
|
|
displayName: (option: OptionValue) => String(option),
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
(e: "input", value: OptionValue): void;
|
|
(e: "change", value: { option: OptionValue; index: number }): void;
|
|
(e: "update:modelValue", value: OptionValue): 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>
|