@teaui/core
Version:
A high-level terminal UI library for Node
389 lines • 13.3 kB
JavaScript
import * as unicode from '@teaui/term';
import { View } from '../View.js';
import { Container } from '../Container.js';
import { Point, Size, Rect } from '../geometry.js';
import { Box } from './Box.js';
import { Button } from './Button.js';
import { Checkbox } from './Checkbox.js';
import { Modal } from './Modal.js';
import { Stack } from './Stack.js';
import { ScrollableList } from './ScrollableList.js';
import { Separator } from './Separator.js';
import { Text } from './Text.js';
import { isMouseClicked } from '../events/index.js';
import { Space } from './Space.js';
import { Style } from '../Style.js';
export class Dropdown extends View {
dropdownSelector;
#title;
#showModal = false;
#modal;
#multiple;
#onSelectCallback;
constructor({ multiple, ...props }) {
super(props);
this.#multiple = multiple ?? false;
this.dropdownSelector = new DropdownSelector({
purpose: this.purpose,
multiple: this.#multiple,
choices: [],
selected: [],
onSelect: () => this.#onSelect(),
});
this.#modal = new Modal({
dim: false,
dismissOnClick: true,
dismissOnEsc: true,
onDismiss: () => {
this.#showModal = false;
},
child: this.dropdownSelector,
});
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.purpose = this.purpose;
}
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(_available) {
const size = new Size(unicode.stringSize(this.#titleLines()));
return size.grow(8, 0);
}
receiveMouse(event, system) {
super.receiveMouse(event, system);
if (isMouseClicked(event)) {
this.#showModal = true;
}
}
render(viewport) {
if (viewport.isEmpty) {
return;
}
if (this.#showModal) {
viewport.requestModal(this.#modal);
}
viewport.registerMouse(['mouse.move', 'mouse.button.left']);
const lines = this.#titleLines();
const textStyle = this.purpose.ui({
isHover: this.isHover && !this.#showModal,
});
viewport.paint(textStyle);
const pt = new 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 Point(viewport.contentSize.width - 2, viewport.contentSize.height / 2), textStyle);
}
}
class DropdownSelector extends Container {
#choices;
#selected;
#multiple;
#onSelect;
#scrollView;
#box = new Box({ maxHeight: 24, border: BORDERS.box });
#checkbox;
constructor({ choices, selected, multiple, onSelect, ...viewProps }) {
super({ ...viewProps });
this.add(this.#box);
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 Checkbox({
title: 'Select all',
value: this.#isAllSelected(),
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 ScrollableList({
data: this.#choices.map(([, choice]) => choice),
renderItem: (choice, row) => this.renderItem(choice, row),
});
if (multiple) {
const content = Stack.down([]);
content.add(Stack.right([this.#checkbox, Space.horizontal(2)], { width: 'shrink' }));
content.add(this.#checkbox);
content.add(this.#scrollView);
this.#box.add(content);
this.add(Stack.right([
new Space({ flex: 1 }),
new Text({
text: '├─┤',
style: new Style({ background: this.purpose.textBackgroundColor }),
}),
], { y: 1, width: 'shrink' }));
}
else {
this.#box.add(this.#scrollView);
}
}
#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.updateData(choices.map(([, choice]) => choice));
}
renderItem(choice, row) {
const button = this.#cellButton(choice, row);
return Stack.right({
fill: false,
children: [
['flex1', button],
new Separator({ direction: 'vertical', border: 'single' }),
],
});
}
#cellButton(choice, row) {
const lines = this.#choices[row][0];
const isSelected = [...this.#selected].some(index => index === row);
const button = new Button({
purpose: isSelected ? 'selected' : undefined,
border: 'none',
align: 'left',
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();
},
});
button.add(new Text({
lines: lines.map((line, index) => {
return dropdownPrefix(this.#multiple, index, isSelected) + line;
}),
}));
return button;
}
render(viewport) {
if (viewport.isEmpty) {
return super.render(viewport);
}
const naturalSize = this.naturalSize(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;
}
const rect = new 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: ['─', '│', '╭', '╮', '╰', '╯', '─', '│'],
box: ['─', '│', '╭', '┬─╮', '╰', '┴─╯', '─', '│'],
};
const BOX = {
multiple: {
unchecked: '☐ ',
checked: '☑ ',
},
single: {
unchecked: '◯ ',
checked: '⦿ ',
},
};
//# sourceMappingURL=Dropdown.js.map