Skip to content

Commit 8057a13

Browse files
Added ListBox component
1 parent 7a07171 commit 8057a13

File tree

4 files changed

+350
-0
lines changed

4 files changed

+350
-0
lines changed

src/ListBox.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Widgets } from "@farjs/blessed";
2+
3+
export interface ListBoxProps {
4+
readonly left: number;
5+
readonly top: number;
6+
readonly width: number;
7+
readonly height: number;
8+
readonly style: Widgets.Types.TStyle;
9+
readonly items: string[];
10+
readonly selected: number;
11+
onAction(index: number): void;
12+
onSelect?(index: number): void;
13+
}

src/ListBox.mjs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @typedef {import("@farjs/blessed").Widgets.Events.IKeyEventArg} IKeyEventArg
3+
* @typedef {import("./ListBox").ListBoxProps} ListBoxProps
4+
*/
5+
import React, { useLayoutEffect, useState } from "react";
6+
import { createListViewport } from "./ListViewport.mjs";
7+
import ListView from "./ListView.mjs";
8+
import ScrollBar from "./ScrollBar.mjs";
9+
10+
const h = React.createElement;
11+
12+
/**
13+
* @param {ListBoxProps} props
14+
*/
15+
const ListBox = (props) => {
16+
const { listViewComp, scrollBarComp } = ListBox;
17+
18+
const [viewport, setViewport] = useState(
19+
createListViewport(props.selected, props.items.length, props.height)
20+
);
21+
const selected = viewport.offset + viewport.focused;
22+
/** @type {(ch: any, key: IKeyEventArg) => void} */
23+
const onKeypress = (_, key) => {
24+
switch (key.full) {
25+
case "return":
26+
props.onAction(selected);
27+
break;
28+
default:
29+
const vp = viewport.onKeypress(key.full);
30+
if (vp) {
31+
setViewport(vp);
32+
}
33+
break;
34+
}
35+
};
36+
37+
useLayoutEffect(() => {
38+
props.onSelect?.call(null, selected);
39+
}, [selected]);
40+
41+
return h(
42+
"button",
43+
{
44+
left: props.left,
45+
top: props.top,
46+
width: props.width,
47+
height: props.height,
48+
onKeypress,
49+
},
50+
h(listViewComp, {
51+
left: 0,
52+
top: 0,
53+
width: props.width,
54+
height: props.height,
55+
items: props.items,
56+
viewport,
57+
setViewport,
58+
style: props.style,
59+
onClick: props.onAction,
60+
}),
61+
62+
viewport.length > viewport.viewLength
63+
? h(scrollBarComp, {
64+
left: props.width,
65+
top: 0,
66+
length: viewport.viewLength,
67+
style: props.style,
68+
value: viewport.offset,
69+
extent: viewport.viewLength,
70+
min: 0,
71+
max: viewport.length - viewport.viewLength,
72+
onChange: (offset) => {
73+
setViewport(viewport.updated(offset));
74+
},
75+
})
76+
: null
77+
);
78+
};
79+
80+
ListBox.displayName = "ListBox";
81+
ListBox.listViewComp = ListView;
82+
ListBox.scrollBarComp = ScrollBar;
83+
84+
export default ListBox;

test/ListBox.test.mjs

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* @typedef {import('../src/ListBox').ListBoxProps} ListBoxProps
3+
*/
4+
import React from "react";
5+
import TestRenderer from "react-test-renderer";
6+
import { assertComponents, mockComponent } from "react-assert";
7+
import assert from "node:assert/strict";
8+
import mockFunction from "mock-fn";
9+
import DefaultTheme from "../src/theme/DefaultTheme.mjs";
10+
import { createListViewport } from "../src/ListViewport.mjs";
11+
import ListView from "../src/ListView.mjs";
12+
import ScrollBar from "../src/ScrollBar.mjs";
13+
import ListBox from "../src/ListBox.mjs";
14+
15+
const h = React.createElement;
16+
17+
const { describe, it } = await (async () => {
18+
// @ts-ignore
19+
const module = process.isBun ? "bun:test" : "node:test";
20+
// @ts-ignore
21+
return process.isBun // @ts-ignore
22+
? Promise.resolve({ describe: (_, fn) => fn(), it: test })
23+
: import(module);
24+
})();
25+
26+
ListBox.listViewComp = mockComponent(ListView);
27+
ListBox.scrollBarComp = mockComponent(ScrollBar);
28+
const { listViewComp, scrollBarComp } = ListBox;
29+
30+
describe("ListBox.test.mjs", () => {
31+
it("should scroll when onChange in ScrollBar", () => {
32+
//given
33+
const props = getListBoxProps({ ...defaultProps, height: 1 });
34+
const renderer = TestRenderer.create(h(ListBox, props));
35+
assertListBox(renderer.root, props, true);
36+
const scrollBar = renderer.root.findByType(scrollBarComp);
37+
const offset = 1;
38+
39+
//when
40+
scrollBar.props.onChange(offset);
41+
42+
//then
43+
assertListBox(renderer.root, props, true, offset);
44+
});
45+
46+
it("should call onSelect if new index when update", () => {
47+
//given
48+
const onSelect = mockFunction((index) => {
49+
//then
50+
if (onSelect.times === 1) {
51+
assert.deepEqual(index, 1);
52+
} else {
53+
assert.deepEqual(index, 0);
54+
}
55+
});
56+
const props = getListBoxProps({ ...defaultProps, selected: 1, onSelect });
57+
const renderer = TestRenderer.create(h(ListBox, props));
58+
const listView = renderer.root.findByType(listViewComp).props;
59+
assert.deepEqual(listView.viewport.offset, 0);
60+
assert.deepEqual(listView.viewport.focused, 1);
61+
const viewport = listView.viewport.updated(listView.viewport.offset, 0);
62+
63+
//when
64+
listView.setViewport(viewport);
65+
66+
//then
67+
assert.deepEqual(onSelect.times, 2);
68+
});
69+
70+
it("should not call onSelect if same index when update", () => {
71+
//given
72+
const onSelect = mockFunction((index) => {
73+
//then
74+
assert.deepEqual(index, 1);
75+
});
76+
const props = getListBoxProps({ ...defaultProps, selected: 1, onSelect });
77+
const renderer = TestRenderer.create(h(ListBox, props));
78+
const listView = renderer.root.findByType(listViewComp).props;
79+
assert.deepEqual(listView.viewport.offset, 0);
80+
assert.deepEqual(listView.viewport.focused, 1);
81+
const viewport = createListViewport(
82+
1,
83+
listView.viewport.length,
84+
listView.viewport.viewLength
85+
);
86+
87+
//when
88+
listView.setViewport(viewport);
89+
90+
//then
91+
assert.deepEqual(onSelect.times, 1);
92+
});
93+
94+
it("should update viewport and call onSelect when onKeypress(down)", () => {
95+
//given
96+
const onSelect = mockFunction((index) => {
97+
//then
98+
if (onSelect.times === 1) {
99+
assert.deepEqual(index, 0);
100+
} else {
101+
assert.deepEqual(index, 1);
102+
}
103+
});
104+
const props = getListBoxProps({ ...defaultProps, onSelect });
105+
const renderer = TestRenderer.create(h(ListBox, props));
106+
assertListBox(renderer.root, props, false);
107+
const button = renderer.root.findByType("button");
108+
109+
//when
110+
button.props.onKeypress(null, { full: "down" });
111+
112+
//then
113+
assert.deepEqual(onSelect.times, 2);
114+
assertListBox(renderer.root, { ...props, selected: 1 }, false);
115+
});
116+
117+
it("should call onAction when onKeypress(return)", () => {
118+
//given
119+
const onAction = mockFunction((index) => {
120+
//then
121+
assert.deepEqual(index, 1);
122+
});
123+
const props = getListBoxProps({ ...defaultProps, selected: 1, onAction });
124+
const renderer = TestRenderer.create(h(ListBox, props));
125+
const button = renderer.root.findByType("button");
126+
127+
//when
128+
button.props.onKeypress(null, { full: "return" });
129+
130+
//then
131+
assert.deepEqual(onAction.times, 1);
132+
});
133+
134+
it("should render without ScrollBar", () => {
135+
//given
136+
const props = getListBoxProps({
137+
...defaultProps,
138+
width: 10,
139+
items: new Array(5).fill("item"),
140+
});
141+
142+
//when
143+
const result = TestRenderer.create(h(ListBox, props)).root;
144+
145+
//then
146+
assertListBox(result, props, false);
147+
});
148+
149+
it("should render with ScrollBar", () => {
150+
//given
151+
const props = getListBoxProps({
152+
...defaultProps,
153+
width: 5,
154+
height: 20,
155+
items: new Array(25).fill("item"),
156+
});
157+
158+
//when
159+
const result = TestRenderer.create(h(ListBox, props)).root;
160+
161+
//then
162+
assertListBox(result, props, true);
163+
});
164+
});
165+
166+
/**
167+
* @typedef {{
168+
* width: number,
169+
* height: number,
170+
* selected: number,
171+
* items: string[],
172+
* onAction(index: number): void,
173+
* onSelect?(index: number): void
174+
* }} DefaultProps
175+
* @type {DefaultProps}
176+
*/
177+
const defaultProps = {
178+
width: 20,
179+
height: 30,
180+
selected: 0,
181+
items: [" item 1", " item 2"],
182+
onAction: () => {},
183+
};
184+
185+
/**
186+
* @param {DefaultProps} props
187+
* @returns {ListBoxProps}
188+
*/
189+
function getListBoxProps(props = defaultProps) {
190+
return {
191+
left: 1,
192+
top: 2,
193+
width: props.width,
194+
height: props.height,
195+
selected: props.selected,
196+
items: props.items,
197+
style: DefaultTheme.popup.menu,
198+
onAction: props.onAction,
199+
onSelect: props.onSelect,
200+
};
201+
}
202+
203+
/**
204+
* @param {TestRenderer.ReactTestInstance} result
205+
* @param {ListBoxProps} props
206+
* @param {boolean} showScrollBar
207+
* @param {number} [offset]
208+
*/
209+
function assertListBox(result, props, showScrollBar, offset = 0) {
210+
assert.deepEqual(ListBox.displayName, "ListBox");
211+
212+
const lv = result.findByType(listViewComp);
213+
214+
assertComponents(
215+
result.children,
216+
h(
217+
"button",
218+
{
219+
left: props.left,
220+
top: props.top,
221+
width: props.width,
222+
height: props.height,
223+
},
224+
...[
225+
h(listViewComp, {
226+
left: 0,
227+
top: 0,
228+
width: props.width,
229+
height: props.height,
230+
items: props.items,
231+
viewport: { ...lv.props.viewport, offset, focused: props.selected },
232+
setViewport: mockFunction(),
233+
style: props.style,
234+
onClick: props.onAction,
235+
}),
236+
showScrollBar
237+
? h(scrollBarComp, {
238+
left: props.width,
239+
top: 0,
240+
length: props.height,
241+
style: props.style,
242+
value: offset,
243+
extent: props.height,
244+
min: 0,
245+
max: props.items.length - props.height,
246+
onChange: mockFunction(),
247+
})
248+
: null,
249+
].filter((h) => h)
250+
)
251+
);
252+
}

test/all.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
await import("./Button.test.mjs");
22
await import("./ButtonsPanel.test.mjs");
3+
await import("./ListBox.test.mjs");
34
await import("./ListView.test.mjs");
45
await import("./ListViewport.test.mjs");
56
await import("./ScrollBar.test.mjs");

0 commit comments

Comments
 (0)