Skip to content

Instantly share code, notes, and snippets.

@iErik
Created June 3, 2019 17:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iErik/0127b1265cc79d96f7d3676eeb608488 to your computer and use it in GitHub Desktop.
Save iErik/0127b1265cc79d96f7d3676eeb608488 to your computer and use it in GitHub Desktop.
<template>
<c-input-container
v-click-outside="close"
:class="containerClasses"
:style="{ '--options-length': (computedOptions || []).length }"
v-bind="containerAttributes"
@click.native="opened = !opened"
>
<select
v-if="mobileNative || nativeCompatible"
v-model="selected"
:name="name"
:class="['native-select', { '-hidden': nativeCompatible }]"
>
<option
v-for="(option, index) in options"
:key="index"
>
{{ getItem(option) }}
</option>
</select>
<div
:style="borderStyles"
class="border"
/>
<div
:class="selectedClasses"
tabindex="0"
@keydown="keyboardHandler"
>
<slot name="icon">
<c-icon v-if="icon" :icon="icon" class="icon" />
</slot>
<slot :selected="value">
<c-transition mode="out-in" duration="200">
<span
:class="['text', { '-placeholder': !value }]"
:key="selected"
>
{{ selected }}
</span>
</c-transition>
<slot name="select-icon">
<c-icon v-if="selectIcon" icon="chevron-down" class="select-icon" size="11" />
</slot>
</slot>
</div>
<div
:style="optionsWrapperStyles"
class="options-wrapper"
>
<transition name="options">
<section
v-show="opened && !mobileNative"
:class="optionsClasses"
ref="optionsList"
@scroll="checkPagination"
>
<slot :options="options" name="options">
<div
v-for="(option, index) in computedOptions"
:key="index"
:class="optionClasses(index)"
@click.stop="selected = options[index]"
@mouseover="focusedItem = index"
>
<slot :option="option" name="option">
<p class="text">{{ getItem(option) }}</p>
<span :class="['dot', { '-selected': isSelected === index }]" />
</slot>
</div>
</slot>
</section>
</transition>
</div>
</c-input-container>
</template>
<script>
import CInputContainer from '../CInputContainer'
import CIcon from '../CIcon'
import CTransition from '../CTransition'
import clickOutside from '../../directives/clickOutside'
// import { Shadowed } from '../../mixins'
export default {
name: 'CSelect',
components: { CInputContainer, CIcon, CTransition },
// mixins: [ Shadowed({ refName: 'optionsList' }) ],
directives: { clickOutside },
props: {
/**
* The options array, it can either be an array of objects, in which
* case the trackBy/displayBy props are necessary, or an array of any
* other primitive type.
*/
options: {
type: Array,
required: true
},
/**
* The input name.
*/
name: {
type: String,
default: 'select'
},
/**
* If `options` is an array of objects, CSelect will use this prop
* to compare the currently select item.
*/
trackBy: String,
/**
* If `options` is an array of objects, CSelect will use this prop
* to get the label of the option/value to be displayed for the user.
*/
displayBy: String,
/**
* The icon to show field.
*/
icon: String,
/**
* The icon of the dropdown indicator (the little arrow on the left).
*/
selectIcon: {
type: String,
default: 'chevron-down'
},
/**
* The placeholder of the field.
*/
placeholder: {
type: String,
default: 'Selecione uma opção'
},
/**
* The value of the field.
*/
value: [Object, String],
/**
* Set style and behavior for using native mobile select.
*/
mobileNative: Boolean,
/**
* Set a hidden native select input for native forms compatibility.
*/
nativeCompatible: Boolean,
/**
* Jumbo style.
*/
jumbo: Boolean,
/**
* Disables the field
*/
disabled: Boolean,
/**
* Paginates the option list.
*/
paginated: Boolean,
/**
* Determines how many items to show at a time, only relevant
* when the `paginated` prop is set to `true`.
*/
paginationThreshold: {
type: [Number, String],
default: 10
}
},
data () {
return {
opened: false,
focusedItem: -1,
paginationPosition: +this.paginationThreshold
}
},
computed: {
selected: {
get () {
if (this.placeholder && !this.value) return this.placeholder
const value = (this.options || [])
.find(option => {
return this.trackBy && option instanceof Object
? option[this.trackBy] === this.value[this.trackBy]
: option === this.value
})
if (!value) return 'Opção inválida'
if (this.value) {
if (this.displayBy && value[this.displayBy]) {
return value[this.displayBy]
? value[this.displayBy]
: process.env.NODE_ENV === 'development' ? 'error: displayBy prop does not exist' : ''
} else {
return this.value
}
}
},
set (item) {
this.focusedItem = -1
this.close()
this.$emit('input', item)
}
},
containerClasses () {
return ['c-select', {
'-opened': this.opened && !this.mobileNative,
'-mobile-native': this.mobileNative,
'-selected': this.value !== '',
'-disabled': this.disabled,
'-has-icon': this.icon,
'-jumbo': this.jumbo,
}]
},
containerAttributes () {
return {
...this.$attrs,
...(this.mobileNative || this.nativeCompatible ? {} : { name: this.name })
}
},
selectedClasses () {
return [ 'selected', {
'-slot': this.$slots.default || this.$scopedSlots.default
}]
},
optionsClasses () {
return [ 'options', {
'-slot': this.$slots.options || this.$scopedSlots.options
}]
},
isSelected () {
return this.options.findIndex(option => {
return this.trackBy
? (this.value || {})[this.trackBy] === option[this.trackBy]
: this.value === option
})
},
borderStyles () {
if (!this.jumbo) {
const height = this.opened && !this.mobileNative
? `${this.computedOptions.length * 40 + 40}px`
: '40px'
return { height }
}
},
optionsWrapperStyles () {
if (!this.jumbo) {
const height = `${(this.computedOptions || []).length * 41 - 1}px`
return { height, top: 40 }
}
},
computedOptions () {
return this.paginated
? this.options.slice(0, this.paginationPosition)
: this.options
}
},
methods: {
getItem (option) {
if (this.displayBy) {
return option[this.displayBy]
? option[this.displayBy]
: process.env.NODE_ENV === 'development' ? 'error: displayBy prop does not exist' : ''
}
return option
},
paginate () {
if (this.options.length !== this.computedOptions.length)
this.paginationPosition += +this.paginationThreshold
},
checkPagination () {
const el = this.$refs.optionsList
const scrollTop = el.scrollTop
const scrollHeight = el.scrollHeight
const clientHeight = el.clientHeight
const scrollHeightThreshold = scrollHeight - 60
if ((scrollTop + clientHeight) >= scrollHeightThreshold) this.paginate()
},
close () {
this.focusedItem = -1
this.opened = false
},
open () {
this.opened = true
},
optionClasses (index) {
return [ 'option', {
'-slot': this.$slots.option || this.$scopedSlots.option,
'-focused': this.focusedItem === index
}]
},
keyboardHandler (ev) {
if (ev.key === 'Enter') !this.onEnter() && ev.preventDefault()
else if (ev.key === 'ArrowUp') !this.onArrowUp() && ev.preventDefault()
else if (ev.key === 'ArrowDown') !this.onArrowDown() && ev.preventDefault()
else if (ev.key === 'Tab') this.close()
},
onEnter () {
if (!this.opened) this.open()
else if (this.focusedItem > -1) { this.selected = this.options[this.focusedItem] }
},
onArrowUp () {
if (this.focusedItem > -1) this.focusedItem -= 1
if (this.focusedItem === -1) this.close()
},
onArrowDown () {
if (!this.opened) this.open()
if (this.focusedItem < this.options.length - 1) this.focusedItem += 1
}
}
}
</script>
<style lang="scss">
.c-select {
position: relative;
overflow: visible;
z-index: 0;
// margin-bottom transition to support
// CInputContainer margin-bottom transition
transition: z-index .3s, margin-bottom .3s;
& > .native-select.-hidden {
position: absolute;
visibility: hidden;
}
&.-disabled {
user-select: none;
pointer-events: none;
& > .selected {
background-color: map-get($text-color, base-05);
color: map-get($text-color, base-80);
}
}
& > .border {
position: absolute;
top: 0;
left: 0;
width: 100%;
background: #FFF;
border-radius: 5px;
border: 1px solid map-get($text-color, base-10);
transition: height .3s, box-shadow .3s;
z-index: 1;
max-height: 240px;
}
& > .selected {
position: relative;
border: 1px solid transparent;
height: 40px;
display: flex;
padding-left: 20px;
align-items: center;
justify-content: flex-start;
cursor: pointer;
outline: none;
z-index: 1;
& > .icon {
box-sizing: content-box;
fill: map-get($text-color, base-30);
}
& > .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: map-get($text-color, base-80);
font-size: 14px;
font-family: $base-font-family;
&.-placeholder { color: map-get($text-color, base-30); }
}
& > .select-icon {
margin-left: auto;
margin-right: 17px;
fill: map-get($text-color, base-50);
transition: transform .3s, fill .3s;
}
}
& > .options-wrapper {
overflow: hidden;
position: absolute;
left: 0;
right: 0;
border: none;
background: transparent;
pointer-events: none;
z-index: 1;
// @include shadowed(70px, #FFF);
&, & > .options { max-height: 200px }
& > .options { overflow-y: auto; }
& > .options > .option {
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px;
height: 40px;
border-bottom: 1px solid map-get($text-color, base-10);
&:last-child { border-bottom: none; }
&.-focused { background-color: map-get($text-color, base-02); }
& > .text {
position: relative;
color: map-get($text-color, base-80);
font-family: $base-font-family;
font-size: 14px;
}
& > .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: set-linear-gradient(horizontal);
opacity: 0;
transition: opacity 1s;
&.-selected { opacity: 1; }
}
}
// transitions
.options-enter-active, .options-leave-active {
transition: transform .3s, opacity .1s;
}
.options-enter, .options-leave-to {
transform: translateY(-100%);
opacity: 0;
}
}
&:focus-within:not(.-opened) > .border {
border-color: rgba($primary-color, .35);
@include hover();
}
&.-validation:not(.-opened) > .border {
border-color: rgba($negative-color, .35);
@include hover($negative-color);
}
&.-has-icon > .selected { padding-left: 12px; }
&.-has-icon > .selected > .text { padding-left: 10px; }
&.-opened {
z-index: $z-index-1;
& > .border {
box-shadow: 0 2px 6px -2px rgba(0, 0, 0, 0.2);
background: linear-gradient(180deg, #FFFFFF 0%, rgba(255,255,255,0.9) 100%);
}
& > .options-wrapper { pointer-events: all; }
& > .selected {
border-radius: 5px 5px 0 0;
border-bottom: 1px solid transparent;
cursor: default;
& > .select-icon {
fill: $base-text-color;
transform: rotateX(180deg);
}
}
}
&.-mobile-native {
& > .native-select {
width: 100%;
height: 40px;
z-index: $z-index-1;
position: absolute;
opacity: 0;
}
& > .border {
background-color: rgba(255,255,255,0.1);
border-radius: 20px;
}
& > .selected > .icon {
fill: rgba(255, 255, 255, 0.8);
filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
}
& > .selected > .text {
color: #FFF;
opacity: 0.8;
font-family: $title-font-family;
font-size: 11px;
font-weight: $title-font-weight;
text-transform: uppercase;
}
& > .selected > .select-icon {
fill: rgba(255, 255, 255, 0.5);
filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3));
}
}
&.-jumbo {
& > .border {
height: 50px;
max-height: 250px;
}
&.-opened > .border { height: calc(var(--options-length)*40px + 50px); }
& > .selected {
height: 50px;
padding-left: 15px;
border-radius: 5px;
& > .select-icon { margin-right: 27px; }
& > .text { padding-top: 16px; }
& > .text.-placeholder { display: none; }
&::before {
$background: (
light: rgba(map-get($negative-color-map, light), 0.2),
dark: rgba(map-get($negative-color-map, dark), 0.2)
);
content: '';
display: block;
position: absolute;
background: set-linear-gradient(315deg, $background);
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
opacity: 0;
transition: opacity .3s;
}
}
& > .label {
z-index: $z-index-2;
top: 50%;
transform: translateY(-50%);
padding-left: 15px;
padding-right: 50px;
margin-bottom: 0;
font-family: $base-font-family;
font-size: 14px;
color: map-get($text-color, base-30);
pointer-events: none;
transition: top .3s, font-size .3s;
}
&.-selected > .label {
top: 6px;
transform: translateY(0);
font-size: 11px;
}
&.-has-icon > .label { left: 30px; }
& > .options-wrapper {
top: 50px;
height: calc(var(--options-length)*41px + 1px);
& > .options > .option {
padding-left: 20px;
padding-right: 29px;
}
}
&.-validation {
&:not(.-opened) > .selected {
color: map-get($negative-color-map, light);
&::before { opacity: 1; }
& > .select-icon { fill: map-get($negative-color-map, light); }
}
&:not(.-opened) > .border {
background: transparent;
border-color: rgba($negative-color, .3);
box-shadow: none;
}
&:not(.-opened) > .label { color: map-get($negative-color-map, light); }
& > .jumbo-validation {
visibility: hidden;
position: absolute;
}
}
@include responsive (mobile, desktop) {
& > .border {
height: 60px;
max-height: 260px;
}
&.-opened > .border { height: calc(var(--options-length)*40px + 60px); }
& > .options-wrapper { top: 60px; }
& > .selected {
height: 60px;
padding-left: 20px;
}
& > .label {
padding: 0 20px;
}
&.-selected > .label { top: 14px; }
}
}
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment