@tooee/choose 0.2.3 → 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.
Files changed (94) hide show
  1. package/README.md +2 -2
  2. package/dist/{ChooseFilter.d.ts → choose-filter.d.ts} +2 -2
  3. package/dist/choose-filter.d.ts.map +1 -0
  4. package/dist/choose-filter.js +11 -0
  5. package/dist/choose-filter.js.map +1 -0
  6. package/dist/choose-highlighted-text.d.ts +9 -0
  7. package/dist/choose-highlighted-text.d.ts.map +1 -0
  8. package/dist/{ChooseHighlightedText.js → choose-highlighted-text.js} +10 -8
  9. package/dist/choose-highlighted-text.js.map +1 -0
  10. package/dist/{ChooseList.d.ts → choose-list.d.ts} +3 -3
  11. package/dist/choose-list.d.ts.map +1 -0
  12. package/dist/{ChooseList.js → choose-list.js} +20 -15
  13. package/dist/choose-list.js.map +1 -0
  14. package/dist/{ChooseOverlay.d.ts → choose-overlay.d.ts} +6 -6
  15. package/dist/choose-overlay.d.ts.map +1 -0
  16. package/dist/{ChooseOverlay.js → choose-overlay.js} +15 -13
  17. package/dist/choose-overlay.js.map +1 -0
  18. package/dist/{ChoosePanel.d.ts → choose-panel.d.ts} +5 -5
  19. package/dist/choose-panel.d.ts.map +1 -0
  20. package/dist/choose-panel.js +31 -0
  21. package/dist/choose-panel.js.map +1 -0
  22. package/dist/choose.d.ts +28 -0
  23. package/dist/choose.d.ts.map +1 -0
  24. package/dist/{Choose.js → choose.js} +25 -18
  25. package/dist/choose.js.map +1 -0
  26. package/dist/default-provider.d.ts +2 -2
  27. package/dist/default-provider.d.ts.map +1 -1
  28. package/dist/default-provider.js +4 -4
  29. package/dist/default-provider.js.map +1 -1
  30. package/dist/fuzzy.d.ts +1 -1
  31. package/dist/fuzzy.d.ts.map +1 -1
  32. package/dist/fuzzy.js +5 -5
  33. package/dist/fuzzy.js.map +1 -1
  34. package/dist/index.d.ts +12 -12
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +6 -6
  37. package/dist/index.js.map +1 -1
  38. package/dist/launch.d.ts +1 -1
  39. package/dist/launch.d.ts.map +1 -1
  40. package/dist/launch.js +5 -4
  41. package/dist/launch.js.map +1 -1
  42. package/dist/source.d.ts +2 -2
  43. package/dist/source.d.ts.map +1 -1
  44. package/dist/source.js +8 -6
  45. package/dist/source.js.map +1 -1
  46. package/dist/types.d.ts +1 -1
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/use-choose-dialog.d.ts +12 -10
  49. package/dist/use-choose-dialog.d.ts.map +1 -1
  50. package/dist/use-choose-dialog.js +98 -77
  51. package/dist/use-choose-dialog.js.map +1 -1
  52. package/dist/use-choose-dialog.typecheck.d.ts +1 -1
  53. package/dist/use-choose-dialog.typecheck.d.ts.map +1 -1
  54. package/dist/use-choose-dialog.typecheck.js +5 -5
  55. package/dist/use-choose-dialog.typecheck.js.map +1 -1
  56. package/dist/use-choose.d.ts +16 -16
  57. package/dist/use-choose.d.ts.map +1 -1
  58. package/dist/use-choose.js +176 -138
  59. package/dist/use-choose.js.map +1 -1
  60. package/package.json +7 -7
  61. package/src/{ChooseFilter.tsx → choose-filter.tsx} +18 -16
  62. package/src/choose-highlighted-text.tsx +49 -0
  63. package/src/{ChooseList.tsx → choose-list.tsx} +73 -60
  64. package/src/choose-overlay.tsx +96 -0
  65. package/src/{ChoosePanel.tsx → choose-panel.tsx} +41 -34
  66. package/src/choose.tsx +142 -0
  67. package/src/default-provider.ts +18 -15
  68. package/src/fuzzy.ts +16 -16
  69. package/src/index.ts +23 -23
  70. package/src/launch.tsx +15 -12
  71. package/src/source.ts +15 -9
  72. package/src/types.ts +12 -12
  73. package/src/use-choose-dialog.tsx +168 -139
  74. package/src/use-choose-dialog.typecheck.ts +19 -18
  75. package/src/use-choose.ts +349 -302
  76. package/dist/Choose.d.ts +0 -21
  77. package/dist/Choose.d.ts.map +0 -1
  78. package/dist/Choose.js.map +0 -1
  79. package/dist/ChooseFilter.d.ts.map +0 -1
  80. package/dist/ChooseFilter.js +0 -9
  81. package/dist/ChooseFilter.js.map +0 -1
  82. package/dist/ChooseHighlightedText.d.ts +0 -9
  83. package/dist/ChooseHighlightedText.d.ts.map +0 -1
  84. package/dist/ChooseHighlightedText.js.map +0 -1
  85. package/dist/ChooseList.d.ts.map +0 -1
  86. package/dist/ChooseList.js.map +0 -1
  87. package/dist/ChooseOverlay.d.ts.map +0 -1
  88. package/dist/ChooseOverlay.js.map +0 -1
  89. package/dist/ChoosePanel.d.ts.map +0 -1
  90. package/dist/ChoosePanel.js +0 -25
  91. package/dist/ChoosePanel.js.map +0 -1
  92. package/src/Choose.tsx +0 -126
  93. package/src/ChooseHighlightedText.tsx +0 -42
  94. 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, 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"
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
- function ThemedLine({ content, color }: { content: ReactNode; color: string }) {
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 && !state.error && state.matches.length === 0 && emptyContent != null && (
74
- <ThemedLine content={emptyContent} color={theme.textMuted} />
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 && <text content={`${match.item.icon} `} fg={theme.textMuted} />}
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
- isActive,
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") return
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, type Mode } from "@tooee/commands"
3
- import { CloseButton, useTheme } from "@tooee/themes"
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
- ? ["↑↓ navigate", "Enter confirm", ...(options.multi ? ["Tab toggle"] : []), "Esc commands"]
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({ mode, defaults }) : defaults.join(" ")
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 != null && (
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 != null && (
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 != null &&
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
+ };
@@ -1,19 +1,22 @@
1
- import type { ChooseItem, ChooseContentProvider } from "./types.js"
1
+ import type { ChooseItem, ChooseContentProvider } from "./types.js";
2
2
 
3
- export function createStdinChooseProvider(): ChooseContentProvider {
4
- return {
5
- async load(): Promise<ChooseItem[]> {
6
- const text = await new Response(Bun.stdin.stream() as unknown as ReadableStream).text()
7
- return text
8
- .split("\n")
9
- .filter((line) => line.length > 0)
10
- .map((line) => ({ text: line }))
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 function createStaticProvider(items: ChooseItem[]): ChooseContentProvider {
16
+ export const createStaticProvider = function createStaticProvider(
17
+ items: ChooseItem[],
18
+ ): ChooseContentProvider {
16
19
  return {
17
20
  load: () => items,
18
- }
19
- }
21
+ };
22
+ };