mirror of
https://github.com/koel/koel
synced 2024-12-01 00:09:17 +00:00
feat: add fade effect to overflown lists (#1618)
This commit is contained in:
parent
baa2e45a5d
commit
8be339a23a
17 changed files with 110 additions and 31 deletions
|
@ -94,7 +94,8 @@ export default abstract class UnitTestCase {
|
|||
'koel-clickaway': {},
|
||||
'koel-focus': {},
|
||||
'koel-tooltip': {},
|
||||
'koel-hide-broken-icon': {}
|
||||
'koel-hide-broken-icon': {},
|
||||
'koel-overflow-fade': {}
|
||||
},
|
||||
components: {
|
||||
icon: this.stub('icon')
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
import { clickaway, focus, hideBrokenIcon, tooltip } from '@/directives'
|
||||
import { clickaway, focus, hideBrokenIcon, overflowFade, tooltip } from '@/directives'
|
||||
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
|
||||
import { RouterKey } from '@/symbols'
|
||||
import { routes } from '@/config'
|
||||
|
@ -14,6 +14,7 @@ createApp(App)
|
|||
.directive('koel-clickaway', clickaway)
|
||||
.directive('koel-tooltip', tooltip)
|
||||
.directive('koel-hide-broken-icon', hideBrokenIcon)
|
||||
.directive('koel-overflow-fade', overflowFade)
|
||||
/**
|
||||
* For Ancelot, the ancient cross of war
|
||||
* for the holy town of Gods
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
<div
|
||||
ref="scroller"
|
||||
v-koel-overflow-fade
|
||||
:class="`as-${viewMode}`"
|
||||
class="albums main-scroll-wrap"
|
||||
data-testid="album-list"
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
<div
|
||||
ref="scroller"
|
||||
v-koel-overflow-fade
|
||||
:class="`as-${viewMode}`"
|
||||
class="artists main-scroll-wrap"
|
||||
data-testid="artist-list"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<section id="homeWrapper">
|
||||
<ScreenHeader layout="collapsed">{{ greeting }}</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap" @scroll="scrolling">
|
||||
<div v-koel-overflow-fade class="main-scroll-wrap" @scroll="scrolling">
|
||||
<ScreenEmptyState v-if="libraryEmpty">
|
||||
<template #icon>
|
||||
<icon :icon="faVolumeOff" />
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap">
|
||||
<div v-koel-overflow-fade class="main-scroll-wrap">
|
||||
<div
|
||||
v-if="mediaPathSetUp"
|
||||
:class="{ droppable }"
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
</template>
|
||||
</ScreenHeader>
|
||||
|
||||
<div class="main-scroll-wrap">
|
||||
<div v-koel-overflow-fade class="main-scroll-wrap">
|
||||
<ul class="users">
|
||||
<li v-for="user in users" :key="user.id">
|
||||
<UserCard :user="user" />
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<section class="existing-playlists">
|
||||
<p>Add {{ pluralize(songs, 'song') }} to</p>
|
||||
|
||||
<ul>
|
||||
<ul v-koel-overflow-fade>
|
||||
<template v-if="config.queue">
|
||||
<template v-if="queue.length">
|
||||
<li
|
||||
|
@ -103,6 +103,7 @@ watch(songs, () => songs.value.length || close())
|
|||
}
|
||||
|
||||
ul {
|
||||
position: relative;
|
||||
max-height: 12rem;
|
||||
overflow-y: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
<li @click="addSongsToFavorite">Favorites</li>
|
||||
</template>
|
||||
<li v-if="normalPlaylists.length" class="separator" />
|
||||
<ul v-if="normalPlaylists.length" class="normal-playlists">
|
||||
<ul v-if="normalPlaylists.length" v-koel-overflow-fade class="playlists">
|
||||
<li v-for="p in normalPlaylists" :key="p.id" @click="addSongsToExistingPlaylist(p)">{{ p.name }}</li>
|
||||
</ul>
|
||||
<li class="separator" />
|
||||
|
@ -163,8 +163,9 @@ eventBus.on('SONG_CONTEXT_MENU_REQUESTED', async (e, _songs) => {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul.normal-playlists {
|
||||
max-height: 256px;
|
||||
ul.playlists {
|
||||
position: relative;
|
||||
max-height: 192px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div ref="scroller" class="virtual-scroller" @scroll.passive="onScroll">
|
||||
<div ref="scroller" v-koel-overflow-fade class="virtual-scroller" @scroll.passive="onScroll">
|
||||
<div :style="{ height: `${totalHeight}px` }">
|
||||
<div :style="{ transform: `translateY(${offsetY}px)`}">
|
||||
<template v-for="item in renderedItems">
|
||||
|
@ -23,7 +23,7 @@ const scrollTop = ref(0)
|
|||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scrolled-to-end'): void,
|
||||
(e: 'scroll', event: MouseEvent): void
|
||||
(e: 'scroll', event: Event): void
|
||||
}>()
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight.value)
|
||||
|
@ -36,7 +36,7 @@ const renderedItems = computed(() => {
|
|||
return items.value.slice(startPosition.value, startPosition.value + count)
|
||||
})
|
||||
|
||||
const onScroll = e => requestAnimationFrame(() => {
|
||||
const onScroll = (e: Event) => requestAnimationFrame(() => {
|
||||
scrollTop.value = (e.target as HTMLElement).scrollTop
|
||||
|
||||
if (!scroller.value) return
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from './clickaway'
|
|||
export * from './focus'
|
||||
export * from './tooltip'
|
||||
export * from './hideBrokenIcon'
|
||||
export * from './overflowFade'
|
||||
|
|
18
resources/assets/js/directives/overflowFade.ts
Normal file
18
resources/assets/js/directives/overflowFade.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Directive } from 'vue'
|
||||
|
||||
const toggleClasses = (el: HTMLElement) => {
|
||||
el.classList.toggle('fade-top', el.scrollTop !== 0)
|
||||
el.classList.toggle('fade-bottom', el.scrollTop + el.clientHeight !== el.scrollHeight)
|
||||
}
|
||||
|
||||
const observeVisibility = (el: HTMLElement, callback: Closure) => {
|
||||
const observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting && callback()))
|
||||
observer.observe(el)
|
||||
}
|
||||
|
||||
export const overflowFade: Directive = {
|
||||
mounted: async (el: HTMLElement) => {
|
||||
observeVisibility(el, () => toggleClasses(el))
|
||||
el.addEventListener('scroll', () => requestAnimationFrame(() => toggleClasses(el)))
|
||||
}
|
||||
}
|
|
@ -19,32 +19,40 @@ html.non-mac {
|
|||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-bg-primary);
|
||||
border: 0px none var(--color-text-primary);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:hover {
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track:active {
|
||||
background: #333333;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
|
||||
&.as-list {
|
||||
gap: 0.7em 1em;;
|
||||
gap: 0.7em 1em;
|
||||
align-content: start;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
|
||||
|
@ -91,7 +91,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.bio, .wiki {
|
||||
.bio,
|
||||
.wiki {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
|
@ -106,7 +107,8 @@
|
|||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.cover, .cool-guys-posing {
|
||||
.cover,
|
||||
.cool-guys-posing {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
|
@ -137,7 +139,8 @@
|
|||
margin: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
.bio, .wiki {
|
||||
.bio,
|
||||
.wiki {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
@ -7,7 +9,8 @@
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
body, html {
|
||||
body,
|
||||
html {
|
||||
@include themed-background();
|
||||
|
||||
color: var(--color-text-primary);
|
||||
|
@ -18,7 +21,11 @@ body, html {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
input, select, button, textarea, .btn {
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
textarea,
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
|
@ -29,7 +36,8 @@ input, select, button, textarea, .btn {
|
|||
border-radius: .3rem;
|
||||
margin: 0;
|
||||
|
||||
&:required, &:invalid {
|
||||
&:required,
|
||||
&:invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
@ -52,7 +60,8 @@ input, select, button, textarea, .btn {
|
|||
}
|
||||
}
|
||||
|
||||
button, [role=button] {
|
||||
button,
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
@ -87,11 +96,13 @@ a {
|
|||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:link, &:visited {
|
||||
&:link,
|
||||
&:visited {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
@ -189,7 +200,8 @@ label {
|
|||
}
|
||||
}
|
||||
|
||||
.form-row input:not([type="checkbox"]), .form-row select {
|
||||
.form-row input:not([type="checkbox"]),
|
||||
.form-row select {
|
||||
margin-top: .7rem;
|
||||
display: block;
|
||||
height: 32px;
|
||||
|
@ -291,7 +303,9 @@ label {
|
|||
}
|
||||
}
|
||||
|
||||
.context-menu, .submenu, menu {
|
||||
.context-menu,
|
||||
.submenu,
|
||||
menu {
|
||||
@include context-menu();
|
||||
position: fixed;
|
||||
|
||||
|
@ -335,3 +349,29 @@ label {
|
|||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--fade-size: 3rem;
|
||||
}
|
||||
|
||||
.fade-top {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
|
||||
mask-image: linear-gradient(to bottom, transparent, black var(--fade-size));
|
||||
}
|
||||
|
||||
.fade-bottom {
|
||||
-webkit-mask-image: linear-gradient(to top, transparent, black var(--fade-size));
|
||||
mask-image: linear-gradient(to top, transparent, black var(--fade-size));
|
||||
}
|
||||
|
||||
.fade-top.fade-bottom {
|
||||
-webkit-mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
|
||||
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
|
||||
-webkit-mask-size: 100% 51%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
|
||||
mask: linear-gradient(to bottom, transparent, black var(--fade-size)) top,
|
||||
linear-gradient(to top, transparent, black var(--fade-size)) bottom;
|
||||
mask-size: 100% 51%;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,15 @@
|
|||
.skeleton {
|
||||
.pulse, &.pulse {
|
||||
|
||||
.pulse,
|
||||
&.pulse {
|
||||
animation: skeleton-pulse 2s infinite;
|
||||
background-color: rgba(255, 255, 255, .05);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -34,4 +34,3 @@
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue