feat(ui): add collapse (Accordion) feature to Section component

This commit is contained in:
upsiflu 2025-03-20 21:34:18 +01:00
parent 45d18dc493
commit c42f08babe
4 changed files with 134 additions and 35 deletions

View File

@ -111,6 +111,7 @@ store.dispatch('auth/fetchUser')
<ReportModal /> <ReportModal />
<UploadModal v-if="store.state.auth.authenticated" /> <UploadModal v-if="store.state.auth.authenticated" />
<SearchModal /> <SearchModal />
<ServiceMessages />
</div> </div>
</template> </template>

View File

@ -26,23 +26,25 @@ const size = computed(() => (Object.entries(props).find(([key, value]) => value
:is(h1, h2, h3, h4, h5, h6).heading { margin: 0; padding:0; vertical-align: baseline; align-self: baseline;} :is(h1, h2, h3, h4, h5, h6).heading { margin: 0; padding:0; vertical-align: baseline; align-self: baseline;}
/* Page heading */ /* Page heading */
.pageheading { font-size: 36px; font-weight: 900; letter-spacing: -1px; } :is(*, .vp-doc h3).pageheading { font-size: 36px; font-weight: 900; letter-spacing: -1px; }
/* Section heading, Modal heading [DEFAULT] */ /* Section heading, Modal heading [DEFAULT] */
/* TODO: Decide on a size. All mockups on https://design.funkwhale.audio/ have 20px. */
.sectionheading { font-size: 28px; font-weight: 700; letter-spacing: -.5px; } .sectionheading { font-size: 28px; font-weight: 700; letter-spacing: -.5px; }
:is(*, .vp-doc h3).sectionheading { font-size: 20px; font-weight: 700; letter-spacing: -.5px; }
/* Form subsection */ /* Form subsection */
.subsectionheading {font-size: 16px; font-weight: 600; letter-spacing: 0; } :is(*, .vp-doc h3).subsectionheading {font-size: 16px; font-weight: 600; letter-spacing: 0; }
/* input caption */ /* input caption */
.caption {font-size: 14px; font-weight: 600; letter-spacing: .25px; } :is(*, .vp-doc h3).caption {font-size: 14px; font-weight: 600; letter-spacing: .25px; }
/* Tab title, Channel title, Card title, Activity title */ /* Tab title, Channel title, Card title, Activity title */
.title { font-size: 16px; font-weight: 700; line-height: 18px; } :is(*, .vp-doc h3).title { font-size: 16px; font-weight: 700; line-height: 18px; }
/* Primary radio title */ /* Primary radio title */
.radio { font-size: 28px; font-weight: 900; letter-spacing: -.5px; } :is(*, .vp-doc h3).radio { font-size: 28px; font-weight: 900; letter-spacing: -.5px; }
/* Secondary radio title */ /* Secondary radio title */
.secondary { font-size: 28px; font-weight: 300; letter-spacing: -.5px; } :is(*, .vp-doc h3).secondary { font-size: 28px; font-weight: 300; letter-spacing: -.5px; }
</style> </style>

View File

@ -12,13 +12,17 @@ const actionComponents
= { Button, Link } = { Button, Link }
const props = defineProps<{ const props = defineProps<{
[M in 'no-items' | 'tiny-items' | 'small-items' | 'medium-items']?: true } [M in 'no-items' | 'tiny-items' | 'small-items' | 'medium-items']?: true
}
& { & {
alignLeft?: boolean; alignLeft?: boolean;
collapsed?: boolean; action?: { text: string } & (RouterLinkProps | { onClick: (...args: any[]) => void | Promise<void> })
}
& {
[Operation in 'expand' | 'collapse']?: () => void
} }
& { [H in 'h1' | 'h2' | 'h3']?: string } & { [H in 'h1' | 'h2' | 'h3']?: string }
& { action?: { text: string } & (RouterLinkProps | { onClick: (...args: any[]) => void | Promise<void> }) }>() >()
const heading const heading
= props.h1 = props.h1
@ -41,27 +45,65 @@ const headerGrid
<Layout <Layout
header header
:grid="headerGrid" :grid="headerGrid"
:style="`${'alignLeft' in props && props.alignLeft ? 'justify-content: start' : ''}; :style="`
margin-top: -64px;`" ${ 'alignLeft' in props && props.alignLeft ? 'justify-content: start' : '' };
${ expand || collapse ? '' : 'margin-top: -64px;' }`
"
> >
<!-- The title row's width is a multiple of the expected items' column span --> <!-- The title row's width is a multiple of the expected items' column span -->
<Layout <Layout
flex flex
no-gap no-gap
style="grid-column: 1 / -1; align-self: baseline;" style="
grid-column: 1 / -1;
align-self: baseline;
position: relative;
"
> >
<!-- Set distance between baseline and previous row --> <template v-if="expand || collapse">
<Spacer <Button
v full
:size="64" align-text="left"
style="align-self: baseline;" align-self="end"
/> :class="$style.summary"
<Heading :aria-pressed="!!collapse"
v-if="heading" raised
v-bind="heading" @click="() => expand ? expand() : collapse ? collapse() : (() => { return })()"
style="align-self: baseline; padding:0 0 24px 0; margin:0;" >
/> <Heading
<Spacer grow /> v-if="heading"
v-bind="heading"
caption
/>
</Button>
<i
:class="!!expand ? 'bi bi-chevron-down' : 'bi bi-chevron-up'"
style="
position: absolute;
top: 12px;
right: 0;
pointer-events: none;
"
/>
</template>
<template v-else>
<!-- Set distance between baseline and previous row -->
<Spacer
v
:size="64"
style="align-self: baseline;"
/>
<Heading
v-if="heading"
v-bind="heading"
style="
align-self: baseline;
padding: 0 0 24px 0;
margin: 0;
"
/>
<Spacer grow />
</template>
<!-- Action! You can either specify `to` or `onClick`. --> <!-- Action! You can either specify `to` or `onClick`. -->
<component <component
:is="'onClick' in action ? actionComponents.Button : actionComponents.Link" :is="'onClick' in action ? actionComponents.Button : actionComponents.Link"
@ -69,14 +111,14 @@ const headerGrid
ghost ghost
thin-font thin-font
align-self="baseline" align-self="baseline"
:align-text="'collapsed' in props ? 'left' : undefined" :align-text="expand || collapse ? 'left' : undefined"
:aria-pressed="props.collapsed === false || undefined" :aria-pressed="collapse"
:class="{ :class="{
[$style.action]: true, [$style.action]: true,
[$style.transparent]: 'primary' in props || 'secondary' in props || 'destructive' in props, [$style.transparent]: 'primary' in props || 'secondary' in props || 'destructive' in props,
[$style.full]: 'collapsed' in props [$style.full]: expand || collapse
}" }"
v-bind="{...fallthroughProps, ...action, ['collapsed' in props ? 'full' : 'min-content']: true}" v-bind="{ ...fallthroughProps, ...action, [expand || collapse ? 'full' : 'min-content']: true }"
> >
{{ action?.text }} {{ action?.text }}
</component> </component>
@ -87,18 +129,22 @@ const headerGrid
<Layout <Layout
main main
:inert="'collapsed' in props && props.collapsed" :inert="!!expand"
:style="`${ :style="`${
'alignLeft' in props && props.alignLeft 'alignLeft' in props && props.alignLeft
? 'justify-content: start;' ? 'justify-content: start;'
: '' : ''
} ${ }${
'collapsed' in props && props.collapsed !!expand
? 'grid-template-rows: 0fr; overflow: hidden; max-height: 0;' ? 'grid-template-rows: 0fr; overflow: hidden; max-height: 0;'
: 'max-height: 4000px;' : 'max-height: 4000px;'
}${
!!collapse
? 'padding: 12px 0;'
: ''
} }
position: relative; position: relative;
transition: max-height .5s, grid-template-rows .3s;` transition: max-height .5s, grid-template-rows .3s, padding .2s;`
" "
grid="auto / repeat(auto-fit, 46px)" grid="auto / repeat(auto-fit, 46px)"
> >
@ -108,6 +154,12 @@ const headerGrid
</template> </template>
<style module lang="scss"> <style module lang="scss">
.summary {
align-self: baseline;
min-width: calc(100% + 32px);
margin: 0 -16px;
--fw-border-radius: 32px;
}
.action { .action {
&.transparent { &.transparent {

View File

@ -99,6 +99,8 @@ const user: User = {
is_superuser: true, is_superuser: true,
privacy_level: "everyone" privacy_level: "everyone"
} }
const sections = ref<boolean[]>([false, false, false])
</script> </script>
```ts ```ts
@ -238,13 +240,13 @@ Note the spacer above the layout. By default, sections begin at the baseline of
<Layout stack gap-64 class="preview" style="margin: 0 -40px; padding: 0 25px;"> <Layout stack gap-64 class="preview" style="margin: 0 -40px; padding: 0 25px;">
<Section :alignLeft="alignLeft" small-items h3="Cards (small items)" :action="{ text:'Documentation on Cards', to:'../card' }"> <Section :alignLeft="alignLeft" small-items h3="Cards (small items)" :action="{ text:'Documentation on Cards', to:'../card' }">
<Card small title="Relatively Long Album Name"> <Card small default solid raised title="Relatively Long Album Name">
Artist Name Artist Name
</Card> </Card>
<Card small title="Relatively Long Album Name"> <Card small default solid raised title="Relatively Long Album Name">
Artist Name Artist Name
</Card> </Card>
<Card small title="Relatively Long Album Name"> <Card small default solid raised title="Relatively Long Album Name">
Artist Name Artist Name
</Card> </Card>
</Section> </Section>
@ -257,6 +259,48 @@ Note the spacer above the layout. By default, sections begin at the baseline of
</Layout> </Layout>
## Collapse and expand the section
By adding either `collapse` or `expand` to the props, you add Accordion behavior to the section.
The heading will become clickable.
```ts
const sections = ref([false, false, false])
```
```vue-html
<Section
v-for="(section, index) in sections"
:key="`${index}${section}`"
:h3="`Section ${index} (${section})`"
v-bind="
section
? { collapse: () => { console.log('collapse!'); sections[index] = false } }
: { expand: () => { console.log('expand!'); sections[index] = true } }
"
>
Content {{ section }}
</Section>
```
<Section
v-for="(section, index) in sections"
:key="`${index}${section}`"
:h3="`Section ${index}`"
align-left
no-items
v-bind="
section
? { collapse: () => { console.log('collapse!'); sections[index] = false } }
: { expand: () => { console.log('expand!'); sections[index] = true } }
"
>
<Card
title="Content"
full
/>
</Section>
## Responsivity ## Responsivity
- Cards and Activities snap to the grid columns. They have intrinsic widths, expressed in the number of columns they span. For `Card`, it is `3` and for `Activity`, it is `4`. - Cards and Activities snap to the grid columns. They have intrinsic widths, expressed in the number of columns they span. For `Card`, it is `3` and for `Activity`, it is `4`.