mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-19 18:15:57 +00:00
feat(blox): add comparison-table block
Responsive desktop table + mobile stacked-cards competitor comparison. Supports category headings, boolean check/cross/partial and text cell values, row striping, highlighted column with gradient border, and a CTA row. Per-cell zebra striping opts out on the highlighted column to avoid ring paint-order conflicts.
This commit is contained in:
1 parent
dc14b48b42
commit
c0458a2e8f
1 file changed
+28
-55
@@ -9,7 +9,11 @@ function renderText(text) {
|
|||||||
return String(text)
|
return String(text)
|
||||||
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
|
||||||
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
.replace(/\*(.*?)\*/g, "<em>$1</em>")
|
||||||
.replace(/`(.*?)`/g, (_, code) => `<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">${code}</code>`);
|
.replace(
|
||||||
|
/`(.*?)`/g,
|
||||||
|
(_, code) =>
|
||||||
|
`<code class="px-1.5 py-0.5 rounded font-mono text-sm bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200">${code}</code>`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a cell value into one of: {kind: "check"} | {kind: "cross"} | {kind: "partial", note} | {kind: "text", value, emphasis}
|
// Resolve a cell value into one of: {kind: "check"} | {kind: "cross"} | {kind: "partial", note} | {kind: "text", value, emphasis}
|
||||||
@@ -30,18 +34,13 @@ function resolveCell(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CellContent({cell, isHighlighted}) {
|
function CellContent({cell, isHighlighted}) {
|
||||||
const positiveColor = isHighlighted
|
const positiveColor = isHighlighted ? "text-primary-600 dark:text-primary-400" : "text-green-500 dark:text-green-400";
|
||||||
? "text-primary-600 dark:text-primary-400"
|
|
||||||
: "text-green-500 dark:text-green-400";
|
|
||||||
const partialColor = "text-amber-500 dark:text-amber-400";
|
const partialColor = "text-amber-500 dark:text-amber-400";
|
||||||
|
|
||||||
if (cell.kind === "check") {
|
if (cell.kind === "check") {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-center gap-1.5">
|
<div class="flex items-center justify-center gap-1.5">
|
||||||
<span
|
<span class={`h-5 w-5 ${positiveColor}`} dangerouslySetInnerHTML={{__html: CHECK_SVG}} />
|
||||||
class={`h-5 w-5 ${positiveColor}`}
|
|
||||||
dangerouslySetInnerHTML={{__html: CHECK_SVG}}
|
|
||||||
/>
|
|
||||||
{cell.note && <span class="text-xs text-gray-500 dark:text-gray-400">{cell.note}</span>}
|
{cell.note && <span class="text-xs text-gray-500 dark:text-gray-400">{cell.note}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -49,10 +48,7 @@ function CellContent({cell, isHighlighted}) {
|
|||||||
if (cell.kind === "cross") {
|
if (cell.kind === "cross") {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-center gap-1.5">
|
<div class="flex items-center justify-center gap-1.5">
|
||||||
<span
|
<span class="h-5 w-5 text-gray-400 dark:text-gray-500" dangerouslySetInnerHTML={{__html: X_SVG}} />
|
||||||
class="h-5 w-5 text-gray-400 dark:text-gray-500"
|
|
||||||
dangerouslySetInnerHTML={{__html: X_SVG}}
|
|
||||||
/>
|
|
||||||
{cell.note && <span class="text-xs text-gray-500 dark:text-gray-400">{cell.note}</span>}
|
{cell.note && <span class="text-xs text-gray-500 dark:text-gray-400">{cell.note}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -60,10 +56,7 @@ function CellContent({cell, isHighlighted}) {
|
|||||||
if (cell.kind === "partial") {
|
if (cell.kind === "partial") {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-center gap-1.5">
|
<div class="flex items-center justify-center gap-1.5">
|
||||||
<span
|
<span class={`h-5 w-5 ${partialColor}`} dangerouslySetInnerHTML={{__html: MINUS_SVG}} />
|
||||||
class={`h-5 w-5 ${partialColor}`}
|
|
||||||
dangerouslySetInnerHTML={{__html: MINUS_SVG}}
|
|
||||||
/>
|
|
||||||
{cell.note && <span class="text-xs text-gray-500 dark:text-gray-400">{cell.note}</span>}
|
{cell.note && <span class="text-xs text-gray-500 dark:text-gray-400">{cell.note}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -94,7 +87,6 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
|||||||
<div class="hidden md:block relative pt-5">
|
<div class="hidden md:block relative pt-5">
|
||||||
{/* Card — overflow-visible so the highlight ring can extend above its top edge */}
|
{/* Card — overflow-visible so the highlight ring can extend above its top edge */}
|
||||||
<div class="relative rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 overflow-visible shadow-sm">
|
<div class="relative rounded-2xl ring-1 ring-gray-200 dark:ring-gray-700 bg-white dark:bg-gray-800 overflow-visible shadow-sm">
|
||||||
|
|
||||||
{/* Highlight column FILL — paints BEFORE table, behind cells. Cell content sits on top. */}
|
{/* Highlight column FILL — paints BEFORE table, behind cells. Cell content sits on top. */}
|
||||||
{highlightIdx >= 0 && (
|
{highlightIdx >= 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -105,10 +97,7 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
|||||||
|
|
||||||
{/* Recommended badge — sits on the highlight ring's top edge as a tab */}
|
{/* Recommended badge — sits on the highlight ring's top edge as a tab */}
|
||||||
{highlighted?.badge && (
|
{highlighted?.badge && (
|
||||||
<div
|
<div class="absolute z-10" style={`top: -1.25rem; left: calc(${colLeftPct + colWidthPct / 2}%); transform: translate(-50%, -50%);`}>
|
||||||
class="absolute z-10"
|
|
||||||
style={`top: -1.25rem; left: calc(${colLeftPct + colWidthPct / 2}%); transform: translate(-50%, -50%);`}
|
|
||||||
>
|
|
||||||
<span class="inline-block px-3 py-1 text-xs font-semibold rounded-full bg-primary-600 text-white shadow-md whitespace-nowrap">
|
<span class="inline-block px-3 py-1 text-xs font-semibold rounded-full bg-primary-600 text-white shadow-md whitespace-nowrap">
|
||||||
{highlighted.badge}
|
{highlighted.badge}
|
||||||
</span>
|
</span>
|
||||||
@@ -126,11 +115,7 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
|||||||
<div class={`text-base font-bold ${isYou ? "text-primary-600 dark:text-primary-400" : "text-gray-900 dark:text-white"}`}>
|
<div class={`text-base font-bold ${isYou ? "text-primary-600 dark:text-primary-400" : "text-gray-900 dark:text-white"}`}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</div>
|
</div>
|
||||||
{c.tagline && (
|
{c.tagline && <div class="text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal">{c.tagline}</div>}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 font-normal">
|
|
||||||
{c.tagline}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Competitor badges render inline (highlighted column uses the ring-tab instead) */}
|
{/* Competitor badges render inline (highlighted column uses the ring-tab instead) */}
|
||||||
{c.badge && !isYou && (
|
{c.badge && !isYou && (
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@@ -153,7 +138,10 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
|||||||
if (isCategory) {
|
if (isCategory) {
|
||||||
return (
|
return (
|
||||||
<tr key={ri}>
|
<tr key={ri}>
|
||||||
<td colSpan={competitors.length + 1} class="px-6 pt-8 pb-3 text-base font-bold text-gray-900 dark:text-white border-t border-gray-200 dark:border-gray-700">
|
<td
|
||||||
|
colSpan={competitors.length + 1}
|
||||||
|
class="px-6 pt-8 pb-3 text-base font-bold text-gray-900 dark:text-white border-t border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
{row.feature}
|
{row.feature}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -175,9 +163,7 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
|||||||
<tr key={ri} class="border-t border-gray-100 dark:border-gray-700/50">
|
<tr key={ri} class="border-t border-gray-100 dark:border-gray-700/50">
|
||||||
<td class={`px-6 py-4 ${cellBg(-1)}`}>
|
<td class={`px-6 py-4 ${cellBg(-1)}`}>
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{row.feature}</div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">{row.feature}</div>
|
||||||
{row.note && (
|
{row.note && <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{row.note}</div>}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{row.note}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
{competitors.map((_, ci) => (
|
{competitors.map((_, ci) => (
|
||||||
<td key={ci} class={`px-6 py-4 ${cellBg(ci)}`}>
|
<td key={ci} class={`px-6 py-4 ${cellBg(ci)}`}>
|
||||||
@@ -225,9 +211,7 @@ function DesktopTable({competitors, rows, cta, icon_svgs, rowStriping = false})
|
|||||||
|
|
||||||
function MobileCards({competitors, rows, cta, icon_svgs}) {
|
function MobileCards({competitors, rows, cta, icon_svgs}) {
|
||||||
const highlightIdx = competitors.findIndex((c) => c.highlight);
|
const highlightIdx = competitors.findIndex((c) => c.highlight);
|
||||||
const ordered = highlightIdx >= 0
|
const ordered = highlightIdx >= 0 ? [competitors[highlightIdx], ...competitors.filter((_, i) => i !== highlightIdx)] : competitors;
|
||||||
? [competitors[highlightIdx], ...competitors.filter((_, i) => i !== highlightIdx)]
|
|
||||||
: competitors;
|
|
||||||
|
|
||||||
const ctaIconSvg = cta?.icon ? (icon_svgs?.[cta.icon] ?? null) : null;
|
const ctaIconSvg = cta?.icon ? (icon_svgs?.[cta.icon] ?? null) : null;
|
||||||
const isExtCta = cta?.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
|
const isExtCta = cta?.url && (cta.url.startsWith("http://") || cta.url.startsWith("https://"));
|
||||||
@@ -244,27 +228,25 @@ function MobileCards({competitors, rows, cta, icon_svgs}) {
|
|||||||
>
|
>
|
||||||
<div class="flex items-baseline justify-between mb-4">
|
<div class="flex items-baseline justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div class={`text-lg font-bold ${isYou ? "text-primary-600 dark:text-primary-400" : "text-gray-900 dark:text-white"}`}>
|
<div class={`text-lg font-bold ${isYou ? "text-primary-600 dark:text-primary-400" : "text-gray-900 dark:text-white"}`}>{c.name}</div>
|
||||||
{c.name}
|
{c.tagline && <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{c.tagline}</div>}
|
||||||
</div>
|
|
||||||
{c.tagline && (
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{c.tagline}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{c.badge && (
|
{c.badge && (
|
||||||
<span class={`px-2.5 py-0.5 text-xs font-semibold rounded-full ${isYou ? "bg-primary-600 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"}`}>
|
<span
|
||||||
|
class={`px-2.5 py-0.5 text-xs font-semibold rounded-full ${isYou ? "bg-primary-600 text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"}`}
|
||||||
|
>
|
||||||
{c.badge}
|
{c.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-3">
|
<ul class="space-y-3">
|
||||||
{rows.filter((r) => !r.category).map((row, ri) => (
|
{rows
|
||||||
|
.filter((r) => !r.category)
|
||||||
|
.map((row, ri) => (
|
||||||
<li key={ri} class="flex items-start justify-between gap-4">
|
<li key={ri} class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{row.feature}</div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">{row.feature}</div>
|
||||||
{row.note && (
|
{row.note && <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{row.note}</div>}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{row.note}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<CellContent cell={resolveCell((row.values ?? [])[ci])} isHighlighted={isYou} />
|
<CellContent cell={resolveCell((row.values ?? [])[ci])} isHighlighted={isYou} />
|
||||||
@@ -299,23 +281,14 @@ export const ComparisonTableBlock = ({content = {}, design = {}, icon_svgs = {}}
|
|||||||
<div class="max-w-6xl mx-auto">
|
<div class="max-w-6xl mx-auto">
|
||||||
{(title || subtitle || text) && (
|
{(title || subtitle || text) && (
|
||||||
<div class="text-center max-w-3xl mx-auto mb-10 lg:mb-14">
|
<div class="text-center max-w-3xl mx-auto mb-10 lg:mb-14">
|
||||||
{subtitle && (
|
{subtitle && <p class="text-xs font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">{subtitle}</p>}
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-3">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{title && (
|
{title && (
|
||||||
<h2
|
<h2
|
||||||
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white tracking-tight mb-4"
|
class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white tracking-tight mb-4"
|
||||||
dangerouslySetInnerHTML={{__html: renderText(title)}}
|
dangerouslySetInnerHTML={{__html: renderText(title)}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{text && (
|
{text && <p class="text-lg text-gray-600 dark:text-gray-400" dangerouslySetInnerHTML={{__html: renderText(text)}} />}
|
||||||
<p
|
|
||||||
class="text-lg text-gray-600 dark:text-gray-400"
|
|
||||||
dangerouslySetInnerHTML={{__html: renderText(text)}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user