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:
@@ -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>
|
||||
|
||||
320
packages/ui/src/stories/modal/TabbedModal.stories.ts
Normal file
320
packages/ui/src/stories/modal/TabbedModal.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user