@kiwicom/orbit-components
Version:
Orbit-components is a React component library which provides developers with the easiest possible way of building Kiwi.com's products.
532 lines (531 loc) • 31.5 kB
JavaScript
"use strict";
"use client";
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default;
exports.__esModule = true;
exports.default = void 0;
var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
var _react = _interopRequireDefault(require("react"));
var _clsx = _interopRequireDefault(require("clsx"));
var _helpers = require("./helpers");
var _InputSelectOption = _interopRequireDefault(require("./InputSelectOption"));
var _CloseCircle = _interopRequireDefault(require("../icons/CloseCircle"));
var _InputField = _interopRequireDefault(require("../InputField"));
var _useRandomId = require("../hooks/useRandomId");
var _useClickOutside = _interopRequireDefault(require("../hooks/useClickOutside"));
var _Box = _interopRequireDefault(require("../Box"));
var _Text = _interopRequireDefault(require("../Text"));
var _Stack = _interopRequireDefault(require("../Stack"));
var _useMediaQuery = _interopRequireDefault(require("../hooks/useMediaQuery"));
var _Modal = _interopRequireWildcard(require("../Modal"));
var _ModalCloseButton = _interopRequireDefault(require("../Modal/ModalCloseButton"));
var _Button = _interopRequireDefault(require("../Button"));
var _Heading = _interopRequireDefault(require("../Heading"));
var _ModalSection = require("../Modal/ModalSection");
var _ModalHeader = require("../Modal/ModalHeader");
var _tailwind = require("../common/tailwind");
/**
* @orbit-doc-start
* README
* ----------
* # InputSelect
*
* To implement the InputSelect component into your project you'll need to add the import:
*
* ```jsx
* import InputSelect from "@kiwicom/orbit-components/lib/InputSelect";
* ```
*
* After adding import to your project you can use it simply like:
*
* ```jsx
* const options = [
* {
* title: "Option 1",
* value: 1,
* description: "Description for option 1",
* },
* {
* title: "Option 2",
* value: 2,
* description: "Description for option 2",
* },
* ...
* ];
*
* <InputSelect options={options} labelClear="Clear value" />;
* ```
*
* By using the `onOptionSelect` prop you can have access to the selected option to update your app state based on that. This is called with `null` when the input value is cleared or there is no selected option.
* Do not rely on the input's `value` attribute to get the selected value.
*
* ## Groups
*
* Optionally, each option can have a `group` property. If defined, options are displayed grouped with the name of the group as a label separator.
* All groups are displayed first by default. After that, if `showAll` is set to `true` (default), all options are displayed (the ones with a group and the ones without a group, following the order of the array of options). If `showAll` is set to `false`, only the options without a defined group are displayed on that bottom list.
* The `showAllLabel` allows to customize the label displayed before the bottom part. If `showAll` is set to `true`, the default value is `"All options"`. If it's set to `false`, the default value is `Other options`.
*
* ```jsx
* const options = [
* {
* title: "Option 1",
* value: 1,
* description: "Description for option 1",
* },
* {
* title: "Option 2",
* value: 2,
* description: "Description for option 2",
* group: "Group name",
* },
* ...
* ];
* ```
*
* If `prevSelected` is defined, a special group is displayed on top of every options, with the options passed to that prop. This prop is optional and its state is **not** controlled by the component.
*
* ### Props
*
* The table below contains all types of props available in the InputSelect component.
*
* | Name | Type | Default | Description |
* | :---------------- | :-------------------------------------------------------------------------------- | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
* | **options** | [`Option[]`](#option) | | **Required.** The content of the InputSelect, passed as array of objects. |
* | defaultSelected | `Option` | | Default selected option. Must be one of the options passed on the `options` prop. |
* | prevSelected | `Option` | | This displays the previously selected option on top of every other options. |
* | prevSelectedLabel | `string` | `"Previously selected"` | The label to be displayed before the previously selected option. |
* | dataAttrs | `Object` | | Optional prop for passing `data-*` attributes to the `input` DOM element. |
* | dataTest | `string` | | Optional prop for testing purposes. |
* | disabled | `boolean` | `false` | If `true`, the InputSelect will be disabled. |
* | error | `React.Node` | | The error message for the InputSelect. |
* | help | `React.Node` | | The help message for the InputSelect. |
* | id | `string` | | Adds `id` HTML attribute to an element. |
* | label | `string` | | The label for the InputSelect. |
* | name | `string` | | The name for the InputSelect. |
* | onBlur | `event => void \| Promise` | | Function for handling onBlur event. |
* | onChange | `event => void \| Promise` | | Function for handling onChange event on the text input. For option selection change, use `onOptionSelect`. |
* | onFocus | `event => void \| Promise` | | Function for handling onFocus event. |
* | onKeyDown | `event => void \| Promise` | | Function for handling onKeyDown event. |
* | onKeyUp | `event => void \| Promise` | | Function for handling onKeyUp event. |
* | onMouseUp | `event => void \| Promise` | | Function for handling onMouseUp event. |
* | onMouseDown | `event => void \| Promise` | | Function for handling onMouseDown event. |
* | placeholder | `TranslationString` | | The placeholder for the InputSelect. |
* | ref | `func` | | Prop for forwarded ref of the InputSelect. |
* | required | `boolean` | `false` | If true, the label is displayed as required. |
* | readOnly | `boolean` | | If true, InputSelect will be readonly. |
* | tabIndex | `string \| number` | | Specifies the tab order of an element. |
* | width | `string` | `100%` | Specifies width of the InputSelect. |
* | maxWidth | `string` | | Specifies max-width of the InputSelect. |
* | maxHeight | `string` | `400px` | Specifies max height of the dropdown with results for InputSelect. |
* | onOptionSelect | `(opt: Option \| null) => void` | | Callback that fires when an option is selected. |
* | onClose | `(opt: Option \| null) => void` | | Callback that fires when the list of options is closed by other means than selecting an option. It is called with the value of the selected or null, if nothing is selected. |
* | emptyState | `React.Node` | `"No results found."` | Message to display when no options are available. If a string is passed, paddings are automatically applied. |
* | labelClose | `string` | `Close` | The label for the close button in the dropdown. |
* | showAll | `boolean` | `true` | If set to true, it will display all options at the end of the list. If set to false, it will display only the options without a group at the end of the list. |
* | showAllLabel | `string` | `"All options" \| "Other options"` | The label displayed before showing the last group of options. If `showAll` is true, the default value is `"All options"`. If it is false, the default value is `"Other options"`. |
* | insideInputGroup | `boolean` | `false` | If true, the InputSelect will be rendered inside InputGroup. |
* | spaceAfter | `"none" \| "smallest" \| "small" \| "normal" \| "medium" \| "large" \| "largest"` | | Additional `margin-bottom` after component. |
* | labelClear | `string` | | Required label for the clear value button. |
*
* ### enum
*
* | spaceAfter |
* | :----------- |
* | `"none"` |
* | `"smallest"` |
* | `"small"` |
* | `"normal"` |
* | `"medium"` |
* | `"large"` |
* | `"largest"` |
*
* ## Option
*
* The table below contains all types of props available for the object in the `Option` array.
*
* | Name | Type | Description |
* | :---------- | :------------------- | :---------------------------------------------------------------------------------------------------------------------- |
* | **title** | `string` | **Required.** The title of the Option. |
* | **value** | `string \| number` | **Required.** The value of the Option. Should be unique in each option on the array of options passed to `InputSelect`. |
* | description | `string` | The description of the Option. |
* | group | `string` | The group of the Option. |
* | prefix | `React.Node` | A prefix to the title. Can be an icon, flag, etc. |
*
*
* @orbit-doc-end
*/
const InputSelect = ({
onChange,
options,
defaultSelected,
prevSelected,
prevSelectedLabel = "Previously selected",
id,
onFocus,
label,
showAll = true,
showAllLabel = showAll ? "All options" : "Other options",
help,
error,
onBlur,
placeholder,
labelClose = "Close",
emptyState = "No results found.",
onOptionSelect,
onClose,
disabled,
maxHeight = "400px",
maxWidth,
onKeyDown,
spaceAfter,
readOnly,
labelClear,
ref,
...props
}) => {
const randomId = (0, _useRandomId.useRandomIdSeed)();
const labelRef = _react.default.useRef(null);
const inputId = id || randomId("input");
const dropdownId = randomId("dropdown");
const dropdownRef = _react.default.useRef(null);
const [isOpened, setIsOpened] = _react.default.useState(false);
const [inputValue, setInputValue] = _react.default.useState(defaultSelected ? options.find(opt => opt.value === defaultSelected.value)?.title || String(defaultSelected.title) : "");
const [selectedOption, setSelectedOption] = _react.default.useState(defaultSelected || null);
const [activeIdx, setActiveIdx] = _react.default.useState(0);
const [activeDescendant, setActiveDescendant] = _react.default.useState(undefined);
const [isScrolled, setIsScrolled] = _react.default.useState(false);
const [topOffset, setTopOffset] = _react.default.useState(0);
const refs = _react.default.useMemo(() => {
return {};
}, []);
const {
isLargeMobile
} = (0, _useMediaQuery.default)();
const groupedOptions = _react.default.useMemo(() => (0, _helpers.groupOptions)(options, showAll, prevSelected), [options, prevSelected, showAll]);
const [results, setResults] = _react.default.useState(groupedOptions);
_react.default.useEffect(() => {
if (inputValue.length === 0) {
setResults(groupedOptions);
} else {
const filtered = options.filter(({
title
}) => {
return title.toLowerCase().includes(inputValue.toLowerCase());
});
setResults({
groups: [],
all: filtered,
flattened: filtered
});
}
}, [inputValue, options, groupedOptions]);
_react.default.useEffect(() => {
setActiveDescendant(refs[activeIdx]?.current?.id || undefined);
}, [activeIdx, refs]);
const handleClose = selection => {
if (!selection) {
if (inputValue === "") {
if (onOptionSelect) onOptionSelect(null);
setSelectedOption(null);
} else if (inputValue !== selectedOption?.title) {
setInputValue(selectedOption?.title || "");
}
setResults(groupedOptions);
setActiveIdx(0);
}
if (onClose && isOpened) onClose(selection || (inputValue === "" ? null : selectedOption));
setIsOpened(false);
};
const handleCloseClick = () => {
handleClose();
};
(0, _useClickOutside.default)(labelRef, handleCloseClick);
const handleFocus = ev => {
if (onFocus) onFocus(ev);
if (!readOnly) {
setIsOpened(true);
setResults(results || groupedOptions);
}
};
const handleBlur = ev => {
if (onBlur) onBlur(ev);
};
const handleInputChange = ev => {
const {
value
} = ev.currentTarget;
if (onChange) onChange(ev);
if (!isOpened) setIsOpened(true);
setInputValue(value);
setActiveIdx(0);
};
const handleDropdownKey = ev => {
if (!isOpened && (ev.code === "Enter" || ev.code === "ArrowDown" || ev.code === "ArrowUp")) {
setIsOpened(true);
return;
}
if (isOpened && ev.code === "Escape") handleClose();
if (isOpened && ev.code === "Enter") {
ev.preventDefault();
if (results.all.length !== 0) {
const option = results.flattened[activeIdx];
setSelectedOption(option);
setInputValue(option.title);
if (onOptionSelect) onOptionSelect(option);
handleClose(option);
}
}
if (ev.code === "ArrowDown") {
if (results.flattened.length - 1 > activeIdx) {
const nextIdx = activeIdx + 1;
setActiveIdx(nextIdx);
if (dropdownRef && dropdownRef.current) {
dropdownRef.current.scrollTop = refs[nextIdx].current?.offsetTop;
}
}
}
if (ev.code === "ArrowUp") {
if (activeIdx > 0) {
const prevIdx = activeIdx - 1;
setActiveIdx(prevIdx);
if (dropdownRef && dropdownRef.current) {
dropdownRef.current.scrollTop = refs[prevIdx].current?.offsetTop;
}
}
}
};
const input = /*#__PURE__*/_react.default.createElement(_InputField.default, (0, _extends2.default)({
help: isLargeMobile && help,
error: isLargeMobile && error,
label: isLargeMobile && label,
disabled: disabled,
onFocus: handleFocus,
onBlur: handleBlur,
onChange: handleInputChange,
id: inputId,
placeholder: placeholder,
autoFocus: !isLargeMobile,
role: "combobox",
value: inputValue,
onKeyDown: !readOnly ? ev => {
if (onKeyDown) onKeyDown(ev);
handleDropdownKey(ev);
} : undefined,
ariaHasPopup: isOpened,
ariaExpanded: isOpened,
ariaAutocomplete: "list",
ariaActiveDescendant: activeDescendant,
ariaControls: isOpened ? dropdownId : undefined,
autoComplete: "off",
ref: ref,
readOnly: readOnly,
prefix: selectedOption && selectedOption.prefix,
suffix: !readOnly && String(inputValue).length > 1 && /*#__PURE__*/_react.default.createElement("button", {
className: (0, _clsx.default)("me-200 appearance-none border-0 bg-transparent p-0", disabled && "pointer-events-none cursor-not-allowed"),
type: "button",
onClick: ev => {
ev.preventDefault();
if (onOptionSelect) onOptionSelect(null);
setInputValue("");
setResults(groupedOptions);
setSelectedOption(null);
setActiveIdx(0);
}
}, /*#__PURE__*/_react.default.createElement(_CloseCircle.default, {
color: "primary",
ariaLabel: labelClear
}))
}, props));
const renderOptions = () => {
if (results.groups.length === 0) {
return results.all.map((option, idx) => {
const {
title,
description,
prefix,
value: optValue
} = option;
const optionId = randomId(`${title}_${optValue}`);
const isSelected = optValue === selectedOption?.value;
const optionRef = /*#__PURE__*/_react.default.createRef();
refs[idx] = optionRef;
return /*#__PURE__*/_react.default.createElement(_InputSelectOption.default, {
key: optionId,
id: optionId,
active: activeIdx === idx,
isSelected: isSelected,
ref: optionRef,
title: title,
description: description,
prefix: prefix,
onClick: ev => {
ev.preventDefault();
setActiveIdx(idx);
setResults(groupedOptions);
if (onOptionSelect) onOptionSelect(option);
if (isLargeMobile) setIsOpened(false);
if (!isSelected) {
setInputValue(title);
setSelectedOption(option);
handleClose(option);
}
}
});
});
}
let idx = -1;
return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, results.groups.map((group, groupIdx) => {
const prevSelectedOption = prevSelected && groupIdx === 0;
const {
group: groupTitle
} = group[0];
const groupId = randomId(prevSelectedOption ? "prevSelected" : `${groupTitle}`);
return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, {
key: groupId
}, /*#__PURE__*/_react.default.createElement(_Box.default, {
padding: "300"
}, /*#__PURE__*/_react.default.createElement(_Text.default, {
type: "secondary"
}, prevSelectedOption ? prevSelectedLabel : groupTitle)), group.map(option => {
idx += 1;
const optionIdx = idx;
const optionRef = /*#__PURE__*/_react.default.createRef();
refs[optionIdx] = optionRef;
const {
title,
description,
prefix,
value: optValue
} = option;
const optionId = randomId(`${title}_${optValue}`);
const isSelected = optValue === selectedOption?.value;
return /*#__PURE__*/_react.default.createElement(_InputSelectOption.default, {
key: optionId,
id: optionId,
active: !!isLargeMobile && activeIdx === optionIdx,
isSelected: isSelected,
ref: optionRef,
title: title,
description: description,
prefix: prefix,
onClick: ev => {
ev.preventDefault();
if (onOptionSelect) onOptionSelect(option);
setActiveIdx(optionIdx);
setResults(groupedOptions);
if (isLargeMobile) setIsOpened(false);
if (!isSelected) {
setInputValue(title);
setSelectedOption(option);
handleClose(option);
}
}
});
}));
}), /*#__PURE__*/_react.default.createElement(_Box.default, {
padding: "300"
}, /*#__PURE__*/_react.default.createElement(_Text.default, {
type: "secondary"
}, showAllLabel)), results.all.map(option => {
const {
title,
description,
prefix,
value: optValue,
group
} = option;
if (group && !showAll) return null;
idx += 1;
const optionRef = /*#__PURE__*/_react.default.createRef();
const optionIdx = idx;
refs[optionIdx] = optionRef;
const optionId = randomId(`all_${title}_${optValue}`);
const isSelected = optValue === selectedOption?.value;
return /*#__PURE__*/_react.default.createElement(_InputSelectOption.default, {
key: optionId,
id: optionId,
active: activeIdx === optionIdx,
isSelected: isSelected,
ref: optionRef,
title: title,
description: description,
prefix: prefix,
onClick: ev => {
ev.preventDefault();
if (onOptionSelect) onOptionSelect(option);
setActiveIdx(optionIdx);
setResults(groupedOptions);
if (isLargeMobile) setIsOpened(false);
if (!isSelected) {
setInputValue(title);
setSelectedOption(option);
handleClose(option);
}
}
});
}));
};
const noResults = typeof emptyState === "string" ? /*#__PURE__*/_react.default.createElement(_Box.default, {
padding: "400"
}, /*#__PURE__*/_react.default.createElement(_Text.default, null, emptyState)) : emptyState;
const dropdown = isOpened && /*#__PURE__*/_react.default.createElement("div", {
className: (0, _clsx.default)("font-base bg-white-normal lm:absolute lm:inset-x-0 lm:overflow-y-scroll lm:shadow-level1 lm:rounded-100 z-[3] flex w-full flex-col", label ? "lm:top-[calc(theme(height.form-box-normal)+theme(spacing.800))]" : "lm:top-[calc(theme(height.form-box-normal)+theme(spacing.200))]"),
style: isLargeMobile ? {
maxHeight,
maxWidth
} : undefined,
role: "listbox",
id: dropdownId,
"aria-labelledby": inputId,
ref: dropdownRef
}, results.all.length === 0 ? noResults : renderOptions());
return /*#__PURE__*/_react.default.createElement("div", {
className: (0, _clsx.default)("orbit-input-select relative block", spaceAfter && _tailwind.spaceAfterClasses[spaceAfter]),
ref: labelRef
}, isLargeMobile ? /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, input, dropdown) : /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement(_InputField.default, {
label: label,
help: help,
error: error,
onFocus: !readOnly ? () => setIsOpened(true) : ev => onFocus ? onFocus(ev) : undefined,
readOnly: true,
role: "textbox",
placeholder: placeholder,
value: inputValue,
prefix: selectedOption && selectedOption.prefix,
dataTest: props.dataTest
}), isOpened && /*#__PURE__*/_react.default.createElement("div", {
className: (0, _clsx.default)("[&_.orbit-input-field-field]:mt-200 [&_.orbit-modal-footer]:shadow-none [&_.orbit-modal-header-container]:sticky [&_.orbit-modal-header-container]:top-0 [&_.orbit-modal-wrapper-content]:h-full", isScrolled && topOffset > 50 && "[&_.orbit-modal-header-container]:pb-400 [&_.orbit-modal-header-container]:shadow-fixed")
}, /*#__PURE__*/_react.default.createElement(_Modal.default, {
labelClose: labelClose,
onClose: handleCloseClick,
fixedFooter: true,
onScroll: ev => {
if (!isLargeMobile) {
ev.preventDefault();
setIsScrolled(true);
setTopOffset(ev.currentTarget.scrollTop);
}
},
mobileHeader: false,
ariaLabel: label
}, /*#__PURE__*/_react.default.createElement(_ModalHeader.ModalHeaderWrapper, {
className: "!p-400 !mb-0"
}, label && /*#__PURE__*/_react.default.createElement(_Stack.default, {
align: "center",
justify: "between"
}, /*#__PURE__*/_react.default.createElement(_Box.default, null, /*#__PURE__*/_react.default.createElement(_Heading.default, {
type: "title2"
}, label)), /*#__PURE__*/_react.default.createElement(_ModalCloseButton.default, {
onClick: handleCloseClick,
title: labelClose
})), input), /*#__PURE__*/_react.default.createElement(_ModalSection.ModalSectionWrapper, {
className: "!p-0"
}, dropdown), /*#__PURE__*/_react.default.createElement(_Modal.ModalFooter, {
flex: "100%"
}, /*#__PURE__*/_react.default.createElement(_Button.default, {
type: "secondary",
fullWidth: true,
onClick: handleCloseClick
}, labelClose))))));
};
var _default = exports.default = InputSelect;