refactor: redesign custom recurrence editor to match modal UI

This commit is contained in:
Linus Rath
2026-06-12 00:15:54 +02:00
parent 38570b1723
commit fe5645c818
+134 -178
View File
@@ -2,7 +2,6 @@
import { useState } from "react"; import { useState } from "react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { X } from "lucide-react";
import { addYears, format } from "date-fns"; import { addYears, format } from "date-fns";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -19,13 +18,6 @@ const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"];
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 }; const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"]; const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const FREQ_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_freq_day",
weekly: "recurrence.editor_freq_week",
monthly: "recurrence.editor_freq_month",
yearly: "recurrence.editor_freq_year",
};
const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = { const UNIT_LABEL_KEYS: Record<EditorFrequency, string> = {
daily: "recurrence.editor_unit_days", daily: "recurrence.editor_unit_days",
weekly: "recurrence.editor_unit_weeks", weekly: "recurrence.editor_unit_weeks",
@@ -217,7 +209,7 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
); );
}; };
const handleSave = () => { const buildRule = (): CalendarRecurrenceRule => {
const built: CalendarRecurrenceRule = { const built: CalendarRecurrenceRule = {
"@type": "RecurrenceRule", "@type": "RecurrenceRule",
frequency, frequency,
@@ -252,134 +244,110 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
} }
} }
onSave(built); return built;
}; };
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"; const handleSave = () => onSave(buildRule());
const summary = buildRecurrenceSummary(buildRule(), t, locale);
const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return ( return (
<div className="mt-2 rounded-md border border-border bg-muted/20 p-4 space-y-4"> <div className="mt-2 rounded-md border border-border bg-muted/20 p-3 space-y-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-2 text-sm">
<div className="flex flex-wrap gap-1.5"> <span className="shrink-0">{t("recurrence.editor_every")}</span>
<Input
type="number"
min={1}
max={999}
value={interval}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-16 shrink-0"
aria-label={t("recurrence.editor_every")}
/>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as EditorFrequency)}
className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.title")}
>
{EDITOR_FREQUENCIES.map((f) => ( {EDITOR_FREQUENCIES.map((f) => (
<option key={f} value={f}>{t(UNIT_LABEL_KEYS[f])}</option>
))}
</select>
</div>
{frequency === "weekly" && (
<div className="flex gap-1">
{WEEKDAYS.map((day) => (
<button <button
key={f} key={day}
type="button" type="button"
onClick={() => setFrequency(f)} onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
aria-pressed={weekDays.includes(day)}
className={ className={
frequency === f weekDays.includes(day)
? "px-3 py-1.5 text-sm rounded-md border border-primary text-primary bg-primary/10" ? "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-primary text-primary bg-primary/10 transition-colors"
: "px-3 py-1.5 text-sm rounded-md border border-input text-foreground hover:bg-muted transition-colors" : "flex-1 min-w-0 px-1 py-1.5 text-xs font-medium rounded-md border border-input text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
} }
> >
{t(FREQ_LABEL_KEYS[f])} {weekdayName(day, locale, "short")}
</button> </button>
))} ))}
</div> </div>
<button )}
type="button"
onClick={onCancel}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("form.cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3"> {frequency === "yearly" && (
<p className="text-sm font-medium">{t("recurrence.editor_repeats_on")}</p>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span>{t("recurrence.editor_every")}</span> <span className="shrink-0">{capitalize(t("recurrence.editor_in"))}</span>
<Input <select
type="number" value={month}
min={1} onChange={(e) => setMonth(parseInt(e.target.value, 10))}
max={999} className={`${selectCls} flex-1 min-w-0`}
value={interval} aria-label={t("recurrence.editor_in")}
onChange={(e) => { >
const n = parseInt(e.target.value, 10); {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1); <option key={m} value={m}>{capitalize(monthName(m, locale))}</option>
}}
className="w-20"
aria-label={t("recurrence.editor_every")}
/>
<span>{t(UNIT_LABEL_KEYS[frequency])}</span>
</div>
{frequency === "weekly" && (
<div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWeekDay(day)}
title={weekdayName(day, locale)}
className={
weekDays.includes(day)
? "px-2.5 py-1.5 text-sm rounded-md border border-primary text-primary bg-primary/10"
: "px-2.5 py-1.5 text-sm rounded-md border border-input text-foreground hover:bg-muted transition-colors"
}
>
{weekdayName(day, locale, "short")}
</button>
))} ))}
</div> </select>
)} </div>
)}
{frequency === "yearly" && ( {(frequency === "monthly" || frequency === "yearly") && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span>{t("recurrence.editor_in")}</span> <select
<select value={monthlyMode}
value={month} onChange={(e) => setMonthlyMode(e.target.value as MonthlyMode)}
onChange={(e) => setMonth(parseInt(e.target.value, 10))} className={`${selectCls} shrink-0`}
className={selectCls} aria-label={t("recurrence.editor_repeats_on")}
aria-label={t("recurrence.editor_in")} >
> <option value="day">{capitalize(t("recurrence.editor_on_day"))}</option>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => ( <option value="nth">{capitalize(t("recurrence.editor_on_the"))}</option>
<option key={m} value={m}>{capitalize(monthName(m, locale))}</option> </select>
))} {monthlyMode === "day" ? (
</select> <Input
</div> type="number"
)} min={1}
max={31}
{(frequency === "monthly" || frequency === "yearly") && ( value={monthDay}
<div className="space-y-2"> onChange={(e) => {
<label className="flex items-center gap-2 text-sm"> const n = parseInt(e.target.value, 10);
<input setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
type="radio" }}
name="recurrence-monthly-mode" className="w-16 shrink-0"
checked={monthlyMode === "day"} aria-label={t("recurrence.editor_on_day")}
onChange={() => setMonthlyMode("day")} />
className="border-input" ) : (
/> <>
<span>{t("recurrence.editor_on_day")}</span>
<Input
type="number"
min={1}
max={31}
value={monthDay}
disabled={monthlyMode !== "day"}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMonthDay(Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : 1);
}}
className="w-20"
aria-label={t("recurrence.editor_on_day")}
/>
</label>
<label className="flex flex-wrap items-center gap-2 text-sm">
<input
type="radio"
name="recurrence-monthly-mode"
checked={monthlyMode === "nth"}
onChange={() => setMonthlyMode("nth")}
className="border-input"
/>
<span>{t("recurrence.editor_on_the")}</span>
<select <select
value={nth} value={nth}
disabled={monthlyMode !== "nth"}
onChange={(e) => setNth(parseInt(e.target.value, 10))} onChange={(e) => setNth(parseInt(e.target.value, 10))}
className={selectCls} className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")} aria-label={t("recurrence.editor_on_the")}
> >
{[1, 2, 3, 4, -1].map((n) => ( {[1, 2, 3, 4, -1].map((n) => (
@@ -388,83 +356,71 @@ export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: Recurre
</select> </select>
<select <select
value={nthDay} value={nthDay}
disabled={monthlyMode !== "nth"}
onChange={(e) => setNthDay(e.target.value)} onChange={(e) => setNthDay(e.target.value)}
className={selectCls} className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_on_the")} aria-label={t("recurrence.editor_on_the")}
> >
{WEEKDAYS.map((d) => ( {WEEKDAYS.map((d) => (
<option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option> <option key={d} value={d}>{capitalize(weekdayName(d, locale))}</option>
))} ))}
</select> </select>
</label> </>
</div> )}
)} </div>
</div> )}
<div className="space-y-2"> <div className="flex items-center gap-2 text-sm">
<p className="text-sm font-medium">{t("recurrence.editor_ends")}</p> <span className="shrink-0">{t("recurrence.editor_ends")}</span>
<label className="flex items-center gap-2 text-sm"> <select
<input value={endsMode}
type="radio" onChange={(e) => setEndsMode(e.target.value as EndsMode)}
name="recurrence-ends-mode" className={`${selectCls} ${endsMode === "never" ? "flex-1" : "shrink-0"} min-w-0`}
checked={endsMode === "never"} aria-label={t("recurrence.editor_ends")}
onChange={() => setEndsMode("never")} >
className="border-input" <option value="never">{t("recurrence.editor_never")}</option>
/> <option value="on">{t("recurrence.until")}</option>
<span>{t("recurrence.editor_never")}</span> <option value="after">{t("recurrence.editor_ends_after")}</option>
</label> </select>
<label className="flex items-center gap-2 text-sm"> {endsMode === "on" && (
<input
type="radio"
name="recurrence-ends-mode"
checked={endsMode === "on"}
onChange={() => setEndsMode("on")}
className="border-input"
/>
<span>{t("recurrence.editor_ends_on")}</span>
<input <input
type="date" type="date"
value={untilDate} value={untilDate}
disabled={endsMode !== "on"}
onChange={(e) => setUntilDate(e.target.value)} onChange={(e) => setUntilDate(e.target.value)}
className={selectCls} className={`${selectCls} flex-1 min-w-0`}
aria-label={t("recurrence.editor_ends_on")} aria-label={t("recurrence.editor_ends_on")}
/> />
</label> )}
<label className="flex items-center gap-2 text-sm"> {endsMode === "after" && (
<input <>
type="radio" <Input
name="recurrence-ends-mode" type="number"
checked={endsMode === "after"} min={1}
onChange={() => setEndsMode("after")} max={999}
className="border-input" value={count}
/> onChange={(e) => {
<span>{t("recurrence.editor_ends_after")}</span> const n = parseInt(e.target.value, 10);
<Input setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
type="number" }}
min={1} className="w-16 shrink-0"
max={999} aria-label={t("recurrence.editor_ends_after")}
value={count} />
disabled={endsMode !== "after"} <span className="text-muted-foreground truncate">{t("recurrence.editor_occurrences")}</span>
onChange={(e) => { </>
const n = parseInt(e.target.value, 10); )}
setCount(Number.isFinite(n) ? Math.max(1, n) : 1);
}}
className="w-20"
aria-label={t("recurrence.editor_ends_after")}
/>
<span>{t("recurrence.editor_occurrences")}</span>
</label>
</div> </div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<Button variant="outline" size="sm" onClick={onCancel}> <p className="text-xs text-muted-foreground truncate min-w-0" title={summary ?? undefined}>
{t("form.cancel")} {summary}
</Button> </p>
<Button size="sm" onClick={handleSave}> <div className="flex gap-2 shrink-0">
{t("form.save")} <Button variant="outline" size="sm" onClick={onCancel}>
</Button> {t("form.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("form.save")}
</Button>
</div>
</div> </div>
</div> </div>
); );