feat(web): Favoriten-Widget — optionaler Titel (Kopfzeile, FavoritesConfig, i18n)

- Favoriten-Widget: config.title (nur typeof string) steuert eine Kopfzeile
  im Notiz-Look; leer -> keine Kopfzeile; im Bearbeitungsmodus immer ein
  Titelfeld (widgetNoDrag, 1500 ms entprellt, { title })
- Einstellungen -> Dashboard -> Widgets: FavoritesConfig (Muster NoteConfig)
  mit uebersetzter Beschriftung, Instanz-Kopfzeile zeigt "— {title}" jetzt
  fuer note UND favorites
- NoteConfig: hart kodiertes "Title" durch uebersetzten Schluessel ersetzt
- 3 neue Uebersetzungsschluessel (note.titleLabel, favorites.titleLabel,
  favorites.titlePlaceholder) in de/en
- favorites-Tests 7+4, Panel-Tests 7+3, Umlaut-Waechter 3/3 gruen, tsc Exit 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:39:48 +02:00
parent 684f063a8e
commit 7f1ee3b1f0
6 changed files with 294 additions and 10 deletions
@@ -182,3 +182,55 @@ describe('WidgetSettingsPanel — Kalender-Einstellungen (quick-260916-htc)', ()
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('30');
});
});
const fav = (de as { widgets: { favorites: Record<string, string> } }).widgets.favorites;
const note = (de as { widgets: { note: Record<string, string> } }).widgets.note;
describe('WidgetSettingsPanel — Favoriten-Titel (quick-260916-iex)', () => {
function renderFavoritesExpanded(config: Record<string, unknown> = {}) {
const onWidgetUpdate = vi.fn();
const widget = { id: 'f1', widgetType: 'favorites', config };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Favoriten #1/ }));
return { onWidgetUpdate };
}
it('Test B1: Instanz-Kopfzeile zeigt "— Titel", Feld vorbelegt, Aenderung ruft updateWidgetConfig/onWidgetUpdate auf', async () => {
const { onWidgetUpdate } = renderFavoritesExpanded({ title: 'Werkzeuge' });
expect(screen.getByRole('button', { name: /Favoriten #1/ })).toHaveTextContent('— Werkzeuge');
const input = screen.getByLabelText(fav.titleLabel) as HTMLInputElement;
expect(input.value).toBe('Werkzeuge');
fireEvent.change(input, { target: { value: 'Werkzeuge 2' } });
await vi.waitFor(() =>
expect(onWidgetUpdate).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' }),
);
expect(updateWidgetConfig).toHaveBeenCalledWith('f1', { title: 'Werkzeuge 2' });
});
it('Test B2: ohne Titel bzw. mit Leerraum-Titel keine "—" in der Kopfzeile, Feld leer', () => {
renderFavoritesExpanded({});
expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—');
expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe('');
cleanup();
renderFavoritesExpanded({ title: ' ' });
expect(screen.getByRole('button', { name: /Favoriten #1/ })).not.toHaveTextContent('—');
expect((screen.getByLabelText(fav.titleLabel) as HTMLInputElement).value).toBe('');
});
it('Test B3: Notiz-Instanz zeigt uebersetzte Beschriftung "Titel" statt hart kodiertem "Title"', () => {
const onWidgetUpdate = vi.fn();
const widget = { id: 'n1', widgetType: 'note', config: { title: 'Einkauf' } };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Notizen #1/ }));
const input = screen.getByLabelText(note.titleLabel) as HTMLInputElement;
expect(input.value).toBe('Einkauf');
expect(screen.queryByText('Title')).not.toBeInTheDocument();
});
});
@@ -117,11 +117,12 @@ export function WidgetSettingsPanel({
>
<span>
{t(def.nameKey)} #{idx + 1}
{widget.widgetType === 'note' &&
{(widget.widgetType === 'note' ||
widget.widgetType === 'favorites') &&
typeof widget.config.title === 'string' &&
widget.config.title !== '' && (
widget.config.title.trim() !== '' && (
<span className="ml-2 text-muted-foreground">
— {widget.config.title}
— {widget.config.title.trim()}
</span>
)}
</span>
@@ -184,6 +185,16 @@ export function WidgetSettingsPanel({
}
/>
)}
{/* Favorites config (quick-260916-iex) */}
{widget.widgetType === 'favorites' && (
<FavoritesConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
/>
)}
</div>
)}
</div>
@@ -413,7 +424,7 @@ function NoteConfig({
return (
<div>
<label htmlFor="note-title" className="mb-1 block text-sm text-foreground">
Title
{t('note.titleLabel')}
</label>
<input
id="note-title"
@@ -425,3 +436,37 @@ function NoteConfig({
</div>
);
}
// Favorites config (quick-260916-iex, Muster NoteConfig)
function FavoritesConfig({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (cfg: Record<string, unknown>) => void;
}) {
const t = useTranslations('widgets');
const rawTitle = typeof config.title === 'string' ? config.title : '';
// Anzeige getrimmt (Leerraum-only-Titel sind fachlich "kein Titel"),
// gesendet wird der rohe Tippwert (onChange trimmt nicht selbst).
const title = rawTitle.trim() === '' ? '' : rawTitle;
return (
<div>
<label
htmlFor="favorites-title"
className="mb-1 block text-sm text-foreground"
>
{t('favorites.titleLabel')}
</label>
<input
id="favorites-title"
type="text"
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
value={title}
placeholder={t('favorites.titlePlaceholder')}
onChange={(e) => onChange({ title: e.target.value })}
/>
</div>
);
}