expensify-common 2.0.188 → 2.0.190
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Logger.js +11 -1
- package/dist/esm/API.d.ts +11 -0
- package/dist/esm/API.js +786 -0
- package/dist/esm/APIDeferred.d.ts +7 -0
- package/dist/esm/APIDeferred.js +216 -0
- package/dist/esm/BrowserDetect.d.ts +19 -0
- package/dist/esm/BrowserDetect.js +105 -0
- package/dist/esm/CLI.js +37 -29
- package/dist/esm/CONST.d.ts +1727 -0
- package/dist/esm/CONST.js +1847 -0
- package/dist/esm/Cookie.d.ts +68 -0
- package/dist/esm/Cookie.js +159 -0
- package/dist/esm/CredentialsWrapper.d.ts +32 -0
- package/dist/esm/CredentialsWrapper.js +46 -0
- package/dist/esm/Device.d.ts +8 -0
- package/dist/esm/Device.js +24 -0
- package/dist/esm/ExpenseRule.d.ts +39 -0
- package/dist/esm/ExpenseRule.js +77 -0
- package/dist/esm/ExpensiMark.d.ts +197 -0
- package/dist/esm/ExpensiMark.js +1374 -0
- package/dist/esm/Func.d.ts +40 -0
- package/dist/esm/Func.js +69 -0
- package/dist/esm/Log.d.ts +3 -0
- package/dist/esm/Log.js +35 -0
- package/dist/esm/Logger.d.ts +82 -0
- package/dist/esm/Logger.js +148 -0
- package/dist/esm/Network.d.ts +6 -0
- package/dist/esm/Network.js +168 -0
- package/dist/esm/Num.d.ts +95 -0
- package/dist/esm/Num.js +190 -0
- package/dist/esm/PageEvent.d.ts +25 -0
- package/dist/esm/PageEvent.js +22 -0
- package/dist/esm/PubSub.d.ts +2 -0
- package/dist/esm/PubSub.js +111 -0
- package/dist/esm/ReportHistoryStore.d.ts +8 -0
- package/dist/esm/ReportHistoryStore.js +199 -0
- package/dist/esm/SafeString.js +2 -2
- package/dist/esm/Templates.d.ts +58 -0
- package/dist/esm/Templates.js +196 -0
- package/dist/esm/Url.d.ts +10 -0
- package/dist/esm/Url.js +15 -0
- package/dist/esm/components/CopyText.d.ts +45 -0
- package/dist/esm/components/CopyText.js +56 -0
- package/dist/esm/components/StepProgressBar.d.ts +26 -0
- package/dist/esm/components/StepProgressBar.js +40 -0
- package/dist/esm/components/form/element/combobox.d.ts +231 -0
- package/dist/esm/components/form/element/combobox.js +812 -0
- package/dist/esm/components/form/element/dropdown.d.ts +35 -0
- package/dist/esm/components/form/element/dropdown.js +61 -0
- package/dist/esm/components/form/element/dropdownItem.d.ts +55 -0
- package/dist/esm/components/form/element/dropdownItem.js +113 -0
- package/dist/esm/components/form/element/onOffSwitch.d.ts +94 -0
- package/dist/esm/components/form/element/onOffSwitch.js +167 -0
- package/dist/esm/components/form/element/switch.d.ts +58 -0
- package/dist/esm/components/form/element/switch.js +98 -0
- package/dist/esm/fastMerge.d.ts +9 -0
- package/dist/esm/fastMerge.js +58 -0
- package/dist/esm/index.d.ts +22 -0
- package/dist/esm/index.js +22 -0
- package/dist/esm/jquery.expensifyIframify.d.ts +9 -0
- package/dist/esm/jquery.expensifyIframify.js +397 -0
- package/dist/esm/md5.d.ts +2 -0
- package/dist/esm/md5.js +170 -0
- package/dist/esm/mixins/PubSub.d.ts +20 -0
- package/dist/esm/mixins/PubSub.js +47 -0
- package/dist/esm/mixins/extraClasses.d.ts +8 -0
- package/dist/esm/mixins/extraClasses.js +37 -0
- package/dist/esm/mixins/validationClasses.d.ts +12 -0
- package/dist/esm/mixins/validationClasses.js +30 -0
- package/dist/esm/str.d.ts +604 -0
- package/dist/esm/str.js +1036 -0
- package/dist/esm/tlds.d.ts +2 -0
- package/dist/esm/tlds.js +2 -0
- package/dist/esm/utils.d.ts +30 -0
- package/dist/esm/utils.js +65 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -1
- package/package.json +221 -4
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom';
|
|
3
|
+
import PropTypes from 'prop-types';
|
|
4
|
+
import cn from 'classnames';
|
|
5
|
+
import defer from 'lodash/defer';
|
|
6
|
+
import get from 'lodash/get';
|
|
7
|
+
import has from 'lodash/has';
|
|
8
|
+
import isEqual from 'lodash/isEqual';
|
|
9
|
+
import template from 'lodash/template';
|
|
10
|
+
import uniqBy from 'lodash/uniqBy';
|
|
11
|
+
import Str from '../../../str';
|
|
12
|
+
import DropDown from './dropdown';
|
|
13
|
+
import * as Utils from '../../../utils';
|
|
14
|
+
/**
|
|
15
|
+
* Stops events from doing anything outside of the current function (in theory).
|
|
16
|
+
* This doesn't work between jquery and React events because React's event propagation is a separate system.
|
|
17
|
+
*
|
|
18
|
+
* @param {SyntheticEvent} e
|
|
19
|
+
*/
|
|
20
|
+
function stopEvent(e) {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
if (e.nativeEvent) {
|
|
23
|
+
e.nativeEvent.stopImmediatePropagation();
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
e.stopImmediatePropagation();
|
|
27
|
+
}
|
|
28
|
+
e.stopPropagation();
|
|
29
|
+
}
|
|
30
|
+
const propTypes = {
|
|
31
|
+
// These are the elements to show in the dropdown
|
|
32
|
+
options: PropTypes.arrayOf(PropTypes.shape({
|
|
33
|
+
// The value of the option, should be unique
|
|
34
|
+
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
35
|
+
// The human readable text of the option
|
|
36
|
+
text: PropTypes.string,
|
|
37
|
+
// If we need more than text to style more
|
|
38
|
+
children: PropTypes.element,
|
|
39
|
+
// Whether or not this tag has an error (like out of policy)
|
|
40
|
+
hasError: PropTypes.bool,
|
|
41
|
+
// If this option is disabled, then it can't be selected and needs styled differently
|
|
42
|
+
disabled: PropTypes.bool,
|
|
43
|
+
// An id to use when checking if the options changed. It's used to avoid triggering changes when passing
|
|
44
|
+
// children to it, if the id didn't change we assume no other properties changed either.
|
|
45
|
+
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
46
|
+
// Whether or not the object should be displayed as a divider
|
|
47
|
+
// Used to identify the divider in some UI elements that need a separation between listed options
|
|
48
|
+
divider: PropTypes.bool,
|
|
49
|
+
// Whether or not the option is represented more than once in the list of options and should be selected
|
|
50
|
+
// Used in some UI elements that may display the same user details multiple times to identify which ones
|
|
51
|
+
// are duplicates
|
|
52
|
+
selected: PropTypes.bool,
|
|
53
|
+
})).isRequired,
|
|
54
|
+
// A default value to have selected
|
|
55
|
+
defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
56
|
+
// A default value to have selected - Use this one rather than defaultValue when the parent view might change the value passed.
|
|
57
|
+
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
58
|
+
// A list of options to show as selected too. This is used when we are using this component to implement a multi
|
|
59
|
+
// select, where another component holds all the selected options and this component only has to show the ones
|
|
60
|
+
// that are already selected with the check mark.
|
|
61
|
+
alreadySelectedOptions: PropTypes.arrayOf(PropTypes.shape({
|
|
62
|
+
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
63
|
+
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
64
|
+
text: PropTypes.string,
|
|
65
|
+
})),
|
|
66
|
+
// A callback that is fired when the value of the selector has changed
|
|
67
|
+
onChange: PropTypes.func,
|
|
68
|
+
// A callback that is fired when the value is cleared out by deleting text in the input
|
|
69
|
+
onClear: PropTypes.func,
|
|
70
|
+
// Callback fired when the dropdown is open/closed. It will pass a boolean param with true if it's open
|
|
71
|
+
onDropdownStateChange: PropTypes.func,
|
|
72
|
+
// Maximum amount of items to show
|
|
73
|
+
maxItemsToShow: PropTypes.number,
|
|
74
|
+
// Maximum amount of search results
|
|
75
|
+
maxSearchResults: PropTypes.number,
|
|
76
|
+
// By default we show the selected option's value
|
|
77
|
+
// You can overwrite it to display a different property (like 'text')
|
|
78
|
+
propertyToDisplay: PropTypes.oneOf(['text', 'value']),
|
|
79
|
+
// Whether or not the combobox should be open when we initialize it
|
|
80
|
+
openOnInit: PropTypes.bool,
|
|
81
|
+
// A property to allow the combobox set to any manual entry the user chooses
|
|
82
|
+
allowAnyValue: PropTypes.bool,
|
|
83
|
+
// What text should we show when the text entered doesn't match any of the results. It's an underscore template
|
|
84
|
+
// that will be passed `value` as the current entered text.
|
|
85
|
+
noResultsText: PropTypes.string,
|
|
86
|
+
// A property to determine if the selector is user editable
|
|
87
|
+
isReadOnly: PropTypes.bool,
|
|
88
|
+
// Text to use for the placeholder, defaults to "Type to search..."
|
|
89
|
+
placeholder: PropTypes.string,
|
|
90
|
+
// An array of extra classes to put on the combobox
|
|
91
|
+
extraClasses: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]),
|
|
92
|
+
// This makes this component hidden till someone manually calls openDropdown
|
|
93
|
+
hideUntilManuallyOpened: PropTypes.bool,
|
|
94
|
+
// Do we want to always show the selected options on top?
|
|
95
|
+
alwaysShowSelectedOnTop: PropTypes.bool,
|
|
96
|
+
// Should this combobox have an error state on init?
|
|
97
|
+
hasInitialError: PropTypes.bool,
|
|
98
|
+
// Bootstrap 4 compatibility flag
|
|
99
|
+
bs4: PropTypes.bool,
|
|
100
|
+
// Always include the disabled options in search results
|
|
101
|
+
showDisabledOptionsInResults: PropTypes.bool,
|
|
102
|
+
// Do we want to autoscroll to the top of the selector on item selection
|
|
103
|
+
autoScrollToTop: PropTypes.bool,
|
|
104
|
+
// Classes to apply to the dropdown in the combobox
|
|
105
|
+
dropDownClasses: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.object]),
|
|
106
|
+
};
|
|
107
|
+
const defaultProps = {
|
|
108
|
+
defaultValue: '',
|
|
109
|
+
value: '',
|
|
110
|
+
onChange: () => { },
|
|
111
|
+
onClear: () => { },
|
|
112
|
+
onDropdownStateChange: () => { },
|
|
113
|
+
maxItemsToShow: 500,
|
|
114
|
+
maxSearchResults: 200,
|
|
115
|
+
propertyToDisplay: 'value',
|
|
116
|
+
allowAnyValue: false,
|
|
117
|
+
openOnInit: false,
|
|
118
|
+
isReadOnly: false,
|
|
119
|
+
alreadySelectedOptions: [],
|
|
120
|
+
noResultsText: 'No results',
|
|
121
|
+
placeholder: 'Type to search...',
|
|
122
|
+
extraClasses: [],
|
|
123
|
+
hideUntilManuallyOpened: false,
|
|
124
|
+
alwaysShowSelectedOnTop: false,
|
|
125
|
+
hasInitialError: false,
|
|
126
|
+
bs4: false,
|
|
127
|
+
showDisabledOptionsInResults: false,
|
|
128
|
+
autoScrollToTop: true,
|
|
129
|
+
dropDownClasses: [],
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* A combobox useful for searching for values in a large list
|
|
133
|
+
*/
|
|
134
|
+
class Combobox extends React.Component {
|
|
135
|
+
constructor(props) {
|
|
136
|
+
super(props);
|
|
137
|
+
// Bind to our private methods
|
|
138
|
+
this.getStartState = this.getStartState.bind(this);
|
|
139
|
+
this.getTruncatedOptions = this.getTruncatedOptions.bind(this);
|
|
140
|
+
this.getValue = this.getValue.bind(this);
|
|
141
|
+
this.setValue = this.setValue.bind(this);
|
|
142
|
+
this.setText = this.setText.bind(this);
|
|
143
|
+
this.setDisabled = this.setDisabled.bind(this);
|
|
144
|
+
this.setError = this.setError.bind(this);
|
|
145
|
+
this.deselectCurrentOption = this.deselectCurrentOption.bind(this);
|
|
146
|
+
this.switchFocusedIndex = this.switchFocusedIndex.bind(this);
|
|
147
|
+
this.reset = this.reset.bind(this);
|
|
148
|
+
this.resetFocusedElements = this.resetFocusedElements.bind(this);
|
|
149
|
+
this.handleClickAway = this.handleClickAway.bind(this);
|
|
150
|
+
this.resetClickAwayHandler = this.resetClickAwayHandler.bind(this);
|
|
151
|
+
this.clearError = this.clearError.bind(this);
|
|
152
|
+
this.toggleDropdown = this.toggleDropdown.bind(this);
|
|
153
|
+
this.openDropdown = this.openDropdown.bind(this);
|
|
154
|
+
this.closeDropdown = this.closeDropdown.bind(this);
|
|
155
|
+
this.closeDropdownOnTabOut = this.closeDropdownOnTabOut.bind(this);
|
|
156
|
+
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
157
|
+
this.performSearch = this.performSearch.bind(this);
|
|
158
|
+
this.handleClick = this.handleClick.bind(this);
|
|
159
|
+
this.state = this.getStartState();
|
|
160
|
+
this.scrollPosition = 0;
|
|
161
|
+
}
|
|
162
|
+
componentDidMount() {
|
|
163
|
+
if (!this.props.openOnInit) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Our dropdown will be open, so we need to listen for our click away events
|
|
167
|
+
// and put focus on the input
|
|
168
|
+
defer(this.resetClickAwayHandler);
|
|
169
|
+
$(this.value).focus().select();
|
|
170
|
+
}
|
|
171
|
+
// eslint-disable-next-line react/no-unsafe
|
|
172
|
+
UNSAFE_componentWillReceiveProps(nextProps) {
|
|
173
|
+
if (nextProps.value && !isEqual(nextProps.value, this.state.currentValue)) {
|
|
174
|
+
this.setValue(nextProps.value);
|
|
175
|
+
}
|
|
176
|
+
if (nextProps.options !== undefined) {
|
|
177
|
+
// If the options have an id property, we use that to compare them and determine if they changed, if not
|
|
178
|
+
// we'll use the whole options array.
|
|
179
|
+
const optionsChanged = has(nextProps.options, '0.id')
|
|
180
|
+
? nextProps.options.some((option, index) => !isEqual(option.id, this.props.options[index] && this.props.options[index].id))
|
|
181
|
+
: !isEqual(nextProps.options, this.props.options);
|
|
182
|
+
// Check if alreadySelectedOptions changed - compare by id if available, otherwise by value or full comparison
|
|
183
|
+
const hasAlreadySelectedId = nextProps.alreadySelectedOptions && nextProps.alreadySelectedOptions.length > 0 && has(nextProps.alreadySelectedOptions[0], 'id');
|
|
184
|
+
const alreadySelectedChanged = hasAlreadySelectedId
|
|
185
|
+
? nextProps.alreadySelectedOptions.length !== this.props.alreadySelectedOptions.length ||
|
|
186
|
+
nextProps.alreadySelectedOptions.some((alreadySelectedOption, index) => {
|
|
187
|
+
const prevOption = this.props.alreadySelectedOptions[index];
|
|
188
|
+
return !prevOption || !isEqual(alreadySelectedOption.id, prevOption.id);
|
|
189
|
+
})
|
|
190
|
+
: !isEqual(nextProps.alreadySelectedOptions, this.props.alreadySelectedOptions);
|
|
191
|
+
if (optionsChanged || alreadySelectedChanged) {
|
|
192
|
+
// If only alreadySelectedOptions changed and dropdown is open, just update options without resetting
|
|
193
|
+
if (!optionsChanged && alreadySelectedChanged && this.state.isDropdownOpen) {
|
|
194
|
+
const { currentValue } = this.state;
|
|
195
|
+
this.setState(() => ({
|
|
196
|
+
options: this.getTruncatedOptions(currentValue, nextProps.alreadySelectedOptions),
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// Options changed or dropdown is closed - do full reset
|
|
201
|
+
this.reset(false, nextProps.options, nextProps.alreadySelectedOptions);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (nextProps.openOnInit !== undefined && !isEqual(nextProps.openOnInit, this.props.openOnInit)) {
|
|
206
|
+
this.setState({
|
|
207
|
+
isDropdownOpen: nextProps.openOnInit,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (nextProps.isReadOnly !== undefined && !isEqual(nextProps.isReadOnly, this.props.isReadOnly)) {
|
|
211
|
+
this.setState({
|
|
212
|
+
isDisabled: nextProps.isReadOnly,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
componentDidUpdate() {
|
|
217
|
+
// Set scroll position to the last known location when applicable
|
|
218
|
+
if (!this.dropDown || this.props.autoScrollToTop) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.dropDown.scrollTop = this.scrollPosition;
|
|
222
|
+
}
|
|
223
|
+
componentWillUnmount() {
|
|
224
|
+
// Don't ever let this handler linger after unmounting
|
|
225
|
+
$('body').off('click.clickaway', this.handleClickAway);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Handle state changes for when a user clicks on an item in the dropdown
|
|
229
|
+
*
|
|
230
|
+
* @param {String|Number} selectedValue
|
|
231
|
+
*/
|
|
232
|
+
handleClick(selectedValue) {
|
|
233
|
+
this.deselectCurrentOption();
|
|
234
|
+
// Select the new item, set our new indexes, close the dropdown
|
|
235
|
+
// Unselect all other options
|
|
236
|
+
let newSelectedIndex = this.options.findIndex((option) => option.value === selectedValue);
|
|
237
|
+
let currentlySelectedOption = this.options.find((option) => option.value === selectedValue);
|
|
238
|
+
// If allowAnyValue is true and currentValue is absent then set it manually to what the user has entered.
|
|
239
|
+
if (newSelectedIndex === -1 && this.props.allowAnyValue && selectedValue) {
|
|
240
|
+
newSelectedIndex = 0;
|
|
241
|
+
currentlySelectedOption = {
|
|
242
|
+
text: selectedValue,
|
|
243
|
+
value: selectedValue,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// Get the scroll position of the currently selected value
|
|
247
|
+
this.scrollPosition = this.dropDown.scrollTop;
|
|
248
|
+
const stateUpdateCallback = () => {
|
|
249
|
+
this.resetClickAwayHandler();
|
|
250
|
+
// Fire our onChange callback
|
|
251
|
+
this.initialValue = selectedValue;
|
|
252
|
+
this.props.onChange(selectedValue);
|
|
253
|
+
};
|
|
254
|
+
this.setState({
|
|
255
|
+
options: this.getTruncatedOptions(selectedValue),
|
|
256
|
+
selectedIndex: newSelectedIndex,
|
|
257
|
+
focusedIndex: newSelectedIndex,
|
|
258
|
+
currentValue: selectedValue,
|
|
259
|
+
currentText: (currentlySelectedOption && currentlySelectedOption.text) || '',
|
|
260
|
+
isDropdownOpen: false,
|
|
261
|
+
hasError: (currentlySelectedOption && currentlySelectedOption.hasError) || false,
|
|
262
|
+
}, stateUpdateCallback);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Fired when we "click away" with the dropdown open
|
|
266
|
+
*
|
|
267
|
+
* @param {SyntheticEvent} e
|
|
268
|
+
*/
|
|
269
|
+
handleClickAway(e) {
|
|
270
|
+
// Ignore the click away event if something within our combobox was clicked
|
|
271
|
+
// BIG HACK! I had to add the check for the support password click because it was causing a near
|
|
272
|
+
// unreproducible bug: https://github.com/Expensify/Expensify/issues/76745 that only I could reproduce.
|
|
273
|
+
// It is 100% unexplainable, but works for me
|
|
274
|
+
if ($(e.target).attr('id') === 'supportPassword' ||
|
|
275
|
+
($(e.target).parents('.expensify-input-group').length && $(e.target).parents('.expensify-input-group')[0] === ReactDOM.findDOMNode(this)) // eslint-disable-line react/no-find-dom-node
|
|
276
|
+
) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// If the dropdown is open then we don't really care and can do an early return
|
|
280
|
+
if (!this.state.isDropdownOpen) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.reset(true);
|
|
284
|
+
// Trigger a change so that inline editor knows to cancel itself
|
|
285
|
+
this.props.onChange(this.initialValue || this.props.value || this.props.defaultValue);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* This is triggered whenever there is a key up event in the text input
|
|
289
|
+
*
|
|
290
|
+
* @param {SyntheticEvent} e
|
|
291
|
+
*/
|
|
292
|
+
handleKeyDown(e) {
|
|
293
|
+
let oldFocusedIndex;
|
|
294
|
+
let newFocusedIndex;
|
|
295
|
+
let currentValue;
|
|
296
|
+
let currentText;
|
|
297
|
+
const updateStateDownKey = (state) => ({
|
|
298
|
+
focusedIndex: newFocusedIndex,
|
|
299
|
+
options: state.options,
|
|
300
|
+
isDropdownOpen: true,
|
|
301
|
+
});
|
|
302
|
+
const updateStateUpKey = (state) => ({
|
|
303
|
+
focusedIndex: newFocusedIndex,
|
|
304
|
+
options: state.options,
|
|
305
|
+
isDropdownOpen: true,
|
|
306
|
+
});
|
|
307
|
+
const updateStateEnterKey = (state) => ({
|
|
308
|
+
options: this.getTruncatedOptions(currentValue),
|
|
309
|
+
selectedIndex: state.focusedIndex,
|
|
310
|
+
currentValue,
|
|
311
|
+
currentText,
|
|
312
|
+
isDropdownOpen: false,
|
|
313
|
+
});
|
|
314
|
+
const resetStateEnterKey = () => {
|
|
315
|
+
this.resetClickAwayHandler();
|
|
316
|
+
// Fire our onChange callback
|
|
317
|
+
this.props.onChange(currentValue);
|
|
318
|
+
this.initialValue = currentValue;
|
|
319
|
+
};
|
|
320
|
+
// Handle the arrow keys
|
|
321
|
+
switch (e.which) {
|
|
322
|
+
case 40:
|
|
323
|
+
// Down - move focused selection down
|
|
324
|
+
oldFocusedIndex = this.state.focusedIndex;
|
|
325
|
+
newFocusedIndex = oldFocusedIndex + 1;
|
|
326
|
+
// Wrap around to the top of the list
|
|
327
|
+
if (newFocusedIndex > this.state.options.length - 1) {
|
|
328
|
+
newFocusedIndex = 0;
|
|
329
|
+
}
|
|
330
|
+
this.switchFocusedIndex(oldFocusedIndex, newFocusedIndex);
|
|
331
|
+
this.setState(updateStateDownKey, this.resetClickAwayHandler);
|
|
332
|
+
stopEvent(e);
|
|
333
|
+
break;
|
|
334
|
+
case 38:
|
|
335
|
+
// Up - move focused selection up
|
|
336
|
+
oldFocusedIndex = this.state.focusedIndex;
|
|
337
|
+
newFocusedIndex = oldFocusedIndex - 1;
|
|
338
|
+
// Wrap around to the bottom of the list
|
|
339
|
+
if (newFocusedIndex < 0) {
|
|
340
|
+
newFocusedIndex = this.state.options.length - 1;
|
|
341
|
+
}
|
|
342
|
+
this.switchFocusedIndex(oldFocusedIndex, newFocusedIndex);
|
|
343
|
+
this.setState(updateStateUpKey, this.resetClickAwayHandler);
|
|
344
|
+
stopEvent(e);
|
|
345
|
+
break;
|
|
346
|
+
case 13: {
|
|
347
|
+
// Enter - set selection
|
|
348
|
+
if (!this.state.isDropdownOpen) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Don't select disabled things
|
|
352
|
+
if (this.state.options[this.state.focusedIndex].disabled) {
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
this.deselectCurrentOption();
|
|
356
|
+
currentValue = this.state.options[this.state.focusedIndex].value;
|
|
357
|
+
currentText = this.state.options[this.state.focusedIndex].text;
|
|
358
|
+
// if allowAnyValue is true and currentValue is absent then set it manually to what the user has entered
|
|
359
|
+
if (!currentValue && this.props.allowAnyValue) {
|
|
360
|
+
currentValue = e.target.value;
|
|
361
|
+
currentText = currentValue;
|
|
362
|
+
}
|
|
363
|
+
this.setState(updateStateEnterKey, resetStateEnterKey);
|
|
364
|
+
stopEvent(e);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case 9:
|
|
368
|
+
case 27:
|
|
369
|
+
// Tab & Escape - clear selection
|
|
370
|
+
this.reset(true);
|
|
371
|
+
// Fire our onChange callback
|
|
372
|
+
this.props.onChange(this.initialValue || this.props.value || this.props.defaultValue);
|
|
373
|
+
// Stop the event from propagating if the escape key is pressed
|
|
374
|
+
if (e.which === 27) {
|
|
375
|
+
stopEvent(e);
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
default:
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* @param {Boolean} noDefaultValue Overrides the default value to have '' as the currentValue
|
|
384
|
+
* @param {Object[]} [newOptions] when resetting this component, we can specify new options to use
|
|
385
|
+
* @param {Object[]} [newAlreadySelectedOptions] when resetting this component, we can specify new already selected options to use
|
|
386
|
+
*
|
|
387
|
+
* @returns {Object}
|
|
388
|
+
*/
|
|
389
|
+
getStartState(noDefaultValue, newOptions, newAlreadySelectedOptions) {
|
|
390
|
+
this.options = newOptions || this.props.options;
|
|
391
|
+
const value = this.props.value || this.props.defaultValue || '';
|
|
392
|
+
const currentValue = this.initialValue || value;
|
|
393
|
+
const matchingOptionWithoutSMSDomain = (o) => (Str.isString(o) ? Str.removeSMSDomain(o.value) : o.value) === currentValue && !o.isFake;
|
|
394
|
+
// We use removeSMSDomain here in case currentValue is a phone number
|
|
395
|
+
let defaultSelectedOption = this.options.find(matchingOptionWithoutSMSDomain);
|
|
396
|
+
// If no default was found and initialText was present then we can use initialText values
|
|
397
|
+
if (!defaultSelectedOption && this.initialText) {
|
|
398
|
+
defaultSelectedOption = {};
|
|
399
|
+
defaultSelectedOption.text = this.initialText;
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
isDropdownOpen: this.props.openOnInit,
|
|
403
|
+
currentValue: noDefaultValue ? this.initialValue || '' : value,
|
|
404
|
+
currentText: defaultSelectedOption ? defaultSelectedOption.text : '',
|
|
405
|
+
options: this.getTruncatedOptions(currentValue, newAlreadySelectedOptions),
|
|
406
|
+
focusedIndex: 0,
|
|
407
|
+
selectedIndex: null,
|
|
408
|
+
isDisabled: this.props.isReadOnly,
|
|
409
|
+
hasError: (defaultSelectedOption && defaultSelectedOption.hasError) || this.props.hasInitialError,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Returns a set of our full options according to the maxItemsToShow
|
|
414
|
+
*
|
|
415
|
+
* @param {String|Number} currentValue
|
|
416
|
+
* @param {Object[]} [newAlreadySelectedOptions]
|
|
417
|
+
* @returns {Array}
|
|
418
|
+
*/
|
|
419
|
+
getTruncatedOptions(currentValue, newAlreadySelectedOptions) {
|
|
420
|
+
const alreadySelected = newAlreadySelectedOptions || this.props.alreadySelectedOptions;
|
|
421
|
+
// Get the divider index if we have one
|
|
422
|
+
const findDivider = (option) => option.divider;
|
|
423
|
+
const dividerIndex = this.options.findIndex(findDivider);
|
|
424
|
+
// Split into two arrays everything before and after the divider (if the divider does not exist then we'll return a single array)
|
|
425
|
+
const splitOptions = dividerIndex ? [this.options.slice(0, dividerIndex + 1), this.options.slice(dividerIndex + 1)] : [this.options];
|
|
426
|
+
const formatOption = (option, index) => (Object.assign({ focused: false, isSelected: (option.selected && isEqual(option.value, currentValue)) || !!alreadySelected.find((item) => item.value === option.value), originalIndex: index }, option));
|
|
427
|
+
const sortByOption = (o) => {
|
|
428
|
+
// Unselectable text-only entries (isFake: true) go to the bottom and selected entries go to the top only if alwaysShowSelectedOnTop was passed
|
|
429
|
+
if (o.showLast) {
|
|
430
|
+
return 2;
|
|
431
|
+
}
|
|
432
|
+
return o.isSelected && this.props.alwaysShowSelectedOnTop ? 0 : 1;
|
|
433
|
+
};
|
|
434
|
+
// Take each array and format it, sort it, and move selected items to top (if applicable)
|
|
435
|
+
const formatOptions = (array, arrayStartIndex) => array
|
|
436
|
+
.map((option, index) => formatOption(option, arrayStartIndex + index))
|
|
437
|
+
.sort((a, b) => {
|
|
438
|
+
const sortDiff = sortByOption(a) - sortByOption(b);
|
|
439
|
+
// If sort priority is the same, preserve original order using originalIndex
|
|
440
|
+
return sortDiff !== 0 ? sortDiff : a.originalIndex - b.originalIndex;
|
|
441
|
+
})
|
|
442
|
+
.slice(0, this.props.maxItemsToShow);
|
|
443
|
+
let cumulativeIndex = 0;
|
|
444
|
+
const truncatedOptions = splitOptions
|
|
445
|
+
.map((array) => {
|
|
446
|
+
const result = formatOptions(array, cumulativeIndex);
|
|
447
|
+
cumulativeIndex += array.length;
|
|
448
|
+
return result;
|
|
449
|
+
})
|
|
450
|
+
.flat();
|
|
451
|
+
if (!truncatedOptions.length) {
|
|
452
|
+
truncatedOptions.push({
|
|
453
|
+
text: 'No items',
|
|
454
|
+
value: '',
|
|
455
|
+
isSelectable: false,
|
|
456
|
+
isFake: true,
|
|
457
|
+
showLast: true,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
else if (this.options.length > this.props.maxItemsToShow) {
|
|
461
|
+
truncatedOptions.push({
|
|
462
|
+
text: `Viewing first ${this.props.maxItemsToShow} items. Search to see more.`,
|
|
463
|
+
value: '',
|
|
464
|
+
isSelectable: false,
|
|
465
|
+
isFake: true,
|
|
466
|
+
showLast: true,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return truncatedOptions;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Returns the selected value(s)
|
|
473
|
+
*
|
|
474
|
+
* @returns {String}
|
|
475
|
+
*/
|
|
476
|
+
getValue() {
|
|
477
|
+
return this.state.currentValue;
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Sets the current value
|
|
481
|
+
*
|
|
482
|
+
* @param {String} val
|
|
483
|
+
*/
|
|
484
|
+
setValue(val) {
|
|
485
|
+
// We need to look in `this.options` for the matching option because `this.state.options` is a truncated list
|
|
486
|
+
// and might not have every option
|
|
487
|
+
const optionMatchingVal = this.options.find((option) => option.value === val);
|
|
488
|
+
const currentText = get(optionMatchingVal, 'text', '');
|
|
489
|
+
const deselectOption = (initialOption) => {
|
|
490
|
+
const option = initialOption;
|
|
491
|
+
const isSelected = isEqual(option.value, val);
|
|
492
|
+
option.isSelected = isSelected || !!this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value);
|
|
493
|
+
return option;
|
|
494
|
+
};
|
|
495
|
+
const deselectOptions = (options) => options.map(deselectOption);
|
|
496
|
+
const setValueState = (state) => ({
|
|
497
|
+
currentValue: val,
|
|
498
|
+
currentText,
|
|
499
|
+
// Deselect all other options but the one matching our value
|
|
500
|
+
options: deselectOptions(state.options),
|
|
501
|
+
});
|
|
502
|
+
this.initialValue = val;
|
|
503
|
+
this.setState(setValueState);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Hard sets the current text to what we want
|
|
507
|
+
*
|
|
508
|
+
* @param {String} val
|
|
509
|
+
*/
|
|
510
|
+
setText(val) {
|
|
511
|
+
// See if there is a value in the options that matches the text we want to set. If the option
|
|
512
|
+
// does exist, then use the text property of that option for the text to display. If the option
|
|
513
|
+
// does not exist, then just display whatever value was passed
|
|
514
|
+
const optionMatchingVal = this.options.find((option) => option.value === val);
|
|
515
|
+
const currentText = (optionMatchingVal && optionMatchingVal.text) || val;
|
|
516
|
+
this.initialValue = currentText;
|
|
517
|
+
this.initialText = currentText;
|
|
518
|
+
this.setState({ currentText });
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Sets the disabled state of this component
|
|
522
|
+
*
|
|
523
|
+
* @param {Boolean} isDisabled
|
|
524
|
+
*/
|
|
525
|
+
setDisabled(isDisabled) {
|
|
526
|
+
this.setState({ isDisabled });
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Set an error for a specific tag
|
|
530
|
+
*/
|
|
531
|
+
setError() {
|
|
532
|
+
this.setState({
|
|
533
|
+
hasError: true,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Deselects any current option if it exists
|
|
538
|
+
*/
|
|
539
|
+
deselectCurrentOption() {
|
|
540
|
+
if (this.state.selectedIndex === null || !this.state.options[this.state.selectedIndex]) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const setValueState = (state) => {
|
|
544
|
+
const newOptions = [...state.options];
|
|
545
|
+
newOptions[state.selectedIndex].isSelected = false;
|
|
546
|
+
return {
|
|
547
|
+
options: newOptions,
|
|
548
|
+
};
|
|
549
|
+
};
|
|
550
|
+
this.setState(setValueState);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Swaps the focused index from {oldFocusedIndex} to {newFocusedIndex}
|
|
554
|
+
*
|
|
555
|
+
* @param {number} oldFocusedIndex
|
|
556
|
+
* @param {number} newFocusedIndex
|
|
557
|
+
*/
|
|
558
|
+
switchFocusedIndex(oldFocusedIndex, newFocusedIndex) {
|
|
559
|
+
const setFocusedState = (state) => {
|
|
560
|
+
const newOptions = [...state.options];
|
|
561
|
+
newOptions[oldFocusedIndex].focused = false;
|
|
562
|
+
newOptions[newFocusedIndex].focused = true;
|
|
563
|
+
return {
|
|
564
|
+
options: newOptions,
|
|
565
|
+
};
|
|
566
|
+
};
|
|
567
|
+
this.setState(setFocusedState);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Returns the selector to it's original state
|
|
571
|
+
*
|
|
572
|
+
* @param {Boolean} noDefaultValue Overrides the initials state defaultValue by setting the currentValue to ''
|
|
573
|
+
* @param {Object[]} [newOptions] we can recreate it with new options
|
|
574
|
+
* @param {Object[]} [newAlreadySelectedOptions] we can recreate it with new already selected options
|
|
575
|
+
*/
|
|
576
|
+
reset(noDefaultValue, newOptions, newAlreadySelectedOptions) {
|
|
577
|
+
if (newOptions) {
|
|
578
|
+
this.options = newOptions;
|
|
579
|
+
}
|
|
580
|
+
const state = this.getStartState(noDefaultValue, this.options, newAlreadySelectedOptions);
|
|
581
|
+
const handleDropdownStateChange = () => {
|
|
582
|
+
this.props.onDropdownStateChange(!!state.isDropdownOpen);
|
|
583
|
+
};
|
|
584
|
+
this.setState(state, handleDropdownStateChange);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* When the dropdown is closed, we reset the focused property of all of our options
|
|
588
|
+
*/
|
|
589
|
+
resetFocusedElements() {
|
|
590
|
+
const resetFocusedProperty = (o) => {
|
|
591
|
+
const option = o;
|
|
592
|
+
option.focused = false;
|
|
593
|
+
return option;
|
|
594
|
+
};
|
|
595
|
+
const setValueState = (state) => ({
|
|
596
|
+
options: state.options.map(resetFocusedProperty),
|
|
597
|
+
});
|
|
598
|
+
this.setState(setValueState);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Sets a clicklistener to close the dropdown if it is currently open
|
|
602
|
+
*/
|
|
603
|
+
resetClickAwayHandler() {
|
|
604
|
+
$('body').off('click.clickaway', this.handleClickAway);
|
|
605
|
+
if (this.state.isDropdownOpen) {
|
|
606
|
+
$('body').on('click.clickaway', this.handleClickAway);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
this.resetFocusedElements();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Clear an error for a specific tag
|
|
614
|
+
*/
|
|
615
|
+
clearError() {
|
|
616
|
+
this.setState({
|
|
617
|
+
hasError: false,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Opens or closes the dropdown
|
|
622
|
+
*/
|
|
623
|
+
toggleDropdown() {
|
|
624
|
+
if (this.state.isDropdownOpen) {
|
|
625
|
+
this.closeDropdown();
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
this.openDropdown();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Just open the dropdown (when the input gets focus)
|
|
633
|
+
*/
|
|
634
|
+
openDropdown() {
|
|
635
|
+
if (this.state.isDropdownOpen) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const setValueState = () => ({
|
|
639
|
+
isDropdownOpen: true,
|
|
640
|
+
});
|
|
641
|
+
const resetState = () => {
|
|
642
|
+
this.props.onDropdownStateChange(true);
|
|
643
|
+
this.resetClickAwayHandler();
|
|
644
|
+
$(this.value).focus().select();
|
|
645
|
+
};
|
|
646
|
+
this.setState(setValueState, resetState);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Closes the dropdown and removes the click handler that calls this function after the dropdown is closed
|
|
650
|
+
*
|
|
651
|
+
* @param {SyntheticEvent} [e]
|
|
652
|
+
*/
|
|
653
|
+
closeDropdown(e) {
|
|
654
|
+
if (!this.state.isDropdownOpen) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
// If the dropdown is being closed from clicking on our input, then we actually don't close the dropdown. This
|
|
658
|
+
// occurs because when first opening the dropdown when the input receives focus, it fires off the clickaway
|
|
659
|
+
// event and I couldn't stop it. So this is the work around.
|
|
660
|
+
if (e && ($(e.target)[0] === $(this.value)[0] || $(e.target).parent().hasClass('disabled'))) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const setValueState = () => ({
|
|
664
|
+
isDropdownOpen: false,
|
|
665
|
+
});
|
|
666
|
+
const resetState = () => {
|
|
667
|
+
this.props.onDropdownStateChange(false);
|
|
668
|
+
this.resetClickAwayHandler();
|
|
669
|
+
};
|
|
670
|
+
this.setState(setValueState, resetState);
|
|
671
|
+
// The value a user selects is set in state prior to this function running so we want to always treat this as if
|
|
672
|
+
// it were just a blur event and reset the input to an empty value and then let onChange handle showing the proper value
|
|
673
|
+
// This is because users who click the arrow used to be able to save incorrect values in the combobox: https://github.com/Expensify/Expensify/issues/75793#issuecomment-380260662
|
|
674
|
+
this.reset(true);
|
|
675
|
+
this.props.onChange(this.initialValue || this.props.value || this.props.defaultValue);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* If the user presses tab when the dropdown button is focused, then we close the dropdown
|
|
679
|
+
* because they are trying to get to the next form components
|
|
680
|
+
*
|
|
681
|
+
* @param {SyntheticEvent} e
|
|
682
|
+
*/
|
|
683
|
+
closeDropdownOnTabOut(e) {
|
|
684
|
+
if (e.which !== 9) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
this.handleClick(this.state.currentValue);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* This is triggered whenever there is a change event in the text input
|
|
691
|
+
* (from any valid input that's not being handled from the keyUp event)
|
|
692
|
+
*
|
|
693
|
+
* @param {SyntheticEvent} e
|
|
694
|
+
*/
|
|
695
|
+
performSearch(e) {
|
|
696
|
+
// If empty value, hide the dropdown, update the initial fields and show the original options.
|
|
697
|
+
const value = e.target.value;
|
|
698
|
+
if (value === '') {
|
|
699
|
+
this.initialValue = '';
|
|
700
|
+
this.initialText = '';
|
|
701
|
+
// This will show all the original items while the input field stays empty. Also the dropdown will remain open.
|
|
702
|
+
this.setState({
|
|
703
|
+
currentValue: '',
|
|
704
|
+
currentText: '',
|
|
705
|
+
isDropdownOpen: true,
|
|
706
|
+
options: this.getTruncatedOptions(''),
|
|
707
|
+
}, this.resetClickAwayHandler);
|
|
708
|
+
// Trigger the callback to clear out the value
|
|
709
|
+
this.props.onClear();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// Search our original options. We want:
|
|
713
|
+
// Exact matches first
|
|
714
|
+
// beginning-of-string matches second
|
|
715
|
+
// middle-of-string matches last
|
|
716
|
+
const matchRegexes = [new RegExp(`^${Str.escapeForRegExp(value)}$`, 'i'), new RegExp(`^${Str.escapeForRegExp(value)}`, 'i'), new RegExp(Str.escapeForRegExp(value), 'i')];
|
|
717
|
+
let hasMoreResults = false;
|
|
718
|
+
let matches = new Set();
|
|
719
|
+
const searchOptions = uniqBy(this.options, 'value');
|
|
720
|
+
for (let i = 0; i < matchRegexes.length; i++) {
|
|
721
|
+
if (matches.size < this.props.maxSearchResults) {
|
|
722
|
+
for (let j = 0; j < searchOptions.length; j++) {
|
|
723
|
+
const option = searchOptions[j];
|
|
724
|
+
const isDisabled = option.disabled;
|
|
725
|
+
const isMatch = matchRegexes[i].test(option.text.toString().replaceAll(' ', ''));
|
|
726
|
+
// Don't include the disabled options that match the regex unless we specified we want them to show.
|
|
727
|
+
// If we want them to show then add them whether they match the regex or not.
|
|
728
|
+
if ((!isDisabled && isMatch) || (isDisabled && this.props.showDisabledOptionsInResults && isMatch)) {
|
|
729
|
+
matches.add(option);
|
|
730
|
+
}
|
|
731
|
+
if (matches.size === this.props.maxSearchResults) {
|
|
732
|
+
hasMoreResults = true;
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
hasMoreResults = true;
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
matches = Array.from(matches);
|
|
743
|
+
const formatOption = (option) => (Object.assign({ focused: false, isSelected: isEqual(option.value ? option.value.toUpperCase : '', value.toUpperCase()) || !!this.props.alreadySelectedOptions.find((optionItem) => optionItem.value === option.value) }, option));
|
|
744
|
+
const options = matches.map(formatOption);
|
|
745
|
+
// Focus the first option if there is one and show a message dependent on what options are present
|
|
746
|
+
if (options.length) {
|
|
747
|
+
options[0].focused = true;
|
|
748
|
+
if (hasMoreResults) {
|
|
749
|
+
options.push({
|
|
750
|
+
text: `Viewing first ${options.length} results`,
|
|
751
|
+
value: '',
|
|
752
|
+
isSelectable: false,
|
|
753
|
+
isFake: true,
|
|
754
|
+
showLast: true,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
options.push({
|
|
760
|
+
text: this.props.allowAnyValue ? value : template(this.props.noResultsText)({ value }),
|
|
761
|
+
value: this.props.allowAnyValue ? value : '',
|
|
762
|
+
isSelectable: this.props.allowAnyValue,
|
|
763
|
+
isFake: true,
|
|
764
|
+
showLast: true,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
const setValueState = (state) => {
|
|
768
|
+
let shouldShowDropdown = state.isDropdownOpen;
|
|
769
|
+
// If we don't have an empty value, show the dropdown.
|
|
770
|
+
if (value.trim() !== '' && !shouldShowDropdown) {
|
|
771
|
+
shouldShowDropdown = true;
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
currentValue: value,
|
|
775
|
+
currentText: value,
|
|
776
|
+
isDropdownOpen: shouldShowDropdown,
|
|
777
|
+
focusedIndex: 0,
|
|
778
|
+
options,
|
|
779
|
+
};
|
|
780
|
+
};
|
|
781
|
+
this.setState(setValueState, this.resetClickAwayHandler);
|
|
782
|
+
}
|
|
783
|
+
render() {
|
|
784
|
+
if (this.props.hideUntilManuallyOpened && !this.state.isDropdownOpen) {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
const inputGroupClasses = {
|
|
788
|
+
'input-group': true,
|
|
789
|
+
'expensify-input-group': true,
|
|
790
|
+
open: this.state.isDropdownOpen,
|
|
791
|
+
};
|
|
792
|
+
const inputGroupBtnClasses = {
|
|
793
|
+
'input-group-btn': !this.props.bs4,
|
|
794
|
+
'input-group-append': this.props.bs4,
|
|
795
|
+
open: this.state.isDropdownOpen,
|
|
796
|
+
};
|
|
797
|
+
const toggleBtnClasses = this.props.bs4 ? 'dropdown-toggle dropdown-toggle-split btn btn-outline-primary' : 'dropdown-toggle btn btn-default';
|
|
798
|
+
return (React.createElement("div", { className: cn(inputGroupClasses, this.props.extraClasses), onKeyDown: this.handleKeyDown, role: "presentation" },
|
|
799
|
+
React.createElement("input", { ref: (ref) => (this.value = ref), type: "text", className: cn(['form-control', { error: this.state.hasError }]), disabled: this.state.isDisabled, "aria-label": "...", onChange: this.performSearch, onKeyDown: this.closeDropdownOnTabOut, value: this.props.propertyToDisplay === 'value' ? Utils.unescapeText(this.state.currentValue) : Utils.unescapeText(this.state.currentText.replaceAll(' ', '')), onFocus: this.openDropdown, autoComplete: "off", placeholder: this.props.placeholder, tabIndex: "0" }),
|
|
800
|
+
React.createElement("div", { className: cn(inputGroupBtnClasses) },
|
|
801
|
+
React.createElement("button", { type: "button", className: cn(toggleBtnClasses, { error: this.state.hasError }), disabled: this.state.isDisabled, onClick: this.toggleDropdown, onKeyDown: this.closeDropdownOnTabOut, tabIndex: "-1" },
|
|
802
|
+
!this.props.bs4 && React.createElement("span", { className: "caret" }),
|
|
803
|
+
React.createElement("span", { className: "sr-only" }, "Toggle Dropdown")),
|
|
804
|
+
this.state.isDropdownOpen && (React.createElement(DropDown, { ref: (ref) => (this.dropDown = ref), options: this.state.options, bs4: this.props.bs4, extraClasses: cn({
|
|
805
|
+
'expensify-dropdown': true,
|
|
806
|
+
show: this.props.bs4 && this.state.isDropdownOpen,
|
|
807
|
+
}, this.props.dropDownClasses), onChange: this.handleClick })))));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
Combobox.propTypes = propTypes;
|
|
811
|
+
Combobox.defaultProps = defaultProps;
|
|
812
|
+
export default Combobox;
|