-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
8057a13
commit 19b45e5
Showing
4 changed files
with
358 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}) | ||
) | ||
); | ||
} |