feat: add collapsible library groups in app (#5739)

* feat: add collapsible library groups in app

* feat: use accordion rather than custom

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
kk
2026-04-16 16:44:52 +03:00
committed by GitHub
parent 7b5c746757
commit 3c2cc7568d
4 changed files with 88 additions and 75 deletions

View File

@@ -10,6 +10,7 @@ import {
TrashIcon,
} from '@modrinth/assets'
import {
Accordion,
DropdownSelect,
formatLoader,
injectNotificationManager,
@@ -133,12 +134,33 @@ const state = useStorage(
{
group: 'Group',
sortBy: 'Name',
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const search = ref('')
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName, collapsed) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
@@ -280,18 +302,21 @@ const filteredResults = computed(() => {
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
<div
<Accordion
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
value,
}))"
:key="instanceSection.key"
:divider="instanceSection.key !== 'None'"
:open-by-default="!isSectionCollapsed(instanceSection.key)"
class="row"
@on-open="setSectionCollapsed(instanceSection.key, false)"
@on-close="setSectionCollapsed(instanceSection.key, true)"
>
<div v-if="instanceSection.key !== 'None'" class="divider">
<p>{{ instanceSection.key }}</p>
<hr aria-hidden="true" />
</div>
<template v-if="instanceSection.key !== 'None'" #title>
<span class="text-base">{{ instanceSection.key }}</span>
</template>
<section class="instances">
<Instance
v-for="instance in instanceSection.value"
@@ -301,7 +326,7 @@ const filteredResults = computed(() => {
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/>
</section>
</div>
</Accordion>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
@@ -316,73 +341,7 @@ const filteredResults = computed(() => {
</template>
<style lang="scss" scoped>
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
.divider {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 1rem;
margin-bottom: 1rem;
p {
margin: 0;
font-size: 1rem;
white-space: nowrap;
color: var(--color-contrast);
}
hr {
background-color: var(--color-gray);
height: 1px;
width: 100%;
border: none;
}
}
}
.header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: inherit;
margin: 1rem 1rem 0 !important;
padding: 1rem;
width: calc(100% - 2rem);
.iconified-input {
flex-grow: 1;
input {
min-width: 100%;
}
}
.sort-dropdown {
width: 10rem;
}
.filter-dropdown {
width: 15rem;
}
.group-dropdown {
width: 10rem;
}
.labeled_button {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
}
.instances {

View File

@@ -3,8 +3,6 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
export type IconComponent = FunctionalComponent<SVGAttributes>
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
@@ -394,6 +392,8 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export type IconComponent = FunctionalComponent<SVGAttributes>
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon

View File

@@ -1,7 +1,30 @@
<template>
<div v-bind="$attrs">
<div v-if="divider && !!slots.title" class="flex items-center gap-4 mb-4">
<button
:class="
buttonClass ??
'group flex items-center gap-1 bg-transparent m-0 p-0 border-none cursor-pointer'
"
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
>
<slot name="button" :open="isOpen">
<div
class="flex items-center gap-1 whitespace-nowrap transition-colors text-primary group-hover:text-contrast"
>
<slot name="title" :open="isOpen" />
<DropdownIcon
v-if="!forceOpen"
class="size-5 transition-transform duration-300 shrink-0 text-secondary group-hover:text-primary"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</slot>
</button>
<hr class="h-px w-full border-none bg-divider" aria-hidden="true" />
</div>
<button
v-if="!!slots.title"
v-else-if="!!slots.title"
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
>
@@ -44,6 +67,7 @@ const props = withDefaults(
titleWrapperClass?: string
forceOpen?: boolean
overflowVisible?: boolean
divider?: boolean
}>(),
{
type: 'standard',
@@ -53,6 +77,7 @@ const props = withDefaults(
titleWrapperClass: null,
forceOpen: false,
overflowVisible: false,
divider: false,
},
)

View File

@@ -156,6 +156,35 @@ export const NestedContent: StoryObj = {
}),
}
export const Divider: StoryObj = {
render: () => ({
components: { Accordion },
template: /*html*/ `
<div class="flex flex-col gap-4">
<Accordion divider open-by-default>
<template #title>
<span class="text-base font-semibold text-contrast">Category A</span>
</template>
<div class="mt-2 flex flex-wrap gap-2">
<span class="px-3 py-2 bg-bg-raised rounded text-sm">Item 1</span>
<span class="px-3 py-2 bg-bg-raised rounded text-sm">Item 2</span>
<span class="px-3 py-2 bg-bg-raised rounded text-sm">Item 3</span>
</div>
</Accordion>
<Accordion divider>
<template #title>
<span class="text-base font-semibold text-contrast">Category B</span>
</template>
<div class="mt-2 flex flex-wrap gap-2">
<span class="px-3 py-2 bg-bg-raised rounded text-sm">Item 4</span>
<span class="px-3 py-2 bg-bg-raised rounded text-sm">Item 5</span>
</div>
</Accordion>
</div>
`,
}),
}
export const AllStates: StoryObj = {
render: () => ({
components: { Accordion },