@tooee/choose 0.1.22 → 0.2.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/README.md +28 -2
- package/dist/Choose.d.ts +14 -17
- package/dist/Choose.d.ts.map +1 -1
- package/dist/Choose.js +42 -266
- package/dist/Choose.js.map +1 -1
- package/dist/ChooseFilter.d.ts +12 -0
- package/dist/ChooseFilter.d.ts.map +1 -0
- package/dist/ChooseFilter.js +9 -0
- package/dist/ChooseFilter.js.map +1 -0
- package/dist/ChooseHighlightedText.d.ts +9 -0
- package/dist/ChooseHighlightedText.d.ts.map +1 -0
- package/dist/ChooseHighlightedText.js +30 -0
- package/dist/ChooseHighlightedText.js.map +1 -0
- package/dist/ChooseList.d.ts +28 -0
- package/dist/ChooseList.d.ts.map +1 -0
- package/dist/ChooseList.js +51 -0
- package/dist/ChooseList.js.map +1 -0
- package/dist/ChooseOverlay.d.ts +34 -5
- package/dist/ChooseOverlay.d.ts.map +1 -1
- package/dist/ChooseOverlay.js +29 -200
- package/dist/ChooseOverlay.js.map +1 -1
- package/dist/ChoosePanel.d.ts +30 -0
- package/dist/ChoosePanel.d.ts.map +1 -0
- package/dist/ChoosePanel.js +25 -0
- package/dist/ChoosePanel.js.map +1 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/source.d.ts +5 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +12 -0
- package/dist/source.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-choose.d.ts +80 -0
- package/dist/use-choose.d.ts.map +1 -0
- package/dist/use-choose.js +365 -0
- package/dist/use-choose.js.map +1 -0
- package/package.json +11 -11
- package/src/Choose.tsx +68 -355
- package/src/ChooseFilter.tsx +45 -0
- package/src/ChooseHighlightedText.tsx +42 -0
- package/src/ChooseList.tsx +137 -0
- package/src/ChooseOverlay.tsx +73 -307
- package/src/ChoosePanel.tsx +123 -0
- package/src/index.ts +26 -1
- package/src/source.ts +12 -0
- package/src/types.ts +6 -0
- package/src/use-choose.ts +479 -0
package/src/Choose.tsx
CHANGED
|
@@ -1,272 +1,72 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
3
|
-
import { useKeyboard } from "@opentui/react"
|
|
1
|
+
import { useImperativeHandle, type Ref } from "react"
|
|
4
2
|
import { AppLayout } from "@tooee/layout"
|
|
5
3
|
import { useHasOverlay, useHasModalOverlay } from "@tooee/overlays"
|
|
6
4
|
import { ThemePicker, useTheme } from "@tooee/themes"
|
|
7
5
|
import { useThemeCommands, useQuitCommand } from "@tooee/shell"
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import type { ActionDefinition } from "@tooee/commands"
|
|
17
|
-
import type { ChooseItem, ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js"
|
|
18
|
-
import { fuzzyFilter } from "./fuzzy.js"
|
|
19
|
-
|
|
20
|
-
declare module "@tooee/commands" {
|
|
21
|
-
interface CommandContext {
|
|
22
|
-
/** Contributed by Choose: the current selection state. */
|
|
23
|
-
choose: {
|
|
24
|
-
activeItem: ChooseItem | undefined
|
|
25
|
-
selectedItems: ChooseItem[]
|
|
26
|
-
filterQuery: string
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ChooseProps {
|
|
6
|
+
import { useCommandContext, type ActionDefinition } from "@tooee/commands"
|
|
7
|
+
import { ChooseFilter } from "./ChooseFilter.js"
|
|
8
|
+
import { ChooseList, type ChooseListProps } from "./ChooseList.js"
|
|
9
|
+
import { buildChooseHints } from "./ChoosePanel.js"
|
|
10
|
+
import type { ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js"
|
|
11
|
+
import { useChoose, type ChooseController } from "./use-choose.js"
|
|
12
|
+
|
|
13
|
+
export interface ChooseProps {
|
|
32
14
|
contentProvider: ChooseContentProvider
|
|
33
15
|
options?: ChooseOptions
|
|
16
|
+
/** Legacy name retained for source compatibility. */
|
|
34
17
|
actions?: ActionDefinition[]
|
|
35
|
-
/**
|
|
18
|
+
/** Additive alias used by new chooser compositions. */
|
|
19
|
+
commands?: ActionDefinition[]
|
|
20
|
+
controllerRef?: Ref<ChooseController>
|
|
21
|
+
renderItem?: ChooseListProps["renderItem"]
|
|
22
|
+
/** @deprecated Prefer commands with an id such as `submit`. */
|
|
36
23
|
onConfirm?: (result: ChooseResult) => void
|
|
37
|
-
/** @deprecated
|
|
24
|
+
/** @deprecated Prefer commands or launch lifecycle handling. */
|
|
38
25
|
onCancel?: () => void
|
|
39
26
|
}
|
|
40
27
|
|
|
41
|
-
export function Choose({
|
|
28
|
+
export function Choose({
|
|
29
|
+
contentProvider,
|
|
30
|
+
options,
|
|
31
|
+
actions,
|
|
32
|
+
commands,
|
|
33
|
+
controllerRef,
|
|
34
|
+
renderItem,
|
|
35
|
+
onConfirm,
|
|
36
|
+
onCancel,
|
|
37
|
+
}: ChooseProps) {
|
|
42
38
|
const { theme } = useTheme()
|
|
43
|
-
const [items, setItems] = useState<ChooseItem[]>([])
|
|
44
|
-
const [filterQuery, setFilterQuery] = useState("")
|
|
45
|
-
const [activeIndex, setActiveIndex] = useState(0)
|
|
46
|
-
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set())
|
|
47
|
-
const [loading, setLoading] = useState(true)
|
|
48
|
-
const [error, setError] = useState<string | null>(null)
|
|
49
|
-
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
50
39
|
const { invoke } = useCommandContext()
|
|
51
|
-
|
|
40
|
+
const effectiveCommands = commands ?? actions
|
|
52
41
|
const multi = options?.multi ?? false
|
|
53
42
|
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
const result = contentProvider.load()
|
|
56
|
-
if (result instanceof Promise) {
|
|
57
|
-
let active = true
|
|
58
|
-
setLoading(true)
|
|
59
|
-
setError(null)
|
|
60
|
-
result
|
|
61
|
-
.then((loaded) => {
|
|
62
|
-
// Ignore stale results after contentProvider changed
|
|
63
|
-
if (!active) return
|
|
64
|
-
setItems(loaded)
|
|
65
|
-
setLoading(false)
|
|
66
|
-
})
|
|
67
|
-
.catch((err: unknown) => {
|
|
68
|
-
if (!active) return
|
|
69
|
-
setItems([])
|
|
70
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
71
|
-
setLoading(false)
|
|
72
|
-
})
|
|
73
|
-
return () => {
|
|
74
|
-
active = false
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
setItems(result)
|
|
78
|
-
setError(null)
|
|
79
|
-
setLoading(false)
|
|
80
|
-
}, [contentProvider])
|
|
81
|
-
|
|
82
|
-
// Derived state: filtered items computed from items + filterQuery (no extra render cycle)
|
|
83
|
-
const filteredItems = useMemo(() => fuzzyFilter(items, filterQuery), [items, filterQuery])
|
|
84
|
-
|
|
85
|
-
// Reset activeIndex when filter changes (render-time state adjustment)
|
|
86
|
-
const [prevFilterQuery, setPrevFilterQuery] = useState("")
|
|
87
|
-
if (filterQuery !== prevFilterQuery) {
|
|
88
|
-
setPrevFilterQuery(filterQuery)
|
|
89
|
-
setActiveIndex(0)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
43
|
const { name: themeName, picker: themePicker } = useThemeCommands()
|
|
93
44
|
useQuitCommand({ onQuit: () => onCancel?.() })
|
|
94
|
-
|
|
95
|
-
const setMode = useSetMode()
|
|
45
|
+
|
|
96
46
|
const hasOverlay = useHasOverlay()
|
|
97
47
|
const hasModalOverlay = useHasModalOverlay()
|
|
98
48
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
49
|
+
const choose = useChoose({
|
|
50
|
+
source: contentProvider,
|
|
51
|
+
multi,
|
|
52
|
+
onSubmit: (result) => {
|
|
53
|
+
// Historical standalone behaviour: a command named `submit` wins over
|
|
54
|
+
// the deprecated callback, so existing action-driven CLIs are unchanged.
|
|
55
|
+
if (effectiveCommands?.some((action) => action.id === "submit")) {
|
|
56
|
+
invoke("submit")
|
|
57
|
+
return
|
|
105
58
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const match = filteredItems[activeIndex]
|
|
109
|
-
return match ? [match.item] : []
|
|
110
|
-
}, [multi, selectedIndices, items, filteredItems, activeIndex])
|
|
111
|
-
|
|
112
|
-
useProvideCommandContext(() => ({
|
|
113
|
-
choose: {
|
|
114
|
-
activeItem: filteredItems[activeIndex]?.item,
|
|
115
|
-
selectedItems: getSelectedItems(),
|
|
116
|
-
filterQuery,
|
|
59
|
+
if (multi || result.items.length > 0) onConfirm?.(result)
|
|
60
|
+
else onCancel?.()
|
|
117
61
|
},
|
|
118
|
-
exit: () => onCancel?.(),
|
|
119
|
-
}))
|
|
120
|
-
|
|
121
|
-
useActions(actions)
|
|
122
|
-
|
|
123
|
-
const moveUp = useCallback(() => {
|
|
124
|
-
setActiveIndex((i) => Math.max(0, i - 1))
|
|
125
|
-
}, [])
|
|
126
|
-
|
|
127
|
-
const moveDown = useCallback(() => {
|
|
128
|
-
setActiveIndex((i) => Math.min(filteredItems.length - 1, i + 1))
|
|
129
|
-
}, [filteredItems.length])
|
|
130
|
-
|
|
131
|
-
// Register a/i to return to insert mode from cursor mode
|
|
132
|
-
useCommand({
|
|
133
|
-
id: "choose:insert-mode-a",
|
|
134
|
-
title: "Insert mode",
|
|
135
|
-
hotkey: "a",
|
|
136
|
-
modes: ["cursor"],
|
|
137
|
-
handler: () => setMode("insert"),
|
|
138
|
-
hidden: true,
|
|
139
|
-
})
|
|
140
|
-
useCommand({
|
|
141
|
-
id: "choose:insert-mode-i",
|
|
142
|
-
title: "Insert mode",
|
|
143
|
-
hotkey: "i",
|
|
144
|
-
modes: ["cursor"],
|
|
145
|
-
handler: () => setMode("insert"),
|
|
146
|
-
hidden: true,
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
// Register j/k for vim-style navigation in cursor mode
|
|
150
|
-
useCommand({
|
|
151
|
-
id: "choose:move-down",
|
|
152
|
-
title: "Move down",
|
|
153
|
-
hotkey: "j",
|
|
154
|
-
modes: ["cursor"],
|
|
155
|
-
handler: moveDown,
|
|
156
|
-
hidden: true,
|
|
157
|
-
})
|
|
158
|
-
useCommand({
|
|
159
|
-
id: "choose:move-up",
|
|
160
|
-
title: "Move up",
|
|
161
|
-
hotkey: "k",
|
|
162
|
-
modes: ["cursor"],
|
|
163
|
-
handler: moveUp,
|
|
164
|
-
hidden: true,
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
const toggleSelection = useCallback(
|
|
168
|
-
(index: number) => {
|
|
169
|
-
setSelectedIndices((prev) => {
|
|
170
|
-
const next = new Set(prev)
|
|
171
|
-
const origIndex = filteredItems[index]?.originalIndex
|
|
172
|
-
if (origIndex === undefined) return prev
|
|
173
|
-
if (next.has(origIndex)) {
|
|
174
|
-
next.delete(origIndex)
|
|
175
|
-
} else {
|
|
176
|
-
next.add(origIndex)
|
|
177
|
-
}
|
|
178
|
-
return next
|
|
179
|
-
})
|
|
180
|
-
},
|
|
181
|
-
[filteredItems],
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
const confirm = useCallback(() => {
|
|
185
|
-
// If there's a "submit" action, invoke it via the command system
|
|
186
|
-
if (actions?.some((a) => a.id === "submit")) {
|
|
187
|
-
invoke("submit")
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Legacy: use onConfirm/onCancel callbacks
|
|
192
|
-
if (multi) {
|
|
193
|
-
const selected = Array.from(selectedIndices).map((i) => items[i])
|
|
194
|
-
if (selected.length === 0 && filteredItems[activeIndex]) {
|
|
195
|
-
onConfirm?.({ items: [filteredItems[activeIndex].item] })
|
|
196
|
-
} else {
|
|
197
|
-
onConfirm?.({ items: selected })
|
|
198
|
-
}
|
|
199
|
-
} else {
|
|
200
|
-
const match = filteredItems[activeIndex]
|
|
201
|
-
if (match) {
|
|
202
|
-
onConfirm?.({ items: [match.item] })
|
|
203
|
-
} else {
|
|
204
|
-
onCancel?.()
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}, [
|
|
208
|
-
multi,
|
|
209
|
-
selectedIndices,
|
|
210
|
-
items,
|
|
211
|
-
filteredItems,
|
|
212
|
-
activeIndex,
|
|
213
|
-
onConfirm,
|
|
214
62
|
onCancel,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
])
|
|
218
|
-
|
|
219
|
-
useKeyboard((key) => {
|
|
220
|
-
// Raw useKeyboard handlers subscribe before the command dispatcher, so
|
|
221
|
-
// preventDefault/surface arbitration cannot protect them. While any
|
|
222
|
-
// overlay (e.g. the theme picker) is open, its modal command surface owns
|
|
223
|
-
// input and this handler must stand down.
|
|
224
|
-
if (hasOverlay) return
|
|
225
|
-
if (key.name === "escape") {
|
|
226
|
-
if (mode === "insert") {
|
|
227
|
-
// Switch to cursor mode (allows theme switching, quit, etc.)
|
|
228
|
-
setMode("cursor")
|
|
229
|
-
} else {
|
|
230
|
-
// In cursor mode, escape cancels
|
|
231
|
-
onCancel?.()
|
|
232
|
-
}
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
if (key.name === "return") {
|
|
236
|
-
confirm()
|
|
237
|
-
return
|
|
238
|
-
}
|
|
239
|
-
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
|
240
|
-
key.preventDefault()
|
|
241
|
-
moveUp()
|
|
242
|
-
return
|
|
243
|
-
}
|
|
244
|
-
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
|
245
|
-
key.preventDefault()
|
|
246
|
-
moveDown()
|
|
247
|
-
return
|
|
248
|
-
}
|
|
249
|
-
if (multi && key.name === "tab") {
|
|
250
|
-
if (key.shift) {
|
|
251
|
-
toggleSelection(activeIndex)
|
|
252
|
-
moveUp()
|
|
253
|
-
} else {
|
|
254
|
-
toggleSelection(activeIndex)
|
|
255
|
-
moveDown()
|
|
256
|
-
}
|
|
257
|
-
return
|
|
258
|
-
}
|
|
63
|
+
commands: effectiveCommands,
|
|
64
|
+
suspended: hasOverlay,
|
|
259
65
|
})
|
|
260
66
|
|
|
261
|
-
|
|
262
|
-
useEffect(() => {
|
|
263
|
-
if (scrollRef.current && filteredItems.length > 0) {
|
|
264
|
-
// +1 accounts for the filter input row at the top of scroll content
|
|
265
|
-
scrollRef.current.scrollTop = Math.max(0, activeIndex + 1 - 5)
|
|
266
|
-
}
|
|
267
|
-
}, [activeIndex, filteredItems.length])
|
|
67
|
+
useImperativeHandle(controllerRef, () => choose.controller, [choose.controller])
|
|
268
68
|
|
|
269
|
-
if (loading) {
|
|
69
|
+
if (choose.state.loading) {
|
|
270
70
|
return (
|
|
271
71
|
<box>
|
|
272
72
|
<text content="Loading..." fg={theme.textMuted} />
|
|
@@ -274,21 +74,15 @@ export function Choose({ contentProvider, options, actions, onConfirm, onCancel
|
|
|
274
74
|
)
|
|
275
75
|
}
|
|
276
76
|
|
|
277
|
-
if (error) {
|
|
77
|
+
if (choose.state.error) {
|
|
278
78
|
return (
|
|
279
79
|
<box>
|
|
280
|
-
<text content={`Error: ${error}`} fg={theme.error} />
|
|
80
|
+
<text content={`Error: ${choose.state.error}`} fg={theme.error} />
|
|
281
81
|
</box>
|
|
282
82
|
)
|
|
283
83
|
}
|
|
284
84
|
|
|
285
|
-
const
|
|
286
|
-
const hintParts =
|
|
287
|
-
mode === "insert"
|
|
288
|
-
? ["↑↓ navigate", "Enter confirm", "Esc commands"]
|
|
289
|
-
: ["j/k navigate", "i insert", "Esc/q quit", "Enter confirm"]
|
|
290
|
-
if (multi && mode === "insert") hintParts.splice(2, 0, "Tab toggle")
|
|
291
|
-
const hint = hintParts.join(" ")
|
|
85
|
+
const hints = buildChooseHints(choose.view.mode, { multi })
|
|
292
86
|
|
|
293
87
|
return (
|
|
294
88
|
<AppLayout
|
|
@@ -299,13 +93,22 @@ export function Choose({ contentProvider, options, actions, onConfirm, onCancel
|
|
|
299
93
|
}
|
|
300
94
|
statusBar={{
|
|
301
95
|
items: [
|
|
302
|
-
{
|
|
303
|
-
|
|
96
|
+
{
|
|
97
|
+
label: "Matches:",
|
|
98
|
+
value: `${choose.state.matches.length}/${choose.state.items.length}`,
|
|
99
|
+
},
|
|
100
|
+
...(multi
|
|
101
|
+
? [
|
|
102
|
+
{
|
|
103
|
+
label: "Selected:",
|
|
104
|
+
value: String(choose.state.selectedOriginalIndices.size),
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
: []),
|
|
304
108
|
{ label: "Theme:", value: themeName },
|
|
305
|
-
{ label: "", value:
|
|
109
|
+
{ label: "", value: hints.join(" ") },
|
|
306
110
|
],
|
|
307
111
|
}}
|
|
308
|
-
scrollRef={scrollRef}
|
|
309
112
|
scrollProps={{ focused: false }}
|
|
310
113
|
overlay={
|
|
311
114
|
themePicker.isOpen ? (
|
|
@@ -319,106 +122,16 @@ export function Choose({ contentProvider, options, actions, onConfirm, onCancel
|
|
|
319
122
|
) : undefined
|
|
320
123
|
}
|
|
321
124
|
>
|
|
322
|
-
<box flexDirection="column">
|
|
323
|
-
{
|
|
324
|
-
<
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
textColor={theme.text}
|
|
332
|
-
placeholderColor={theme.textMuted}
|
|
333
|
-
cursorColor={theme.primary}
|
|
334
|
-
style={{ flexGrow: 1 }}
|
|
335
|
-
/>
|
|
336
|
-
<text content={` ${filteredItems.length}/${items.length}`} fg={theme.textMuted} />
|
|
337
|
-
</box>
|
|
338
|
-
|
|
339
|
-
{/* Empty state message */}
|
|
340
|
-
{filteredItems.length === 0 && !loading && options?.emptyMessage && (
|
|
341
|
-
<box height={1} style={{ paddingLeft: 2, paddingTop: 1 }}>
|
|
342
|
-
<text content={options.emptyMessage} fg={theme.textMuted} />
|
|
343
|
-
</box>
|
|
344
|
-
)}
|
|
345
|
-
|
|
346
|
-
{/* Item list */}
|
|
347
|
-
{filteredItems.map((match, idx) => {
|
|
348
|
-
const isActive = idx === activeIndex
|
|
349
|
-
const isSelected = selectedIndices.has(match.originalIndex)
|
|
350
|
-
return (
|
|
351
|
-
<box
|
|
352
|
-
key={match.originalIndex}
|
|
353
|
-
flexDirection="row"
|
|
354
|
-
height={1}
|
|
355
|
-
backgroundColor={isActive ? theme.backgroundElement : undefined}
|
|
356
|
-
style={{ paddingLeft: 1 }}
|
|
357
|
-
onMouseDown={(event) => {
|
|
358
|
-
// Like the raw keyboard handler above, row clicks must stand
|
|
359
|
-
// down while a modal overlay (e.g. the theme picker) is up:
|
|
360
|
-
// centered overlays leave clickable margins around them, and
|
|
361
|
-
// mouse events route through the hit-grid, bypassing
|
|
362
|
-
// command-surface arbitration entirely.
|
|
363
|
-
if (hasModalOverlay) return
|
|
364
|
-
if (event.button === 0) setActiveIndex(idx)
|
|
365
|
-
}}
|
|
366
|
-
>
|
|
367
|
-
{multi && (
|
|
368
|
-
<text
|
|
369
|
-
content={isSelected ? "✓ " : " "}
|
|
370
|
-
fg={isSelected ? theme.accent : theme.textMuted}
|
|
371
|
-
/>
|
|
372
|
-
)}
|
|
373
|
-
{match.item.icon && <text content={`${match.item.icon} `} fg={theme.textMuted} />}
|
|
374
|
-
<text fg={isActive ? theme.primary : theme.text}>
|
|
375
|
-
{renderHighlightedText(match.item.text, match.positions, theme.warning)}
|
|
376
|
-
</text>
|
|
377
|
-
{match.item.description && (
|
|
378
|
-
<text content={` ${match.item.description}`} fg={theme.textMuted} />
|
|
379
|
-
)}
|
|
380
|
-
</box>
|
|
381
|
-
)
|
|
382
|
-
})}
|
|
125
|
+
<box flexDirection="column" style={{ flexGrow: 1 }}>
|
|
126
|
+
<ChooseFilter choose={choose} placeholder={options?.placeholder} />
|
|
127
|
+
<ChooseList
|
|
128
|
+
choose={choose}
|
|
129
|
+
rowClick="activate"
|
|
130
|
+
renderItem={renderItem}
|
|
131
|
+
emptyContent={options?.emptyMessage}
|
|
132
|
+
suspended={hasModalOverlay}
|
|
133
|
+
/>
|
|
383
134
|
</box>
|
|
384
135
|
</AppLayout>
|
|
385
136
|
)
|
|
386
137
|
}
|
|
387
|
-
|
|
388
|
-
function renderHighlightedText(text: string, positions: number[], highlightColor: string) {
|
|
389
|
-
if (positions.length === 0) {
|
|
390
|
-
return text
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const posSet = new Set(positions)
|
|
394
|
-
const parts: Array<{ text: string; highlight: boolean }> = []
|
|
395
|
-
let current = ""
|
|
396
|
-
let currentHighlight = false
|
|
397
|
-
|
|
398
|
-
for (let i = 0; i < text.length; i++) {
|
|
399
|
-
const isHighlight = posSet.has(i)
|
|
400
|
-
if (i === 0) {
|
|
401
|
-
currentHighlight = isHighlight
|
|
402
|
-
current = text[i]
|
|
403
|
-
} else if (isHighlight === currentHighlight) {
|
|
404
|
-
current += text[i]
|
|
405
|
-
} else {
|
|
406
|
-
parts.push({ text: current, highlight: currentHighlight })
|
|
407
|
-
current = text[i]
|
|
408
|
-
currentHighlight = isHighlight
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
if (current) {
|
|
412
|
-
parts.push({ text: current, highlight: currentHighlight })
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return parts.map((part, i) =>
|
|
416
|
-
part.highlight ? (
|
|
417
|
-
<span key={i} fg={highlightColor}>
|
|
418
|
-
{part.text}
|
|
419
|
-
</span>
|
|
420
|
-
) : (
|
|
421
|
-
part.text
|
|
422
|
-
),
|
|
423
|
-
)
|
|
424
|
-
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { CloseButton, useTheme } from "@tooee/themes"
|
|
3
|
+
import type { UseChooseResult } from "./use-choose.js"
|
|
4
|
+
|
|
5
|
+
export interface ChooseFilterProps {
|
|
6
|
+
choose: UseChooseResult
|
|
7
|
+
prompt?: ReactNode
|
|
8
|
+
placeholder?: string
|
|
9
|
+
right?: ReactNode
|
|
10
|
+
onClose?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Controlled filter row shared by fullscreen and overlay chooser assemblies. */
|
|
14
|
+
export function ChooseFilter({
|
|
15
|
+
choose,
|
|
16
|
+
prompt = "> ",
|
|
17
|
+
placeholder = "Filter...",
|
|
18
|
+
right,
|
|
19
|
+
onClose,
|
|
20
|
+
}: ChooseFilterProps) {
|
|
21
|
+
const { theme } = useTheme()
|
|
22
|
+
const { state, view } = choose
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<box flexDirection="row" height={1} style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
26
|
+
{typeof prompt === "string" ? <text content={prompt} fg={theme.accent} /> : prompt}
|
|
27
|
+
<input
|
|
28
|
+
ref={view.filterRef}
|
|
29
|
+
focused={view.filterFocused}
|
|
30
|
+
value={state.filterQuery}
|
|
31
|
+
placeholder={placeholder}
|
|
32
|
+
onInput={view.onFilterInput}
|
|
33
|
+
onKeyDown={view.onFilterKeyDown}
|
|
34
|
+
backgroundColor="transparent"
|
|
35
|
+
textColor={theme.text}
|
|
36
|
+
placeholderColor={theme.textMuted}
|
|
37
|
+
cursorColor={theme.primary}
|
|
38
|
+
style={{ flexGrow: 1 }}
|
|
39
|
+
/>
|
|
40
|
+
<text content={` ${state.matches.length}/${state.items.length}`} fg={theme.textMuted} />
|
|
41
|
+
{right}
|
|
42
|
+
{onClose && <CloseButton onClose={onClose} />}
|
|
43
|
+
</box>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment, type ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
export interface ChooseHighlightedTextProps {
|
|
4
|
+
text: string
|
|
5
|
+
positions: number[]
|
|
6
|
+
highlightColor: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Render fuzzy-match characters with one shared segmentation implementation. */
|
|
10
|
+
export function ChooseHighlightedText({
|
|
11
|
+
text,
|
|
12
|
+
positions,
|
|
13
|
+
highlightColor,
|
|
14
|
+
}: ChooseHighlightedTextProps): ReactNode {
|
|
15
|
+
if (positions.length === 0) return text
|
|
16
|
+
|
|
17
|
+
const positionSet = new Set(positions)
|
|
18
|
+
const parts: Array<{ text: string; highlighted: boolean }> = []
|
|
19
|
+
let current = ""
|
|
20
|
+
let currentHighlighted = false
|
|
21
|
+
|
|
22
|
+
for (let index = 0; index < text.length; index++) {
|
|
23
|
+
const highlighted = positionSet.has(index)
|
|
24
|
+
if (index === 0) {
|
|
25
|
+
current = text[index]
|
|
26
|
+
currentHighlighted = highlighted
|
|
27
|
+
} else if (highlighted === currentHighlighted) {
|
|
28
|
+
current += text[index]
|
|
29
|
+
} else {
|
|
30
|
+
parts.push({ text: current, highlighted: currentHighlighted })
|
|
31
|
+
current = text[index]
|
|
32
|
+
currentHighlighted = highlighted
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (current) parts.push({ text: current, highlighted: currentHighlighted })
|
|
36
|
+
|
|
37
|
+
return parts.map((part, index) => (
|
|
38
|
+
<Fragment key={index}>
|
|
39
|
+
{part.highlighted ? <span fg={highlightColor}>{part.text}</span> : part.text}
|
|
40
|
+
</Fragment>
|
|
41
|
+
))
|
|
42
|
+
}
|