quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
611 lines (502 loc) • 16.3 kB
JavaScript
import { h, ref, computed, watch, Transition, nextTick, onActivated, onDeactivated, onBeforeUnmount, onMounted, getCurrentInstance } from 'vue'
import { isRuntimeSsrPreHydration } from '../../plugins/Platform.js'
import QIcon from '../../components/icon/QIcon.js'
import QSpinner from '../../components/spinner/QSpinner.js'
import useDark, { useDarkProps } from '../../composables/private/use-dark.js'
import useValidate, { useValidateProps } from './use-validate.js'
import useSplitAttrs from './use-split-attrs.js'
import { hSlot } from '../../utils/private/render.js'
import uid from '../../utils/uid.js'
import { prevent, stopAndPrevent } from '../../utils/event.js'
import { addFocusFn, removeFocusFn } from '../../utils/private/focus-manager.js'
function getTargetUid (val) {
return val === void 0 ? `f_${ uid() }` : val
}
export function fieldValueIsFilled (val) {
return val !== void 0
&& val !== null
&& ('' + val).length > 0
}
export const useFieldProps = {
...useDarkProps,
...useValidateProps,
label: String,
stackLabel: Boolean,
hint: String,
hideHint: Boolean,
prefix: String,
suffix: String,
labelColor: String,
color: String,
bgColor: String,
filled: Boolean,
outlined: Boolean,
borderless: Boolean,
standout: [ Boolean, String ],
square: Boolean,
loading: Boolean,
labelSlot: Boolean,
bottomSlots: Boolean,
hideBottomSpace: Boolean,
rounded: Boolean,
dense: Boolean,
itemAligned: Boolean,
counter: Boolean,
clearable: Boolean,
clearIcon: String,
disable: Boolean,
readonly: Boolean,
autofocus: Boolean,
for: String,
maxlength: [ Number, String ]
}
export const useFieldEmits = [ 'update:modelValue', 'clear', 'focus', 'blur', 'popupShow', 'popupHide' ]
export function useFieldState () {
const { props, attrs, proxy, vnode } = getCurrentInstance()
const isDark = useDark(props, proxy.$q)
return {
isDark,
editable: computed(() =>
props.disable !== true && props.readonly !== true
),
innerLoading: ref(false),
focused: ref(false),
hasPopupOpen: false,
splitAttrs: useSplitAttrs(attrs, vnode),
targetUid: ref(getTargetUid(props.for)),
rootRef: ref(null),
targetRef: ref(null),
controlRef: ref(null)
/**
* user supplied additionals:
* innerValue - computed
* floatingLabel - computed
* inputRef - computed
* fieldClass - computed
* hasShadow - computed
* controlEvents - Object with fn(e)
* getControl - fn
* getInnerAppend - fn
* getControlChild - fn
* getShadowControl - fn
* showPopup - fn
*/
}
}
export default function (state) {
const { props, emit, slots, attrs, proxy } = getCurrentInstance()
const { $q } = proxy
let focusoutTimer = null
if (state.hasValue === void 0) {
state.hasValue = computed(() => fieldValueIsFilled(props.modelValue))
}
if (state.emitValue === void 0) {
state.emitValue = value => {
emit('update:modelValue', value)
}
}
if (state.controlEvents === void 0) {
state.controlEvents = {
onFocusin: onControlFocusin,
onFocusout: onControlFocusout
}
}
Object.assign(state, {
clearValue,
onControlFocusin,
onControlFocusout,
focus
})
if (state.computedCounter === void 0) {
state.computedCounter = computed(() => {
if (props.counter !== false) {
const len = typeof props.modelValue === 'string' || typeof props.modelValue === 'number'
? ('' + props.modelValue).length
: (Array.isArray(props.modelValue) === true ? props.modelValue.length : 0)
const max = props.maxlength !== void 0
? props.maxlength
: props.maxValues
return len + (max !== void 0 ? ' / ' + max : '')
}
})
}
const {
isDirtyModel,
hasRules,
hasError,
errorMessage,
resetValidation
} = useValidate(state.focused, state.innerLoading)
const floatingLabel = state.floatingLabel !== void 0
? computed(() => props.stackLabel === true || state.focused.value === true || state.floatingLabel.value === true)
: computed(() => props.stackLabel === true || state.focused.value === true || state.hasValue.value === true)
const shouldRenderBottom = computed(() =>
props.bottomSlots === true
|| props.hint !== void 0
|| hasRules.value === true
|| props.counter === true
|| props.error !== null
)
const styleType = computed(() => {
if (props.filled === true) { return 'filled' }
if (props.outlined === true) { return 'outlined' }
if (props.borderless === true) { return 'borderless' }
if (props.standout) { return 'standout' }
return 'standard'
})
const classes = computed(() =>
`q-field row no-wrap items-start q-field--${ styleType.value }`
+ (state.fieldClass !== void 0 ? ` ${ state.fieldClass.value }` : '')
+ (props.rounded === true ? ' q-field--rounded' : '')
+ (props.square === true ? ' q-field--square' : '')
+ (floatingLabel.value === true ? ' q-field--float' : '')
+ (hasLabel.value === true ? ' q-field--labeled' : '')
+ (props.dense === true ? ' q-field--dense' : '')
+ (props.itemAligned === true ? ' q-field--item-aligned q-item-type' : '')
+ (state.isDark.value === true ? ' q-field--dark' : '')
+ (state.getControl === void 0 ? ' q-field--auto-height' : '')
+ (state.focused.value === true ? ' q-field--focused' : '')
+ (hasError.value === true ? ' q-field--error' : '')
+ (hasError.value === true || state.focused.value === true ? ' q-field--highlighted' : '')
+ (props.hideBottomSpace !== true && shouldRenderBottom.value === true ? ' q-field--with-bottom' : '')
+ (props.disable === true ? ' q-field--disabled' : (props.readonly === true ? ' q-field--readonly' : ''))
)
const contentClass = computed(() =>
'q-field__control relative-position row no-wrap'
+ (props.bgColor !== void 0 ? ` bg-${ props.bgColor }` : '')
+ (
hasError.value === true
? ' text-negative'
: (
typeof props.standout === 'string' && props.standout.length > 0 && state.focused.value === true
? ` ${ props.standout }`
: (props.color !== void 0 ? ` text-${ props.color }` : '')
)
)
)
const hasLabel = computed(() =>
props.labelSlot === true || props.label !== void 0
)
const labelClass = computed(() =>
'q-field__label no-pointer-events absolute ellipsis'
+ (props.labelColor !== void 0 && hasError.value !== true ? ` text-${ props.labelColor }` : '')
)
const controlSlotScope = computed(() => ({
id: state.targetUid.value,
editable: state.editable.value,
focused: state.focused.value,
floatingLabel: floatingLabel.value,
modelValue: props.modelValue,
emitValue: state.emitValue
}))
const attributes = computed(() => {
const acc = {
for: state.targetUid.value
}
if (props.disable === true) {
acc[ 'aria-disabled' ] = 'true'
}
else if (props.readonly === true) {
acc[ 'aria-readonly' ] = 'true'
}
return acc
})
watch(() => props.for, val => {
// don't transform targetUid into a computed
// prop as it will break SSR
state.targetUid.value = getTargetUid(val)
})
function focusHandler () {
const el = document.activeElement
let target = state.targetRef !== void 0 && state.targetRef.value
if (target && (el === null || el.id !== state.targetUid.value)) {
target.hasAttribute('tabindex') === true || (target = target.querySelector('[tabindex]'))
if (target && target !== el) {
target.focus({ preventScroll: true })
}
}
}
function focus () {
addFocusFn(focusHandler)
}
function blur () {
removeFocusFn(focusHandler)
const el = document.activeElement
if (el !== null && state.rootRef.value.contains(el)) {
el.blur()
}
}
function onControlFocusin (e) {
if (focusoutTimer !== null) {
clearTimeout(focusoutTimer)
focusoutTimer = null
}
if (state.editable.value === true && state.focused.value === false) {
state.focused.value = true
emit('focus', e)
}
}
function onControlFocusout (e, then) {
focusoutTimer !== null && clearTimeout(focusoutTimer)
focusoutTimer = setTimeout(() => {
focusoutTimer = null
if (
document.hasFocus() === true && (
state.hasPopupOpen === true
|| state.controlRef === void 0
|| state.controlRef.value === null
|| state.controlRef.value.contains(document.activeElement) !== false
)
) {
return
}
if (state.focused.value === true) {
state.focused.value = false
emit('blur', e)
}
then !== void 0 && then()
})
}
function clearValue (e) {
// prevent activating the field but keep focus on desktop
stopAndPrevent(e)
if ($q.platform.is.mobile !== true) {
const el = (state.targetRef !== void 0 && state.targetRef.value) || state.rootRef.value
el.focus()
}
else if (state.rootRef.value.contains(document.activeElement) === true) {
document.activeElement.blur()
}
if (props.type === 'file') { // TODO vue3
// do not let focus be triggered
// as it will make the native file dialog
// appear for another selection
state.inputRef.value.value = null
}
emit('update:modelValue', null)
emit('clear', props.modelValue)
nextTick(() => {
resetValidation()
if ($q.platform.is.mobile !== true) {
isDirtyModel.value = false
}
})
}
function getContent () {
const node = []
slots.prepend !== void 0 && node.push(
h('div', {
class: 'q-field__prepend q-field__marginal row no-wrap items-center',
key: 'prepend',
onClick: prevent
}, slots.prepend())
)
node.push(
h('div', {
class: 'q-field__control-container col relative-position row no-wrap q-anchor--skip'
}, getControlContainer())
)
hasError.value === true && props.noErrorIcon === false && node.push(
getInnerAppendNode('error', [
h(QIcon, { name: $q.iconSet.field.error, color: 'negative' })
])
)
if (props.loading === true || state.innerLoading.value === true) {
node.push(
getInnerAppendNode(
'inner-loading-append',
slots.loading !== void 0
? slots.loading()
: [ h(QSpinner, { color: props.color }) ]
)
)
}
else if (props.clearable === true && state.hasValue.value === true && state.editable.value === true) {
node.push(
getInnerAppendNode('inner-clearable-append', [
h(QIcon, {
class: 'q-field__focusable-action',
tag: 'button',
name: props.clearIcon || $q.iconSet.field.clear,
tabindex: 0,
type: 'button',
'aria-hidden': null,
role: null,
onClick: clearValue
})
])
)
}
slots.append !== void 0 && node.push(
h('div', {
class: 'q-field__append q-field__marginal row no-wrap items-center',
key: 'append',
onClick: prevent
}, slots.append())
)
state.getInnerAppend !== void 0 && node.push(
getInnerAppendNode('inner-append', state.getInnerAppend())
)
state.getControlChild !== void 0 && node.push(
state.getControlChild()
)
return node
}
function getControlContainer () {
const node = []
props.prefix !== void 0 && props.prefix !== null && node.push(
h('div', {
class: 'q-field__prefix no-pointer-events row items-center'
}, props.prefix)
)
if (state.getShadowControl !== void 0 && state.hasShadow.value === true) {
node.push(
state.getShadowControl()
)
}
if (state.getControl !== void 0) {
node.push(state.getControl())
}
// internal usage only:
else if (slots.rawControl !== void 0) {
node.push(slots.rawControl())
}
else if (slots.control !== void 0) {
node.push(
h('div', {
ref: state.targetRef,
class: 'q-field__native row',
tabindex: -1,
...state.splitAttrs.attributes.value,
'data-autofocus': props.autofocus === true || void 0
}, slots.control(controlSlotScope.value))
)
}
hasLabel.value === true && node.push(
h('div', {
class: labelClass.value
}, hSlot(slots.label, props.label))
)
props.suffix !== void 0 && props.suffix !== null && node.push(
h('div', {
class: 'q-field__suffix no-pointer-events row items-center'
}, props.suffix)
)
return node.concat(hSlot(slots.default))
}
function getBottom () {
let msg, key
if (hasError.value === true) {
if (errorMessage.value !== null) {
msg = [ h('div', { role: 'alert' }, errorMessage.value) ]
key = `q--slot-error-${ errorMessage.value }`
}
else {
msg = hSlot(slots.error)
key = 'q--slot-error'
}
}
else if (props.hideHint !== true || state.focused.value === true) {
if (props.hint !== void 0) {
msg = [ h('div', props.hint) ]
key = `q--slot-hint-${ props.hint }`
}
else {
msg = hSlot(slots.hint)
key = 'q--slot-hint'
}
}
const hasCounter = props.counter === true || slots.counter !== void 0
if (props.hideBottomSpace === true && hasCounter === false && msg === void 0) {
return
}
const main = h('div', {
key,
class: 'q-field__messages col'
}, msg)
return h('div', {
class: 'q-field__bottom row items-start q-field__bottom--'
+ (props.hideBottomSpace !== true ? 'animated' : 'stale'),
onClick: prevent
}, [
props.hideBottomSpace === true
? main
: h(Transition, { name: 'q-transition--field-message' }, () => main),
hasCounter === true
? h('div', {
class: 'q-field__counter'
}, slots.counter !== void 0 ? slots.counter() : state.computedCounter.value)
: null
])
}
function getInnerAppendNode (key, content) {
return content === null
? null
: h('div', {
key,
class: 'q-field__append q-field__marginal row no-wrap items-center q-anchor--skip'
}, content)
}
let shouldActivate = false
onDeactivated(() => {
shouldActivate = true
})
onActivated(() => {
shouldActivate === true && props.autofocus === true && proxy.focus()
})
onMounted(() => {
if (isRuntimeSsrPreHydration.value === true && props.for === void 0) {
state.targetUid.value = getTargetUid()
}
props.autofocus === true && proxy.focus()
})
onBeforeUnmount(() => {
focusoutTimer !== null && clearTimeout(focusoutTimer)
})
// expose public methods
Object.assign(proxy, { focus, blur })
return function renderField () {
const labelAttrs = state.getControl === void 0 && slots.control === void 0
? {
...state.splitAttrs.attributes.value,
'data-autofocus': props.autofocus === true || void 0,
...attributes.value
}
: attributes.value
return h('label', {
ref: state.rootRef,
class: [
classes.value,
attrs.class
],
style: attrs.style,
...labelAttrs
}, [
slots.before !== void 0
? h('div', {
class: 'q-field__before q-field__marginal row no-wrap items-center',
onClick: prevent
}, slots.before())
: null,
h('div', {
class: 'q-field__inner relative-position col self-stretch'
}, [
h('div', {
ref: state.controlRef,
class: contentClass.value,
tabindex: -1,
...state.controlEvents
}, getContent()),
shouldRenderBottom.value === true
? getBottom()
: null
]),
slots.after !== void 0
? h('div', {
class: 'q-field__after q-field__marginal row no-wrap items-center',
onClick: prevent
}, slots.after())
: null
])
}
}