initial
This commit is contained in:
329
layers/ui/components/input/index.vue
Normal file
329
layers/ui/components/input/index.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<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>
|
||||
139
layers/ui/components/input/styles.scss
Normal file
139
layers/ui/components/input/styles.scss
Normal file
@@ -0,0 +1,139 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@use 'sass:selector';
|
||||
|
||||
.ui-input {
|
||||
$self: &;
|
||||
overflow: hidden;
|
||||
|
||||
&__wrapper {
|
||||
--border-color: var(--input-border-color);
|
||||
--border-width: 1px;
|
||||
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--input-background);
|
||||
border-radius: 12px;
|
||||
outline: var(--border-width) solid var(--border-color);
|
||||
outline-offset: calc(var(--border-width) * -1);
|
||||
padding-inline: 16px;
|
||||
transition: outline-color .2s ease-out;
|
||||
|
||||
#{$self}:not(.is-disabled) & {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
#{$self}:not(.is-disabled):not(.is-readonly) &:hover {
|
||||
--border-width: 2px;
|
||||
}
|
||||
|
||||
#{$self}:not(.is-readonly).is-focused & {
|
||||
--border-width: 2px;
|
||||
--border-color: var(--input-focused-border-color);
|
||||
}
|
||||
|
||||
#{$self}.is-readonly & {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#{$self}.is-invalid & {
|
||||
--border-color: var(--input-invalid-border-color);
|
||||
}
|
||||
|
||||
#{$self}.is-disabled & {
|
||||
--border-color: var(--input-disabled-border-color);
|
||||
|
||||
background: var(--input-disabled-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__control {
|
||||
@include txt-i-m;
|
||||
|
||||
padding: 22px 0 8px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
caret-color: var(--input-caret-color);
|
||||
color: var(--input-color);
|
||||
|
||||
&::-ms-reveal,
|
||||
&::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:-webkit-autofill {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--input-color);
|
||||
}
|
||||
|
||||
#{$self}.is-readonly & {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#{$self}.is-disabled & {
|
||||
color: var(--input-disabled-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
@include txt-i-m;
|
||||
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 15px;
|
||||
left: 0;
|
||||
color: var(--input-label-color);
|
||||
transform-origin: 0 0;
|
||||
transition-duration: .2s;
|
||||
transition-property: transform, color;
|
||||
transition-timing-function: linear, ease-out;
|
||||
|
||||
#{$self}.is-focused &,
|
||||
#{$self}.has-value & {
|
||||
transform: translateY(-7px) scale(0.78);
|
||||
color: var(--input-label-filled-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
@include txt-s-m;
|
||||
|
||||
margin-top: 4px;
|
||||
padding-inline: 16px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
&__validation-message {
|
||||
color: var(--input-validation-message-color);
|
||||
}
|
||||
|
||||
&__caption {
|
||||
color: var(--input-caption-color);
|
||||
}
|
||||
|
||||
@include element-variant('input', '', (
|
||||
'border-color': $clr-grey-300,
|
||||
'background': $clr-white,
|
||||
'caret-color': $clr-cyan-500,
|
||||
'color': $clr-black,
|
||||
'focused-border-color': $clr-cyan-500,
|
||||
'invalid-border-color': $clr-red-500,
|
||||
'disabled-border-color': $clr-grey-300,
|
||||
'disabled-background': $clr-grey-100,
|
||||
'disabled-color': $clr-grey-500,
|
||||
'label-color': $clr-grey-400,
|
||||
'label-filled-color': $clr-grey-500,
|
||||
'validation-message-color': $clr-red-500,
|
||||
'caption-color': $clr-grey-600
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user