330 lines
7.2 KiB
Vue
330 lines
7.2 KiB
Vue
<template>
|
|
<div
|
|
:class="[
|
|
cn.b(),
|
|
cn.m(nativeType),
|
|
cn.is('invalid', invalid),
|
|
cn.is('disabled', disabled),
|
|
cn.is('focused', focused),
|
|
cn.is('readonly', readonly),
|
|
cn.has('value', isFilled),
|
|
]"
|
|
>
|
|
<label :class="[cn.e('wrapper')]">
|
|
<div :class="[cn.e('content')]">
|
|
<input
|
|
ref="inputRef"
|
|
:inputmode="inputmode"
|
|
:name="id"
|
|
:class="[cn.e('control')]"
|
|
:type="inputType"
|
|
:autocomplete="autocomplete"
|
|
:disabled="disabled"
|
|
:value="inputValue"
|
|
:readonly="readonly"
|
|
:tabindex="readonly ? -1 : 0"
|
|
@input="handleInput"
|
|
@focus="onFocus"
|
|
@blur="onBlur"
|
|
@change="onChange"
|
|
>
|
|
|
|
<p :class="[cn.e('label')]">{{ label }}</p>
|
|
|
|
<div
|
|
v-if="renderSuffixSlot"
|
|
:class="[cn.e('suffix')]"
|
|
>
|
|
<slot name="suffix">
|
|
<UiButton
|
|
v-if="showPasswordToggler"
|
|
type="ghost"
|
|
color="secondary"
|
|
size="small"
|
|
:icon="showPassword ? 'non-visibility' : 'visibility'"
|
|
@click="togglePassword"
|
|
@mousedown.prevent
|
|
/>
|
|
|
|
<UiButton
|
|
v-else-if="clearable"
|
|
icon="cross"
|
|
type="ghost"
|
|
color="secondary"
|
|
size="small"
|
|
@click="handleChange('', false)"
|
|
@mousedown.prevent
|
|
/>
|
|
|
|
<UiButton
|
|
v-else-if="copyable"
|
|
type="ghost"
|
|
color="secondary"
|
|
size="small"
|
|
icon="s-copy"
|
|
@click="copy"
|
|
@mousedown.prevent
|
|
/>
|
|
</slot>
|
|
</div>
|
|
</div>
|
|
</label>
|
|
|
|
<div
|
|
v-if="!readonly && (!!rules || !!caption || $slots.caption)"
|
|
:class="[cn.e('bottom')]"
|
|
>
|
|
<span
|
|
v-if="invalid"
|
|
:class="[cn.e('validation-message')]"
|
|
>
|
|
{{ errorMessage }}
|
|
</span>
|
|
|
|
<slot
|
|
v-else-if="caption || $slots.caption"
|
|
name="caption"
|
|
>
|
|
<span :class="[cn.e('caption')]">
|
|
{{ caption }}
|
|
</span>
|
|
</slot>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useField } from 'vee-validate'
|
|
import IMask from 'imask'
|
|
import { isEqual } from 'lodash-es'
|
|
|
|
import type { InputHTMLAttributes } from 'vue'
|
|
import type { RuleExpression } from 'vee-validate'
|
|
import type { FactoryArg as IMaskOptions, InputMask } from 'imask'
|
|
|
|
export interface Props {
|
|
id: string
|
|
nativeType?: 'text' | 'password'
|
|
label: string
|
|
disabled?: boolean
|
|
caption?: string
|
|
copyable?: boolean
|
|
clearable?: boolean
|
|
autofocus?: boolean
|
|
readonly?: boolean
|
|
autocomplete?: InputHTMLAttributes['autocomplete']
|
|
inputmode?: InputHTMLAttributes['inputmode']
|
|
rules?: RuleExpression<string>
|
|
mask?: IMaskOptions
|
|
modelValue?: string
|
|
}
|
|
|
|
defineOptions({
|
|
name: 'UiInput',
|
|
})
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
nativeType: 'text',
|
|
disabled: false,
|
|
copyable: false,
|
|
clearable: false,
|
|
autofocus: false,
|
|
readonly: false,
|
|
autocomplete: 'off',
|
|
inputmode: 'text',
|
|
})
|
|
|
|
defineEmits<{
|
|
'update:modelValue': []
|
|
focus: []
|
|
blur: []
|
|
}>()
|
|
|
|
function isEmptyValue(value: string) {
|
|
return [null, undefined, ''].includes(value)
|
|
}
|
|
|
|
const slots = useSlots()
|
|
|
|
const cn = useClassname('ui-input')
|
|
|
|
const { value, errorMessage, setValue, handleChange, handleBlur } = useField(
|
|
toRef(props, 'id'),
|
|
toRef(props, 'rules'),
|
|
{
|
|
validateOnValueUpdate: false,
|
|
initialValue: !isEmptyValue(props.modelValue)
|
|
? props.modelValue
|
|
: undefined,
|
|
syncVModel: true,
|
|
},
|
|
)
|
|
|
|
const inputRef = ref(null)
|
|
const maskInstance = shallowRef<InputMask<IMaskOptions>>()
|
|
const maskOptions = toRef(props, 'mask')
|
|
const showPassword = ref(false)
|
|
const focused = ref(false)
|
|
const maskedValue = ref()
|
|
|
|
let valueEvent = false
|
|
let acceptEvent = false
|
|
let updateOptionsEvent = false
|
|
|
|
const active = computed(() => !props.disabled)
|
|
const invalid = computed(() => active.value && !!errorMessage.value)
|
|
|
|
const inputValue = computed(() => {
|
|
return maskInstance.value ? maskedValue.value : value.value
|
|
})
|
|
const isFilled = computed(() => !isEmptyValue(inputValue.value))
|
|
|
|
const isPassword = computed(() => props.nativeType === 'password')
|
|
const showPasswordToggler = computed(() => isPassword.value && active.value)
|
|
const copyable = computed(() => {
|
|
if (isPassword.value)
|
|
return false
|
|
|
|
return props.copyable && isFilled.value
|
|
})
|
|
const clearable = computed(
|
|
() => active.value && props.clearable && isFilled.value,
|
|
)
|
|
|
|
const renderSuffixSlot = computed(() => {
|
|
return (
|
|
slots.suffix
|
|
|| showPasswordToggler.value
|
|
|| copyable.value
|
|
|| clearable.value
|
|
)
|
|
})
|
|
|
|
const inputType = computed(() => {
|
|
return !isPassword.value || showPassword.value ? 'text' : props.nativeType
|
|
})
|
|
|
|
function togglePassword() {
|
|
showPassword.value = !showPassword.value
|
|
}
|
|
|
|
function focus() {
|
|
inputRef.value?.focus()
|
|
}
|
|
|
|
function handleInput(event: Event) {
|
|
if (!maskInstance.value)
|
|
handleChange(event, !!errorMessage.value)
|
|
}
|
|
|
|
function initializeMask() {
|
|
if (props.mask) {
|
|
if (!inputRef.value)
|
|
return
|
|
if (maskInstance.value)
|
|
destroyMask()
|
|
|
|
maskInstance.value = IMask(inputRef.value, props.mask).on('accept', () => {
|
|
if (valueEvent || updateOptionsEvent)
|
|
return
|
|
|
|
acceptEvent = true
|
|
|
|
handleChange(maskInstance.value.unmaskedValue, !!errorMessage.value)
|
|
maskedValue.value = maskInstance.value.value
|
|
|
|
nextTick(() => (acceptEvent = false))
|
|
})
|
|
|
|
maskInstance.value.unmaskedValue = value.value?.toString() || ''
|
|
maskedValue.value = maskInstance.value.value
|
|
}
|
|
}
|
|
|
|
function destroyMask() {
|
|
maskInstance.value?.destroy()
|
|
maskInstance.value = null
|
|
}
|
|
|
|
function onChange(event: Event) {
|
|
if (!errorMessage.value)
|
|
handleChange(maskInstance.value ? maskInstance.value.unmaskedValue : event)
|
|
}
|
|
|
|
function copy() {
|
|
navigator.clipboard?.writeText(inputValue.value)
|
|
}
|
|
|
|
watch(value, (value) => {
|
|
if (maskInstance.value) {
|
|
if (acceptEvent)
|
|
return
|
|
|
|
valueEvent = true
|
|
maskInstance.value.unmaskedValue = value?.toString() || ''
|
|
maskedValue.value = maskInstance.value.value
|
|
|
|
nextTick(() => (valueEvent = false))
|
|
}
|
|
})
|
|
|
|
watch(maskOptions, (newOptions, prevValue) => {
|
|
if (newOptions) {
|
|
if (!maskInstance.value) {
|
|
initializeMask()
|
|
}
|
|
else {
|
|
if (isEqual(newOptions, prevValue))
|
|
return
|
|
|
|
updateOptionsEvent = true
|
|
|
|
maskInstance.value.updateOptions(toRaw(newOptions))
|
|
maskInstance.value.unmaskedValue = value.value?.toString() || ''
|
|
maskedValue.value = maskInstance.value.value
|
|
|
|
nextTick(() => (updateOptionsEvent = false))
|
|
}
|
|
}
|
|
else {
|
|
destroyMask()
|
|
}
|
|
})
|
|
|
|
function onFocus(e: Event) {
|
|
focused.value = true
|
|
}
|
|
|
|
function onBlur(e: Event) {
|
|
focused.value = false
|
|
|
|
handleBlur(e)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!isEmptyValue(props.modelValue) && !props.disabled)
|
|
setValue(props.modelValue)
|
|
|
|
initializeMask()
|
|
|
|
await nextTick()
|
|
|
|
if (!maskInstance.value)
|
|
setValue(inputRef.value.value, false)
|
|
|
|
if (props.autofocus)
|
|
focus()
|
|
})
|
|
|
|
onUnmounted(destroyMask)
|
|
|
|
defineExpose({
|
|
focused,
|
|
maskInstance,
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
@use 'styles';
|
|
</style>
|