quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
499 lines (412 loc) • 12.8 kB
JavaScript
import {
h,
ref,
computed,
watch,
onBeforeUnmount,
onMounted,
nextTick,
getCurrentInstance
} from 'vue'
import useField, {
useFieldState,
useFieldProps,
useFieldEmits,
fieldValueIsFilled
} from '../../composables/private.use-field/use-field.js'
import useMask, { useMaskProps } from './use-mask.js'
import {
useFormProps,
useFormInputNameAttr
} from '../../composables/use-form/private.use-form.js'
import useFileFormDomProps from '../../composables/private.use-file/use-file-dom-props.js'
import useKeyComposition from '../../composables/private.use-key-composition/use-key-composition.js'
import { createComponent } from '../../utils/private.create/create.js'
import { stop } from '../../utils/event/event.js'
import { addFocusFn } from '../../utils/private.focus/focus-manager.js'
import { injectProp } from '../../utils/private.inject-obj-prop/inject-obj-prop.js'
export default createComponent({
name: 'QInput',
inheritAttrs: false,
props: {
...useFieldProps,
...useMaskProps,
...useFormProps,
// override of useFieldProps > modelValue
modelValue: __QUASAR_SSR_SERVER__
? {} // SSR does not know about FileList
: [String, Number, FileList],
shadowText: String,
type: {
type: String,
default: 'text'
},
debounce: [String, Number],
autogrow: Boolean, // makes a textarea
inputClass: [Array, String, Object],
inputStyle: [Array, String, Object]
},
emits: [
...useFieldEmits,
'paste',
'change',
'keydown',
'click',
'animationend'
],
setup(props, { emit, attrs }) {
const { proxy } = getCurrentInstance()
const { $q } = proxy
const temp = {}
let emitCachedValue = NaN,
typedNumber,
stopValueWatcher,
emitTimer = null,
emitValueFn
const inputRef = ref(null)
const nameProp = useFormInputNameAttr(props)
const {
innerValue,
hasMask,
moveCursorForPaste,
updateMaskValue,
onMaskedKeydown,
onMaskedClick
} = useMask(props, emit, emitValue, inputRef)
const formDomProps = useFileFormDomProps(props, /* type guard */ true)
const hasValue = computed(() => fieldValueIsFilled(innerValue.value))
const onComposition = useKeyComposition(onInput)
const state = useFieldState({ changeEvent: true })
const isTextarea = computed(
() => props.type === 'textarea' || props.autogrow === true
)
const isTypeText = computed(
() =>
isTextarea.value === true ||
['text', 'search', 'url', 'tel', 'password'].includes(props.type)
)
const onEvents = computed(() => {
const evt = {
...state.splitAttrs.listeners.value,
onInput,
onPaste,
// Safari < 10.2 & UIWebView doesn't fire compositionend when
// switching focus before confirming composition choice
// this also fixes the issue where some browsers e.g. iOS Chrome
// fires "change" instead of "input" on autocomplete.
onChange,
onBlur: onFinishEditing,
onFocus: stop
}
evt.onCompositionstart =
evt.onCompositionupdate =
evt.onCompositionend =
onComposition
if (hasMask.value === true) {
evt.onKeydown = onMaskedKeydown
// reset selection anchor on pointer selection
evt.onClick = onMaskedClick
}
if (props.autogrow === true) {
evt.onAnimationend = onAnimationend
}
return evt
})
const inputAttrs = computed(() => {
const acc = {
tabindex: 0,
'data-autofocus': props.autofocus === true || void 0,
rows: props.type === 'textarea' ? 6 : void 0,
'aria-label': props.label,
name: nameProp.value,
...state.splitAttrs.attributes.value,
id: state.targetUid.value,
maxlength: props.maxlength,
disabled: props.disable === true,
readonly: props.readonly === true
}
if (isTextarea.value === false) {
acc.type = props.type
}
if (props.autogrow === true) {
acc.rows = 1
}
return acc
})
// some browsers lose the native input value
// so we need to reattach it dynamically
// (like type="password" <-> type="text"; see #12078)
watch(
() => props.type,
() => {
if (inputRef.value) {
inputRef.value.value = props.modelValue
}
}
)
watch(
() => props.modelValue,
v => {
if (hasMask.value === true) {
if (stopValueWatcher === true) {
stopValueWatcher = false
if (String(v) === emitCachedValue) return
}
updateMaskValue(v)
} else if (innerValue.value !== v) {
innerValue.value = v
if (
props.type === 'number' &&
temp.hasOwnProperty('value') === true
) {
if (typedNumber === true) {
typedNumber = false
} else {
delete temp.value
}
}
}
// textarea only
if (props.autogrow === true) nextTick(adjustHeight)
}
)
watch(
() => props.autogrow,
val => {
// textarea only
if (val === true) {
nextTick(adjustHeight)
}
// if it has a number of rows set respect it
else if (inputRef.value !== null && attrs.rows > 0) {
inputRef.value.style.height = 'auto'
}
}
)
watch(
() => props.dense,
() => {
if (props.autogrow === true) nextTick(adjustHeight)
}
)
function focus() {
addFocusFn(() => {
const el = document.activeElement
if (
inputRef.value !== null &&
inputRef.value !== el &&
(el === null || el.id !== state.targetUid.value)
) {
inputRef.value.focus({ preventScroll: true })
}
})
}
function select() {
inputRef.value?.select()
}
function onPaste(e) {
if (hasMask.value === true && props.reverseFillMask !== true) {
const inp = e.target
moveCursorForPaste(inp, inp.selectionStart, inp.selectionEnd)
}
emit('paste', e)
}
function onInput(e) {
if (!e || !e.target) return
if (props.type === 'file') {
emit('update:modelValue', e.target.files)
return
}
const val = e.target.value
if (e.target.qComposing === true) {
temp.value = val
return
}
if (hasMask.value === true) {
updateMaskValue(val, false, e.inputType)
} else {
emitValue(val)
if (isTypeText.value === true && e.target === document.activeElement) {
const { selectionStart, selectionEnd } = e.target
if (selectionStart !== void 0 && selectionEnd !== void 0) {
nextTick(() => {
if (
e.target === document.activeElement &&
val.indexOf(e.target.value) === 0
) {
e.target.setSelectionRange(selectionStart, selectionEnd)
}
})
}
}
}
// we need to trigger it immediately too,
// to avoid "flickering"
if (props.autogrow === true) adjustHeight()
}
function onAnimationend(e) {
emit('animationend', e)
adjustHeight()
}
function emitValue(val, stopWatcher) {
emitValueFn = () => {
emitTimer = null
if (props.type !== 'number' && temp.hasOwnProperty('value') === true) {
delete temp.value
}
if (props.modelValue !== val && emitCachedValue !== val) {
emitCachedValue = val
if (stopWatcher === true) stopValueWatcher = true
emit('update:modelValue', val)
nextTick(() => {
if (emitCachedValue === val) emitCachedValue = NaN
})
}
emitValueFn = void 0
}
if (props.type === 'number') {
typedNumber = true
temp.value = val
}
if (props.debounce !== void 0) {
if (emitTimer !== null) clearTimeout(emitTimer)
temp.value = val
emitTimer = setTimeout(emitValueFn, props.debounce)
} else {
emitValueFn()
}
}
// textarea only
function adjustHeight() {
requestAnimationFrame(() => {
const inp = inputRef.value
if (inp !== null) {
const parentStyle = inp.parentNode.style
// chrome does not keep scroll #15498
const { scrollTop } = inp
// chrome calculates a smaller scrollHeight when in a .column container
const { overflowY, maxHeight } =
$q.platform.is.firefox === true ? {} : window.getComputedStyle(inp)
// on firefox or if overflowY is specified as scroll #14263, #14344
// we don't touch overflow
// firefox is not so bad in the end
const changeOverflow = overflowY !== void 0 && overflowY !== 'scroll'
// reset height of textarea to a small size to detect the real height
// but keep the total control size the same
if (changeOverflow === true) inp.style.overflowY = 'hidden'
parentStyle.marginBottom = inp.scrollHeight - 1 + 'px'
inp.style.height = '1px'
inp.style.height = inp.scrollHeight + 'px'
// we should allow scrollbars only
// if there is maxHeight and content is taller than maxHeight
if (changeOverflow === true) {
inp.style.overflowY =
parseInt(maxHeight, 10) < inp.scrollHeight ? 'auto' : 'hidden'
}
parentStyle.marginBottom = ''
inp.scrollTop = scrollTop
}
})
}
function onChange(e) {
onComposition(e)
if (emitTimer !== null) {
clearTimeout(emitTimer)
emitTimer = null
}
emitValueFn?.()
emit('change', e.target.value)
}
function onFinishEditing(e) {
if (e !== void 0) stop(e)
if (emitTimer !== null) {
clearTimeout(emitTimer)
emitTimer = null
}
emitValueFn?.()
typedNumber = false
stopValueWatcher = false
delete temp.value
// we need to use setTimeout instead of this.$nextTick
// to avoid a bug where focusout is not emitted for type date/time/week/...
if (props.type !== 'file') {
setTimeout(() => {
if (inputRef.value !== null) {
inputRef.value.value =
innerValue.value !== void 0 ? innerValue.value : ''
}
})
}
}
function getCurValue() {
return temp.hasOwnProperty('value') === true
? temp.value
: innerValue.value !== void 0
? innerValue.value
: ''
}
onBeforeUnmount(() => {
onFinishEditing()
})
onMounted(() => {
// textarea only
if (props.autogrow === true) adjustHeight()
})
Object.assign(state, {
innerValue,
fieldClass: computed(
() =>
`q-${isTextarea.value === true ? 'textarea' : 'input'}` +
(props.autogrow === true ? ' q-textarea--autogrow' : '')
),
hasShadow: computed(
() =>
props.type !== 'file' &&
typeof props.shadowText === 'string' &&
props.shadowText.length !== 0
),
inputRef,
emitValue,
hasValue,
floatingLabel: computed(
() =>
(hasValue.value === true &&
(props.type !== 'number' || isNaN(innerValue.value) === false)) ||
fieldValueIsFilled(props.displayValue)
),
getControl: () =>
h(isTextarea.value === true ? 'textarea' : 'input', {
ref: inputRef,
class: ['q-field__native q-placeholder', props.inputClass],
style: props.inputStyle,
...inputAttrs.value,
...onEvents.value,
...(props.type !== 'file'
? { value: getCurValue() }
: formDomProps.value)
}),
getShadowControl: () =>
h(
'div',
{
class:
'q-field__native q-field__shadow absolute-bottom no-pointer-events' +
(isTextarea.value === true ? '' : ' text-no-wrap')
},
[
h('span', { class: 'invisible' }, getCurValue()),
h('span', props.shadowText)
]
)
})
const renderFn = useField(state)
// expose public methods
Object.assign(proxy, {
focus,
select,
getNativeElement: () => inputRef.value // deprecated
})
injectProp(proxy, 'nativeEl', () => inputRef.value)
return renderFn
}
})