752 lines
20 KiB
Vue
752 lines
20 KiB
Vue
<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 { ArrowDown, ArrowUp, MoreVertical } from "lucide-vue-next";
|
|
import { codigosResposta } from "p-respostas";
|
|
import type { EliTabelaConsulta } from "./types-eli-tabela";
|
|
|
|
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>();
|
|
const colunaOrdenacao = ref<string | null>(null);
|
|
const direcaoOrdenacao = ref<"asc" | "desc">("asc");
|
|
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 alternarOrdenacao(chave?: string) {
|
|
if (!chave) {
|
|
return;
|
|
}
|
|
|
|
if (colunaOrdenacao.value === chave) {
|
|
direcaoOrdenacao.value =
|
|
direcaoOrdenacao.value === "asc" ? "desc" : "asc";
|
|
} else {
|
|
colunaOrdenacao.value = chave;
|
|
direcaoOrdenacao.value = "asc";
|
|
}
|
|
|
|
void carregar();
|
|
}
|
|
|
|
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();
|
|
|
|
const parametrosOrdenacao = colunaOrdenacao.value
|
|
? {
|
|
coluna_ordem: colunaOrdenacao.value as never,
|
|
direcao_ordem: direcaoOrdenacao.value,
|
|
}
|
|
: undefined;
|
|
|
|
try {
|
|
const tabelaConfig = props.tabela;
|
|
const res = await tabelaConfig.consulta(parametrosOrdenacao);
|
|
|
|
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 (acao.exibir === undefined) {
|
|
return true;
|
|
}
|
|
|
|
if (typeof acao.exibir === "boolean") {
|
|
return acao.exibir;
|
|
}
|
|
|
|
return false;
|
|
})
|
|
);
|
|
|
|
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();
|
|
colunaOrdenacao.value = null;
|
|
direcaoOrdenacao.value = "asc";
|
|
// 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) => {
|
|
const chaveOrdenacao =
|
|
coluna.coluna_ordem !== undefined
|
|
? (coluna.coluna_ordem as unknown as string)
|
|
: undefined;
|
|
const ordenavel = Boolean(chaveOrdenacao);
|
|
const ativa = ordenavel && colunaOrdenacao.value === chaveOrdenacao;
|
|
const iconeOrdenacao = ordenavel
|
|
? ativa
|
|
? h(direcaoOrdenacao.value === "asc" ? ArrowUp : ArrowDown, {
|
|
class: "eli-tabela__th-icone",
|
|
size: 16,
|
|
strokeWidth: 2,
|
|
"aria-hidden": "true",
|
|
})
|
|
: h(ArrowUp, {
|
|
class: "eli-tabela__th-icone eli-tabela__th-icone--oculto",
|
|
size: 16,
|
|
strokeWidth: 2,
|
|
"aria-hidden": "true",
|
|
})
|
|
: null;
|
|
|
|
const conteudo = ordenavel
|
|
? h(
|
|
"button",
|
|
{
|
|
type: "button",
|
|
class: [
|
|
"eli-tabela__th-botao",
|
|
ativa ? "eli-tabela__th-botao--ativo" : undefined,
|
|
],
|
|
onClick: () => alternarOrdenacao(chaveOrdenacao),
|
|
},
|
|
[
|
|
h("span", { class: "eli-tabela__th-texto" }, coluna.rotulo),
|
|
iconeOrdenacao,
|
|
]
|
|
)
|
|
: h("span", { class: "eli-tabela__th-label" }, coluna.rotulo);
|
|
|
|
return h(
|
|
"th",
|
|
{
|
|
class: [
|
|
"eli-tabela__th",
|
|
ordenavel ? "eli-tabela__th--ordenavel" : undefined,
|
|
],
|
|
scope: "col",
|
|
},
|
|
conteudo
|
|
);
|
|
});
|
|
|
|
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) => {
|
|
const fallbackVisivel =
|
|
acao.exibir === undefined
|
|
? true
|
|
: typeof acao.exibir === "boolean"
|
|
? acao.exibir
|
|
: false;
|
|
|
|
return {
|
|
acao,
|
|
indice,
|
|
visivel: visibilidade[indice] ?? fallbackVisivel,
|
|
};
|
|
})
|
|
.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__th--ordenavel {
|
|
padding: 0;
|
|
}
|
|
|
|
.eli-tabela__th--ordenavel .eli-tabela__th-botao {
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.eli-tabela__th-botao {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 8px;
|
|
width: 100%;
|
|
background: transparent;
|
|
border: none;
|
|
font: inherit;
|
|
color: inherit;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: color 0.2s ease;
|
|
}
|
|
|
|
.eli-tabela__th-botao:hover,
|
|
.eli-tabela__th-botao:focus-visible {
|
|
color: rgba(15, 23, 42, 0.85);
|
|
}
|
|
|
|
.eli-tabela__th-botao:focus-visible {
|
|
outline: 2px solid rgba(37, 99, 235, 0.45);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.eli-tabela__th-botao--ativo {
|
|
color: rgba(37, 99, 235, 0.95);
|
|
}
|
|
|
|
.eli-tabela__th-texto {
|
|
flex: 1;
|
|
min-width: 0;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.eli-tabela__th-icone {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.eli-tabela__th-icone--oculto {
|
|
opacity: 0;
|
|
}
|
|
|
|
.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>
|