AttachmentList

ui attachmentslist

Liste der Anhänge eines Liedes mit Typ-Icon, Gruppen-Trennern und Inline-Umbenennung. Enthält AttachmentIcon (Typ-Glyph je AttachmentKind) und die Sortier-/Beschriftungs-Helfer: Anzeige-Typen zuerst (PDF, Bild, ChordPro, SongBeamer, MusicXML, Link), dann Medien (Audio, YouTube), dann Sonstiges — ein Trenner erscheint nur beim Gruppenwechsel.

Keine interaktive Live-Demo — siehe Code & Beispiele unten.

Abhängigkeiten

npm: @repo/data-core tokens icon icon-button

Barrierefreiheit

  • Jede Zeilen-Aktion ist ein IconButton mit Pflicht-Label.
  • Die Inline-Umbenennung bestätigt mit Enter und bricht mit Escape ab.

Beispiele

Anhänge eines Liedes
<AttachmentList
  items={attachments}
  activeId={activeId}
  onOpen={open}
  onRename={rename}
  onDelete={remove}
/>
Nur das Typ-Icon
<AttachmentIcon kind="pdf" size={16} />

Genutzte Tokens · 20

--accent-border--accent-fg--accent-soft--bg--divider--fg--focus-ring--muted--radius-sm--section-bridge--section-chorus--section-verse--space-1--space-2--space-3--surface--surface-2--tag-warn-fg--text-sm--text-xs

Quelldateien

components/ui/AttachmentList.tsx tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { AttachmentKind } from '@repo/data-core';
import { AttachmentIcon } from './AttachmentIcon.tsx';
import { CheckIcon, CloseIcon } from './Icon.tsx';
import { IconButton } from './IconButton.tsx';
import { attachmentGroup, attachmentKindLabel, sortAttachments } from './attachments.ts';
import styles from './AttachmentList.module.css';

/**
 * Anhang-Liste im mga-scores-`AttachmentsSheet`-Muster: pro Zeile
 * `Typ-Icon | Name + Meta | Aktionen`, sortiert nach Anzeige-Gewicht und mit
 * Trennern zwischen Anzeige / Medien / Sonstiges.
 *
 * Rein präsentational: WELCHE Aktionen eine Zeile trägt, entscheidet die App
 * über `actionsFor` — jede App zeigt nur, was ihre Quelle wirklich kann
 * (worship: öffnen/Download/Favorit; ein Editor-Tool zusätzlich Bearbeiten/Löschen).
 */
export interface AttachmentAction {
  id: string;
  /** Wird als aria-label und title gesetzt (der Button zeigt nur ein Glyph). */
  label: string;
  icon: ReactNode;
  tone?: 'neutral' | 'accent' | 'danger';
  /** Für Toggles wie „Favorit" → aria-pressed. */
  pressed?: boolean;
  disabled?: boolean;
  onSelect: () => void;
}

export interface AttachmentListItem {
  id: string;
  name: string;
  kind: AttachmentKind;
  /** Zusatzinfo hinter dem Typ (Änderungsdatum, gekürzte URL). */
  meta?: string;
  favorite?: boolean;
}

export interface AttachmentListProps {
  items: readonly AttachmentListItem[];
  /** Der aktuell im Render-Bereich angezeigte Anhang. */
  activeId?: string | null;
  onSelect?: (item: AttachmentListItem) => void;
  actionsFor?: (item: AttachmentListItem) => AttachmentAction[];
  emptyHint?: string;
  /** Inline-Umbenennen: die App hält den Zustand, die Liste zeigt das Eingabefeld. */
  renamingId?: string | null;
  onRenameCommit?: (item: AttachmentListItem, name: string) => void;
  onRenameCancel?: () => void;
}

export function AttachmentList({
  items,
  activeId = null,
  onSelect,
  actionsFor,
  emptyHint = 'Keine Anhänge.',
  renamingId = null,
  onRenameCommit,
  onRenameCancel,
}: AttachmentListProps) {
  const sorted = sortAttachments(items);
  if (sorted.length === 0) return <p className={styles.empty}>{emptyHint}</p>;

  return (
    <ul className={styles.list}>
      {sorted.map((item, i) => {
        const vorige = sorted[i - 1];
        const trenner = vorige !== undefined && attachmentGroup(vorige.kind) !== attachmentGroup(item.kind);
        const aktiv = item.id === activeId;
        const aktionen = actionsFor?.(item) ?? [];
        const inhalt = (
          <>
            <span className={styles.icon} data-kind={item.kind}>
              <AttachmentIcon kind={item.kind} size={20} />
            </span>
            <span className={styles.body}>
              <span className={styles.name}>{item.name}</span>
              <span className={styles.sub}>
                <span>{attachmentKindLabel(item.kind)}</span>
                {item.meta && (
                  <>
                    <span className={styles.dot} aria-hidden />
                    <span className={styles.meta}>{item.meta}</span>
                  </>
                )}
              </span>
            </span>
          </>
        );

        const imUmbenennen = renamingId === item.id;

        return (
          <li key={item.id} className={trenner ? styles.afterSeparator : undefined}>
            <div className={`${styles.row} ${aktiv ? styles.active : ''} ${item.favorite ? styles.favorite : ''}`}>
              {imUmbenennen ? (
                <RenameField
                  kind={item.kind}
                  initial={item.name}
                  onCommit={(name) => onRenameCommit?.(item, name)}
                  onCancel={() => onRenameCancel?.()}
                />
              ) : onSelect ? (
                <button
                  type="button"
                  className={styles.main}
                  onClick={() => onSelect(item)}
                  {...(aktiv ? { 'aria-current': true as const } : {})}
                >
                  {inhalt}
                </button>
              ) : (
                <div className={styles.main}>{inhalt}</div>
              )}
              {!imUmbenennen && aktionen.length > 0 && (
                <div className={styles.actions}>
                  {aktionen.map((a) => (
                    <IconButton
                      key={a.id}
                      size="sm"
                      label={a.label}
                      onClick={a.onSelect}
                      {...(a.tone ? { tone: a.tone } : {})}
                      {...(a.pressed === undefined ? {} : { pressed: a.pressed })}
                      {...(a.disabled ? { disabled: true } : {})}
                    >
                      {a.icon}
                    </IconButton>
                  ))}
                </div>
              )}
            </div>
          </li>
        );
      })}
    </ul>
  );
}

/** Inline-Eingabe zum Umbenennen (Enter speichert, Esc bricht ab). */
function RenameField({
  kind,
  initial,
  onCommit,
  onCancel,
}: {
  kind: AttachmentKind;
  initial: string;
  onCommit: (name: string) => void;
  onCancel: () => void;
}) {
  const [wert, setWert] = useState(initial);
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => ref.current?.select(), []);
  const commit = () => {
    const name = wert.trim();
    if (name) onCommit(name);
    else onCancel();
  };
  return (
    <div className={styles.renameRow}>
      <span className={styles.icon} data-kind={kind}>
        <AttachmentIcon kind={kind} size={20} />
      </span>
      <input
        ref={ref}
        className={styles.renameInput}
        value={wert}
        onChange={(e) => setWert(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter') commit();
          else if (e.key === 'Escape') onCancel();
        }}
        aria-label="Neuer Name"
      />
      <div className={styles.actions}>
        <IconButton size="sm" tone="accent" label="Speichern" onClick={commit}>
          <CheckIcon size={16} />
        </IconButton>
        <IconButton size="sm" label="Abbrechen" onClick={onCancel}>
          <CloseIcon size={16} />
        </IconButton>
      </div>
    </div>
  );
}
components/ui/AttachmentList.module.css css
.list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 2px;
}

.empty {
  margin: 0;
  color: var(--muted);
  font-size: var(--text-xs);
}

/* Trenner zwischen den Gruppen (Anzeige → Medien → Sonstiges). */
.afterSeparator {
  margin-top: var(--space-2);
  padding-top: var(--space-2);
  border-top: 1px solid var(--divider);
}

.row {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
}
.row:hover {
  background: color-mix(in oklab, var(--fg), transparent 95%);
}

.active {
  background: var(--accent-soft);
  border-color: var(--accent-border);
}
.active:hover {
  background: var(--accent-soft);
}

.main {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-2);
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  border-radius: var(--radius-sm);
}
.main:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}

.icon {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  flex: 0 0 auto;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--muted);
}
.active .icon {
  color: var(--accent-fg);
  background: var(--surface);
}
/* Typ-Akzente wie im Bestand: Anzeige-Typen farbig, Medien/Sonstiges neutral. */
.icon[data-kind='pdf'],
.icon[data-kind='musicxml'] {
  color: var(--section-chorus);
}
.icon[data-kind='sng'],
.icon[data-kind='chordpro'] {
  color: var(--section-verse);
}
.icon[data-kind='image'] {
  color: var(--section-bridge);
}

.body {
  min-width: 0;
  display: grid;
  gap: 1px;
}

.name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-sm);
  font-weight: 500;
}

.sub {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  font-size: 10px;
  color: var(--muted);
}
.meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.dot {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: currentColor;
  flex: 0 0 auto;
  opacity: 0.6;
}

.actions {
  display: flex;
  align-items: center;
  gap: 1px;
  padding-right: 4px;
  flex: 0 0 auto;
}

/* Inline-Umbenennen. */
.renameRow {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2);
}
.renameInput {
  flex: 1;
  min-width: 0;
  height: 30px;
  border: 1px solid var(--accent-border);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--fg);
  font: inherit;
  font-size: var(--text-sm);
  padding: 0 var(--space-2);
}
.renameInput:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* Favorit: dezenter Rand links (der Stern trägt die Hauptaussage) — wie art-item--fav. */
.favorite {
  border-left-color: var(--tag-warn-fg);
}
components/ui/AttachmentIcon.tsx tsx
import type { AttachmentKind } from '@repo/data-core';
import {
  FileIcon,
  FileMusicIcon,
  FilePdfIcon,
  HeadphonesIcon,
  ImageIcon,
  LinkIcon,
  SlideIcon,
  SongIcon,
  YoutubeIcon,
  type IconProps,
} from './Icon.tsx';

/**
 * Typ-Icon eines Anhangs. Die Semantik ist aus dem mga-scores-`AttachmentsSheet`
 * übernommen (PDF-Dokument · Bild · Noten/Akkorde · Präsentations-Folien ·
 * Noten-Dokument · Audio · YouTube · Kette), nur im neuen Familien-Stil
 * (Tabler, Strichstärke 1.75) statt der handgezeichneten SVGs.
 *
 * Abweichung: mga-scores zeichnete für ChordPro einen G-Schlüssel — den gibt es
 * im Tabler-Set nicht; das Noten-Icon trägt dieselbe Bedeutung. Und `image`
 * fiel dort auf das Ketten-Icon zurück (Lücke), hier bekommt es sein Bild-Icon.
 */
const BY_KIND: Record<AttachmentKind, (p: IconProps) => React.JSX.Element> = {
  pdf: FilePdfIcon,
  image: ImageIcon,
  chordpro: SongIcon,
  sng: SlideIcon,
  musicxml: FileMusicIcon,
  audio: HeadphonesIcon,
  youtube: YoutubeIcon,
  link: LinkIcon,
  other: FileIcon,
};

export interface AttachmentIconProps extends IconProps {
  kind: AttachmentKind;
}

export function AttachmentIcon({ kind, size = 20, ...rest }: AttachmentIconProps) {
  const Glyph = BY_KIND[kind];
  return <Glyph size={size} {...rest} />;
}
components/ui/attachments.ts ts
import type { AttachmentKind } from '@repo/data-core';

/**
 * Anzeige-Ordnung und -Beschriftung der Anhang-Typen — 1:1 aus dem
 * mga-scores-`AttachmentsSheet` übernommen, damit die Sortierung und die
 * Gruppen-Trenner sich anfühlen wie im Bestand.
 *
 * Gewichte bündeln die primär interessanten Typen nach oben:
 *   Gruppe A (0–9)   Anzeige: PDF · Bild · ChordPro · SongBeamer · MusicXML · Link
 *   Gruppe B (10–19) Medien:  Audio · YouTube
 *   Gruppe C (20+)   Sonstiges
 * Ein Trenner erscheint nur beim Sprung zwischen diesen Gruppen.
 */
const SORT_WEIGHT: Record<AttachmentKind, number> = {
  pdf: 0,
  image: 1,
  chordpro: 2,
  sng: 3,
  musicxml: 4,
  link: 5,
  audio: 10,
  youtube: 11,
  other: 20,
};

const KIND_LABEL: Record<AttachmentKind, string> = {
  pdf: 'PDF',
  image: 'Bild',
  chordpro: 'ChordPro',
  sng: 'SongBeamer',
  musicxml: 'MusicXML',
  audio: 'Audio',
  youtube: 'YouTube',
  link: 'Link',
  other: 'Datei',
};

export function attachmentKindLabel(kind: AttachmentKind): string {
  return KIND_LABEL[kind];
}

export function attachmentSortWeight(kind: AttachmentKind): number {
  return SORT_WEIGHT[kind];
}

/** Gruppen-Eimer (0 Anzeige · 1 Medien · 2 Sonstiges) — steuert die Trenner. */
export function attachmentGroup(kind: AttachmentKind): number {
  const w = SORT_WEIGHT[kind];
  return w < 10 ? 0 : w < 20 ? 1 : 2;
}

/** Stabil nach Anzeige-Gewicht sortieren (gleiche Typen behalten ihre Reihenfolge). */
export function sortAttachments<T extends { kind: AttachmentKind }>(items: readonly T[]): T[] {
  return items
    .map((item, index) => ({ item, index }))
    .sort((a, b) => attachmentSortWeight(a.item.kind) - attachmentSortWeight(b.item.kind) || a.index - b.index)
    .map(({ item }) => item);
}

/** URL für die Meta-Zeile kürzen (Host + gekappter Pfad) — mga-scores-Verhalten. */
export function shortenUrl(url: string): string {
  try {
    const u = new URL(url);
    return u.host + (u.pathname.length > 14 ? `${u.pathname.slice(0, 14)}…` : u.pathname);
  } catch {
    return url.length > 30 ? `${url.slice(0, 30)}…` : url;
  }
}

/** Kurzes Datum für die Meta-Zeile („08. Jul."). */
export function formatAttachmentDate(iso: string): string {
  return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
}

Holen

# MCP (Claude Code / Cursor)
get_component({ name: "attachment-list" })

# Registry direkt
https://www.ekklesi.tools/design/registry/components/attachment-list.json