Improved mobile search experience

This commit is contained in:
lucast committed 2026-10-06 04:08:50 +02:00
1 parent 1fce02ad84
commit ce0bf50223
5 files changed
+241 -21

No files matched your search

+59 -1
View File
@@ -30,7 +30,16 @@ const entryType = computed(() =>
<template>
<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
v-if="artworkSrc"
:src="artworkSrc"
@@ -93,6 +102,10 @@ const entryType = computed(() =>
justify-content: center;
border-radius: $border-radius-standard;
cursor: pointer;
border: 0;
padding: 0;
color: inherit;
background: transparent;
img {
max-width: 100%;
@@ -140,4 +153,49 @@ const entryType = computed(() =>
.artists {
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>
+4 -1
View File
@@ -112,7 +112,10 @@ const visibleDropdownMenuItems = computed(() =>
<ArtistName :artists="track.artists" class="artists" />
</div>
<DropdownMenu :items="visibleDropdownMenuItems">
<DropdownMenu
:items="visibleDropdownMenuItems"
:label="`Options for ${track.name}`"
>
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
+33 -1
View File
@@ -15,6 +15,7 @@ interface TrackRowProps {
extendedInfo?: boolean
showImage?: boolean
queueMode?: boolean
touchFriendly?: boolean
}
const props = defineProps<TrackRowProps>()
@@ -140,7 +141,21 @@ const artworkSrc = computed(() => {
v-if="props.track"
name="lucide:play"
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')"
@keydown.enter.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
@keydown.space.prevent="
props.touchFriendly && emit('playAlbumAtIndex')
"
/>
<!-- Keep the existing TrackRow artwork dimensions unchanged. -->
<NuxtImg
@@ -194,7 +209,11 @@ const artworkSrc = computed(() => {
class="queue-handle"
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" />
</DropdownMenu>
</article>
@@ -274,6 +293,10 @@ const artworkSrc = computed(() => {
opacity: 0;
z-index: 1;
font-size: 24px;
&:focus-visible {
opacity: 1;
}
}
.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 {
opacity: 1;
}
+54 -8
View File
@@ -12,16 +12,20 @@ interface SearchResult {
}
const route = useRoute()
const router = useRouter()
const { currentSong, playAlbum } = usePlayer()
const query = computed(() => {
const value = route.query.q
const firstValue = Array.isArray(value) ? value[0] : value
return (firstValue ?? "").trim()
})
const searchInput = ref(query.value)
const debouncedQuery = ref(query.value)
const querySettled = computed(() => query.value === debouncedQuery.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
watch(query, (value) => {
searchInput.value = value
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => {
debouncedQuery.value = value
@@ -29,9 +33,14 @@ watch(query, (value) => {
}, 300)
})
onBeforeUnmount(() => {
clearTimeout(debounceTimeout)
})
const updateSearchRoute = (value: string) => {
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", {
query: { q: debouncedQuery, limit: 9 },
@@ -87,15 +96,30 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
<template>
<main class="search-page">
<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…
</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.
</p>
<p
v-else-if="query && status === 'success' && results.length === 0"
v-else-if="query && querySettled && status === 'success' && results.length === 0"
class="message"
>
No results for “{{ query }}”.
@@ -109,7 +133,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
</template>
<section
v-if="query && results.length"
v-if="query && querySettled && status === 'success' && results.length"
class="results"
aria-label="Search results"
>
@@ -126,7 +150,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
</section>
<section
v-else-if="recentTracks.length && (!query || status === 'success')"
v-else-if="
recentTracks.length &&
(!query || (querySettled && status === 'success'))
"
class="results"
aria-label="Recently played tracks"
>
@@ -136,6 +163,7 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
:track="track"
:index="index"
show-image
touch-friendly
@play-album-at-index="playResult(track)"
/>
</section>
@@ -161,6 +189,10 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
}
}
.mobile-search {
display: none;
}
.results {
display: flex;
flex-direction: column;
@@ -172,4 +204,18 @@ useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search
opacity: 0.65;
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>
+91 -10
View File
@@ -7,6 +7,7 @@ export interface DropdownMenuItem {
interface DropdownMenuProps {
items: DropdownMenuItem[][]
label?: string
}
const props = defineProps<DropdownMenuProps>()
@@ -15,17 +16,46 @@ const open = ref(false)
const mounted = ref(false)
const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root")
const triggerRef = useTemplateRef<HTMLButtonElement>("trigger-ref")
const menuRef = useTemplateRef<HTMLDivElement>("menu-ref")
const menuPosition = ref({ top: 0, left: 0 })
const toggle = async () => {
open.value = !open.value
const menuItems = () =>
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()
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 el = root.value
@@ -48,12 +78,34 @@ const updatePlacement = () => {
}
const handleOutsideClick = (e: MouseEvent) => {
if (!root.value) return
if (!root.value.contains(e.target as Node)) close()
const target = e.target as Node
if (root.value?.contains(target) || menuRef.value?.contains(target)) return
close()
}
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 = () => {
@@ -68,7 +120,7 @@ onMounted(() => {
mounted.value = true
})
onBeforeMount(() => {
onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick)
document.removeEventListener("keydown", handleKey)
window.removeEventListener("resize", handleReposition)
@@ -78,15 +130,26 @@ onBeforeMount(() => {
<template>
<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 />
</div>
</button>
<Transition name="dropdown">
<Teleport v-if="mounted" to="body">
<div
ref="menu-ref"
v-show="open"
class="dropdown-menu"
role="menu"
:class="placement"
:style="{
top: `${menuPosition.top}px`,
@@ -97,11 +160,13 @@ onBeforeMount(() => {
v-for="(group, i) in items"
:key="`group-${i}`"
class="group"
role="group"
>
<button
v-for="(item, i) in group"
:key="`item-${i}`"
class="item"
role="menuitem"
@click="
() => {
item.onSelect?.()
@@ -127,6 +192,22 @@ onBeforeMount(() => {
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 {
position: fixed;
z-index: 1000;