Add server unzipping (#3622)

* Initial unzipping feature

* Remove explicit backup provider naming from frontend

* CF placeholder

* Use regex for CF links

* Lint

* Add unzip warning for conflicting files, fix hydration error

* Adjust conflict modal ui

* Fix old queued ops sticking around, remove conflict warning

* Add vscode "editor.detectIndentation": true
This commit is contained in:
Prospector
2025-05-07 19:08:38 -07:00
committed by GitHub
parent 1884410e0d
commit 16766be82f
23 changed files with 1042 additions and 255 deletions

View File

@@ -1,85 +1,140 @@
<template>
<div class="vue-notification-group">
<div class="vue-notification-group experimental-styles-within">
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
:key="item.id"
class="vue-notification-wrapper"
@click="notifications.splice(index, 1)"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
<div class="notification-title" v-html="item.title"></div>
<div class="notification-content" v-html="item.text"></div>
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
<div
class="w-2"
:class="{
'bg-red': item.type === 'error',
'bg-orange': item.type === 'warning',
'bg-green': item.type === 'success',
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
></div>
<div
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
>
<div
class="flex items-center"
:class="{
'text-red': item.type === 'error',
'text-orange': item.type === 'warning',
'text-green': item.type === 'success',
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
}"
>
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
<InfoIcon v-else class="h-6 w-6" />
</div>
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
<div class="flex items-center gap-1">
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
x{{ item.count }}
</div>
<ButtonStyled circular size="small">
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
<CheckIcon v-if="copied[createNotifText(item)]" />
<CopyIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled circular size="small">
<button v-tooltip="`Dismiss`" @click="notifications.splice(index, 1)">
<XIcon />
</button>
</ButtonStyled>
</div>
<div></div>
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
<template v-if="item.errorCode">
<div></div>
<div
class="m-0 text-wrap text-xs font-medium text-secondary"
v-html="item.errorCode"
></div>
</template>
</div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup>
import { ButtonStyled } from "@modrinth/ui";
import {
XCircleIcon,
CheckCircleIcon,
CheckIcon,
InfoIcon,
IssuesIcon,
XIcon,
CopyIcon,
} from "@modrinth/assets";
const notifications = useNotifications();
function stopTimer(notif) {
clearTimeout(notif.timer);
}
const copied = ref({});
const createNotifText = (notif) => {
let text = "";
if (notif.title) {
text += notif.title;
}
if (notif.text) {
if (text.length > 0) {
text += "\n";
}
text += notif.text;
}
if (notif.errorCode) {
if (text.length > 0) {
text += "\n";
}
text += notif.errorCode;
}
return text;
};
function copyToClipboard(notif) {
const text = createNotifText(notif);
copied.value[text] = true;
navigator.clipboard.writeText(text);
setTimeout(() => {
delete copied.value[text];
}, 2000);
}
</script>
<style lang="scss" scoped>
.vue-notification {
background: var(--color-blue) !important;
border-left: 5px solid var(--color-blue) !important;
color: var(--color-brand-inverted) !important;
box-sizing: border-box;
text-align: left;
font-size: 12px;
padding: 10px;
margin: 0 5px 5px;
&.success {
background: var(--color-green) !important;
border-left-color: var(--color-green) !important;
}
&.warn {
background: var(--color-orange) !important;
border-left-color: var(--color-orange) !important;
}
&.error {
background: var(--color-red) !important;
border-left-color: var(--color-red) !important;
}
}
.vue-notification-group {
position: fixed;
right: 25px;
bottom: 25px;
z-index: 99999999;
width: 300px;
right: 1.5rem;
bottom: 1.5rem;
z-index: 200;
width: 450px;
@media screen and (max-width: 500px) {
width: calc(100% - 0.75rem * 2);
right: 0.75rem;
bottom: 0.75rem;
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;
margin-bottom: 10px;
.vue-notification-template {
border-radius: var(--size-rounded-card);
margin: 0;
.notification-title {
font-size: var(--font-size-lg);
margin-right: auto;
font-weight: 600;
}
.notification-content {
margin-right: auto;
font-size: var(--font-size-md);
}
}
&:last-child {
margin: 0;
}
@@ -98,10 +153,18 @@ function stopTimer(notif) {
.notifs-enter-active,
.notifs-leave-active,
.notifs-move {
transition: all 0.5s;
transition: all 0.25s ease-in-out;
}
.notifs-enter-from,
.notifs-leave-to {
opacity: 0;
}
.notifs-enter-from {
transform: translateY(100%) scale(0.8);
}
.notifs-leave-to {
transform: translateX(100%) scale(0.8);
}
</style>

View File

@@ -53,6 +53,7 @@
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #extract><PackageOpenIcon /> Extract</template>
<template #rename><EditIcon /> Rename</template>
<template #move><RightArrowIcon /> Move</template>
<template #download><DownloadIcon /> Download</template>
@@ -73,6 +74,8 @@ import {
FolderOpenIcon,
FileIcon,
RightArrowIcon,
PackageOpenIcon,
FileArchiveIcon,
} from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue";
import { renderToString } from "vue/server-renderer";
@@ -99,15 +102,14 @@ interface FileItemProps {
const props = defineProps<FileItemProps>();
const emit = defineEmits<{
(e: "rename", item: { name: string; type: string; path: string }): void;
(e: "move", item: { name: string; type: string; path: string }): void;
(
e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
item: { name: string; type: string; path: string },
): void;
(
e: "moveDirectTo",
item: { name: string; type: string; path: string; destination: string },
): void;
(e: "download", item: { name: string; type: string; path: string }): void;
(e: "delete", item: { name: string; type: string; path: string }): void;
(e: "edit", item: { name: string; type: string; path: string }): void;
(e: "contextmenu", x: number, y: number): void;
}>();
@@ -143,6 +145,7 @@ const codeExtensions = Object.freeze([
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
const supportedArchiveExtensions = Object.freeze(["zip"]);
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
const route = shallowRef(useRoute());
@@ -156,7 +159,18 @@ const containerClasses = computed(() => [
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
const isZip = computed(() => fileExtension.value === "zip");
const menuOptions = computed(() => [
{
id: "extract",
shown: isZip.value,
action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
},
{
divider: true,
shown: isZip.value,
},
{
id: "rename",
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
@@ -189,6 +203,7 @@ const iconComponent = computed(() => {
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
return FileIcon;
});

View File

@@ -30,6 +30,7 @@
:size="item.size"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@extract="$emit('extract', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@move-direct-to="$emit('moveDirectTo', $event)"
@@ -49,14 +50,12 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(e: "delete", item: any): void;
(e: "rename", item: any): void;
(e: "download", item: any): void;
(e: "move", item: any): void;
(e: "edit", item: any): void;
(
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
item: any,
): void;
(e: "contextmenu", item: any, x: number, y: number): void;
(e: "loadMore"): void;
(e: "moveDirectTo", item: any): void;
}>();
const ITEM_HEIGHT = 61;

View File

@@ -117,7 +117,8 @@
</div>
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
aria-label="Create new..."
@@ -125,6 +126,10 @@
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true },
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
<PlusIcon aria-hidden="true" />
@@ -132,7 +137,16 @@
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
</UiServersTeleportOverflowMenu>
<template #upload-zip>
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
</template>
<template #install-from-url>
<LinkIcon aria-hidden="true" /> Upload from .zip URL
</template>
<template #install-cf-pack>
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</header>
@@ -140,6 +154,9 @@
<script setup lang="ts">
import {
LinkIcon,
CurseForgeIcon,
FileArchiveIcon,
BoxIcon,
PlusIcon,
UploadIcon,
@@ -150,7 +167,7 @@ import {
ChevronRightIcon,
FilterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled, OverflowMenu } from "@modrinth/ui";
import { ref, computed } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
@@ -158,12 +175,14 @@ const props = defineProps<{
breadcrumbSegments: string[];
searchQuery: string;
currentFilter: string;
baseId: string;
}>();
defineEmits<{
(e: "navigate", index: number): void;
(e: "create", type: "file" | "directory"): void;
(e: "upload"): void;
(e: "upload" | "upload-zip"): void;
(e: "unzip-from-url", cf: boolean): void;
(e: "update:searchQuery", value: string): void;
(e: "filter", type: string): void;
}>();

View File

@@ -0,0 +1,56 @@
<template>
<ConfirmModal
ref="modal"
title="Do you want to overwrite these conflicting files?"
:proceed-label="`Overwrite`"
:proceed-icon="CheckIcon"
@proceed="proceed"
>
<div class="flex max-w-[30rem] flex-col gap-4">
<p class="m-0 font-semibold leading-normal">
<template v-if="hasMany">
Over 100 files will be overwritten if you proceed with extraction; here is just some of
them:
</template>
<template v-else>
The following {{ files.length }} files already exist on your server, and will be
overwritten if you proceed with extraction:
</template>
</p>
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
<XIcon class="shrink-0 text-red" /> {{ file }}
</li>
</ul>
</div>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ConfirmModal } from "@modrinth/ui";
import { ref } from "vue";
import { XIcon, CheckIcon } from "@modrinth/assets";
const path = ref("");
const files = ref<string[]>([]);
const emit = defineEmits<{
(e: "proceed", path: string): void;
}>();
const modal = ref<typeof ConfirmModal>();
const hasMany = computed(() => files.value.length > 100);
const show = (zipPath: string, conflictingFiles: string[]) => {
path.value = zipPath;
files.value = conflictingFiles;
modal.value?.show();
};
const proceed = () => {
emit("proceed", path.value);
};
defineExpose({ show });
</script>

View File

@@ -1,101 +1,105 @@
<template>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} Uploads
<div>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
v-bind="$attrs"
:class="['flex flex-col p-4 text-sm text-contrast']"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<UiServersPanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status === 'error' ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<UiServersPanelSpinner
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status === 'error' ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
<template v-else-if="item.status === 'error'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
</template>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Transition>
</div>
</template>
<script setup lang="ts">
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, nextTick } from "vue";
import type { FSModule } from "~/composables/pyroServers.ts";
interface UploadItem {
file: File;

View File

@@ -0,0 +1,159 @@
<template>
<NewModal
ref="modal"
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
>
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-bold text-contrast">
{{ cf ? `How to get the modpack version's URL` : "URL of .zip file" }}
</div>
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
<li>
<a
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
target="_blank"
rel="noopener noreferrer"
>
Find the CurseForge modpack
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
</a>
you'd like to install on your server.
</li>
<li>
On the modpack's page, go to the
<span class="font-semibold text-primary">"Files"</span> tab, and
<span class="font-semibold text-primary">select the version</span> of the modpack you
want to install.
</li>
<li>
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
install, and paste it in the box below.
</li>
</ol>
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
<input
ref="urlInput"
v-model="url"
autofocus
:disabled="submitted"
type="text"
data-1p-ignore
data-lpignore="true"
data-protonpass-ignore="true"
required
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
autocomplete="off"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
<SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else class="h-5 w-5" />
{{ submitted ? "Installing..." : "Install" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
{{ submitted ? "Close" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
import { handleError, type Server } from "~/composables/pyroServers.ts";
const cf = ref(false);
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modal = ref<typeof NewModal>();
const urlInput = ref<HTMLInputElement | null>(null);
const url = ref("");
const submitted = ref(false);
const trimmedUrl = computed(() => url.value.trim());
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/;
const error = computed(() => {
if (trimmedUrl.value.length === 0) {
return "URL is required.";
}
if (cf.value && !regex.test(trimmedUrl.value)) {
return "URL must be a CurseForge modpack version URL.";
} else if (!cf.value && !trimmedUrl.value.includes("/")) {
return "URL must be valid.";
}
return "";
});
const handleSubmit = async () => {
submitted.value = true;
if (!error.value) {
// hide();
try {
const dry = await props.server.fs?.extractFile(trimmedUrl.value, true, true);
if (!cf.value || dry.modpack_name) {
await props.server.fs?.extractFile(trimmedUrl.value, true, false, true);
hide();
} else {
submitted.value = false;
handleError(
new ServersError(
"Could not find CurseForge modpack at that URL.",
undefined,
undefined,
undefined,
{
context: "Error installing modpack",
error: `url: ${url.value}`,
description: "Could not find CurseForge modpack at that URL.",
},
),
);
}
} catch (error) {
submitted.value = false;
console.error("Error installing:", error);
handleError(error);
}
}
};
const show = (isCf: boolean) => {
cf.value = isCf;
url.value = "";
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
urlInput.value?.focus();
}, 100);
});
};
const hide = () => {
modal.value?.hide();
};
defineExpose({ show, hide });
</script>

View File

@@ -32,68 +32,68 @@
@mousedown.stop
@mouseleave="handleMouseLeave"
>
<ButtonStyled
<template
v-for="(option, index) in filteredOptions"
:key="option.id"
type="transparent"
role="menuitem"
:color="option.color"
:key="isDivider(option) ? `divider-${index}` : option.id"
>
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
<div v-if="isDivider(option)" class="h-px w-full bg-button-bg"></div>
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
</template>
</div>
</Transition>
</Teleport>
@@ -112,9 +112,20 @@ interface Option {
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
}
type Divider = {
divider: true;
shown?: boolean;
};
type Item = Option | Divider;
function isDivider(item: Item): item is Divider {
return (item as Divider).divider;
}
const props = withDefaults(
defineProps<{
options: Option[];
options: Item[];
hoverable?: boolean;
}>(),
{
@@ -338,7 +349,9 @@ const handleKeydown = (event: KeyboardEvent) => {
case " ":
event.preventDefault();
if (selectedIndex.value >= 0) {
selectOption(filteredOptions.value[selectedIndex.value]);
const option = filteredOptions.value[selectedIndex.value];
if (isDivider(option)) break;
selectOption(option);
}
break;
case "Escape":
@@ -361,8 +374,9 @@ const handleKeydown = (event: KeyboardEvent) => {
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase();
const matchIndex = filteredOptions.value.findIndex((option) =>
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
const matchIndex = filteredOptions.value.findIndex(
(option) =>
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
);
if (matchIndex !== -1) {
selectedIndex.value = matchIndex;