This commit is contained in:
Nadar
2026-03-17 13:24:22 +03:00
commit 82e5ac9d81
554 changed files with 29637 additions and 0 deletions

View 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>

View 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
))
}