Skip to content

Commit 19b45e5

Browse files
Added ListPopup component
1 parent 8057a13 commit 19b45e5

File tree

4 files changed

+358
-0
lines changed

4 files changed

+358
-0
lines changed

src/popup/ListPopup.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface ListPopupProps {
2+
readonly title: string;
3+
readonly items: string[];
4+
onAction(index: number): void;
5+
onClose(): void;
6+
readonly selected?: number;
7+
onSelect?(index: number): void;
8+
onKeypress?(keyFull: string): boolean;
9+
readonly footer?: string;
10+
readonly textPaddingLeft?: number;
11+
readonly textPaddingRight?: number;
12+
readonly itemWrapPrefixLen?: number;
13+
}

src/popup/ListPopup.mjs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @typedef {import("./ModalContent").BlessedPadding} BlessedPadding
3+
* @typedef {import("./ListPopup").ListPopupProps} ListPopupProps
4+
*/
5+
import React from "react";
6+
import Theme from "../theme/Theme.mjs";
7+
import Popup from "./Popup.mjs";
8+
import ModalContent from "./ModalContent.mjs";
9+
import WithSize from "../WithSize.mjs";
10+
import ListBox from "../ListBox.mjs";
11+
import TextLine from "../TextLine.mjs";
12+
13+
const h = React.createElement;
14+
15+
/**
16+
* @param {ListPopupProps} props
17+
*/
18+
const ListPopup = (props) => {
19+
const { popupComp, modalContentComp, withSizeComp, listBoxComp } = ListPopup;
20+
21+
const items = props.items;
22+
const theme = Theme.useTheme().popup.menu;
23+
const textPaddingLeft = props.textPaddingLeft ?? 2;
24+
const textPaddingRight = props.textPaddingRight ?? 1;
25+
const itemWrapPrefixLen = props.itemWrapPrefixLen ?? 3;
26+
const textPaddingLen = textPaddingLeft + textPaddingRight;
27+
const textPaddingLeftStr = " ".repeat(textPaddingLeft);
28+
const textPaddingRightStr = " ".repeat(textPaddingRight);
29+
30+
return h(
31+
popupComp,
32+
{
33+
onClose: props.onClose,
34+
onKeypress: props.onKeypress,
35+
},
36+
h(withSizeComp, {
37+
render: (width, height) => {
38+
const maxContentWidth =
39+
items.length === 0
40+
? 2 * (ListPopup.paddingHorizontal + 1)
41+
: items.reduce((_1, _2) => Math.max(_1, _2.length), 0) +
42+
2 * (ListPopup.paddingHorizontal + 1);
43+
44+
const maxContentHeight =
45+
items.length + 2 * (ListPopup.paddingVertical + 1);
46+
47+
const modalWidth = Math.min(
48+
Math.max(minWidth, maxContentWidth + textPaddingLen),
49+
Math.max(minWidth, width)
50+
);
51+
const modalHeight = Math.min(
52+
Math.max(minHeight, maxContentHeight),
53+
Math.max(minHeight, height - 4)
54+
);
55+
56+
const contentWidth = modalWidth - 2 * (ListPopup.paddingHorizontal + 1); // padding + border
57+
const contentHeight = modalHeight - 2 * (ListPopup.paddingVertical + 1);
58+
59+
return h(
60+
modalContentComp,
61+
{
62+
title: props.title,
63+
width: modalWidth,
64+
height: modalHeight,
65+
style: theme,
66+
padding: ListPopup.padding,
67+
footer: props.footer,
68+
},
69+
h(listBoxComp, {
70+
left: 1,
71+
top: 1,
72+
width: contentWidth,
73+
height: contentHeight,
74+
selected: props.selected ?? 0,
75+
items: items.map((item) => {
76+
return (
77+
textPaddingLeftStr +
78+
TextLine.wrapText(
79+
item,
80+
contentWidth - textPaddingLen,
81+
itemWrapPrefixLen
82+
) +
83+
textPaddingRightStr
84+
);
85+
}),
86+
style: theme,
87+
onAction: (index) => {
88+
if (items.length > 0) {
89+
props.onAction(index);
90+
}
91+
},
92+
onSelect: props.onSelect,
93+
})
94+
);
95+
},
96+
})
97+
);
98+
};
99+
100+
ListPopup.displayName = "ListPopup";
101+
ListPopup.popupComp = Popup;
102+
ListPopup.modalContentComp = ModalContent;
103+
ListPopup.withSizeComp = WithSize;
104+
ListPopup.listBoxComp = ListBox;
105+
106+
ListPopup.paddingHorizontal = 2;
107+
ListPopup.paddingVertical = 1;
108+
109+
const minWidth = 50 + 2 * (ListPopup.paddingHorizontal + 1); // padding + border
110+
const minHeight = 10 + 2 * (ListPopup.paddingVertical + 1);
111+
112+
/** @type {BlessedPadding} */
113+
ListPopup.padding = {
114+
left: ListPopup.paddingHorizontal,
115+
right: ListPopup.paddingHorizontal,
116+
top: ListPopup.paddingVertical,
117+
bottom: ListPopup.paddingVertical,
118+
};
119+
120+
export default ListPopup;

test/all.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ await import("./menu/MenuBarTrigger.test.mjs");
2323
await import("./menu/MenuPopup.test.mjs");
2424
await import("./menu/SubMenu.test.mjs");
2525

26+
await import("./popup/ListPopup.test.mjs");
2627
await import("./popup/MessageBox.test.mjs");
2728
await import("./popup/Modal.test.mjs");
2829
await import("./popup/ModalContent.test.mjs");

test/popup/ListPopup.test.mjs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* @typedef {import('../../src/popup/ListPopup').ListPopupProps} ListPopupProps
3+
*/
4+
import React from "react";
5+
import TestRenderer from "react-test-renderer";
6+
import assert from "node:assert/strict";
7+
import { assertComponent, assertComponents, mockComponent } from "react-assert";
8+
import mockFunction from "mock-fn";
9+
import DefaultTheme from "../../src/theme/DefaultTheme.mjs";
10+
import withThemeContext from "../theme/withThemeContext.mjs";
11+
import Popup from "../../src/popup/Popup.mjs";
12+
import ModalContent from "../../src/popup/ModalContent.mjs";
13+
import WithSize from "../../src/WithSize.mjs";
14+
import ListBox from "../../src/ListBox.mjs";
15+
import ListPopup from "../../src/popup/ListPopup.mjs";
16+
17+
const h = React.createElement;
18+
19+
const { describe, it } = await (async () => {
20+
// @ts-ignore
21+
const module = process.isBun ? "bun:test" : "node:test";
22+
// @ts-ignore
23+
return process.isBun // @ts-ignore
24+
? Promise.resolve({ describe: (_, fn) => fn(), it: test })
25+
: import(module);
26+
})();
27+
28+
ListPopup.popupComp = mockComponent(Popup);
29+
ListPopup.modalContentComp = mockComponent(ModalContent);
30+
ListPopup.withSizeComp = mockComponent(WithSize);
31+
ListPopup.listBoxComp = mockComponent(ListBox);
32+
33+
const { popupComp, modalContentComp, withSizeComp, listBoxComp } = ListPopup;
34+
35+
describe("ListPopup.test.mjs", () => {
36+
it("should not call onAction if empty items when onAction", () => {
37+
//given
38+
const onAction = mockFunction();
39+
const props = { ...getListPopupProps(), items: [], onAction };
40+
const comp = TestRenderer.create(
41+
withThemeContext(h(ListPopup, props))
42+
).root;
43+
const renderContent = comp.findByType(withSizeComp).props.render(60, 20);
44+
const resultContent = TestRenderer.create(renderContent).root;
45+
46+
//when
47+
resultContent.findByType(listBoxComp).props.onAction(1);
48+
49+
//then
50+
assert.deepEqual(onAction.times, 0);
51+
});
52+
53+
it("should call onAction when onAction", () => {
54+
//given
55+
const onAction = mockFunction((index) => {
56+
//then
57+
assert.deepEqual(index, 1);
58+
});
59+
const props = { ...getListPopupProps(), onAction };
60+
const comp = TestRenderer.create(
61+
withThemeContext(h(ListPopup, props))
62+
).root;
63+
const renderContent = comp.findByType(withSizeComp).props.render(60, 20);
64+
const resultContent = TestRenderer.create(renderContent).root;
65+
66+
//when
67+
resultContent.findByType(listBoxComp).props.onAction(1);
68+
69+
//then
70+
assert.deepEqual(onAction.times, 1);
71+
});
72+
73+
it("should render popup with empty list", () => {
74+
//given
75+
const props = { ...getListPopupProps(), items: [] };
76+
77+
//when
78+
const result = TestRenderer.create(
79+
withThemeContext(h(ListPopup, props))
80+
).root;
81+
82+
//then
83+
assertListPopup(result, props, [], [60, 20], [56, 14]);
84+
});
85+
86+
it("should render popup with min size", () => {
87+
//given
88+
const props = { ...getListPopupProps(), items: new Array(20).fill("item") };
89+
90+
//when
91+
const result = TestRenderer.create(
92+
withThemeContext(h(ListPopup, props))
93+
).root;
94+
95+
//then
96+
assertListPopup(
97+
result,
98+
props,
99+
new Array(20).fill(" item "),
100+
[55, 13],
101+
[56, 14]
102+
);
103+
});
104+
105+
it("should render popup with max height", () => {
106+
//given
107+
const items = new Array(20).fill("item");
108+
const props = { ...getListPopupProps(), items, selected: items.length - 1 };
109+
110+
//when
111+
const result = TestRenderer.create(
112+
withThemeContext(h(ListPopup, props))
113+
).root;
114+
115+
//then
116+
assertListPopup(
117+
result,
118+
props,
119+
new Array(20).fill(" item "),
120+
[60, 20],
121+
[56, 16]
122+
);
123+
});
124+
125+
it("should render popup with max width", () => {
126+
//given
127+
const props = {
128+
...getListPopupProps(),
129+
items: new Array(20).fill(
130+
"iteeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeem"
131+
),
132+
};
133+
134+
//when
135+
const result = TestRenderer.create(
136+
withThemeContext(h(ListPopup, props))
137+
).root;
138+
139+
//then
140+
assertListPopup(
141+
result,
142+
props,
143+
new Array(20).fill(
144+
" ite...eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeem "
145+
),
146+
[60, 20],
147+
[60, 16]
148+
);
149+
});
150+
});
151+
152+
/**
153+
* @returns {ListPopupProps}
154+
*/
155+
function getListPopupProps() {
156+
return {
157+
title: "Test Title",
158+
items: ["item 1", "item 2"],
159+
onAction: () => {},
160+
onClose: () => {},
161+
footer: "test footer",
162+
};
163+
}
164+
165+
/**
166+
* @param {TestRenderer.ReactTestInstance} result
167+
* @param {ListPopupProps} props
168+
* @param {string[]} items
169+
* @param {number[]} screenSize
170+
* @param {number[]} expectedSize
171+
*/
172+
function assertListPopup(result, props, items, screenSize, expectedSize) {
173+
assert.deepEqual(ListPopup.displayName, "ListPopup");
174+
175+
const theme = DefaultTheme.popup.menu;
176+
const [width, height] = screenSize;
177+
const [expectedWidth, expectedHeight] = expectedSize;
178+
const contentWidth = expectedWidth - 2 * (ListPopup.paddingHorizontal + 1);
179+
const contentHeight = expectedHeight - 2 * (ListPopup.paddingVertical + 1);
180+
181+
const withSizeProps = result.findByType(withSizeComp).props;
182+
const content = TestRenderer.create(withSizeProps.render(width, height)).root;
183+
assertComponent(
184+
content,
185+
h(
186+
modalContentComp,
187+
{
188+
title: props.title,
189+
width: expectedWidth,
190+
height: expectedHeight,
191+
style: theme,
192+
padding: ListPopup.padding,
193+
left: undefined,
194+
footer: props.footer,
195+
},
196+
h(listBoxComp, {
197+
left: 1,
198+
top: 1,
199+
width: contentWidth,
200+
height: contentHeight,
201+
selected: props.selected ?? 0,
202+
items: items,
203+
style: theme,
204+
onAction: () => {},
205+
onSelect: props.onSelect,
206+
})
207+
)
208+
);
209+
210+
assertComponents(
211+
result.children,
212+
h(
213+
popupComp,
214+
{
215+
onClose: () => {},
216+
focusable: undefined,
217+
onKeypress: undefined,
218+
},
219+
h(withSizeComp, {
220+
render: mockFunction(),
221+
})
222+
)
223+
);
224+
}

0 commit comments

Comments
 (0)