deepspace 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +7 -6
- package/dist/{chunk-2GUGZLAX.js → chunk-YBXIUDZJ.js} +2 -2
- package/dist/{chunk-2GUGZLAX.js.map → chunk-YBXIUDZJ.js.map} +1 -1
- package/dist/cli.js +1036 -966
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +26 -434
- package/dist/index.js +99 -864
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +4 -41
- package/dist/schema.js +2 -242
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +6 -162
- package/dist/server.js +13 -281
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +4 -160
- package/dist/worker.js +15 -281
- package/dist/worker.js.map +1 -1
- package/features/messaging/feature.json +3 -43
- package/features/messaging/src/ChatPage.tsx +12 -16
- package/features/messaging/src/components/chat/ChatHeader.tsx +5 -13
- package/features/messaging/src/components/chat/MessageItem.tsx +7 -9
- package/features/messaging/src/components/chat/MessageList.tsx +57 -40
- package/features/messaging/src/components/chat/ThreadPanel.tsx +25 -11
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +10 -29
- package/features/messaging/src/hooks/useChatChannel.ts +18 -23
- package/features/messaging/src/messaging-schema.ts +0 -2
- package/features/search-bar/feature.json +9 -20
- package/features/search-bar/src/InlineSearch.tsx +101 -0
- package/features/search-bar/src/__tests__/search-model.test.tsx +38 -0
- package/features/search-bar/src/search-model.ts +2 -149
- package/package.json +1 -1
- package/features/messaging/src/ChatMultiPage.tsx +0 -298
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +0 -188
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +0 -163
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +0 -147
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +0 -344
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +0 -460
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +0 -190
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +0 -149
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +0 -159
- package/features/messaging/src/hooks/useMultiChannel.ts +0 -142
- package/features/search-bar/src/SearchOverlay.tsx +0 -566
- package/features/search-bar/src/__tests__/search-model.test.ts +0 -40
- package/features/sidebar/feature.json +0 -19
- package/features/sidebar/src/AppSidebar.tsx +0 -558
|
@@ -1,566 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { Button, EmptyState, SearchInput, cn } from '@/components/ui'
|
|
3
|
-
import { Loader2 } from 'lucide-react'
|
|
4
|
-
import {
|
|
5
|
-
createRecentStorageKey,
|
|
6
|
-
readRecentSearches,
|
|
7
|
-
rememberRecentItem,
|
|
8
|
-
rememberRecentQuery,
|
|
9
|
-
type RecentSearchEntry,
|
|
10
|
-
} from './search-model'
|
|
11
|
-
|
|
12
|
-
const recentQueryMarker = Symbol('recent-query')
|
|
13
|
-
|
|
14
|
-
export interface SearchItem {
|
|
15
|
-
id: string
|
|
16
|
-
title: string
|
|
17
|
-
subtitle?: string
|
|
18
|
-
description?: string
|
|
19
|
-
group?: string
|
|
20
|
-
keywords?: string[]
|
|
21
|
-
meta?: ReactNode
|
|
22
|
-
disabled?: boolean
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface RecentQueryItem extends SearchItem {
|
|
26
|
-
[recentQueryMarker]: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type SearchResultRenderer<T extends SearchItem = SearchItem> = (
|
|
30
|
-
item: T,
|
|
31
|
-
query: string,
|
|
32
|
-
) => ReactNode
|
|
33
|
-
|
|
34
|
-
interface SearchBaseProps<T extends SearchItem = SearchItem> {
|
|
35
|
-
query: string
|
|
36
|
-
onQueryChange: (query: string) => void
|
|
37
|
-
items: T[]
|
|
38
|
-
onSelect: (item: T) => void
|
|
39
|
-
placeholder?: string
|
|
40
|
-
loading?: boolean
|
|
41
|
-
error?: string | null
|
|
42
|
-
emptyTitle?: string
|
|
43
|
-
emptyDescription?: string
|
|
44
|
-
className?: string
|
|
45
|
-
renderTitle?: SearchResultRenderer<T>
|
|
46
|
-
renderSubtitle?: SearchResultRenderer<T>
|
|
47
|
-
renderDescription?: SearchResultRenderer<T>
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface SearchOverlayProps<T extends SearchItem = SearchItem> extends SearchBaseProps<T> {
|
|
51
|
-
open: boolean
|
|
52
|
-
onOpenChange: (open: boolean) => void
|
|
53
|
-
triggerLabel?: string
|
|
54
|
-
title?: string
|
|
55
|
-
description?: string
|
|
56
|
-
onSearchSubmit?: (query: string) => void
|
|
57
|
-
searchSubmitLabel?: string
|
|
58
|
-
showRecentSearches?: boolean
|
|
59
|
-
recentStorageKey?: string
|
|
60
|
-
maxRecentItems?: number
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface InlineSearchProps<T extends SearchItem = SearchItem> extends SearchBaseProps<T> {
|
|
64
|
-
label?: string
|
|
65
|
-
description?: string
|
|
66
|
-
dimContent?: boolean
|
|
67
|
-
openOnFocus?: boolean
|
|
68
|
-
maxResultsHeightClassName?: string
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function SearchOverlay<T extends SearchItem = SearchItem>({
|
|
72
|
-
open,
|
|
73
|
-
onOpenChange,
|
|
74
|
-
query,
|
|
75
|
-
onQueryChange,
|
|
76
|
-
items,
|
|
77
|
-
onSelect,
|
|
78
|
-
triggerLabel = 'Search',
|
|
79
|
-
placeholder = 'Search...',
|
|
80
|
-
title = 'Search',
|
|
81
|
-
description,
|
|
82
|
-
onSearchSubmit,
|
|
83
|
-
searchSubmitLabel = 'Search all results',
|
|
84
|
-
loading = false,
|
|
85
|
-
error = null,
|
|
86
|
-
emptyTitle = 'No matches',
|
|
87
|
-
emptyDescription = 'Try another search term.',
|
|
88
|
-
className,
|
|
89
|
-
renderTitle,
|
|
90
|
-
renderSubtitle,
|
|
91
|
-
renderDescription,
|
|
92
|
-
showRecentSearches = true,
|
|
93
|
-
recentStorageKey,
|
|
94
|
-
maxRecentItems = 5,
|
|
95
|
-
}: SearchOverlayProps<T>) {
|
|
96
|
-
const panelRef = useRef<HTMLDivElement>(null)
|
|
97
|
-
const inputRef = useRef<HTMLInputElement>(null)
|
|
98
|
-
const [recentSearches, setRecentSearches] = useState<RecentSearchEntry[]>([])
|
|
99
|
-
const resolvedRecentStorageKey =
|
|
100
|
-
recentStorageKey ?? createRecentStorageKey(title, placeholder, triggerLabel)
|
|
101
|
-
const recentItems = useMemo(() => {
|
|
102
|
-
const itemsById = new Map(items.map((item) => [item.id, item]))
|
|
103
|
-
return recentSearches.flatMap((recent, index): SearchItem[] => {
|
|
104
|
-
if (recent.kind === 'query') {
|
|
105
|
-
const queryItem: RecentQueryItem = {
|
|
106
|
-
id: `deepspace-recent-query:${index}`,
|
|
107
|
-
title: recent.query,
|
|
108
|
-
subtitle: 'Search query',
|
|
109
|
-
group: 'Recent',
|
|
110
|
-
keywords: [recent.query],
|
|
111
|
-
[recentQueryMarker]: recent.query,
|
|
112
|
-
}
|
|
113
|
-
return [queryItem]
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const item = itemsById.get(recent.itemId)
|
|
117
|
-
return item ? [{ ...item, group: 'Recent' }] : []
|
|
118
|
-
})
|
|
119
|
-
}, [items, recentSearches])
|
|
120
|
-
const showRecents = showRecentSearches && query.trim() === '' && recentItems.length > 0
|
|
121
|
-
const visibleItems: SearchItem[] = showRecents ? recentItems : items
|
|
122
|
-
const canSubmitSearch = Boolean(onSearchSubmit && query.trim())
|
|
123
|
-
|
|
124
|
-
function submitSearch(rawQuery: string) {
|
|
125
|
-
const trimmed = rawQuery.trim()
|
|
126
|
-
if (!trimmed || !onSearchSubmit) return
|
|
127
|
-
if (showRecentSearches) {
|
|
128
|
-
setRecentSearches(rememberRecentQuery(resolvedRecentStorageKey, trimmed, maxRecentItems))
|
|
129
|
-
}
|
|
130
|
-
onSearchSubmit(trimmed)
|
|
131
|
-
onOpenChange(false)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
useEffect(() => {
|
|
135
|
-
if (!open) return
|
|
136
|
-
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0)
|
|
137
|
-
if (showRecentSearches) {
|
|
138
|
-
setRecentSearches(readRecentSearches(resolvedRecentStorageKey, maxRecentItems))
|
|
139
|
-
}
|
|
140
|
-
return () => window.clearTimeout(focusTimer)
|
|
141
|
-
}, [maxRecentItems, open, resolvedRecentStorageKey, showRecentSearches])
|
|
142
|
-
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
if (!open) return
|
|
145
|
-
|
|
146
|
-
function onPointerDown(event: MouseEvent) {
|
|
147
|
-
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
|
|
148
|
-
onOpenChange(false)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
document.addEventListener('mousedown', onPointerDown)
|
|
153
|
-
return () => document.removeEventListener('mousedown', onPointerDown)
|
|
154
|
-
}, [onOpenChange, open])
|
|
155
|
-
|
|
156
|
-
return (
|
|
157
|
-
<div className={className}>
|
|
158
|
-
<Button type="button" onClick={() => onOpenChange(true)} data-testid="search-trigger">
|
|
159
|
-
{triggerLabel}
|
|
160
|
-
</Button>
|
|
161
|
-
|
|
162
|
-
{open && (
|
|
163
|
-
<div
|
|
164
|
-
className="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm px-3 py-4 sm:py-16"
|
|
165
|
-
data-testid="search-overlay"
|
|
166
|
-
>
|
|
167
|
-
<div
|
|
168
|
-
ref={panelRef}
|
|
169
|
-
role="dialog"
|
|
170
|
-
aria-modal="true"
|
|
171
|
-
aria-label={title}
|
|
172
|
-
className="mx-auto flex max-h-[min(720px,calc(100vh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
|
173
|
-
>
|
|
174
|
-
<div className="border-b border-border p-4">
|
|
175
|
-
<div className="mb-3 flex items-start justify-between gap-4">
|
|
176
|
-
<div>
|
|
177
|
-
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
|
178
|
-
{description && (
|
|
179
|
-
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
180
|
-
)}
|
|
181
|
-
</div>
|
|
182
|
-
<Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
|
183
|
-
Close
|
|
184
|
-
</Button>
|
|
185
|
-
</div>
|
|
186
|
-
<SearchInput
|
|
187
|
-
ref={inputRef}
|
|
188
|
-
value={query}
|
|
189
|
-
onChange={(event) => onQueryChange(event.target.value)}
|
|
190
|
-
onClear={() => onQueryChange('')}
|
|
191
|
-
placeholder={placeholder}
|
|
192
|
-
aria-label={placeholder}
|
|
193
|
-
data-testid="search-input"
|
|
194
|
-
/>
|
|
195
|
-
{canSubmitSearch && (
|
|
196
|
-
<Button
|
|
197
|
-
type="button"
|
|
198
|
-
variant="ghost"
|
|
199
|
-
size="sm"
|
|
200
|
-
className="mt-2 w-full justify-start text-muted-foreground"
|
|
201
|
-
onClick={() => submitSearch(query)}
|
|
202
|
-
data-testid="search-submit"
|
|
203
|
-
>
|
|
204
|
-
{searchSubmitLabel}: "{query.trim()}"
|
|
205
|
-
</Button>
|
|
206
|
-
)}
|
|
207
|
-
</div>
|
|
208
|
-
|
|
209
|
-
<SearchPanel
|
|
210
|
-
items={visibleItems}
|
|
211
|
-
query={showRecents ? '' : query}
|
|
212
|
-
onSelect={(item) => {
|
|
213
|
-
if (showRecents) {
|
|
214
|
-
if (isRecentQueryItem(item)) {
|
|
215
|
-
onQueryChange(item[recentQueryMarker])
|
|
216
|
-
submitSearch(item[recentQueryMarker])
|
|
217
|
-
return
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const currentItem = items.find((candidate) => candidate.id === item.id)
|
|
221
|
-
if (!currentItem) return
|
|
222
|
-
onSelect(currentItem)
|
|
223
|
-
onOpenChange(false)
|
|
224
|
-
return
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (showRecentSearches) {
|
|
228
|
-
setRecentSearches(
|
|
229
|
-
rememberRecentItem(resolvedRecentStorageKey, item.id, maxRecentItems),
|
|
230
|
-
)
|
|
231
|
-
}
|
|
232
|
-
onSelect(item as T)
|
|
233
|
-
onOpenChange(false)
|
|
234
|
-
}}
|
|
235
|
-
onClose={() => onOpenChange(false)}
|
|
236
|
-
loading={loading}
|
|
237
|
-
error={error}
|
|
238
|
-
emptyTitle={emptyTitle}
|
|
239
|
-
emptyDescription={emptyDescription}
|
|
240
|
-
onSubmitQuery={onSearchSubmit ? submitSearch : undefined}
|
|
241
|
-
renderTitle={
|
|
242
|
-
showRecents
|
|
243
|
-
? undefined
|
|
244
|
-
: (renderTitle as SearchResultRenderer<SearchItem> | undefined)
|
|
245
|
-
}
|
|
246
|
-
renderSubtitle={
|
|
247
|
-
showRecents
|
|
248
|
-
? undefined
|
|
249
|
-
: (renderSubtitle as SearchResultRenderer<SearchItem> | undefined)
|
|
250
|
-
}
|
|
251
|
-
renderDescription={
|
|
252
|
-
showRecents
|
|
253
|
-
? undefined
|
|
254
|
-
: (renderDescription as SearchResultRenderer<SearchItem> | undefined)
|
|
255
|
-
}
|
|
256
|
-
/>
|
|
257
|
-
</div>
|
|
258
|
-
</div>
|
|
259
|
-
)}
|
|
260
|
-
</div>
|
|
261
|
-
)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export function InlineSearch<T extends SearchItem = SearchItem>({
|
|
265
|
-
query,
|
|
266
|
-
onQueryChange,
|
|
267
|
-
items,
|
|
268
|
-
onSelect,
|
|
269
|
-
placeholder = 'Search...',
|
|
270
|
-
label,
|
|
271
|
-
description,
|
|
272
|
-
loading = false,
|
|
273
|
-
error = null,
|
|
274
|
-
emptyTitle = 'No matches',
|
|
275
|
-
emptyDescription = 'Try another search term.',
|
|
276
|
-
dimContent = false,
|
|
277
|
-
openOnFocus = true,
|
|
278
|
-
maxResultsHeightClassName = 'max-h-80',
|
|
279
|
-
className,
|
|
280
|
-
renderTitle,
|
|
281
|
-
renderSubtitle,
|
|
282
|
-
renderDescription,
|
|
283
|
-
}: InlineSearchProps<T>) {
|
|
284
|
-
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
285
|
-
const [open, setOpen] = useState(false)
|
|
286
|
-
const showPanel = open && (openOnFocus || query.trim().length > 0)
|
|
287
|
-
|
|
288
|
-
useEffect(() => {
|
|
289
|
-
function onPointerDown(event: MouseEvent) {
|
|
290
|
-
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
|
291
|
-
setOpen(false)
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
document.addEventListener('mousedown', onPointerDown)
|
|
296
|
-
return () => document.removeEventListener('mousedown', onPointerDown)
|
|
297
|
-
}, [])
|
|
298
|
-
|
|
299
|
-
return (
|
|
300
|
-
<div ref={wrapperRef} className={cn('relative', className)} data-testid="inline-search">
|
|
301
|
-
{dimContent && showPanel && (
|
|
302
|
-
<div
|
|
303
|
-
className="fixed inset-0 z-30 bg-background/50 backdrop-blur-[2px]"
|
|
304
|
-
aria-hidden="true"
|
|
305
|
-
data-testid="inline-search-dimmer"
|
|
306
|
-
/>
|
|
307
|
-
)}
|
|
308
|
-
|
|
309
|
-
<div className="relative z-40">
|
|
310
|
-
{(label || description) && (
|
|
311
|
-
<div className="mb-2">
|
|
312
|
-
{label && <label className="text-sm font-medium text-foreground">{label}</label>}
|
|
313
|
-
{description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
|
|
314
|
-
</div>
|
|
315
|
-
)}
|
|
316
|
-
<SearchInput
|
|
317
|
-
value={query}
|
|
318
|
-
onChange={(event) => {
|
|
319
|
-
onQueryChange(event.target.value)
|
|
320
|
-
setOpen(true)
|
|
321
|
-
}}
|
|
322
|
-
onFocus={() => setOpen(true)}
|
|
323
|
-
onClear={() => {
|
|
324
|
-
onQueryChange('')
|
|
325
|
-
setOpen(true)
|
|
326
|
-
}}
|
|
327
|
-
placeholder={placeholder}
|
|
328
|
-
aria-label={placeholder}
|
|
329
|
-
data-testid="inline-search-input"
|
|
330
|
-
/>
|
|
331
|
-
|
|
332
|
-
{showPanel && (
|
|
333
|
-
<div
|
|
334
|
-
className={cn(
|
|
335
|
-
'absolute left-0 right-0 top-full z-50 mt-2 overflow-hidden rounded-xl border border-border bg-card shadow-xl',
|
|
336
|
-
maxResultsHeightClassName,
|
|
337
|
-
)}
|
|
338
|
-
data-testid="inline-search-panel"
|
|
339
|
-
>
|
|
340
|
-
<SearchPanel
|
|
341
|
-
items={items}
|
|
342
|
-
query={query}
|
|
343
|
-
onSelect={(item) => {
|
|
344
|
-
onSelect(item)
|
|
345
|
-
setOpen(false)
|
|
346
|
-
}}
|
|
347
|
-
onClose={() => setOpen(false)}
|
|
348
|
-
loading={loading}
|
|
349
|
-
error={error}
|
|
350
|
-
emptyTitle={emptyTitle}
|
|
351
|
-
emptyDescription={emptyDescription}
|
|
352
|
-
compact
|
|
353
|
-
renderTitle={renderTitle}
|
|
354
|
-
renderSubtitle={renderSubtitle}
|
|
355
|
-
renderDescription={renderDescription}
|
|
356
|
-
/>
|
|
357
|
-
</div>
|
|
358
|
-
)}
|
|
359
|
-
</div>
|
|
360
|
-
</div>
|
|
361
|
-
)
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
interface SearchPanelProps<T extends SearchItem = SearchItem> {
|
|
365
|
-
items: T[]
|
|
366
|
-
query: string
|
|
367
|
-
onSelect: (item: T) => void
|
|
368
|
-
onClose: () => void
|
|
369
|
-
loading?: boolean
|
|
370
|
-
error?: string | null
|
|
371
|
-
emptyTitle: string
|
|
372
|
-
emptyDescription: string
|
|
373
|
-
compact?: boolean
|
|
374
|
-
onSubmitQuery?: (query: string) => void
|
|
375
|
-
renderTitle?: SearchResultRenderer<T>
|
|
376
|
-
renderSubtitle?: SearchResultRenderer<T>
|
|
377
|
-
renderDescription?: SearchResultRenderer<T>
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function SearchPanel<T extends SearchItem = SearchItem>({
|
|
381
|
-
items,
|
|
382
|
-
query,
|
|
383
|
-
onSelect,
|
|
384
|
-
onClose,
|
|
385
|
-
loading = false,
|
|
386
|
-
error = null,
|
|
387
|
-
emptyTitle,
|
|
388
|
-
emptyDescription,
|
|
389
|
-
compact = false,
|
|
390
|
-
onSubmitQuery,
|
|
391
|
-
renderTitle,
|
|
392
|
-
renderSubtitle,
|
|
393
|
-
renderDescription,
|
|
394
|
-
}: SearchPanelProps<T>) {
|
|
395
|
-
const [activeIndex, setActiveIndex] = useState(0)
|
|
396
|
-
const enabledItems = items.filter((item) => !item.disabled)
|
|
397
|
-
|
|
398
|
-
useEffect(() => {
|
|
399
|
-
setActiveIndex(0)
|
|
400
|
-
}, [items])
|
|
401
|
-
|
|
402
|
-
function moveActive(delta: number) {
|
|
403
|
-
if (enabledItems.length === 0) return
|
|
404
|
-
setActiveIndex((current) => (current + delta + enabledItems.length) % enabledItems.length)
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function selectItem(item: T) {
|
|
408
|
-
if (item.disabled) return
|
|
409
|
-
onSelect(item)
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
useEffect(() => {
|
|
413
|
-
document.addEventListener('keydown', onKeyDown)
|
|
414
|
-
return () => document.removeEventListener('keydown', onKeyDown)
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
function onKeyDown(event: globalThis.KeyboardEvent) {
|
|
418
|
-
if (event.key === 'Escape') {
|
|
419
|
-
event.preventDefault()
|
|
420
|
-
onClose()
|
|
421
|
-
} else if (event.key === 'ArrowDown') {
|
|
422
|
-
event.preventDefault()
|
|
423
|
-
moveActive(1)
|
|
424
|
-
} else if (event.key === 'ArrowUp') {
|
|
425
|
-
event.preventDefault()
|
|
426
|
-
moveActive(-1)
|
|
427
|
-
} else if (event.key === 'Enter') {
|
|
428
|
-
if (onSubmitQuery && query.trim()) {
|
|
429
|
-
event.preventDefault()
|
|
430
|
-
onSubmitQuery(query)
|
|
431
|
-
return
|
|
432
|
-
}
|
|
433
|
-
const active = enabledItems[activeIndex]
|
|
434
|
-
if (active) {
|
|
435
|
-
event.preventDefault()
|
|
436
|
-
selectItem(active)
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
return (
|
|
442
|
-
<div
|
|
443
|
-
className={cn('min-h-0 flex-1 overflow-y-auto p-2', compact ? 'max-h-80' : '')}
|
|
444
|
-
tabIndex={-1}
|
|
445
|
-
>
|
|
446
|
-
{loading ? (
|
|
447
|
-
<div
|
|
448
|
-
className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground"
|
|
449
|
-
data-testid="search-loading"
|
|
450
|
-
>
|
|
451
|
-
<Loader2 className="h-4 w-4 animate-spin" />
|
|
452
|
-
Searching...
|
|
453
|
-
</div>
|
|
454
|
-
) : error ? (
|
|
455
|
-
<div className="px-3 py-10 text-center text-sm text-destructive" data-testid="search-error">
|
|
456
|
-
{error}
|
|
457
|
-
</div>
|
|
458
|
-
) : items.length === 0 ? (
|
|
459
|
-
<div data-testid="search-empty">
|
|
460
|
-
<EmptyState
|
|
461
|
-
title={emptyTitle}
|
|
462
|
-
description={emptyDescription}
|
|
463
|
-
className={compact ? 'py-8' : undefined}
|
|
464
|
-
/>
|
|
465
|
-
</div>
|
|
466
|
-
) : (
|
|
467
|
-
<SearchResultList
|
|
468
|
-
items={items}
|
|
469
|
-
query={query}
|
|
470
|
-
activeItemId={enabledItems[activeIndex]?.id}
|
|
471
|
-
onSelect={selectItem}
|
|
472
|
-
renderTitle={renderTitle}
|
|
473
|
-
renderSubtitle={renderSubtitle}
|
|
474
|
-
renderDescription={renderDescription}
|
|
475
|
-
/>
|
|
476
|
-
)}
|
|
477
|
-
</div>
|
|
478
|
-
)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export interface SearchResultListProps<T extends SearchItem = SearchItem> {
|
|
482
|
-
items: T[]
|
|
483
|
-
query?: string
|
|
484
|
-
activeItemId?: string
|
|
485
|
-
onSelect: (item: T) => void
|
|
486
|
-
renderTitle?: SearchResultRenderer<T>
|
|
487
|
-
renderSubtitle?: SearchResultRenderer<T>
|
|
488
|
-
renderDescription?: SearchResultRenderer<T>
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export function SearchResultList<T extends SearchItem = SearchItem>({
|
|
492
|
-
items,
|
|
493
|
-
query = '',
|
|
494
|
-
activeItemId,
|
|
495
|
-
onSelect,
|
|
496
|
-
renderTitle,
|
|
497
|
-
renderSubtitle,
|
|
498
|
-
renderDescription,
|
|
499
|
-
}: SearchResultListProps<T>) {
|
|
500
|
-
const groups = groupItems(items)
|
|
501
|
-
|
|
502
|
-
return (
|
|
503
|
-
<div className="space-y-3" data-testid="search-results">
|
|
504
|
-
{groups.map(([group, groupItems]) => (
|
|
505
|
-
<section key={group}>
|
|
506
|
-
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
507
|
-
{group}
|
|
508
|
-
</div>
|
|
509
|
-
<div className="space-y-1">
|
|
510
|
-
{groupItems.map((item) => {
|
|
511
|
-
const active = item.id === activeItemId
|
|
512
|
-
return (
|
|
513
|
-
<button
|
|
514
|
-
key={item.id}
|
|
515
|
-
type="button"
|
|
516
|
-
disabled={item.disabled}
|
|
517
|
-
onClick={() => onSelect(item)}
|
|
518
|
-
className={cn(
|
|
519
|
-
'flex w-full items-start justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors',
|
|
520
|
-
active ? 'bg-primary/10 text-foreground' : 'hover:bg-muted/70',
|
|
521
|
-
item.disabled && 'cursor-not-allowed opacity-50',
|
|
522
|
-
)}
|
|
523
|
-
data-testid="search-result"
|
|
524
|
-
data-active={active ? 'true' : 'false'}
|
|
525
|
-
>
|
|
526
|
-
<span className="min-w-0">
|
|
527
|
-
<span className="block truncate text-sm font-medium text-foreground">
|
|
528
|
-
{renderTitle ? renderTitle(item, query) : item.title}
|
|
529
|
-
</span>
|
|
530
|
-
{item.subtitle && (
|
|
531
|
-
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
|
532
|
-
{renderSubtitle ? renderSubtitle(item, query) : item.subtitle}
|
|
533
|
-
</span>
|
|
534
|
-
)}
|
|
535
|
-
{item.description && (
|
|
536
|
-
<span className="mt-1 block text-xs text-muted-foreground">
|
|
537
|
-
{renderDescription ? renderDescription(item, query) : item.description}
|
|
538
|
-
</span>
|
|
539
|
-
)}
|
|
540
|
-
</span>
|
|
541
|
-
{item.meta && (
|
|
542
|
-
<span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>
|
|
543
|
-
)}
|
|
544
|
-
</button>
|
|
545
|
-
)
|
|
546
|
-
})}
|
|
547
|
-
</div>
|
|
548
|
-
</section>
|
|
549
|
-
))}
|
|
550
|
-
</div>
|
|
551
|
-
)
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function groupItems<T extends SearchItem>(items: T[]): Array<[string, T[]]> {
|
|
555
|
-
const groups = new Map<string, T[]>()
|
|
556
|
-
for (const item of items) {
|
|
557
|
-
const group = item.group ?? 'Results'
|
|
558
|
-
if (!groups.has(group)) groups.set(group, [])
|
|
559
|
-
groups.get(group)!.push(item)
|
|
560
|
-
}
|
|
561
|
-
return Array.from(groups.entries())
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function isRecentQueryItem(item: SearchItem): item is RecentQueryItem {
|
|
565
|
-
return recentQueryMarker in item
|
|
566
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { readRecentSearches, rememberRecentItem, rememberRecentQuery } from '../search-model'
|
|
3
|
-
|
|
4
|
-
const storageKey = 'search-history-test'
|
|
5
|
-
let values: Map<string, string>
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
values = new Map()
|
|
9
|
-
vi.stubGlobal('window', {
|
|
10
|
-
localStorage: {
|
|
11
|
-
getItem: (key: string) => values.get(key) ?? null,
|
|
12
|
-
setItem: (key: string, value: string) => values.set(key, value),
|
|
13
|
-
},
|
|
14
|
-
})
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
afterEach(() => vi.unstubAllGlobals())
|
|
18
|
-
|
|
19
|
-
describe('recent search history', () => {
|
|
20
|
-
it('stores only the stable ID for a selected result', () => {
|
|
21
|
-
expect(rememberRecentItem(storageKey, 'post-1', 5)).toEqual([
|
|
22
|
-
{ kind: 'item', itemId: 'post-1' },
|
|
23
|
-
])
|
|
24
|
-
expect(JSON.parse(values.get(storageKey) ?? 'null')).toEqual([
|
|
25
|
-
{ kind: 'item', itemId: 'post-1' },
|
|
26
|
-
])
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('ignores legacy result snapshots instead of replaying incomplete objects', () => {
|
|
30
|
-
values.set(storageKey, JSON.stringify([{ id: 'post-1', title: 'Old title' }]))
|
|
31
|
-
expect(readRecentSearches(storageKey, 5)).toEqual([])
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('deduplicates submitted queries without changing their display casing', () => {
|
|
35
|
-
rememberRecentQuery(storageKey, 'Project Alpha', 5)
|
|
36
|
-
expect(rememberRecentQuery(storageKey, 'project alpha', 5)).toEqual([
|
|
37
|
-
{ kind: 'query', query: 'project alpha' },
|
|
38
|
-
])
|
|
39
|
-
})
|
|
40
|
-
})
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "sidebar",
|
|
3
|
-
"name": "Sidebar",
|
|
4
|
-
"category": "nav",
|
|
5
|
-
"description": "Collapsible left navigation sidebar with role-based filtering",
|
|
6
|
-
"details": "Animated sidebar with collapsed icon-only mode, expanded mode with labels and user chip, mobile overlay drawer, localStorage persistence, role-based nav filtering via useUser, and active state via react-router-dom.",
|
|
7
|
-
"files": [
|
|
8
|
-
{
|
|
9
|
-
"src": "src/AppSidebar.tsx",
|
|
10
|
-
"dest": "src/components/sidebar/AppSidebar.tsx"
|
|
11
|
-
}
|
|
12
|
-
],
|
|
13
|
-
"requires": [],
|
|
14
|
-
"instructions": [
|
|
15
|
-
"Import AppSidebar and SidebarMobileHeader into your layout",
|
|
16
|
-
"Configure nav items array with paths, icons, and optional role restrictions"
|
|
17
|
-
],
|
|
18
|
-
"patterns": []
|
|
19
|
-
}
|