import { useId } from "react"; import type { SocialLink } from "../lib/settingsSchema"; import { headerSocialLinks, safeSocialSvg, scopeSvgIds, sizeSocialSvg, type HeaderWidth, } from "../lib/socialLinks"; import { cn } from "../lib/utils"; export type SocialLinksPlacement = "header" | "footer"; // THE SOCIAL ROW, for any header or footer. The links are the operator's // (settings.json `socialLinks`, or a site's or the homepage's own list), each an // icon with its label as its accessible name — never text beside it. // // - `placement: "header"` shows the header's list for `width` // (headerSocialLinks): "wide", every link up to four (the `featured` ones // kept first); "narrow", only the `featured` ones. A header renders both, // each shown by CSS at its own widths; `"footer"` shows every link. // - Each link is a 36 px key around a 20 px glyph, 44 px under a coarse // pointer. The glyph is the link's colour (`--muted-foreground`, the // foreground on hover) when the icon is single-colour; an icon of two or // more colours keeps its own (normalizeSocialSvg). // - Focus: the ring colour's 2 px ring. In forced colours a box-shadow is not // drawn, so the browser's own focus outline is left in place there. // - The ids inside each inlined icon are scoped to this row and this link // (scopeSvgIds): a page inlines the same icon more than once, and a gradient // defined in a copy that is `display: none` would not paint in the others. // - An icon is inlined only if it passes the save-time check again // (safeSocialSvg). One that does not is not injected: the link shows its // label as text instead, so a bad file costs the icon, never the page. // // Server-safe and client-safe: no state, no effects; `useId` works in both. // Renders nothing when there is nothing to show, so a caller can drop the // column or the group around it on the same condition. // Complete literal class strings (Tailwind v4 scans them as written). // A key clips what it holds (`overflow-hidden`, `contain: paint`): an icon // paints inside its 36 px box and nowhere else. The focus ring is the key's // own box-shadow, outside that clip. A refused icon's text fallback is capped // at 10rem and ends in an ellipsis, its full label in `title`. const KEY = "inline-flex size-9 shrink-0 items-center justify-center overflow-hidden [contain:paint] rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:size-11 [&_svg]:size-5 [&_svg]:shrink-0"; const TEXT_KEY = "inline-block h-9 max-w-40 shrink-0 truncate rounded-md px-2 text-sm leading-9 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring not-forced-colors:focus-visible:outline-none pointer-coarse:h-11 pointer-coarse:leading-[2.75rem]"; export function SocialLinks({ links, placement, width = "wide", className, }: { links: readonly SocialLink[]; placement: SocialLinksPlacement; // The header's list: which of its two widths this copy is. width?: HeaderWidth; className?: string; }) { const scope = `sl${useId().replace(/[^A-Za-z0-9_-]/g, "")}`; const shown = placement === "header" ? headerSocialLinks(links, width) : [...links]; if (shown.length === 0) return null; return ( ); }