UNPKG

@flanksource/clicky-ui

Version:

Flanksource Clicky UI — React component library built on shadcn/ui with light/dark and density theming.

668 lines (667 loc) 24.4 kB
import { jsxs, Fragment, jsx } from "react/jsx-runtime"; import { Children, isValidElement } from "react"; import { cva } from "class-variance-authority"; import { cn } from "../lib/utils.js"; import { Icon } from "./Icon.js"; const badgeVariants = cva( "inline-flex items-center gap-1 rounded-full border border-transparent font-medium whitespace-nowrap align-middle", { variants: { tone: { neutral: "", success: "", danger: "", warning: "", info: "" }, variant: { soft: "", solid: "", outline: "bg-transparent" }, size: { xxs: "min-h-4 px-1.5 py-0.5 text-[9px] leading-none", xs: "min-h-[18px] px-1.5 py-0.5 text-[10px] leading-none", sm: "h-4 px-1.5 py-0 text-[10px] leading-none", md: "px-2 py-0.5 text-xs", lg: "px-2.5 py-1 text-sm" } }, compoundVariants: [ { tone: "neutral", variant: "soft", class: "bg-muted text-foreground" }, { tone: "neutral", variant: "solid", class: "bg-foreground text-background" }, { tone: "neutral", variant: "outline", class: "border-border text-foreground" }, { tone: "success", variant: "soft", class: "bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-300" }, { tone: "success", variant: "solid", class: "bg-green-500 text-white" }, { tone: "success", variant: "outline", class: "border-green-500 text-green-700 dark:text-green-400" }, { tone: "danger", variant: "soft", class: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300" }, { tone: "danger", variant: "solid", class: "bg-red-500 text-white" }, { tone: "danger", variant: "outline", class: "border-red-500 text-red-700 dark:text-red-400" }, { tone: "warning", variant: "soft", class: "bg-yellow-100 text-yellow-800 dark:bg-yellow-500/20 dark:text-yellow-300" }, { tone: "warning", variant: "solid", class: "bg-yellow-400 text-yellow-950" }, { tone: "warning", variant: "outline", class: "border-yellow-500 text-yellow-700 dark:text-yellow-400" }, { tone: "info", variant: "soft", class: "bg-blue-100 text-blue-800 dark:bg-blue-500/20 dark:text-blue-300" }, { tone: "info", variant: "solid", class: "bg-blue-500 text-white" }, { tone: "info", variant: "outline", class: "border-blue-500 text-blue-700 dark:text-blue-400" } ], defaultVariants: { tone: "neutral", variant: "soft", size: "md" } } ); const LEGACY_VARIANTS = /* @__PURE__ */ new Set(["soft", "solid", "outline"]); const RICH_VARIANTS = /* @__PURE__ */ new Set([ "status", "metric", "custom", "outlined", "label" ]); const RICH_SIZE_CLASSES = { xxs: { frame: "min-h-4", segment: "px-1.5 py-0.5", text: "text-[10px] leading-4", icon: "h-3 w-3", gap: "gap-1" }, xs: { frame: "min-h-[18px]", segment: "px-2 py-0.5", text: "text-[11px] leading-4", icon: "h-3.5 w-3.5", gap: "gap-1" }, sm: { frame: "min-h-5", segment: "px-2 py-0.5", text: "text-xs leading-4", icon: "h-3.5 w-3.5", gap: "gap-1.5" }, md: { frame: "min-h-6", segment: "px-2.5 py-1", text: "text-sm leading-4", icon: "h-4 w-4", gap: "gap-1.5" }, lg: { frame: "min-h-7", segment: "px-3 py-1.5", text: "text-base leading-5", icon: "h-5 w-5", gap: "gap-2" } }; const STATUS_STYLES = { success: { soft: "bg-emerald-500/12 text-emerald-700 border-emerald-200", outlined: "bg-background text-emerald-700 border-emerald-300" }, error: { soft: "bg-rose-500/12 text-rose-700 border-rose-200", outlined: "bg-background text-rose-700 border-rose-300" }, warning: { soft: "bg-amber-400/15 text-amber-800 border-amber-200", outlined: "bg-background text-amber-800 border-amber-300" }, info: { soft: "bg-sky-500/12 text-sky-700 border-sky-200", outlined: "bg-background text-sky-700 border-sky-300" } }; function isLegacyVariant(variant) { return variant != null && LEGACY_VARIANTS.has(variant); } function isCssColor(value) { return value != null && /^(#|rgb|hsl|oklch|var\(|color\()/i.test(value); } function toneToStatus(tone) { switch (tone) { case "success": return "success"; case "danger": return "error"; case "warning": return "warning"; case "info": return "info"; default: return void 0; } } function getShapeClasses(shape) { switch (shape) { case "rounded": return "rounded-md"; case "square": return "rounded-sm"; default: return "rounded-full"; } } function isRichVariant(variant) { return variant != null && RICH_VARIANTS.has(variant); } function applyColorValue(value, property, style, classes) { if (value == null) return; if (isCssColor(value)) { style[property] = value; return; } classes.push(value); } function normalizeMaxWidth(maxWidth) { if (maxWidth == null) return void 0; if (typeof maxWidth === "number") { return `${Math.max(1, Math.floor(maxWidth))}ch`; } return maxWidth; } function deriveCharacterBudget(maxWidth, fallback = 24) { if (typeof maxWidth === "number" && Number.isFinite(maxWidth) && maxWidth > 0) { return Math.max(4, Math.floor(maxWidth)); } if (typeof maxWidth === "string") { const trimmed = maxWidth.trim(); const chMatch = trimmed.match(/^(\d+(?:\.\d+)?)ch$/i); if (chMatch) return Math.max(4, Math.floor(Number(chMatch[1]))); const numericMatch = trimmed.match(/^(\d+(?:\.\d+)?)$/); if (numericMatch) return Math.max(4, Math.floor(Number(numericMatch[1]))); } return fallback; } function shouldExposeFullTextTitle({ maxWidth, truncate, wrap }) { return maxWidth != null || truncate != null || wrap; } function toPlainText(node) { if (node == null || typeof node === "boolean") return null; if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) { const pieces = Children.toArray(node).map(toPlainText); return pieces.every((piece) => piece != null) ? pieces.join("") : null; } if (isValidElement(node)) { return toPlainText(node.props.children); } return null; } function truncateSuffix(text, budget) { if (text.length <= budget) return text; if (budget <= 1) return "…"; return `${text.slice(0, budget - 1)}…`; } function truncatePrefix(text, budget) { if (text.length <= budget) return text; if (budget <= 1) return "…"; return `…${text.slice(-(budget - 1))}`; } function truncateMiddle(text, budget) { if (text.length <= budget) return text; if (budget <= 3) return truncateSuffix(text, budget); const available = budget - 1; const head = Math.max(1, Math.ceil(available / 2)); const tail = Math.max(1, available - head); return `${text.slice(0, head)}…${text.slice(-tail)}`; } function truncateSegmentStart(text, budget) { if (text.length <= budget) return text; if (budget <= 2) return truncateSuffix(text, budget); return `${text.slice(0, budget - 1)}…`; } function truncateSegmentEnd(text, budget) { if (text.length <= budget) return text; if (budget <= 2) return truncatePrefix(text, budget); return `…${text.slice(-(budget - 1))}`; } function summarizeMiddlePathSegment(segment) { if (segment == null || segment.length === 0) return "…"; if (segment.length <= 2) return `${segment}…`; return `${segment[0]}…`; } function compressStructuredText(head, middle, tail, budget, separator) { const pieces = [head]; if (middle) pieces.push(middle); pieces.push(tail); const preferred = pieces.join(separator); if (preferred.length <= budget) return preferred; const separatorCount = pieces.length - 1; const reserved = separator.length * separatorCount + (middle ? middle.length : 0); const available = Math.max(4, budget - reserved); const headBudget = Math.max(3, Math.min(head.length, Math.floor(available * 0.4))); const tailBudget = Math.max(4, available - headBudget); const compactHead = truncateSegmentStart(head, headBudget); const compactTail = truncateSegmentEnd(tail, tailBudget); const compactPieces = [compactHead]; if (middle) compactPieces.push(middle); compactPieces.push(compactTail); const compact = compactPieces.join(separator); return compact.length <= budget ? compact : truncateMiddle(preferred, budget); } function getStructuredBudget(budget) { return Math.max(budget, 32); } function truncatePath(text, budget) { const smartBudget = getStructuredBudget(budget); const hasLeadingSlash = text.startsWith("/"); const segments = text.split("/").filter(Boolean); if (segments.length < 2) return truncateMiddle(text, smartBudget); const head = `${hasLeadingSlash ? "/" : ""}${segments[0]}`; const tail = segments[segments.length - 1] ?? text; const middle = segments.length > 2 ? summarizeMiddlePathSegment(segments.slice(1, -1).join("/")) : void 0; return compressStructuredText(head, middle, tail, smartBudget, "/"); } function truncateUrl(text, budget) { const smartBudget = getStructuredBudget(budget); try { const url = new URL(text); const host = url.host; const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length === 0) { if (host.length <= smartBudget) return host; return truncateMiddle(`${host}${url.search}${url.hash}`, smartBudget); } if (pathSegments.length === 1) { const singlePath = `${host}/${pathSegments[0] ?? host}`; if (singlePath.length <= smartBudget) return singlePath; const hostOnly = `${host}/…`; return hostOnly.length <= smartBudget ? hostOnly : truncateMiddle(singlePath, smartBudget); } const tail = pathSegments[pathSegments.length - 1] ?? host; const firstSegment = pathSegments[0] ?? ""; const summarizedMiddle = pathSegments.length > 2 ? summarizeMiddlePathSegment(pathSegments.slice(1, -1).join("/")) : void 0; const candidates = [ [host, firstSegment, summarizedMiddle, tail].filter(Boolean).join("/"), `${host}/…/${tail}`, `${host}/…` ]; const candidate = candidates.find((entry) => entry.length <= smartBudget); return candidate ?? truncateMiddle(`${host}/${tail}`, smartBudget); } catch { return truncateMiddle(text, smartBudget); } } function isLikelyUrl(text) { return /^[a-z][a-z\d+\-.]*:\/\//i.test(text) || text.startsWith("//"); } function isLikelyImage(text) { if (/\s/.test(text) || isLikelyUrl(text) || text.startsWith("/")) return false; if (!text.includes("/")) return false; const tail = text.slice(text.lastIndexOf("/") + 1); return tail.includes(":") || tail.includes("@sha256:"); } function splitImageTail(segment) { const digestIndex = segment.indexOf("@sha256:"); if (digestIndex >= 0) { return { name: segment.slice(0, digestIndex), suffix: segment.slice(digestIndex) }; } const tagIndex = segment.lastIndexOf(":"); if (tagIndex > 0) { return { name: segment.slice(0, tagIndex), suffix: segment.slice(tagIndex) }; } return { name: segment, suffix: "" }; } function truncateImage(text, budget) { const smartBudget = getStructuredBudget(budget); const segments = text.split("/").filter(Boolean); if (segments.length < 2) return truncateMiddle(text, smartBudget); const tailSegment = segments[segments.length - 1] ?? text; const tailParts = splitImageTail(tailSegment); const tailNameOnly = `…${tailParts.name}`; if (tailNameOnly.length <= smartBudget) return tailNameOnly; if (segments.length === 2) { return compressStructuredText( segments[0] ?? text, void 0, tailParts.name || tailSegment, smartBudget, "/" ); } const head = `${segments[0]}/${segments[1] ?? ""}`.replace(/\/$/, ""); const tail = tailParts.suffix.length > 0 && smartBudget >= tailSegment.length ? tailSegment : tailParts.name || tailSegment; const middle = segments.length > 3 ? summarizeMiddlePathSegment(segments.slice(2, -1).join("/")) : segments.length === 3 ? "…" : void 0; return compressStructuredText(head, middle, tail, smartBudget, "/"); } function truncateArn(text, budget) { const smartBudget = getStructuredBudget(budget); const parts = text.split(":"); if (parts.length < 6) return truncateMiddle(text, smartBudget); const service = parts[2] ?? parts[1] ?? "arn"; const resource = parts.slice(5).join(":"); const resourceSegments = resource.split(/[/:]/).filter(Boolean); const resourceType = resourceSegments.length > 1 ? resourceSegments[0] : void 0; const resourceName = resourceSegments[resourceSegments.length - 1] ?? resource; const resourceNameTail = resourceName.includes("-") && resourceName.split("-").length > 2 ? resourceName.split("-").slice(-2).join("-") : resourceName; const typedTail = resourceType != null && resourceType !== resourceNameTail ? `${resourceType}/${resourceNameTail}` : resourceNameTail; const preferred = `${service}:…${typedTail}`; if (preferred.length <= smartBudget) return preferred; const separatorBudget = 2; const resourceBudget = Math.max( 4, Math.min(resourceNameTail.length, smartBudget - separatorBudget - 2) ); const serviceBudget = Math.max(2, smartBudget - resourceBudget - separatorBudget); return `${truncateSegmentStart(service, serviceBudget)}:…${truncateMiddle(resourceNameTail, resourceBudget)}`; } function detectTruncateStyle(text) { if (text.startsWith("arn:")) return "arn"; if (isLikelyUrl(text)) return "url"; if (isLikelyImage(text)) return "image"; if (text.includes("/")) return "path"; return "suffix"; } function truncateText(text, truncate, maxWidth) { const budget = deriveCharacterBudget(maxWidth); const mode = truncate === "auto" ? detectTruncateStyle(text) : truncate; switch (mode) { case "prefix": return truncatePrefix(text, budget); case "suffix": return truncateSuffix(text, budget); case "arn": return truncateArn(text, budget); case "image": return truncateImage(text, budget); case "path": return truncatePath(text, budget); case "url": return truncateUrl(text, budget); default: return truncateSuffix(text, budget); } } function resolveDisplayNode(node, truncate, maxWidth, alwaysTitle = false) { const fullText = toPlainText(node); if (fullText == null || truncate == null) { return { content: node, fullText, title: alwaysTitle ? fullText ?? void 0 : void 0 }; } const content = truncateText(fullText, truncate, maxWidth); return { content, fullText, title: alwaysTitle || content !== fullText ? fullText : void 0 }; } function copyTextToClipboard(text) { var _a; const clipboard = (_a = globalThis.navigator) == null ? void 0 : _a.clipboard; if ((clipboard == null ? void 0 : clipboard.writeText) == null) return; void clipboard.writeText(text).catch(() => void 0); } function renderRoot({ href, target, rel, className, style, content, canCopy, copyText }) { if (href != null) { return /* @__PURE__ */ jsx( "a", { href, target, rel: rel ?? (target === "_blank" ? "noopener noreferrer" : void 0), className, style, children: content } ); } if (canCopy && copyText != null) { return /* @__PURE__ */ jsx( "button", { type: "button", className, style, onClick: () => copyTextToClipboard(copyText), children: content } ); } return /* @__PURE__ */ jsx("span", { className, style, children: content }); } function Badge({ tone, variant, size = "md", icon, count, children, className, status, label, value, color, textColor, borderColor, shape, href, target = "_self", rel, wrap = false, maxWidth, truncate, clickToCopy, labelClassName, valueClassName }) { const richSignals = label != null || value != null || status != null || shape != null || href != null || wrap === true || color != null || textColor != null || borderColor != null || labelClassName != null || valueClassName != null; const resolvedVariant = variant ?? (richSignals ? "metric" : "soft"); const normalizedMaxWidth = normalizeMaxWidth(maxWidth); const shouldWrapText = wrap && truncate == null; const showFullTextTitle = shouldExposeFullTextTitle({ maxWidth, truncate, wrap }); const textBehaviorClasses = shouldWrapText ? "min-w-0 whitespace-normal break-words [overflow-wrap:anywhere]" : normalizedMaxWidth != null || truncate != null ? "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap" : "whitespace-nowrap"; if (isLegacyVariant(resolvedVariant)) { const legacyStyle = {}; if (normalizedMaxWidth != null) { legacyStyle.maxWidth = normalizedMaxWidth; } const legacyChildren = resolveDisplayNode(children, truncate, maxWidth, showFullTextTitle); const legacyCopyText = legacyChildren.fullText ?? (count !== void 0 ? String(count) : void 0); const canCopy2 = href == null && (clickToCopy ?? true) && legacyCopyText != null; return renderRoot({ href, target, rel, canCopy: canCopy2, copyText: legacyCopyText, className: cn( badgeVariants({ tone, variant: resolvedVariant, size }), (normalizedMaxWidth != null || shouldWrapText || truncate != null) && "min-w-0 max-w-full", shouldWrapText && "whitespace-normal", canCopy2 && "cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", className ), style: legacyStyle, content: /* @__PURE__ */ jsxs(Fragment, { children: [ icon && /* @__PURE__ */ jsx(Icon, { name: icon, className: "text-[1em]" }), count !== void 0 && /* @__PURE__ */ jsx("span", { children: count }), children != null && /* @__PURE__ */ jsx("span", { className: textBehaviorClasses, title: legacyChildren.title, children: legacyChildren.content }) ] }) }); } if (!isRichVariant(resolvedVariant)) { return null; } const semanticStatus = status ?? toneToStatus(tone) ?? "info"; const sizeClasses = RICH_SIZE_CLASSES[size]; const richLabel = label ?? children; const richValue = value ?? (richLabel == null && count !== void 0 ? count : void 0); const resolvedLabel = richValue == null ? resolveDisplayNode(richLabel, truncate, maxWidth, showFullTextTitle) : { content: richLabel, fullText: toPlainText(richLabel), title: showFullTextTitle ? toPlainText(richLabel) ?? void 0 : void 0 }; const resolvedValue = richValue != null ? resolveDisplayNode(richValue, truncate, maxWidth, showFullTextTitle) : { content: richValue, fullText: void 0, title: void 0 }; const copyText = resolvedValue.fullText ?? resolvedLabel.fullText ?? (count !== void 0 ? String(count) : void 0); const canCopy = href == null && (clickToCopy ?? true) && copyText != null; const shapeClass = getShapeClasses(shape ?? "pill"); const wrapperStyle = {}; const wrapperClasses = [ "inline-flex align-middle items-stretch border font-medium shadow-none", sizeClasses.frame, sizeClasses.text, normalizedMaxWidth != null || shouldWrapText || truncate != null ? "min-w-0 max-w-full" : "", shapeClass, shouldWrapText ? "whitespace-normal" : "whitespace-nowrap", href ? "transition-opacity hover:opacity-80" : "", canCopy ? "cursor-pointer text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1" : "" ]; if (normalizedMaxWidth != null) { wrapperStyle.maxWidth = normalizedMaxWidth; } if (resolvedVariant === "status") { wrapperClasses.push(STATUS_STYLES[semanticStatus].soft); } else if (resolvedVariant === "metric") { wrapperClasses.push("bg-muted/60 text-foreground border-border"); } else if (resolvedVariant === "outlined") { if (status != null || toneToStatus(tone) != null) { wrapperClasses.push(STATUS_STYLES[semanticStatus].outlined); } else { wrapperClasses.push("bg-background text-foreground border-border"); } } else if (resolvedVariant === "custom") { wrapperClasses.push("border-transparent bg-accent text-accent-foreground"); } else { wrapperClasses.push("border-border bg-background text-foreground"); } if (resolvedVariant === "custom" || resolvedVariant === "outlined" && status == null && toneToStatus(tone) == null) { applyColorValue(color, "backgroundColor", wrapperStyle, wrapperClasses); applyColorValue(textColor, "color", wrapperStyle, wrapperClasses); applyColorValue(borderColor, "borderColor", wrapperStyle, wrapperClasses); } if (resolvedVariant !== "label" && borderColor != null && status == null && toneToStatus(tone) == null) { applyColorValue(borderColor, "borderColor", wrapperStyle, wrapperClasses); } const iconEl = icon ? /* @__PURE__ */ jsx(Icon, { name: icon, className: cn("shrink-0", sizeClasses.icon) }) : null; if (resolvedVariant === "label") { const labelClasses2 = [ "inline-flex items-center self-stretch", richValue != null ? "shrink-0" : "min-w-0", sizeClasses.segment, sizeClasses.gap, richValue != null ? "border-r border-border/70" : "" ]; const valueClasses2 = [ "inline-flex min-w-0 items-center self-stretch text-foreground", richLabel != null || iconEl != null ? "flex-1" : "", sizeClasses.segment ]; const labelStyle = {}; if (richLabel != null || iconEl != null) { labelClasses2.push("bg-secondary text-secondary-foreground"); applyColorValue(color, "backgroundColor", labelStyle, labelClasses2); applyColorValue(textColor, "color", labelStyle, labelClasses2); } applyColorValue(borderColor, "borderColor", wrapperStyle, wrapperClasses); return renderRoot({ href, target, rel, canCopy, copyText, className: cn(wrapperClasses, className), style: wrapperStyle, content: /* @__PURE__ */ jsxs(Fragment, { children: [ (richLabel != null || iconEl != null) && /* @__PURE__ */ jsxs("span", { className: cn(labelClasses2, labelClassName), style: labelStyle, children: [ iconEl, richLabel != null && /* @__PURE__ */ jsx("span", { className: textBehaviorClasses, title: resolvedLabel.title, children: resolvedLabel.content }) ] }), richValue != null && /* @__PURE__ */ jsx("span", { className: cn(valueClasses2, valueClassName), children: /* @__PURE__ */ jsx("span", { className: textBehaviorClasses, title: resolvedValue.title, children: resolvedValue.content }) }) ] }) }); } const labelClasses = [ "inline-flex items-center", richValue != null ? "shrink-0" : "min-w-0", sizeClasses.segment, sizeClasses.gap, richValue != null ? "border-r border-current/10" : "" ]; const valueClasses = [ "inline-flex min-w-0 items-center font-semibold", richLabel != null || iconEl != null ? "flex-1" : "", sizeClasses.segment ]; if (resolvedVariant === "metric") { labelClasses.push("text-muted-foreground"); valueClasses.push("text-foreground"); } return renderRoot({ href, target, rel, canCopy, copyText, className: cn(wrapperClasses, className), style: wrapperStyle, content: /* @__PURE__ */ jsxs(Fragment, { children: [ (richLabel != null || iconEl != null) && /* @__PURE__ */ jsxs("span", { className: cn(labelClasses, labelClassName), children: [ iconEl, richLabel != null && /* @__PURE__ */ jsx("span", { className: textBehaviorClasses, title: resolvedLabel.title, children: resolvedLabel.content }) ] }), richValue != null && /* @__PURE__ */ jsx("span", { className: cn(valueClasses, valueClassName), children: /* @__PURE__ */ jsx("span", { className: textBehaviorClasses, title: resolvedValue.title, children: resolvedValue.content }) }) ] }) }); } export { Badge, badgeVariants }; //# sourceMappingURL=Badge.js.map