Skip to content

Instantly share code, notes, and snippets.

@iErik
Created May 13, 2020 19:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iErik/8067bf13e5832b1d279f3aefe3f659b5 to your computer and use it in GitHub Desktop.
Save iErik/8067bf13e5832b1d279f3aefe3f659b5 to your computer and use it in GitHub Desktop.
<template>
<input-container v-bind="$attrs" :is-active="!!value || hasFocus">
<template v-for="slotName in fieldSlots" :slot="slotName">
<slot :name="slotName" />
</template>
<component
:is="componentIs"
:type="type"
:class="classes"
:disabled="disabled"
:readonly="readonly"
:name="name"
v-html="['textarea'].includes(type) && value"
v-bind="[$attrs, $props]"
@input="runMask"
@focus="emitFocus"
@blur="emitBlur"
@keyup="$emit('keyup', $event)"
class="basic-input"
ref="input"
/>
</input-container>
</template>
<script>
import InputContainer from './InputContainer'
import MaskMixin from './MaskMixin'
export default {
name: 'basic-input',
components: { InputContainer },
mixins: [MaskMixin],
data() {
return {
hasFocus: false,
innerValue: this.__getInitialMaskedValue()
}
},
props: {
value: [String, Number],
disabled: Boolean,
readonly: Boolean,
name: {
default: '',
type: String,
required: true
},
type: {
default: 'text',
type: String,
validator: val =>
[
'text',
'password',
'tel',
'url',
'email',
'textarea',
'number'
].includes(val)
},
color: {
type: String,
default: 'primary'
}
},
watch: {
value: {
handler(newValue, oldValue) {
if (
newValue !== oldValue &&
(newValue === '' || (newValue === null && this.$refs.input))
) {
this.$refs.input.value = newValue
}
}
}
},
computed: {
componentIs() {
return this.isTextarea ? 'textarea' : 'input'
},
isTextarea() {
return this.type === 'textarea'
},
classes() {
return {
'basic-input': true,
'basic-input__textarea': this.type === 'textarea',
'basic-input--readonly': this.readonly,
'basic-input--disabled': this.disabled
}
},
fieldSlots() {
return ['before', 'after', 'label', 'append', 'error', 'hint']
}
},
methods: {
emitFocus(ev) {
this.hasFocus = true
this.$emit('focus', ev)
},
emitBlur(ev) {
this.hasFocus = false
this.$emit('focus', ev)
},
runMask(e) {
let value = e.target.value
if (this.mask && !this.isTextarea) return this.__updateMaskValue(value)
this.__emitValue(value)
},
__emitValue(value) {
this.$emit('input', value)
}
}
}
</script>
<style lang="scss" scoped>
.basic-input {
border-width: 1px;
border-radius: 0.25rem;
color: var(--color-font-base);
line-height: 1.25;
display: inline-block;
width: 100%;
height: 100%;
padding-left: 0.75rem;
padding-right: 0.75rem;
//padding-top: 0.5rem;
//padding-bottom: 0.5rem;
&:focus {
outline: 0;
border-color: var(--color-primary);
}
&:hover {
border-color: var(--color-primary);
}
&__textarea {
width: 100%;
}
&--hasError {
border-width: 1px;
border-color: #f56565;
}
&--readonly {
color: var(--color-gray-300);
}
&--disabled {
color: var(--color-gray-300);
}
}
</style>
<template>
<div class="input-container">
<div v-if="$slots.before" class="input-container__before">
<slot name="before" />
</div>
<div :class="innerClasses">
<div :class="innerFieldClasses">
<label v-if="hasLabel" :class="innerLabelClasses">
<slot name="label">{{ label || '&nbsp;' }}</slot>
</label>
<div class="input-container__inner__input">
<slot />
<div v-if="$slots.append" class="input-container__inner__append">
<slot name="append" />
</div>
</div>
</div>
<div
v-if="($slots.hint || hint) && !hasError"
class="input-container__inner__hint"
>
<slot name="hint">{{ hint }}</slot>
</div>
<div v-if="hasError" class="input-container__inner__error">
<slot name="error">
{{ errorMessage || 'Há um erro neste campo' }}
</slot>
</div>
</div>
<div v-if="$slots.after" class="input-container__after">
<slot name="after" />
</div>
</div>
</template>
<script>
export default {
name: 'input-container',
props: {
/**
* The field's label
*/
label: {
type: String,
default: ''
},
/**
* Wether or not the input has a value
*/
isActive: {
type: Boolean,
default: false
},
/**
* The hint text to display below the field
*/
hint: {
type: String,
default: ''
},
/**
* The error message to be displayed for validation.
*/
errorMessage: {
type: String,
default: ''
}
},
computed: {
hasError() {
if (!this.$slots.error) return false
let slotText = this.$slots.error
.map(item => item.text)
.join('')
.trim()
return !!slotText || !!this.errorMessage
},
hasLabel() {
return !!this.$slots.label || !!this.label
},
innerClasses() {
return ['input-container__inner', { 'input-container__inner--hasLabel': this.hasLabel }]
},
innerLabelClasses() {
return [
'input-container__inner__label',
{
'input-container__inner__label--top': this.isActive
}
]
},
innerFieldClasses() {
return [
'input-container__inner__field',
{
'input-container__inner--hasAppend': this.$slots.append,
'input-container__inner--hasError': this.hasError
}
]
}
}
}
</script>
<style lang="scss" scoped>
$fieldHeight: 48px;
.input-container {
display: flex;
flex-wrap: wrap;
&__before {
display: flex;
flex-wrap: nowrap;
align-items: center;
padding-right: 1rem;
height: $fieldHeight;
}
&__after {
flex-wrap: wrap;
display: flex;
align-items: center;
height: $fieldHeight;
button {
margin-top: 0;
margin-bottom: 0;
}
}
&__inner {
width: auto;
position: relative;
max-width: 100%;
flex-grow: 10000;
flex-shrink: 1;
flex-basis: 0%;
&__field {
position: relative;
height: 48px;
&:hover .input-container__inner__label {
color: var(--color-primary);
}
}
&__label {
position: absolute;
z-index: 20;
top: 50%;
left: 15px;
transform: translateY(-50%);
user-select: none;
color: var(--color-gray);
font-size: var(--text-base);
transition: top 200ms ease, font-size 200ms ease, left 200ms ease,
padding 200ms ease;
&--active,
&--top {
color: var(--color-primary);
}
&--top {
top: -7px;
left: 8px;
font-size: var(--text-xs);
padding: 0 5px;
transform: translateY(0px);
background-color: #fff;
}
}
&__hint {
display: block;
letter-spacing: 0.025em;
font-size: var(--text-sm);
margin-bottom: 0.5rem;
margin-top: 0.5rem;
color: var(--color-font-base);
}
&__error {
display: block;
letter-spacing: 0.025em;
color: var(--color-red);
font-size: 0.875rem;
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
&__input {
position: relative;
z-index: 10;
height: 100%;
}
&__append {
right: 4.85px;
display: flex;
align-items: center;
height: 100%;
position: absolute;
bottom: 0;
z-index: 10;
button {
margin-left: 0;
margin-right: 0;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
&--hasLabel {
.input-container__inner__input ::placeholder {
display: none;
visibility: hidden;
}
}
&--hasError {
input {
border: 1px solid var(--color-red);
}
}
&--hasAppend {
input {
padding-right: 3rem;
}
}
}
}
</style>
// leave NAMED_MASKS at top of file (code referenced from docs)
const NAMED_MASKS = {
date: '####/##/##',
datetime: '####/##/## ##:##',
time: '##:##',
fulltime: '##:##:##',
phone: '(###) ### - ####',
card: '#### #### #### ####'
}
const TOKENS = {
'#': { pattern: '[\\d]', negate: '[^\\d]' },
S: { pattern: '[a-zA-Z]', negate: '[^a-zA-Z]' },
N: { pattern: '[0-9a-zA-Z]', negate: '[^0-9a-zA-Z]' },
A: {
pattern: '[a-zA-Z]',
negate: '[^a-zA-Z]',
transform: v => v.toLocaleUpperCase()
},
a: {
pattern: '[a-zA-Z]',
negate: '[^a-zA-Z]',
transform: v => v.toLocaleLowerCase()
},
X: {
pattern: '[0-9a-zA-Z]',
negate: '[^0-9a-zA-Z]',
transform: v => v.toLocaleUpperCase()
},
x: {
pattern: '[0-9a-zA-Z]',
negate: '[^0-9a-zA-Z]',
transform: v => v.toLocaleLowerCase()
}
}
const KEYS = Object.keys(TOKENS)
KEYS.forEach(key => {
TOKENS[key].regex = new RegExp(TOKENS[key].pattern)
})
const tokenRegexMask = new RegExp(
'\\\\([^.*+?^${}()|([\\]])|([.*+?^${}()|[\\]])|([' +
KEYS.join('') +
'])|(.)',
'g'
),
escRegex = /[.*+?^${}()|[\]\\]/g
const MARKER = String.fromCharCode(1)
export default {
props: {
mask: String,
reverseFillMask: Boolean,
fillMask: [Boolean, String],
unmaskedValue: Boolean
},
watch: {
type() {
this.__updateMaskInternals()
},
mask(v) {
if (v !== void 0) {
this.__updateMaskValue(this.innerValue, true)
} else {
const val = this.__unmask(this.innerValue)
this.__updateMaskInternals()
this.value !== val && this.$emit('input', val)
}
},
fillMask() {
this.hasMask === true && this.__updateMaskValue(this.innerValue, true)
},
reverseFillMask() {
this.hasMask === true && this.__updateMaskValue(this.innerValue, true)
},
unmaskedValue() {
this.hasMask === true && this.__updateMaskValue(this.innerValue)
}
},
methods: {
__getInitialMaskedValue() {
this.__updateMaskInternals()
if (this.hasMask === true) {
const masked = this.__mask(this.__unmask(this.value))
return this.fillMask !== false ? this.__fillWithMask(masked) : masked
}
return this.value
},
__getPaddedMaskMarked(size) {
if (size < this.maskMarked.length) {
return this.maskMarked.slice(-size)
}
let maskMarked = this.maskMarked,
padPos = maskMarked.indexOf(MARKER),
pad = ''
if (padPos > -1) {
for (let i = size - maskMarked.length; i > 0; i--) {
pad += MARKER
}
maskMarked =
maskMarked.slice(0, padPos) + pad + maskMarked.slice(padPos)
}
return maskMarked
},
__updateMaskInternals() {
this.hasMask =
this.mask !== void 0 &&
this.mask.length > 0 &&
['text', 'search', 'url', 'tel', 'password'].includes(this.type)
if (this.hasMask === false) {
this.computedUnmask = void 0
this.maskMarked = ''
this.maskReplaced = ''
return
}
const computedMask =
NAMED_MASKS[this.mask] === void 0
? this.mask
: NAMED_MASKS[this.mask],
fillChar =
typeof this.fillMask === 'string' && this.fillMask.length > 0
? this.fillMask.slice(0, 1)
: '_',
fillCharEscaped = fillChar.replace(escRegex, '\\$&'),
unmask = [],
extract = [],
mask = []
let firstMatch = this.reverseFillMask === true,
unmaskChar = '',
negateChar = ''
computedMask.replace(tokenRegexMask, (_, char1, esc, token, char2) => {
if (token !== void 0) {
const c = TOKENS[token]
mask.push(c)
negateChar = c.negate
if (firstMatch === true) {
extract.push(
'(?:' +
negateChar +
'+?)?(' +
c.pattern +
'+)?(?:' +
negateChar +
'+?)?(' +
c.pattern +
'+)?'
)
firstMatch = false
}
extract.push('(?:' + negateChar + '+?)?(' + c.pattern + ')?')
} else if (esc !== void 0) {
unmaskChar = '\\' + esc
mask.push(esc)
unmask.push('([^' + unmaskChar + ']+)?' + unmaskChar + '?')
} else {
const c = char1 !== void 0 ? char1 : char2
unmaskChar = c.replace(escRegex, '\\\\$&')
mask.push(c)
unmask.push('([^' + unmaskChar + ']+)?' + unmaskChar + '?')
}
})
const unmaskMatcher = new RegExp(
'^' +
unmask.join('') +
'(' +
(unmaskChar === '' ? '.' : '[^' + unmaskChar + ']') +
'+)?' +
'$'
),
extractMatcher = new RegExp(
'^' +
(this.reverseFillMask === true ? fillCharEscaped + '*' : '') +
extract.join('') +
'(' +
(negateChar === '' ? '.' : negateChar) +
'+)?' +
(this.reverseFillMask === true ? '' : fillCharEscaped + '*') +
'$'
)
this.computedMask = mask
this.computedUnmask = val => {
const unmaskMatch = unmaskMatcher.exec(val)
if (unmaskMatch !== null) {
val = unmaskMatch.slice(1).join('')
}
const extractMatch = extractMatcher.exec(val)
if (extractMatch !== null) {
return extractMatch.slice(1).join('')
}
return val
}
this.maskMarked = mask
.map(v => (typeof v === 'string' ? v : MARKER))
.join('')
this.maskReplaced = this.maskMarked.split(MARKER).join(fillChar)
},
__updateMaskValue(rawVal, updateMaskInternals) {
const inp = this.$refs.input,
oldCursor =
this.reverseFillMask === true
? inp.value.length - inp.selectionEnd
: inp.selectionEnd,
unmasked = this.__unmask(rawVal)
// Update here so unmask uses the original fillChar
updateMaskInternals === true && this.__updateMaskInternals()
const masked =
this.fillMask !== false
? this.__fillWithMask(this.__mask(unmasked))
: this.__mask(unmasked),
changed = this.innerValue !== masked
// We want to avoid "flickering" so we set value immediately
inp.value !== masked && (inp.value = masked)
changed === true && (this.innerValue = masked)
this.$nextTick(() => {
if (this.reverseFillMask === true) {
if (changed === true) {
const cursor = Math.max(
0,
masked.length - (masked === this.maskReplaced ? 0 : oldCursor + 1)
)
this.__moveCursorRightReverse(inp, cursor, cursor)
} else {
const cursor = masked.length - oldCursor
inp.setSelectionRange(cursor, cursor)
}
} else if (changed === true) {
if (masked === this.maskReplaced) {
this.__moveCursorLeft(inp, 0, 0)
} else {
const cursor = Math.max(
0,
this.maskMarked.indexOf(MARKER),
oldCursor - 1
)
this.__moveCursorRight(inp, cursor, cursor)
}
} else {
this.__moveCursorLeft(inp, oldCursor, oldCursor)
}
})
const val = this.unmaskedValue === true ? this.__unmask(masked) : masked
this.value !== val && this.__emitValue(val, true)
},
__moveCursorLeft(inp, start, end, selection) {
const noMarkBefore =
this.maskMarked.slice(start - 1).indexOf(MARKER) === -1
let i = Math.max(0, start - 1)
for (; i >= 0; i--) {
if (this.maskMarked[i] === MARKER) {
start = i
noMarkBefore === true && start++
break
}
}
if (
i < 0 &&
this.maskMarked[start] !== void 0 &&
this.maskMarked[start] !== MARKER
) {
return this.__moveCursorRight(inp, 0, 0)
}
start >= 0 &&
inp.setSelectionRange(
start,
selection === true ? end : start,
'backward'
)
},
__moveCursorRight(inp, start, end, selection) {
const limit = inp.value.length
let i = Math.min(limit, end + 1)
for (; i <= limit; i++) {
if (this.maskMarked[i] === MARKER) {
end = i
break
} else if (this.maskMarked[i - 1] === MARKER) {
end = i
}
}
if (
i > limit &&
this.maskMarked[end - 1] !== void 0 &&
this.maskMarked[end - 1] !== MARKER
) {
return this.__moveCursorLeft(inp, limit, limit)
}
inp.setSelectionRange(selection ? start : end, end, 'forward')
},
__moveCursorLeftReverse(inp, start, end, selection) {
const maskMarked = this.__getPaddedMaskMarked(inp.value.length)
let i = Math.max(0, start - 1)
for (; i >= 0; i--) {
if (maskMarked[i - 1] === MARKER) {
start = i
break
} else if (maskMarked[i] === MARKER) {
start = i
if (i === 0) {
break
}
}
}
if (
i < 0 &&
maskMarked[start] !== void 0 &&
maskMarked[start] !== MARKER
) {
return this.__moveCursorRightReverse(inp, 0, 0)
}
start >= 0 &&
inp.setSelectionRange(
start,
selection === true ? end : start,
'backward'
)
},
__moveCursorRightReverse(inp, start, end, selection) {
const limit = inp.value.length,
maskMarked = this.__getPaddedMaskMarked(limit),
noMarkBefore = maskMarked.slice(0, end + 1).indexOf(MARKER) === -1
let i = Math.min(limit, end + 1)
for (; i <= limit; i++) {
if (maskMarked[i - 1] === MARKER) {
end = i
end > 0 && noMarkBefore === true && end--
break
}
}
if (
i > limit &&
maskMarked[end - 1] !== void 0 &&
maskMarked[end - 1] !== MARKER
) {
return this.__moveCursorLeftReverse(inp, limit, limit)
}
inp.setSelectionRange(selection === true ? start : end, end, 'forward')
},
__onMaskedKeydown(e) {
const inp = this.$refs.input,
start = inp.selectionStart,
end = inp.selectionEnd
if (e.keyCode === 37 || e.keyCode === 39) {
// Left / Right
const fn = this[
'__moveCursor' +
(e.keyCode === 39 ? 'Right' : 'Left') +
(this.reverseFillMask === true ? 'Reverse' : '')
]
e.preventDefault()
fn(inp, start, end, e.shiftKey)
} else if (
e.keyCode === 8 && // Backspace
this.reverseFillMask !== true &&
start === end
) {
this.__moveCursorLeft(inp, start, end, true)
} else if (
e.keyCode === 46 && // Delete
this.reverseFillMask === true &&
start === end
) {
this.__moveCursorRightReverse(inp, start, end, true)
}
this.$emit('keydown', e)
},
__mask(val) {
if (val === void 0 || val === null || val === '') {
return ''
}
if (this.reverseFillMask === true) {
return this.__maskReverse(val)
}
const mask = this.computedMask
let valIndex = 0,
output = ''
for (let maskIndex = 0; maskIndex < mask.length; maskIndex++) {
const valChar = val[valIndex],
maskDef = mask[maskIndex]
if (typeof maskDef === 'string') {
output += maskDef
valChar === maskDef && valIndex++
} else if (valChar !== void 0 && maskDef.regex.test(valChar)) {
output +=
maskDef.transform !== void 0 ? maskDef.transform(valChar) : valChar
valIndex++
} else {
return output
}
}
return output
},
__maskReverse(val) {
const mask = this.computedMask,
firstTokenIndex = this.maskMarked.indexOf(MARKER)
let valIndex = val.length - 1,
output = ''
for (let maskIndex = mask.length - 1; maskIndex >= 0; maskIndex--) {
const maskDef = mask[maskIndex]
let valChar = val[valIndex]
if (typeof maskDef === 'string') {
output = maskDef + output
valChar === maskDef && valIndex--
} else if (valChar !== void 0 && maskDef.regex.test(valChar)) {
do {
output =
(maskDef.transform !== void 0
? maskDef.transform(valChar)
: valChar) + output
valIndex--
valChar = val[valIndex]
// eslint-disable-next-line no-unmodified-loop-condition
} while (
firstTokenIndex === maskIndex &&
valChar !== void 0 &&
maskDef.regex.test(valChar)
)
} else {
return output
}
}
return output
},
__unmask(val) {
return typeof val !== 'string' || this.computedUnmask === void 0
? val
: this.computedUnmask(val)
},
__fillWithMask(val) {
if (this.maskReplaced.length - val.length <= 0) {
return val
}
return this.reverseFillMask === true && val.length > 0
? this.maskReplaced.slice(0, -val.length) + val
: val + this.maskReplaced.slice(val.length)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment