UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

169 lines • 9.04 kB
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