fix: name the event editor's title, date, time and select fields

This commit is contained in:
Linus Rath
2026-10-29 18:03:00 +01:00
parent dcc21acd07
commit 0e2a05e0ea
3 changed files with 57 additions and 24 deletions
@@ -0,0 +1,25 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { unnamedFields } from '@/lib/__tests__/helpers/form-labels';
import { EventModal } from '@/components/calendar/event-modal';
import type { Calendar } from '@/lib/jmap/types';
// The event editor names every field (see form-labels.test.tsx). The live
// audit found the title, the date and time fields and two selects unnamed.
const calendars = [
{ id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
{ id: 'cal-2', name: 'Home', myRights: { mayWriteAll: true, mayWriteOwn: true } },
] as unknown as Calendar[];
describe('event editor fields have labels', () => {
it('names the title, dates, times, calendar and repeat fields', () => {
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByRole('textbox', { name: 'form.title' })).toBeInTheDocument();
expect(screen.getByLabelText('form.start_date')).toHaveAttribute('type', 'date');
expect(screen.getByLabelText('form.end_time')).toHaveAttribute('type', 'time');
expect(screen.getByRole('combobox', { name: 'form.calendar_select' })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: 'recurrence.title' })).toBeInTheDocument();
expect(unnamedFields()).toEqual([]);
});
});
+28 -23
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
import { useTranslations, useLocale } from "next-intl";
import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
@@ -269,6 +269,9 @@ export function EventModal({
}: EventModalProps) {
const t = useTranslations("calendar");
const paint = useCvdColor();
// Ids that tie each visible label to its field.
const labelIdBase = useId();
const labelFor = (key: string) => `${labelIdBase}-${key}`;
const tCommon = useTranslations("common");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -1218,8 +1221,8 @@ export function EventModal({
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
<Input
<label htmlFor={labelFor("title")} className="text-sm font-medium mb-1 block">{t("form.title")}</label>
<Input id={labelFor("title")}
ref={titleRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -1229,8 +1232,8 @@ export function EventModal({
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("form.description")}</label>
<textarea
<label htmlFor={labelFor("description")} className="text-sm font-medium mb-1 block">{t("form.description")}</label>
<textarea id={labelFor("description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("form.description")}
@@ -1241,8 +1244,8 @@ export function EventModal({
</div>
<div>
<label className="text-sm font-medium mb-1 block">{t("form.location")}</label>
<Input
<label htmlFor={labelFor("location")} className="text-sm font-medium mb-1 block">{t("form.location")}</label>
<Input id={labelFor("location")}
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder={t("form.location")}
@@ -1251,13 +1254,14 @@ export function EventModal({
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<label htmlFor={labelFor("meeting_link")} className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Video className="w-4 h-4" />
<Video className="w-4 h-4" aria-hidden="true" />
{t("form.meeting_link")}
</span>
</label>
<Input
id={labelFor("meeting_link")}
type="url"
value={virtualLocation}
onChange={(e) => setVirtualLocation(e.target.value)}
@@ -1286,15 +1290,16 @@ export function EventModal({
</div>
<div>
<label className="text-sm font-medium mb-1 block">
<label htmlFor={labelFor("participants")} className="text-sm font-medium mb-1 block">
<span className="flex items-center gap-1.5">
<Users className="w-4 h-4" />
<Users className="w-4 h-4" aria-hidden="true" />
{t("participants.title")}
</span>
</label>
{/* The organizer decides who is invited; an attendee's save
keeps the list as it came. */}
<ParticipantInput
id={labelFor("participants")}
ref={participantInputRef}
participants={attendees}
onAdd={handleAddAttendee}
@@ -1338,8 +1343,8 @@ export function EventModal({
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input
<label htmlFor={labelFor("start_date")} className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input id={labelFor("start_date")}
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
@@ -1348,8 +1353,8 @@ export function EventModal({
</div>
{!allDay && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
<input
<label htmlFor={labelFor("start_time")} className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
<input id={labelFor("start_time")}
type="time"
value={startTime}
onChange={(e) => handleStartTimeChange(e.target.value)}
@@ -1358,8 +1363,8 @@ export function EventModal({
</div>
)}
<div>
<label className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
<input
<label htmlFor={labelFor("end_date")} className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
<input id={labelFor("end_date")}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
@@ -1368,8 +1373,8 @@ export function EventModal({
</div>
{!allDay && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
<input
<label htmlFor={labelFor("end_time")} className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
<input id={labelFor("end_time")}
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
@@ -1401,8 +1406,8 @@ export function EventModal({
{selectableCalendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select id={labelFor("calendar_select")}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
@@ -1417,9 +1422,9 @@ export function EventModal({
)}
<div>
<label className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<label htmlFor={labelFor("title-1")} className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<div className="flex items-center gap-2">
<select
<select id={labelFor("title-1")}
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
+4 -1
View File
@@ -18,6 +18,8 @@ interface ParticipantInputProps {
onAdd: (participant: Participant) => void;
onRemove: (email: string) => void;
disabled?: boolean;
/** The field's id, for the label above it. */
id?: string;
}
export interface ParticipantInputHandle {
@@ -26,7 +28,7 @@ export interface ParticipantInputHandle {
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled }, ref) {
export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantInputProps>(function ParticipantInput({ participants, onAdd, onRemove, disabled, id }, ref) {
const t = useTranslations("calendar.participants");
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<Participant[]>([]);
@@ -143,6 +145,7 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
otherwise offered logins for the e-mail placeholder. */}
<Input
ref={inputRef}
id={id}
type="search"
autoComplete="off"
enterKeyHint="enter"