Files
AstralRinth/packages/ui/src/components/base/Accordion.vue
T
kk 3c2cc7568d 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>
2026-04-16 13:44:52 +00:00

151 lines
3.4 KiB
Vue

<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-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())"
>
<slot name="button" :open="isOpen">
<div class="flex items-center gap-1 w-full text-contrast">
<slot name="title" :open="isOpen" />
<DropdownIcon
v-if="!forceOpen"
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</slot>
<slot name="summary" />
</button>
<div
class="accordion-content"
:class="{ open: isOpen, 'overflow-visible': overflowVisible && showOverflow }"
@transitionend="onTransitionEnd"
>
<div>
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { computed, ref, useSlots, watch } from 'vue'
const props = withDefaults(
defineProps<{
openByDefault?: boolean
type?: 'standard' | 'outlined' | 'transparent'
buttonClass?: string
contentClass?: string
titleWrapperClass?: string
forceOpen?: boolean
overflowVisible?: boolean
divider?: boolean
}>(),
{
type: 'standard',
openByDefault: false,
buttonClass: null,
contentClass: null,
titleWrapperClass: null,
forceOpen: false,
overflowVisible: false,
divider: false,
},
)
const toggledOpen = ref(props.openByDefault)
const isOpen = computed(() => toggledOpen.value || props.forceOpen)
const showOverflow = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
watch(
() => props.openByDefault,
(newValue) => {
if (newValue !== toggledOpen.value) {
toggledOpen.value = newValue
}
},
{ immediate: true },
)
function open() {
toggledOpen.value = true
emit('onOpen')
}
function close() {
showOverflow.value = false
toggledOpen.value = false
emit('onClose')
}
function onTransitionEnd() {
if (isOpen.value) {
showOverflow.value = true
}
}
defineExpose({
open,
close,
isOpen: toggledOpen,
})
defineOptions({
inheritAttrs: false,
})
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
.accordion-content.overflow-visible > div {
overflow: visible;
}
</style>