1// ToolsModal.js - Modal with icons for exploring archive tools 2import { useEffect, useRef, useCallback } from 'react'; 3import { html } from '../html.js?v=3.8.36'; 4import { X, Compass, Map, BookOpen, HelpCircle, BarChart3, BookMarked, Monitor } from 'lucide-react'; 5import { resolveSitePath } from '../utils/pathResolver.js?v=3.8.36'; 6import { acquireBodyScrollLock } from '../services/bodyScrollLock.js?v=3.8.36'; 7 8// Tool definitions with categories 9const TOOLS = { 10 archive: [ 11 { 12 id: 'start', 13 name: 'Start here', 14 description: 'Choose a guided path into the archive', 15 icon: Compass, 16 action: 'start', 17 status: 'ready' 18 }, 19 { 20 id: 'desktop', 21 name: 'Archive desktop', 22 description: 'Archive desktop is still in development', 23 icon: Monitor, 24 action: 'desktop', 25 status: 'beta' 26 } 27 ], 28 dissertation: [ 29 { 30 id: 'mindmap', 31 name: 'Mind map', 32 description: 'Interactive dissertation structure', 33 icon: Map, 34 action: 'mindmap', // in-page action 35 status: 'ready' 36 }, 37 { 38 id: 'faq', 39 name: 'FAQ', 40 description: 'Archive & dissertation Q&A', 41 icon: HelpCircle, 42 href: 'faq/', 43 status: 'ready' 44 }, 45 { 46 id: 'reader', 47 name: 'Dissertation reader', 48 description: 'Read the full text', 49 icon: BookMarked, 50 href: 'dissertation/reader/', 51 status: 'ready' 52 } 53 ], 54 data: [ 55 { 56 id: 'dataviz', 57 name: 'Data visualization', 58 description: 'Charts and analysis', 59 icon: BarChart3, 60 href: 'tools/active/dataviz/dataviz.html', 61 status: 'beta' 62 } 63 ] 64}; 65 66const ToolsModal = ({ isOpen, onClose, onSelectTool }) => { 67 const modalRef = useRef(null); 68 const closeButtonRef = useRef(null); 69 const openerRef = useRef(null); 70 const backgroundStateRef = useRef([]); 71 72 // Move focus into the dialog, isolate the page behind it, then restore the 73 // exact opener when the dialog closes. 74 useEffect(() => { 75 if (!isOpen) return undefined; 76 77 openerRef.current = document.activeElement instanceof HTMLElement 78 ? document.activeElement 79 : null; 80 closeButtonRef.current?.focus(); 81 82 const backdrop = modalRef.current?.parentElement; 83 const backgroundElements = backdrop?.parentElement 84 ? Array.from(backdrop.parentElement.children).filter((element) => element !== backdrop) 85 : []; 86 87 backgroundStateRef.current = backgroundElements.map((element) => ({ 88 element, 89 ariaHidden: element.getAttribute('aria-hidden'), 90 inert: element.inert 91 })); 92 93 backgroundStateRef.current.forEach(({ element }) => { 94 element.inert = true; 95 element.setAttribute('aria-hidden', 'true'); 96 }); 97 98 return () => { 99 backgroundStateRef.current.forEach(({ element, ariaHidden, inert }) => { 100 element.inert = inert; 101 if (ariaHidden === null) { 102 element.removeAttribute('aria-hidden'); 103 } else { 104 element.setAttribute('aria-hidden', ariaHidden); 105 } 106 }); 107 backgroundStateRef.current = []; 108 109 if (openerRef.current?.isConnected) { 110 openerRef.current?.focus(); 111 } 112 openerRef.current = null; 113 }; 114 }, [isOpen]); 115 116 // Keep keyboard focus inside the modal and preserve Escape-to-close. 117 useEffect(() => { 118 const handleKeyDown = (e) => { 119 if (e.key === 'Escape' && isOpen) { 120 e.preventDefault(); 121 onClose(); 122 return; 123 } 124 125 if (e.key === 'Tab' && isOpen && modalRef.current) { 126 const focusableElements = Array.from(modalRef.current.querySelectorAll( 127 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' 128 )).filter((element) => element.getAttribute('aria-hidden') !== 'true' && element.offsetParent !== null); 129 130 if (focusableElements.length === 0) { 131 e.preventDefault(); 132 modalRef.current.focus(); 133 return; 134 } 135 136 const firstElement = focusableElements[0]; 137 const lastElement = focusableElements[focusableElements.length - 1]; 138 const focusIsOutside = !modalRef.current.contains(document.activeElement); 139 140 if (e.shiftKey && (document.activeElement === firstElement || focusIsOutside)) { 141 e.preventDefault(); 142 lastElement.focus(); 143 } else if (!e.shiftKey && (document.activeElement === lastElement || focusIsOutside)) { 144 e.preventDefault(); 145 firstElement.focus(); 146 } 147 } 148 }; 149 150 document.addEventListener('keydown', handleKeyDown); 151 return () => document.removeEventListener('keydown', handleKeyDown); 152 }, [isOpen, onClose]); 153 154 // Lock body scroll when open 155 useEffect(() => { 156 if (!isOpen) return undefined; 157 return acquireBodyScrollLock(); 158 }, [isOpen]); 159 160 const handleToolClick = useCallback((tool) => { 161 if (tool.action) { 162 // In-page action 163 onSelectTool(tool.action); 164 onClose(); 165 } else if (tool.href) { 166 // Site-root-relative path resolved for the current environment so the 167 // link works in local preview and GitHub Pages, not just production. 168 window.location.href = resolveSitePath(tool.href); 169 } 170 }, [onSelectTool, onClose]); 171 172 const handleBackdropClick = (e) => { 173 if (e.target === e.currentTarget) { 174 onClose(); 175 } 176 }; 177 178 if (!isOpen) return null; 179 180 const renderToolCard = (tool) => { 181 const IconComponent = tool.icon; 182 const toolDescriptionId = `tool-${tool.id}-description`; 183 const toolStatusId = `tool-${tool.id}-status`; 184 const cardDescriptionIds = tool.status === 'beta' 185 ? `${toolDescriptionId} ${toolStatusId}` 186 : toolDescriptionId; 187 188 return html` 189 <button 190 key=${tool.id} 191 aria-label=${tool.name} 192 aria-describedby=${cardDescriptionIds} 193 onClick=${() => handleToolClick(tool)} 194 className="archive-panel archive-tool-card group relative flex flex-col items-center p-4 sm:p-6" 195 > 196 ${tool.status === 'beta' && html`
197 <span id=${toolStatusId} className="archive-section-label absolute top-2 right-2"> 198 Beta 199 </span> 200 `} 201 202 <div className="w-12 h-12 flex items-center justify-center rounded-none bg-stone-100 group-hover:bg-stone-200 transition-colors mb-3"> 203 <${IconComponent} className="w-6 h-6 text-stone-600 group-hover:text-stone-800 transition-colors" aria-hidden="true" /> 204 </div> 205 206 <h3 className="font-display text-sm font-bold text-stone-800 mb-1 text-center"> 207 ${tool.name} 208 </h3> 209 210 <p id=${toolDescriptionId} className="text-xs text-stone-500 text-center leading-relaxed"> 211 ${tool.description} 212 </p> 213 </button> 214 `; 215 }; 216 217 return html` 218 <div 219 className="archive-tools-dialog-backdrop archive-dialog-backdrop z-[100]" 220 onClick=${handleBackdropClick} 221 role="dialog" 222 aria-modal="true" 223 aria-labelledby="tools-modal-title" 224 > 225 <div 226 ref=${modalRef} 227 className="archive-dialog archive-tools-dialog" 228 tabIndex="-1" 229 > 230 <div className="archive-tools-dialog__header sticky top-0 px-6 py-4 flex items-center justify-between z-10"> 231 <div> 232 <h2 id="tools-modal-title" className="font-display text-xl text-stone-800"> 233 Tools 234 </h2> 235 <p className="text-xs text-stone-500 mt-1"> 236 Choose a tool to explore the archive and dissertation 237 </p> 238 </div> 239 <button 240 ref=${closeButtonRef} 241 onClick=${onClose} 242 className="archive-action archive-action--quiet archive-tools-dialog__close" 243 aria-label="Close tools menu" 244 > 245 <${X} className="w-5 h-5" aria-hidden="true" /> 246 </button> 247 </div> 248 249 <div className="p-6 space-y-8"> 250 <section> 251 <h3 className="archive-tools-dialog__section-title"> 252 <${Compass} className="w-4 h-4" aria-hidden="true" /> 253 Explore the archive 254 </h3> 255 <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 gap-4"> 256 ${TOOLS.archive.map((tool) => renderToolCard(tool))} 257 </div> 258 </section> 259 260 <section> 261 <h3 className="archive-tools-dialog__section-title"> 262 <${BookOpen} className="w-4 h-4" aria-hidden="true" /> 263 Dissertation tools 264 </h3> 265 <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 gap-4"> 266 ${TOOLS.dissertation.map((tool) => renderToolCard(tool))} 267 </div> 268 </section> 269 270 <section> 271 <h3 className="archive-tools-dialog__section-title"> 272 <${BarChart3} className="w-4 h-4" aria-hidden="true" /> 273 Data tools 274 </h3> 275 <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 gap-4"> 276 ${TOOLS.data.map((tool) => renderToolCard(tool))} 277 </div> 278 </section> 279 </div> 280 281 <div className="archive-notice archive-tools-dialog__notice"> 282 <p className="text-xs text-stone-700 text-center"> 283 Tools marked <span className="archive-section-label">Beta</span> are still in development 284 </p> 285 </div> 286 </div> 287 </div> 288 `; 289}; 290 291export default ToolsModal;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.