bkp
This commit is contained in:
parent
8bb5aea15e
commit
24c07da6f8
17 changed files with 1458 additions and 371 deletions
616
src/components/eli/EliTabela/EliTabela.vue
Normal file
616
src/components/eli/EliTabela/EliTabela.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue