️ fix: resolve all svelte-check a11y warnings across web apps

- Fix 121 accessibility warnings across 9 web apps (manacore, clock, chat,
  manadeck, calendar, zitare, contacts, picture, todo)
- Add proper ARIA attributes (role, tabindex, aria-label) to interactive elements
- Add onkeydown handlers alongside onclick for keyboard accessibility
- Add svelte-ignore comments for intentional patterns (modals, dropdowns)
- Update svelte-check threshold from error to warning in pre-commit hook
- Fix script compatibility for bash 3.x (remove associative arrays)
- Add comprehensive documentation for svelte-check patterns and fixes

All web apps now pass svelte-check with 0 errors and 0 warnings.
Pre-commit hooks will block any future commits with warnings.
This commit is contained in:
Wuesteon 2025-12-15 19:09:01 +01:00
parent b949037fa5
commit 42e5e97390
101 changed files with 1048 additions and 558 deletions

View file

@ -23,6 +23,7 @@
let saving = $state(false);
let deleting = $state(false);
let uploadingPhoto = $state(false);
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let photoInput: HTMLInputElement;
// Edit form state
@ -1089,15 +1090,6 @@
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
gap: 1rem;
}
.spinner-lg {
width: 3rem;
height: 3rem;
@ -1105,11 +1097,6 @@
animation: spin 1s linear infinite;
}
.loading-text {
color: hsl(var(--color-muted-foreground));
font-size: 0.9375rem;
}
/* Error */
.error-container {
display: flex;

View file

@ -19,6 +19,7 @@
// Infinite scroll
let intersectionObserver: IntersectionObserver | null = null;
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let loadMoreTrigger: HTMLDivElement;
// Batch selection state

View file

@ -445,12 +445,6 @@
}
/* Loading & Empty */
.loading {
display: flex;
justify-content: center;
padding: 1.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;

View file

@ -157,9 +157,10 @@
>
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
<span class="filter-label" id="tag-filter-label">{$_('filters.tag')}</span>
<select
class="filter-select"
aria-labelledby="tag-filter-label"
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
@ -172,9 +173,10 @@
<!-- Contact Info Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.contactInfo')}</label>
<span class="filter-label" id="contact-filter-label">{$_('filters.contactInfo')}</span>
<select
class="filter-select"
aria-labelledby="contact-filter-label"
value={contactFilter}
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
>
@ -188,9 +190,10 @@
<!-- Birthday Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
<span class="filter-label" id="birthday-filter-label">{$_('filters.birthdayLabel')}</span>
<select
class="filter-select"
aria-labelledby="birthday-filter-label"
value={birthdayFilter}
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
>
@ -204,9 +207,10 @@
<!-- Company Filter -->
{#if companies.length > 0}
<div class="filter-section">
<label class="filter-label">{$_('filters.company')}</label>
<span class="filter-label" id="company-filter-label">{$_('filters.company')}</span>
<select
class="filter-select"
aria-labelledby="company-filter-label"
value={selectedCompany || ''}
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
>
@ -320,9 +324,10 @@
<div class="filter-panel">
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
<span class="filter-label" id="tag-filter-label">{$_('filters.tag')}</span>
<select
class="filter-select"
aria-labelledby="tag-filter-label"
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
@ -335,9 +340,10 @@
<!-- Contact Info Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.contactInfo')}</label>
<span class="filter-label" id="contact-filter-label">{$_('filters.contactInfo')}</span>
<select
class="filter-select"
aria-labelledby="contact-filter-label"
value={contactFilter}
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
>
@ -351,9 +357,10 @@
<!-- Birthday Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
<span class="filter-label" id="birthday-filter-label">{$_('filters.birthdayLabel')}</span>
<select
class="filter-select"
aria-labelledby="birthday-filter-label"
value={birthdayFilter}
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
>
@ -367,9 +374,10 @@
<!-- Company Filter -->
{#if companies.length > 0}
<div class="filter-section">
<label class="filter-label">{$_('filters.company')}</label>
<span class="filter-label" id="company-filter-label">{$_('filters.company')}</span>
<select
class="filter-select"
aria-labelledby="company-filter-label"
value={selectedCompany || ''}
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
>

View file

@ -15,6 +15,7 @@
let loading = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let inputElement: HTMLInputElement;
// Reset state when modal opens
@ -109,12 +110,13 @@
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_interactive_supports_focus -->
<div
class="search-backdrop"
role="dialog"
aria-modal="true"
aria-label="Kontakt suchen"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>

View file

@ -49,10 +49,14 @@
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="bg-card rounded-xl shadow-xl w-full max-w-md p-6 space-y-6">
<!-- Header -->
@ -62,6 +66,7 @@
type="button"
onclick={onClose}
class="text-muted-foreground hover:text-foreground transition-colors"
aria-label={$_('common.close')}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -92,8 +97,10 @@
<!-- Format Selection -->
<div class="space-y-3">
<label class="block text-sm font-medium text-foreground">{$_('export.format')}</label>
<div class="grid grid-cols-2 gap-3">
<span class="block text-sm font-medium text-foreground" id="format-label"
>{$_('export.format')}</span
>
<div class="grid grid-cols-2 gap-3" role="group" aria-labelledby="format-label">
<button
type="button"
onclick={() => (format = 'vcard')}

View file

@ -212,6 +212,7 @@
export { resetZoom, zoomIn, zoomOut };
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={containerElement}
class="network-graph-container"
@ -253,6 +254,7 @@
{@const isSelected = node.id === networkStore.selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
@ -262,6 +264,7 @@
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleNodeClick(node)}
role="button"
tabindex="0"
aria-label={node.name}

View file

@ -37,6 +37,7 @@
previousNodeCount = currentNodeCount;
});
// svelte-ignore non_reactive_update - Component reference doesn't need reactivity
let graphComponent: NetworkGraph;
let graphContainer: HTMLDivElement;

View file

@ -404,28 +404,6 @@
opacity: 1;
}
/* Loading */
.loading-container {
display: flex;
justify-content: center;
padding: 4rem 0;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
display: flex;