feat: use Floating UI for "Add To" menu (#1584)

This commit is contained in:
Phan An 2022-11-13 16:18:24 +01:00 committed by GitHub
parent af30e632fd
commit 2ea9f582a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 129 additions and 133 deletions

View file

@ -26,7 +26,7 @@ const email = ref(isDemo() ? DEMO_ACCOUNT.email : '')
const password = ref(isDemo() ? DEMO_ACCOUNT.password : '')
const failed = ref(false)
const emit = defineEmits(['loggedin'])
const emit = defineEmits<{ (e: 'loggedin'): void }>()
const login = async () => {
try {

View file

@ -70,7 +70,7 @@ const {
latestVersionReleaseUrl
} = useNewVersionNotification()
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
onMounted(async () => {

View file

@ -16,8 +16,6 @@ import { onMounted } from 'vue'
const { base, ContextMenuBase, open, trigger } = useContextMenu()
const emit = defineEmits(['itemClicked'])
const actionToEventMap: Record<string, EventName> = {
'new-playlist': 'MODAL_SHOW_CREATE_PLAYLIST_FORM',
'new-smart-playlist': 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM',

View file

@ -42,8 +42,7 @@ const dialog = requireInjection(DialogBoxKey)
const loading = ref(false)
const name = ref('')
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const submit = async () => {

View file

@ -43,8 +43,7 @@ const dialog = requireInjection(DialogBoxKey)
const loading = ref(false)
const name = ref('')
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const submit = async () => {

View file

@ -60,8 +60,7 @@ const submit = async () => {
}
}
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -60,8 +60,7 @@ const submit = async () => {
}
}
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -60,7 +60,7 @@ const dialog = requireInjection(DialogBoxKey)
const router = requireInjection(RouterKey)
const name = ref('')
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -73,7 +73,7 @@ const {
onGroupChanged
} = useSmartPlaylistForm(mutablePlaylist.rules)
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -96,7 +96,10 @@ watch(availableOperators, () => {
const valueSuffix = computed(() => selectedOperator.value?.unit || selectedModel.value?.unit)
const emit = defineEmits(['input', 'remove'])
const emit = defineEmits<{
(e: 'input', rule: SmartPlaylistRule): void,
(e: 'remove'): void
}>()
const onInput = () => {
emit('input', {
@ -104,7 +107,7 @@ const onInput = () => {
model: selectedModel.value,
operator: selectedOperator.value?.operator,
value: availableInputs.value.map(input => input.value)
} as SmartPlaylistRule)
})
}
const removeRule = () => emit('remove')

View file

@ -37,7 +37,7 @@ const Rule = defineAsyncComponent(() => import('@/components/playlist/smart-play
const mutatedGroup = reactive<SmartPlaylistRuleGroup>(JSON.parse(JSON.stringify(group.value)))
const emit = defineEmits(['input'])
const emit = defineEmits<{ (e: 'input', group: SmartPlaylistRuleGroup): void }>()
const notifyParentForUpdate = () => emit('input', mutatedGroup)

View file

@ -9,7 +9,7 @@ import inputTypes from '@/config/smart-playlist/inputTypes'
const props = withDefaults(defineProps<{ type?: keyof typeof inputTypes, value?: any }>(), { value: undefined })
const { type } = toRefs(props)
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits<{ (e: 'update:modelValue', value: any): void }>()
const value = computed({
get: () => props.value,

View file

@ -14,18 +14,20 @@ exports[`renders 1`] = `
</div>
<div class="song-list-controls" data-testid="song-list-controls" data-v-d396e0d2="" data-v-5691beb5-s="">
<div class="wrapper" data-v-d396e0d2=""><span class="btn-group" uppercased="" data-v-e884c19a="" data-v-d396e0d2=""><button type="button" class="btn-shuffle-all" data-testid="btn-shuffle-all" orange="" title="Shuffle all songs" data-v-e368fe26="" data-v-d396e0d2=""><br data-testid="icon" icon="[object Object]" fixed-width="" data-v-d396e0d2=""> All </button><!--v-if--><!--v-if--><!--v-if--></span><span class="btn-group" data-v-e884c19a="" data-v-d396e0d2=""></span></div>
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="" style="display: none;">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
</ul>
</section>
<section class="new-playlist" data-testid="new-playlist" data-v-42061e3e="">
<p data-v-42061e3e="">or create a new playlist</p>
<form class="form-save form-simple form-new-playlist" data-v-42061e3e=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-42061e3e=""><button type="submit" title="Save" data-v-e368fe26="" data-v-42061e3e="">⏎</button></form>
</section>
<div class="menu-wrapper" data-v-d396e0d2="">
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="" data-v-d396e0d2="">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 0 songs to</p>
<ul data-v-42061e3e="">
<li data-testid="queue" tabindex="0" data-v-42061e3e="">Queue</li>
<li class="favorites" data-testid="add-to-favorites" tabindex="0" data-v-42061e3e=""> Favorites </li>
</ul>
</section>
<section class="new-playlist" data-testid="new-playlist" data-v-42061e3e="">
<p data-v-42061e3e="">or create a new playlist</p>
<form class="form-save form-simple form-new-playlist" data-v-42061e3e=""><input data-testid="new-playlist-name" placeholder="Playlist name" required="" type="text" data-v-42061e3e=""><button type="submit" title="Save" data-v-e368fe26="" data-v-42061e3e="">⏎</button></form>
</section>
</div>
</div>
</div>
</main>

View file

@ -1,13 +1,5 @@
<template>
<div
v-show="showing"
v-koel-clickaway="close"
v-koel-focus
class="add-to"
data-testid="add-to-menu"
tabindex="0"
@keydown.esc="close"
>
<div class="add-to" data-testid="add-to-menu" tabindex="0">
<section class="existing-playlists">
<p>Add {{ pluralize(songs, 'song') }} to</p>
@ -86,8 +78,8 @@ import Btn from '@/components/ui/Btn.vue'
const toaster = requireInjection(MessageToasterKey)
const router = requireInjection(RouterKey)
const props = defineProps<{ songs: Song[], showing: Boolean, config: AddToMenuConfig }>()
const { songs, showing, config } = toRefs(props)
const props = defineProps<{ songs: Song[], config: AddToMenuConfig }>()
const { songs, config } = toRefs(props)
const newPlaylistName = ref('')
const queue = toRef(queueStore.state, 'songs')
@ -96,7 +88,7 @@ const currentSong = queueStore.current
const allPlaylists = toRef(playlistStore.state, 'playlists')
const playlists = computed(() => allPlaylists.value.filter(playlist => !playlist.is_smart))
const emit = defineEmits(['closing'])
const emit = defineEmits<{ (e: 'closing'): void }>()
const close = () => emit('closing')
const {
@ -135,8 +127,6 @@ const createNewPlaylistFromSongs = async () => {
<style lang="scss" scoped>
.add-to {
@include context-menu();
width: 100%;
max-width: 225px;
padding: .75rem;
@ -181,34 +171,23 @@ const createNewPlaylistFromSongs = async () => {
}
}
&::before {
display: block;
content: " ";
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid var(--color-bg-primary);
position: absolute;
top: -7px;
left: calc(50% - 10px);
}
form {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
border-radius: 3px;
overflow: hidden;
input[type="text"] {
width: 100%;
border-radius: 5px 0 0 5px;
height: 28px;
border-radius: 0;
}
button[type="submit"] {
margin-top: 0;
border-radius: 0 5px 5px 0 !important;
border-radius: 0;
height: 28px;
line-height: 28px;
padding-top: 0;

View file

@ -299,7 +299,7 @@ const open = async () => {
Object.assign(initialFormData, formData)
}
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -104,7 +104,14 @@ import SongListSorter from '@/components/song/SongListSorter.vue'
const { startDragging } = useDraggable('songs')
const { getDroppedData, acceptsDrop } = useDroppable(['songs'])
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
const emit = defineEmits<{
(e: 'press:enter', event: KeyboardEvent): void,
(e: 'press:delete'): void,
(e: 'reorder', song: Song): void,
(e: 'sort', field: SongListSortField, order: SortOrder): void,
(e: 'scroll-breakpoint', direction: 'up' | 'down'): void,
(e: 'scrolled-to-end'): void,
}>()
const [items] = requireInjection<[Ref<Song[]>]>(SongsKey)
const [selectedSongs, setSelectedSongs] = requireInjection<[Ref<Song[]>, Closure]>(SelectedSongsKey)

View file

@ -59,16 +59,6 @@ new class extends UnitTestCase {
expect(emitted().playSelected[0]).toEqual([false])
})
it('toggles Add To menu', async () => {
const { getByTitle, getByTestId } = this.renderComponent()
await fireEvent.click(getByTitle('Add selected songs to…'))
expect(getByTestId('add-to-menu').style.display).toBe('')
await fireEvent.click(getByTitle('Cancel'))
expect(getByTestId('add-to-menu').style.display).toBe('none')
})
it('clears queue', async () => {
const { emitted, getByTitle } = this.renderComponent(0, { clearQueue: true })

View file

@ -54,14 +54,7 @@
</template>
</template>
<Btn
v-if="selectedSongs.length"
:title="`${showingAddToMenu ? 'Cancel' : 'Add selected songs to…'}`"
class="btn-add-to"
data-testid="add-to-btn"
green
@click.prevent.stop="toggleAddToMenu"
>
<Btn v-if="showAddToButton" ref="addToButton" green @click.prevent.stop="toggleAddToMenu">
{{ showingAddToMenu ? 'Cancel' : 'Add To…' }}
</Btn>
@ -86,21 +79,18 @@
</BtnGroup>
</div>
<AddToMenu
v-koel-clickaway="closeAddToMenu"
:config="mergedConfig.addTo"
:showing="showingAddToMenu"
:songs="selectedSongs"
@closing="closeAddToMenu"
/>
<div ref="addToMenu" v-koel-clickaway="closeAddToMenu" class="menu-wrapper">
<AddToMenu :config="mergedConfig.addTo" :songs="selectedSongs" @closing="closeAddToMenu"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, Ref, ref, toRefs, watch } from 'vue'
import { SelectedSongsKey, SongsKey } from '@/symbols'
import { requireInjection } from '@/utils'
import { useFloatingUi } from '@/composables'
import AddToMenu from '@/components/song/AddToMenu.vue'
import Btn from '@/components/ui/Btn.vue'
@ -109,10 +99,12 @@ import BtnGroup from '@/components/ui/BtnGroup.vue'
const props = withDefaults(defineProps<{ config?: Partial<SongListControlsConfig> }>(), { config: () => ({}) })
const { config } = toRefs(props)
const [songs] = requireInjection(SongsKey)
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
const [selectedSongs] = requireInjection(SelectedSongsKey)
const el = ref<HTMLElement>()
const addToButton = ref<InstanceType<Btn>>()
const addToMenu = ref<HTMLDivElement>()
const showingAddToMenu = ref(false)
const altPressed = ref(false)
@ -130,10 +122,14 @@ const mergedConfig = computed((): SongListControlsConfig => Object.assign({
}, config.value)
)
const showAddToButton = computed(() => Boolean(selectedSongs.value.length))
const showClearQueueButton = computed(() => mergedConfig.value.clearQueue)
const showDeletePlaylistButton = computed(() => mergedConfig.value.deletePlaylist)
const emit = defineEmits(['playAll', 'playSelected', 'clearQueue', 'deletePlaylist', 'refresh'])
const emit = defineEmits<{
(e: 'playAll' | 'playSelected', shuffle: boolean): void,
(e: 'clearQueue' | 'deletePlaylist' | 'refresh'): void,
}>()
const shuffle = () => emit('playAll', true)
const shuffleSelected = () => emit('playSelected', true)
@ -142,25 +138,30 @@ const playSelected = () => emit('playSelected', false)
const clearQueue = () => emit('clearQueue')
const deletePlaylist = () => emit('deletePlaylist')
const refresh = () => emit('refresh')
const closeAddToMenu = () => (showingAddToMenu.value = false)
const registerKeydown = (event: KeyboardEvent) => event.key === 'Alt' && (altPressed.value = true)
const registerKeyup = (event: KeyboardEvent) => event.key === 'Alt' && (altPressed.value = false)
const toggleAddToMenu = async () => {
showingAddToMenu.value = !showingAddToMenu.value
if (!showingAddToMenu.value) {
return
}
let usedFloatingUi: ReturnType<typeof useFloatingUi>
watch(showAddToButton, async showingButton => {
await nextTick()
const btnAddTo = el.value?.querySelector<HTMLButtonElement>('.btn-add-to')!
const { left: btnLeft, bottom: btnBottom, width: btnWidth } = btnAddTo.getBoundingClientRect()
const contextMenu = el.value?.querySelector<HTMLElement>('.add-to')!
const menuWidth = contextMenu.getBoundingClientRect().width
contextMenu.style.top = `${btnBottom + 10}px`
contextMenu.style.left = `${btnLeft + btnWidth / 2 - menuWidth / 2}px`
if (showingButton) {
usedFloatingUi = useFloatingUi(addToButton.value.button, addToMenu, { autoTrigger: false })
usedFloatingUi.setup()
} else {
usedFloatingUi?.teardown()
}
}, { immediate: true })
const closeAddToMenu = () => {
usedFloatingUi?.hide()
showingAddToMenu.value = false
}
const toggleAddToMenu = () => {
showingAddToMenu.value ? usedFloatingUi?.hide() : usedFloatingUi?.show()
showingAddToMenu.value = !showingAddToMenu.value
}
onMounted(() => {
@ -168,9 +169,11 @@ onMounted(() => {
window.addEventListener('keyup', registerKeyup)
})
onUnmounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('keydown', registerKeydown)
window.removeEventListener('keyup', registerKeyup)
usedFloatingUi?.teardown()
})
</script>
@ -182,5 +185,12 @@ onUnmounted(() => {
display: flex;
gap: .5rem;
}
.menu-wrapper {
@include context-menu();
padding: 0;
display: none;
}
}
</style>

View file

@ -23,7 +23,7 @@ import { useFloatingUi } from '@/composables'
const props = defineProps<{ field?: SongListSortField, order?: SortOrder }>()
const { field, order } = toRefs(props)
const emit = defineEmits<{ (e: 'sort', payload: SongListSortField): void }>()
const emit = defineEmits<{ (e: 'sort', field: SongListSortField): void }>()
const button = ref<HTMLButtonElement>()
const menu = ref<HTMLDivElement>()
@ -76,7 +76,6 @@ button {
}
menu {
width: max-content;
text-transform: none;
letter-spacing: 0;

View file

@ -1,7 +1,7 @@
// Vitest Snapshot v1
exports[`renders 1`] = `
<div class="add-to" data-testid="add-to-menu" tabindex="0" data-v-42061e3e="">
<div class="add-to" data-testid="add-to-menu" tabindex="0" showing="true" data-v-42061e3e="">
<section class="existing-playlists" data-v-42061e3e="">
<p data-v-42061e3e="">Add 5 songs to</p>
<ul data-v-42061e3e="">

View file

@ -28,7 +28,11 @@ const props = withDefaults(
{ layout: 'full' }
)
const emit = defineEmits(['dblclick', 'contextmenu', 'dragstart'])
const emit = defineEmits<{
(e: 'dblclick'): void,
(e: 'dragstart', event: DragEvent): void,
(e: 'contextmenu', event: MouseEvent): void
}>()
const onDblClick = () => emit('dblclick')
const onDragStart = (e: DragEvent) => emit('dragstart', e)

View file

@ -1,9 +1,19 @@
<template>
<button type="button">
<button type="button" ref="button">
<slot>Click me</slot>
</button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const button = ref<HTMLButtonElement>()
defineExpose({
button
})
</script>
<style lang="scss" scoped>
button {
background: var(--color-blue);

View file

@ -7,7 +7,7 @@
<script lang="ts" setup>
import { faTimes } from '@fortawesome/free-solid-svg-icons'
defineEmits(['click'])
defineEmits<{ (e: 'click'): void }>()
</script>
<style lang="scss" scoped>

View file

@ -15,7 +15,7 @@ const props = withDefaults(defineProps<{ modelValue?: any }>(), {
const checked = ref(props.modelValue)
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const onInput = (event: InputEvent) => {
checked.value = (event.target as HTMLInputElement).checked

View file

@ -46,7 +46,7 @@ import { equalizerPresets as presets } from '@/config'
import Btn from '@/components/ui/Btn.vue'
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const bands = audioService.bands
const root = ref<HTMLElement>()

View file

@ -50,7 +50,7 @@ import { useThirdPartyServices } from '@/composables'
const props = defineProps<{ modelValue?: ExtraPanelTab }>()
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits<{ (e: 'update:modelValue', value: ExtraPanelTab): void }>()
const { useYouTube } = useThirdPartyServices()

View file

@ -12,7 +12,7 @@
<script lang="ts" setup>
import { faSearchMinus, faSearchPlus } from '@fortawesome/free-solid-svg-icons'
const emit = defineEmits(['in', 'out'])
const emit = defineEmits<{ (e: 'in' | 'out'): void }>()
</script>
<style lang="scss" scoped>

View file

@ -41,7 +41,7 @@ const typeIcon = computed(() => {
let timeoutHandler: number
const emit = defineEmits(['dismiss'])
const emit = defineEmits<{ (e: 'dismiss', message: ToastMessage): void }>()
const dismiss = () => {
emit('dismiss', message.value)

View file

@ -13,7 +13,7 @@ import { computed } from 'vue'
const props = withDefaults(defineProps<{ modelValue?: boolean }>(), { modelValue: false })
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const value = computed({
get: () => props.modelValue,

View file

@ -32,7 +32,8 @@ import { faList } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = withDefaults(defineProps<{ modelValue?: ArtistAlbumViewMode }>(), { modelValue: 'thumbnails' })
const emit = defineEmits(['update:modelValue'])
const emit = defineEmits<{ (e: 'update:modelValue', value: ArtistAlbumViewMode): void }>()
const value = computed({
get: () => props.modelValue,

View file

@ -21,7 +21,10 @@ const scrollerHeight = ref(0)
const renderAhead = 5
const scrollTop = ref(0)
const emit = defineEmits(['scrolled-to-end', 'scroll'])
const emit = defineEmits<{
(e: 'scrolled-to-end'): void,
(e: 'scroll', event: MouseEvent): void
}>()
const totalHeight = computed(() => items.value.length * itemHeight.value)
const startPosition = computed(() => Math.max(0, Math.floor(scrollTop.value / itemHeight.value) - renderAhead))

View file

@ -91,8 +91,7 @@ const submit = async () => {
}
}
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -96,8 +96,7 @@ const submit = async () => {
}
}
const emit = defineEmits(['close'])
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const maybeClose = async () => {

View file

@ -159,24 +159,20 @@
@mixin context-menu() {
padding: .4rem 0;
width: max-content;
min-width: 144px;
background-color: var(--color-bg-context-menu);
position: fixed;
border-radius: 4px;
display: flex;
justify-content: center;
flex-direction: column;
z-index: 1001;
align-items: stretch;
text-align: left;
box-shadow: inset 0 0 0 rgba(255, 255, 255, 0.3), 0 2px 15px 4px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.1);
filter: drop-shadow(0 5px 15px rgba(0, 0, 0, .5));
input[type="search"], input[type="text"], input[type="email"], input[type="url"] {
background: var(--color-text-primary);
&:focus {
background: var(--color-text-primary);
}
:deep(.arrow) {
background-color: var(--color-bg-context-menu);
position: absolute;
width: 8px;
height: 8px;
transform: rotate(45deg);
}
}