ColumnWrapper.vue
2 years ago
CxVuiCollapseMini.vue
2 years ago
CxVuiDate.vue
2 years ago
CxVuiFSelect.vue
2 years ago
CxVuiPopup.vue
2 years ago
CxVuiSelect.vue
2 years ago
CxVuiTabs.vue
2 years ago
CxVuiTabsPanel.vue
2 years ago
Delimiter.vue
2 years ago
DetailsTable.vue
2 years ago
DetailsTableRow.vue
2 years ago
DetailsTableRowValue.vue
2 years ago
ExternalLink.vue
2 years ago
ListComponents.vue
2 years ago
PrintButton.vue
2 years ago
RowWrapper.vue
2 years ago
Tooltip.vue
2 years ago
CxVuiFSelect.vue
345 lines
| 1 | <template> |
| 2 | <div class="cx-vui-f-select"> |
| 3 | <div :class="{ |
| 4 | 'cx-vui-f-select__selected': true, |
| 5 | 'cx-vui-f-select__selected-not-empty': this.value.length > 0 |
| 6 | }"> |
| 7 | <div |
| 8 | v-for="option in value" |
| 9 | class="cx-vui-f-select__selected-option" |
| 10 | @click="handleResultClick( option )" |
| 11 | > |
| 12 | <template v-if="$slots[ 'option-' + option ]"> |
| 13 | <slot :name="'option-' + option"></slot> |
| 14 | </template> |
| 15 | <template v-else> |
| 16 | <span v-if="!isNonRemovable( option )" class="cx-vui-f-select__selected-option-icon"> |
| 17 | <svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">><path |
| 18 | d="M10 1.00671L6.00671 5L10 8.99329L8.99329 10L5 6.00671L1.00671 10L0 8.99329L3.99329 5L0 1.00671L1.00671 0L5 3.99329L8.99329 0L10 1.00671Z"/></svg> |
| 19 | </span> |
| 20 | {{ getOptionLabel( option ) }} |
| 21 | </template> |
| 22 | </div> |
| 23 | </div> |
| 24 | <div |
| 25 | v-click-outside.capture="onClickOutside" |
| 26 | v-click-outside:mousedown.capture="onClickOutside" |
| 27 | v-click-outside:touchstart.capture="onClickOutside" |
| 28 | |
| 29 | @keydown.up.prevent="handleOptionsNav" |
| 30 | @keydown.down.prevent="handleOptionsNav" |
| 31 | @keydown.tab="handleOptionsNav" |
| 32 | @keydown.enter="handleEnter" |
| 33 | |
| 34 | class="cx-vui-f-select__control" |
| 35 | > |
| 36 | <input |
| 37 | :id="elementId" |
| 38 | :placeholder="placeholder" |
| 39 | :autocomplete="autocomplete" |
| 40 | type="text" |
| 41 | :value="query" |
| 42 | @input="handleInput" |
| 43 | @focus="handleFocus" |
| 44 | :class="{ |
| 45 | 'cx-vui-f-select__input': true, |
| 46 | 'cx-vui-input--in-focus': inFocus, |
| 47 | 'cx-vui-input': true, |
| 48 | ['cx-vui-input--' + stateType() ]: stateType(), |
| 49 | 'size-fullwidth': true, |
| 50 | 'has-error': error, |
| 51 | }" |
| 52 | > |
| 53 | <div class="cx-vui-f-select__results" v-if="inFocus"> |
| 54 | <div |
| 55 | v-if="! filteredOptions.length" |
| 56 | v-html="notFoundMeassge" |
| 57 | class="cx-vui-f-select__results-message" |
| 58 | ></div> |
| 59 | <div v-else> |
| 60 | <div |
| 61 | v-for="( option, optionIndex ) in filteredOptions" |
| 62 | :class="{ |
| 63 | 'cx-vui-f-select__result': true, |
| 64 | 'in-focus': optionIndex === optionInFocus, |
| 65 | 'is-selected': isSelectedOption( option ) |
| 66 | }" |
| 67 | @click="handleResultClick( option.value )" |
| 68 | >{{ getOptionLabel( option ) }} |
| 69 | </div> |
| 70 | </div> |
| 71 | </div> |
| 72 | </div> |
| 73 | <select |
| 74 | :placeholder="placeholder" |
| 75 | :disabled="disabled" |
| 76 | :readonly="readonly" |
| 77 | :multiple="multiple" |
| 78 | :value="currentValues" |
| 79 | class="cx-vui-f-select__select-tag" |
| 80 | > |
| 81 | <option |
| 82 | v-for="option in currentValues" |
| 83 | :value="option" |
| 84 | selected |
| 85 | ></option> |
| 86 | </select> |
| 87 | </div> |
| 88 | </template> |
| 89 | |
| 90 | <script> |
| 91 | import { directive as clickOutside } from 'v-click-outside-x'; |
| 92 | |
| 93 | export default { |
| 94 | name: 'CxVuiFSelect', |
| 95 | directives: { clickOutside }, |
| 96 | props: { |
| 97 | value: { |
| 98 | type: [ String, Number, Array ], |
| 99 | default: '', |
| 100 | }, |
| 101 | placeholder: { |
| 102 | type: String, |
| 103 | default: '', |
| 104 | }, |
| 105 | optionsList: { |
| 106 | type: Array, |
| 107 | default: function () { |
| 108 | return []; |
| 109 | }, |
| 110 | }, |
| 111 | disabled: { |
| 112 | type: Boolean, |
| 113 | default: false, |
| 114 | }, |
| 115 | readonly: { |
| 116 | type: Boolean, |
| 117 | default: false, |
| 118 | }, |
| 119 | error: { |
| 120 | type: Boolean, |
| 121 | default: false, |
| 122 | }, |
| 123 | multiple: { |
| 124 | type: Boolean, |
| 125 | default: false, |
| 126 | }, |
| 127 | autocomplete: { |
| 128 | validator( value ) { |
| 129 | return [ 'on', 'off' ].includes( value ); |
| 130 | }, |
| 131 | default: 'off', |
| 132 | }, |
| 133 | conditions: { |
| 134 | type: Array, |
| 135 | default: function () { |
| 136 | return []; |
| 137 | }, |
| 138 | }, |
| 139 | remote: { |
| 140 | type: Boolean, |
| 141 | default: false, |
| 142 | }, |
| 143 | remoteCallback: { |
| 144 | type: Function, |
| 145 | }, |
| 146 | remoteTrigger: { |
| 147 | type: Number, |
| 148 | default: 3, |
| 149 | }, |
| 150 | remoteTriggerMessage: { |
| 151 | type: String, |
| 152 | default: 'Please enter %d char(s) to start search', |
| 153 | }, |
| 154 | notFoundMeassge: { |
| 155 | type: String, |
| 156 | default: 'There is no items find matching this query', |
| 157 | }, |
| 158 | loadingMessage: { |
| 159 | type: String, |
| 160 | default: 'Loading...', |
| 161 | }, |
| 162 | preventWrap: { |
| 163 | type: Boolean, |
| 164 | default: false, |
| 165 | }, |
| 166 | // Wrapper related props (should be passed into wrapper component) |
| 167 | wrapperCss: { |
| 168 | type: Array, |
| 169 | default: function () { |
| 170 | return []; |
| 171 | }, |
| 172 | }, |
| 173 | // basically used from injected |
| 174 | elementId: { |
| 175 | type: String, |
| 176 | }, |
| 177 | // basically used from injected |
| 178 | stateType: { |
| 179 | type: Function, |
| 180 | }, |
| 181 | }, |
| 182 | data() { |
| 183 | return { |
| 184 | query: '', |
| 185 | inFocus: false, |
| 186 | optionInFocus: false, |
| 187 | loading: false, |
| 188 | loaded: false, |
| 189 | }; |
| 190 | }, |
| 191 | created() { |
| 192 | if ( !this.currentValues ) { |
| 193 | this.currentValues = []; |
| 194 | } |
| 195 | }, |
| 196 | computed: { |
| 197 | filteredOptions() { |
| 198 | if ( !this.query ) { |
| 199 | return this.optionsList; |
| 200 | } |
| 201 | else { |
| 202 | return this.optionsList.filter( option => { |
| 203 | return option.label.includes( this.query ) || option.value.includes( this.query ); |
| 204 | } ); |
| 205 | } |
| 206 | }, |
| 207 | }, |
| 208 | methods: { |
| 209 | handleFocus( event ) { |
| 210 | this.inFocus = true; |
| 211 | this.$emit( 'on-focus', event ); |
| 212 | }, |
| 213 | handleOptionsNav( event ) { |
| 214 | |
| 215 | // next |
| 216 | if ( 'ArrowUp' === event.key || 'Tab' === event.key ) { |
| 217 | this.navigateOptions( -1 ); |
| 218 | } |
| 219 | // prev |
| 220 | if ( 'ArrowDown' === event.key ) { |
| 221 | this.navigateOptions( 1 ); |
| 222 | } |
| 223 | |
| 224 | }, |
| 225 | navigateOptions( direction ) { |
| 226 | |
| 227 | if ( false === this.optionInFocus ) { |
| 228 | this.optionInFocus = -1; |
| 229 | } |
| 230 | |
| 231 | let index = this.optionInFocus + direction; |
| 232 | let maxLength = this.filteredOptions.length - 1; |
| 233 | |
| 234 | if ( maxLength < 0 ) { |
| 235 | maxLength = 0; |
| 236 | } |
| 237 | |
| 238 | if ( index < 0 ) { |
| 239 | index = 0; |
| 240 | } |
| 241 | else if ( index > maxLength ) { |
| 242 | index = maxLength; |
| 243 | } |
| 244 | |
| 245 | this.optionInFocus = index; |
| 246 | |
| 247 | }, |
| 248 | onClickOutside( event ) { |
| 249 | |
| 250 | if ( this.inFocus ) { |
| 251 | this.inFocus = false; |
| 252 | this.$emit( 'on-blur', event ); |
| 253 | } |
| 254 | |
| 255 | }, |
| 256 | handleInput( event ) { |
| 257 | this.$emit( 'input', event.target.value ); |
| 258 | this.query = event.target.value; |
| 259 | |
| 260 | if ( !this.inFocus ) { |
| 261 | this.inFocus = true; |
| 262 | } |
| 263 | |
| 264 | }, |
| 265 | handleEnter() { |
| 266 | |
| 267 | if ( false === this.optionInFocus || !this.optionsList[ this.optionInFocus ] ) { |
| 268 | return; |
| 269 | } |
| 270 | |
| 271 | let value = this.filteredOptions[ this.optionInFocus ].value; |
| 272 | |
| 273 | this.handleResultClick( value ); |
| 274 | |
| 275 | }, |
| 276 | handleResultClick( value ) { |
| 277 | if ( this.isNonRemovable( value ) ) { |
| 278 | return; |
| 279 | } |
| 280 | |
| 281 | if ( this.value.includes( value ) ) { |
| 282 | this.removeValue( value ); |
| 283 | } |
| 284 | else if ( this.multiple ) { |
| 285 | this.storeValues( [ |
| 286 | ...new Set( this.value ), |
| 287 | value, |
| 288 | ] ); |
| 289 | } |
| 290 | else { |
| 291 | this.storeValues( value ); |
| 292 | } |
| 293 | |
| 294 | this.inFocus = false; |
| 295 | this.optionInFocus = false; |
| 296 | this.query = ''; |
| 297 | |
| 298 | }, |
| 299 | removeValue( value ) { |
| 300 | if ( !this.multiple ) { |
| 301 | this.storeValues( '' ); |
| 302 | } |
| 303 | const filtered = this.value.filter( current => current !== value ); |
| 304 | |
| 305 | this.storeValues( filtered ); |
| 306 | }, |
| 307 | storeValues( value ) { |
| 308 | this.$emit( 'change', this.sanitizeValue( value ) ); |
| 309 | }, |
| 310 | getOptionLabel( option ) { |
| 311 | const optionValue = 'string' === typeof option ? option : option.value; |
| 312 | const find = this.optionsList.find( ( { value } ) => value === optionValue ); |
| 313 | |
| 314 | return find?.label ?? ''; |
| 315 | }, |
| 316 | sanitizeValue( value ) { |
| 317 | if ( this.multiple ) { |
| 318 | return Array.isArray( value ) ? value : [ value ].filter( Boolean ); |
| 319 | } |
| 320 | |
| 321 | return Array.isArray( value ) ? value[ 0 ] : value; |
| 322 | }, |
| 323 | isSelectedOption( option ) { |
| 324 | const optionValue = 'string' === typeof option ? option : option.value; |
| 325 | |
| 326 | return this.value.includes( optionValue ); |
| 327 | }, |
| 328 | isNonRemovable( option ) { |
| 329 | const optionValue = 'string' === typeof option ? option : option.value; |
| 330 | const find = this.optionsList.find( ( { value } ) => value === optionValue ); |
| 331 | |
| 332 | return find?.nonRemovable ?? false; |
| 333 | }, |
| 334 | }, |
| 335 | inject: [ 'elementId', 'stateType' ], |
| 336 | }; |
| 337 | </script> |
| 338 | |
| 339 | <style scoped lang="scss"> |
| 340 | .cx-vui-input { |
| 341 | &--warning-danger { |
| 342 | border: 1px solid #d63638; |
| 343 | } |
| 344 | } |
| 345 | </style> |