@teaui/core
Version:
A high-level terminal UI library for Node
367 lines • 12.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Dropdown = void 0;
const sys_1 = require("../sys");
const View_1 = require("../View");
const Container_1 = require("../Container");
const geometry_1 = require("../geometry");
const components_1 = require("../components");
const events_1 = require("../events");
class Dropdown extends View_1.View {
dropdownSelector;
#title;
#showModal = false;
#multiple;
#onSelectCallback;
constructor({ multiple, ...props }) {
super(props);
this.#multiple = multiple ?? false;
this.dropdownSelector = new DropdownSelector({
theme: this.theme,
multiple: this.#multiple,
choices: [],
selected: [],
onSelect: () => this.#onSelect(),
});
this.#update(props);
}
update(props) {
this.#update(props);
super.update(props);
}
#update({ title, choices, selected, onSelect }) {
this.#onSelectCallback = onSelect;
this.#title = title ? title.split('\n') : undefined;
this.choices = choices;
this.selected = selected;
this.dropdownSelector.theme = this.theme;
}
get choices() {
return this.dropdownSelector.choices;
}
set choices(choices) {
this.dropdownSelector.choices = choices;
}
#titleLines() {
if (this.#title !== undefined &&
this.dropdownSelector.selectedValues.length === 0) {
return this.#title;
}
return this.dropdownSelector.selectedText ?? ['<select>'];
}
dismissModal() {
this.#showModal = false;
}
get selected() {
if (this.#multiple) {
return this.dropdownSelector.selectedValues;
}
else {
return this.dropdownSelector.selectedValue;
}
}
set selected(selected) {
this.dropdownSelector.selectedRows = dropdownSelectedRows(selected, this.dropdownSelector.choices, this.#multiple);
}
#onSelect() {
if (this.#multiple) {
;
this.#onSelectCallback?.(this.dropdownSelector.selectedValues);
}
else {
this.dismissModal();
const value = this.dropdownSelector.selectedValue;
if (value !== undefined) {
;
this.#onSelectCallback?.(value);
}
}
this.invalidateSize();
}
naturalSize() {
const size = new geometry_1.Size(sys_1.unicode.stringSize(this.#titleLines()));
return size.grow(8, 0);
}
receiveMouse(event, system) {
super.receiveMouse(event, system);
if ((0, events_1.isMouseClicked)(event)) {
this.#showModal = true;
}
}
render(viewport) {
if (viewport.isEmpty) {
return;
}
if (this.#showModal) {
viewport.requestModal(this.dropdownSelector, () => {
this.#showModal = false;
});
}
viewport.registerMouse(['mouse.move', 'mouse.button.left']);
const lines = this.#titleLines();
const textStyle = this.theme.ui({
isHover: this.isHover && !this.#showModal,
});
viewport.paint(textStyle);
const pt = new geometry_1.Point(0, 0).mutableCopy();
const lineIndexOffset = ~~((viewport.contentSize.height - lines.length) / 2);
for (; pt.y < viewport.contentSize.height; pt.y++) {
const lineIndex = pt.y - lineIndexOffset;
if (lineIndex >= 0 && lineIndex < lines.length) {
viewport.write(lines[lineIndex], pt.offset(1, 0), textStyle);
}
viewport.write(`▏ `, pt.offset(viewport.contentSize.width - 3, 0), textStyle);
}
viewport.write(this.#showModal
? ARROWS.open
: this.isHover
? ARROWS.hover
: ARROWS.default, new geometry_1.Point(viewport.contentSize.width - 2, viewport.contentSize.height / 2), textStyle);
}
}
exports.Dropdown = Dropdown;
class DropdownSelector extends Container_1.Container {
#choices;
#selected;
#multiple;
#onSelect;
#scrollView;
#box = new components_1.Box({ maxHeight: 24, border: BORDERS.below });
#checkbox;
constructor({ choices, selected, multiple, onSelect, ...viewProps }) {
super({ ...viewProps });
this.#choices = choices.map(([text, value]) => [
text.split('\n'),
value,
text,
]);
this.#selected = new Set(selected);
this.#multiple = multiple;
this.#onSelect = onSelect;
this.#checkbox = new components_1.Checkbox({
title: 'Select all',
value: false,
onChange: value => {
if (value) {
this.#selected = new Set(Array(this.#choices.length).keys());
}
else {
this.#selected = new Set();
}
onSelect();
this.#scrollView.invalidateAllRows('view');
},
});
this.#scrollView = new components_1.ScrollableList({
items: this.#choices.map(([, choice]) => choice),
cellForItem: (choice, row) => this.cellForItem(choice, row),
});
const content = new components_1.Stack({ direction: 'down', children: [] });
if (multiple) {
content.add(this.#checkbox);
}
content.add(this.#scrollView);
this.#box.add(content);
this.add(this.#box);
this.#checkbox.value = this.#isAllSelected();
}
#isAllSelected() {
return this.#selected.size === this.#choices.length;
}
get selectedRows() {
return [...this.#selected];
}
set selectedRows(rows) {
new Set([...this.#selected, ...rows]).forEach(selected => {
const item = this.#choices[selected][1];
this.#scrollView.invalidateItem(item, 'view');
});
this.#selected = new Set(rows);
}
get selectedText() {
if (this.#selected.size === 0) {
return undefined;
}
if (this.#selected.size > 1) {
const rows = [...this.#selected];
rows.sort();
// honestly, it's strange to use multiple lines in your dropdown choices...
// but we support it! When multiple items are selected, the text becomes:
// 1. join each line with a space
// 2. join each entry with a comma
// e.g. "Selected\n1", "Selected\n2" becomes "Selected 1, Selected 2"
return [rows.map(index => this.#choices[index][0].join(' ')).join(', ')];
}
// if only one item is selected, make that the title, preserving multiple lines
const [row] = [...this.#selected];
return this.#choices[row][0];
}
get selectedValue() {
if (this.#selected.size === 0) {
return undefined;
}
const [row] = [...this.#selected];
return this.#choices[row][1];
}
get selectedValues() {
return [...this.#selected].map(index => this.#choices[index][1]);
}
get choices() {
return this.#choices.map(([_, choice, text]) => [text, choice]);
}
/**
* Sets new choices, preserving the previously selected items.
*/
set choices(choices) {
const selected = [...this.#selected].map(index => this.#choices[index][1]);
this.#choices = choices.map(([text, choice]) => [
text.split('\n'),
choice,
text,
]);
this.#selected = new Set(selected.flatMap(item => {
const index = choices.findIndex(([_, choice]) => choice === item);
if (index === -1) {
return [];
}
return [index];
}));
this.#scrollView.updateItems(choices.map(([, choice]) => choice));
}
cellForItem(choice, row) {
const button = this.#cellButton(choice, row);
return components_1.Stack.right({
fill: false,
children: [
['flex1', button],
new components_1.Separator({ direction: 'vertical', border: 'single' }),
],
});
}
#cellButton(choice, row) {
const lines = this.#choices[row][0];
const isSelected = [...this.#selected].some(index => index === row);
return new components_1.Button({
theme: isSelected ? 'selected' : undefined,
border: 'none',
align: 'left',
child: new components_1.Text({
lines: lines.map((line, index) => {
return dropdownPrefix(this.#multiple, index, isSelected) + line;
}),
}),
onClick: () => {
this.#selected.forEach(selected => {
const item = this.#choices[selected][1];
this.#scrollView.invalidateItem(item, 'view');
});
this.#scrollView.invalidateItem(choice, 'view');
if (this.#multiple) {
if (this.#selected.has(row)) {
this.#selected.delete(row);
}
else {
this.#selected.add(row);
}
}
else {
this.#selected = new Set([row]);
}
this.#checkbox.value = this.#isAllSelected();
this.#onSelect();
},
});
}
render(viewport) {
if (viewport.isEmpty) {
return super.render(viewport);
}
const naturalSize = this.naturalSize(viewport.contentSize).max(viewport.contentSize);
const fitsBelow = viewport.parentRect.maxY() + naturalSize.height <
viewport.contentSize.height;
const fitsAbove = naturalSize.height <= viewport.parentRect.minY();
// 1. doesn't fit above or below, pick the side that has more room
// 2. prefer below
// 3. otherwise above
let placement;
let height = naturalSize.height;
if (!fitsBelow && !fitsAbove) {
const spaceBelow = viewport.contentSize.height - viewport.parentRect.maxY() + 1;
const spaceAbove = viewport.parentRect.minY() + 1;
if (spaceAbove > spaceBelow) {
placement = 'above';
height = spaceAbove;
}
else {
placement = 'below';
height = spaceBelow;
}
}
else if (fitsBelow) {
placement = 'below';
}
else {
placement = 'above';
}
const width = Math.max(viewport.parentRect.size.width, Math.min(naturalSize.width, viewport.contentSize.width - viewport.parentRect.minX()));
const x = Math.max(0, viewport.parentRect.maxX() - width, viewport.parentRect.minX());
let y;
if (placement === 'below') {
y = viewport.parentRect.maxY();
}
else {
y = viewport.parentRect.minY() - height;
}
this.#box.border = BORDERS[placement];
const rect = new geometry_1.Rect([x, y], [width, height]);
viewport.clipped(rect, inside => super.render(inside));
}
}
function dropdownSelectedRows(selected, choices, multiple) {
let selectedItems;
if (multiple) {
selectedItems = selected;
}
else if (selected !== undefined) {
selectedItems = [selected];
}
else {
return [];
}
return selectedItems.flatMap(item => {
const index = choices.findIndex(([_, choice]) => choice === item);
if (index === -1) {
return [];
}
return [index];
});
}
function dropdownPrefix(multiple, index, isSelected) {
if (index !== 0) {
return ' ';
}
if (multiple) {
return isSelected ? BOX.multiple.checked : BOX.multiple.unchecked;
}
else {
return isSelected ? BOX.single.checked : BOX.single.unchecked;
}
}
const ARROWS = { hover: '▼', default: '▽', open: '◇' };
const BORDERS = {
control: ['─', '│', '╭', '╮', '╰', '╯'],
hover: ['─', '│', '╭', '╮', '╰', '╯', '─', '│'],
below: ['─', '│', '╭', '╮', '╰', '╯', '─', '│'],
above: ['─', '│', '╭', '╮', '╰', '╯', '─', '│'],
};
const BOX = {
multiple: {
unchecked: '☐ ',
checked: '☑ ',
},
single: {
unchecked: '◯ ',
checked: '⦿ ',
},
};
//# sourceMappingURL=Dropdown.js.map