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
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