@tooee/choose 0.2.4 → 0.3.0
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 +2 -2
- package/dist/{ChooseFilter.d.ts → choose-filter.d.ts} +2 -2
- package/dist/choose-filter.d.ts.map +1 -0
- package/dist/choose-filter.js +11 -0
- package/dist/choose-filter.js.map +1 -0
- package/dist/choose-highlighted-text.d.ts +9 -0
- package/dist/choose-highlighted-text.d.ts.map +1 -0
- package/dist/{ChooseHighlightedText.js → choose-highlighted-text.js} +10 -8
- package/dist/choose-highlighted-text.js.map +1 -0
- package/dist/{ChooseList.d.ts → choose-list.d.ts} +3 -3
- package/dist/choose-list.d.ts.map +1 -0
- package/dist/{ChooseList.js → choose-list.js} +20 -15
- package/dist/choose-list.js.map +1 -0
- package/dist/{ChooseOverlay.d.ts → choose-overlay.d.ts} +6 -6
- package/dist/choose-overlay.d.ts.map +1 -0
- package/dist/{ChooseOverlay.js → choose-overlay.js} +15 -13
- package/dist/choose-overlay.js.map +1 -0
- package/dist/{ChoosePanel.d.ts → choose-panel.d.ts} +5 -5
- package/dist/choose-panel.d.ts.map +1 -0
- package/dist/choose-panel.js +31 -0
- package/dist/choose-panel.js.map +1 -0
- package/dist/choose.d.ts +28 -0
- package/dist/choose.d.ts.map +1 -0
- package/dist/{Choose.js → choose.js} +25 -18
- package/dist/choose.js.map +1 -0
- package/dist/default-provider.d.ts +2 -2
- package/dist/default-provider.d.ts.map +1 -1
- package/dist/default-provider.js +4 -4
- package/dist/default-provider.js.map +1 -1
- package/dist/fuzzy.d.ts +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +5 -5
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +1 -1
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +5 -4
- package/dist/launch.js.map +1 -1
- package/dist/source.d.ts +2 -2
- package/dist/source.d.ts.map +1 -1
- package/dist/source.js +8 -6
- package/dist/source.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-choose-dialog.d.ts +12 -10
- package/dist/use-choose-dialog.d.ts.map +1 -1
- package/dist/use-choose-dialog.js +98 -77
- package/dist/use-choose-dialog.js.map +1 -1
- package/dist/use-choose-dialog.typecheck.d.ts +1 -1
- package/dist/use-choose-dialog.typecheck.d.ts.map +1 -1
- package/dist/use-choose-dialog.typecheck.js +5 -5
- package/dist/use-choose-dialog.typecheck.js.map +1 -1
- package/dist/use-choose.d.ts +16 -16
- package/dist/use-choose.d.ts.map +1 -1
- package/dist/use-choose.js +176 -138
- package/dist/use-choose.js.map +1 -1
- package/package.json +7 -7
- package/src/{ChooseFilter.tsx → choose-filter.tsx} +18 -16
- package/src/choose-highlighted-text.tsx +49 -0
- package/src/{ChooseList.tsx → choose-list.tsx} +73 -60
- package/src/choose-overlay.tsx +96 -0
- package/src/{ChoosePanel.tsx → choose-panel.tsx} +41 -34
- package/src/choose.tsx +142 -0
- package/src/default-provider.ts +18 -15
- package/src/fuzzy.ts +16 -16
- package/src/index.ts +23 -23
- package/src/launch.tsx +15 -12
- package/src/source.ts +15 -9
- package/src/types.ts +12 -12
- package/src/use-choose-dialog.tsx +168 -139
- package/src/use-choose-dialog.typecheck.ts +19 -18
- package/src/use-choose.ts +349 -302
- package/dist/Choose.d.ts +0 -21
- package/dist/Choose.d.ts.map +0 -1
- package/dist/Choose.js.map +0 -1
- package/dist/ChooseFilter.d.ts.map +0 -1
- package/dist/ChooseFilter.js +0 -9
- package/dist/ChooseFilter.js.map +0 -1
- package/dist/ChooseHighlightedText.d.ts +0 -9
- package/dist/ChooseHighlightedText.d.ts.map +0 -1
- package/dist/ChooseHighlightedText.js.map +0 -1
- package/dist/ChooseList.d.ts.map +0 -1
- package/dist/ChooseList.js.map +0 -1
- package/dist/ChooseOverlay.d.ts.map +0 -1
- package/dist/ChooseOverlay.js.map +0 -1
- package/dist/ChoosePanel.d.ts.map +0 -1
- package/dist/ChoosePanel.js +0 -25
- package/dist/ChoosePanel.js.map +0 -1
- package/src/Choose.tsx +0 -126
- package/src/ChooseHighlightedText.tsx +0 -42
- package/src/ChooseOverlay.tsx +0 -88
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Fragment } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface ChooseHighlightedTextProps {
|
|
5
|
+
text: string;
|
|
6
|
+
positions: number[];
|
|
7
|
+
highlightColor: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Render fuzzy-match characters with one shared segmentation implementation. */
|
|
11
|
+
export const ChooseHighlightedText = function ChooseHighlightedText({
|
|
12
|
+
text,
|
|
13
|
+
positions,
|
|
14
|
+
highlightColor,
|
|
15
|
+
}: ChooseHighlightedTextProps): ReactNode {
|
|
16
|
+
if (positions.length === 0) {
|
|
17
|
+
return text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const positionSet = new Set(positions);
|
|
21
|
+
const parts: { text: string; highlighted: boolean }[] = [];
|
|
22
|
+
let current = "";
|
|
23
|
+
let currentHighlighted = false;
|
|
24
|
+
|
|
25
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
26
|
+
const highlighted = positionSet.has(index);
|
|
27
|
+
if (index === 0) {
|
|
28
|
+
current = text[index];
|
|
29
|
+
currentHighlighted = highlighted;
|
|
30
|
+
} else if (highlighted === currentHighlighted) {
|
|
31
|
+
current += text[index];
|
|
32
|
+
} else {
|
|
33
|
+
parts.push({ highlighted: currentHighlighted, text: current });
|
|
34
|
+
current = text[index];
|
|
35
|
+
currentHighlighted = highlighted;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (current) {
|
|
39
|
+
parts.push({ highlighted: currentHighlighted, text: current });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parts.map(
|
|
43
|
+
(part, index): ReactNode => (
|
|
44
|
+
<Fragment key={index}>
|
|
45
|
+
{part.highlighted ? <span fg={highlightColor}>{part.text}</span> : part.text}
|
|
46
|
+
</Fragment>
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -1,48 +1,55 @@
|
|
|
1
|
-
import { useEffect, useRef
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import type {
|
|
7
|
-
import type {
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
4
|
+
import { useTheme } from "@tooee/themes";
|
|
5
|
+
import { ChooseHighlightedText } from "./choose-highlighted-text.js";
|
|
6
|
+
import type { FuzzyMatch } from "./fuzzy.js";
|
|
7
|
+
import type { ChooseItem } from "./types.js";
|
|
8
|
+
import type { UseChooseResult } from "./use-choose.js";
|
|
8
9
|
|
|
9
10
|
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
|
|
11
|
+
item: ChooseItem;
|
|
12
|
+
match: FuzzyMatch;
|
|
13
|
+
index: number;
|
|
14
|
+
originalIndex: number;
|
|
15
|
+
positions: number[];
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
isSelected: boolean;
|
|
18
|
+
defaultContent: ReactNode;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export interface ChooseListProps {
|
|
21
|
-
choose: UseChooseResult
|
|
22
|
+
choose: UseChooseResult;
|
|
22
23
|
/** 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
|
|
24
|
+
rowClick?: "activate" | "submit" | "none";
|
|
25
|
+
renderItem?: (context: ChooseItemRenderContext) => ReactNode;
|
|
26
|
+
loadingContent?: ReactNode;
|
|
27
|
+
errorContent?: (error: string) => ReactNode;
|
|
28
|
+
emptyContent?: ReactNode;
|
|
28
29
|
/** Mouse guard for visible rows covered by another surface. */
|
|
29
|
-
suspended?: boolean
|
|
30
|
+
suspended?: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
const ThemedLine = function ThemedLine({
|
|
34
|
+
content,
|
|
35
|
+
color,
|
|
36
|
+
}: {
|
|
37
|
+
content: ReactNode;
|
|
38
|
+
color: string;
|
|
39
|
+
}): ReactNode {
|
|
33
40
|
return (
|
|
34
41
|
<box height={1} style={{ paddingLeft: 2 }}>
|
|
35
42
|
{typeof content === "string" ? <text content={content} fg={color} /> : content}
|
|
36
43
|
</box>
|
|
37
|
-
)
|
|
38
|
-
}
|
|
44
|
+
);
|
|
45
|
+
};
|
|
39
46
|
|
|
40
|
-
function defaultErrorContent(error: string): ReactNode {
|
|
41
|
-
return `Error: ${error}
|
|
42
|
-
}
|
|
47
|
+
const defaultErrorContent = function defaultErrorContent(error: string): ReactNode {
|
|
48
|
+
return `Error: ${error}`;
|
|
49
|
+
};
|
|
43
50
|
|
|
44
51
|
/** Scrollable chooser rows plus loading/error/empty presentation. */
|
|
45
|
-
export function ChooseList({
|
|
52
|
+
export const ChooseList = function ChooseList({
|
|
46
53
|
choose,
|
|
47
54
|
rowClick = "activate",
|
|
48
55
|
renderItem,
|
|
@@ -50,33 +57,35 @@ export function ChooseList({
|
|
|
50
57
|
errorContent = defaultErrorContent,
|
|
51
58
|
emptyContent,
|
|
52
59
|
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)
|
|
60
|
+
}: ChooseListProps): ReactNode {
|
|
61
|
+
const { theme } = useTheme();
|
|
62
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
63
|
+
const { state, controller } = choose;
|
|
64
|
+
const interactionSuspended = choose.view.suspended || (suspended ?? false);
|
|
58
65
|
|
|
59
66
|
useEffect(() => {
|
|
60
67
|
if (scrollRef.current && state.matches.length > 0) {
|
|
61
|
-
scrollRef.current.scrollTop = Math.max(0, state.activeIndex - 5)
|
|
68
|
+
scrollRef.current.scrollTop = Math.max(0, state.activeIndex - 5);
|
|
62
69
|
}
|
|
63
|
-
}, [state.activeIndex, state.matches.length])
|
|
70
|
+
}, [state.activeIndex, state.matches.length]);
|
|
64
71
|
|
|
65
72
|
return (
|
|
66
73
|
<scrollbox ref={scrollRef} flexDirection="column" style={{ flexGrow: 1 }} focused={false}>
|
|
67
74
|
{state.loading && <ThemedLine content={loadingContent} color={theme.textMuted} />}
|
|
68
75
|
|
|
69
|
-
{!state.loading && state.error && (
|
|
76
|
+
{!state.loading && state.error !== null && state.error !== "" && (
|
|
70
77
|
<ThemedLine content={errorContent(state.error)} color={theme.error} />
|
|
71
78
|
)}
|
|
72
79
|
|
|
73
|
-
{!state.loading &&
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
{!state.loading &&
|
|
81
|
+
(state.error?.length ?? 0) === 0 &&
|
|
82
|
+
state.matches.length === 0 &&
|
|
83
|
+
emptyContent !== null &&
|
|
84
|
+
emptyContent !== undefined && <ThemedLine content={emptyContent} color={theme.textMuted} />}
|
|
76
85
|
|
|
77
|
-
{state.matches.map((match, index) => {
|
|
78
|
-
const isActive = index === state.activeIndex
|
|
79
|
-
const isSelected = state.selectedOriginalIndices.has(match.originalIndex)
|
|
86
|
+
{state.matches.map((match, index): ReactNode => {
|
|
87
|
+
const isActive = index === state.activeIndex;
|
|
88
|
+
const isSelected = state.selectedOriginalIndices.has(match.originalIndex);
|
|
80
89
|
const defaultContent = (
|
|
81
90
|
<>
|
|
82
91
|
{state.multi && (
|
|
@@ -85,7 +94,9 @@ export function ChooseList({
|
|
|
85
94
|
fg={isSelected ? theme.accent : theme.textMuted}
|
|
86
95
|
/>
|
|
87
96
|
)}
|
|
88
|
-
{match.item.icon
|
|
97
|
+
{(match.item.icon?.length ?? 0) > 0 && (
|
|
98
|
+
<text content={`${match.item.icon} `} fg={theme.textMuted} />
|
|
99
|
+
)}
|
|
89
100
|
<text fg={isActive ? theme.primary : theme.text}>
|
|
90
101
|
<ChooseHighlightedText
|
|
91
102
|
text={match.item.text}
|
|
@@ -93,21 +104,21 @@ export function ChooseList({
|
|
|
93
104
|
highlightColor={theme.warning}
|
|
94
105
|
/>
|
|
95
106
|
</text>
|
|
96
|
-
{match.item.description && (
|
|
107
|
+
{(match.item.description?.length ?? 0) > 0 && (
|
|
97
108
|
<text content={` ${match.item.description}`} fg={theme.textMuted} />
|
|
98
109
|
)}
|
|
99
110
|
</>
|
|
100
|
-
)
|
|
111
|
+
);
|
|
101
112
|
const context: ChooseItemRenderContext = {
|
|
113
|
+
defaultContent,
|
|
114
|
+
index,
|
|
115
|
+
isActive,
|
|
116
|
+
isSelected,
|
|
102
117
|
item: match.item,
|
|
103
118
|
match,
|
|
104
|
-
index,
|
|
105
119
|
originalIndex: match.originalIndex,
|
|
106
120
|
positions: match.positions,
|
|
107
|
-
|
|
108
|
-
isSelected,
|
|
109
|
-
defaultContent,
|
|
110
|
-
}
|
|
121
|
+
};
|
|
111
122
|
|
|
112
123
|
return (
|
|
113
124
|
<box
|
|
@@ -117,21 +128,23 @@ export function ChooseList({
|
|
|
117
128
|
backgroundColor={isActive ? theme.backgroundElement : undefined}
|
|
118
129
|
style={{ paddingLeft: 1 }}
|
|
119
130
|
onMouseDown={(event) => {
|
|
120
|
-
if (interactionSuspended || event.button !== 0 || rowClick === "none")
|
|
131
|
+
if (interactionSuspended || event.button !== 0 || rowClick === "none") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
121
134
|
if (rowClick === "submit") {
|
|
122
|
-
event.preventDefault()
|
|
123
|
-
event.stopPropagation()
|
|
124
|
-
controller.setActiveIndex(index)
|
|
125
|
-
controller.submit()
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
controller.setActiveIndex(index);
|
|
138
|
+
controller.submit();
|
|
126
139
|
} else {
|
|
127
|
-
controller.setActiveIndex(index)
|
|
140
|
+
controller.setActiveIndex(index);
|
|
128
141
|
}
|
|
129
142
|
}}
|
|
130
143
|
>
|
|
131
144
|
{renderItem ? renderItem(context) : defaultContent}
|
|
132
145
|
</box>
|
|
133
|
-
)
|
|
146
|
+
);
|
|
134
147
|
})}
|
|
135
148
|
</scrollbox>
|
|
136
|
-
)
|
|
137
|
-
}
|
|
149
|
+
);
|
|
150
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useImperativeHandle } from "react";
|
|
2
|
+
import type { ReactNode, Ref } from "react";
|
|
3
|
+
import type { ActionDefinition } from "@tooee/commands";
|
|
4
|
+
import { ChooseFilter } from "./choose-filter.js";
|
|
5
|
+
import { ChooseList } from "./choose-list.js";
|
|
6
|
+
import type { ChooseListProps } from "./choose-list.js";
|
|
7
|
+
import { ChoosePanel } from "./choose-panel.js";
|
|
8
|
+
import type { ChoosePanelProps } from "./choose-panel.js";
|
|
9
|
+
import type { ChooseItem, ChooseResult, ChooseSource } from "./types.js";
|
|
10
|
+
import { useChoose } from "./use-choose.js";
|
|
11
|
+
import type { ChooseController } from "./use-choose.js";
|
|
12
|
+
|
|
13
|
+
interface ChooseOverlayBaseProps {
|
|
14
|
+
items: ChooseSource;
|
|
15
|
+
prompt?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
emptyMessage?: string;
|
|
18
|
+
onCancel: () => void;
|
|
19
|
+
commands?: ActionDefinition[];
|
|
20
|
+
controllerRef?: Ref<ChooseController>;
|
|
21
|
+
renderItem?: ChooseListProps["renderItem"];
|
|
22
|
+
hints?: ChoosePanelProps["hints"];
|
|
23
|
+
statusRight?: ReactNode;
|
|
24
|
+
footer?: ReactNode;
|
|
25
|
+
inset?: ChoosePanelProps["inset"];
|
|
26
|
+
/** Nested modal content rendered above the chooser panel. */
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
/** Explicit interaction guard for legacy child overlays without a command surface. */
|
|
29
|
+
suspended?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ChooseOverlayProps = ChooseOverlayBaseProps &
|
|
33
|
+
(
|
|
34
|
+
| {
|
|
35
|
+
multi?: false;
|
|
36
|
+
onSelect: (item: ChooseItem) => void | Promise<void>;
|
|
37
|
+
onSubmit?: never;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
multi: true;
|
|
41
|
+
onSubmit: (result: ChooseResult) => void | Promise<void>;
|
|
42
|
+
onSelect?: never;
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Composed picker surface. Hosts should mount it on an owned modal command
|
|
48
|
+
* surface (as Tooee's overlay manager does) so nested surfaces suspend it.
|
|
49
|
+
*/
|
|
50
|
+
export const ChooseOverlay = function ChooseOverlay(props: ChooseOverlayProps): ReactNode {
|
|
51
|
+
const multi = props.multi === true;
|
|
52
|
+
const choose = useChoose({
|
|
53
|
+
commandScope: "choose-overlay",
|
|
54
|
+
commands: props.commands,
|
|
55
|
+
multi,
|
|
56
|
+
onCancel: props.onCancel,
|
|
57
|
+
onSubmit: (result): void | Promise<void> => {
|
|
58
|
+
if (multi) {
|
|
59
|
+
return props.onSubmit(result);
|
|
60
|
+
}
|
|
61
|
+
const [item] = result.items;
|
|
62
|
+
if (item !== undefined) {
|
|
63
|
+
return props.onSelect(item);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
source: props.items,
|
|
67
|
+
suspended: props.suspended,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
useImperativeHandle(props.controllerRef, () => choose.controller, [choose.controller]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<ChoosePanel
|
|
75
|
+
title={props.prompt}
|
|
76
|
+
filter={
|
|
77
|
+
<ChooseFilter choose={choose} placeholder={props.placeholder} onClose={props.onCancel} />
|
|
78
|
+
}
|
|
79
|
+
multi={multi}
|
|
80
|
+
hints={props.hints}
|
|
81
|
+
statusRight={props.statusRight}
|
|
82
|
+
footer={props.footer}
|
|
83
|
+
inset={props.inset}
|
|
84
|
+
>
|
|
85
|
+
<ChooseList
|
|
86
|
+
choose={choose}
|
|
87
|
+
rowClick="submit"
|
|
88
|
+
renderItem={props.renderItem}
|
|
89
|
+
emptyContent={props.emptyMessage}
|
|
90
|
+
suspended={props.suspended}
|
|
91
|
+
/>
|
|
92
|
+
</ChoosePanel>
|
|
93
|
+
{props.children}
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
@@ -1,47 +1,53 @@
|
|
|
1
|
-
import type { ReactNode } from "react"
|
|
2
|
-
import { useMode
|
|
3
|
-
import {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useMode } from "@tooee/commands";
|
|
3
|
+
import type { Mode } from "@tooee/commands";
|
|
4
|
+
import { CloseButton, useTheme } from "@tooee/themes";
|
|
4
5
|
|
|
5
|
-
export function buildChooseHints(
|
|
6
|
+
export const buildChooseHints = function buildChooseHints(
|
|
6
7
|
mode: Mode,
|
|
7
8
|
options: { multi?: boolean; extra?: string[] } = {},
|
|
8
9
|
): string[] {
|
|
9
10
|
const base =
|
|
10
11
|
mode === "insert"
|
|
11
|
-
? [
|
|
12
|
+
? [
|
|
13
|
+
"↑↓ navigate",
|
|
14
|
+
"Enter confirm",
|
|
15
|
+
...(options.multi === true ? ["Tab toggle"] : []),
|
|
16
|
+
"Esc commands",
|
|
17
|
+
]
|
|
12
18
|
: [
|
|
13
19
|
"j/k navigate",
|
|
14
20
|
"i insert",
|
|
15
|
-
...(options.multi ? ["Tab toggle"] : []),
|
|
21
|
+
...(options.multi === true ? ["Tab toggle"] : []),
|
|
16
22
|
"Esc/q cancel",
|
|
17
23
|
"Enter confirm",
|
|
18
|
-
]
|
|
19
|
-
return options.extra ? [...base, ...options.extra] : base
|
|
20
|
-
}
|
|
24
|
+
];
|
|
25
|
+
return options.extra ? [...base, ...options.extra] : base;
|
|
26
|
+
};
|
|
21
27
|
|
|
22
|
-
export type ChoosePanelInsetValue = number | "auto" | `${number}
|
|
28
|
+
export type ChoosePanelInsetValue = number | "auto" | `${number}%`;
|
|
23
29
|
|
|
24
30
|
export interface ChoosePanelInset {
|
|
25
|
-
left?: ChoosePanelInsetValue
|
|
26
|
-
right?: ChoosePanelInsetValue
|
|
27
|
-
top?: ChoosePanelInsetValue
|
|
28
|
-
bottom?: ChoosePanelInsetValue
|
|
31
|
+
left?: ChoosePanelInsetValue;
|
|
32
|
+
right?: ChoosePanelInsetValue;
|
|
33
|
+
top?: ChoosePanelInsetValue;
|
|
34
|
+
bottom?: ChoosePanelInsetValue;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
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
|
|
38
|
+
title?: ReactNode;
|
|
39
|
+
filter: ReactNode;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
multi?: boolean;
|
|
42
|
+
hints?: (context: { mode: Mode; defaults: string[] }) => ReactNode;
|
|
43
|
+
statusRight?: ReactNode;
|
|
44
|
+
footer?: ReactNode;
|
|
45
|
+
onClose?: () => void;
|
|
46
|
+
inset?: ChoosePanelInset;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** Bordered chooser chrome with filter, content, hint/status, and footer slots. */
|
|
44
|
-
export function ChoosePanel({
|
|
50
|
+
export const ChoosePanel = function ChoosePanel({
|
|
45
51
|
title,
|
|
46
52
|
filter,
|
|
47
53
|
children,
|
|
@@ -51,11 +57,11 @@ export function ChoosePanel({
|
|
|
51
57
|
footer,
|
|
52
58
|
onClose,
|
|
53
59
|
inset,
|
|
54
|
-
}: ChoosePanelProps) {
|
|
55
|
-
const { theme } = useTheme()
|
|
56
|
-
const mode = useMode()
|
|
57
|
-
const defaults = buildChooseHints(mode, { multi })
|
|
58
|
-
const hintContent = hints ? hints({
|
|
60
|
+
}: ChoosePanelProps): ReactNode {
|
|
61
|
+
const { theme } = useTheme();
|
|
62
|
+
const mode = useMode();
|
|
63
|
+
const defaults = buildChooseHints(mode, { multi });
|
|
64
|
+
const hintContent = hints ? hints({ defaults, mode }) : defaults.join(" ");
|
|
59
65
|
|
|
60
66
|
return (
|
|
61
67
|
<box
|
|
@@ -69,7 +75,7 @@ export function ChoosePanel({
|
|
|
69
75
|
border
|
|
70
76
|
borderColor={theme.borderActive}
|
|
71
77
|
>
|
|
72
|
-
{title
|
|
78
|
+
{title !== null && title !== undefined && (
|
|
73
79
|
<box
|
|
74
80
|
flexDirection="row"
|
|
75
81
|
height={1}
|
|
@@ -91,7 +97,7 @@ export function ChoosePanel({
|
|
|
91
97
|
{filter}
|
|
92
98
|
{children}
|
|
93
99
|
|
|
94
|
-
{footer
|
|
100
|
+
{footer !== null && footer !== undefined && (
|
|
95
101
|
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
96
102
|
{footer}
|
|
97
103
|
</box>
|
|
@@ -111,7 +117,8 @@ export function ChoosePanel({
|
|
|
111
117
|
hintContent
|
|
112
118
|
)}
|
|
113
119
|
</box>
|
|
114
|
-
{statusRight
|
|
120
|
+
{statusRight !== null &&
|
|
121
|
+
statusRight !== undefined &&
|
|
115
122
|
(typeof statusRight === "string" ? (
|
|
116
123
|
<text content={statusRight} fg={theme.textMuted} />
|
|
117
124
|
) : (
|
|
@@ -119,5 +126,5 @@ export function ChoosePanel({
|
|
|
119
126
|
))}
|
|
120
127
|
</box>
|
|
121
128
|
</box>
|
|
122
|
-
)
|
|
123
|
-
}
|
|
129
|
+
);
|
|
130
|
+
};
|
package/src/choose.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useImperativeHandle } from "react";
|
|
2
|
+
import type { Ref } from "react";
|
|
3
|
+
import { AppLayout } from "@tooee/layout";
|
|
4
|
+
import { useHasOverlay, useHasModalOverlay } from "@tooee/overlays";
|
|
5
|
+
import { useTheme } from "@tooee/themes";
|
|
6
|
+
import { useThemeCommands, useQuitCommand } from "@tooee/shell";
|
|
7
|
+
import { useCommandContext } from "@tooee/commands";
|
|
8
|
+
import type { ActionDefinition } from "@tooee/commands";
|
|
9
|
+
import { ChooseFilter } from "./choose-filter.js";
|
|
10
|
+
import { ChooseList } from "./choose-list.js";
|
|
11
|
+
import type { ChooseListProps } from "./choose-list.js";
|
|
12
|
+
import { buildChooseHints } from "./choose-panel.js";
|
|
13
|
+
import type { ChooseContentProvider, ChooseOptions, ChooseResult } from "./types.js";
|
|
14
|
+
import { useChoose } from "./use-choose.js";
|
|
15
|
+
import type { ChooseController } from "./use-choose.js";
|
|
16
|
+
|
|
17
|
+
export interface ChooseProps {
|
|
18
|
+
contentProvider: ChooseContentProvider;
|
|
19
|
+
options?: ChooseOptions;
|
|
20
|
+
/** Legacy name retained for source compatibility. */
|
|
21
|
+
actions?: ActionDefinition[];
|
|
22
|
+
/** Additive alias used by new chooser compositions. */
|
|
23
|
+
commands?: ActionDefinition[];
|
|
24
|
+
controllerRef?: Ref<ChooseController>;
|
|
25
|
+
renderItem?: ChooseListProps["renderItem"];
|
|
26
|
+
/**
|
|
27
|
+
* Called with the selection when the chooser is submitted. This is the
|
|
28
|
+
* component-level equivalent of `useChoose`'s `onSubmit` and is how a host
|
|
29
|
+
* (including `launch()`) receives a `ChooseResult`. A registered command with
|
|
30
|
+
* the id `submit` still takes precedence, because a command can express its
|
|
31
|
+
* own submit behaviour — but commands receive a `CommandContext`, not a
|
|
32
|
+
* result, so they are not a replacement for this callback.
|
|
33
|
+
*/
|
|
34
|
+
onConfirm?: (result: ChooseResult) => void;
|
|
35
|
+
/** Called when the chooser is cancelled (quit, or submit with no selection). */
|
|
36
|
+
onCancel?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Choose = function Choose({
|
|
40
|
+
contentProvider,
|
|
41
|
+
options,
|
|
42
|
+
actions,
|
|
43
|
+
commands,
|
|
44
|
+
controllerRef,
|
|
45
|
+
renderItem,
|
|
46
|
+
onConfirm,
|
|
47
|
+
onCancel,
|
|
48
|
+
}: ChooseProps): React.ReactNode {
|
|
49
|
+
const { theme } = useTheme();
|
|
50
|
+
const { invoke } = useCommandContext();
|
|
51
|
+
const effectiveCommands = commands ?? actions;
|
|
52
|
+
const multi = options?.multi ?? false;
|
|
53
|
+
|
|
54
|
+
const { name: themeName } = useThemeCommands();
|
|
55
|
+
useQuitCommand({ onQuit: () => onCancel?.() });
|
|
56
|
+
|
|
57
|
+
const hasOverlay = useHasOverlay();
|
|
58
|
+
const hasModalOverlay = useHasModalOverlay();
|
|
59
|
+
|
|
60
|
+
const choose = useChoose({
|
|
61
|
+
commands: effectiveCommands,
|
|
62
|
+
multi,
|
|
63
|
+
onCancel,
|
|
64
|
+
onSubmit: (result) => {
|
|
65
|
+
// Standalone behaviour: a command named `submit` wins over `onConfirm`, so
|
|
66
|
+
// action-driven CLIs keep their own submit semantics.
|
|
67
|
+
if (effectiveCommands?.some((action) => action.id === "submit") === true) {
|
|
68
|
+
invoke("submit");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (multi || result.items.length > 0) {
|
|
72
|
+
onConfirm?.(result);
|
|
73
|
+
} else {
|
|
74
|
+
onCancel?.();
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
source: contentProvider,
|
|
78
|
+
suspended: hasOverlay,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
useImperativeHandle(controllerRef, () => choose.controller, [choose.controller]);
|
|
82
|
+
|
|
83
|
+
if (choose.state.loading) {
|
|
84
|
+
return (
|
|
85
|
+
<box>
|
|
86
|
+
<text content="Loading..." fg={theme.textMuted} />
|
|
87
|
+
</box>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if ((choose.state.error?.length ?? 0) > 0) {
|
|
92
|
+
return (
|
|
93
|
+
<box>
|
|
94
|
+
<text content={`Error: ${choose.state.error}`} fg={theme.error} />
|
|
95
|
+
</box>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const hints = buildChooseHints(choose.view.mode, { multi });
|
|
100
|
+
let titleBar: { title: string } | undefined;
|
|
101
|
+
if (options?.title !== undefined && options.title !== "") {
|
|
102
|
+
titleBar = { title: options.title };
|
|
103
|
+
} else if (options?.prompt !== undefined && options.prompt !== "") {
|
|
104
|
+
titleBar = { title: options.prompt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<AppLayout
|
|
109
|
+
titleBar={titleBar}
|
|
110
|
+
statusBar={{
|
|
111
|
+
items: [
|
|
112
|
+
{
|
|
113
|
+
label: "Matches:",
|
|
114
|
+
value: `${choose.state.matches.length}/${choose.state.items.length}`,
|
|
115
|
+
},
|
|
116
|
+
...(multi
|
|
117
|
+
? [
|
|
118
|
+
{
|
|
119
|
+
label: "Selected:",
|
|
120
|
+
value: String(choose.state.selectedOriginalIndices.size),
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
: []),
|
|
124
|
+
{ label: "Theme:", value: themeName },
|
|
125
|
+
{ label: "", value: hints.join(" ") },
|
|
126
|
+
],
|
|
127
|
+
}}
|
|
128
|
+
scrollProps={{ focused: false }}
|
|
129
|
+
>
|
|
130
|
+
<box flexDirection="column" style={{ flexGrow: 1 }}>
|
|
131
|
+
<ChooseFilter choose={choose} placeholder={options?.placeholder} />
|
|
132
|
+
<ChooseList
|
|
133
|
+
choose={choose}
|
|
134
|
+
rowClick="activate"
|
|
135
|
+
renderItem={renderItem}
|
|
136
|
+
emptyContent={options?.emptyMessage}
|
|
137
|
+
suspended={hasModalOverlay}
|
|
138
|
+
/>
|
|
139
|
+
</box>
|
|
140
|
+
</AppLayout>
|
|
141
|
+
);
|
|
142
|
+
};
|
package/src/default-provider.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import type { ChooseItem, ChooseContentProvider } from "./types.js"
|
|
1
|
+
import type { ChooseItem, ChooseContentProvider } from "./types.js";
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
3
|
+
export const createStdinChooseProvider =
|
|
4
|
+
function createStdinChooseProvider(): ChooseContentProvider {
|
|
5
|
+
return {
|
|
6
|
+
async load(): Promise<ChooseItem[]> {
|
|
7
|
+
const text = await new Response(Bun.stdin.stream()).text();
|
|
8
|
+
return text
|
|
9
|
+
.split("\n")
|
|
10
|
+
.filter((line) => line.length > 0)
|
|
11
|
+
.map((line) => ({ text: line }));
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
};
|
|
14
15
|
|
|
15
|
-
export
|
|
16
|
+
export const createStaticProvider = function createStaticProvider(
|
|
17
|
+
items: ChooseItem[],
|
|
18
|
+
): ChooseContentProvider {
|
|
16
19
|
return {
|
|
17
20
|
load: () => items,
|
|
18
|
-
}
|
|
19
|
-
}
|
|
21
|
+
};
|
|
22
|
+
};
|