feat(ui): Splittable playbutton with consistent popover
This commit is contained in:
parent
b20456e427
commit
c443593619
|
@ -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"> <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"> <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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue