451 lines
12 KiB
Vue

<template>
<div class="ds-form-item" :class="stateClasses">
<label class="ds-input-label" v-show="!!label" :for="id">
{{ label }}
</label>
<div
class="ds-select-wrap"
:class="[isOpen && 'ds-select-is-open']"
:tabindex="searchable ? -1 : tabindex"
@keydown.tab="closeAndBlur"
@keydown.self.down.prevent="pointerNext"
@keydown.self.up.prevent="pointerPrev"
@keypress.enter.prevent.stop="handleEnter"
@keyup.esc="close"
>
<div v-if="resolvedIcon" class="ds-select-icon">
<os-icon :icon="resolvedIcon" />
</div>
<div
class="ds-select"
@click="openAndFocus"
:class="[
resolvedIcon && 'ds-select-has-icon',
resolvedIconRight && 'ds-select-has-icon-right',
multiple && 'ds-select-multiple',
]"
>
<div v-if="multiple" class="ds-selected-options">
<div
class="ds-selected-option"
v-for="(val, index) in innerValue"
:key="val[labelProp] || val"
>
<slot name="optionitem" :value="val">
<os-badge removable @remove="deselectOption(index)" variant="primary" :size="size">
{{ val[labelProp] || val }}
</os-badge>
</slot>
</div>
<input
ref="search"
class="ds-select-search"
autocomplete="off"
:id="id"
:name="name ? name : model"
:autofocus="autofocus"
:placeholder="placeholder"
:tabindex="tabindex"
:disabled="disabled"
v-model="searchString"
@focus="openAndFocus"
@keydown.tab="closeAndBlur"
@keydown.delete.stop="deselectLastOption"
@keydown.down.prevent="handleKeyDown"
@keydown.up.prevent="handleKeyUp"
@keypress.enter.prevent.stop="handleEnter"
@keyup.esc="close"
/>
</div>
<div v-else class="ds-select-value">
<slot v-if="innerValue" name="value" :value="innerValue">
{{ innerValue[labelProp] || innerValue }}
</slot>
<div v-else-if="placeholder" class="ds-select-placeholder">
{{ placeholder }}
</div>
</div>
<input
v-if="!multiple"
ref="search"
class="ds-select-search"
autocomplete="off"
:id="id"
:name="name ? name : model"
:autofocus="autofocus"
:placeholder="placeholder"
:tabindex="tabindex"
:disabled="disabled"
v-model="searchString"
@focus="openAndFocus"
@keydown.tab="closeAndBlur"
@keydown.delete.stop="deselectLastOption"
@keydown.down.prevent="handleKeyDown"
@keydown.up.prevent="handleKeyUp"
@keypress.enter.prevent.stop="handleEnter"
@keyup.esc="close"
/>
</div>
<div class="ds-select-dropdown">
<div class="ds-select-dropdown-message" v-if="!options || !options.length">
{{ noOptionsAvailable }}
</div>
<div class="ds-select-dropdown-message" v-else-if="!filteredOptions.length">
{{ noOptionsFound }} "{{ searchString }}"
</div>
<ul class="ds-select-options" ref="options" v-else>
<li
class="ds-select-option"
:class="[
isSelected(option) && 'ds-select-option-is-selected',
pointer === index && 'ds-select-option-hover',
]"
v-for="(option, index) in filteredOptions"
@click="handleSelect(option)"
@mouseover="setPointer(index)"
:key="option[labelProp] || option"
>
<slot name="option" :option="option">
{{ option[labelProp] || option }}
</slot>
</li>
</ul>
</div>
<div v-if="resolvedIconRight" class="ocelot-select-icon-right">
<os-icon :icon="resolvedIconRight" />
</div>
</div>
</div>
</template>
<script>
import { OsIcon, OsBadge } from '@ocelot-social/ui'
import { resolveIcon } from '~/utils/iconRegistry'
export default {
name: 'OcelotSelect',
components: { OsIcon, OsBadge },
props: {
value: {
type: [String, Object, Number, Array],
default: null,
},
model: {
type: String,
default: null,
},
name: {
type: String,
default: null,
},
label: {
type: String,
default: null,
},
id: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
readonly: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'base',
validator: (value) => /^(small|base|large)$/.test(value),
},
tabindex: {
type: Number,
default: 0,
},
multiple: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: null,
},
autofocus: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: null,
},
iconRight: {
type: String,
default: 'angle-down',
},
options: {
type: Array,
default: () => [],
},
labelProp: {
type: String,
default: 'label',
},
searchable: {
type: Boolean,
default: true,
},
autoResetSearch: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
filter: {
type: Function,
default: (option, searchString = '', labelProp) => {
const value = String(option[labelProp] || option)
const searchParts = typeof searchString === 'string' ? searchString.split(' ') : []
return searchParts.every((part) => {
if (!part) return true
return value.toLowerCase().includes(part.toLowerCase())
})
},
},
noOptionsAvailable: {
type: String,
default: 'No options available.',
},
noOptionsFound: {
type: String,
default: 'No options found for:',
},
},
data() {
return {
innerValue: null,
error: null,
focus: false,
searchString: '',
pointer: 0,
isOpen: false,
hadKeyboardInput: null,
}
},
computed: {
resolvedIcon() {
return resolveIcon(this.icon)
},
resolvedIconRight() {
return resolveIcon(this.iconRight)
},
isInteractionBlocked() {
return this.disabled || this.readonly || this.loading
},
stateClasses() {
return [
this.size && `ds-input-size-${this.size}`,
this.disabled && 'ds-input-is-disabled',
this.readonly && 'ds-input-is-readonly',
this.error && 'ds-input-has-error',
this.focus && 'ds-input-has-focus',
]
},
filteredOptions() {
if (!this.searchString) return this.options
return this.options.filter((option) => this.filter(option, this.searchString, this.labelProp))
},
pointerMax() {
return this.filteredOptions.length - 1
},
},
watch: {
value: {
handler(value) {
this.innerValue = value
},
deep: true,
immediate: true,
},
pointerMax(max) {
if (max < this.pointer) {
this.$nextTick(() => {
this.pointer = max
})
}
},
searchString() {
this.setPointer(-1)
},
},
mounted() {
this._clickOutsideHandler = (e) => {
if (!this.$el.contains(e.target)) {
this.closeAndBlur()
}
}
document.addEventListener('click', this._clickOutsideHandler, true)
},
beforeDestroy() {
document.removeEventListener('click', this._clickOutsideHandler, true)
clearTimeout(this.hadKeyboardInput)
},
methods: {
// --- Input / Value ---
input(value) {
this.innerValue = value
this.$emit('input', value)
},
// --- Selection ---
selectOption(option) {
if (this.multiple) {
this.selectMultiOption(option)
} else {
this.input(option)
}
},
selectMultiOption(value) {
if (!this.innerValue) return this.input([value])
const index = this.innerValue.indexOf(value)
if (index < 0) return this.input([...this.innerValue, value])
this.deselectOption(index)
},
deselectOption(index) {
const newArray = [...this.innerValue]
newArray.splice(index, 1)
this.input(newArray)
},
deselectLastOption() {
if (this.multiple && this.innerValue && this.innerValue.length && !this.searchString.length) {
this.deselectOption(this.innerValue.length - 1)
}
},
isSelected(option) {
if (!this.innerValue) return false
if (this.multiple) return this.innerValue.includes(option)
return this.innerValue === option
},
// --- Select interaction ---
handleSelect(option) {
if (this.isInteractionBlocked) return
if (this.pointerMax < 0) return
this.selectOption(option)
if (this.autoResetSearch || this.multiple) this.resetSearch()
if (this.multiple) {
this.$refs.search.focus()
this.handleFocus()
} else {
this.close()
}
},
resetSearch() {
this.searchString = ''
},
openAndFocus() {
if (this.isInteractionBlocked) return
this.open()
if (this.autoResetSearch) this.resetSearch()
if (!this.focus || this.multiple) {
this.$refs.search.focus()
this.handleFocus()
}
},
open() {
if (this.autoResetSearch || this.multiple) this.resetSearch()
this.isOpen = true
},
close() {
this.isOpen = false
},
closeAndBlur() {
this.close()
if (this.$refs.search) this.$refs.search.blur()
this.handleBlur()
},
handleFocus() {
this.focus = true
},
handleBlur() {
this.focus = false
},
// --- Keyboard navigation ---
handleEnter(e) {
if (this.isInteractionBlocked) return
if (this.pointer >= 0) {
this.selectPointerOption()
} else {
this.setPointer(-1)
this.$emit('enter', e)
}
},
handleKeyUp() {
if (this.isInteractionBlocked) return
if (!this.isOpen) {
this.open()
return
}
this.pointerPrev()
},
handleKeyDown() {
if (this.isInteractionBlocked) return
if (!this.isOpen) {
this.open()
return
}
this.pointerNext()
},
setPointer(index) {
if (!this.hadKeyboardInput) this.pointer = index
},
pointerPrev() {
if (this.pointer <= 0) {
this.pointer = this.pointerMax
} else {
this.pointer--
}
this.scrollToHighlighted()
},
pointerNext() {
if (this.pointer >= this.pointerMax) {
this.pointer = 0
} else {
this.pointer++
}
this.scrollToHighlighted()
},
scrollToHighlighted() {
clearTimeout(this.hadKeyboardInput)
if (!this.$refs.options || !this.$refs.options.children.length || this.pointerMax <= -1)
return
this.hadKeyboardInput = setTimeout(() => {
this.hadKeyboardInput = null
}, 250)
this.$refs.options.children[this.pointer].scrollIntoView({ block: 'nearest' })
},
selectPointerOption() {
this.handleSelect(this.filteredOptions[this.pointer])
},
},
}
</script>
<style lang="scss">
/* Styles inherited from global styleguide CSS (ds-select, ds-form-item classes).
* Once ds-select is fully removed from the styleguide, move the styles here. */
.ocelot-select-icon-right {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
}
</style>