fix(quick-260921-jt4): MarketplaceCard-Klick wird echte deckende Schaltflaeche

- Karten-onClick entfernt, deckende <button> als Geschwister traegt
  handleCardClick nur wenn isActive (D-01, verschachtelte Buttons
  vermieden wie bei DropZone.tsx aus 260921-bi2)
- Aktivieren/Deaktivieren-Schaltflaeche bleibt eigener Tab-Stopp,
  Fussbereich bekommt relative fuer korrekten Stapelkontext
- Neuer Katalogschluessel marketplace.openDetail in de.json/en.json
- Tests: echte Tastaturbetaetigung (Enter auf fokussierter
  Schaltflaeche) statt Klick-Behauptung; kein Overlay im
  nicht-aktivierten Zustand

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
2026-09-21 14:40:18 +02:00
parent 7557c9aafe
commit a8531d44df
4 changed files with 105 additions and 17 deletions
@@ -4,15 +4,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl useTranslations
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
useTranslations: () => (key: string, values?: Record<string, string | number>) => {
const translations: Record<string, string> = {
activate: 'Modul aktivieren',
deactivate: 'Deaktivieren',
statusActive: 'Aktiviert',
statusAvailable: 'Verfuegbar',
statusNoAccess: 'Kein Zugriff',
openDetail: 'Details zu {name} öffnen',
};
return translations[key] ?? key;
const template = translations[key] ?? key;
if (!values) return template;
return Object.entries(values).reduce(
(acc, [placeholder, value]) => acc.replace(`{${placeholder}}`, String(value)),
template,
);
},
}));
@@ -81,7 +87,15 @@ describe('MarketplaceCard', () => {
expect(onToggle).toHaveBeenCalledWith('mod-1', false);
});
it('shows the "Kein Zugriff" badge for an active module without access and clicking the card triggers the toast instead of navigating (D-08)', async () => {
it('does not render a card overlay button for a not-yet-activated module (D-01)', () => {
render(<MarketplaceCard {...baseProps} isActive={false} />);
expect(
screen.queryByRole('button', { name: 'Details zu Domaincheck öffnen' }),
).not.toBeInTheDocument();
});
it('shows the "Kein Zugriff" badge for an active module without access and mouse-clicking the overlay button triggers the toast instead of navigating (D-08)', async () => {
const onOpenDetail = vi.fn();
const onLockedClick = vi.fn();
const user = userEvent.setup();
@@ -98,13 +112,13 @@ describe('MarketplaceCard', () => {
expect(screen.getByText('Kein Zugriff')).toBeInTheDocument();
await user.click(screen.getByText('Domaincheck'));
await user.click(screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' }));
expect(onLockedClick).toHaveBeenCalledOnce();
expect(onOpenDetail).not.toHaveBeenCalled();
});
it('does not show the "Kein Zugriff" badge for an active module with access and clicking the card navigates', async () => {
it('does not show the "Kein Zugriff" badge for an active module with access and mouse-clicking the overlay button navigates', async () => {
const onOpenDetail = vi.fn();
const onLockedClick = vi.fn();
const user = userEvent.setup();
@@ -121,14 +135,80 @@ describe('MarketplaceCard', () => {
expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument();
await user.click(screen.getByText('Domaincheck'));
await user.click(screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' }));
expect(onOpenDetail).toHaveBeenCalledOnce();
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
expect(onLockedClick).not.toHaveBeenCalled();
});
it('leaves the not-activated state unchanged: no access badge, and clicking the card does not fire navigate or locked callbacks', async () => {
it('opens the module detail via a real keyboard activation of the focused overlay button (D-01)', async () => {
const onOpenDetail = vi.fn();
const user = userEvent.setup();
render(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={true}
onOpenDetail={onOpenDetail}
/>,
);
const overlay = screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' });
overlay.focus();
await user.keyboard('{Enter}');
expect(onOpenDetail).toHaveBeenCalledOnce();
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
});
it('triggers the locked hint via a real keyboard activation of the focused overlay button (D-01, D-08)', async () => {
const onLockedClick = vi.fn();
const onOpenDetail = vi.fn();
const user = userEvent.setup();
render(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={false}
onLockedClick={onLockedClick}
onOpenDetail={onOpenDetail}
/>,
);
const overlay = screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' });
overlay.focus();
await user.keyboard('{Enter}');
expect(onLockedClick).toHaveBeenCalledOnce();
expect(onOpenDetail).not.toHaveBeenCalled();
});
it('keeps the activate/deactivate footer button as its own reachable tab stop that does not additionally open the detail overlay', async () => {
const onToggle = vi.fn();
const onOpenDetail = vi.fn();
const user = userEvent.setup();
render(
<MarketplaceCard
{...baseProps}
isActive={true}
hasAccess={true}
onToggle={onToggle}
onOpenDetail={onOpenDetail}
/>,
);
await user.click(screen.getByRole('button', { name: 'Deaktivieren' }));
expect(onToggle).toHaveBeenCalledOnce();
expect(onToggle).toHaveBeenCalledWith('mod-1', true);
expect(onOpenDetail).not.toHaveBeenCalled();
});
it('leaves the not-activated state unchanged: no access badge, no overlay button, and clicking the visible text does not fire navigate or locked callbacks', async () => {
const onOpenDetail = vi.fn();
const onLockedClick = vi.fn();
const user = userEvent.setup();
@@ -114,16 +114,22 @@ export function MarketplaceCard({
return (
<div
className={`rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked
? 'opacity-60 cursor-not-allowed'
: isActive
? 'hover:shadow-md hover:border-primary/30 cursor-pointer'
: 'hover:shadow-md hover:border-primary/30'
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
}`}
style={{ minHeight: '180px' }}
onClick={isActive ? handleCardClick : undefined}
>
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
{isActive && (
<button
type="button"
onClick={handleCardClick}
aria-label={t('openDetail', { name })}
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
)}
{/* Header: icon + name + badges */}
<div className="flex items-start gap-3 mb-3">
<div className="rounded-md bg-muted p-2.5 shrink-0">
@@ -167,7 +173,7 @@ export function MarketplaceCard({
{/* Footer: action button — only rendered for users who may activate/deactivate */}
{canManage && (
<div className="flex items-center justify-end">
<div className="relative flex items-center justify-end">
<button
type="button"
onClick={(e) => {
+2 -1
View File
@@ -747,7 +747,8 @@
"accessDenied": "Zugriff verweigert",
"statusNoAccess": "Kein Zugriff",
"toastNoAccess": "Kein Zugriff auf dieses Modul — wenden Sie sich an Ihren Administrator.",
"notFound": "Dieses Modul wurde nicht gefunden."
"notFound": "Dieses Modul wurde nicht gefunden.",
"openDetail": "Details zu {name} öffnen"
},
"certManager": {
"title": "Zertifikat-Manager",
+2 -1
View File
@@ -747,7 +747,8 @@
"accessDenied": "Access denied",
"statusNoAccess": "No Access",
"toastNoAccess": "No access to this module — please contact your administrator.",
"notFound": "This module was not found."
"notFound": "This module was not found.",
"openDetail": "Open details for {name}"
},
"certManager": {
"title": "Certificate Manager",