From 454fddb061111506ef2f081a5a333c0043d8d442 Mon Sep 17 00:00:00 2001 From: andreLMpena Date: Mon, 22 Dec 2025 08:39:01 -0300 Subject: [PATCH 1/3] feat: componente de badge --- src/componentes/EliBadge/EliBadge.vue | 128 ++++++++++++++++++ src/componentes/EliBadge/README.md | 182 ++++++++++++++++++++++++++ src/componentes/EliBadge/index.ts | 4 + src/index.ts | 3 + src/playground/App.vue | 12 +- 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/componentes/EliBadge/EliBadge.vue create mode 100644 src/componentes/EliBadge/README.md create mode 100644 src/componentes/EliBadge/index.ts diff --git a/src/componentes/EliBadge/EliBadge.vue b/src/componentes/EliBadge/EliBadge.vue new file mode 100644 index 0000000..260974e --- /dev/null +++ b/src/componentes/EliBadge/EliBadge.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/componentes/EliBadge/README.md b/src/componentes/EliBadge/README.md new file mode 100644 index 0000000..66a8911 --- /dev/null +++ b/src/componentes/EliBadge/README.md @@ -0,0 +1,182 @@ +# EliBadge + +**Componente base de badge do design system** + +O `EliBadge` encapsula o `v-badge` do Vuetify para aplicar padrões visuais, tipagem e comportamento previsível em toda a aplicação. + +> ⚠️ **Nunca use `v-badge` diretamente fora do design system.** +> Utilize sempre o `EliBadge` para manter consistência visual e compatibilidade entre versões do Vuetify. + +--- + +## Visão geral + +O `EliBadge` foi projetado para: + +- Garantir consistência visual no uso de badges; +- Fornecer uma API tipada (TypeScript) e com autocomplete; +- Controlar corretamente a exibição do badge sem afetar o conteúdo principal; +- Permitir presets semânticos de `border-radius` e valores CSS customizados; +- Repassar atributos e listeners de forma transparente ao `v-badge`. + +### Principais decisões de implementação + +- `inheritAttrs: false` — o componente faz `v-bind="$attrs"` manualmente no `v-badge`. +- Uso de CSS Variable (`--eli-badge-radius`) para controlar o `border-radius`. +- Presets tipados para `radius` + fallback para valores CSS livres. +- O slot padrão **nunca é removido**: apenas o badge é condicional. + +--- + +## Tipagem (TypeScript) + +```ts +type LocalBadge = + | "top right" + | "right center" + | "bottom right" + | "top center" + | "bottom center" + | "top left" + | "left center" + | "bottom left"; + +type Offset = "-20" | "-15" | "-10" | "-5" | "0" | "20" | "15" | "10" | "5"; + +type BadgeRadiusPreset = "suave" | "pill"; + +type CssLength = `${number}px` | `${number}rem` | `${number}%` | "0"; + +--- + +## Props +| Prop | Tipo | Default | Descrição | +| ---------- | -------------------------------- | ------------- | ---------------------------------------------- | +| `color` | `string` | `"primary"` | Cor visual do badge (Vuetify theme). | +| `location` | `LocalBadge` | `"top right"` | Posição do badge em relação ao conteúdo. | +| `offsetX` | `Offset` | `"0"` | Deslocamento horizontal. | +| `offsetY` | `Offset` | `"0"` | Deslocamento vertical. | +| `dot` | `boolean` | `false` | Exibe badge no formato de ponto. | +| `visible` | `boolean` | `true` | Controla a exibição do badge (slot permanece). | +| `badge` | `string \| number \| undefined` | `undefined` | Conteúdo textual ou numérico do badge. | +| `radius` | `BadgeRadiusPreset \| CssLength` | `"suave"` | Preset ou valor CSS de `border-radius`. | + +Presets de radius +{ + suave: "4px", // cantos levemente arredondados + pill: "10px", // cantos bem arredondados +} + +--- + + Repasso de atributos e listeners + +Como inheritAttrs: false e v-bind="$attrs" são usados: + + 1. Atributos HTML (ex.: type, aria-label, class, style) passados para serão aplicados ao v-badge filho. + + 2. Listeners (ex.: @click) também são repassados ao v-badge — use o componente como se estivesse escutando eventos diretamente no v-badge. + +Exemplo: + + mdi-bell + + +--- + +Slot + +O EliBadge expõe um slot padrão para o conteúdo que será "badged" — normalmente um ícone, avatar ou texto. + +Exemplos: + + + mdi-bell + + + + + + +Se visible for false, o slot continua sendo renderizado (o badge some, mas o conteúdo permanece). + +--- + +Exemplos de uso + +Preset suave (padrão): + + mdi-email + + +Preset pill (mais arredondado): + + mdi-chat + + +Valor custom: + + mdi-alert + + + + mdi-star + + + +Esconder só o badge (manter conteúdo): + + Vistoria + + + +Mostrar dot (ponto): + + Usuário + + +--- + +Acessibilidade (A11y) + +1. Forneça aria-label quando o badge transmitir informação importante sem texto adicional. +2. Evite usar cor sozinha para transmitir significado — combine com texto ou atributos ARIA. +3. Para badges que comunicam contagem (ex.: notificações), adicione aria-live ou texto alternativo +no componente pai conforme a necessidade do caso de uso. + +--- + +Boas práticas + +1. Prefira presets (suave, pill) para consistência visual; use valores custom apenas quando necessário. +2. Não aplique estilos inline que conflitem com tokens do design system; prefira classes e variáveis do Vuetify. +3. Documente o uso do visible e badge nos locais onde o componente for amplamente adotado. +4. Evite usar visible=false se você espera apenas esconder zero/empty — prefira lógica que passe badge = undefined ou :visible="count > 0". + +--- + +Testes + +Recomenda-se testar: + +1. Renderização com badge presente e badge === undefined. +2. Comportamento de visible (assegurar que o slot continua visível quando visible=false). +3. dot true/false. +4. Aplicação da variável CSS (--eli-badge-radius) e que o border-radius interno do Vuetify muda conforme o radius. +5. $attrs repassados para o v-badge (por exemplo: aria-label, class). + +Exemplo (pseudocódigo): + const wrapper = mount(EliBadge, { + props: { badge: '3' }, + slots: { default: '' } + }); + expect(wrapper.html()).toContain('Inbox'); + expect(wrapper.findComponent({ name: 'v-badge' }).exists()).toBe(true); + +--- + +Observações sobre Vuetify + +1. O EliBadge usa seletores com ::v-deep para alterar o border-radius do elemento interno do v-badge. Isso funciona para Vuetify 2 e 3, mas as classes internas podem variar entre versões. Se você atualizar o Vuetify, verifique os nomes de classe (.v-badge__badge ou .v-badge__content) e ajuste o seletor se necessário. + +2. Prop names do v-badge (ex.: location, offset-x, offset-y, content, dot) podem variar entre versões do Vuetify — reveja a docs da versão em uso se algo não for aplicado como esperado. diff --git a/src/componentes/EliBadge/index.ts b/src/componentes/EliBadge/index.ts new file mode 100644 index 0000000..31741df --- /dev/null +++ b/src/componentes/EliBadge/index.ts @@ -0,0 +1,4 @@ +import EliBadge from "./EliBadge.vue"; + +export { EliBadge }; +export default EliBadge; diff --git a/src/index.ts b/src/index.ts index 1caa83d..ccf79eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ import type { App } from "vue"; import { EliOlaMundo } from "./componentes/EliOlaMundo"; import { EliBotao } from "./componentes/EliBotao"; +import { EliBadge } from "./componentes/EliBadge"; export { EliOlaMundo }; export { EliBotao }; +export { EliBadge }; export default { install(app: App) { app.component("EliOlaMundo", EliOlaMundo); app.component("EliBotao", EliBotao); + app.component("EliBadge", EliBadge); }, }; diff --git a/src/playground/App.vue b/src/playground/App.vue index cb10261..c5b1ec5 100644 --- a/src/playground/App.vue +++ b/src/playground/App.vue @@ -6,18 +6,28 @@ > Button + + Vistoria + From 6c84508996de90b70c5137d1ec5f2275ab73af2a Mon Sep 17 00:00:00 2001 From: andreLMpena Date: Mon, 22 Dec 2025 14:00:44 -0300 Subject: [PATCH 2/3] feat: inputs de texto e tipos cpf/cnpj, telefone e tipos numericos inteiro e decimal --- src/componentes/EliInput/EliInput.vue | 256 ++++++++++++++++++++ src/componentes/EliInput/README.md | 0 src/componentes/EliInput/index.ts | 4 + src/componentes/EliInput/utils/cpfCnpj.ts | 24 ++ src/componentes/EliInput/utils/numerico.ts | 17 ++ src/componentes/EliInput/utils/telefone.ts | 31 +++ src/componentes/EliOlaMundo/EliOlaMundo.vue | 123 +++++++++- src/index.ts | 3 + src/playground/App.vue | 19 +- 9 files changed, 454 insertions(+), 23 deletions(-) create mode 100644 src/componentes/EliInput/EliInput.vue create mode 100644 src/componentes/EliInput/README.md create mode 100644 src/componentes/EliInput/index.ts create mode 100644 src/componentes/EliInput/utils/cpfCnpj.ts create mode 100644 src/componentes/EliInput/utils/numerico.ts create mode 100644 src/componentes/EliInput/utils/telefone.ts diff --git a/src/componentes/EliInput/EliInput.vue b/src/componentes/EliInput/EliInput.vue new file mode 100644 index 0000000..96c1b91 --- /dev/null +++ b/src/componentes/EliInput/EliInput.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/src/componentes/EliInput/README.md b/src/componentes/EliInput/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/componentes/EliInput/index.ts b/src/componentes/EliInput/index.ts new file mode 100644 index 0000000..d616913 --- /dev/null +++ b/src/componentes/EliInput/index.ts @@ -0,0 +1,4 @@ +import EliInput from "./EliInput.vue"; + +export { EliInput }; +export default EliInput; diff --git a/src/componentes/EliInput/utils/cpfCnpj.ts b/src/componentes/EliInput/utils/cpfCnpj.ts new file mode 100644 index 0000000..8e66ac1 --- /dev/null +++ b/src/componentes/EliInput/utils/cpfCnpj.ts @@ -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); +} diff --git a/src/componentes/EliInput/utils/numerico.ts b/src/componentes/EliInput/utils/numerico.ts new file mode 100644 index 0000000..45b30f7 --- /dev/null +++ b/src/componentes/EliInput/utils/numerico.ts @@ -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, "."); +} diff --git a/src/componentes/EliInput/utils/telefone.ts b/src/componentes/EliInput/utils/telefone.ts new file mode 100644 index 0000000..66fbd46 --- /dev/null +++ b/src/componentes/EliInput/utils/telefone.ts @@ -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); +} diff --git a/src/componentes/EliOlaMundo/EliOlaMundo.vue b/src/componentes/EliOlaMundo/EliOlaMundo.vue index 7949159..f947815 100644 --- a/src/componentes/EliOlaMundo/EliOlaMundo.vue +++ b/src/componentes/EliOlaMundo/EliOlaMundo.vue @@ -1,23 +1,136 @@ diff --git a/src/index.ts b/src/index.ts index ccf79eb..2892810 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,15 +2,18 @@ import type { App } from "vue"; import { EliOlaMundo } from "./componentes/EliOlaMundo"; import { EliBotao } from "./componentes/EliBotao"; import { EliBadge } from "./componentes/EliBadge"; +import { EliInput } from "./componentes/EliInput"; export { EliOlaMundo }; export { EliBotao }; export { EliBadge }; +export { EliInput }; export default { install(app: App) { app.component("EliOlaMundo", EliOlaMundo); app.component("EliBotao", EliBotao); app.component("EliBadge", EliBadge); + app.component("EliInput", EliInput); }, }; diff --git a/src/playground/App.vue b/src/playground/App.vue index c5b1ec5..ab71d04 100644 --- a/src/playground/App.vue +++ b/src/playground/App.vue @@ -1,33 +1,16 @@ From 4fdc5a95ce7245c35db8e0c1d0dbfca3c2d4c4a5 Mon Sep 17 00:00:00 2001 From: andreLMpena Date: Tue, 23 Dec 2025 15:41:00 -0300 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20tipo=20cep=20e=20tipo=20select=20ma?= =?UTF-8?q?is=20documenta=C3=A7=C3=A3o=20do=20eli=20input=20(ainda=20incom?= =?UTF-8?q?pleta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/componentes/EliInput/EliInput.vue | 56 ++++++++++ src/componentes/EliInput/README.md | 116 ++++++++++++++++++++ src/componentes/EliInput/utils/cep.ts | 9 ++ src/componentes/EliOlaMundo/EliOlaMundo.vue | 40 ++++++- 4 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 src/componentes/EliInput/utils/cep.ts diff --git a/src/componentes/EliInput/EliInput.vue b/src/componentes/EliInput/EliInput.vue index 96c1b91..4a9827b 100644 --- a/src/componentes/EliInput/EliInput.vue +++ b/src/componentes/EliInput/EliInput.vue @@ -47,6 +47,28 @@ v-bind="attrs" /> + + + { + // Normaliza options para [{ label, value, disabled? }] + return (props.options || []).map((o: any) => + o && typeof o === "object" && ("label" in o || "value" in o) + ? { label: o.label ?? String(o.value), value: o.value, disabled: o.disabled } + : { label: String(o), value: o } + ); + }); + + function optLabel(opt: any) { + if (opt && typeof opt === "object") return opt.label ?? String(opt.value); + return String(opt); + } + + function optValue(opt: any) { + if (opt && typeof opt === "object") return opt.value; + return opt; + } + + return { attrs, value, @@ -234,6 +287,9 @@ export default defineComponent({ onInput, onFocus: () => emit("focus"), onBlur: () => emit("blur"), + computedItems, + optLabel, + optValue, }; }, }); diff --git a/src/componentes/EliInput/README.md b/src/componentes/EliInput/README.md index e69de29..5c4244b 100644 --- a/src/componentes/EliInput/README.md +++ b/src/componentes/EliInput/README.md @@ -0,0 +1,116 @@ +# 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 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' | 'cep' | 'select' + | TipoNumerico; +``` + +--- + +## Props + +| Prop | Tipo | Default | Descrição | +| ---------------- | --------------------------- | --------------- | ------------------------------------------------------ | +| `modelValue` | `string \| number \| any[]` | `""` | 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 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 + +``` + +--- + +## 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). +* 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. \ No newline at end of file diff --git a/src/componentes/EliInput/utils/cep.ts b/src/componentes/EliInput/utils/cep.ts new file mode 100644 index 0000000..701fd34 --- /dev/null +++ b/src/componentes/EliInput/utils/cep.ts @@ -0,0 +1,9 @@ +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"); +} diff --git a/src/componentes/EliOlaMundo/EliOlaMundo.vue b/src/componentes/EliOlaMundo/EliOlaMundo.vue index f947815..f100efb 100644 --- a/src/componentes/EliOlaMundo/EliOlaMundo.vue +++ b/src/componentes/EliOlaMundo/EliOlaMundo.vue @@ -18,20 +18,44 @@ density="compact" /> - + - + + + + + ([]); return { nome, email, documento, + estado, telefone, mensagem, senha, @@ -129,6 +156,7 @@ export default defineComponent({ habilidades, idade, altura, + cep, valor, }; },