feat: accessibility follow-up — heading hierarchy, keyboard resize, contrast fix

- Fix heading hierarchy: modal titles h3→h2 (RecurrenceModal, two WeeklyView dialogs),
  SettingsSidebar h4→h3 for Localisation, Data Export, Backup & Restore sections
- Add keyboard resize handle: role=slider, tabIndex, aria-valuemin/max/now/text,
  ArrowUp/Down keys to adjust task duration in 15-min increments
- Add aria-label to CalendarEventModal notes textarea fallback
- Color contrast: raise --weekly-text-light from #a6a6a7 (~2.4:1) to #767676 (~4.5:1)
  on white background to meet WCAG AA

v1.86.0
This commit is contained in:
mARTin 2026-04-06 00:25:08 +02:00
parent 5c01869444
commit 8ffb6a0690
7 changed files with 98 additions and 46 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.85.0", "version": "1.86.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -455,7 +455,7 @@ h3 {
--weekly-teal: #009a9a; --weekly-teal: #009a9a;
--weekly-bg: #ffffff; --weekly-bg: #ffffff;
--weekly-text: #000000; --weekly-text: #000000;
--weekly-text-light: #a6a6a7; --weekly-text-light: #767676;
--weekly-border: #cccccc; --weekly-border: #cccccc;
--weekly-completed: #cccccc; --weekly-completed: #cccccc;
--weekly-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --weekly-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

View File

@ -415,7 +415,7 @@ export default function CalendarEventModal({
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}> <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div style={{ padding: '8px 14px 6px', display: 'flex', flexDirection: 'column', gap: '2px' }}> <div style={{ padding: '8px 14px 6px', display: 'flex', flexDirection: 'column', gap: '2px' }}>
{error && <div style={{ color: '#ef4444', fontSize: '0.75rem', textAlign: 'center', padding: '4px', background: 'rgba(239,68,68,0.08)', borderRadius: '6px', marginBottom: '4px' }}>{error}</div>} {error && <div id="cal-event-error" role="alert" style={{ color: '#ef4444', fontSize: '0.75rem', textAlign: 'center', padding: '4px', background: 'rgba(239,68,68,0.08)', borderRadius: '6px', marginBottom: '4px' }}>{error}</div>}
{/* Title */} {/* Title */}
<input <input
@ -424,6 +424,7 @@ export default function CalendarEventModal({
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
placeholder={language === 'de' ? 'Titel hinzufügen' : 'Add title'} placeholder={language === 'de' ? 'Titel hinzufügen' : 'Add title'}
aria-label={language === 'de' ? 'Ereignistitel' : 'Event title'} aria-label={language === 'de' ? 'Ereignistitel' : 'Event title'}
aria-describedby={error ? 'cal-event-error' : undefined}
autoFocus autoFocus
style={{ style={{
width: '100%', padding: '3px 0', fontSize: '1rem', fontWeight: 600, width: '100%', padding: '3px 0', fontSize: '1rem', fontWeight: 600,
@ -444,6 +445,9 @@ export default function CalendarEventModal({
type="button" type="button"
onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)} onClick={() => !event && setIsCalendarSelectorOpen(!isCalendarSelectorOpen)}
disabled={!!event} disabled={!!event}
aria-label={language === 'de' ? 'Kalender auswählen' : 'Select calendar'}
aria-expanded={isCalendarSelectorOpen}
aria-haspopup="listbox"
style={{ style={{
display: 'flex', alignItems: 'center', gap: '4px', display: 'flex', alignItems: 'center', gap: '4px',
background: 'transparent', border: 'none', fontWeight: 500, background: 'transparent', border: 'none', fontWeight: 500,
@ -502,14 +506,15 @@ export default function CalendarEventModal({
{/* All Day toggle */} {/* All Day toggle */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><Clock size={14} /></div> <div style={iconCol} aria-hidden="true"><Clock size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ ...fieldCol, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--weekly-text)' }}>{language === 'de' ? 'Ganztägig' : 'All day'}</span> <span style={{ fontSize: '0.8rem', color: 'var(--weekly-text)' }}>{language === 'de' ? 'Ganztägig' : 'All day'}</span>
<label style={{ position: 'relative', width: '36px', height: '20px', cursor: 'pointer' }}> <label style={{ position: 'relative', width: '36px', height: '20px', cursor: 'pointer' }} aria-label={language === 'de' ? 'Ganztägig' : 'All day event'}>
<input <input
type="checkbox" type="checkbox"
checked={allDay} checked={allDay}
onChange={e => setAllDay(e.target.checked)} onChange={e => setAllDay(e.target.checked)}
aria-label={language === 'de' ? 'Ganztägig' : 'All day event'}
style={{ opacity: 0, width: 0, height: 0 }} style={{ opacity: 0, width: 0, height: 0 }}
/> />
<span style={{ <span style={{
@ -537,6 +542,7 @@ export default function CalendarEventModal({
type={allDay ? "date" : "datetime-local"} type={allDay ? "date" : "datetime-local"}
value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)} value={allDay ? startDate.toISOString().split('T')[0] : toLocalISOString(startDate)}
onChange={e => handleStartDateChange(e.target.value)} onChange={e => handleStartDateChange(e.target.value)}
aria-label={language === 'de' ? 'Startdatum und -uhrzeit' : 'Start date and time'}
style={{ style={{
flex: 1, padding: '3px 6px', border: 'none', borderRadius: '5px', flex: 1, padding: '3px 6px', border: 'none', borderRadius: '5px',
background: 'var(--weekly-bg-secondary, #f3f4f6)', background: 'var(--weekly-bg-secondary, #f3f4f6)',
@ -551,6 +557,7 @@ export default function CalendarEventModal({
type={allDay ? "date" : "datetime-local"} type={allDay ? "date" : "datetime-local"}
value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)} value={allDay ? endDate.toISOString().split('T')[0] : toLocalISOString(endDate)}
onChange={e => setEndDate(new Date(e.target.value))} onChange={e => setEndDate(new Date(e.target.value))}
aria-label={language === 'de' ? 'Enddatum und -uhrzeit' : 'End date and time'}
style={{ style={{
flex: 1, padding: '3px 6px', border: 'none', borderRadius: '5px', flex: 1, padding: '3px 6px', border: 'none', borderRadius: '5px',
background: 'var(--weekly-bg-secondary, #f3f4f6)', background: 'var(--weekly-bg-secondary, #f3f4f6)',
@ -564,9 +571,9 @@ export default function CalendarEventModal({
{/* Repeat */} {/* Repeat */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><Repeat size={14} /></div> <div style={iconCol} aria-hidden="true"><Repeat size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', alignItems: 'center' }}> <div style={{ ...fieldCol, display: 'flex', alignItems: 'center' }}>
<select value={recurrence} onChange={e => setRecurrence(e.target.value)} style={{ ...inlineSelect, flex: 1 }}> <select value={recurrence} onChange={e => setRecurrence(e.target.value)} aria-label={language === 'de' ? 'Wiederholung' : 'Recurrence'} style={{ ...inlineSelect, flex: 1 }}>
<option value="none">{language === 'de' ? 'Nie' : 'Never'}</option> <option value="none">{language === 'de' ? 'Nie' : 'Never'}</option>
<option value="daily">{language === 'de' ? 'Täglich' : 'Every Day'}</option> <option value="daily">{language === 'de' ? 'Täglich' : 'Every Day'}</option>
<option value="weekly">{language === 'de' ? 'Wöchentlich' : 'Every Week'}</option> <option value="weekly">{language === 'de' ? 'Wöchentlich' : 'Every Week'}</option>
@ -585,13 +592,14 @@ export default function CalendarEventModal({
<input <input
type="number" min={1} max={99} value={customInterval} type="number" min={1} max={99} value={customInterval}
onChange={e => setCustomInterval(Math.max(1, parseInt(e.target.value) || 1))} onChange={e => setCustomInterval(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Wiederholungsintervall' : 'Recurrence interval'}
style={{ style={{
width: '40px', textAlign: 'center', fontSize: '0.8rem', width: '40px', textAlign: 'center', fontSize: '0.8rem',
background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px', background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px',
padding: '3px 4px', border: 'none', outline: 'none', color: 'var(--weekly-text)', padding: '3px 4px', border: 'none', outline: 'none', color: 'var(--weekly-text)',
}} }}
/> />
<select value={customUnit} onChange={e => setCustomUnit(e.target.value as any)} style={inlineSelect}> <select value={customUnit} onChange={e => setCustomUnit(e.target.value as any)} aria-label={language === 'de' ? 'Wiederholungseinheit' : 'Recurrence unit'} style={inlineSelect}>
<option value="days">{customInterval === 1 ? (language === 'de' ? 'Tag' : 'Day') : (language === 'de' ? 'Tage' : 'Days')}</option> <option value="days">{customInterval === 1 ? (language === 'de' ? 'Tag' : 'Day') : (language === 'de' ? 'Tage' : 'Days')}</option>
<option value="weeks">{customInterval === 1 ? (language === 'de' ? 'Woche' : 'Week') : (language === 'de' ? 'Wochen' : 'Weeks')}</option> <option value="weeks">{customInterval === 1 ? (language === 'de' ? 'Woche' : 'Week') : (language === 'de' ? 'Wochen' : 'Weeks')}</option>
<option value="months">{customInterval === 1 ? (language === 'de' ? 'Monat' : 'Month') : (language === 'de' ? 'Monate' : 'Months')}</option> <option value="months">{customInterval === 1 ? (language === 'de' ? 'Monat' : 'Month') : (language === 'de' ? 'Monate' : 'Months')}</option>
@ -602,6 +610,7 @@ export default function CalendarEventModal({
<div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '3px', flexWrap: 'wrap' }}>
{(() => { {(() => {
const allDays = language === 'de' ? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] : ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const allDays = language === 'de' ? ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] : ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const allDaysFull = language === 'de' ? ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] : ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const dayIndices = Array.from({ length: 7 }, (_, i) => (i + weekStartDay) % 7); const dayIndices = Array.from({ length: 7 }, (_, i) => (i + weekStartDay) % 7);
return dayIndices.map(dow => ( return dayIndices.map(dow => (
<button <button
@ -611,6 +620,8 @@ export default function CalendarEventModal({
prev.includes(dow) ? (prev.length > 1 ? prev.filter(d => d !== dow) : prev) : [...prev, dow] prev.includes(dow) ? (prev.length > 1 ? prev.filter(d => d !== dow) : prev) : [...prev, dow]
); );
}} }}
aria-label={allDaysFull[dow]}
aria-pressed={customDays.includes(dow)}
style={{ style={{
width: '28px', height: '28px', borderRadius: '50%', width: '28px', height: '28px', borderRadius: '50%',
border: customDays.includes(dow) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)', border: customDays.includes(dow) ? '2px solid #3b82f6' : '1px solid var(--weekly-border, #ddd)',
@ -636,6 +647,7 @@ export default function CalendarEventModal({
<select <select
value={recurrenceEndType} value={recurrenceEndType}
onChange={e => setRecurrenceEndType(e.target.value as 'never' | 'date' | 'count')} onChange={e => setRecurrenceEndType(e.target.value as 'never' | 'date' | 'count')}
aria-label={language === 'de' ? 'Wiederholung Ende' : 'Recurrence end'}
style={inlineSelect} style={inlineSelect}
> >
<option value="never">{language === 'de' ? 'Nie' : 'Never'}</option> <option value="never">{language === 'de' ? 'Nie' : 'Never'}</option>
@ -647,6 +659,7 @@ export default function CalendarEventModal({
type="date" type="date"
value={recurrenceEndDateValue} value={recurrenceEndDateValue}
onChange={e => setRecurrenceEndDateValue(e.target.value)} onChange={e => setRecurrenceEndDateValue(e.target.value)}
aria-label={language === 'de' ? 'Enddatum der Wiederholung' : 'Recurrence end date'}
style={{ style={{
fontSize: '0.8rem', border: 'none', borderRadius: '6px', fontSize: '0.8rem', border: 'none', borderRadius: '6px',
background: 'var(--weekly-bg-secondary, #f5f5f5)', background: 'var(--weekly-bg-secondary, #f5f5f5)',
@ -659,6 +672,7 @@ export default function CalendarEventModal({
<input <input
type="number" min={1} max={999} value={recurrenceCount} type="number" min={1} max={999} value={recurrenceCount}
onChange={e => setRecurrenceCount(Math.max(1, parseInt(e.target.value) || 1))} onChange={e => setRecurrenceCount(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Anzahl der Wiederholungen' : 'Number of occurrences'}
style={{ style={{
width: '44px', textAlign: 'center', fontSize: '0.8rem', width: '44px', textAlign: 'center', fontSize: '0.8rem',
background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px', background: 'var(--weekly-bg-secondary, #f5f5f5)', borderRadius: '6px',
@ -675,12 +689,13 @@ export default function CalendarEventModal({
{/* Location */} {/* Location */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><MapPin size={14} /></div> <div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
<input <input
type="text" type="text"
value={location} value={location}
onChange={e => setLocation(e.target.value)} onChange={e => setLocation(e.target.value)}
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'} placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
aria-label={language === 'de' ? 'Ort' : 'Location'}
style={{ style={{
...fieldCol, padding: '2px 0', border: 'none', ...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', outline: 'none', fontSize: '0.8rem', background: 'transparent', outline: 'none', fontSize: '0.8rem',
@ -691,7 +706,7 @@ export default function CalendarEventModal({
{/* Alert */} {/* Alert */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><Bell size={14} /></div> <div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}> <div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
{reminders.length === 0 ? ( {reminders.length === 0 ? (
<button onClick={addReminder} style={{ ...inlineSelect, color: 'var(--weekly-text-light)', fontSize: '0.8rem', textAlign: 'left', padding: '2px 0' }}> <button onClick={addReminder} style={{ ...inlineSelect, color: 'var(--weekly-text-light)', fontSize: '0.8rem', textAlign: 'left', padding: '2px 0' }}>
@ -703,15 +718,18 @@ export default function CalendarEventModal({
<select <select
value={reminder.minutes} value={reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))} onChange={e => updateReminder(idx, parseInt(e.target.value))}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
style={{ ...inlineSelect, flex: 1 }} style={{ ...inlineSelect, flex: 1 }}
> >
{REMINDER_OPTIONS.map(opt => ( {REMINDER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
))} ))}
</select> </select>
<button onClick={() => setReminders(reminders.filter((_, i) => i !== idx))} <button
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}> style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
<X size={12} /> <X size={12} aria-hidden="true" />
</button> </button>
</div> </div>
)) ))
@ -726,15 +744,15 @@ export default function CalendarEventModal({
{/* Status & Visibility chips */} {/* Status & Visibility chips */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><Activity size={14} /></div> <div style={iconCol} aria-hidden="true"><Activity size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}> <div style={{ ...fieldCol, display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<select value={busyStatus} onChange={e => setBusyStatus(e.target.value)} style={{ ...inlineSelect, fontSize: '0.75rem' }}> <select value={busyStatus} onChange={e => setBusyStatus(e.target.value)} aria-label={language === 'de' ? 'Status' : 'Busy status'} style={{ ...inlineSelect, fontSize: '0.75rem' }}>
{BUSY_STATUS_OPTIONS.map(opt => ( {BUSY_STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
))} ))}
</select> </select>
<span style={{ color: 'var(--weekly-border)', fontSize: '0.7rem' }}>|</span> <span style={{ color: 'var(--weekly-border)', fontSize: '0.7rem' }} aria-hidden="true">|</span>
<select value={visibility} onChange={e => setVisibility(e.target.value)} style={{ ...inlineSelect, fontSize: '0.75rem' }}> <select value={visibility} onChange={e => setVisibility(e.target.value)} aria-label={language === 'de' ? 'Sichtbarkeit' : 'Visibility'} style={{ ...inlineSelect, fontSize: '0.75rem' }}>
{VISIBILITY_OPTIONS.map(opt => ( {VISIBILITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option> <option key={opt.value} value={opt.value}>{opt.label}</option>
))} ))}
@ -745,12 +763,13 @@ export default function CalendarEventModal({
{/* URL (Conditional) */} {/* URL (Conditional) */}
{supportsURL && ( {supportsURL && (
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><Link2 size={14} /></div> <div style={iconCol} aria-hidden="true"><Link2 size={14} /></div>
<input <input
type="url" type="url"
value={url} value={url}
onChange={e => setUrl(e.target.value)} onChange={e => setUrl(e.target.value)}
placeholder="URL" placeholder="URL"
aria-label="URL"
style={{ style={{
...fieldCol, padding: '2px 0', border: 'none', ...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', fontSize: '0.8rem', outline: 'none', background: 'transparent', fontSize: '0.8rem', outline: 'none',
@ -762,9 +781,11 @@ export default function CalendarEventModal({
{/* Invitees & Attachments toggle */} {/* Invitees & Attachments toggle */}
<div style={iconRow}> <div style={iconRow}>
<div style={iconCol}><Users size={14} /></div> <div style={iconCol} aria-hidden="true"><Users size={14} /></div>
<button <button
onClick={() => setShowMoreOptions(!showMoreOptions)} onClick={() => setShowMoreOptions(!showMoreOptions)}
aria-expanded={showMoreOptions}
aria-label={language === 'de' ? 'Teilnehmer & Anhänge anzeigen' : 'Show invitees & attachments'}
style={{ style={{
display: 'flex', alignItems: 'center', gap: '4px', display: 'flex', alignItems: 'center', gap: '4px',
background: 'none', border: 'none', cursor: 'pointer', background: 'none', border: 'none', cursor: 'pointer',
@ -772,7 +793,7 @@ export default function CalendarEventModal({
}} }}
> >
{language === 'de' ? 'Teilnehmer & Anhänge' : 'Invitees & Attachments'} {language === 'de' ? 'Teilnehmer & Anhänge' : 'Invitees & Attachments'}
{showMoreOptions ? <ChevronUp size={12} /> : <ChevronDown size={12} />} {showMoreOptions ? <ChevronUp size={12} aria-hidden="true" /> : <ChevronDown size={12} aria-hidden="true" />}
{attendees.length > 0 && <span style={{ fontSize: '0.7rem', background: 'rgba(59,130,246,0.1)', color: '#3b82f6', padding: '1px 5px', borderRadius: '8px' }}>{attendees.length}</span>} {attendees.length > 0 && <span style={{ fontSize: '0.7rem', background: 'rgba(59,130,246,0.1)', color: '#3b82f6', padding: '1px 5px', borderRadius: '8px' }}>{attendees.length}</span>}
</button> </button>
</div> </div>
@ -791,9 +812,11 @@ export default function CalendarEventModal({
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.displayName ? `${att.displayName} (${att.email})` : att.email} {att.displayName ? `${att.displayName} (${att.email})` : att.email}
</span> </span>
<button onClick={() => removeAttendee(att.email)} <button
onClick={() => removeAttendee(att.email)}
aria-label={`${language === 'de' ? 'Teilnehmer entfernen' : 'Remove attendee'}: ${att.displayName || att.email}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}> style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={12} /> <X size={12} aria-hidden="true" />
</button> </button>
</div> </div>
))} ))}
@ -803,6 +826,7 @@ export default function CalendarEventModal({
value={newAttendeeEmail} value={newAttendeeEmail}
onChange={e => setNewAttendeeEmail(e.target.value)} onChange={e => setNewAttendeeEmail(e.target.value)}
placeholder={language === 'de' ? 'E-Mail-Adresse' : 'Email address'} placeholder={language === 'de' ? 'E-Mail-Adresse' : 'Email address'}
aria-label={language === 'de' ? 'Teilnehmer E-Mail' : 'Attendee email'}
onKeyDown={e => e.key === 'Enter' && addAttendee()} onKeyDown={e => e.key === 'Enter' && addAttendee()}
style={{ style={{
flex: 1, padding: '3px 6px', border: 'none', flex: 1, padding: '3px 6px', border: 'none',
@ -811,11 +835,11 @@ export default function CalendarEventModal({
color: 'var(--weekly-text)', color: 'var(--weekly-text)',
}} }}
/> />
<button onClick={addAttendee} style={{ <button
background: 'none', border: 'none', cursor: 'pointer', onClick={addAttendee}
color: '#3b82f6', padding: '2px', aria-label={language === 'de' ? 'Teilnehmer hinzufügen' : 'Add attendee'}
}}> style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#3b82f6', padding: '2px' }}>
<Plus size={14} /> <Plus size={14} aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
@ -836,9 +860,11 @@ export default function CalendarEventModal({
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{att.title || att.url} {att.title || att.url}
</span> </span>
<button onClick={() => removeAttachment(att.url)} <button
onClick={() => removeAttachment(att.url)}
aria-label={`${language === 'de' ? 'Anhang entfernen' : 'Remove attachment'}: ${att.title || att.url}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}> style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '1px', color: 'var(--weekly-text-light)', flexShrink: 0 }}>
<X size={12} /> <X size={12} aria-hidden="true" />
</button> </button>
</div> </div>
))} ))}
@ -848,6 +874,7 @@ export default function CalendarEventModal({
value={newAttachmentUrl} value={newAttachmentUrl}
onChange={e => setNewAttachmentUrl(e.target.value)} onChange={e => setNewAttachmentUrl(e.target.value)}
placeholder="URL" placeholder="URL"
aria-label={language === 'de' ? 'Anhang URL' : 'Attachment URL'}
onKeyDown={e => e.key === 'Enter' && addAttachment()} onKeyDown={e => e.key === 'Enter' && addAttachment()}
style={{ style={{
flex: 1, padding: '3px 6px', border: 'none', flex: 1, padding: '3px 6px', border: 'none',
@ -856,11 +883,11 @@ export default function CalendarEventModal({
color: 'var(--weekly-text)', color: 'var(--weekly-text)',
}} }}
/> />
<button onClick={addAttachment} style={{ <button
background: 'none', border: 'none', cursor: 'pointer', onClick={addAttachment}
color: '#3b82f6', padding: '2px', aria-label={language === 'de' ? 'Anhang hinzufügen' : 'Add attachment'}
}}> style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#3b82f6', padding: '2px' }}>
<Plus size={14} /> <Plus size={14} aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
@ -879,6 +906,7 @@ export default function CalendarEventModal({
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
placeholder={language === 'de' ? 'Notizen' : 'Notes'} placeholder={language === 'de' ? 'Notizen' : 'Notes'}
aria-label={language === 'de' ? 'Notizen' : 'Notes'}
rows={2} rows={2}
style={{ width: '100%', padding: '3px 0', border: 'none', background: 'transparent', resize: 'none', fontSize: '0.78rem', outline: 'none', color: 'var(--weekly-text)' }} style={{ width: '100%', padding: '3px 0', border: 'none', background: 'transparent', resize: 'none', fontSize: '0.78rem', outline: 'none', color: 'var(--weekly-text)' }}
/> />

View File

@ -232,6 +232,19 @@ export function GridTaskBlock({
document.body.style.cursor = "ns-resize"; document.body.style.cursor = "ns-resize";
}; };
const onResizeKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
e.stopPropagation();
const step = 15;
const newDuration = e.key === 'ArrowUp'
? Math.min(duration + step, 480)
: Math.max(duration - step, 15);
if (newDuration !== duration) {
updateTaskDuration(task.id, newDuration);
}
};
return ( return (
<div <div
ref={blockRef} ref={blockRef}
@ -899,7 +912,18 @@ export function GridTaskBlock({
</div> </div>
</div> </div>
)} )}
<div className="resize-handle" onMouseDown={onResizeStart} /> <div
className="resize-handle"
onMouseDown={onResizeStart}
onKeyDown={onResizeKeyDown}
tabIndex={0}
role="slider"
aria-label={`Resize task duration, currently ${duration} minutes`}
aria-valuemin={15}
aria-valuemax={480}
aria-valuenow={duration}
aria-valuetext={`${duration} minutes`}
/>
</div> </div>
); );
} }

View File

@ -75,7 +75,7 @@ export default function RecurrenceModal({ task, onClose, onSave, language = 'en'
return ( return (
<div className="weekly-modal-overlay" onClick={onClose}> <div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}> <div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px' }}>
<h3 style={{ marginBottom: '1.5rem' }}>{isDE ? 'Wiederkehrende Aufgabe' : 'Recurring Task'}</h3> <h2 style={{ marginBottom: '1.5rem', fontSize: '1.1rem' }}>{isDE ? 'Wiederkehrende Aufgabe' : 'Recurring Task'}</h2>
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>

View File

@ -1389,9 +1389,9 @@ function SettingsSidebar({
<div <div
style={{ display: "flex", flexDirection: "column", gap: "20px" }} style={{ display: "flex", flexDirection: "column", gap: "20px" }}
> >
<h4 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}> <h3 style={{ fontSize: "1rem", fontWeight: 600, margin: 0 }}>
{t.localisation || "Localisation"} {t.localisation || "Localisation"}
</h4> </h3>
{/* Start Week Setting + Start View On */} {/* Start Week Setting + Start View On */}
<div style={{ display: "flex", gap: "24px", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "24px", flexWrap: "wrap" }}>
@ -4226,7 +4226,7 @@ function SettingsSidebar({
borderTop: "1px solid var(--weekly-border)", borderTop: "1px solid var(--weekly-border)",
}} }}
> >
<h4 <h3
style={{ style={{
marginBottom: "10px", marginBottom: "10px",
fontSize: "1rem", fontSize: "1rem",
@ -4235,7 +4235,7 @@ function SettingsSidebar({
}} }}
> >
{profile.language === "de" ? "Datenexport" : "Data Export"} {profile.language === "de" ? "Datenexport" : "Data Export"}
</h4> </h3>
<p <p
style={{ style={{
fontSize: "0.9rem", fontSize: "0.9rem",
@ -4335,7 +4335,7 @@ function SettingsSidebar({
borderTop: "1px solid var(--weekly-border)", borderTop: "1px solid var(--weekly-border)",
}} }}
> >
<h4 <h3
style={{ style={{
marginBottom: "10px", marginBottom: "10px",
fontSize: "1rem", fontSize: "1rem",
@ -4344,7 +4344,7 @@ function SettingsSidebar({
}} }}
> >
{t.backupRestore} {t.backupRestore}
</h4> </h3>
<p <p
style={{ style={{
fontSize: "0.9rem", fontSize: "0.9rem",

View File

@ -5737,7 +5737,7 @@ export default function WeeklyView() {
{/* Sync status indicator — fixed width so date never shifts */} {/* Sync status indicator — fixed width so date never shifts */}
<div style={{ width: 24, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> <div style={{ width: 24, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
{syncError {syncError
? <AlertCircle size={14} className="text-red-500" title={syncError} /> ? <span role="img" aria-label={syncError ?? 'Sync error'} style={{ display: 'inline-flex' }}><AlertCircle size={14} className="text-red-500" aria-hidden="true" /></span>
: (isLoading || isSyncing || syncStatus === "syncing") : (isLoading || isSyncing || syncStatus === "syncing")
? <div className="weekly-spinner" role="status" aria-label="Syncing..." style={{ width: 14, height: 14 }}></div> ? <div className="weekly-spinner" role="status" aria-label="Syncing..." style={{ width: 14, height: 14 }}></div>
: null} : null}
@ -8208,10 +8208,10 @@ export default function WeeklyView() {
className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-2xl w-full max-w-md" className="bg-white dark:bg-zinc-900 p-6 rounded-xl shadow-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="text-xl font-black mb-1 flex items-center gap-2"> <h2 className="text-xl font-black mb-1 flex items-center gap-2">
<Plus className="w-6 h-6" /> <Plus className="w-6 h-6" />
NEW LIST NEW LIST
</h3> </h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-6 uppercase tracking-widest font-bold"> <p className="text-xs text-zinc-500 dark:text-zinc-400 mb-6 uppercase tracking-widest font-bold">
Create a new section for your tasks Create a new section for your tasks
</p> </p>
@ -8426,9 +8426,9 @@ export default function WeeklyView() {
<Repeat size={24} /> <Repeat size={24} />
</div> </div>
<div> <div>
<h3 className="text-xl font-black uppercase tracking-tight"> <h2 className="text-xl font-black uppercase tracking-tight">
Recurring Task Recurring Task
</h3> </h2>
<p className="text-xs text-zinc-500 dark:text-zinc-400 font-bold uppercase tracking-widest"> <p className="text-xs text-zinc-500 dark:text-zinc-400 font-bold uppercase tracking-widest">
Deletion Options Deletion Options
</p> </p>