@shopify/cli-kit
Version:
A set of utilities, interfaces, and models that are common across all the platform features
169 lines • 9.04 kB
JavaScript
import { Scrollbar } from './Scrollbar.js';
import { useSelectState } from '../hooks/use-select-state.js';
import useLayout from '../hooks/use-layout.js';
import { handleCtrlC } from '../../ui.js';
import React, { useCallback, forwardRef, useEffect } from 'react';
import { Box, useInput, Text } from 'ink';
import chalk from 'chalk';
import figures from 'figures';
import sortBy from 'lodash/sortBy.js';
function highlightedLabel(label, term) {
if (!term) {
return label;
}
let regex;
try {
regex = new RegExp(term, 'i');
// eslint-disable-next-line no-catch-all/no-catch-all
}
catch (error) {
// term is user provided and could be an invalid regex at that moment (e.g. ending in '\')
return label;
}
return label.replace(regex, (match) => {
return chalk.bold(match);
});
}
function validateKeys(items) {
if (items.some((item) => (item.key?.length ?? 0) > 1)) {
throw new Error('SelectInput: Keys must be a single character');
}
if (!items.every((item) => typeof item.key !== 'undefined' && item.key.length > 0)) {
throw new Error('SelectInput: All items must have keys if one does');
}
}
// eslint-disable-next-line react/function-component-definition
function Item({ item, previousItem, isSelected, highlightedTerm, enableShortcuts, items, hasAnyGroup, index, }) {
const label = highlightedLabel(item.label, highlightedTerm);
let title;
let labelColor;
if (isSelected) {
labelColor = 'cyan';
}
else if (item.disabled) {
labelColor = 'dim';
}
if (typeof previousItem === 'undefined' || item.group !== previousItem.group) {
title = item.group ?? (hasAnyGroup ? 'Other' : undefined);
}
const showKey = enableShortcuts && item.key && item.key.length > 0;
return (React.createElement(Box, { key: index, flexDirection: "column", marginTop: items.indexOf(item) !== 0 && title ? 1 : 0, minHeight: title ? 2 : 1 },
title ? (React.createElement(Box, { marginLeft: 3 },
React.createElement(Text, { bold: true }, title))) : null,
React.createElement(Box, { key: index, marginLeft: hasAnyGroup ? 3 : 0 },
React.createElement(Box, { marginRight: 2 }, isSelected ? React.createElement(Text, { color: "cyan" }, `>`) : React.createElement(Text, null, " ")),
React.createElement(Text, { wrap: "end", color: labelColor }, showKey ? `(${item.key}) ${label}` : label))));
}
const MAX_AVAILABLE_LINES = 25;
// eslint-disable-next-line react/function-component-definition
function SelectInputInner({ items: rawItems, initialItems = rawItems, onChange, enableShortcuts = true, focus = true, emptyMessage = 'No items to select.', defaultValue, highlightedTerm, loading = false, errorMessage, hasMorePages = false, morePagesMessage, availableLines = MAX_AVAILABLE_LINES, onSubmit, inputFixedAreaRef, groupOrder, }, ref) {
let noItems = false;
if (rawItems.length === 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-param-reassign
rawItems = [{ label: emptyMessage, value: null, disabled: true }];
noItems = true;
}
const hasAnyGroup = rawItems.some((item) => typeof item.group !== 'undefined');
const items = sortBy(rawItems, (item) => {
// Items without groups ("Other") always go last
if (!item.group)
return Number.MAX_SAFE_INTEGER + 1;
// If no groupOrder specified, use default behavior
if (!groupOrder)
return Number.MAX_SAFE_INTEGER;
// Items with groups get their position from groupOrder, or MAX_SAFE_INTEGER if not specified
const index = groupOrder.indexOf(item.group);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
});
const itemsHaveKeys = items.some((item) => typeof item.key !== 'undefined' && item.key.length > 0);
if (itemsHaveKeys)
validateKeys(items);
const availableLinesToUse = Math.min(availableLines, MAX_AVAILABLE_LINES);
function maximumLinesLostToGroups(items) {
// Calculate a safe estimate of the limit needed based on the space available
const numberOfGroups = new Set(items.map((item) => item.group).filter((group) => group)).size;
// Add 1 to numberOfGroups because we also have a default Other group
const maxVisibleGroups = Math.ceil(Math.min((availableLinesToUse + 1) / 3, numberOfGroups + 1));
// If we have x visible groups, we lose 1 line to the first group + 2 lines to the rest
return numberOfGroups > 0 ? (maxVisibleGroups - 1) * 2 + 1 : 0;
}
const maxLinesLostToGroups = maximumLinesLostToGroups(items);
const limit = Math.max(2, availableLinesToUse - maxLinesLostToGroups);
const hasLimit = items.length > limit;
const state = useSelectState({
visibleOptionCount: limit,
options: items,
defaultValue,
});
useEffect(() => {
if (typeof state.value !== 'undefined' && state.previousValue !== state.value) {
onChange?.(items.find((item) => item.value === state.value));
}
}, [state.previousValue, state.value, items, onChange]);
const handleArrows = (key) => {
if (key.upArrow) {
state.selectPreviousOption();
}
else if (key.downArrow) {
state.selectNextOption();
}
};
const handleShortcuts = useCallback((input) => {
if (state.visibleOptions.map((item) => item.key).includes(input)) {
const itemWithKey = state.visibleOptions.find((item) => item.key === input);
const item = items.find((item) => item.value === itemWithKey?.value);
if (itemWithKey && !itemWithKey.disabled) {
// keep this order of operations so that there is no flickering
if (onSubmit && item) {
onSubmit(item);
}
state.selectOption({ option: itemWithKey });
}
}
}, [items, onSubmit, state]);
useInput((input, key) => {
handleCtrlC(input, key);
if (typeof state.value !== 'undefined' && key.return) {
const item = items.find((item) => item.value === state.value);
if (item && onSubmit) {
onSubmit(item);
}
}
// check that no special modifier (shift, control, etc.) is being pressed
if (enableShortcuts && input.length > 0 && Object.values(key).every((value) => !value)) {
handleShortcuts(input);
}
else {
handleArrows(key);
}
}, { isActive: focus });
const { twoThirds } = useLayout();
if (loading) {
return (React.createElement(Box, { marginLeft: 3 },
React.createElement(Text, { dimColor: true }, "Loading...")));
}
else if (errorMessage && errorMessage.length > 0) {
return (React.createElement(Box, { marginLeft: 3 },
React.createElement(Text, { color: "red" }, errorMessage)));
}
else {
const optionsHeight = initialItems.length + maximumLinesLostToGroups(initialItems);
const minHeight = hasAnyGroup ? 5 : 2;
const sectionHeight = Math.max(minHeight, Math.min(availableLinesToUse, optionsHeight));
return (React.createElement(Box, { flexDirection: "column", ref: ref, gap: 1, width: twoThirds },
React.createElement(Box, { flexDirection: "row", height: sectionHeight, width: "100%" },
React.createElement(Box, { flexDirection: "column", overflowY: "hidden", flexGrow: 1 }, state.visibleOptions.map((item, index) => (React.createElement(Item, { key: index, item: item, previousItem: state.visibleOptions[index - 1], highlightedTerm: highlightedTerm, isSelected: item.value === state.value, items: state.visibleOptions, enableShortcuts: enableShortcuts, hasAnyGroup: hasAnyGroup, index: index })))),
hasLimit ? (React.createElement(Scrollbar, { containerHeight: sectionHeight, visibleListSectionLength: limit, fullListLength: items.length, visibleFromIndex: state.visibleFromIndex })) : null),
React.createElement(Box, { ref: inputFixedAreaRef }, noItems ? (React.createElement(Box, { marginLeft: 3 },
React.createElement(Text, { dimColor: true }, "Try again with a different keyword."))) : (React.createElement(Box, { marginLeft: 3, flexDirection: "column" },
React.createElement(Text, { dimColor: true }, `Press ${figures.arrowUp}${figures.arrowDown} arrows to select, enter ${itemsHaveKeys ? 'or a shortcut ' : ''}to confirm.`),
hasMorePages ? (React.createElement(Text, null,
React.createElement(Text, { bold: true },
"1-",
items.length,
" of many"),
morePagesMessage ? ` ${morePagesMessage}` : null)) : null)))));
}
}
export const SelectInput = forwardRef(SelectInputInner);
//# sourceMappingURL=SelectInput.js.map