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

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>