feat(ui): Splittable playbutton with consistent popover

This commit is contained in:
ArneBo 2025-01-15 11:00:46 +01:00
parent b20456e427
commit c443593619
5 changed files with 258 additions and 170 deletions

View File

@ -2,14 +2,17 @@
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import usePlayOptions from '~/composables/audio/usePlayOptions'
import useReport from '~/composables/moderation/useReport'
// import { useCurrentElement } from '@vueuse/core'
// import { setupDropdown } from '~/utils/fomantic'
import { useStore } from '~/store'
import { useRouter, useRoute } from 'vue-router'
import { color } from "~/composables/color.ts";
import Button from '~/components/ui/Button.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
interface Props extends PlayOptionsProps {
@ -43,8 +46,8 @@ const props = withDefaults(defineProps<Props>(), {
library: null,
channel: null,
account: null,
dropdownIconClasses: () => ['dropdown'],
playIconClass: () => 'play icon',
dropdownIconClasses: () => ['bi-caret-down-fill'],
playIconClass: () => 'bi bi-play-fill',
buttonClasses: () => ['button'],
discrete: () => false,
dropdownOnly: () => false,
@ -102,141 +105,118 @@ const title = computed(() => {
return ''
})
// const el = useCurrentElement()
// const dropdown = ref()
// onMounted(() => {
// dropdown.value = setupDropdown('.ui.dropdown', el.value)
// })
const isOpen = ref(false)
const openMenu = () => {
// little magic to ensure the menu is always visible in the viewport
// By default, try to display it on the right if there is enough room
// TODO: Re-implement with `Popover` component instead of fomantic dropdown
// const menu = dropdown.value.find('.menu')
// if (menu.hasClass('visible')) return
// const viewportOffset = menu.get(0)?.getBoundingClientRect() ?? { right: 0, left: 0 }
// const viewportWidth = document.documentElement.clientWidth
// const rightOverflow = viewportOffset.right - viewportWidth
// const leftOverflow = -viewportOffset.left
// menu.css({
// cssText: rightOverflow > 0
// ? `left: ${-rightOverflow - 5}px !important;`
// : `right: ${-leftOverflow + 5}px !important;`
// })
}
</script>
<template>
<span
:title="title"
:class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
>
<button
<Popover v-if="!discrete && !iconOnly" v-model:open="isOpen">
<Button
v-if="!dropdownOnly"
:disabled="!playable"
v-bind="{outline: playable, disabled: !playable, solid: !playable}"
split
splitIcon="bi-caret-down-fill"
:aria-label="labels.replacePlay"
:class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete, disabled: !playable}]"
:class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete}]"
@click.stop.prevent="replacePlay()"
@split-click="isOpen = !isOpen"
:split-title="title"
>
<i
v-if="playing"
class="pause icon"
/>
<i
v-else
:class="[playIconClass, 'icon']"
/>
<template v-if="!discrete && !iconOnly">&nbsp;<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot></template>
</button>
<button
v-if="!discrete && !iconOnly"
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"
@click.stop.prevent="openMenu"
>
<i
:class="dropdownIconClasses.concat(['icon'])"
:title="title"
/>
<div class="menu">
<button
class="item basic"
:disabled="!playable"
:title="labels.addToQueue"
@click.stop.prevent="enqueue"
>
<i class="plus icon" />{{ labels.addToQueue }}
</button>
<button
class="item basic"
:disabled="!playable"
:title="labels.playNext"
@click.stop.prevent="enqueueNext()"
>
<i class="step forward icon" />{{ labels.playNext }}
</button>
<button
class="item basic"
:disabled="!playable"
:title="labels.playNow"
@click.stop.prevent="enqueueNext(true)"
>
<i class="play icon" />{{ labels.playNow }}
</button>
<button
v-if="track"
class="item basic"
:disabled="!playable"
:title="labels.startRadio"
@click.stop.prevent="store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
>
<i class="feed icon" />{{ labels.startRadio }}
</button>
<button
v-if="track"
class="item basic"
:disabled="!playable"
@click.stop="store.commit('playlists/chooseTrack', track)"
>
<i class="list icon" />
{{ labels.addToPlaylist }}
</button>
<button
v-if="track && route.name !== 'library.tracks.detail'"
class="item basic"
@click.stop.prevent="router.push(`/library/tracks/${track?.id}/`)"
>
<i class="info icon" />
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
{{ t('components.audio.PlayButton.button.episodeDetails') }}
</span>
<span v-else>
{{ t('components.audio.PlayButton.button.trackDetails') }}
</span>
</button>
<div class="divider" />
<button
v-if="filterableArtist"
class="item basic"
:disabled="!filterableArtist"
:title="labels.hideArtist"
@click.stop.prevent="filterArtist"
>
<i class="eye slash outline icon" />
{{ labels.hideArtist }}
</button>
<button
v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})"
:key="obj.target.type + obj.target.id"
class="item basic"
@click.stop.prevent="report(obj)"
>
<i class="share icon" /> {{ obj.label }}
</button>
</div>
</button>
</span>
<template #main>
<i
v-if="playing"
class="bi bi-pause-fill"
/>
<i
v-else
:class="[playIconClass, 'icon']"
/>
<template v-if="!discrete && !iconOnly">&nbsp;<slot>{{ t('components.audio.PlayButton.button.discretePlay') }}</slot></template>
</template>
</Button>
<template #items>
<PopoverItem
:disabled="!playable"
:title="labels.addToQueue"
@click.stop.prevent="enqueue"
>
<i class="bi bi-plus" />{{ labels.addToQueue }}
</PopoverItem>
<PopoverItem
class="item basic"
:disabled="!playable"
:title="labels.playNext"
@click.stop.prevent="enqueueNext()"
>
<i class="bi bi-skip-forward-fill" />{{ labels.playNext }}
</PopoverItem>
<PopoverItem
class="item basic"
:disabled="!playable"
:title="labels.playNow"
@click.stop.prevent="enqueueNext(true)"
>
<i class="bi bi-play-fill" />{{ labels.playNow }}
</PopoverItem>
<PopoverItem
v-if="track"
class="item basic"
:disabled="!playable"
:title="labels.startRadio"
@click.stop.prevent="store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
>
<i class="bi bi-broadcast" />{{ labels.startRadio }}
</PopoverItem>
<PopoverItem
v-if="track"
class="item basic"
:disabled="!playable"
@click.stop="store.commit('playlists/chooseTrack', track)"
>
<i class="bi bi-list" />
{{ labels.addToPlaylist }}
</PopoverItem>
<PopoverItem
v-if="track && route.name !== 'library.tracks.detail'"
class="item basic"
@click.stop.prevent="router.push(`/library/tracks/${track?.id}/`)"
>
<i class="bi bi-info-circle" />
<span v-if="track.artist_credit?.some(ac => ac.artist.content_category === 'podcast')">
{{ t('components.audio.PlayButton.button.episodeDetails') }}
</span>
<span v-else>
{{ t('components.audio.PlayButton.button.trackDetails') }}
</span>
</PopoverItem>
<hr>
<PopoverItem
v-if="filterableArtist"
class="item basic"
:disabled="!filterableArtist"
:title="labels.hideArtist"
@click.stop.prevent="filterArtist"
>
<i class="bi bi-eye-slash" />
{{ labels.hideArtist }}
</PopoverItem>
<PopoverItem
v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})"
:key="obj.target.type + obj.target.id"
class="item basic"
@click.stop.prevent="report(obj)"
>
<i class="bi bi-share" /> {{ obj.label }}
</PopoverItem>
</template>
</Popover>
</template>

View File

@ -20,6 +20,11 @@ const props = defineProps<{
onClick?: (...args: any[]) => void | Promise<void>
split?: boolean // Add this prop for split button support
splitIcon?: string // Add this prop for the split button icon
splitTitle?: string // Add this prop
onSplitClick?: (...args: any[]) => void | Promise<void> // Add click handler for split part
autofocus? : boolean
ariaPressed? : true
} & (ColorProps | DefaultProps)
@ -53,12 +58,14 @@ onMounted(() => {
</script>
<template>
<button ref="button"
<div v-if="split" class="funkwhale button-group">
<button
ref="button"
v-bind="color(props, ['interactive'])(
width(props, isIconOnly ? ['square'] : ['normalHeight', 'buttonWidth'])(
align(props, { alignSelf:'start', alignText:'center' })(
)))"
class="funkwhale button"
class="funkwhale button split-main"
:aria-pressed="props.ariaPressed"
:class="{
'is-loading': isLoading,
@ -69,6 +76,8 @@ onMounted(() => {
}"
@click="click"
>
<slot name="main">
<i v-if="icon && !icon.startsWith('right ')" :class="['bi', icon]" />
<span>
@ -76,13 +85,72 @@ onMounted(() => {
</span>
<i v-if="icon && icon.startsWith('right ')" :class="['bi', icon.replace('right ', '')]" />
<Loader v-if="isLoading" :container="false" />
</slot>
<Loader v-if="isLoading" :container="false" />
</button>
<button
v-bind="color(props, ['interactive'])(
width(props, ['square'])(
align(props, { alignSelf:'start', alignText:'center' })(
)))"
class="funkwhale button split-toggle"
:title="splitTitle"
@click="onSplitClick"
>
<i :class="['bi', splitIcon]" />
</button>
</div>
<button
v-else
ref="button"
v-bind="color(props, ['interactive'])(
width(props, isIconOnly ? ['square'] : ['normalHeight', 'buttonWidth'])(
align(props, { alignSelf:'start', alignText:'center' })(
)))"
class="funkwhale button"
:aria-pressed="props.ariaPressed"
:class="{
'is-loading': isLoading,
'is-icon-only': isIconOnly,
'has-icon': !!icon,
'is-round': round,
'is-shadow': shadow,
}"
@click="click"
>
<i v-if="icon && !icon.startsWith('right ')" :class="['bi', icon]" />
<span>
<slot />
</span>
<i v-if="icon && icon.startsWith('right ')" :class="['bi', icon.replace('right ', '')]" />
<Loader v-if="isLoading" :container="false" />
</button>
</template>
<style lang="scss">
.funkwhale {
&.button-group {
display: inline-flex;
.button {
display: inline-flex; // Ensure consistent display
align-items: center;
&.split-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid var(--fw-color);
flex: 0 0 auto;
}
&.split-toggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 8px;
flex: 0 0 auto;
}
}
}
&.button {
// Layout

View File

@ -122,8 +122,8 @@ watch(open, (isOpen) => {
<template>
<div
ref="slot"
class="funkwhale popover-container"
style="display:contents;"
:class="['funkwhale popover-container', { 'split-button': $slots.default?.$el?.classList?.contains('button-group') }]"
:style="$slots.default?.$el?.classList?.contains('button-group') ? 'display: inline-flex' : 'display: contents'"
>
<slot
:isOpen="open"

View File

@ -1,5 +1,10 @@
.funkwhale.popover-container {
width: max-content;
&.split-button {
display: inline-flex;
gap: 0;
}
}
.funkwhale.popover-outer {
@ -19,9 +24,6 @@
&.popover {
border: 1px solid var(--fw-border-color);
background-color: color-mix(in oklab, var(--background-color) 98%, var(--color));
.popover-item:hover {
background-color: color-mix(in oklab, var(--hover-background-color) 98%, var(--color));
}
hr {
border-bottom: 1px solid var(--fw-border-color);
@ -62,29 +64,5 @@
padding-top: 12px;
margin-bottom: 12px;
}
.popover-item {
cursor: pointer;
color: var(--color) !important;
text-decoration: none;
padding-left: 8px;
height: 32px;
display: flex;
align-items: center;
border-radius: var(--fw-border-radius);
white-space: nowrap;
> .bi:first-child {
margin-right: 16px;
}
> .bi:last-child {
margin-right: 8px;
}
> .after {
margin-left: auto;
}
}
}
}

View File

@ -3,6 +3,8 @@ import { inject, ref } from 'vue'
import { type RouterLinkProps, RouterLink } from 'vue-router'
import { POPOVER_CONTEXT_INJECTION_KEY, type PopoverContext } from '~/injection-keys'
import Button from '~/components/ui/Button.vue'
const emit = defineEmits<{'internal:id': [value: number]}>()
const { parentPopoverContext, to } = defineProps<{
@ -39,18 +41,78 @@ emit('internal:id', id)
<div class="after" />
<slot name="after" />
</RouterLink>
<button v-else
<Button v-else
ghost
thinFont
style="
width: 100%;
textAlign: left;
gap: 8px;
"
@mouseover="hoveredItem = id"
class="popover-item ghost"
class="popover-item"
>
<slot />
<div class="after">
<slot name="after" />
</div>
</button>
</Button>
</template>
<style scoped>
div { color:var(--fw-text-color); }
</style>
<style lang="scss">
.popover .popover-item {
cursor: pointer;
color: var(--color) !important;
text-decoration: none;
padding: 0px 8px;
height: 32px;
display: flex;
align-items: center;
border-radius: var(--fw-border-radius);
white-space: nowrap;
&:hover {
background-color: var(--hover-background-color);
}
> .bi:first-child {
margin-right: 16px;
}
> .bi:last-child {
margin-right: 8px;
}
> .after {
margin-left: auto;
}
}
.popover .popover-item.button {
justify-content: flex-start !important;
height: 32px !important;
padding: 0px 8px;
border: none;
align-items: center;
&:hover {
background-color: var(--hover-background-color) !important;
}
span {
width: 100%;
i {
font-size: 14px;
}
> i {
margin-right: 14px !important;
}
.after {
margin-right: 0px;
float: right;
}
}
}
</style>