PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/components/ToolsModal.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:30 UTC 9,454 bytes, 291 lines download raw bytes

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.