@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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react"
|
|
2
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
3
|
+
import { useTheme } from "@tooee/themes"
|
|
4
|
+
import { ChooseHighlightedText } from "./ChooseHighlightedText.js"
|
|
5
|
+
import type { FuzzyMatch } from "./fuzzy.js"
|
|
6
|
+
import type { ChooseItem } from "./types.js"
|
|
7
|
+
import type { UseChooseResult } from "./use-choose.js"
|
|
8
|
+
|
|
9
|
+
export interface ChooseItemRenderContext {
|
|
10
|
+
item: ChooseItem
|
|
11
|
+
match: FuzzyMatch
|
|
12
|
+
index: number
|
|
13
|
+
originalIndex: number
|
|
14
|
+
positions: number[]
|
|
15
|
+
isActive: boolean
|
|
16
|
+
isSelected: boolean
|
|
17
|
+
defaultContent: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChooseListProps {
|
|
21
|
+
choose: UseChooseResult
|
|
22
|
+
/** Fullscreen defaults to activate; picker overlays default to submit. */
|
|
23
|
+
rowClick?: "activate" | "submit" | "none"
|
|
24
|
+
renderItem?: (context: ChooseItemRenderContext) => ReactNode
|
|
25
|
+
loadingContent?: ReactNode
|
|
26
|
+
errorContent?: (error: string) => ReactNode
|
|
27
|
+
emptyContent?: ReactNode
|
|
28
|
+
/** Mouse guard for visible rows covered by another surface. */
|
|
29
|
+
suspended?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ThemedLine({ content, color }: { content: ReactNode; color: string }) {
|
|
33
|
+
return (
|
|
34
|
+
<box height={1} style={{ paddingLeft: 2 }}>
|
|
35
|
+
{typeof content === "string" ? <text content={content} fg={color} /> : content}
|
|
36
|
+
</box>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function defaultErrorContent(error: string): ReactNode {
|
|
41
|
+
return `Error: ${error}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Scrollable chooser rows plus loading/error/empty presentation. */
|
|
45
|
+
export function ChooseList({
|
|
46
|
+
choose,
|
|
47
|
+
rowClick = "activate",
|
|
48
|
+
renderItem,
|
|
49
|
+
loadingContent = "Loading...",
|
|
50
|
+
errorContent = defaultErrorContent,
|
|
51
|
+
emptyContent,
|
|
52
|
+
suspended,
|
|
53
|
+
}: ChooseListProps) {
|
|
54
|
+
const { theme } = useTheme()
|
|
55
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
56
|
+
const { state, controller } = choose
|
|
57
|
+
const interactionSuspended = choose.view.suspended || (suspended ?? false)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (scrollRef.current && state.matches.length > 0) {
|
|
61
|
+
scrollRef.current.scrollTop = Math.max(0, state.activeIndex - 5)
|
|
62
|
+
}
|
|
63
|
+
}, [state.activeIndex, state.matches.length])
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<scrollbox ref={scrollRef} flexDirection="column" style={{ flexGrow: 1 }} focused={false}>
|
|
67
|
+
{state.loading && <ThemedLine content={loadingContent} color={theme.textMuted} />}
|
|
68
|
+
|
|
69
|
+
{!state.loading && state.error && (
|
|
70
|
+
<ThemedLine content={errorContent(state.error)} color={theme.error} />
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
{!state.loading && !state.error && state.matches.length === 0 && emptyContent != null && (
|
|
74
|
+
<ThemedLine content={emptyContent} color={theme.textMuted} />
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
{state.matches.map((match, index) => {
|
|
78
|
+
const isActive = index === state.activeIndex
|
|
79
|
+
const isSelected = state.selectedOriginalIndices.has(match.originalIndex)
|
|
80
|
+
const defaultContent = (
|
|
81
|
+
<>
|
|
82
|
+
{state.multi && (
|
|
83
|
+
<text
|
|
84
|
+
content={isSelected ? "✓ " : " "}
|
|
85
|
+
fg={isSelected ? theme.accent : theme.textMuted}
|
|
86
|
+
/>
|
|
87
|
+
)}
|
|
88
|
+
{match.item.icon && <text content={`${match.item.icon} `} fg={theme.textMuted} />}
|
|
89
|
+
<text fg={isActive ? theme.primary : theme.text}>
|
|
90
|
+
<ChooseHighlightedText
|
|
91
|
+
text={match.item.text}
|
|
92
|
+
positions={match.positions}
|
|
93
|
+
highlightColor={theme.warning}
|
|
94
|
+
/>
|
|
95
|
+
</text>
|
|
96
|
+
{match.item.description && (
|
|
97
|
+
<text content={` ${match.item.description}`} fg={theme.textMuted} />
|
|
98
|
+
)}
|
|
99
|
+
</>
|
|
100
|
+
)
|
|
101
|
+
const context: ChooseItemRenderContext = {
|
|
102
|
+
item: match.item,
|
|
103
|
+
match,
|
|
104
|
+
index,
|
|
105
|
+
originalIndex: match.originalIndex,
|
|
106
|
+
positions: match.positions,
|
|
107
|
+
isActive,
|
|
108
|
+
isSelected,
|
|
109
|
+
defaultContent,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<box
|
|
114
|
+
key={match.originalIndex}
|
|
115
|
+
flexDirection="row"
|
|
116
|
+
height={1}
|
|
117
|
+
backgroundColor={isActive ? theme.backgroundElement : undefined}
|
|
118
|
+
style={{ paddingLeft: 1 }}
|
|
119
|
+
onMouseDown={(event) => {
|
|
120
|
+
if (interactionSuspended || event.button !== 0 || rowClick === "none") return
|
|
121
|
+
if (rowClick === "submit") {
|
|
122
|
+
event.preventDefault()
|
|
123
|
+
event.stopPropagation()
|
|
124
|
+
controller.setActiveIndex(index)
|
|
125
|
+
controller.submit()
|
|
126
|
+
} else {
|
|
127
|
+
controller.setActiveIndex(index)
|
|
128
|
+
}
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{renderItem ? renderItem(context) : defaultContent}
|
|
132
|
+
</box>
|
|
133
|
+
)
|
|
134
|
+
})}
|
|
135
|
+
</scrollbox>
|
|
136
|
+
)
|
|
137
|
+
}
|
package/src/ChooseOverlay.tsx
CHANGED
|
@@ -1,322 +1,88 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { useImperativeHandle, type ReactNode, type Ref } from "react"
|
|
2
|
+
import type { ActionDefinition } from "@tooee/commands"
|
|
3
|
+
import { ChooseFilter } from "./ChooseFilter.js"
|
|
4
|
+
import { ChooseList, type ChooseListProps } from "./ChooseList.js"
|
|
5
|
+
import { ChoosePanel, type ChoosePanelProps } from "./ChoosePanel.js"
|
|
6
|
+
import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js"
|
|
7
|
+
import { useChoose, type ChooseController } from "./use-choose.js"
|
|
8
|
+
|
|
9
|
+
interface ChooseOverlayBaseProps {
|
|
10
|
+
items: ChooseSource
|
|
10
11
|
prompt?: string
|
|
11
12
|
placeholder?: string
|
|
12
13
|
emptyMessage?: string
|
|
13
|
-
onSelect: (item: ChooseItem) => void | Promise<void>
|
|
14
14
|
onCancel: () => void
|
|
15
|
+
commands?: ActionDefinition[]
|
|
16
|
+
controllerRef?: Ref<ChooseController>
|
|
17
|
+
renderItem?: ChooseListProps["renderItem"]
|
|
18
|
+
hints?: ChoosePanelProps["hints"]
|
|
19
|
+
statusRight?: ReactNode
|
|
20
|
+
footer?: ReactNode
|
|
21
|
+
inset?: ChoosePanelProps["inset"]
|
|
22
|
+
/** Nested modal content rendered above the chooser panel. */
|
|
23
|
+
children?: ReactNode
|
|
24
|
+
/** Explicit interaction guard for legacy child overlays without a command surface. */
|
|
25
|
+
suspended?: boolean
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
onCancel,
|
|
24
|
-
}: ChooseOverlayProps) {
|
|
25
|
-
const { theme } = useTheme()
|
|
26
|
-
const mode = useMode()
|
|
27
|
-
const setMode = useSetMode()
|
|
28
|
-
|
|
29
|
-
const [items, setItems] = useState<ChooseItem[]>(
|
|
30
|
-
Array.isArray(itemsOrLoader) ? itemsOrLoader : [],
|
|
31
|
-
)
|
|
32
|
-
const [loading, setLoading] = useState(!Array.isArray(itemsOrLoader))
|
|
33
|
-
const [error, setError] = useState<string | null>(null)
|
|
34
|
-
const [filterQuery, setFilterQuery] = useState("")
|
|
35
|
-
const [activeIndex, setActiveIndex] = useState(0)
|
|
36
|
-
const scrollRef = useRef<ScrollBoxRenderable>(null)
|
|
37
|
-
|
|
38
|
-
// Load items if async loader provided
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (typeof itemsOrLoader === "function") {
|
|
41
|
-
let active = true
|
|
42
|
-
setLoading(true)
|
|
43
|
-
setError(null)
|
|
44
|
-
itemsOrLoader()
|
|
45
|
-
.then((loaded) => {
|
|
46
|
-
// Ignore stale results after itemsOrLoader changed
|
|
47
|
-
if (!active) return
|
|
48
|
-
setItems(loaded)
|
|
49
|
-
setLoading(false)
|
|
50
|
-
})
|
|
51
|
-
.catch((err: unknown) => {
|
|
52
|
-
if (!active) return
|
|
53
|
-
setItems([])
|
|
54
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
55
|
-
setLoading(false)
|
|
56
|
-
})
|
|
57
|
-
return () => {
|
|
58
|
-
active = false
|
|
28
|
+
export type ChooseOverlayProps = ChooseOverlayBaseProps &
|
|
29
|
+
(
|
|
30
|
+
| {
|
|
31
|
+
multi?: false
|
|
32
|
+
onSelect: (item: ChooseItem) => void | Promise<void>
|
|
33
|
+
onSubmit?: never
|
|
59
34
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// Reset activeIndex when filter changes
|
|
67
|
-
const [prevFilterQuery, setPrevFilterQuery] = useState("")
|
|
68
|
-
if (filterQuery !== prevFilterQuery) {
|
|
69
|
-
setPrevFilterQuery(filterQuery)
|
|
70
|
-
setActiveIndex(0)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const moveUp = useCallback(() => {
|
|
74
|
-
setActiveIndex((i) => Math.max(0, i - 1))
|
|
75
|
-
}, [])
|
|
76
|
-
|
|
77
|
-
const moveDown = useCallback(() => {
|
|
78
|
-
setActiveIndex((i) => Math.min(filteredItems.length - 1, i + 1))
|
|
79
|
-
}, [filteredItems.length])
|
|
80
|
-
|
|
81
|
-
const confirm = useCallback(() => {
|
|
82
|
-
const match = filteredItems[activeIndex]
|
|
83
|
-
if (match) {
|
|
84
|
-
onSelect(match.item)
|
|
85
|
-
}
|
|
86
|
-
}, [filteredItems, activeIndex, onSelect])
|
|
87
|
-
|
|
88
|
-
const leaveInsertModeOrCancel = useCallback(() => {
|
|
89
|
-
if (mode === "insert") {
|
|
90
|
-
setMode("cursor")
|
|
91
|
-
} else {
|
|
92
|
-
onCancel()
|
|
93
|
-
}
|
|
94
|
-
}, [mode, onCancel, setMode])
|
|
95
|
-
|
|
96
|
-
const enterInsertMode = useCallback(() => {
|
|
97
|
-
setMode("insert")
|
|
98
|
-
}, [setMode])
|
|
35
|
+
| {
|
|
36
|
+
multi: true
|
|
37
|
+
onSubmit: (result: ChooseResult) => void | Promise<void>
|
|
38
|
+
onSelect?: never
|
|
39
|
+
}
|
|
40
|
+
)
|
|
99
41
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
title: "Insert mode",
|
|
119
|
-
hotkey: "a",
|
|
120
|
-
modes: ["cursor"],
|
|
121
|
-
hidden: true,
|
|
122
|
-
handler: enterInsertMode,
|
|
123
|
-
})
|
|
124
|
-
useCommand({
|
|
125
|
-
id: "choose-overlay:move-down-vim",
|
|
126
|
-
title: "Move down",
|
|
127
|
-
hotkey: "j",
|
|
128
|
-
modes: ["cursor"],
|
|
129
|
-
hidden: true,
|
|
130
|
-
handler: moveDown,
|
|
131
|
-
})
|
|
132
|
-
useCommand({
|
|
133
|
-
id: "choose-overlay:move-up-vim",
|
|
134
|
-
title: "Move up",
|
|
135
|
-
hotkey: "k",
|
|
136
|
-
modes: ["cursor"],
|
|
137
|
-
hidden: true,
|
|
138
|
-
handler: moveUp,
|
|
139
|
-
})
|
|
140
|
-
useCommand({
|
|
141
|
-
id: "choose-overlay:confirm",
|
|
142
|
-
title: "Confirm",
|
|
143
|
-
hotkey: "Enter",
|
|
144
|
-
modes: ["insert", "cursor"],
|
|
145
|
-
hidden: true,
|
|
146
|
-
handler: confirm,
|
|
42
|
+
/**
|
|
43
|
+
* Composed picker surface. Hosts should mount it on an owned modal command
|
|
44
|
+
* surface (as Tooee's overlay manager does) so nested surfaces suspend it.
|
|
45
|
+
*/
|
|
46
|
+
export function ChooseOverlay(props: ChooseOverlayProps) {
|
|
47
|
+
const multi = props.multi === true
|
|
48
|
+
const choose = useChoose({
|
|
49
|
+
source: props.items,
|
|
50
|
+
multi,
|
|
51
|
+
onSubmit: (result) => {
|
|
52
|
+
if (multi) return props.onSubmit(result)
|
|
53
|
+
const item = result.items[0]
|
|
54
|
+
if (item) return props.onSelect(item)
|
|
55
|
+
},
|
|
56
|
+
onCancel: props.onCancel,
|
|
57
|
+
commands: props.commands,
|
|
58
|
+
commandScope: "choose-overlay",
|
|
59
|
+
suspended: props.suspended,
|
|
147
60
|
})
|
|
148
|
-
useCommand({
|
|
149
|
-
id: "choose-overlay:move-up",
|
|
150
|
-
title: "Move up",
|
|
151
|
-
hotkey: "up",
|
|
152
|
-
modes: ["insert", "cursor"],
|
|
153
|
-
hidden: true,
|
|
154
|
-
handler: moveUp,
|
|
155
|
-
})
|
|
156
|
-
useCommand({
|
|
157
|
-
id: "choose-overlay:move-up-ctrl-p",
|
|
158
|
-
title: "Move up",
|
|
159
|
-
hotkey: "ctrl+p",
|
|
160
|
-
modes: ["insert", "cursor"],
|
|
161
|
-
hidden: true,
|
|
162
|
-
handler: moveUp,
|
|
163
|
-
})
|
|
164
|
-
useCommand({
|
|
165
|
-
id: "choose-overlay:move-down",
|
|
166
|
-
title: "Move down",
|
|
167
|
-
hotkey: "down",
|
|
168
|
-
modes: ["insert", "cursor"],
|
|
169
|
-
hidden: true,
|
|
170
|
-
handler: moveDown,
|
|
171
|
-
})
|
|
172
|
-
useCommand({
|
|
173
|
-
id: "choose-overlay:move-down-ctrl-n",
|
|
174
|
-
title: "Move down",
|
|
175
|
-
hotkey: "ctrl+n",
|
|
176
|
-
modes: ["insert", "cursor"],
|
|
177
|
-
hidden: true,
|
|
178
|
-
handler: moveDown,
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
// Auto-scroll to keep active item visible
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
if (scrollRef.current && filteredItems.length > 0) {
|
|
184
|
-
scrollRef.current.scrollTop = Math.max(0, activeIndex - 5)
|
|
185
|
-
}
|
|
186
|
-
}, [activeIndex, filteredItems.length])
|
|
187
61
|
|
|
188
|
-
|
|
189
|
-
mode === "insert" ? "Enter confirm Esc commands" : "j/k navigate i insert Esc cancel"
|
|
62
|
+
useImperativeHandle(props.controllerRef, () => choose.controller, [choose.controller])
|
|
190
63
|
|
|
191
64
|
return (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{/* Filter input row */}
|
|
211
|
-
<box flexDirection="row" height={1} style={{ paddingLeft: 1, paddingRight: 1 }}>
|
|
212
|
-
<text content="> " fg={theme.accent} />
|
|
213
|
-
<input
|
|
214
|
-
focused={mode === "insert"}
|
|
215
|
-
placeholder={placeholder ?? "Filter..."}
|
|
216
|
-
onInput={setFilterQuery}
|
|
217
|
-
backgroundColor="transparent"
|
|
218
|
-
textColor={theme.text}
|
|
219
|
-
placeholderColor={theme.textMuted}
|
|
220
|
-
cursorColor={theme.primary}
|
|
221
|
-
style={{ flexGrow: 1 }}
|
|
65
|
+
<>
|
|
66
|
+
<ChoosePanel
|
|
67
|
+
title={props.prompt}
|
|
68
|
+
filter={
|
|
69
|
+
<ChooseFilter choose={choose} placeholder={props.placeholder} onClose={props.onCancel} />
|
|
70
|
+
}
|
|
71
|
+
multi={multi}
|
|
72
|
+
hints={props.hints}
|
|
73
|
+
statusRight={props.statusRight}
|
|
74
|
+
footer={props.footer}
|
|
75
|
+
inset={props.inset}
|
|
76
|
+
>
|
|
77
|
+
<ChooseList
|
|
78
|
+
choose={choose}
|
|
79
|
+
rowClick="submit"
|
|
80
|
+
renderItem={props.renderItem}
|
|
81
|
+
emptyContent={props.emptyMessage}
|
|
82
|
+
suspended={props.suspended}
|
|
222
83
|
/>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
{/* Scrollable item list */}
|
|
228
|
-
<scrollbox ref={scrollRef} flexDirection="column" style={{ flexGrow: 1 }} focused={false}>
|
|
229
|
-
{loading && (
|
|
230
|
-
<box height={1} style={{ paddingLeft: 2 }}>
|
|
231
|
-
<text content="Loading..." fg={theme.textMuted} />
|
|
232
|
-
</box>
|
|
233
|
-
)}
|
|
234
|
-
|
|
235
|
-
{!loading && error && (
|
|
236
|
-
<box height={1} style={{ paddingLeft: 2 }}>
|
|
237
|
-
<text content={`Error: ${error}`} fg={theme.error} />
|
|
238
|
-
</box>
|
|
239
|
-
)}
|
|
240
|
-
|
|
241
|
-
{!loading && !error && filteredItems.length === 0 && emptyMessage && (
|
|
242
|
-
<box height={1} style={{ paddingLeft: 2 }}>
|
|
243
|
-
<text content={emptyMessage} fg={theme.textMuted} />
|
|
244
|
-
</box>
|
|
245
|
-
)}
|
|
246
|
-
|
|
247
|
-
{filteredItems.map((match, idx) => {
|
|
248
|
-
const isActive = idx === activeIndex
|
|
249
|
-
return (
|
|
250
|
-
<box
|
|
251
|
-
key={match.originalIndex}
|
|
252
|
-
flexDirection="row"
|
|
253
|
-
height={1}
|
|
254
|
-
backgroundColor={isActive ? theme.backgroundElement : undefined}
|
|
255
|
-
style={{ paddingLeft: 1 }}
|
|
256
|
-
onMouseDown={(event) => {
|
|
257
|
-
// Left-click picks the item — same code path as Enter confirm.
|
|
258
|
-
// (Standalone Choose keeps click-to-highlight; a modal picker
|
|
259
|
-
// overlay follows menu semantics like ContextMenu instead.)
|
|
260
|
-
if (event.button !== 0) return
|
|
261
|
-
event.preventDefault()
|
|
262
|
-
event.stopPropagation()
|
|
263
|
-
onSelect(match.item)
|
|
264
|
-
}}
|
|
265
|
-
>
|
|
266
|
-
{match.item.icon && <text content={`${match.item.icon} `} fg={theme.textMuted} />}
|
|
267
|
-
<text fg={isActive ? theme.primary : theme.text}>
|
|
268
|
-
{renderHighlightedText(match.item.text, match.positions, theme.warning)}
|
|
269
|
-
</text>
|
|
270
|
-
{match.item.description && (
|
|
271
|
-
<text content={` ${match.item.description}`} fg={theme.textMuted} />
|
|
272
|
-
)}
|
|
273
|
-
</box>
|
|
274
|
-
)
|
|
275
|
-
})}
|
|
276
|
-
</scrollbox>
|
|
277
|
-
|
|
278
|
-
{/* Hint line */}
|
|
279
|
-
<box height={1} paddingLeft={1} paddingRight={1} backgroundColor={theme.backgroundElement}>
|
|
280
|
-
<text content={hintText} fg={theme.textMuted} />
|
|
281
|
-
</box>
|
|
282
|
-
</box>
|
|
283
|
-
)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function renderHighlightedText(text: string, positions: number[], highlightColor: string) {
|
|
287
|
-
if (positions.length === 0) {
|
|
288
|
-
return text
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const posSet = new Set(positions)
|
|
292
|
-
const parts: Array<{ text: string; highlight: boolean }> = []
|
|
293
|
-
let current = ""
|
|
294
|
-
let currentHighlight = false
|
|
295
|
-
|
|
296
|
-
for (let i = 0; i < text.length; i++) {
|
|
297
|
-
const isHighlight = posSet.has(i)
|
|
298
|
-
if (i === 0) {
|
|
299
|
-
currentHighlight = isHighlight
|
|
300
|
-
current = text[i]
|
|
301
|
-
} else if (isHighlight === currentHighlight) {
|
|
302
|
-
current += text[i]
|
|
303
|
-
} else {
|
|
304
|
-
parts.push({ text: current, highlight: currentHighlight })
|
|
305
|
-
current = text[i]
|
|
306
|
-
currentHighlight = isHighlight
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (current) {
|
|
310
|
-
parts.push({ text: current, highlight: currentHighlight })
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
return parts.map((part, i) =>
|
|
314
|
-
part.highlight ? (
|
|
315
|
-
<span key={i} fg={highlightColor}>
|
|
316
|
-
{part.text}
|
|
317
|
-
</span>
|
|
318
|
-
) : (
|
|
319
|
-
part.text
|
|
320
|
-
),
|
|
84
|
+
</ChoosePanel>
|
|
85
|
+
{props.children}
|
|
86
|
+
</>
|
|
321
87
|
)
|
|
322
88
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
import { useMode, type Mode } from "@tooee/commands"
|
|
3
|
+
import { CloseButton, useTheme } from "@tooee/themes"
|
|
4
|
+
|
|
5
|
+
export function buildChooseHints(
|
|
6
|
+
mode: Mode,
|
|
7
|
+
options: { multi?: boolean; extra?: string[] } = {},
|
|
8
|
+
): string[] {
|
|
9
|
+
const base =
|
|
10
|
+
mode === "insert"
|
|
11
|
+
? ["↑↓ navigate", "Enter confirm", ...(options.multi ? ["Tab toggle"] : []), "Esc commands"]
|
|
12
|
+
: [
|
|
13
|
+
"j/k navigate",
|
|
14
|
+
"i insert",
|
|
15
|
+
...(options.multi ? ["Tab toggle"] : []),
|
|
16
|
+
"Esc/q cancel",
|
|
17
|
+
"Enter confirm",
|
|
18
|
+
]
|
|
19
|
+
return options.extra ? [...base, ...options.extra] : base
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ChoosePanelInsetValue = number | "auto" | `${number}%`
|
|
23
|
+
|
|
24
|
+
export interface ChoosePanelInset {
|
|
25
|
+
left?: ChoosePanelInsetValue
|
|
26
|
+
right?: ChoosePanelInsetValue
|
|
27
|
+
top?: ChoosePanelInsetValue
|
|
28
|
+
bottom?: ChoosePanelInsetValue
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChoosePanelProps {
|
|
32
|
+
title?: ReactNode
|
|
33
|
+
filter: ReactNode
|
|
34
|
+
children: ReactNode
|
|
35
|
+
multi?: boolean
|
|
36
|
+
hints?: (context: { mode: Mode; defaults: string[] }) => ReactNode
|
|
37
|
+
statusRight?: ReactNode
|
|
38
|
+
footer?: ReactNode
|
|
39
|
+
onClose?: () => void
|
|
40
|
+
inset?: ChoosePanelInset
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bordered chooser chrome with filter, content, hint/status, and footer slots. */
|
|
44
|
+
export function ChoosePanel({
|
|
45
|
+
title,
|
|
46
|
+
filter,
|
|
47
|
+
children,
|
|
48
|
+
multi,
|
|
49
|
+
hints,
|
|
50
|
+
statusRight,
|
|
51
|
+
footer,
|
|
52
|
+
onClose,
|
|
53
|
+
inset,
|
|
54
|
+
}: ChoosePanelProps) {
|
|
55
|
+
const { theme } = useTheme()
|
|
56
|
+
const mode = useMode()
|
|
57
|
+
const defaults = buildChooseHints(mode, { multi })
|
|
58
|
+
const hintContent = hints ? hints({ mode, defaults }) : defaults.join(" ")
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<box
|
|
62
|
+
position="absolute"
|
|
63
|
+
left={inset?.left ?? "20%"}
|
|
64
|
+
right={inset?.right ?? "20%"}
|
|
65
|
+
top={inset?.top ?? "20%"}
|
|
66
|
+
bottom={inset?.bottom ?? "20%"}
|
|
67
|
+
flexDirection="column"
|
|
68
|
+
backgroundColor={theme.backgroundPanel}
|
|
69
|
+
border
|
|
70
|
+
borderColor={theme.borderActive}
|
|
71
|
+
>
|
|
72
|
+
{title != null && (
|
|
73
|
+
<box
|
|
74
|
+
flexDirection="row"
|
|
75
|
+
height={1}
|
|
76
|
+
paddingLeft={1}
|
|
77
|
+
paddingRight={1}
|
|
78
|
+
backgroundColor={theme.backgroundElement}
|
|
79
|
+
>
|
|
80
|
+
{typeof title === "string" ? (
|
|
81
|
+
<text content={title} fg={theme.accent} style={{ flexGrow: 1 }} />
|
|
82
|
+
) : (
|
|
83
|
+
<box flexDirection="row" style={{ flexGrow: 1 }}>
|
|
84
|
+
{title}
|
|
85
|
+
</box>
|
|
86
|
+
)}
|
|
87
|
+
{onClose && <CloseButton onClose={onClose} />}
|
|
88
|
+
</box>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{filter}
|
|
92
|
+
{children}
|
|
93
|
+
|
|
94
|
+
{footer != null && (
|
|
95
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
96
|
+
{footer}
|
|
97
|
+
</box>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<box
|
|
101
|
+
flexDirection="row"
|
|
102
|
+
height={1}
|
|
103
|
+
paddingLeft={1}
|
|
104
|
+
paddingRight={1}
|
|
105
|
+
backgroundColor={theme.backgroundElement}
|
|
106
|
+
>
|
|
107
|
+
<box flexDirection="row" style={{ flexGrow: 1 }}>
|
|
108
|
+
{typeof hintContent === "string" ? (
|
|
109
|
+
<text content={hintContent} fg={theme.textMuted} />
|
|
110
|
+
) : (
|
|
111
|
+
hintContent
|
|
112
|
+
)}
|
|
113
|
+
</box>
|
|
114
|
+
{statusRight != null &&
|
|
115
|
+
(typeof statusRight === "string" ? (
|
|
116
|
+
<text content={statusRight} fg={theme.textMuted} />
|
|
117
|
+
) : (
|
|
118
|
+
statusRight
|
|
119
|
+
))}
|
|
120
|
+
</box>
|
|
121
|
+
</box>
|
|
122
|
+
)
|
|
123
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
export { Choose } from "./Choose.js"
|
|
2
|
+
export type { ChooseProps } from "./Choose.js"
|
|
2
3
|
export { ChooseOverlay } from "./ChooseOverlay.js"
|
|
3
4
|
export type { ChooseOverlayProps } from "./ChooseOverlay.js"
|
|
5
|
+
export { ChoosePanel, buildChooseHints } from "./ChoosePanel.js"
|
|
6
|
+
export type { ChoosePanelProps, ChoosePanelInset, ChoosePanelInsetValue } from "./ChoosePanel.js"
|
|
7
|
+
export { ChooseFilter } from "./ChooseFilter.js"
|
|
8
|
+
export type { ChooseFilterProps } from "./ChooseFilter.js"
|
|
9
|
+
export { ChooseList } from "./ChooseList.js"
|
|
10
|
+
export type { ChooseItemRenderContext, ChooseListProps } from "./ChooseList.js"
|
|
11
|
+
export { ChooseHighlightedText } from "./ChooseHighlightedText.js"
|
|
12
|
+
export type { ChooseHighlightedTextProps } from "./ChooseHighlightedText.js"
|
|
13
|
+
export { useChoose } from "./use-choose.js"
|
|
14
|
+
export type {
|
|
15
|
+
ChooseCommandGroup,
|
|
16
|
+
ChooseController,
|
|
17
|
+
ChooseState,
|
|
18
|
+
ChooseViewModel,
|
|
19
|
+
UseChooseOptions,
|
|
20
|
+
UseChooseResult,
|
|
21
|
+
} from "./use-choose.js"
|
|
22
|
+
export { loadChooseSource } from "./source.js"
|
|
4
23
|
export { launch } from "./launch.js"
|
|
5
24
|
export type { ChooseLaunchOptions } from "./launch.js"
|
|
6
25
|
export { createStdinChooseProvider, createStaticProvider } from "./default-provider.js"
|
|
7
26
|
export { fuzzyFilter } from "./fuzzy.js"
|
|
8
27
|
export type { FuzzyMatch } from "./fuzzy.js"
|
|
9
|
-
export type {
|
|
28
|
+
export type {
|
|
29
|
+
ChooseItem,
|
|
30
|
+
ChooseContentProvider,
|
|
31
|
+
ChooseSource,
|
|
32
|
+
ChooseResult,
|
|
33
|
+
ChooseOptions,
|
|
34
|
+
} from "./types.js"
|