Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions app/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -280,11 +280,6 @@ p > span > code,
border: 1px solid var(--border);
}

/* View transition for search box (includes / and input) */
.search-box {
view-transition-name: search-box;
}

/* Safari search input fixes */
input[type='search'] {
-webkit-appearance: none;
Expand Down Expand Up @@ -313,13 +308,6 @@ input[type='search']::-webkit-search-results-decoration {
animation: none;
}

/* Customize the view transition animations for specific elements */
::view-transition-old(search-box),
::view-transition-new(search-box) {
animation-duration: 0.3s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

/* Locking the scroll whenever any of the modals are open */
html:has(dialog:modal) {
overflow: hidden;
Expand Down
20 changes: 3 additions & 17 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,12 @@ function expandMobileSearch() {
})
}

watch(
isOnSearchPage,
visible => {
if (!visible) return

searchBoxRef.value?.focus()
nextTick(() => {
searchBoxRef.value?.focus()
})
},
{ flush: 'sync' },
)

function handleSearchBlur() {
showFullSearch.value = false
// Collapse expanded search on mobile after blur (with delay for click handling)
// Collapse expanded search on mobile after blur
// But don't collapse if we're on the search page
if (isMobile.value && !isOnSearchPage.value) {
setTimeout(() => {
isSearchExpandedManually.value = false
}, 150)
isSearchExpandedManually.value = false
}
}

Expand Down Expand Up @@ -122,6 +107,7 @@ onKeyStroke(
<div
class="flex-1 flex items-center justify-center md:gap-6"
:class="{ 'hidden sm:flex': !isSearchExpanded }"
v-if="!isOnHomePage"
>
<!-- Search bar (hidden on mobile unless expanded) -->
<HeaderSearchBox
Expand Down
72 changes: 34 additions & 38 deletions app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'
import { normalizeSearchParam } from '#shared/utils/url'

withDefaults(
Expand All @@ -16,20 +15,24 @@ const emit = defineEmits(['blur', 'focus'])
const router = useRouter()
const route = useRoute()

const isSearchFocused = shallowRef(false)

const showSearchBar = computed(() => {
return route.name !== 'index'
})

// Local input value (updates immediately as user types)
const searchQuery = shallowRef(normalizeSearchParam(route.query.q))
const searchQuery = shallowRef('')
watch(
() => route.query.q,
query => {
searchQuery.value = normalizeSearchParam(query)
},
{ immediate: true },
)

// Pages that have their own local filter using ?q
const pagesWithLocalFilter = new Set(['~username', 'org'])

// Debounced URL update for search query
const updateUrlQuery = debounce((value: string) => {
const updateUrlQuery = (value: string) => {
// Don't navigate away from pages that use ?q for local filtering
if (pagesWithLocalFilter.has(route.name as string)) {
return
Expand All @@ -48,37 +51,24 @@ const updateUrlQuery = debounce((value: string) => {
q: value,
},
})
}, 250)

// Watch input and debounce URL updates
watch(searchQuery, value => {
updateUrlQuery(value)
})

// Sync input with URL when navigating (e.g., back button)
watch(
() => route.query.q,
urlQuery => {
// Don't sync from pages that use ?q for local filtering
if (pagesWithLocalFilter.has(route.name as string)) {
return
}
const value = normalizeSearchParam(urlQuery)
if (searchQuery.value !== value) {
searchQuery.value = value
}
},
)
}

function handleSearchBlur() {
isSearchFocused.value = false
emit('blur')
}
function handleSearchFocus() {
isSearchFocused.value = true
emit('focus')
}

const inputRef = useTemplateRef('inputRef')
function focus() {
const input = inputRef.value
if (input) {
input.focus()
input.setSelectionRange(input.value.length, input.value.length)
}
}

function handleSubmit() {
if (pagesWithLocalFilter.has(route.name as string)) {
router.push({
Expand All @@ -88,15 +78,15 @@ function handleSubmit() {
},
})
} else {
updateUrlQuery.flush()
updateUrlQuery(searchQuery.value)
}

focus()
}

onMounted(focus)

// Expose focus method for parent components
const inputRef = useTemplateRef('inputRef')
function focus() {
inputRef.value?.focus()
}
defineExpose({ focus })
</script>
<template>
Expand All @@ -106,8 +96,10 @@ defineExpose({ focus })
{{ $t('search.label') }}
</label>

<div class="relative group" :class="{ 'is-focused': isSearchFocused }">
<div class="search-box relative flex items-center">
<div
class="relative group bg-bg-subtle border border-border rounded-md transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus-within:border-accent focus-visible:(outline-2 outline-accent/70)"
>
<div class="flex items-center">
<span
class="absolute inset-is-3 text-fg-subtle font-mono text-sm pointer-events-none transition-colors duration-200 motion-reduce:transition-none [.group:hover:not(:focus-within)_&]:text-fg/80 group-focus-within:text-accent z-1"
>
Expand All @@ -122,11 +114,15 @@ defineExpose({ focus })
name="q"
:placeholder="$t('search.placeholder')"
v-bind="noCorrect"
class="w-full min-w-25 bg-bg-subtle border border-border rounded-md ps-7 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)"
class="w-full ps-7 pe-3 py-1.5 font-mono text-sm text-fg placeholder:text-fg-subtle outline-none"
@focus="handleSearchFocus"
@blur="handleSearchBlur"
/>
<button type="submit" class="sr-only">{{ $t('search.button') }}</button>

<button type="submit" class="flex items-center justify-center p-2">
<span class="sr-only">{{ $t('search.button') }}</span>
<span class="i-carbon-search w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</form>
Expand Down
35 changes: 13 additions & 22 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,20 @@
<script setup lang="ts">
import { debounce } from 'perfect-debounce'
import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'

const searchQuery = shallowRef('')
const searchInputRef = useTemplateRef('searchInputRef')
const { focused: isSearchFocused } = useFocus(searchInputRef)

async function search() {
const query = searchQuery.value.trim()
if (!query) return
await navigateTo({
path: '/search',
query: query ? { q: query } : undefined,
})
const newQuery = searchQuery.value.trim()
if (newQuery !== query) {
await search()
if (!searchQuery.value) {
return
}
}

const handleInput = isTouchDevice()
? search
: debounce(search, 250, { leading: true, trailing: true })
navigateTo({
name: 'search',
query: {
q: searchQuery.value,
},
})
}

useSeoMeta({
title: () => $t('seo.home.title'),
Expand Down Expand Up @@ -66,17 +59,17 @@ defineOgImageComponent('Default', {
class="w-full max-w-xl motion-safe:animate-slide-up motion-safe:animate-fill-both"
style="animation-delay: 0.2s"
>
<form method="GET" action="/search" class="relative" @submit.prevent.trim="search">
<form method="GET" action="/search" class="relative" @submit.prevent="search">
<label for="home-search" class="sr-only">
{{ $t('search.label') }}
</label>

<div class="relative group" :class="{ 'is-focused': isSearchFocused }">
<div class="relative group">
<div
class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100"
/>
Comment on lines +67 to 70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Gradient glow effect is now inert — is-focused class is never applied.

The group-[.is-focused]:opacity-100 on line 69 depends on the parent .group div having an is-focused class. Since the dynamic class binding that previously toggled is-focused has been removed from line 67, this gradient overlay will permanently stay at opacity-0. Either remove the dead overlay div, or replace the trigger with a CSS-only approach (e.g. group-focus-within:opacity-100).

Option: use CSS focus-within instead
               class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100"
+              class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-focus-within:opacity-100"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="relative group">
<div
class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-[.is-focused]:opacity-100"
/>
<div class="relative group">
<div
class="absolute -inset-px rounded-lg bg-gradient-to-r from-fg/0 via-fg/5 to-fg/0 opacity-0 transition-opacity duration-500 blur-sm group-focus-within:opacity-100"
/>


<div class="search-box relative flex items-center">
<div class="relative flex items-center">
<span
class="absolute inset-is-4 text-fg-subtle font-mono text-lg pointer-events-none transition-colors duration-200 motion-reduce:transition-none [.group:hover:not(:focus-within)_&]:text-fg/80 group-focus-within:text-accent z-1"
>
Expand All @@ -85,15 +78,13 @@ defineOgImageComponent('Default', {

<input
id="home-search"
ref="searchInputRef"
v-model="searchQuery"
v-model.trim="searchQuery"
type="search"
name="q"
autofocus
:placeholder="$t('search.placeholder')"
v-bind="noCorrect"
class="w-full bg-bg-subtle border border-border rounded-xl ps-8 pe-24 h-14 py-4 font-mono text-base text-fg placeholder:text-fg-subtle transition-[border-color,outline-color] duration-300 motion-reduce:transition-none hover:border-fg-subtle outline-2 outline-transparent focus:border-accent focus-visible:(outline-2 outline-accent/70)"
@input="handleInput"
/>

<button
Expand Down
35 changes: 0 additions & 35 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,6 @@ const updateUrlPage = debounce((page: number) => {
// The actual search query (from URL, used for API calls)
const query = computed(() => normalizeSearchParam(route.query.q))

// Track if page just loaded (for hiding "Searching..." during view transition)
const hasInteracted = shallowRef(false)
onMounted(() => {
// Small delay to let view transition complete
setTimeout(() => {
hasInteracted.value = true
}, 300)
})

// Infinite scroll / pagination state
const pageSize = 25
const currentPage = shallowRef(1)
Expand Down Expand Up @@ -168,8 +159,6 @@ function handleClearFilter(chip: FilterChip) {

// Should we show the loading spinner?
const showSearching = computed(() => {
// Don't show during initial page load (view transition)
if (!hasInteracted.value) return false
// Show if pending and no results yet
return status.value === 'pending' && displayResults.value.length === 0
})
Expand All @@ -190,7 +179,6 @@ function handlePageChange(page: number) {
// Reset page when query changes
watch(query, () => {
currentPage.value = 1
hasInteracted.value = true
})

// Check if current query could be a valid package name
Expand Down Expand Up @@ -552,29 +540,6 @@ async function navigateToPackage(packageName: string) {
// Track the input value when user pressed Enter (for navigating when results arrive)
const pendingEnterQuery = shallowRef<string | null>(null)

// Watch for results to navigate when Enter was pressed before results arrived
watch(displayResults, results => {
if (!pendingEnterQuery.value) return

// Check if input is still focused (user hasn't started navigating or clicked elsewhere)
if (document.activeElement?.tagName !== 'INPUT') {
pendingEnterQuery.value = null
return
}

// Navigate if first result matches the query that was entered
const firstResult = results[0]
// eslint-disable-next-line no-console
console.log('[search] watcher fired', {
pending: pendingEnterQuery.value,
firstResult: firstResult?.package.name,
})
if (firstResult?.package.name === pendingEnterQuery.value) {
pendingEnterQuery.value = null
navigateToPackage(firstResult.package.name)
}
})

function handleResultsKeydown(e: KeyboardEvent) {
// If the active element is an input, navigate to exact match or wait for results
if (e.key === 'Enter' && document.activeElement?.tagName === 'INPUT') {
Expand Down
1 change: 0 additions & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ export default defineNuxtConfig({
experimental: {
entryImportMap: false,
viteEnvironmentApi: true,
viewTransition: true,
typedPages: true,
},

Expand Down
Loading