Skip to content

Commit 6eed63a

Browse files
authored
Search revamp (#6)
1 parent 395a849 commit 6eed63a

File tree

9 files changed

+1045
-0
lines changed

9 files changed

+1045
-0
lines changed

astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export default defineConfig({
9999
components: {
100100
Head: './src/components/Head.astro',
101101
Header: './src/components/Header.astro',
102+
Search: './src/components/Search.astro',
102103
ThemeSelect: './src/components/ThemeSelect.astro',
103104
PageTitle: './src/components/PageTitle.astro',
104105
SiteTitle: './src/components/SiteTitle.astro',

src/components/Search.astro

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
---
2+
import type { Props } from '@astrojs/starlight/props';
3+
import SearchDialogWrapper from './react/SearchDialogWrapper';
4+
---
5+
6+
<site-search data-translations={JSON.stringify({})}>
7+
<button data-open-modal class="search-trigger" aria-label="Search">
8+
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
9+
<circle cx="11" cy="11" r="8" />
10+
<path d="m21 21-4.35-4.35" />
11+
</svg>
12+
<span class="search-label">Search</span>
13+
<kbd class="search-kbd">
14+
<span class="sr-only">Press </span>
15+
<span>⌘</span>
16+
<span>K</span>
17+
</kbd>
18+
</button>
19+
20+
<SearchDialogWrapper client:load />
21+
</site-search>
22+
23+
<script>
24+
class SiteSearch extends HTMLElement {
25+
constructor() {
26+
super();
27+
28+
const openBtn = this.querySelector('[data-open-modal]');
29+
30+
openBtn?.addEventListener('click', () => {
31+
this.dispatchEvent(new CustomEvent('open-search', { bubbles: true }));
32+
});
33+
34+
// Handle keyboard shortcut
35+
document.addEventListener('keydown', (e) => {
36+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
37+
e.preventDefault();
38+
this.dispatchEvent(new CustomEvent('open-search', { bubbles: true }));
39+
}
40+
});
41+
}
42+
}
43+
44+
customElements.define('site-search', SiteSearch);
45+
</script>
46+
47+
<style>
48+
site-search {
49+
display: contents;
50+
}
51+
52+
.search-trigger {
53+
display: flex;
54+
align-items: center;
55+
gap: 0.5rem;
56+
padding: 0.375rem 0.75rem;
57+
background: var(--sl-color-gray-6);
58+
border: 1px solid var(--sl-color-hairline);
59+
border-radius: 0.375rem;
60+
color: var(--sl-color-gray-2);
61+
font-size: 0.875rem;
62+
cursor: pointer;
63+
transition: all 0.15s ease;
64+
margin-left: auto;
65+
}
66+
67+
.search-trigger:hover {
68+
background: var(--sl-color-gray-5);
69+
border-color: var(--sl-color-gray-4);
70+
}
71+
72+
.search-icon {
73+
width: 1rem;
74+
height: 1rem;
75+
opacity: 0.7;
76+
}
77+
78+
.search-label {
79+
display: none;
80+
}
81+
82+
@media (min-width: 640px) {
83+
.search-label {
84+
display: inline;
85+
}
86+
}
87+
88+
.search-kbd {
89+
display: none;
90+
align-items: center;
91+
gap: 0.125rem;
92+
padding: 0.125rem 0.375rem;
93+
margin-left: 0.5rem;
94+
font-size: 0.625rem;
95+
font-family: var(--sl-font-system-mono);
96+
color: var(--sl-color-gray-3);
97+
background: var(--sl-color-gray-5);
98+
border-radius: 0.25rem;
99+
}
100+
101+
@media (min-width: 640px) {
102+
.search-kbd {
103+
display: inline-flex;
104+
}
105+
}
106+
107+
.sr-only {
108+
position: absolute;
109+
width: 1px;
110+
height: 1px;
111+
padding: 0;
112+
margin: -1px;
113+
overflow: hidden;
114+
clip: rect(0, 0, 0, 0);
115+
white-space: nowrap;
116+
border-width: 0;
117+
}
118+
</style>
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import React, {
2+
useRef,
3+
useState,
4+
useEffect,
5+
useCallback,
6+
type ReactNode,
7+
type MouseEventHandler,
8+
type UIEvent,
9+
} from 'react'
10+
import { motion, useInView } from 'motion/react'
11+
import { cn } from '@/lib/utils'
12+
13+
interface AnimatedItemProps {
14+
children: ReactNode
15+
delay?: number
16+
index: number
17+
onMouseEnter?: MouseEventHandler<HTMLDivElement>
18+
onClick?: MouseEventHandler<HTMLDivElement>
19+
}
20+
21+
export const AnimatedItem: React.FC<AnimatedItemProps> = ({
22+
children,
23+
delay = 0,
24+
index,
25+
onMouseEnter,
26+
onClick,
27+
}) => {
28+
const ref = useRef<HTMLDivElement>(null)
29+
const inView = useInView(ref, { amount: 0.5, once: false })
30+
31+
return (
32+
<motion.div
33+
ref={ref}
34+
data-index={index}
35+
onMouseEnter={onMouseEnter}
36+
onClick={onClick}
37+
initial={{ scale: 0.8, opacity: 0, y: 15 }}
38+
animate={inView ? { scale: 1, opacity: 1, y: 0 } : { scale: 0.8, opacity: 0, y: 15 }}
39+
transition={{
40+
type: 'spring',
41+
stiffness: 300,
42+
damping: 20,
43+
delay,
44+
}}
45+
className="cursor-pointer"
46+
>
47+
{children}
48+
</motion.div>
49+
)
50+
}
51+
52+
export interface AnimatedListItem {
53+
id: string
54+
content: ReactNode
55+
}
56+
57+
interface AnimatedListProps {
58+
/** Array of items to display - can be strings or objects with id and content */
59+
items: (string | AnimatedListItem)[]
60+
/** Callback when an item is selected */
61+
onItemSelect?: (item: string | AnimatedListItem, index: number) => void
62+
/** Show gradient overlays at top/bottom when scrollable */
63+
showGradients?: boolean
64+
/** Enable keyboard navigation (arrow keys, tab, enter) */
65+
enableArrowNavigation?: boolean
66+
/** Additional class for the container */
67+
className?: string
68+
/** Additional class for each item wrapper */
69+
itemClassName?: string
70+
/** Show custom scrollbar */
71+
displayScrollbar?: boolean
72+
/** Initial selected index (-1 for none) */
73+
initialSelectedIndex?: number
74+
/** Custom render function for items */
75+
renderItem?: (
76+
item: string | AnimatedListItem,
77+
index: number,
78+
isSelected: boolean
79+
) => ReactNode
80+
}
81+
82+
export const AnimatedList: React.FC<AnimatedListProps> = ({
83+
items = [],
84+
onItemSelect,
85+
showGradients = true,
86+
enableArrowNavigation = true,
87+
className = '',
88+
itemClassName = '',
89+
displayScrollbar = true,
90+
initialSelectedIndex = -1,
91+
renderItem,
92+
}) => {
93+
const listRef = useRef<HTMLDivElement>(null)
94+
const [selectedIndex, setSelectedIndex] = useState<number>(initialSelectedIndex)
95+
const [keyboardNav, setKeyboardNav] = useState<boolean>(false)
96+
const [topGradientOpacity, setTopGradientOpacity] = useState<number>(0)
97+
const [bottomGradientOpacity, setBottomGradientOpacity] = useState<number>(1)
98+
99+
const handleItemMouseEnter = useCallback((index: number) => {
100+
setSelectedIndex(index)
101+
setKeyboardNav(false)
102+
}, [])
103+
104+
const handleItemClick = useCallback(
105+
(item: string | AnimatedListItem, index: number) => {
106+
setSelectedIndex(index)
107+
onItemSelect?.(item, index)
108+
},
109+
[onItemSelect]
110+
)
111+
112+
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
113+
const { scrollTop, scrollHeight, clientHeight } = e.target as HTMLDivElement
114+
setTopGradientOpacity(Math.min(scrollTop / 50, 1))
115+
const bottomDistance = scrollHeight - (scrollTop + clientHeight)
116+
setBottomGradientOpacity(
117+
scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1)
118+
)
119+
}
120+
121+
// Keyboard navigation
122+
useEffect(() => {
123+
if (!enableArrowNavigation) return
124+
125+
const handleKeyDown = (e: KeyboardEvent) => {
126+
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
127+
e.preventDefault()
128+
setKeyboardNav(true)
129+
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1))
130+
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
131+
e.preventDefault()
132+
setKeyboardNav(true)
133+
setSelectedIndex((prev) => Math.max(prev - 1, 0))
134+
} else if (e.key === 'Enter') {
135+
if (selectedIndex >= 0 && selectedIndex < items.length) {
136+
e.preventDefault()
137+
onItemSelect?.(items[selectedIndex], selectedIndex)
138+
}
139+
}
140+
}
141+
142+
window.addEventListener('keydown', handleKeyDown)
143+
return () => window.removeEventListener('keydown', handleKeyDown)
144+
}, [items, selectedIndex, onItemSelect, enableArrowNavigation])
145+
146+
// Scroll selected item into view
147+
useEffect(() => {
148+
if (!keyboardNav || selectedIndex < 0 || !listRef.current) return
149+
150+
const container = listRef.current
151+
const selectedItem = container.querySelector(
152+
`[data-index="${selectedIndex}"]`
153+
) as HTMLElement | null
154+
155+
if (selectedItem) {
156+
const extraMargin = 50
157+
const containerScrollTop = container.scrollTop
158+
const containerHeight = container.clientHeight
159+
const itemTop = selectedItem.offsetTop
160+
const itemBottom = itemTop + selectedItem.offsetHeight
161+
162+
if (itemTop < containerScrollTop + extraMargin) {
163+
container.scrollTo({ top: itemTop - extraMargin, behavior: 'smooth' })
164+
} else if (itemBottom > containerScrollTop + containerHeight - extraMargin) {
165+
container.scrollTo({
166+
top: itemBottom - containerHeight + extraMargin,
167+
behavior: 'smooth',
168+
})
169+
}
170+
}
171+
setKeyboardNav(false)
172+
}, [selectedIndex, keyboardNav])
173+
174+
// Check initial scroll state
175+
useEffect(() => {
176+
if (!listRef.current) return
177+
const { scrollHeight, clientHeight } = listRef.current
178+
setBottomGradientOpacity(scrollHeight <= clientHeight ? 0 : 1)
179+
}, [items])
180+
181+
const getItemContent = (item: string | AnimatedListItem): ReactNode => {
182+
if (typeof item === 'string') return item
183+
return item.content
184+
}
185+
186+
const getItemId = (item: string | AnimatedListItem, index: number): string => {
187+
if (typeof item === 'string') return `item-${index}`
188+
return item.id
189+
}
190+
191+
const defaultRenderItem = (
192+
item: string | AnimatedListItem,
193+
index: number,
194+
isSelected: boolean
195+
) => (
196+
<div
197+
className={cn(
198+
'px-3 py-2 rounded-md transition-colors',
199+
'border border-transparent',
200+
isSelected
201+
? 'bg-accent text-accent-foreground border-border'
202+
: 'hover:bg-muted',
203+
itemClassName
204+
)}
205+
>
206+
{typeof item === 'string' ? (
207+
<p className="m-0 text-sm">{item}</p>
208+
) : (
209+
item.content
210+
)}
211+
</div>
212+
)
213+
214+
return (
215+
<div className={cn('relative w-full', className)}>
216+
<div
217+
ref={listRef}
218+
className={cn(
219+
'max-h-[400px] overflow-y-auto p-2 space-y-1',
220+
displayScrollbar
221+
? '[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-thumb]:rounded-full'
222+
: 'scrollbar-hide'
223+
)}
224+
onScroll={handleScroll}
225+
style={{
226+
scrollbarWidth: displayScrollbar ? 'thin' : 'none',
227+
scrollbarColor: 'var(--border) transparent',
228+
}}
229+
>
230+
{items.map((item, index) => (
231+
<AnimatedItem
232+
key={getItemId(item, index)}
233+
delay={0.05}
234+
index={index}
235+
onMouseEnter={() => handleItemMouseEnter(index)}
236+
onClick={() => handleItemClick(item, index)}
237+
>
238+
{renderItem
239+
? renderItem(item, index, selectedIndex === index)
240+
: defaultRenderItem(item, index, selectedIndex === index)}
241+
</AnimatedItem>
242+
))}
243+
</div>
244+
245+
{showGradients && (
246+
<>
247+
<div
248+
className="absolute top-0 left-0 right-0 h-12 bg-gradient-to-b from-background to-transparent pointer-events-none transition-opacity duration-300"
249+
style={{ opacity: topGradientOpacity }}
250+
/>
251+
<div
252+
className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none transition-opacity duration-300"
253+
style={{ opacity: bottomGradientOpacity }}
254+
/>
255+
</>
256+
)}
257+
</div>
258+
)
259+
}
260+
261+
export default AnimatedList

0 commit comments

Comments
 (0)