Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions src/components/CategoryGrid/CategoryGrid.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
:root {
/* tweak this if needed after a quick eyeball check */
--col-header-min: 96px; /* works well for title + up to 2 blurb lines */
}

/* spacing around the block */
.wrapper { padding: 3rem 0; }

/* ---------- Mobile (accordion) ---------- */
.categoryDetails {
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 12px;
overflow: hidden;
background: var(--ifm-card-background-color);
& + & { margin-top: 1rem; }
}
.categorySummary {
display: flex;
flex-direction: column;
gap: .25rem;
padding: 1rem;
cursor: pointer;
position: relative;
summary::-webkit-details-marker { display: none; }
}
.categoryTitle {
margin: 0;
font-size: clamp(1.1rem, 1.2vw + 1rem, 1.35rem);
}
.categoryBlurb { margin: .25rem 0 0 0; opacity: .8; }

/* simple vertical list on mobile */
.tileList { list-style: none; margin: 0; padding: 0 1rem 1rem; }
.tileListItem + .tileListItem { margin-top: .5rem; }

.tileRow {
display: flex;
align-items: flex-start; /* align top for multiline titles */
gap: 12px;
background: var(--ifm-card-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 12px;
padding: .75rem .9rem;
text-decoration: none;
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
&:hover,
&:focus-visible {
transform: translateY(-1px);
border-color: var(--ifm-color-primary);
box-shadow: 0 8px 24px rgba(0,0,0,.06);
outline: none;
}
}

/* ---------- Desktop: expanding columns ---------- */
.categoryColumns {
display: flex;
gap: 16px;
}

/* base column look */
.categoryCol {
--radius: 16px;
flex: 1 1 0px;
display: flex;
flex-direction: column;
background: var(--ifm-background-surface-color);
border-radius: var(--radius);
padding: 16px;
transition: transform 180ms ease, box-shadow 180ms ease;
box-shadow: 0 2px 6px rgba(0,0,0,.08); /* subtle shadow */
}

/* Hover container effect: slightly compress others, expand hovered */
//.categoryColumns:hover .categoryCol { flex: .95 1 0px; }
.categoryCol:hover,
.categoryCol.isFocused {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0,0,0,.12);
}

/* column header */
.colHeader {
/* reserve consistent space for title + blurb across all columns */
min-height: var(--col-header-min);
display: flex;
flex-direction: column;
justify-content: flex-start;
margin-bottom: 4px; /* keep your existing spacing */
}

.colHeader .categoryTitle {
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
margin: 0 0 0.25rem 0;
}
.colHeader .categoryBlurb {
font-size: 0.95rem;
line-height: 1.4;
opacity: 0.8;
margin: 0;
max-width: 90%;
}

/* vertical tiles inside each column */
.colTiles {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}

.colTileItem { min-width: 0; }

.colTile {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-radius: 8px;
text-decoration: none;
transition: background 150ms ease;
}

.colTile:hover,
.colTile:focus-visible {
background: rgba(0,0,0,0.04); /* subtle hover bg */
outline: none;
}

.colTile,
.tileRow {
align-items: center; /* ensure icon + text share the same vertical center */
}

.iconWrap {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center; /* keep the icon centered even if text wraps */
height: 22px; /* = icon size */
}

.iconWrap img.featureSvg {
width: 20px;
height: 20px;
object-fit: contain;
display: block;
}

/* Column tile text container */
.colTileText {
display: flex;
align-items: center;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
flex-wrap: wrap;
}

.tileTitle {
font-size: 0.85rem;
font-weight: 500;
line-height: 1.3;
white-space: normal;
}

/* badges */
.badge {
font-size: 0.7rem;
font-weight: 600;
padding: 1px 5px;
border-radius: 999px;
border: 1px solid currentColor;
line-height: 1;
}
.badge_NEW { color: var(--ifm-color-success); }
.badge_EA { color: var(--ifm-color-warning); }
.badge_GA { color: var(--ifm-color-primary); }

/* responsive: fall back to mobile when narrow */
@media (max-width: 900px) {
.categoryColumns { display: none; }
}
205 changes: 205 additions & 0 deletions src/components/CategoryGrid/CategoryGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import React, { useEffect, useMemo, useState } from "react";
import Link from "@docusaurus/Link";
import clsx from "clsx";
import styles from "./CategoryGrid.module.scss";
import type { Category } from "./categories.data";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { moduleList } from "@site/src/components/LearnAboutPlatform/data/moduleListData";

/* ----- utils ----- */
const stripLeadingSlash = (p?: string) => (p && p.startsWith("/") ? p.slice(1) : p) || "";
const joinBase = (baseUrl: string, rel: string) =>
(baseUrl.endsWith("/") ? baseUrl : baseUrl + "/") + stripLeadingSlash(rel);

/* Optional overrides for odd assets */
const ICON_ALIASES: Record<string, string> = {
// ar: "img/icon_artifact_registry.svg",
};

const kebab = (s: string) =>
s.toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const snake = (s: string) =>
s.toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");

function useIsMobile(breakpoint = 900) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
const onChange = () => setIsMobile(mq.matches);
onChange();
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, [breakpoint]);
return isMobile;
}

function useModuleIconMap() {
return useMemo(() => {
const map = new Map<string, string>();
moduleList.forEach((m: any) => {
if (!m?.module) return;
const rel = (m.icon && stripLeadingSlash(m.icon)) || `img/icon-${m.module}.svg`;
map.set(m.module, rel);
});
return map;
}, []);
}

function buildIconCandidates(
item: Category["items"][number],
moduleIconMap: Map<string, string>
) {
const roots = ["img", "icons"];
const list: string[] = [];

if (item.icon) list.push(stripLeadingSlash(item.icon));
if (item.module && ICON_ALIASES[item.module]) list.push(ICON_ALIASES[item.module]);
if (item.module && moduleIconMap.get(item.module)) list.push(moduleIconMap.get(item.module)!);

if (item.module) {
const hy = `icon-${item.module}`;
const us = `icon_${item.module}`;
roots.forEach((r) => list.push(`${r}/${hy}.svg`, `${r}/${us}.svg`, `${r}/${hy}.png`, `${r}/${us}.png`));
}

const k = kebab(item.name);
const s = snake(item.name);
roots.forEach((r) =>
list.push(
`${r}/icon-${k}.svg`,
`${r}/icon_${s}.svg`,
`${r}/${k}.svg`,
`${r}/${s}.svg`,
`${r}/icon-${k}.png`,
`${r}/icon_${s}.png`,
`${r}/${k}.png`,
`${r}/${s}.png`
)
);

return Array.from(new Set(list));
}

function FallbackImg({
baseUrl,
candidates,
className,
alt = "",
}: {
baseUrl: string;
candidates: string[];
className?: string;
alt?: string;
}) {
const [idx, setIdx] = useState(0);
const src = candidates[idx] ? joinBase(baseUrl, candidates[idx]) : "";
return (
<img
src={src}
className={className}
alt={alt}
loading="lazy"
onError={() => {
if (idx < candidates.length - 1) setIdx(idx + 1);
else if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console
console.warn("Icon not found; tried:", candidates);
}
}}
/>
);
}

/* ----- main ----- */
export default function CategoryGrid({ categories }: { categories: Category[] }) {
const isMobile = useIsMobile();
const { siteConfig: { baseUrl = "/" } = {} } = useDocusaurusContext();
const moduleIconMap = useModuleIconMap();

// Track which column is keyboard-focused so it expands like hover.
const [focusedCol, setFocusedCol] = useState<number | null>(null);

return (
<section className={styles.wrapper} aria-label="Documentation categories">
<div className="container">
{isMobile ? (
// Mobile: collapsible accordions (unchanged)
<>
{categories.map((cat, i) => (
<details key={cat.title} className={styles.categoryDetails}>
<summary className={styles.categorySummary}>
<span className={styles.categoryTitle}>{cat.title}</span>
{cat.blurb && <span className={styles.categoryBlurb}>{cat.blurb}</span>}
</summary>
<ul className={styles.tileList} role="list">
{cat.items.map((item) => (
<li key={item.name} className={styles.tileListItem}>
<Link to={item.href} className={styles.tileRow} aria-label={`${item.name} documentation`}>
<div className={styles.iconWrap} aria-hidden="true">
<FallbackImg
baseUrl={baseUrl}
candidates={buildIconCandidates(item, moduleIconMap)}
className={styles.featureSvg}
/>
</div>
<span className={styles.tileTitle}>{item.name}</span>
{item.badge && <span className={clsx(styles.badge, styles[`badge_${item.badge}`])}>{item.badge}</span>}
</Link>
</li>
))}
</ul>
</details>
))}
</>
) : (
// Desktop: columns that expand on hover/focus
<div
className={styles.categoryColumns}
data-focused={focusedCol !== null ? focusedCol : undefined}
>
{categories.map((cat, idx) => (
<div
key={cat.title}
className={clsx(styles.categoryCol, focusedCol === idx && styles.isFocused)}
onFocusCapture={() => setFocusedCol(idx)}
onBlurCapture={(e) => {
// collapse when focus leaves the column
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node)) {
setFocusedCol(null);
}
}}
>
<header className={styles.colHeader}>
<h2 className={styles.categoryTitle}>{cat.title}</h2>
{cat.blurb && <p className={styles.categoryBlurb}>{cat.blurb}</p>}
</header>

<ul className={styles.colTiles} role="list">
{cat.items.map((item) => (
<li key={item.name} className={styles.colTileItem}>
<Link to={item.href} className={styles.colTile} aria-label={`${item.name} documentation`}>
<div className={styles.iconWrap} aria-hidden="true">
<FallbackImg
baseUrl={baseUrl}
candidates={buildIconCandidates(item, moduleIconMap)}
className={styles.featureSvg}
/>
</div>
<div className={styles.colTileText}>
<span className={styles.tileTitle}>{item.name}</span>
{item.badge && (
<span className={clsx(styles.badge, styles[`badge_${item.badge}`])}>{item.badge}</span>
)}
</div>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
</section>
);
}
Loading