This commit is contained in:
Luiz Silva 2026-01-27 13:03:42 -03:00
parent 052337b9da
commit c4a0d31686
3 changed files with 569 additions and 194 deletions

View file

@ -4,7 +4,7 @@
</template>
<script lang="ts">
import { defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, watch } from "vue";
import { computed, 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";
@ -30,8 +30,29 @@ export default defineComponent({
const acoesVisiveis = ref<boolean[][]>([]);
const menuAberto = ref<number | null>(null);
const menuElementos = new Map<number, HTMLElement>();
const paginaAtual = ref(1);
const colunaOrdenacao = ref<string | null>(null);
const direcaoOrdenacao = ref<"asc" | "desc">("asc");
const registrosPorConsulta = computed(() => {
const valor = props.tabela.registros_por_consulta;
if (typeof valor === "number" && valor > 0) {
return Math.floor(valor);
}
return 10;
});
const totalPaginas = computed(() => {
const limite = registrosPorConsulta.value;
if (!limite || limite <= 0) {
return 1;
}
const total = quantidade.value;
if (!total) {
return 1;
}
return Math.max(1, Math.ceil(total / limite));
});
let carregamentoSequencial = 0;
function registrarMenuElemento(indice: number, elemento: HTMLElement | null) {
@ -60,12 +81,91 @@ export default defineComponent({
if (colunaOrdenacao.value === chave) {
direcaoOrdenacao.value =
direcaoOrdenacao.value === "asc" ? "desc" : "asc";
void carregar();
} else {
colunaOrdenacao.value = chave;
direcaoOrdenacao.value = "asc";
if (paginaAtual.value !== 1) {
paginaAtual.value = 1;
} else {
void carregar();
}
}
}
function paginaAnterior() {
if (paginaAtual.value > 1) {
paginaAtual.value -= 1;
}
}
function proximaPagina() {
if (paginaAtual.value < totalPaginas.value) {
paginaAtual.value += 1;
}
}
function irParaPagina(pagina: number) {
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value);
if (alvo !== paginaAtual.value) {
paginaAtual.value = alvo;
}
}
function rangePaginacao(maximoBotoes = 7) {
const total = totalPaginas.value;
const atual = paginaAtual.value;
const resultado: Array<{ label: string; pagina?: number; ativo?: boolean; disabled?: boolean }> = [];
if (total <= maximoBotoes) {
for (let pagina = 1; pagina <= total; pagina += 1) {
resultado.push({
label: String(pagina),
pagina,
ativo: pagina === atual,
});
}
return resultado;
}
void carregar();
const adicionarPagina = (pagina: number) => {
resultado.push({
label: String(pagina),
pagina,
ativo: pagina === atual,
});
};
const adicionarEllipsis = () => {
resultado.push({ label: "…", disabled: true });
};
const visiveis = Math.max(3, maximoBotoes - 2);
let inicio = Math.max(2, atual - Math.floor(visiveis / 2));
let fim = inicio + visiveis - 1;
if (fim >= total) {
fim = total - 1;
inicio = fim - visiveis + 1;
}
adicionarPagina(1);
if (inicio > 2) {
adicionarEllipsis();
}
for (let pagina = inicio; pagina <= fim; pagina += 1) {
adicionarPagina(pagina);
}
if (fim < total - 1) {
adicionarEllipsis();
}
adicionarPagina(total);
return resultado;
}
function handleClickFora(evento: MouseEvent) {
@ -133,16 +233,27 @@ export default defineComponent({
menuAberto.value = null;
menuElementos.clear();
const parametrosOrdenacao = colunaOrdenacao.value
? {
coluna_ordem: colunaOrdenacao.value as never,
direcao_ordem: direcaoOrdenacao.value,
}
: undefined;
const limite = Math.max(1, registrosPorConsulta.value);
const offset = (paginaAtual.value - 1) * limite;
const parametrosConsulta: {
coluna_ordem?: never;
direcao_ordem?: "asc" | "desc";
offSet: number;
limit: number;
} = {
offSet: offset,
limit: limite,
};
if (colunaOrdenacao.value) {
parametrosConsulta.coluna_ordem = colunaOrdenacao.value as never;
parametrosConsulta.direcao_ordem = direcaoOrdenacao.value;
}
try {
const tabelaConfig = props.tabela;
const res = await tabelaConfig.consulta(parametrosOrdenacao);
const res = await tabelaConfig.consulta(parametrosConsulta);
if (idCarregamento !== carregamentoSequencial) {
return;
@ -161,6 +272,16 @@ export default defineComponent({
linhas.value = valores;
quantidade.value = total;
const totalPaginasRecalculado = Math.max(
1,
Math.ceil((total || 0) / limite)
);
if (paginaAtual.value > totalPaginasRecalculado) {
paginaAtual.value = totalPaginasRecalculado;
return;
}
const acoes = tabelaConfig.acoes ?? [];
if (!acoes.length) {
@ -230,6 +351,12 @@ export default defineComponent({
void carregar();
});
watch(paginaAtual, (nova, antiga) => {
if (nova !== antiga) {
void carregar();
}
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickFora);
menuElementos.clear();
@ -241,8 +368,22 @@ export default defineComponent({
menuElementos.clear();
colunaOrdenacao.value = null;
direcaoOrdenacao.value = "asc";
// Caso a definição de tabela/consulta mude
void carregar();
if (paginaAtual.value !== 1) {
paginaAtual.value = 1;
} else {
void carregar();
}
}
);
watch(
() => props.tabela.registros_por_consulta,
() => {
if (paginaAtual.value !== 1) {
paginaAtual.value = 1;
} else {
void carregar();
}
}
);
@ -334,190 +475,265 @@ export default defineComponent({
);
}
const podeVoltar = paginaAtual.value > 1;
const podeAvancar = paginaAtual.value < totalPaginas.value;
const botoesPaginacao = rangePaginacao();
const conteudoTabela = [
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
);
})
),
]),
];
if (totalPaginas.value > 1 && quantidade.value > 0) {
conteudoTabela.push(
h(
"nav",
{
class: "eli-tabela__paginacao",
role: "navigation",
"aria-label": "Paginação de resultados",
},
[
h(
"button",
{
type: "button",
class: "eli-tabela__pagina-botao",
onClick: paginaAnterior,
disabled: !podeVoltar,
"aria-label": "Página anterior",
},
"<<"
),
...botoesPaginacao.map((item, indice) =>
item.disabled || !item.pagina
? h(
"span",
{
key: `${item.label}-${indice}`,
class: "eli-tabela__pagina-ellipsis",
"aria-hidden": "true",
},
item.label
)
: h(
"button",
{
key: `${item.label}-${indice}`,
type: "button",
class: [
"eli-tabela__pagina-botao",
item.ativo
? "eli-tabela__pagina-botao--ativo"
: undefined,
],
disabled: item.ativo,
"aria-current": item.ativo ? "page" : undefined,
"aria-label": `Ir para página ${item.label}`,
onClick: () => {
if (item.pagina) {
irParaPagina(item.pagina);
}
},
},
item.label
)
),
h(
"button",
{
type: "button",
class: "eli-tabela__pagina-botao",
onClick: proximaPagina,
disabled: !podeAvancar,
"aria-label": "Próxima página",
},
">>"
),
]
)
);
}
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
);
})
),
]),
]
conteudoTabela
);
};
},
@ -602,6 +818,66 @@ export default defineComponent({
opacity: 0;
}
.eli-tabela__paginacao {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.eli-tabela__pagina-botao {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #ffffff;
font-size: 0.875rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.82);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__pagina-botao:hover,
.eli-tabela__pagina-botao:focus-visible {
background-color: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.4);
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__pagina-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__pagina-botao:disabled {
cursor: default;
opacity: 0.5;
background: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.75);
}
.eli-tabela__pagina-botao--ativo {
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.4);
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__pagina-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
color: rgba(107, 114, 128, 0.85);
font-size: 0.9rem;
}
.eli-tabela__tr:last-child .eli-tabela__td {
border-bottom: none;
}

View file

@ -32,9 +32,12 @@ export type EliTabelaAcao<T> = {
*/
export type EliTabelaConsulta<T> = {
colunas: EliColuna<T>[];
registros_por_consulta?: number;
consulta: (parametrosConsulta?: {
coluna_ordem?: keyof T;
direcao_ordem?: "asc" | "desc";
offSet?: number;
limit?: number;
}) => Promise<tipoResposta<EliConsultaPaginada<T>>>;
/** Mensagem exibida quando a consulta retorna ok porém sem dados. */
mensagemVazio?: string;

View file

@ -50,6 +50,84 @@ export default defineComponent({
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
];
const ordenarLinhas = (
@ -77,7 +155,22 @@ export default defineComponent({
});
};
const aplicarPaginacao = (
linhas: Linha[],
parametros?: { offSet?: number; limit?: number }
) => {
const offset = Math.max(0, parametros?.offSet ?? 0);
const limit = parametros?.limit ?? linhas.length;
if (limit === undefined || limit <= 0) {
return linhas.slice(offset);
}
return linhas.slice(offset, offset + limit);
};
const tabelaOk: EliTabelaConsulta<Linha> = {
registros_por_consulta: 2,
colunas: [
{
rotulo: "Nome",
@ -96,7 +189,8 @@ export default defineComponent({
],
acoes: acoesTabela,
consulta: async (parametrosConsulta) => {
const valores = ordenarLinhas(linhasPadrao, parametrosConsulta);
const ordenadas = ordenarLinhas(linhasPadrao, parametrosConsulta);
const valores = aplicarPaginacao(ordenadas, parametrosConsulta);
return {
cod: codigosResposta.sucesso,
@ -104,7 +198,7 @@ export default defineComponent({
eErro: false,
mensagem: undefined,
valor: {
quantidade: valores.length,
quantidade: linhasPadrao.length,
valores,
},
};
@ -112,8 +206,9 @@ export default defineComponent({
};
const tabelaVazia: EliTabelaConsulta<Linha> = {
registros_por_consulta: tabelaOk.registros_por_consulta,
colunas: tabelaOk.colunas,
consulta: async () => {
consulta: async (_parametrosConsulta) => {
return {
cod: codigosResposta.sucesso,
eCerto: true,
@ -130,9 +225,10 @@ export default defineComponent({
};
const tabelaErro: EliTabelaConsulta<Linha> = {
registros_por_consulta: tabelaOk.registros_por_consulta,
colunas: tabelaOk.colunas,
acoes: acoesTabela,
consulta: async () => {
consulta: async (_parametrosConsulta) => {
return {
cod: codigosResposta.erroConhecido,
eCerto: false,