From 7446c09f91166037a1b8c3123159a16d17654cbf Mon Sep 17 00:00:00 2001 From: Mateusz Bartosik Date: Tue, 2 Jun 2026 10:40:30 +0200 Subject: [PATCH 1/4] Limit sample card tag rows to a single line with "+X more" Add OverflowTagRow, a reusable row that measures tag widths via a ResizeObserver and collapses overflow into a "+X more" pill, keeping each category row on a single line and adapting to the card's own width. Clicking the pill expands the row. In SampleCard, render the Challenges & Solutions and Features rows through OverflowTagRow and move selected (matching) tags to the front so the reason a card matched stays visible after filtering. --- .../Samples/Hub/Partials/OverflowTagRow.tsx | 157 ++++++++++++++++++ .../Samples/Hub/Partials/SampleCard.tsx | 56 +++---- 2 files changed, 180 insertions(+), 33 deletions(-) create mode 100644 src/components/Samples/Hub/Partials/OverflowTagRow.tsx diff --git a/src/components/Samples/Hub/Partials/OverflowTagRow.tsx b/src/components/Samples/Hub/Partials/OverflowTagRow.tsx new file mode 100644 index 0000000000..929edd03a5 --- /dev/null +++ b/src/components/Samples/Hub/Partials/OverflowTagRow.tsx @@ -0,0 +1,157 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import clsx from "clsx"; +import Tag from "@site/src/theme/Tag"; + +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +export interface OverflowTagItem { + label: string; + key: string; + category?: string; +} + +interface OverflowTagRowProps { + tags: OverflowTagItem[]; + onTagClick?: (e: React.MouseEvent, tag: OverflowTagItem) => void; + isTagSelected?: (tag: OverflowTagItem) => boolean; +} + +const GAP_PX = 4; + +export default function OverflowTagRow({ tags, onTagClick, isTagSelected }: OverflowTagRowProps) { + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(tags.length); + const [expanded, setExpanded] = useState(false); + + const recompute = useCallback(() => { + const el = measureRef.current; + if (!el) { + return; + } + + const containerWidth = el.clientWidth; + if (containerWidth === 0) { + return; + } + + const nodes = Array.from(el.children) as HTMLElement[]; + const pill = nodes[nodes.length - 1]; + const tagNodes = nodes.slice(0, tags.length); + const widths = tagNodes.map((node) => node.offsetWidth); + + const totalWithGaps = widths.reduce((sum, w, i) => sum + w + (i > 0 ? GAP_PX : 0), 0); + + if (totalWithGaps <= containerWidth) { + setVisibleCount(tags.length); + return; + } + + const available = containerWidth - pill.offsetWidth - GAP_PX; + let used = 0; + let count = 0; + for (let i = 0; i < widths.length; i++) { + const next = widths[i] + (count > 0 ? GAP_PX : 0); + if (used + next <= available) { + used += next; + count += 1; + } else { + break; + } + } + + setVisibleCount(Math.max(count, 1)); + }, [tags.length]); + + useIsomorphicLayoutEffect(() => { + const el = measureRef.current; + if (!el) { + return undefined; + } + + let cancelled = false; + const run = () => { + if (!cancelled) { + recompute(); + } + }; + + const observer = new ResizeObserver(run); + observer.observe(el); + run(); + const raf = requestAnimationFrame(run); + if (typeof document !== "undefined" && document.fonts?.ready) { + document.fonts.ready.then(run).catch(() => {}); + } + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + observer.disconnect(); + }; + }, [recompute, tags]); + + useEffect(() => { + setExpanded(false); + }, [tags]); + + const renderTag = (tag: OverflowTagItem) => ( + onTagClick(e, tag) : undefined} + className={clsx( + "shrink-0 whitespace-nowrap", + onTagClick && "cursor-pointer", + isTagSelected && !isTagSelected(tag) && "opacity-50" + )} + > + {tag.label} + + ); + + const hiddenCount = tags.length - visibleCount; + const visibleTags = expanded ? tags : tags.slice(0, visibleCount); + const hiddenTags = tags.slice(visibleCount); + + return ( +
+ +
+ {visibleTags.map(renderTag)} + {!expanded && hiddenCount > 0 && ( + + )} +
+
+ ); +} diff --git a/src/components/Samples/Hub/Partials/SampleCard.tsx b/src/components/Samples/Hub/Partials/SampleCard.tsx index 62ab8252bf..912571cb86 100644 --- a/src/components/Samples/Hub/Partials/SampleCard.tsx +++ b/src/components/Samples/Hub/Partials/SampleCard.tsx @@ -3,8 +3,8 @@ import Link from "@docusaurus/Link"; import Heading from "@theme/Heading"; import LazyImage from "@site/src/components/Common/LazyImage"; import clsx from "clsx"; -import Tag from "@site/src/theme/Tag"; import LanguageTag from "@site/src/components/Samples/Hub/Partials/LanguageTag"; +import OverflowTagRow from "@site/src/components/Samples/Hub/Partials/OverflowTagRow"; interface TagWithCategory { label: string; @@ -56,6 +56,16 @@ export default function SampleCard({ return selectedTags.has(tag.key); }; + const selectedFirst = (list: TagWithCategory[]) => { + if (!selectedTags || selectedTags.size === 0) { + return list; + } + return [...list].sort((a, b) => Number(selectedTags.has(b.key)) - Number(selectedTags.has(a.key))); + }; + + const orderedChallengesSolutionsTags = selectedFirst(challengesSolutionsTags); + const orderedFeatureTags = selectedFirst(featureTags); + return (
{description}

- {challengesSolutionsTags.length > 0 && ( + {orderedChallengesSolutionsTags.length > 0 && (
Challenges & Solutions -
- {challengesSolutionsTags.map((tag) => ( - handleTagClick(e, tag) : undefined} - className={clsx( - onTagClick && "cursor-pointer", - !isTagSelected(tag) && "opacity-50" - )} - > - {tag.label} - - ))} -
+
)} - {featureTags.length > 0 && ( + {orderedFeatureTags.length > 0 && (
Features -
- {featureTags.map((tag) => ( - handleTagClick(e, tag) : undefined} - className={clsx( - onTagClick && "cursor-pointer", - !isTagSelected(tag) && "opacity-50" - )} - > - {tag.label} - - ))} -
+
)}
From 07b5b5e622316fbf832b4535c906f3e613cb323b Mon Sep 17 00:00:00 2001 From: Mateusz Bartosik Date: Tue, 2 Jun 2026 10:46:04 +0200 Subject: [PATCH 2/4] Apply single-line tag overflow to guide cards Generalize OverflowTagRow to also handle permalink-based tags and move it to components/Common so it can be shared. Use it in CardWithImage (the guide grid cards, homepage use cases, and tag-doc list) in place of the fixed two-tag useTagLimit, so tags fill the available width on a single line before collapsing into a width-aware "+X more" pill. --- src/components/Common/CardWithImage.tsx | 38 ++----------------- .../Partials => Common}/OverflowTagRow.tsx | 25 ++++++++---- .../Samples/Hub/Partials/SampleCard.tsx | 2 +- 3 files changed, 22 insertions(+), 43 deletions(-) rename src/components/{Samples/Hub/Partials => Common}/OverflowTagRow.tsx (84%) diff --git a/src/components/Common/CardWithImage.tsx b/src/components/Common/CardWithImage.tsx index 64634d3dcf..21feac7742 100644 --- a/src/components/Common/CardWithImage.tsx +++ b/src/components/Common/CardWithImage.tsx @@ -7,8 +7,7 @@ import Badge from "@site/src/components/Common/Badge"; import LazyImage from "@site/src/components/Common/LazyImage"; import isInternalUrl from "@docusaurus/isInternalUrl"; import clsx from "clsx"; -import { useTagLimit } from "@site/src/hooks/useTagLimit"; -import Tag from "@site/src/theme/Tag"; +import OverflowTagRow from "@site/src/components/Common/OverflowTagRow"; export interface CardWithImageProps { title: string; @@ -39,10 +38,6 @@ export default function CardWithImage({ const hasTags = tags.length > 0; const hasDate = date !== undefined; - const { visibleTags, hiddenCount, isExpanded, expandTags } = useTagLimit({ - tags, - }); - return (
{description}

{(hasTags || hasDate) && ( -
- {hasTags && ( -
- {visibleTags.map((tag) => ( - - {tag.label} - - ))} - {!isExpanded && hiddenCount > 0 && ( - { - e.preventDefault(); - e.stopPropagation(); - expandTags(); - }} - title="Show all tags" - className="pointer-events-auto" - > - +{hiddenCount} more - - )} -
- )} +
+ {hasTags && } {hasDate &&

{date}

}
)} diff --git a/src/components/Samples/Hub/Partials/OverflowTagRow.tsx b/src/components/Common/OverflowTagRow.tsx similarity index 84% rename from src/components/Samples/Hub/Partials/OverflowTagRow.tsx rename to src/components/Common/OverflowTagRow.tsx index 929edd03a5..8cc0697fa6 100644 --- a/src/components/Samples/Hub/Partials/OverflowTagRow.tsx +++ b/src/components/Common/OverflowTagRow.tsx @@ -6,7 +6,8 @@ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffec export interface OverflowTagItem { label: string; - key: string; + key?: string; + permalink?: string; category?: string; } @@ -14,11 +15,14 @@ interface OverflowTagRowProps { tags: OverflowTagItem[]; onTagClick?: (e: React.MouseEvent, tag: OverflowTagItem) => void; isTagSelected?: (tag: OverflowTagItem) => boolean; + className?: string; } const GAP_PX = 4; -export default function OverflowTagRow({ tags, onTagClick, isTagSelected }: OverflowTagRowProps) { +const tagKey = (tag: OverflowTagItem) => tag.key ?? tag.permalink ?? tag.label; + +export default function OverflowTagRow({ tags, onTagClick, isTagSelected, className }: OverflowTagRowProps) { const measureRef = useRef(null); const [visibleCount, setVisibleCount] = useState(tags.length); const [expanded, setExpanded] = useState(false); @@ -75,6 +79,9 @@ export default function OverflowTagRow({ tags, onTagClick, isTagSelected }: Over } }; + // The mirror's box width tracks the container, but its contents reflow + // when fonts finish loading after hydration — and that reflow doesn't + // resize the mirror, so recompute now, next frame, and once fonts settle. const observer = new ResizeObserver(run); observer.observe(el); run(); @@ -96,11 +103,12 @@ export default function OverflowTagRow({ tags, onTagClick, isTagSelected }: Over const renderTag = (tag: OverflowTagItem) => ( onTagClick(e, tag) : undefined} className={clsx( - "shrink-0 whitespace-nowrap", + "shrink-0 whitespace-nowrap pointer-events-auto", onTagClick && "cursor-pointer", isTagSelected && !isTagSelected(tag) && "opacity-50" )} @@ -114,14 +122,15 @@ export default function OverflowTagRow({ tags, onTagClick, isTagSelected }: Over const hiddenTags = tags.slice(visibleCount); return ( -
+
+ {/* Off-flow mirror used only for measurement: every tag + a worst-case pill. */} +
{visibleTags.map(renderTag)} + {!expanded && hiddenCount > 0 && (