Skip to content

Commit

Permalink
Added ListPopup component
Browse files Browse the repository at this point in the history
  • Loading branch information
viktor-podzigun committed Feb 20, 2024
1 parent 8057a13 commit 19b45e5
Show file tree
Hide file tree
Showing 4 changed files with 358 additions and 0 deletions.
13 changes: 13 additions & 0 deletions src/popup/ListPopup.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface ListPopupProps {
readonly title: string;
readonly items: string[];
onAction(index: number): void;
onClose(): void;
readonly selected?: number;
onSelect?(index: number): void;
onKeypress?(keyFull: string): boolean;
readonly footer?: string;
readonly textPaddingLeft?: number;
readonly textPaddingRight?: number;
readonly itemWrapPrefixLen?: number;
}
120 changes: 120 additions & 0 deletions src/popup/ListPopup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @typedef {import("./ModalContent").BlessedPadding} BlessedPadding
* @typedef {import("./ListPopup").ListPopupProps} ListPopupProps
*/
import React from "react";
import Theme from "../theme/Theme.mjs";
import Popup from "./Popup.mjs";
import ModalContent from "./ModalContent.mjs";
import WithSize from "../WithSize.mjs";
import ListBox from "../ListBox.mjs";
import TextLine from "../TextLine.mjs";

const h = React.createElement;

/**
* @param {ListPopupProps} props
*/
const ListPopup = (props) => {
const { popupComp, modalContentComp, withSizeComp, listBoxComp } = ListPopup;

const items = props.items;
const theme = Theme.useTheme().popup.menu;
const textPaddingLeft = props.textPaddingLeft ?? 2;
const textPaddingRight = props.textPaddingRight ?? 1;
const itemWrapPrefixLen = props.itemWrapPrefixLen ?? 3;
const textPaddingLen = textPaddingLeft + textPaddingRight;
const textPaddingLeftStr = " ".repeat(textPaddingLeft);
const textPaddingRightStr = " ".repeat(textPaddingRight);

return h(
popupComp,
{
onClose: props.onClose,
onKeypress: props.onKeypress,
},
h(withSizeComp, {
render: (width, height) => {
const maxContentWidth =
items.length === 0
? 2 * (ListPopup.paddingHorizontal + 1)
: items.reduce((_1, _2) => Math.max(_1, _2.length), 0) +
2 * (ListPopup.paddingHorizontal + 1);

const maxContentHeight =
items.length + 2 * (ListPopup.paddingVertical + 1);

const modalWidth = Math.min(
Math.max(minWidth, maxContentWidth + textPaddingLen),
Math.max(minWidth, width)
);
const modalHeight = Math.min(
Math.max(minHeight, maxContentHeight),
Math.max(minHeight, height - 4)
);

const contentWidth = modalWidth - 2 * (ListPopup.paddingHorizontal + 1); // padding + border
const contentHeight = modalHeight - 2 * (ListPopup.paddingVertical + 1);

return h(
modalContentComp,
{
title: props.title,
width: modalWidth,
height: modalHeight,
style: theme,
padding: ListPopup.padding,
footer: props.footer,
},
h(listBoxComp, {
left: 1,
top: 1,
width: contentWidth,
height: contentHeight,
selected: props.selected ?? 0,
items: items.map((item) => {
return (
textPaddingLeftStr +
TextLine.wrapText(
item,
contentWidth - textPaddingLen,
itemWrapPrefixLen
) +
textPaddingRightStr
);
}),
style: theme,
onAction: (index) => {
if (items.length > 0) {
props.onAction(index);
}
},
onSelect: props.onSelect,
})
);
},
})
);
};

ListPopup.displayName = "ListPopup";
ListPopup.popupComp = Popup;
ListPopup.modalContentComp = ModalContent;
ListPopup.withSizeComp = WithSize;
ListPopup.listBoxComp = ListBox;

ListPopup.paddingHorizontal = 2;
ListPopup.paddingVertical = 1;

const minWidth = 50 + 2 * (ListPopup.paddingHorizontal + 1); // padding + border
const minHeight = 10 + 2 * (ListPopup.paddingVertical + 1);

/** @type {BlessedPadding} */
ListPopup.padding = {
left: ListPopup.paddingHorizontal,
right: ListPopup.paddingHorizontal,
top: ListPopup.paddingVertical,
bottom: ListPopup.paddingVertical,
};

export default ListPopup;
1 change: 1 addition & 0 deletions test/all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ await import("./menu/MenuBarTrigger.test.mjs");
await import("./menu/MenuPopup.test.mjs");
await import("./menu/SubMenu.test.mjs");

await import("./popup/ListPopup.test.mjs");
await import("./popup/MessageBox.test.mjs");
await import("./popup/Modal.test.mjs");
await import("./popup/ModalContent.test.mjs");
Expand Down
224 changes: 224 additions & 0 deletions test/popup/ListPopup.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* @typedef {import('../../src/popup/ListPopup').ListPopupProps} ListPopupProps
*/
import React from "react";
import TestRenderer from "react-test-renderer";
import assert from "node:assert/strict";
import { assertComponent, assertComponents, mockComponent } from "react-assert";
import mockFunction from "mock-fn";
import DefaultTheme from "../../src/theme/DefaultTheme.mjs";
import withThemeContext from "../theme/withThemeContext.mjs";
import Popup from "../../src/popup/Popup.mjs";
import ModalContent from "../../src/popup/ModalContent.mjs";
import WithSize from "../../src/WithSize.mjs";
import ListBox from "../../src/ListBox.mjs";
import ListPopup from "../../src/popup/ListPopup.mjs";

const h = React.createElement;

const { describe, it } = await (async () => {
// @ts-ignore
const module = process.isBun ? "bun:test" : "node:test";
// @ts-ignore
return process.isBun // @ts-ignore
? Promise.resolve({ describe: (_, fn) => fn(), it: test })
: import(module);
})();

ListPopup.popupComp = mockComponent(Popup);
ListPopup.modalContentComp = mockComponent(ModalContent);
ListPopup.withSizeComp = mockComponent(WithSize);
ListPopup.listBoxComp = mockComponent(ListBox);

const { popupComp, modalContentComp, withSizeComp, listBoxComp } = ListPopup;

describe("ListPopup.test.mjs", () => {
it("should not call onAction if empty items when onAction", () => {
//given
const onAction = mockFunction();
const props = { ...getListPopupProps(), items: [], onAction };
const comp = TestRenderer.create(
withThemeContext(h(ListPopup, props))
).root;
const renderContent = comp.findByType(withSizeComp).props.render(60, 20);
const resultContent = TestRenderer.create(renderContent).root;

//when
resultContent.findByType(listBoxComp).props.onAction(1);

//then
assert.deepEqual(onAction.times, 0);
});

it("should call onAction when onAction", () => {
//given
const onAction = mockFunction((index) => {
//then
assert.deepEqual(index, 1);
});
const props = { ...getListPopupProps(), onAction };
const comp = TestRenderer.create(
withThemeContext(h(ListPopup, props))
).root;
const renderContent = comp.findByType(withSizeComp).props.render(60, 20);
const resultContent = TestRenderer.create(renderContent).root;

//when
resultContent.findByType(listBoxComp).props.onAction(1);

//then
assert.deepEqual(onAction.times, 1);
});

it("should render popup with empty list", () => {
//given
const props = { ...getListPopupProps(), items: [] };

//when
const result = TestRenderer.create(
withThemeContext(h(ListPopup, props))
).root;

//then
assertListPopup(result, props, [], [60, 20], [56, 14]);
});

it("should render popup with min size", () => {
//given
const props = { ...getListPopupProps(), items: new Array(20).fill("item") };

//when
const result = TestRenderer.create(
withThemeContext(h(ListPopup, props))
).root;

//then
assertListPopup(
result,
props,
new Array(20).fill(" item "),
[55, 13],
[56, 14]
);
});

it("should render popup with max height", () => {
//given
const items = new Array(20).fill("item");
const props = { ...getListPopupProps(), items, selected: items.length - 1 };

//when
const result = TestRenderer.create(
withThemeContext(h(ListPopup, props))
).root;

//then
assertListPopup(
result,
props,
new Array(20).fill(" item "),
[60, 20],
[56, 16]
);
});

it("should render popup with max width", () => {
//given
const props = {
...getListPopupProps(),
items: new Array(20).fill(
"iteeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeem"
),
};

//when
const result = TestRenderer.create(
withThemeContext(h(ListPopup, props))
).root;

//then
assertListPopup(
result,
props,
new Array(20).fill(
" ite...eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeem "
),
[60, 20],
[60, 16]
);
});
});

/**
* @returns {ListPopupProps}
*/
function getListPopupProps() {
return {
title: "Test Title",
items: ["item 1", "item 2"],
onAction: () => {},
onClose: () => {},
footer: "test footer",
};
}

/**
* @param {TestRenderer.ReactTestInstance} result
* @param {ListPopupProps} props
* @param {string[]} items
* @param {number[]} screenSize
* @param {number[]} expectedSize
*/
function assertListPopup(result, props, items, screenSize, expectedSize) {
assert.deepEqual(ListPopup.displayName, "ListPopup");

const theme = DefaultTheme.popup.menu;
const [width, height] = screenSize;
const [expectedWidth, expectedHeight] = expectedSize;
const contentWidth = expectedWidth - 2 * (ListPopup.paddingHorizontal + 1);
const contentHeight = expectedHeight - 2 * (ListPopup.paddingVertical + 1);

const withSizeProps = result.findByType(withSizeComp).props;
const content = TestRenderer.create(withSizeProps.render(width, height)).root;
assertComponent(
content,
h(
modalContentComp,
{
title: props.title,
width: expectedWidth,
height: expectedHeight,
style: theme,
padding: ListPopup.padding,
left: undefined,
footer: props.footer,
},
h(listBoxComp, {
left: 1,
top: 1,
width: contentWidth,
height: contentHeight,
selected: props.selected ?? 0,
items: items,
style: theme,
onAction: () => {},
onSelect: props.onSelect,
})
)
);

assertComponents(
result.children,
h(
popupComp,
{
onClose: () => {},
focusable: undefined,
onKeypress: undefined,
},
h(withSizeComp, {
render: mockFunction(),
})
)
);
}

0 comments on commit 19b45e5

Please sign in to comment.