mirror of
https://github.com/koel/koel
synced 2024-11-24 13:13:05 +00:00
feat: add tooltips for better UX (#1554)
This commit is contained in:
parent
64601411d8
commit
b96e072c02
22 changed files with 200 additions and 27 deletions
|
@ -40,6 +40,7 @@
|
|||
"@babel/polyfill": "^7.8.7",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"@faker-js/faker": "^6.2.0",
|
||||
"@floating-ui/dom": "^1.0.3",
|
||||
"@testing-library/cypress": "^8.0.2",
|
||||
"@testing-library/vue": "^6.5.1",
|
||||
"@types/axios": "^0.14.0",
|
||||
|
|
|
@ -2,7 +2,6 @@ import isMobile from 'ismobilejs'
|
|||
import { isObject, mergeWith } from 'lodash'
|
||||
import { cleanup, render, RenderOptions } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import { clickaway, focus } from '@/directives'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { commonStore, userStore } from '@/stores'
|
||||
import { http } from '@/services'
|
||||
|
@ -87,8 +86,9 @@ export default abstract class UnitTestCase {
|
|||
return render(component, deepMerge({
|
||||
global: {
|
||||
directives: {
|
||||
'koel-clickaway': clickaway,
|
||||
'koel-focus': focus
|
||||
'koel-clickaway': {},
|
||||
'koel-focus': {},
|
||||
'koel-tooltip': {}
|
||||
},
|
||||
components: {
|
||||
icon: this.stub('icon')
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
import { clickaway, focus } from '@/directives'
|
||||
import { clickaway, focus, tooltip } from '@/directives'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { RouterKey } from '@/symbols'
|
||||
import { routes } from '@/config'
|
||||
|
@ -11,6 +11,7 @@ createApp(App)
|
|||
.component('icon', FontAwesomeIcon)
|
||||
.directive('koel-focus', focus)
|
||||
.directive('koel-clickaway', clickaway)
|
||||
.directive('koel-tooltip', tooltip)
|
||||
/**
|
||||
* For Ancelot, the ancient cross of war
|
||||
* for the holy town of Gods
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
|
||||
<button
|
||||
v-if="song?.playback_state === 'Playing'"
|
||||
v-koel-tooltip.top
|
||||
class="visualizer-btn"
|
||||
data-testid="toggle-visualizer-btn"
|
||||
title="Show/hide the visualizer"
|
||||
title="Toggle the visualizer"
|
||||
type="button"
|
||||
@click.prevent="toggleVisualizer"
|
||||
>
|
||||
|
@ -16,6 +17,7 @@
|
|||
|
||||
<button
|
||||
v-if="useEqualizer"
|
||||
v-koel-tooltip.top
|
||||
:class="{ active: showEqualizer }"
|
||||
:title="`${ showEqualizer ? 'Hide' : 'Show'} equalizer`"
|
||||
class="equalizer"
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
exports[`renders 1`] = `
|
||||
<div class="extra-controls" data-testid="other-controls" data-v-8bf5fe81="">
|
||||
<div class="wrapper" data-v-8bf5fe81="">
|
||||
<!--v-if--><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Show/hide the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button>
|
||||
<!--v-if--><button class="visualizer-btn" data-testid="toggle-visualizer-btn" title="Toggle the visualizer" type="button" data-v-8bf5fe81=""><br data-testid="icon" icon="[object Object]" data-v-8bf5fe81=""></button>
|
||||
<!--v-if--><br data-testid="Volume" data-v-8bf5fe81="">
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
exports[`renders with a current song 1`] = `
|
||||
<div class="playback-controls" data-testid="footer-middle-pane" data-v-2e8b419d="">
|
||||
<div class="buttons" data-v-2e8b419d=""><button title="Unlike Fahrstuhl to Heaven by Led Zeppelin" data-testid="like-btn" type="button" class="like-btn" data-v-2e8b419d=""><br data-testid="btn-like-liked" icon="[object Object]"></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current mode: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
|
||||
<div class="buttons" data-v-2e8b419d=""><button title="Unlike Fahrstuhl to Heaven by Led Zeppelin" data-testid="like-btn" type="button" class="like-btn" data-v-2e8b419d=""><br data-testid="btn-like-liked" icon="[object Object]"></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
|
||||
<div class="fa-layers" data-v-cab48a7c=""><br data-testid="icon" icon="[object Object]" data-v-cab48a7c="">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
@ -12,7 +12,7 @@ exports[`renders with a current song 1`] = `
|
|||
|
||||
exports[`renders without a current song 1`] = `
|
||||
<div class="playback-controls" data-testid="footer-middle-pane" data-v-2e8b419d="">
|
||||
<div class="buttons" data-v-2e8b419d=""><button type="button" data-v-2e8b419d=""></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current mode: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
|
||||
<div class="buttons" data-v-2e8b419d=""><button type="button" data-v-2e8b419d=""></button><!-- a placeholder to maintain the flex layout --><button type="button" title="Play previous song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><br data-testid="PlayButton" data-v-2e8b419d=""><button type="button" title="Play next song" data-v-2e8b419d=""><br data-testid="icon" icon="[object Object]" data-v-2e8b419d=""></button><button class="repeat-mode-btn" title="Change repeat mode (current: No Repeat)" data-testid="repeat-mode-switch" type="button" data-v-cab48a7c="" data-v-2e8b419d="">
|
||||
<div class="fa-layers" data-v-cab48a7c=""><br data-testid="icon" icon="[object Object]" data-v-cab48a7c="">
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
|
@ -7,11 +7,11 @@
|
|||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<button title="About Koel" type="button" @click.prevent="openAboutKoelModal">
|
||||
<button v-koel-tooltip.left title="About Koel" type="button" @click.prevent="openAboutKoelModal">
|
||||
<icon :icon="faInfoCircle"/>
|
||||
</button>
|
||||
|
||||
<button title="Log out" type="button" @click.prevent="logout">
|
||||
<button v-koel-tooltip.left title="Log out" type="button" @click.prevent="logout">
|
||||
<icon :icon="faArrowRightFromBracket"/>
|
||||
</button>
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
:isFirstGroup="index === 0"
|
||||
@input="onGroupChanged"
|
||||
/>
|
||||
<Btn class="btn-add-group" green small uppercase @click.prevent="addGroup">
|
||||
<Btn class="btn-add-group" green small title="Add a new group" uppercase @click.prevent="addGroup">
|
||||
<icon :icon="faPlus"/>
|
||||
Group
|
||||
</Btn>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
:isFirstGroup="index === 0"
|
||||
@input="onGroupChanged"
|
||||
/>
|
||||
<Btn class="btn-add-group" green small uppercase @click.prevent="addGroup">
|
||||
<Btn class="btn-add-group" green small title="Add a new group" uppercase @click.prevent="addGroup">
|
||||
<icon :icon="faPlus"/>
|
||||
</Btn>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="row" data-testid="smart-playlist-rule-row">
|
||||
<Btn class="remove-rule" red @click.prevent="removeRule">
|
||||
<Btn class="remove-rule" red title="Remove this rule" @click.prevent="removeRule">
|
||||
<icon :icon="faTrashCan"/>
|
||||
</Btn>
|
||||
|
||||
|
|
|
@ -68,6 +68,7 @@
|
|||
|
||||
<Btn
|
||||
v-if="showDeletePlaylistButton"
|
||||
v-koel-tooltip
|
||||
class="del btn-delete-playlist"
|
||||
red
|
||||
title="Delete this playlist"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<button
|
||||
id="extraTabLyrics"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'Lyrics' }"
|
||||
title="Lyrics"
|
||||
type="button"
|
||||
|
@ -10,6 +11,7 @@
|
|||
</button>
|
||||
<button
|
||||
id="extraTabArtist"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'Artist' }"
|
||||
title="Artist information"
|
||||
type="button"
|
||||
|
@ -19,6 +21,7 @@
|
|||
</button>
|
||||
<button
|
||||
id="extraTabAlbum"
|
||||
v-koel-tooltip.left
|
||||
:class="{ active: value === 'Album' }"
|
||||
title="Album information"
|
||||
type="button"
|
||||
|
@ -28,6 +31,7 @@
|
|||
</button>
|
||||
<button
|
||||
v-if="useYouTube"
|
||||
v-koel-tooltip.left
|
||||
id="extraTabYouTube"
|
||||
:class="{ active: value === 'YouTube' }"
|
||||
title="Related YouTube videos"
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
<template>
|
||||
<a class="view-profile" data-testid="view-profile-link" href="/#/profile" title="View/edit user profile">
|
||||
<a
|
||||
v-koel-tooltip.left
|
||||
class="view-profile"
|
||||
data-testid="view-profile-link"
|
||||
href="/#/profile"
|
||||
title="Profile and preferences"
|
||||
>
|
||||
<img :alt="`Avatar of ${currentUser.name}`" :src="currentUser.avatar"/>
|
||||
</a>
|
||||
</template>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
<template>
|
||||
<button
|
||||
v-koel-tooltip.top
|
||||
:class="{ active: mode !== 'NO_REPEAT' }"
|
||||
:title="`Change repeat mode (current mode: ${readableMode})`"
|
||||
:title="`Change repeat mode (current: ${readableMode})`"
|
||||
data-testid="repeat-mode-switch"
|
||||
type="button"
|
||||
@click.prevent="changeMode"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<span class="view-modes">
|
||||
<label
|
||||
v-koel-tooltip
|
||||
:class="{ active: value === 'thumbnails' }"
|
||||
class="thumbnails"
|
||||
data-testid="view-mode-thumbnail"
|
||||
|
@ -12,6 +13,7 @@
|
|||
</label>
|
||||
|
||||
<label
|
||||
v-koel-tooltip
|
||||
:class="{ active: value === 'list' }"
|
||||
class="list"
|
||||
data-testid="view-mode-list"
|
||||
|
|
|
@ -1,23 +1,26 @@
|
|||
<template>
|
||||
<span id="volume" class="volume" :class="level">
|
||||
<icon
|
||||
v-if="level === 'muted'"
|
||||
:icon="faVolumeMute"
|
||||
fixed-width
|
||||
<span
|
||||
v-show="level === 'muted'"
|
||||
v-koel-tooltip.top
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Unmute"
|
||||
@click="unmute"
|
||||
/>
|
||||
<icon
|
||||
v-else
|
||||
:icon="level === 'discreet' ? faVolumeLow : faVolumeHigh"
|
||||
fixed-width
|
||||
>
|
||||
<icon :icon="faVolumeMute" fixed-width/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-show="level !== 'muted'"
|
||||
v-koel-tooltip.top
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="Mute"
|
||||
@click="mute"
|
||||
/>
|
||||
>
|
||||
<icon :icon="level === 'discreet' ? faVolumeLow : faVolumeHigh" fixed-width/>
|
||||
</span>
|
||||
|
||||
<input
|
||||
id="volumeInput"
|
||||
|
@ -75,7 +78,6 @@ eventBus.on('KOEL_READY', () => setLevel(preferences.volume))
|
|||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
[type=range] {
|
||||
margin: 0 0 0 8px;
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `<a class="view-profile" data-testid="view-profile-link" href="/#/profile" title="View/edit user profile" data-v-663f2e50=""><img alt="Avatar of John Doe" src="https://example.com/avatar.jpg" data-v-663f2e50=""></a>`;
|
||||
exports[`renders 1`] = `<a class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences" data-v-663f2e50=""><img alt="Avatar of John Doe" src="https://example.com/avatar.jpg" data-v-663f2e50=""></a>`;
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './clickaway'
|
||||
export * from './focus'
|
||||
export * from './tooltip'
|
||||
|
|
110
resources/assets/js/directives/tooltip.ts
Normal file
110
resources/assets/js/directives/tooltip.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { arrow, autoUpdate, computePosition, offset, Placement } from '@floating-ui/dom'
|
||||
import { Directive, DirectiveBinding } from 'vue'
|
||||
|
||||
type ElementWithTooltip = HTMLElement & {
|
||||
$tooltip?: HTMLDivElement,
|
||||
$cleanup?: Closure
|
||||
}
|
||||
|
||||
const getOrCreateTooltip = (el: ElementWithTooltip): HTMLElement => {
|
||||
if (el.$tooltip) return el.$tooltip
|
||||
|
||||
el.$tooltip = document.createElement('div')
|
||||
el.$tooltip.classList.add('tooltip')
|
||||
|
||||
const arrow = document.createElement('div')
|
||||
arrow.classList.add('tooltip-arrow')
|
||||
|
||||
const content = document.createElement('div')
|
||||
content.classList.add('tooltip-content')
|
||||
|
||||
el.$tooltip.appendChild(content)
|
||||
el.$tooltip.appendChild(arrow)
|
||||
|
||||
document.body.appendChild(el.$tooltip)
|
||||
|
||||
return el.$tooltip
|
||||
}
|
||||
|
||||
const init = (el: ElementWithTooltip, binding: DirectiveBinding) => {
|
||||
const $tooltip = getOrCreateTooltip(el)
|
||||
|
||||
// make sure the actual title is removed from the element, but keep a backup for the updated() hook calls
|
||||
$tooltip.querySelector<HTMLDivElement>('.tooltip-content')!.innerText = binding.value
|
||||
|| el.title
|
||||
|| el.getAttribute('data-title')
|
||||
|| el.innerText
|
||||
|
||||
if (el.title && !el.getAttribute('data-title')) {
|
||||
el.setAttribute('data-title', el.title)
|
||||
el.removeAttribute('title')
|
||||
}
|
||||
|
||||
const $arrow = $tooltip.querySelector<HTMLDivElement>('.tooltip-arrow')!
|
||||
|
||||
let placement: Placement = 'bottom'
|
||||
|
||||
;(['left', 'right', 'top', 'bottom'] as Placement[]).forEach(p => {
|
||||
if (binding.modifiers[p]) {
|
||||
placement = p
|
||||
}
|
||||
})
|
||||
|
||||
const update = async () => {
|
||||
const { x, y, middlewareData } = await computePosition(el, $tooltip, {
|
||||
placement,
|
||||
middleware: [
|
||||
arrow({ element: $arrow }),
|
||||
offset(8)
|
||||
]
|
||||
})
|
||||
|
||||
Object.assign($tooltip.style, {
|
||||
top: `${y}px`,
|
||||
left: `${x}px`
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
const { x: arrowX, y: arrowY } = middlewareData.arrow
|
||||
|
||||
const staticSide = {
|
||||
top: 'bottom',
|
||||
right: 'left',
|
||||
bottom: 'top',
|
||||
left: 'right'
|
||||
}[placement.split('-')[0]]
|
||||
|
||||
Object.assign($arrow.style, {
|
||||
left: arrowX != null ? `${arrowX}px` : '',
|
||||
top: arrowY != null ? `${arrowY}px` : '',
|
||||
right: '',
|
||||
bottom: '',
|
||||
// @ts-ignore
|
||||
[staticSide]: '-4px'
|
||||
})
|
||||
}
|
||||
|
||||
el.$cleanup = el.$cleanup || autoUpdate(el, $tooltip, update)
|
||||
|
||||
const showTooltip = async () => {
|
||||
$tooltip.classList.add('show')
|
||||
await update()
|
||||
}
|
||||
|
||||
const hideTooltip = () => $tooltip.classList.remove('show')
|
||||
|
||||
el.addEventListener('mouseenter', showTooltip)
|
||||
el.addEventListener('focus', showTooltip)
|
||||
el.addEventListener('mouseleave', hideTooltip)
|
||||
el.addEventListener('blur', hideTooltip)
|
||||
}
|
||||
|
||||
export const tooltip: Directive = {
|
||||
mounted: init,
|
||||
updated: init,
|
||||
|
||||
unmounted: (el: ElementWithTooltip, binding) => {
|
||||
el.$cleanup && el.$cleanup()
|
||||
el.$tooltip && document.removeChild(el.$tooltip)
|
||||
}
|
||||
}
|
|
@ -11,4 +11,5 @@
|
|||
@import "#/vendor/_nprogress.scss";
|
||||
|
||||
@import "#/partials/_skeleton.scss";
|
||||
@import '#/partials/_tooltip.scss';
|
||||
@import "#/partials/_shared.scss";
|
||||
|
|
29
resources/assets/sass/partials/_tooltip.scss
Normal file
29
resources/assets/sass/partials/_tooltip.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
.tooltip {
|
||||
opacity: 0;
|
||||
color: rgba(255, 255, 255, .8);
|
||||
width: max-content;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #111;
|
||||
padding: 5px 12px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0px 1px 1px rgba(0, 0, 0, .3));
|
||||
z-index: 9999;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transition: opacity .5s ease-in-out;
|
||||
transition-delay: .3s;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
position: absolute;
|
||||
background: #111;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
12
yarn.lock
12
yarn.lock
|
@ -978,6 +978,18 @@
|
|||
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-6.3.1.tgz#1ae963dd40405450a2945408cba553e1afa3e0fb"
|
||||
integrity sha512-8YXBE2ZcU/pImVOHX7MWrSR/X5up7t6rPWZlk34RwZEcdr3ua6X+32pSd6XuOQRN+vbuvYNfA6iey8NbrjuMFQ==
|
||||
|
||||
"@floating-ui/core@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.1.tgz#00e64d74e911602c8533957af0cce5af6b2e93c8"
|
||||
integrity sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==
|
||||
|
||||
"@floating-ui/dom@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.3.tgz#b439c8a66436c2cae8d97e889f0b76cce757a6ec"
|
||||
integrity sha512-6H1kwjkOZKabApNtXRiYHvMmYJToJ1DV7rQ3xc/WJpOABhQIOJJOdz2AOejj8X+gcybaFmBpisVTZxBZAM3V0w==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.0.1"
|
||||
|
||||
"@fortawesome/fontawesome-common-types@6.2.0":
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz#76467a94aa888aeb22aafa43eb6ff889df3a5a7f"
|
||||
|
|
Loading…
Reference in a new issue