diff --git a/frontend/src/components/Requirements.jsx b/frontend/src/components/Requirements.jsx index 0600b8bc..129b505c 100644 --- a/frontend/src/components/Requirements.jsx +++ b/frontend/src/components/Requirements.jsx @@ -5,8 +5,13 @@ import 'react-treeview/react-treeview.css'; import TreeView from 'react-treeview/lib/react-treeview.js'; const REQUIREMENTS_POPOVER_CLEANUP_KEY = '__tigerpathReqPopoverCleanup'; +const HEADER_POPOVER_CLEANUP_KEY = '__tigerpathHeaderPopoverCleanup'; const TREE_ITEM_CLICK_HANDLER_KEY = '__tigerpathTreeItemClickHandler'; +function escapeHref(url) { + return String(url).replace(/&/g, '&').replace(/"/g, '"'); +} + export default function Requirements({ onChange, requirements, schedule }) { const [loading, setLoading] = useState(false); const containerRef = useRef(null); @@ -52,7 +57,9 @@ export default function Requirements({ onChange, requirements, schedule }) { const Popover = window.bootstrap?.Popover; if (!Popover) return; - const reqLabels = containerRef.current.querySelectorAll('.reqLabel'); + const reqLabels = containerRef.current.querySelectorAll( + '.reqLabel:not(.reqLabel-main)' + ); reqLabels.forEach((reqLabel) => { const existingCleanup = reqLabel[REQUIREMENTS_POPOVER_CLEANUP_KEY]; if (typeof existingCleanup === 'function') { @@ -100,6 +107,48 @@ export default function Requirements({ onChange, requirements, schedule }) { }); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Main-req info icon: manual trigger + shared hover bridge so the popover stays + // open while moving the pointer onto links (Bootstrap hover trigger closes in the gap). + const addHeaderPopovers = useCallback(() => { + if (!containerRef.current) return; + const Popover = window.bootstrap?.Popover; + if (!Popover) return; + + const icons = containerRef.current.querySelectorAll('.info-icon'); + + icons.forEach((icon) => { + const existingCleanup = icon[HEADER_POPOVER_CLEANUP_KEY]; + if (typeof existingCleanup === 'function') { + existingCleanup(); + } + + const existing = Popover.getInstance(icon); + if (existing) existing.dispose(); + + const popoverInstance = new Popover(icon, { + trigger: 'manual', + html: true, + animation: true, + placement: 'left', + fallbackPlacements: ['left', 'top', 'bottom'], + boundary: 'viewport', + template: + '', + sanitize: false, + }); + + const cleanupHover = bindManualHoverPopover(icon, popoverInstance, { + hideDelayMs: 400, + }); + + icon[HEADER_POPOVER_CLEANUP_KEY] = () => { + cleanupHover(); + popoverInstance.dispose(); + delete icon[HEADER_POPOVER_CLEANUP_KEY]; + }; + }); + }, []); + const getReqCourses = (req_path) => { const searchQueryLabel = 'Satisfying: ' + req_path.split('//').pop(); onChange('searchQuery', searchQueryLabel); @@ -118,9 +167,10 @@ export default function Requirements({ onChange, requirements, schedule }) { requestAnimationFrame(() => { makeNodesClickable(); addReqPopovers(); + addHeaderPopovers(); }); } - }, [requirements, makeNodesClickable, addReqPopovers]); + }, [requirements, makeNodesClickable, addReqPopovers, addHeaderPopovers]); const toggleSettle = (course, pathTo, settle) => { let pathToType = pathTo.split('//', 3).join('//'); @@ -257,39 +307,27 @@ export default function Requirements({ onChange, requirements, schedule }) { ) { finished = 'req-done'; } - popoverContent = '
'; - if (mainReq.explanation) { - popoverContent += - '

' + mainReq.explanation.split('\n').join('
') + '

'; - } else if (mainReq.description) { - popoverContent += - '

' + mainReq.description.split('\n').join('
') + '

'; - } - if (mainReq.contacts) { - popoverContent += '
Contacts:
'; - mainReq.contacts.forEach((contact) => { + const urls = Array.isArray(mainReq.urls) + ? mainReq.urls.filter(Boolean) + : []; + popoverContent = '
'; + if (urls.length > 0) { + const show = urls.slice(0, 2); + const linkLabels = + show.length === 1 + ? ['Department page'] + : ['Department page', 'More information']; + show.forEach((url, i) => { popoverContent += - '

' + - contact.type + - ':
' + - contact.name + - '
' + - contact.email + - '

'; - }); - } - if (mainReq.urls) { - popoverContent += '
Reference Links:
'; - mainReq.urls.forEach((url) => { - popoverContent += - '

' + - url + + linkLabels[i] + '

'; }); + } else { + popoverContent += + '

No official program link is listed for this requirement.

'; } popoverContent += '
'; } else { @@ -317,14 +355,18 @@ export default function Requirements({ onChange, requirements, schedule }) { } let mainReqLabel = ( -
' + name + ''} - data-bs-content={popoverContent} - > - {name} +
+ {name} +
); + return ( { clearHideTimeout(); triggerElement.removeEventListener('mouseenter', onTriggerMouseEnter); triggerElement.removeEventListener('mouseleave', onTriggerMouseLeave); + triggerElement.removeEventListener('shown.bs.popover', onPopoverShown); triggerElement.removeEventListener('hidden.bs.popover', onPopoverHidden); detachPopoverListeners(); };