Skip to content

Commit 24c6153

Browse files
authored
Command K (#5)
* cleanup from base-stack * sidebar initial working version * functional table of content component * small change in the knip.json * custom script to validate content folder positions inside frontmatter, and some fixes in sidebar, toc and ocumentation page * updated readme.md file * updated readme.md file * small refactoring and added documnetation for some components * footer component * initial theme switcher, changes on UI, additional components * fonts, some fixes * small change * convention xx-file-name.mdx update - still updates needed * refactoring * small changes and updated package.json * ts fix? * ts fix? * ts fix? * ts fix? * ts fix? * sidebar fix * added content folder and some fixes and improvements in UI * removed _index route, added index.mdx file for hopemage, reorganized routes.ts * refactoring * small update in update-frontmatter logic * small refactoring * small ui improvements * small ui improvements * refactoring * command palette component - initial version * refactoring * theme toggle fix * refactoring ad vitest tests for some helper functions * small refactoring * fix in breadcrumbs building * fix in breadcrumbs building * unit tests and small improvements * comments and tests * refactoring * initial changes * small fixes * fixed so it doesnt contains v1.0.1 now * fixes * small update * small fixes * small change * small update * dropdown versions and fixed build script * check-with no verify * small fix in sidebar * working version * for now in url for documentation page it will be shown every verion, even last one * dropdown fix * load content collections fix * sorting tags in versions.ts * refactoring * improvements * update * updated docs.build.ts * updates with docs.build.ts * small update * updates * updates * changes * updates * small changes * small fix in breadcrumbs tests * small update * removed DEFAULT_BRANCH env from the yml and passed using cli args in the generate:docs command * small update * reorganized and refactored command k * fix to work with multiple versions * refactoring * fix with versions and search history * refactoring * removed unused icons * small refactoring * fixes to handle search on server side * refactoring * update so search Index is created on the app startup * small fix * small fix * small updates
1 parent 65c0d00 commit 24c6153

40 files changed

+1417
-8
lines changed

app/components/backdrop.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { cn } from "~/utils/css"
2+
3+
export const Backdrop = ({ onClose }: { onClose: () => void }) => (
4+
// biome-ignore lint/a11y/useKeyWithClickEvents: We don't need keyboard events for backdrop
5+
<div
6+
className={cn("fixed inset-0 bg-[var(--color-modal-backdrop)] backdrop-blur-sm transition-opacity duration-200")}
7+
onClick={(e) => {
8+
if (e.target === e.currentTarget) {
9+
onClose()
10+
}
11+
}}
12+
/>
13+
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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 { useKeyboardNavigation } from "../hooks/use-keyboard-navigation"
7+
import { useModalState } from "../hooks/use-modal-state"
8+
import { useSearch } from "../hooks/use-search"
9+
import { useSearchHistory } from "../hooks/use-search-history"
10+
import type { HistoryItem, MatchType, 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+
placeholder?: string
20+
version: Version
21+
}
22+
23+
export const CommandK = ({ placeholder, version }: CommandPaletteProps) => {
24+
const { t } = useTranslation()
25+
const navigate = useNavigate()
26+
const inputRef = useRef<HTMLInputElement>(null)
27+
const [query, setQuery] = useState("")
28+
const { isOpen, openModal, closeModal } = useModalState()
29+
const { history, addToHistory, clearHistory, removeFromHistory } = useSearchHistory(version)
30+
const { results, search } = useSearch({ version })
31+
32+
const hasQuery = !!query.trim()
33+
const hasResults = !!results.length
34+
const hasHistory = !!history.length
35+
const searchPlaceholder = placeholder ?? t("placeholders.search_documentation")
36+
37+
const handleClose = () => {
38+
closeModal()
39+
setQuery("")
40+
search("")
41+
}
42+
43+
const navigateToPage = (id: string) => {
44+
const path = [version, id]
45+
.filter(Boolean)
46+
.map((s) => s.replace(/^\/+|\/+$/g, ""))
47+
.join("/")
48+
49+
navigate(`/${path}`)
50+
}
51+
52+
const handleResultSelect = (result: SearchResult) => {
53+
if (!isOpen) return
54+
const rowItem = result.item
55+
const matchType: MatchType = result.refIndex === 0 ? "heading" : "paragraph"
56+
const historyItem = {
57+
...rowItem,
58+
type: matchType,
59+
highlightedText: result.highlightedText,
60+
}
61+
62+
addToHistory(historyItem)
63+
navigateToPage(rowItem.id)
64+
handleClose()
65+
}
66+
67+
const handleHistorySelect = (item: HistoryItem) => {
68+
navigateToPage(item.id)
69+
handleClose()
70+
}
71+
72+
const handleToggle = () => {
73+
isOpen ? handleClose() : openModal()
74+
}
75+
76+
const { selectedIndex } = useKeyboardNavigation({
77+
isOpen,
78+
results,
79+
onSelect: handleResultSelect,
80+
onClose: handleClose,
81+
onToggle: handleToggle,
82+
})
83+
84+
if (!isOpen) {
85+
return <TriggerButton onOpen={openModal} placeholder={searchPlaceholder} />
86+
}
87+
88+
const renderBody = () => {
89+
if (hasQuery) {
90+
if (!hasResults) return <EmptyState query={query} />
91+
92+
return results.map((result, index) => (
93+
<SearchResultRow
94+
key={`${result.item.id}-${result.refIndex}`}
95+
item={result.item}
96+
highlightedText={result.highlightedText}
97+
isSelected={index === selectedIndex}
98+
onClick={() => handleResultSelect(result)}
99+
matchType={result.refIndex === 0 ? "heading" : "paragraph"}
100+
/>
101+
))
102+
}
103+
104+
if (hasHistory) {
105+
return (
106+
<SearchHistory
107+
history={history}
108+
onSelect={handleHistorySelect}
109+
onRemove={removeFromHistory}
110+
onClear={clearHistory}
111+
/>
112+
)
113+
}
114+
115+
return <EmptyState />
116+
}
117+
118+
return (
119+
<Modal isOpen={isOpen} onClose={handleClose} getInitialFocus={() => inputRef.current} ariaLabel={searchPlaceholder}>
120+
<SearchInput
121+
ref={inputRef}
122+
value={query}
123+
onChange={(val) => {
124+
setQuery(val)
125+
search(val.trim())
126+
}}
127+
placeholder={searchPlaceholder}
128+
/>
129+
<div className="max-h-96 overflow-y-auto overscroll-contain" aria-label={searchPlaceholder}>
130+
{renderBody()}
131+
</div>
132+
<ResultsFooter resultsCount={results.length} query={query} />
133+
</Modal>
134+
)
135+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { useTranslation } from "react-i18next"
2+
import { KeyboardHint } from "./keyboard-hint"
3+
import { ResultsFooterNote } from "./results-footer-note"
4+
5+
export const EmptyState = ({ query }: { query?: string }) => {
6+
const { t } = useTranslation()
7+
if (query) {
8+
return (
9+
<div className="px-4 py-8 text-center">
10+
<p className="font-medium text-[var(--color-empty-text)]">
11+
{t("text.no_results_for")} "{query}"
12+
</p>
13+
<p className="mt-1 text-[var(--color-empty-text-muted)] text-sm">{t("text.adjust_search")}</p>
14+
</div>
15+
)
16+
}
17+
18+
return (
19+
<div className="space-y-6 px-4 py-8 text-center">
20+
<p className="mb-4 font-medium text-[var(--color-empty-text)]">{t("text.start_typing_to_search")}</p>
21+
<div className="flex items-center justify-center gap-6 text-[var(--color-empty-text-muted)] text-xs">
22+
<KeyboardHint keys={["↑", "↓"]} label={t("controls.navigate")} />
23+
<KeyboardHint keys="↵" label={t("controls.select")} />
24+
<KeyboardHint keys="⇥" label={t("controls.cycle")} />
25+
</div>
26+
<ResultsFooterNote />
27+
</div>
28+
)
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Kbd } from "~/ui/kbd"
2+
import { cn } from "~/utils/css"
3+
4+
interface KeyboardHintProps {
5+
keys: string | string[]
6+
label: string
7+
className?: string
8+
}
9+
10+
export const KeyboardHint = ({ keys, label, className }: KeyboardHintProps) => {
11+
const keyArray = Array.isArray(keys) ? keys : [keys]
12+
13+
return (
14+
<div className={cn("flex items-center gap-1", className)}>
15+
{keyArray.map((key) => (
16+
<Kbd key={key}>{key}</Kbd>
17+
))}
18+
<span>{label}</span>
19+
</div>
20+
)
21+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useTranslation } from "react-i18next"
2+
3+
export const ResultsFooterNote = () => {
4+
const { t } = useTranslation()
5+
return (
6+
<span className="text-[var(--color-footer-text)] text-xs opacity-70">
7+
{t("p.search_by")}{" "}
8+
<span className="font-semibold">
9+
<a href="https://www.forge42.dev/" target="_blank" rel="noopener noreferrer">
10+
Forge 42
11+
</a>
12+
</span>
13+
</span>
14+
)
15+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useTranslation } from "react-i18next"
2+
import { cn } from "~/utils/css"
3+
import { KeyboardHint } from "./keyboard-hint"
4+
import { ResultsFooterNote } from "./results-footer-note"
5+
6+
export const ResultsFooter = ({
7+
resultsCount,
8+
query,
9+
}: {
10+
resultsCount: number
11+
query: string
12+
}) => {
13+
const { t } = useTranslation()
14+
if (!query || resultsCount === 0) return null
15+
16+
return (
17+
<div className={cn("border-[var(--color-footer-border)] border-t bg-[var(--color-footer-bg)] px-4 py-3")}>
18+
<div className="flex items-center justify-between text-xs">
19+
<span className="font-medium text-[var(--color-footer-text)]">{t("text.result", { count: resultsCount })}</span>
20+
<div className="flex items-center gap-4 text-[var(--color-footer-text)]">
21+
<KeyboardHint keys={["↑", "↓"]} label={t("controls.navigate")} />
22+
<KeyboardHint keys="↵" label={t("controls.select")} />
23+
<ResultsFooterNote />
24+
</div>
25+
</div>
26+
</div>
27+
)
28+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { useTranslation } from "react-i18next"
2+
import { Icon } from "~/ui/icon/icon"
3+
import { cn } from "~/utils/css"
4+
import type { HistoryItem } from "../search-types"
5+
import { SearchResultRow } from "./search-result"
6+
interface SearchHistoryProps {
7+
history: HistoryItem[]
8+
onSelect: (item: HistoryItem) => void
9+
onRemove: (id: string) => void
10+
onClear: () => void
11+
}
12+
13+
const SearchHistoryHeader = ({ onClear }: Pick<SearchHistoryProps, "onClear">) => {
14+
const { t } = useTranslation()
15+
return (
16+
<div
17+
className={cn(
18+
"flex items-center justify-between border-[var(--color-history-header-border)] border-b",
19+
"bg-[var(--color-history-header-bg)] px-4 py-3"
20+
)}
21+
>
22+
<div className="flex items-center gap-2">
23+
<Icon name="Clock" className="size-4 text-[var(--color-result-meta)]" />
24+
<span className="font-medium text-[var(--color-history-header-text)] text-sm">{t("text.recent_searches")}</span>
25+
</div>
26+
<ClearHistoryButton onClear={onClear} />
27+
</div>
28+
)
29+
}
30+
31+
const ClearHistoryButton = ({ onClear }: Pick<SearchHistoryProps, "onClear">) => {
32+
const { t } = useTranslation()
33+
return (
34+
<button
35+
type="button"
36+
onClick={onClear}
37+
className={cn(
38+
"flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
39+
"text-[var(--color-result-meta)] hover:bg-[var(--color-history-clear-hover-bg)] hover:text-[var(--color-history-clear-hover-text)]"
40+
)}
41+
title="Clear history"
42+
aria-label="Clear search history"
43+
>
44+
<Icon name="Trash2" className="size-3" />
45+
<span className="hidden sm:inline">{t("buttons.clear")}</span>
46+
</button>
47+
)
48+
}
49+
50+
const RemoveItemButton = ({
51+
onRemove,
52+
id,
53+
}: {
54+
onRemove: Pick<SearchHistoryProps, "onRemove">["onRemove"]
55+
id: string
56+
}) => (
57+
<button
58+
type="button"
59+
onClick={(e) => {
60+
e.stopPropagation()
61+
onRemove(id)
62+
}}
63+
className={cn(
64+
"-translate-y-1/2 absolute top-1/2 right-2 flex h-6 w-6 items-center justify-center rounded-full border opacity-0 transition-all duration-150 group-hover:opacity-100",
65+
"border-[var(--color-history-remove-border)] bg-[var(--color-history-remove-bg)] text-[var(--color-history-remove-text)]",
66+
"hover:border-[var(--color-history-remove-hover-border)] hover:text-[var(--color-history-remove-hover-text)]"
67+
)}
68+
title="Remove from history"
69+
aria-label={"Remove from history"}
70+
>
71+
<Icon name="X" className="size-3" />
72+
</button>
73+
)
74+
75+
const HistoryItemRow = ({
76+
item,
77+
index,
78+
onSelect,
79+
onRemove,
80+
}: {
81+
item: HistoryItem
82+
index: number
83+
onSelect: Pick<SearchHistoryProps, "onSelect">["onSelect"]
84+
onRemove: Pick<SearchHistoryProps, "onRemove">["onRemove"]
85+
}) => (
86+
<div key={`${item.id}-${index}`} className="group relative">
87+
<SearchResultRow
88+
item={item}
89+
highlightedText={item.highlightedText ?? item.title}
90+
isSelected={false}
91+
onClick={() => onSelect(item)}
92+
matchType={item.type ?? "heading"}
93+
/>
94+
<RemoveItemButton onRemove={onRemove} id={item.id} />
95+
</div>
96+
)
97+
98+
const HistoryItemsList = ({
99+
history,
100+
onSelect,
101+
onRemove,
102+
}: {
103+
history: HistoryItem[]
104+
onSelect: Pick<SearchHistoryProps, "onSelect">["onSelect"]
105+
onRemove: Pick<SearchHistoryProps, "onRemove">["onRemove"]
106+
}) => (
107+
<div className="max-h-64 overflow-y-auto">
108+
{history.map((item, index) => (
109+
<HistoryItemRow key={`${item.id}-${index}`} item={item} index={index} onSelect={onSelect} onRemove={onRemove} />
110+
))}
111+
</div>
112+
)
113+
114+
export const SearchHistory = ({ history, onSelect, onRemove, onClear }: SearchHistoryProps) => {
115+
if (history.length === 0) return null
116+
return (
117+
<div>
118+
<SearchHistoryHeader onClear={onClear} />
119+
<HistoryItemsList history={history} onSelect={onSelect} onRemove={onRemove} />
120+
</div>
121+
)
122+
}

0 commit comments

Comments
 (0)