Semaphor

PDF Export Support

Make expandable sections work correctly in PDF exports

When your custom visual has expandable sections -- accordions, collapsible panels, expandable table rows -- Semaphor's PDF exporter needs to know how to expand them before capturing the page. Add three data attributes to your toggle elements to enable this.

This is the Print State Protocol. Without it, the PDF captures whatever is visible at the time, leaving collapsed content hidden in the export.


How It Works

During PDF export, Semaphor:

  1. Scans the page for elements with data-spr-expand-id
  2. Programmatically clicks each one to expand it
  3. Waits for any data-transitioning flags to clear
  4. Captures the fully expanded state

Your component does not need to detect PDF mode or change its rendering. The exporter handles everything through the DOM attributes.

Under the Hood

The protocol works in three stages:

  1. Capture -- When a user exports a PDF, Semaphor reads all elements with data-spr-expand-id from the current page and records which ones are expanded (those with aria-expanded="true").

  2. Transfer -- The captured state is serialized as JSON and sent to Semaphor's PDF generation service. No state is stored permanently -- it exists only for the duration of the export.

  3. Apply -- Semaphor renders the dashboard in a headless browser, finds every element with data-spr-expand-id, and clicks each one that needs to match the captured state. If any element has data-transitioning set to true (async loading), Semaphor waits for it to finish before continuing. Once all sections are settled, the page is captured as a PDF.

Because this happens automatically, your component never needs to know whether it is being rendered for screen or PDF. Just add the attributes, and Semaphor handles the rest.


Required Attributes

AttributeRequiredPurpose
data-spr-expand-idYesUnique, stable identifier for the expandable section
aria-expandedYesIndicates current expand/collapse state (also improves accessibility)
data-transitioningNoSignals that async data is loading after expansion

Place all attributes on the clickable toggle element (the button, row header, or link that expands the section).


Basic Example

A table where each row expands to show detail fields:

expandable-table.tsx
import { useState } from 'react';
import { SingleInputVisualProps } from '../../config-types';
 
export function ExpandableTable({ data, settings }: SingleInputVisualProps) {
  const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
 
  if (!data?.length) {
    return (
      <div className="flex items-center justify-center h-full text-muted-foreground">
        No data
      </div>
    );
  }
 
  const toggleRow = (index: number) => {
    setExpandedRows(prev => {
      const next = new Set(prev);
      if (next.has(index)) next.delete(index);
      else next.add(index);
      return next;
    });
  };
 
  const keys = Object.keys(data[0]);
 
  return (
    <div className="space-y-1">
      {data.map((row, i) => (
        <div key={i} className="border rounded">
          <button
            data-spr-expand-id={`row-${row[keys[0]]}`}
            aria-expanded={expandedRows.has(i)}
            onClick={() => toggleRow(i)}
            className="w-full text-left px-3 py-2 font-medium hover:bg-muted/50"
          >
            {String(row[keys[0]])}
          </button>
          {expandedRows.has(i) && (
            <div className="px-3 py-2 border-t text-sm">
              {keys.slice(1).map(k => (
                <div key={k}>
                  {k}: {String(row[k])}
                </div>
              ))}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

The three key lines are on the button:

  • data-spr-expand-id -- stable ID derived from the row's first column value
  • aria-expanded -- tracks whether the row is currently open or closed
  • onClick -- handler that responds to programmatic .click() events from the exporter

ID Naming Conventions

IDs must be stable and data-derived. The exporter relies on consistent IDs across renders.

Good IDs

data-spr-expand-id={`category-${row.categoryId}`}
data-spr-expand-id={`row-${rowIndex}-details`}
data-spr-expand-id={`section-${section.slug}`}

These produce the same ID every time the component renders with the same data.

Bad IDs

data-spr-expand-id={Math.random()}        // Different on every render
data-spr-expand-id={crypto.randomUUID()}   // Not stable across renders
data-spr-expand-id={`item-${Date.now()}`}  // Changes with time

Random or time-based IDs break the exporter because it cannot reliably target the same element twice.


Async Expansion

Some sections load data when expanded (fetching details from an API, running a sub-query). Use data-transitioning to tell the exporter to wait before capturing.

async-expand.tsx
import { useState } from 'react';
 
function AsyncDetailRow({ id, label }: { id: string; label: string }) {
  const [isExpanded, setIsExpanded] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [details, setDetails] = useState<string | null>(null);
 
  const handleExpand = async () => {
    if (isExpanded) {
      setIsExpanded(false);
      return;
    }
 
    setIsExpanded(true);
    setIsLoading(true);
 
    const result = await fetchDetails(id);
    setDetails(result);
    setIsLoading(false);
  };
 
  return (
    <div className="border rounded">
      <button
        data-spr-expand-id={`detail-${id}`}
        aria-expanded={isExpanded}
        data-transitioning={isLoading}
        onClick={handleExpand}
        className="w-full text-left px-3 py-2 font-medium"
      >
        {label}
      </button>
      {isExpanded && (
        <div className="px-3 py-2 border-t text-sm">
          {isLoading ? 'Loading...' : details}
        </div>
      )}
    </div>
  );
}

The exporter clicks the button, sees data-transitioning set to true, and waits. Once loading completes and data-transitioning becomes false, it proceeds to the next expandable or captures the page.


Nested Expandables

For expandable sections inside other expandable sections, use hierarchical IDs separated by /. This tells the exporter the correct expansion order -- parent first, then children.

nested-expand.tsx
// Parent toggle
<button
  data-spr-expand-id="region-north"
  aria-expanded={isRegionExpanded}
  onClick={toggleRegion}
>
  North Region
</button>
 
// Child toggle (inside parent's expanded content)
<button
  data-spr-expand-id="region-north/store-123"
  aria-expanded={isStoreExpanded}
  onClick={toggleStore}
>
  Store 123
</button>
 
// Deeper nesting works the same way
<button
  data-spr-expand-id="region-north/store-123/dept-electronics"
  aria-expanded={isDeptExpanded}
  onClick={toggleDept}
>
  Electronics
</button>

The / separator is a convention, not enforced syntax. It keeps IDs readable and ensures the exporter can infer parent-child relationships.


Checklist

When adding expandable sections to your custom visual:

  • Add data-spr-expand-id with a stable, data-derived ID to every toggle element
  • Add aria-expanded to reflect the current open/closed state
  • Ensure the toggle element responds to .click() events
  • For async expansion, add data-transitioning while data is loading
  • For nested sections, use hierarchical IDs with / separator
  • Never use random or time-based values for IDs

On this page