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 todayKey = localDateString(new Date());
|
||||||
const [now, setNow] = useState(() => new Date());
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
const [flippedDutyId, setFlippedDutyId] = useState<number | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
@@ -209,7 +210,14 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
|||||||
className="min-w-0 overflow-hidden"
|
className="min-w-0 overflow-hidden"
|
||||||
{...(isCurrent ? { "data-current-duty": true } : {})}
|
{...(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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { Phone, ArrowLeft } from "lucide-react";
|
|||||||
export interface DutyTimelineCardProps {
|
export interface DutyTimelineCardProps {
|
||||||
duty: DutyWithUser;
|
duty: DutyWithUser;
|
||||||
isCurrent: boolean;
|
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 {
|
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
|
* 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).
|
* (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 { t } = useTranslation();
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [localFlipped, setLocalFlipped] = useState(false);
|
||||||
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const hasContacts = Boolean(
|
const hasContacts = Boolean(
|
||||||
@@ -60,13 +69,31 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
|||||||
const typeLabel = isCurrent
|
const typeLabel = isCurrent
|
||||||
? t("duty.now_on_duty")
|
? t("duty.now_on_duty")
|
||||||
: t(`event_type.${duty.event_type || "duty"}`);
|
: t(`event_type.${duty.event_type || "duty"}`);
|
||||||
const timeStr = useMemo(
|
const timeStr = useMemo(() => buildTimeStr(duty), [duty]);
|
||||||
() => buildTimeStr(duty),
|
|
||||||
[duty.start_at, duty.end_at]
|
|
||||||
);
|
|
||||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
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) {
|
if (!hasContacts) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -121,10 +148,7 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
|||||||
size="icon"
|
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"
|
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")}
|
aria-label={t("contact.show")}
|
||||||
onClick={() => {
|
onClick={handleFlipToBack}
|
||||||
setFlipped(true);
|
|
||||||
setTimeout(() => backBtnRef.current?.focus(), 310);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Phone className="size-[18px]" aria-hidden />
|
<Phone className="size-[18px]" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -159,10 +183,7 @@ export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
|
|||||||
size="icon"
|
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"
|
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")}
|
aria-label={t("contact.back")}
|
||||||
onClick={() => {
|
onClick={handleFlipToFront}
|
||||||
setFlipped(false);
|
|
||||||
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-[18px]" aria-hidden />
|
<ArrowLeft className="size-[18px]" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user