CoolFace
Apppublic

lerobot/robot-learning-tutorial

sourceHugging Faceupdated 1y agoView on Hugging Face
508likes
TableOfContents.astro384 linesDownload Raw Back to components
1---2export interface Props { tableOfContentAutoCollapse?: boolean }3const { tableOfContentAutoCollapse = false } = Astro.props as Props;4---5<nav class="table-of-contents" aria-label="Table of Contents" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>6  <div class="title">Table of Contents</div>7  <div id="article-toc-placeholder"></div>8</nav>9<details class="table-of-contents-mobile">10  <summary>Table of Contents</summary>11  <div id="article-toc-mobile-placeholder"></div>12</details>13 14<script is:inline>15  // Build TOC from article headings (h2/h3/h4) and render into the sticky aside16  const buildTOC = () => {17    const holder = document.getElementById('article-toc-placeholder');18    const holderMobile = document.getElementById('article-toc-mobile-placeholder');19    // Always rebuild TOC to avoid stale entries20    if (holder) holder.innerHTML = '';21    if (holderMobile) holderMobile.innerHTML = '';22    const articleRoot = document.querySelector('section.content-grid main');23    if (!articleRoot) return;24    const headings = articleRoot.querySelectorAll('h2, h3, h4');25    if (!headings.length) return;26 27    // Inclure tous les titres H2/H3/H4 sans filtrer "Table of contents"28    const headingsArr = Array.from(headings);29    if (!headingsArr.length) return;30 31    // Ensure unique ids for headings (deduplicate duplicates)32    const usedIds = new Set();33    const slugify = (s) => String(s || '')34      .toLowerCase()35      .trim()36      .replace(/\s+/g, '_')37      .replace(/[^a-z0-9_\-]/g, '');38    headingsArr.forEach((h) => {39      let id = (h.id || '').trim();40      if (!id) {41        const base = slugify(h.textContent || '');42        id = base || 'section';43      }44      let candidate = id;45      let n = 2;46      while (usedIds.has(candidate)) {47        candidate = `${id}-${n++}`;48      }49      if (h.id !== candidate) h.id = candidate;50      usedIds.add(candidate);51    });52 53    const nav = document.createElement('nav');54    let ulStack = [document.createElement('ul')];55    nav.appendChild(ulStack[0]);56 57    const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;58    let prev = 2;59    let h2Count = -1;60    const h2List = headingsArr.filter(h => h.tagName === 'H2');61    headingsArr.forEach((h) => {62      const lvl = levelOf(h.tagName);63      // adjust depth64      while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }65      while (lvl < prev) { ulStack.pop(); prev--; }66      const li = document.createElement('li');67      const a = document.createElement('a');68      a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';69      li.appendChild(a);70      if (lvl === 2) {71        h2Count += 1;72        li.setAttribute('data-h2-idx', String(h2Count));73      }74      ulStack[ulStack.length-1].appendChild(li);75    });76 77    if (holder) holder.appendChild(nav);78    const navClone = nav.cloneNode(true);79    if (holderMobile) holderMobile.appendChild(navClone);80 81    // active link on scroll82    const links = [83      ...(holder ? holder.querySelectorAll('a') : []),84      ...(holderMobile ? holderMobile.querySelectorAll('a') : [])85    ];86    // Read breakpoint from CSS var and set autoCollapse only on desktop (disabled on mobile)87    const getCollapsePx = () => {88      const root = document.documentElement;89      const raw = getComputedStyle(root).getPropertyValue('--bp-content-collapse').trim();90      return raw || '1100px';91    };92    const mq = window.matchMedia(`(max-width: ${getCollapsePx()})`);93    const attrEnabled = (document.querySelector('.table-of-contents')?.getAttribute('data-auto-collapse') === '1');94    let autoCollapse = attrEnabled && !mq.matches;95 96    // Inject styles for collapsible & animation97    const ensureStyles = () => {98      if (document.getElementById('toc-collapse-style')) return;99      const style = document.createElement('style');100      style.id = 'toc-collapse-style';101      style.textContent = `102        .table-of-contents nav.table-of-contents-collapsible > ul > li > ul,103        details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }104        .table-of-contents nav.table-of-contents-collapsible > ul > li.collapsed > ul,105        details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li.collapsed > ul { display: block; }106      `;107      document.head.appendChild(style);108    };109    ensureStyles();110 111    const getTopLevelItems = () => {112      const sideNav = holder ? holder.querySelector('nav') : null;113      const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;114      const q = (navEl) => navEl ? Array.from(navEl.querySelectorAll(':scope > ul > li[data-h2-idx]')) : [];115      return { sideNav, mobileNav, sideTop: q(sideNav), mobileTop: q(mobileNav) };116    };117 118    const setNavCollapsible = () => {119      const sideNav = holder ? holder.querySelector('nav') : null;120      const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;121      if (sideNav) sideNav.classList.add('table-of-contents-collapsible');122      if (mobileNav) mobileNav.classList.add('table-of-contents-collapsible');123    };124 125    const measure = (el) => {126      if (!el) return 0;127      // Temporarily set height to auto to measure scrollHeight reliably128      const prev = el.style.height;129      el.style.height = 'auto';130      const h = el.scrollHeight;131      el.style.height = prev || '';132      return h;133    };134 135    const animateTo = (el, target) => {136      if (!el) return;137      const current = parseFloat(getComputedStyle(el).height) || 0;138      if (Math.abs(current - target) < 1) {139        el.style.height = target ? 'auto' : '0px';140        return;141      }142      el.style.height = current + 'px';143      // Force reflow144      void el.offsetHeight;145      el.style.height = target + 'px';146      const onEnd = (e) => {147        if (e.propertyName !== 'height') return;148        el.removeEventListener('transitionend', onEnd);149        if (target > 0) el.style.height = 'auto';150      };151      el.addEventListener('transitionend', onEnd);152    };153 154    let prevActiveIdx = -1;155    const setCollapsedState = (activeIdx) => {156      if (!autoCollapse) return;157      if (activeIdx == null || activeIdx < 0) activeIdx = 0;158      const { sideTop, mobileTop } = getTopLevelItems();159      const update = (items) => items.forEach((li) => {160        const idx = Number(li.getAttribute('data-h2-idx') || '-1');161        const sub = li.querySelector(':scope > ul');162        if (!sub) return;163        if (idx === activeIdx) {164          li.classList.remove('collapsed');165          const target = measure(sub);166          animateTo(sub, target);167        } else {168          li.classList.add('collapsed');169          animateTo(sub, 0);170        }171      });172      update(sideTop);173      update(mobileTop);174      setNavCollapsible();175      prevActiveIdx = activeIdx;176    };177 178    // When switching between desktop/mobile, refresh autoCollapse and expand all on mobile179    const expandAll = () => {180      const { sideTop, mobileTop } = getTopLevelItems();181      const expand = (items) => items.forEach((li) => {182        li.classList.remove('collapsed');183        const sub = li.querySelector(':scope > ul');184        if (sub) sub.style.height = 'auto';185      });186      expand(sideTop);187      expand(mobileTop);188    };189 190    const onMqChange = () => {191      autoCollapse = attrEnabled && !mq.matches;192      if (!autoCollapse) {193        expandAll();194      } else {195        setCollapsedState(prevActiveIdx);196      }197    };198    if (mq.addEventListener) mq.addEventListener('change', onMqChange);199    else if (mq.addListener) mq.addListener(onMqChange);200 201    const onScroll = () => {202      // active link highlight203      let activeIdx = -1;204      for (let i = headingsArr.length - 1; i >= 0; i--) {205        const top = headingsArr[i].getBoundingClientRect().top;206        if (top - 60 <= 0) {207          links.forEach(l => l.classList.remove('active'));208          const id = '#' + headingsArr[i].id;209          const actives = Array.from(links).filter(l => l.getAttribute('href') === id);210          actives.forEach(a => a.classList.add('active'));211          if (headingsArr[i].tagName === 'H2') {212            activeIdx = h2List.indexOf(headingsArr[i]);213          } else {214            for (let j = i; j >= 0; j--) {215              if (headingsArr[j].tagName === 'H2') { activeIdx = h2List.indexOf(headingsArr[j]); break; }216            }217          }218          break;219        }220      }221      if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);222    };223 224    // If auto-collapse, collapse immediately (expand first section) before any scroll225    if (autoCollapse) setCollapsedState(0);226 227    window.addEventListener('scroll', onScroll);228    // Initialize state229    onScroll();230 231    // Close mobile accordion when a link inside it is clicked232    if (holderMobile) {233      const details = holderMobile.closest('details');234      holderMobile.addEventListener('click', (ev) => {235        const target = ev.target;236        const anchor = target && 'closest' in target ? target.closest('a') : null;237        if (anchor instanceof HTMLAnchorElement && details && details.open) {238          details.open = false;239        }240      });241    }242  };243 244  if (document.readyState === 'loading') {245    document.addEventListener('DOMContentLoaded', buildTOC, { once: true });246  } else { buildTOC(); }247</script>248 249<style is:global>250  /* Sticky aside */251  .table-of-contents {252    position: sticky;253    top: 32px;254    margin-top: 12px;255  }256 257  .table-of-contents nav {258    border-left: 1px solid var(--border-color);259    padding-left: 16px;260    font-size: 13px;261  }262 263  .table-of-contents .title {264    font-weight: 600;265    font-size: 14px;266    margin-bottom: 8px;267  }268 269  /* Look & feel */270  .table-of-contents nav ul {271    margin: 0 0 6px;272    padding-left: 1em;273  }274 275  .table-of-contents nav li {276    list-style: none;277    margin: .25em 0;278  }279 280  .table-of-contents nav a,281  .table-of-contents nav a:link,282  .table-of-contents nav a:visited {283    color: var(--text-color);284    text-decoration: none;285    border-bottom: none;286  }287 288  .table-of-contents nav > ul > li > a {289    font-weight: 700;290  }291 292  .table-of-contents nav a:hover {293    text-decoration: underline solid var(--muted-color);294  }295 296  .table-of-contents nav a.active {297    text-decoration: underline;298  }299 300  /* Mobile accordion */301  .table-of-contents-mobile {302    display: none;303    margin: 8px 0 16px;304  }305 306  .table-of-contents-mobile > summary {307    cursor: pointer;308    list-style: none;309    padding: var(--spacing-3) var(--spacing-4);310    border: 1px solid var(--border-color);311    border-radius: 8px;312    color: var(--text-color);313    font-weight: 600;314    position: relative;315  }316 317  .table-of-contents-mobile[open] > summary {318    border-bottom-left-radius: 0;319    border-bottom-right-radius: 0;320  }321 322  /* Disclosure arrow for mobile summary */323  .table-of-contents-mobile > summary::after {324    content: '';325    position: absolute;326    right: var(--spacing-4);327    top: 50%;328    width: 8px;329    height: 8px;330    border-right: 2px solid currentColor;331    border-bottom: 2px solid currentColor;332    transform: translateY(-70%) rotate(45deg);333    transition: transform 150ms ease;334    opacity: .7;335  }336 337  .table-of-contents-mobile[open] > summary::after {338    transform: translateY(-30%) rotate(-135deg);339  }340 341  .table-of-contents-mobile nav {342    border-left: none;343    padding: 10px 12px;344    font-size: 14px;345    border: 1px solid var(--border-color);346    border-top: none;347    border-bottom-left-radius: 8px;348    border-bottom-right-radius: 8px;349  }350 351  .table-of-contents-mobile nav ul {352    margin: 0 0 6px;353    padding-left: 1em;354  }355 356  .table-of-contents-mobile nav li {357    list-style: none;358    margin: .25em 0;359  }360 361  .table-of-contents-mobile nav a,362  .table-of-contents-mobile nav a:link,363  .table-of-contents-mobile nav a:visited {364    color: var(--text-color);365    text-decoration: none;366    border-bottom: none;367  }368 369  .table-of-contents-mobile nav > ul > li > a {370    font-weight: 700;371  }372 373  .table-of-contents-mobile nav a:hover {374    text-decoration: underline solid var(--muted-color);375  }376 377  .table-of-contents-mobile nav a.active {378    text-decoration: underline;379  }380 381 382 383</style>384