mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: make Tentative, Accepted and other status text readable on light backgrounds
This commit is contained in:
@@ -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(<RsvpButtons status={null} onRespond={vi.fn()} />);
|
||||
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(<RsvpButtons status="tentative" onRespond={vi.fn()} />);
|
||||
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(<ParticipantStatusBadge status="accepted" />);
|
||||
expect(screen.getByText('participants.accepted').className).toContain('text-green-700');
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLo
|
||||
const t = useTranslations("calendar.status");
|
||||
return (
|
||||
<div role="alert" className="flex items-center gap-2 border-b border-border bg-warning/10 px-4 py-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-warning" />
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<span className="min-w-0 flex-1">{what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
{t("retry")}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
|
||||
interface EventDetailPopoverProps {
|
||||
@@ -407,7 +408,7 @@ export function EventDetailPopover({
|
||||
{event.title || t("events.no_title")}
|
||||
</h3>
|
||||
{event.status === "tentative" && (
|
||||
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
|
||||
{t("detail.tentative")}
|
||||
</span>
|
||||
)}
|
||||
@@ -546,7 +547,7 @@ export function EventDetailPopover({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ParticipantStatusBadge status={p.status} t={t} />
|
||||
<ParticipantStatusBadge status={p.status} className="text-[10px]" />
|
||||
</div>
|
||||
))}
|
||||
{participants.length > 5 && (
|
||||
@@ -652,47 +653,7 @@ export function EventDetailPopover({
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t("participants.rsvp_label")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||
onClick={() => onRsvp("accepted")}
|
||||
className={
|
||||
userCurrentStatus === "accepted"
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"
|
||||
}
|
||||
>
|
||||
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||
onClick={() => onRsvp("tentative")}
|
||||
className={
|
||||
userCurrentStatus === "tentative"
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"
|
||||
}
|
||||
>
|
||||
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||
onClick={() => onRsvp("declined")}
|
||||
className={
|
||||
userCurrentStatus === "declined"
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"
|
||||
}
|
||||
>
|
||||
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
<RsvpButtons status={userCurrentStatus} onRespond={onRsvp} size="xs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -701,29 +662,3 @@ export function EventDetailPopover({
|
||||
|
||||
return createPortal(popover, document.body);
|
||||
}
|
||||
|
||||
function ParticipantStatusBadge({
|
||||
status,
|
||||
t,
|
||||
}: {
|
||||
status: CalendarParticipant["participationStatus"];
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
accepted: "text-success",
|
||||
declined: "text-destructive",
|
||||
tentative: "text-warning",
|
||||
"needs-action": "text-muted-foreground",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
accepted: "participants.accepted",
|
||||
declined: "participants.declined",
|
||||
tentative: "participants.tentative",
|
||||
"needs-action": "participants.needs_action",
|
||||
};
|
||||
return (
|
||||
<span className={`text-[10px] flex-shrink-0 ${colors[status] || ""}`}>
|
||||
{t(labels[status] || labels["needs-action"])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge status={p.status} t={t} />
|
||||
<ParticipantStatusBadge status={p.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1209,7 +1210,7 @@ export function EventModal({
|
||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge status={p.status} t={t} />
|
||||
<ParticipantStatusBadge status={p.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 && (
|
||||
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
|
||||
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" />
|
||||
{t("participants.limit_warning", { max: maxParticipants })}
|
||||
</p>
|
||||
@@ -1660,65 +1661,9 @@ function RsvpBar({ status, onRespond, t }: {
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={status === "accepted" ? "default" : "outline"}
|
||||
onClick={() => onRespond("accepted")}
|
||||
className={status === "accepted"
|
||||
? "bg-success hover:bg-success/80 text-success-foreground"
|
||||
: "text-success border-success/30 hover:bg-success/10"}
|
||||
>
|
||||
{status === "accepted" && <Check className="w-4 h-4 me-1" />}
|
||||
{t("participants.accepted")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={status === "tentative" ? "default" : "outline"}
|
||||
onClick={() => onRespond("tentative")}
|
||||
className={status === "tentative"
|
||||
? "bg-warning hover:bg-warning/80 text-warning-foreground"
|
||||
: "border border-warning/30 text-warning hover:bg-warning/10"}
|
||||
>
|
||||
{status === "tentative" && <Check className="w-4 h-4 me-1" />}
|
||||
{t("participants.tentative")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={status === "declined" ? "default" : "ghost"}
|
||||
onClick={() => onRespond("declined")}
|
||||
className={status === "declined"
|
||||
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
|
||||
: "text-destructive hover:bg-destructive/10"}
|
||||
>
|
||||
{status === "declined" && <Check className="w-4 h-4 me-1" />}
|
||||
{t("participants.declined")}
|
||||
</Button>
|
||||
</div>
|
||||
<RsvpButtons status={status} onRespond={onRespond} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, t }: {
|
||||
status: CalendarParticipant['participationStatus'];
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
// 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<string, string> = {
|
||||
accepted: "text-success",
|
||||
declined: "text-destructive",
|
||||
tentative: "text-warning",
|
||||
"needs-action": "text-muted-foreground",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
accepted: "participants.accepted",
|
||||
declined: "participants.declined",
|
||||
tentative: "participants.tentative",
|
||||
"needs-action": "participants.needs_action",
|
||||
};
|
||||
return <span className={`text-xs ${colors[status] || ""}`}>{t(labels[status] || labels["needs-action"])}</span>;
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<span className={cn("text-xs flex-shrink-0", STATUS_TEXT[status] ?? "", className)}>
|
||||
{t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The chosen answer is filled; dark text keeps it readable on the light
|
||||
// green and yellow fills.
|
||||
const CHOSEN: Record<string, string> = {
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ANSWERS.map((answer) => {
|
||||
const chosen = status === answer.status;
|
||||
return (
|
||||
<Button
|
||||
key={answer.status}
|
||||
size={size}
|
||||
variant="outline"
|
||||
aria-pressed={chosen}
|
||||
onClick={() => onRespond(answer.status)}
|
||||
className={chosen ? CHOSEN[answer.status] : STATUS_TEXT[answer.status]}
|
||||
>
|
||||
{chosen && <Check className="w-4 h-4 me-1" />}
|
||||
{t(answer.label)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user