refactor(ui): add icon, slot, and action button/link props to Section

This commit is contained in:
upsiflu 2025-03-23 21:27:55 +01:00
parent 027ad5ddae
commit 779cdbd66d
12 changed files with 305 additions and 157 deletions

View File

@ -109,7 +109,6 @@ const save = async () => {
<template>
<Section
align-left
no-items
:h2="group.label"
>
<form

View File

@ -29,7 +29,6 @@ const canEdit = store.state.auth.availablePermissions.library
<Layout stack>
<Spacer />
<Section
no-items
align-left
:h2="canEdit
? t('components.library.ArtistEdit.header.edit')

View File

@ -579,7 +579,6 @@ const isServerDisclosureOpen = ref(false)
<Section
:h2="t('components.library.FileUpload.header.server')"
align-left
no-items
v-bind="
isServerDisclosureOpen
? { collapse: () => { isServerDisclosureOpen = false } }

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, useSlots, onMounted, watch, onUnmounted, nextTick } from 'vue'
import { ref, computed, useSlots, onMounted, onUnmounted, nextTick } from 'vue'
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width'

View File

@ -2,8 +2,10 @@
import { computed } from 'vue'
const props = defineProps<{
[H in `h${'1' | '2' | '3' | '4' | '5' | '6' | '7'}`]? : string
} & {[S in 'page-heading' | 'section-heading' | 'subsection-heading' | 'caption' | 'title' | 'radio' | 'secondary' ]? : true}>()
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & {
[S in 'page-heading' | 'section-heading' | 'subsection-heading' | 'caption' | 'title' | 'radio' | 'secondary' ]? : true
}>()
const [level, title] = Object.entries(props).find(([key, value]) => value && key.startsWith('h')) || ['h1', '']
const size = computed(() => (Object.entries(props).find(([key, value]) => value && key !== level)?.[0] || 'section-heading').replace('-', '').toLowerCase())

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import type { RouterLinkProps } from 'vue-router'
import { useAttrs } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
@ -12,54 +11,45 @@ const actionComponents
= { Button, Link }
const props = defineProps<{
[M in 'no-items' | 'tiny-items' | 'small-items' | 'medium-items']?: true
}
& {
alignLeft?: boolean;
action?: { text: string } & (RouterLinkProps | { onClick: (...args: any[]) => void | Promise<void> })
}
& {
[Operation in 'expand' | 'collapse']?: () => void
}
& { [H in 'h1' | 'h2' | 'h3']?: string }
>()
columnsPerItem?: 1 | 2 | 3 | 4
alignLeft?: boolean
action?: { text: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>)
icon?: string
} & {
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
} & {
[S in 'page-heading' | 'section-heading' | 'subsection-heading' | 'caption' | 'title' | 'radio' | 'secondary' ]? : true
} & {
[Operation in 'expand' | 'collapse']?: () => void
}>()
const heading
= props.h1
? ({ h1: props.h1, pageHeading: true }) as const
: props.h2
? ({ h2: props.h2 }) as const
: ({ h3: props.h3 }) as const
const numberOfColumnsPerItem
= 'noItems' in props && props.noItems ? 1 : 'tinyItems' in props && props.tinyItems ? 2 : 'smallItems' in props && props.smallItems ? 3 : 4
const { style, ...fallthroughProps } = useAttrs()
const headerGrid
= `auto / repeat(auto-fit, calc(46px * ${numberOfColumnsPerItem} + 32px * ${numberOfColumnsPerItem - 1}))`
</script>
<template>
<section style="flex-grow: 1;">
<Layout
header
:grid="headerGrid"
:style="`
${ 'alignLeft' in props && props.alignLeft ? 'justify-content: start' : '' };
${ expand || collapse ? '' : 'margin-top: -64px;' }`
v-bind="columnsPerItem
? { grid: `auto / repeat(auto-fit, calc(46px * ${columnsPerItem} + 32px * ${(columnsPerItem) - 1}))` }
: { flex: true }
"
:class="[alignLeft && $style.left, expand || collapse ? $style.collapsible : $style.uncollapsible]"
>
<!-- The title row's width is a multiple of the expected items' column span -->
<Layout
flex
no-gap
style="
grid-column: 1 / -1;
align-self: baseline;
align-items: baseline;
position: relative;
flex-grow: 1;
"
>
<!-- Accordion? -->
<template v-if="expand || collapse">
<Button
full
@ -67,14 +57,12 @@ const headerGrid
align-self="end"
:class="$style.summary"
:aria-pressed="!!collapse"
v-bind="action"
raised
@click="() => expand ? expand() : collapse ? collapse() : (() => { return })()"
>
<Heading
v-if="heading"
v-bind="heading"
caption
/>
<slot name="topleft" />
<Heading v-bind="props" />
</Button>
<i
:class="!!expand ? 'bi bi-chevron-down' : 'bi bi-chevron-up'"
@ -86,18 +74,28 @@ const headerGrid
"
/>
</template>
<!-- Normal (non-accordion)? -->
<template v-else>
<!-- Set distance between baseline and previous row -->
<Spacer
v
:size="64"
style="align-self: baseline;"
/>
<div
v-if="icon"
style="display: flex; justify-content: center; align-items: center; width: 48px;"
>
<i
:class="['bi', icon]"
style="font-size: 18px;"
/>
</div>
<slot name="topleft" />
<Heading
v-if="heading"
v-bind="heading"
v-bind="props"
style="
align-self: baseline;
padding: 0 0 24px 0;
margin: 0;
"
@ -108,17 +106,11 @@ const headerGrid
<component
:is="'onClick' in action ? actionComponents.Button : actionComponents.Link"
v-if="action"
ghost
thin-font
min-content
align-self="baseline"
:align-text="expand || collapse ? 'start' : undefined"
:aria-pressed="collapse"
:class="{
[$style.action]: true,
[$style.transparent]: 'primary' in props || 'secondary' in props || 'destructive' in props,
[$style.full]: expand || collapse
}"
v-bind="{ ...fallthroughProps, ...action, [expand || collapse ? 'full' : 'min-content']: true }"
:class="$style.action"
v-bind="action"
>
{{ action?.text }}
</component>
@ -144,9 +136,12 @@ const headerGrid
: ''
}
position: relative;
transition: max-height .5s, grid-template-rows .3s, padding .2s;`
transition: max-height .5s, grid-template-rows .3s, padding .2s;
`"
v-bind="columnsPerItem
? { grid: `auto / repeat(auto-fit, 46px)` }
: { flex: true }
"
grid="auto / repeat(auto-fit, 46px)"
>
<slot />
</Layout>
@ -154,6 +149,15 @@ const headerGrid
</template>
<style module lang="scss">
// Thank you, css, for offering this weird alternative to !important
header.left.left {
justify-content: start;
}
.uncollapsible {
margin-top: -64px;
}
.summary {
align-self: baseline;
min-width: calc(100% + 32px);
@ -161,12 +165,8 @@ const headerGrid
--fw-border-radius: 32px;
}
.action {
&.transparent {
margin-right: 16px;
}
&.full {
min-width: 100%;
}
// Visually push ghost link and non-solid button to the edge
.action:global(.interactive:not(:is(.primary, .solid, .destructive, .secondary)):is(button, a.ghost)) {
margin-right: -16px;
}
</style>

View File

@ -104,7 +104,6 @@ const recentActivity = ref(0)
</span>
<!-- TODO: Translate Edit Link -->
<Section
no-items
:h1="props.username"
:action="{ text:'Edit profile', to:'/settings' }"
solid

View File

@ -148,10 +148,11 @@ const showCreateModal = ref(false)
:h1="t('views.channels.SubscriptionsList.title')"
:action="{
text: t('views.channels.SubscriptionsList.link.addNew'),
onClick: ()=> showSubscribeModal = true
onClick: () => { showSubscribeModal = true },
primary: true,
icon: 'bi-plus'
}"
icon="bi-plus"
primary
page-heading
/>
<Modal
v-model="showSubscribeModal"
@ -203,10 +204,11 @@ const showCreateModal = ref(false)
:h1="t('views.auth.ProfileOverview.header.channels')"
:action="{
text: t('views.channels.SubscriptionsList.link.addNew'),
onClick: ()=> showCreateModal = true
onClick: () => { showCreateModal = true },
primary: true,
icon: 'bi-plus'
}"
icon="bi-plus"
primary
page-heading
/>
<Layout
form

View File

@ -68,10 +68,11 @@ const showSubscribeModal = ref(false)
:h1="labels.title"
:action="{
text: t('views.channels.SubscriptionsList.link.addNew'),
onClick: ()=> showSubscribeModal = true
onClick: () => { showSubscribeModal = true },
primary: true,
icon: 'bi-plus'
}"
icon="bi-plus"
primary
page-heading
/>
<Modal
v-model="showSubscribeModal"

View File

@ -123,13 +123,14 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
:action="{
onClick: () => { store.commit('playlists/showModal', true) },
text: t('views.playlists.List.button.manage'),
primary: true,
icon: 'bi-music-note-list',
ariaPressed: store.state.playlists.showModal
}"
icon="bi-music-note-list"
primary
:aria-pressed="store.state.playlists.showModal"
/>
<Header
v-else
page-heading
:h1="t('views.playlists.List.header.browse')"
/>
@ -222,7 +223,7 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
<Spacer v-if="result && result.results.length > 0" />
<!-- Search results -->
<Section small-items>
<Section :columns-per-item="3">
<Loader v-if="isLoading" />
<Alert
v-if="result && result.results.length === 0"

View File

@ -7,6 +7,7 @@ import Card from '~/components/ui/Card.vue'
import Layout from '~/components/ui/Layout.vue'
import Toggle from '~/components/ui/Toggle.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Pill from '~/components/ui/Pill.vue'
import Activity from '~/components/ui/Activity.vue'
import Section from '~/components/ui/Section.vue'
@ -109,67 +110,184 @@ import Section from '~/components/ui/Section.vue'
# Layout section
Sections divide the page vertically. Choose an appropriate heading level for each section: `h1` or `h2` or `h3`.
Sections divide the page vertically. Choose an appropriate heading level for each section.
You can use all props for [Heading](../heading.md), including `h1` to `h6` and [stylistic variants](../heading.md#visual-sizes-for-page-sections-and-subsections) such as `radio` or `page-heading`.
```vue-html
<Section h3="My title" />
<Section h1="My title" />
```
### Align the section to the page
<Spacer />
<Section h1="My title" />
```vue-html
<Section h3="My title" alignLeft />
<Section
h2="My title"
radio
/>
```
<Spacer />
<Section
h2="My title"
radio
/>
## Align the section
```vue-html
<Section h2="My title" alignLeft />
```
### Make the section header align with the section contents
The section aligns its title and items to a grid, following the designs. To make sure the header of a section exactly aligns with its contents, set the item width:
The section aligns its title and items to a grid, following the designs. To make sure the header of a section exactly aligns with its contents, set the item width (in number of columns). For example,
Do you want to align the header to
<style module>
.table {
margin: 0 -184px;
transform: scale(80%);
}
.table div[class*='language-'] {
margin: -8px -16px !important;
}
</style>
- `no-items` (mixed content),
- `tiny-items` (each item is one column wide),
- `small-items` (each item is two columns wide, e.g. default cards),
- `medium-items` (each item is three columns wide, such as Activities or medium cards)?
If all items stretch all columns (`style=grid-column: 1 / -1`), use `no-items`.
<Layout grid :class="$style.table">
<Card title="Mixed content">
```vue-html
<Section medium-items />
:columns-per-item="1"
```
### Provide an action
The link or button will be shown on the right side of the header.
</Card>
<Card title="Normal cards">
```vue-html
<Layout stack gap-64>
<Spacer />
<Section h3="With a link"
:action="{ text:'My library', to:'/' }" />
:columns-per-item="3"
```
<Section h3="With a button"
:action="{ text:'Say hello!', onClick:()=>console.log('Hello') }" />
</Card>
<Card title="Large cards, Activities">
```vue-html
:columns-per-item="4"
```
</Card>
</Layout>
For a complete overview of column widths for common funkwhale components, see [the table in using-width](../using-width.md#widths-in-the-grid)
### Move individual items within and across grid-cells
For child items, you can use all known CSS grid placement techniques:
<Layout grid :class="$style.table">
<Card title="Stretch over all columns">
```css
grid-column: 1 / -1;
```
Fill the whole grid, no matter how wide the screen is
</Card>
<Card title="Span multiple rows/columns">
```css
grid-row: span 3
```
</Card>
<Card title="Move within grid cell">
```css
align-self: start;
justify-self: center;
```
Place individual items to the edge of their current cell or cells
</Card>
</Layout>
## Provide an action
The link or button will be shown on the right side of the header. Use `action.text` to set the label (required).
You can use all [`Link` props](../link.md) or [`Button` props](../button.md) inside the `action` prop! Note that the button or link label will be in line with the heading.
```vue-html
<Spacer />
<Layout stack gap-64>
<Section
h2="With a link"
:action="{
text: 'My library',
to: '/',
icon: 'bi-star'
}"
/>
<Section
h2="With a button"
:action="{
text: 'Say hello!',
onClick: ()=>console.log('Hello'),
primary: true,
solid: true
}"
/>
</Layout>
```
<Spacer />
<Layout stack gap-64>
<Section h3="With a link" :action="{ text:'My library', to:'/' }" />
<Section h3="With a button" :action="{ text:'Say hello!', onClick:()=>console.log('Hello') }" />
<Section
h2="With a link"
:action="{
text: 'My library',
to: '/',
icon: 'bi-star'
}"
/>
<Section
h2="With a button"
:action="{
text: 'Say hello!',
onClick: ()=>console.log('Hello'),
primary: true,
solid: true
}"
/>
</Layout>
You can add props to the Link or Button, for example to make them `primary` or add an icon:
## Add icons and slots
```vue-html{1}
<Section solid primary icon="bi-star"
h3="Example" :action="{ text:'Say hello!', onClick:()=>console.log('Hello') }" />
```vue-html
<Section
icon="bi-heart"
>
<template #topleft>
<Pill>#Audiology</Pill>
<Spacer size-12 />
<Pill>#Phonologics</Pill>
</template>
</Section>
```
<Spacer :size="40"/>
<Spacer />
<Section solid primary icon="bi-star"
h3="Example" :action="{ text:'Say hello!', onClick:()=>console.log('Hello') }" />
<Section
icon="bi-heart"
>
<template #topleft>
<Pill>#Audiology</Pill>
<Spacer size-12 />
<Pill>#Phonologics</Pill>
</template>
</Section>
## Set gaps between consecutive sections
@ -177,22 +295,6 @@ Place consecutive sections into a [Layout stack](../layout) with a 64px gap (`ga
Note the spacer above the layout. By default, sections begin at the baseline of the heading. This enables us to explicitly define the vertical rhythm, independently of the heading's line height.
```vue-html
<Spacer/>
<Layout stack gap-64>
<Section h3="Section 1" />
<Section h3="Section 2" />
</Layout>
```
<Spacer />
<Layout stack gap-64>
<Section h3="Section 1" />
<Section h3="Section 2" />
</Layout>
<Spacer />
## Mix sections of different item widths
```vue-html
@ -204,28 +306,40 @@ Note the spacer above the layout. By default, sections begin at the baseline of
<Layout stack gap-64>
<Section :alignLeft="alignLeft" small-items h3="Cards (small items)" :action="{ text:'more...', to:'/' }">
<Card small title="Relatively Long Album Name">
Artist Name
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
</Card>
<Card small title="Relatively Long Album Name">
Artist Name
</Card>
</Section>
<Section
<Section
:alignLeft="alignLeft"
medium-items
h3="Activities (medium items)"
:action="{ text:'more...', to:'/' }"
>
:columns-per-item="2"
h2="Cards (2-wide items)"
:action="{
text:'Documentation on Cards',
to:'../card'
}"
>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
</Section>
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="Activities (3-wide items)"
:action="{
text:'Delete selected items',
onClick:()=>console.log('Deleted :-)')
}"
>
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
</Section>
</Section>
</Layout>
```
@ -239,7 +353,15 @@ 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;">
<Section :alignLeft="alignLeft" small-items h3="Cards (small items)" :action="{ text:'Documentation on Cards', to:'../card' }">
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="Cards (2-wide items)"
:action="{
text:'Documentation on Cards',
to:'../card'
}"
>
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
@ -249,20 +371,28 @@ Note the spacer above the layout. By default, sections begin at the baseline of
<Card small default solid raised title="Relatively Long Album Name">
Artist Name
</Card>
</Section>
</Section>
<Section :alignLeft="alignLeft" medium-items h3="Activities (medium items)" :action="{ text:'Delete selected items', onClick:()=>console.log('Deleted :-)') }">
<Section
:alignLeft="alignLeft"
:columns-per-item="4"
h2="Activities (3-wide items)"
:action="{
text:'Delete selected items',
onClick:()=>console.log('Deleted :-)')
}"
>
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
<Activity :track="track" :user="user" />
</Section>
</Section>
</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.
The heading will become a clickable button.
```ts
const sections = ref([false, false, false])
@ -272,11 +402,12 @@ const sections = ref([false, false, false])
<Section
v-for="(section, index) in sections"
:key="`${index}${section}`"
:h3="`Section ${index} (${section})`"
:h2="`Section ${index} (${section})`"
align-left
v-bind="
section
? { collapse: () => { console.log('collapse!'); sections[index] = false } }
: { expand: () => { console.log('expand!'); sections[index] = true } }
? { collapse: () => { sections[index] = false } }
: { expand: () => { sections[index] = true } }
"
>
Content {{ section }}
@ -286,13 +417,12 @@ const sections = ref([false, false, false])
<Section
v-for="(section, index) in sections"
:key="`${index}${section}`"
:h3="`Section ${index}`"
:h2="`Section ${index}`"
align-left
no-items
v-bind="
section
? { collapse: () => { console.log('collapse!'); sections[index] = false } }
: { expand: () => { console.log('expand!'); sections[index] = true } }
? { collapse: () => { sections[index] = false } }
: { expand: () => { sections[index] = true } }
"
>
<Card

View File

@ -25,7 +25,15 @@ The page grid consists of 46px wide tracks, separated by 32px wide gaps. [See ex
Use the [Layout Section component](/components/ui/layout/section) to structure the page into separate sections, each with a heading. Make sure the heading level hierarchy makes sense.
```vue-html
<Section :alignLeft="alignLeft" small-items h3="My albums" :action="{ text:'Go to library', to:'/' }">
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="My albums"
:action="{
text:'Go to library',
to:'/'
}"
>
<Card small solid yellow title="Album 1" />
<Card small solid green title="Album 2" />
<Card small solid blue title="Album 3" />
@ -34,7 +42,15 @@ Use the [Layout Section component](/components/ui/layout/section) to structure t
<Spacer :size="32" />
<Section :alignLeft="alignLeft" small-items h3="My albums" :action="{ text:'Go to library', to:'/' }">
<Section
:alignLeft="alignLeft"
:columns-per-item="3"
h2="My albums"
:action="{
text:'Go to library',
to:'/'
}"
>
<Card small solid yellow title="Album 1" />
<Card small solid green title="Album 2" />
<Card small solid blue title="Album 3" />