This commit is contained in:
Luiz Silva 2026-01-28 09:44:18 -03:00
parent 933ba17ae8
commit 4cc8bb736d
6 changed files with 1129 additions and 977 deletions

2
dist/eli-vue.css vendored

File diff suppressed because one or more lines are too long

1753
dist/eli-vue.es.js vendored

File diff suppressed because it is too large Load diff

22
dist/eli-vue.umd.js vendored

File diff suppressed because one or more lines are too long

View file

@ -5,13 +5,141 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
type: PropType<EliTabelaConsulta<any>>; type: PropType<EliTabelaConsulta<any>>;
required: true; required: true;
}; };
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, { }>, {
[key: string]: any; tabela: import("vue").ComputedRef<EliTabelaConsulta<any>>;
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{ carregando: import("vue").Ref<boolean, boolean>;
erro: import("vue").Ref<string | null, string | null>;
linhas: import("vue").Ref<unknown[], unknown[]>;
quantidade: import("vue").Ref<number, number>;
menuAberto: import("vue").Ref<number | null, number | null>;
valorBusca: import("vue").Ref<string, string>;
paginaAtual: import("vue").Ref<number, number>;
colunaOrdenacao: import("vue").Ref<string | null, string | null>;
direcaoOrdenacao: import("vue").Ref<"desc" | "asc", "desc" | "asc">;
totalPaginas: import("vue").ComputedRef<number>;
exibirBusca: import("vue").ComputedRef<boolean>;
acoesCabecalho: import("vue").ComputedRef<{
icone?: import("lucide-vue-next").LucideIcon;
cor?: string;
rotulo: string;
acao: () => void;
}[]>;
temAcoesCabecalho: import("vue").ComputedRef<boolean>;
temAcoes: import("vue").ComputedRef<boolean>;
ArrowUp: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
ArrowDown: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
MoreVertical: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
isOrdenavel: (coluna: any) => boolean;
obterClasseAlinhamento: (alinhamento?: string) => "eli-tabela__celula--direita" | "eli-tabela__celula--centro" | "eli-tabela__celula--esquerda";
obterMaxWidth: (largura?: number | string) => string | undefined;
obterTooltipCelula: (celula: unknown) => any;
alternarOrdenacao: (chave?: string) => void;
atualizarBusca: (texto: string) => void;
irParaPagina: (pagina: number) => void;
registrarMenuElemento: (indice: number, elemento: HTMLElement | null) => void;
acoesDisponiveisPorLinha: (i: number) => {
acao: import("./types-eli-tabela").EliTabelaAcao<any>;
indice: number;
visivel: boolean;
}[];
possuiAcoes: (i: number) => boolean;
toggleMenu: (i: number) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
tabela: { tabela: {
type: PropType<EliTabelaConsulta<any>>; type: PropType<EliTabelaConsulta<any>>;
required: true; required: true;
}; };
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; }>> & Readonly<{}>, {}, {}, {
EliTabelaCaixaDeBusca: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
modelo: {
type: StringConstructor;
required: false;
default: string;
};
}>, {
texto: import("vue").Ref<string, string>;
emitirBusca: () => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
buscar(valor: string): boolean;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
modelo: {
type: StringConstructor;
required: false;
default: string;
};
}>> & Readonly<{
onBuscar?: ((valor: string) => any) | undefined;
}>, {
modelo: string;
}, {}, {
Search: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaPaginacao: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
pagina: {
type: NumberConstructor;
required: true;
};
totalPaginas: {
type: NumberConstructor;
required: true;
};
maximoBotoes: {
type: NumberConstructor;
required: false;
};
}>, {
botoes: import("vue").ComputedRef<{
label: string;
pagina?: number;
ativo?: boolean;
ehEllipsis?: boolean;
}[]>;
irParaPagina: (pagina: number | undefined) => void;
anteriorDesabilitado: import("vue").ComputedRef<boolean>;
proximaDesabilitada: import("vue").ComputedRef<boolean>;
paginaAtual: import("vue").ComputedRef<number>;
totalPaginasExibidas: import("vue").ComputedRef<number>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
alterar(pagina: number): boolean;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
pagina: {
type: NumberConstructor;
required: true;
};
totalPaginas: {
type: NumberConstructor;
required: true;
};
maximoBotoes: {
type: NumberConstructor;
required: false;
};
}>> & Readonly<{
onAlterar?: ((pagina: number) => any) | undefined;
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaCelula: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
celula: {
type: PropType<import("./types-eli-tabela").ComponenteCelula>;
required: true;
};
}>, {
Componente: import("vue").ComputedRef<import("vue").Component>;
dadosParaComponente: import("vue").ComputedRef<{
texto: string;
acao?: () => void;
} | {
numero: number;
acao?: () => void;
}>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
celula: {
type: PropType<import("./types-eli-tabela").ComponenteCelula>;
required: true;
};
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
ArrowUp: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
ArrowDown: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
MoreVertical: import("vue").FunctionalComponent<import("lucide-vue-next").LucideProps, {}, any, {}>;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
declare const _default: typeof __VLS_export; declare const _default: typeof __VLS_export;
export default _default; export default _default;

View file

@ -1,6 +1,6 @@
{ {
"name": "eli-vue", "name": "eli-vue",
"version": "0.1.37", "version": "0.1.39",
"private": false, "private": false,
"main": "./dist/eli-vue.umd.js", "main": "./dist/eli-vue.umd.js",
"module": "./dist/eli-vue.es.js", "module": "./dist/eli-vue.es.js",

View file

@ -1,5 +1,14 @@
<template> <template>
<div class="eli-tabela"> <div class="eli-tabela">
<div
v-if="isDev"
style="position: fixed; left: 8px; bottom: 8px; z-index: 999999; background: rgba(185,28,28,0.9); color: #fff; padding: 6px 10px; border-radius: 8px; font-size: 12px; max-width: 500px;"
>
<div><b>EliTabela debug</b></div>
<div>menuAberto: {{ menuAberto }}</div>
<div>menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}</div>
</div>
<div v-if="carregando" class="eli-tabela eli-tabela--carregando" aria-busy="true"> <div v-if="carregando" class="eli-tabela eli-tabela--carregando" aria-busy="true">
Carregando... Carregando...
</div> </div>
@ -147,54 +156,68 @@
:aria-controls="possuiAcoes(i) ? `eli-tabela-acoes-menu-${i}` : undefined" :aria-controls="possuiAcoes(i) ? `eli-tabela-acoes-menu-${i}` : undefined"
:aria-label="possuiAcoes(i) ? 'Ações da linha' : 'Nenhuma ação disponível'" :aria-label="possuiAcoes(i) ? 'Ações da linha' : 'Nenhuma ação disponível'"
:title="possuiAcoes(i) ? 'Ações' : 'Nenhuma ação disponível'" :title="possuiAcoes(i) ? 'Ações' : 'Nenhuma ação disponível'"
@click.stop="toggleMenu(i)" @click.stop="toggleMenu(i, $event)"
> >
<MoreVertical class="eli-tabela__acoes-toggle-icone" :size="18" :stroke-width="2" /> <MoreVertical class="eli-tabela__acoes-toggle-icone" :size="18" :stroke-width="2" />
</button> </button>
<ul
v-if="menuAberto === i && possuiAcoes(i)"
:id="`eli-tabela-acoes-menu-${i}`"
class="eli-tabela__acoes-menu"
role="menu"
:aria-labelledby="`eli-tabela-acoes-toggle-${i}`"
>
<li
v-for="item in acoesDisponiveisPorLinha(i)"
:key="`acao-${i}-${item.indice}`"
class="eli-tabela__acoes-item"
role="none"
>
<button
type="button"
class="eli-tabela__acoes-item-botao"
:style="{ color: item.acao.cor }"
role="menuitem"
:aria-label="item.acao.rotulo"
:title="item.acao.rotulo"
@click.stop="
() => {
menuAberto = null;
item.acao.acao(linha as never);
}
"
>
<component
:is="item.acao.icone"
class="eli-tabela__acoes-item-icone"
:size="16"
:stroke-width="2"
/>
<span class="eli-tabela__acoes-item-texto">{{ item.acao.rotulo }}</span>
</button>
</li>
</ul>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Menu teleportado para o body para evitar limitações de z-index/stacking do <table> -->
<Teleport to="body">
<ul
v-if="menuAberto !== null && possuiAcoes(menuAberto)"
:id="`eli-tabela-acoes-menu-${menuAberto}`"
ref="menuPopup"
class="eli-tabela__acoes-menu"
role="menu"
:aria-labelledby="`eli-tabela-acoes-toggle-${menuAberto}`"
:style="{
position: 'fixed',
top: `${menuPopupPos.top}px`,
left: `${menuPopupPos.left}px`,
zIndex: 999999,
}"
>
<li
v-for="item in acoesDisponiveisPorLinha(menuAberto)"
:key="`acao-${menuAberto}-${item.indice}`"
class="eli-tabela__acoes-item"
role="none"
>
<button
type="button"
class="eli-tabela__acoes-item-botao"
:style="{ color: item.acao.cor }"
role="menuitem"
:aria-label="item.acao.rotulo"
:title="item.acao.rotulo"
@click.stop="
() => {
const indice = menuAberto;
const linha = indice === null ? null : linhas[indice];
menuAberto = null;
if (linha !== null && linha !== undefined) {
item.acao.acao(linha as never);
}
}
"
>
<component
:is="item.acao.icone"
class="eli-tabela__acoes-item-icone"
:size="16"
:stroke-width="2"
/>
<span class="eli-tabela__acoes-item-texto">{{ item.acao.rotulo }}</span>
</button>
</li>
</ul>
</Teleport>
<EliTabelaPaginacao <EliTabelaPaginacao
v-if="totalPaginas > 1 && quantidade > 0" v-if="totalPaginas > 1 && quantidade > 0"
:pagina="paginaAtual" :pagina="paginaAtual"
@ -241,6 +264,7 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const isDev = import.meta.env.DEV;
const carregando = ref(false); const carregando = ref(false);
const erro = ref<string | null>(null); const erro = ref<string | null>(null);
const linhas = ref<unknown[]>([]); const linhas = ref<unknown[]>([]);
@ -248,7 +272,8 @@ export default defineComponent({
const acoesVisiveis = ref<boolean[][]>([]); const acoesVisiveis = ref<boolean[][]>([]);
const menuAberto = ref<number | null>(null); const menuAberto = ref<number | null>(null);
const menuElementos = new Map<number, HTMLElement>(); const menuPopup = ref<HTMLElement | null>(null);
const menuPopupPos = ref({ top: 0, left: 0 });
const valorBusca = ref<string>(""); const valorBusca = ref<string>("");
const paginaAtual = ref(1); const paginaAtual = ref(1);
@ -283,19 +308,38 @@ export default defineComponent({
let carregamentoSequencial = 0; let carregamentoSequencial = 0;
function registrarMenuElemento(indice: number, elemento: HTMLElement | null) { function atualizarPosicaoMenu(anchor: HTMLElement) {
if (elemento) { const rect = anchor.getBoundingClientRect();
menuElementos.set(indice, elemento); const gap = 8;
} else {
menuElementos.delete(indice); // Alinha no canto inferior direito do botão.
// Se estourar a tela para baixo, abre para cima.
const alturaMenu = menuPopup.value?.offsetHeight ?? 0;
const larguraMenu = menuPopup.value?.offsetWidth ?? 180;
let top = rect.bottom + gap;
const left = rect.right - larguraMenu;
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
top = rect.top - gap - alturaMenu;
} }
menuPopupPos.value = {
top: Math.max(gap, Math.round(top)),
left: Math.max(gap, Math.round(left)),
};
} }
function handleClickFora(evento: MouseEvent) { function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) return; if (menuAberto.value === null) return;
const container = menuElementos.get(menuAberto.value); const alvo = evento.target as Node;
if (container && container.contains(evento.target as Node)) return; if (menuPopup.value && menuPopup.value.contains(alvo)) return;
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value });
}
menuAberto.value = null; menuAberto.value = null;
} }
@ -394,9 +438,39 @@ export default defineComponent({
return acoesDisponiveisPorLinha(i).length > 0; return acoesDisponiveisPorLinha(i).length > 0;
} }
function toggleMenu(i: number) { function toggleMenu(i: number, evento?: MouseEvent) {
if (!possuiAcoes(i)) return; if (!possuiAcoes(i)) return;
menuAberto.value = menuAberto.value === i ? null : i;
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value });
}
if (menuAberto.value === i) {
menuAberto.value = null;
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => fechou", { i });
}
return;
}
menuAberto.value = i;
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => abriu", { i });
}
// posiciona assim que abrir
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null;
if (anchor) {
// primeiro posicionamento (antes do menu medir)
atualizarPosicaoMenu(anchor);
// reposiciona no próximo frame para pegar altura real
requestAnimationFrame(() => atualizarPosicaoMenu(anchor));
}
} }
async function carregar() { async function carregar() {
@ -405,7 +479,6 @@ export default defineComponent({
erro.value = null; erro.value = null;
acoesVisiveis.value = []; acoesVisiveis.value = [];
menuAberto.value = null; menuAberto.value = null;
menuElementos.clear();
const limite = Math.max(1, registrosPorConsulta.value); const limite = Math.max(1, registrosPorConsulta.value);
const offset = (paginaAtual.value - 1) * limite; const offset = (paginaAtual.value - 1) * limite;
@ -512,7 +585,6 @@ export default defineComponent({
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", handleClickFora); document.removeEventListener("click", handleClickFora);
menuElementos.clear();
}); });
watch( watch(
@ -537,7 +609,6 @@ export default defineComponent({
() => props.tabela, () => props.tabela,
() => { () => {
menuAberto.value = null; menuAberto.value = null;
menuElementos.clear();
colunaOrdenacao.value = null; colunaOrdenacao.value = null;
direcaoOrdenacao.value = "asc"; direcaoOrdenacao.value = "asc";
valorBusca.value = ""; valorBusca.value = "";
@ -562,11 +633,11 @@ export default defineComponent({
watch(linhas, () => { watch(linhas, () => {
menuAberto.value = null; menuAberto.value = null;
menuElementos.clear();
}); });
return { return {
// state // state
isDev,
tabela, tabela,
carregando, carregando,
erro, erro,
@ -600,10 +671,13 @@ export default defineComponent({
alternarOrdenacao, alternarOrdenacao,
atualizarBusca, atualizarBusca,
irParaPagina, irParaPagina,
registrarMenuElemento,
acoesDisponiveisPorLinha, acoesDisponiveisPorLinha,
possuiAcoes, possuiAcoes,
toggleMenu, toggleMenu,
// popup
menuPopup,
menuPopupPos,
}; };
}, },
}); });
@ -869,10 +943,11 @@ export default defineComponent({
} }
.eli-tabela__acoes-menu { .eli-tabela__acoes-menu {
position: absolute; /*
top: 100%; O menu é renderizado via Teleport e posicionado com `position: fixed`
margin-top: 8px; via style inline, para não sofrer com as limitações de stacking/z-index
right: 0; de elementos dentro de <table>.
*/
min-width: 180px; min-width: 180px;
padding: 6px 0; padding: 6px 0;
margin: 0; margin: 0;