node_modules updated, buefy add to lib folder

This commit is contained in:
2019-06-04 14:29:48 +02:00
parent 0a3f6ff240
commit 5d28bc36e1
3957 changed files with 306575 additions and 21738 deletions
+152
View File
@@ -0,0 +1,152 @@
import { mount } from '@vue/test-utils'
import BAutocomplete from '@components/autocomplete/Autocomplete'
const findStringsStartingWith = (array, value) =>
array.filter((x) => x.startsWith(value))
const DATA_LIST = [
'Angular',
'Angular 2',
'Aurelia',
'Backbone',
'Ember',
'jQuery',
'Meteor',
'Node.js',
'Polymer',
'React',
'RxJS',
'Vue.js'
]
const dropdownMenu = '.dropdown-menu'
let wrapper, $input, $dropdown
describe('BAutocomplete', () => {
beforeEach(() => {
wrapper = mount(BAutocomplete)
$input = wrapper.find('input')
$dropdown = wrapper.find(dropdownMenu)
})
it('is called', () => {
expect(wrapper.name()).toBe('BAutocomplete')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('has an input type', () => {
expect(wrapper.contains('.control .input[type=text]')).toBeTruthy()
})
it('has a dropdown menu hidden by default', () => {
expect(wrapper.contains(dropdownMenu)).toBeTruthy()
expect($dropdown.isVisible()).toBeFalsy()
})
it('can emit input, focus and blur events', async () => {
const VALUE_TYPED = 'test'
wrapper.setProps({ data: DATA_LIST })
$input.trigger('focus')
expect(wrapper.emitted()['focus']).toBeTruthy()
$input.setValue(VALUE_TYPED)
await wrapper.vm.$nextTick()
const valueEmitted = wrapper.emitted()['input'][0]
expect(valueEmitted).toContainEqual(VALUE_TYPED)
$input.trigger('blur')
expect(wrapper.emitted()['blur']).toBeTruthy()
})
it('can autocomplete with keydown', async () => {
const VALUE_TYPED = 'Ang'
wrapper.setProps({ data: DATA_LIST })
$input.trigger('focus')
$input.setValue(VALUE_TYPED)
await wrapper.vm.$nextTick()
expect($dropdown.isVisible()).toBeTruthy()
const itemsInDropdowm = findStringsStartingWith(DATA_LIST, VALUE_TYPED)
$input.trigger('keydown.down')
$input.trigger('keydown.enter')
await wrapper.vm.$nextTick()
expect($input.element.value).toBe(itemsInDropdowm[0])
expect($dropdown.isVisible()).toBeFalsy()
})
it('check validity when value change', () => {
wrapper.setProps({ data: DATA_LIST })
wrapper.vm.$refs.input.checkHtml5Validity = jest.fn()
wrapper.vm.isValid = false
wrapper.setProps({ value: 'test' })
expect(wrapper.vm.$refs.input.checkHtml5Validity).toBeCalled()
})
it('close dropdown on esc', () => {
wrapper.setProps({ data: DATA_LIST })
wrapper.vm.isActive = true
expect($dropdown.isVisible()).toBeTruthy()
$input.trigger('keyup.esc')
expect($dropdown.isVisible()).toBeFalsy()
})
it('close dropdown on click outside', () => {
wrapper.setProps({ data: DATA_LIST })
wrapper.vm.isActive = true
expect($dropdown.isVisible()).toBeTruthy()
window.document.body.click()
expect($dropdown.isVisible()).toBeFalsy()
})
it('open dropdown on down key click', () => {
wrapper.setProps({ data: DATA_LIST })
expect($dropdown.isVisible()).toBeFalsy()
$input.trigger('focus')
$input.trigger('keydown.down')
expect($dropdown.isVisible()).toBeTruthy()
})
it('can openOnFocus and keepFirst', async () => {
wrapper.setProps({
data: DATA_LIST,
openOnFocus: true,
keepFirst: true
})
expect($dropdown.isVisible()).toBeFalsy()
$input.trigger('focus')
await wrapper.vm.$nextTick()
expect($dropdown.isVisible()).toBeTruthy()
expect(wrapper.vm.hovered).toBe(DATA_LIST[0])
})
it('reset events before destroy', () => {
document.removeEventListener = jest.fn()
window.removeEventListener = jest.fn()
wrapper.destroy()
expect(document.removeEventListener).toBeCalledWith('click', expect.any(Function))
expect(window.removeEventListener).toBeCalledWith('resize', expect.any(Function))
})
})
+387
View File
@@ -0,0 +1,387 @@
<template>
<div class="autocomplete control" :class="{'is-expanded': expanded}">
<b-input
v-model="newValue"
ref="input"
type="text"
:size="size"
:loading="loading"
:rounded="rounded"
:icon="icon"
:icon-pack="iconPack"
:maxlength="maxlength"
:autocomplete="newAutocomplete"
v-bind="$attrs"
@input="onInput"
@focus="focused"
@blur="onBlur"
@keyup.native.esc.prevent="isActive = false"
@keydown.native.tab="tabPressed"
@keydown.native.enter.prevent="enterPressed"
@keydown.native.up.prevent="keyArrows('up')"
@keydown.native.down.prevent="keyArrows('down')"
/>
<transition name="fade">
<div
class="dropdown-menu"
:class="{ 'is-opened-top': !isListInViewportVertically }"
v-show="isActive && (data.length > 0 || hasEmptySlot || hasHeaderSlot)"
ref="dropdown">
<div class="dropdown-content" v-show="isActive">
<div
v-if="hasHeaderSlot"
class="dropdown-item">
<slot name="header"/>
</div>
<a
v-for="(option, index) in data"
:key="index"
class="dropdown-item"
:class="{ 'is-hovered': option === hovered }"
@click="setSelected(option)">
<slot
v-if="hasDefaultSlot"
:option="option"
:index="index"
/>
<span v-else>
{{ getValue(option, true) }}
</span>
</a>
<div
v-if="data.length === 0 && hasEmptySlot"
class="dropdown-item is-disabled">
<slot name="empty"/>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import { getValueByPath } from '../../utils/helpers'
import FormElementMixin from '../../utils/FormElementMixin'
import Input from '../input/Input'
export default {
name: 'BAutocomplete',
components: {
[Input.name]: Input
},
mixins: [FormElementMixin],
inheritAttrs: false,
props: {
value: [Number, String],
data: {
type: Array,
default: () => []
},
field: {
type: String,
default: 'value'
},
keepFirst: Boolean,
clearOnSelect: Boolean,
openOnFocus: Boolean,
customFormatter: Function
},
data() {
return {
selected: null,
hovered: null,
isActive: false,
newValue: this.value,
newAutocomplete: this.autocomplete || 'off',
isListInViewportVertically: true,
hasFocus: false,
_isAutocomplete: true,
_elementRef: 'input'
}
},
computed: {
/**
* White-listed items to not close when clicked.
* Add input, dropdown and all children.
*/
whiteList() {
const whiteList = []
whiteList.push(this.$refs.input.$el.querySelector('input'))
whiteList.push(this.$refs.dropdown)
// Add all chidren from dropdown
if (this.$refs.dropdown !== undefined) {
const children = this.$refs.dropdown.querySelectorAll('*')
for (const child of children) {
whiteList.push(child)
}
}
return whiteList
},
/**
* Check if exists default slot
*/
hasDefaultSlot() {
return !!this.$scopedSlots.default
},
/**
* Check if exists "empty" slot
*/
hasEmptySlot() {
return !!this.$slots.empty
},
/**
* Check if exists "header" slot
*/
hasHeaderSlot() {
return !!this.$slots.header
}
},
watch: {
/**
* When dropdown is toggled, check the visibility to know when
* to open upwards.
*/
isActive(active) {
if (active) {
this.calcDropdownInViewportVertical()
} else {
this.$nextTick(() => this.setHovered(null))
// Timeout to wait for the animation to finish before recalculating
setTimeout(() => {
this.calcDropdownInViewportVertical()
}, 100)
}
},
/**
* When updating input's value
* 1. Emit changes
* 2. If value isn't the same as selected, set null
* 3. Close dropdown if value is clear or else open it
*/
newValue(value) {
this.$emit('input', value)
// Check if selected is invalid
const currentValue = this.getValue(this.selected)
if (currentValue && currentValue !== value) {
this.setSelected(null, false)
}
// Close dropdown if input is clear or else open it
if (this.hasFocus && (!this.openOnFocus || value)) {
this.isActive = !!value
}
},
/**
* When v-model is changed:
* 1. Update internal value.
* 2. If it's invalid, validate again.
*/
value(value) {
this.newValue = value
!this.isValid && this.$refs.input.checkHtml5Validity()
},
/**
* Select first option if "keep-first
*/
data(value) {
// Keep first option always pre-selected
if (this.keepFirst) {
this.selectFirstOption(value)
}
}
},
methods: {
/**
* Set which option is currently hovered.
*/
setHovered(option) {
if (option === undefined) return
this.hovered = option
},
/**
* Set which option is currently selected, update v-model,
* update input value and close dropdown.
*/
setSelected(option, closeDropdown = true) {
if (option === undefined) return
this.selected = option
this.$emit('select', this.selected)
if (this.selected !== null) {
this.newValue = this.clearOnSelect ? '' : this.getValue(this.selected)
}
closeDropdown && this.$nextTick(() => { this.isActive = false })
},
/**
* Select first option
*/
selectFirstOption(options) {
this.$nextTick(() => {
if (options.length) {
// If has visible data or open on focus, keep updating the hovered
if (this.openOnFocus || (this.newValue !== '' && this.hovered !== options[0])) {
this.setHovered(options[0])
}
} else {
this.setHovered(null)
}
})
},
/**
* Enter key listener.
* Select the hovered option.
*/
enterPressed() {
if (this.hovered === null) return
this.setSelected(this.hovered)
},
/**
* Tab key listener.
* Select hovered option if it exists, close dropdown, then allow
* native handling to move to next tabbable element.
*/
tabPressed() {
if (this.hovered === null) {
this.isActive = false
return
}
this.setSelected(this.hovered)
},
/**
* Close dropdown if clicked outside.
*/
clickedOutside(event) {
if (this.whiteList.indexOf(event.target) < 0) this.isActive = false
},
/**
* Return display text for the input.
* If object, get value from path, or else just the value.
*/
getValue(option) {
if (!option) return
if (typeof this.customFormatter !== 'undefined') {
return this.customFormatter(option)
}
return typeof option === 'object'
? getValueByPath(option, this.field)
: option
},
/**
* Calculate if the dropdown is vertically visible when activated,
* otherwise it is openened upwards.
*/
calcDropdownInViewportVertical() {
this.$nextTick(() => {
/**
* this.$refs.dropdown may be undefined
* when Autocomplete is conditional rendered
*/
if (this.$refs.dropdown === undefined) return
const rect = this.$refs.dropdown.getBoundingClientRect()
this.isListInViewportVertically = (
rect.top >= 0 &&
rect.bottom <= (window.innerHeight ||
document.documentElement.clientHeight)
)
})
},
/**
* Arrows keys listener.
* If dropdown is active, set hovered option, or else just open.
*/
keyArrows(direction) {
const sum = direction === 'down' ? 1 : -1
if (this.isActive) {
let index = this.data.indexOf(this.hovered) + sum
index = index > this.data.length - 1 ? this.data.length : index
index = index < 0 ? 0 : index
this.setHovered(this.data[index])
const list = this.$refs.dropdown.querySelector('.dropdown-content')
const element = list.querySelectorAll('a.dropdown-item:not(.is-disabled)')[index]
if (!element) return
const visMin = list.scrollTop
const visMax = list.scrollTop + list.clientHeight - element.clientHeight
if (element.offsetTop < visMin) {
list.scrollTop = element.offsetTop
} else if (element.offsetTop >= visMax) {
list.scrollTop = (
element.offsetTop -
list.clientHeight +
element.clientHeight
)
}
} else {
this.isActive = true
}
},
/**
* Focus listener.
* If value is the same as selected, select all text.
*/
focused(event) {
if (this.getValue(this.selected) === this.newValue) {
this.$el.querySelector('input').select()
}
if (this.openOnFocus) {
this.isActive = true
if (this.keepFirst) {
this.selectFirstOption(this.data)
}
}
this.hasFocus = true
this.$emit('focus', event)
},
/**
* Blur listener.
*/
onBlur(event) {
this.hasFocus = false
this.$emit('blur', event)
},
onInput(event) {
const currentValue = this.getValue(this.selected)
if (currentValue && currentValue === this.newValue) return
this.$emit('typing', this.newValue)
}
},
created() {
if (typeof window !== 'undefined') {
document.addEventListener('click', this.clickedOutside)
window.addEventListener('resize', this.calcDropdownInViewportVertical)
}
},
beforeDestroy() {
if (typeof window !== 'undefined') {
document.removeEventListener('click', this.clickedOutside)
window.removeEventListener('resize', this.calcDropdownInViewportVertical)
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Autocomplete from './Autocomplete'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Autocomplete)
}
}
use(Plugin)
export default Plugin
export {
Autocomplete
}
+58
View File
@@ -0,0 +1,58 @@
import { shallowMount, mount } from '@vue/test-utils'
import BButton from '@components/button/Button'
describe('BButton', () => {
it('is called', () => {
const wrapper = shallowMount(BButton)
expect(wrapper.name()).toBe('BButton')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('emit a click event', () => {
const wrapper = shallowMount(BButton)
const click = jest.fn()
wrapper.vm.$on('click', click)
wrapper.find('.button').trigger('click')
expect(click).toHaveBeenCalledTimes(1)
})
it('should show icon', () => {
const wrapper = mount(BButton, {
propsData: {
iconLeft: 'plus'
}
})
expect(wrapper.contains('.icon')).toBe(true)
})
it('should be medium', () => {
const wrapper = shallowMount(BButton, {
propsData: {
size: 'is-medium'
}
})
expect(wrapper.classes()).toContain('is-medium')
})
it('should be small + icon', () => {
const wrapper = mount(BButton, {
propsData: {
size: 'is-small',
iconLeft: 'plus'
}
})
expect(wrapper.classes()).toContain('is-small')
expect(wrapper.contains('.icon')).toBe(true)
})
it('should be large + icon', () => {
const wrapper = mount(BButton, {
propsData: {
size: 'is-large',
iconLeft: 'plus'
}
})
expect(wrapper.classes()).toContain('is-large')
expect(wrapper.contains('.icon')).toBe(true)
})
})
+94
View File
@@ -0,0 +1,94 @@
<template>
<component
:is="tag"
class="button"
:type="nativeType"
:class="[size, type, {
'is-rounded': rounded,
'is-loading': loading,
'is-outlined': outlined,
'is-inverted': inverted,
'is-focused': focused,
'is-active': active,
'is-hovered': hovered,
'is-selected': selected
}]"
@click="$emit('click', $event)"
>
<b-icon
v-if="iconLeft"
:pack="iconPack"
:icon="iconLeft"
:size="iconSize"
/>
<span v-if="label">{{ label }}</span>
<span v-else-if="$slots.default">
<slot />
</span>
<b-icon
v-if="iconRight"
:pack="iconPack"
:icon="iconRight"
:size="iconSize"
/>
</component>
</template>
<script>
import Icon from '../icon/Icon'
export default {
name: 'BButton',
components: {
[Icon.name]: Icon
},
props: {
type: [String, Object],
size: String,
label: String,
iconPack: String,
iconLeft: String,
iconRight: String,
rounded: Boolean,
loading: Boolean,
outlined: Boolean,
inverted: Boolean,
focused: Boolean,
active: Boolean,
hovered: Boolean,
selected: Boolean,
nativeType: {
type: String,
default: 'button',
validator: (value) => {
return [
'button',
'submit',
'reset'
].indexOf(value) >= 0
}
},
tag: {
type: String,
default: 'button',
validator: (value) => {
return [
'button',
'a',
'input'
].indexOf(value) >= 0
}
}
},
computed: {
iconSize() {
if (!this.size || this.size === 'is-medium') {
return 'is-small'
} else if (this.size === 'is-large') {
return 'is-medium'
}
return this.size
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Button from './Button'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Button)
}
}
use(Plugin)
export default Plugin
export {
Button
}
+27
View File
@@ -0,0 +1,27 @@
import { shallowMount } from '@vue/test-utils'
import BCheckbox from '@components/checkbox/Checkbox'
let wrapper
describe('BCheckbox', () => {
beforeEach(() => {
wrapper = shallowMount(BCheckbox)
})
it('is called', () => {
expect(wrapper.name()).toBe('BCheckbox')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('has an input checkbox', () => {
expect(wrapper.contains('label input[type=checkbox]')).toBeTruthy()
})
it('emit input event with value when value change', () => {
wrapper.setProps({ value: true })
expect(wrapper.vm.computedValue).toBeTruthy()
wrapper.vm.computedValue = false
const valueEmitted = wrapper.emitted()['input'][0]
expect(valueEmitted).toContainEqual(false)
})
})
+70
View File
@@ -0,0 +1,70 @@
<template>
<label
class="b-checkbox checkbox"
:class="[size, { 'is-disabled': disabled }]"
ref="label"
:disabled="disabled"
@keydown.prevent.enter="$refs.label.click()">
<input
v-model="computedValue"
:indeterminate.prop="indeterminate"
type="checkbox"
@click.stop
:disabled="disabled"
:required="required"
:name="name"
:value="nativeValue"
:true-value="trueValue"
:false-value="falseValue">
<span class="check" :class="type" />
<span class="control-label"><slot/></span>
</label>
</template>
<script>
export default {
name: 'BCheckbox',
props: {
value: [String, Number, Boolean, Function, Object, Array, Symbol],
nativeValue: [String, Number, Boolean, Function, Object, Array, Symbol],
indeterminate: Boolean,
type: String,
disabled: Boolean,
required: Boolean,
name: String,
size: String,
trueValue: {
type: [String, Number, Boolean, Function, Object, Array, Symbol],
default: true
},
falseValue: {
type: [String, Number, Boolean, Function, Object, Array, Symbol],
default: false
}
},
data() {
return {
newValue: this.value
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
}
}
},
watch: {
/**
* When v-model change, set internal value.
*/
value(value) {
this.newValue = value
}
}
}
</script>
+27
View File
@@ -0,0 +1,27 @@
import { shallowMount } from '@vue/test-utils'
import BCheckboxButton from '@components/checkbox/CheckboxButton'
let wrapper
describe('BCheckboxButton', () => {
beforeEach(() => {
wrapper = shallowMount(BCheckboxButton)
})
it('is called', () => {
expect(wrapper.name()).toBe('BCheckboxButton')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('has an input checkbox', () => {
expect(wrapper.contains('label input[type=checkbox]')).toBeTruthy()
})
it('emit input event with value when value change', () => {
wrapper.setProps({ value: true })
expect(wrapper.vm.computedValue).toBeTruthy()
wrapper.vm.computedValue = false
const valueEmitted = wrapper.emitted()['input'][0]
expect(valueEmitted).toContainEqual(false)
})
})
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="control">
<label
class="b-checkbox checkbox button"
ref="label"
:class="[checked ? type : null, size, {
'is-disabled': disabled,
'is-focused': isFocused
}]"
:disabled="disabled"
@keydown.prevent.enter="$refs.label.click()">
<slot/>
<input
v-model="computedValue"
type="checkbox"
@click.stop
:disabled="disabled"
:required="required"
:name="name"
:value="nativeValue"
@focus="isFocused = true"
@blur="isFocused = false">
</label>
</div>
</template>
<script>
export default {
name: 'BCheckboxButton',
props: {
value: [String, Number, Boolean, Function, Object, Array, Symbol],
nativeValue: [String, Number, Boolean, Function, Object, Array, Symbol],
disabled: Boolean,
required: Boolean,
name: String,
size: String,
type: {
type: String,
default: 'is-primary'
}
},
data() {
return {
newValue: this.value,
isFocused: false
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
}
},
checked() {
if (Array.isArray(this.newValue)) {
return this.newValue.indexOf(this.nativeValue) >= 0
}
return this.newValue === this.nativeValue
}
},
watch: {
/**
* When v-model change, set internal value.
*/
value(value) {
this.newValue = value
}
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
import Checkbox from './Checkbox'
import CheckboxButton from './CheckboxButton'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Checkbox)
registerComponent(Vue, CheckboxButton)
}
}
use(Plugin)
export default Plugin
export {
Checkbox,
CheckboxButton
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BClockpicker from '@components/clockpicker/Clockpicker'
describe('BClockpicker', () => {
it('is called', () => {
const wrapper = shallowMount(BClockpicker)
expect(wrapper.name()).toBe('BClockpicker')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+239
View File
@@ -0,0 +1,239 @@
<template>
<div class="b-clockpicker control" :class="[size, type, {'is-expanded': expanded}]">
<b-dropdown
v-if="!isMobile || inline"
ref="dropdown"
:position="position"
:disabled="disabled"
:inline="inline">
<b-input
v-if="!inline"
ref="input"
slot="trigger"
autocomplete="off"
:value="formatValue(dateSelected)"
:placeholder="placeholder"
:size="size"
:icon="icon"
:icon-pack="iconPack"
:loading="loading"
:disabled="disabled"
:readonly="!editable"
:rounded="rounded"
v-bind="$attrs"
@change.native="onChange($event.target.value)"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event) && checkHtml5Validity()"/>
<div
class="card"
:disabled="disabled"
custom>
<header v-if="inline" class="card-header">
<div class="b-clockpicker-header card-header-title">
<div class="b-clockpicker-time">
<span
class="b-clockpicker-btn"
:class="{ active: isSelectingHour }"
@click="isSelectingHour = true">{{ hoursDisplay }}</span>
<span>:</span>
<span
class="b-clockpicker-btn"
:class="{ active: !isSelectingHour }"
@click="isSelectingHour = false">{{ minutesDisplay }}</span>
</div>
<div v-if="!isHourFormat24" class="b-clockpicker-period">
<div
class="b-clockpicker-btn"
:class="{ active: meridienSelected == AM }"
@click="onMeridienClick(AM)">am</div>
<div
class="b-clockpicker-btn"
:class="{ active: meridienSelected == PM }"
@click="onMeridienClick(PM)">pm</div>
</div>
</div>
</header>
<div class="card-content">
<div
class="b-clockpicker-body"
:style="{ width: faceSize + 'px', height: faceSize + 'px' }">
<div v-if="!inline" class="b-clockpicker-time">
<div
class="b-clockpicker-btn"
:class="{ active: isSelectingHour }"
@click="isSelectingHour = true">Hours</div>
<span
class="b-clockpicker-btn"
:class="{ active: !isSelectingHour }"
@click="isSelectingHour = false">Min</span>
</div>
<div v-if="!isHourFormat24 && !inline" class="b-clockpicker-period">
<div
class="b-clockpicker-btn"
:class="{ active: meridienSelected == AM }"
@click="onMeridienClick(AM)">{{ AM }}</div>
<div
class="b-clockpicker-btn"
:class="{ active: meridienSelected == PM }"
@click="onMeridienClick(PM)">{{ PM }}</div>
</div>
<b-clockpicker-face
:picker-size="faceSize"
:min="minFaceValue"
:max="maxFaceValue"
:face-numbers="isSelectingHour ? hours : minutes"
:disabled-values="faceDisabledValues()"
:double="isSelectingHour && isHourFormat24"
:value="isSelectingHour ? hoursSelected : minutesSelected"
@input="onClockInput"
@change="onClockChange" />
</div>
</div>
<footer
v-if="$slots.default !== undefined && $slots.default.length"
class="b-clockpicker-footer card-footer">
<slot/>
</footer>
</div>
</b-dropdown>
<b-input
v-else
ref="input"
type="time"
autocomplete="off"
:value="formatHHMMSS(value)"
:placeholder="placeholder"
:size="size"
:icon="icon"
:icon-pack="iconPack"
:loading="loading"
:max="formatHHMMSS(maxTime)"
:min="formatHHMMSS(minTime)"
:disabled="disabled"
:readonly="false"
v-bind="$attrs"
@change.native="onChangeNativePicker"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event) && checkHtml5Validity()"/>
</div>
</template>
<script>
import TimepickerMixin from '../../utils/TimepickerMixin'
import Dropdown from '../dropdown/Dropdown'
import DropdownItem from '../dropdown/DropdownItem'
import Input from '../input/Input'
import Field from '../field/Field'
import Icon from '../icon/Icon'
import ClockpickerFace from './ClockpickerFace'
const outerPadding = 12
export default {
name: 'BClockpicker',
components: {
[ClockpickerFace.name]: ClockpickerFace,
[Input.name]: Input,
[Field.name]: Field,
[Icon.name]: Icon,
[Dropdown.name]: Dropdown,
[DropdownItem.name]: DropdownItem
},
mixins: [TimepickerMixin],
props: {
pickerSize: {
type: Number,
default: 290
},
hourFormat: {
type: String,
default: '12',
validator: (value) => {
return value === '24' || value === '12'
}
},
incrementMinutes: {
type: Number,
default: 5
},
autoSwitch: {
type: Boolean,
default: true
},
type: {
type: String,
default: 'is-primary'
}
},
data() {
return {
isSelectingHour: true,
isDragging: false,
_isClockpicker: true
}
},
computed: {
hoursDisplay() {
if (this.hoursSelected == null) return '--'
if (this.isHourFormat24) return this.pad(this.hoursSelected)
let display = this.hoursSelected
if (this.meridienSelected === this.PM) display -= 12
if (display === 0) display = 12
return display
},
minutesDisplay() {
return this.minutesSelected == null ? '--' : this.pad(this.minutesSelected)
},
minFaceValue() {
return this.isSelectingHour &&
!this.isHourFormat24 &&
this.meridienSelected === this.PM ? 12 : 0
},
maxFaceValue() {
return this.isSelectingHour
? (!this.isHourFormat24 && this.meridienSelected === this.AM ? 11 : 23)
: 59
},
faceFormatter() {
return this.isSelectingHour && !this.isHourFormat24
? (val) => val
: this.formatNumber
},
faceSize() {
return this.pickerSize - (outerPadding * 2)
}
},
methods: {
onClockInput(value) {
if (this.isSelectingHour) {
this.hoursSelected = value
this.onHoursChange(value)
} else {
this.minutesSelected = value
this.onMinutesChange(value)
}
},
onClockChange(value) {
if (this.autoSwitch && this.isSelectingHour) {
this.isSelectingHour = !this.isSelectingHour
}
},
onMeridienClick(value) {
if (this.meridienSelected !== value) {
this.meridienSelected = value
this.onMeridienChange(value)
}
},
faceDisabledValues() {
return this.isSelectingHour ? this.isHourDisabled : this.isMinuteDisabled
}
},
created() {
this.incrementMinutes = 5
}
}
</script>
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BClockpickerFace from '@components/clockpicker/ClockpickerFace'
describe('BClockpickerFace', () => {
it('is called', () => {
const wrapper = shallowMount(BClockpickerFace)
expect(wrapper.name()).toBe('BClockpickerFace')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+250
View File
@@ -0,0 +1,250 @@
<template>
<div
class="b-clockpicker-face"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
@mousemove="onDragMove"
@touchstart="onMouseDown"
@touchend="onMouseUp"
@touchmove="onDragMove">
<div
class="b-clockpicker-face-outer-ring"
ref="clock">
<div
class="b-clockpicker-face-hand"
:style="handStyle" />
<span
v-for="(num, index) of faceNumbers"
:key="index"
class="b-clockpicker-face-number"
:class="getFaceNumberClasses(num)"
:style="{ transform: getNumberTranslate(num.value) }">
<span>{{ num.label }}</span>
</span>
</div>
</div>
</template>
<script>
// These should match the variables in clockpicker.scss
const indicatorSize = 40
const paddingInner = 5
export default {
name: 'BClockpickerFace',
props: {
pickerSize: Number,
min: Number,
max: Number,
double: Boolean,
value: Number,
faceNumbers: Array,
disabledValues: Function
},
data() {
return {
isDragging: false,
inputValue: this.value,
prevAngle: 720
}
},
computed: {
/**
* How many number indicators are shown on the face
*/
count() {
return this.max - this.min + 1
},
/**
* How many number indicators are shown per ring on the face
*/
countPerRing() {
return this.double ? (this.count / 2) : this.count
},
/**
* Radius of the clock face
*/
radius() {
return this.pickerSize / 2
},
/**
* Radius of the outer ring of number indicators
*/
outerRadius() {
return this.radius -
paddingInner -
indicatorSize / 2
},
/**
* Radius of the inner ring of number indicators
*/
innerRadius() {
return Math.max(this.outerRadius * 0.6,
this.outerRadius - paddingInner - indicatorSize)
// 48px gives enough room for the outer ring of numbers
},
/**
* The angle for each selectable value
* For hours this ends up being 30 degrees, for minutes 6 degrees
*/
degreesPerUnit() {
return 360 / this.countPerRing
},
/**
* Used for calculating x/y grid location based on degrees
*/
degrees() {
return this.degreesPerUnit * Math.PI / 180
},
/**
* Calculates the angle the clock hand should be rotated for the
* selected value
*/
handRotateAngle() {
let currentAngle = this.prevAngle
while (currentAngle < 0) currentAngle += 360
let targetAngle = this.calcHandAngle(this.displayedValue)
let degreesDiff = this.shortestDistanceDegrees(currentAngle, targetAngle)
let angle = this.prevAngle + degreesDiff
return angle
},
/**
* Determines how long the selector hand is based on if the
* selected value is located along the outer or inner ring
*/
handScale() {
return this.calcHandScale(this.displayedValue)
},
handStyle() {
return {
transform: `rotate(${this.handRotateAngle}deg) scaleY(${this.handScale})`,
transition: '.3s cubic-bezier(.25,.8,.50,1)'
}
},
/**
* The value the hand should be pointing at
*/
displayedValue() {
return this.inputValue == null ? this.min : this.inputValue
}
},
watch: {
value(value) {
if (value !== this.inputValue) {
this.prevAngle = this.handRotateAngle
}
this.inputValue = value
}
},
methods: {
isDisabled(value) {
return this.disabledValues && this.disabledValues(value)
},
/**
* Calculates the distance between two points
*/
euclidean(p0, p1) {
const dx = p1.x - p0.x
const dy = p1.y - p0.y
return Math.sqrt(dx * dx + dy * dy)
},
shortestDistanceDegrees(start, stop) {
const modDiff = (stop - start) % 360
let shortestDistance = 180 - Math.abs(Math.abs(modDiff) - 180)
return (modDiff + 360) % 360 < 180 ? shortestDistance * 1 : shortestDistance * -1
},
/**
* Calculates the angle of the line from the center point
* to the given point.
*/
coordToAngle(center, p1) {
const value = 2 *
Math.atan2(p1.y - center.y - this.euclidean(center, p1), p1.x - center.x)
return Math.abs(value * 180 / Math.PI)
},
/**
* Generates the inline style translate() property for a
* number indicator, which determines it's location on the
* clock face
*/
getNumberTranslate(value) {
const { x, y } = this.getNumberCoords(value)
return `translate(${x}px, ${y}px)`
},
/***
* Calculates the coordinates on the clock face for a number
* indicator value
*/
getNumberCoords(value) {
const radius = this.isInnerRing(value) ? this.innerRadius : this.outerRadius
return {
x: Math.round(radius * Math.sin((value - this.min) * this.degrees)),
y: Math.round(-radius * Math.cos((value - this.min) * this.degrees))
}
},
getFaceNumberClasses(num) {
return {
'active': num.value === this.displayedValue,
'disabled': this.isDisabled(num.value)
}
},
/**
* Determines if a value resides on the inner ring
*/
isInnerRing(value) {
return this.double && (value - this.min >= this.countPerRing)
},
calcHandAngle(value) {
let angle = this.degreesPerUnit * (value - this.min)
if (this.isInnerRing(value)) angle -= 360
return angle
},
calcHandScale(value) {
return this.isInnerRing(value)
? ((this.innerRadius) / this.outerRadius)
: 1
},
onMouseDown(e) {
e.preventDefault()
this.isDragging = true
this.onDragMove(e)
},
onMouseUp() {
this.isDragging = false
if (!this.isDisabled(this.inputValue)) {
this.$emit('change', this.inputValue)
}
},
onDragMove(e) {
e.preventDefault()
if (!this.isDragging && e.type !== 'click') return
const { width, top, left } = this.$refs.clock.getBoundingClientRect()
const { clientX, clientY } = 'touches' in e ? e.touches[0] : e
const center = { x: width / 2, y: -width / 2 }
const coords = { x: clientX - left, y: top - clientY }
const handAngle = Math.round(this.coordToAngle(center, coords) + 360) % 360
const insideClick = this.double && this.euclidean(center, coords) <
(this.outerRadius + this.innerRadius) / 2 - 16
let value = Math.round(handAngle / this.degreesPerUnit) +
this.min +
(insideClick ? this.countPerRing : 0)
// Necessary to fix edge case when selecting left part of max value
if (handAngle >= (360 - this.degreesPerUnit / 2)) {
value = insideClick ? this.max : this.min
}
this.update(value)
},
update(value) {
if (this.inputValue !== value && !this.isDisabled(value)) {
this.prevAngle = this.handRotateAngle
this.inputValue = value
this.$emit('input', value)
}
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Clockpicker from './Clockpicker'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Clockpicker)
}
}
use(Plugin)
export default Plugin
export {
Clockpicker
}
+112
View File
@@ -0,0 +1,112 @@
import { shallowMount } from '@vue/test-utils'
import BCollapse from '@components/collapse/Collapse'
describe('BCollapse', () => {
it('is called', () => {
const wrapper = shallowMount(BCollapse)
expect(wrapper.name()).toBe('BCollapse')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('default props and vm', () => {
const wrapper = shallowMount(BCollapse)
expect(wrapper.props().open).toBe(true)
expect(wrapper.props().animation).toBe('fade')
expect(wrapper.vm.isOpen).toBe(true)
})
it('set default open prop', () => {
const wrapper = shallowMount(BCollapse, {
propsData: {
open: false
}
})
expect(wrapper.props().open).toBe(false)
expect(wrapper.vm.isOpen).toBe(false)
})
it('call toggle method', async () => {
const wrapper = shallowMount(BCollapse, {
propsData: {
open: false
}
})
expect(wrapper.find('.collapse-content').isVisible()).toBe(false)
wrapper.vm.toggle()
await wrapper.vm.$nextTick()
expect(wrapper.vm.isOpen).toBe(true)
expect(wrapper.find('.collapse-content').isVisible()).toBe(true)
})
it('emit a click event', () => {
const wrapper = shallowMount(BCollapse, {
propsData: {
open: false
}
})
expect(wrapper.find('.collapse-content').isVisible()).toBe(false)
const updateOpen = jest.fn()
wrapper.vm.$on('update:open', updateOpen)
wrapper.find('.collapse-trigger').trigger('click')
expect(updateOpen).toHaveBeenCalledTimes(1)
expect(updateOpen).toHaveBeenCalledWith(true)
expect(wrapper.vm.isOpen).toBe(true)
expect(wrapper.find('.collapse-content').isVisible()).toBe(true)
})
it('update open prop', () => {
const wrapper = shallowMount(BCollapse, {
propsData: {
open: false
}
})
expect(wrapper.find('.collapse-content').isVisible()).toBe(false)
wrapper.setProps({ open: true })
expect(wrapper.vm.isOpen).toBe(true)
expect(wrapper.find('.collapse-content').isVisible()).toBe(true)
})
it('should have trigger slot', () => {
const triggerSlot = '<strong> Header </strong>'
const wrapper = shallowMount(BCollapse, {
slots: {
trigger: triggerSlot
}
})
expect(wrapper.find('.collapse-trigger :first-child').html()).toBe(triggerSlot)
})
it('should have default slot', () => {
const slotDefault = '<div> Content </div>'
const wrapper = shallowMount(BCollapse, {
slots: {
default: slotDefault
}
})
expect(wrapper.find('.collapse-content :first-child').html()).toBe(slotDefault)
})
it('should emit open event', () => {
const wrapper = shallowMount(BCollapse, {
propsData: {
open: false
}
})
const open = jest.fn()
wrapper.vm.$on('open', open)
wrapper.find('.collapse-trigger').trigger('click')
expect(open).toHaveBeenCalledTimes(1)
})
it('should emit close event', () => {
const wrapper = shallowMount(BCollapse, {
propsData: {
open: true
}
})
const close = jest.fn()
wrapper.vm.$on('close', close)
wrapper.find('.collapse-trigger').trigger('click')
expect(close).toHaveBeenCalledTimes(1)
})
})
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="collapse">
<div class="collapse-trigger" @click="toggle">
<slot name="trigger" :open="isOpen" />
</div>
<transition :name="animation">
<div
:id="ariaId"
:aria-expanded="isOpen"
class="collapse-content"
v-show="isOpen">
<slot/>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'BCollapse',
props: {
open: {
type: Boolean,
default: true
},
animation: {
type: String,
default: 'fade'
},
ariaId: {
type: String,
default: ''
}
},
data() {
return {
isOpen: this.open
}
},
watch: {
open(value) {
this.isOpen = value
}
},
methods: {
/**
* Toggle and emit events
*/
toggle() {
this.isOpen = !this.isOpen
this.$emit('update:open', this.isOpen)
this.$emit(this.isOpen ? 'open' : 'close')
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Collapse from './Collapse'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Collapse)
}
}
use(Plugin)
export default Plugin
export {
Collapse
}
+92
View File
@@ -0,0 +1,92 @@
import {
mount
} from '@vue/test-utils'
import BDatepicker from '@components/datepicker/Datepicker'
const dropdownMenu = '.dropdown-menu'
let wrapper, $dropdown, $input
describe('BDatepicker', () => {
beforeEach(() => {
wrapper = mount(BDatepicker, {
stubs: {
transition: false
}
})
$dropdown = wrapper.find(dropdownMenu)
$input = wrapper.find('input')
})
it('is called', () => {
expect(wrapper.name()).toBe('BDatepicker')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('has an input type', () => {
expect(wrapper.contains('.control .input[type=text]')).toBeTruthy()
})
it('has a dropdown menu hidden by default', () => {
expect(wrapper.contains(dropdownMenu)).toBeTruthy()
expect($dropdown.isVisible()).toBeFalsy()
})
it('has no input type when inline', () => {
wrapper.setProps({
inline: 'true'
})
expect(wrapper.contains('.control .input[type=text]')).toBeFalsy()
})
it('has a dropdown menu visible when inline', () => {
wrapper.setProps({
inline: 'true'
})
expect(wrapper.contains(dropdownMenu)).toBeTruthy()
expect($dropdown.isVisible()).toBeTruthy()
})
it('call toggle method', () => {
wrapper.vm.$refs.dropdown.isActive = false
wrapper.vm.toggle()
expect(wrapper.vm.$refs.dropdown.isActive).toBeTruthy()
expect($dropdown.isVisible()).toBeTruthy()
wrapper.vm.toggle(false)
expect(wrapper.vm.$refs.dropdown.isActive).toBeFalsy()
})
it('can emit input, focus and blur events', () => {
$input.trigger('focus')
expect(wrapper.emitted()['focus']).toBeTruthy()
$input.trigger('blur')
expect(wrapper.emitted()['blur']).toBeTruthy()
})
it('can open datepicker on openOnfocus', () => {
wrapper.setProps({
openOnFocus: true
})
$input.trigger('focus')
expect(wrapper.vm.$refs.dropdown.isActive).toBeTruthy()
expect($dropdown.isVisible()).toBeTruthy()
})
it('can open datepicker on enter', () => {
wrapper.setProps({
openOnFocus: false
})
$input.trigger('focus')
$input.trigger('keyup.enter')
expect(wrapper.vm.$refs.dropdown.isActive).toBeTruthy()
expect($dropdown.isVisible()).toBeTruthy()
})
it('can close datepicker on esc', () => {
wrapper.vm.$refs.dropdown.isActive = true
const keyupEvent = new Event('keyup')
keyupEvent.keyCode = 27
window.document.dispatchEvent(keyupEvent)
wrapper.vm.$nextTick(() => expect($dropdown.isVisible()).toBeFalsy())
})
})
+520
View File
@@ -0,0 +1,520 @@
<template>
<div class="datepicker control" :class="[size, {'is-expanded': expanded}]">
<b-dropdown
v-if="!isMobile || inline"
ref="dropdown"
:position="position"
:disabled="disabled"
:inline="inline">
<b-input
v-if="!inline"
ref="input"
slot="trigger"
autocomplete="off"
:value="formatValue(dateSelected)"
:placeholder="placeholder"
:size="size"
:icon="icon"
:icon-pack="iconPack"
:rounded="rounded"
:loading="loading"
:disabled="disabled"
:readonly="!editable"
v-bind="$attrs"
@click.native.stop="toggle(true)"
@keyup.native.enter="toggle(true)"
@change.native="onChange($event.target.value)"
@focus="handleOnFocus"
@blur="onBlur" />
<b-dropdown-item :disabled="disabled" custom>
<header class="datepicker-header">
<template v-if="$slots.header !== undefined && $slots.header.length">
<slot name="header" />
</template>
<div
v-else
class="pagination field is-centered"
:class="size">
<a
v-show="!isFirstMonth && !disabled"
class="pagination-previous"
role="button"
href="#"
:disabled="disabled"
@click.prevent="decrementMonth"
@keydown.enter.prevent="decrementMonth"
@keydown.space.prevent="decrementMonth">
<b-icon
icon="chevron-left"
:pack="iconPack"
both
type="is-primary is-clickable"/>
</a>
<a
v-show="!isLastMonth && !disabled"
class="pagination-next"
role="button"
href="#"
:disabled="disabled"
@click.prevent="incrementMonth"
@keydown.enter.prevent="incrementMonth"
@keydown.space.prevent="incrementMonth">
<b-icon
icon="chevron-right"
:pack="iconPack"
both
type="is-primary is-clickable"/>
</a>
<div class="pagination-list">
<b-field>
<b-select
v-model="focusedDateData.month"
:disabled="disabled"
:size="size">
<option
v-for="(month, index) in monthNames"
:value="index"
:key="month">
{{ month }}
</option>
</b-select>
<b-select
v-model="focusedDateData.year"
:disabled="disabled"
:size="size">
<option
v-for="year in listOfYears"
:value="year"
:key="year">
{{ year }}
</option>
</b-select>
</b-field>
</div>
</div>
</header>
<div class="datepicker-content">
<b-datepicker-table
v-model="dateSelected"
:day-names="dayNames"
:month-names="monthNames"
:first-day-of-week="firstDayOfWeek"
:min-date="minDate"
:max-date="maxDate"
:focused="focusedDateData"
:disabled="disabled"
:unselectable-dates="unselectableDates"
:unselectable-days-of-week="unselectableDaysOfWeek"
:selectable-dates="selectableDates"
:events="events"
:indicators="indicators"
:date-creator="dateCreator"
@close="toggle(false)"/>
</div>
<footer
v-if="$slots.default !== undefined && $slots.default.length"
class="datepicker-footer">
<slot/>
</footer>
</b-dropdown-item>
</b-dropdown>
<b-input
v-else
ref="input"
type="date"
autocomplete="off"
:value="formatYYYYMMDD(value)"
:placeholder="placeholder"
:size="size"
:icon="icon"
:icon-pack="iconPack"
:loading="loading"
:max="formatYYYYMMDD(maxDate)"
:min="formatYYYYMMDD(minDate)"
:disabled="disabled"
:readonly="false"
v-bind="$attrs"
@change.native="onChangeNativePicker"
@click.native.stop="toggle(true)"
@keyup.native.enter="toggle(true)"
@focus="handleOnFocus"
@blur="onBlur"/>
</div>
</template>
<script>
import FormElementMixin from '../../utils/FormElementMixin'
import { isMobile } from '../../utils/helpers'
import config from '../../utils/config'
import Dropdown from '../dropdown/Dropdown'
import DropdownItem from '../dropdown/DropdownItem'
import Input from '../input/Input'
import Field from '../field/Field'
import Select from '../select/Select'
import Icon from '../icon/Icon'
import DatepickerTable from './DatepickerTable'
export default {
name: 'BDatepicker',
components: {
[DatepickerTable.name]: DatepickerTable,
[Input.name]: Input,
[Field.name]: Field,
[Select.name]: Select,
[Icon.name]: Icon,
[Dropdown.name]: Dropdown,
[DropdownItem.name]: DropdownItem
},
mixins: [FormElementMixin],
inheritAttrs: false,
props: {
value: Date,
dayNames: {
type: Array,
default: () => {
if (Array.isArray(config.defaultDayNames)) {
return config.defaultDayNames
} else {
return [
'Su',
'M',
'Tu',
'W',
'Th',
'F',
'S'
]
}
}
},
monthNames: {
type: Array,
default: () => {
if (Array.isArray(config.defaultMonthNames)) {
return config.defaultMonthNames
} else {
return [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
]
}
}
},
firstDayOfWeek: {
type: Number,
default: () => {
if (typeof config.defaultFirstDayOfWeek === 'number') {
return config.defaultFirstDayOfWeek
} else {
return 0
}
}
},
inline: Boolean,
minDate: Date,
maxDate: Date,
focusedDate: Date,
placeholder: String,
editable: Boolean,
disabled: Boolean,
unselectableDates: Array,
unselectableDaysOfWeek: {
type: Array,
default: () => { return config.defaultUnselectableDaysOfWeek }
},
selectableDates: Array,
dateFormatter: {
type: Function,
default: (date) => {
if (typeof config.defaultDateFormatter === 'function') {
return config.defaultDateFormatter(date)
} else {
const yyyyMMdd = date.getFullYear() +
'/' + (date.getMonth() + 1) +
'/' + date.getDate()
const d = new Date(yyyyMMdd)
return d.toLocaleDateString()
}
}
},
dateParser: {
type: Function,
default: (date) => {
if (typeof config.defaultDateParser === 'function') {
return config.defaultDateParser(date)
} else {
return new Date(Date.parse(date))
}
}
},
dateCreator: {
type: Function,
default: () => {
if (typeof config.defaultDateCreator === 'function') {
return config.defaultDateCreator()
} else {
return new Date()
}
}
},
mobileNative: {
type: Boolean,
default: () => {
return config.defaultDatepickerMobileNative
}
},
position: String,
events: Array,
indicators: {
type: String,
default: 'dots'
},
openOnFocus: Boolean
},
data() {
const focusedDate = this.value || this.focusedDate || this.dateCreator()
return {
dateSelected: this.value,
focusedDateData: {
month: focusedDate.getMonth(),
year: focusedDate.getFullYear()
},
_elementRef: 'input',
_isDatepicker: true
}
},
computed: {
/*
* Returns an array of years for the year dropdown. If earliest/latest
* dates are set by props, range of years will fall within those dates.
*/
listOfYears() {
let latestYear = this.focusedDateData.year + 3
if (this.maxDate && this.maxDate.getFullYear() < latestYear) {
latestYear = this.maxDate.getFullYear()
}
let earliestYear = (latestYear - 100) + 3
if (this.minDate && this.minDate.getFullYear() > earliestYear) {
earliestYear = this.minDate.getFullYear()
}
const arrayOfYears = []
for (let i = earliestYear; i <= latestYear; i++) {
arrayOfYears.push(i)
}
return arrayOfYears.reverse()
},
isFirstMonth() {
if (!this.minDate) return false
const dateToCheck = new Date(this.focusedDateData.year, this.focusedDateData.month)
const date = new Date(this.minDate.getFullYear(), this.minDate.getMonth())
return (dateToCheck <= date)
},
isLastMonth() {
if (!this.maxDate) return false
const dateToCheck = new Date(this.focusedDateData.year, this.focusedDateData.month)
const date = new Date(this.maxDate.getFullYear(), this.maxDate.getMonth())
return (dateToCheck >= date)
},
isMobile() {
return this.mobileNative && isMobile.any()
}
},
watch: {
/*
* Emit input event with selected date as payload, set isActive to false.
* Update internal focusedDateData
*/
dateSelected(value) {
const currentDate = !value ? this.dateCreator() : value
this.focusedDateData = {
month: currentDate.getMonth(),
year: currentDate.getFullYear()
}
this.$emit('input', value)
this.toggle()
},
/**
* When v-model is changed:
* 1. Update internal value.
* 2. If it's invalid, validate again.
*/
value(value) {
this.dateSelected = value
!this.isValid && this.$refs.input.checkHtml5Validity()
},
focusedDate(value) {
if (value) {
this.focusedDateData = {
month: value.getMonth(),
year: value.getFullYear()
}
}
},
/*
* Emit input event on month and/or year change
*/
'focusedDateData.month'(value) {
this.$emit('change-month', value)
},
'focusedDateData.year'(value) {
this.$emit('change-year', value)
}
},
methods: {
/*
* Emit input event with selected date as payload for v-model in parent
*/
updateSelectedDate(date) {
this.dateSelected = date
},
/*
* Parse string into date
*/
onChange(value) {
const date = this.dateParser(value)
if (date && !isNaN(date)) {
this.dateSelected = date
} else {
// Force refresh input value when not valid date
this.dateSelected = null
this.$refs.input.newValue = this.dateSelected
}
},
/*
* Format date into string
*/
formatValue(value) {
if (value && !isNaN(value)) {
return this.dateFormatter(value)
} else {
return null
}
},
/*
* Either decrement month by 1 if not January or decrement year by 1
* and set month to 11 (December)
*/
decrementMonth() {
if (this.disabled) return
if (this.focusedDateData.month > 0) {
this.focusedDateData.month -= 1
} else {
this.focusedDateData.month = 11
this.focusedDateData.year -= 1
}
},
/*
* Either increment month by 1 if not December or increment year by 1
* and set month to 0 (January)
*/
incrementMonth() {
if (this.disabled) return
if (this.focusedDateData.month < 11) {
this.focusedDateData.month += 1
} else {
this.focusedDateData.month = 0
this.focusedDateData.year += 1
}
},
/*
* Format date into string 'YYYY-MM-DD'
*/
formatYYYYMMDD(value) {
const date = new Date(value)
if (value && !isNaN(date)) {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
return year + '-' +
((month < 10 ? '0' : '') + month) + '-' +
((day < 10 ? '0' : '') + day)
}
return ''
},
/*
* Parse date from string
*/
onChangeNativePicker(event) {
const date = event.target.value
this.dateSelected = date ? new Date(date.replace(/-/g, '/')) : null
},
/*
* Toggle datepicker
*/
toggle(active) {
if (this.$refs.dropdown) {
this.$refs.dropdown.isActive = typeof active === 'boolean'
? active
: !this.$refs.dropdown.isActive
}
},
/*
* Call default onFocus method and show datepicker
*/
handleOnFocus() {
this.onFocus()
if (this.openOnFocus) {
this.toggle(true)
}
},
/**
* Keypress event that is bound to the document.
*/
keyPress(event) {
// Esc key
if (this.$refs.dropdown.isActive && event.keyCode === 27) {
this.toggle(false)
}
}
},
created() {
if (typeof window !== 'undefined') {
document.addEventListener('keyup', this.keyPress)
}
},
beforeDestroy() {
if (typeof window !== 'undefined') {
document.removeEventListener('keyup', this.keyPress)
}
}
}
</script>
+47
View File
@@ -0,0 +1,47 @@
import { shallowMount } from '@vue/test-utils'
import BDatepickerTable from '@components/datepicker/DatepickerTable'
import config, {setOptions} from '@utils/config'
describe('BDatepickerTable', () => {
beforeEach(() => {
setOptions(Object.assign(config, {
defaultMonthNames: [
'January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December'
],
defaultDayNames: ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'S'],
focusedDate: new Date('2018-07')
}))
})
it('is called', () => {
const wrapper = shallowMount(BDatepickerTable, {
propsData: {
dayNames: config.defaultDayNames,
monthNames: config.defaultMonthNames,
focused: {
month: config.focusedDate.getMonth(),
year: config.focusedDate.getFullYear()
}
}
})
expect(wrapper.name()).toBe('BDatepickerTable')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('assign events to weeks even if the event has a time', () => {
const wrapper = shallowMount(BDatepickerTable, {
propsData: {
dayNames: config.defaultDayNames,
monthNames: config.defaultMonthNames,
events: [new Date('July 22, 2018 07:22:13'), new Date('July 23, 2018 00:00:00')],
focused: {
month: config.focusedDate.getMonth(),
year: config.focusedDate.getFullYear()
}
}
})
expect(wrapper.vm.eventsInThisWeek(wrapper.vm.weeksInThisMonth[3]).length).toEqual(2)
})
})
+187
View File
@@ -0,0 +1,187 @@
<template>
<section class="datepicker-table">
<header class="datepicker-header">
<div
v-for="(day, index) in visibleDayNames"
:key="index"
class="datepicker-cell">
{{ day }}
</div>
</header>
<div class="datepicker-body" :class="{'has-events':hasEvents}">
<b-datepicker-table-row
v-for="(week, index) in weeksInThisMonth"
:key="index"
:selected-date="value"
:week="week"
:month="focused.month"
:min-date="minDate"
:max-date="maxDate"
:disabled="disabled"
:unselectable-dates="unselectableDates"
:unselectable-days-of-week="unselectableDaysOfWeek"
:selectable-dates="selectableDates"
:events="eventsInThisWeek(week)"
:indicators="indicators"
:date-creator="dateCreator"
@select="updateSelectedDate"/>
</div>
</section>
</template>
<script>
import DatepickerTableRow from './DatepickerTableRow'
export default {
name: 'BDatepickerTable',
components: {
[DatepickerTableRow.name]: DatepickerTableRow
},
props: {
value: Date,
dayNames: Array,
monthNames: Array,
firstDayOfWeek: Number,
events: Array,
indicators: String,
minDate: Date,
maxDate: Date,
focused: Object,
disabled: Boolean,
dateCreator: Function,
unselectableDates: Array,
unselectableDaysOfWeek: Array,
selectableDates: Array
},
computed: {
visibleDayNames() {
const visibleDayNames = []
let index = this.firstDayOfWeek
while (visibleDayNames.length < this.dayNames.length) {
const currentDayName = this.dayNames[(index % this.dayNames.length)]
visibleDayNames.push(currentDayName)
index++
}
return visibleDayNames
},
hasEvents() {
return this.events && this.events.length
},
/*
* Return array of all events in the specified month
*/
eventsInThisMonth() {
if (!this.events) return []
const monthEvents = []
for (let i = 0; i < this.events.length; i++) {
let event = this.events[i]
if (!event.hasOwnProperty('date')) {
event = { date: event }
}
if (!event.hasOwnProperty('type')) {
event.type = 'is-primary'
}
if (
event.date.getMonth() === this.focused.month &&
event.date.getFullYear() === this.focused.year
) {
monthEvents.push(event)
}
}
return monthEvents
},
/*
* Return array of all weeks in the specified month
*/
weeksInThisMonth() {
const month = this.focused.month
const year = this.focused.year
const weeksInThisMonth = []
const daysInThisMonth = new Date(year, month + 1, 0).getDate()
let startingDay = 1
while (startingDay <= daysInThisMonth + 6) {
const newWeek = this.weekBuilder(startingDay, month, year)
let weekValid = false
newWeek.forEach((day) => {
if (day.getMonth() === month) {
weekValid = true
}
})
if (weekValid) {
weeksInThisMonth.push(newWeek)
}
startingDay += 7
}
return weeksInThisMonth
}
},
methods: {
/*
* Emit input event with selected date as payload for v-model in parent
*/
updateSelectedDate(date) {
this.$emit('input', date)
},
/*
* Return array of all days in the week that the startingDate is within
*/
weekBuilder(startingDate, month, year) {
const thisMonth = new Date(year, month)
const thisWeek = []
const dayOfWeek = new Date(year, month, startingDate).getDay()
const end = dayOfWeek >= this.firstDayOfWeek
? (dayOfWeek - this.firstDayOfWeek)
: ((7 - this.firstDayOfWeek) + dayOfWeek)
let daysAgo = 1
for (let i = 0; i < end; i++) {
thisWeek.unshift(new Date(
thisMonth.getFullYear(),
thisMonth.getMonth(),
startingDate - daysAgo)
)
daysAgo++
}
thisWeek.push(new Date(year, month, startingDate))
let daysForward = 1
while (thisWeek.length < 7) {
thisWeek.push(new Date(year, month, startingDate + daysForward))
daysForward++
}
return thisWeek
},
eventsInThisWeek(week) {
return this.eventsInThisMonth.filter((event) => {
const stripped = new Date(Date.parse(event.date))
stripped.setHours(0)
stripped.setMinutes(0)
stripped.setSeconds(0)
stripped.setMilliseconds(0)
const timed = stripped.getTime()
return week.some((weekDate) => weekDate.getTime() === timed)
})
}
}
}
</script>
@@ -0,0 +1,26 @@
import { shallowMount } from '@vue/test-utils'
import BDatepickerTableRow from '@components/datepicker/DatepickerTableRow'
describe('BDatepickerTableRow', () => {
it('is called', () => {
const wrapper = shallowMount(BDatepickerTableRow, {
propsData: {
week: [
new Date('Sun Dec 31 2017 00:00:00 GMT-0200 (-02)'),
new Date('Mon Jan 01 2018 00:00:00 GMT-0200 (-02)'),
new Date('Tue Jan 02 2018 00:00:00 GMT-0200 (-02)'),
new Date('Wed Jan 03 2018 00:00:00 GMT-0200 (-02)'),
new Date('Thu Jan 04 2018 00:00:00 GMT-0200 (-02)'),
new Date('Fri Jan 05 2018 00:00:00 GMT-0200 (-02)'),
new Date('Sat Jan 06 2018 00:00:00 GMT-0200 (-02)')
],
month: 12,
dateCreator: function () {
return new Date()
}
}
})
expect(wrapper.name()).toBe('BDatepickerTableRow')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="datepicker-row">
<template v-for="(day, index) in week">
<a
v-if="selectableDate(day) && !disabled"
:key="index"
:class="[classObject(day), {'has-event':eventsDateMatch(day)}, indicators]"
class="datepicker-cell"
role="button"
href="#"
:disabled="disabled"
@click.prevent="emitChosenDate(day)"
@keydown.enter.prevent="emitChosenDate(day)"
@keydown.space.prevent="emitChosenDate(day)">
{{ day.getDate() }}
<div class="events" v-if="eventsDateMatch(day)">
<div
class="event"
:class="event.type"
v-for="(event, index) in eventsDateMatch(day)"
:key="index"/>
</div>
</a>
<div
v-else
:key="index"
:class="classObject(day)"
class="datepicker-cell">
{{ day.getDate() }}
</div>
</template>
</div>
</template>
<script>
export default {
name: 'BDatepickerTableRow',
props: {
selectedDate: Date,
week: {
type: Array,
required: true
},
month: {
type: Number,
required: true
},
minDate: Date,
maxDate: Date,
disabled: Boolean,
unselectableDates: Array,
unselectableDaysOfWeek: Array,
selectableDates: Array,
events: Array,
indicators: String,
dateCreator: Function
},
methods: {
/*
* Check that selected day is within earliest/latest params and
* is within this month
*/
selectableDate(day) {
const validity = []
if (this.minDate) {
validity.push(day >= this.minDate)
}
if (this.maxDate) {
validity.push(day <= this.maxDate)
}
validity.push(day.getMonth() === this.month)
if (this.selectableDates) {
for (let i = 0; i < this.selectableDates.length; i++) {
const enabledDate = this.selectableDates[i]
if (day.getDate() === enabledDate.getDate() &&
day.getFullYear() === enabledDate.getFullYear() &&
day.getMonth() === enabledDate.getMonth()) {
return true
} else {
validity.push(false)
}
}
}
if (this.unselectableDates) {
for (let i = 0; i < this.unselectableDates.length; i++) {
const disabledDate = this.unselectableDates[i]
validity.push(
day.getDate() !== disabledDate.getDate() ||
day.getFullYear() !== disabledDate.getFullYear() ||
day.getMonth() !== disabledDate.getMonth()
)
}
}
if (this.unselectableDaysOfWeek) {
for (let i = 0; i < this.unselectableDaysOfWeek.length; i++) {
const dayOfWeek = this.unselectableDaysOfWeek[i]
validity.push(day.getDay() !== dayOfWeek)
}
}
return validity.indexOf(false) < 0
},
/*
* Emit select event with chosen date as payload
*/
emitChosenDate(day) {
if (this.disabled) return
if (this.selectableDate(day)) {
this.$emit('select', day)
}
},
eventsDateMatch(day) {
if (!this.events.length) return false
const dayEvents = []
for (let i = 0; i < this.events.length; i++) {
if (this.events[i].date.getDay() === day.getDay()) {
dayEvents.push(this.events[i])
}
}
if (!dayEvents.length) {
return false
}
return dayEvents
},
/*
* Build classObject for cell using validations
*/
classObject(day) {
function dateMatch(dateOne, dateTwo) {
// if either date is null or undefined, return false
if (!dateOne || !dateTwo) {
return false
}
return (dateOne.getDate() === dateTwo.getDate() &&
dateOne.getFullYear() === dateTwo.getFullYear() &&
dateOne.getMonth() === dateTwo.getMonth())
}
return {
'is-selected': dateMatch(day, this.selectedDate),
'is-today': dateMatch(day, this.dateCreator()),
'is-selectable': this.selectableDate(day) && !this.disabled,
'is-unselectable': !this.selectableDate(day) || this.disabled
}
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Datepicker from './Datepicker'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Datepicker)
}
}
use(Plugin)
export default Plugin
export {
Datepicker
}
+12
View File
@@ -0,0 +1,12 @@
import { shallowMount } from '@vue/test-utils'
import BDialog from '@components/dialog/Dialog'
describe('BDialog', () => {
it('is called', () => {
const wrapper = shallowMount(BDialog, {
attachToDocument: true
})
expect(wrapper.name()).toBe('BDialog')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+206
View File
@@ -0,0 +1,206 @@
<template>
<transition :name="animation">
<div
v-if="isActive"
class="dialog modal is-active"
:class="size">
<div class="modal-background" @click="cancel('outside')"/>
<div class="modal-card animation-content">
<header class="modal-card-head" v-if="title">
<p class="modal-card-title">{{ title }}</p>
</header>
<section
class="modal-card-body"
:class="{ 'is-titleless': !title, 'is-flex': hasIcon }">
<div class="media">
<div class="media-left" v-if="hasIcon">
<b-icon
:icon="icon ? icon : iconByType"
:pack="iconPack"
:type="type"
:both="!icon"
size="is-large"/>
</div>
<div class="media-content">
<p v-html="message"/>
<div v-if="hasInput" class="field">
<div class="control">
<input
v-model="prompt"
class="input"
ref="input"
:class="{ 'is-danger': validationMessage }"
v-bind="inputAttrs"
@keyup.enter="confirm">
</div>
<p class="help is-danger">{{ validationMessage }}</p>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button
v-if="showCancel"
class="button"
ref="cancelButton"
@click="cancel('button')">
{{ cancelText }}
</button>
<button
class="button"
:class="type"
ref="confirmButton"
@click="confirm">
{{ confirmText }}
</button>
</footer>
</div>
</div>
</transition>
</template>
<script>
import Icon from '../icon/Icon'
import Modal from '../modal/Modal'
import config from '../../utils/config'
import { removeElement } from '../../utils/helpers'
export default {
name: 'BDialog',
components: {
[Icon.name]: Icon
},
extends: Modal,
props: {
title: String,
message: String,
icon: String,
iconPack: String,
hasIcon: Boolean,
type: {
type: String,
default: 'is-primary'
},
size: String,
confirmText: {
type: String,
default: () => {
return config.defaultDialogConfirmText
? config.defaultDialogConfirmText
: 'OK'
}
},
cancelText: {
type: String,
default: () => {
return config.defaultDialogCancelText
? config.defaultDialogCancelText
: 'Cancel'
}
},
hasInput: Boolean, // Used internally to know if it's prompt
inputAttrs: {
type: Object,
default: () => ({})
},
onConfirm: {
type: Function,
default: () => {}
},
focusOn: {
type: String,
default: 'confirm'
}
},
data() {
const prompt = this.hasInput
? this.inputAttrs.value || ''
: ''
return {
prompt,
isActive: false,
validationMessage: ''
}
},
computed: {
/**
* Icon name (MDI) based on the type.
*/
iconByType() {
switch (this.type) {
case 'is-info':
return 'information'
case 'is-success':
return 'check-circle'
case 'is-warning':
return 'alert'
case 'is-danger':
return 'alert-circle'
default:
return null
}
},
showCancel() {
return this.cancelOptions.indexOf('button') >= 0
}
},
methods: {
/**
* If it's a prompt Dialog, validate the input.
* Call the onConfirm prop (function) and close the Dialog.
*/
confirm() {
if (this.$refs.input !== undefined) {
if (!this.$refs.input.checkValidity()) {
this.validationMessage = this.$refs.input.validationMessage
this.$nextTick(() => this.$refs.input.select())
return
}
}
this.onConfirm(this.prompt)
this.close()
},
/**
* Close the Dialog.
*/
close() {
this.isActive = false
// Timeout for the animation complete before destroying
setTimeout(() => {
this.$destroy()
removeElement(this.$el)
}, 150)
}
},
beforeMount() {
// Insert the Dialog component in body tag
this.$nextTick(() => {
document.body.appendChild(this.$el)
})
},
mounted() {
this.isActive = true
if (typeof this.inputAttrs.required === 'undefined') {
this.$set(this.inputAttrs, 'required', true)
}
this.$nextTick(() => {
// Handle which element receives focus
if (this.hasInput) {
this.$refs.input.focus()
} else if (this.focusOn === 'cancel' && this.showCancel) {
this.$refs.cancelButton.focus()
} else {
this.$refs.confirmButton.focus()
}
})
}
}
</script>
+54
View File
@@ -0,0 +1,54 @@
import Vue from 'vue'
import Dialog from './Dialog'
import { use, registerComponent, registerComponentProgrammatic } from '../../utils/plugins'
function open(propsData) {
const vm = typeof window !== 'undefined' && window.Vue ? window.Vue : Vue
const DialogComponent = vm.extend(Dialog)
return new DialogComponent({
el: document.createElement('div'),
propsData
})
}
const DialogProgrammatic = {
alert(params) {
let message
if (typeof params === 'string') message = params
const defaultParam = {
canCancel: false,
message
}
const propsData = Object.assign(defaultParam, params)
return open(propsData)
},
confirm(params) {
const defaultParam = {}
const propsData = Object.assign(defaultParam, params)
return open(propsData)
},
prompt(params) {
const defaultParam = {
hasInput: true,
confirmText: 'Done'
}
const propsData = Object.assign(defaultParam, params)
return open(propsData)
}
}
const Plugin = {
install(Vue) {
registerComponent(Vue, Dialog)
registerComponentProgrammatic(Vue, '$dialog', DialogProgrammatic)
}
}
use(Plugin)
export default Plugin
export {
DialogProgrammatic as Dialog
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BDropdown from '@components/dropdown/Dropdown'
describe('BDropdown', () => {
it('is called', () => {
const wrapper = shallowMount(BDropdown)
expect(wrapper.name()).toBe('BDropdown')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+189
View File
@@ -0,0 +1,189 @@
<template>
<div class="dropdown" :class="rootClasses">
<div
v-if="!inline"
role="button"
ref="trigger"
class="dropdown-trigger"
@click="toggle"
aria-haspopup="true">
<slot name="trigger"/>
</div>
<transition :name="animation">
<div
v-if="isMobileModal"
v-show="isActive"
class="background"
:aria-hidden="!isActive"
/>
</transition>
<transition :name="animation">
<div
v-show="(!disabled && (isActive || hoverable)) || inline"
ref="dropdownMenu"
class="dropdown-menu"
:aria-hidden="!isActive">
<div
class="dropdown-content"
:role="ariaRoleMenu">
<slot/>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'BDropdown',
props: {
value: {
type: [String, Number, Boolean, Object, Array, Symbol, Function],
default: null
},
disabled: Boolean,
hoverable: Boolean,
inline: Boolean,
position: {
type: String,
validator(value) {
return [
'is-top-right',
'is-top-left',
'is-bottom-left'
].indexOf(value) > -1
}
},
mobileModal: {
type: Boolean,
default: true
},
ariaRole: {
type: String,
default: ''
},
animation: {
type: String,
default: 'fade'
}
},
data() {
return {
selected: this.value,
isActive: false,
_isDropdown: true // Used internally by DropdownItem
}
},
computed: {
rootClasses() {
return [this.position, {
'is-disabled': this.disabled,
'is-hoverable': this.hoverable,
'is-inline': this.inline,
'is-active': this.isActive || this.inline,
'is-mobile-modal': this.isMobileModal
}]
},
isMobileModal() {
return this.mobileModal && !this.inline && !this.hoverable
},
ariaRoleMenu() {
return this.ariaRole === 'menu' || this.ariaRole === 'list' ? this.ariaRole : null
}
},
watch: {
/**
* When v-model is changed set the new selected item.
*/
value(value) {
this.selected = value
},
/**
* Emit event when isActive value is changed.
*/
isActive(value) {
this.$emit('active-change', value)
}
},
methods: {
/**
* Click listener from DropdownItem.
* 1. Set new selected item.
* 2. Emit input event to update the user v-model.
* 3. Close the dropdown.
*/
selectItem(value) {
if (this.selected !== value) {
this.$emit('change', value)
this.selected = value
}
this.$emit('input', value)
this.isActive = false
},
/**
* White-listed items to not close when clicked.
*/
isInWhiteList(el) {
if (el === this.$refs.dropdownMenu) return true
if (el === this.$refs.trigger) return true
// All chidren from dropdown
if (this.$refs.dropdownMenu !== undefined) {
const children = this.$refs.dropdownMenu.querySelectorAll('*')
for (const child of children) {
if (el === child) {
return true
}
}
}
// All children from trigger
if (this.$refs.trigger !== undefined) {
const children = this.$refs.trigger.querySelectorAll('*')
for (const child of children) {
if (el === child) {
return true
}
}
}
return false
},
/**
* Close dropdown if clicked outside.
*/
clickedOutside(event) {
if (this.inline) return
if (!this.isInWhiteList(event.target)) this.isActive = false
},
/**
* Toggle dropdown if it's not disabled.
*/
toggle() {
if (this.disabled || this.hoverable) return
if (!this.isActive) {
// if not active, toggle after clickOutside event
// this fixes toggling programmatic
this.$nextTick(() => { this.isActive = !this.isActive })
} else {
this.isActive = !this.isActive
}
}
},
created() {
if (typeof window !== 'undefined') {
document.addEventListener('click', this.clickedOutside)
}
},
beforeDestroy() {
if (typeof window !== 'undefined') {
document.removeEventListener('click', this.clickedOutside)
}
}
}
</script>
+85
View File
@@ -0,0 +1,85 @@
<template>
<hr v-if="separator" class="dropdown-divider">
<a
v-else-if="!custom && !hasLink"
class="dropdown-item"
:class="anchorClasses"
@click="selectItem"
:role="ariaRoleItem"
tabindex="0">
<slot/>
</a>
<div
v-else
:class="itemClasses"
@click="selectItem"
:role="ariaRoleItem"
tabindex="0">
<slot/>
</div>
</template>
<script>
export default {
name: 'BDropdownItem',
props: {
value: {
type: [String, Number, Boolean, Object, Array, Symbol, Function],
default: null
},
separator: Boolean,
disabled: Boolean,
custom: Boolean,
paddingless: Boolean,
hasLink: Boolean,
ariaRole: {
type: String,
default: ''
}
},
computed: {
anchorClasses() {
return {
'is-disabled': this.$parent.disabled || this.disabled,
'is-paddingless': this.paddingless,
'is-active': this.value !== null && this.value === this.$parent.selected
}
},
itemClasses() {
return {
'dropdown-item': !this.hasLink,
'is-disabled': this.disabled,
'is-paddingless': this.paddingless,
'is-active': this.value !== null && this.value === this.$parent.selected,
'has-link': this.hasLink
}
},
ariaRoleItem() {
return this.ariaRole === 'menuitem' || this.ariaRole === 'listitem' ? this.ariaRole : null
},
/**
* Check if item can be clickable.
*/
isClickable() {
return !this.$parent.disabled && !this.separator && !this.disabled && !this.custom
}
},
methods: {
/**
* Click listener, select the item.
*/
selectItem() {
if (!this.isClickable) return
this.$parent.selectItem(this.value)
this.$emit('click')
}
},
created() {
if (!this.$parent.$data._isDropdown) {
this.$destroy()
throw new Error('You should wrap bDropdownItem on a bDropdown')
}
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
import Dropdown from './Dropdown'
import DropdownItem from './DropdownItem'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Dropdown)
registerComponent(Vue, DropdownItem)
}
}
use(Plugin)
export default Plugin
export {
Dropdown,
DropdownItem
}
+191
View File
@@ -0,0 +1,191 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import BField from '@components/field/Field'
import BFieldBody from '@components/field/FieldBody'
import BInput from '@components/input/Input'
const localVue = createLocalVue()
localVue.component('b-field', BField)
localVue.component('b-field-body', BFieldBody)
localVue.component('b-input', BInput)
describe('BField', () => {
it('is called', () => {
const wrapper = shallowMount(BField)
expect(wrapper.name()).toBe('BField')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('sets fieldLabelSize to "is-normal" when horizontal==true and input elements are inside', () => {
const wrapper = mount(BField, {
localVue,
propsData: { horizontal: true },
slots: { default: [BInput] }
})
expect(wrapper.vm._data).toEqual(expect.objectContaining({fieldLabelSize: 'is-normal'}))
})
describe('class names for the root div.field', () => {
it('contains "is-expanded" when prop "expanded" is set', () => {
const wrapper = shallowMount(BField, { propsData: { expanded: true } })
expect(wrapper.find('.field').classes()).toContain('is-expanded')
})
it('contains "is-grouped-multiline" when prop "groupMultiline" is set', () => {
const wrapper = shallowMount(BField, { propsData: { groupMultiline: true } })
expect(wrapper.find('.field').classes()).toContain('is-grouped-multiline')
})
})
describe('Passing a message prop', () => {
const generateMountOptions = ({message}) => {
return {
propsData: {message},
localVue,
slots: {
default: [BInput, '<button class="button">Button</button>']
}
}
}
it('adds a help <p> element in the root div.field when "message" prop is passed', () => {
const message = 'Some string message'
const mountOptions = generateMountOptions({message})
const wrapper = shallowMount(BField, mountOptions)
expect(wrapper.find('.field').find('p.help').text()).toEqual(message)
})
it('given an array of string as message, it joins the messages with line breaks', () => {
const message = [
'Some string message 1',
'Some string message 2',
'Some string message 3',
'Some string message 4',
'Some string message 5'
]
const mountOptions = generateMountOptions({message})
const wrapper = shallowMount(BField, mountOptions)
expect(wrapper.find('p.help').html()).toContain(message.join(' <br> '))
})
it('given an object as message, it grabs the keys and joins them with line breaks', () => {
const message = {
message1: 'Some string message 1',
message2: 'Some string message 2',
message3: 'Some string message 3',
message4: 'Some string message 4',
message5: 'Some string message 5'
}
const mountOptions = generateMountOptions({message})
const wrapper = shallowMount(BField, mountOptions)
expect(wrapper.find('p.help').html()).toContain(Object.keys(message).join(' <br> '))
})
it(`given an array of string with an object as one of the elements as message, it grabs the
keys of the object and joins them with the messages with line breaks`, () => {
const message = [
'Some string message 1',
{
message2: 'Some string message 2',
message3: 'Some string message 3'
},
'Some string message 4',
'Some string message 5'
]
const mountOptions = generateMountOptions({message})
const wrapper = shallowMount(BField, mountOptions)
expect(wrapper.find('p.help').html()).toContain([
message[0],
'message2',
'message3',
message[2],
message[3]
].join(' <br> '))
})
})
describe('Passing true for grouped prop', () => {
const mountOptions = {
propsData: {
grouped: true
},
localVue,
slots: {
default: [BInput, '<button class="button">Button</button>']
}
}
it('groups the children together', () => {
const wrapper = shallowMount(BField, mountOptions)
expect(wrapper.find('.field').classes()).toContain('is-grouped')
})
it('appends the classname with value of position when "position" prop is passed', () => {
const {propsData} = mountOptions
const wrapper = shallowMount(BField, {
...mountOptions,
propsData: {
...propsData,
position: 'is-centered'
}
})
expect(wrapper.find('.field').classes()).toContain('is-grouped')
expect(wrapper.find('.field').classes()).toContain('is-grouped-centered')
})
it('adds a label element under the root div.field when "label" prop is passed', () => {
const {propsData} = mountOptions
const wrapper = shallowMount(BField, {
...mountOptions,
propsData: {
...propsData,
label: 'Some label'
}
})
expect(wrapper.find('.field').find('label').isVisible()).toBe(true)
})
})
describe('Passing true for horizontal prop', () => {
const generateMountOptions = (props) => {
return {
propsData: {
horizontal: true,
...props
},
localVue,
slots: {
default: BField
}
}
}
it('adds "is-horizontal" to root div.field', () => {
const mountOptions = generateMountOptions()
const wrapper = shallowMount(BField, mountOptions)
expect(wrapper.find('.field').classes()).toContain('is-horizontal')
})
it('produces a div.field-label + .field-body', () => {
const mountOptions = generateMountOptions()
const wrapper = mount(BField, mountOptions)
expect(wrapper.find('.field').find('.field-label').isVisible()).toBe(true)
expect(wrapper.find('.field').find('.field-label + .field-body').isVisible()).toBe(true)
})
it('adds a label element under .field-label when "label" prop is passed', () => {
const mountOptions = generateMountOptions({label: 'Some label'})
const wrapper = mount(BField, mountOptions)
expect(wrapper.find('.field-label').find('label.label').isVisible()).toBe(true)
})
it('adds a help <p> element in the div.field-body when "message" prop is passed', () => {
const message = 'Some message'
const mountOptions = generateMountOptions({message})
const wrapper = mount(BField, mountOptions)
const helpWrapper = wrapper.find('.field').find('.field-body').find('.help')
expect(helpWrapper.isVisible()).toBe(true)
expect(helpWrapper.text()).toEqual(message)
})
})
})
+182
View File
@@ -0,0 +1,182 @@
<template>
<div class="field" :class="[rootClasses, fieldType()]">
<div
v-if="horizontal"
class="field-label"
:class="[customClass, fieldLabelSize]">
<label
v-if="label"
:for="labelFor"
:class="customClass"
class="label" >
{{ label }}
</label>
</div>
<template v-else>
<label
v-if="label"
:for="labelFor"
:class="customClass"
class="label">
{{ label }}
</label>
</template>
<b-field-body
v-if="horizontal"
:message="newMessage ? formattedMessage : ''"
:type="newType">
<slot/>
</b-field-body>
<template v-else>
<slot/>
</template>
<p
v-if="newMessage && !horizontal"
v-html="formattedMessage"
class="help"
:class="newType"
/>
</div>
</template>
<script>
import FieldBody from './FieldBody'
export default {
name: 'BField',
components: {
[FieldBody.name]: FieldBody
},
props: {
type: [String, Object],
label: String,
labelFor: String,
message: [String, Array, Object],
grouped: Boolean,
groupMultiline: Boolean,
position: String,
expanded: Boolean,
horizontal: Boolean,
addons: {
type: Boolean,
default: true
},
customClass: String
},
data() {
return {
newType: this.type,
newMessage: this.message,
fieldLabelSize: null,
_isField: true // Used internally by Input and Select
}
},
computed: {
rootClasses() {
return [this.newPosition, {
'is-expanded': this.expanded,
'is-grouped-multiline': this.groupMultiline,
'is-horizontal': this.horizontal
}]
},
/**
* Correct Bulma class for the side of the addon or group.
*
* This is not kept like the others (is-small, etc.),
* because since 'has-addons' is set automatically it
* doesn't make sense to teach users what addons are exactly.
*/
newPosition() {
if (this.position === undefined) return
const position = this.position.split('-')
if (position.length < 1) return
const prefix = this.grouped
? 'is-grouped-'
: 'has-addons-'
if (this.position) return prefix + position[1]
},
/**
* Formatted message in case it's an array
* (each element is separated by <br> tag)
*/
formattedMessage() {
if (typeof this.newMessage === 'string') {
return this.newMessage
} else {
let messages = []
if (Array.isArray(this.newMessage)) {
this.newMessage.forEach((message) => {
if (typeof message === 'string') {
messages.push(message)
} else {
for (let key in message) {
if (message[key]) {
messages.push(key)
}
}
}
})
} else {
for (let key in this.newMessage) {
if (this.newMessage[key]) {
messages.push(key)
}
}
}
return messages.filter((m) => { if (m) return m }).join(' <br> ')
}
}
},
watch: {
/**
* Set internal type when prop change.
*/
type(value) {
this.newType = value
},
/**
* Set internal message when prop change.
*/
message(value) {
this.newMessage = value
}
},
methods: {
/**
* Field has addons if there are more than one slot
* (element / component) in the Field.
* Or is grouped when prop is set.
* Is a method to be called when component re-render.
*/
fieldType() {
if (this.grouped) return 'is-grouped'
let renderedNode = 0
if (this.$slots.default) {
renderedNode = this.$slots.default.reduce((i, node) => node.tag ? i + 1 : i, 0)
}
if (
renderedNode > 1 &&
this.addons &&
!this.horizontal
) {
return 'has-addons'
}
}
},
mounted() {
if (this.horizontal) {
// Bulma docs: .is-normal for any .input or .button
const elements = this.$el.querySelectorAll('.input, .select, .button, .textarea')
if (elements.length > 0) {
this.fieldLabelSize = 'is-normal'
}
}
}
}
</script>
+72
View File
@@ -0,0 +1,72 @@
import { shallowMount, mount, createLocalVue } from '@vue/test-utils'
import BFieldBody from '@components/field/FieldBody'
import BField from '@components/field/Field'
import BInput from '@components/input/Input'
const localVue = createLocalVue()
localVue.component('b-field', BField)
describe('BFieldBody', () => {
it('is called', () => {
const wrapper = shallowMount(BFieldBody, {
slots: {
default: 'content'
}
})
expect(wrapper.name()).toBe('BFieldBody')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('should render the text when the child is a text', () => {
const text = 'text'
const wrapper = shallowMount(BFieldBody, {
slots: {
default: text
}
})
expect(wrapper.text()).toBe(text)
})
describe('when child is an element', () => {
it('should wrap the element in a b-field', () => {
const wrapper = mount(BFieldBody, {
slots: {
default: BInput
},
localVue
})
expect(wrapper.exists(BField)).toBe(true)
})
it('should pass type prop to the b-field element', () => {
const type = 'is-success'
const wrapper = mount(BFieldBody, {
slots: {
default: BInput
},
propsData: {
type
},
localVue
})
expect(wrapper.find(BField).find('input').classes()).toContain(type)
})
it('should render the message when message prop is passed', () => {
const message = 'some message yo'
const type = 'is-danger'
const wrapper = mount(BFieldBody, {
slots: {
default: BInput
},
propsData: {
type,
message
},
localVue
})
expect(wrapper.find('p.help').classes()).toContain(type)
expect(wrapper.find('p.help').text()).toBe(message)
})
})
})
+25
View File
@@ -0,0 +1,25 @@
<script>
export default {
name: 'BFieldBody',
props: {
message: {
type: String
},
type: {
type: [String, Object]
}
},
render(createElement) {
return createElement('div', { attrs: { 'class': 'field-body' } }, this.$slots.default.map((element) => {
// skip returns and comments
if (!element.tag) {
return element
}
if (this.message) {
return createElement('b-field', { attrs: { message: this.message, 'type': this.type } }, [element])
}
return createElement('b-field', { attrs: { 'type': this.type } }, [element])
}))
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Field from './Field'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Field)
}
}
use(Plugin)
export default Plugin
export {
Field
}
+125
View File
@@ -0,0 +1,125 @@
import { shallowMount } from '@vue/test-utils'
import BIcon from '@components/icon/Icon'
describe('BIcon', () => {
it('render correctly', () => {
const wrapper = shallowMount(BIcon)
expect(wrapper.html()).toMatchSnapshot()
})
it('is vue instance', () => {
const wrapper = shallowMount(BIcon)
expect(wrapper.name()).toBe('BIcon')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('render icon when icon property is passed', () => {
const wrapper = shallowMount(BIcon, {
propsData: { icon: 'eye' }
})
expect(wrapper.classes()).toContain('icon')
expect(wrapper.find('i').classes()).toContain('mdi', 'mdi-eye', 'mdi-24px')
})
it('render a colored icon when type is passed', () => {
const wrapper = shallowMount(BIcon, {
propsData: {
icon: 'eye',
type: 'is-primary'
}
})
expect(wrapper.classes()).toContain('has-text-primary')
})
it('render icon package correctly when the pack property is is passed.', () => {
const wrapper = shallowMount(BIcon, {
propsData: {
icon: 'eye',
pack: 'fa'
}
})
expect(wrapper.find('i').classes()).toContain('fa-eye')
})
it('use both packages when the both property is is passed', () => {
const wrapper = shallowMount(BIcon, {
propsData: {
icon: 'eye',
both: true
}
})
expect(wrapper.find('i').classes()).toContain('mdi-eye')
wrapper.setProps({ pack: 'fa' })
const equivalentIcons = {
'check': 'check',
'information': 'info-circle',
'check-circle': 'check-circle',
'alert': 'exclamation-triangle',
'alert-circle': 'exclamation-circle',
'arrow-up': 'arrow-up',
'chevron-right': 'angle-right',
'chevron-left': 'angle-left',
'chevron-down': 'angle-down',
'eye': 'eye',
'eye-off': 'eye-slash',
'menu-down': 'caret-down',
'menu-up': 'caret-up',
'other': 'other'
}
const expectEquivalentIcon = (icon, expected) => {
wrapper.setProps({ icon })
expect(wrapper.find('i').classes()).toContain(`fa-${expected}`)
}
Object.keys(equivalentIcons).forEach((icon) => {
expectEquivalentIcon(icon, equivalentIcons[icon])
})
})
it('display size when size propery is passed', () => {
const wrapper = shallowMount(BIcon, {
propsData: {
icon: 'eye'
}
})
expect(wrapper.find('i').classes()).toContain('mdi-24px')
wrapper.setProps({ size: 'is-small' })
expect(wrapper.find('i').classes()).toContainEqual('mdi', 'mdi-eye')
wrapper.setProps({ size: 'is-medium' })
expect(wrapper.find('i').classes()).toContain('mdi-36px')
wrapper.setProps({ size: 'is-large' })
expect(wrapper.find('i').classes()).toContain('mdi-48px')
})
it('overrides icon font size when customSize property is passed', () => {
const wrapper = shallowMount(BIcon, {
propsData: {
icon: 'eye',
pack: 'fa',
customSize: 'fa-2x'
}
})
expect(wrapper.find('i').classes()).toContainEqual('fa', 'fa-2x')
})
it('render custom classes when customClass property is passed', () => {
const wrapper = shallowMount(BIcon, {
propsData: {
icon: 'eye',
customClass: 'foo-bar'
}
})
expect(wrapper.find('i').classes()).toContain('foo-bar')
})
})
+122
View File
@@ -0,0 +1,122 @@
<template>
<span class="icon" :class="[newType, size]">
<i
v-if="!useIconComponent"
:class="[newPack, newIcon, newCustomSize, customClass]"/>
<component
v-else
:is="useIconComponent"
:icon="[newPack, newIcon]"
:size="newCustomSize"
:class="[customClass]"/>
</span>
</template>
<script>
import config from '../../utils/config'
export default {
name: 'BIcon',
props: {
type: [String, Object],
pack: String,
icon: String,
size: String,
customSize: String,
customClass: String,
both: Boolean // This is used internally to show both MDI and FA icon
},
computed: {
/**
* Internal icon name based on the pack.
* If pack is 'fa', gets the equivalent FA icon name of the MDI,
* internal icons are always MDI.
*/
newIcon() {
return this.newPack === 'mdi'
? `${this.newPack}-${this.icon}`
: this.addFAPrefix(this.getEquivalentIconOf(this.icon))
},
newPack() {
return this.pack || config.defaultIconPack
},
newType() {
if (!this.type) return
let splitType = []
if (typeof this.type === 'string') {
splitType = this.type.split('-')
} else {
for (let key in this.type) {
if (this.type[key]) {
splitType = key.split('-')
break
}
}
}
if (splitType.length <= 1) return
return `has-text-${splitType[1]}`
},
newCustomSize() {
return this.customSize || this.customSizeByPack
},
customSizeByPack() {
const defaultSize = this.newPack === 'mdi'
? 'mdi-24px'
: this.addFAPrefix('lg')
const mediumSize = this.newPack === 'mdi'
? 'mdi-36px'
: this.addFAPrefix('2x')
const largeSize = this.newPack === 'mdi'
? 'mdi-48px'
: this.addFAPrefix('3x')
switch (this.size) {
case 'is-small': return
case 'is-medium': return mediumSize
case 'is-large': return largeSize
default: return defaultSize
}
},
useIconComponent() {
return config.defaultIconComponent
}
},
methods: {
addFAPrefix(value) {
if (this.useIconComponent) {
return value
}
return `fa-${value}`
},
/**
* Equivalent FA icon name of the MDI.
*/
getEquivalentIconOf(value) {
// Only transform the class if the both prop is set to true
if (!this.both) {
return value
}
switch (value) {
case 'check': return 'check'
case 'information': return 'info-circle'
case 'check-circle': return 'check-circle'
case 'alert': return 'exclamation-triangle'
case 'alert-circle': return 'exclamation-circle'
case 'arrow-up': return 'arrow-up'
case 'chevron-right': return 'angle-right'
case 'chevron-left': return 'angle-left'
case 'chevron-down': return 'angle-down'
case 'eye': return 'eye'
case 'eye-off': return 'eye-slash'
case 'menu-down': return 'caret-down'
case 'menu-up': return 'caret-up'
default: return value
}
}
}
}
</script>
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BIcon render correctly 1`] = `<span class="icon"><i class="mdi mdi-undefined mdi-24px"></i></span>`;
+17
View File
@@ -0,0 +1,17 @@
import Icon from './Icon'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Icon)
}
}
use(Plugin)
export default Plugin
export {
Icon
}
+61
View File
@@ -0,0 +1,61 @@
import Autocomplete from './autocomplete'
import Button from './button'
import Checkbox from './checkbox'
import Collapse from './collapse'
import Clockpicker from './clockpicker'
import Datepicker from './datepicker'
import Dialog from './dialog'
import Dropdown from './dropdown'
import Field from './field'
import Icon from './icon'
import Input from './input'
import Loading from './loading'
import Message from './message'
import Modal from './modal'
import Notification from './notification'
import Numberinput from './numberinput'
import Pagination from './pagination'
import Radio from './radio'
import Select from './select'
import Snackbar from './snackbar'
import Switch from './switch'
import Table from './table'
import Tabs from './tabs'
import Tag from './tag'
import Taginput from './taginput'
import Timepicker from './timepicker'
import Toast from './toast'
import Tooltip from './tooltip'
import Upload from './upload'
export {
Autocomplete,
Button,
Checkbox,
Clockpicker,
Collapse,
Datepicker,
Dialog,
Dropdown,
Field,
Icon,
Input,
Loading,
Message,
Modal,
Notification,
Numberinput,
Pagination,
Radio,
Select,
Snackbar,
Switch,
Table,
Tabs,
Tag,
Taginput,
Timepicker,
Toast,
Tooltip,
Upload
}
+166
View File
@@ -0,0 +1,166 @@
import { shallowMount, mount } from '@vue/test-utils'
import BInput from '@components/input/Input'
import BIcon from '@components/icon/Icon'
describe('BInput', () => {
it('render correctly', () => {
const wrapper = shallowMount(BInput)
expect(wrapper.html()).toMatchSnapshot()
})
it('is vue instance', () => {
const wrapper = shallowMount(BInput)
expect(wrapper.name()).toBe('BInput')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('renders input element by default', () => {
const wrapper = shallowMount(BInput)
expect(wrapper.contains('input')).toBeTruthy()
expect(wrapper.classes()).toContain('control')
})
it('render textarea element when type is textarea', () => {
const wrapper = shallowMount(BInput, {
propsData: { type: 'textarea' }
})
const target = wrapper.find('textarea')
expect(target.exists()).toBeTruthy()
expect(target.classes()).toContain('textarea')
})
it('displays the icon when the icon property is true', () => {
const wrapper = shallowMount(BInput, {
propsData: { icon: 'magnify' }
})
const target = wrapper.find(BIcon)
expect(target.exists()).toBeTruthy()
})
it('display counter when the maxlength property is passed', () => {
const wrapper = shallowMount(BInput, {
propsData: { value: 'foo', maxlength: 100 }
})
const counter = wrapper.find('small.counter')
expect(counter.exists()).toBeTruthy()
expect(counter.text()).toBe('3 / 100')
})
it('no display counter when hasCounter property set for false', () => {
const wrapper = shallowMount(BInput, {
propsData: { maxlength: 100 }
})
expect(wrapper.find('small.counter').exists()).toBeTruthy()
wrapper.setProps({ hasCounter: false })
expect(wrapper.find('small.counter').exists()).toBeFalsy()
})
it('render field password when the type property is password', () => {
const wrapper = shallowMount(BInput, {
propsData: {
type: 'password',
passwordReveal: true
}
})
const target = wrapper.find('input')
expect(target.exists()).toBeTruthy()
expect(target.attributes().type).toBe('password')
})
it('toggles the visibility of the password to true when the togglePasswordVisibility method is called', (done) => {
const wrapper = mount(BInput, {
propsData: {
value: 'foo',
type: 'password',
passwordReveal: true
}
})
wrapper.setProps({ value: 'bar' })
expect(wrapper.find('input').exists()).toBeTruthy()
expect(wrapper.find('input').attributes().type).toBe('password')
const visibilityIcon = wrapper.find('.icon.is-clickable')
expect(visibilityIcon.exists()).toBeTruthy()
visibilityIcon.trigger('click')
wrapper.setProps({ passwordReveal: false })
expect(wrapper.find('input').attributes().type).toBe('text')
wrapper.vm.$nextTick(done)
})
it('render the placeholder and readonly attribute when passed', () => {
const wrapper = shallowMount(BInput, {
attrs: { placeholder: 'Awesome!', readonly: true }
})
const target = wrapper.find('input')
expect(target.element.getAttribute('placeholder')).toBe('Awesome!')
expect(target.element.getAttribute('readonly')).toBe('readonly')
})
it('expands input when expanded property is passed', () => {
const wrapper = shallowMount(BInput, {
propsData: { expanded: true }
})
expect(wrapper.classes()).toContain('is-expanded')
})
it('display loading icon when loading property passed', () => {
const wrapper = shallowMount(BInput, {
propsData: { loading: true, icon: 'magnify' }
})
expect(wrapper.classes()).toContain('is-loading')
})
it('keep its value on blur', async () => {
const wrapper = mount(BInput, {
propsData: {
value: 'foo'
},
methods: {
checkHtml5Validity: () => true
}
})
const input = wrapper.find('input')
input.element.value = 'bar'
input.trigger('input')
input.trigger('blur')
expect(input.element.value).toBe('bar')
})
it('change status icon when statusType updated', () => {
const parent = {
data: () => ({
newType: 'is-success',
_isField: true
}),
components: {BInput},
template: `<b-input />`
}
const wrapper = mount(parent)
const input = wrapper.find(BInput)
expect(input.vm.statusTypeIcon).toBe('check')
wrapper.setData({ newType: 'is-danger' })
expect(input.vm.statusTypeIcon).toBe('alert-circle')
wrapper.setData({ newType: 'is-info' })
expect(input.vm.statusTypeIcon).toBe('information')
wrapper.setData({ newType: 'is-warning' })
expect(input.vm.statusTypeIcon).toBe('alert')
})
})
+214
View File
@@ -0,0 +1,214 @@
<template>
<div class="control" :class="rootClasses">
<input
v-if="type !== 'textarea'"
ref="input"
class="input"
:class="[inputClasses, customClass]"
:type="newType"
:autocomplete="newAutocomplete"
:maxlength="maxlength"
:value="computedValue"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
@focus="onFocus">
<textarea
v-else
ref="textarea"
class="textarea"
:class="[inputClasses, customClass]"
:maxlength="maxlength"
:value="computedValue"
v-bind="$attrs"
@input="onInput"
@blur="onBlur"
@focus="onFocus"/>
<b-icon
v-if="icon"
class="is-left"
:icon="icon"
:pack="iconPack"
:size="iconSize"/>
<b-icon
v-if="!loading && (passwordReveal || statusType)"
class="is-right"
:class="{ 'is-clickable': passwordReveal }"
:icon="passwordReveal ? passwordVisibleIcon : statusTypeIcon"
:pack="iconPack"
:size="iconSize"
:type="!passwordReveal ? statusType : 'is-primary'"
both
@click.native="togglePasswordVisibility"/>
<small
v-if="maxlength && hasCounter && type !== 'number'"
class="help counter"
:class="{ 'is-invisible': !isFocused }">
{{ valueLength }} / {{ maxlength }}
</small>
</div>
</template>
<script>
import Icon from '../icon/Icon'
import config from '../../utils/config'
import FormElementMixin from '../../utils/FormElementMixin'
export default {
name: 'BInput',
components: {
[Icon.name]: Icon
},
mixins: [FormElementMixin],
inheritAttrs: false,
props: {
value: [Number, String],
type: {
type: String,
default: 'text'
},
passwordReveal: Boolean,
hasCounter: {
type: Boolean,
default: () => config.defaultInputHasCounter
},
customClass: {
type: String,
default: ''
}
},
data() {
return {
newValue: this.value,
newType: this.type,
newAutocomplete: this.autocomplete || config.defaultInputAutocomplete,
isPasswordVisible: false,
_elementRef: this.type === 'textarea'
? 'textarea'
: 'input'
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
!this.isValid && this.checkHtml5Validity()
}
},
rootClasses() {
return [
this.iconPosition,
this.size,
{
'is-expanded': this.expanded,
'is-loading': this.loading,
'is-clearfix': !this.hasMessage
}
]
},
inputClasses() {
return [
this.statusType,
this.size,
{ 'is-rounded': this.rounded }
]
},
hasIconRight() {
return this.passwordReveal || this.loading || this.statusType
},
/**
* Position of the icon or if it's both sides.
*/
iconPosition() {
if (this.icon && this.hasIconRight) {
return 'has-icons-left has-icons-right'
} else if (!this.icon && this.hasIconRight) {
return 'has-icons-right'
} else if (this.icon) {
return 'has-icons-left'
}
},
/**
* Icon name (MDI) based on the type.
*/
statusTypeIcon() {
switch (this.statusType) {
case 'is-success': return 'check'
case 'is-danger': return 'alert-circle'
case 'is-info': return 'information'
case 'is-warning': return 'alert'
}
},
/**
* Check if have any message prop from parent if it's a Field.
*/
hasMessage() {
return !!this.statusMessage
},
/**
* Current password-reveal icon name.
*/
passwordVisibleIcon() {
return !this.isPasswordVisible ? 'eye' : 'eye-off'
},
/**
* Get value length
*/
valueLength() {
if (typeof this.computedValue === 'string') {
return this.computedValue.length
} else if (typeof this.computedValue === 'number') {
return this.computedValue.toString().length
}
return 0
}
},
watch: {
/**
* When v-model is changed:
* 1. Set internal value.
*/
value(value) {
this.newValue = value
}
},
methods: {
/**
* Toggle the visibility of a password-reveal input
* by changing the type and focus the input right away.
*/
togglePasswordVisibility() {
this.isPasswordVisible = !this.isPasswordVisible
this.newType = this.isPasswordVisible ? 'text' : 'password'
this.$nextTick(() => {
this.$refs.input.focus()
})
},
/**
* Input's 'input' event listener, 'nextTick' is used to prevent event firing
* before ui update, helps when using masks (Cleavejs and potentially others).
*/
onInput(event) {
this.$nextTick(() => {
if (event.target) {
this.computedValue = event.target.value
}
})
}
}
}
</script>
@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BInput render correctly 1`] = `
<div class="control is-clearfix"><input type="text" autocomplete="on" class="input">
<!---->
<!---->
<!---->
</div>
`;
+17
View File
@@ -0,0 +1,17 @@
import Input from './Input'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Input)
}
}
use(Plugin)
export default Plugin
export {
Input
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BLoading from '@components/loading/Loading'
describe('BLoading', () => {
it('Is called', () => {
const wrapper = shallowMount(BLoading)
expect(wrapper.name()).toBe('BLoading')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+112
View File
@@ -0,0 +1,112 @@
<template>
<transition :name="animation">
<div
class="loading-overlay is-active"
:class="{ 'is-full-page': isFullPage }"
v-if="isActive">
<div class="loading-background" @click="cancel"/>
<slot>
<div class="loading-icon" />
</slot>
</div>
</transition>
</template>
<script>
import { removeElement } from '../../utils/helpers'
import { HTMLElement } from '../../utils/ssr'
export default {
name: 'BLoading',
props: {
active: Boolean,
programmatic: Boolean,
container: [Object, Function, HTMLElement],
isFullPage: {
type: Boolean,
default: true
},
animation: {
type: String,
default: 'fade'
},
canCancel: {
type: Boolean,
default: false
},
onCancel: {
type: Function,
default: () => {}
}
},
data() {
return {
isActive: this.active || false
}
},
watch: {
active(value) {
this.isActive = value
}
},
methods: {
/**
* Close the Modal if canCancel.
*/
cancel() {
if (!this.canCancel || !this.isActive) return
this.close()
},
/**
* Emit events, and destroy modal if it's programmatic.
*/
close() {
this.onCancel.apply(null, arguments)
this.$emit('close')
this.$emit('update:active', false)
// Timeout for the animation complete before destroying
if (this.programmatic) {
this.isActive = false
setTimeout(() => {
this.$destroy()
removeElement(this.$el)
}, 150)
}
},
/**
* Keypress event that is bound to the document.
*/
keyPress(event) {
// Esc key
if (event.keyCode === 27) this.cancel()
}
},
created() {
if (typeof window !== 'undefined') {
document.addEventListener('keyup', this.keyPress)
}
},
beforeMount() {
// Insert the Loading component in body tag
// only if it's programmatic
if (this.programmatic) {
if (!this.container) {
document.body.appendChild(this.$el)
} else {
this.isFullPage = false
this.container.appendChild(this.$el)
}
}
},
mounted() {
if (this.programmatic) this.isActive = true
},
beforeDestroy() {
if (typeof window !== 'undefined') {
document.removeEventListener('keyup', this.keyPress)
}
}
}
</script>
+36
View File
@@ -0,0 +1,36 @@
import Vue from 'vue'
import Loading from './Loading'
import { use, registerComponent, registerComponentProgrammatic } from '../../utils/plugins'
const LoadingProgrammatic = {
open(params) {
const defaultParam = {
programmatic: true
}
const propsData = Object.assign(defaultParam, params)
const vm = typeof window !== 'undefined' && window.Vue ? window.Vue : Vue
const LoadingComponent = vm.extend(Loading)
return new LoadingComponent({
el: document.createElement('div'),
propsData
})
}
}
const Plugin = {
install(Vue) {
registerComponent(Vue, Loading)
registerComponentProgrammatic(Vue, '$loading', LoadingProgrammatic)
}
}
use(Plugin)
export default Plugin
export {
Loading,
LoadingProgrammatic
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BMessage from '@components/message/Message'
describe('BMessage', () => {
it('is called', () => {
const wrapper = shallowMount(BMessage)
expect(wrapper.name()).toBe('BMessage')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+51
View File
@@ -0,0 +1,51 @@
<template>
<transition name="fade">
<article
v-if="isActive"
class="message"
:class="[type, size]">
<header v-if="title" class="message-header">
<p>{{ title }}</p>
<button
v-if="closable"
type="button"
class="delete"
@click="close"
:aria-label="ariaCloseLabel"
/>
</header>
<section class="message-body">
<div class="media">
<div v-if="icon && hasIcon" class="media-left">
<b-icon
:icon="icon"
:pack="iconPack"
:class="type"
both
:size="newIconSize"/>
</div>
<div class="media-content">
<slot/>
</div>
</div>
</section>
</article>
</transition>
</template>
<script>
import MessageMixin from '../../utils/MessageMixin.js'
export default {
name: 'BMessage',
mixins: [MessageMixin],
props: {
ariaCloseLabel: String
},
data() {
return {
newIconSize: this.iconSize || this.size || 'is-large'
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Message from './Message'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Message)
}
}
use(Plugin)
export default Plugin
export {
Message
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BModal from '@components/modal/Modal'
describe('BModal', () => {
it('is called', () => {
const wrapper = shallowMount(BModal)
expect(wrapper.name()).toBe('BModal')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+204
View File
@@ -0,0 +1,204 @@
<template>
<transition :name="animation">
<div v-if="isActive" class="modal is-active">
<div class="modal-background" @click="cancel('outside')"/>
<div
class="animation-content"
:class="{ 'modal-content': !hasModalCard }"
:style="{ maxWidth: newWidth }">
<component
v-if="component"
v-bind="props"
v-on="events"
:is="component"
@close="close"/>
<div
v-else-if="content"
v-html="content"/>
<slot v-else/>
</div>
<button
type="button"
v-if="showX"
class="modal-close is-large"
@click="cancel('x')"/>
</div>
</transition>
</template>
<script>
import { removeElement } from '../../utils/helpers'
import config from '../../utils/config'
export default {
name: 'BModal',
props: {
active: Boolean,
component: [Object, Function],
content: String,
programmatic: Boolean,
props: Object,
events: Object,
width: {
type: [String, Number],
default: 960
},
hasModalCard: Boolean,
animation: {
type: String,
default: 'zoom-out'
},
canCancel: {
type: [Array, Boolean],
default: () => {
return config.defaultModalCanCancel
? config.defaultModalCanCancel
: ['escape', 'x', 'outside', 'button']
}
},
onCancel: {
type: Function,
default: () => {}
},
scroll: {
type: String,
default: () => {
return config.defaultModalScroll
? config.defaultModalScroll
: 'clip'
},
validator: (value) => {
return [
'clip',
'keep'
].indexOf(value) >= 0
}
}
},
data() {
return {
isActive: this.active || false,
savedScrollTop: null,
newWidth: typeof this.width === 'number'
? this.width + 'px'
: this.width
}
},
computed: {
cancelOptions() {
return typeof this.canCancel === 'boolean'
? this.canCancel
? ['escape', 'x', 'outside', 'button']
: []
: this.canCancel
},
showX() {
return this.cancelOptions.indexOf('x') >= 0
}
},
watch: {
active(value) {
this.isActive = value
},
isActive() {
this.handleScroll()
}
},
methods: {
handleScroll() {
if (typeof window === 'undefined') return
if (this.scroll === 'clip') {
if (this.isActive) {
document.documentElement.classList.add('is-clipped')
} else {
document.documentElement.classList.remove('is-clipped')
}
return
}
this.savedScrollTop = !this.savedScrollTop
? document.documentElement.scrollTop
: this.savedScrollTop
if (this.isActive) {
document.body.classList.add('is-noscroll')
} else {
document.body.classList.remove('is-noscroll')
}
if (this.isActive) {
document.body.style.top = `-${this.savedScrollTop}px`
return
}
document.documentElement.scrollTop = this.savedScrollTop
document.body.style.top = null
this.savedScrollTop = null
},
/**
* Close the Modal if canCancel and call the onCancel prop (function).
*/
cancel(method) {
if (this.cancelOptions.indexOf(method) < 0) return
this.onCancel.apply(null, arguments)
this.close()
},
/**
* Call the onCancel prop (function).
* Emit events, and destroy modal if it's programmatic.
*/
close() {
this.$emit('close')
this.$emit('update:active', false)
// Timeout for the animation complete before destroying
if (this.programmatic) {
this.isActive = false
setTimeout(() => {
this.$destroy()
removeElement(this.$el)
}, 150)
}
},
/**
* Keypress event that is bound to the document.
*/
keyPress(event) {
// Esc key
if (this.isActive && event.keyCode === 27) this.cancel('escape')
}
},
created() {
if (typeof window !== 'undefined') {
document.addEventListener('keyup', this.keyPress)
}
},
beforeMount() {
// Insert the Modal component in body tag
// only if it's programmatic
this.programmatic && document.body.appendChild(this.$el)
},
mounted() {
if (this.programmatic) this.isActive = true
else if (this.isActive) this.handleScroll()
},
beforeDestroy() {
if (typeof window !== 'undefined') {
document.removeEventListener('keyup', this.keyPress)
// reset scroll
document.documentElement.classList.remove('is-clipped')
const savedScrollTop = !this.savedScrollTop
? document.documentElement.scrollTop
: this.savedScrollTop
document.body.classList.remove('is-noscroll')
document.documentElement.scrollTop = savedScrollTop
document.body.style.top = null
}
}
}
</script>
+46
View File
@@ -0,0 +1,46 @@
import Vue from 'vue'
import Modal from './Modal'
import { use, registerComponent, registerComponentProgrammatic } from '../../utils/plugins'
const ModalProgrammatic = {
open(params) {
let content
let parent
if (typeof params === 'string') content = params
const defaultParam = {
programmatic: true,
content
}
if (params.parent) {
parent = params.parent
delete params.parent
}
const propsData = Object.assign(defaultParam, params)
const vm = typeof window !== 'undefined' && window.Vue ? window.Vue : Vue
const ModalComponent = vm.extend(Modal)
return new ModalComponent({
parent,
el: document.createElement('div'),
propsData
})
}
}
const Plugin = {
install(Vue) {
registerComponent(Vue, Modal)
registerComponentProgrammatic(Vue, '$modal', ModalProgrammatic)
}
}
use(Plugin)
export default Plugin
export {
Modal,
ModalProgrammatic
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BNotification from '@components/notification/Notification'
describe('BNotification', () => {
it('is called', () => {
const wrapper = shallowMount(BNotification)
expect(wrapper.name()).toBe('BNotification')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+43
View File
@@ -0,0 +1,43 @@
<template>
<transition name="fade">
<article
v-show="isActive"
class="notification"
:class="[type, position]">
<button
v-if="closable"
class="delete"
type="button"
@click="close"
:aria-label="ariaCloseLabel"
/>
<div class="media">
<div v-if="icon && hasIcon" class="media-left">
<b-icon
:icon="icon"
:pack="iconPack"
both
size="is-large"
aria-hidden/>
</div>
<div class="media-content">
<p class="text" v-if="message">{{ message }}</p>
<slot v-else/>
</div>
</div>
</article>
</transition>
</template>
<script>
import MessageMixin from '../../utils/MessageMixin.js'
export default {
name: 'BNotification',
mixins: [MessageMixin],
props: {
position: String,
ariaCloseLabel: String
}
}
</script>
+26
View File
@@ -0,0 +1,26 @@
<template>
<b-notification
v-bind="$options.propsData"
@close="close" />
</template>
<script>
import config from '../../utils/config'
import NoticeMixin from '../../utils/NoticeMixin.js'
export default {
name: 'BNotificationNotice',
mixins: [NoticeMixin],
props: {
indefinite: {
type: Boolean,
default: false
}
},
data() {
return {
newDuration: this.duration || config.detaultNotificationDuration
}
}
}
</script>
+48
View File
@@ -0,0 +1,48 @@
import Vue from 'vue'
import Notification from './Notification'
import NotificationNotice from './NotificationNotice'
import config from '../../utils/config'
import { use, registerComponent, registerComponentProgrammatic } from '../../utils/plugins'
const NotificationProgrammatic = {
open(params) {
let message
let parent
if (typeof params === 'string') message = params
const defaultParam = {
message,
position: config.defaultNotificationPosition || 'is-top-right'
}
if (params.parent) {
parent = params.parent
delete params.parent
}
const propsData = Object.assign(defaultParam, typeof params === 'string' ? {} : params)
const vm = typeof window !== 'undefined' && window.Vue ? window.Vue : Vue
const NotificationNoticeComponent = vm.extend(NotificationNotice)
return new NotificationNoticeComponent({
parent,
el: document.createElement('div'),
propsData
})
}
}
const Plugin = {
install(Vue) {
registerComponent(Vue, Notification)
registerComponentProgrammatic(Vue, '$notification', NotificationProgrammatic)
}
}
use(Plugin)
export default Plugin
export {
Notification,
NotificationProgrammatic
}
+37
View File
@@ -0,0 +1,37 @@
import { shallowMount, mount } from '@vue/test-utils'
import BNumberinput from '@components/numberinput/Numberinput'
describe('BNumberinput', () => {
it('is called', () => {
const wrapper = shallowMount(BNumberinput)
expect(wrapper.name()).toBe('BNumberinput')
expect(wrapper.isVueInstance()).toBeTruthy()
})
it('renders by default', () => {
const wrapper = mount(BNumberinput)
expect(wrapper.contains('input')).toBeTruthy()
expect(wrapper.contains('button')).toBeTruthy()
expect(wrapper.classes()).toContain('is-grouped')
})
it('set controls position', () => {
const wrapper = mount(BNumberinput, {
propsData: {
controlsPosition: 'compact'
}
})
expect(wrapper.contains('input')).toBeTruthy()
expect(wrapper.contains('button')).toBeTruthy()
expect(wrapper.classes()).toContain('has-addons')
})
it('default value is 0', () => {
const wrapper = mount(BNumberinput)
const input = wrapper.find('input')
expect(input.element.value).toBe('0')
})
})
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="b-numberinput field" :class="fieldClasses">
<p class="control">
<button
type="button"
class="button"
:class="buttonClasses"
:disabled="disabled || disabledMin"
@mousedown="onStartLongPress($event, false)"
@mouseup="onStopLongPress(false)"
@mouseleave="onStopLongPress(false)"
@touchstart.prevent="onStartLongPress($event, false)"
@touchend="onStopLongPress(false)"
@touchcancel="onStopLongPress(false)"
@click="onControlClick($event, false)">
<b-icon
icon="minus"
:pack="iconPack"
:size="iconSize"/>
</button>
</p>
<b-input
type="number"
v-model.number="computedValue"
v-bind="$attrs"
:step="newStep"
:max="max"
:min="min"
:size="size"
:disabled="disabled"
:readonly="!editable"
:loading="loading"
:rounded="rounded"
:icon="icon"
:icon-pack="iconPack"
:autocomplete="autocomplete"
:expanded="expanded"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)" />
<p class="control">
<button
type="button"
class="button"
:class="buttonClasses"
:disabled="disabled || disabledMax"
@mousedown="onStartLongPress($event, true)"
@mouseup="onStopLongPress(true)"
@mouseleave="onStopLongPress(true)"
@touchstart.prevent="onStartLongPress($event, true)"
@touchend="onStopLongPress(true)"
@touchcancel="onStopLongPress(true)"
@click="onControlClick($event, true)">
<b-icon
icon="plus"
:pack="iconPack"
:size="iconSize"/>
</button>
</p>
</div>
</template>
<script>
import Icon from '../icon/Icon'
import Input from '../input/Input'
import FormElementMixin from '../../utils/FormElementMixin'
export default {
name: 'BNumberinput',
components: {
[Icon.name]: Icon,
[Input.name]: Input
},
mixins: [FormElementMixin],
inheritAttrs: false,
props: {
value: Number,
min: [Number, String],
max: [Number, String],
step: [Number, String],
disabled: Boolean,
type: {
type: String,
default: 'is-primary'
},
editable: {
type: Boolean,
default: true
},
controlsRounded: {
type: Boolean,
default: false
},
controlsPosition: String
},
data() {
return {
newValue: this.value || parseFloat(this.min) || 0,
newStep: this.step || 1,
_elementRef: 'input'
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
!this.isValid && this.checkHtml5Validity()
}
},
fieldClasses() {
return [
{ 'has-addons': this.controlsPosition === 'compact' },
{ 'is-grouped': this.controlsPosition !== 'compact' }
]
},
buttonClasses() {
return [
this.type,
this.size,
{ 'is-rounded': this.controlsRounded }
]
},
minNumber() {
return typeof this.min === 'string'
? parseFloat(this.min) : this.min
},
maxNumber() {
return typeof this.max === 'string'
? parseFloat(this.max) : this.max
},
stepNumber() {
return typeof this.newStep === 'string'
? parseFloat(this.newStep) : this.newStep
},
disabledMin() {
return (this.computedValue - this.stepNumber) < this.minNumber
},
disabledMax() {
return (this.computedValue + this.stepNumber) > this.maxNumber
},
stepDecimals() {
const step = this.stepNumber.toString()
const index = step.indexOf('.')
if (index >= 0) {
return step.substring(index + 1).length
}
return 0
}
},
watch: {
/**
* When v-model is changed:
* 1. Set internal value.
*/
value(value) {
this.newValue = value
}
},
methods: {
decrement() {
if (typeof this.minNumber === 'undefined' ||
(this.computedValue - this.stepNumber) >= this.minNumber) {
const value = this.computedValue - this.stepNumber
this.computedValue = parseFloat(value.toFixed(this.stepDecimals))
}
},
increment() {
if (typeof this.maxNumber === 'undefined' ||
(this.computedValue + this.stepNumber) <= this.maxNumber) {
const value = this.computedValue + this.stepNumber
this.computedValue = parseFloat(value.toFixed(this.stepDecimals))
}
},
onControlClick(event, inc) {
if (event.detail !== 0) return
if (inc) this.increment()
else this.decrement()
},
onStartLongPress(event, inc) {
if (event.button !== 0 && event.type !== 'touchstart') return
this._$intervalTime = new Date()
clearInterval(this._$intervalRef)
this._$intervalRef = this._$intervalRef = setInterval(() => {
if (inc) this.increment()
else this.decrement()
}, 100)
},
onStopLongPress(inc) {
const d = new Date()
if (d - this._$intervalTime < 100) {
if (inc) this.increment()
else this.decrement()
}
clearInterval(this._$intervalRef)
this._$intervalRef = null
},
onChange(event) {
const value = event.target.value
if (value) {
const number = parseFloat(value)
if (number < this.minNumber) {
this.computedValue = this.minNumber
} else if (number > this.maxNumber) {
this.computedValue = this.maxNumber
}
}
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Numberinput from './Numberinput'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Numberinput)
}
}
use(Plugin)
export default Plugin
export {
Numberinput
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BPagination from '@components/pagination/Pagination'
describe('BPagination', () => {
it('is called', () => {
const wrapper = shallowMount(BPagination)
expect(wrapper.name()).toBe('BPagination')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+262
View File
@@ -0,0 +1,262 @@
<template>
<nav class="pagination" :class="rootClasses">
<a
role="button"
href="#"
class="pagination-previous"
:disabled="!hasPrev"
@click.prevent="prev"
:aria-label="ariaPreviousLabel">
<b-icon
icon="chevron-left"
:pack="iconPack"
both
aria-hidden/>
</a>
<a
role="button"
href="#"
class="pagination-next"
:disabled="!hasNext"
@click.prevent="next"
:aria-label="ariaNextLabel">
<b-icon
icon="chevron-right"
:pack="iconPack"
both
aria-hidden/>
</a>
<ul class="pagination-list" v-if="!simple">
<!--First-->
<li v-if="hasFirst">
<a
role="button"
href="#"
class="pagination-link"
@click.prevent="first"
:aria-label="getAriaPageLabel(1, false)">
1
</a>
</li>
<li v-if="hasFirstEllipsis"><span class="pagination-ellipsis">&hellip;</span></li>
<!--Pages-->
<li v-for="page in pagesInRange" :key="page.number">
<a
role="button"
href="#"
class="pagination-link"
:class="{ 'is-current': page.isCurrent }"
@click.prevent="page.click"
:aria-label="getAriaPageLabel(page.number, page.isCurrent)"
:aria-current="page.isCurrent">
{{ page.number }}
</a>
</li>
<!--Last-->
<li v-if="hasLastEllipsis"><span class="pagination-ellipsis">&hellip;</span></li>
<li v-if="hasLast">
<a
role="button"
href="#"
class="pagination-link"
@click.prevent="last"
:aria-label="getAriaPageLabel(pageCount, false)">
{{ pageCount }}
</a>
</li>
</ul>
<small class="info" v-if="simple">
<template v-if="perPage == 1">
{{ firstItem }} / {{ total }}
</template>
<template v-else>
{{ firstItem }}-{{ Math.min(current * perPage, total) }} / {{ total }}
</template>
</small>
</nav>
</template>
<script>
import Icon from '../icon/Icon'
export default {
name: 'BPagination',
components: {
[Icon.name]: Icon
},
props: {
total: [Number, String],
perPage: {
type: [Number, String],
default: 20
},
current: {
type: [Number, String],
default: 1
},
size: String,
simple: Boolean,
rounded: Boolean,
order: String,
iconPack: String,
ariaNextLabel: String,
ariaPreviousLabel: String,
ariaPageLabel: String,
ariaCurrentLabel: String
},
computed: {
rootClasses() {
return [
this.order,
this.size,
{
'is-simple': this.simple,
'is-rounded': this.rounded
}
]
},
/**
* Total page size (count).
*/
pageCount() {
return Math.ceil(this.total / this.perPage)
},
/**
* First item of the page (count).
*/
firstItem() {
const firstItem = this.current * this.perPage - this.perPage + 1
return firstItem >= 0 ? firstItem : 0
},
/**
* Check if previous button is available.
*/
hasPrev() {
return this.current > 1
},
/**
* Check if first page button should be visible.
*/
hasFirst() {
return this.current >= 3
},
/**
* Check if first ellipsis should be visible.
*/
hasFirstEllipsis() {
return this.current >= 4
},
/**
* Check if last page button should be visible.
*/
hasLast() {
return this.current <= this.pageCount - 2
},
/**
* Check if last ellipsis should be visible.
*/
hasLastEllipsis() {
return this.current < this.pageCount - 2 && this.current <= this.pageCount - 3
},
/**
* Check if next button is available.
*/
hasNext() {
return this.current < this.pageCount
},
/**
* Get near pages, 1 before and 1 after the current.
* Also add the click event to the array.
*/
pagesInRange() {
if (this.simple) return
const left = Math.max(1, this.current - 1)
const right = Math.min(this.current + 1, this.pageCount)
const pages = []
for (let i = left; i <= right; i++) {
pages.push({
number: i,
isCurrent: this.current === i,
click: (event) => {
if (this.current === i) return
this.$emit('change', i)
this.$emit('update:current', i)
// Set focus on element to keep tab order
this.$nextTick(() => event.target.focus())
}
})
}
return pages
}
},
watch: {
/**
* If current page is trying to be greater than page count, set to last.
*/
pageCount(value) {
if (this.current > value) this.last()
}
},
methods: {
/**
* Previous button click listener.
*/
prev() {
if (!this.hasPrev) return
this.$emit('change', this.current - 1)
this.$emit('update:current', this.current - 1)
},
/**
* First button click listener.
*/
first() {
this.$emit('change', 1)
this.$emit('update:current', 1)
},
/**
* Last button click listener.
*/
last() {
this.$emit('change', this.pageCount)
this.$emit('update:current', this.pageCount)
},
/**
* Next button click listener.
*/
next() {
if (!this.hasNext) return
this.$emit('change', this.current + 1)
this.$emit('update:current', this.current + 1)
},
/**
* Get text for aria-label according to page number.
*/
getAriaPageLabel(pageNumber, isCurrent) {
if (this.ariaPageLabel && (!isCurrent || !this.ariaCurrentLabel)) {
return this.ariaPageLabel + ' ' + pageNumber + '.'
} else if (this.ariaPageLabel && isCurrent && this.ariaCurrentLabel) {
return this.ariaCurrentLabel + ', ' + this.ariaPageLabel + ' ' + pageNumber + '.'
}
return null
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Pagination from './Pagination'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Pagination)
}
}
use(Plugin)
export default Plugin
export {
Pagination
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BRadio from '@components/radio/Radio'
describe('BRadio', () => {
it('is called', () => {
const wrapper = shallowMount(BRadio)
expect(wrapper.name()).toBe('BRadio')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+60
View File
@@ -0,0 +1,60 @@
<template>
<label
class="b-radio radio"
ref="label"
:class="[size, { 'is-disabled': disabled }]"
:disabled="disabled"
:tabindex="disabled ? false : 0"
@keydown.prevent.enter.space="$refs.label.click()">
<input
v-model="computedValue"
tabindex="-1"
type="radio"
@click.stop
:disabled="disabled"
:required="required"
:name="name"
:value="nativeValue">
<span class="check" :class="type" />
<span class="control-label"><slot/></span>
</label>
</template>
<script>
export default {
name: 'BRadio',
props: {
value: [String, Number, Boolean, Function, Object, Array, Symbol],
nativeValue: [String, Number, Boolean, Function, Object, Array, Symbol],
type: String,
disabled: Boolean,
required: Boolean,
name: String,
size: String
},
data() {
return {
newValue: this.value
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
}
}
},
watch: {
/**
* When v-model change, set internal value.
*/
value(value) {
this.newValue = value
}
}
}
</script>
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BRadioButton from '@components/radio/RadioButton'
describe('BRadioButton', () => {
it('is called', () => {
const wrapper = shallowMount(BRadioButton)
expect(wrapper.name()).toBe('BRadioButton')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+64
View File
@@ -0,0 +1,64 @@
<template>
<div class="control">
<label
class="b-radio radio button"
ref="label"
:class="[newValue === nativeValue ? type : null, size]"
:disabled="disabled"
:tabindex="disabled ? false : 0"
@keydown.prevent.enter.space="$refs.label.click()">
<slot/>
<input
v-model="computedValue"
tabindex="-1"
type="radio"
@click.stop
:disabled="disabled"
:required="required"
:name="name"
:value="nativeValue">
</label>
</div>
</template>
<script>
export default {
name: 'BRadioButton',
props: {
value: [String, Number, Boolean, Function, Object, Array, Symbol],
nativeValue: [String, Number, Boolean, Function, Object, Array, Symbol],
type: {
type: String,
default: 'is-primary'
},
disabled: Boolean,
required: Boolean,
name: String,
size: String
},
data() {
return {
newValue: this.value
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
}
}
},
watch: {
/**
* When v-model change, set internal value.
*/
value(value) {
this.newValue = value
}
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
import Radio from './Radio'
import RadioButton from './RadioButton'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Radio)
registerComponent(Vue, RadioButton)
}
}
use(Plugin)
export default Plugin
export {
Radio,
RadioButton
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowMount } from '@vue/test-utils'
import BSelect from '@components/select/Select'
describe('BSelect', () => {
it('is called', () => {
const wrapper = shallowMount(BSelect)
expect(wrapper.name()).toBe('BSelect')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+98
View File
@@ -0,0 +1,98 @@
<template>
<div
class="control"
:class="{ 'is-expanded': expanded, 'has-icons-left': icon }">
<span class="select" :class="spanClasses">
<select
v-model="computedValue"
ref="select"
:multiple="multiple"
:size="nativeSize"
v-bind="$attrs"
@blur="$emit('blur', $event) && checkHtml5Validity()"
@focus="$emit('focus', $event)">
<template v-if="placeholder">
<option
v-if="computedValue == null"
:value="null"
disabled
hidden>
{{ placeholder }}
</option>
</template>
<slot/>
</select>
</span>
<b-icon
v-if="icon"
class="is-left"
:icon="icon"
:pack="iconPack"
:size="iconSize"/>
</div>
</template>
<script>
import Icon from '../icon/Icon'
import FormElementMixin from '../../utils/FormElementMixin'
export default {
name: 'BSelect',
components: {
[Icon.name]: Icon
},
mixins: [FormElementMixin],
inheritAttrs: false,
props: {
value: {
type: [String, Number, Boolean, Object, Array, Symbol, Function],
default: null
},
placeholder: String,
multiple: Boolean,
nativeSize: [String, Number]
},
data() {
return {
selected: this.value,
_elementRef: 'select'
}
},
computed: {
computedValue: {
get() {
return this.selected
},
set(value) {
this.selected = value
this.$emit('input', value)
!this.isValid && this.checkHtml5Validity()
}
},
spanClasses() {
return [this.size, this.statusType, {
'is-fullwidth': this.expanded,
'is-loading': this.loading,
'is-multiple': this.multiple,
'is-rounded': this.rounded,
'is-empty': this.selected === null
}]
}
},
watch: {
/**
* When v-model is changed:
* 1. Set the selected option.
* 2. If it's invalid, validate again.
*/
value(value) {
this.selected = value
!this.isValid && this.checkHtml5Validity()
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Select from './Select'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Select)
}
}
use(Plugin)
export default Plugin
export {
Select
}
+14
View File
@@ -0,0 +1,14 @@
import { shallowMount } from '@vue/test-utils'
import BSnackbar from '@components/snackbar/Snackbar'
describe('BSnackbar', () => {
HTMLElement.prototype.insertAdjacentElement = jest.fn()
it('is called', () => {
const wrapper = shallowMount(BSnackbar, {
attachToDocument: true
})
expect(wrapper.name()).toBe('BSnackbar')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+58
View File
@@ -0,0 +1,58 @@
<template>
<transition
:enter-active-class="transition.enter"
:leave-active-class="transition.leave">
<div
v-show="isActive"
class="snackbar"
:class="[type,position]">
<p class="text">{{ message }}</p>
<div
v-if="actionText"
class="action"
@click="action"
:class="type">
<button class="button">{{ actionText }}</button>
</div>
</div>
</transition>
</template>
<script>
import config from '../../utils/config'
import NoticeMixin from '../../utils/NoticeMixin.js'
export default {
name: 'BSnackbar',
mixins: [NoticeMixin],
props: {
actionText: {
type: String,
default: 'OK'
},
onAction: {
type: Function,
default: () => {}
},
indefinite: {
type: Boolean,
default: false
}
},
data() {
return {
newDuration: this.duration || config.defaultSnackbarDuration
}
},
methods: {
/**
* Click listener.
* Call action prop before closing (from Mixin).
*/
action() {
this.onAction()
this.close()
}
}
}
</script>
+46
View File
@@ -0,0 +1,46 @@
import Vue from 'vue'
import Snackbar from './Snackbar'
import config from '../../utils/config'
import { use, registerComponentProgrammatic } from '../../utils/plugins'
const SnackbarProgrammatic = {
open(params) {
let message
let parent
if (typeof params === 'string') message = params
const defaultParam = {
type: 'is-success',
position: config.defaultSnackbarPosition || 'is-bottom-right',
message
}
if (params.parent) {
parent = params.parent
delete params.parent
}
const propsData = Object.assign(defaultParam, params)
const vm = typeof window !== 'undefined' && window.Vue ? window.Vue : Vue
const SnackbarComponent = vm.extend(Snackbar)
return new SnackbarComponent({
parent,
el: document.createElement('div'),
propsData
})
}
}
const Plugin = {
install(Vue) {
registerComponentProgrammatic(Vue, '$snackbar', SnackbarProgrammatic)
}
}
use(Plugin)
export default Plugin
export {
SnackbarProgrammatic as Snackbar
}
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BSwitch from '@components/switch/Switch'
describe('BSwitch', () => {
it('is called', () => {
const wrapper = shallowMount(BSwitch)
expect(wrapper.name()).toBe('BSwitch')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+73
View File
@@ -0,0 +1,73 @@
<template>
<label
class="switch"
:class="[size, { 'is-disabled': disabled }]"
ref="label"
:disabled="disabled"
@keydown.prevent.enter="$refs.label.click()"
@mousedown="isMouseDown = true"
@mouseup="isMouseDown = false"
@mouseout="isMouseDown = false"
@blur="isMouseDown = false">
<input
v-model="computedValue"
type="checkbox"
@click.stop
:disabled="disabled"
:name="name"
:required="required"
:value="nativeValue"
:true-value="trueValue"
:false-value="falseValue">
<span class="check" :class="[{ 'is-elastic': isMouseDown && !disabled }, type]"/>
<span class="control-label"><slot/></span>
</label>
</template>
<script>
export default {
name: 'BSwitch',
props: {
value: [String, Number, Boolean, Function, Object, Array, Symbol],
nativeValue: [String, Number, Boolean, Function, Object, Array, Symbol],
disabled: Boolean,
type: String,
name: String,
required: Boolean,
size: String,
trueValue: {
type: [String, Number, Boolean, Function, Object, Array, Symbol],
default: true
},
falseValue: {
type: [String, Number, Boolean, Function, Object, Array, Symbol],
default: false
}
},
data() {
return {
newValue: this.value,
isMouseDown: false
}
},
computed: {
computedValue: {
get() {
return this.newValue
},
set(value) {
this.newValue = value
this.$emit('input', value)
}
}
},
watch: {
/**
* When v-model change, set internal value.
*/
value(value) {
this.newValue = value
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Switch from './Switch'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Switch)
}
}
use(Plugin)
export default Plugin
export {
Switch
}
+10
View File
@@ -0,0 +1,10 @@
import { mount } from '@vue/test-utils'
import BTable from '@components/table/Table'
describe('BTable', () => {
it('is called', () => {
const wrapper = mount(BTable)
expect(wrapper.name()).toBe('BTable')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+801
View File
@@ -0,0 +1,801 @@
<template>
<div class="b-table" :class="{ 'is-loading': loading }">
<b-table-mobile-sort
v-if="mobileCards && hasSortablenewColumns"
:current-sort-column="currentSortColumn"
:is-asc="isAsc"
:columns="newColumns"
:placeholder="mobileSortPlaceholder"
@sort="(column) => sort(column)"
/>
<div class="table-wrapper">
<table
class="table"
:class="tableClasses"
:tabindex="!focusable ? false : 0"
@keydown.self.prevent.up="pressedArrow(-1)"
@keydown.self.prevent.down="pressedArrow(1)">
<thead v-if="newColumns.length">
<tr>
<th v-if="showDetailRowIcon" width="40px"/>
<th class="checkbox-cell" v-if="checkable">
<b-checkbox
:value="isAllChecked"
:disabled="isAllUncheckable"
@change.native="checkAll"/>
</th>
<th
v-for="(column, index) in visibleColumns"
:key="index"
:class="{
'is-current-sort': currentSortColumn === column,
'is-sortable': column.sortable
}"
:style="{ width: column.width + 'px' }"
@click.stop="sort(column)">
<div
class="th-wrap"
:class="{
'is-numeric': column.numeric,
'is-centered': column.centered
}">
<slot
v-if="$scopedSlots.header"
name="header"
:column="column"
:index="index"
/>
<template v-else>{{ column.label }}</template>
<b-icon
v-show="currentSortColumn === column"
icon="arrow-up"
:pack="iconPack"
both
size="is-small"
:class="{ 'is-desc': !isAsc }"/>
</div>
</th>
</tr>
</thead>
<tbody v-if="visibleData.length">
<template v-for="(row, index) in visibleData">
<tr
:key="customRowKey ? row[customRowKey] : index"
:class="[rowClass(row, index), {
'is-selected': row === selected,
'is-checked': isRowChecked(row),
}]"
@click="selectRow(row)"
@dblclick="$emit('dblclick', row)"
@contextmenu="$emit('contextmenu', row, $event)"
:draggable="draggable"
@dragstart="handleDragStart($event, row, index)"
@drop="handleDrop($event, row, index)"
@dragover="handleDragOver($event, row, index)"
@dragleave="handleDragLeave($event, row, index)">
<td
v-if="showDetailRowIcon"
class="chevron-cell"
>
<a
v-if="hasDetailedVisible(row)"
role="button"
@click.stop="toggleDetails(row)">
<b-icon
icon="chevron-right"
:pack="iconPack"
both
:class="{'is-expanded': isVisibleDetailRow(row)}"/>
</a>
</td>
<td class="checkbox-cell" v-if="checkable">
<b-checkbox
:disabled="!isRowCheckable(row)"
:value="isRowChecked(row)"
@change.native="checkRow(row)"
@click.native.stop
/>
</td>
<slot
v-if="$scopedSlots.default"
:row="row"
:index="index"
/>
<template v-else>
<BTableColumn
v-for="column in newColumns"
v-bind="column"
:key="column.field"
internal>
<span
v-if="column.renderHtml"
v-html="getValueByPath(row, column.field)"
/>
<template v-else>
{{ getValueByPath(row, column.field) }}
</template>
</BTableColumn>
</template>
</tr>
<!-- Do not add `key` here (breaks details) -->
<!-- eslint-disable-next-line -->
<tr
v-if="isActiveDetailRow(row)"
class="detail">
<td :colspan="columnCount">
<div class="detail-container">
<slot
name="detail"
:row="row"
:index="index"/>
</div>
</td>
</tr>
<slot
v-if="isActiveCustomDetailRow(row)"
name="detail"
:row="row"
:index="index"
/>
</template>
</tbody>
<tbody v-else>
<tr class="is-empty">
<td :colspan="columnCount">
<slot name="empty"/>
</td>
</tr>
</tbody>
<tfoot v-if="$slots.footer !== undefined">
<tr class="table-footer">
<slot name="footer" v-if="hasCustomFooterSlot()"/>
<th :colspan="columnCount" v-else>
<slot name="footer"/>
</th>
</tr>
</tfoot>
</table>
</div>
<div v-if="(checkable && hasBottomLeftSlot()) || paginated" class="level">
<div class="level-left">
<slot name="bottom-left"/>
</div>
<div class="level-right">
<div v-if="paginated" class="level-item">
<b-pagination
:icon-pack="iconPack"
:total="newDataTotal"
:per-page="perPage"
:simple="paginationSimple"
:size="paginationSize"
:current="newCurrentPage"
@change="pageChanged"
:aria-next-label="ariaNextLabel"
:aria-previous-label="ariaPreviousLabel"
:aria-page-label="ariaPageLabel"
:aria-current-label="ariaCurrentLabel" />
</div>
</div>
</div>
</div>
</template>
<script>
import { getValueByPath, indexOf } from '../../utils/helpers'
import Checkbox from '../checkbox/Checkbox'
import Icon from '../icon/Icon'
import Pagination from '../pagination/Pagination'
import TableMobileSort from './TableMobileSort'
import TableColumn from './TableColumn'
export default {
name: 'BTable',
components: {
[Checkbox.name]: Checkbox,
[Icon.name]: Icon,
[Pagination.name]: Pagination,
[TableMobileSort.name]: TableMobileSort,
[TableColumn.name]: TableColumn
},
props: {
data: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
bordered: Boolean,
striped: Boolean,
narrowed: Boolean,
hoverable: Boolean,
loading: Boolean,
detailed: Boolean,
checkable: Boolean,
selected: Object,
focusable: Boolean,
customIsChecked: Function,
isRowCheckable: {
type: Function,
default: () => true
},
checkedRows: {
type: Array,
default: () => []
},
mobileCards: {
type: Boolean,
default: true
},
defaultSort: [String, Array],
defaultSortDirection: {
type: String,
default: 'asc'
},
paginated: Boolean,
currentPage: {
type: Number,
default: 1
},
perPage: {
type: [Number, String],
default: 20
},
showDetailIcon: {
type: Boolean,
default: true
},
paginationSimple: Boolean,
paginationSize: String,
backendSorting: Boolean,
rowClass: {
type: Function,
default: () => ''
},
openedDetailed: {
type: Array,
default: () => []
},
hasDetailedVisible: {
type: Function,
default: () => true
},
detailKey: {
type: String,
default: ''
},
customDetailRow: {
type: Boolean,
default: false
},
backendPagination: Boolean,
total: {
type: [Number, String],
default: 0
},
iconPack: String,
mobileSortPlaceholder: String,
customRowKey: String,
draggable: {
type: Boolean,
defualt: false
},
ariaNextLabel: String,
ariaPreviousLabel: String,
ariaPageLabel: String,
ariaCurrentLabel: String
},
data() {
return {
getValueByPath,
newColumns: [...this.columns],
visibleDetailRows: this.openedDetailed,
newData: this.data,
newDataTotal: this.backendPagination ? this.total : this.data.length,
newCheckedRows: [...this.checkedRows],
newCurrentPage: this.currentPage,
currentSortColumn: {},
isAsc: true,
firstTimeSort: true, // Used by first time initSort
_isTable: true // Used by TableColumn
}
},
computed: {
/**
* return if detailed row tabled
* will be with chevron column & icon or not
*/
showDetailRowIcon() {
return this.detailed && this.showDetailIcon
},
tableClasses() {
return {
'is-bordered': this.bordered,
'is-striped': this.striped,
'is-narrow': this.narrowed,
'has-mobile-cards': this.mobileCards,
'is-hoverable': (
(this.hoverable || this.focusable) &&
this.visibleData.length
)
}
},
/**
* Splitted data based on the pagination.
*/
visibleData() {
if (!this.paginated) return this.newData
const currentPage = this.newCurrentPage
const perPage = this.perPage
if (this.newData.length <= perPage) {
return this.newData
} else {
const start = (currentPage - 1) * perPage
const end = parseInt(start, 10) + parseInt(perPage, 10)
return this.newData.slice(start, end)
}
},
visibleColumns() {
if (!this.newColumns) return this.newColumns
return this.newColumns.filter((column) => {
return column.visible || column.visible === undefined
})
},
/**
* Check if all rows in the page are checked.
*/
isAllChecked() {
const validVisibleData = this.visibleData.filter(
(row) => this.isRowCheckable(row))
if (validVisibleData.length === 0) return false
const isAllChecked = validVisibleData.some((currentVisibleRow) => {
return indexOf(this.newCheckedRows, currentVisibleRow, this.customIsChecked) < 0
})
return !isAllChecked
},
/**
* Check if all rows in the page are checkable.
*/
isAllUncheckable() {
const validVisibleData = this.visibleData.filter(
(row) => this.isRowCheckable(row))
return validVisibleData.length === 0
},
/**
* Check if has any sortable column.
*/
hasSortablenewColumns() {
return this.newColumns.some((column) => {
return column.sortable
})
},
/**
* Return total column count based if it's checkable or expanded
*/
columnCount() {
let count = this.newColumns.length
count += this.checkable ? 1 : 0
count += this.detailed ? 1 : 0
return count
}
},
watch: {
/**
* When data prop change:
* 1. Update internal value.
* 2. Reset newColumns (thead), in case it's on a v-for loop.
* 3. Sort again if it's not backend-sort.
* 4. Set new total if it's not backend-paginated.
*/
data(value) {
// Save newColumns before resetting
const newColumns = this.newColumns
this.newColumns = []
this.newData = value
// Prevent table from being headless, data could change and created hook
// on column might not trigger
this.$nextTick(() => {
if (!this.newColumns.length) this.newColumns = newColumns
})
if (!this.backendSorting) {
this.sort(this.currentSortColumn, true)
}
if (!this.backendPagination) {
this.newDataTotal = value.length
}
},
/**
* When Pagination total change, update internal total
* only if it's backend-paginated.
*/
total(newTotal) {
if (!this.backendPagination) return
this.newDataTotal = newTotal
},
/**
* When checkedRows prop change, update internal value without
* mutating original data.
*/
checkedRows(rows) {
this.newCheckedRows = [...rows]
},
columns(value) {
this.newColumns = [...value]
},
newColumns(value) {
this.checkSort()
},
/**
* When the user wants to control the detailed rows via props.
* Or wants to open the details of certain row with the router for example.
*/
openedDetailed(expandedRows) {
this.visibleDetailRows = expandedRows
},
currentPage(newVal) {
this.newCurrentPage = newVal
}
},
methods: {
/**
* Sort an array by key without mutating original data.
* Call the user sort function if it was passed.
*/
sortBy(array, key, fn, isAsc) {
let sorted = []
// Sorting without mutating original data
if (fn && typeof fn === 'function') {
sorted = [...array].sort((a, b) => fn(a, b, isAsc))
} else {
sorted = [...array].sort((a, b) => {
// Get nested values from objects
let newA = getValueByPath(a, key)
let newB = getValueByPath(b, key)
// sort boolean type
if (typeof newA === 'boolean' && typeof newB === 'boolean') {
return isAsc ? newA - newB : newB - newA
}
if (!newA && newA !== 0) return 1
if (!newB && newB !== 0) return -1
if (newA === newB) return 0
newA = (typeof newA === 'string')
? newA.toUpperCase()
: newA
newB = (typeof newB === 'string')
? newB.toUpperCase()
: newB
return isAsc
? newA > newB ? 1 : -1
: newA > newB ? -1 : 1
})
}
return sorted
},
/**
* Sort the column.
* Toggle current direction on column if it's sortable
* and not just updating the prop.
*/
sort(column, updatingData = false) {
if (!column || !column.sortable) return
if (!updatingData) {
this.isAsc = column === this.currentSortColumn
? !this.isAsc
: (this.defaultSortDirection.toLowerCase() !== 'desc')
}
if (!this.firstTimeSort) {
this.$emit('sort', column.field, this.isAsc ? 'asc' : 'desc')
}
if (!this.backendSorting) {
this.newData = this.sortBy(
this.newData,
column.field,
column.customSort,
this.isAsc
)
}
this.currentSortColumn = column
},
/**
* Check if the row is checked (is added to the array).
*/
isRowChecked(row) {
return indexOf(this.newCheckedRows, row, this.customIsChecked) >= 0
},
/**
* Remove a checked row from the array.
*/
removeCheckedRow(row) {
const index = indexOf(this.newCheckedRows, row, this.customIsChecked)
if (index >= 0) {
this.newCheckedRows.splice(index, 1)
}
},
/**
* Header checkbox click listener.
* Add or remove all rows in current page.
*/
checkAll() {
const isAllChecked = this.isAllChecked
this.visibleData.forEach((currentRow) => {
this.removeCheckedRow(currentRow)
if (!isAllChecked) {
if (this.isRowCheckable(currentRow)) {
this.newCheckedRows.push(currentRow)
}
}
})
this.$emit('check', this.newCheckedRows)
this.$emit('check-all', this.newCheckedRows)
// Emit checked rows to update user variable
this.$emit('update:checkedRows', this.newCheckedRows)
},
/**
* Row checkbox click listener.
* Add or remove a single row.
*/
checkRow(row) {
if (!this.isRowChecked(row)) {
this.newCheckedRows.push(row)
} else {
this.removeCheckedRow(row)
}
this.$emit('check', this.newCheckedRows, row)
// Emit checked rows to update user variable
this.$emit('update:checkedRows', this.newCheckedRows)
},
/**
* Row click listener.
* Emit all necessary events.
*/
selectRow(row, index) {
this.$emit('click', row)
if (this.selected === row) return
// Emit new and old row
this.$emit('select', row, this.selected)
// Emit new row to update user variable
this.$emit('update:selected', row)
},
/**
* Paginator change listener.
*/
pageChanged(page) {
this.newCurrentPage = page > 0 ? page : 1
this.$emit('page-change', this.newCurrentPage)
this.$emit('update:currentPage', this.newCurrentPage)
},
/**
* Toggle to show/hide details slot
*/
toggleDetails(obj) {
const found = this.isVisibleDetailRow(obj)
if (found) {
this.closeDetailRow(obj)
this.$emit('details-close', obj)
} else {
this.openDetailRow(obj)
this.$emit('details-open', obj)
}
// Syncs the detailed rows with the parent component
this.$emit('update:openedDetailed', this.visibleDetailRows)
},
openDetailRow(obj) {
const index = this.handleDetailKey(obj)
this.visibleDetailRows.push(index)
},
closeDetailRow(obj) {
const index = this.handleDetailKey(obj)
const i = this.visibleDetailRows.indexOf(index)
this.visibleDetailRows.splice(i, 1)
},
isVisibleDetailRow(obj) {
const index = this.handleDetailKey(obj)
const result = this.visibleDetailRows.indexOf(index) >= 0
return result
},
isActiveDetailRow(row) {
return this.detailed && !this.customDetailRow && this.isVisibleDetailRow(row)
},
isActiveCustomDetailRow(row) {
return this.detailed && this.customDetailRow && this.isVisibleDetailRow(row)
},
/**
* When the detailKey is defined we use the object[detailKey] as index.
* If not, use the object reference by default.
*/
handleDetailKey(index) {
const key = this.detailKey
return !key.length
? index
: index[key]
},
checkPredefinedDetailedRows() {
const defaultExpandedRowsDefined = this.openedDetailed.length > 0
if (defaultExpandedRowsDefined && !this.detailKey.length) {
throw new Error('If you set a predefined opened-detailed, you must provide a unique key using the prop "detail-key"')
}
},
/**
* Call initSort only first time (For example async data).
*/
checkSort() {
if (this.newColumns.length && this.firstTimeSort) {
this.initSort()
this.firstTimeSort = false
} else if (this.newColumns.length) {
if (this.currentSortColumn.field) {
for (let i = 0; i < this.newColumns.length; i++) {
if (this.newColumns[i].field === this.currentSortColumn.field) {
this.currentSortColumn = this.newColumns[i]
break
}
}
}
}
},
/**
* Check if footer slot has custom content.
*/
hasCustomFooterSlot() {
if (this.$slots.footer.length > 1) return true
const tag = this.$slots.footer[0].tag
if (tag !== 'th' && tag !== 'td') return false
return true
},
/**
* Check if bottom-left slot exists.
*/
hasBottomLeftSlot() {
return typeof this.$slots['bottom-left'] !== 'undefined'
},
/**
* Table arrow keys listener, change selection.
*/
pressedArrow(pos) {
if (!this.visibleData.length) return
let index = this.visibleData.indexOf(this.selected) + pos
// Prevent from going up from first and down from last
index = index < 0
? 0
: index > this.visibleData.length - 1
? this.visibleData.length - 1
: index
this.selectRow(this.visibleData[index])
},
/**
* Focus table element if has selected prop.
*/
focus() {
if (!this.focusable) return
this.$el.querySelector('table').focus()
},
/**
* Initial sorted column based on the default-sort prop.
*/
initSort() {
if (!this.defaultSort) return
let sortField = ''
let sortDirection = this.defaultSortDirection
if (Array.isArray(this.defaultSort)) {
sortField = this.defaultSort[0]
if (this.defaultSort[1]) {
sortDirection = this.defaultSort[1]
}
} else {
sortField = this.defaultSort
}
this.newColumns.forEach((column) => {
if (column.field === sortField) {
this.isAsc = sortDirection.toLowerCase() !== 'desc'
this.sort(column, true)
}
})
},
/**
* Emits drag start event
*/
handleDragStart(event, row, index) {
this.$emit('dragstart', {event, row, index})
},
/**
* Emits drop event
*/
handleDrop(event, row, index) {
this.$emit('drop', {event, row, index})
},
/**
* Emits drag over event
*/
handleDragOver(event, row, index) {
this.$emit('dragover', {event, row, index})
},
/**
* Emits drag leave event
*/
handleDragLeave(event, row, index) {
this.$emit('dragleave', {event, row, index})
}
},
mounted() {
this.checkPredefinedDetailedRows()
this.checkSort()
}
}
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<td
v-if="visible"
:class="rootClasses"
:data-label="label">
<span><slot/></span>
</td>
</template>
<script>
export default {
name: 'BTableColumn',
props: {
label: String,
customKey: [String, Number],
field: String,
meta: [String, Number, Boolean, Function, Object, Array, Symbol],
width: [Number, String],
numeric: Boolean,
centered: Boolean,
sortable: Boolean,
visible: {
type: Boolean,
default: true
},
customSort: Function,
internal: Boolean // Used internally by Table
},
data() {
return {
newKey: this.customKey || this.label
}
},
computed: {
rootClasses() {
return {
'has-text-right': this.numeric && !this.centered,
'has-text-centered': this.centered
}
}
},
methods: {
addRefToTable() {
if (!this.$parent.$data._isTable) {
this.$destroy()
throw new Error('You should wrap bTableColumn on a bTable')
}
if (this.internal) return
// Since we're using scoped prop the columns gonna be multiplied,
// this finds when to stop based on the newKey property.
const repeated = this.$parent.newColumns.some(
(column) => column.newKey === this.newKey)
!repeated && this.$parent.newColumns.push(this)
}
},
beforeMount() {
this.addRefToTable()
},
beforeUpdate() {
this.addRefToTable()
},
beforeDestroy() {
const index = this.$parent.newColumns.map(
(column) => column.newKey).indexOf(this.newKey)
if (index >= 0) {
this.$parent.newColumns.splice(index, 1)
}
}
}
</script>
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BTableMobileSort from '@components/table/TableMobileSort'
describe('BTableMobileSort', () => {
it('is called', () => {
const wrapper = shallowMount(BTableMobileSort)
expect(wrapper.name()).toBe('BTableMobileSort')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="field table-mobile-sort">
<div class="field has-addons">
<b-select v-model="mobileSort" expanded>
<template v-if="placeholder">
<option
v-show="showPlaceholder"
:value="{}"
selected
disabled
hidden>
{{ placeholder }}
</option>
</template>
<option
v-for="(column, index) in columns"
v-if="column.sortable"
:key="index"
:value="column">
{{ column.label }}
</option>
</b-select>
<div class="control">
<button
class="button is-primary"
@click="sort">
<b-icon
v-show="currentSortColumn === mobileSort"
:class="{ 'is-desc': !isAsc }"
icon="arrow-up"
size="is-small"
both
/>
</button>
</div>
</div>
</div>
</template>
<script>
import Select from '../select/Select'
import Icon from '../icon/Icon'
export default {
name: 'BTableMobileSort',
components: {
[Select.name]: Select,
[Icon.name]: Icon
},
props: {
currentSortColumn: Object,
isAsc: Boolean,
columns: Array,
placeholder: String
},
data() {
return {
mobileSort: this.currentSortColumn
}
},
computed: {
showPlaceholder() {
return !this.columns || !this.columns.some((column) => column === this.mobileSort)
}
},
watch: {
mobileSort(column) {
if (this.currentSortColumn === column) return
this.$emit('sort', column)
},
currentSortColumn(column) {
this.mobileSort = column
}
},
methods: {
sort() {
this.$emit('sort', this.mobileSort)
}
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
import Table from './Table'
import TableColumn from './TableColumn'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Table)
registerComponent(Vue, TableColumn)
}
}
use(Plugin)
export default Plugin
export {
Table,
TableColumn
}
+83
View File
@@ -0,0 +1,83 @@
<script>
export default {
name: 'BTabItem',
props: {
label: String,
icon: String,
iconPack: String,
disabled: Boolean,
visible: {
type: Boolean,
default: true
}
},
data() {
return {
isActive: false,
transitionName: null
}
},
methods: {
/**
* Activate tab, alter animation name based on the index.
*/
activate(oldIndex, index) {
this.transitionName = index < oldIndex
? 'slide-next'
: 'slide-prev'
this.isActive = true
},
/**
* Deactivate tab, alter animation name based on the index.
*/
deactivate(oldIndex, index) {
this.transitionName = index < oldIndex
? 'slide-next'
: 'slide-prev'
this.isActive = false
}
},
created() {
if (!this.$parent.$data._isTabs) {
this.$destroy()
throw new Error('You should wrap bTabItem on a bTabs')
}
this.$parent.tabItems.push(this)
},
beforeDestroy() {
const index = this.$parent.tabItems.indexOf(this)
if (index >= 0) {
this.$parent.tabItems.splice(index, 1)
}
},
render(createElement) {
// if destroy apply v-if
if (this.$parent.destroyOnHide) {
if (!this.isActive || !this.visible) {
return
}
}
const vnode = createElement('div', {
directives: [{
name: 'show',
value: this.isActive && this.visible
}],
attrs: { 'class': 'tab-item' }
}, this.$slots.default)
// check animated prop
if (this.$parent.animated) {
return createElement('transition', {
props: {
'name': this.transitionName
},
on: {
'before-enter': () => { this.$parent.isTransitioning = true },
'after-enter': () => { this.$parent.isTransitioning = false }
}
}, [vnode])
}
return vnode
}
}
</script>
+10
View File
@@ -0,0 +1,10 @@
import { mount } from '@vue/test-utils'
import BTabs from '@components/tabs/Tabs'
describe('BTabs', () => {
it('is called', () => {
const wrapper = mount(BTabs)
expect(wrapper.name()).toBe('BTabs')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="b-tabs" :class="{ 'is-fullwidth': expanded }">
<nav class="tabs" :class="navClasses">
<ul>
<li
v-for="(tabItem, index) in tabItems"
:key="index"
v-show="tabItem.visible"
:class="{ 'is-active': activeTab === index, 'is-disabled': tabItem.disabled }">
<a @click="tabClick(index)">
<template v-if="tabItem.$slots.header">
<b-slot-component
:component="tabItem"
name="header"
tag="span" />
</template>
<template v-else>
<b-icon
v-if="tabItem.icon"
:icon="tabItem.icon"
:pack="tabItem.iconPack"
:size="size"/>
<span>{{ tabItem.label }}</span>
</template>
</a>
</li>
</ul>
</nav>
<section class="tab-content" :class="{'is-transitioning': isTransitioning}">
<slot/>
</section>
</div>
</template>
<script>
import Icon from '../icon/Icon'
import SlotComponent from '../../utils/SlotComponent'
export default {
name: 'BTabs',
components: {
[Icon.name]: Icon,
[SlotComponent.name]: SlotComponent
},
props: {
value: Number,
expanded: Boolean,
type: String,
size: String,
position: String,
animated: {
type: Boolean,
default: true
},
destroyOnHide: {
type: Boolean,
default: false
}
},
data() {
return {
activeTab: this.value || 0,
tabItems: [],
contentHeight: 0,
isTransitioning: false,
_isTabs: true // Used internally by TabItem
}
},
computed: {
navClasses() {
return [
this.type,
this.size,
this.position,
{
'is-fullwidth': this.expanded,
'is-toggle-rounded is-toggle': this.type === 'is-toggle-rounded'
}
]
}
},
watch: {
/**
* When v-model is changed set the new active tab.
*/
value(value) {
this.changeTab(value)
},
/**
* When tab-items are updated, set active one.
*/
tabItems() {
if (this.activeTab < this.tabItems.length) {
this.tabItems[this.activeTab].isActive = true
}
}
},
methods: {
/**
* Change the active tab and emit change event.
*/
changeTab(newIndex) {
if (this.activeTab === newIndex) return
if (this.activeTab < this.tabItems.length) {
this.tabItems[this.activeTab].deactivate(this.activeTab, newIndex)
}
this.tabItems[newIndex].activate(this.activeTab, newIndex)
this.activeTab = newIndex
this.$emit('change', newIndex)
},
/**
* Tab click listener, emit input event and change active tab.
*/
tabClick(value) {
this.$emit('input', value)
this.changeTab(value)
}
},
mounted() {
if (this.activeTab < this.tabItems.length) {
this.tabItems[this.activeTab].isActive = true
}
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
import Tabs from './Tabs'
import TabItem from './TabItem'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Tabs)
registerComponent(Vue, TabItem)
}
}
use(Plugin)
export default Plugin
export {
Tabs,
TabItem
}
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BTag from '@components/tag/Tag'
describe('BTag', () => {
it('is called', () => {
const wrapper = shallowMount(BTag)
expect(wrapper.name()).toBe('BTag')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+68
View File
@@ -0,0 +1,68 @@
<template>
<div v-if="attached && closable" class="tags has-addons">
<span
class="tag"
:class="[type, size, { 'is-rounded': rounded }]">
<span :class="{ 'has-ellipsis': ellipsis }">
<slot/>
</span>
</span>
<a
class="tag is-delete"
role="button"
:tabindex="tabstop ? 0 : false"
:disabled="disabled"
:class="[size, { 'is-rounded': rounded }]"
@click="close()"
@keyup.delete.prevent="close()"
/>
</div>
<span
v-else
class="tag"
:class="[type, size, { 'is-rounded': rounded }]">
<span :class="{ 'has-ellipsis': ellipsis }">
<slot/>
</span>
<a
v-if="closable"
role="button"
class="delete is-small"
:disabled="disabled"
:tabindex="tabstop ? 0 : false"
@click="close()"
@keyup.delete.prevent="close()"
/>
</span>
</template>
<script>
export default {
name: 'BTag',
props: {
attached: Boolean,
closable: Boolean,
type: String,
size: String,
rounded: Boolean,
disabled: Boolean,
ellipsis: Boolean,
tabstop: {
type: Boolean,
default: true
}
},
methods: {
/**
* Emit close event when delete button is clicked
* or delete key is pressed.
*/
close() {
if (this.disabled) return
this.$emit('close')
}
}
}
</script>
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BTaglist from '@components/tag/Taglist'
describe('BTaglist', () => {
it('is called', () => {
const wrapper = shallowMount(BTaglist)
expect(wrapper.name()).toBe('BTaglist')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="tags" :class="{ 'has-addons': attached }">
<slot/>
</div>
</template>
<script>
export default {
name: 'BTaglist',
props: {
attached: Boolean
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
import Tag from './Tag'
import Taglist from './Taglist'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Tag)
registerComponent(Vue, Taglist)
}
}
use(Plugin)
export default Plugin
export {
Tag,
Taglist
}
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BTaginput from '@components/taginput/Taginput'
describe('BTaginput', () => {
it('is called', () => {
const wrapper = shallowMount(BTaginput)
expect(wrapper.name()).toBe('BTaginput')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+310
View File
@@ -0,0 +1,310 @@
<template>
<div class="taginput control" :class="rootClasses">
<div
class="taginput-container"
:class="[statusType, size, containerClasses]"
:disabled="disabled"
@click="hasInput && focus($event)">
<b-tag
v-for="(tag, index) in tags"
:key="index"
:type="type"
:size="size"
:rounded="rounded"
:attached="attached"
:tabstop="false"
:disabled="disabled"
:ellipsis="ellipsis"
:closable="closable"
@close="removeTag(index)">
{{ getNormalizedTagText(tag) }}
</b-tag>
<b-autocomplete
ref="autocomplete"
v-if="hasInput"
v-model="newTag"
v-bind="$attrs"
:data="data"
:field="field"
:icon="icon"
:icon-pack="iconPack"
:maxlength="maxlength"
:has-counter="false"
:size="size"
:disabled="disabled"
:loading="loading"
:autocomplete="nativeAutocomplete"
:keep-first="!allowNew"
@typing="onTyping"
@focus="onFocus"
@blur="customOnBlur"
@keydown.native="keydown"
@select="onSelect">
<template :slot="headerSlotName">
<slot name="header" />
</template>
<template
:slot="defaultSlotName"
slot-scope="props">
<slot
:option="props.option"
:index="props.index" />
</template>
<template :slot="emptySlotName">
<slot name="empty" />
</template>
</b-autocomplete>
</div>
<p v-if="maxtags || maxlength" class="help counter">
<template v-if="maxlength && valueLength > 0">
{{ valueLength }} / {{ maxlength }}
</template>
<template v-else-if="maxtags">
{{ tagsLength }} / {{ maxtags }}
</template>
</p>
</div>
</template>
<script>
import { getValueByPath } from '../../utils/helpers'
import Tag from '../tag/Tag'
import Autocomplete from '../autocomplete/Autocomplete'
import FormElementMixin from '../../utils/FormElementMixin'
export default {
name: 'BTaginput',
components: {
[Autocomplete.name]: Autocomplete,
[Tag.name]: Tag
},
mixins: [FormElementMixin],
inheritAttrs: false,
props: {
value: {
type: Array,
default: () => []
},
data: {
type: Array,
default: () => []
},
type: String,
rounded: {
type: Boolean,
default: false
},
attached: {
type: Boolean,
default: false
},
maxtags: {
type: [Number, String],
required: false
},
field: {
type: String,
default: 'value'
},
autocomplete: Boolean,
nativeAutocomplete: String,
disabled: Boolean,
ellipsis: Boolean,
closable: {
type: Boolean,
default: true
},
confirmKeyCodes: {
type: Array,
default: () => [13, 188]
},
removeOnKeys: {
type: Array,
default: () => [8]
},
allowNew: Boolean,
onPasteSeparators: {
type: Array,
default: () => [',']
},
beforeAdding: {
type: Function,
default: () => true
},
allowDuplicates: {
type: Boolean,
default: false
}
},
data() {
return {
tags: this.value || [],
newTag: '',
_elementRef: 'input',
_isTaginput: true
}
},
computed: {
rootClasses() {
return {
'is-expanded': this.expanded
}
},
containerClasses() {
return {
'is-focused': this.isFocused,
'is-focusable': this.hasInput
}
},
valueLength() {
return this.newTag.trim().length
},
defaultSlotName() {
return this.hasDefaultSlot ? 'default' : 'dontrender'
},
emptySlotName() {
return this.hasEmptySlot ? 'empty' : 'dontrender'
},
headerSlotName() {
return this.hasHeaderSlot ? 'header' : 'dontrender'
},
hasDefaultSlot() {
return !!this.$scopedSlots.default
},
hasEmptySlot() {
return !!this.$slots.empty
},
hasHeaderSlot() {
return !!this.$slots.header
},
/**
* Show the input field if a maxtags hasn't been set or reached.
*/
hasInput() {
return this.maxtags == null || this.tagsLength < this.maxtags
},
tagsLength() {
return this.tags.length
},
/**
* If Taginput has onPasteSeparators prop,
* returning new RegExp used to split pasted string.
*/
separatorsAsRegExp() {
const sep = this.onPasteSeparators
return sep.length ? new RegExp(sep.map((s) => {
return s ? s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') : null
}).join('|'), 'g') : null
}
},
watch: {
/**
* When v-model is changed set internal value.
*/
value(value) {
this.tags = value
},
hasInput() {
if (!this.hasInput) this.onBlur()
}
},
methods: {
addTag(tag) {
const tagToAdd = tag || this.newTag.trim()
if (tagToAdd) {
if (!this.autocomplete) {
const reg = this.separatorsAsRegExp
if (reg && tagToAdd.match(reg)) {
tagToAdd.split(reg)
.map((t) => t.trim())
.filter((t) => t.length !== 0)
.map(this.addTag)
return
}
}
// Add the tag input if it is not blank
// or previously added (if not allowDuplicates).
const add = !this.allowDuplicates ? this.tags.indexOf(tagToAdd) === -1 : true
if (add && this.beforeAdding(tagToAdd)) {
this.tags.push(tagToAdd)
this.$emit('input', this.tags)
this.$emit('add', tagToAdd)
}
}
this.newTag = ''
},
getNormalizedTagText(tag) {
if (typeof tag === 'object') {
return getValueByPath(tag, this.field)
}
return tag
},
customOnBlur($event) {
// Add tag on-blur if not select only
if (!this.autocomplete) this.addTag()
this.onBlur($event)
},
onSelect(option) {
if (!option) return
this.addTag(option)
this.$nextTick(() => {
this.newTag = ''
})
},
removeTag(index) {
var tag = this.tags.splice(index, 1)[0]
this.$emit('input', this.tags)
this.$emit('remove', tag)
return tag
},
removeLastTag() {
if (this.tagsLength > 0) {
this.removeTag(this.tagsLength - 1)
}
},
keydown(event) {
if (this.removeOnKeys.indexOf(event.keyCode) !== -1 && !this.newTag.length) {
this.removeLastTag()
}
// Stop if is to accept select only
if (this.autocomplete && !this.allowNew) return
if (this.confirmKeyCodes.indexOf(event.keyCode) >= 0) {
event.preventDefault()
this.addTag()
}
},
onTyping($event) {
this.$emit('typing', $event.trim())
}
}
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import Taginput from './Taginput'
import { use, registerComponent } from '../../utils/plugins'
const Plugin = {
install(Vue) {
registerComponent(Vue, Taginput)
}
}
use(Plugin)
export default Plugin
export {
Taginput
}
+11
View File
@@ -0,0 +1,11 @@
import { shallowMount } from '@vue/test-utils'
import BTimepicker from '@components/timepicker/Timepicker'
describe('BTimepicker', () => {
it('is called', () => {
const wrapper = shallowMount(BTimepicker)
expect(wrapper.name()).toBe('BTimepicker')
expect(wrapper.isVueInstance()).toBeTruthy()
})
})
+128
View File
@@ -0,0 +1,128 @@
<template>
<div class="timepicker control" :class="[size, {'is-expanded': expanded}]">
<b-dropdown
v-if="!isMobile || inline"
ref="dropdown"
:position="position"
:disabled="disabled"
:inline="inline">
<b-input
v-if="!inline"
ref="input"
slot="trigger"
autocomplete="off"
:value="formatValue(dateSelected)"
:placeholder="placeholder"
:size="size"
:icon="icon"
:icon-pack="iconPack"
:loading="loading"
:disabled="disabled"
:readonly="!editable"
:rounded="rounded"
v-bind="$attrs"
@change.native="onChange($event.target.value)"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event) && checkHtml5Validity()"/>
<b-dropdown-item :disabled="disabled" custom>
<b-field grouped position="is-centered">
<b-select
v-model="hoursSelected"
@change.native="onHoursChange($event.target.value)"
:disabled="disabled"
placeholder="00">
<option
v-for="hour in hours"
:value="hour.value"
:key="hour.value"
:disabled="isHourDisabled(hour.value)">
{{ hour.label }}
</option>
</b-select>
<span class="control is-colon">:</span>
<b-select
v-model="minutesSelected"
@change.native="onMinutesChange($event.target.value)"
:disabled="disabled"
placeholder="00">
<option
v-for="minute in minutes"
:value="minute.value"
:key="minute.value"
:disabled="isMinuteDisabled(minute.value)">
{{ minute.label }}
</option>
</b-select>
<b-select
v-model="meridienSelected"
@change.native="onMeridienChange($event.target.value)"
v-if="!isHourFormat24"
:disabled="disabled">
<option
v-for="meridien in meridiens"
:value="meridien"
:key="meridien">
{{ meridien }}
</option>
</b-select>
</b-field>
<footer
v-if="$slots.default !== undefined && $slots.default.length"
class="timepicker-footer">
<slot/>
</footer>
</b-dropdown-item>
</b-dropdown>
<b-input
v-else
ref="input"
type="time"
autocomplete="off"
:value="formatHHMMSS(value)"
:placeholder="placeholder"
:size="size"
:icon="icon"
:icon-pack="iconPack"
:loading="loading"
:max="formatHHMMSS(maxTime)"
:min="formatHHMMSS(minTime)"
:disabled="disabled"
:readonly="false"
v-bind="$attrs"
@change.native="onChangeNativePicker"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event) && checkHtml5Validity()"/>
</div>
</template>
<script>
import { default as TimepickerMixin } from '../../utils/TimepickerMixin'
import Dropdown from '../dropdown/Dropdown'
import DropdownItem from '../dropdown/DropdownItem'
import Input from '../input/Input'
import Field from '../field/Field'
import Select from '../select/Select'
import Icon from '../icon/Icon'
export default {
name: 'BTimepicker',
components: {
[Input.name]: Input,
[Field.name]: Field,
[Select.name]: Select,
[Icon.name]: Icon,
[Dropdown.name]: Dropdown,
[DropdownItem.name]: DropdownItem
},
mixins: [TimepickerMixin],
inheritAttrs: false,
data() {
return {
_isTimepicker: true
}
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More