This commit is contained in:
darenhsu
2022-07-17 13:16:16 +08:00
parent 84759556ff
commit befd344ab0
28070 changed files with 4008428 additions and 1 deletions
+178
View File
@@ -0,0 +1,178 @@
// Imports
@import './_variables.scss'
// Theme
+theme(v-alert) using ($material)
.v-alert--prominent .v-alert__icon
&:after
background: map-get($material, 'dividers')
// Sheet
+sheet(v-alert, $alert-elevation, $alert-border-radius, $alert-shaped-border-radius)
// Block
.v-alert
display: block
font-size: $alert-font-size
margin-bottom: $alert-margin
padding: $alert-padding
position: relative
transition: $primary-transition
&:not(.v-sheet--tile)
border-radius: $alert-border-radius
> .v-icon,
> .v-alert__content
+ltr()
margin-right: $alert-margin
+rtl()
margin-left: $alert-margin
> .v-icon + .v-alert__content
+ltr()
margin-right: 0
+rtl()
margin-left: 0
> .v-alert__content + .v-icon
+ltr()
margin-right: 0
+rtl()
margin-left: 0
// Elements
.v-alert__border
border-style: solid
border-width: $alert-border-width
content: ''
position: absolute
&:not(.v-alert__border--has-color)
opacity: $alert-border-opacity
&--left,
&--right
bottom: 0
top: 0
&--bottom,
&--top
left: 0
right: 0
&--bottom
border-bottom-left-radius: inherit
border-bottom-right-radius: inherit
bottom: 0
&--left
+ltr()
border-top-left-radius: inherit
border-bottom-left-radius: inherit
left: 0
+rtl()
border-top-right-radius: inherit
border-bottom-right-radius: inherit
right: 0
&--right
+ltr()
border-top-right-radius: inherit
border-bottom-right-radius: inherit
right: 0
+rtl()
border-top-left-radius: inherit
border-bottom-left-radius: inherit
left: 0
&--top
border-top-left-radius: inherit
border-top-right-radius: inherit
top: 0
.v-alert__content
flex: 1 1 auto
.v-alert__dismissible
+ltr()
margin: -16px -8px -16px 8px
+rtl()
margin: -16px 8px -16px -8px
.v-alert__icon
align-self: flex-start
border-radius: 50%
height: $alert-icon-size
min-width: $alert-icon-size
position: relative
+ltr()
margin-right: 16px
+rtl()
margin-left: 16px
&.v-icon
font-size: $alert-icon-size
.v-alert__wrapper
align-items: center
border-radius: inherit
display: flex
// Modifiers
.v-alert--dense
padding-top: $alert-padding / 2
padding-bottom: $alert-padding / 2
.v-alert__border
border-width: $alert-dense-border-width
.v-alert--outlined
background: transparent !important
border: $alert-outline !important
.v-alert__icon
color: inherit !important
.v-alert--prominent
.v-alert__icon
align-self: center
height: $alert-prominent-icon-size
min-width: $alert-prominent-icon-size
&:after
background: currentColor !important
border-radius: 50%
bottom: 0
content: ''
left: 0
opacity: 0.16
position: absolute
right: 0
top: 0
&.v-icon
font-size: $alert-prominent-icon-font-size
.v-alert--text
background: transparent !important
&:before
background-color: currentColor
border-radius: inherit
bottom: 0
content: ''
left: 0
opacity: 0.12
position: absolute
pointer-events: none
right: 0
top: 0
+251
View File
@@ -0,0 +1,251 @@
// Styles
import './VAlert.sass'
// Extensions
import VSheet from '../VSheet'
// Components
import VBtn from '../VBtn'
import VIcon from '../VIcon'
// Mixins
import Toggleable from '../../mixins/toggleable'
import Themeable from '../../mixins/themeable'
import Transitionable from '../../mixins/transitionable'
// Utilities
import mixins from '../../util/mixins'
import { breaking } from '../../util/console'
// Types
import { VNodeData } from 'vue'
import { VNode } from 'vue/types'
/* @vue/component */
export default mixins(
VSheet,
Toggleable,
Transitionable
).extend({
name: 'v-alert',
props: {
border: {
type: String,
validator (val: string) {
return [
'top',
'right',
'bottom',
'left',
].includes(val)
},
},
closeLabel: {
type: String,
default: '$vuetify.close',
},
coloredBorder: Boolean,
dense: Boolean,
dismissible: Boolean,
closeIcon: {
type: String,
default: '$cancel',
},
icon: {
default: '',
type: [Boolean, String],
validator (val: boolean | string) {
return typeof val === 'string' || val === false
},
},
outlined: Boolean,
prominent: Boolean,
text: Boolean,
type: {
type: String,
validator (val: string) {
return [
'info',
'error',
'success',
'warning',
].includes(val)
},
},
value: {
type: Boolean,
default: true,
},
},
computed: {
__cachedBorder (): VNode | null {
if (!this.border) return null
let data: VNodeData = {
staticClass: 'v-alert__border',
class: {
[`v-alert__border--${this.border}`]: true,
},
}
if (this.coloredBorder) {
data = this.setBackgroundColor(this.computedColor, data)
data.class['v-alert__border--has-color'] = true
}
return this.$createElement('div', data)
},
__cachedDismissible (): VNode | null {
if (!this.dismissible) return null
const color = this.iconColor
return this.$createElement(VBtn, {
staticClass: 'v-alert__dismissible',
props: {
color,
icon: true,
small: true,
},
attrs: {
'aria-label': this.$vuetify.lang.t(this.closeLabel),
},
on: {
click: () => (this.isActive = false),
},
}, [
this.$createElement(VIcon, {
props: { color },
}, this.closeIcon),
])
},
__cachedIcon (): VNode | null {
if (!this.computedIcon) return null
return this.$createElement(VIcon, {
staticClass: 'v-alert__icon',
props: { color: this.iconColor },
}, this.computedIcon)
},
classes (): object {
const classes: Record<string, boolean> = {
...VSheet.options.computed.classes.call(this),
'v-alert--border': Boolean(this.border),
'v-alert--dense': this.dense,
'v-alert--outlined': this.outlined,
'v-alert--prominent': this.prominent,
'v-alert--text': this.text,
}
if (this.border) {
classes[`v-alert--border-${this.border}`] = true
}
return classes
},
computedColor (): string {
return this.color || this.type
},
computedIcon (): string | boolean {
if (this.icon === false) return false
if (typeof this.icon === 'string' && this.icon) return this.icon
if (!['error', 'info', 'success', 'warning'].includes(this.type)) return false
return `$${this.type}`
},
hasColoredIcon (): boolean {
return (
this.hasText ||
(Boolean(this.border) && this.coloredBorder)
)
},
hasText (): boolean {
return this.text || this.outlined
},
iconColor (): string | undefined {
return this.hasColoredIcon ? this.computedColor : undefined
},
isDark (): boolean {
if (
this.type &&
!this.coloredBorder &&
!this.outlined
) return true
return Themeable.options.computed.isDark.call(this)
},
},
created () {
/* istanbul ignore next */
if (this.$attrs.hasOwnProperty('outline')) {
breaking('outline', 'outlined', this)
}
},
methods: {
genWrapper (): VNode {
const children = [
this.$slots.prepend || this.__cachedIcon,
this.genContent(),
this.__cachedBorder,
this.$slots.append,
this.$scopedSlots.close
? this.$scopedSlots.close({ toggle: this.toggle })
: this.__cachedDismissible,
]
const data: VNodeData = {
staticClass: 'v-alert__wrapper',
}
return this.$createElement('div', data, children)
},
genContent (): VNode {
return this.$createElement('div', {
staticClass: 'v-alert__content',
}, this.$slots.default)
},
genAlert (): VNode {
let data: VNodeData = {
staticClass: 'v-alert',
attrs: {
role: 'alert',
},
class: this.classes,
style: this.styles,
directives: [{
name: 'show',
value: this.isActive,
}],
}
if (!this.coloredBorder) {
const setColor = this.hasText ? this.setTextColor : this.setBackgroundColor
data = setColor(this.computedColor, data)
}
return this.$createElement('div', data, [this.genWrapper()])
},
/** @public */
toggle () {
this.isActive = !this.isActive
},
},
render (h): VNode {
const render = this.genAlert()
if (!this.transition) return render
return h('transition', {
props: {
name: this.transition,
origin: this.origin,
mode: this.mode,
},
}, [render])
},
})
+194
View File
@@ -0,0 +1,194 @@
// Components
import VAlert from '../VAlert'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
// Types
import { ExtractVue } from '../../../util/mixins'
describe('VAlert.ts', () => {
type Instance = ExtractVue<typeof VAlert>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VAlert, {
...options,
// https://github.com/vuejs/vue-test-utils/issues/1130
sync: false,
mocks: {
$vuetify: {
lang: {
t: (val: string) => val,
},
},
},
})
}
})
it('should be open by default', async () => {
const wrapper = mountFunction()
expect(wrapper.element.style.display).toBe('')
expect(wrapper.html()).toMatchSnapshot()
wrapper.setProps({ value: false })
await wrapper.vm.$nextTick()
expect(wrapper.element.style.display).toBe('none')
expect(wrapper.html()).toMatchSnapshot()
})
it('should have a close icon', () => {
const wrapper = mountFunction({
propsData: { dismissible: true },
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should be dismissible', async () => {
const wrapper = mountFunction({
propsData: {
dismissible: true,
},
})
const icon = wrapper.find('.v-alert__dismissible')
const input = jest.fn(show => wrapper.setProps({ show }))
wrapper.vm.$on('input', input)
icon.trigger('click')
await wrapper.vm.$nextTick()
expect(input).toHaveBeenCalledWith(false)
expect(wrapper.html()).toMatchSnapshot()
})
it('should have a custom icon', () => {
const wrapper = mountFunction({
propsData: {
icon: 'list',
},
})
const icon = wrapper.find('.v-alert__icon')
expect(icon.text()).toBe('list')
})
it('should have no icon', () => {
const wrapper = mountFunction()
expect(wrapper.contains('.v-icon')).toBe(false)
})
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
it.skip('should display contextual colors by type', async () => {
const wrapper = mountFunction({
propsData: { type: 'error' },
})
expect(wrapper.classes('error')).toBe(true)
wrapper.setProps({ type: 'success' })
await wrapper.vm.$nextTick()
expect(wrapper.classes('success')).toBe(true)
wrapper.setProps({ type: 'warning' })
await wrapper.vm.$nextTick()
expect(wrapper.classes('warning')).toBe(true)
wrapper.setProps({ type: 'info' })
await wrapper.vm.$nextTick()
expect(wrapper.classes('info')).toBe(true)
})
it('should allow overriding color for contextual alert', () => {
const wrapper = mountFunction({
propsData: {
type: 'error',
color: 'primary',
},
})
expect(wrapper.classes('primary')).toBe(true)
})
it('should allow overriding icon for contextual alert', () => {
const wrapper = mountFunction({
propsData: {
type: 'error',
icon: 'block',
},
})
const icon = wrapper.find('.v-alert__icon')
expect(icon.text()).toBe('block')
})
it('should render custom dismissible icon', () => {
const wrapper = mountFunction({
propsData: {
dismissible: true,
closeIcon: 'foo',
},
})
const icon = wrapper.find('.v-alert__content + .v-btn')
expect(icon.text()).toBe('foo')
})
it('should show border', async () => {
const directions = ['top', 'right', 'bottom', 'left']
const wrapper = mountFunction()
expect(wrapper.classes('v-alert--border')).toBe(false)
for (const border of directions) {
wrapper.setProps({ border })
await wrapper.vm.$nextTick()
expect(wrapper.classes('v-alert--border')).toBe(true)
expect(wrapper.classes(`v-alert--border-${border}`)).toBe(true)
}
})
it('should move color classes to border and icon elements', async () => {
const wrapper = mountFunction({
propsData: {
color: 'pink',
border: 'left',
},
})
const border = wrapper.find('.v-alert__border')
expect(wrapper.classes('pink')).toBe(true)
expect(border.classes('pink')).toBe(false)
wrapper.setProps({ coloredBorder: true })
await wrapper.vm.$nextTick()
expect(wrapper.classes('pink')).toBe(false)
expect(border.classes('pink')).toBe(true)
expect(border.classes('v-alert__border--has-color')).toBe(true)
})
it('should toggle isActive state', () => {
const wrapper = mountFunction()
expect(wrapper.vm.isActive).toBe(true)
wrapper.vm.toggle()
expect(wrapper.vm.isActive).toBe(false)
})
})
@@ -0,0 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VAlert.ts should be dismissible 1`] = `
<div role="alert"
class="v-alert v-sheet theme--light"
style="display: none;"
>
<div class="v-alert__wrapper">
<div class="v-alert__content">
</div>
<button type="button"
class="v-alert__dismissible v-btn v-btn--flat v-btn--icon v-btn--round theme--light v-size--small"
aria-label="$vuetify.close"
>
<span class="v-btn__content">
<i aria-hidden="true"
class="v-icon notranslate material-icons theme--light"
>
$cancel
</i>
</span>
</button>
</div>
</div>
`;
exports[`VAlert.ts should be open by default 1`] = `
<div role="alert"
class="v-alert v-sheet theme--light"
>
<div class="v-alert__wrapper">
<div class="v-alert__content">
</div>
</div>
</div>
`;
exports[`VAlert.ts should be open by default 2`] = `
<div role="alert"
class="v-alert v-sheet theme--light"
style="display: none;"
>
<div class="v-alert__wrapper">
<div class="v-alert__content">
</div>
</div>
</div>
`;
exports[`VAlert.ts should have a close icon 1`] = `
<div role="alert"
class="v-alert v-sheet theme--light"
>
<div class="v-alert__wrapper">
<div class="v-alert__content">
</div>
<button type="button"
class="v-alert__dismissible v-btn v-btn--flat v-btn--icon v-btn--round theme--light v-size--small"
aria-label="$vuetify.close"
>
<span class="v-btn__content">
<i aria-hidden="true"
class="v-icon notranslate material-icons theme--light"
>
$cancel
</i>
</span>
</button>
</div>
</div>
`;
+15
View File
@@ -0,0 +1,15 @@
@import '../../styles/styles.sass';
$alert-elevation: 0 !default;
$alert-border-opacity: 0.26 !default;
$alert-border-radius: $border-radius-root !default;
$alert-shaped-border-radius: map-get($rounded, 'xl') $alert-border-radius !default;
$alert-border-width: 4px !default;
$alert-dense-border-width: medium !default;
$alert-font-size: 16px !default;
$alert-icon-size: 24px !default;
$alert-margin: 16px !default;
$alert-outline: thin solid currentColor !default;
$alert-padding: 16px !default;
$alert-prominent-icon-font-size: 32px !default;
$alert-prominent-icon-size: 48px !default;
+4
View File
@@ -0,0 +1,4 @@
import VAlert from './VAlert'
export { VAlert }
export default VAlert
+43
View File
@@ -0,0 +1,43 @@
@import '../../styles/styles.sass'
// Theme
+theme(v-application) using ($material)
background: map-get($material, 'background')
color: map-deep-get($material, 'text', 'primary')
.text
&--primary
color: map-deep-get($material, 'text', 'primary') !important
&--secondary
color: map-deep-get($material, 'text', 'secondary') !important
&--disabled
color: map-deep-get($material, 'text', 'disabled') !important
.v-application
display: flex
a
cursor: pointer
&--is-rtl
direction: rtl
&--wrap
flex: 1 1 auto
backface-visibility: hidden
display: flex
flex-direction: column
min-height: 100vh
max-width: 100%
position: relative
// Firefox overrides
@-moz-document url-prefix()
@media print
.v-application
display: block
&--wrap
display: block
+57
View File
@@ -0,0 +1,57 @@
// Styles
import './VApp.sass'
// Mixins
import Themeable from '../../mixins/themeable'
// Utilities
import mixins from '../../util/mixins'
/* @vue/component */
export default mixins(
Themeable
).extend({
name: 'v-app',
props: {
dark: {
type: Boolean,
default: undefined,
},
id: {
type: String,
default: 'app',
},
light: {
type: Boolean,
default: undefined,
},
},
computed: {
isDark (): boolean {
return this.$vuetify.theme.dark
},
},
beforeCreate () {
if (!this.$vuetify || (this.$vuetify === this.$root as any)) {
throw new Error('Vuetify is not properly initialized, see https://vuetifyjs.com/getting-started/quick-start#bootstrapping-the-vuetify-object')
}
},
render (h) {
const wrapper = h('div', { staticClass: 'v-application--wrap' }, this.$slots.default)
return h('div', {
staticClass: 'v-application',
class: {
'v-application--is-rtl': this.$vuetify.rtl,
'v-application--is-ltr': !this.$vuetify.rtl,
...this.themeClasses,
},
attrs: { 'data-app': true },
domProps: { id: this.id },
}, [wrapper])
},
})
+72
View File
@@ -0,0 +1,72 @@
// Components
import VApp from '../VApp'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
describe('VApp.ts', () => {
type Instance = InstanceType<typeof VApp>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VApp, {
...options,
})
}
})
it('should match a snapshot', () => {
const wrapper = mountFunction({
mocks: {
$vuetify: {
rtl: false,
theme: {
dark: false,
},
},
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should have data-app attribute', () => {
const wrapper = mountFunction({
mocks: {
$vuetify: {
rtl: false,
theme: {
dark: false,
},
},
},
})
const app = wrapper.find('.v-application')
expect(app.attributes()['data-app']).toBe('true')
})
it('should allow a custom id', () => {
const wrapper = mountFunction({
propsData: {
id: 'inspire',
},
mocks: {
$vuetify: {
rtl: false,
theme: {
dark: false,
},
},
},
})
const app = wrapper.find('.v-application')
expect(app.attributes().id).toBe('inspire')
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VApp.ts should allow a custom id 1`] = `
<div data-app="true"
class="v-application v-application--is-ltr theme--light"
id="inspire"
>
<div class="v-application--wrap">
</div>
</div>
`;
exports[`VApp.ts should match a snapshot 1`] = `
<div data-app="true"
class="v-application v-application--is-ltr theme--light"
id="app"
>
<div class="v-application--wrap">
</div>
</div>
`;
+4
View File
@@ -0,0 +1,4 @@
import VApp from './VApp'
export { VApp }
export default VApp
+48
View File
@@ -0,0 +1,48 @@
@import './_variables.scss'
+theme('v-app-bar.v-toolbar.v-sheet') using ($material)
background-color: map-get($material, 'app-bar')
+sheet('v-app-bar.v-toolbar', $app-bar-elevation, $app-bar-border-radius, $app-bar-shaped-border-radius)
// Block
.v-app-bar
+bootable()
// Modifier
.v-app-bar.v-app-bar--fixed
position: fixed
top: 0
z-index: 5
.v-app-bar.v-app-bar--hide-shadow
// Workaround for cascaded elevation styles
// that persist when the bar is inactive.
+elevation(0, true)
.v-app-bar--fade-img-on-scroll
.v-toolbar__image .v-image__image
transition: $app-bar-transition
.v-app-bar.v-toolbar--prominent.v-app-bar--shrink-on-scroll
.v-toolbar__content
will-change: height
.v-toolbar__image
will-change: opacity
&.v-app-bar--collapse-on-scroll
.v-toolbar__extension
display: none
&.v-app-bar--is-scrolled
.v-toolbar__title
padding-top: $app-bar-scrolled-title-padding-bottom
&:not(.v-app-bar--bottom)
.v-toolbar__title
padding-bottom: $app-bar-scrolled-title-padding-bottom
.v-app-bar.v-app-bar--shrink-on-scroll
.v-toolbar__title
font-size: inherit
+286
View File
@@ -0,0 +1,286 @@
// Styles
import './VAppBar.sass'
// Extensions
import VToolbar from '../VToolbar/VToolbar'
// Directives
import Scroll from '../../directives/scroll'
// Mixins
import Applicationable from '../../mixins/applicationable'
import Scrollable from '../../mixins/scrollable'
import SSRBootable from '../../mixins/ssr-bootable'
import Toggleable from '../../mixins/toggleable'
// Utilities
import { convertToUnit } from '../../util/helpers'
import mixins from '../../util/mixins'
// Types
import { VNode } from 'vue'
const baseMixins = mixins(
VToolbar,
Scrollable,
SSRBootable,
Toggleable,
Applicationable('top', [
'clippedLeft',
'clippedRight',
'computedHeight',
'invertedScroll',
'isExtended',
'isProminent',
'value',
])
)
/* @vue/component */
export default baseMixins.extend({
name: 'v-app-bar',
directives: { Scroll },
props: {
clippedLeft: Boolean,
clippedRight: Boolean,
collapseOnScroll: Boolean,
elevateOnScroll: Boolean,
fadeImgOnScroll: Boolean,
hideOnScroll: Boolean,
invertedScroll: Boolean,
scrollOffScreen: Boolean,
shrinkOnScroll: Boolean,
value: {
type: Boolean,
default: true,
},
},
data () {
return {
isActive: this.value,
}
},
computed: {
applicationProperty (): string {
return !this.bottom ? 'top' : 'bottom'
},
canScroll (): boolean {
return (
Scrollable.options.computed.canScroll.call(this) &&
(
this.invertedScroll ||
this.elevateOnScroll ||
this.hideOnScroll ||
this.collapseOnScroll ||
this.isBooted ||
// If falsey, user has provided an
// explicit value which should
// overwrite anything we do
!this.value
)
)
},
classes (): object {
return {
...VToolbar.options.computed.classes.call(this),
'v-toolbar--collapse': this.collapse || this.collapseOnScroll,
'v-app-bar': true,
'v-app-bar--clipped': this.clippedLeft || this.clippedRight,
'v-app-bar--fade-img-on-scroll': this.fadeImgOnScroll,
'v-app-bar--elevate-on-scroll': this.elevateOnScroll,
'v-app-bar--fixed': !this.absolute && (this.app || this.fixed),
'v-app-bar--hide-shadow': this.hideShadow,
'v-app-bar--is-scrolled': this.currentScroll > 0,
'v-app-bar--shrink-on-scroll': this.shrinkOnScroll,
}
},
computedContentHeight (): number {
if (!this.shrinkOnScroll) return VToolbar.options.computed.computedContentHeight.call(this)
const height = this.computedOriginalHeight
const min = this.dense ? 48 : 56
const max = height
const difference = max - min
const iteration = difference / this.computedScrollThreshold
const offset = this.currentScroll * iteration
return Math.max(min, max - offset)
},
computedFontSize (): number | undefined {
if (!this.isProminent) return undefined
const max = this.dense ? 96 : 128
const difference = max - this.computedContentHeight
const increment = 0.00347
// 1.5rem to a minimum of 1.25rem
return Number((1.50 - difference * increment).toFixed(2))
},
computedLeft (): number {
if (!this.app || this.clippedLeft) return 0
return this.$vuetify.application.left
},
computedMarginTop (): number {
if (!this.app) return 0
return this.$vuetify.application.bar
},
computedOpacity (): number | undefined {
if (!this.fadeImgOnScroll) return undefined
const opacity = Math.max(
(this.computedScrollThreshold - this.currentScroll) / this.computedScrollThreshold,
0
)
return Number(parseFloat(opacity).toFixed(2))
},
computedOriginalHeight (): number {
let height = VToolbar.options.computed.computedContentHeight.call(this)
if (this.isExtended) height += parseInt(this.extensionHeight)
return height
},
computedRight (): number {
if (!this.app || this.clippedRight) return 0
return this.$vuetify.application.right
},
computedScrollThreshold (): number {
if (this.scrollThreshold) return Number(this.scrollThreshold)
return this.computedOriginalHeight - (this.dense ? 48 : 56)
},
computedTransform (): number {
if (
!this.canScroll ||
(this.elevateOnScroll && this.currentScroll === 0 && this.isActive)
) return 0
if (this.isActive) return 0
const scrollOffScreen = this.scrollOffScreen
? this.computedHeight
: this.computedContentHeight
return this.bottom ? scrollOffScreen : -scrollOffScreen
},
hideShadow (): boolean {
if (this.elevateOnScroll && this.isExtended) {
return this.currentScroll < this.computedScrollThreshold
}
if (this.elevateOnScroll) {
return this.currentScroll === 0 ||
this.computedTransform < 0
}
return (
!this.isExtended ||
this.scrollOffScreen
) && this.computedTransform !== 0
},
isCollapsed (): boolean {
if (!this.collapseOnScroll) {
return VToolbar.options.computed.isCollapsed.call(this)
}
return this.currentScroll > 0
},
isProminent (): boolean {
return (
VToolbar.options.computed.isProminent.call(this) ||
this.shrinkOnScroll
)
},
styles (): object {
return {
...VToolbar.options.computed.styles.call(this),
fontSize: convertToUnit(this.computedFontSize, 'rem'),
marginTop: convertToUnit(this.computedMarginTop),
transform: `translateY(${convertToUnit(this.computedTransform)})`,
left: convertToUnit(this.computedLeft),
right: convertToUnit(this.computedRight),
}
},
},
watch: {
canScroll: 'onScroll',
computedTransform () {
// Normally we do not want the v-app-bar
// to update the application top value
// to avoid screen jump. However, in
// this situation, we must so that
// the clipped drawer can update
// its top value when scrolled
if (
!this.canScroll ||
(!this.clippedLeft && !this.clippedRight)
) return
this.callUpdate()
},
invertedScroll (val: boolean) {
this.isActive = !val || this.currentScroll !== 0
},
},
created () {
if (this.invertedScroll) this.isActive = false
},
methods: {
genBackground () {
const render = VToolbar.options.methods.genBackground.call(this)
render.data = this._b(render.data || {}, render.tag!, {
style: { opacity: this.computedOpacity },
})
return render
},
updateApplication (): number {
return this.invertedScroll
? 0
: this.computedHeight + this.computedTransform
},
thresholdMet () {
if (this.invertedScroll) {
this.isActive = this.currentScroll > this.computedScrollThreshold
return
}
if (this.hideOnScroll) {
this.isActive = this.isScrollingUp ||
this.currentScroll < this.computedScrollThreshold
}
if (this.currentThreshold < this.computedScrollThreshold) return
this.savedScroll = this.currentScroll
},
},
render (h): VNode {
const render = VToolbar.options.render.call(this, h)
render.data = render.data || {}
if (this.canScroll) {
render.data.directives = render.data.directives || []
render.data.directives.push({
arg: this.scrollTarget,
name: 'scroll',
value: this.onScroll,
})
}
return render
},
})
+28
View File
@@ -0,0 +1,28 @@
// Components
import VIcon from '../VIcon'
import VBtn from '../VBtn/VBtn'
// Types
import Vue from 'vue'
/* @vue/component */
export default Vue.extend({
name: 'v-app-bar-nav-icon',
functional: true,
render (h, { slots, listeners, props, data }) {
const d = Object.assign(data, {
staticClass: (`v-app-bar__nav-icon ${data.staticClass || ''}`).trim(),
props: {
...props,
icon: true,
},
on: listeners,
})
const defaultSlot = slots().default
return h(VBtn, d, defaultSlot || [h(VIcon, '$menu')])
},
})
+339
View File
@@ -0,0 +1,339 @@
// Libraries
import Vue from 'vue'
// Components
import VAppBar from '../VAppBar'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
import { ExtractVue } from '../../../util/mixins'
import { scrollWindow } from '../../../../test'
describe('AppBar.ts', () => {
type Instance = ExtractVue<typeof VAppBar>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VAppBar, {
mocks: {
$vuetify: {
application: {
top: 0,
register: () => {},
unregister: () => {},
},
breakpoint: {},
},
},
...options,
})
}
})
it('should calculate paddings ', () => {
const wrapper = mountFunction()
wrapper.vm.$vuetify.application.left = 42
wrapper.vm.$vuetify.application.right = 84
wrapper.setProps({ app: false, clippedLeft: false, clippedRight: false })
expect(wrapper.vm.computedLeft).toBe(0)
expect(wrapper.vm.computedRight).toBe(0)
wrapper.setProps({ app: false, clippedLeft: true, clippedRight: true })
expect(wrapper.vm.computedLeft).toBe(0)
expect(wrapper.vm.computedRight).toBe(0)
wrapper.setProps({ app: true, clippedLeft: false, clippedRight: false })
expect(wrapper.vm.computedLeft).toBe(42)
expect(wrapper.vm.computedRight).toBe(84)
wrapper.setProps({ app: true, clippedLeft: true, clippedRight: true })
expect(wrapper.vm.computedLeft).toBe(0)
expect(wrapper.vm.computedRight).toBe(0)
})
it('should scroll off screen', async () => {
const wrapper = mountFunction({
attachToDocument: true,
propsData: { hideOnScroll: true, scrollThreshold: 300 },
})
expect(wrapper.vm.isActive).toBe(true)
expect(wrapper.vm.currentScroll).toBe(0)
await scrollWindow(100)
expect(wrapper.vm.isActive).toBe(true)
expect(wrapper.vm.currentScroll).toBe(100)
await scrollWindow(600)
expect(wrapper.vm.isActive).toBe(false)
expect(wrapper.vm.currentScroll).toBe(600)
await scrollWindow(475)
await scrollWindow(0)
expect(wrapper.vm.currentScroll).toBe(0)
expect(wrapper.vm.isActive).toBe(true)
expect(wrapper.vm.currentScroll).toBe(0)
wrapper.setProps({ invertedScroll: true })
await wrapper.vm.$nextTick()
expect(wrapper.vm.isActive).toBe(false)
await scrollWindow(0)
await scrollWindow(475)
expect(wrapper.vm.isActive).toBe(true)
})
it('should hide when inverted scroll is enabled and page is scrolled to the top', async () => {
const wrapper = mountFunction({
attachToDocument: true,
propsData: { hideOnScroll: true, invertedScroll: true, scrollThreshold: 300 },
})
expect(wrapper.vm.currentScroll).toBe(0)
expect(wrapper.vm.isActive).toBe(false)
await scrollWindow(475)
expect(wrapper.vm.isActive).toBe(true)
await scrollWindow(0)
wrapper.setProps({ invertedScroll: false })
await wrapper.vm.$nextTick()
await scrollWindow(475)
wrapper.setProps({ invertedScroll: true })
expect(wrapper.vm.isActive).toBe(true)
await scrollWindow(0)
expect(wrapper.vm.isActive).toBe(false)
})
it('should set active based on value', async () => {
const wrapper = mountFunction({
propsData: {
hideOnScroll: true,
},
})
expect(wrapper.vm.isActive).toBe(true)
wrapper.setProps({ value: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.isActive).toBe(false)
})
it('should set margin top', () => {
const wrapper = mountFunction({
propsData: {
app: true,
},
})
Vue.set(wrapper.vm.$vuetify.application, 'bar', 24)
expect(wrapper.vm.computedMarginTop).toBe(24)
})
it('should set isActive false when created and vertical-scroll', () => {
const wrapper = mountFunction({
propsData: {
invertedScroll: true,
},
})
expect(wrapper.vm.isActive).toBe(false)
})
it('should hide shadow when using elevate-on-scroll', () => {
const wrapper = mountFunction({
propsData: {
elevateOnScroll: true,
},
})
expect(wrapper.vm.hideShadow).toBe(true)
wrapper.setData({ currentScroll: 100 })
expect(wrapper.vm.hideShadow).toBe(false)
})
it('should collapse-on-scroll', () => {
const wrapper = mountFunction({
propsData: {
collapseOnScroll: true,
},
})
wrapper.setData({ currentScroll: 0 })
expect(wrapper.vm.isCollapsed).toBeFalsy()
wrapper.setData({ currentScroll: 100 })
expect(wrapper.vm.isCollapsed).toBeTruthy()
})
it('should calculate font size', () => {
const wrapper = mountFunction({
propsData: {
shrinkOnScroll: false,
prominent: false,
},
})
expect(wrapper.vm.computedFontSize).toBeUndefined()
wrapper.setProps({
shrinkOnScroll: true,
prominent: true,
})
expect(wrapper.vm.computedFontSize).toBeDefined()
expect(wrapper.vm.computedFontSize).toBe(1.5)
})
it('should render with background', () => {
const wrapper = mountFunction({
propsData: {
src: '/test.jpg',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should calculate opacity', () => {
const wrapper = mountFunction({
propsData: {
src: '/test.jpg',
fadeImgOnScroll: true,
},
})
expect(wrapper.vm.computedOpacity).toBe(1)
wrapper.setProps({ fadeImgOnScroll: true })
expect(wrapper.vm.computedOpacity).toBe(1)
wrapper.setData({ currentScroll: 5 })
expect(wrapper.vm.computedOpacity).toBe(0.38)
wrapper.setData({ currentScroll: 100 })
expect(wrapper.vm.computedOpacity).toBe(0)
})
// https://github.com/vuetifyjs/vuetify/issues/4985
// https://github.com/vuetifyjs/vuetify/issues/8337
it('should scroll toolbar and extension completely off screen', async () => {
const wrapper = mountFunction({
propsData: {
hideOnScroll: true,
extended: true,
},
})
expect(wrapper.vm.computedTransform).toBe(0)
await scrollWindow(500)
expect(wrapper.vm.computedTransform).toBe(-64)
wrapper.setProps({ bottom: true, scrollOffScreen: true })
expect(wrapper.vm.computedTransform).toBe(112)
expect(wrapper.vm.hideShadow).toBe(true)
wrapper.setProps({ scrollOffScreen: false })
expect(wrapper.vm.hideShadow).toBe(false)
})
it('should work with hide-on-scroll and elevate-on-scroll', async () => {
const wrapper = mountFunction({
propsData: {
hideOnScroll: true,
elevateOnScroll: true,
},
})
expect(wrapper.vm.computedTransform).toBe(0)
expect(wrapper.vm.hideShadow).toBe(true)
await scrollWindow(1000)
expect(wrapper.vm.computedTransform).toBe(-64)
expect(wrapper.vm.hideShadow).toBe(true)
await scrollWindow(600)
expect(wrapper.vm.computedTransform).toBe(0)
expect(wrapper.vm.hideShadow).toBe(false)
})
it('should show shadow when hide-on-scroll and elevate-on-scroll and extended are all true', async () => {
const wrapper = mountFunction({
propsData: {
hideOnScroll: true,
elevateOnScroll: true,
extended: true,
},
})
expect(wrapper.vm.computedTransform).toBe(0)
expect(wrapper.vm.hideShadow).toBe(true)
await scrollWindow(1000)
expect(wrapper.vm.computedTransform).toBe(-64)
expect(wrapper.vm.hideShadow).toBe(false)
await scrollWindow(600)
expect(wrapper.vm.computedTransform).toBe(0)
expect(wrapper.vm.hideShadow).toBe(false)
await scrollWindow(0)
expect(wrapper.vm.computedTransform).toBe(0)
expect(wrapper.vm.hideShadow).toBe(true)
})
// https://github.com/vuetifyjs/vuetify/issues/9993
it('should be active when hide-on-scroll and within threshold', async () => {
const wrapper = mountFunction({
propsData: {
hideOnScroll: true,
scrollThreshold: 100,
},
})
wrapper.setProps({ value: false })
await scrollWindow(-100)
expect(wrapper.vm.isActive).toBe(false)
await scrollWindow(1)
expect(wrapper.vm.isActive).toBe(true)
})
// https://github.com/vuetifyjs/vuetify/issues/8583
it('when scroll position is 0, v-model should be able to be control visibility regardless of other props', () => {
const wrapper = mountFunction({
propsData: {
elevateOnScroll: true,
},
})
expect(wrapper.vm.isActive).toBe(true)
expect(wrapper.vm.computedTransform).toBe(0)
wrapper.setProps({ value: false })
expect(wrapper.vm.isActive).toBe(false)
expect(wrapper.vm.computedTransform).not.toBe(0)
})
})
@@ -0,0 +1,30 @@
// Libraries
import Vue from 'vue'
// Components
import VAppBarNavIcon from '../VAppBarNavIcon'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
import { ExtractVue } from '../../../util/mixins'
describe('AppBarNavIcon.ts', () => {
type Instance = ExtractVue<typeof VAppBarNavIcon>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VAppBarNavIcon, {
...options,
})
}
})
it('should render correctly', () => {
const wrapper = mountFunction()
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AppBar.ts should render with background 1`] = `
<header class="v-sheet theme--light v-toolbar v-app-bar"
style="height: 64px; margin-top: 0px; transform: translateY(0px); left: 0px; right: 0px;"
>
<div class="v-toolbar__image">
<div class="v-image v-responsive theme--light"
style="height: 64px;"
>
<div class="v-image__image v-image__image--preload v-image__image--cover"
style="background-position: center center;"
name="fade-transition"
mode="in-out"
>
</div>
<div class="v-responsive__content">
</div>
</div>
</div>
<div class="v-toolbar__content"
style="height: 64px;"
>
</div>
</header>
`;
@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AppBarNavIcon.ts should render correctly 1`] = `
<button type="button"
class="v-app-bar__nav-icon v-btn v-btn--flat v-btn--icon v-btn--round theme--light v-size--default"
>
<span class="v-btn__content">
<i aria-hidden="true"
class="v-icon notranslate material-icons theme--light"
>
$menu
</i>
</span>
</button>
`;
+7
View File
@@ -0,0 +1,7 @@
@import '../../styles/styles.sass';
$app-bar-border-radius: 0 !default;
$app-bar-elevation: 4 !default;
$app-bar-scrolled-title-padding-bottom: 9px !default;
$app-bar-shaped-border-radius: map-get($rounded, 'xl') $app-bar-border-radius !default;
$app-bar-transition: .4s opacity map-get($transition, 'fast-out-slow-in') !default;
+11
View File
@@ -0,0 +1,11 @@
import VAppBar from './VAppBar'
import VAppBarNavIcon from './VAppBarNavIcon'
export { VAppBar, VAppBarNavIcon }
export default {
$_vuetify_subcomponents: {
VAppBar,
VAppBarNavIcon,
},
}
+45
View File
@@ -0,0 +1,45 @@
@import './_variables.scss'
.v-autocomplete
&.v-input > .v-input__control > .v-input__slot
cursor: text
input
align-self: center
// Overwrite v-text-fields
// minimum-width default
&.v-select.v-input--is-focused
input
min-width: $autocomplete-focused-input
// Chips force input to wrap
// so we remove height and
// padding until focused
&:not(.v-input--is-focused).v-select--chips input
max-height: 0
padding: 0
&--is-selecting-index
input
opacity: 0
// When a single select, does not have
// selections padding
&.v-text-field--enclosed:not(.v-text-field--solo):not(.v-text-field--single-line):not(.v-text-field--outlined)
.v-select__slot > input
margin-top: $autocomplete-enclosed-input-margin-top
&.v-input--dense
.v-select__slot > input
margin-top: $autocomplete-dense-enclosed-input-margin-top
&:not(.v-input--is-disabled).v-select.v-text-field
input
pointer-events: inherit
&__content.v-menu__content
border-radius: 0
.v-card
border-radius: 0
+424
View File
@@ -0,0 +1,424 @@
// Styles
import './VAutocomplete.sass'
// Extensions
import VSelect, { defaultMenuProps as VSelectMenuProps } from '../VSelect/VSelect'
import VTextField from '../VTextField/VTextField'
// Utilities
import mergeData from '../../util/mergeData'
import {
getObjectValueByPath,
getPropertyFromItem,
keyCodes,
} from '../../util/helpers'
// Types
import { PropType } from 'vue'
const defaultMenuProps = {
...VSelectMenuProps,
offsetY: true,
offsetOverflow: true,
transition: false,
}
/* @vue/component */
export default VSelect.extend({
name: 'v-autocomplete',
props: {
allowOverflow: {
type: Boolean,
default: true,
},
autoSelectFirst: {
type: Boolean,
default: false,
},
filter: {
type: Function,
default: (item: any, queryText: string, itemText: string) => {
return itemText.toLocaleLowerCase().indexOf(queryText.toLocaleLowerCase()) > -1
},
},
hideNoData: Boolean,
menuProps: {
type: VSelect.options.props.menuProps.type,
default: () => defaultMenuProps,
},
noFilter: Boolean,
searchInput: {
type: String as PropType<string | undefined>,
default: undefined,
},
},
data () {
return {
lazySearch: this.searchInput,
}
},
computed: {
classes (): object {
return {
...VSelect.options.computed.classes.call(this),
'v-autocomplete': true,
'v-autocomplete--is-selecting-index': this.selectedIndex > -1,
}
},
computedItems (): object[] {
return this.filteredItems
},
selectedValues (): object[] {
return this.selectedItems.map(item => this.getValue(item))
},
hasDisplayedItems (): boolean {
return this.hideSelected
? this.filteredItems.some(item => !this.hasItem(item))
: this.filteredItems.length > 0
},
currentRange (): number {
if (this.selectedItem == null) return 0
return String(this.getText(this.selectedItem)).length
},
filteredItems (): object[] {
if (!this.isSearching || this.noFilter || this.internalSearch == null) return this.allItems
return this.allItems.filter(item => {
const value = getPropertyFromItem(item, this.itemText)
const text = value != null ? String(value) : ''
return this.filter(item, String(this.internalSearch), text)
})
},
internalSearch: {
get (): string | undefined {
return this.lazySearch
},
set (val: any) {
this.lazySearch = val
this.$emit('update:search-input', val)
},
},
isAnyValueAllowed (): boolean {
return false
},
isDirty (): boolean {
return this.searchIsDirty || this.selectedItems.length > 0
},
isSearching (): boolean {
return (
this.multiple &&
this.searchIsDirty
) || (
this.searchIsDirty &&
this.internalSearch !== this.getText(this.selectedItem)
)
},
menuCanShow (): boolean {
if (!this.isFocused) return false
return this.hasDisplayedItems || !this.hideNoData
},
$_menuProps (): object {
const props = VSelect.options.computed.$_menuProps.call(this);
(props as any).contentClass = `v-autocomplete__content ${(props as any).contentClass || ''}`.trim()
return {
...defaultMenuProps,
...props,
}
},
searchIsDirty (): boolean {
return this.internalSearch != null &&
this.internalSearch !== ''
},
selectedItem (): any {
if (this.multiple) return null
return this.selectedItems.find(i => {
return this.valueComparator(this.getValue(i), this.getValue(this.internalValue))
})
},
listData () {
const data = VSelect.options.computed.listData.call(this) as any
data.props = {
...data.props,
items: this.virtualizedItems,
noFilter: (
this.noFilter ||
!this.isSearching ||
!this.filteredItems.length
),
searchInput: this.internalSearch,
}
return data
},
},
watch: {
filteredItems: 'onFilteredItemsChanged',
internalValue: 'setSearch',
isFocused (val) {
if (val) {
document.addEventListener('copy', this.onCopy)
this.$refs.input && this.$refs.input.select()
} else {
document.removeEventListener('copy', this.onCopy)
this.updateSelf()
}
},
isMenuActive (val) {
if (val || !this.hasSlot) return
this.lazySearch = undefined
},
items (val, oldVal) {
// If we are focused, the menu
// is not active, hide no data is enabled,
// and items change
// User is probably async loading
// items, try to activate the menu
if (
!(oldVal && oldVal.length) &&
this.hideNoData &&
this.isFocused &&
!this.isMenuActive &&
val.length
) this.activateMenu()
},
searchInput (val: string) {
this.lazySearch = val
},
internalSearch: 'onInternalSearchChanged',
itemText: 'updateSelf',
},
created () {
this.setSearch()
},
methods: {
onFilteredItemsChanged (val: never[], oldVal: never[]) {
// TODO: How is the watcher triggered
// for duplicate items? no idea
if (val === oldVal) return
this.setMenuIndex(-1)
this.$nextTick(() => {
if (
!this.internalSearch ||
(val.length !== 1 &&
!this.autoSelectFirst)
) return
this.$refs.menu.getTiles()
this.setMenuIndex(0)
})
},
onInternalSearchChanged () {
this.updateMenuDimensions()
},
updateMenuDimensions () {
// Type from menuable is not making it through
this.isMenuActive && this.$refs.menu && this.$refs.menu.updateDimensions()
},
changeSelectedIndex (keyCode: number) {
// Do not allow changing of selectedIndex
// when search is dirty
if (this.searchIsDirty) return
if (this.multiple && keyCode === keyCodes.left) {
if (this.selectedIndex === -1) {
this.selectedIndex = this.selectedItems.length - 1
} else {
this.selectedIndex--
}
} else if (this.multiple && keyCode === keyCodes.right) {
if (this.selectedIndex >= this.selectedItems.length - 1) {
this.selectedIndex = -1
} else {
this.selectedIndex++
}
} else if (keyCode === keyCodes.backspace || keyCode === keyCodes.delete) {
this.deleteCurrentItem()
}
},
deleteCurrentItem () {
const curIndex = this.selectedIndex
const curItem = this.selectedItems[curIndex]
// Do nothing if input or item is disabled
if (
!this.isInteractive ||
this.getDisabled(curItem)
) return
const lastIndex = this.selectedItems.length - 1
// Select the last item if
// there is no selection
if (
this.selectedIndex === -1 &&
lastIndex !== 0
) {
this.selectedIndex = lastIndex
return
}
const length = this.selectedItems.length
const nextIndex = curIndex !== length - 1
? curIndex
: curIndex - 1
const nextItem = this.selectedItems[nextIndex]
if (!nextItem) {
this.setValue(this.multiple ? [] : undefined)
} else {
this.selectItem(curItem)
}
this.selectedIndex = nextIndex
},
clearableCallback () {
this.internalSearch = undefined
VSelect.options.methods.clearableCallback.call(this)
},
genInput () {
const input = VTextField.options.methods.genInput.call(this)
input.data = mergeData(input.data!, {
attrs: {
'aria-activedescendant': getObjectValueByPath(this.$refs.menu, 'activeTile.id'),
autocomplete: getObjectValueByPath(input.data!, 'attrs.autocomplete', 'off'),
},
domProps: { value: this.internalSearch },
})
return input
},
genInputSlot () {
const slot = VSelect.options.methods.genInputSlot.call(this)
slot.data!.attrs!.role = 'combobox'
return slot
},
genSelections () {
return this.hasSlot || this.multiple
? VSelect.options.methods.genSelections.call(this)
: []
},
onClick (e: MouseEvent) {
if (!this.isInteractive) return
this.selectedIndex > -1
? (this.selectedIndex = -1)
: this.onFocus()
if (!this.isAppendInner(e.target)) this.activateMenu()
},
onInput (e: Event) {
if (
this.selectedIndex > -1 ||
!e.target
) return
const target = e.target as HTMLInputElement
const value = target.value
// If typing and menu is not currently active
if (target.value) this.activateMenu()
this.internalSearch = value
this.badInput = target.validity && target.validity.badInput
},
onKeyDown (e: KeyboardEvent) {
const keyCode = e.keyCode
VSelect.options.methods.onKeyDown.call(this, e)
// The ordering is important here
// allows new value to be updated
// and then moves the index to the
// proper location
this.changeSelectedIndex(keyCode)
},
onSpaceDown (e: KeyboardEvent) { /* noop */ },
onTabDown (e: KeyboardEvent) {
VSelect.options.methods.onTabDown.call(this, e)
this.updateSelf()
},
onUpDown (e: Event) {
// Prevent screen from scrolling
e.preventDefault()
// For autocomplete / combobox, cycling
// interfers with native up/down behavior
// instead activate the menu
this.activateMenu()
},
selectItem (item: object) {
VSelect.options.methods.selectItem.call(this, item)
this.setSearch()
},
setSelectedItems () {
VSelect.options.methods.setSelectedItems.call(this)
// #4273 Don't replace if searching
// #4403 Don't replace if focused
if (!this.isFocused) this.setSearch()
},
setSearch () {
// Wait for nextTick so selectedItem
// has had time to update
this.$nextTick(() => {
if (
!this.multiple ||
!this.internalSearch ||
!this.isMenuActive
) {
this.internalSearch = (
!this.selectedItems.length ||
this.multiple ||
this.hasSlot
)
? null
: this.getText(this.selectedItem)
}
})
},
updateSelf () {
if (!this.searchIsDirty &&
!this.internalValue
) return
if (!this.valueComparator(
this.internalSearch,
this.getValue(this.internalValue)
)) {
this.setSearch()
}
},
hasItem (item: any) {
return this.selectedValues.indexOf(this.getValue(item)) > -1
},
onCopy (event: ClipboardEvent) {
if (this.selectedIndex === -1) return
const currentItem = this.selectedItems[this.selectedIndex]
const currentItemText = this.getText(currentItem)
event.clipboardData!.setData('text/plain', currentItemText)
event.clipboardData!.setData('text/vnd.vuetify.autocomplete.item+plain', currentItemText)
event.preventDefault()
},
},
})
@@ -0,0 +1,530 @@
// Components
import VAutocomplete from '../VAutocomplete'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
import { keyCodes } from '../../../util/helpers'
describe('VAutocomplete.ts', () => {
type Instance = InstanceType<typeof VAutocomplete>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
document.body.setAttribute('data-app', 'true')
mountFunction = (options = {}) => {
return mount(VAutocomplete, {
...options,
// https://github.com/vuejs/vue-test-utils/issues/1130
sync: false,
mocks: {
$vuetify: {
lang: {
t: (val: string) => val,
},
theme: {
dark: false,
},
},
},
})
}
})
it('should have explicit tabindex passed through when autocomplete', () => {
const wrapper = mountFunction({
attrs: {
tabindex: 10,
},
})
expect(wrapper.vm.$refs.input.tabIndex).toBe(10)
expect(wrapper.vm.$el.tabIndex).toBe(-1)
})
it('should emit search input changes', async () => {
const wrapper = mountFunction({
propsData: {
},
})
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
const update = jest.fn()
wrapper.vm.$on('update:search-input', update)
element.value = 'test'
input.trigger('input')
await wrapper.vm.$nextTick()
expect(update).toHaveBeenCalledWith('test')
})
it('should filter autocomplete search results', async () => {
const wrapper = mountFunction({
propsData: { items: ['foo', 'bar'] },
})
wrapper.setData({ internalSearch: 'foo' })
expect(wrapper.vm.filteredItems).toHaveLength(1)
expect(wrapper.vm.filteredItems[0]).toBe('foo')
})
it('should filter numeric primitives', () => {
const wrapper = mountFunction({
propsData: {
items: [1, 2],
},
})
wrapper.setData({ internalSearch: 1 })
expect(wrapper.vm.filteredItems).toHaveLength(1)
expect(wrapper.vm.filteredItems[0]).toBe(1)
})
it('should activate when search changes and not active', async () => {
const wrapper = mountFunction({
propsData: {
items: [1, 2, 3, 4],
multiple: true,
},
})
wrapper.vm.isMenuActive = true
await wrapper.vm.$nextTick()
wrapper.setData({ internalSearch: 2 })
await wrapper.vm.$nextTick()
expect(wrapper.vm.isMenuActive).toBe(true)
})
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
it.skip('should set searchValue to null when deactivated', async () => {
const wrapper = mountFunction({
propsData: {
items: [1, 2, 3, 4],
multiple: true,
},
})
await wrapper.vm.$nextTick()
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
input.trigger('focus')
element.value = '2'
input.trigger('input')
expect(wrapper.vm.internalSearch).toBe('2')
wrapper.setProps({
multiple: false,
value: 1,
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.internalSearch).toBe(1)
input.trigger('focus')
element.value = '3'
input.trigger('input')
input.trigger('blur')
expect(wrapper.vm.internalSearch).toBe('3')
})
it('should render role=combobox correctly when autocomplete', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.$el.getAttribute('role')).toBeFalsy()
const input = wrapper.find('.v-input__slot')
expect(input.element.getAttribute('role')).toBe('combobox')
})
it('should not duplicate items after items update when caching is turned on', async () => {
const wrapper = mountFunction({
propsData: {
cacheItems: true,
returnObject: true,
itemText: 'text',
itemValue: 'id',
items: [],
},
})
wrapper.setProps({ items: [{ id: 1, text: 'A' }] })
expect(wrapper.vm.computedItems).toHaveLength(1)
wrapper.setProps({ items: [{ id: 1, text: 'A' }] })
expect(wrapper.vm.computedItems).toHaveLength(1)
})
it('should cache items passed via prop', async () => {
const wrapper = mountFunction({
propsData: {
cacheItems: true,
items: [1, 2, 3, 4],
},
})
expect(wrapper.vm.computedItems).toHaveLength(4)
wrapper.setProps({ items: [5] })
expect(wrapper.vm.computedItems).toHaveLength(5)
})
it('should not filter text with no items', async () => {
const wrapper = mountFunction({
propsData: {
eager: true,
items: ['foo', 'bar'],
},
})
await wrapper.vm.$nextTick()
wrapper.setProps({ searchInput: 'asdf' })
// Wait for watcher
await wrapper.vm.$nextTick()
const tile = wrapper.find('.v-list-item__title')
expect(tile.text()).toBe('$vuetify.noDataText')
})
it('should not display menu when tab focused', async () => {
const wrapper = mountFunction({
propsData: {
items: [1, 2],
value: 1,
},
})
const input = wrapper.find('input')
input.trigger('focus')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isMenuActive).toBe(false)
})
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
// eslint-disable-next-line max-statements
it.skip('should change selected index', async () => {
const wrapper = mountFunction({
attachToDocument: true,
propsData: {
items: ['foo', 'bar', 'fizz'],
multiple: true,
value: ['foo', 'bar', 'fizz'],
},
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedIndex).toBe(-1)
expect(wrapper.vm.selectedItems).toHaveLength(3)
// Right arrow
wrapper.vm.changeSelectedIndex(keyCodes.right)
expect(wrapper.vm.selectedIndex).toBe(0)
wrapper.vm.changeSelectedIndex(keyCodes.right)
expect(wrapper.vm.selectedIndex).toBe(1)
wrapper.vm.changeSelectedIndex(keyCodes.right)
expect(wrapper.vm.selectedIndex).toBe(2)
// Left arrow
wrapper.vm.changeSelectedIndex(keyCodes.left)
expect(wrapper.vm.selectedIndex).toBe(1)
wrapper.vm.changeSelectedIndex(keyCodes.left)
expect(wrapper.vm.selectedIndex).toBe(0)
wrapper.vm.changeSelectedIndex(keyCodes.left)
expect(wrapper.vm.selectedIndex).toBe(-1)
wrapper.vm.changeSelectedIndex(keyCodes.left)
expect(wrapper.vm.selectedIndex).toBe(2)
wrapper.vm.changeSelectedIndex(keyCodes.left)
expect(wrapper.vm.selectedIndex).toBe(1)
// Delete key
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedIndex).toBe(1)
wrapper.vm.changeSelectedIndex(keyCodes.left)
expect(wrapper.vm.selectedIndex).toBe(0)
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedIndex).toBe(0)
// Should not change index if search is dirty
wrapper.setProps({ searchInput: 'foo' })
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
expect(wrapper.vm.selectedIndex).toBe(0)
expect(wrapper.vm.selectedItems).toHaveLength(1)
wrapper.setProps({ searchInput: undefined })
// Should not proceed if keyCode doesn't match
wrapper.vm.changeSelectedIndex(99)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedIndex).toBe(0)
expect(wrapper.vm.selectedItems).toHaveLength(1)
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedItems).toHaveLength(0)
expect(wrapper.vm.selectedIndex).toBe(-1)
// Should not change/error if called with no selection
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedIndex).toBe(-1)
wrapper.setProps({ value: ['foo', 'bar', 'fizz'] })
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedItems).toHaveLength(3)
wrapper.vm.selectedIndex = 2
// Simulating removing items when an index already selected
wrapper.setProps({ value: ['foo', 'bar'] })
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedIndex).toBe(2)
// Backspace
wrapper.vm.changeSelectedIndex(keyCodes.delete)
expect(wrapper.vm.selectedIndex).toBe(-1)
})
it('should conditionally show the menu', async () => {
const wrapper = mountFunction({
attachToDocument: true,
propsData: {
items: ['foo', 'bar', 'fizz'],
},
})
const slot = wrapper.find('.v-input__slot')
const input = wrapper.find('input')
// Focus input should only focus
input.trigger('focus')
expect(wrapper.vm.isFocused).toBe(true)
expect(wrapper.vm.menuCanShow).toBe(true)
expect(wrapper.vm.isMenuActive).toBe(false)
// Clicking input should open menu
slot.trigger('click')
expect(wrapper.vm.isMenuActive).toBe(true)
expect(wrapper.vm.menuCanShow).toBe(true)
wrapper.setProps({ searchInput: 'foo' })
expect(wrapper.vm.isMenuActive).toBe(true)
expect(wrapper.vm.menuCanShow).toBe(true)
// Should close menu but keep focus
input.trigger('keydown.esc')
expect(wrapper.vm.isFocused).toBe(true)
expect(wrapper.vm.isMenuActive).toBe(false)
expect(wrapper.vm.menuCanShow).toBe(true)
// TODO: Add expects for tags when impl
})
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
it.skip('should have the correct selected item', async () => {
const wrapper = mountFunction({
propsData: {
items: ['foo', 'bar', 'fizz'],
multiple: true,
value: ['foo'],
},
})
expect(wrapper.vm.selectedItem).toBeNull()
wrapper.setProps({
multiple: false,
value: 'foo',
})
expect(wrapper.vm.selectedItem).toBe('foo')
})
it('should reset lazySearch', async () => {
const wrapper = mountFunction({
propsData: {
chips: true,
items: ['foo', 'bar', 'fizz'],
searchInput: 'foo',
},
})
expect(wrapper.vm.lazySearch).toBe('foo')
expect(wrapper.vm.hasSlot).toBe(true)
wrapper.setData({ isMenuActive: true })
await wrapper.vm.$nextTick()
wrapper.setData({ isMenuActive: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.lazySearch).toBeUndefined()
})
it('should select input text on focus', async () => {
const wrapper = mountFunction()
const select = jest.fn()
wrapper.vm.$refs.input.select = select
const input = wrapper.find('input')
input.trigger('focus')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isFocused).toBe(true)
expect(select).toHaveBeenCalledTimes(1)
input.trigger('keydown.tab')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isFocused).toBe(false)
expect(select).toHaveBeenCalledTimes(1)
})
it('should not respond to click', () => {
const onFocus = jest.fn()
const wrapper = mountFunction({
propsData: { disabled: true },
methods: { onFocus },
})
const slot = wrapper.find('.v-input__slot')
slot.trigger('click')
expect(onFocus).not.toHaveBeenCalled()
wrapper.setProps({ disabled: false, readonly: true })
slot.trigger('click')
expect(onFocus).not.toHaveBeenCalled()
wrapper.setProps({ readonly: false })
slot.trigger('click')
expect(onFocus).toHaveBeenCalled()
})
it('should react to keydown', () => {
const activateMenu = jest.fn()
const changeSelectedIndex = jest.fn()
const onEscDown = jest.fn()
const onTabDown = jest.fn()
const wrapper = mountFunction({
methods: {
activateMenu,
changeSelectedIndex,
onEscDown,
onTabDown,
},
})
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
expect(wrapper.vm.isMenuActive).toBe(false)
input.trigger('keydown.enter')
input.trigger('keydown.space')
input.trigger('keydown.up')
input.trigger('keydown.down')
expect(activateMenu).toHaveBeenCalledTimes(4)
input.trigger('keydown.esc')
expect(onEscDown).toHaveBeenCalledTimes(1)
input.trigger('keydown.tab')
expect(onTabDown).toHaveBeenCalledTimes(1)
// Skip menu activation
wrapper.setData({ isMenuActive: true })
element.value = 'foo'
input.trigger('input')
wrapper.setProps({ hideSelected: true })
expect(wrapper.vm.genSelections()).toEqual([])
})
it('should change autocomplete attribute', () => {
const wrapper = mountFunction({
attrs: {
autocomplete: 'on',
},
})
expect(wrapper.vm.$attrs.autocomplete).toBe('on')
})
it('should not delete item if readonly', async () => {
const wrapper = mountFunction({
propsData: {
items: ['a', 'b', 'c'],
multiple: true,
value: ['a', 'b', 'c'],
},
})
wrapper.vm.changeSelectedIndex(keyCodes.right)
wrapper.vm.changeSelectedIndex(keyCodes.right)
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedItems).toHaveLength(2)
wrapper.setProps({
readonly: true,
})
wrapper.vm.changeSelectedIndex(keyCodes.backspace)
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedItems).toHaveLength(2)
})
})
@@ -0,0 +1,468 @@
// Components
import VAutocomplete from '../VAutocomplete'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
import { compileToFunctions } from 'vue-template-compiler'
describe('VAutocomplete.ts', () => {
type Instance = InstanceType<typeof VAutocomplete>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
document.body.setAttribute('data-app', 'true')
mountFunction = (options = {}) => {
return mount(VAutocomplete, {
// https://github.com/vuejs/vue-test-utils/issues/1130
sync: false,
mocks: {
$vuetify: {
lang: {
t: (val: string) => val,
},
theme: {
dark: false,
},
},
},
...options,
})
}
})
// https://github.com/vuetifyjs/vuetify/issues/3793
it('should reset menu index after selection', async () => {
const wrapper = mountFunction({
propsData: {
items: ['foo', 'bar'],
value: 'foo',
},
})
expect(wrapper.vm.isMenuActive).toBe(false)
const slot = wrapper.find('.v-input__slot')
slot.trigger('click')
expect(wrapper.vm.isMenuActive).toBe(true)
expect(wrapper.vm.getMenuIndex()).toBe(-1)
})
it('should not remove a disabled item', () => {
const wrapper = mountFunction({
propsData: {
chips: true,
multiple: true,
items: [
{ text: 'foo', value: 'foo', disabled: true },
{ text: 'bar', value: 'bar' },
],
value: ['foo', 'bar'],
},
})
const chips = wrapper.find('.v-chip')
const input = wrapper.find('input')
expect(chips.element.classList.contains('v-chip--disabled')).toBe(true)
input.trigger('focus')
input.trigger('keydown.left')
expect(wrapper.vm.selectedIndex).toBe(1)
input.trigger('keydown.delete')
expect(wrapper.vm.internalValue).toEqual(['foo'])
input.trigger('keydown.delete')
expect(wrapper.vm.internalValue).toEqual(['foo'])
})
it('should not filter results', async () => {
const wrapper = mountFunction({
propsData: {
items: ['foo', 'bar'],
},
})
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
element.value = 'foo'
input.trigger('input')
expect(wrapper.vm.filteredItems).toHaveLength(1)
wrapper.setProps({ noFilter: true })
await wrapper.vm.$nextTick()
expect(wrapper.vm.filteredItems).toHaveLength(2)
})
it.skip('should hide menu when no data', async () => {
const wrapper = mountFunction()
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
input.trigger('focus')
element.value = 'foo'
input.trigger('input')
expect(wrapper.vm.menuCanShow).toBe(true)
wrapper.setProps({ hideNoData: true })
await wrapper.vm.$nextTick()
expect(wrapper.vm.menuCanShow).toBe(false)
wrapper.setProps({ hideNoData: false })
await wrapper.vm.$nextTick()
expect(wrapper.vm.menuCanShow).toBe(true)
// If we are hiding selected
// filtered will have a positive length
// but the hidden items will not show
// check to make sure when all values are
// selected to close the menu
wrapper.setProps({
hideNoData: true,
hideSelected: true,
items: [1, 2, 3, 4],
multiple: true,
value: [1, 2, 3],
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.menuCanShow).toBe(true)
wrapper.setProps({ value: [1, 2, 3, 4] })
await wrapper.vm.$nextTick()
expect(wrapper.vm.menuCanShow).toBe(false)
})
it('should not hide menu when no data but has no-data slot', async () => {
const wrapper = mountFunction({
propsData: {
combobox: true,
},
slots: {
'no-data': [compileToFunctions('<span>show me</span>')],
},
})
const input = wrapper.find('input')
input.trigger('focus')
await wrapper.vm.$nextTick()
expect(wrapper.vm.menuCanShow).toBe(true)
})
// https://github.com/vuetifyjs/vuetify/issues/2834
it('should not update search if selectedIndex is > -1', () => {
const wrapper = mountFunction()
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
input.trigger('focus')
element.value = 'foo'
input.trigger('input')
expect(wrapper.vm.internalSearch).toBe('foo')
wrapper.setData({
lazySearch: '',
selectedIndex: 0,
})
expect(wrapper.vm.internalSearch).toBe('')
element.value = 'bar'
input.trigger('input')
expect(wrapper.vm.internalSearch).toBe('')
})
it('should clear search input on clear callback', async () => {
const wrapper = mountFunction({
propsData: {
clearable: true,
items: ['foo'],
value: 'foo',
},
})
const icon = wrapper.find('.v-input__append-inner .v-icon')
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
element.value = 'foobar'
input.trigger('input')
expect(wrapper.vm.internalSearch).toBe('foobar')
icon.trigger('click')
expect(wrapper.vm.internalSearch).toBeUndefined()
})
it('should propagate content class', () => {
const wrapper = mountFunction({
propsData: {
menuProps: { contentClass: 'foobar', eager: true },
},
})
const content = wrapper.find('.v-autocomplete__content')
expect(content.element.classList.contains('foobar')).toBe(true)
})
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
it.skip('should update the displayed value when items changes', async () => {
const wrapper = mountFunction({
propsData: {
value: 1,
items: [],
},
})
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
await wrapper.vm.$nextTick()
wrapper.setProps({ items: [{ text: 'foo', value: 1 }] })
await wrapper.vm.$nextTick()
expect(element.value).toBe('foo')
})
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
it.skip('should show menu when items are added for the first time and hide-no-data is enabled', async () => {
const wrapper = mountFunction({
propsData: {
hideNoData: true,
items: [],
},
})
const input = wrapper.find('input')
input.trigger('focus')
expect(wrapper.vm.isMenuActive).toBe(false)
expect(wrapper.vm.isFocused).toBe(true)
wrapper.setProps({
items: ['Foo', 'Bar'],
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.isMenuActive).toBe(true)
})
it('should not show menu when items are updated and hide-no-data is enabled ', async () => {
const wrapper = mountFunction({
propsData: {
hideNoData: true,
items: ['Something first'],
},
})
const input = wrapper.find('input')
input.trigger('focus')
expect(wrapper.vm.isMenuActive).toBe(false)
expect(wrapper.vm.isFocused).toBe(true)
wrapper.setProps({
items: ['Foo', 'Bar'],
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.isMenuActive).toBe(false)
})
// https://github.com/vuetifyjs/vuetify/issues/5110
// TODO: this fails without sync, nextTick doesn't help
// https://github.com/vuejs/vue-test-utils/issues/1130
it.skip('should set internal search', async () => {
const wrapper = mountFunction({
propsData: {
value: undefined,
items: [0, 1, 2],
},
})
// Initial value
expect(wrapper.vm.internalSearch).toBeUndefined()
wrapper.vm.setSearch()
await wrapper.vm.$nextTick()
// !this.selectedItem
expect(wrapper.vm.internalSearch).toBeNull()
wrapper.setData({ internalSearch: undefined })
wrapper.setProps({ multiple: true, value: 1 })
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedItems).toHaveLength(1)
wrapper.vm.setSearch()
await wrapper.vm.$nextTick()
// this.multiple
expect(wrapper.vm.internalSearch).toBeNull()
wrapper.setData({ internalSearch: undefined })
wrapper.setProps({ multiple: false, value: 0 })
await wrapper.vm.$nextTick()
expect(wrapper.vm.internalSearch).toBe(0)
})
it('should auto select first', async () => {
const wrapper = mountFunction({
propsData: {
autoSelectFirst: true,
items: [
'foo',
'foobar',
'bar',
],
},
})
await wrapper.vm.$nextTick()
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
input.trigger('focus')
element.value = 'fo'
input.trigger('input')
input.trigger('keydown.enter')
await wrapper.vm.$nextTick()
expect(wrapper.vm.getMenuIndex()).toBe(0)
})
// https://github.com/vuetifyjs/vuetify/issues/4580
it('should display menu when hide-no-date and hide-selected are enabled and selected item does not match search', async () => {
const wrapper = mountFunction({
propsData: {
items: [1, 2],
value: 1,
hideNoData: true,
hideSelected: true,
},
})
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
input.trigger('focus')
await wrapper.vm.$nextTick()
element.value = '2'
input.trigger('input')
await wrapper.vm.$nextTick()
expect(wrapper.vm.menuCanShow).toBe(true)
})
it('should retain search value when item selected and multiple is enabled', async () => {
const wrapper = mountFunction({
propsData: {
items: ['Sandra Adams', 'Ali Connors', 'Trevor Hansen', 'Tucker Smith'],
multiple: true,
},
})
await wrapper.vm.$nextTick()
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
input.trigger('focus')
element.value = 't'
input.trigger('input')
wrapper.vm.selectItem('Trevor Hansen')
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedItems).toHaveLength(1)
expect(wrapper.vm.internalSearch).toBe('t')
})
it('should update render dynamically when itemText changes', async () => {
const wrapper = mountFunction({
propsData: {
returnObject: true,
itemText: 'labels.1033',
items: [
{
id: 1,
labels: { 1033: 'ID 1 English', 1036: 'ID 1 French' },
},
{
id: 2,
labels: { 1033: 'ID 2 English', 1036: 'ID 2 French' },
},
],
},
})
await wrapper.vm.$nextTick()
wrapper.vm.selectItem(wrapper.vm.items[0])
await wrapper.vm.$nextTick()
expect(wrapper.vm.internalSearch).toEqual('ID 1 English')
wrapper.setProps({ itemText: 'labels.1036' })
await wrapper.vm.$nextTick()
expect(wrapper.vm.computedItems).toHaveLength(2)
expect(wrapper.vm.internalSearch).toEqual('ID 1 French')
})
it('should not replicate html select hotkeys in v-autocomplete', async () => {
const onKeyPress = jest.fn()
const wrapper = mountFunction({
propsData: {
items: ['aaa', 'foo', 'faa'],
},
methods: { onKeyPress },
})
const input = wrapper.find('input')
input.trigger('focus')
await wrapper.vm.$nextTick()
input.trigger('keypress', { key: 'f' })
await wrapper.vm.$nextTick()
expect(onKeyPress).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,206 @@
// Components
import VAutocomplete from '../VAutocomplete'
// Utilities
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
describe('VAutocomplete.ts', () => {
type Instance = InstanceType<typeof VAutocomplete>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
document.body.setAttribute('data-app', 'true')
mountFunction = (options = {}) => {
return mount(VAutocomplete, {
...options,
mocks: {
$vuetify: {
lang: {
t: (val: string) => val,
},
theme: {
dark: false,
},
},
},
})
}
})
it('should have the correct role', async () => {
const wrapper = mountFunction()
const inputSlot = wrapper.find('.v-input__slot')
expect(inputSlot.element.getAttribute('role')).toBe('combobox')
})
// https://github.com/vuetifyjs/vuetify/issues/7259
it('should update search when same item is selected', async () => {
const wrapper = mountFunction({
propsData: {
items: ['foo'],
value: 'foo',
},
})
await wrapper.vm.$nextTick()
const input = wrapper.find('input')
const element = input.element as HTMLInputElement
expect(element.value).toBe('foo')
input.trigger('focus')
input.trigger('click')
element.value = 'fo'
input.trigger('input')
const item = wrapper.find('.v-list-item')
item.trigger('click')
await wrapper.vm.$nextTick()
expect(element.value).toBe('foo')
})
it('should copy selected item if multiple', async () => {
const wrapper = mountFunction({
propsData: {
items: ['aaa', 'bbb', 'ccc'],
value: ['aaa', 'bbb'],
chips: true,
multiple: true,
},
})
const input = wrapper.find('input')
const chip = wrapper.findAll('.v-chip').at(1)
const setData = jest.fn()
const event = {
clipboardData: {
setData,
},
preventDefault: jest.fn(),
}
input.trigger('focus')
chip.trigger('click')
wrapper.vm.onCopy(event)
expect(setData).toHaveBeenCalledWith('text/plain', 'bbb')
expect(setData).toHaveBeenCalledWith('text/vnd.vuetify.autocomplete.item+plain', 'bbb')
expect(event.preventDefault).toHaveBeenCalled()
})
it('should not copy anything if there is no selected item', async () => {
const wrapper = mountFunction({
propsData: {
items: ['aaa', 'bbb', 'ccc'],
value: ['aaa', 'bbb'],
chips: true,
multiple: true,
},
})
const input = wrapper.find('input')
const setData = jest.fn()
const event = {
clipboardData: {
setData,
},
preventDefault: jest.fn(),
}
input.trigger('focus')
wrapper.vm.onCopy(event)
expect(setData).not.toHaveBeenCalled()
})
// https://github.com/vuetifyjs/vuetify/issues/9654
// https://github.com/vuetifyjs/vuetify/issues/11639
it('should delete value when pressing backspace', () => {
const wrapper = mountFunction({
propsData: {
chips: true,
items: ['foo', 'bar', 'fizz', 'buzz'],
value: 'foo',
},
})
const input = wrapper.find('input')
input.trigger('focus')
input.trigger('keydown.backspace')
input.trigger('keydown.backspace')
expect(wrapper.vm.internalValue).toBeUndefined()
wrapper.setProps({
multiple: true,
value: ['foo', 'bar'],
})
input.trigger('keydown.backspace')
input.trigger('keydown.backspace')
expect(wrapper.vm.internalValue).toEqual(['foo'])
})
it('should not change selectedIndex to 0 when backspace is pressed', () => {
const wrapper = mountFunction({
propsData: {
items: ['f', 'b'],
value: 'f',
},
})
const input = wrapper.find('input')
input.trigger('focus')
input.trigger('keydown.backspace')
expect(wrapper.vm.selectedIndex).toBe(-1)
})
it('should close menu when append icon is clicked', async () => {
const wrapper = mountFunction({
propsData: {
items: ['foo', 'bar'],
},
})
const append = wrapper.find('.v-input__append-inner')
const slot = wrapper.find('.v-input__slot')
slot.trigger('click')
expect(wrapper.vm.isMenuActive).toBe(true)
append.trigger('mousedown')
append.trigger('mouseup')
append.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isMenuActive).toBe(false)
})
it('should open menu when append icon is clicked', async () => {
const wrapper = mountFunction({
propsData: {
items: ['foo', 'bar'],
},
})
const append = wrapper.find('.v-input__append-inner')
append.trigger('mousedown')
append.trigger('mouseup')
append.trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isMenuActive).toBe(true)
})
})
+5
View File
@@ -0,0 +1,5 @@
@import '../../styles/styles.sass';
$autocomplete-enclosed-input-margin-top: 24px !default;
$autocomplete-dense-enclosed-input-margin-top: 20px !default;
$autocomplete-focused-input: 64px !default;
+4
View File
@@ -0,0 +1,4 @@
import VAutocomplete from './VAutocomplete'
export { VAutocomplete }
export default VAutocomplete
+22
View File
@@ -0,0 +1,22 @@
@import './_variables.scss'
.v-avatar
align-items: center
border-radius: $avatar-border-radius
display: inline-flex
justify-content: center
line-height: normal
position: relative
text-align: center
vertical-align: middle
overflow: hidden
img,
svg,
.v-icon,
.v-image,
.v-responsive__content
border-radius: inherit
display: inline-flex
height: inherit
width: inherit
+60
View File
@@ -0,0 +1,60 @@
import './VAvatar.sass'
// Mixins
import Colorable from '../../mixins/colorable'
import Measurable from '../../mixins/measurable'
import Roundable from '../../mixins/roundable'
// Utilities
import { convertToUnit } from '../../util/helpers'
// Types
import { VNode } from 'vue'
import mixins from '../../util/mixins'
export default mixins(
Colorable,
Measurable,
Roundable,
/* @vue/component */
).extend({
name: 'v-avatar',
props: {
left: Boolean,
right: Boolean,
size: {
type: [Number, String],
default: 48,
},
},
computed: {
classes (): object {
return {
'v-avatar--left': this.left,
'v-avatar--right': this.right,
...this.roundedClasses,
}
},
styles (): object {
return {
height: convertToUnit(this.size),
minWidth: convertToUnit(this.size),
width: convertToUnit(this.size),
...this.measurableStyles,
}
},
},
render (h): VNode {
const data = {
staticClass: 'v-avatar',
class: this.classes,
style: this.styles,
on: this.$listeners,
}
return h('div', this.setBackgroundColor(this.color, data), this.$slots.default)
},
})
+35
View File
@@ -0,0 +1,35 @@
// Libraries
import Vue from 'vue'
// Components
import VAvatar from '../VAvatar'
// Utilities
import {
createLocalVue,
mount,
Wrapper,
} from '@vue/test-utils'
describe('VAvatar', () => {
let mountFunction: (options?: object) => Wrapper<Vue>
let localVue: typeof Vue
beforeEach(() => {
localVue = createLocalVue()
mountFunction = (options = {}) => {
return mount(VAvatar, {
localVue,
...options,
})
}
})
it('should have an v-avatar class', () => {
const wrapper = mountFunction()
expect(wrapper.classes()).toContain('v-avatar')
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VAvatar should have an v-avatar class 1`] = `
<div class="v-avatar"
style="height: 48px; min-width: 48px; width: 48px;"
>
</div>
`;
+3
View File
@@ -0,0 +1,3 @@
@import '../../styles/styles.sass';
$avatar-border-radius: 50% !default;
+4
View File
@@ -0,0 +1,4 @@
import VAvatar from './VAvatar'
export { VAvatar }
export default VAvatar
+106
View File
@@ -0,0 +1,106 @@
// Imports
@import './_variables.scss'
+theme(v-badge) using ($material)
.v-badge__badge::after
border-color: map-get($material, 'cards')
.v-badge
display: inline-block
line-height: $badge-line-height
position: relative
&__badge
border-radius: $badge-border-radius
color: $badge-color
display: inline-block
font-size: $badge-font-size
height: $badge-height
letter-spacing: $badge-letter-spacing
line-height: 1
min-width: $badge-min-width
padding: $badge-padding
pointer-events: auto
position: absolute
text-align: center
text-indent: 0
top: $badge-top
transition: $primary-transition
white-space: nowrap
+ltr()
right: $badge-right
+rtl()
left: $badge-right
.v-icon
color: inherit
font-size: $badge-font-size
margin: $badge-icon-margin
.v-img
height: $badge-font-size
width: $badge-font-size
&__wrapper
flex: 0 1
height: 100%
left: 0
pointer-events: none
position: absolute
top: 0
width: 100%
&--avatar
.v-badge__badge
padding: 0
.v-avatar
height: $badge-height !important
min-width: 0 !important
max-width: $badge-min-width !important
&--bordered
.v-badge__badge::after
border-radius: inherit
border-width: $badge-bordered-width
border-style: solid
bottom: 0
content: ''
left: 0
position: absolute
right: 0
top: 0
transform: scale(1.15)
&--dot
.v-badge__badge
border-radius: $badge-dot-border-radius
height: $badge-dot-height
min-width: 0
padding: 0
width: $badge-dot-width
&::after
border-width: $badge-dot-border-width
&--icon
.v-badge__badge
padding: $badge-icon-padding
&--inline
align-items: center
display: inline-flex
justify-content: center
.v-badge__badge,
.v-badge__wrapper
position: relative
.v-badge__wrapper
margin: $badge-wrapper-margin
&--tile
.v-badge__badge
border-radius: 0
+198
View File
@@ -0,0 +1,198 @@
// Styles
import './VBadge.sass'
// Components
import VIcon from '../VIcon/VIcon'
// Mixins
import Colorable from '../../mixins/colorable'
import Themeable from '../../mixins/themeable'
import Toggleable from '../../mixins/toggleable'
import Transitionable from '../../mixins/transitionable'
import { factory as PositionableFactory } from '../../mixins/positionable'
// Utilities
import mixins from '../../util/mixins'
import {
convertToUnit,
getSlot,
} from '../../util/helpers'
// Types
import { VNode } from 'vue'
export default mixins(
Colorable,
PositionableFactory(['left', 'bottom']),
Themeable,
Toggleable,
Transitionable,
/* @vue/component */
).extend({
name: 'v-badge',
props: {
avatar: Boolean,
bordered: Boolean,
color: {
type: String,
default: 'primary',
},
content: { required: false },
dot: Boolean,
label: {
type: String,
default: '$vuetify.badge',
},
icon: String,
inline: Boolean,
offsetX: [Number, String],
offsetY: [Number, String],
overlap: Boolean,
tile: Boolean,
transition: {
type: String,
default: 'scale-rotate-transition',
},
value: { default: true },
},
computed: {
classes (): object {
return {
'v-badge--avatar': this.avatar,
'v-badge--bordered': this.bordered,
'v-badge--bottom': this.bottom,
'v-badge--dot': this.dot,
'v-badge--icon': this.icon != null,
'v-badge--inline': this.inline,
'v-badge--left': this.left,
'v-badge--overlap': this.overlap,
'v-badge--tile': this.tile,
...this.themeClasses,
}
},
computedBottom (): string {
return this.bottom ? 'auto' : this.computedYOffset
},
computedLeft (): string {
if (this.isRtl) {
return this.left ? this.computedXOffset : 'auto'
}
return this.left ? 'auto' : this.computedXOffset
},
computedRight (): string {
if (this.isRtl) {
return this.left ? 'auto' : this.computedXOffset
}
return !this.left ? 'auto' : this.computedXOffset
},
computedTop (): string {
return this.bottom ? this.computedYOffset : 'auto'
},
computedXOffset (): string {
return this.calcPosition(this.offsetX)
},
computedYOffset (): string {
return this.calcPosition(this.offsetY)
},
isRtl (): boolean {
return this.$vuetify.rtl
},
// Default fallback if offsetX
// or offsetY are undefined.
offset (): number {
if (this.overlap) return this.dot ? 8 : 12
return this.dot ? 2 : 4
},
styles (): object {
if (this.inline) return {}
return {
bottom: this.computedBottom,
left: this.computedLeft,
right: this.computedRight,
top: this.computedTop,
}
},
},
methods: {
calcPosition (offset: string | number): string {
return `calc(100% - ${convertToUnit(offset || this.offset)})`
},
genBadge () {
const lang = this.$vuetify.lang
const label = this.$attrs['aria-label'] || lang.t(this.label)
const data = this.setBackgroundColor(this.color, {
staticClass: 'v-badge__badge',
style: this.styles,
attrs: {
'aria-atomic': this.$attrs['aria-atomic'] || 'true',
'aria-label': label,
'aria-live': this.$attrs['aria-live'] || 'polite',
title: this.$attrs.title,
role: this.$attrs.role || 'status',
},
directives: [{
name: 'show',
value: this.isActive,
}],
})
const badge = this.$createElement('span', data, [this.genBadgeContent()])
if (!this.transition) return badge
return this.$createElement('transition', {
props: {
name: this.transition,
origin: this.origin,
mode: this.mode,
},
}, [badge])
},
genBadgeContent () {
// Dot prop shows no content
if (this.dot) return undefined
const slot = getSlot(this, 'badge')
if (slot) return slot
if (this.content) return String(this.content)
if (this.icon) return this.$createElement(VIcon, this.icon)
return undefined
},
genBadgeWrapper () {
return this.$createElement('span', {
staticClass: 'v-badge__wrapper',
}, [this.genBadge()])
},
},
render (h): VNode {
const badge = [this.genBadgeWrapper()]
const children = [getSlot(this)]
const {
'aria-atomic': _x,
'aria-label': _y,
'aria-live': _z,
role,
title,
...attrs
} = this.$attrs
if (this.inline && this.left) children.unshift(badge)
else children.push(badge)
return h('span', {
staticClass: 'v-badge',
attrs,
class: this.classes,
}, children)
},
})
+133
View File
@@ -0,0 +1,133 @@
// Components
import VBadge from '../VBadge'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
import { compileToFunctions } from 'vue-template-compiler'
// Types
import { ExtractVue } from '../../../util/mixins'
describe('VBadge.ts', () => {
type Instance = ExtractVue<typeof VBadge>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VBadge, {
mocks: {
$vuetify: {
lang: { t: (text = '') => text },
},
},
...options,
})
}
})
it('should render component and match snapshot', async () => {
const wrapper = mountFunction({
slots: {
badge: [compileToFunctions('<span>content</span>')],
default: [compileToFunctions('<span>element</span>')],
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with with value=false and match snapshot', async () => {
const wrapper = mountFunction({
propsData: {
value: false,
},
slots: {
badge: [compileToFunctions('<span>content</span>')],
default: [compileToFunctions('<span>element</span>')],
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with bottom prop', () => {
const wrapper = mountFunction({
propsData: {
bottom: true,
},
})
expect(wrapper.classes('v-badge--bottom')).toBeTruthy()
})
it('should render component with left prop', () => {
const wrapper = mountFunction({
propsData: {
left: true,
},
})
expect(wrapper.classes('v-badge--left')).toBeTruthy()
})
it('should render component with overlap prop', () => {
const wrapper = mountFunction({
propsData: {
overlap: true,
},
})
expect(wrapper.classes('v-badge--overlap')).toBeTruthy()
})
it('should render component with color prop', () => {
const wrapper = mountFunction({
propsData: {
color: 'green lighten-1',
},
slots: {
badge: [compileToFunctions('<span>content</span>')],
},
})
const badge = wrapper.find('.v-badge__badge')
expect(badge.classes('green')).toBeTruthy()
expect(badge.classes('lighten-1')).toBeTruthy()
})
it('should render component with transition element', () => {
const transitionStub = {
name: 'transition',
render: jest.fn(),
}
mountFunction({
stubs: {
transition: transitionStub,
},
})
expect(transitionStub.render).toHaveBeenCalled()
})
it('should render component without transition element', () => {
const transitionStub = {
name: 'transition',
render: jest.fn(),
}
mountFunction({
propsData: {
transition: '',
},
stubs: {
transition: transitionStub,
},
})
expect(transitionStub.render).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VBadge.ts should render component and match snapshot 1`] = `
<span class="v-badge theme--light">
<span>
element
</span>
<span class="v-badge__wrapper">
<span aria-atomic="true"
aria-label="$vuetify.badge"
aria-live="polite"
role="status"
class="v-badge__badge primary"
>
<span>
content
</span>
</span>
</span>
</span>
`;
exports[`VBadge.ts should render component with with value=false and match snapshot 1`] = `
<span class="v-badge theme--light">
<span>
element
</span>
<span class="v-badge__wrapper">
<span aria-atomic="true"
aria-label="$vuetify.badge"
aria-live="polite"
role="status"
class="v-badge__badge primary"
style="display: none;"
>
<span>
content
</span>
</span>
</span>
</span>
`;
+21
View File
@@ -0,0 +1,21 @@
@import '../../styles/styles.sass';
$badge-border-radius: 10px !default;
$badge-bordered-width: 2px !default;
$badge-color: map-get($shades, 'white') !default;
$badge-dot-border-radius: 4.5px;
$badge-dot-border-width: 1.5px !default;
$badge-dot-height: 9px !default;
$badge-dot-width: 9px !default;
$badge-font-family: $body-font-family !default;
$badge-font-size: 12px !default;
$badge-height: 20px !default;
$badge-icon-margin: 0 -2px !default;
$badge-icon-padding: 4px 6px !default;
$badge-letter-spacing: 0 !default;
$badge-line-height: 1 !default;
$badge-min-width: 20px !default;
$badge-padding: 4px 6px !default;
$badge-right: auto !default;
$badge-top: auto !default;
$badge-wrapper-margin: 0 4px !default;
+4
View File
@@ -0,0 +1,4 @@
import VBadge from './VBadge'
export { VBadge }
export default VBadge
+147
View File
@@ -0,0 +1,147 @@
// Imports
@import './_variables.scss'
// Theme
+theme('v-banner.v-sheet') using ($material)
background-color: transparent
.v-banner__wrapper
border-bottom: thin solid map-get($material, 'dividers')
+sheet(v-banner, $banner-elevation, $banner-border-radius, $banner-shaped-border-radius)
// Block
.v-banner
position: relative
+elevationTransition()
// Element
.v-banner__actions
align-items: center
align-self: flex-end
display: flex
flex: 1 0 auto
justify-content: flex-end
margin-bottom: -$banner-y-padding / 2
+ltr()
margin-left: $banner-actions-start-margin
+rtl()
margin-right: $banner-actions-start-margin
& > * // Margin between actions
+ltr()
margin-left: $banner-actions-margin
+rtl()
margin-right: $banner-actions-margin
.v-banner__content
align-items: center
display: flex
flex: 1 1 auto
overflow: hidden
.v-banner__text
flex: 1 1 auto
line-height: $banner-line-height
max-width: 100%
.v-banner__icon
display: inline-flex
flex: 0 0 auto
+ltr()
margin-right: $banner-start-padding
+rtl()
margin-left: $banner-start-padding
.v-banner__wrapper
align-items: center
display: flex
flex: 1 1 auto
+ltr()
padding: $banner-y-padding $banner-end-padding $banner-y-padding $banner-start-padding
+rtl()
padding: $banner-y-padding $banner-start-padding $banner-y-padding $banner-end-padding
// Modifiers
.v-banner--single-line
.v-banner__actions
margin-bottom: 0
align-self: center
.v-banner__text
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.v-banner__wrapper
padding-top: $banner-y-padding / 2
padding-bottom: $banner-y-padding / 2
.v-banner--has-icon
.v-banner__wrapper
+ltr()
padding-left: $banner-icon-padding
+rtl()
padding-right: $banner-icon-padding
.v-banner--is-mobile
.v-banner__actions
flex: 1 0 100%
margin-left: 0
margin-right: 0
padding-top: $banner-mobile-actions-top-padding
.v-banner__wrapper
flex-wrap: wrap
padding-top: $banner-mobile-top-padding
+ltr()
padding-left: $banner-mobile-start-padding
+rtl()
padding-right: $banner-mobile-start-padding
&.v-banner--has-icon
.v-banner__wrapper
padding-top: $banner-mobile-multiline-padding
&.v-banner--single-line
.v-banner__actions
flex: initial
padding-top: 0
+ltr()
margin-left: $banner-mobile-singleline-padding
+rtl()
margin-right: $banner-mobile-singleline-padding
.v-banner__wrapper
flex-wrap: nowrap
padding-top: $banner-mobile-padding
.v-banner__icon
+ltr()
margin-right: $banner-icon-padding
+rtl()
margin-left: $banner-icon-padding
.v-banner__content
+ltr()
padding-right: $banner-content-padding
+rtl()
padding-left: $banner-content-padding
.v-banner__wrapper
flex-wrap: nowrap
padding-top: $banner-mobile-padding
+164
View File
@@ -0,0 +1,164 @@
// Styles
import './VBanner.sass'
// Extensions
import VSheet from '../VSheet'
// Components
import VAvatar from '../VAvatar'
import VIcon from '../VIcon'
import { VExpandTransition } from '../transitions'
// Mixins
import Mobile from '../../mixins/mobile'
import Toggleable from '../../mixins/toggleable'
// Utilities
import mixins from '../../util/mixins'
import {
convertToUnit,
getSlot,
} from '../../util/helpers'
// Typeslint
import { VNode } from 'vue'
/* @vue/component */
export default mixins(
VSheet,
Mobile,
Toggleable
).extend({
name: 'v-banner',
inheritAttrs: false,
props: {
app: Boolean,
icon: String,
iconColor: String,
singleLine: Boolean,
sticky: Boolean,
value: {
type: Boolean,
default: true,
},
},
computed: {
classes (): object {
return {
...VSheet.options.computed.classes.call(this),
'v-banner--has-icon': this.hasIcon,
'v-banner--is-mobile': this.isMobile,
'v-banner--single-line': this.singleLine,
'v-banner--sticky': this.isSticky,
}
},
hasIcon (): boolean {
return Boolean(this.icon || this.$slots.icon)
},
isSticky (): boolean {
return this.sticky || this.app
},
styles (): object {
const styles: Record<string, any> = { ...VSheet.options.computed.styles.call(this) }
if (this.isSticky) {
const top = !this.app
? 0
: (this.$vuetify.application.bar + this.$vuetify.application.top)
styles.top = convertToUnit(top)
styles.position = 'sticky'
styles.zIndex = 1
}
return styles
},
},
methods: {
/** @public */
toggle () {
this.isActive = !this.isActive
},
iconClick (e: MouseEvent) {
this.$emit('click:icon', e)
},
genIcon () {
if (!this.hasIcon) return undefined
let content
if (this.icon) {
content = this.$createElement(VIcon, {
props: {
color: this.iconColor,
size: 28,
},
}, [this.icon])
} else {
content = this.$slots.icon
}
return this.$createElement(VAvatar, {
staticClass: 'v-banner__icon',
props: {
color: this.color,
size: 40,
},
on: {
click: this.iconClick,
},
}, [content])
},
genText () {
return this.$createElement('div', {
staticClass: 'v-banner__text',
}, this.$slots.default)
},
genActions () {
const children = getSlot(this, 'actions', {
dismiss: () => this.isActive = false,
})
if (!children) return undefined
return this.$createElement('div', {
staticClass: 'v-banner__actions',
}, children)
},
genContent () {
return this.$createElement('div', {
staticClass: 'v-banner__content',
}, [
this.genIcon(),
this.genText(),
])
},
genWrapper () {
return this.$createElement('div', {
staticClass: 'v-banner__wrapper',
}, [
this.genContent(),
this.genActions(),
])
},
},
render (h): VNode {
return h(VExpandTransition, [
h('div', this.setBackgroundColor(this.color, {
staticClass: 'v-banner',
attrs: this.attrs$,
class: this.classes,
style: this.styles,
directives: [{
name: 'show',
value: this.isActive,
}],
}), [this.genWrapper()]),
])
},
})
+228
View File
@@ -0,0 +1,228 @@
// Components
import VBanner from '../VBanner'
// Services
import { Breakpoint } from '../../../services/breakpoint'
import { preset } from '../../../presets/default'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
// Types
import { ExtractVue } from '../../../util/mixins'
describe('VBanner.ts', () => {
type Instance = ExtractVue<typeof VBanner>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VBanner, {
...options,
mocks: {
$vuetify: {
application: {
top: 0,
bar: 0,
},
breakpoint: {
mobile: true,
mobileBreakpoint: 1264,
width: 1000,
},
},
},
})
}
})
it('should render component with content', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render sinle-line component with content', () => {
const wrapper = mountFunction({
props: {
singleLine: true,
},
slots: {
default: 'Hello, World!',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with icon', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
propsData: {
icon: 'mdi-plus',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with icon slot', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
icon: { render: h => h('span', ['icon']) },
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with actions', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
actions: { render: h => h('div', [h('button', ['OK']), h('button', ['Cancel'])]) },
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should emit click:icon event', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
propsData: {
icon: 'mdi-plus',
},
})
const fn = jest.fn()
wrapper.vm.$on('click:icon', fn)
const icon = wrapper.find('.v-banner__icon')
// expect(fn).not.toHaveBeenCalled()
icon.trigger('click')
// expect(fn).toHaveBeenCalled()
})
it('should not render icon container if icon property and slot aren\'t passed', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
})
expect(wrapper.findAll('.v-banner__icon')).toHaveLength(0)
})
it('should not render actions container if slot isn\'t passed', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
})
expect(wrapper.findAll('.v-banner__actions')).toHaveLength(0)
})
it('should render icon, content and actions containers', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
icon: 'Hello, World!',
actions: 'Hello, World!',
},
})
expect(wrapper.findAll('.v-banner__content')).toHaveLength(1)
expect(wrapper.findAll('.v-banner__icon')).toHaveLength(1)
expect(wrapper.findAll('.v-banner__actions')).toHaveLength(1)
})
it('should toggle', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
})
expect(wrapper.vm.isActive).toBeTruthy()
wrapper.vm.toggle()
expect(wrapper.vm.isActive).toBeFalsy()
})
it('should be dismissable', () => {
const wrapper = mountFunction({
slots: {
default: 'Hello, World!',
},
scopedSlots: {
actions (props) {
return this.$createElement('div', {
on: {
click: props.dismiss,
},
staticClass: 'test',
})
},
},
})
const test = wrapper.find('.test')
expect(wrapper.vm.isActive).toBeTruthy()
test.trigger('click')
expect(wrapper.vm.isActive).toBeFalsy()
})
it('should be responsive', () => {
const wrapper = mount(VBanner, {
slots: {
default: 'Hello, World!',
},
mocks: {
$vuetify: {
breakpoint: new Breakpoint(preset),
},
},
})
expect(wrapper.classes('v-banner--is-mobile')).toBeTruthy()
})
it('should apply sticky when using the app prop', () => {
const wrapper = mountFunction({
propsData: { app: true },
})
expect(wrapper.vm.isSticky).toBe(true)
expect(wrapper.html()).toMatchSnapshot()
wrapper.setProps({
app: false,
sticky: true,
})
expect(wrapper.vm.isSticky).toBe(true)
expect(wrapper.html()).toMatchSnapshot()
wrapper.setProps({ sticky: false })
expect(wrapper.vm.isSticky).toBe(false)
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VBanner.ts should apply sticky when using the app prop 1`] = `
<div class="v-banner v-sheet theme--light v-banner--is-mobile v-banner--sticky"
style="top: 0px; position: sticky; z-index: 1;"
>
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-banner__text">
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should apply sticky when using the app prop 2`] = `
<div class="v-banner v-sheet theme--light v-banner--is-mobile v-banner--sticky"
style="top: 0px; position: sticky; z-index: 1;"
>
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-banner__text">
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should apply sticky when using the app prop 3`] = `
<div class="v-banner v-sheet theme--light v-banner--is-mobile"
style
>
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-banner__text">
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should render component with actions 1`] = `
<div class="v-banner v-sheet theme--light v-banner--is-mobile">
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-banner__text">
Hello, World!
</div>
</div>
<div class="v-banner__actions">
<div>
<button>
OK
</button>
<button>
Cancel
</button>
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should render component with content 1`] = `
<div class="v-banner v-sheet theme--light v-banner--is-mobile">
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-banner__text">
Hello, World!
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should render component with icon 1`] = `
<div class="v-banner v-sheet theme--light v-banner--has-icon v-banner--is-mobile">
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-avatar v-banner__icon"
style="height: 40px; min-width: 40px; width: 40px;"
>
<i aria-hidden="true"
class="v-icon notranslate mdi mdi-plus theme--light"
style="font-size: 28px;"
>
</i>
</div>
<div class="v-banner__text">
Hello, World!
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should render component with icon slot 1`] = `
<div class="v-banner v-sheet theme--light v-banner--has-icon v-banner--is-mobile">
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-avatar v-banner__icon"
style="height: 40px; min-width: 40px; width: 40px;"
>
<span>
icon
</span>
</div>
<div class="v-banner__text">
Hello, World!
</div>
</div>
</div>
</div>
`;
exports[`VBanner.ts should render sinle-line component with content 1`] = `
<div class="v-banner v-sheet theme--light v-banner--is-mobile">
<div class="v-banner__wrapper">
<div class="v-banner__content">
<div class="v-banner__text">
Hello, World!
</div>
</div>
</div>
</div>
`;
+19
View File
@@ -0,0 +1,19 @@
@import '../../styles/styles.sass';
$banner-actions-start-margin: 90px !default;
$banner-actions-margin: 8px !default;
$banner-border-radius: 0 !default;
$banner-elevation: 0 !default;
$banner-line-height: 20px !default;
$banner-start-padding: 24px !default;
$banner-end-padding: 8px !default;
$banner-y-padding: 16px !default;
$banner-icon-padding: 16px !default;
$banner-content-padding: 8px !default;
$banner-mobile-padding: 10px !default;
$banner-mobile-multiline-padding: 24px !default;
$banner-mobile-start-padding: 16px !default;
$banner-mobile-top-padding: 16px !default;
$banner-mobile-actions-top-padding: 12px !default;
$banner-mobile-singleline-padding: 36px !default;
$banner-shaped-border-radius: map-get($rounded, 'xl') $banner-border-radius !default;
+4
View File
@@ -0,0 +1,4 @@
import VBanner from './VBanner'
export { VBanner }
export default VBanner
@@ -0,0 +1,92 @@
@import './_variables.scss'
// Theme
+theme(v-bottom-navigation) using ($material)
background-color: map-get($material, 'bottom-navigation')
color: map-deep-get($material, 'text', 'primary')
.v-btn:not(.v-btn--active)
color: map-deep-get($material, 'text', 'secondary') !important
// Block
.v-item-group.v-bottom-navigation
bottom: 0
display: flex
left: 0
justify-content: center
width: 100%
+elevation(4)
.v-btn:not(.v-btn--flat):not(.v-btn--text):not(.v-btn--outlined)
background-color: transparent
.v-btn
border-radius: 0
box-shadow: none
flex: 0 1 auto
font-size: $bottom-nav-btn-font-size
height: inherit
max-width: $bottom-nav-btn-max-width
min-width: $bottom-nav-btn-min-width
position: relative
text-transform: none
&:after
content: none
.v-btn__content
flex-direction: column-reverse
height: inherit
> *:not(.v-icon)
line-height: 1.2
&.v-btn--active
color: inherit
&:not(:hover):before
opacity: 0
// Modifier
.v-item-group.v-bottom-navigation--absolute,
.v-item-group.v-bottom-navigation--fixed
z-index: 4
.v-item-group.v-bottom-navigation--absolute
position: absolute
.v-item-group.v-bottom-navigation--active
transform: translate(0, 0)
.v-item-group.v-bottom-navigation--fixed
position: fixed
.v-item-group.v-bottom-navigation--grow
.v-btn
width: 100%
.v-item-group.v-bottom-navigation--horizontal
.v-btn > .v-btn__content
flex-direction: row-reverse
> .v-icon
margin-bottom: 0
margin-right: 16px
.v-item-group.v-bottom-navigation--shift
.v-btn .v-btn__content > *:not(.v-icon)
opacity: 0
position: absolute
top: $bottom-nav-shift-btn-top
transform: scale(.9)
transition: $primary-transition
.v-btn--active .v-btn__content
> .v-icon
transform: translateY(-8px)
> *:not(.v-icon)
opacity: 1
top: $bottom-nav-shift-btn-active-top
transform: scale(1)
@@ -0,0 +1,140 @@
// Styles
import './VBottomNavigation.sass'
// Mixins
import Applicationable from '../../mixins/applicationable'
import ButtonGroup from '../../mixins/button-group'
import Colorable from '../../mixins/colorable'
import Measurable from '../../mixins/measurable'
import Proxyable from '../../mixins/proxyable'
import Scrollable from '../../mixins/scrollable'
import Themeable from '../../mixins/themeable'
import { factory as ToggleableFactory } from '../../mixins/toggleable'
// Utilities
import mixins from '../../util/mixins'
import { breaking } from '../../util/console'
// Types
import { VNode } from 'vue'
export default mixins(
Applicationable('bottom', [
'height',
'inputValue',
]),
Colorable,
Measurable,
ToggleableFactory('inputValue'),
Proxyable,
Scrollable,
Themeable
/* @vue/component */
).extend({
name: 'v-bottom-navigation',
props: {
activeClass: {
type: String,
default: 'v-btn--active',
},
backgroundColor: String,
grow: Boolean,
height: {
type: [Number, String],
default: 56,
},
hideOnScroll: Boolean,
horizontal: Boolean,
inputValue: {
type: Boolean,
default: true,
},
mandatory: Boolean,
shift: Boolean,
},
data () {
return {
isActive: this.inputValue,
}
},
computed: {
canScroll (): boolean {
return (
Scrollable.options.computed.canScroll.call(this) &&
(
this.hideOnScroll ||
!this.inputValue
)
)
},
classes (): object {
return {
'v-bottom-navigation--absolute': this.absolute,
'v-bottom-navigation--grow': this.grow,
'v-bottom-navigation--fixed': !this.absolute && (this.app || this.fixed),
'v-bottom-navigation--horizontal': this.horizontal,
'v-bottom-navigation--shift': this.shift,
}
},
styles (): object {
return {
...this.measurableStyles,
transform: this.isActive ? 'none' : 'translateY(100%)',
}
},
},
created () {
/* istanbul ignore next */
if (this.$attrs.hasOwnProperty('active')) {
breaking('active.sync', 'value or v-model', this)
}
},
methods: {
thresholdMet () {
this.isActive = !this.isScrollingUp
this.$emit('update:input-value', this.isActive)
},
updateApplication (): number {
return this.$el
? this.$el.clientHeight
: 0
},
updateValue (val: any) {
this.$emit('change', val)
},
},
render (h): VNode {
const data = this.setBackgroundColor(this.backgroundColor, {
staticClass: 'v-bottom-navigation',
class: this.classes,
style: this.styles,
props: {
activeClass: this.activeClass,
mandatory: Boolean(
this.mandatory ||
this.value !== undefined
),
value: this.internalValue,
},
on: { change: this.updateValue },
})
if (this.canScroll) {
data.directives = data.directives || []
data.directives.push({
arg: this.scrollTarget,
name: 'scroll',
value: this.onScroll,
})
}
return h(ButtonGroup, this.setTextColor(this.color, data), this.$slots.default)
},
})
@@ -0,0 +1,136 @@
// Libraries
import Vue from 'vue'
// Components
import VBottomNavigation from '../VBottomNavigation'
import VBtn from '../../VBtn/VBtn'
// Utilities
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
function createBtn (val = null) {
const options = {
attrs: {},
props: { text: true },
}
if (val) options.attrs = { value: val }
return Vue.component('test', {
render (h) {
return h(VBtn, options)
},
})
}
describe('VBottomNavigation.ts', () => {
type Instance = InstanceType<typeof VBottomNavigation>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options: MountOptions<Instance> = {}) => {
return mount(VBottomNavigation, {
mocks: {
$vuetify: {
application: {
bottom: 0,
register: () => {},
unregister: () => {},
},
},
},
...options,
})
}
})
it('should be visible with a true value', async () => {
const wrapper = mountFunction({
propsData: { inputValue: true },
slots: {
default: [VBtn, VBtn],
},
})
await wrapper.vm.$nextTick()
expect(wrapper.vm.styles).toMatchSnapshot()
expect(wrapper.html()).toMatchSnapshot()
wrapper.setProps({ inputValue: false })
expect(wrapper.vm.styles).toMatchSnapshot()
expect(wrapper.html()).toMatchSnapshot()
})
it('should update application when height or inputValue changes', () => {
const wrapper = mountFunction({
propsData: {
app: true,
},
slots: {
default: [VBtn, VBtn],
},
})
const spy = jest.spyOn(wrapper.vm, 'updateApplication')
wrapper.setProps({ height: 80 })
expect(spy).toHaveBeenCalled()
wrapper.setProps({ inputValue: false })
expect(spy).toHaveBeenCalledTimes(2)
})
it('should fire an event and activate/deactivate when reached threshold', async () => {
const updateInputValue = jest.fn()
const wrapper = mountFunction()
wrapper.vm.$on('update:input-value', updateInputValue)
expect(updateInputValue).not.toHaveBeenCalled()
// Scrolling down
wrapper.vm.currentScroll = 1000
wrapper.vm.previousScroll = 900
wrapper.vm.isScrollingUp = false
wrapper.vm.thresholdMet()
expect(updateInputValue).toHaveBeenCalled()
expect(wrapper.vm.isActive).toBeTruthy()
// Scrolling down
wrapper.vm.currentScroll = 900
wrapper.vm.previousScroll = 1000
wrapper.vm.isScrollingUp = true
wrapper.vm.thresholdMet()
expect(updateInputValue).toHaveBeenCalled()
expect(wrapper.vm.isActive).toBeFalsy()
})
it('should fire change event when updated', () => {
const change = jest.fn()
const wrapper = mountFunction({
propsData: {
app: true,
},
slots: {
default: [VBtn, VBtn],
},
listeners: {
change,
},
})
expect(change).not.toHaveBeenCalled()
wrapper.find('button').trigger('click')
expect(change).toHaveBeenCalled()
})
})
@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VBottomNavigation.ts should be visible with a true value 1`] = `
Object {
"height": "56px",
"transform": "none",
}
`;
exports[`VBottomNavigation.ts should be visible with a true value 2`] = `
<div class="v-bottom-navigation v-item-group theme--light"
style="height: 56px; transform: none;"
>
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</button>
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</button>
</div>
`;
exports[`VBottomNavigation.ts should be visible with a true value 3`] = `
Object {
"height": "56px",
"transform": "translateY(100%)",
}
`;
exports[`VBottomNavigation.ts should be visible with a true value 4`] = `
<div class="v-bottom-navigation v-item-group theme--light"
style="height: 56px; transform: translateY(100%);"
>
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</button>
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</button>
</div>
`;
@@ -0,0 +1,7 @@
@import '../../styles/styles.sass';
$bottom-nav-btn-font-size: map-deep-get($headings, 'caption', 'size') !default;
$bottom-nav-btn-min-width: 80px !default;
$bottom-nav-btn-max-width: 168px !default;
$bottom-nav-shift-btn-top: calc(100% - 12px) !default;
$bottom-nav-shift-btn-active-top: calc(100% - 22px) !default;
+4
View File
@@ -0,0 +1,4 @@
import VBottomNavigation from './VBottomNavigation'
export { VBottomNavigation }
export default VBottomNavigation
+23
View File
@@ -0,0 +1,23 @@
@import './_variables.scss'
// Transition
.bottom-sheet-transition
&-enter
transform: translateY(100%)
&-leave-to
transform: translateY(100%)
// Block
.v-bottom-sheet.v-dialog
align-self: flex-end
border-radius: 0
flex: 0 1 auto
margin: 0
overflow: visible
&.v-bottom-sheet--inset
max-width: $bottom-sheet-inset-width
@media #{map-get($display-breakpoints, 'xs-only')}
max-width: none
+31
View File
@@ -0,0 +1,31 @@
import './VBottomSheet.sass'
// Extensions
import VDialog from '../VDialog/VDialog'
/* @vue/component */
export default VDialog.extend({
name: 'v-bottom-sheet',
props: {
inset: Boolean,
maxWidth: {
type: [String, Number],
default: 'auto',
},
transition: {
type: String,
default: 'bottom-sheet-transition',
},
},
computed: {
classes (): object {
return {
...VDialog.options.computed.classes.call(this),
'v-bottom-sheet': true,
'v-bottom-sheet--inset': this.inset,
}
},
},
})
+3
View File
@@ -0,0 +1,3 @@
@import '../../styles/styles.sass';
$bottom-sheet-inset-width: 70% !default;
+4
View File
@@ -0,0 +1,4 @@
import VBottomSheet from './VBottomSheet'
export { VBottomSheet }
export default VBottomSheet
+45
View File
@@ -0,0 +1,45 @@
@import './_variables'
// Theme
+theme(v-breadcrumbs) using ($material)
.v-breadcrumbs__divider, .v-breadcrumbs__item--disabled
color: map-deep-get($material, 'text', 'disabled')
// Block
.v-breadcrumbs
align-items: center
display: flex
flex-wrap: wrap
flex: $breadcrumbs-flex
list-style-type: none
margin: $breadcrumbs-margin
padding: $breadcrumbs-padding
li
align-items: center
display: inline-flex
font-size: $breadcrumbs-item-font-size
.v-icon
font-size: $breadcrumbs-item-large-font-size
&:nth-child(even)
padding: $breadcrumbs-even-child-padding
// Element
.v-breadcrumbs__item
align-items: center
display: inline-flex
text-decoration: none
transition: $primary-transition
&--disabled
pointer-events: none
// Modifier
.v-breadcrumbs--large
li
font-size: $breadcrumbs-item-large-font-size
.v-icon
font-size: $breadcrumbs-item-large-font-size
+76
View File
@@ -0,0 +1,76 @@
// Styles
import './VBreadcrumbs.sass'
// Types
import { VNode, PropType } from 'vue'
// Components
import VBreadcrumbsItem from './VBreadcrumbsItem'
import VBreadcrumbsDivider from './VBreadcrumbsDivider'
// Mixins
import Themeable from '../../mixins/themeable'
// Utils
import mixins from '../../util/mixins'
export default mixins(
Themeable
/* @vue/component */
).extend({
name: 'v-breadcrumbs',
props: {
divider: {
type: String,
default: '/',
},
items: {
type: Array as PropType<any[]>,
default: () => ([]),
},
large: Boolean,
},
computed: {
classes (): object {
return {
'v-breadcrumbs--large': this.large,
...this.themeClasses,
}
},
},
methods: {
genDivider () {
return this.$createElement(VBreadcrumbsDivider, this.$slots.divider ? this.$slots.divider : this.divider)
},
genItems () {
const items = []
const hasSlot = !!this.$scopedSlots.item
const keys = []
for (let i = 0; i < this.items.length; i++) {
const item = this.items[i]
keys.push(item.text)
if (hasSlot) items.push(this.$scopedSlots.item!({ item }))
else items.push(this.$createElement(VBreadcrumbsItem, { key: keys.join('.'), props: item }, [item.text]))
if (i < this.items.length - 1) items.push(this.genDivider())
}
return items
},
},
render (h): VNode {
const children = this.$slots.default || this.genItems()
return h('ul', {
staticClass: 'v-breadcrumbs',
class: this.classes,
}, children)
},
})
@@ -0,0 +1,3 @@
import { createSimpleFunctional } from '../../util/helpers'
export default createSimpleFunctional('v-breadcrumbs__divider', 'li')
+45
View File
@@ -0,0 +1,45 @@
import Routable from '../../mixins/routable'
import mixins from '../../util/mixins'
import { VNode } from 'vue'
/* @vue/component */
export default mixins(Routable).extend({
name: 'v-breadcrumbs-item',
props: {
// In a breadcrumb, the currently
// active item should be dimmed
activeClass: {
type: String,
default: 'v-breadcrumbs__item--disabled',
},
ripple: {
type: [Boolean, Object],
default: false,
},
},
computed: {
classes (): object {
return {
'v-breadcrumbs__item': true,
[this.activeClass]: this.disabled,
}
},
},
render (h): VNode {
const { tag, data } = this.generateRouteLink()
return h('li', [
h(tag, {
...data,
attrs: {
...data.attrs,
'aria-current': this.isActive && this.isLink ? 'page' : undefined,
},
}, this.$slots.default),
])
},
})
@@ -0,0 +1,98 @@
// Components
import VBreadcrumbs from '../VBreadcrumbs'
import VBreadcrumbsItem from '../VBreadcrumbsItem'
// Utilities
import { compileToFunctions } from 'vue-template-compiler'
import {
mount,
Wrapper,
} from '@vue/test-utils'
describe('VBreadcrumbs.ts', () => {
type Instance = InstanceType<typeof VBreadcrumbs>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VBreadcrumbs, {
...options,
})
}
})
it('should have breadcrumbs classes', () => {
const wrapper = mount(VBreadcrumbs)
expect(wrapper.classes('v-breadcrumbs')).toBe(true)
expect(wrapper.html()).toMatchSnapshot()
})
it('should render items without slot', () => {
const wrapper = mountFunction({
propsData: {
items: [
{ text: 'a' },
{ text: 'b' },
{ text: 'c' },
{ text: 'd' },
],
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should not complain about identical keys', () => {
mountFunction({
propsData: {
items: [
{ text: 'a' },
{ text: 'a' },
],
},
})
expect(`Duplicate keys detected: 'a'`).not.toHaveBeenWarned()
})
it('should use slot to render items if present', () => {
const wrapper = mountFunction({
propsData: {
items: [
{ text: 'a' },
{ text: 'b' },
{ text: 'c' },
{ text: 'd' },
],
},
scopedSlots: {
item (props) {
return this.$createElement(VBreadcrumbsItem, {
key: props.item.text,
}, props.item.text.toUpperCase())
},
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should use a custom divider slot', () => {
const wrapper = mountFunction({
propsData: {
items: [
{ text: 'a' },
{ text: 'b' },
{ text: 'c' },
{ text: 'd' },
],
},
slots: {
divider: '/divider/',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,40 @@
// Components
import VBreadcrumbsItem from '../VBreadcrumbsItem'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
describe('VBreadcrumbsItem.ts', () => {
type Instance = InstanceType<typeof VBreadcrumbsItem>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VBreadcrumbsItem, {
...options,
})
}
})
it('should render component and match snapshot', () => {
const wrapper = mountFunction()
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with active & link state and match snapshot', () => {
const wrapper = mountFunction({
propsData: {
link: true,
},
})
wrapper.setData({
isActive: true,
})
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VBreadcrumbs.ts should have breadcrumbs classes 1`] = `
<ul class="v-breadcrumbs theme--light">
</ul>
`;
exports[`VBreadcrumbs.ts should render items without slot 1`] = `
<ul class="v-breadcrumbs theme--light">
<li>
<div class="v-breadcrumbs__item">
a
</div>
</li>
<li class="v-breadcrumbs__divider">
/
</li>
<li>
<div class="v-breadcrumbs__item">
b
</div>
</li>
<li class="v-breadcrumbs__divider">
/
</li>
<li>
<div class="v-breadcrumbs__item">
c
</div>
</li>
<li class="v-breadcrumbs__divider">
/
</li>
<li>
<div class="v-breadcrumbs__item">
d
</div>
</li>
</ul>
`;
exports[`VBreadcrumbs.ts should use a custom divider slot 1`] = `
<ul class="v-breadcrumbs theme--light">
<li>
<div class="v-breadcrumbs__item">
a
</div>
</li>
<li class="v-breadcrumbs__divider">
/divider/
</li>
<li>
<div class="v-breadcrumbs__item">
b
</div>
</li>
<li class="v-breadcrumbs__divider">
/divider/
</li>
<li>
<div class="v-breadcrumbs__item">
c
</div>
</li>
<li class="v-breadcrumbs__divider">
/divider/
</li>
<li>
<div class="v-breadcrumbs__item">
d
</div>
</li>
</ul>
`;
exports[`VBreadcrumbs.ts should use slot to render items if present 1`] = `
<ul class="v-breadcrumbs theme--light">
<li>
<div class="v-breadcrumbs__item">
A
</div>
</li>
<li class="v-breadcrumbs__divider">
/
</li>
<li>
<div class="v-breadcrumbs__item">
B
</div>
</li>
<li class="v-breadcrumbs__divider">
/
</li>
<li>
<div class="v-breadcrumbs__item">
C
</div>
</li>
<li class="v-breadcrumbs__divider">
/
</li>
<li>
<div class="v-breadcrumbs__item">
D
</div>
</li>
</ul>
`;
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VBreadcrumbsItem.ts should render component and match snapshot 1`] = `
<li>
<div class="v-breadcrumbs__item">
</div>
</li>
`;
exports[`VBreadcrumbsItem.ts should render component with active & link state and match snapshot 1`] = `
<li>
<div class="v-breadcrumbs__item"
aria-current="page"
>
</div>
</li>
`;
+9
View File
@@ -0,0 +1,9 @@
@import '../../styles/styles.sass';
$breadcrumbs-flex: 0 1 auto !default;
$breadcrumbs-padding: 18px 12px !default;
$breadcrumbs-even-child-padding: 0 12px !default;
$breadcrumbs-item-font-size: 14px !default;
$breadcrumbs-item-large-font-size: 16px !default;
$breadcrumbs-margin: 0 !default;
$breadcrumbs-padding: 0 14px !default;
+13
View File
@@ -0,0 +1,13 @@
import VBreadcrumbs from './VBreadcrumbs'
import VBreadcrumbsItem from './VBreadcrumbsItem'
import VBreadcrumbsDivider from './VBreadcrumbsDivider'
export { VBreadcrumbs, VBreadcrumbsItem, VBreadcrumbsDivider }
export default {
$_vuetify_subcomponents: {
VBreadcrumbs,
VBreadcrumbsItem,
VBreadcrumbsDivider,
},
}
+272
View File
@@ -0,0 +1,272 @@
// Imports
@import './_variables.scss'
// Theme
.v-btn:not(.v-btn--outlined)
&.primary,
&.secondary,
&.accent,
&.success,
&.error,
&.warning,
&.info
color: map-deep-get($material-dark, 'text', 'primary')
+theme(v-btn) using ($material)
color: map-deep-get($material, 'text', 'primary')
&.v-btn--disabled
color: map-deep-get($material, 'buttons', 'disabled') !important
.v-icon,
.v-btn__loading
color: map-deep-get($material, 'buttons', 'disabled') !important
&:not(.v-btn--flat):not(.v-btn--text):not(.v-btn--outlined)
background-color: map-deep-get($material, 'buttons', 'focused') !important
&:not(.v-btn--flat):not(.v-btn--text):not(.v-btn--outlined)
background-color: map-get($material, 'app-bar')
&.v-btn--outlined.v-btn--text
border-color: map-get($material, 'dividers')
&.v-btn--icon
color: map-deep-get($material, 'icons', 'active')
+states($material)
// Block
.v-btn
align-items: center
border-radius: $btn-border-radius
display: inline-flex
flex: 0 0 auto
font-weight: $btn-font-weight
letter-spacing: $btn-letter-spacing
justify-content: center
outline: 0
position: relative
text-decoration: none
text-indent: $btn-letter-spacing
text-transform: $btn-text-transform
transition-duration: $btn-transition-duration
transition-property: box-shadow, transform, opacity
transition-timing-function: $btn-transition-fn
user-select: none
vertical-align: middle
white-space: nowrap
@each $name, $size in $btn-font-sizes
&.v-size--#{$name}
font-size: $size
&:before
border-radius: inherit
bottom: 0
color: inherit
content: ''
left: 0
opacity: 0
pointer-events: none
position: absolute
right: 0
top: 0
transition: $btn-transition
&:before
background-color: currentColor
&:not(.v-btn--disabled)
will-change: box-shadow
&:not(.v-btn--round)
@each $name, $size in $btn-sizes
&.v-size--#{$name}
height: #{$size}px
min-width: #{round($size * 1.777777777777778)}px // default ratio
padding: 0 #{$size / 2.25}px
> .v-btn__content .v-icon
color: inherit
// Elements
.v-btn__content
align-items: center
color: inherit
display: flex
// https://github.com/vuetifyjs/vuetify/issues/7580
flex: 1 0 auto
justify-content: inherit
line-height: normal
// Fixes bug where IE11 moves
// button content when clicked
// https://stackoverflow.com/questions/10305658/prevent-button-from-shifting-during-click-in-ie
position: relative
.v-icon--left,
.v-icon--right
font-size: $btn-icon-font-size
height: $btn-icon-font-size
width: $btn-icon-font-size
.v-icon--left
+ltr()
margin-left: -4px
margin-right: 8px
+rtl()
margin-left: 8px
margin-right: -4px
.v-icon--right
+ltr()
margin-left: 8px
margin-right: -4px
+rtl()
margin-left: -4px
margin-right: 8px
.v-btn__loader
align-items: center
display: flex
height: 100%
justify-content: center
left: 0
position: absolute
top: 0
width: 100%
// Modifiers
.v-btn:not(.v-btn--text):not(.v-btn--outlined)
&.v-btn--active:before
opacity: $btn-active-opacity
&:hover:before
opacity: $btn-hover-opacity
&:focus:before
opacity: $btn-focus-opacity
.v-btn--absolute,
.v-btn--fixed
position: absolute
&.v-btn--right
right: map-get($grid-gutters, 'lg')
&.v-btn--left
left: map-get($grid-gutters, 'lg')
&.v-btn--top
top: map-get($grid-gutters, 'lg')
&.v-btn--bottom
bottom: map-get($grid-gutters, 'lg')
.v-btn--block
display: flex
flex: 1 0 auto
min-width: 100% !important
max-width: auto
.v-btn--contained
+elevation(2)
&:after
+elevation(4)
&:active
+elevation(8)
.v-btn--depressed
box-shadow: none !important
.v-btn--disabled
box-shadow: none
pointer-events: none
.v-btn--icon,
.v-btn--fab
min-height: 0
min-width: 0
padding: 0
@each $name, $size in $fab-icon-sizes
&.v-size--#{$name}
.v-icon
height: #{$size}px
font-size: #{$size}px
width: #{$size}px
.v-btn--icon
@each $name, $size in $btn-sizes
&.v-size--#{$name}
height: #{$size}px
width: #{$size}px
.v-btn--fab
&.v-btn--contained
+elevation(6)
&:after
+elevation(8)
&:active
+elevation(12)
&.v-btn--fixed,
&.v-btn--absolute
z-index: 4
@each $name, $size in $fab-sizes
&.v-size--#{$name}
height: #{$size}px
width: #{$size}px
&.v-btn--absolute
&.v-btn--bottom
bottom: -#{$size / 2}px
&.v-btn--top
top: -#{$size / 2}px
.v-btn--fixed
position: fixed
.v-btn--loading
pointer-events: none
transition: none
.v-btn__content
opacity: 0
.v-btn--outlined
border: $btn-outline-border-width solid currentColor
.v-btn--outlined,
.v-btn--round
.v-btn__content
.v-icon
color: currentColor
.v-btn--outlined,
.v-btn--flat,
.v-btn--text
background-color: transparent
.v-btn--outlined,
.v-btn--round,
.v-btn--rounded
&:before
border-radius: inherit
.v-btn--round
border-radius: 50%
.v-btn--rounded
border-radius: $btn-rounded-border-radius
.v-btn--tile
border-radius: 0
+198
View File
@@ -0,0 +1,198 @@
// Styles
import './VBtn.sass'
// Extensions
import VSheet from '../VSheet'
// Components
import VProgressCircular from '../VProgressCircular'
// Mixins
import { factory as GroupableFactory } from '../../mixins/groupable'
import { factory as ToggleableFactory } from '../../mixins/toggleable'
import Positionable from '../../mixins/positionable'
import Routable from '../../mixins/routable'
import Sizeable from '../../mixins/sizeable'
// Utilities
import mixins, { ExtractVue } from '../../util/mixins'
import { breaking } from '../../util/console'
// Types
import { VNode } from 'vue'
import { PropValidator, PropType } from 'vue/types/options'
import { RippleOptions } from '../../directives/ripple'
const baseMixins = mixins(
VSheet,
Routable,
Positionable,
Sizeable,
GroupableFactory('btnToggle'),
ToggleableFactory('inputValue')
/* @vue/component */
)
interface options extends ExtractVue<typeof baseMixins> {
$el: HTMLElement
}
export default baseMixins.extend<options>().extend({
name: 'v-btn',
props: {
activeClass: {
type: String,
default (): string | undefined {
if (!this.btnToggle) return ''
return this.btnToggle.activeClass
},
} as any as PropValidator<string>,
block: Boolean,
depressed: Boolean,
fab: Boolean,
icon: Boolean,
loading: Boolean,
outlined: Boolean,
retainFocusOnClick: Boolean,
rounded: Boolean,
tag: {
type: String,
default: 'button',
},
text: Boolean,
tile: Boolean,
type: {
type: String,
default: 'button',
},
value: null as any as PropType<any>,
},
data: () => ({
proxyClass: 'v-btn--active',
}),
computed: {
classes (): any {
return {
'v-btn': true,
...Routable.options.computed.classes.call(this),
'v-btn--absolute': this.absolute,
'v-btn--block': this.block,
'v-btn--bottom': this.bottom,
'v-btn--contained': this.contained,
'v-btn--depressed': (this.depressed) || this.outlined,
'v-btn--disabled': this.disabled,
'v-btn--fab': this.fab,
'v-btn--fixed': this.fixed,
'v-btn--flat': this.isFlat,
'v-btn--icon': this.icon,
'v-btn--left': this.left,
'v-btn--loading': this.loading,
'v-btn--outlined': this.outlined,
'v-btn--right': this.right,
'v-btn--round': this.isRound,
'v-btn--rounded': this.rounded,
'v-btn--router': this.to,
'v-btn--text': this.text,
'v-btn--tile': this.tile,
'v-btn--top': this.top,
...this.themeClasses,
...this.groupClasses,
...this.elevationClasses,
...this.sizeableClasses,
}
},
contained (): boolean {
return Boolean(
!this.isFlat &&
!this.depressed &&
// Contained class only adds elevation
// is not needed if user provides value
!this.elevation
)
},
computedRipple (): RippleOptions | boolean {
const defaultRipple = this.icon || this.fab ? { circle: true } : true
if (this.disabled) return false
else return this.ripple != null ? this.ripple : defaultRipple
},
isFlat (): boolean {
return Boolean(
this.icon ||
this.text ||
this.outlined
)
},
isRound (): boolean {
return Boolean(
this.icon ||
this.fab
)
},
styles (): object {
return {
...this.measurableStyles,
}
},
},
created () {
const breakingProps = [
['flat', 'text'],
['outline', 'outlined'],
['round', 'rounded'],
]
/* istanbul ignore next */
breakingProps.forEach(([original, replacement]) => {
if (this.$attrs.hasOwnProperty(original)) breaking(original, replacement, this)
})
},
methods: {
click (e: MouseEvent): void {
// TODO: Remove this in v3
!this.retainFocusOnClick && !this.fab && e.detail && this.$el.blur()
this.$emit('click', e)
this.btnToggle && this.toggle()
},
genContent (): VNode {
return this.$createElement('span', {
staticClass: 'v-btn__content',
}, this.$slots.default)
},
genLoader (): VNode {
return this.$createElement('span', {
class: 'v-btn__loader',
}, this.$slots.loader || [this.$createElement(VProgressCircular, {
props: {
indeterminate: true,
size: 23,
width: 2,
},
})])
},
},
render (h): VNode {
const children = [
this.genContent(),
this.loading && this.genLoader(),
]
const setColor = !this.isFlat ? this.setBackgroundColor : this.setTextColor
const { tag, data } = this.generateRouteLink()
if (tag === 'button') {
data.attrs!.type = this.type
data.attrs!.disabled = this.disabled
}
data.attrs!.value = ['string', 'number'].includes(typeof this.value)
? this.value
: JSON.stringify(this.value)
return h(tag, this.disabled ? data : setColor(this.color, data), children)
},
})
+308
View File
@@ -0,0 +1,308 @@
// Libraries
import Vue from 'vue'
// Plugins
import Router from 'vue-router'
// Components
import VBtn from '../VBtn'
// Utilities
import {
createLocalVue,
mount,
Wrapper,
} from '@vue/test-utils'
import { compileToFunctions } from 'vue-template-compiler'
describe('VBtn.ts', () => {
let mountFunction: (options?: object) => Wrapper<Vue>
let router: Router
let localVue: typeof Vue
beforeEach(() => {
router = new Router()
localVue = createLocalVue()
localVue.use(Router)
mountFunction = (options = {}) => {
return mount(VBtn, {
localVue,
router,
...options,
})
}
})
it('should render component and match snapshot', () => {
expect(mountFunction().html()).toMatchSnapshot()
})
it('should render component with color prop and match snapshot', () => {
expect(mountFunction({
propsData: {
color: 'green darken-1',
},
}).html()).toMatchSnapshot()
expect(mountFunction({
propsData: {
color: 'green darken-1',
text: true,
},
}).html()).toMatchSnapshot()
})
it('should render component with loader slot and match snapshot', () => {
const wrapper = mountFunction({
propsData: {
loading: true,
},
slots: {
loader: [compileToFunctions('<span>loader</span>')],
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render component with loader and match snapshot', () => {
const wrapper = mount(VBtn, {
propsData: {
loading: true,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render tile button and match snapshot', () => {
const wrapper = mount(VBtn, {
propsData: {
tile: true,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render an <a> tag when using href prop', () => {
const wrapper = mountFunction({
propsData: {
href: 'http://www.google.com',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render specified tag when using tag prop', () => {
const wrapper = mountFunction({
propsData: {
tag: 'a',
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should register and unregister', () => {
const register = jest.fn()
const unregister = jest.fn()
const wrapper = mountFunction({
provide: {
btnToggle: {
register,
unregister,
},
},
})
expect(register).toHaveBeenCalled()
wrapper.destroy()
expect(unregister).toHaveBeenCalled()
})
it('should emit a click event', async () => {
const wrapper = mountFunction({
propsData: {
href: '#!',
},
})
const click = jest.fn()
wrapper.vm.$on('click', click)
wrapper.trigger('click')
wrapper.setProps({ href: undefined, to: '/foo' })
wrapper.trigger('click')
expect(click.mock.calls).toHaveLength(2)
})
it('should use custom active-class', () => {
const wrapper = mountFunction({
propsData: {
inputValue: true,
activeClass: 'foo',
},
})
expect(wrapper.classes('foo')).toBe(true)
})
it('should have v-btn--depressed class when using depressed prop', () => {
const wrapper = mountFunction({
propsData: {
depressed: true,
},
})
expect(wrapper.classes('v-btn--depressed')).toBe(true)
})
it('should have v-btn--flat class when using flat and depressed props', () => {
const wrapper = mountFunction({
propsData: {
depressed: true,
text: true,
},
})
expect(wrapper.classes('v-btn--text')).toBe(true)
})
it('should have v-btn--outlined and v-btn--depressed classes when using outlined prop', () => {
const wrapper = mountFunction({
propsData: {
outlined: true,
},
})
expect(wrapper.classes('v-btn--outlined')).toBe(true)
expect(wrapper.classes('v-btn--depressed')).toBe(true)
})
it('should have the correct icon classes', () => {
const wrapper = mountFunction({
propsData: {
icon: true,
},
})
expect(wrapper.classes('v-btn--icon')).toBe(true)
wrapper.setProps({ icon: false })
expect(wrapper.classes('v-btn--icon')).toBe(false)
})
it('should have the correct elevation', async () => { // eslint-disable-line max-statements
const wrapper = mountFunction()
wrapper.setProps({ disabled: true })
expect(wrapper.classes('elevation-2')).toBe(false)
expect(wrapper.classes('v-btn--disabled')).toBe(true)
wrapper.setProps({ disabled: false, elevation: 24 })
expect(wrapper.classes('elevation-24')).toBe(true)
wrapper.setProps({ elevation: 2 })
expect(wrapper.classes('elevation-2')).toBe(true)
})
it('should toggle on route change if provided a to prop', async () => {
const toggle = jest.fn()
const register = jest.fn()
const unregister = jest.fn()
const wrapper = mountFunction({
provide: {
btnToggle: {
activeClass: 'foobar',
register,
unregister,
},
},
methods: { toggle },
ref: 'link',
})
router.push('/foobar')
await wrapper.vm.$nextTick()
expect(toggle).not.toHaveBeenCalled()
wrapper.setProps({ to: 'fizzbuzz' })
router.push('/fizzbuzz')
await wrapper.vm.$nextTick()
expect(toggle).toHaveBeenCalled()
})
it('should call toggle when used in button group', () => {
const register = jest.fn()
const unregister = jest.fn()
const toggle = jest.fn()
const wrapper = mountFunction({
provide: {
btnToggle: { register, unregister },
},
methods: { toggle },
})
wrapper.trigger('click')
expect(toggle).toHaveBeenCalled()
})
it('should stringify non string|number values', () => {
const wrapper = mountFunction({
propsData: {
value: 'foo',
},
})
expect(wrapper.attributes('value')).toBe('foo')
wrapper.setProps({ value: 2 })
expect(wrapper.attributes('value')).toBe('2')
wrapper.setProps({ value: { foo: 'bar' } })
expect(wrapper.attributes('value')).toBe('{"foo":"bar"}')
})
it('should not add color classes if disabled', () => {
const wrapper = mountFunction({
propsData: {
color: 'primary--text text--darken-2',
},
})
expect(wrapper.html()).toMatchSnapshot()
wrapper.setProps({
disabled: true,
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should retain focus when clicked', async () => {
const wrapper = mountFunction({
propsData: {
retainFocusOnClick: true,
},
})
const event = new MouseEvent('click', { detail: 1 })
const blur = jest.fn()
wrapper.element.blur = blur
wrapper.element.dispatchEvent(event)
expect(blur).not.toHaveBeenCalled()
wrapper.setProps({ retainFocusOnClick: false })
wrapper.element.dispatchEvent(event)
expect(blur).toHaveBeenCalled()
})
})
@@ -0,0 +1,121 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VBtn.ts should not add color classes if disabled 1`] = `
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default primary--text text--darken-2"
>
<span class="v-btn__content">
</span>
</button>
`;
exports[`VBtn.ts should not add color classes if disabled 2`] = `
<button type="button"
class="v-btn v-btn--contained v-btn--disabled theme--light v-size--default"
disabled="disabled"
>
<span class="v-btn__content">
</span>
</button>
`;
exports[`VBtn.ts should render an <a> tag when using href prop 1`] = `
<a href="http://www.google.com"
class="v-btn v-btn--contained theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</a>
`;
exports[`VBtn.ts should render component and match snapshot 1`] = `
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</button>
`;
exports[`VBtn.ts should render component with color prop and match snapshot 1`] = `
<button type="button"
class="v-btn v-btn--contained theme--light v-size--default green darken-1"
>
<span class="v-btn__content">
</span>
</button>
`;
exports[`VBtn.ts should render component with color prop and match snapshot 2`] = `
<button type="button"
class="v-btn v-btn--flat v-btn--text theme--light v-size--default green--text text--darken-1"
>
<span class="v-btn__content">
</span>
</button>
`;
exports[`VBtn.ts should render component with loader and match snapshot 1`] = `
<button type="button"
class="v-btn v-btn--contained v-btn--loading theme--light v-size--default"
>
<span class="v-btn__content">
</span>
<span class="v-btn__loader">
<div role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
class="v-progress-circular v-progress-circular--indeterminate"
style="height: 23px; width: 23px;"
>
<svg xmlns="http://www.w3.org/2000/svg"
viewbox="21.904761904761905 21.904761904761905 43.80952380952381 43.80952380952381"
style="transform: rotate(0deg);"
>
<circle fill="transparent"
cx="43.80952380952381"
cy="43.80952380952381"
r="20"
stroke-width="3.8095238095238093"
stroke-dasharray="125.664"
stroke-dashoffset="125.66370614359172px"
class="v-progress-circular__overlay"
>
</circle>
</svg>
<div class="v-progress-circular__info">
</div>
</div>
</span>
</button>
`;
exports[`VBtn.ts should render component with loader slot and match snapshot 1`] = `
<button type="button"
class="v-btn v-btn--contained v-btn--loading theme--light v-size--default"
>
<span class="v-btn__content">
</span>
<span class="v-btn__loader">
<span>
loader
</span>
</span>
</button>
`;
exports[`VBtn.ts should render specified tag when using tag prop 1`] = `
<a class="v-btn v-btn--contained theme--light v-size--default">
<span class="v-btn__content">
</span>
</a>
`;
exports[`VBtn.ts should render tile button and match snapshot 1`] = `
<button type="button"
class="v-btn v-btn--contained v-btn--tile theme--light v-size--default"
>
<span class="v-btn__content">
</span>
</button>
`;
+65
View File
@@ -0,0 +1,65 @@
@import '../../styles/styles.sass';
@import '../../styles/tools/_functions.sass';
$btn-active-opacity: 0.18 !default;
$btn-border-radius: $border-radius-root !default;
$btn-focus-opacity: 0.24 !default;
$btn-font-weight: 500 !default;
$btn-hover-opacity: 0.08 !default;
$btn-icon-font-size: 18px !default;
$btn-icon-padding: 12px !default;
$btn-letter-spacing: .0892857143em !default;
$btn-outline-border-width: thin !default;
$btn-rounded-border-radius: 28px !default;
$btn-text-transform: uppercase !default;
$btn-transition-duration: 0.28s !default;
$btn-transition-fn: map-get($transition, 'fast-out-slow-in') !default;
$btn-transition: opacity 0.2s map-get($transition, 'ease-in-out') !default;
$btn-sizes: () !default;
$btn-sizes: map-deep-merge(
(
'x-small': 20,
'small': 28,
'default': 36,
'large': 44,
'x-large': 52
),
$btn-sizes
);
$btn-font-sizes: () !default;
$btn-font-sizes: map-deep-merge(
(
'x-small': .625rem,
'small': .75rem,
'default': .875rem,
'large': .875rem,
'x-large': 1rem
),
$btn-font-sizes
);
$fab-sizes: () !default;
$fab-sizes: map-deep-merge(
(
'x-small': 32,
'small': 40,
'default': 56,
'large': 64,
'x-large': 72
),
$fab-sizes
);
$fab-icon-sizes: () !default;
$fab-icon-sizes: map-deep-merge(
(
'x-small': 18,
'small': 24,
'default': 24,
'large': 28,
'x-large': 32
),
$fab-icon-sizes
);
+4
View File
@@ -0,0 +1,4 @@
import VBtn from './VBtn'
export { VBtn }
export default VBtn
+82
View File
@@ -0,0 +1,82 @@
// Imports
@import './_variables.scss'
// Theme
+theme(v-btn-toggle) using ($material)
&:not(.v-btn-toggle--group)
background: map-get($material, 'cards')
color: map-deep-get($material, 'text', 'primary')
.v-btn.v-btn
border-color: map-get($material, 'dividers') !important
&:focus:not(:active)
border-color: map-deep-get($material, 'buttons', 'disabled')
.v-icon
color: map-deep-get($material, 'toggle-buttons', 'color')
// Block
.v-btn-toggle
border-radius: $btn-toggle-border-radius
display: inline-flex
max-width: 100%
> .v-btn.v-btn
border-radius: 0
border-style: solid
border-width: thin
box-shadow: none
box-shadow: none
opacity: $btn-toggle-btn-opacity
padding: $btn-toggle-btn-padding
&:first-child
border-top-left-radius: inherit
border-bottom-left-radius: inherit
&:last-child
border-top-right-radius: inherit
border-bottom-right-radius: inherit
&--active
color: inherit
opacity: 1
&:after
display: none
&:not(:first-child)
border-left-width: 0
&:not(.v-btn-toggle--dense)
.v-btn.v-btn.v-size--default
height: $btn-toggle-btn-height
min-height: 0
min-width: $btn-toggle-btn-width
.v-btn-toggle--borderless
> .v-btn.v-btn
border-width: 0
.v-btn-toggle--dense
> .v-btn.v-btn
padding: $btn-toggle-dense-btn-padding
.v-btn-toggle--group
border-radius: 0
> .v-btn.v-btn
background-color: transparent !important
border-color: transparent
margin: $btn-toggle-group-btn-margin
min-width: auto
.v-btn-toggle--rounded
border-radius: $btn-toggle-round-border-radius
.v-btn-toggle--shaped
border-radius: $btn-toggle-shaped-border-radius $btn-toggle-border-radius
.v-btn-toggle--tile
border-radius: 0
+55
View File
@@ -0,0 +1,55 @@
// Styles
import './VBtnToggle.sass'
// Mixins
import ButtonGroup from '../../mixins/button-group'
import Colorable from '../../mixins/colorable'
// Utilities
import mixins from '../../util/mixins'
/* @vue/component */
export default mixins(
ButtonGroup,
Colorable
).extend({
name: 'v-btn-toggle',
props: {
backgroundColor: String,
borderless: Boolean,
dense: Boolean,
group: Boolean,
rounded: Boolean,
shaped: Boolean,
tile: Boolean,
},
computed: {
classes (): object {
return {
...ButtonGroup.options.computed.classes.call(this),
'v-btn-toggle': true,
'v-btn-toggle--borderless': this.borderless,
'v-btn-toggle--dense': this.dense,
'v-btn-toggle--group': this.group,
'v-btn-toggle--rounded': this.rounded,
'v-btn-toggle--shaped': this.shaped,
'v-btn-toggle--tile': this.tile,
...this.themeClasses,
}
},
},
methods: {
genData () {
const data = this.setTextColor(this.color, {
...ButtonGroup.options.methods.genData.call(this),
})
if (this.group) return data
return this.setBackgroundColor(this.backgroundColor, data)
},
},
})
@@ -0,0 +1,36 @@
// Components
import VBtnToggle from '../VBtnToggle'
// Utilities
import {
mount,
Wrapper,
} from '@vue/test-utils'
// Types
import { ExtractVue } from '../../../util/mixins'
describe('VBtnToggle.ts', () => {
type Instance = ExtractVue<typeof VBtnToggle>
let mountFunction: (options?: object) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options = {}) => {
return mount(VBtnToggle, {
...options,
})
}
})
it('should not apply background color with group', () => {
const wrapper = mountFunction({
propsData: { backgroundColor: 'primary' },
})
expect(wrapper.element.classList.contains('primary')).toBeTruthy()
wrapper.setProps({ group: true })
expect(wrapper.element.classList.contains('primary')).toBeFalsy()
})
})
+11
View File
@@ -0,0 +1,11 @@
@import '../../styles/styles.sass';
$btn-toggle-border-radius: $border-radius-root !default;
$btn-toggle-shaped-border-radius: 24px !default;
$btn-toggle-btn-height: 48px !default;
$btn-toggle-btn-padding: 0 12px !default;
$btn-toggle-btn-width: 48px !default;
$btn-toggle-btn-opacity: 0.8 !default;
$btn-toggle-round-border-radius: 24px !default;
$btn-toggle-dense-btn-padding: 0 8px !default;
$btn-toggle-group-btn-margin: 4px !default;
+4
View File
@@ -0,0 +1,4 @@
import VBtnToggle from './VBtnToggle'
export { VBtnToggle }
export default VBtnToggle
+380
View File
@@ -0,0 +1,380 @@
// Styles
// import '../../stylus/components/_calendar-daily.styl'
// Types
import { VNode, Component } from 'vue'
// Mixins
import CalendarWithEvents from './mixins/calendar-with-events'
// Util
import props from './util/props'
import {
DAYS_IN_MONTH_MAX,
DAY_MIN,
DAYS_IN_WEEK,
parseTimestamp,
validateTimestamp,
relativeDays,
nextDay,
prevDay,
copyTimestamp,
updateFormatted,
updateWeekday,
updateRelative,
getStartOfMonth,
getEndOfMonth,
VTime,
VTimestampInput,
timestampToDate,
} from './util/timestamp'
// Calendars
import VCalendarMonthly from './VCalendarMonthly'
import VCalendarDaily from './VCalendarDaily'
import VCalendarWeekly from './VCalendarWeekly'
import VCalendarCategory from './VCalendarCategory'
import { CalendarTimestamp, CalendarFormatter } from 'vuetify/types'
// Types
interface VCalendarRenderProps {
start: CalendarTimestamp
end: CalendarTimestamp
component: string | Component
maxDays: number
weekdays: number[]
categories: string[]
}
/* @vue/component */
export default CalendarWithEvents.extend({
name: 'v-calendar',
props: {
...props.calendar,
...props.weeks,
...props.intervals,
...props.category,
},
data: () => ({
lastStart: null as CalendarTimestamp | null,
lastEnd: null as CalendarTimestamp | null,
}),
computed: {
parsedValue (): CalendarTimestamp {
return (validateTimestamp(this.value)
? parseTimestamp(this.value, true)
: (this.parsedStart || this.times.today))
},
parsedCategoryDays (): number {
return parseInt(this.categoryDays) || 1
},
renderProps (): VCalendarRenderProps {
const around = this.parsedValue
let component: any = null
let maxDays = this.maxDays
let weekdays = this.parsedWeekdays
let categories = this.parsedCategories
let start = around
let end = around
switch (this.type) {
case 'month':
component = VCalendarMonthly
start = getStartOfMonth(around)
end = getEndOfMonth(around)
break
case 'week':
component = VCalendarDaily
start = this.getStartOfWeek(around)
end = this.getEndOfWeek(around)
maxDays = 7
break
case 'day':
component = VCalendarDaily
maxDays = 1
weekdays = [start.weekday]
break
case '4day':
component = VCalendarDaily
end = relativeDays(copyTimestamp(end), nextDay, 4)
updateFormatted(end)
maxDays = 4
weekdays = [
start.weekday,
(start.weekday + 1) % 7,
(start.weekday + 2) % 7,
(start.weekday + 3) % 7,
]
break
case 'custom-weekly':
component = VCalendarWeekly
start = this.parsedStart || around
end = this.parsedEnd
break
case 'custom-daily':
component = VCalendarDaily
start = this.parsedStart || around
end = this.parsedEnd
break
case 'category':
const days = this.parsedCategoryDays
component = VCalendarCategory
end = relativeDays(copyTimestamp(end), nextDay, days)
updateFormatted(end)
maxDays = days
weekdays = []
for (let i = 0; i < days; i++) {
weekdays.push((start.weekday + i) % 7)
}
categories = this.getCategoryList(categories)
break
default:
throw new Error(this.type + ' is not a valid Calendar type')
}
return { component, start, end, maxDays, weekdays, categories }
},
eventWeekdays (): number[] {
return this.renderProps.weekdays
},
categoryMode (): boolean {
return this.type === 'category'
},
title (): string {
const { start, end } = this.renderProps
const spanYears = start.year !== end.year
const spanMonths = spanYears || start.month !== end.month
if (spanYears) {
return this.monthShortFormatter(start, true) + ' ' + start.year + ' - ' + this.monthShortFormatter(end, true) + ' ' + end.year
}
if (spanMonths) {
return this.monthShortFormatter(start, true) + ' - ' + this.monthShortFormatter(end, true) + ' ' + end.year
} else {
return this.monthLongFormatter(start, false) + ' ' + start.year
}
},
monthLongFormatter (): CalendarFormatter {
return this.getFormatter({
timeZone: 'UTC', month: 'long',
})
},
monthShortFormatter (): CalendarFormatter {
return this.getFormatter({
timeZone: 'UTC', month: 'short',
})
},
parsedCategories (): string[] {
return typeof this.categories === 'string' && this.categories
? this.categories.split(/\s*,\s*/)
: Array.isArray(this.categories)
? this.categories as string[]
: []
},
},
watch: {
renderProps: 'checkChange',
},
mounted () {
this.updateEventVisibility()
this.checkChange()
},
updated () {
window.requestAnimationFrame(this.updateEventVisibility)
},
methods: {
checkChange (): void {
const { lastStart, lastEnd } = this
const { start, end } = this.renderProps
if (!lastStart || !lastEnd ||
start.date !== lastStart.date ||
end.date !== lastEnd.date) {
this.lastStart = start
this.lastEnd = end
this.$emit('change', { start, end })
}
},
move (amount = 1): void {
const moved = copyTimestamp(this.parsedValue)
const forward = amount > 0
const mover = forward ? nextDay : prevDay
const limit = forward ? DAYS_IN_MONTH_MAX : DAY_MIN
let times = forward ? amount : -amount
while (--times >= 0) {
switch (this.type) {
case 'month':
moved.day = limit
mover(moved)
break
case 'week':
relativeDays(moved, mover, DAYS_IN_WEEK)
break
case 'day':
relativeDays(moved, mover, 1)
break
case '4day':
relativeDays(moved, mover, 4)
break
case 'category':
relativeDays(moved, mover, this.parsedCategoryDays)
break
}
}
updateWeekday(moved)
updateFormatted(moved)
updateRelative(moved, this.times.now)
if (this.value instanceof Date) {
this.$emit('input', timestampToDate(moved))
} else if (typeof this.value === 'number') {
this.$emit('input', timestampToDate(moved).getTime())
} else {
this.$emit('input', moved.date)
}
this.$emit('moved', moved)
},
next (amount = 1): void {
this.move(amount)
},
prev (amount = 1): void {
this.move(-amount)
},
timeToY (time: VTime, clamp = true): number | false {
const c = this.$children[0] as any
if (c && c.timeToY) {
return c.timeToY(time, clamp)
} else {
return false
}
},
timeDelta (time: VTime): number | false {
const c = this.$children[0] as any
if (c && c.timeDelta) {
return c.timeDelta(time)
} else {
return false
}
},
minutesToPixels (minutes: number): number {
const c = this.$children[0] as any
if (c && c.minutesToPixels) {
return c.minutesToPixels(minutes)
} else {
return -1
}
},
scrollToTime (time: VTime): boolean {
const c = this.$children[0] as any
if (c && c.scrollToTime) {
return c.scrollToTime(time)
} else {
return false
}
},
parseTimestamp (input: VTimestampInput, required?: false): CalendarTimestamp | null {
return parseTimestamp(input, required, this.times.now)
},
timestampToDate (timestamp: CalendarTimestamp): Date {
return timestampToDate(timestamp)
},
getCategoryList (categories: string[]): string[] {
if (!this.noEvents) {
const categoryMap = categories.reduce((map, category, index) => {
map[category] = { index, count: 0 }
return map
}, Object.create(null))
if (!this.categoryHideDynamic || !this.categoryShowAll) {
let categoryLength = categories.length
this.parsedEvents.forEach(ev => {
let category = ev.category
if (typeof category !== 'string') {
category = this.categoryForInvalid
}
if (!category) {
return
}
if (category in categoryMap) {
categoryMap[category].count++
} else if (!this.categoryHideDynamic) {
categoryMap[category] = {
index: categoryLength++,
count: 1,
}
}
})
}
if (!this.categoryShowAll) {
for (const category in categoryMap) {
if (categoryMap[category].count === 0) {
delete categoryMap[category]
}
}
}
categories = Object.keys(categoryMap)
}
return categories
},
},
render (h): VNode {
const { start, end, maxDays, component, weekdays, categories } = this.renderProps
return h(component, {
staticClass: 'v-calendar',
class: {
'v-calendar-events': !this.noEvents,
},
props: {
...this.$props,
start: start.date,
end: end.date,
maxDays,
weekdays,
categories,
},
directives: [{
modifiers: { quiet: true },
name: 'resize',
value: this.updateEventVisibility,
}],
on: {
...this.$listeners,
'click:date': (day: CalendarTimestamp) => {
if (this.$listeners['input']) {
this.$emit('input', day.date)
}
if (this.$listeners['click:date']) {
this.$emit('click:date', day)
}
},
},
scopedSlots: this.getScopedSlots(),
})
},
})
+27
View File
@@ -0,0 +1,27 @@
@import './_variables.scss'
// Theme
+theme(v-calendar-category) using ($material)
.v-calendar-category__column,
.v-calendar-category__column-header
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-category
.v-calendar-category__category
text-align: center
.v-calendar-daily__day-container
.v-calendar-category__columns
position: absolute
height: 100%
width: 100%
top: 0
.v-calendar-category__columns
display: flex
.v-calendar-category__column,
.v-calendar-category__column-header
flex: 1 1 auto
width: 0
position: relative
+95
View File
@@ -0,0 +1,95 @@
// Styles
import './VCalendarCategory.sass'
// Types
import { VNode } from 'vue'
// Mixins
import VCalendarDaily from './VCalendarDaily'
// Util
import { getSlot } from '../../util/helpers'
import { CalendarTimestamp } from 'types'
import props from './util/props'
/* @vue/component */
export default VCalendarDaily.extend({
name: 'v-calendar-category',
props: props.category,
computed: {
classes (): object {
return {
'v-calendar-daily': true,
'v-calendar-category': true,
...this.themeClasses,
}
},
parsedCategories (): string[] {
return typeof this.categories === 'string' && this.categories
? this.categories.split(/\s*,\s*/)
: Array.isArray(this.categories)
? this.categories as string[]
: []
},
},
methods: {
genDayHeader (day: CalendarTimestamp, index: number): VNode[] {
const data = {
staticClass: 'v-calendar-category__columns',
}
const scope = {
week: this.days, ...day, index,
}
const children = this.parsedCategories.map(category => this.genDayHeaderCategory(day, this.getCategoryScope(scope, category)))
return [this.$createElement('div', data, children)]
},
getCategoryScope (scope: any, category: string) {
return {
...scope,
category: category === this.categoryForInvalid ? null : category,
}
},
genDayHeaderCategory (day: CalendarTimestamp, scope: any): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-category__column-header',
on: this.getDefaultMouseEventHandlers(':day-category', e => {
return this.getCategoryScope(this.getSlotScope(day), scope.category)
}),
}, [
getSlot(this, 'category', scope) || this.genDayHeaderCategoryTitle(scope.category),
getSlot(this, 'day-header', scope),
])
},
genDayHeaderCategoryTitle (category: string) {
return this.$createElement('div', {
staticClass: 'v-calendar-category__category',
}, category === null ? this.categoryForInvalid : category)
},
genDayBody (day: CalendarTimestamp): VNode[] {
const data = {
staticClass: 'v-calendar-category__columns',
}
const children = this.parsedCategories.map(category => this.genDayBodyCategory(day, category))
return [this.$createElement('div', data, children)]
},
genDayBodyCategory (day: CalendarTimestamp, category: string): VNode {
const data = {
staticClass: 'v-calendar-category__column',
on: this.getDefaultMouseEventHandlers(':time-category', e => {
return this.getCategoryScope(this.getSlotScope(this.getTimestampAtEvent(e, day)), category)
}),
}
const children = getSlot(this, 'day-body', () => this.getCategoryScope(this.getSlotScope(day), category))
return this.$createElement('div', data, children)
},
},
})
+140
View File
@@ -0,0 +1,140 @@
@import './_variables.scss'
// Theme
+theme(v-calendar-daily) using ($material)
background-color: map-deep-get($material, 'calendar', 'background-color')
border-left: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
border-top: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-daily__intervals-head
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
&::after
background: map-deep-get($material, 'calendar', 'line-color')
background: linear-gradient(90deg, transparent, map-deep-get($material, 'calendar', 'line-color'))
.v-calendar-daily_head-day
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
border-bottom: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
color: map-deep-get($material, 'calendar', 'text-color')
&.v-past
.v-calendar-daily_head-weekday,
.v-calendar-daily_head-day-label
color: map-deep-get($material, 'calendar', 'past-color')
.v-calendar-daily__intervals-body
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-daily__interval-text
color: map-deep-get($material, 'calendar', 'interval-color')
.v-calendar-daily__day
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
border-bottom: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-daily__day-interval
border-top: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
&:first-child
border-top: none !important
.v-calendar-daily__interval::after
border-top: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-daily
display: flex
flex-direction: column
overflow: hidden
height: 100%
.v-calendar-daily__head
flex: none
display: flex
.v-calendar-daily__intervals-head
flex: none
position: relative
&::after
position: absolute
bottom: 0px
height: $calendar-line-width
left: 0
right: 0
content: ''
.v-calendar-daily_head-day
flex: 1 1 auto
width: 0
position: relative
.v-calendar-daily_head-weekday
user-select: none
padding: $calendar-daily-weekday-padding
font-size: $calendar-daily-weekday-font-size
text-align: center
text-transform: uppercase
.v-calendar-daily_head-day-label
user-select: none
padding: $calendar-daily-day-padding
cursor: pointer
text-align: center
.v-calendar-daily__body
flex: 1 1 60%
overflow: hidden
display: flex
position: relative
flex-direction: column
.v-calendar-daily__scroll-area
overflow-y: scroll
flex: 1 1 auto
display: flex
align-items: flex-start
.v-calendar-daily__pane
width: 100%
overflow-y: hidden
flex: none
display: flex
align-items: flex-start
.v-calendar-daily__day-container
display: flex
flex: 1
width: 100%
height: 100%
.v-calendar-daily__intervals-body
flex: none
user-select: none
.v-calendar-daily__interval
text-align: $calendar-daily-interval-gutter-align
padding-right: $calendar-daily-interval-gutter-line-width
border-bottom: none
position: relative
&::after
width: $calendar-daily-interval-gutter-line-width
position: absolute
height: $calendar-line-width
display: block
content: ''
right: 0
bottom: -$calendar-line-width
.v-calendar-daily__interval-text
display: block
position: relative
top: $calendar-daily-interval-gutter-top
font-size: $calendar-daily-interval-gutter-font-size
padding-right: $calendar-daily-interval-gutter-width
.v-calendar-daily__day
flex: 1
width: 0
position: relative
+259
View File
@@ -0,0 +1,259 @@
// Styles
import './VCalendarDaily.sass'
// Types
import { VNode } from 'vue'
// Directives
import Resize from '../../directives/resize'
// Components
import VBtn from '../VBtn'
// Mixins
import CalendarWithIntervals from './mixins/calendar-with-intervals'
// Util
import { convertToUnit, getSlot } from '../../util/helpers'
import { CalendarTimestamp } from 'vuetify/types'
/* @vue/component */
export default CalendarWithIntervals.extend({
name: 'v-calendar-daily',
directives: { Resize },
data: () => ({
scrollPush: 0,
}),
computed: {
classes (): object {
return {
'v-calendar-daily': true,
...this.themeClasses,
}
},
},
mounted () {
this.init()
},
methods: {
init () {
this.$nextTick(this.onResize)
},
onResize () {
this.scrollPush = this.getScrollPush()
},
getScrollPush (): number {
const area = this.$refs.scrollArea as HTMLElement
const pane = this.$refs.pane as HTMLElement
return area && pane ? (area.offsetWidth - pane.offsetWidth) : 0
},
genHead (): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-daily__head',
style: {
marginRight: this.scrollPush + 'px',
},
}, [
this.genHeadIntervals(),
...this.genHeadDays(),
])
},
genHeadIntervals (): VNode {
const width: string | undefined = convertToUnit(this.intervalWidth)
return this.$createElement('div', {
staticClass: 'v-calendar-daily__intervals-head',
style: {
width,
},
}, getSlot(this, 'interval-header'))
},
genHeadDays (): VNode[] {
return this.days.map(this.genHeadDay)
},
genHeadDay (day: CalendarTimestamp, index: number): VNode {
return this.$createElement('div', {
key: day.date,
staticClass: 'v-calendar-daily_head-day',
class: this.getRelativeClasses(day),
on: this.getDefaultMouseEventHandlers(':day', _e => {
return this.getSlotScope(day)
}),
}, [
this.genHeadWeekday(day),
this.genHeadDayLabel(day),
...this.genDayHeader(day, index),
])
},
genDayHeader (day: CalendarTimestamp, index: number): VNode[] {
return getSlot(this, 'day-header', () => ({
week: this.days, ...day, index,
})) || []
},
genHeadWeekday (day: CalendarTimestamp): VNode {
const color = day.present ? this.color : undefined
return this.$createElement('div', this.setTextColor(color, {
staticClass: 'v-calendar-daily_head-weekday',
}), this.weekdayFormatter(day, this.shortWeekdays))
},
genHeadDayLabel (day: CalendarTimestamp): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-daily_head-day-label',
}, getSlot(this, 'day-label-header', day) || [this.genHeadDayButton(day)])
},
genHeadDayButton (day: CalendarTimestamp): VNode {
const color = day.present ? this.color : 'transparent'
return this.$createElement(VBtn, {
props: {
color,
fab: true,
depressed: true,
},
on: this.getMouseEventHandlers({
'click:date': { event: 'click', stop: true },
'contextmenu:date': { event: 'contextmenu', stop: true, prevent: true, result: false },
}, _e => {
return day
}),
}, this.dayFormatter(day, false))
},
genBody (): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-daily__body',
}, [
this.genScrollArea(),
])
},
genScrollArea (): VNode {
return this.$createElement('div', {
ref: 'scrollArea',
staticClass: 'v-calendar-daily__scroll-area',
}, [
this.genPane(),
])
},
genPane (): VNode {
return this.$createElement('div', {
ref: 'pane',
staticClass: 'v-calendar-daily__pane',
style: {
height: convertToUnit(this.bodyHeight),
},
}, [
this.genDayContainer(),
])
},
genDayContainer (): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-daily__day-container',
}, [
this.genBodyIntervals(),
...this.genDays(),
])
},
genDays (): VNode[] {
return this.days.map(this.genDay)
},
genDay (day: CalendarTimestamp, index: number): VNode {
return this.$createElement('div', {
key: day.date,
staticClass: 'v-calendar-daily__day',
class: this.getRelativeClasses(day),
on: this.getDefaultMouseEventHandlers(':time', e => {
return this.getSlotScope(this.getTimestampAtEvent(e, day))
}),
}, [
...this.genDayIntervals(index),
...this.genDayBody(day),
])
},
genDayBody (day: CalendarTimestamp): VNode[] {
return getSlot(this, 'day-body', () => this.getSlotScope(day)) || []
},
genDayIntervals (index: number): VNode[] {
return this.intervals[index].map(this.genDayInterval)
},
genDayInterval (interval: CalendarTimestamp): VNode {
const height: string | undefined = convertToUnit(this.intervalHeight)
const styler = this.intervalStyle || this.intervalStyleDefault
const data = {
key: interval.time,
staticClass: 'v-calendar-daily__day-interval',
style: {
height,
...styler(interval),
},
}
const children = getSlot(this, 'interval', () => this.getSlotScope(interval))
return this.$createElement('div', data, children)
},
genBodyIntervals (): VNode {
const width: string | undefined = convertToUnit(this.intervalWidth)
const data = {
staticClass: 'v-calendar-daily__intervals-body',
style: {
width,
},
on: this.getDefaultMouseEventHandlers(':interval', e => {
return this.getTimestampAtEvent(e, this.parsedStart)
}),
}
return this.$createElement('div', data, this.genIntervalLabels())
},
genIntervalLabels (): VNode[] | null {
if (!this.intervals.length) return null
return this.intervals[0].map(this.genIntervalLabel)
},
genIntervalLabel (interval: CalendarTimestamp): VNode {
const height: string | undefined = convertToUnit(this.intervalHeight)
const short: boolean = this.shortIntervals
const shower = this.showIntervalLabel || this.showIntervalLabelDefault
const show = shower(interval)
const label = show ? this.intervalFormatter(interval, short) : undefined
return this.$createElement('div', {
key: interval.time,
staticClass: 'v-calendar-daily__interval',
style: {
height,
},
}, [
this.$createElement('div', {
staticClass: 'v-calendar-daily__interval-text',
}, label),
])
},
},
render (h): VNode {
return h('div', {
class: this.classes,
on: {
dragstart: (e: MouseEvent) => {
e.preventDefault()
},
},
directives: [{
modifiers: { quiet: true },
name: 'resize',
value: this.onResize,
}],
}, [
!this.hideHeader ? this.genHead() : '',
this.genBody(),
])
},
})
+27
View File
@@ -0,0 +1,27 @@
// Styles
import './VCalendarWeekly.sass'
// Mixins
import VCalendarWeekly from './VCalendarWeekly'
// Util
import { parseTimestamp, getStartOfMonth, getEndOfMonth } from './util/timestamp'
import { CalendarTimestamp } from 'vuetify/types'
/* @vue/component */
export default VCalendarWeekly.extend({
name: 'v-calendar-monthly',
computed: {
staticClass (): string {
return 'v-calendar-monthly v-calendar-weekly'
},
parsedStart (): CalendarTimestamp {
return getStartOfMonth(parseTimestamp(this.start, true))
},
parsedEnd (): CalendarTimestamp {
return getEndOfMonth(parseTimestamp(this.end, true))
},
},
})
+118
View File
@@ -0,0 +1,118 @@
@import './_variables.scss'
// Theme
+theme(v-calendar-weekly) using ($material)
background-color: map-deep-get($material, 'calendar', 'background-color')
border-top: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
border-left: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-weekly__head-weekday
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
color: map-deep-get($material, 'calendar', 'text-color')
&.v-past
color: map-deep-get($material, 'calendar', 'past-color')
&.v-outside
background-color: map-deep-get($material, 'calendar', 'outside-background-color')
.v-calendar-weekly__head-weeknumber
background-color: map-deep-get($material, 'calendar', 'weeknumber-background-color')
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
.v-calendar-weekly__day
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
border-bottom: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
color: map-deep-get($material, 'calendar', 'text-color')
&.v-outside
background-color: map-deep-get($material, 'calendar', 'outside-background-color')
.v-calendar-weekly__weeknumber
background-color: map-deep-get($material, 'calendar', 'weeknumber-background-color')
border-right: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
border-bottom: map-deep-get($material, 'calendar', 'line-color') $calendar-line-width solid
color: map-deep-get($material, 'calendar', 'text-color')
.v-calendar-weekly
width: 100%
height: 100%
display: flex
flex-direction: column
// https://github.com/vuetifyjs/vuetify/issues/8319
min-height: 0
.v-calendar-weekly__head
display: flex
user-select: none
.v-calendar-weekly__head-weekday
flex: 1 0 20px
user-select: none
padding: $calendar-weekly-weekday-padding
font-size: $calendar-weekly-weekday-font-size
overflow: hidden
text-align: center
text-overflow: ellipsis
text-transform: uppercase
white-space: nowrap
.v-calendar-weekly__head-weeknumber
position: relative
flex: 0 0 $calendar-weekly-weeknumber-flex-basis
.v-calendar-weekly__week
display: flex
flex: 1
height: unset
// https://github.com/vuetifyjs/vuetify/issues/8319
min-height: 0
.v-calendar-weekly__weeknumber
display: flex
flex: 0 0 $calendar-weekly-weeknumber-flex-basis
height: unset
min-height: 0
padding-top: $calendar-weekly-weeknumber-padding-top
text-align: center
> small
width: 100% !important
.v-calendar-weekly__day
flex: 1
width: 0
overflow: hidden
user-select: none
position: relative
padding: $calendar-weekly-day-padding
// https://github.com/vuetifyjs/vuetify/issues/9058
// https://bugzilla.mozilla.org/show_bug.cgi?id=1114904
min-width: 0
&.v-present
.v-calendar-weekly__day-month
color: currentColor
.v-calendar-weekly__day-label
text-decoration: none
user-select: none
cursor: pointer
box-shadow: none
text-align: center
margin: $calendar-weekly-day-label-margin
.v-btn
font-size: $calendar-weekly-day-label-font-size
text-transform: none
.v-calendar-weekly__day-month
position: absolute
text-decoration: none
user-select: none
box-shadow: none
top: 0
left: $calendar-weekly-day-month-left
height: $calendar-weekly-day-label-size
line-height: $calendar-weekly-day-label-size
+216
View File
@@ -0,0 +1,216 @@
// Styles
import './VCalendarWeekly.sass'
// Types
import { VNode } from 'vue'
// Components
import VBtn from '../VBtn'
// Mixins
import CalendarBase from './mixins/calendar-base'
// Util
import { getSlot } from '../../util/helpers'
import { weekNumber } from '../../util/dateTimeUtils'
import props from './util/props'
import {
createDayList,
getDayIdentifier,
createNativeLocaleFormatter,
} from './util/timestamp'
import { CalendarTimestamp, CalendarFormatter } from 'vuetify/types'
/* @vue/component */
export default CalendarBase.extend({
name: 'v-calendar-weekly',
props: props.weeks,
computed: {
staticClass (): string {
return 'v-calendar-weekly'
},
classes (): object {
return this.themeClasses
},
parsedMinWeeks (): number {
return parseInt(this.minWeeks)
},
days (): CalendarTimestamp[] {
const minDays = this.parsedMinWeeks * this.parsedWeekdays.length
const start = this.getStartOfWeek(this.parsedStart)
const end = this.getEndOfWeek(this.parsedEnd)
return createDayList(
start,
end,
this.times.today,
this.weekdaySkips,
Number.MAX_SAFE_INTEGER,
minDays
)
},
todayWeek (): CalendarTimestamp[] {
const today = this.times.today
const start = this.getStartOfWeek(today)
const end = this.getEndOfWeek(today)
return createDayList(
start,
end,
today,
this.weekdaySkips,
this.parsedWeekdays.length,
this.parsedWeekdays.length
)
},
monthFormatter (): CalendarFormatter {
if (this.monthFormat) {
return this.monthFormat as CalendarFormatter
}
const longOptions = { timeZone: 'UTC', month: 'long' }
const shortOptions = { timeZone: 'UTC', month: 'short' }
return createNativeLocaleFormatter(
this.currentLocale,
(_tms, short) => short ? shortOptions : longOptions
)
},
},
methods: {
isOutside (day: CalendarTimestamp): boolean {
const dayIdentifier = getDayIdentifier(day)
return dayIdentifier < getDayIdentifier(this.parsedStart) ||
dayIdentifier > getDayIdentifier(this.parsedEnd)
},
genHead (): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-weekly__head',
}, this.genHeadDays())
},
genHeadDays (): VNode[] {
const header = this.todayWeek.map(this.genHeadDay)
if (this.showWeek) {
header.unshift(this.$createElement('div', {
staticClass: 'v-calendar-weekly__head-weeknumber',
}))
}
return header
},
genHeadDay (day: CalendarTimestamp, index: number): VNode {
const outside = this.isOutside(this.days[index])
const color = day.present ? this.color : undefined
return this.$createElement('div', this.setTextColor(color, {
key: day.date,
staticClass: 'v-calendar-weekly__head-weekday',
class: this.getRelativeClasses(day, outside),
}), this.weekdayFormatter(day, this.shortWeekdays))
},
genWeeks (): VNode[] {
const days = this.days
const weekDays = this.parsedWeekdays.length
const weeks: VNode[] = []
for (let i = 0; i < days.length; i += weekDays) {
weeks.push(this.genWeek(days.slice(i, i + weekDays), this.getWeekNumber(days[i])))
}
return weeks
},
genWeek (week: CalendarTimestamp[], weekNumber: number): VNode {
const weekNodes = week.map((day, index) => this.genDay(day, index, week))
if (this.showWeek) {
weekNodes.unshift(this.genWeekNumber(weekNumber))
}
return this.$createElement('div', {
key: week[0].date,
staticClass: 'v-calendar-weekly__week',
}, weekNodes)
},
getWeekNumber (determineDay: CalendarTimestamp) {
return weekNumber(
determineDay.year,
determineDay.month - 1,
determineDay.day,
this.parsedWeekdays[0],
parseInt(this.localeFirstDayOfYear)
)
},
genWeekNumber (weekNumber: number) {
return this.$createElement('div', {
staticClass: 'v-calendar-weekly__weeknumber',
}, [
this.$createElement('small', String(weekNumber)),
])
},
genDay (day: CalendarTimestamp, index: number, week: CalendarTimestamp[]): VNode {
const outside = this.isOutside(day)
return this.$createElement('div', {
key: day.date,
staticClass: 'v-calendar-weekly__day',
class: this.getRelativeClasses(day, outside),
on: this.getDefaultMouseEventHandlers(':day', _e => day),
}, [
this.genDayLabel(day),
...(getSlot(this, 'day', () => ({ outside, index, week, ...day })) || []),
])
},
genDayLabel (day: CalendarTimestamp): VNode {
return this.$createElement('div', {
staticClass: 'v-calendar-weekly__day-label',
}, getSlot(this, 'day-label', day) || [this.genDayLabelButton(day)])
},
genDayLabelButton (day: CalendarTimestamp): VNode {
const color = day.present ? this.color : 'transparent'
const hasMonth = day.day === 1 && this.showMonthOnFirst
return this.$createElement(VBtn, {
props: {
color,
fab: true,
depressed: true,
small: true,
},
on: this.getMouseEventHandlers({
'click:date': { event: 'click', stop: true },
'contextmenu:date': { event: 'contextmenu', stop: true, prevent: true, result: false },
}, _e => day),
}, hasMonth
? this.monthFormatter(day, this.shortMonths) + ' ' + this.dayFormatter(day, false)
: this.dayFormatter(day, false)
)
},
genDayMonth (day: CalendarTimestamp): VNode | string {
const color = day.present ? this.color : undefined
return this.$createElement('div', this.setTextColor(color, {
staticClass: 'v-calendar-weekly__day-month',
}), getSlot(this, 'day-month', day) || this.monthFormatter(day, this.shortMonths))
},
},
render (h): VNode {
return h('div', {
staticClass: this.staticClass,
class: this.classes,
on: {
dragstart: (e: MouseEvent) => {
e.preventDefault()
},
},
}, [
!this.hideHeader ? this.genHead() : '',
...this.genWeeks(),
])
},
})
@@ -0,0 +1,145 @@
import { parseDate } from '../util/timestamp'
import VCalendar from '../VCalendar'
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import { ExtractVue } from '../../../util/mixins'
describe('VCalendar', () => {
type Instance = ExtractVue<typeof VCalendar>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(VCalendar, {
// https://github.com/vuejs/vue-test-utils/issues/1130
sync: false,
mocks: {
$vuetify: {
lang: {
current: 'en-US',
},
},
},
...options,
})
}
})
it('should render day view', async () => {
const wrapper = mountFunction({
propsData: {
type: 'day',
start: '2018-01-29',
end: '2018-02-04',
now: '2019-02-17',
},
methods: {
getNow: () => parseDate(new Date('2019-02-17')),
},
})
expect(wrapper.classes('v-calendar-daily')).toBeTruthy()
expect(wrapper.html()).toMatchSnapshot()
})
it('should render 4-day view', async () => {
const wrapper = mountFunction({
propsData: {
type: '4day',
start: '2018-01-29',
end: '2018-02-04',
now: '2019-02-17',
},
methods: {
getNow: () => parseDate(new Date('2019-02-17')),
},
})
expect(wrapper.classes('v-calendar-daily')).toBeTruthy()
expect(wrapper.html()).toMatchSnapshot()
})
it('should render week view', async () => {
const wrapper = mountFunction({
propsData: {
type: 'week',
start: '2018-01-29',
end: '2018-02-04',
now: '2019-02-17',
},
methods: {
getNow: () => parseDate(new Date('2019-02-17')),
},
})
expect(wrapper.classes('v-calendar-daily')).toBeTruthy()
expect(wrapper.html()).toMatchSnapshot()
})
it('should render month view', async () => {
const wrapper = mountFunction({
propsData: {
type: 'month',
start: '2018-01-29',
end: '2018-02-04',
now: '2019-02-17',
},
methods: {
getNow: () => parseDate(new Date('2019-02-17')),
},
})
expect(wrapper.classes('v-calendar-monthly')).toBeTruthy()
expect(wrapper.html()).toMatchSnapshot()
})
it('should parse value', async () => {
const wrapper = mountFunction({
propsData: {
value: '2019-02-02',
start: '2019-01-29',
end: '2019-02-04',
},
})
expect(wrapper.vm.parsedValue.date).toBe('2019-02-02')
})
it('should parse start', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
},
})
expect(wrapper.vm.parsedValue.date).toBe('2019-01-29')
})
it('should go to correct day when using next/prev public functions', async () => {
const wrapper = mountFunction({
propsData: {
value: '2019-01-11',
type: 'day',
weekdays: [1, 2, 3, 4, 5],
},
})
const input = jest.fn(value => wrapper.setProps({ value }))
wrapper.vm.$on('input', input)
expect(wrapper.html()).toMatchSnapshot()
wrapper.vm.next()
await wrapper.vm.$nextTick()
expect(wrapper.html()).toMatchSnapshot()
wrapper.vm.prev()
await wrapper.vm.$nextTick()
expect(wrapper.html()).toMatchSnapshot()
})
})
@@ -0,0 +1,185 @@
import VCalendarDaily from '../VCalendarDaily'
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import { ExtractVue } from '../../../util/mixins'
describe('VCalendarDaily', () => {
type Instance = ExtractVue<typeof VCalendarDaily>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(VCalendarDaily, {
...options,
mocks: {
$vuetify: {
lang: {
current: 'en-US',
},
},
},
})
}
})
it('should render component and have v-calendar-daily class', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
},
})
expect(wrapper.classes('v-calendar-daily')).toBeTruthy()
expect(wrapper.html()).toMatchSnapshot()
})
it('should compute scrollPush on init', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
},
})
jest.spyOn(wrapper.vm, 'getScrollPush').mockImplementation(_ => 123)
expect(wrapper.vm.scrollPush).toBe(0)
expect(wrapper.vm.getScrollPush).not.toHaveBeenCalled()
await wrapper.vm.$nextTick()
expect(wrapper.vm.getScrollPush).toHaveBeenCalled()
expect(wrapper.vm.scrollPush).toBe(123)
})
it('should compute scrollPush properly', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
},
})
expect(wrapper.vm.getScrollPush()).toBe(0)
Object.defineProperty(wrapper.vm.$refs.scrollArea, 'offsetWidth', { value: 100 })
Object.defineProperty(wrapper.vm.$refs.pane, 'offsetWidth', { value: 25 })
expect(wrapper.vm.getScrollPush()).toBe(75)
})
it('should render correctly with intervalMinutes prop', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
intervalMinutes: 40,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render correctly with maxDays prop', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
maxDays: 5,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
// TODO: Re-enable once test can be done without breaking travis
it.skip('should render correctly without shortIntervals prop', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
shortIntervals: false,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render correctly with intervalHeight prop', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
intervalHeight: 70,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render correctly with firstInterval prop', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
firstInterval: 2,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should render correctly with intervalCount prop', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
intervalCount: 12,
},
})
expect(wrapper.html()).toMatchSnapshot()
})
it('should use custom interval formatter and render correctly', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
intervalFormat: jest.fn(x => `test: ${x.date} ${x.time}`),
},
})
expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.vm.intervalFormat).toHaveBeenCalled()
})
it('should use custom interval style function and render correctly', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
intervalStyle: jest.fn(x => ({
opacity: x.hour / 24,
})),
},
})
expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.vm.intervalStyle).toHaveBeenCalled()
})
it('should use custom showIntervalLabel function and render correctly', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
showIntervalLabel: jest.fn(x => (x.hour % 2 === 0)),
},
})
expect(wrapper.html()).toMatchSnapshot()
expect(wrapper.vm.showIntervalLabel).toHaveBeenCalled()
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
@import '../../styles/styles.sass';
$calendar-line-width: 1px !default;
$calendar-daily-weekday-padding: 3px 0px 0px 0px !default;
$calendar-daily-weekday-font-size: 11px !default;
$calendar-daily-day-padding: 0px 0px 3px 0px !default;
$calendar-daily-day-font-size: 40px !default;
$calendar-daily-interval-gutter-top: -6px !default;
$calendar-daily-interval-gutter-width: 4px !default;
$calendar-daily-interval-gutter-align: right !default;
$calendar-daily-interval-gutter-line-width: 8px !default;
$calendar-daily-interval-gutter-font-size: 10px !default;
$calendar-weekly-weekday-padding: 0px 4px 0px 4px !default;
$calendar-weekly-weekday-font-size: 11px !default;
$calendar-weekly-day-padding: 0px 0px 0px 0px !default;
$calendar-weekly-day-label-size: 32px !default;
$calendar-weekly-day-label-font-size: 12px !default;
$calendar-weekly-day-label-margin: 4px 0 0 0 !default;
$calendar-weekly-day-month-left: 36px !default;
$calendar-weekly-weeknumber-flex-basis: 24px !default;
$calendar-weekly-weeknumber-padding-top: 14.5px !default;
$calendar-event-bottom-space: 1px !default;
$calendar-event-border-width: 1px !default;
$calendar-event-border-radius: $border-radius-root !default;
$calendar-event-font-size: 12px !default;
$calendar-event-line-height: 20px !default;
$calendar-event-right-empty: 10px !default;
+17
View File
@@ -0,0 +1,17 @@
import VCalendar from './VCalendar'
import VCalendarDaily from './VCalendarDaily'
import VCalendarWeekly from './VCalendarWeekly'
import VCalendarMonthly from './VCalendarMonthly'
import VCalendarCategory from './VCalendarCategory'
export { VCalendar, VCalendarCategory, VCalendarDaily, VCalendarWeekly, VCalendarMonthly }
export default {
$_vuetify_subcomponents: {
VCalendar,
VCalendarCategory,
VCalendarDaily,
VCalendarWeekly,
VCalendarMonthly,
},
}
@@ -0,0 +1,225 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`calendar-base.ts should create a day list 1`] = `
Array [
Object {
"date": "2019-01-29",
"day": 29,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 1,
"past": true,
"present": false,
"time": "",
"weekday": 2,
"year": 2019,
},
Object {
"date": "2019-01-30",
"day": 30,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 1,
"past": true,
"present": false,
"time": "",
"weekday": 3,
"year": 2019,
},
Object {
"date": "2019-01-31",
"day": 31,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 1,
"past": true,
"present": false,
"time": "",
"weekday": 4,
"year": 2019,
},
Object {
"date": "2019-02-01",
"day": 1,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 5,
"year": 2019,
},
Object {
"date": "2019-02-02",
"day": 2,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 6,
"year": 2019,
},
Object {
"date": "2019-02-03",
"day": 3,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 0,
"year": 2019,
},
Object {
"date": "2019-02-04",
"day": 4,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 1,
"year": 2019,
},
Object {
"date": "2019-02-05",
"day": 5,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 2,
"year": 2019,
},
Object {
"date": "2019-02-06",
"day": 6,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 3,
"year": 2019,
},
Object {
"date": "2019-02-07",
"day": 7,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 4,
"year": 2019,
},
Object {
"date": "2019-02-08",
"day": 8,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": true,
"present": false,
"time": "",
"weekday": 5,
"year": 2019,
},
]
`;
exports[`calendar-base.ts should generate classes 1`] = `
Object {
"v-future": false,
"v-outside": false,
"v-past": false,
"v-present": false,
}
`;
exports[`calendar-base.ts should generate classes with outside 1`] = `
Object {
"v-future": false,
"v-outside": true,
"v-past": false,
"v-present": false,
}
`;
exports[`calendar-base.ts should parse start & end 1`] = `
Object {
"date": "2019-01-29",
"day": 29,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 1,
"past": false,
"present": false,
"time": "",
"weekday": 2,
"year": 2019,
}
`;
exports[`calendar-base.ts should parse start & end 2`] = `
Object {
"date": "2019-02-08",
"day": 8,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": false,
"present": false,
"time": "",
"weekday": 5,
"year": 2019,
}
`;
@@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`calendar-with-events.ts should get events map 1`] = `
Object {
"2019-02-12": Object {
"events": Array [
<div
data-date="2019-02-12"
data-event="test"
/>,
],
"more": null,
"parent": <div>
<div
data-date="2019-02-12"
data-event="test"
/>
<div
data-date="2019-02-13"
data-event="test1"
/>
<div
data-date="2019-02-13"
data-event="test2"
data-more="123"
/>
</div>,
},
"2019-02-13": Object {
"events": Array [
<div
data-date="2019-02-13"
data-event="test1"
/>,
],
"more": <div
data-date="2019-02-13"
data-event="test2"
data-more="123"
/>,
"parent": <div>
<div
data-date="2019-02-12"
data-event="test"
/>
<div
data-date="2019-02-13"
data-event="test1"
/>
<div
data-date="2019-02-13"
data-event="test2"
data-more="123"
/>
</div>,
},
}
`;
@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`times.ts should parse timestamp 1`] = `
Object {
"date": "2019-02-08",
"day": 8,
"future": false,
"hasDay": true,
"hasTime": false,
"hour": 0,
"minute": 0,
"month": 2,
"past": false,
"present": false,
"time": "",
"weekday": 5,
"year": 2019,
}
`;
@@ -0,0 +1,176 @@
import CalendarBase from '../calendar-base'
import { parseTimestamp } from '../../util/timestamp'
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import { ExtractVue } from '../../../../util/mixins'
const Mock = CalendarBase.extend({
render: h => h('div'),
})
describe('calendar-base.ts', () => {
type Instance = ExtractVue<typeof Mock>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(Mock, {
...options,
mocks: {
$vuetify: {
lang: {
current: 'en-US',
},
},
},
})
}
})
it('should parse start & end', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.parsedStart).toBeDefined()
expect(wrapper.vm.parsedStart).toMatchSnapshot()
expect(wrapper.vm.parsedEnd).toBeDefined()
expect(wrapper.vm.parsedEnd).toMatchSnapshot()
})
it('should create a day list', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.days).toBeDefined()
expect(wrapper.vm.days).toHaveLength(11)
expect(wrapper.vm.days).toMatchSnapshot()
expect(wrapper.vm.days[0].date).toBe('2019-01-29')
expect(wrapper.vm.days[10].date).toBe('2019-02-08')
})
it('should calculate weekday skips', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.weekdaySkips).toBeDefined()
expect(wrapper.vm.weekdaySkips).toHaveLength(7)
})
it('should generate classes', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.getRelativeClasses(parseTimestamp('2019-01-28'))).toBeDefined()
expect(wrapper.vm.getRelativeClasses(parseTimestamp('2019-01-28'))).toMatchSnapshot()
})
it('should generate classes with outside', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.getRelativeClasses(parseTimestamp('2019-01-28'), true)).toBeDefined()
expect(wrapper.vm.getRelativeClasses(parseTimestamp('2019-01-28'), true)).toMatchSnapshot()
})
it('should return weekdayFormatter equal to weekdayFormat prop', async () => {
const weekdayFormat = x => x
const wrapper = mountFunction({
propsData: {
weekdayFormat,
},
})
expect(wrapper.vm.weekdayFormatter).toEqual(weekdayFormat)
})
it('should long-format weekday', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.weekdayFormatter).toBeDefined()
expect(typeof wrapper.vm.weekdayFormatter).toEqual('function')
expect(wrapper.vm.weekdayFormatter(parseTimestamp('2019-01-28'), false)).toEqual('Monday')
expect(wrapper.vm.weekdayFormatter(parseTimestamp('2019-01-27'), false)).toEqual('Sunday')
expect(wrapper.vm.weekdayFormatter(parseTimestamp('2019-01-29'), false)).toEqual('Tuesday')
})
it('should short-format weekday', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.weekdayFormatter).toBeDefined()
expect(typeof wrapper.vm.weekdayFormatter).toEqual('function')
expect(wrapper.vm.weekdayFormatter(parseTimestamp('2019-01-28'), true)).toEqual('Mon')
expect(wrapper.vm.weekdayFormatter(parseTimestamp('2019-01-27'), true)).toEqual('Sun')
expect(wrapper.vm.weekdayFormatter(parseTimestamp('2019-01-29'), true)).toEqual('Tue')
})
it('should get start of week', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.getStartOfWeek(parseTimestamp('2019-01-28')).weekday).toEqual(0)
expect(wrapper.vm.getStartOfWeek(parseTimestamp('2019-01-03')).weekday).toEqual(0)
})
it('should get end of week', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.getEndOfWeek(parseTimestamp('2019-03-28')).weekday).toEqual(6)
expect(wrapper.vm.getEndOfWeek(parseTimestamp('2019-12-31')).weekday).toEqual(6)
})
it('should return dayFormatter equal to dayFormat prop', async () => {
const dayFormat = x => x
const wrapper = mountFunction({
propsData: {
dayFormat,
},
})
expect(wrapper.vm.dayFormatter).toEqual(dayFormat)
})
it('should format day', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-08',
},
})
expect(wrapper.vm.weekdayFormatter).toBeDefined()
expect(typeof wrapper.vm.weekdayFormatter).toEqual('function')
expect(wrapper.vm.dayFormatter(parseTimestamp('2019-01-28'), false)).toEqual('28')
expect(wrapper.vm.dayFormatter(parseTimestamp('2019-01-27'), false)).toEqual('27')
expect(wrapper.vm.dayFormatter(parseTimestamp('2019-01-29'), false)).toEqual('29')
})
})
@@ -0,0 +1,236 @@
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import CalendarWithEvents from '../calendar-with-events'
import { parseTimestamp } from '../../util/timestamp'
import { parseEvent } from '../../util/events'
const Mock = CalendarWithEvents.extend({
render: h => h('div'),
})
describe('calendar-with-events.ts', () => {
type Instance = InstanceType<typeof Mock>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(Mock, options)
}
})
it('should check if there is no events', async () => {
const wrapper = mount(Mock)
expect(wrapper.vm.noEvents).toBeTruthy()
wrapper.setProps({
events: [
{
start: '2019-02-12',
},
],
})
expect(wrapper.vm.noEvents).toBeFalsy()
})
it('should parse events', async () => {
const wrapper = mount(Mock, {
propsData: {
events: [
{
start: '2019-02-12',
},
],
},
})
expect(wrapper.vm.parsedEvents).toBeDefined()
expect(wrapper.vm.parsedEvents).toHaveLength(1)
expect(wrapper.vm.parsedEvents[0]).toMatchObject({ start: { date: '2019-02-12' }, end: { date: '2019-02-12' } })
})
it('should work with event colors', async () => {
const wrapper = mount(Mock, {
propsData: {
eventColor: () => 'green',
},
})
expect(wrapper.vm.eventColorFunction).toBeDefined()
expect(typeof wrapper.vm.eventColorFunction).toBe('function')
expect(wrapper.vm.eventColorFunction()).toBe('green')
wrapper.setProps({
eventColor: 'red',
})
expect(wrapper.vm.eventColorFunction).toBeDefined()
expect(typeof wrapper.vm.eventColorFunction).toBe('function')
expect(wrapper.vm.eventColorFunction()).toBe('red')
})
it('should work with event text colors', async () => {
const wrapper = mount(Mock, {
propsData: {
eventTextColor: () => 'green',
},
})
expect(wrapper.vm.eventTextColorFunction).toBeDefined()
expect(typeof wrapper.vm.eventTextColorFunction).toBe('function')
expect(wrapper.vm.eventTextColorFunction()).toBe('green')
wrapper.setProps({
eventTextColor: 'red',
})
expect(wrapper.vm.eventTextColorFunction).toBeDefined()
expect(typeof wrapper.vm.eventTextColorFunction).toBe('function')
expect(wrapper.vm.eventTextColorFunction()).toBe('red')
})
it('should work with event names', async () => {
const wrapper = mount(Mock, {
propsData: {
eventName: () => 'Meetup',
},
})
expect(wrapper.vm.eventNameFunction).toBeDefined()
expect(typeof wrapper.vm.eventNameFunction).toBe('function')
expect(wrapper.vm.eventNameFunction({ start: { date: '2019-02-12' }, input: { Meetup: 'Meetup' } })).toBe('Meetup')
expect(wrapper.vm.eventNameFunction({ start: { date: '2019-02-12', hour: 8, minute: 30, hasTime: true }, input: { Meetup: 'Meetup' } })).toBe('Meetup')
wrapper.setProps({
eventName: 'x',
})
expect(wrapper.vm.eventNameFunction).toBeDefined()
expect(typeof wrapper.vm.eventNameFunction).toBe('function')
expect(wrapper.vm.eventNameFunction({ start: { date: '2019-02-12' }, input: { x: 'Conference' } })).toBe('Conference')
expect(wrapper.vm.eventNameFunction({ start: { date: '2019-02-12', hour: 8, minute: 30, hasTime: true }, input: { x: 'Conference' } })).toMatch('Conference') // will match 8:30 AM|| 08:30 AM || 8:30 || 08:30
})
it('should format time', async () => {
const testData1 = { date: '2019-01-01', hour: 8, minute: 30 }
const testData2 = { date: '2019-01-01', hour: 17, minute: 45 }
const testData3 = { date: '2019-01-01', hour: 9, minute: 5 }
const testData4 = { date: '2019-01-01', hour: 15, minute: 0 }
const wrapper = mount(Mock)
// Depending on the time format of the underlying system
// (12-hour with `h` || 12-hour with `hh` || 24-hour with `h` || 24-hour with `hh`),
// we expect the value passed to be-
expect(wrapper.vm.formatTime(testData1, true)).toMatch(/^0?8:30( AM)?$/) // 8:30 AM || 08:30 AM || 8:30 || 08:30
expect(wrapper.vm.formatTime(testData2, true)).toMatch(/^(0?5:45 PM|17:45)$/) // 5:45 PM || 05:45 PM || 17:45
expect(wrapper.vm.formatTime(testData3, true)).toMatch(/^0?9:05( AM)?$/) // 9:05 AM || 09:05 AM || 9:05 || 09:45
expect(wrapper.vm.formatTime(testData4, true)).toMatch(/^(0?3 PM|15)$/) // 3 AM || 03 AM || 15
})
it('should get events map', async () => {
const wrapper = mountFunction({
render: h => h('div', [
h('div', {
ref: 'events',
refInFor: true,
attrs: {
'data-event': 'test',
'data-date': '2019-02-12',
},
}),
h('div', {
ref: 'events',
refInFor: true,
attrs: {
'data-event': 'test1',
'data-date': '2019-02-13',
},
}),
h('div', {
ref: 'events',
refInFor: true,
attrs: {
'data-event': 'test2',
'data-date': '2019-02-13',
'data-more': '123',
},
}),
]),
})
expect(wrapper.vm.getEventsMap()).toMatchSnapshot()
})
it('should get events for day', async () => {
const wrapper = mount(Mock, {
propsData: {
events: [
{
start: '2019-02-12 8:30',
end: '2019-02-12 12:00',
},
{
start: '2019-02-11',
end: '2019-02-13',
},
],
},
})
expect(wrapper.vm.getEventsForDay(parseTimestamp('2019-02-10'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDay(parseTimestamp('2019-02-11'))).toHaveLength(1)
expect(wrapper.vm.getEventsForDay(parseTimestamp('2019-02-12'))).toHaveLength(1)
expect(wrapper.vm.getEventsForDay(parseTimestamp('2019-02-13'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDay(parseTimestamp('2019-02-14'))).toHaveLength(0)
})
it('should get events for all day', async () => {
const wrapper = mount(Mock, {
propsData: {
events: [
{
start: '2019-02-12 8:30',
end: '2019-02-12 12:00',
},
{
start: '2019-02-11',
end: '2019-02-13',
},
],
},
})
expect(wrapper.vm.getEventsForDayAll(parseTimestamp('2019-02-10'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDayAll(parseTimestamp('2019-02-11'))).toHaveLength(1)
expect(wrapper.vm.getEventsForDayAll(parseTimestamp('2019-02-12'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDayAll(parseTimestamp('2019-02-13'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDayAll(parseTimestamp('2019-02-14'))).toHaveLength(0)
})
it('should get timed events for day', async () => {
const wrapper = mount(Mock, {
propsData: {
events: [
{
start: '2019-02-12 8:30',
end: '2019-02-12 12:00',
},
{
start: '2019-02-11',
end: '2019-02-13',
},
],
},
})
expect(wrapper.vm.getEventsForDayTimed(parseTimestamp('2019-02-10'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDayTimed(parseTimestamp('2019-02-11'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDayTimed(parseTimestamp('2019-02-12'))).toHaveLength(1)
expect(wrapper.vm.getEventsForDayTimed(parseTimestamp('2019-02-13'))).toHaveLength(0)
expect(wrapper.vm.getEventsForDayTimed(parseTimestamp('2019-02-14'))).toHaveLength(0)
})
})
@@ -0,0 +1,358 @@
import CalendarWithIntervals from '../calendar-with-intervals'
import { CalendarTimestamp } from 'vuetify/types'
import { parseTimestamp } from '../../util/timestamp'
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import { ExtractVue } from '../../../../util/mixins'
const Mock = CalendarWithIntervals.extend({
render: h => h('div'),
})
const createMouseEvent = (x, y) => ({
clientX: x,
clientY: y,
currentTarget: document.body,
})
const createTouchEvent = (x, y) => ({
touches: [{
clientX: x,
clientY: y,
}],
currentTarget: document.body,
})
describe('calendar-with-intervals.ts', () => {
type Instance = ExtractVue<typeof Mock>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(Mock, {
...options,
mocks: {
$vuetify: {
lang: {
current: 'en-US',
},
},
},
})
}
})
it('should parse all data', async () => {
const wrapper = mountFunction({
propsData: {
firstInterval: '1',
intervalMinutes: '30',
intervalCount: '10',
intervalHeight: '20',
},
})
expect(wrapper.vm.parsedFirstInterval).toBeDefined()
expect(wrapper.vm.parsedFirstInterval).toBe(1)
expect(wrapper.vm.parsedIntervalMinutes).toBeDefined()
expect(wrapper.vm.parsedIntervalMinutes).toBe(30)
expect(wrapper.vm.parsedIntervalCount).toBeDefined()
expect(wrapper.vm.parsedIntervalCount).toBe(10)
expect(wrapper.vm.parsedIntervalHeight).toBeDefined()
expect(wrapper.vm.parsedIntervalHeight).toBe(20)
})
it('should generate firstMinute', async () => {
const wrapper = mountFunction({
propsData: {
firstInterval: '2',
intervalMinutes: '30',
},
})
expect(wrapper.vm.firstMinute).toBeDefined()
expect(wrapper.vm.firstMinute).toBe(60)
})
it('should generate bodyHeight', async () => {
const wrapper = mountFunction({
propsData: {
intervalCount: '10',
intervalHeight: '20',
},
})
expect(wrapper.vm.bodyHeight).toBeDefined()
expect(wrapper.vm.bodyHeight).toBe(200)
})
it('should generate days', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
},
})
expect(wrapper.vm.days).toBeDefined()
expect(wrapper.vm.days).toHaveLength(7)
expect(wrapper.vm.days[0].date).toBe('2019-01-29')
expect(wrapper.vm.days[6].date).toBe('2019-02-04')
expect(wrapper.vm.days).toMatchSnapshot()
wrapper.setProps({
start: '2019-01-29',
end: '2019-02-02',
})
expect(wrapper.vm.days).toBeDefined()
expect(wrapper.vm.days).toHaveLength(5)
expect(wrapper.vm.days[0].date).toBe('2019-01-29')
expect(wrapper.vm.days[4].date).toBe('2019-02-02')
expect(wrapper.vm.days).toMatchSnapshot()
})
it('should generate intervals', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
},
})
expect(wrapper.vm.intervals).toBeDefined()
expect(wrapper.vm.intervals).toHaveLength(7)
expect(wrapper.vm.intervals[0]).toHaveLength(24)
expect(wrapper.vm.intervals[0][0].date).toBe('2019-01-29')
expect(wrapper.vm.intervals[6][0].date).toBe('2019-02-04')
expect(wrapper.vm.intervals).toMatchSnapshot()
wrapper.setProps({
start: '2019-01-29',
end: '2019-02-02',
})
expect(wrapper.vm.intervals).toBeDefined()
expect(wrapper.vm.intervals).toHaveLength(5)
expect(wrapper.vm.intervals[0]).toHaveLength(24)
expect(wrapper.vm.intervals[0][0].date).toBe('2019-01-29')
expect(wrapper.vm.intervals[4][0].date).toBe('2019-02-02')
expect(wrapper.vm.intervals).toMatchSnapshot()
})
it('should generate intervalFormatter', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.intervalFormatter).toBeDefined()
expect(typeof wrapper.vm.intervalFormatter).toBe('function')
})
// TODO: Re-enable when test doesn't break travis
it.skip('should format interval', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.intervalFormatter({ date: '2019-02-08', hour: 8, minute: 30 } as CalendarTimestamp, false)).toBe('8:30 AM')
expect(wrapper.vm.intervalFormatter({ date: '2019-02-08', hour: 20, minute: 30 } as CalendarTimestamp, false)).toBe('8:30 PM')
expect(wrapper.vm.intervalFormatter({ date: '2019-02-08', hour: 0, minute: 30 } as CalendarTimestamp, false)).toBe('12:30 AM')
expect(wrapper.vm.intervalFormatter({ date: '2019-02-08', hour: 8, minute: 30 } as CalendarTimestamp, true)).toBe('8:30 AM')
expect(wrapper.vm.intervalFormatter({ date: '2019-02-08', hour: 20, minute: 30 } as CalendarTimestamp, true)).toBe('8:30 PM')
expect(wrapper.vm.intervalFormatter({ date: '2019-02-08', hour: 0, minute: 30 } as CalendarTimestamp, true)).toBe('12:30 AM')
})
it('should return intervalFormat if has one', async () => {
const intervalFormat = x => x
const wrapper = mountFunction({
propsData: {
intervalFormat,
},
})
expect(wrapper.vm.intervalFormatter).toBeDefined()
expect(typeof wrapper.vm.intervalFormatter).toBe('function')
expect(wrapper.vm.intervalFormatter).toBe(intervalFormat)
})
it('should generate slot scope', async () => {
const wrapper = mountFunction()
expect(wrapper.vm.getSlotScope(parseTimestamp('2019-02-08'))).toBeDefined()
expect(wrapper.vm.getSlotScope(parseTimestamp('2019-02-08')).date).toBe('2019-02-08')
const scope = wrapper.vm.getSlotScope(parseTimestamp('2019-02-08'))
delete scope.week
expect(scope).toMatchSnapshot()
expect(typeof wrapper.vm.getSlotScope(parseTimestamp('2019-02-08')).timeToY).toBe('function')
expect(typeof wrapper.vm.getSlotScope(parseTimestamp('2019-02-08')).timeDelta).toBe('function')
expect(typeof wrapper.vm.getSlotScope(parseTimestamp('2019-02-08')).minutesToPixels).toBe('function')
})
it('should convert time to Y', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.timeToY).toBe('function')
expect(wrapper.vm.timeToY('08:30')).toBeDefined()
expect(wrapper.vm.timeToY('08:30')).toBe(408)
expect(wrapper.vm.timeToY('09:30')).toBe(456)
expect(Math.round(wrapper.vm.timeToY('23:50') || 0)).toBe(1144)
wrapper.setProps({
firstInterval: 5,
intervalCount: 5,
intervalMinutes: 10,
bodyHeight: 400,
})
expect(wrapper.vm.timeToY('08:30')).toBe(240)
expect(wrapper.vm.timeToY('09:30')).toBe(240)
expect(wrapper.vm.timeToY('23:50')).toBe(240)
expect(wrapper.vm.timeToY('00:05')).toBe(0)
expect(Math.round(wrapper.vm.timeToY('08:30', false) || 0)).toBe(2208)
expect(wrapper.vm.timeToY('09:30', false)).toBe(2496)
expect(wrapper.vm.timeToY('23:50', false)).toBe(6624)
expect(wrapper.vm.timeToY('bad')).toBe(false)
})
it('should convert time delta', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.timeDelta).toBe('function')
expect(wrapper.vm.timeDelta('08:30')).toBeDefined()
expect(wrapper.vm.timeDelta('08:30')).toBe((8 * 60 + 30) / 1440)
expect(wrapper.vm.timeDelta('09:30')).toBe((9 * 60 + 30) / 1440)
expect(Math.round(wrapper.vm.timeDelta('23:50') || 0)).toBe(1)
wrapper.setProps({
firstInterval: 5,
intervalCount: 5,
intervalMinutes: 10,
bodyHeight: 400,
})
expect(wrapper.vm.timeDelta('08:30')).toBe((8 * 60 + 30 - 50) / 50)
expect(wrapper.vm.timeDelta('09:30')).toBe((9 * 60 + 30 - 50) / 50)
expect(wrapper.vm.timeDelta('23:50')).toBe((23 * 60 + 50 - 50) / 50)
expect(wrapper.vm.timeDelta('00:50')).toBe(0)
expect(wrapper.vm.timeDelta('bad')).toBe(false)
})
it('should convert minutes to pixels', async () => {
const wrapper = mountFunction({
propsData: {
intervalMinutes: 5,
bodyHeight: 200,
},
})
expect(wrapper.vm.minutesToPixels).toBeDefined()
expect(typeof wrapper.vm.minutesToPixels).toBe('function')
expect(wrapper.vm.minutesToPixels(5)).toBeDefined()
expect(wrapper.vm.minutesToPixels(5)).toBe(48)
expect(wrapper.vm.minutesToPixels(10)).toBe(96)
expect(wrapper.vm.minutesToPixels(50)).toBe(480)
wrapper.setProps({
intervalMinutes: 10,
bodyHeight: 400,
})
expect(wrapper.vm.minutesToPixels(5)).toBe(24)
expect(wrapper.vm.minutesToPixels(10)).toBe(48)
expect(wrapper.vm.minutesToPixels(50)).toBe(240)
})
it('should scroll to time', async () => {
const wrapper = mountFunction({
render: h => h('div', [
h('div', {
ref: 'scrollArea',
}),
]),
})
wrapper.vm.scrollToTime('8:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(408)
wrapper.vm.scrollToTime('12:30')
expect(Math.round((wrapper.vm.$refs.scrollArea as any).scrollTop)).toBe(600)
wrapper.vm.scrollToTime('20:00')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(960)
wrapper.setProps({
intervalMinutes: 5,
bodyHeight: 200,
})
wrapper.vm.scrollToTime('8:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(1152)
wrapper.vm.scrollToTime('12:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(1152)
wrapper.vm.scrollToTime('20:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(1152)
wrapper.setProps({
intervalMinutes: 30,
bodyHeight: 1700,
})
wrapper.vm.scrollToTime('8:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(816)
wrapper.vm.scrollToTime('12:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(1152)
wrapper.vm.scrollToTime('20:30')
expect((wrapper.vm.$refs.scrollArea as any).scrollTop).toBe(1152)
expect(wrapper.vm.scrollToTime('20:19')).toBe(true)
expect(wrapper.vm.scrollToTime('bad')).toBe(false)
})
it('should get timestamp at mouse event', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.getTimestampAtEvent).toBe('function')
expect(wrapper.vm.getTimestampAtEvent(createMouseEvent(0, 100) as unknown as MouseEvent, { time: '20:00' } as CalendarTimestamp)).toMatchObject({ hour: 2, minute: 5 })
expect(wrapper.vm.getTimestampAtEvent(createMouseEvent(0, 150) as unknown as MouseEvent, { time: '20:00' } as CalendarTimestamp)).toMatchObject({ hour: 3, minute: 7 })
expect(wrapper.vm.getTimestampAtEvent(createMouseEvent(0, 200) as unknown as MouseEvent, { time: '20:00' } as CalendarTimestamp)).toMatchObject({ hour: 4, minute: 10 })
})
it('should get timestamp at touch event', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.getTimestampAtEvent).toBe('function')
expect(wrapper.vm.getTimestampAtEvent(createTouchEvent(0, 100) as unknown as TouchEvent, { time: '20:00' } as CalendarTimestamp)).toMatchObject({ hour: 2, minute: 5 })
expect(wrapper.vm.getTimestampAtEvent(createTouchEvent(0, 150) as unknown as TouchEvent, { time: '20:00' } as CalendarTimestamp)).toMatchObject({ hour: 3, minute: 7 })
expect(wrapper.vm.getTimestampAtEvent(createTouchEvent(0, 200) as unknown as TouchEvent, { time: '20:00' } as CalendarTimestamp)).toMatchObject({ hour: 4, minute: 10 })
})
it('should get style', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.intervalStyleDefault).toBe('function')
expect(wrapper.vm.intervalStyleDefault({} as CalendarTimestamp)).toBeUndefined()
})
it('should show interval label', async () => {
const wrapper = mountFunction({
propsData: {
start: '2019-01-29',
end: '2019-02-04',
firstInterval: 5,
},
})
expect(typeof wrapper.vm.showIntervalLabelDefault).toBe('function')
expect(wrapper.vm.showIntervalLabelDefault({})).toBeTruthy()
expect(wrapper.vm.showIntervalLabelDefault({ hour: 0, minute: 5 } as CalendarTimestamp)).toBeTruthy()
expect(wrapper.vm.showIntervalLabelDefault({ hour: 12, minute: 30 } as CalendarTimestamp)).toBeTruthy()
expect(wrapper.vm.showIntervalLabelDefault({ hour: 13, minute: 0 } as CalendarTimestamp)).toBeTruthy()
expect(wrapper.vm.showIntervalLabelDefault({ hour: 13, minute: 30 } as CalendarTimestamp)).toBeTruthy()
})
})
@@ -0,0 +1,90 @@
import Mouse from '../mouse'
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import { ExtractVue } from '../../../../util/mixins'
const Mock = Mouse.extend({
render: h => h('div'),
})
describe('mouse.ts', () => {
type Instance = ExtractVue<typeof Mock>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(Mock, options)
}
})
it('should generate mouse event handlers', async () => {
const noop = e => e
const wrapper = mount(Mock, {
listeners: {
click: noop,
},
})
expect(typeof wrapper.vm.getMouseEventHandlers({ click: { event: 'click' } }, noop).click).toBe('function')
})
it('should generate default mouse event handlers', async () => {
const noop = e => e
const wrapper = mount(Mock, {
listeners: {
click: noop,
},
})
expect(typeof wrapper.vm.getDefaultMouseEventHandlers('', noop).click).toBe('function')
expect(Object.keys(typeof wrapper.vm.getDefaultMouseEventHandlers('', noop))).toHaveLength(6)
})
it('should emit events', async () => {
const fn = jest.fn()
const wrapper = mount(Mock, {
listeners: {
click: fn,
},
})
const { click } = wrapper.vm.getMouseEventHandlers({ click: { event: 'click' } }, () => {})
Array.isArray(click) ? click[0](null) : click(null)
expect(fn).toHaveBeenCalledTimes(1)
})
it('should handle prevent modifier', async () => {
const fn = jest.fn()
const wrapper = mount(Mock, {
listeners: {
click: fn,
},
})
const event = { preventDefault: () => {} }
const spy = jest.spyOn(event, 'preventDefault')
const { click } = wrapper.vm.getMouseEventHandlers({ click: { event: 'click', prevent: true } }, () => {})
Array.isArray(click) ? click[0](event as MouseEvent) : click(event as MouseEvent)
expect(fn).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledTimes(1)
})
it('should handle stop modifier', async () => {
const fn = jest.fn()
const wrapper = mount(Mock, {
listeners: {
click: fn,
},
})
const event = { stopPropagation: () => {} }
const spy = jest.spyOn(event, 'stopPropagation')
const { click } = wrapper.vm.getMouseEventHandlers({ click: { event: 'click', stop: true } }, () => {})
Array.isArray(click) ? click[0](event as MouseEvent) : click(event as MouseEvent)
expect(fn).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,79 @@
import Times from '../times'
import {
mount,
Wrapper,
MountOptions,
} from '@vue/test-utils'
import { ExtractVue } from '../../../../util/mixins'
import { CalendarTimestamp } from 'vuetify/types'
const Mock = Times.extend({
render: h => h('div'),
})
describe('times.ts', () => {
type Instance = ExtractVue<typeof Mock>
let mountFunction: (options?: MountOptions<Instance>) => Wrapper<Instance>
beforeEach(() => {
mountFunction = (options?: MountOptions<Instance>) => {
return mount(Mock, options)
}
})
it('should parse timestamp', async () => {
const wrapper = mountFunction({
propsData: {
now: '2019-02-08',
},
})
expect(wrapper.vm.parsedNow).toBeDefined()
expect(wrapper.vm.parsedNow).toMatchSnapshot()
})
it('should update day', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.updateDay).toBe('function')
const target = {}
const now = {
date: '2019-02-08',
year: '2019',
month: '2',
day: '8',
weekday: '4',
}
wrapper.vm.updateDay(now as unknown as CalendarTimestamp, target as unknown as CalendarTimestamp)
expect(target).toEqual(now)
})
it('should not update day if dates are equal', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.updateDay).toBe('function')
const target = { date: '2019-02-08' }
const now = {
date: '2019-02-08',
year: '2019',
month: '2',
day: '8',
weekday: '4',
}
wrapper.vm.updateDay(now as unknown as CalendarTimestamp, target as unknown as CalendarTimestamp)
expect(target).not.toEqual(now)
})
it('should not update time if times are equal', async () => {
const wrapper = mountFunction()
expect(typeof wrapper.vm.updateTime).toBe('function')
const target = { time: '08:30' }
const now = {
time: '08:30',
hour: '8',
minute: '30',
}
wrapper.vm.updateTime(now as unknown as CalendarTimestamp, target as unknown as CalendarTimestamp)
expect(target).not.toEqual(now)
})
})
+122
View File
@@ -0,0 +1,122 @@
// Mixins
import mixins from '../../../util/mixins'
import Colorable from '../../../mixins/colorable'
import Localable from '../../../mixins/localable'
import Mouse from './mouse'
import Themeable from '../../../mixins/themeable'
import Times from './times'
// Directives
import Resize from '../../../directives/resize'
// Util
import props from '../util/props'
import {
parseTimestamp,
getWeekdaySkips,
createDayList,
createNativeLocaleFormatter,
getStartOfWeek,
getEndOfWeek,
getTimestampIdentifier,
} from '../util/timestamp'
import { CalendarTimestamp, CalendarFormatter } from 'vuetify/types'
export default mixins(
Colorable,
Localable,
Mouse,
Themeable,
Times
/* @vue/component */
).extend({
name: 'calendar-base',
directives: {
Resize,
},
props: props.base,
computed: {
parsedWeekdays (): number[] {
return Array.isArray(this.weekdays)
? this.weekdays
: (this.weekdays || '').split(',').map(x => parseInt(x, 10))
},
weekdaySkips (): number[] {
return getWeekdaySkips(this.parsedWeekdays)
},
weekdaySkipsReverse (): number [] {
const reversed = this.weekdaySkips.slice()
reversed.reverse()
return reversed
},
parsedStart (): CalendarTimestamp {
return parseTimestamp(this.start, true)
},
parsedEnd (): CalendarTimestamp {
const start = this.parsedStart
const end: CalendarTimestamp = this.end ? parseTimestamp(this.end) || start : start
return getTimestampIdentifier(end) < getTimestampIdentifier(start) ? start : end
},
days (): CalendarTimestamp[] {
return createDayList(
this.parsedStart,
this.parsedEnd,
this.times.today,
this.weekdaySkips
)
},
dayFormatter (): CalendarFormatter {
if (this.dayFormat) {
return this.dayFormat as CalendarFormatter
}
const options = { timeZone: 'UTC', day: 'numeric' }
return createNativeLocaleFormatter(
this.currentLocale,
(_tms, _short) => options
)
},
weekdayFormatter (): CalendarFormatter {
if (this.weekdayFormat) {
return this.weekdayFormat as CalendarFormatter
}
const longOptions = { timeZone: 'UTC', weekday: 'long' }
const shortOptions = { timeZone: 'UTC', weekday: 'short' }
return createNativeLocaleFormatter(
this.currentLocale,
(_tms, short) => short ? shortOptions : longOptions
)
},
},
methods: {
getRelativeClasses (timestamp: CalendarTimestamp, outside = false): object {
return {
'v-present': timestamp.present,
'v-past': timestamp.past,
'v-future': timestamp.future,
'v-outside': outside,
}
},
getStartOfWeek (timestamp: CalendarTimestamp): CalendarTimestamp {
return getStartOfWeek(timestamp, this.parsedWeekdays, this.times.today)
},
getEndOfWeek (timestamp: CalendarTimestamp): CalendarTimestamp {
return getEndOfWeek(timestamp, this.parsedWeekdays, this.times.today)
},
getFormatter (options: object): CalendarFormatter {
return createNativeLocaleFormatter(
this.locale,
(_tms, _short) => options
)
},
},
})

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