<!-- GENERATED by scripts/build-llms.mjs from llms/utility.md — do not edit this file. -->

# `lr-export-button`

- **Import** `import '@aceshooting/lyra-ui/components/lr-export-button.js';` (stable tag alias; registers the tag)
- **Class** `LyraExportButton`, also available unregistered from `@aceshooting/lyra-ui/components/utility/export-button/export-button.class.js`
- **Family** `components/utility/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 6 parts, 14 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-export-button`

CSV/JSON download button with extensible event-driven formats — either single-format (click exports
immediately) or multi-format (click opens a small menu).

**Properties:**

- `rows: readonly Readonly<Record<string, unknown>>[] = []` (attribute: false) — assignment takes
  shallow frozen snapshots of the collection and row records; nested cell values remain opaque; the
  built-in download reads this **after** the cancelable `lr-export` event, so a listener that lets
  the download proceed may assign `.rows` from inside its own handler and that data is what gets
  downloaded
- `columns: readonly Readonly<LyraCsvColumn>[] = []` (attribute: false) — assignment takes a
  shallow frozen snapshot. `{ key, label }` acts as a field allow-list **and**
  CSV header-label source for **both** export formats when non-empty. Left empty, **both** CSV and
  JSON fall back to the union of the rows' own keys (`key`/`label` both set to the key name) instead
  of CSV degrading to a header-less/blank file while only JSON had a fallback — so an unconfigured
  export still produces a proper header + data file in either format
- `filename: string = 'export'`
- `getRows?: () => readonly Record<string, unknown>[]` (attribute: false) — lazy row source,
  consulted only when a built-in CSV/JSON download is actually about to be built: after a
  non-prevented `lr-export`, and never for a custom format this component does not serialize itself.
  When set it replaces `rows` for that download, so a consumer can export a collection it already
  holds — an `<lr-table>`'s `viewRows`, say — without copying it into this element and keeping a
  second live copy. The `columns` fallback derives its header row from the lazily supplied rows. A
  non-array return is treated as no rows, matching how `rows` normalizes one; a callback that throws
  is reported through `lr-export-error` and the shared failure announcement, since an export whose
  data could not be collected has failed
- `bom: boolean = false` (reflected) — prepends a UTF-8 byte-order mark (U+FEFF) to the built-in
  CSV download only. Excel on Windows ignores a downloaded file's MIME charset and decodes a
  BOM-less CSV with the system ANSI code page, so accented, Arabic, CJK, and typographic characters
  render as mojibake; the BOM makes Excel detect UTF-8 and decode correctly. Google Sheets,
  LibreOffice, and Numbers already sniff UTF-8 correctly with or without it, so leaving this `false`
  changes nothing for them. Never applies to the built-in JSON download — RFC 8259 forbids a BOM
  there
- `formats: readonly LyraExportFormatOption[] = ['csv']` (attribute: false; shallow frozen
  snapshot), where
  `LyraExportFormatOption` is the built-in `LyraExportFormat = 'csv' | 'json'` or a
  `LyraExportFormatDescriptor = { formatId: string; label: string; description?: string;
extension?: string }`. Descriptor labels/descriptions are consumer-supplied, already-localized
  copy. `formatId` must be nonempty and unique; malformed options and later duplicates are omitted
  first-wins before menu state, focus reconciliation, or export events. Custom format ids are
  event-only; no custom encoder is bundled
- `size?: LyraSize` — optional density on the shared `2xs` through `xl` ladder, including the
  `small`/`medium`/`large` aliases. It changes trigger and menu-row typography and padding while
  retaining the shared 40px minimum hit-area floor. Unset preserves the established geometry
- `appearance?: LyraExportButtonAppearance` — `outlined` or `quiet` trigger treatment. Unset
  preserves the established surface, border, and text colors
- `disabled: boolean = false` (reflected) — also disables every `[part="menu-item"]` button, not just
  the trigger
- `loading: boolean = false` (reflected) — controlled busy state for an async or server-generated
  export; sets host/trigger `aria-busy` and disables the trigger and menu items. The component does
  not toggle it automatically
- `label?: string` — trigger button text; omission uses the localized `exportButtonLabel` default.
  Every supplied string, including `''` and `'Export'`, remains caller-owned visible copy. An empty
  or whitespace-only visible label keeps the localized default as the trigger's accessible name;
  the effective accessible name also feeds the format-choice menu's localized name
- `accessibleLabel: string | null = null` (attribute `aria-label`) — overrides the trigger's
  accessible name and feeds the localized format-menu name without changing the visible label.
  Presence is authoritative, so an explicit empty string is preserved; `null` restores naming from
  a nonempty visible label or the localized fallback
- `open: boolean = false` (reflected)

**Methods:** `focus(options?)`, `blur()`, and `click()` forward to the native trigger button.

**Events:** `lr-export` (`detail: { format: string }`, **cancelable** — call `preventDefault()` to
substitute your own server-generated download instead of the built-in client-side one; the rows a
non-prevented built-in download serializes are read **after** this dispatch, so `.rows` assigned from
inside the listener still reaches it, and `getRows` is consulted at the same point),
`lr-export-complete` (`detail: { format: 'csv' | 'json' }`, fires only after a non-cancelled
built-in download completes), `lr-export-error` (`detail: { format: 'csv' | 'json', error:
unknown }`, fires when a built-in export cannot be serialized or downloaded; the same failure is
also shown via the trigger's `trigger-error` part and announced through the shared light-DOM live
region, so a listener is needed only for additional handling; activation does not throw into
consumer code), `lr-show`, `lr-hide` (cancelable format-menu visibility transitions; self-imposed
closes caused by disablement, loading, or an unusable format list emit neither event)

**Slots:** none.

**CSS parts:** `trigger`, `trigger-error` (present alongside `trigger` on the same button —
`part="trigger trigger-error"` — after a built-in CSV/JSON export fails; cleared on the next export
attempt regardless of outcome — style with `::part(trigger-error)`), `menu`, `menu-item`,
`format-label`, `format-description`

**Themeable custom properties:** the trigger's paint is settable per state. At rest,
`--lr-export-button-background`, `--lr-export-button-color` and `--lr-export-button-border`; on
hover, `--lr-export-button-hover-background`, `--lr-export-button-hover-color` and
`--lr-export-button-hover-border`; while pressed, `--lr-export-button-active-background`,
`--lr-export-button-active-color` and `--lr-export-button-active-border`. Each layers over whatever
the current `appearance` resolves to and leaves the other paints alone, so they are also the way to
keep `appearance="outlined"` chrome while returning its label to neutral text — `outlined` paints
the label `--lr-color-brand` and, unlike `lr-button`, this component has no `variant` of its own to
opt out. Before 18.4.0 none of these existed and the only lever was `::part(trigger)`. Plus shared
tokens, including `--lr-popover-viewport-clamp` (default `92vw`) — the shared narrow-viewport ceiling the `menu`'s max-inline-size is `min()`ed
against, alongside its own `20rem` cap and the positioner's available space. See `lr-tour` for the
shared-clamp note.

The menu popup is a floating surface and paints from the **shared overlay-surface family** (16.0.0):
`--lr-overlay-surface` (default `var(--lr-color-surface-overlay)`), `--lr-overlay-border` (default
`var(--lr-color-border)`) and `--lr-overlay-shadow-anchored` (default `var(--lr-shadow-m)`). None is
declared on `:host`, so one declaration on `:root` — or on any ancestor, to scope it — retints this
surface together with every other floating surface in the library. `--lr-overlay-radius` (default `var(--lr-radius)`) is the matching corner radius.

`--lr-positioning-strategy` (16.0.0) — the format menu reads this same cascading `absolute`/`fixed`
override documented on `<lr-popover>` when it is (re)positioned, falling back to its own `fixed`
default when nothing is set. There is no per-instance `positioning-strategy` property on
`<lr-export-button>`; set the custom property on `:root`, a theme, or one clipping ancestor to
change every unset export button beneath it.

**Optional peer deps:** none.

```html
<lr-export-button id="exp" filename="report" label="Export"></lr-export-button>
<script type="module">
  const exp = document.getElementById("exp");
  exp.rows = [{ name: "Alpha", value: 1 }];
  exp.columns = [
    { key: "name", label: "Name" },
    { key: "value", label: "Value" },
  ];
  exp.formats = ["csv", "json"]; // shows a format-choice menu instead of exporting immediately
  exp.addEventListener("lr-export", (e) =>
    console.log("exporting", e.detail.format)
  );

  // Custom formats supply menu copy but remain application-handled.
  exp.formats = [
    "csv",
    {
      formatId: "xlsx",
      label: "Excel workbook",
      description: "Preserves spreadsheet data types",
      extension: "xlsx",
    },
  ];
  exp.addEventListener("lr-export", async (e) => {
    if (e.detail.format !== "xlsx") return;
    e.preventDefault();
    exp.loading = true;
    try {
      await exportWorkbook(exp.rows);
    } finally {
      exp.loading = false;
    }
  });
</script>
```

Package-level CSV utilities (used internally, also exported for standalone use — `import {
escapeCsvField, buildCsv, downloadBlob } from
'@aceshooting/lyra-ui/components/utility/export-button/csv.js'`):

```ts
escapeCsvField(value: unknown): string   // quotes/escapes; neutralizes leading whitespace and ASCII/fullwidth =,+,-,@ formula prefixes with an apostrophe
buildCsv(rows: readonly Readonly<Record<string, unknown>>[], columns: readonly LyraCsvColumn[], options?: LyraBuildCsvOptions): string  // CRLF-joined, header row included; options.bom prepends a UTF-8 byte-order mark
downloadBlob(content: string, filename: string, mime: string, ownerDocument?: Document): void // triggers a browser download in the supplied document realm
```

**Known gotchas:**

- Format options are read from their direct data fields into a frozen list. Accessor-backed, missing,
  malformed, or throwing descriptor fields are ignored rather than evaluated; later menu rendering
  and `lr-export` events use that snapshot. The snapshot includes only direct `formatId`, `label`,
  `description`, and `extension` fields; accepted records are frozen and never reread from the
  caller. An open multi-format menu is a non-modal overlay owned by the element's
  current document: only its topmost menu handles that document's Escape, outside pointer, or Tab.
  It rebinds after document adoption; Escape or a selected export returns focus to its own trigger,
  while a Tab close permits normal document navigation. Placement uses the current trigger and panel
  geometry and remains clamped to the current document's viewport.
- CSV and JSON are the only built-in encoders. To offer XLSX/PDF/etc., pass an
  `LyraExportFormatDescriptor` and handle its `formatId` from `lr-export`; custom formats never trigger a
  download or `lr-export-complete` on their own. A descriptor's optional `extension` is metadata
  for that handler, not automatic filename handling.
- CSV formula-injection guarding and the deferred (5s) `URL.revokeObjectURL` (works around Safari
  cancelling in-flight downloads on immediate revoke) are genuine, safe-to-rely-on strengths.
  Finite JavaScript numbers remain numeric CSV cells (including negative and decimal values);
  caller-supplied strings such as `"-12"` still take the formula-safe text path. `NaN` and
  infinities are non-numeric values and are escaped as text.
- `bom` addresses an Excel-on-Windows-specific defect: Excel ignores a downloaded file's MIME
  charset and falls back to decoding a BOM-less CSV with the system ANSI code page, garbling
  accented, Arabic, CJK, and typographic characters. Setting `bom` prepends U+FEFF ahead of the
  CSV header row so Excel detects UTF-8. It changes nothing for Google Sheets, LibreOffice, or
  Numbers, which already sniff UTF-8 correctly either way, and it never reaches the JSON download
  under any setting — RFC 8259 forbids a BOM in JSON.
- `open` is valid only when `formats` contains more than one choice. An invalid open request is
  normalized closed without a false `lr-show`/`lr-hide` pair; shrinking an open menu to one format,
  or becoming `disabled`/`loading`, closes it and repairs focus. JSON projection safely preserves
  an own enumerable column literally named `__proto__`.
- an empty format list, including one whose descriptors are all rejected during validation,
  disables the trigger because no export action exists.
- the multi-format menu (`role="menu"`) supports full arrow-key navigation — ArrowUp/ArrowDown move
  between items (opening the menu and seeding the right one focused, if it was closed), Home/End
  jump to the first/last item once open, Escape closes it and returns focus to the trigger button,
  and a completed export also returns focus to the trigger — not just Escape/click-outside as
  before.
- the positioned menu is constrained to the inline/block space reported by the positioner, wraps
  long localized format labels/descriptions, scrolls when necessary, and disables its transition
  under reduced motion.

---
