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,
|
TrashIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
DropdownSelect,
|
DropdownSelect,
|
||||||
formatLoader,
|
formatLoader,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
@@ -133,12 +134,33 @@ const state = useStorage(
|
|||||||
{
|
{
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
sortBy: 'Name',
|
sortBy: 'Name',
|
||||||
|
collapsedGroups: [],
|
||||||
},
|
},
|
||||||
localStorage,
|
localStorage,
|
||||||
{ mergeDefaults: true },
|
{ mergeDefaults: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const search = ref('')
|
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 filteredResults = computed(() => {
|
||||||
const { group = 'Group', sortBy = 'Name' } = state.value
|
const { group = 'Group', sortBy = 'Name' } = state.value
|
||||||
@@ -280,18 +302,21 @@ const filteredResults = computed(() => {
|
|||||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||||
</DropdownSelect>
|
</DropdownSelect>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Accordion
|
||||||
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
|
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
}))"
|
}))"
|
||||||
:key="instanceSection.key"
|
:key="instanceSection.key"
|
||||||
|
:divider="instanceSection.key !== 'None'"
|
||||||
|
:open-by-default="!isSectionCollapsed(instanceSection.key)"
|
||||||
class="row"
|
class="row"
|
||||||
|
@on-open="setSectionCollapsed(instanceSection.key, false)"
|
||||||
|
@on-close="setSectionCollapsed(instanceSection.key, true)"
|
||||||
>
|
>
|
||||||
<div v-if="instanceSection.key !== 'None'" class="divider">
|
<template v-if="instanceSection.key !== 'None'" #title>
|
||||||
<p>{{ instanceSection.key }}</p>
|
<span class="text-base">{{ instanceSection.key }}</span>
|
||||||
<hr aria-hidden="true" />
|
</template>
|
||||||
</div>
|
|
||||||
<section class="instances">
|
<section class="instances">
|
||||||
<Instance
|
<Instance
|
||||||
v-for="instance in instanceSection.value"
|
v-for="instance in instanceSection.value"
|
||||||
@@ -301,7 +326,7 @@ const filteredResults = computed(() => {
|
|||||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</Accordion>
|
||||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
|
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
|
||||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||||
<template #play> <PlayIcon /> Play </template>
|
<template #play> <PlayIcon /> Play </template>
|
||||||
@@ -316,73 +341,7 @@ const filteredResults = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
width: 100%;
|
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 {
|
.instances {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||||
|
|
||||||
export type IconComponent = FunctionalComponent<SVGAttributes>
|
|
||||||
|
|
||||||
import _AffiliateIcon from './icons/affiliate.svg?component'
|
import _AffiliateIcon from './icons/affiliate.svg?component'
|
||||||
import _AlignLeftIcon from './icons/align-left.svg?component'
|
import _AlignLeftIcon from './icons/align-left.svg?component'
|
||||||
import _ArchiveIcon from './icons/archive.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 _ZoomInIcon from './icons/zoom-in.svg?component'
|
||||||
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
||||||
|
|
||||||
|
export type IconComponent = FunctionalComponent<SVGAttributes>
|
||||||
|
|
||||||
export const AffiliateIcon = _AffiliateIcon
|
export const AffiliateIcon = _AffiliateIcon
|
||||||
export const AlignLeftIcon = _AlignLeftIcon
|
export const AlignLeftIcon = _AlignLeftIcon
|
||||||
export const ArchiveIcon = _ArchiveIcon
|
export const ArchiveIcon = _ArchiveIcon
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-bind="$attrs">
|
<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
|
<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'"
|
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||||
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
|
@click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
|
||||||
>
|
>
|
||||||
@@ -44,6 +67,7 @@ const props = withDefaults(
|
|||||||
titleWrapperClass?: string
|
titleWrapperClass?: string
|
||||||
forceOpen?: boolean
|
forceOpen?: boolean
|
||||||
overflowVisible?: boolean
|
overflowVisible?: boolean
|
||||||
|
divider?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: 'standard',
|
type: 'standard',
|
||||||
@@ -53,6 +77,7 @@ const props = withDefaults(
|
|||||||
titleWrapperClass: null,
|
titleWrapperClass: null,
|
||||||
forceOpen: false,
|
forceOpen: false,
|
||||||
overflowVisible: 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 = {
|
export const AllStates: StoryObj = {
|
||||||
render: () => ({
|
render: () => ({
|
||||||
components: { Accordion },
|
components: { Accordion },
|
||||||
|
|||||||
Reference in New Issue
Block a user