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

@@ -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>
`,
}),
}