From 38b8e36fab0e268b40fa35d376ff0482e5d90525 Mon Sep 17 00:00:00 2001 From: A Charlwood Date: Fri, 13 Feb 2026 17:45:59 +0000 Subject: [PATCH] Task 17: Add KPI flip card interaction Add click-to-flip interaction on LatestResults metric cards: - CSS perspective-based 3D flip (400ms ease-in-out) - Front face shows value/label/sub, back shows explanation text - Single-card accordion: only one card flipped at a time - Keyboard accessible: Enter/Space to flip, aria-label with state - prefers-reduced-motion: instant visibility swap, no 3D animation - Back face: accent-light background, 12px secondary text Co-Authored-By: Claude Opus 4.6 --- src/components/tiles/LatestResultsTile.tsx | 72 +++++++++++++++++++--- src/index.css | 48 +++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/components/tiles/LatestResultsTile.tsx b/src/components/tiles/LatestResultsTile.tsx index 8823c28..1c60969 100644 --- a/src/components/tiles/LatestResultsTile.tsx +++ b/src/components/tiles/LatestResultsTile.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState, useCallback } from 'react' import { Card, CardHeader } from '../Card' import { kpis } from '@/data/kpis' import type { KPI } from '@/types/pmr' @@ -11,14 +11,31 @@ const colorMap: Record = { interface MetricCardProps { kpi: KPI + isFlipped: boolean + onFlip: (id: string) => void } -function MetricCard({ kpi }: MetricCardProps) { - const cardStyles: React.CSSProperties = { - padding: '14px', +function MetricCard({ kpi, isFlipped, onFlip }: MetricCardProps) { + const handleClick = () => { + onFlip(kpi.id) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onFlip(kpi.id) + } + } + + const outerStyles: React.CSSProperties = { borderRadius: 'var(--radius-sm)', border: '1px solid var(--border-light)', background: 'var(--bg-dashboard)', + overflow: 'hidden', + } + + const innerStyles: React.CSSProperties = { + padding: '14px', } const valueStyles: React.CSSProperties = { @@ -43,16 +60,48 @@ function MetricCard({ kpi }: MetricCardProps) { marginTop: '4px', } + const backStyles: React.CSSProperties = { + padding: '14px', + background: 'var(--accent-light)', + fontSize: '12px', + color: 'var(--text-secondary)', + lineHeight: 1.5, + fontFamily: 'var(--font-ui)', + display: 'flex', + alignItems: 'center', + } + return ( -
-
{kpi.value}
-
{kpi.label}
-
{kpi.sub}
+
+
+
+
{kpi.value}
+
{kpi.label}
+
{kpi.sub}
+
+
+ {kpi.explanation} +
+
) } export function LatestResultsTile() { + const [flippedCardId, setFlippedCardId] = useState(null) + + const handleFlip = useCallback((id: string) => { + setFlippedCardId((prev) => (prev === id ? null : id)) + }, []) + const gridStyles: React.CSSProperties = { display: 'grid', gridTemplateColumns: '1fr 1fr', @@ -64,7 +113,12 @@ export function LatestResultsTile() {
{kpis.map((kpi) => ( - + ))}
diff --git a/src/index.css b/src/index.css index cb4968c..c175242 100644 --- a/src/index.css +++ b/src/index.css @@ -277,6 +277,54 @@ html { } } +/* KPI flip cards */ +.metric-card { + perspective: 1000px; + cursor: pointer; +} + +.metric-card-inner { + transition: transform 0.4s ease-in-out; + transform-style: preserve-3d; + position: relative; +} + +.metric-card-inner.flipped { + transform: rotateY(180deg); +} + +.metric-card-front, +.metric-card-back { + backface-visibility: hidden; +} + +.metric-card-back { + transform: rotateY(180deg); + position: absolute; + inset: 0; +} + +@media (prefers-reduced-motion: reduce) { + .metric-card-inner { + transition: none; + } + + .metric-card-inner.flipped .metric-card-front { + visibility: hidden; + } + + .metric-card-inner .metric-card-back { + visibility: hidden; + } + + .metric-card-inner.flipped .metric-card-back { + visibility: visible; + transform: none; + position: absolute; + inset: 0; + } +} + /* Activity grid responsive */ .activity-grid { display: grid;