Skip to content

Commit 1ee9736

Browse files
Added ComboBox component
1 parent b94dc4d commit 1ee9736

File tree

4 files changed

+918
-0
lines changed

4 files changed

+918
-0
lines changed

src/ComboBox.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface ComboBoxProps {
2+
readonly left: number;
3+
readonly top: number;
4+
readonly width: number;
5+
readonly items: string[];
6+
readonly value: string;
7+
onChange(value: string): void;
8+
onEnter?(): void;
9+
}

src/ComboBox.mjs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/**
2+
* @typedef {import("@farjs/blessed").BlessedProgram} BlessedProgram
3+
* @typedef {import("@farjs/blessed").Widgets.BlessedElement} BlessedElement
4+
* @typedef {import("./ListViewport").ListViewport} ListViewport
5+
* @typedef {import("./TextInput").TextInputState} TextInputState
6+
* @typedef {import("./ComboBox").ComboBoxProps} ComboBoxProps
7+
*/
8+
import React, { useRef, useState } from "react";
9+
import { createListViewport } from "./ListViewport.mjs";
10+
import PopupOverlay from "./popup/PopupOverlay.mjs";
11+
import Theme from "./theme/Theme.mjs";
12+
import TextInput from "./TextInput.mjs";
13+
import ComboBoxPopup from "./ComboBoxPopup.mjs";
14+
15+
const h = React.createElement;
16+
17+
/**
18+
* @param {ComboBoxProps} props
19+
*/
20+
const ComboBox = (props) => {
21+
const { textInputComp, comboBoxPopup } = ComboBox;
22+
23+
const inputRef = /** @type {React.MutableRefObject<BlessedElement>} */ (
24+
useRef()
25+
);
26+
const programRef =
27+
/** @type {React.MutableRefObject<BlessedProgram | null>} */ (useRef(null));
28+
const autoCompleteTimeoutRef =
29+
/** @type {React.MutableRefObject<NodeJS.Timeout | null>} */ (useRef(null));
30+
31+
const [maybePopup, setPopup] = useState(
32+
/** @type {ListViewport | null} */ (null)
33+
);
34+
const [state, setState] = useState(
35+
/** @type {TextInputState} */ (TextInput.createState())
36+
);
37+
const currTheme = Theme.useTheme();
38+
const theme = currTheme.popup.menu;
39+
const arrowStyle = currTheme.popup.regular;
40+
41+
function showOrHidePopup() {
42+
if (maybePopup) hidePopup();
43+
else {
44+
showPopup(
45+
createListViewport(0, props.items.length, ComboBoxPopup.maxItems)
46+
);
47+
}
48+
}
49+
50+
/** @type {(viewport: ListViewport) => void} */
51+
function showPopup(viewport) {
52+
setPopup(viewport);
53+
54+
if (programRef.current) {
55+
programRef.current.hideCursor();
56+
}
57+
}
58+
59+
function hidePopup() {
60+
setPopup(null);
61+
62+
if (programRef.current) {
63+
programRef.current.showCursor();
64+
}
65+
}
66+
67+
/** @type {(offset: number, index: number) => void} */
68+
function onSelectAction(offset, index) {
69+
if (props.items.length > 0) {
70+
props.onChange(props.items[offset + index]);
71+
hidePopup();
72+
73+
process.stdin.emit("keypress", undefined, {
74+
name: "end",
75+
ctrl: false,
76+
meta: false,
77+
shift: false,
78+
});
79+
}
80+
}
81+
82+
/** @type {(key: string) => void} */
83+
function onAutoCompleteAction(key) {
84+
const value =
85+
state.selStart !== -1
86+
? props.value.slice(0, Math.min(state.selStart, props.value.length))
87+
: props.value;
88+
89+
const newValue = (() => {
90+
if (key.length === 1) return `${value}${key}`;
91+
if (key.startsWith("S-") && key.length > 2) {
92+
return `${value}${key.slice(2).toUpperCase()}`;
93+
}
94+
if (key === "space") return `${value} `;
95+
return value;
96+
})();
97+
98+
if (newValue !== value) {
99+
if (autoCompleteTimeoutRef.current) {
100+
global.clearTimeout(autoCompleteTimeoutRef.current);
101+
autoCompleteTimeoutRef.current = null;
102+
}
103+
104+
const existing = props.items.find((_) => _.startsWith(newValue));
105+
if (existing) {
106+
autoCompleteTimeoutRef.current = global.setTimeout(() => {
107+
props.onChange(existing);
108+
109+
process.stdin.emit("keypress", undefined, {
110+
name: "end",
111+
ctrl: false,
112+
meta: false,
113+
shift: true,
114+
});
115+
}, 25);
116+
}
117+
}
118+
}
119+
120+
/** @type {(keyFull: string) => boolean} */
121+
const onKeypress = (keyFull) => {
122+
let processed = !!maybePopup;
123+
switch (keyFull) {
124+
case "escape":
125+
case "tab":
126+
hidePopup();
127+
break;
128+
case "C-up":
129+
case "C-down":
130+
showOrHidePopup();
131+
processed = true;
132+
break;
133+
case "return":
134+
if (maybePopup) {
135+
onSelectAction(maybePopup.offset, maybePopup.focused);
136+
}
137+
break;
138+
default:
139+
if (maybePopup) {
140+
const vp = maybePopup.onKeypress(keyFull);
141+
if (vp) {
142+
setPopup(vp);
143+
}
144+
} else onAutoCompleteAction(keyFull);
145+
break;
146+
}
147+
148+
return processed;
149+
};
150+
151+
return h(
152+
React.Fragment,
153+
null,
154+
155+
h(textInputComp, {
156+
inputRef: inputRef,
157+
left: props.left,
158+
top: props.top,
159+
width: props.width,
160+
value: props.value,
161+
state,
162+
stateUpdater: setState,
163+
onChange: props.onChange,
164+
onEnter: props.onEnter,
165+
onKeypress,
166+
}),
167+
168+
h("text", {
169+
width: 1,
170+
height: 1,
171+
left: props.left + props.width,
172+
top: props.top,
173+
clickable: true,
174+
mouse: true,
175+
autoFocus: false,
176+
style: arrowStyle,
177+
onClick: () => {
178+
const el = inputRef.current;
179+
if (el && el.screen.focused !== el) {
180+
el.focus();
181+
}
182+
showOrHidePopup();
183+
},
184+
content: ComboBox.arrowDownCh,
185+
}),
186+
187+
maybePopup
188+
? h(
189+
"form",
190+
{
191+
/** @type {(el?: BlessedElement) => void} */
192+
ref: (el) => {
193+
if (el) {
194+
programRef.current = el.screen.program;
195+
}
196+
},
197+
clickable: true,
198+
mouse: true,
199+
autoFocus: false,
200+
style: PopupOverlay.style,
201+
onClick: hidePopup,
202+
},
203+
h(comboBoxPopup, {
204+
left: props.left,
205+
top: props.top + 1,
206+
width: props.width,
207+
items: props.items,
208+
viewport: maybePopup,
209+
setViewport: setPopup,
210+
style: theme,
211+
onClick: (index) => {
212+
onSelectAction(0, index);
213+
},
214+
})
215+
)
216+
: null
217+
);
218+
};
219+
220+
ComboBox.displayName = "ComboBox";
221+
ComboBox.textInputComp = TextInput;
222+
ComboBox.comboBoxPopup = ComboBoxPopup;
223+
224+
ComboBox.arrowDownCh = "\u2193"; // ↓
225+
226+
export default ComboBox;

0 commit comments

Comments
 (0)