feat: add controlled flip functionality to DutyTimelineCard component
- Introduced `isFlipped` and `onFlipChange` props to `DutyTimelineCard` for controlled flipping behavior. - Updated `DutyList` to manage the flipped state of duty cards, allowing only one card to be flipped at a time. - Enhanced user interaction by implementing dedicated functions for flipping the card to contacts and back, improving usability.
This commit is contained in:
@@ -108,6 +108,7 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||
|
||||
const todayKey = localDateString(new Date());
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [flippedDutyId, setFlippedDutyId] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
@@ -209,7 +210,14 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||
className="min-w-0 overflow-hidden"
|
||||
{...(isCurrent ? { "data-current-duty": true } : {})}
|
||||
>
|
||||
<DutyTimelineCard duty={duty} isCurrent={isCurrent} />
|
||||
<DutyTimelineCard
|
||||
duty={duty}
|
||||
isCurrent={isCurrent}
|
||||
isFlipped={flippedDutyId === duty.id}
|
||||
onFlipChange={(flip) =>
|
||||
setFlippedDutyId(flip ? duty.id : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,10 @@ import { Phone, ArrowLeft } from "lucide-react";
|
||||
export interface DutyTimelineCardProps {
|
||||
duty: DutyWithUser;
|
||||
isCurrent: boolean;
|
||||
/** When provided, card is controlled: only one card can be flipped at a time (managed by parent). */
|
||||
isFlipped?: boolean;
|
||||
/** Called when user flips to contacts (true) or back (false). Used with isFlipped for controlled mode. */
|
||||
onFlipChange?: (flipped: boolean) => void;
|
||||
}
|
||||
|
||||
function buildTimeStr(duty: DutyWithUser): string {
|
||||
@@ -48,9 +52,14 @@ const borderByType = {
|
||||
* Renders a single duty card. If duty has phone or username, wraps in a flip card
|
||||
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
|
||||
*/
|
||||
export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
||||
export function DutyTimelineCard({
|
||||
duty,
|
||||
isCurrent,
|
||||
isFlipped,
|
||||
onFlipChange,
|
||||
}: DutyTimelineCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [localFlipped, setLocalFlipped] = useState(false);
|
||||
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const hasContacts = Boolean(
|
||||
@@ -60,13 +69,31 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
||||
const typeLabel = isCurrent
|
||||
? t("duty.now_on_duty")
|
||||
: t(`event_type.${duty.event_type || "duty"}`);
|
||||
const timeStr = useMemo(
|
||||
() => buildTimeStr(duty),
|
||||
[duty.start_at, duty.end_at]
|
||||
);
|
||||
const timeStr = useMemo(() => buildTimeStr(duty), [duty]);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
const isControlled = onFlipChange != null;
|
||||
const flipped = isControlled ? (isFlipped ?? false) : localFlipped;
|
||||
|
||||
const handleFlipToBack = () => {
|
||||
if (isControlled) {
|
||||
onFlipChange?.(true);
|
||||
} else {
|
||||
setLocalFlipped(true);
|
||||
}
|
||||
setTimeout(() => backBtnRef.current?.focus(), 310);
|
||||
};
|
||||
|
||||
const handleFlipToFront = () => {
|
||||
if (isControlled) {
|
||||
onFlipChange?.(false);
|
||||
} else {
|
||||
setLocalFlipped(false);
|
||||
}
|
||||
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
||||
};
|
||||
|
||||
if (!hasContacts) {
|
||||
return (
|
||||
<div
|
||||
@@ -121,10 +148,7 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.show")}
|
||||
onClick={() => {
|
||||
setFlipped(true);
|
||||
setTimeout(() => backBtnRef.current?.focus(), 310);
|
||||
}}
|
||||
onClick={handleFlipToBack}
|
||||
>
|
||||
<Phone className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
@@ -159,10 +183,7 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||
aria-label={t("contact.back")}
|
||||
onClick={() => {
|
||||
setFlipped(false);
|
||||
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
||||
}}
|
||||
onClick={handleFlipToFront}
|
||||
>
|
||||
<ArrowLeft className="size-[18px]" aria-hidden />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user