VisorVisor
Blocks

Export Menu

Export button + popover composing a format-picker radio group, optional scope checkboxes, and an async-aware Cancel/Export footer. Drop-in for any admin list's export affordance.

Preview

Default — CSV / JSON / PDF baseline

Custom formats with icons

With scope toggles

Async loading state

onExport may return a Promise. While it's pending, the Export button shows a spinner, both buttons are disabled, and the popover stays open until the promise resolves.

Disabled format with tooltip

Mark a format disabled and provide a disabledReason — hovering the row surfaces the reason in a tooltip.

Installation

npx visor add export-menu --block

This copies files into your project:

  • blocks/export-menu/export-menu.tsx — the block component
  • blocks/export-menu/export-menu.module.css — the styles

It also installs the button, popover, radio-group, checkbox, label, and tooltip primitives if they aren't already present.

Usage

'use client';

import {
  ExportMenu,
  defaultExportFormats,
} from '@/blocks/export-menu/export-menu';

export function OrganizationsPageHeader({ count }: { count: number }) {
  async function handleExport(
    format: string,
    scopes: Record<string, boolean>
  ) {
    const res = await fetch('/api/organizations/export', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ format, scopes }),
    });
    if (!res.ok) throw new Error('Export failed');
    const blob = await res.blob();
    const url = URL.createObjectURL(blob);
    window.open(url, '_blank');
  }

  return (
    <ExportMenu
      formats={defaultExportFormats()}
      scopes={[
        { key: 'archived', label: 'Include archived organizations' },
        { key: 'suspended', label: 'Include suspended', defaultChecked: true },
      ]}
      heading={`Export ${count} organizations`}
      onExport={handleExport}
    />
  );
}

defaultExportFormats() returns the Low Orbit baseline: CSV, JSON, PDF — extend or replace as needed.

Props

PropTypeDefaultDescription
formatsExportFormat[]Required. The list of available export formats. First non-disabled format is preselected.
onExport(format, scopes) => void | Promise<void>Required. Called with the selected format value and current scope state. Returning a Promise puts the submit button in a loading state until resolution.
labelReactNode"Export"Trigger label.
iconReactNode<DownloadSimple />Trigger icon. Pass null to hide.
scopesExportScope[]Optional scope toggles rendered as a checkbox section.
triggerVariant"primary" | "secondary" | "ghost""secondary"Visual emphasis on the trigger button. primary maps to the default filled Button variant.
headingReactNodelabelOverride the popover header text (e.g. "Export 24 organizations").
classNamestringForwarded to the trigger button.

ExportFormat

FieldTypeRequiredDescription
valuestringYesIdentifier passed to onExport.
labelReactNodeYesDisplay name shown in the radio row.
descriptionReactNodeNoSecondary line under the label.
iconReactNodeNoIcon shown to the left of the label text.
disabledbooleanNoGreys out the radio and prevents selection.
disabledReasonstringNoWhen set together with disabled, hovering the row shows a tooltip with this reason.

ExportScope

FieldTypeRequiredDescription
keystringYesIdentifier used in the scopes map passed to onExport.
labelReactNodeYesCheckbox label.
descriptionReactNodeNoSecondary line under the label.
defaultCheckedbooleanNoInitial checked state. State resets each time the popover opens.

Accessibility

  • The trigger is a <Button> with aria-haspopup="dialog" so screen readers announce the affordance correctly.
  • The popover content uses role="dialog" with an aria-label derived from the heading.
  • Radix Popover traps focus inside the content while open, returns focus to the trigger on close, and closes on Escape.
  • The submit button uses aria-busy={true} during async submission and is disabled until the Promise resolves; Cancel is also disabled during pending so the user can't dismiss mid-export.
  • Enter on any non-button element inside the popover submits the selected format. Native Enter on the Cancel / Export buttons still does the expected thing.
  • Disabled formats render with data-disabled and a disabledReason tooltip — the reason is announced via the Radix tooltip on hover and focus.
  • The spinner respects prefers-reduced-motion: under that preference the rotation slows from 800ms to 2.4s so the indicator is still legible but no longer rapidly animated.

About Blocks

Blocks are complete UI patterns made up of multiple Visor components. Unlike individual components, blocks represent larger, composed sections of UI — such as admin shells, login forms, or export menus.