Skip to content

Commit d6b25e3

Browse files
committed
feat: add search function to the docs
1 parent e828d51 commit d6b25e3

14 files changed

Lines changed: 538 additions & 40 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
@use '../../variables' as *;
2+
3+
.command-palette {
4+
position: fixed;
5+
top: 15%;
6+
left: 50%;
7+
transform: translateX(-50%);
8+
width: 640px;
9+
max-height: 520px;
10+
background: var(--ty-color-bg-elevated);
11+
border: 1px solid var(--ty-color-border-secondary);
12+
border-radius: 12px;
13+
box-shadow:
14+
0 16px 70px rgba(0, 0, 0, 0.15),
15+
0 0 0 1px rgba(0, 0, 0, 0.05);
16+
display: flex;
17+
flex-direction: column;
18+
overflow: hidden;
19+
z-index: 1002;
20+
21+
&__input-wrapper {
22+
padding: 12px 12px 0;
23+
}
24+
25+
&__input.ty-input {
26+
width: 100%;
27+
border-radius: 8px;
28+
}
29+
30+
&__results {
31+
overflow-y: auto;
32+
flex: 1;
33+
padding: 8px;
34+
}
35+
36+
&__group {
37+
&:not(:first-child) {
38+
margin-top: 8px;
39+
}
40+
}
41+
42+
&__group-label {
43+
padding: 8px 12px 4px;
44+
font-size: 12px;
45+
font-weight: 600;
46+
color: var(--ty-color-text-tertiary);
47+
text-transform: uppercase;
48+
letter-spacing: 0.05em;
49+
}
50+
51+
&__item {
52+
display: flex;
53+
align-items: center;
54+
justify-content: space-between;
55+
padding: 8px 12px;
56+
border-radius: 8px;
57+
cursor: pointer;
58+
transition: background 0.1s;
59+
60+
&_active {
61+
background: var(--ty-color-fill-secondary);
62+
}
63+
}
64+
65+
&__item-title {
66+
font-size: 14px;
67+
color: var(--ty-color-text);
68+
69+
.command-palette__item_active & {
70+
color: var(--ty-color-primary);
71+
}
72+
}
73+
74+
&__item-path {
75+
font-size: 12px;
76+
color: var(--ty-color-text-tertiary);
77+
margin-left: 12px;
78+
flex-shrink: 0;
79+
}
80+
81+
&__empty {
82+
padding: 32px 16px;
83+
text-align: center;
84+
color: var(--ty-color-text-tertiary);
85+
font-size: 14px;
86+
}
87+
88+
@media (max-width: $size-xs) {
89+
width: calc(100% - 32px);
90+
top: 10%;
91+
}
92+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+
import { useNavigate } from 'react-router-dom';
3+
import { Input, Overlay } from '@tiny-design/react';
4+
import { useLocaleContext } from '../../context/locale-context';
5+
import { getGuideMenu, getThemeMenu, getComponentMenu, RouterItem } from '../../routers';
6+
import './command-palette.scss';
7+
8+
interface SearchItem {
9+
title: string;
10+
route: string;
11+
section: string;
12+
fullPath: string;
13+
}
14+
15+
interface CommandPaletteProps {
16+
open: boolean;
17+
onOpenChange: (open: boolean) => void;
18+
}
19+
20+
function flattenMenuItems(
21+
items: RouterItem[],
22+
section: string,
23+
pathPrefix: string,
24+
): SearchItem[] {
25+
const result: SearchItem[] = [];
26+
for (const item of items) {
27+
if (item.children) {
28+
for (const child of item.children) {
29+
if (child.route) {
30+
result.push({
31+
title: child.title,
32+
route: child.route,
33+
section: item.title,
34+
fullPath: `${pathPrefix}/${child.route}`,
35+
});
36+
}
37+
}
38+
} else if (item.route) {
39+
result.push({
40+
title: item.title,
41+
route: item.route,
42+
section,
43+
fullPath: `${pathPrefix}/${item.route}`,
44+
});
45+
}
46+
}
47+
return result;
48+
}
49+
50+
export const CommandPalette = ({ open, onOpenChange }: CommandPaletteProps): React.ReactElement => {
51+
const [query, setQuery] = useState('');
52+
const [activeIndex, setActiveIndex] = useState(0);
53+
const inputRef = useRef<HTMLInputElement>(null);
54+
const resultListRef = useRef<HTMLDivElement>(null);
55+
const navigate = useNavigate();
56+
const { siteLocale } = useLocaleContext();
57+
58+
const searchIndex = useMemo(() => {
59+
const guideItems = flattenMenuItems(getGuideMenu(siteLocale), siteLocale.nav.guide, '/guide');
60+
const themeItems = flattenMenuItems(getThemeMenu(siteLocale), siteLocale.nav.theme, '/theme');
61+
const componentItems = flattenMenuItems(
62+
getComponentMenu(siteLocale),
63+
siteLocale.nav.components,
64+
'/components',
65+
);
66+
return [...guideItems, ...themeItems, ...componentItems];
67+
}, [siteLocale]);
68+
69+
const filteredItems = useMemo(() => {
70+
if (!query.trim()) return searchIndex;
71+
const q = query.toLowerCase();
72+
return searchIndex.filter((item) => item.title.toLowerCase().includes(q));
73+
}, [query, searchIndex]);
74+
75+
const groupedItems = useMemo(() => {
76+
const groups: { section: string; items: SearchItem[] }[] = [];
77+
for (const item of filteredItems) {
78+
const last = groups[groups.length - 1];
79+
if (last && last.section === item.section) {
80+
last.items.push(item);
81+
} else {
82+
groups.push({ section: item.section, items: [item] });
83+
}
84+
}
85+
return groups;
86+
}, [filteredItems]);
87+
88+
const close = useCallback(() => {
89+
onOpenChange(false);
90+
}, [onOpenChange]);
91+
92+
const selectItem = useCallback(
93+
(item: SearchItem) => {
94+
navigate(item.fullPath);
95+
close();
96+
},
97+
[navigate, close],
98+
);
99+
100+
// Reset state when closing
101+
useEffect(() => {
102+
if (!open) {
103+
setQuery('');
104+
setActiveIndex(0);
105+
}
106+
}, [open]);
107+
108+
// Reset active index when query changes
109+
useEffect(() => {
110+
setActiveIndex(0);
111+
}, [query]);
112+
113+
// Global Cmd/Ctrl+K shortcut
114+
useEffect(() => {
115+
const handleKeyDown = (e: KeyboardEvent) => {
116+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
117+
e.preventDefault();
118+
onOpenChange(!open);
119+
}
120+
};
121+
document.addEventListener('keydown', handleKeyDown);
122+
return () => document.removeEventListener('keydown', handleKeyDown);
123+
}, [open, onOpenChange]);
124+
125+
// Scroll active item into view
126+
useEffect(() => {
127+
if (!resultListRef.current) return;
128+
const activeEl = resultListRef.current.querySelector('[data-active="true"]');
129+
if (activeEl) {
130+
activeEl.scrollIntoView({ block: 'nearest' });
131+
}
132+
}, [activeIndex]);
133+
134+
const handleInputKeyDown = (e: React.KeyboardEvent) => {
135+
const total = filteredItems.length;
136+
if (total === 0) return;
137+
138+
switch (e.key) {
139+
case 'ArrowDown':
140+
e.preventDefault();
141+
setActiveIndex((prev) => (prev + 1) % total);
142+
break;
143+
case 'ArrowUp':
144+
e.preventDefault();
145+
setActiveIndex((prev) => (prev - 1 + total) % total);
146+
break;
147+
case 'Enter':
148+
e.preventDefault();
149+
if (filteredItems[activeIndex]) {
150+
selectItem(filteredItems[activeIndex]);
151+
}
152+
break;
153+
case 'Escape':
154+
e.preventDefault();
155+
close();
156+
break;
157+
}
158+
};
159+
160+
const handleEntered = () => {
161+
inputRef.current?.focus();
162+
};
163+
164+
let flatIndex = 0;
165+
166+
return (
167+
<Overlay isShow={open} clickCallback={close} onEntered={handleEntered} zIndex={1001}>
168+
<div className="command-palette" onClick={(e) => e.stopPropagation()}>
169+
<div className="command-palette__input-wrapper">
170+
<Input
171+
ref={inputRef}
172+
className="command-palette__input"
173+
value={query}
174+
onChange={(e) => setQuery(e.target.value)}
175+
onKeyDown={handleInputKeyDown}
176+
placeholder={siteLocale.commandPalette.placeholder}
177+
size="lg"
178+
prefix={
179+
<svg
180+
width="18"
181+
height="18"
182+
viewBox="0 0 24 24"
183+
fill="none"
184+
stroke="currentColor"
185+
strokeWidth="2"
186+
strokeLinecap="round"
187+
strokeLinejoin="round">
188+
<circle cx="11" cy="11" r="8" />
189+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
190+
</svg>
191+
}
192+
/>
193+
</div>
194+
<div className="command-palette__results" ref={resultListRef}>
195+
{filteredItems.length === 0 ? (
196+
<div className="command-palette__empty">{siteLocale.commandPalette.noResults}</div>
197+
) : (
198+
groupedItems.map((group) => (
199+
<div key={group.section} className="command-palette__group">
200+
<div className="command-palette__group-label">{group.section}</div>
201+
{group.items.map((item) => {
202+
const idx = flatIndex++;
203+
const isActive = idx === activeIndex;
204+
return (
205+
<div
206+
key={item.fullPath}
207+
className={`command-palette__item${isActive ? ' command-palette__item_active' : ''}`}
208+
data-active={isActive}
209+
onMouseEnter={() => setActiveIndex(idx)}
210+
onClick={() => selectItem(item)}>
211+
<span className="command-palette__item-title">{item.title}</span>
212+
<span className="command-palette__item-path">{item.fullPath}</span>
213+
</div>
214+
);
215+
})}
216+
</div>
217+
))
218+
)}
219+
</div>
220+
</div>
221+
</Overlay>
222+
);
223+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react';
2+
import { Keyboard } from '@tiny-design/react';
3+
4+
type SearchTriggerProps = {
5+
onClick: () => void;
6+
};
7+
8+
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
9+
10+
export const SearchTrigger = ({ onClick }: SearchTriggerProps): React.ReactElement => {
11+
return (
12+
<button className="header__search-trigger" onClick={onClick} aria-label="Search docs">
13+
<svg
14+
width="14"
15+
height="14"
16+
viewBox="0 0 24 24"
17+
fill="none"
18+
stroke="currentColor"
19+
strokeWidth="2.5"
20+
strokeLinecap="round"
21+
strokeLinejoin="round">
22+
<circle cx="11" cy="11" r="8" />
23+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
24+
</svg>
25+
<Keyboard>{isMac ? '⌘ + K' : 'Ctrl + K'}</Keyboard>
26+
</button>
27+
);
28+
};

apps/docs/src/components/header/header.scss

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,26 @@
102102
padding: 4px 13px;
103103
}
104104

105+
&__search-trigger {
106+
display: inline-flex;
107+
align-items: center;
108+
gap: 6px;
109+
height: 28px;
110+
padding: 0 8px;
111+
border: 1px solid var(--ty-color-border);
112+
border-radius: 14px;
113+
background: var(--ty-color-bg-elevated);
114+
color: var(--ty-color-text-tertiary);
115+
cursor: pointer;
116+
transition: all 0.2s;
117+
font-size: 12px;
118+
119+
&:hover {
120+
color: var(--ty-color-primary);
121+
border-color: var(--ty-color-primary);
122+
}
123+
}
124+
105125
&__locale-toggle {
106126
display: inline-flex;
107127
align-items: center;
@@ -164,5 +184,9 @@
164184
&__home-link {
165185
display: none;
166186
}
187+
188+
&__search-trigger .ty-keyboard {
189+
display: none;
190+
}
167191
}
168192
}

0 commit comments

Comments
 (0)