This commit is contained in:
Luiz Silva 2026-01-27 12:07:22 -03:00
parent 8bb5aea15e
commit 24c07da6f8
17 changed files with 1458 additions and 371 deletions

2
dist/eli-vue.css vendored
View file

@ -1 +1 @@
[data-v-de2fbf2f] .v-badge__badge,[data-v-de2fbf2f] .v-badge__content{border-radius:var(--eli-badge-radius)!important}.eli-input[data-v-756cb549]{width:100%}.checkbox-group[data-v-756cb549]{display:flex;gap:8px;flex-wrap:wrap}.cursor-pointer[data-v-756cb549]{cursor:pointer}.eli-cartao[data-v-6c492bd9]{border-radius:12px}.eli-cartao__titulo[data-v-6c492bd9]{display:flex;align-items:center;justify-content:space-between;gap:12px}.eli-cartao__titulo-texto[data-v-6c492bd9]{min-width:0}.eli-cartao__conteudo[data-v-6c492bd9]{padding-top:8px}.eli-cartao__acoes[data-v-6c492bd9]{padding-top:0}.eli-cartao--cancelado[data-v-6c492bd9]{opacity:.85}.eli-data-hora[data-v-71afabb6]{width:100%}
[data-v-de2fbf2f] .v-badge__badge,[data-v-de2fbf2f] .v-badge__content{border-radius:var(--eli-badge-radius)!important}.eli-input[data-v-756cb549]{width:100%}.checkbox-group[data-v-756cb549]{display:flex;gap:8px;flex-wrap:wrap}.cursor-pointer[data-v-756cb549]{cursor:pointer}.eli-cartao[data-v-6c492bd9]{border-radius:12px}.eli-cartao__titulo[data-v-6c492bd9]{display:flex;align-items:center;justify-content:space-between;gap:12px}.eli-cartao__titulo-texto[data-v-6c492bd9]{min-width:0}.eli-cartao__conteudo[data-v-6c492bd9]{padding-top:8px}.eli-cartao__acoes[data-v-6c492bd9]{padding-top:0}.eli-cartao--cancelado[data-v-6c492bd9]{opacity:.85}.eli-data-hora[data-v-71afabb6],.eli-tabela[data-v-cc67b529]{width:100%}.eli-tabela__table[data-v-cc67b529]{width:100%;border-collapse:separate;border-spacing:0;border:1px solid rgba(0,0,0,.12);border-radius:12px;overflow:hidden}.eli-tabela__th[data-v-cc67b529],.eli-tabela__td[data-v-cc67b529]{padding:10px 12px;border-bottom:1px solid rgba(0,0,0,.08);vertical-align:top}.eli-tabela__th[data-v-cc67b529]{text-align:left;font-weight:600;background:#00000008}.eli-tabela__tr:last-child .eli-tabela__td[data-v-cc67b529]{border-bottom:none}.eli-tabela__td--clicavel[data-v-cc67b529]{cursor:pointer}.eli-tabela__td--clicavel[data-v-cc67b529]:hover{background:#00000008}.eli-tabela--erro[data-v-cc67b529]{border:1px solid rgba(220,53,69,.35);border-radius:12px;padding:12px}.eli-tabela--carregando[data-v-cc67b529]{border:1px dashed rgba(0,0,0,.25);border-radius:12px;padding:12px;opacity:.8}.eli-tabela__erro-titulo[data-v-cc67b529]{font-weight:700;margin-bottom:4px}.eli-tabela__erro-mensagem[data-v-cc67b529]{opacity:.9}.eli-tabela--vazio[data-v-cc67b529]{border:1px dashed rgba(0,0,0,.25);border-radius:12px;padding:12px;opacity:.8}

867
dist/eli-vue.es.js vendored

File diff suppressed because it is too large Load diff

2
dist/eli-vue.umd.js vendored

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,17 @@
import { PropType } from "vue";
import type { EliTabelaConsulta } from "./types";
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
tabela: {
type: PropType<EliTabelaConsulta<any>>;
required: true;
};
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
[key: string]: any;
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
tabela: {
type: PropType<EliTabelaConsulta<any>>;
required: true;
};
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
declare const _default: typeof __VLS_export;
export default _default;

View file

@ -0,0 +1,6 @@
import type { VNodeChild } from "vue";
export type EliCelulaTextoSimples = {
tipo: "texto-simples";
texto: string;
};
export declare const renderEliCelulaTextoSimples: (celula: EliCelulaTextoSimples) => VNodeChild;

View file

@ -0,0 +1,3 @@
export { default as EliTabela } from "./EliTabela.vue";
export * from "./types";
export * from "./celulas/EliCelulaTextoSimples";

View file

@ -0,0 +1,24 @@
import type { tipoResposta } from "p-respostas";
import type { VNodeChild } from "vue";
export type ComponenteCelula = VNodeChild;
export type EliColuna<T> = {
rotulo: string;
celula: (linha: T) => ComponenteCelula;
acao?: () => void;
};
export type EliConsultaPaginada<T> = {
valores: T[];
quantidade: number;
};
/**
* Estrutura de dados para uma tabela alimentada por uma consulta.
*
* - `colunas`: definição de colunas e como renderizar cada célula
* - `resposta`: função assíncrona que retorna uma resposta padronizada
*/
export type EliTabelaConsulta<T> = {
colunas: EliColuna<T>[];
resposta: () => Promise<tipoResposta<EliConsultaPaginada<T>>>;
/** Mensagem exibida quando a consulta retorna ok porém sem dados. */
mensagemVazio?: string;
};

View file

@ -5,11 +5,13 @@ import { EliBadge } from "./componentes/indicador";
import { EliInput } from "./componentes/campo";
import { EliCartao } from "./componentes/cartao";
import { EliDataHora } from "./componentes/data_hora";
import { EliTabela } from "./components/eli/EliTabela";
export { EliOlaMundo };
export { EliBotao };
export { EliBadge };
export { EliInput };
export { EliCartao };
export { EliDataHora };
export { EliTabela };
declare const EliVue: Plugin;
export default EliVue;

View file

@ -1,6 +1,6 @@
{
"name": "eli-vue",
"version": "0.1.19",
"version": "0.1.22",
"private": false,
"main": "./dist/eli-vue.umd.js",
"module": "./dist/eli-vue.es.js",
@ -35,6 +35,8 @@
"vuetify": "^3.11.2"
},
"dependencies": {
"dayjs": "^1.11.19"
"dayjs": "^1.11.19",
"lucide-vue-next": "^0.563.0",
"p-respostas": "git+https://git2.idz.one/publico/_respostas.git"
}
}

96
pnpm-lock.yaml generated
View file

@ -11,6 +11,12 @@ importers:
dayjs:
specifier: ^1.11.19
version: 1.11.19
lucide-vue-next:
specifier: ^0.563.0
version: 0.563.0(vue@3.5.25(typescript@5.9.3))
p-respostas:
specifier: git+https://git2.idz.one/publico/_respostas.git
version: git+https://git2.idz.one/publico/_respostas.git#8c24d790ace7255404745dcbdf12c5396e8b9843(cross-fetch@4.1.0)(dayjs@1.11.19)(uuid@11.1.0)
devDependencies:
'@mdi/font':
specifier: ^7.4.47
@ -489,6 +495,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
cross-fetch@4.1.0:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -554,6 +563,11 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
lucide-vue-next@0.563.0:
resolution: {integrity: sha512-zsE/lCKtmaa7bGfhSpN84br1K9YoQ5pCN+2oKWjQQG3Lo6ufUUKBuHSjNFI6RvUevxaajNXb8XwFUKeTXG3sIA==}
peerDependencies:
vue: '>=3.0.1'
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -575,6 +589,29 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
p-comuns@git+https://git2.idz.one/publico/_comuns.git#d783fa12940a5b1bcafa5038bd1c49c3f5f9b7fc:
resolution: {commit: d783fa12940a5b1bcafa5038bd1c49c3f5f9b7fc, repo: https://git2.idz.one/publico/_comuns.git, type: git}
version: 0.298.0
peerDependencies:
cross-fetch: 4.1.0
dayjs: ^1.11.18
uuid: ^11.1.0
zod: 4.1.4
p-respostas@git+https://git2.idz.one/publico/_respostas.git#8c24d790ace7255404745dcbdf12c5396e8b9843:
resolution: {commit: 8c24d790ace7255404745dcbdf12c5396e8b9843, repo: https://git2.idz.one/publico/_respostas.git, type: git}
version: 0.56.0
engines: {node: '>=20'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@ -619,6 +656,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@ -631,6 +671,10 @@ packages:
resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==}
engines: {node: '>=4'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
vite-plugin-vuetify@2.1.2:
resolution: {integrity: sha512-I/wd6QS+DO6lHmuGoi1UTyvvBTQ2KDzQZ9oowJQEJ6OcjWfJnscYXx2ptm6S7fJSASuZT8jGRBL3LV4oS3LpaA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -711,6 +755,15 @@ packages:
webpack-plugin-vuetify:
optional: true
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
zod@4.1.4:
resolution: {integrity: sha512-2YqJuWkU6IIK9qcE4k1lLLhyZ6zFw7XVRdQGpV97jEIZwTrscUw+DY31Xczd8nwaoksyJUIxCojZXwckJovWxA==}
snapshots:
'@babel/helper-string-parser@7.27.1': {}
@ -1042,6 +1095,12 @@ snapshots:
dependencies:
readdirp: 4.1.2
cross-fetch@4.1.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
csstype@3.2.3: {}
dayjs@1.11.19: {}
@ -1111,6 +1170,10 @@ snapshots:
is-number@7.0.0:
optional: true
lucide-vue-next@0.563.0(vue@3.5.25(typescript@5.9.3)):
dependencies:
vue: 3.5.25(typescript@5.9.3)
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -1130,6 +1193,26 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
p-comuns@git+https://git2.idz.one/publico/_comuns.git#d783fa12940a5b1bcafa5038bd1c49c3f5f9b7fc(cross-fetch@4.1.0)(dayjs@1.11.19)(uuid@11.1.0)(zod@4.1.4):
dependencies:
cross-fetch: 4.1.0
dayjs: 1.11.19
uuid: 11.1.0
zod: 4.1.4
p-respostas@git+https://git2.idz.one/publico/_respostas.git#8c24d790ace7255404745dcbdf12c5396e8b9843(cross-fetch@4.1.0)(dayjs@1.11.19)(uuid@11.1.0):
dependencies:
p-comuns: git+https://git2.idz.one/publico/_comuns.git#d783fa12940a5b1bcafa5038bd1c49c3f5f9b7fc(cross-fetch@4.1.0)(dayjs@1.11.19)(uuid@11.1.0)(zod@4.1.4)
zod: 4.1.4
transitivePeerDependencies:
- cross-fetch
- dayjs
- uuid
path-browserify@1.0.1: {}
picocolors@1.1.1: {}
@ -1195,6 +1278,8 @@ snapshots:
is-number: 7.0.0
optional: true
tr46@0.0.3: {}
typescript@5.9.3: {}
undici-types@7.16.0:
@ -1202,6 +1287,8 @@ snapshots:
upath@2.0.1: {}
uuid@11.1.0: {}
vite-plugin-vuetify@2.1.2(vite@6.4.1(@types/node@24.10.1)(sass@1.94.2))(vue@3.5.25(typescript@5.9.3))(vuetify@3.11.2):
dependencies:
'@vuetify/loader-shared': 2.1.1(vue@3.5.25(typescript@5.9.3))(vuetify@3.11.2)
@ -1250,3 +1337,12 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
vite-plugin-vuetify: 2.1.2(vite@6.4.1(@types/node@24.10.1)(sass@1.94.2))(vue@3.5.25(typescript@5.9.3))(vuetify@3.11.2)
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
zod@4.1.4: {}

View file

@ -0,0 +1,616 @@
<template>
<!-- Render é feito no script via função render para suportar VNodeChild em células -->
<div />
</template>
<script lang="ts">
import { defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, watch } from "vue";
import type { ComponentPublicInstance } from "vue";
import { MoreVertical } from "lucide-vue-next";
import { codigosResposta } from "p-respostas";
import type { EliTabelaConsulta } from "./types";
export default defineComponent({
name: "EliTabela",
inheritAttrs: false,
props: {
tabela: {
// Observação: este componente é generic-friendly.
// Usamos `any` aqui para permitir passar `EliTabelaConsulta<T>` de qualquer T
// sem brigar com invariância do TS (por causa do callback `celula(linha: T)`).
type: Object as PropType<EliTabelaConsulta<any>>,
required: true,
},
},
setup(props) {
const carregando = ref(false);
const erro = ref<string | null>(null);
const linhas = ref<unknown[]>([]);
const quantidade = ref<number>(0);
const acoesVisiveis = ref<boolean[][]>([]);
const menuAberto = ref<number | null>(null);
const menuElementos = new Map<number, HTMLElement>();
let carregamentoSequencial = 0;
function registrarMenuElemento(indice: number, elemento: HTMLElement | null) {
if (elemento) {
menuElementos.set(indice, elemento);
} else {
menuElementos.delete(indice);
}
}
function criarRegistradorMenu(indice: number) {
return (elemento: Element | ComponentPublicInstance | null) => {
if (elemento instanceof HTMLElement) {
registrarMenuElemento(indice, elemento);
} else {
registrarMenuElemento(indice, null);
}
};
}
function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) {
return;
}
const container = menuElementos.get(menuAberto.value);
if (container && container.contains(evento.target as Node)) {
return;
}
menuAberto.value = null;
}
function normalizarFilhos(filhos: unknown) {
// `VNodeChild` pode ser null/undefined/boolean.
// Para a assinatura de `h()`, normalizamos para string vazia.
if (filhos === null || filhos === undefined || filhos === false) {
return "";
}
return filhos as never;
}
function renderErro(mensagem: string) {
return h(
"div",
{
class: "eli-tabela eli-tabela--erro",
role: "alert",
},
[
h("div", { class: "eli-tabela__erro-titulo" }, "Erro"),
h("div", { class: "eli-tabela__erro-mensagem" }, mensagem),
]
);
}
function renderVazio(mensagem?: string) {
return h(
"div",
{
class: "eli-tabela eli-tabela--vazio",
},
mensagem ?? "Nenhum registro encontrado."
);
}
function renderCarregando() {
return h(
"div",
{
class: "eli-tabela eli-tabela--carregando",
"aria-busy": "true",
},
"Carregando..."
);
}
async function carregar() {
const idCarregamento = ++carregamentoSequencial;
carregando.value = true;
erro.value = null;
acoesVisiveis.value = [];
menuAberto.value = null;
menuElementos.clear();
try {
const tabelaConfig = props.tabela;
const res = await tabelaConfig.resposta();
if (idCarregamento !== carregamentoSequencial) {
return;
}
if (res.cod !== codigosResposta.sucesso) {
linhas.value = [];
quantidade.value = 0;
erro.value = res.mensagem;
return;
}
const valores = res.valor?.valores ?? [];
const total = res.valor?.quantidade ?? valores.length;
linhas.value = valores;
quantidade.value = total;
const acoes = tabelaConfig.acoes ?? [];
if (!acoes.length) {
acoesVisiveis.value = [];
return;
}
const preResultado = valores.map(() =>
acoes.map((acao) => {
if (typeof acao.exibir === "boolean") {
return acao.exibir;
}
return acao.exibir ? false : true;
})
);
acoesVisiveis.value = preResultado;
const visibilidade = await Promise.all(
valores.map(async (linha) =>
Promise.all(
acoes.map(async (acao) => {
if (acao.exibir === undefined) {
return true;
}
if (typeof acao.exibir === "boolean") {
return acao.exibir;
}
try {
const resultado = acao.exibir(linha as never);
return Boolean(await Promise.resolve(resultado));
} catch {
return false;
}
})
)
)
);
if (idCarregamento === carregamentoSequencial) {
acoesVisiveis.value = visibilidade;
}
} catch (e) {
if (idCarregamento !== carregamentoSequencial) {
return;
}
linhas.value = [];
quantidade.value = 0;
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados.";
} finally {
if (idCarregamento === carregamentoSequencial) {
carregando.value = false;
}
}
}
onMounted(() => {
document.addEventListener("click", handleClickFora);
void carregar();
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickFora);
menuElementos.clear();
});
watch(
() => props.tabela,
() => {
menuAberto.value = null;
menuElementos.clear();
// Caso a definição de tabela/consulta mude
void carregar();
}
);
watch(linhas, () => {
menuAberto.value = null;
menuElementos.clear();
});
return () => {
const tabela = props.tabela;
if (carregando.value) {
return renderCarregando();
}
if (erro.value) {
return renderErro(erro.value);
}
const colunas = tabela.colunas;
const acoes = tabela.acoes ?? [];
const temAcoes = acoes.length > 0;
if (!linhas.value.length) {
return renderVazio(tabela.mensagemVazio);
}
const cabecalho = colunas.map((coluna) =>
h("th", { class: "eli-tabela__th", scope: "col" }, coluna.rotulo)
);
if (temAcoes) {
cabecalho.push(
h(
"th",
{ class: "eli-tabela__th eli-tabela__th--acoes", scope: "col" },
"Ações"
)
);
}
return h(
"div",
{
class: "eli-tabela",
},
[
h("table", { class: "eli-tabela__table" }, [
h(
"thead",
{ class: "eli-tabela__thead" },
h(
"tr",
{ class: "eli-tabela__tr eli-tabela__tr--header" },
cabecalho
)
),
h(
"tbody",
{ class: "eli-tabela__tbody" },
linhas.value.map((linha, i) => {
const celulas = colunas.map((coluna, j) =>
h(
"td",
{
class: [
"eli-tabela__td",
coluna.acao ? "eli-tabela__td--clicavel" : undefined,
],
key: `${i}-${j}`,
onClick: coluna.acao ? () => coluna.acao?.() : undefined,
},
normalizarFilhos(coluna.celula(linha as never))
)
);
if (temAcoes) {
const visibilidade = acoesVisiveis.value[i] ?? [];
const acoesDisponiveis = acoes
.map((acao, indice) => ({
acao,
indice,
visivel:
visibilidade[indice] ??
(typeof acao.exibir === "boolean"
? acao.exibir
: acao.exibir
? false
: true),
}))
.filter((item) => item.visivel);
const possuiAcoes = acoesDisponiveis.length > 0;
if (!possuiAcoes && menuAberto.value === i) {
menuAberto.value = null;
}
const estaAberto = menuAberto.value === i;
const toggleId = `eli-tabela-acoes-toggle-${i}`;
const menuId = `eli-tabela-acoes-menu-${i}`;
const botaoToggle = h(
"button",
{
id: toggleId,
class: "eli-tabela__acoes-toggle",
type: "button",
disabled: !possuiAcoes,
onClick: (evento: MouseEvent) => {
evento.stopPropagation();
if (!possuiAcoes) {
return;
}
menuAberto.value = estaAberto ? null : i;
},
"aria-haspopup": "menu",
"aria-expanded": estaAberto ? "true" : "false",
"aria-controls": possuiAcoes ? menuId : undefined,
"aria-label": possuiAcoes
? "Ações da linha"
: "Nenhuma ação disponível",
title: possuiAcoes ? "Ações" : "Nenhuma ação disponível",
},
[
h(MoreVertical, {
class: "eli-tabela__acoes-toggle-icone",
size: 18,
strokeWidth: 2,
}),
]
);
const menu =
estaAberto && possuiAcoes
? h(
"ul",
{
id: menuId,
class: "eli-tabela__acoes-menu",
role: "menu",
"aria-labelledby": toggleId,
},
acoesDisponiveis.map(({ acao, indice }) =>
h(
"li",
{
key: `acao-${indice}`,
class: "eli-tabela__acoes-item",
role: "none",
},
h(
"button",
{
type: "button",
class: "eli-tabela__acoes-item-botao",
style: {
color: acao.cor,
},
onClick: (evento: MouseEvent) => {
evento.stopPropagation();
menuAberto.value = null;
acao.acao(linha as never);
},
role: "menuitem",
"aria-label": acao.rotulo,
title: acao.rotulo,
},
[
h(acao.icone, {
class: "eli-tabela__acoes-item-icone",
size: 16,
strokeWidth: 2,
}),
h(
"span",
{ class: "eli-tabela__acoes-item-texto" },
acao.rotulo
),
]
)
)
)
)
: null;
const classesContainer = [
"eli-tabela__acoes-container",
];
if (estaAberto) {
classesContainer.push("eli-tabela__acoes-container--aberto");
}
celulas.push(
h(
"td",
{
class: ["eli-tabela__td", "eli-tabela__td--acoes"],
key: `${i}-acoes`,
},
h(
"div",
{
class: classesContainer,
ref: criarRegistradorMenu(i),
},
[botaoToggle, menu]
)
)
);
}
return h(
"tr",
{ class: "eli-tabela__tr", key: i },
celulas
);
})
),
]),
]
);
};
},
});
</script>
<style scoped>
.eli-tabela {
width: 100%;
}
.eli-tabela__table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 12px;
overflow: visible;
}
.eli-tabela__th,
.eli-tabela__td {
padding: 10px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
vertical-align: top;
}
.eli-tabela__th {
text-align: left;
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
}
.eli-tabela__tr:last-child .eli-tabela__td {
border-bottom: none;
}
.eli-tabela__td--clicavel {
cursor: pointer;
}
.eli-tabela__td--clicavel:hover {
background: rgba(0, 0, 0, 0.03);
}
.eli-tabela--erro {
border: 1px solid rgba(220, 53, 69, 0.35);
border-radius: 12px;
padding: 12px;
}
.eli-tabela--carregando {
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
opacity: 0.8;
}
.eli-tabela__erro-titulo {
font-weight: 700;
margin-bottom: 4px;
}
.eli-tabela__erro-mensagem {
opacity: 0.9;
}
.eli-tabela--vazio {
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
opacity: 0.8;
}
.eli-tabela__th--acoes {
text-align: right;
white-space: nowrap;
}
.eli-tabela__td--acoes {
white-space: nowrap;
}
.eli-tabela__acoes-container {
display: flex;
justify-content: flex-end;
position: relative;
z-index: 1;
}
.eli-tabela__acoes-container--aberto {
z-index: 200;
}
.eli-tabela__acoes-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
border: none;
background: transparent;
color: rgba(15, 23, 42, 0.72);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acoes-toggle-icone {
display: block;
}
.eli-tabela__acoes-toggle:hover,
.eli-tabela__acoes-toggle:focus-visible {
background-color: rgba(15, 23, 42, 0.08);
color: rgba(15, 23, 42, 0.95);
}
.eli-tabela__acoes-toggle:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__acoes-toggle:disabled {
cursor: default;
color: rgba(148, 163, 184, 0.8);
background: transparent;
}
.eli-tabela__acoes-menu {
position: absolute;
top: 100%;
margin-top: 8px;
right: 0;
min-width: 180px;
padding: 6px 0;
margin: 0;
list-style: none;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
z-index: 1000;
}
.eli-tabela__acoes-item {
margin: 0;
}
.eli-tabela__acoes-item-botao {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.eli-tabela__acoes-item-botao:hover,
.eli-tabela__acoes-item-botao:focus-visible {
background-color: rgba(15, 23, 42, 0.06);
}
.eli-tabela__acoes-item-botao:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.eli-tabela__acoes-item-icone {
flex-shrink: 0;
}
.eli-tabela__acoes-item-texto {
flex: 1;
text-align: left;
}
</style>

View file

@ -0,0 +1,7 @@
import type { VNodeChild } from "vue";
export type EliCelulaTextoSimples = { tipo: "texto-simples"; texto: string };
export const renderEliCelulaTextoSimples = (
celula: EliCelulaTextoSimples
): VNodeChild => celula.texto;

View file

@ -0,0 +1,4 @@
export { default as EliTabela } from "./EliTabela.vue";
export * from "./types";
export * from "./celulas/EliCelulaTextoSimples";

View file

@ -0,0 +1,38 @@
import type { tipoResposta } from "p-respostas";
import type { LucideIcon } from "lucide-vue-next";
import type { VNodeChild } from "vue";
export type ComponenteCelula = VNodeChild;
export type EliColuna<T> = {
rotulo: string;
celula: (linha: T) => ComponenteCelula;
acao?: () => void;
};
export type EliConsultaPaginada<T> = {
valores: T[];
quantidade: number;
};
export type EliTabelaAcao<T> = {
icone: LucideIcon;
cor: string;
rotulo: string;
acao: (linha: T) => void;
exibir?: boolean | ((linha: T) => Promise<boolean> | boolean);
};
/**
* Estrutura de dados para uma tabela alimentada por uma consulta.
*
* - `colunas`: definição de colunas e como renderizar cada célula
* - `resposta`: função assíncrona que retorna uma resposta padronizada
*/
export type EliTabelaConsulta<T> = {
colunas: EliColuna<T>[];
resposta: () => Promise<tipoResposta<EliConsultaPaginada<T>>>;
/** Mensagem exibida quando a consulta retorna ok porém sem dados. */
mensagemVazio?: string;
acoes?: EliTabelaAcao<T>[];
};

View file

@ -5,6 +5,7 @@ import { EliBadge } from "./componentes/indicador";
import { EliInput } from "./componentes/campo";
import { EliCartao } from "./componentes/cartao";
import { EliDataHora } from "./componentes/data_hora";
import { EliTabela } from "./components/eli/EliTabela";
export { EliOlaMundo };
export { EliBotao };
@ -12,6 +13,7 @@ export { EliBadge };
export { EliInput };
export { EliCartao };
export { EliDataHora };
export { EliTabela };
const EliVue: Plugin = {
install(app: App) {
@ -21,6 +23,7 @@ const EliVue: Plugin = {
app.component("EliInput", EliInput);
app.component("EliCartao", EliCartao);
app.component("EliDataHora", EliDataHora);
app.component("EliTabela", EliTabela);
},
};

View file

@ -8,6 +8,7 @@
<v-tab value="cartao">Cartão</v-tab>
<v-tab value="campo">Campo</v-tab>
<v-tab value="data_hora">Data e hora</v-tab>
<v-tab value="tabela">Tabela</v-tab>
<v-tab value="ola_mundo">Demo</v-tab>
</v-tabs>
@ -18,6 +19,7 @@
<CartaoPlayground v-else-if="aba === 'cartao'" />
<CampoPlayground v-else-if="aba === 'campo'" />
<DataHoraPlayground v-else-if="aba === 'data_hora'" />
<TabelaPlayground v-else-if="aba === 'tabela'" />
<OlaMundoPlayground v-else />
</v-container>
</template>
@ -29,6 +31,7 @@ import IndicadorPlayground from "./indicador.playground.vue";
import CartaoPlayground from "./cartao.playground.vue";
import CampoPlayground from "./campo.playground.vue";
import DataHoraPlayground from "./data_hora.playground.vue";
import TabelaPlayground from "./tabela.playground.vue";
import OlaMundoPlayground from "./ola_mundo.playground.vue";
export default defineComponent({
@ -39,6 +42,7 @@ export default defineComponent({
CartaoPlayground,
CampoPlayground,
DataHoraPlayground,
TabelaPlayground,
OlaMundoPlayground,
},
data() {
@ -49,6 +53,7 @@ export default defineComponent({
| "cartao"
| "campo"
| "data_hora"
| "tabela"
| "ola_mundo",
};
}

View file

@ -0,0 +1,131 @@
<template>
<section class="stack">
<h2>EliTabela</h2>
<EliTabela :tabela="tabelaOk" />
<EliTabela :tabela="tabelaVazia" />
<EliTabela :tabela="tabelaErro" />
</section>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { codigosResposta } from "p-respostas";
import { Eye, Trash2 } from "lucide-vue-next";
import { EliTabela } from "@/components/eli/EliTabela";
import type { EliTabelaConsulta } from "@/components/eli/EliTabela";
type Linha = {
nome: string;
email: string;
};
export default defineComponent({
name: "TabelaPlayground",
components: { EliTabela },
setup() {
const acoesTabela: EliTabelaConsulta<Linha>["acoes"] = [
{
icone: Eye,
cor: "#2563eb",
rotulo: "Detalhes",
acao: (linha) => {
console.log("Visualizar detalhes de", linha.nome);
},
},
{
icone: Trash2,
cor: "#dc2626",
rotulo: "Remover",
acao: (linha) => {
console.log("Remover registro de", linha.nome);
},
exibir: async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
return true;
},
},
];
const tabelaOk: EliTabelaConsulta<Linha> = {
colunas: [
{
rotulo: "Nome",
celula: (l) => l.nome,
},
{
rotulo: "E-mail",
celula: (l) => l.email,
acao: () => {
// Exemplo de ação: poderia abrir detalhes
console.log("clicou na coluna e-mail");
},
},
],
acoes: acoesTabela,
resposta: async () => {
return {
cod: codigosResposta.sucesso,
eCerto: true,
eErro: false,
mensagem: undefined,
valor: {
quantidade: 2,
valores: [
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
],
},
};
},
};
const tabelaVazia: EliTabelaConsulta<Linha> = {
colunas: tabelaOk.colunas,
resposta: async () => {
return {
cod: codigosResposta.sucesso,
eCerto: true,
eErro: false,
mensagem: undefined,
valor: {
quantidade: 0,
valores: [],
},
};
},
mensagemVazio: "Nada para mostrar aqui.",
acoes: acoesTabela,
};
const tabelaErro: EliTabelaConsulta<Linha> = {
colunas: tabelaOk.colunas,
acoes: acoesTabela,
resposta: async () => {
return {
cod: codigosResposta.erroConhecido,
eCerto: false,
eErro: true,
mensagem: "Falha ao buscar dados",
valor: undefined,
};
},
};
return { tabelaOk, tabelaVazia, tabelaErro };
},
});
</script>
<style scoped>
.stack {
display: grid;
gap: 16px;
}
.stack :deep(.eli-tabela) {
max-width: 900px;
}
</style>