feat: inputs de texto e tipos cpf/cnpj, telefone e tipos numericos inteiro e decimal

This commit is contained in:
andreLMpena 2025-12-22 14:00:44 -03:00
parent 454fddb061
commit 6c84508996
9 changed files with 454 additions and 23 deletions

View file

@ -0,0 +1,256 @@
<template>
<div class="eli-input">
<!-- TEXT LIKE INPUTS -->
<v-text-field
v-if="isTextLike"
v-model="value"
:type="inputHtmlType"
:label="label"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable && type !== 'password'"
:error="error"
:error-messages="errorMessages"
:hint="hint"
:persistent-hint="persistentHint"
:density="density"
:variant="variant"
:color="internalColor"
:inputmode="inputMode"
v-bind="attrs"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
>
<!-- PASSWORD TOGGLE -->
<template
v-if="type === 'password' && showPasswordToggle"
#append-inner
>
<v-icon
class="cursor-pointer"
@click="togglePassword"
>
{{ showPassword ? "mdi-eye-off" : "mdi-eye" }}
</v-icon>
</template>
</v-text-field>
<!-- TEXTAREA -->
<v-textarea
v-else-if="type === 'textarea'"
v-model="value"
:label="label"
:rows="rows"
:density="density"
:variant="variant"
v-bind="attrs"
/>
<!-- RADIO -->
<v-radio-group
v-else-if="type === 'radio'"
v-model="value"
:row="row"
>
<v-radio
v-for="opt in options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</v-radio-group>
<!-- CHECKBOX -->
<div v-else-if="type === 'checkbox'" class="checkbox-group">
<v-checkbox
v-for="opt in options"
:key="opt.value"
v-model="value"
:label="opt.label"
:value="opt.value"
:density="density"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, PropType } from "vue";
import { formatarCpfCnpj } from "./utils/cpfCnpj";
import { formatTelefone } from "./utils/telefone";
import { formatarDecimal, formatarMoeda, somenteNumeros } from "./utils/numerico"
type Option = {
label: string;
value: any;
disabled?: boolean;
};
type InputVariant =
| "outlined"
| "filled"
| "plain"
| "solo"
| "solo-filled"
| "solo-inverted"
| "underlined";
type Density = "default" | "comfortable" | "compact";
type TipoNumerico =
| "numericoInteiro"
| "numericoDecimal"
| "numericoMoeda";
type InputType =
| "text"
| "password"
| "email"
| "search"
| "url"
| "textarea"
| "radio"
| "checkbox"
| "telefone"
| "cpfCnpj"
| TipoNumerico;
export default defineComponent({
name: "EliInput",
inheritAttrs: false,
props: {
modelValue: { type: [String, Number, Array] as any, default: "" },
type: { type: String as PropType<InputType>, default: "text" },
label: String,
placeholder: String,
disabled: Boolean,
error: Boolean,
errorMessages: { type: [String, Array] as any, default: () => [] },
hint: String,
persistentHint: Boolean,
rows: { type: Number, default: 4 },
options: { type: Array as PropType<Option[]>, default: () => [] },
clearable: Boolean,
variant: { type: String as PropType<InputVariant>, default: "outlined" },
density: { type: String as PropType<Density>, default: "comfortable" },
color: { type: String, default: "primary" },
row: Boolean,
showPasswordToggle: Boolean,
},
emits: ["update:modelValue", "change", "focus", "blur"],
setup(props, { emit, attrs }) {
const focused = ref(false);
const showPassword = ref(false);
const value = computed({
get: () => props.modelValue,
set: (v) => {
emit("update:modelValue", v);
emit("change", v);
},
});
const isTextLike = computed(() =>
[
"text",
"password",
"email",
"search",
"url",
"telefone",
"cpfCnpj",
"numericoInteiro",
"numericoDecimal",
"numericoMoeda",
].includes(props.type)
);
const inputHtmlType = computed(() =>
props.type === "password"
? showPassword.value
? "text"
: "password"
: "text"
);
const inputMode = computed(() => {
if (props.type === "telefone") return "tel";
if (props.type.startsWith("numerico")) return "numeric";
return undefined;
});
const internalColor = computed(() =>
props.error ? "error" : focused.value ? props.color : undefined
);
function onInput(e: Event) {
const target = e.target as HTMLInputElement;
let resultado = target.value;
switch (props.type) {
case "numericoInteiro":
resultado = somenteNumeros(resultado);
break;
case "numericoDecimal":
resultado = formatarDecimal(resultado);
break;
case "numericoMoeda":
resultado = formatarMoeda(resultado);
break;
case "telefone":
resultado = formatTelefone(resultado);
break;
case "cpfCnpj":
resultado = formatarCpfCnpj(resultado);
break;
}
target.value = resultado;
emit("update:modelValue", resultado);
emit("change", resultado);
}
function togglePassword() {
showPassword.value = !showPassword.value;
}
return {
attrs,
value,
isTextLike,
inputHtmlType,
inputMode,
internalColor,
showPassword,
togglePassword,
onInput,
onFocus: () => emit("focus"),
onBlur: () => emit("blur"),
};
},
});
</script>
<style scoped>
.eli-input {
width: 100%;
}
.checkbox-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.cursor-pointer {
cursor: pointer;
}
</style>

View file

View file

@ -0,0 +1,4 @@
import EliInput from "./EliInput.vue";
export { EliInput };
export default EliInput;

View file

@ -0,0 +1,24 @@
function somenteNumeros(v: string): string {
return v.replace(/\D+/g, "");
}
export function formatarCpfCnpj(v: string): string {
const d = somenteNumeros(v);
// CPF
if (d.length <= 11) {
return d
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d{1,2})$/, "$1-$2")
.slice(0, 14);
}
// CNPJ
return d
.replace(/^(\d{2})(\d)/, "$1.$2")
.replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3")
.replace(/\.(\d{3})(\d)/, ".$1/$2")
.replace(/(\d{4})(\d)/, "$1-$2")
.slice(0, 18);
}

View file

@ -0,0 +1,17 @@
export function somenteNumeros(valor: string) {
return valor.replace(/\D+/g, "");
}
export function formatarDecimal(valor: string) {
const limpo = valor.replace(/[^\d,]/g, "");
const partes = limpo.split(",");
return partes.length > 2 ? partes[0] + "," + partes.slice(1).join("") : limpo;
}
export function formatarMoeda(valor: string) {
const numero = somenteNumeros(valor);
if (!numero) return "";
const inteiro = (parseInt(numero, 10) / 100).toFixed(2);
return inteiro.replace(".", ",").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}

View file

@ -0,0 +1,31 @@
// utils/telefone.ts
/**
* Remove tudo que não é número
*/
export function sanitizeTelefone(value: string): string {
return value.replace(/\D+/g, "");
}
/**
* Aplica máscara dinâmica de telefone BR
*/
export function formatTelefone(value: string): string {
const digits = sanitizeTelefone(value);
if (!digits) return "";
// (99) 9999-9999
if (digits.length <= 10) {
return digits
.replace(/^(\d{2})(\d)/, "($1) $2")
.replace(/(\d{4})(\d)/, "$1-$2")
.slice(0, 14);
}
// (99) 99999-9999
return digits
.replace(/^(\d{2})(\d)/, "($1) $2")
.replace(/(\d{5})(\d)/, "$1-$2")
.slice(0, 15);
}

View file

@ -1,23 +1,136 @@
<template> <template>
<v-container> <v-container>
<v-card class="mx-auto" max_width="400"> <v-card class="mx-auto" max_width="400">
<v-card-title>Olá Mundo!</v-card-title> <v-card-title>
<EliBadge :badge="'Novo'" offset-x="-15" location="right center">
Olá Mundo!
</EliBadge>
</v-card-title>
<v-card-text> <v-card-text>
Este é um componente de exemplo integrado com Vuetify. Este é um componente de exemplo integrado com Vuetify.
<div class="grid-example">
<!-- text normal -->
<EliInput
v-model="nome"
label="Nome"
placeholder="Digite o nome"
density="compact"
/>
<EliInput v-model="idade" type="numericoInteiro" label="Idade" density="default" />
<EliInput v-model="altura" type="numericoDecimal" label="Altura" density="comfortable" />
<EliInput v-model="valor" type="numericoMoeda" label="Valor" />
<EliInput v-model="telefone" type="telefone" label="Telefone" />
<EliInput
v-model="documento"
type="cpfCnpj"
label="CPF / CNPJ"
/>
<EliInput
v-model="email"
label="Email"
placeholder="email@exemplo.com"
/>
<EliInput
v-model="senha"
label="Senha"
type="password"
:showPasswordToggle="true"
placeholder="Digite sua senha"
/>
<!-- textarea -->
<EliInput
type="textarea"
v-model="mensagem"
label="Mensagem"
:rows="5"
/>
<!-- radio -->
<EliInput
type="radio"
v-model="cor"
label="Cor favorita"
:options="[
{ label: 'Azul', value: 'azul' },
{ label: 'Verde', value: 'verde' },
]"
/>
<!-- checkbox group -->
<EliInput
type="checkbox"
v-model="habilidades"
:options="[
{ label: 'Vue', value: 'vue' },
{ label: 'React', value: 'react' },
]"
/>
<!-- erro -->
<EliInput
v-model="nome"
label="Nome"
:error="true"
:error-messages="['Obrigatório']"
/>
</div>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-btn color="primary" variant="elevated" block style="padding: 10px;"> <EliBotao color="primary" variant="elevated" block>
Botão Vuetify Botão Vuetify
</v-btn> </EliBotao>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue" import { defineComponent, ref } from "vue";
import EliBotao from "../EliBotao/EliBotao.vue";
import EliBadge from "../EliBadge/EliBadge.vue";
import EliInput from "../EliInput/EliInput.vue";
export default defineComponent({ export default defineComponent({
name: "EliOlaMundo", name: "EliOlaMundo",
}) components: {
EliBotao,
EliBadge,
EliInput,
},
setup() {
const nome = ref("");
const telefone = ref("");
const idade = ref("");
const altura = ref("");
const valor = ref("");
const email = ref("");
const mensagem = ref("");
const senha = ref("");
const documento = ref("")
const cor = ref(null);
const habilidades = ref<any[]>([]);
return {
nome,
email,
documento,
telefone,
mensagem,
senha,
cor,
habilidades,
idade,
altura,
valor,
};
},
});
</script> </script>

View file

@ -2,15 +2,18 @@ import type { App } from "vue";
import { EliOlaMundo } from "./componentes/EliOlaMundo"; import { EliOlaMundo } from "./componentes/EliOlaMundo";
import { EliBotao } from "./componentes/EliBotao"; import { EliBotao } from "./componentes/EliBotao";
import { EliBadge } from "./componentes/EliBadge"; import { EliBadge } from "./componentes/EliBadge";
import { EliInput } from "./componentes/EliInput";
export { EliOlaMundo }; export { EliOlaMundo };
export { EliBotao }; export { EliBotao };
export { EliBadge }; export { EliBadge };
export { EliInput };
export default { export default {
install(app: App) { install(app: App) {
app.component("EliOlaMundo", EliOlaMundo); app.component("EliOlaMundo", EliOlaMundo);
app.component("EliBotao", EliBotao); app.component("EliBotao", EliBotao);
app.component("EliBadge", EliBadge); app.component("EliBadge", EliBadge);
app.component("EliInput", EliInput);
}, },
}; };

View file

@ -1,33 +1,16 @@
<template> <template>
<EliOlaMundo /> <EliOlaMundo />
<EliBotao
color="primary"
@click="() => {console.log('xxx')}"
>
Button
</EliBotao>
<EliBadge
badge="Novo"
offset-x="-20"
location="right center"
radius="pill"
>
Vistoria
</EliBadge>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { EliOlaMundo } from '@/componentes/EliOlaMundo' import { EliOlaMundo } from '@/componentes/EliOlaMundo'
import { EliBotao } from '@/componentes/EliBotao';
import EliBadge from '@/componentes/EliBadge/EliBadge.vue';
export default defineComponent({ export default defineComponent({
name: 'App', name: 'App',
components: { components: {
EliOlaMundo, EliOlaMundo,
EliBotao,
EliBadge,
} }
}) })
</script> </script>