Actions
Padrão #31
openAjustar diretiva Popover Para Funcionar com campos Select
Start date:
07/09/2025
Due date:
% Done:
0%
Estimated time:
Description
Ajustar diretiva do popover para funcionar com campos select, ao selecionar um campo, aparece o ícone do popover
Updated by jean sodré 6 months ago · Edited
- Status changed from em execução to teste
Segue o código completo da diretiva do popover:
.directive('contextualPopover', function($document, $window, $timeout) {
return {
restrict: 'E',
transclude: true,
scope: {
title: '@',
show: '=',
trigger: '@',
width: '@',
iconTarget: '@',
obrigatorio: '@',
watchModel: '=?', // Modelo a ser observado
triggerValue: '@?', // Valor que dispara o popover
triggerValues: '=?', // Array de valores que disparam o popover
customSelectTarget: '@?' // ID do custom-select alvo
},
template: `
<div class="popover-container {{getObrigatorioClass()}}"
ng-show="show"
ng-style="getPopoverStyle()"
ng-click="preventClose($event)"
ng-mouseenter="onMouseEnterPopover()"
ng-mouseleave="onMouseLeavePopover()">
<div class="popover-content">
<div class="popover-tag {{getTagClass()}}">
{{getTag()}}
</div>
<div class="popover-body" ng-transclude></div>
<div class="popover-footer footer-saved">
<span style="color: #bdbdbd">
Salvamento Automático
</span>
<span class="flex align-items">
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#0A8754"><path d="m429-336 238-237-51-51-187 186-85-84-51 51 136 135Zm51 240q-79 0-149-30t-122.5-82.5Q156-261 126-331T96-480q0-80 30-149.5t82.5-122Q261-804 331-834t149-30q80 0 149.5 30t122 82.5Q804-699 834-629.5T864-480q0 79-30 149t-82.5 122.5Q699-156 629.5-126T480-96Zm0-72q130 0 221-91t91-221q0-130-91-221t-221-91q-130 0-221 91t-91 221q0 130 91 221t221 91Zm0-312Z"/></svg>
Salvo
</span>
</div>
</div>
</div>
`,
link: function(scope, element) {
// VARIÁVEIS DE CONTROLE
let triggerElement = null;
let editIcon = null;
let isMouseOverPopover = false;
let mouseOutTimeout = null;
let scrollListeners = [];
let scrollDebounceTimeout = null;
let scrollPollingInterval = null;
let lastScrollPositions = {};
let initialPopoverPosition = null;
let currentMousePosition = { x: 0, y: 0 };
let customSelectElement = null; //
let isCustomSelectMode = false; //
// CONFIGURAÇÕES
const MARGIN = 10;
const MOUSE_OUT_DELAY = 15000;
const PROXIMITY_THRESHOLD = 2;
// UTILITÁRIOS DE POPOVER
scope.preventClose = function(event) {
event.stopPropagation();
};
scope.onMouseEnterPopover = function() {
clearTimeout(mouseOutTimeout);
isMouseOverPopover = true;
console.log('Mouse sobre popover - cancelando timer de fechamento');
};
scope.onMouseLeavePopover = function() {
isMouseOverPopover = false;
console.log('Mouse saiu do popover - iniciando timer de 15s');
startMouseOutTimer();
};
// CONTROLE DE PROXIMIDADE DO MOUSE
function updateMousePosition(event) {
currentMousePosition = {
x: event.clientX,
y: event.clientY
};
}
function calculateDistance(pos1, pos2) {
const deltaX = pos1.x - pos2.x;
const deltaY = pos1.y - pos2.y;
return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
}
function isMouseNearInitialPosition() {
if (!initialPopoverPosition) return false;
const distance = calculateDistance(currentMousePosition, initialPopoverPosition);
console.log('Distância do mouse ao ponto inicial:', Math.round(distance), 'px');
return distance <= PROXIMITY_THRESHOLD;
}
function shouldPreventScrollClose() {
const mouseOverPopover = isMouseOverPopover;
const nearInitialPosition = isMouseNearInitialPosition();
console.log('Mouse sobre popover:', mouseOverPopover, '| Próximo ao inicial:', nearInitialPosition);
return mouseOverPopover && nearInitialPosition;
}
scope.getPopoverStyle = function() {
let style = scope.position || {};
if (scope.width) {
style.width = scope.width;
}
return style;
};
scope.getObrigatorioClass = function() {
return scope.obrigatorio === "true" ? "obrigatorio-popover" : "opcional-popover";
};
scope.getTag = function() {
return scope.obrigatorio === "true" ? "obrigatório" : "opcional";
};
scope.getTagClass = function() {
return scope.obrigatorio === "true" ? "obrigatorio-tag" : "opcional-tag";
};
function startMouseOutTimer() {
clearTimeout(mouseOutTimeout);
if (isMouseOverPopover) {
console.log('Mouse ainda sobre popover - não iniciando timer');
return;
}
console.log('Iniciando timer de mouse fora: 15s');
mouseOutTimeout = setTimeout(function() {
if (scope.show && !isMouseOverPopover) {
console.log('💤 Fechando popover por mouse fora (15s)');
scope.$apply(function() {
scope.show = false;
});
}
}, MOUSE_OUT_DELAY);
}
// DETECÇÃO DE SCROLL UNIVERSAL
function handleScroll(event) {
if (scrollDebounceTimeout) {
clearTimeout(scrollDebounceTimeout);
}
scrollDebounceTimeout = setTimeout(function() {
if (!scope.show) {
return;
}
// Verifica se o scroll foi dentro do popover
const popoverContainer = element[0].querySelector('.popover-container');
const isScrollInsidePopover = popoverContainer && (
popoverContainer.contains(event.target) ||
popoverContainer === event.target
);
if (isScrollInsidePopover) {
return;
}
if (shouldPreventScrollClose()) {
return;
}
try {
scope.$apply(function() {
scope.show = false;
});
} catch (error) {
scope.show = false;
}
}, 1);
}
// POLLING DE SCROLL COMO FALLBACK
function startScrollPolling() {
if (scrollPollingInterval) {
clearInterval(scrollPollingInterval);
}
// Armazena posições iniciais
lastScrollPositions = {
window: $window.pageYOffset || 0,
documentElement: ($document[0].documentElement && $document[0].documentElement.scrollTop) || 0,
body: ($document[0].body && $document[0].body.scrollTop) || 0
};
scrollPollingInterval = setInterval(function() {
if (!scope.show) {
clearInterval(scrollPollingInterval);
scrollPollingInterval = null;
return;
}
const currentPositions = {
window: $window.pageYOffset || 0,
documentElement: ($document[0].documentElement && $document[0].documentElement.scrollTop) || 0,
body: ($document[0].body && $document[0].body.scrollTop) || 0
};
// Verifica se houve mudança em qualquer posição
let scrollDetected = false;
for (let key in currentPositions) {
const diff = Math.abs(currentPositions[key] - lastScrollPositions[key]);
if (diff > 1) { // Threshold de 1px
scrollDetected = true;
console.log('Polling detectou scroll em:', key);
break;
}
}
if (scrollDetected) {
// Verifica proximidade do mouse antes de fechar
if (shouldPreventScrollClose()) {
console.log('Polling: Mouse sobre popover e próximo - mantendo aberto');
lastScrollPositions = currentPositions; // Atualiza posições para próxima verificação
return;
}
console.log('Fechando popover por polling');
try {
scope.$apply(function() {
scope.show = false;
});
} catch (error) {
scope.show = false;
}
}
lastScrollPositions = currentPositions;
}, 50); // Verifica a cada 200ms
}
// ADICIONAR LISTENERS DE SCROLL
function addScrollListeners() {
console.log('Configurando listeners de scroll');
// Elementos para monitorar
const elements = [window, document, document.documentElement, document.body];
elements.forEach(function(element, index) {
if (element && element.addEventListener) {
element.addEventListener('scroll', handleScroll, {
passive: true,
capture: true
});
scrollListeners.push({
element: element,
handler: handleScroll
});
}
});
// Listener adicional no documento
if ($document[0] && $document[0].addEventListener) {
$document[0].addEventListener('scroll', handleScroll, {
passive: true,
capture: true
});
scrollListeners.push({
element: $document[0],
handler: handleScroll
});
}
// Adiciona listener para rastrear posição do mouse
document.addEventListener('mousemove', updateMousePosition, { passive: true });
scrollListeners.push({
element: document,
handler: updateMousePosition,
type: 'mousemove'
});
// Inicia polling como fallback
startScrollPolling();
}
// REMOVER LISTENERS DE SCROLL
function removeScrollListeners() {
if (scrollDebounceTimeout) {
clearTimeout(scrollDebounceTimeout);
scrollDebounceTimeout = null;
}
if (scrollPollingInterval) {
clearInterval(scrollPollingInterval);
scrollPollingInterval = null;
}
scrollListeners.forEach(function(listener, index) {
if (listener.element && listener.element.removeEventListener) {
const eventType = listener.type || 'scroll';
listener.element.removeEventListener(eventType, listener.handler, { capture: true });
}
});
scrollListeners = [];
}
// FUNÇÃO PARA LIMPAR TODOS OS TIMERS
function clearAllTimers() {
if (mouseOutTimeout) {
clearTimeout(mouseOutTimeout);
mouseOutTimeout = null;
}
if (scrollDebounceTimeout) {
clearTimeout(scrollDebounceTimeout);
scrollDebounceTimeout = null;
}
if (scrollPollingInterval) {
clearInterval(scrollPollingInterval);
scrollPollingInterval = null;
}
}
// EVENTOS DE TECLADO (ESC)
function handleKeyDown(event) {
if (!scope.show) return;
if (event.keyCode === 27 || event.key === 'Escape') {
scope.$apply(function() {
scope.show = false;
});
event.preventDefault();
event.stopPropagation();
}
}
function handleClickOutside(event) {
if (!scope.show) return;
// Verifica se tem custom-select aberto
if (document.body.hasAttribute('data-custom-select-open')) {
console.log('Custom-select aberto - não fechando popover');
return;
}
const popoverContainer = element[0].querySelector('.popover-container');
// Verifica se clicou dentro do popover
if (popoverContainer && popoverContainer.contains(event.target)) {
console.log('Clique dentro do popover - não fechando');
return;
}
// Verifica se clicou no trigger
if (triggerElement && triggerElement.contains(event.target)) {
console.log('Clique no trigger - não fechando');
return;
}
// Verifica se clicou no ícone de edição
if (editIcon && editIcon.contains(event.target)) {
console.log('Clique no ícone - não fechando');
return;
}
// Verifica se clicou em elementos de custom-select
const customSelectClasses = [
'custom-select-wrapper', 'select-header', 'select-options-body', 'option', 'arrow-icon'
];
for (let className of customSelectClasses) {
if (event.target.classList && event.target.classList.contains(className)) {
console.log('Clique em custom-select - não fechando');
return;
}
}
if (event.target.closest && (
event.target.closest('.custom-select-wrapper') ||
event.target.closest('.select-options-body')
)) {
console.log('Clique em elemento filho de custom-select - não fechando');
return;
}
scope.$apply(function() {
scope.show = false;
});
}
// CRIAÇÃO E CONTROLE DO ÍCONE DE EDIÇÃO
function createEditIcon() {
if (editIcon) return;
editIcon = document.createElement('span');
editIcon.className = 'popover-edit-icon';
if (scope.obrigatorio === "true") {
editIcon.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
`;
} else {
editIcon.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px"
fill="#1f1f1f"><path d="M444-144v-300H144v-72h300v-300h72v300h300v72H516v300h-72Z"/>
</svg>
`;
}
editIcon.title = 'Editar';
if (isCustomSelectMode) {
setIconEnabled(true);
} else {
setIconDisabled(true);
}
editIcon.addEventListener('click', function(e) {
e.stopPropagation();
e.preventDefault();
if (!editIcon.hasAttribute('data-disabled')) {
scope.$apply(function() {
scope.show = !scope.show;
if (scope.show) {
$timeout(updatePosition, 10);
}
});
}
});
return editIcon;
}
function setIconDisabled(disabled) {
if (!editIcon) return;
if (disabled) {
editIcon.setAttribute('data-disabled', 'true');
editIcon.title = 'Marque a opção para editar';
editIcon.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
cursor: not-allowed;
color: #ccc;
background: #f5f5f5;
border-radius: 3px;
border: 1px solid #e0e0e0;
margin-left: 8px;
transition: all 0.2s ease;
vertical-align: middle;
opacity: 0.6;
`;
} else {
setIconEnabled(true);
}
}
function setIconEnabled(enabled) {
if (!editIcon) return;
if (enabled) {
editIcon.removeAttribute('data-disabled');
editIcon.title = 'Editar';
editIcon.style.cssText = `
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
cursor: pointer;
color: #666;
background: #f8f9fa;
border-radius: 3px;
border: 1px solid #dee2e6;
margin-left: 8px;
transition: all 0.2s ease;
vertical-align: middle;
opacity: 1;
`;
// Remove eventos anteriores
editIcon.removeEventListener('mouseenter', iconMouseEnter);
editIcon.removeEventListener('mouseleave', iconMouseLeave);
// Adiciona novos eventos
editIcon.addEventListener('mouseenter', iconMouseEnter);
editIcon.addEventListener('mouseleave', iconMouseLeave);
}
}
function iconMouseEnter() {
if (!editIcon.hasAttribute('data-disabled')) {
editIcon.style.color = '#007bff';
editIcon.style.borderColor = '#007bff';
editIcon.style.backgroundColor = '#e3f2fd';
}
}
function iconMouseLeave() {
if (!editIcon.hasAttribute('data-disabled')) {
editIcon.style.color = '#666';
editIcon.style.borderColor = '#dee2e6';
editIcon.style.backgroundColor = '#f8f9fa';
}
}
function toggleIconVisibility(show) {
if (!editIcon) return;
editIcon.style.display = show ? 'inline-flex' : 'none';
if (show) {
setIconEnabled(true);
} else {
scope.show = false;
}
}
function positionEditIconForCustomSelect() {
if (!editIcon || !customSelectElement) return;
// Encontra o wrapper do custom-select
const wrapper = customSelectElement.querySelector('.custom-select-wrapper');
if (wrapper) {
if (wrapper.nextSibling) {
wrapper.parentNode.insertBefore(editIcon, wrapper.nextSibling);
} else {
wrapper.parentNode.appendChild(editIcon);
}
}
}
function positionEditIcon() {
if (!editIcon) return;
if (isCustomSelectMode) {
positionEditIconForCustomSelect();
return;
}
// Lógica original para trigger normal
let targetElement = null;
if (scope.iconTarget) {
targetElement = document.getElementById(scope.iconTarget);
}
if (!targetElement && triggerElement) {
if (triggerElement.id) {
targetElement = document.querySelector(`label[for="${triggerElement.id}"]`);
}
if (!targetElement) {
targetElement = triggerElement;
}
}
if (targetElement) {
if (targetElement.nextSibling) {
targetElement.parentNode.insertBefore(editIcon, targetElement.nextSibling);
} else {
targetElement.parentNode.appendChild(editIcon);
}
}
}
function showEditIcon() {
if (!editIcon) {
createEditIcon();
}
positionEditIcon();
editIcon.style.display = 'inline-flex';
}
function removeEditIcon() {
if (editIcon && editIcon.parentNode) {
editIcon.parentNode.removeChild(editIcon);
editIcon = null;
}
}
function calculateBestPosition(triggerRect, popoverRect) {
const viewportWidth = $window.innerWidth;
const viewportHeight = $window.innerHeight;
const popoverWidth = scope.width ? parseInt(scope.width) : popoverRect.width;
const popoverHeight = popoverRect.height;
const spaceAbove = triggerRect.top;
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceLeft = triggerRect.left;
const spaceRight = viewportWidth - triggerRect.right;
let position = {
position: 'fixed',
zIndex: '9999'
};
// Posição vertical
if (spaceBelow >= popoverHeight + MARGIN) {
position.top = triggerRect.bottom + MARGIN + 'px';
} else if (spaceAbove >= popoverHeight + MARGIN) {
position.top = (triggerRect.top - popoverHeight - MARGIN) + 'px';
} else {
position.top = Math.max(MARGIN, (viewportHeight - popoverHeight) / 2) + 'px';
}
// Posição horizontal
if (spaceRight >= popoverWidth + MARGIN) {
position.left = triggerRect.right + MARGIN + 'px';
} else if (spaceLeft >= popoverWidth + MARGIN) {
position.left = (triggerRect.left - popoverWidth - MARGIN) + 'px';
} else {
position.left = Math.max(MARGIN, (viewportWidth - popoverWidth) / 2) + 'px';
}
return position;
}
function updatePosition() {
let referenceElement = null;
if (isCustomSelectMode && customSelectElement) {
referenceElement = customSelectElement.querySelector('.custom-select-wrapper');
} else if (triggerElement) {
referenceElement = triggerElement;
}
if (!referenceElement) return;
const triggerRect = referenceElement.getBoundingClientRect();
const popoverElement = element[0].querySelector('.popover-container');
if (!popoverElement) return;
const popoverRect = popoverElement.getBoundingClientRect();
scope.position = calculateBestPosition(triggerRect, popoverRect);
if (!scope.$$phase) {
scope.$apply();
}
}
function shouldShowIcon(value) {
if (!value) return false;
// Verifica se o valor está na lista de valores que disparam
if (scope.triggerValues && Array.isArray(scope.triggerValues)) {
return scope.triggerValues.includes(value);
}
// Verifica se é o valor específico que dispara
if (scope.triggerValue) {
return value === scope.triggerValue;
}
return false;
}
scope.$watch('watchModel', function(newVal, oldVal) {
if (!isCustomSelectMode) return;
console.log('Modelo do custom-select mudou:', oldVal, '->', newVal);
if (shouldShowIcon(newVal)) {
console.log('Valor dispara popover - mostrando ícone');
toggleIconVisibility(true);
} else {
console.log('Valor não dispara popover - ocultando ícone');
toggleIconVisibility(false);
}
});
// Watcher original para trigger
scope.$watch('trigger', function(newVal) {
if (newVal && !isCustomSelectMode) {
triggerElement = document.getElementById(newVal);
if (triggerElement) {
triggerElement.addEventListener('change', function() {
scope.$apply(function() {
if (triggerElement.checked) {
setIconDisabled(false);
} else {
scope.show = false;
setIconDisabled(true);
}
});
});
$timeout(function() {
showEditIcon();
setIconDisabled(!triggerElement.checked);
}, 50);
}
}
});
scope.$watch('customSelectTarget', function(newVal) {
if (newVal) {
isCustomSelectMode = true;
customSelectElement = document.getElementById(newVal);
if (customSelectElement) {
$timeout(function() {
createEditIcon();
positionEditIconForCustomSelect();
// Verifica valor inicial
if (shouldShowIcon(scope.watchModel)) {
toggleIconVisibility(true);
} else {
toggleIconVisibility(false);
}
}, 100);
}
}
});
scope.$watch('show', function(newVal) {
if (newVal) {
// Reset controles
isMouseOverPopover = false;
// Armazena posição inicial
let referenceElement = null;
if (isCustomSelectMode && customSelectElement) {
referenceElement = customSelectElement.querySelector('.custom-select-wrapper');
} else if (triggerElement) {
referenceElement = triggerElement;
}
if (referenceElement) {
const triggerRect = referenceElement.getBoundingClientRect();
initialPopoverPosition = {
x: triggerRect.left + triggerRect.width / 2,
y: triggerRect.top + triggerRect.height / 2
};
}
// Adiciona listeners de scroll
addScrollListeners();
// Inicia timer de mouse fora
if (!isMouseOverPopover) {
startMouseOutTimer();
}
// Atualiza posição
$timeout(updatePosition, 10);
} else {
console.log('🎯 Fechando popover');
clearAllTimers();
removeScrollListeners();
initialPopoverPosition = null; // Reset da posição inicial
}
});
scope.$watch('width', function(newVal, oldVal) {
if (newVal !== oldVal && scope.show) {
$timeout(updatePosition, 10);
}
});
// EVENTOS GLOBAIS
$document.on('click', handleClickOutside);
$document.on('keydown', handleKeyDown);
angular.element($window).on('resize', function() {
if (scope.show) {
$timeout(updatePosition, 10);
}
});
// CLEANUP
scope.$on('$destroy', function() {
clearAllTimers();
removeScrollListeners();
removeEditIcon();
if (triggerElement) {
triggerElement.removeEventListener('change');
}
$document.off('click', handleClickOutside);
$document.off('keydown', handleKeyDown);
angular.element($window).off('resize');
});
}
};
})
As partes adicionadas para possibilitar a seleção em um select deixei comentada para documentação e consulta futura
Updated by jean sodré 6 months ago
- Copied to Padrão #33: Checkbox diretiva hover e click added
Updated by jean sodré 6 months ago
- Copied to Padrão #34: Ajustar diretiva de select para permitir desabilitar opções, dependendo da seleção do usuário added
Actions