quasar
Version:
Build high-performance VueJS user interfaces (SPA, PWA, SSR, Mobile and Desktop) in record time
743 lines (635 loc) • 17.7 kB
JavaScript
import {
h,
ref,
computed,
Transition,
nextTick,
onActivated,
onDeactivated,
onBeforeUnmount,
onMounted,
getCurrentInstance
} from 'vue'
import QIcon from '../../components/icon/QIcon.js'
import QSpinner from '../../components/spinner/QSpinner.js'
import useId from '../use-id/use-id.js'
import useSplitAttrs from '../use-split-attrs/use-split-attrs.js'
import useDark, {
useDarkProps
} from '../../composables/private.use-dark/use-dark.js'
import useValidate, {
useValidateProps
} from '../private.use-validate/use-validate.js'
import { hSlot } from '../../utils/private.render/render.js'
import { prevent, stopAndPrevent } from '../../utils/event/event.js'
import {
addFocusFn,
removeFocusFn
} from '../../utils/private.focus/focus-manager.js'
export function fieldValueIsFilled(val) {
return val !== void 0 && val !== null && String(val).length !== 0
}
export const useNonInputFieldProps = {
...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
}
export const useFieldProps = {
...useNonInputFieldProps,
maxlength: [Number, String]
}
export const useFieldEmits = ['update:modelValue', 'clear', 'focus', 'blur']
export function useFieldState({
requiredForAttr = true,
tagProp,
changeEvent = false
} = {}) {
const { props, proxy } = getCurrentInstance()
const isDark = useDark(props, proxy.$q)
const targetUid = useId({
required: requiredForAttr,
getValue: () => props.for
})
return {
requiredForAttr,
changeEvent,
tag: tagProp === true ? computed(() => props.tag) : { value: 'label' },
isDark,
editable: computed(() => props.disable !== true && props.readonly !== true),
innerLoading: ref(false),
focused: ref(false),
hasPopupOpen: false,
splitAttrs: useSplitAttrs(),
targetUid,
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 useField(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'
? String(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 = {}
if (state.targetUid.value) {
acc.for = state.targetUid.value
}
if (props.disable === true) {
acc['aria-disabled'] = 'true'
}
return acc
})
function focusHandler() {
const el = document.activeElement
let target = state.targetRef?.value
if (target && (el === null || el.id !== state.targetUid.value)) {
if (target.hasAttribute('tabindex') !== true) {
target = target.querySelector('[tabindex]')
}
if (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) {
if (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?.()
})
}
function clearValue(e) {
// prevent activating the field but keep focus on desktop
stopAndPrevent(e)
if ($q.platform.is.mobile !== true) {
const el = state.targetRef?.value || state.rootRef.value
el.focus()
} else if (state.rootRef.value.contains(document.activeElement) === true) {
document.activeElement.blur()
}
if (props.type === 'file') {
// 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)
if (state.changeEvent === true) emit('change', null)
emit('clear', props.modelValue)
nextTick(() => {
const isDirty = isDirtyModel.value
resetValidation()
isDirtyModel.value = isDirty
})
}
function onClearableKeyup(evt) {
if ([13, 32].includes(evt.keyCode)) clearValue(evt)
}
function getContent() {
const node = []
if (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()
)
)
if (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',
name: props.clearIcon || $q.iconSet.field.clear,
tabindex: 0,
role: 'button',
'aria-hidden': 'false',
'aria-label': $q.lang.label.clear,
onKeyup: onClearableKeyup,
onClick: clearValue
})
])
)
}
if (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()
)
)
}
if (state.getInnerAppend !== void 0) {
node.push(getInnerAppendNode('inner-append', state.getInnerAppend()))
}
if (state.getControlChild !== void 0) {
node.push(state.getControlChild())
}
return node
}
function getControlContainer() {
const node = []
if (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 (hasLabel.value === true) {
node.push(
h(
'div',
{
class: labelClass.value
},
hSlot(slots.label, props.label)
)
)
}
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)
)
)
}
if (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(() => {
if (shouldActivate === true && props.autofocus === true) {
proxy.focus()
}
})
if (props.autofocus === true) {
onMounted(() => {
proxy.focus()
})
}
onBeforeUnmount(() => {
if (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(
state.tag.value,
{
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
]
)
}
}