Skip to content

Commit 41f73bc

Browse files
committed
reorganized and refactored command k
1 parent b8a065d commit 41f73bc

26 files changed

Lines changed: 581 additions & 645 deletions
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { useRef, useState } from "react"
2+
import { useTranslation } from "react-i18next"
3+
import { useNavigate } from "react-router"
4+
import { Modal } from "~/components/modal"
5+
import type { Version } from "~/utils/version-resolvers"
6+
import { fuzzySearch } from "../hooks/use-fuzzy-search"
7+
import { useKeyboardNavigation } from "../hooks/use-keyboard-navigation"
8+
import { useModalState } from "../hooks/use-modal-state"
9+
import { useSearchHistory } from "../hooks/use-search-history"
10+
import type { SearchDoc, SearchResult } from "../search-types"
11+
import { EmptyState } from "./empty-state"
12+
import { ResultsFooter } from "./results-footer"
13+
import { SearchHistory } from "./search-history"
14+
import { SearchInput } from "./search-input"
15+
import { SearchResultRow } from "./search-result"
16+
import { TriggerButton } from "./trigger-button"
17+
18+
interface CommandPaletteProps {
19+
searchIndex: SearchDoc[]
20+
placeholder?: string
21+
version: Version
22+
}
23+
24+
interface HistoryItem extends SearchDoc {
25+
type?: string
26+
slug?: string
27+
highlightedText?: string
28+
}
29+
30+
const withVersion = (version: string, id: string) => `/${version}${id}`
31+
32+
const adaptToRowItem = (doc: SearchDoc): SearchDoc => ({
33+
id: doc.id,
34+
title: doc.title,
35+
subtitle: doc.subtitle,
36+
paragraphs: doc.paragraphs,
37+
})
38+
39+
export const CommandK = ({ searchIndex, placeholder, version }: CommandPaletteProps) => {
40+
const { t } = useTranslation()
41+
const navigate = useNavigate()
42+
const inputRef = useRef<HTMLInputElement>(null)
43+
44+
const [query, setQuery] = useState("")
45+
46+
const { isOpen, openModal, closeModal } = useModalState()
47+
const { history, addToHistory, clearHistory, removeFromHistory } = useSearchHistory()
48+
49+
const results = fuzzySearch(searchIndex, query, {
50+
threshold: 0.8,
51+
minMatchCharLength: 3,
52+
})
53+
54+
const hasQuery = query.trim().length > 0
55+
const hasResults = results.length > 0
56+
const hasHistory = history.length > 0
57+
const searchPlaceholder = placeholder ?? t("placeholders.search_documentation")
58+
59+
const handleClose = () => {
60+
closeModal()
61+
setQuery("")
62+
}
63+
64+
const handleNavigateAndClose = (doc: SearchDoc) => {
65+
navigate(withVersion(version, doc.id))
66+
handleClose()
67+
}
68+
69+
const handleResultSelect = (result: SearchResult) => {
70+
if (!isOpen) return
71+
72+
const rowItem = adaptToRowItem(result.item)
73+
const matchType = result.refIndex === 0 ? "heading" : "paragraph"
74+
75+
const historyItem: HistoryItem = {
76+
...rowItem,
77+
type: matchType,
78+
slug: rowItem.id,
79+
highlightedText: result.highlightedText,
80+
}
81+
82+
addToHistory(historyItem)
83+
handleNavigateAndClose(result.item)
84+
}
85+
86+
const handleHistorySelect = (item: { slug?: string; id?: string }) => {
87+
const id = item.slug || item.id
88+
if (!id) return
89+
90+
navigate(withVersion(version, id))
91+
handleClose()
92+
}
93+
94+
const handleToggle = () => {
95+
isOpen ? handleClose() : openModal()
96+
}
97+
98+
const { selectedIndex } = useKeyboardNavigation({
99+
isOpen,
100+
results,
101+
onSelect: handleResultSelect,
102+
onClose: handleClose,
103+
onToggle: handleToggle,
104+
})
105+
106+
if (!isOpen) {
107+
return <TriggerButton onOpen={openModal} placeholder={searchPlaceholder} />
108+
}
109+
110+
const renderBody = () => {
111+
if (hasQuery) {
112+
if (!hasResults) {
113+
return <EmptyState query={query} />
114+
}
115+
116+
return results.map((result, index) => (
117+
<SearchResultRow
118+
key={`${result.item.id}-${result.refIndex}`}
119+
item={adaptToRowItem(result.item)}
120+
highlightedText={result.highlightedText}
121+
isSelected={index === selectedIndex}
122+
onClick={() => handleResultSelect(result)}
123+
matchType={result.refIndex === 0 ? "heading" : "paragraph"}
124+
/>
125+
))
126+
}
127+
128+
if (hasHistory) {
129+
return (
130+
<SearchHistory
131+
history={history}
132+
onSelect={handleHistorySelect}
133+
onRemove={removeFromHistory}
134+
onClear={clearHistory}
135+
/>
136+
)
137+
}
138+
139+
return <EmptyState />
140+
}
141+
142+
return (
143+
<Modal isOpen={isOpen} onClose={handleClose} getInitialFocus={() => inputRef.current} ariaLabel={searchPlaceholder}>
144+
<SearchInput ref={inputRef} value={query} onChange={setQuery} placeholder={searchPlaceholder} />
145+
146+
<div className="max-h-96 overflow-y-auto overscroll-contain" aria-label={searchPlaceholder}>
147+
{renderBody()}
148+
</div>
149+
150+
<ResultsFooter resultsCount={results.length} query={query} />
151+
</Modal>
152+
)
153+
}

app/components/command-palette/components/empty-state.tsx renamed to app/components/command-k/components/empty-state.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const EmptyState = ({ query }: { query?: string }) => {
2424
}
2525

2626
return (
27-
<div className="px-4 py-8 text-center">
27+
<div className="space-y-6 px-4 py-8 text-center">
2828
<div
2929
className={cn(
3030
"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full",
@@ -73,6 +73,14 @@ export const EmptyState = ({ query }: { query?: string }) => {
7373
<span>{t("controls.cycle")}</span>
7474
</div>
7575
</div>
76+
<span className="text-[var(--color-footer-text)] text-xs opacity-70">
77+
Search by{" "}
78+
<span className="font-semibold">
79+
<a href="https://www.forge42.dev/" target="_blank" rel="noopener noreferrer">
80+
Forge 42
81+
</a>
82+
</span>
83+
</span>
7684
</div>
7785
)
7886
}

app/components/command-palette/components/results-footer.tsx renamed to app/components/command-k/components/results-footer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ export const ResultsFooter = ({
4343
</kbd>
4444
<span>{t("controls.open")}</span>
4545
</div>
46+
<span className="text-[var(--color-footer-text)] text-xs opacity-70">
47+
Search by{" "}
48+
<span className="font-semibold">
49+
<a href="https://www.forge42.dev/" target="_blank" rel="noopener noreferrer">
50+
Forge 42
51+
</a>
52+
</span>
53+
</span>
4654
</div>
4755
</div>
4856
</div>

app/components/command-palette/components/search-history.tsx renamed to app/components/command-k/components/search-history.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { useTranslation } from "react-i18next"
22
import { Icon } from "~/ui/icon/icon"
33
import { cn } from "~/utils/css"
4-
import type { SearchItem } from "../search-types"
4+
import type { SearchDoc } from "../search-types"
55
import { SearchResultRow } from "./search-result"
66

7+
type MatchType = "heading" | "paragraph"
8+
9+
interface HistoryDoc extends SearchDoc {
10+
highlightedText?: string
11+
type?: MatchType
12+
}
13+
714
interface SearchHistoryProps {
8-
history: (SearchItem & { highlightedText?: string })[]
9-
onSelect: (item: SearchItem) => void
15+
history: HistoryDoc[]
16+
onSelect: (item: SearchDoc) => void
1017
onRemove: (itemId: string) => void
1118
onClear: () => void
1219
}
@@ -79,9 +86,9 @@ const HistoryItem = ({
7986
onSelect,
8087
onRemove,
8188
}: {
82-
item: SearchItem & { highlightedText?: string }
89+
item: HistoryDoc
8390
index: number
84-
onSelect: (item: SearchItem) => void
91+
onSelect: (item: SearchDoc) => void
8592
onRemove: (itemId: string) => void
8693
}) => (
8794
<div key={`${item.id}-${index}`} className="group relative">
@@ -90,17 +97,19 @@ const HistoryItem = ({
9097
highlightedText={item.highlightedText ?? item.title}
9198
isSelected={false}
9299
onClick={() => onSelect(item)}
100+
matchType={item.type ?? "heading"}
93101
/>
94102
<RemoveItemButton onRemove={onRemove} itemId={item.id} />
95103
</div>
96104
)
105+
97106
const HistoryItemsList = ({
98107
history,
99108
onSelect,
100109
onRemove,
101110
}: {
102-
history: (SearchItem & { highlightedText?: string })[]
103-
onSelect: (item: SearchItem) => void
111+
history: HistoryDoc[]
112+
onSelect: (item: SearchDoc) => void
104113
onRemove: (itemId: string) => void
105114
}) => (
106115
<div className="max-h-64 overflow-y-auto">
@@ -111,9 +120,7 @@ const HistoryItemsList = ({
111120
)
112121

113122
export const SearchHistory = ({ history, onSelect, onRemove, onClear }: SearchHistoryProps) => {
114-
if (history.length === 0) {
115-
return
116-
}
123+
if (history.length === 0) return null
117124

118125
return (
119126
<div>

app/components/command-palette/components/search-input.tsx renamed to app/components/command-k/components/search-input.tsx

File renamed without changes.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Icon } from "~/ui/icon/icon"
2+
import { cn } from "~/utils/css"
3+
import type { SearchDoc } from "../search-types"
4+
5+
type MatchType = "heading" | "paragraph"
6+
7+
interface SearchResultProps {
8+
item: SearchDoc
9+
highlightedText: string
10+
isSelected: boolean
11+
onClick: () => void
12+
matchType: MatchType
13+
}
14+
15+
const ResultIcon = ({
16+
isSelected,
17+
matchType,
18+
}: {
19+
isSelected: boolean
20+
matchType: MatchType
21+
}) => {
22+
const iconName = matchType === "heading" ? "Hash" : "Pilcrow"
23+
24+
return (
25+
<div
26+
className={cn(
27+
"mt-0.5 transition-colors duration-150",
28+
isSelected ? "text-[var(--color-result-icon-selected)]" : "text-[var(--color-result-icon)]"
29+
)}
30+
>
31+
<Icon name={iconName} className="size-4" />
32+
</div>
33+
)
34+
}
35+
36+
const ResultTitle = ({
37+
title,
38+
highlightedText,
39+
isSelected,
40+
}: {
41+
title: string
42+
highlightedText: string
43+
isSelected: boolean
44+
}) => (
45+
<div
46+
className={cn(
47+
"font-medium leading-snug transition-colors duration-150",
48+
isSelected ? "text-[var(--color-result-selected-text)]" : "text-[var(--color-result-text)]"
49+
)}
50+
>
51+
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: rendering text */}
52+
<span dangerouslySetInnerHTML={{ __html: highlightedText || title }} />
53+
</div>
54+
)
55+
56+
const ResultMetadata = ({
57+
item,
58+
matchType,
59+
}: {
60+
item: SearchDoc
61+
matchType: MatchType
62+
}) => (
63+
<div className="mt-2 text-[var(--color-breadcrumb-text)] text-xs">
64+
{item.title}
65+
{matchType === "paragraph" && item.subtitle ? <span> &gt; {item.subtitle}</span> : null}
66+
</div>
67+
)
68+
69+
const ResultContent = ({
70+
item,
71+
highlightedText,
72+
isSelected,
73+
matchType,
74+
}: {
75+
item: SearchDoc
76+
highlightedText: string
77+
isSelected: boolean
78+
matchType: MatchType
79+
}) => (
80+
<div className="min-w-0 flex-1">
81+
<ResultTitle title={item.title} highlightedText={highlightedText} isSelected={isSelected} />
82+
<ResultMetadata item={item} matchType={matchType} />
83+
</div>
84+
)
85+
86+
const useButtonStyles = (isSelected: boolean) =>
87+
cn(
88+
"flex w-full items-start gap-3 border-r-2 px-4 py-3 text-left transition-all duration-150",
89+
"hover:bg-[var(--color-result-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--color-trigger-focus-ring)]",
90+
isSelected
91+
? "border-[var(--color-result-selected-border)] bg-[var(--color-result-selected)] shadow-sm"
92+
: "border-transparent"
93+
)
94+
95+
export const SearchResultRow = ({ item, highlightedText, isSelected, onClick, matchType }: SearchResultProps) => {
96+
const buttonStyles = useButtonStyles(isSelected)
97+
98+
return (
99+
<button type="button" onClick={onClick} className={buttonStyles}>
100+
<ResultIcon isSelected={isSelected} matchType={matchType} />
101+
<ResultContent item={item} highlightedText={highlightedText} isSelected={isSelected} matchType={matchType} />
102+
</button>
103+
)
104+
}

app/components/command-palette/components/trigger-button.tsx renamed to app/components/command-k/components/trigger-button.tsx

File renamed without changes.

0 commit comments

Comments
 (0)