mirror of https://github.com/rancher/dashboard.git
543 lines
12 KiB
Vue
543 lines
12 KiB
Vue
<script>
|
|
import CompactInput from '@shell/mixins/compact-input';
|
|
import LabeledFormElement from '@shell/mixins/labeled-form-element';
|
|
import { get } from '@shell/utils/object';
|
|
import { LabeledTooltip } from '@components/LabeledTooltip';
|
|
import VueSelectOverrides from '@shell/mixins/vue-select-overrides';
|
|
import { onClickOption, calculatePosition } from '@shell/utils/select';
|
|
import isEqual from 'lodash/isEqual';
|
|
|
|
export default {
|
|
name: 'LabeledSelect',
|
|
|
|
components: { LabeledTooltip },
|
|
mixins: [CompactInput, LabeledFormElement, VueSelectOverrides],
|
|
|
|
props: {
|
|
appendToBody: {
|
|
default: true,
|
|
type: Boolean,
|
|
},
|
|
clearable: {
|
|
default: false,
|
|
type: Boolean
|
|
},
|
|
disabled: {
|
|
default: false,
|
|
type: Boolean
|
|
},
|
|
required: {
|
|
default: false,
|
|
type: Boolean
|
|
},
|
|
hoverTooltip: {
|
|
default: true,
|
|
type: Boolean
|
|
},
|
|
loading: {
|
|
default: false,
|
|
type: Boolean
|
|
},
|
|
localizedLabel: {
|
|
default: false,
|
|
type: Boolean
|
|
},
|
|
optionKey: {
|
|
default: null,
|
|
type: String
|
|
},
|
|
optionLabel: {
|
|
default: 'label',
|
|
type: String
|
|
},
|
|
placement: {
|
|
default: null,
|
|
type: String
|
|
},
|
|
reduce: {
|
|
default: (e) => {
|
|
if (e && typeof e === 'object' && e.value !== undefined) {
|
|
return e.value;
|
|
}
|
|
|
|
return e;
|
|
},
|
|
type: Function
|
|
},
|
|
selectable: {
|
|
default: (opt) => {
|
|
if ( opt ) {
|
|
if ( opt.disabled || opt.kind === 'group' || opt.kind === 'divider' || opt.loading ) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
},
|
|
type: Function
|
|
},
|
|
status: {
|
|
default: null,
|
|
type: String
|
|
},
|
|
tooltip: {
|
|
default: null,
|
|
type: [String, Object]
|
|
},
|
|
value: {
|
|
default: null,
|
|
type: [String, Object, Number, Array, Boolean]
|
|
},
|
|
closeOnSelect: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
selectedVisibility: 'visible',
|
|
shouldOpen: true
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
hasLabel() {
|
|
return this.isCompact ? false : !!this.label || !!this.labelKey || !!this.$slots.label;
|
|
},
|
|
},
|
|
|
|
methods: {
|
|
// resizeHandler = in mixin
|
|
focusSearch() {
|
|
const blurredAgo = Date.now() - this.blurred;
|
|
|
|
if (!this.focused && blurredAgo < 250) {
|
|
return;
|
|
}
|
|
|
|
this.$nextTick(() => {
|
|
const el = this.$refs['select-input']?.searchEl;
|
|
|
|
if (el) {
|
|
el.focus();
|
|
}
|
|
});
|
|
},
|
|
|
|
onFocus() {
|
|
this.selectedVisibility = 'hidden';
|
|
this.onFocusLabeled();
|
|
},
|
|
|
|
onBlur() {
|
|
this.selectedVisibility = 'visible';
|
|
this.onBlurLabeled();
|
|
},
|
|
|
|
onOpen() {
|
|
this.$emit('on-open');
|
|
this.resizeHandler();
|
|
},
|
|
|
|
onClose() {
|
|
this.$emit('on-close');
|
|
},
|
|
|
|
getOptionLabel(option) {
|
|
if (!option) {
|
|
return;
|
|
}
|
|
|
|
// Force to update the option label if prop has been changed
|
|
const isOutdated = !this.options.find(opt => option[this.optionLabel] === opt[this.optionLabel]);
|
|
|
|
if (isOutdated && this.options) {
|
|
const newOption = this.options.find(opt => isEqual(this.reduce(option), this.reduce(opt)));
|
|
|
|
if (newOption) {
|
|
const label = get(newOption, this.optionLabel);
|
|
|
|
return this.localizedLabel ? this.$store.getters['i18n/t'](label) || label : label;
|
|
}
|
|
}
|
|
|
|
if (this.$attrs['get-option-label']) {
|
|
return this.$attrs['get-option-label'](option);
|
|
}
|
|
if (get(option, this.optionLabel)) {
|
|
if (this.localizedLabel) {
|
|
const label = get(option, this.optionLabel);
|
|
|
|
return this.$store.getters['i18n/t'](label) || label;
|
|
} else {
|
|
return get(option, this.optionLabel);
|
|
}
|
|
} else {
|
|
return option;
|
|
}
|
|
},
|
|
|
|
positionDropdown(dropdownList, component, { width }) {
|
|
calculatePosition(dropdownList, component, width, this.placement);
|
|
},
|
|
|
|
get,
|
|
|
|
onClickOption(option, event) {
|
|
onClickOption.call(this, option, event);
|
|
},
|
|
|
|
dropdownShouldOpen(instance, forceOpen = false) {
|
|
const { noDrop, mutableLoading } = instance;
|
|
const { open } = instance;
|
|
const shouldOpen = this.shouldOpen;
|
|
|
|
if (forceOpen) {
|
|
instance.open = true;
|
|
|
|
return true;
|
|
}
|
|
|
|
if (shouldOpen === false) {
|
|
this.shouldOpen = true;
|
|
instance.closeSearchOptions();
|
|
}
|
|
|
|
return noDrop ? false : open && shouldOpen && !mutableLoading;
|
|
},
|
|
|
|
onSearch(newSearchString) {
|
|
if (newSearchString) {
|
|
this.dropdownShouldOpen(this.$refs['select-input'], true);
|
|
}
|
|
},
|
|
|
|
getOptionKey(opt) {
|
|
if (this.optionKey) {
|
|
return get(opt, this.optionKey);
|
|
}
|
|
|
|
return this.getOptionLabel(opt);
|
|
}
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="select"
|
|
class="labeled-select"
|
|
:class="{
|
|
disabled: isView || disabled,
|
|
focused,
|
|
[mode]: true,
|
|
[status]: status,
|
|
taggable: $attrs.taggable,
|
|
taggable: $attrs.multiple,
|
|
hoverable: hoverTooltip,
|
|
'compact-input': isCompact,
|
|
'no-label': !hasLabel,
|
|
}"
|
|
@click="focusSearch"
|
|
@focus="focusSearch"
|
|
>
|
|
<div
|
|
:class="{ 'labeled-container': true, raised, empty, [mode]: true }"
|
|
:style="{ border: 'none' }"
|
|
>
|
|
<label v-if="hasLabel">
|
|
<t
|
|
v-if="labelKey"
|
|
:k="labelKey"
|
|
/>
|
|
<template v-else-if="label">{{ label }}</template>
|
|
|
|
<span
|
|
v-if="requiredField"
|
|
class="required"
|
|
>*</span>
|
|
</label>
|
|
</div>
|
|
<v-select
|
|
ref="select-input"
|
|
v-bind="$attrs"
|
|
class="inline"
|
|
:append-to-body="appendToBody"
|
|
:calculate-position="positionDropdown"
|
|
:class="{ 'no-label': !(label || '').length }"
|
|
:clearable="clearable"
|
|
:disabled="isView || disabled || loading"
|
|
:get-option-key="getOptionKey"
|
|
:get-option-label="(opt) => getOptionLabel(opt)"
|
|
:label="optionLabel"
|
|
:options="options"
|
|
:map-keydown="mappedKeys"
|
|
:placeholder="placeholder"
|
|
:reduce="(x) => reduce(x)"
|
|
:searchable="isSearchable"
|
|
:selectable="selectable"
|
|
:value="value != null && !loading ? value : ''"
|
|
:dropdown-should-open="dropdownShouldOpen"
|
|
v-on="$listeners"
|
|
@search:blur="onBlur"
|
|
@search:focus="onFocus"
|
|
@search="onSearch"
|
|
@open="onOpen"
|
|
@close="onClose"
|
|
@option:selected="$emit('selecting', $event)"
|
|
>
|
|
<template #option="option">
|
|
<template v-if="option.kind === 'group'">
|
|
<div class="vs__option-kind-group">
|
|
<b>{{ getOptionLabel(option) }}</b>
|
|
<div v-if="option.badge">
|
|
{{ option.badge }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="option.kind === 'divider'">
|
|
<hr>
|
|
</template>
|
|
<template v-else-if="option.kind === 'highlighted'">
|
|
<div class="option-kind-highlighted">
|
|
{{ option.label }}
|
|
</div>
|
|
</template>
|
|
<div
|
|
v-else
|
|
@mousedown="(e) => onClickOption(option, e)"
|
|
>
|
|
{{ getOptionLabel(option) }}
|
|
<i
|
|
v-if="option.error"
|
|
class="icon icon-warning pull-right"
|
|
style="font-size: 20px;"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<!-- Pass down templates provided by the caller -->
|
|
<template
|
|
v-for="(_, slot) of $scopedSlots"
|
|
#[slot]="scope"
|
|
>
|
|
<slot
|
|
:name="slot"
|
|
v-bind="scope"
|
|
/>
|
|
</template>
|
|
</v-select>
|
|
<i
|
|
v-if="loading"
|
|
class="icon icon-spinner icon-spin icon-lg"
|
|
/>
|
|
<LabeledTooltip
|
|
v-if="tooltip && !focused"
|
|
:hover="hoverTooltip"
|
|
:value="tooltip"
|
|
:status="status"
|
|
/>
|
|
<LabeledTooltip
|
|
v-if="!!validationMessage"
|
|
:hover="hoverTooltip"
|
|
:value="validationMessage"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang='scss' scoped>
|
|
|
|
.labeled-select {
|
|
position: relative;
|
|
// Prevent namespace field from wiggling or changing
|
|
// height when it is toggled from a LabeledInput to a
|
|
// LabeledSelect.
|
|
padding-bottom: 1px;
|
|
|
|
&.no-label.compact-input {
|
|
::v-deep .vs__actions:after {
|
|
top: -2px;
|
|
}
|
|
|
|
.labeled-container {
|
|
padding: 5px 0 1px 10px;
|
|
}
|
|
}
|
|
|
|
&.no-label:not(.compact-input) {
|
|
height: $input-height;
|
|
padding-top: 4px;
|
|
|
|
::v-deep .vs__actions:after {
|
|
top: 0;
|
|
}
|
|
}
|
|
|
|
.icon-spinner {
|
|
position: absolute;
|
|
left: calc(50% - .5em);
|
|
top: calc(50% - .5em);
|
|
}
|
|
|
|
.labeled-container {
|
|
// Make LabeledSelect and LabeledInput the same height so they
|
|
// don't wiggle when you toggle between them.
|
|
padding: 7px 0 0 $input-padding-sm;
|
|
padding: $input-padding-sm 0 0 $input-padding-sm;
|
|
|
|
label {
|
|
margin: 0;
|
|
}
|
|
|
|
.selected {
|
|
background-color: transparent;
|
|
}
|
|
}
|
|
|
|
&.view {
|
|
&.labeled-input {
|
|
.labeled-container {
|
|
padding: 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
&.taggable.compact-input {
|
|
min-height: $unlabeled-input-height;
|
|
::v-deep .vs__selected-options {
|
|
padding-top: 8px !important;
|
|
}
|
|
}
|
|
|
|
&.taggable:not(.compact-input) {
|
|
min-height: $input-height;
|
|
::v-deep .vs__selected-options {
|
|
// Need to adjust margin when there is a label in the control to add space between the label and the tags
|
|
margin-top: 0px;
|
|
}
|
|
}
|
|
|
|
&:not(.taggable) {
|
|
::v-deep .vs__selected-options {
|
|
// Ensure whole select is clickable to close the select when open
|
|
.vs__selected {
|
|
width: 100%;
|
|
}
|
|
}
|
|
}
|
|
|
|
&.taggable {
|
|
::v-deep .vs__selected-options {
|
|
padding: 3px 0;
|
|
.vs__selected {
|
|
border-color: var(--accent-btn);
|
|
height: 20px;
|
|
min-height: unset !important;
|
|
padding: 0 0 0 7px !important;
|
|
|
|
> button {
|
|
height: 20px;
|
|
line-height: 14px;
|
|
}
|
|
|
|
> button:hover {
|
|
background-color: var(--primary);
|
|
border-radius: 0;
|
|
|
|
&::after {
|
|
color: #fff;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
::v-deep .vs__selected-options {
|
|
margin-top: -5px;
|
|
}
|
|
|
|
::v-deep .v-select:not(.vs--single) {
|
|
.vs__selected-options {
|
|
padding: 5px 0;
|
|
}
|
|
}
|
|
|
|
::v-deep .vs__actions {
|
|
&:after {
|
|
position: relative;
|
|
top: -10px;
|
|
}
|
|
}
|
|
|
|
::v-deep .v-select.vs--open {
|
|
.vs__dropdown-toggle {
|
|
color: var(--outline) !important;
|
|
}
|
|
}
|
|
|
|
::v-deep &.disabled {
|
|
.labeled-container,
|
|
.vs__dropdown-toggle,
|
|
input,
|
|
label {
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
|
|
.no-label ::v-deep {
|
|
&.v-select:not(.vs--single) {
|
|
min-height: 33px;
|
|
}
|
|
|
|
&.selected {
|
|
padding-top: 8px;
|
|
padding-bottom: 9px;
|
|
position: relative;
|
|
max-height: 2.3em;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.vs__selected-options {
|
|
padding: 8px 0 7px 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Styling for option group badge
|
|
.vs__dropdown-menu .vs__dropdown-option .vs__option-kind-group {
|
|
display: flex;
|
|
> b {
|
|
flex: 1;
|
|
}
|
|
> div {
|
|
background-color: var(--primary);
|
|
border-radius: 4px;
|
|
color: var(--primary-text);
|
|
font-size: 12px;
|
|
height: 18px;
|
|
line-height: 18px;
|
|
margin-top: 1px;
|
|
padding: 0 10px;
|
|
}
|
|
}
|
|
|
|
// Styling for option highlighted
|
|
.vs__dropdown-option {
|
|
> .option-kind-highlighted {
|
|
color: var(--dropdown-highlight-text);
|
|
|
|
&:hover {
|
|
color: var(--dropdown-hover-text);
|
|
}
|
|
}
|
|
|
|
&.vs__dropdown-option--selected,
|
|
&.vs__dropdown-option--highlight {
|
|
> .option-kind-highlighted {
|
|
color: var(--dropdown-hover-text);
|
|
}
|
|
}
|
|
}
|
|
|
|
</style>
|