node_modules updated, buefy add to lib folder
This commit is contained in:
+152
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
+26
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
+3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
+9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">…</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">…</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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user