PageSourceSearch

https://theophthalmologist.com/scripts/readOptionTabs.js

js theophthalmologist.com collected 2026-09-24 09:15:56 UTC 19,893 bytes, 483 lines download raw bytes

1// JavaScript Document
2class ActionBar {
3  /* Function Initialize Action Bar : START
4     Initializes one action bar instance, binds tab events, hydrates initial state, and sets up article overflow controls.
5     Function Initialize Action Bar : END */
6  constructor(root) {
7	this.root = root;
8	this.tabs = Array.from(root.querySelectorAll('.action-bar__tab, [role="tab"]'));
9	this.storageKey = this._getStorageKey();
10	this._onClick = this._onClick.bind(this);
11	this._onKeydown = this._onKeydown.bind(this);
12	this.root.addEventListener('click', this._onClick);
13	this.root.addEventListener('keydown', this._onKeydown);
14	this._hydrate();
15
16	this._onOverflowScroll = this._updateOverflowControls.bind(this);
17	this._onOverflowResize = this._updateOverflowControls.bind(this);
18	this._onPrevOverflowClick = () => this._scrollOverflowBy(-1);
19	this._onNextOverflowClick = () => this._scrollOverflowBy(1);
20
21	if (this.root.classList.contains('action-bar')) {
22	  // Wait one tick so any OverlayScrollbars init on the same page can complete first.
23	  setTimeout(() => this._setupOverflowControls(), 0);
24	}
25  }
26
27  /* Function Get Selectors : START
28     Reads a tab's target selector list from data-action-bar-target or href and supports comma-separated panel selectors.
29     Function Get Selectors : END */
30  _getSelectors(tab) {
31	const target = tab.getAttribute('data-action-bar-target');
32	if (target) {
33	  return target.split(',').map(s => s.trim()).filter(Boolean);
34	}
35
36	const href = tab.getAttribute('href');
37	if (!href || !href.startsWith('#')) return [];
38	return [href.trim()];
39  }
40
41  /* Function Get Tab Link : START
42     Finds the navigation link associated with a tab when the tab is an anchor or is nested inside an anchor.
43     Function Get Tab Link : END */
44  _getTabLink(tab) {
45	if (!tab) return null;
46	return tab.matches('a[href]') ? tab : tab.closest('a[href]');
47  }
48
49  /* Function Is Local Tab : START
50     Checks whether a tab controls panels on the current page instead of navigating to another page.
51     Function Is Local Tab : END */
52  _isLocalTab(tab) {
53	return this._getSelectors(tab).length > 0;
54  }
55
56  /* Function Is Disabled Tab : START
57     Checks disabled state for button tabs and aria-disabled state for anchor or button tabs.
58     Function Is Disabled Tab : END */
59  _isDisabledTab(tab) {
60	return Boolean(tab.disabled) || tab.getAttribute('aria-disabled') === 'true';
61  }
62
63  /* Function Get Panels : START
64     Finds all panels targeted by a tab and dedupes them so one tab can safely control multiple panels.
65     Function Get Panels : END */
66  _getPanels(tab) {
67	const sels = this._getSelectors(tab);
68	if (!sels.length) return [];
69	const seen = new Set();
70	const out = [];
71	sels.forEach(sel => {
72	  try {
73		document.querySelectorAll(sel).forEach(el => {
74		  if (!seen.has(el)) { seen.add(el); out.push(el); }
75		});
76	  } catch (_error) {
77		// Ignore invalid selectors so link-style tabs can still work normally.
78	  }
79	});
80	return out;
81  }
82
83  /* Function Get Storage Key : START
84     Builds the sessionStorage key used to remember the active tab for this page and action bar.
85     Function Get Storage Key : END */
86  _getStorageKey() {
87	const rootId = this.root.id || this.root.getAttribute('aria-label') || 'default';
88	return `action-bar:${window.location.pathname}:${rootId}`;
89  }
90
91  /* Function Get Tab Identifier : START
92     Gets the stable identifier used for storing or restoring a tab.
93     Function Get Tab Identifier : END */
94  _getTabIdentifier(tab) {
95	if (!tab) return null;
96	const link = this._getTabLink(tab);
97	return tab.id || this._getSelectors(tab)[0] || tab.getAttribute('href') || (link && link.getAttribute('href')) || null;
98  }
99
100  /* Function Match Tab Identifier : START
101     Checks whether a tab matches a stored identifier, hash, target selector, or href.
102     Function Match Tab Identifier : END */
103  _tabMatchesIdentifier(tab, identifier) {
104	if (!tab || !identifier) return false;
105	const normalizedId = identifier.startsWith('#') ? identifier : `#${identifier}`;
106	return tab.id === identifier ||
107	  this._getSelectors(tab).includes(identifier) ||
108	  this._getSelectors(tab).includes(normalizedId) ||
109	  tab.getAttribute('href') === identifier ||
110	  tab.getAttribute('href') === normalizedId ||
111	  (this._getTabLink(tab) && this._getTabLink(tab).getAttribute('href') === identifier) ||
112	  (this._getTabLink(tab) && this._getTabLink(tab).getAttribute('href') === normalizedId);
113  }
114
115  /* Function Persist Active Tab : START
116     Saves the currently active tab to sessionStorage when storage is available.
117     Function Persist Active Tab : END */
118  _persistActiveTab(tab) {
119	const identifier = this._getTabIdentifier(tab);
120	if (!identifier || !window.sessionStorage) return;
121	try {
122	  window.sessionStorage.setItem(this.storageKey, identifier);
123	} catch (_error) {
124	  // Ignore storage failures and keep the component functional.
125	}
126  }
127
128  /* Function Get Initial Active Tab : START
129     Chooses the initial active tab from the URL hash, saved session state, markup state, or the first tab.
130     Function Get Initial Active Tab : END */
131  _getInitialActiveTab() {
132	const hash = window.location.hash;
133	if (hash) {
134	  const hashMatch = this.tabs.find(tab => this._tabMatchesIdentifier(tab, hash));
135	  if (hashMatch) return hashMatch;
136	}
137
138	if (window.sessionStorage) {
139	  try {
140		const saved = window.sessionStorage.getItem(this.storageKey);
141		if (saved) {
142		  const savedMatch = this.tabs.find(tab => this._tabMatchesIdentifier(tab, saved));
143		  if (savedMatch) return savedMatch;
144		}
145	  } catch (_error) {
146		// Ignore storage failures and fall through to markup state.
147	  }
148	}
149
150	return this.tabs.find(tab => tab.getAttribute('aria-selected') === 'true') || this.tabs[0] || null;
151  }
152
153  /* Function Activate Tab : START
154     Activates a tab, shows its target panels, persists the selection, and scrolls it into view when needed.
155     Function Activate Tab : END */
156  _activate(tab) {
157	if (this._isDisabledTab(tab)) return;
158	this.tabs.forEach(t => this._deactivate(t));
159	const panels = this._getPanels(tab);
160	tab.setAttribute('aria-selected', 'true');
161	tab.removeAttribute('tabindex');
162	panels.forEach(panel => {
163	  panel.hidden = false;
164	  requestAnimationFrame(() => panel.classList.add('action-bar__panel--show'));
165	});
166	this._persistActiveTab(tab);
167	this._ensureTabVisible(tab);
168  }
169
170  /* Function Deactivate Tab : START
171     Deactivates a tab and hides every panel targeted by that tab.
172     Function Deactivate Tab : END */
173  _deactivate(tab) {
174	const panels = this._getPanels(tab);
175	tab.setAttribute('aria-selected', 'false');
176	tab.setAttribute('tabindex', '-1');
177	panels.forEach(panel => {
178	  panel.hidden = true;
179	  panel.classList.remove('action-bar__panel--show');
180	});
181  }
182
183  /* Function Select Tab : START
184     Updates the active tab state for link-style tabs that navigate away instead of controlling local panels.
185     Function Select Tab : END */
186  _selectTab(tab) {
187	if (this._isDisabledTab(tab)) return;
188	this.tabs.forEach(t => {
189	  t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
190	  if (t === tab) t.removeAttribute('tabindex');
191	  else t.setAttribute('tabindex', '-1');
192	});
193	this._persistActiveTab(tab);
194	this._ensureTabVisible(tab);
195  }
196
197  /* Function Handle Click : START
198     Handles pointer activation for tab buttons using event delegation from the action bar root.
199     Function Handle Click : END */
200  _onClick(e) {
201	const btn = e.target.closest('.action-bar__tab, [role="tab"]');
202	if (!btn) return;
203
204	if (this._isDisabledTab(btn)) {
205	  e.preventDefault();
206	  return;
207	}
208
209	if (!this._isLocalTab(btn)) {
210	  this._selectTab(btn);
211	  return;
212	}
213
214	e.preventDefault();
215	this._activate(btn);
216  }
217
218  /* Function Handle Keydown : START
219     Handles keyboard navigation and activation for accessible tab behavior.
220     Function Handle Keydown : END */
221  _onKeydown(e) {
222	const current = e.target.closest('.action-bar__tab, [role="tab"]');
223	if (!current) return;
224	const idx = this.tabs.indexOf(current);
225	if (idx < 0) return;
226	let nextIdx = null;
227	const isVertical = this.root.classList.contains('action-bar--vertical');
228	if (e.key === 'ArrowRight' || (isVertical && e.key === 'ArrowDown'))
229	  nextIdx = (idx + 1) % this.tabs.length;
230	if (e.key === 'ArrowLeft' || (isVertical && e.key === 'ArrowUp'))
231	  nextIdx = (idx - 1 + this.tabs.length) % this.tabs.length;
232	if (e.key === 'Home') nextIdx = 0;
233	if (e.key === 'End') nextIdx = this.tabs.length - 1;
234	if (nextIdx !== null) { e.preventDefault(); this.tabs[nextIdx].focus(); }
235	if (e.key === 'Enter' || e.key === ' ') {
236	  if (this._isLocalTab(current)) {
237		e.preventDefault();
238		this._activate(current);
239	  } else {
240		this._selectTab(current);
241	  }
242	}
243  }
244
245  /* Function Hydrate State : START
246     Normalizes the initial tab/panel state after page load and syncs aria-controls with targeted panel IDs.
247     Function Hydrate State : END */
248  _hydrate() {
249	const initialTab = this._getInitialActiveTab();
250	this.tabs.forEach(t => {
251	  const active = t === initialTab;
252	  t.setAttribute('role', 'tab');
253	  t.setAttribute('aria-selected', active ? 'true' : 'false');
254	  if (!active) t.setAttribute('tabindex', '-1');
255	  if (active) t.removeAttribute('tabindex');
256	  const panels = this._getPanels(t);
257	  panels.forEach(p => {
258		p.hidden = !active;
259		if (active) p.classList.add('action-bar__panel--show');
260	  });
261	  // Optional: sync aria-controls with all targeted IDs (if present)
262	  const ids = panels.map(p => p.id).filter(Boolean);
263	  if (ids.length) t.setAttribute('aria-controls', ids.join(' '));
264	});
265	if (initialTab) {
266	  requestAnimationFrame(() => this._ensureTabVisible(initialTab, { behavior: 'auto', align: 'center' }));
267	}
268  }
269
270  /* Function Setup Overflow Controls : START
271     Creates overflow scroll controls for article action bars and wires resize/scroll listeners.
272     Function Setup Overflow Controls : END */
273  _setupOverflowControls() {
274	this.scrollContainer = this.root.querySelector('.action-bar__scroll-area');
275	if (!this.scrollContainer || this.root.querySelector('[data-action-bar-scroll-prev]')) return;
276
277	this.prevButton = this._createOverflowButton('prev');
278	this.nextButton = this._createOverflowButton('next');
279	this.scrollContainer.append(this.prevButton, this.nextButton);
280
281	this.prevButton.addEventListener('click', this._onPrevOverflowClick);
282	this.nextButton.addEventListener('click', this._onNextOverflowClick);
283
284	if ('ResizeObserver' in window) {
285	  this._resizeObserver = new ResizeObserver(() => this._updateOverflowControls());
286	  this._resizeObserver.observe(this.scrollContainer);
287	  const tabList = this.root.querySelector('.action-bar__list');
288	  if (tabList) this._resizeObserver.observe(tabList);
289	}
290
291	window.addEventListener('resize', this._onOverflowResize);
292	this._attachScrollViewportListener();
293	this._updateOverflowControls();
294	const activeTab = this.tabs.find(tab => tab.getAttribute('aria-selected') === 'true');
295	if (activeTab) {
296	  requestAnimationFrame(() => this._ensureTabVisible(activeTab, { behavior: 'auto', align: 'center' }));
297	}
298  }
299
300  /* Function Create Overflow Button : START
301     Builds the previous or next overflow button used to scroll horizontally through article tabs.
302     Function Create Overflow Button : END */
303  _createOverflowButton(direction) {
304	const button = document.createElement('button');
305	button.type = 'button';
306	button.className = `btn btn--secondary btn--sm btn--icon-only action-bar__scroll-btn action-bar__scroll-btn--${direction}`;
307	button.setAttribute('aria-label', direction === 'prev' ? 'Scroll tabs left' : 'Scroll tabs right');
308	button.toggleAttribute(`data-action-bar-scroll-${direction}`, true);
309	button.hidden = true;
310	button.innerHTML = direction === 'prev'
311	  ? '<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M9.53033 3.46967C9.82322 3.76256 9.82322 4.23744 9.53033 4.53033L6.06066 8L9.53033 11.4697C9.82322 11.7626 9.82322 12.2374 9.53033 12.5303C9.23744 12.8232 8.76256 12.8232 8.46967 12.5303L4.46967 8.53033C4.17678 8.23744 4.17678 7.76256 4.46967 7.46967L8.46967 3.46967C8.76256 3.17678 9.23744 3.17678 9.53033 3.46967Z" fill="currentColor"/></svg>'
312	  : '<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6.46967 3.46967C6.76256 3.17678 7.23744 3.17678 7.53033 3.46967L11.5303 7.46967C11.8232 7.76256 11.8232 8.23744 11.5303 8.53033L7.53033 12.5303C7.23744 12.8232 6.76256 12.8232 6.46967 12.5303C6.17678 12.2374 6.17678 11.7626 6.46967 11.4697L9.93934 8L6.46967 4.53033C6.17678 4.23744 6.17678 3.76256 6.46967 3.46967Z" fill="currentColor"/></svg>';
313	return button;
314  }
315
316  /* Function Get Overlay Instance : START
317     Gets the OverlayScrollbars instance for the tab scroll area when that plugin is available.
318     Function Get Overlay Instance : END */
319  _getOverlayInstance() {
320	if (!this.scrollContainer || !window.OverlayScrollbarsGlobal) return null;
321	const { OverlayScrollbars } = window.OverlayScrollbarsGlobal;
322	if (typeof OverlayScrollbars !== 'function') return null;
323	try {
324	  return OverlayScrollbars(this.scrollContainer);
325	} catch (_error) {
326	  return null;
327	}
328  }
329
330  /* Function Get Scroll Viewport : START
331     Resolves the actual horizontal scrolling element, preferring OverlayScrollbars' viewport when present.
332     Function Get Scroll Viewport : END */
333  _getScrollViewport() {
334	const instance = this._getOverlayInstance();
335	if (instance && typeof instance.elements === 'function') {
336	  const { viewport } = instance.elements();
337	  if (viewport) return viewport;
338	}
339	return this.scrollContainer || null;
340  }
341
342  /* Function Attach Scroll Viewport Listener : START
343     Attaches the scroll listener to the current scroll viewport and swaps listeners if the viewport changes.
344     Function Attach Scroll Viewport Listener : END */
345  _attachScrollViewportListener() {
346	const viewport = this._getScrollViewport();
347	if (!viewport || this.scrollViewport === viewport) return;
348	if (this.scrollViewport) {
349	  this.scrollViewport.removeEventListener('scroll', this._onOverflowScroll);
350	}
351	this.scrollViewport = viewport;
352	this.scrollViewport.addEventListener('scroll', this._onOverflowScroll, { passive: true });
353  }
354
355  /* Function Update Overflow Controls : START
356     Shows, hides, enables, disables, and state-tags the overflow controls based on current horizontal scroll position.
357     Function Update Overflow Controls : END */
358  _updateOverflowControls() {
359	if (!this.scrollContainer || !this.prevButton || !this.nextButton) return;
360
361	this._attachScrollViewportListener();
362	const viewport = this.scrollViewport || this.scrollContainer;
363	const maxScrollLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
364	const isOverflowing = maxScrollLeft > 1;
365	const scrollLeft = viewport.scrollLeft;
366	const hasPrevOverflow = scrollLeft > 1;
367	const hasNextOverflow = scrollLeft < maxScrollLeft - 1;
368
369	this.root.classList.toggle('action-bar--scrollable', isOverflowing);
370	this.root.classList.toggle('action-bar--has-prev', isOverflowing && hasPrevOverflow);
371	this.root.classList.toggle('action-bar--has-next', isOverflowing && hasNextOverflow);
372	this.prevButton.hidden = !isOverflowing;
373	this.nextButton.hidden = !isOverflowing;
374
375	if (!isOverflowing) {
376	  this.prevButton.disabled = true;
377	  this.nextButton.disabled = true;
378	  return;
379	}
380
381	this.prevButton.disabled = !hasPrevOverflow;
382	this.nextButton.disabled = !hasNextOverflow;
383  }
384
385  /* Function Scroll Overflow : START
386     Scrolls the tab list left or right when an overflow control button is clicked.
387     Function Scroll Overflow : END */
388  _scrollOverflowBy(direction) {
389	if (!this.scrollContainer) return;
390
391	this._attachScrollViewportListener();
392	const viewport = this.scrollViewport || this.scrollContainer;
393	const distance = Math.max(viewport.clientWidth * 0.65, 160) * direction;
394
395	viewport.scrollBy({ left: distance, behavior: 'smooth' });
396	requestAnimationFrame(() => this._updateOverflowControls());
397	setTimeout(() => this._updateOverflowControls(), 250);
398  }
399
400  /* Function Ensure Tab Visible : START
401     Keeps the active tab comfortably visible inside the horizontal scroll area.
402     Function Ensure Tab Visible : END */
403  _ensureTabVisible(tab, options = {}) {
404	if (!this.scrollContainer || !tab) return;
405
406	this._attachScrollViewportListener();
407	const viewport = this.scrollViewport || this.scrollContainer;
408	const edgeBuffer = options.edgeBuffer ?? 24;
409	const comfortableBuffer = options.comfortableBuffer ?? Math.max(edgeBuffer, Math.min(viewport.clientWidth * 0.18, 72));
410	const align = options.align ?? 'auto';
411	const behavior = options.behavior ?? 'smooth';
412	const viewportRect = viewport.getBoundingClientRect();
413	const tabRect = tab.getBoundingClientRect();
414	const tabLeft = tabRect.left - viewportRect.left + viewport.scrollLeft;
415	const tabRight = tabLeft + tabRect.width;
416	const visibleLeft = viewport.scrollLeft + comfortableBuffer;
417	const visibleRight = viewport.scrollLeft + viewport.clientWidth - comfortableBuffer;
418	const maxScrollLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
419	let nextScrollLeft = null;
420
421	if (align === 'center') {
422	  nextScrollLeft = Math.min(
423		maxScrollLeft,
424		Math.max(0, tabLeft - ((viewport.clientWidth - tabRect.width) / 2))
425	  );
426	} else if (tabLeft < visibleLeft) {
427	  nextScrollLeft = Math.max(0, tabLeft - comfortableBuffer);
428	} else if (tabRight > visibleRight) {
429	  nextScrollLeft = Math.min(maxScrollLeft, Math.max(0, tabRight - viewport.clientWidth + comfortableBuffer));
430	}
431
432	if (nextScrollLeft !== null) {
433	  viewport.scrollTo({ left: nextScrollLeft, behavior });
434	  requestAnimationFrame(() => this._updateOverflowControls());
435	  setTimeout(() => this._updateOverflowControls(), 250);
436	}
437  }
438
439  /* Function Show Tab : START
440     Public helper for activating a tab element programmatically.
441     Function Show Tab : END */
442  show(tab) { this._activate(tab); }
443
444  /* Function Show Tab By ID : START
445     Public helper for activating a tab by panel ID or hash.
446     Function Show Tab By ID : END */
447  showById(id) {
448	const hash = id.startsWith('#') ? id : `#${id}`;
449	const t = this.tabs.find(t =>
450	  (t.getAttribute('data-action-bar-target') || '').split(',').map(s=>s.trim()).includes(hash) ||
451	  t.getAttribute('href') === hash
452	);
453	if (t) this._activate(t);
454  }
455
456  /* Function Dispose Action Bar : START
457     Removes event listeners and observers created by this action bar instance.
458     Function Dispose Action Bar : END */
459  dispose() {
460	this.root.removeEventListener('click', this._onClick);
461	this.root.removeEventListener('keydown', this._onKeydown);
462	if (this.scrollViewport) {
463	  this.scrollViewport.removeEventListener('scroll', this._onOverflowScroll);
464	}
465	if (this.prevButton) {
466	  this.prevButton.removeEventListener('click', this._onPrevOverflowClick);
467	}
468	if (this.nextButton) {
469	  this.nextButton.removeEventListener('click', this._onNextOverflowClick);
470	}
471	if (this._resizeObserver) {
472	  this._resizeObserver.disconnect();
473	}
474	window.removeEventListener('resize', this._onOverflowResize);
475  }
476}
477
478/* Function Initialize Action Bars On DOM Ready : START
479   Initializes every action bar found on the page after the initial DOM is ready.
480   Function Initialize Action Bars On DOM Ready : END */
481document.addEventListener('DOMContentLoaded', () => {
482	document.querySelectorAll('[data-action-bar]').forEach(el => new ActionBar(el));
483});

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.