240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
import { useState } from "react";
|
|
import { MousePointerClick } from "lucide-react";
|
|
|
|
export type SegmentType =
|
|
| "ic"
|
|
| "dc"
|
|
| "modifier"
|
|
| "conjunction"
|
|
| "punct"
|
|
| "subject"
|
|
| "verb";
|
|
|
|
export interface Segment {
|
|
text: string;
|
|
type: SegmentType;
|
|
label?: string;
|
|
}
|
|
|
|
export interface ClauseExample {
|
|
title: string;
|
|
segments: Segment[];
|
|
}
|
|
|
|
interface ClauseBreakdownWidgetProps {
|
|
examples: ClauseExample[];
|
|
accentColor?: string;
|
|
}
|
|
|
|
const TYPE_STYLES: Record<
|
|
SegmentType,
|
|
{ bg: string; text: string; border: string; ring: string }
|
|
> = {
|
|
ic: {
|
|
bg: "bg-blue-100",
|
|
text: "text-blue-800",
|
|
border: "border-blue-300",
|
|
ring: "#93c5fd",
|
|
},
|
|
dc: {
|
|
bg: "bg-green-100",
|
|
text: "text-green-800",
|
|
border: "border-green-300",
|
|
ring: "#86efac",
|
|
},
|
|
modifier: {
|
|
bg: "bg-orange-100",
|
|
text: "text-orange-800",
|
|
border: "border-orange-300",
|
|
ring: "#fdba74",
|
|
},
|
|
conjunction: {
|
|
bg: "bg-purple-100",
|
|
text: "text-purple-800",
|
|
border: "border-purple-300",
|
|
ring: "#c4b5fd",
|
|
},
|
|
subject: {
|
|
bg: "bg-sky-100",
|
|
text: "text-sky-800",
|
|
border: "border-sky-300",
|
|
ring: "#7dd3fc",
|
|
},
|
|
verb: {
|
|
bg: "bg-rose-100",
|
|
text: "text-rose-800",
|
|
border: "border-rose-300",
|
|
ring: "#fda4af",
|
|
},
|
|
punct: {
|
|
bg: "bg-gray-100",
|
|
text: "text-gray-600",
|
|
border: "border-gray-300",
|
|
ring: "#d1d5db",
|
|
},
|
|
};
|
|
|
|
const TYPE_LABELS: Record<SegmentType, string> = {
|
|
ic: "Independent Clause",
|
|
dc: "Dependent Clause",
|
|
modifier: "Modifier",
|
|
conjunction: "Conjunction",
|
|
subject: "Subject",
|
|
verb: "Verb / Predicate",
|
|
punct: "Punctuation",
|
|
};
|
|
|
|
// Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings)
|
|
const TAB_ACTIVE: Record<string, string> = {
|
|
purple: "border-b-2 border-purple-600 text-purple-700 bg-white",
|
|
teal: "border-b-2 border-teal-600 text-teal-700 bg-white",
|
|
fuchsia: "border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white",
|
|
amber: "border-b-2 border-amber-600 text-amber-700 bg-white",
|
|
};
|
|
|
|
export default function ClauseBreakdownWidget({
|
|
examples,
|
|
accentColor = "purple",
|
|
}: ClauseBreakdownWidgetProps) {
|
|
const [activeTab, setActiveTab] = useState(0);
|
|
const [selected, setSelected] = useState<number | null>(null);
|
|
|
|
const example = examples[activeTab];
|
|
const switchTab = (i: number) => {
|
|
setActiveTab(i);
|
|
setSelected(null);
|
|
};
|
|
|
|
const selectedSeg = selected !== null ? example.segments[selected] : null;
|
|
const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple;
|
|
|
|
// Unique labeled segment types for the legend
|
|
const legendTypes = Array.from(
|
|
new Set(example.segments.filter((s) => s.label).map((s) => s.type)),
|
|
);
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
|
{/* Tab strip */}
|
|
{examples.length > 1 && (
|
|
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
|
|
{examples.map((ex, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => switchTab(i)}
|
|
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
|
|
i === activeTab
|
|
? tabActive
|
|
: "text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
{ex.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{examples.length === 1 && (
|
|
<div className="px-5 pt-4 pb-1">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">
|
|
{example.title}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Instruction */}
|
|
<div className="px-5 pt-3 pb-1 flex items-center gap-1.5">
|
|
<MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
|
<p className="text-xs text-gray-400 italic">
|
|
Click any colored part to see its grammatical role
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sentence display */}
|
|
<div className="px-5 pt-2 pb-3">
|
|
<div className="text-base leading-10 bg-gray-50 rounded-xl border border-gray-200 px-5 py-4 select-none">
|
|
{example.segments.map((seg, i) => {
|
|
if (!seg.label) {
|
|
// Punctuation / unlabeled — plain unstyled text, not clickable
|
|
return (
|
|
<span key={i} className="text-gray-700">
|
|
{seg.text}
|
|
</span>
|
|
);
|
|
}
|
|
const style = TYPE_STYLES[seg.type];
|
|
const isSelected = selected === i;
|
|
return (
|
|
<span
|
|
key={i}
|
|
onClick={() => setSelected(isSelected ? null : i)}
|
|
className={`inline cursor-pointer rounded px-1 py-0.5 mx-0.5 transition-all ${style.bg} ${style.text} ${
|
|
isSelected
|
|
? `border-2 ${style.border} font-semibold`
|
|
: `border ${style.border} hover:opacity-80`
|
|
}`}
|
|
style={
|
|
isSelected
|
|
? {
|
|
outline: `2.5px solid ${style.ring}`,
|
|
outlineOffset: "1px",
|
|
}
|
|
: {}
|
|
}
|
|
>
|
|
{seg.text}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Selection indicator */}
|
|
{selectedSeg ? (
|
|
<div
|
|
className={`mt-3 rounded-xl border-2 px-4 py-3 flex items-start gap-3 ${TYPE_STYLES[selectedSeg.type].bg} ${TYPE_STYLES[selectedSeg.type].border}`}
|
|
>
|
|
<div
|
|
className="w-2.5 h-2.5 rounded-full mt-1.5 shrink-0"
|
|
style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p
|
|
className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}
|
|
>
|
|
{selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]}
|
|
</p>
|
|
<p
|
|
className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}
|
|
>
|
|
"{selectedSeg.text.trim()}"
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="mt-2 text-xs text-gray-400 italic px-1">
|
|
No element selected — click a colored span above.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2">
|
|
{legendTypes.map((type) => {
|
|
const style = TYPE_STYLES[type];
|
|
return (
|
|
<span
|
|
key={type}
|
|
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`}
|
|
>
|
|
<span
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: TYPE_STYLES[type].ring }}
|
|
/>
|
|
{TYPE_LABELS[type]}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|