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
+244 -24

No files matched your search

+59 -1
View File
@@ -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>
+4 -1
View File
@@ -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>
+33 -1
View File
@@ -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
View File
@@ -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>
+94 -13
View File
@@ -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) => {
await nextTick() const items = menuItems()
updatePlacement() 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 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;