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:
@@ -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>
|
||||||
✕
|
<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>
|
>
|
||||||
|
✕
|
||||||
|
</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}
|
||||||
|
|||||||
Reference in New Issue
Block a user