diff --git a/components/calendar/__tests__/rsvp-buttons.test.tsx b/components/calendar/__tests__/rsvp-buttons.test.tsx new file mode 100644 index 000000000..195874b81 --- /dev/null +++ b/components/calendar/__tests__/rsvp-buttons.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ParticipantStatusBadge, RsvpButtons } from '../rsvp-buttons'; + +// "Tentative" (#eab308) and "Accepted" (#22c55e) text on white measured +// about 1.9:1 and 2.3:1, and the chosen Tentative was white on yellow. Text +// uses the darker status shades; a chosen answer gets dark text. + +describe('participation status colours', () => { + it('writes answers in readable status shades, not the light fills', () => { + render(); + const maybe = screen.getByRole('button', { name: 'participants.tentative' }); + expect(maybe.className).toContain('text-amber-600'); + expect(maybe.className).not.toMatch(/\btext-warning\b/); + expect(screen.getByRole('button', { name: 'participants.accepted' }).className).toContain('text-green-700'); + }); + + it('puts dark text on the chosen yellow answer', () => { + render(); + const maybe = screen.getByRole('button', { name: 'participants.tentative' }); + expect(maybe).toHaveAttribute('aria-pressed', 'true'); + expect(maybe.className).toContain('text-neutral-950'); + expect(maybe.className).not.toContain('text-warning-foreground'); + }); + + it('colours a participant\'s answer the same way', () => { + render(); + expect(screen.getByText('participants.accepted').className).toContain('text-green-700'); + }); +}); diff --git a/components/calendar/calendar-load-error-notice.tsx b/components/calendar/calendar-load-error-notice.tsx index b296f7ecc..b12856326 100644 --- a/components/calendar/calendar-load-error-notice.tsx +++ b/components/calendar/calendar-load-error-notice.tsx @@ -18,7 +18,7 @@ export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLo const t = useTranslations("calendar.status"); return (
- + {what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")}
))} {participants.length > 5 && ( @@ -652,47 +653,7 @@ export function EventDetailPopover({

{t("participants.rsvp_label")}

-
- - - -
+ )} @@ -701,29 +662,3 @@ export function EventDetailPopover({ return createPortal(popover, document.body); } - -function ParticipantStatusBadge({ - status, - t, -}: { - status: CalendarParticipant["participationStatus"]; - t: ReturnType; -}) { - const colors: Record = { - accepted: "text-success", - declined: "text-destructive", - tentative: "text-warning", - "needs-action": "text-muted-foreground", - }; - const labels: Record = { - accepted: "participants.accepted", - declined: "participants.declined", - tentative: "participants.tentative", - "needs-action": "participants.needs_action", - }; - return ( - - {t(labels[status] || labels["needs-action"])} - - ); -} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index dd264c6f1..509eccb1c 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -6,8 +6,9 @@ import { toast } from "@/stores/toast-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; -import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; +import { X, Trash2, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons"; import { IconButton } from "@/components/ui/icon-button"; +import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons"; import { format, parseISO, addHours, isSameDay } from "date-fns"; import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor"; @@ -1052,7 +1053,7 @@ export function EventModal({ ({t("participants.organizer").toLowerCase()}) )} - + ))} @@ -1209,7 +1210,7 @@ export function EventModal({ ({t("participants.organizer").toLowerCase()}) )} - + ))} @@ -1385,7 +1386,7 @@ export function EventModal({ it, Stalwart 1.0 refuses to save more participants - the organizer entry a save adds counts too. */} {maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && ( -

+

{t("participants.limit_warning", { max: maxParticipants })}

@@ -1660,65 +1661,9 @@ function RsvpBar({ status, onRespond, t }: { t: ReturnType; }) { return ( -
+
{t("participants.rsvp_label")} -
- - - -
+
); } - -function StatusBadge({ status, t }: { - status: CalendarParticipant['participationStatus']; - t: ReturnType; -}) { - // The organizer is marked by the "(组织者)" suffix on the name; the badge - // shows their participation status (organizers default to accepted) rather - // than repeating the organizer label. - const colors: Record = { - accepted: "text-success", - declined: "text-destructive", - tentative: "text-warning", - "needs-action": "text-muted-foreground", - }; - const labels: Record = { - accepted: "participants.accepted", - declined: "participants.declined", - tentative: "participants.tentative", - "needs-action": "participants.needs_action", - }; - return {t(labels[status] || labels["needs-action"])}; -} diff --git a/components/calendar/rsvp-buttons.tsx b/components/calendar/rsvp-buttons.tsx new file mode 100644 index 000000000..0b3aac608 --- /dev/null +++ b/components/calendar/rsvp-buttons.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Button, type ButtonSize } from "@/components/ui/button"; +import { Check } from "@/components/icons"; +import { cn } from "@/lib/utils"; +import type { CalendarParticipant } from "@/lib/jmap/types"; + +type Status = CalendarParticipant["participationStatus"]; + +/** + * Text in a participation status colour. The status fills (#22c55e, + * #eab308) are too light for text on a light ground, so text uses the + * darker shades (APP.md status colours). + */ +export const STATUS_TEXT: Record = { + accepted: "text-green-700 dark:text-green-400", + declined: "text-red-600 dark:text-red-400", + tentative: "text-amber-600 dark:text-amber-400", + "needs-action": "text-muted-foreground", +}; + +const STATUS_LABEL: Record = { + accepted: "participants.accepted", + declined: "participants.declined", + tentative: "participants.tentative", + "needs-action": "participants.needs_action", +}; + +/** A participant's answer, as a short coloured word. */ +export function ParticipantStatusBadge({ status, className }: { status: Status; className?: string }) { + const t = useTranslations("calendar"); + return ( + + {t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])} + + ); +} + +// The chosen answer is filled; dark text keeps it readable on the light +// green and yellow fills. +const CHOSEN: Record = { + accepted: "bg-success hover:bg-success/90 text-neutral-950 border-transparent", + tentative: "bg-warning hover:bg-warning/90 text-neutral-950 border-transparent", + declined: "bg-destructive hover:bg-destructive/90 text-destructive-foreground border-transparent", +}; + +const ANSWERS: { status: Status; label: string }[] = [ + { status: "accepted", label: "participants.accepted" }, + { status: "tentative", label: "participants.tentative" }, + { status: "declined", label: "participants.declined" }, +]; + +/** The three answers to an invitation, the current one filled. */ +export function RsvpButtons({ + status, + onRespond, + size = "sm", +}: { + status: Status | null; + onRespond: (status: Status) => void; + size?: ButtonSize; +}) { + const t = useTranslations("calendar"); + return ( +
+ {ANSWERS.map((answer) => { + const chosen = status === answer.status; + return ( + + ); + })} +
+ ); +}