fix(quick-260921-bi2): a11y - Rollen, Semantik, Tab-Reihenfolge (32er-Rest, Teil 1/2)

Vier der fuenf noch offenen Regeln aus Teillieferung B bereinigt:

- noRedundantRoles (4): ueberfluessige role-Angaben auf button/time/select
  entfernt (maschineller --unsafe-Fix, gelesen).
- useAriaPropsForRole (1): entfaellt automatisch mit obigem Fix -- das
  <select role="combobox"> in search-widget.tsx verlangte die fehlenden
  ARIA-Attribute nur wegen der ueberfluessigen Rolle.
- useSemanticElements (4): admin-sidebar/settings-sidebar tragen role=
  "navigation" jetzt am <nav> statt am <aside> (kein doppeltes Landmark
  mehr); widget-wrapper.tsx ist jetzt ein echtes <article> statt
  div role="article"; DropZone.tsx trennt die Datei-Entfernen-Schaltflaeche
  als Geschwister ab, damit die Drop-Flaeche selbst ein echtes <button>
  werden kann (ein <button> darf kein zweites <button> verschachteln).
  Die Drop-Flaeche traegt darum jetzt Klick- UND Drag-Handler direkt am
  <button>, sonst waere sie ein "statisches" Element mit Ereignis-Handlern
  geworden (die zurueckgestellten Regeln noStaticElementInteractions /
  noNoninteractiveElementInteractions waeren neu angeschlagen -- geprueft,
  bleiben bei 5/11).
- noNoninteractiveTabindex (1): calculator-widget.tsx traegt jetzt
  tabIndex={-1} statt {0}. Die Zifferntasten sind bereits echte <button>
  und damit selbst Teil der Tab-Reihenfolge; Tastendruecke erreichen
  handleKeyboard weiterhin per Bubbling, sobald eine Taste fokussiert ist.
  Verhalten unveraendert, nur ein wirkungsloser Tab-Stopp auf dem Container
  selbst entfaellt.

Verbleibend: noLabelWithoutControl (22), naechster Schritt.

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 09:33:56 +02:00
parent 73ac08af17
commit 21c85a8b88
8 changed files with 64 additions and 54 deletions
@@ -15,7 +15,6 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
<div className="flex gap-2 overflow-x-auto pb-1"> <div className="flex gap-2 overflow-x-auto pb-1">
<button <button
type="button" type="button"
role="button"
onClick={() => onChange('all')} onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === 'all' value === 'all'
@@ -29,7 +28,6 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
<button <button
key={cat} key={cat}
type="button" type="button"
role="button"
onClick={() => onChange(cat)} onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${ className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === cat value === cat
@@ -28,7 +28,7 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
e.target.value = ''; e.target.value = '';
}; };
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
setIsDragOver(true); setIsDragOver(true);
}; };
@@ -37,7 +37,7 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
setIsDragOver(false); setIsDragOver(false);
}; };
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
setIsDragOver(false); setIsDragOver(false);
const file = e.dataTransfer.files?.[0]; const file = e.dataTransfer.files?.[0];
@@ -55,43 +55,52 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<div {/* a11y/useSemanticElements: kein role="button"-div mehr -- die
role="button" eigentliche Flaeche ist jetzt ein echtes <button> und traegt darum
tabIndex={0} auch Klick UND Drag-Ereignisse (ein <div> ohne Rolle mit
onClick={handleClick} Ereignis-Handlern waere ein statisches, nicht interaktives Element
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }} und faellt unter die zurueckgestellten Regeln noStaticElement-
onDragOver={handleDragOver} Interactions/noNoninteractiveElementInteractions). Die "Entfernen"-
onDragLeave={handleDragLeave} Schaltflaeche liegt darum als Geschwister daneben, nicht mehr
onDrop={handleDrop} verschachtelt in der Flaechen-Schaltflaeche -- ein <button> darf
className={`rounded-lg border-2 border-dashed p-8 text-center cursor-pointer transition-colors ${ kein weiteres <button> enthalten (ungueltiges Verschachteln). */}
isDragOver <div className="relative">
? 'border-primary bg-primary/5' <button
: 'border-border hover:border-primary/50' type="button"
}`} onClick={handleClick}
> onDragOver={handleDragOver}
{currentFile ? ( onDragLeave={handleDragLeave}
<div className="flex items-center justify-center gap-3"> onDrop={handleDrop}
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
{currentFile ? (
<span className="text-sm text-foreground"> <span className="text-sm text-foreground">
{currentFile.name}{' '} {currentFile.name}{' '}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({(currentFile.size / 1024).toFixed(1)} KB) ({(currentFile.size / 1024).toFixed(1)} KB)
</span> </span>
</span> </span>
<button ) : (
type="button" <div className="space-y-1">
onClick={(e) => { e.stopPropagation(); onClear(); }} <p className="text-sm text-muted-foreground">
className="text-muted-foreground hover:text-foreground text-xs border border-border rounded px-2 py-0.5" {t('dropZone.placeholder')}
> </p>
&#x2715; <p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
</button> </div>
</div> )}
) : ( </button>
<div className="space-y-1"> {currentFile && (
<p className="text-sm text-muted-foreground"> <button
{t('dropZone.placeholder')} type="button"
</p> onClick={(e) => { e.stopPropagation(); onClear(); }}
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p> className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
</div> >
&#x2715;
</button>
)} )}
</div> </div>
</div> </div>
@@ -89,17 +89,17 @@ export function AdminSidebar() {
]; ];
return ( return (
<aside // a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar" // das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
role="navigation" // Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
aria-label={t('admin.label')} // dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>.
> <aside className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
<div className="p-4"> <div className="p-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('admin.label')} {t('admin.label')}
</h2> </h2>
</div> </div>
<nav className="flex flex-col gap-1 px-3"> <nav className="flex flex-col gap-1 px-3" aria-label={t('admin.label')}>
{items {items
.filter((item) => item.show) .filter((item) => item.show)
.map((item) => ( .map((item) => (
@@ -322,7 +322,13 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
return ( return (
<div <div
className="flex h-full flex-col gap-1 p-1 select-none" className="flex h-full flex-col gap-1 p-1 select-none"
tabIndex={0} // a11y/noNoninteractiveTabindex: -1 statt 0 -- die Zahlentasten sind
// native <button>-Elemente und damit selbst Teil der Tab-Reihenfolge;
// Tastendruecke erreichen handleKeyboard weiterhin per Bubbling, sobald
// eine dieser Tasten fokussiert ist. -1 haelt den Container weiterhin
// programmatisch fokussierbar, entfernt aber den zusaetzlichen,
// wirkungslosen Tab-Stopp auf dem Container selbst.
tabIndex={-1}
role="application" role="application"
aria-label={t('calculator.name')} aria-label={t('calculator.name')}
onKeyDown={handleKeyboard} onKeyDown={handleKeyboard}
@@ -50,7 +50,6 @@ export function ClockWidget({ config }: WidgetProps) {
verwirft clamp() im Inline-Style, der Browser bekommt die Klasse verwirft clamp() im Inline-Style, der Browser bekommt die Klasse
ohnehin — und "auto = Klasse, fest = Zahl" ist die einfachere Form. */} ohnehin — und "auto = Klasse, fest = Zahl" ist die einfachere Form. */}
<time <time
role="time"
data-font-mode={fixedPt === null ? 'auto' : 'fixed'} data-font-mode={fixedPt === null ? 'auto' : 'fixed'}
className="font-semibold tabular-nums leading-none text-foreground text-[clamp(12px,min(20cqw,50cqh),400px)]" className="font-semibold tabular-nums leading-none text-foreground text-[clamp(12px,min(20cqw,50cqh),400px)]"
style={fixedPt === null ? undefined : { fontSize: `${fixedPt}pt` }} style={fixedPt === null ? undefined : { fontSize: `${fixedPt}pt` }}
@@ -89,7 +89,6 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
<div className="flex h-full items-center gap-2 px-1.5"> <div className="flex h-full items-center gap-2 px-1.5">
{/* Provider dropdown (D-14: ~120px left) */} {/* Provider dropdown (D-14: ~120px left) */}
<select <select
role="combobox"
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground" className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
value={selectedProviderId} value={selectedProviderId}
onChange={(e) => handleProviderChange(e.target.value)} onChange={(e) => handleProviderChange(e.target.value)}
@@ -35,10 +35,9 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm'; : 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm';
return ( return (
<div <article
className={cardClassName} className={cardClassName}
data-widget-id={widget.id} data-widget-id={widget.id}
role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType} aria-label={definition ? t(definition.nameKey) : widget.widgetType}
> >
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts. {/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
@@ -117,6 +116,6 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
</div> </div>
)} )}
</div> </div>
</div> </article>
); );
} }
@@ -33,18 +33,18 @@ export function SettingsSidebar() {
}; };
return ( return (
<aside // a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar" // darunter stehen zwei <nav>-Bereiche (Allgemein, Dashboard); ein drittes
role="navigation" // Navigations-Landmark auf dem Rahmen waere doppelt. Jedes <nav> traegt
aria-label={t('navLabel')} // stattdessen die Beschriftung seiner eigenen Kategorie.
> <aside className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
{/* Allgemein category — above Dashboard (Surface C, 07-06) */} {/* Allgemein category — above Dashboard (Surface C, 07-06) */}
<div className="p-4 pb-2"> <div className="p-4 pb-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('categoryGeneral')} {t('categoryGeneral')}
</h2> </h2>
</div> </div>
<nav className="mb-2 flex flex-col gap-1 px-3"> <nav className="mb-2 flex flex-col gap-1 px-3" aria-label={t('categoryGeneral')}>
<Link <Link
href="/settings/general/account" href="/settings/general/account"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${ className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
@@ -79,7 +79,7 @@ export function SettingsSidebar() {
{t('categoryDashboard')} {t('categoryDashboard')}
</h2> </h2>
</div> </div>
<nav className="flex flex-col gap-1 px-3"> <nav className="flex flex-col gap-1 px-3" aria-label={t('categoryDashboard')}>
{items.map((item) => ( {items.map((item) => (
<Link <Link
key={item.href} key={item.href}