rafatoração de componentes de entrada
This commit is contained in:
parent
6aedf2469f
commit
27c9e4d5e2
45 changed files with 1295 additions and 2605 deletions
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<v-text-field
|
||||
v-model="localValue"
|
||||
:type="inputHtmlType"
|
||||
:inputmode="inputMode"
|
||||
:label="opcoes?.rotulo"
|
||||
:placeholder="opcoes?.placeholder"
|
||||
:counter="opcoes?.limiteCaracteres"
|
||||
|
|
@ -8,12 +10,16 @@
|
|||
v-bind="attrs"
|
||||
@focus="() => emit('focus')"
|
||||
@blur="() => emit('blur')"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from "vue";
|
||||
import type { PadroesEntradas } from "./tiposEntradas";
|
||||
import { formatarCpfCnpj } from "./utils/cpfCnpj";
|
||||
import { formatTelefone } from "./utils/telefone";
|
||||
import { formatarCep } from "./utils/cep";
|
||||
|
||||
type EntradaTexto = PadroesEntradas["texto"];
|
||||
|
||||
|
|
@ -40,6 +46,8 @@ export default defineComponent({
|
|||
blur: () => true,
|
||||
},
|
||||
setup(props, { attrs, emit }) {
|
||||
const formato = computed(() => props.opcoes?.formato ?? "texto");
|
||||
|
||||
const localValue = computed<EntradaTexto["value"]>({
|
||||
get: () => props.value,
|
||||
set: (v) => {
|
||||
|
|
@ -49,7 +57,43 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
return { attrs, emit, localValue };
|
||||
const inputHtmlType = computed(() => {
|
||||
if (formato.value === "email") return "email";
|
||||
if (formato.value === "url") return "url";
|
||||
return "text";
|
||||
});
|
||||
|
||||
const inputMode = computed<string | undefined>(() => {
|
||||
if (formato.value === "telefone") return "tel";
|
||||
if (formato.value === "cpfCnpj" || formato.value === "cep") return "numeric";
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function aplicarFormato(valor: string) {
|
||||
switch (formato.value) {
|
||||
case "telefone":
|
||||
return formatTelefone(valor);
|
||||
case "cpfCnpj":
|
||||
return formatarCpfCnpj(valor);
|
||||
case "cep":
|
||||
return formatarCep(valor);
|
||||
default:
|
||||
return valor;
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const resultado = aplicarFormato(target.value);
|
||||
|
||||
// garante que o input mostre o valor formatado
|
||||
target.value = resultado;
|
||||
|
||||
// regra do projeto: value sempre igual ao que aparece
|
||||
localValue.value = resultado;
|
||||
}
|
||||
|
||||
return { attrs, emit, localValue, inputHtmlType, inputMode, onInput };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -47,18 +47,24 @@ export type PadroesEntradas = {
|
|||
{
|
||||
/** Limite máximo de caracteres permitidos (se definido) */
|
||||
limiteCaracteres?: number
|
||||
|
||||
/**
|
||||
* Formato/máscara aplicada ao texto.
|
||||
* Obs: o `value` SEMPRE será o texto formatado (o que aparece no input).
|
||||
*/
|
||||
formato?: "texto" | "email" | "url" | "telefone" | "cpfCnpj" | "cep"
|
||||
}
|
||||
>
|
||||
|
||||
numero: tipoPadraoEntrada<
|
||||
number | null | undefined,
|
||||
{
|
||||
|
||||
|
||||
/** Unidade de medida (ex.: "kg", "m³") */
|
||||
|
||||
/** Unidade de medida (ex.: "kg", "m³") */
|
||||
sufixo?: string
|
||||
|
||||
/** Moéda (ex.: "R$") */
|
||||
/** Moéda (ex.: "R$") */
|
||||
prefixo?: string
|
||||
|
||||
/**
|
||||
|
|
|
|||
10
src/componentes/EliEntrada/utils/cep.ts
Normal file
10
src/componentes/EliEntrada/utils/cep.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
function somenteNumeros(valor: string) {
|
||||
return valor.replace(/\D+/g, "");
|
||||
}
|
||||
|
||||
/** Formata CEP no padrão 00000-000 */
|
||||
export function formatarCep(valor: string) {
|
||||
const digitos = somenteNumeros(valor);
|
||||
if (!digitos) return "";
|
||||
return digitos.replace(/^(\d{5})(\d)/, "$1-$2").slice(0, 9);
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// utils/telefone.ts
|
||||
|
||||
/**
|
||||
* Remove tudo que não é número
|
||||
*/
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
<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"
|
||||
:suffix="type === 'porcentagem' ? '%' : undefined"
|
||||
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"
|
||||
/>
|
||||
|
||||
<!-- SELECT -->
|
||||
<v-select
|
||||
v-else-if="type === 'select'"
|
||||
v-model="value"
|
||||
:items="computedItems"
|
||||
:label="label"
|
||||
:placeholder="placeholder"
|
||||
:multiple="multiple"
|
||||
:chips="chips"
|
||||
:clearable="clearable"
|
||||
:disabled="disabled"
|
||||
:density="density"
|
||||
:variant="variant"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:error="error"
|
||||
:error-messages="errorMessages"
|
||||
v-bind="attrs"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<!-- RADIO -->
|
||||
<v-radio-group
|
||||
v-else-if="type === 'radio'"
|
||||
v-model="value"
|
||||
:row="row"
|
||||
>
|
||||
<v-radio
|
||||
v-for="opt in computedItems"
|
||||
:key="String(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 computedItems"
|
||||
:key="String(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 type {
|
||||
CampoDensidade,
|
||||
CampoOpcao,
|
||||
CampoOpcaoBruta,
|
||||
CampoTipo,
|
||||
CampoValor,
|
||||
CampoValorMultiplo,
|
||||
CampoVariante,
|
||||
} from "../../tipos";
|
||||
import { formatarCpfCnpj } from "./utils/cpfCnpj";
|
||||
import { formatTelefone } from "./utils/telefone";
|
||||
import { formatarDecimal, formatarMoeda, formatarPorcentagem, somenteNumeros } from "./utils/numerico"
|
||||
import { formatarCep } from "./utils/cep";
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliInput",
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Aceita valor simples (text-like) ou lista de valores (checkbox/select multiple).
|
||||
* O componente não converte tipos automaticamente: mantém o que receber.
|
||||
*/
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Array] as PropType<CampoValor | CampoValorMultiplo>,
|
||||
default: "",
|
||||
},
|
||||
type: { type: String as PropType<CampoTipo>, default: "text" },
|
||||
label: String,
|
||||
placeholder: String,
|
||||
disabled: Boolean,
|
||||
error: Boolean,
|
||||
errorMessages: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
hint: String,
|
||||
persistentHint: Boolean,
|
||||
rows: { type: Number, default: 4 },
|
||||
/**
|
||||
* Para select/radio/checkbox.
|
||||
* Aceita lista já normalizada ({ label, value }) ou valores primitivos.
|
||||
*/
|
||||
options: {
|
||||
type: Array as PropType<Array<CampoOpcaoBruta>>,
|
||||
default: () => [],
|
||||
},
|
||||
clearable: Boolean,
|
||||
variant: { type: String as PropType<CampoVariante>, default: "outlined" },
|
||||
density: { type: String as PropType<CampoDensidade>, default: "comfortable" },
|
||||
color: { type: String, default: "primary" },
|
||||
row: Boolean,
|
||||
showPasswordToggle: Boolean,
|
||||
multiple: Boolean,
|
||||
chips: 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: CampoValor | CampoValorMultiplo) => {
|
||||
emit("update:modelValue", v);
|
||||
emit("change", v);
|
||||
},
|
||||
});
|
||||
|
||||
const isTextLike = computed(() =>
|
||||
[
|
||||
"text",
|
||||
"password",
|
||||
"email",
|
||||
"search",
|
||||
"url",
|
||||
"telefone",
|
||||
"cpfCnpj",
|
||||
"numericoInteiro",
|
||||
"numericoDecimal",
|
||||
"numericoMoeda",
|
||||
"porcentagem",
|
||||
"cep",
|
||||
].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 === "porcentagem") return "decimal";
|
||||
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 "porcentagem":
|
||||
resultado = formatarPorcentagem(resultado);
|
||||
break;
|
||||
|
||||
case "telefone":
|
||||
resultado = formatTelefone(resultado);
|
||||
break;
|
||||
|
||||
case "cpfCnpj":
|
||||
resultado = formatarCpfCnpj(resultado);
|
||||
break;
|
||||
|
||||
case "cep":
|
||||
resultado = formatarCep(resultado);
|
||||
break;
|
||||
}
|
||||
|
||||
target.value = resultado;
|
||||
emit("update:modelValue", resultado);
|
||||
emit("change", resultado);
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
showPassword.value = !showPassword.value;
|
||||
}
|
||||
|
||||
// --- Helpers para select / radio / checkbox (aceita objetos ou primitivos) ---
|
||||
const computedItems = computed<Array<CampoOpcao>>(() => {
|
||||
// Normaliza options para [{ label, value, disabled? }]
|
||||
return (props.options || []).map((o) => {
|
||||
if (o && typeof o === "object" && "value" in o) {
|
||||
const valor = o.value as CampoValor;
|
||||
return {
|
||||
label: o.label ?? String(valor),
|
||||
value: valor,
|
||||
disabled: o.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
const valor = o as CampoValor;
|
||||
return { label: String(valor), value: valor };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
attrs,
|
||||
value,
|
||||
isTextLike,
|
||||
inputHtmlType,
|
||||
inputMode,
|
||||
internalColor,
|
||||
showPassword,
|
||||
togglePassword,
|
||||
onInput,
|
||||
onFocus: () => emit("focus"),
|
||||
onBlur: () => emit("blur"),
|
||||
computedItems,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.eli-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
# EliInput
|
||||
|
||||
**Componente base de input do design system**
|
||||
|
||||
O EliInput unifica vários tipos de campo (v-text-field, v-textarea, v-select, v-radio-group, v-checkbox) em uma única API consistente. Ele encapsula comportamentos, máscaras e regras comuns (CPF/CNPJ, telefone, CEP, numéricos, formatação de moeda etc.) para manter coerência visual e de lógica em toda a aplicação.
|
||||
|
||||
> ⚠️ Nunca use os componentes Vuetify diretamente fora do design system para esses casos.
|
||||
> Utilize sempre EliInput para garantir formatação, tipagem e repasse de atributos padronizados.
|
||||
|
||||
---
|
||||
|
||||
## Visão geral
|
||||
|
||||
EliInput foi projetado para:
|
||||
|
||||
* Centralizar formatação (máscaras) para tipos de entrada comuns (telefone, CPF/CNPJ, CEP, numéricos).
|
||||
* Fornecer uma API única (type) que representa diferentes controles (text, textarea, select, radio, checkbox, etc.).
|
||||
* Repassar atributos/props do pai para o componente Vuetify interno (v-bind="$attrs") mantendo inheritAttrs: false.
|
||||
* Emitir eventos padronizados: update:modelValue, change, focus, blur.
|
||||
|
||||
---
|
||||
|
||||
## Principais decisões de implementação
|
||||
|
||||
* inheritAttrs: false — o componente controla explicitamente para onde os atributos são passados (v-bind="attrs" no elemento interno).
|
||||
* Uso de um computed value que faz emit("update:modelValue", v) e emit("change", v) — assim qualquer v-model no pai funciona como esperado.
|
||||
* Normalização de props.options para aceitar objetos { label, value, disabled } ou primitivos ('A', 1).
|
||||
* Separação clara entre lógica de formatação (aplicada em onInput) e componentes que não devem ser formatados (ex.: v-select).
|
||||
|
||||
---
|
||||
|
||||
## Tipagem (TypeScript)
|
||||
|
||||
```ts
|
||||
type ValorCampo = string | number | boolean | null;
|
||||
type Option = { label: string; value: ValorCampo; disabled?: boolean };
|
||||
|
||||
type InputVariant = 'outlined' | 'filled' | 'plain' | 'solo' | 'solo-filled' | 'solo-inverted' | 'underlined';
|
||||
type Density = 'default' | 'comfortable' | 'compact';
|
||||
type TipoNumerico = 'numericoInteiro' | 'numericoDecimal' | 'numericoMoeda' | 'porcentagem';
|
||||
|
||||
type InputType =
|
||||
| 'text' | 'password' | 'email' | 'search' | 'url' | 'textarea'
|
||||
| 'radio' | 'checkbox' | 'telefone' | 'cpfCnpj' | 'cep' | 'select'
|
||||
| TipoNumerico;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Tipo | Default | Descrição |
|
||||
| ---------------- | --------------------------- | --------------- | ------------------------------------------------------ |
|
||||
| `modelValue` | `string \| number \| boolean \| (string \| number \| boolean \| null)[]` | `""` | Valor controlado (use com `v-model`). |
|
||||
| `type` | `InputType` | `"text"` | Tipo do controle (ver `InputType`). |
|
||||
| `label` | `string` | `-` | Rótulo do campo. |
|
||||
| `placeholder` | `string` | `-` | Texto exibido quando o campo está vazio. |
|
||||
| `disabled` | `boolean` | `false` | Desabilita o campo. |
|
||||
| `error` | `boolean` | `false` | Força estado visual de erro. |
|
||||
| `errorMessages` | `string \| string[]` | `[]` | Mensagem ou lista de mensagens de erro. |
|
||||
| `hint` | `string` | `-` | Texto de ajuda exibido abaixo do campo. |
|
||||
| `persistentHint` | `boolean` | `false` | Mantém o hint sempre visível. |
|
||||
| `variant` | `InputVariant` | `"outlined"` | Variante visual do Vuetify. |
|
||||
| `density` | `Density` | `"comfortable"` | Densidade visual do campo. |
|
||||
| `color` | `string` | `"primary"` | Cor do campo quando focado (ou `error`, se aplicável). |
|
||||
| `clearable` | `boolean` | `false` | Permite limpar o valor do campo. |
|
||||
|
||||
|
||||
## Notas sobre props
|
||||
|
||||
* options: aceita arrays como ['Frontend','Backend'] ou { label:'São Paulo', value:'SP' }. O componente normaliza para { label, value, disabled? }.
|
||||
* type determina quais comportamentos internos são aplicados. Tipos numéricos e máscara (telefone, cpfCnpj, cep) passam por formatação em onInput.
|
||||
* multiple/chips: úteis para type="select". O v-select interno recebe item-title="label" e item-value="value" para compatibilidade com objetos.
|
||||
|
||||
---
|
||||
|
||||
## Emissões (events)
|
||||
|
||||
* update:modelValue — padrão v-model.
|
||||
* change — emitido sempre que o valor muda (alinha com update:modelValue).
|
||||
* focus — quando o campo interno recebe foco.
|
||||
* blur — quando perde o foco.
|
||||
* Observação: como value é um computed com getter/setter que emite ambos, v-model e listeners de mudança no pai funcionarão normalmente.
|
||||
|
||||
---
|
||||
|
||||
## Repasso de atributos e listeners
|
||||
|
||||
* O componente define inheritAttrs: false e usa v-bind="attrs" nos componentes internos (v-text-field, v-select, etc.). Isso implica que:
|
||||
* Atributos HTML (ex.: type, aria-label, class, style) passados para <EliInput> são aplicados ao componente Vuetify interno apropriado.
|
||||
* Listeners (ex.: @click, @keydown) também fazem parte de $attrs e serão repassados para o componente interno — use o EliInput como se estivesse ouvindo eventos diretamente no input.
|
||||
|
||||
## Exemplo:
|
||||
|
||||
```vue
|
||||
<EliInput type="text" v-model="nome" aria-label="Nome completo" @keydown.enter="enviar" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comportamentos de formatação importantes
|
||||
|
||||
* numericoInteiro — remove tudo que não for dígito.
|
||||
* numericoDecimal — mantém separador decimal (aplica formatarDecimal).
|
||||
* numericoMoeda — formata para moeda conforme util (formatarMoeda).
|
||||
* porcentagem — aplica formatação decimal e exibe sufixo `%` automaticamente.
|
||||
* telefone — aplica máscara/format formatTelefone.
|
||||
* cpfCnpj — aplica formatarCpfCnpj.
|
||||
* cep — aplica formatarCep.
|
||||
|
||||
**Importante: a formatação ocorre no onInput (campos text-like). O v-select não passa por onInput — ele usa v-model="value" e o computed que emite o update. Se desejar formatação específica para itens do select (por exemplo, mostrar label formatado), trate nos options antes de passar.**
|
||||
|
||||
---
|
||||
|
||||
## Slot
|
||||
|
||||
* O componente não expõe slots customizados diretamente. Ele controla internamente o append para toggle de senha quando type === 'password' && showPasswordToggle (ícone de olho).
|
||||
* Se você precisa de slots específicos do v-text-field/v-select, considere estender o componente ou criar uma variação que exponha os slots desejados.
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as EliInput } from "./EliInput.vue";
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { somenteNumeros } from "./numerico";
|
||||
|
||||
export function formatarCep(v: string): string {
|
||||
const d = somenteNumeros(v).slice(0, 8);
|
||||
|
||||
if (d.length <= 5) return d;
|
||||
|
||||
return d.replace(/^(\d{5})(\d{1,3})$/, "$1-$2");
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatação para percentual:
|
||||
* - remove '%' caso venha junto (ex: colar "10%")
|
||||
* - mantém apenas dígitos e vírgula (no máximo uma)
|
||||
*/
|
||||
export function formatarPorcentagem(valor: string) {
|
||||
return formatarDecimal(valor.replace(/%/g, ""));
|
||||
}
|
||||
|
||||
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, ".");
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
# EliDataHora
|
||||
|
||||
O `EliDataHora` é um componente de **entrada de data e hora** baseado em `v-text-field` (Vuetify), usando o tipo nativo do HTML `datetime-local`.
|
||||
|
||||
Ele foi criado para oferecer uma solução **estável e leve** sem depender de componentes experimentais do Vuetify.
|
||||
|
||||
## Objetivo
|
||||
|
||||
- Permitir o usuário selecionar **data + hora** com UX nativa do navegador.
|
||||
- Padronizar a API em português (props/eventos) no Design System.
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Tipo | Padrão | Descrição |
|
||||
|------|------|--------|-----------|
|
||||
| `modelValue` | `string \| null` | `null` | **Sempre em ISO 8601**, aceitando UTC absoluto (`Z`) ou com offset (ex.: `2026-01-09T16:15:00Z`, `2026-01-09T13:15:00-03:00`). O componente converte para horário **local** antes de exibir. |
|
||||
| `modo` | `"data" \| "dataHora"` | `"dataHora"` | Define se o campo permite selecionar apenas data (`date`) ou data+hora (`datetime-local`). |
|
||||
| `rotulo` | `string` | `"Data e hora"` | Label do campo. |
|
||||
| `placeholder` | `string` | `""` | Placeholder do campo. |
|
||||
| `desabilitado` | `boolean` | `false` | Desabilita o campo. |
|
||||
| `limpavel` | `boolean` | `false` | Habilita botão de limpar (Vuetify `clearable`). |
|
||||
| `erro` | `boolean` | `false` | Estado de erro visual. |
|
||||
| `mensagensErro` | `string \| string[]` | `[]` | Mensagens de erro. |
|
||||
| `dica` | `string` | `""` | Hint/ajuda abaixo do campo. |
|
||||
| `dicaPersistente` | `boolean` | `false` | Mantém dica sempre visível. |
|
||||
| `densidade` | `CampoDensidade` | `"comfortable"` | Densidade (Vuetify). |
|
||||
| `variante` | `CampoVariante` | `"outlined"` | Variante (Vuetify). |
|
||||
| `min` | `string \| undefined` | `undefined` | Mínimo permitido em ISO 8601 (offset ou `Z`). |
|
||||
| `max` | `string \| undefined` | `undefined` | Máximo permitido em ISO 8601 (offset ou `Z`). |
|
||||
|
||||
> Observação: o atributo HTML `datetime-local` **não inclui timezone**.
|
||||
> Este componente resolve isso convertendo:
|
||||
>
|
||||
> - **entrada**: ISO 8601 (UTC/offset) → **exibição** em horário local
|
||||
> - **saída**: valor selecionado → ISO 8601 com **offset local**
|
||||
|
||||
### Emits
|
||||
|
||||
| Evento | Payload | Quando dispara |
|
||||
|--------|---------|---------------|
|
||||
| `update:modelValue` | `string \| null` | Sempre que o valor muda (padrão do v-model). O payload é ISO 8601 com **offset local**. |
|
||||
| `alterar` | `string \| null` | Alias semântico para mudanças de valor (mesmo payload do v-model). |
|
||||
| `foco` | `void` | Ao focar o campo. |
|
||||
| `desfoco` | `void` | Ao sair do foco. |
|
||||
|
||||
### Slots
|
||||
|
||||
Este componente não define slots próprios. Você pode usar slots do `v-text-field` via `v-bind="$attrs"` caso precise (ver exemplos abaixo).
|
||||
|
||||
## Exemplos
|
||||
|
||||
### 1) Uso básico com v-model
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<EliDataHora v-model="dataHora" />
|
||||
<div class="text-caption">Valor: {{ dataHora }}</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "vue";
|
||||
import { EliDataHora } from "eli-vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { EliDataHora },
|
||||
setup() {
|
||||
const dataHora = ref<string | null>("2026-01-09T16:15:00Z");
|
||||
return { dataHora };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2) Com limites (min/max) e validação visual
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<EliDataHora
|
||||
v-model="dataHora"
|
||||
rotulo="Agendar"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:erro="!dataHora"
|
||||
:mensagensErro="!dataHora ? ['Obrigatório'] : []"
|
||||
limpavel
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Casos de borda / comportamento esperado
|
||||
|
||||
- Ao limpar o campo, o componente emite `null` (não string vazia).
|
||||
- O navegador pode variar a UI do seletor (isso é esperado do `datetime-local`).
|
||||
- `min/max` devem ser strings em ISO 8601 (offset ou `Z`).
|
||||
- Em `modo="data"`, o componente emite ISO no **início do dia** (`00:00:00`) no fuso local.
|
||||
|
||||
## Acessibilidade
|
||||
|
||||
- O `v-text-field` do Vuetify já oferece base de acessibilidade.
|
||||
- Sempre prefira passar `rotulo` significativo.
|
||||
|
||||
## Decisões de implementação
|
||||
|
||||
- Usamos `datetime-local` por ser amplamente suportado e não depender de APIs experimentais.
|
||||
- O componente usa `dayjs` para converter entradas UTC/offset para local antes de exibir e para emitir ISO 8601 com offset local.
|
||||
- Mantemos o valor como `string | null` para evitar conversões implícitas e permitir que cada projeto decida como persistir (UTC/local).
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as EliEntradaDataHora } from "../EliEntrada/EliEntradaDataHora.vue";
|
||||
|
|
@ -11,100 +11,31 @@
|
|||
|
||||
<div class="grid-example">
|
||||
<!-- text normal -->
|
||||
<EliInput
|
||||
v-model="nome"
|
||||
label="Nome"
|
||||
placeholder="Digite o nome"
|
||||
|
||||
<EliEntradaTexto
|
||||
v-model:value="nome"
|
||||
:opcoes="{ rotulo: 'Nome', placeholder: 'Digite o nome' }"
|
||||
density="compact"
|
||||
/>
|
||||
|
||||
<EliInput
|
||||
v-model="idade"
|
||||
type="numericoInteiro"
|
||||
label="Idade"
|
||||
density="default"
|
||||
<EliEntradaTexto
|
||||
v-model:value="telefone"
|
||||
:opcoes="{ rotulo: 'Telefone', formato: 'telefone' }"
|
||||
/>
|
||||
|
||||
<EliInput
|
||||
v-model="altura"
|
||||
type="numericoDecimal"
|
||||
label="Altura"
|
||||
density="comfortable"
|
||||
<EliEntradaTexto
|
||||
v-model:value="cep"
|
||||
:opcoes="{ rotulo: 'CEP', placeholder: '00000-000', formato: 'cep' }"
|
||||
/>
|
||||
|
||||
<EliInput v-model="valor" type="numericoMoeda" label="Valor" />
|
||||
|
||||
<EliInput v-model="telefone" type="telefone" label="Telefone" />
|
||||
|
||||
<EliInput
|
||||
v-model="cep"
|
||||
type="cep"
|
||||
label="CEP"
|
||||
placeholder="00000-000"
|
||||
<EliEntradaTexto
|
||||
v-model:value="documento"
|
||||
:opcoes="{ rotulo: 'CPF / CNPJ', formato: 'cpfCnpj' }"
|
||||
/>
|
||||
|
||||
<EliInput
|
||||
type="select"
|
||||
label="Estado"
|
||||
:options="[
|
||||
{ label: 'São Paulo', value: 'SP' },
|
||||
{ label: 'Rio de Janeiro', value: 'RJ' }
|
||||
]"
|
||||
v-model="estado"
|
||||
multiple
|
||||
/>
|
||||
|
||||
<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']"
|
||||
<EliEntradaTexto
|
||||
v-model:value="email"
|
||||
:opcoes="{ rotulo: 'Email', placeholder: 'email@exemplo.com', formato: 'email' }"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
|
@ -121,45 +52,27 @@
|
|||
import { defineComponent, ref } from "vue";
|
||||
import EliBotao from "../botao/EliBotao.vue";
|
||||
import EliBadge from "../indicador/EliBadge.vue";
|
||||
import EliInput from "../campo/EliInput.vue";
|
||||
|
||||
type Habilidade = "vue" | "react";
|
||||
import EliEntradaTexto from "../EliEntrada/EliEntradaTexto.vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliOlaMundo",
|
||||
components: {
|
||||
EliBotao,
|
||||
EliBadge,
|
||||
EliInput,
|
||||
EliEntradaTexto,
|
||||
},
|
||||
setup() {
|
||||
const nome = ref("");
|
||||
const estado = ref<string[]>([]);
|
||||
const cep = 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<"azul" | "verde" | null>(null);
|
||||
const habilidades = ref<Habilidade[]>([]);
|
||||
return {
|
||||
nome,
|
||||
email,
|
||||
documento,
|
||||
estado,
|
||||
telefone,
|
||||
mensagem,
|
||||
senha,
|
||||
cor,
|
||||
habilidades,
|
||||
idade,
|
||||
altura,
|
||||
cep,
|
||||
valor,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
O `EliOlaMundo` é um componente **de exemplo** usado para validar rapidamente o Design System no playground.
|
||||
|
||||
> Ele não é um componente “de produto”; ele existe para demonstrar integração com Vuetify e mostrar variações de uso de `EliInput`, `EliBotao` e `EliBadge`.
|
||||
> Ele não é um componente “de produto”; ele existe para demonstrar integração com Vuetify e mostrar variações de uso de `EliEntradaTexto`, `EliEntradaNumero`, `EliBotao` e `EliBadge`.
|
||||
|
||||
## Estrutura do repositório (padrão)
|
||||
|
||||
Neste Design System:
|
||||
|
||||
- **Pastas e arquivos** (quando aplicável) preferem português: `botao/`, `campo/`, `indicador/`, etc.
|
||||
- **Componentes** mantêm prefixo técnico `Eli` (PascalCase): `EliBotao`, `EliInput`.
|
||||
- **Pastas e arquivos** (quando aplicável) preferem português: `botao/`, `EliEntrada/`, `indicador/`, etc.
|
||||
- **Componentes** mantêm prefixo técnico `Eli` (PascalCase): `EliBotao`, `EliEntradaTexto`.
|
||||
|
||||
Exemplo:
|
||||
|
||||
|
|
@ -19,9 +19,10 @@ src/componentes/
|
|||
EliBotao.vue
|
||||
index.ts
|
||||
README.md
|
||||
campo/
|
||||
EliInput.vue
|
||||
index.ts
|
||||
EliEntrada/
|
||||
EliEntradaTexto.vue
|
||||
EliEntradaNumero.vue
|
||||
EliEntradaDataHora.vue
|
||||
README.md
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue