refactor: TabbedModal to use NewModal and DI (#5612)

* refactor: tabbed modal to use NewModal

* refactor: use DI for instance settings modal instead of passing down props

* pnpm prepr
This commit is contained in:
Truman Gao
2026-03-19 09:53:53 -07:00
committed by GitHub
parent 93c81631a9
commit 2128fa7ade
11 changed files with 585 additions and 239 deletions

View File

@@ -18,8 +18,9 @@ import { useRouter } from 'vue-router'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types'
import type { GameInstance } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -27,21 +28,21 @@ const router = useRouter()
const deleteConfirmModal = ref()
const props = defineProps<InstanceSettingsTabProps>()
const { instance } = injectInstanceSettings()
const title = ref(props.instance.name)
const icon: Ref<string | undefined> = ref(props.instance.icon_path)
const groups = ref(props.instance.groups)
const title = ref(instance.name)
const icon: Ref<string | undefined> = ref(instance.icon_path)
const groups = ref(instance.groups)
const newCategoryInput = ref('')
const installing = computed(() => props.instance.install_stage !== 'installed')
const installing = computed(() => instance.install_stage !== 'installed')
async function duplicateProfile() {
await duplicate(props.instance.path).catch(handleError)
await duplicate(instance.path).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.loader,
game_version: instance.game_version,
})
}
@@ -52,7 +53,7 @@ const availableGroups = computed(() => [
async function resetIcon() {
icon.value = undefined
await edit_icon(props.instance.path, null).catch(handleError)
await edit_icon(instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
@@ -70,7 +71,7 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(props.instance.path, icon.value).catch(handleError)
await edit_icon(instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
}
@@ -101,7 +102,7 @@ watch(
[title, groups, groups],
async () => {
if (removing.value) return
await edit(props.instance.path, editProfileObject.value).catch(handleError)
await edit(instance.path, editProfileObject.value).catch(handleError)
},
{ deep: true },
)
@@ -109,11 +110,11 @@ watch(
const removing = ref(false)
async function removeProfile() {
removing.value = true
const path = props.instance.path
const path = instance.path
trackEvent('InstanceRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.loader,
game_version: instance.game_version,
})
await router.push({ path: '/' })
@@ -218,7 +219,7 @@ const messages = defineMessages({
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="!border-4 group-hover:brightness-75"
:tint-by="props.instance.path"
:tint-by="instance.path"
no-shadow
/>
<div class="absolute top-0 right-0 m-2">

View File

@@ -10,22 +10,21 @@ import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types'
import type { AppSettings, Hooks } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref(
!!props.instance.hooks.pre_launch ||
!!props.instance.hooks.wrapper ||
!!props.instance.hooks.post_exit,
!!instance.hooks.pre_launch || !!instance.hooks.wrapper || !!instance.hooks.post_exit,
)
const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const hooks = ref(instance.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => {
const editProfile: {
@@ -41,7 +40,7 @@ const editProfileObject = computed(() => {
watch(
[overrideHooks, hooks],
async () => {
await edit(props.instance.path, editProfileObject.value)
await edit(instance.path, editProfileObject.value)
},
{ deep: true },
)

View File

@@ -27,17 +27,15 @@ import {
update_repair_modrinth,
} from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { InstanceSettingsTabProps, Manifest } from '../../../helpers/types'
import type { Manifest } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const props = defineProps<InstanceSettingsTabProps>()
const emit = defineEmits<{
unlinked: []
}>()
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
const [
fabric_versions,
@@ -75,9 +73,9 @@ const [
])
const { data: modpackInfo } = useQuery({
queryKey: computed(() => ['linkedModpackInfo', props.instance.path]),
queryFn: () => get_linked_modpack_info(props.instance.path, 'must_revalidate'),
enabled: computed(() => !!props.instance.linked_data?.project_id && !props.offline),
queryKey: computed(() => ['linkedModpackInfo', instance.path]),
queryFn: () => get_linked_modpack_info(instance.path, 'must_revalidate'),
enabled: computed(() => !!instance.linked_data?.project_id && !offline),
})
const repairing = ref(false)
@@ -103,13 +101,13 @@ function getManifest(loader: string) {
provideAppBackup({
async createBackup() {
const allProfiles = await list()
const prefix = `${props.instance.name} - Backup #`
const prefix = `${instance.name} - Backup #`
const existingNums = allProfiles
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(props.instance.path)
const newPath = await duplicate(instance.path)
await edit(newPath, { name: `${prefix}${nextNum}` })
},
})
@@ -120,30 +118,27 @@ provideInstallationSettings({
const rows = [
{
label: formatMessage(commonMessages.platformLabel),
value: formatLoaderLabel(props.instance.loader),
value: formatLoaderLabel(instance.loader),
},
{
label: formatMessage(commonMessages.gameVersionLabel),
value: props.instance.game_version,
value: instance.game_version,
},
]
if (props.instance.loader !== 'vanilla' && props.instance.loader_version) {
if (instance.loader !== 'vanilla' && instance.loader_version) {
rows.push({
label: formatMessage(messages.loaderVersion, {
loader: formatLoaderLabel(props.instance.loader),
loader: formatLoaderLabel(instance.loader),
}),
value: props.instance.loader_version,
value: instance.loader_version,
})
}
return rows
}),
isLinked: computed(() => !!props.instance.linked_data?.locked),
isLinked: computed(() => !!instance.linked_data?.locked),
isBusy: computed(
() =>
props.instance.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
!!props.offline,
instance.install_stage !== 'installed' || repairing.value || reinstalling.value || !!offline,
),
modpack: computed(() => {
if (!modpackInfo.value) return null
@@ -154,9 +149,9 @@ provideInstallationSettings({
versionNumber: modpackInfo.value.version?.version_number,
}
}),
currentPlatform: computed(() => props.instance.loader),
currentGameVersion: computed(() => props.instance.game_version),
currentLoaderVersion: computed(() => props.instance.loader_version ?? ''),
currentPlatform: computed(() => instance.loader),
currentGameVersion: computed(() => instance.game_version),
currentLoaderVersion: computed(() => instance.loader_version ?? ''),
availablePlatforms: loaders?.value?.map((x) => x.name) ?? [],
resolveGameVersions(loader, showSnapshots) {
@@ -199,50 +194,50 @@ provideInstallationSettings({
if (platform !== 'vanilla' && loaderVersionId) {
editProfile.loader_version = loaderVersionId
}
await edit(props.instance.path, editProfile).catch(handleError)
await edit(instance.path, editProfile).catch(handleError)
},
afterSave: async () => {
await install(props.instance.path, false).catch(handleError)
await install(instance.path, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.loader,
game_version: instance.game_version,
})
},
async repair() {
repairing.value = true
await install(props.instance.path, true).catch(handleError)
await install(instance.path, true).catch(handleError)
repairing.value = false
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.loader,
game_version: instance.game_version,
})
},
async reinstallModpack() {
reinstalling.value = true
await update_repair_modrinth(props.instance.path).catch(handleError)
await update_repair_modrinth(instance.path).catch(handleError)
reinstalling.value = false
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.loader,
game_version: instance.game_version,
})
},
async unlinkModpack() {
await edit(props.instance.path, {
await edit(instance.path, {
linked_data: null as unknown as undefined,
})
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', props.instance.path],
queryKey: ['linkedModpackInfo', instance.path],
})
emit('unlinked')
onUnlinked()
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
const versions = await get_project_versions(props.instance.linked_data!.project_id!).catch(
const versions = await get_project_versions(instance.linked_data!.project_id!).catch(
handleError,
)
return (versions ?? []) as Labrinth.Versions.v2.Version[]
@@ -255,25 +250,25 @@ provideInstallationSettings({
},
async onModpackVersionConfirm(version) {
await update_managed_modrinth_version(props.instance.path, version.id)
await update_managed_modrinth_version(instance.path, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', props.instance.path],
queryKey: ['linkedModpackInfo', instance.path],
})
},
updaterModalProps: computed(() => ({
isApp: true,
currentVersionId:
modpackInfo.value?.update_version_id ?? props.instance.linked_data?.version_id ?? '',
modpackInfo.value?.update_version_id ?? instance.linked_data?.version_id ?? '',
projectIconUrl: modpackInfo.value?.project?.icon_url,
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
currentGameVersion: props.instance.game_version,
currentLoader: props.instance.loader,
currentGameVersion: instance.game_version,
currentLoader: instance.loader,
})),
isServer: false,
isApp: true,
showModpackVersionActions: !props.isMinecraftServer,
showModpackVersionActions: !isMinecraftServer.value,
repairing,
reinstalling,
})

View File

@@ -14,34 +14,31 @@ import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
import type { AppSettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!props.instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaInstall = ref(!!instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.java_path })
const overrideJavaArgs = ref((props.instance.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideJavaArgs = ref((instance.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref((instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '))
const overrideEnvVars = ref((props.instance.custom_env_vars?.length ?? 0) > 0)
const overrideEnvVars = ref((instance.custom_env_vars?.length ?? 0) > 0)
const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
(instance.custom_env_vars ?? globalSettings.custom_env_vars).map((x) => x.join('=')).join(' '),
)
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const overrideMemorySettings = ref(!!instance.memory)
const memory = ref(instance.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
@@ -79,7 +76,7 @@ watch(
memory,
],
async () => {
await edit(props.instance.path, editProfileObject.value)
await edit(instance.path, editProfileObject.value)
},
{ deep: true },
)

View File

@@ -11,24 +11,23 @@ import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
import type { AppSettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref(
!!props.instance.game_resolution || !!props.instance.force_fullscreen,
)
const overrideWindowSettings = ref(!!instance.game_resolution || !!instance.force_fullscreen)
const resolution: Ref<[number, number]> = ref(
props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
)
const fullscreenSetting: Ref<boolean> = ref(
props.instance.force_fullscreen ?? globalSettings.force_fullscreen,
instance.force_fullscreen ?? globalSettings.force_fullscreen,
)
const editProfileObject = computed(() => {
@@ -47,7 +46,7 @@ const editProfileObject = computed(() => {
watch(
[overrideWindowSettings, resolution, fullscreenSetting],
async () => {
await edit(props.instance.path, editProfileObject.value)
await edit(instance.path, editProfileObject.value)
},
{ deep: true },
)

View File

@@ -20,9 +20,8 @@ import {
} from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
@@ -106,15 +105,13 @@ const tabs = [
},
]
const modal = ref()
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
function show() {
modal.value.show()
modal.value?.show()
}
const isOpen = computed(() => modal.value?.isOpen)
defineExpose({ show, isOpen })
defineExpose({ show })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -138,8 +135,8 @@ function devModeCount() {
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0)
if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) {
modal.value!.setTab(0)
}
}
}
@@ -152,49 +149,46 @@ const messages = defineMessages({
})
</script>
<template>
<ModalWrapper ref="modal">
<TabbedModal ref="modal" :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
</span>
</template>
<TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
<template v-if="progress > 0 && progress < 1">
<p class="m-0 mb-2">
{{ formatMessage(messages.downloading, { version: downloadingVersion }) }}
</p>
<ProgressBar :progress="progress" />
</template>
</div>
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
<button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
'text-secondary': !themeStore.devMode,
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
</div>
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
<template v-if="progress > 0 && progress < 1">
<p class="m-0 mb-2">
{{ formatMessage(messages.downloading, { version: downloadingVersion }) }}
</p>
<ProgressBar :progress="progress" />
</template>
</div>
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
<button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
'text-secondary': !themeStore.devMode,
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
</div>
</div>
</template>
</TabbedModal>
</ModalWrapper>
</div>
</template>
</TabbedModal>
</template>

View File

@@ -12,14 +12,13 @@ import {
Avatar,
commonMessages,
defineMessage,
NewModal,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
@@ -28,12 +27,16 @@ import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/profile'
import { provideInstanceSettings } from '@/providers/instance-settings'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
import type { GameInstance } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const emit = defineEmits<{
unlinked: []
}>()
@@ -41,6 +44,13 @@ const emit = defineEmits<{
const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
provideInstanceSettings({
instance: props.instance,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
})
watch(
() => props.instance,
(instance) => {
@@ -58,7 +68,7 @@ watch(
{ immediate: true },
)
const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
const tabs = computed<TabbedModalTab[]>(() => [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
@@ -102,8 +112,7 @@ const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
])
const queryClient = useQueryClient()
const modal = ref()
const tabbedModal = useTemplateRef('tabbedModal')
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function show(tabIndex?: number) {
if (props.instance.linked_data?.project_id) {
@@ -112,7 +121,7 @@ function show(tabIndex?: number) {
queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'),
})
}
modal.value.show()
tabbedModal.value?.show()
if (tabIndex !== undefined) {
nextTick(() => tabbedModal.value?.setTab(tabIndex))
}
@@ -121,8 +130,9 @@ function show(tabIndex?: number) {
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
<TabbedModal
ref="tabbedModal"
:tabs="tabs"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
>
@@ -139,19 +149,5 @@ defineExpose({ show })
}}</span>
</span>
</template>
<TabbedModal
ref="tabbedModal"
:tabs="
tabs.map((tab) => ({
...tab,
props: {
...props,
isMinecraftServer,
onUnlinked: handleUnlinked,
},
}))
"
/>
</NewModal>
</TabbedModal>
</template>

View File

@@ -128,9 +128,3 @@ type AppSettings = {
prev_custom_dir?: string
migrated: boolean
}
export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
isMinecraftServer?: boolean
}

View File

@@ -0,0 +1,14 @@
import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
export interface InstanceSettingsContext {
instance: GameInstance
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
createContext<InstanceSettingsContext>('InstanceSettingsModal', 'instanceSettings')

View File

@@ -1,24 +1,40 @@
<script lang="ts"></script>
<script setup lang="ts">
import { type Component, computed, nextTick, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useScrollIndicator } from '../../composables/scroll-indicator'
const { formatMessage } = useVIntl()
export type Tab<Props> = {
import NewModal from './NewModal.vue'
export interface Tab {
name: MessageDescriptor
icon: Component
content: Component<Props>
props?: Props
content: Component
badge?: MessageDescriptor
shown?: boolean
}
const props = defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tabs: Tab<any>[]
}>()
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
tabs: Tab[]
header?: string
maxWidth?: string
width?: string
closable?: boolean
onHide?: () => void
onShow?: () => void
}>(),
{
header: undefined,
maxWidth: undefined,
width: undefined,
closable: true,
onHide: undefined,
onShow: undefined,
},
)
const visibleTabs = computed(() => props.tabs.filter((tab) => tab.shown !== false))
@@ -28,77 +44,98 @@ const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
useScrollIndicator(scrollContainer)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
function setTab(index: number) {
selectedTab.value = index
nextTick(() => forceCheck())
}
defineExpose({ selectedTab, setTab })
function show(event?: MouseEvent) {
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide, selectedTab, setTab })
</script>
<template>
<div class="grid grid-cols-[auto_1fr]">
<div
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
>
<button
v-for="(tab, index) in visibleTabs"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="() => setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
</button>
<slot name="footer" />
</div>
<div 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>
<NewModal
ref="modal"
:header="header"
:max-width="maxWidth"
:width="width"
:closable="closable"
:on-hide="onHide"
:on-show="onShow"
no-padding
>
<template v-if="$slots.title" #title>
<slot name="title" />
</template>
<div class="grid grid-cols-[auto_1fr] p-4">
<div
ref="scrollContainer"
class="min-w-[400px] h-[500px] overflow-y-auto px-4"
@scroll="checkScrollState"
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
>
<Suspense>
<component
:is="visibleTabs[selectedTab].content"
v-bind="visibleTabs[selectedTab].props ?? {}"
/>
</Suspense>
</div>
<button
v-for="(tab, index) in visibleTabs"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="() => setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
<span
v-if="tab.badge"
class="rounded-full px-1.5 py-0.5 text-xs font-bold bg-brand-highlight text-brand-green"
>
{{ formatMessage(tab.badge) }}
</span>
</button>
<slot name="footer" />
</div>
<div 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-10"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-10"
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-10 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<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>
ref="scrollContainer"
class="min-w-[400px] h-[500px] overflow-y-auto px-4"
@scroll="checkScrollState"
>
<Suspense>
<component :is="visibleTabs[selectedTab].content" />
</Suspense>
</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-10"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-10"
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-10 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
</div>
</div>
</NewModal>
</template>

View File

@@ -0,0 +1,320 @@
import {
CoffeeIcon,
GameIcon,
GaugeIcon,
InfoIcon,
LanguagesIcon,
MonitorIcon,
PaintbrushIcon,
ReportIcon,
SettingsIcon,
ShieldIcon,
WrenchIcon,
} from '@modrinth/assets'
import type { StoryObj } from '@storybook/vue3-vite'
import { defineComponent, h, ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import TabbedModal from '../../components/modal/TabbedModal.vue'
function makeTabContent(label: string, lines = 3) {
return defineComponent({
name: `${label}Tab`,
render() {
return h('div', { class: 'space-y-4 py-2' }, [
h('h2', { class: 'text-xl font-bold text-contrast m-0' }, label),
...Array.from({ length: lines }, (_, i) =>
h('p', { class: 'text-secondary m-0' }, `${label} content paragraph ${i + 1}.`),
),
])
},
})
}
const meta = {
title: 'Modal/TabbedModal',
// @ts-ignore
component: TabbedModal,
}
export default meta
export const Default: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'appearance', defaultMessage: 'Appearance' },
icon: PaintbrushIcon,
content: makeTabContent('Appearance'),
},
{
name: { id: 'privacy', defaultMessage: 'Privacy' },
icon: ShieldIcon,
content: makeTabContent('Privacy'),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open Tabbed Modal</button>
</ButtonStyled>
<TabbedModal ref="modalRef" header="Settings" :tabs="tabs" />
</div>
`,
}),
}
export const WithTitleSlot: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled, SettingsIcon },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'appearance', defaultMessage: 'Appearance' },
icon: PaintbrushIcon,
content: makeTabContent('Appearance'),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open with Title Slot</button>
</ButtonStyled>
<TabbedModal ref="modalRef" :tabs="tabs">
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Custom Title
</span>
</template>
</TabbedModal>
</div>
`,
}),
}
export const WithFooter: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'appearance', defaultMessage: 'Appearance' },
icon: PaintbrushIcon,
content: makeTabContent('Appearance'),
},
{
name: { id: 'privacy', defaultMessage: 'Privacy' },
icon: ShieldIcon,
content: makeTabContent('Privacy'),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open with Footer</button>
</ButtonStyled>
<TabbedModal ref="modalRef" header="Settings" :tabs="tabs">
<template #footer>
<div class="mt-auto text-secondary text-sm">
<p class="m-0">App v1.0.0</p>
<p class="m-0">macOS 15.0</p>
</div>
</template>
</TabbedModal>
</div>
`,
}),
}
export const WithBadge: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'language', defaultMessage: 'Language' },
icon: LanguagesIcon,
content: makeTabContent('Language'),
badge: { id: 'beta', defaultMessage: 'Beta' },
},
{
name: { id: 'privacy', defaultMessage: 'Privacy' },
icon: ShieldIcon,
content: makeTabContent('Privacy'),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open with Badge</button>
</ButtonStyled>
<TabbedModal ref="modalRef" header="Settings" :tabs="tabs" />
</div>
`,
}),
}
export const HiddenTabs: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'hidden', defaultMessage: 'Hidden Tab' },
icon: ReportIcon,
content: makeTabContent('Hidden'),
shown: false,
},
{
name: { id: 'appearance', defaultMessage: 'Appearance' },
icon: PaintbrushIcon,
content: makeTabContent('Appearance'),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open with Hidden Tab</button>
</ButtonStyled>
<TabbedModal ref="modalRef" header="Settings" :tabs="tabs" />
</div>
`,
}),
}
export const ManyTabs: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'general', defaultMessage: 'General' },
icon: InfoIcon,
content: makeTabContent('General'),
},
{
name: { id: 'appearance', defaultMessage: 'Appearance' },
icon: PaintbrushIcon,
content: makeTabContent('Appearance'),
},
{
name: { id: 'language', defaultMessage: 'Language' },
icon: LanguagesIcon,
content: makeTabContent('Language'),
},
{
name: { id: 'privacy', defaultMessage: 'Privacy' },
icon: ShieldIcon,
content: makeTabContent('Privacy'),
},
{
name: { id: 'java', defaultMessage: 'Java and memory' },
icon: CoffeeIcon,
content: makeTabContent('Java and memory'),
},
{
name: { id: 'instances', defaultMessage: 'Default instance options' },
icon: GameIcon,
content: makeTabContent('Default instance options'),
},
{
name: { id: 'resources', defaultMessage: 'Resource management' },
icon: GaugeIcon,
content: makeTabContent('Resource management'),
},
{
name: { id: 'window', defaultMessage: 'Window' },
icon: MonitorIcon,
content: makeTabContent('Window'),
},
{
name: { id: 'hooks', defaultMessage: 'Launch hooks' },
icon: WrenchIcon,
content: makeTabContent('Launch hooks'),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open with Many Tabs</button>
</ButtonStyled>
<TabbedModal ref="modalRef" header="Settings" :tabs="tabs" />
</div>
`,
}),
}
export const ScrollableContent: StoryObj = {
render: () => ({
components: { TabbedModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof TabbedModal> | null>(null)
const tabs = [
{
name: { id: 'long', defaultMessage: 'Long content' },
icon: InfoIcon,
content: makeTabContent('Long content', 30),
},
{
name: { id: 'short', defaultMessage: 'Short content' },
icon: PaintbrushIcon,
content: makeTabContent('Short content', 2),
},
]
return { modalRef, tabs }
},
template: /* html */ `
<div>
<ButtonStyled color="brand">
<button @click="modalRef?.show()">Open with Scrollable Content</button>
</ButtonStyled>
<TabbedModal ref="modalRef" header="Scrollable Demo" :tabs="tabs" />
</div>
`,
}),
}