UNPKG

@teaui/core

Version:

A high-level terminal UI library for Node

389 lines 13.3 kB
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