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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user