vuetify
Version:
Vue Material Component Framework
529 lines (470 loc) • 14 kB
text/typescript
// Styles
import './VTextField.sass'
// Extensions
import VInput from '../VInput'
// Components
import VCounter from '../VCounter'
import VLabel from '../VLabel'
// Mixins
import Intersectable from '../../mixins/intersectable'
import Loadable from '../../mixins/loadable'
import Validatable from '../../mixins/validatable'
// Directives
import resize from '../../directives/resize'
import ripple from '../../directives/ripple'
// Utilities
import { convertToUnit, keyCodes } from '../../util/helpers'
import { breaking, consoleWarn } from '../../util/console'
// Types
import mixins from '../../util/mixins'
import { VNode, PropType } from 'vue/types'
const baseMixins = mixins(
VInput,
Intersectable({
onVisible: [
'onResize',
'tryAutofocus',
],
}),
Loadable,
)
interface options extends InstanceType<typeof baseMixins> {
$refs: {
label: HTMLElement
input: HTMLInputElement
'prepend-inner': HTMLElement
prefix: HTMLElement
suffix: HTMLElement
}
}
const dirtyTypes = ['color', 'file', 'time', 'date', 'datetime-local', 'week', 'month']
/* @vue/component */
export default baseMixins.extend<options>().extend({
name: 'v-text-field',
directives: {
resize,
ripple,
},
inheritAttrs: false,
props: {
appendOuterIcon: String,
autofocus: Boolean,
clearable: Boolean,
clearIcon: {
type: String,
default: '$clear',
},
counter: [Boolean, Number, String],
counterValue: Function as PropType<(value: any) => number>,
filled: Boolean,
flat: Boolean,
fullWidth: Boolean,
label: String,
outlined: Boolean,
placeholder: String,
prefix: String,
prependInnerIcon: String,
reverse: Boolean,
rounded: Boolean,
shaped: Boolean,
singleLine: Boolean,
solo: Boolean,
soloInverted: Boolean,
suffix: String,
type: {
type: String,
default: 'text',
},
},
data: () => ({
badInput: false,
labelWidth: 0,
prefixWidth: 0,
prependWidth: 0,
initialValue: null,
isBooted: false,
isClearing: false,
}),
computed: {
classes (): object {
return {
...VInput.options.computed.classes.call(this),
'v-text-field': true,
'v-text-field--full-width': this.fullWidth,
'v-text-field--prefix': this.prefix,
'v-text-field--single-line': this.isSingle,
'v-text-field--solo': this.isSolo,
'v-text-field--solo-inverted': this.soloInverted,
'v-text-field--solo-flat': this.flat,
'v-text-field--filled': this.filled,
'v-text-field--is-booted': this.isBooted,
'v-text-field--enclosed': this.isEnclosed,
'v-text-field--reverse': this.reverse,
'v-text-field--outlined': this.outlined,
'v-text-field--placeholder': this.placeholder,
'v-text-field--rounded': this.rounded,
'v-text-field--shaped': this.shaped,
}
},
computedColor (): string | undefined {
const computedColor = Validatable.options.computed.computedColor.call(this)
if (!this.soloInverted || !this.isFocused) return computedColor
return this.color || 'primary'
},
computedCounterValue (): number {
if (typeof this.counterValue === 'function') {
return this.counterValue(this.internalValue)
}
return [...(this.internalValue || '').toString()].length
},
hasCounter (): boolean {
return this.counter !== false && this.counter != null
},
hasDetails (): boolean {
return VInput.options.computed.hasDetails.call(this) || this.hasCounter
},
internalValue: {
get (): any {
return this.lazyValue
},
set (val: any) {
this.lazyValue = val
this.$emit('input', this.lazyValue)
},
},
isDirty (): boolean {
return this.lazyValue?.toString().length > 0 || this.badInput
},
isEnclosed (): boolean {
return (
this.filled ||
this.isSolo ||
this.outlined
)
},
isLabelActive (): boolean {
return this.isDirty || dirtyTypes.includes(this.type)
},
isSingle (): boolean {
return (
this.isSolo ||
this.singleLine ||
this.fullWidth ||
// https://material.io/components/text-fields/#filled-text-field
(this.filled && !this.hasLabel)
)
},
isSolo (): boolean {
return this.solo || this.soloInverted
},
labelPosition (): Record<'left' | 'right', string | number | undefined> {
let offset = (this.prefix && !this.labelValue) ? this.prefixWidth : 0
if (this.labelValue && this.prependWidth) offset -= this.prependWidth
return (this.$vuetify.rtl === this.reverse) ? {
left: offset,
right: 'auto',
} : {
left: 'auto',
right: offset,
}
},
showLabel (): boolean {
return this.hasLabel && (!this.isSingle || (!this.isLabelActive && !this.placeholder))
},
labelValue (): boolean {
return !this.isSingle &&
Boolean(this.isFocused || this.isLabelActive || this.placeholder)
},
},
watch: {
// labelValue: 'setLabelWidth', // moved to mounted, see #11533
outlined: 'setLabelWidth',
label () {
this.$nextTick(this.setLabelWidth)
},
prefix () {
this.$nextTick(this.setPrefixWidth)
},
isFocused: 'updateValue',
value (val) {
this.lazyValue = val
},
},
created () {
/* istanbul ignore next */
if (this.$attrs.hasOwnProperty('box')) {
breaking('box', 'filled', this)
}
/* istanbul ignore next */
if (this.$attrs.hasOwnProperty('browser-autocomplete')) {
breaking('browser-autocomplete', 'autocomplete', this)
}
/* istanbul ignore if */
if (this.shaped && !(this.filled || this.outlined || this.isSolo)) {
consoleWarn('shaped should be used with either filled or outlined', this)
}
},
mounted () {
// #11533
this.$watch(() => this.labelValue, this.setLabelWidth)
this.autofocus && this.tryAutofocus()
requestAnimationFrame(() => (this.isBooted = true))
},
methods: {
/** @public */
focus () {
this.onFocus()
},
/** @public */
blur (e?: Event) {
// https://github.com/vuetifyjs/vuetify/issues/5913
// Safari tab order gets broken if called synchronous
window.requestAnimationFrame(() => {
this.$refs.input && this.$refs.input.blur()
})
},
clearableCallback () {
this.$refs.input && this.$refs.input.focus()
this.$nextTick(() => this.internalValue = null)
},
genAppendSlot () {
const slot = []
if (this.$slots['append-outer']) {
slot.push(this.$slots['append-outer'] as VNode[])
} else if (this.appendOuterIcon) {
slot.push(this.genIcon('appendOuter'))
}
return this.genSlot('append', 'outer', slot)
},
genPrependInnerSlot () {
const slot = []
if (this.$slots['prepend-inner']) {
slot.push(this.$slots['prepend-inner'] as VNode[])
} else if (this.prependInnerIcon) {
slot.push(this.genIcon('prependInner'))
}
return this.genSlot('prepend', 'inner', slot)
},
genIconSlot () {
const slot = []
if (this.$slots.append) {
slot.push(this.$slots.append as VNode[])
} else if (this.appendIcon) {
slot.push(this.genIcon('append'))
}
return this.genSlot('append', 'inner', slot)
},
genInputSlot () {
const input = VInput.options.methods.genInputSlot.call(this)
const prepend = this.genPrependInnerSlot()
if (prepend) {
input.children = input.children || []
input.children.unshift(prepend)
}
return input
},
genClearIcon () {
if (!this.clearable) return null
const data = this.isDirty ? undefined : { attrs: { disabled: true } }
return this.genSlot('append', 'inner', [
this.genIcon('clear', this.clearableCallback, data),
])
},
genCounter () {
if (!this.hasCounter) return null
const max = this.counter === true ? this.attrs$.maxlength : this.counter
return this.$createElement(VCounter, {
props: {
dark: this.dark,
light: this.light,
max,
value: this.computedCounterValue,
},
})
},
genControl () {
return VInput.options.methods.genControl.call(this)
},
genDefaultSlot () {
return [
this.genFieldset(),
this.genTextFieldSlot(),
this.genClearIcon(),
this.genIconSlot(),
this.genProgress(),
]
},
genFieldset () {
if (!this.outlined) return null
return this.$createElement('fieldset', {
attrs: {
'aria-hidden': true,
},
}, [this.genLegend()])
},
genLabel () {
if (!this.showLabel) return null
const data = {
props: {
absolute: true,
color: this.validationState,
dark: this.dark,
disabled: this.isDisabled,
focused: !this.isSingle && (this.isFocused || !!this.validationState),
for: this.computedId,
left: this.labelPosition.left,
light: this.light,
right: this.labelPosition.right,
value: this.labelValue,
},
}
return this.$createElement(VLabel, data, this.$slots.label || this.label)
},
genLegend () {
const width = !this.singleLine && (this.labelValue || this.isDirty) ? this.labelWidth : 0
const span = this.$createElement('span', {
domProps: { innerHTML: '​' },
})
return this.$createElement('legend', {
style: {
width: !this.isSingle ? convertToUnit(width) : undefined,
},
}, [span])
},
genInput () {
const listeners = Object.assign({}, this.listeners$)
delete listeners.change // Change should not be bound externally
return this.$createElement('input', {
style: {},
domProps: {
value: (this.type === 'number' && Object.is(this.lazyValue, -0)) ? '-0' : this.lazyValue,
},
attrs: {
...this.attrs$,
autofocus: this.autofocus,
disabled: this.isDisabled,
id: this.computedId,
placeholder: this.placeholder,
readonly: this.isReadonly,
type: this.type,
},
on: Object.assign(listeners, {
blur: this.onBlur,
input: this.onInput,
focus: this.onFocus,
keydown: this.onKeyDown,
}),
ref: 'input',
directives: [{
name: 'resize',
modifiers: { quiet: true },
value: this.onResize,
}],
})
},
genMessages () {
if (!this.showDetails) return null
const messagesNode = VInput.options.methods.genMessages.call(this)
const counterNode = this.genCounter()
return this.$createElement('div', {
staticClass: 'v-text-field__details',
}, [
messagesNode,
counterNode,
])
},
genTextFieldSlot () {
return this.$createElement('div', {
staticClass: 'v-text-field__slot',
}, [
this.genLabel(),
this.prefix ? this.genAffix('prefix') : null,
this.genInput(),
this.suffix ? this.genAffix('suffix') : null,
])
},
genAffix (type: 'prefix' | 'suffix') {
return this.$createElement('div', {
class: `v-text-field__${type}`,
ref: type,
}, this[type])
},
onBlur (e?: Event) {
this.isFocused = false
e && this.$nextTick(() => this.$emit('blur', e))
},
onClick () {
if (this.isFocused || this.isDisabled || !this.$refs.input) return
this.$refs.input.focus()
},
onFocus (e?: Event) {
if (!this.$refs.input) return
if (document.activeElement !== this.$refs.input) {
return this.$refs.input.focus()
}
if (!this.isFocused) {
this.isFocused = true
e && this.$emit('focus', e)
}
},
onInput (e: Event) {
const target = e.target as HTMLInputElement
this.internalValue = target.value
this.badInput = target.validity && target.validity.badInput
},
onKeyDown (e: KeyboardEvent) {
if (e.keyCode === keyCodes.enter) this.$emit('change', this.internalValue)
this.$emit('keydown', e)
},
onMouseDown (e: Event) {
// Prevent input from being blurred
if (e.target !== this.$refs.input) {
e.preventDefault()
e.stopPropagation()
}
VInput.options.methods.onMouseDown.call(this, e)
},
onMouseUp (e: Event) {
if (this.hasMouseDown) this.focus()
VInput.options.methods.onMouseUp.call(this, e)
},
setLabelWidth () {
if (!this.outlined) return
this.labelWidth = this.$refs.label
? Math.min(this.$refs.label.scrollWidth * 0.75 + 6, (this.$el as HTMLElement).offsetWidth - 24)
: 0
},
setPrefixWidth () {
if (!this.$refs.prefix) return
this.prefixWidth = this.$refs.prefix.offsetWidth
},
setPrependWidth () {
if (!this.outlined || !this.$refs['prepend-inner']) return
this.prependWidth = this.$refs['prepend-inner'].offsetWidth
},
tryAutofocus () {
if (
!this.autofocus ||
typeof document === 'undefined' ||
!this.$refs.input ||
document.activeElement === this.$refs.input
) return false
this.$refs.input.focus()
return true
},
updateValue (val: boolean) {
// Sets validationState from validatable
this.hasColor = val
if (val) {
this.initialValue = this.lazyValue
} else if (this.initialValue !== this.lazyValue) {
this.$emit('change', this.lazyValue)
}
},
onResize () {
this.setLabelWidth()
this.setPrefixWidth()
this.setPrependWidth()
},
},
})