Improved mobile search experience
This commit is contained in:
1 parent
1fce02ad84
commit
ce0bf50223
5 files changed
+242
-22
No files matched your search
@@ -30,7 +30,16 @@ const entryType = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
|
<article class="search-row" :class="{ odd: props.index % 2 === 1 }">
|
||||||
<div class="artwork" @click="props.result.type === 'track' && emit('play')">
|
<div
|
||||||
|
class="artwork"
|
||||||
|
:class="{ playable: props.result.type === 'track' }"
|
||||||
|
:role="props.result.type === 'track' ? 'button' : undefined"
|
||||||
|
:tabindex="props.result.type === 'track' ? 0 : undefined"
|
||||||
|
:aria-label="props.result.type === 'track' ? `Play ${props.result.name}` : undefined"
|
||||||
|
@click="props.result.type === 'track' && emit('play')"
|
||||||
|
@keydown.enter.prevent="props.result.type === 'track' && emit('play')"
|
||||||
|
@keydown.space.prevent="props.result.type === 'track' && emit('play')"
|
||||||
|
>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
v-if="artworkSrc"
|
v-if="artworkSrc"
|
||||||
:src="artworkSrc"
|
:src="artworkSrc"
|
||||||
@@ -93,6 +102,10 @@ const entryType = computed(() =>
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: $border-radius-standard;
|
border-radius: $border-radius-standard;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -140,4 +153,49 @@ const entryType = computed(() =>
|
|||||||
.artists {
|
.artists {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-row {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"art details"
|
||||||
|
"art artists";
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
padding-inline: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork {
|
||||||
|
grid-area: art;
|
||||||
|
|
||||||
|
&.playable .play-icon {
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
grid-area: details;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
grid-area: artists;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
:deep(.artist-name) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.artist-name a) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row > span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -112,7 +112,10 @@ const visibleDropdownMenuItems = computed(() =>
|
|||||||
<ArtistName :artists="track.artists" class="artists" />
|
<ArtistName :artists="track.artists" class="artists" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu :items="visibleDropdownMenuItems">
|
<DropdownMenu
|
||||||
|
:items="visibleDropdownMenuItems"
|
||||||
|
:label="`Options for ${track.name}`"
|
||||||
|
>
|
||||||
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface TrackRowProps {
|
|||||||
extendedInfo?: boolean
|
extendedInfo?: boolean
|
||||||
showImage?: boolean
|
showImage?: boolean
|
||||||
queueMode?: boolean
|
queueMode?: boolean
|
||||||
|
touchFriendly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<TrackRowProps>()
|
const props = defineProps<TrackRowProps>()
|
||||||
@@ -140,7 +141,21 @@ const artworkSrc = computed(() => {
|
|||||||
v-if="props.track"
|
v-if="props.track"
|
||||||
name="lucide:play"
|
name="lucide:play"
|
||||||
class="play-icon"
|
class="play-icon"
|
||||||
|
:class="{ 'touch-friendly': props.touchFriendly }"
|
||||||
|
:role="props.touchFriendly ? 'button' : undefined"
|
||||||
|
:tabindex="props.touchFriendly ? 0 : undefined"
|
||||||
|
:aria-label="
|
||||||
|
props.touchFriendly
|
||||||
|
? `Play ${props.track.name}`
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
@click="emit('playAlbumAtIndex')"
|
@click="emit('playAlbumAtIndex')"
|
||||||
|
@keydown.enter.prevent="
|
||||||
|
props.touchFriendly && emit('playAlbumAtIndex')
|
||||||
|
"
|
||||||
|
@keydown.space.prevent="
|
||||||
|
props.touchFriendly && emit('playAlbumAtIndex')
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
|
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
@@ -194,7 +209,11 @@ const artworkSrc = computed(() => {
|
|||||||
class="queue-handle"
|
class="queue-handle"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<DropdownMenu v-else-if="props.track" :items="visibleDropdownMenuItems">
|
<DropdownMenu
|
||||||
|
v-else-if="props.track"
|
||||||
|
:items="visibleDropdownMenuItems"
|
||||||
|
:label="`Options for ${props.track.name}`"
|
||||||
|
>
|
||||||
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
<Icon name="lucide:ellipsis" class="dropdown-icon" />
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</article>
|
</article>
|
||||||
@@ -274,6 +293,10 @@ const artworkSrc = computed(() => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-icon.inline {
|
.play-icon.inline {
|
||||||
@@ -298,6 +321,15 @@ const artworkSrc = computed(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.play-icon.touch-friendly {
|
||||||
|
opacity: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.artists {
|
.artists {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-8
@@ -12,16 +12,20 @@ interface SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { currentSong, playAlbum } = usePlayer()
|
const { currentSong, playAlbum } = usePlayer()
|
||||||
const query = computed(() => {
|
const query = computed(() => {
|
||||||
const value = route.query.q
|
const value = route.query.q
|
||||||
const firstValue = Array.isArray(value) ? value[0] : value
|
const firstValue = Array.isArray(value) ? value[0] : value
|
||||||
return (firstValue ?? "").trim()
|
return (firstValue ?? "").trim()
|
||||||
})
|
})
|
||||||
|
const searchInput = ref(query.value)
|
||||||
const debouncedQuery = ref(query.value)
|
const debouncedQuery = ref(query.value)
|
||||||
|
const querySettled = computed(() => query.value === debouncedQuery.value)
|
||||||
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
watch(query, (value) => {
|
watch(query, (value) => {
|
||||||
|
searchInput.value = value
|
||||||
clearTimeout(debounceTimeout)
|
clearTimeout(debounceTimeout)
|
||||||
debounceTimeout = setTimeout(() => {
|
debounceTimeout = setTimeout(() => {
|
||||||
debouncedQuery.value = value
|
debouncedQuery.value = value
|
||||||
@@ -29,9 +33,14 @@ watch(query, (value) => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
const updateSearchRoute = (value: string) => {
|
||||||
clearTimeout(debounceTimeout)
|
const trimmed = value.trim()
|
||||||
})
|
if (trimmed)
|
||||||
|
router.replace(`/search?q=${encodeURIComponent(trimmed)}`)
|
||||||
|
else router.replace("/search")
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => clearTimeout(debounceTimeout))
|
||||||
|
|
||||||
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
|
const { data, status, error } = await useApiFetch<SearchResult[]>("/search", {
|
||||||
query: { q: debouncedQuery, limit: 9 },
|
query: { q: debouncedQuery, limit: 9 },
|
||||||
@@ -87,15 +96,30 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
|
|||||||
<template>
|
<template>
|
||||||
<main class="search-page">
|
<main class="search-page">
|
||||||
<h1>Search</h1>
|
<h1>Search</h1>
|
||||||
|
<Input
|
||||||
|
class="mobile-search"
|
||||||
|
v-model:value="searchInput"
|
||||||
|
full
|
||||||
|
type="search"
|
||||||
|
name="mobile-search-query"
|
||||||
|
label="Search the library"
|
||||||
|
placeholder="Songs, albums, artists"
|
||||||
|
icon-name="lucide:search"
|
||||||
|
@input="updateSearchRoute"
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="query && status === 'pending'" class="message" role="status">
|
<p
|
||||||
|
v-if="query && (!querySettled || status === 'pending')"
|
||||||
|
class="message"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
Searching…
|
Searching…
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="query && error" class="message" role="alert">
|
<p v-else-if="query && querySettled && error" class="message" role="alert">
|
||||||
Search could not be loaded. Please try again.
|
Search could not be loaded. Please try again.
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-else-if="query && status === 'success' && results.length === 0"
|
v-else-if="query && querySettled && status === 'success' && results.length === 0"
|
||||||
class="message"
|
class="message"
|
||||||
>
|
>
|
||||||
No results for “{{ query }}”.
|
No results for “{{ query }}”.
|
||||||
@@ -109,7 +133,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-if="query && results.length"
|
v-if="query && querySettled && status === 'success' && results.length"
|
||||||
class="results"
|
class="results"
|
||||||
aria-label="Search results"
|
aria-label="Search results"
|
||||||
>
|
>
|
||||||
@@ -126,7 +150,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-else-if="recentTracks.length && (!query || status === 'success')"
|
v-else-if="
|
||||||
|
recentTracks.length &&
|
||||||
|
(!query || (querySettled && status === 'success'))
|
||||||
|
"
|
||||||
class="results"
|
class="results"
|
||||||
aria-label="Recently played tracks"
|
aria-label="Recently played tracks"
|
||||||
>
|
>
|
||||||
@@ -136,6 +163,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
|
|||||||
:track="track"
|
:track="track"
|
||||||
:index="index"
|
:index="index"
|
||||||
show-image
|
show-image
|
||||||
|
touch-friendly
|
||||||
@play-album-at-index="playResult(track)"
|
@play-album-at-index="playResult(track)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -161,6 +189,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -172,4 +204,18 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
|
|||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-search {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results :deep(.track.image) {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+92
-11
@@ -7,6 +7,7 @@ export interface DropdownMenuItem {
|
|||||||
|
|
||||||
interface DropdownMenuProps {
|
interface DropdownMenuProps {
|
||||||
items: DropdownMenuItem[][]
|
items: DropdownMenuItem[][]
|
||||||
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuProps>()
|
const props = defineProps<DropdownMenuProps>()
|
||||||
@@ -15,17 +16,46 @@ const open = ref(false)
|
|||||||
const mounted = ref(false)
|
const mounted = ref(false)
|
||||||
const placement = ref<"bottom" | "top">("bottom")
|
const placement = ref<"bottom" | "top">("bottom")
|
||||||
const root = useTemplateRef("root")
|
const root = useTemplateRef("root")
|
||||||
|
const triggerRef = useTemplateRef<HTMLButtonElement>("trigger-ref")
|
||||||
|
const menuRef = useTemplateRef<HTMLDivElement>("menu-ref")
|
||||||
const menuPosition = ref({ top: 0, left: 0 })
|
const menuPosition = ref({ top: 0, left: 0 })
|
||||||
|
|
||||||
const toggle = async () => {
|
const menuItems = () =>
|
||||||
open.value = !open.value
|
Array.from(
|
||||||
|
menuRef.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
if (open.value) {
|
const focusMenuItem = (index: number) => {
|
||||||
|
const items = menuItems()
|
||||||
|
items[index]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = (restoreFocus = false) => {
|
||||||
|
open.value = false
|
||||||
|
if (restoreFocus) nextTick(() => triggerRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = async (last = false) => {
|
||||||
|
open.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
updatePlacement()
|
updatePlacement()
|
||||||
}
|
const items = menuItems()
|
||||||
|
focusMenuItem(last ? items.length - 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
if (open.value) close(true)
|
||||||
|
else await openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleTriggerKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!open.value) void openMenu(event.key === "ArrowUp")
|
||||||
|
else focusMenuItem(event.key === "ArrowUp" ? menuItems().length - 1 : 0)
|
||||||
}
|
}
|
||||||
const close = () => (open.value = false)
|
|
||||||
|
|
||||||
const updatePlacement = () => {
|
const updatePlacement = () => {
|
||||||
const el = root.value
|
const el = root.value
|
||||||
@@ -48,12 +78,34 @@ const updatePlacement = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOutsideClick = (e: MouseEvent) => {
|
const handleOutsideClick = (e: MouseEvent) => {
|
||||||
if (!root.value) return
|
const target = e.target as Node
|
||||||
if (!root.value.contains(e.target as Node)) close()
|
if (root.value?.contains(target) || menuRef.value?.contains(target)) return
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key == "Escape") close()
|
if (!open.value) return
|
||||||
|
const items = menuItems()
|
||||||
|
const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault()
|
||||||
|
close(true)
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem((activeIndex + 1 + items.length) % items.length)
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem((activeIndex - 1 + items.length) % items.length)
|
||||||
|
} else if (e.key === "Home") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem(0)
|
||||||
|
} else if (e.key === "End") {
|
||||||
|
e.preventDefault()
|
||||||
|
focusMenuItem(items.length - 1)
|
||||||
|
} else if (e.key === "Tab") {
|
||||||
|
close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReposition = () => {
|
const handleReposition = () => {
|
||||||
@@ -68,7 +120,7 @@ onMounted(() => {
|
|||||||
mounted.value = true
|
mounted.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener("click", handleOutsideClick)
|
document.removeEventListener("click", handleOutsideClick)
|
||||||
document.removeEventListener("keydown", handleKey)
|
document.removeEventListener("keydown", handleKey)
|
||||||
window.removeEventListener("resize", handleReposition)
|
window.removeEventListener("resize", handleReposition)
|
||||||
@@ -78,15 +130,26 @@ onBeforeMount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="root" class="dropdown">
|
<div ref="root" class="dropdown">
|
||||||
<div @click="toggle" class="trigger">
|
<button
|
||||||
|
ref="trigger-ref"
|
||||||
|
type="button"
|
||||||
|
class="trigger"
|
||||||
|
:aria-label="props.label ?? 'Open options'"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click="toggle"
|
||||||
|
@keydown="handleTriggerKeydown"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<Teleport v-if="mounted" to="body">
|
<Teleport v-if="mounted" to="body">
|
||||||
<div
|
<div
|
||||||
|
ref="menu-ref"
|
||||||
v-show="open"
|
v-show="open"
|
||||||
class="dropdown-menu"
|
class="dropdown-menu"
|
||||||
|
role="menu"
|
||||||
:class="placement"
|
:class="placement"
|
||||||
:style="{
|
:style="{
|
||||||
top: `${menuPosition.top}px`,
|
top: `${menuPosition.top}px`,
|
||||||
@@ -97,11 +160,13 @@ onBeforeMount(() => {
|
|||||||
v-for="(group, i) in items"
|
v-for="(group, i) in items"
|
||||||
:key="`group-${i}`"
|
:key="`group-${i}`"
|
||||||
class="group"
|
class="group"
|
||||||
|
role="group"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="(item, i) in group"
|
v-for="(item, i) in group"
|
||||||
:key="`item-${i}`"
|
:key="`item-${i}`"
|
||||||
class="item"
|
class="item"
|
||||||
|
role="menuitem"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
item.onSelect?.()
|
item.onSelect?.()
|
||||||
@@ -127,6 +192,22 @@ onBeforeMount(() => {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.trigger {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
|||||||
Reference in new issue
Block a user