refactor: redesign custom recurrence editor to match modal UI
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user