UNPKG

@activecollab/components

Version:

ActiveCollab Components

356 lines (336 loc) 15.4 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import React from "react"; import styled from "styled-components"; import { COVER_RATIO_HERO, StyledChip, StyledClamp, StyledColorCover, StyledFavicon, coverManagement, playControl, Placeholder } from "./shared"; import { formatFetchedAt } from "./unfurl"; import { ArrowRefreshIcon, InsertLinkIcon, WarningTriangleIcon } from "../../../components/Icons"; import { SkeletonLoader } from "../../../components/Loaders"; import { STACKED_CARD_COVER_RATIO as COVER_RATIO, STACKED_CARD_CONTROL_CLASS, StackedCardCover as Cover, StackedCardRow as StackRow } from "../../../components/StackedCard"; import { Body2, Caption1 } from "../../../components/Typography"; import { Typography } from "../../../components/Typography/Typography"; /* There is no global CSS reset in this Storybook, so a raw <button> falls back to the UA font (Arial) instead of the DS stack. Every button below is text, so they all need it. */ const CONTROL_FONT = "-apple-system, BlinkMacSystemFont, \"Roboto\",\n \"Helvetica Neue\", Arial, sans-serif"; /** * LinkCard — a link preview as a LIFECYCLE, not two static layouts. * * Data climbs a progressive ladder (url → basic → full); the anatomy is * CONSTANT at every tier, so each slot swaps a skeleton for real content in * place and the card never reflows on upgrade. Four statuses layer on top: * resolving — climbing the ladder, aspect + rows reserved by skeletons * resolved — the full card * stale — resolved, but past its cache window: shows `fetchedAt` and a * re-unfurl affordance * error — the unfurl failed: favicon + raw URL + retry, NO media * * Compact ↔ expanded is a user-switchable DISPLAY LEVEL on the same link * entity (the data lives in the controller, so switching never refetches or * loses it) — not two separate card types. */ /* ------------------------------------------------------------------ */ /* Small building blocks */ /* ------------------------------------------------------------------ */ const StyledDomainRow = styled.div.withConfig({ displayName: "LinkCard__StyledDomainRow", componentId: "sc-qyea73-0" })(["display:flex;align-items:center;gap:8px;min-height:16px;"]); const StyledChips = styled.div.withConfig({ displayName: "LinkCard__StyledChips", componentId: "sc-qyea73-1" })(["display:flex;align-items:center;gap:6px;min-height:18px;"]); /* The stale strip — a re-unfurl affordance with the fetch time. */ const StyledStaleStrip = styled.div.withConfig({ displayName: "LinkCard__StyledStaleStrip", componentId: "sc-qyea73-2" })(["display:flex;align-items:center;gap:8px;color:var(--color-theme-600);> span{flex:1 1 auto;min-width:0;}"]); const StyledReunfurl = styled.button.withConfig({ displayName: "LinkCard__StyledReunfurl", componentId: "sc-qyea73-3" })(["display:inline-flex;align-items:center;gap:4px;flex:0 0 auto;padding:3px 8px;border:1px solid var(--border-primary);border-radius:6px;background:var(--page-paper-main);color:var(--color-theme-800);font-family:", ";font-size:11px;cursor:pointer;svg{width:12px;height:12px;}&:hover{background:var(--color-theme-200);border-color:var(--sc-selection-ring);}"], CONTROL_FONT); /* The error body — favicon + raw URL + retry, no media (per spec §5). It is a column row like any other: the padding comes from the role, so the error state lines up with the states it replaces instead of setting its own inset. Only the gap is widened — three stacked blocks, not four text lines. */ const StyledErrorRow = styled(StackRow).withConfig({ displayName: "LinkCard__StyledErrorRow", componentId: "sc-qyea73-4" })(["gap:10px;.err-head{display:flex;align-items:center;gap:8px;color:var(--red-alert);}.err-head svg{width:16px;height:16px;flex:0 0 auto;}.err-url{display:flex;align-items:center;gap:8px;word-break:break-all;}"]); const StyledRetry = styled.button.withConfig({ displayName: "LinkCard__StyledRetry", componentId: "sc-qyea73-5" })(["align-self:flex-start;display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--border-primary);border-radius:6px;background:var(--page-paper-main);color:var(--color-theme-800);font-family:", ";cursor:pointer;svg{width:14px;height:14px;}&:hover{background:var(--color-theme-200);border-color:var(--sc-selection-ring);}"], CONTROL_FONT); /* The resolved cover. A production unfurl stores the OpenGraph image, so when the ladder returns one we render a real <img> (the Cover fill already gives it `object-fit: cover`); the fill colour is the fallback for links that carry no image at all. */ const resolvedCover = data => { var _data$heroColor; return data.heroSrc ? /*#__PURE__*/React.createElement("img", { src: data.heroSrc, alt: "" }) : /*#__PURE__*/React.createElement(StyledColorCover, { $color: (_data$heroColor = data.heroColor) != null ? _data$heroColor : "#1D9E75" }); }; /* The expanded footer band — tinted, holds the title + domain under the hero. */ const StyledFooterTitle = styled(Body2).withConfig({ displayName: "LinkCard__StyledFooterTitle", componentId: "sc-qyea73-6" })(["display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"]); /* ------------------------------------------------------------------ */ /* The display-level switch — lives in the entity "…" slot */ /* ------------------------------------------------------------------ */ const StyledDisplayToggle = styled.button.withConfig({ displayName: "LinkCard__StyledDisplayToggle", componentId: "sc-qyea73-7" })(["display:inline-flex;align-items:center;padding:2px 8px;border:1px solid var(--border-primary);border-radius:10px;background:var(--color-theme-200);color:var(--color-theme-700);font-family:", ";font-size:11px;cursor:pointer;white-space:nowrap;&:hover{background:var(--color-theme-300);}"], CONTROL_FONT); const DisplayToggle = _ref => { let display = _ref.display, onToggle = _ref.onToggle; return /*#__PURE__*/React.createElement(StyledDisplayToggle, { type: "button", "data-fixed": true, className: STACKED_CARD_CONTROL_CLASS, "aria-label": "Switch display level", onClick: onToggle }, display === "compact" ? "Expand ▾" : "Compact ▴"); }; /* ------------------------------------------------------------------ */ /* LinkCard */ /* ------------------------------------------------------------------ */ export const LinkCard = _ref2 => { var _data$faviconColor2; let unfurl = _ref2.unfurl, _ref2$display = _ref2.display, display = _ref2$display === void 0 ? "compact" : _ref2$display, onToggleDisplay = _ref2.onToggleDisplay; const status = unfurl.status, tier = unfurl.tier, data = unfurl.data, fetchedAt = unfurl.fetchedAt, retry = unfurl.retry, reunfurl = unfurl.reunfurl; // ── Error: favicon + raw URL + retry, no media ────────────────── if (status === "error") { return /*#__PURE__*/React.createElement(StyledErrorRow, { role: "custom", direction: "column" }, /*#__PURE__*/React.createElement("div", { className: "err-head" }, /*#__PURE__*/React.createElement(WarningTriangleIcon, null), /*#__PURE__*/React.createElement(Caption1, { weight: "bold", color: "tertiary" }, "Couldn\u2019t load a preview")), /*#__PURE__*/React.createElement("div", { className: "err-url" }, /*#__PURE__*/React.createElement(StyledFavicon, { $bg: "var(--color-theme-400)", "data-fixed": true }), /*#__PURE__*/React.createElement(Caption1, { color: "secondary" }, data.url)), /*#__PURE__*/React.createElement(StyledRetry, { type: "button", onClick: retry }, /*#__PURE__*/React.createElement(ArrowRefreshIcon, null), /*#__PURE__*/React.createElement(Typography, { as: "span", variant: "Caption 1" }, "Retry"))); } const hasBasic = tier === "basic" || tier === "full"; const hasFull = tier === "full"; /* The display level belongs to the host, so the switch only appears when a host actually owns one. A surface that fixes the card at one level (the whiteboard pins it to expanded) gets no dead control. */ const toggle = onToggleDisplay ? /*#__PURE__*/React.createElement(DisplayToggle, { display: display, onToggle: onToggleDisplay }) : null; // ── Expanded: hero + tinted footer band (title + domain) ──────── if (display === "expanded") { var _data$faviconColor; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Cover, { aspectRatio: COVER_RATIO_HERO, centerSlot: hasFull && data.playable ? playControl : undefined, topRightSlot: coverManagement() }, hasFull ? resolvedCover(data) : /*#__PURE__*/React.createElement(SkeletonLoader, { style: { width: "100%", height: "100%" } })), /*#__PURE__*/React.createElement(StackRow, { role: "footer", direction: "column", background: "var(--color-theme-200)" }, /*#__PURE__*/React.createElement(StyledDomainRow, null, hasBasic ? /*#__PURE__*/React.createElement(StyledFavicon, { $bg: (_data$faviconColor = data.faviconColor) != null ? _data$faviconColor : "#0F6E56", "data-fixed": true }) : /*#__PURE__*/React.createElement(SkeletonLoader, { "data-fixed": true, style: { width: 16, height: 16, borderRadius: 4 } }), hasBasic ? /*#__PURE__*/React.createElement(Caption1, { color: "tertiary" }, data.domain) : /*#__PURE__*/React.createElement(Caption1, { color: "tertiary" }, data.url), /*#__PURE__*/React.createElement("span", { style: { flex: "1 1 auto" } }), toggle), hasBasic ? /*#__PURE__*/React.createElement(StyledFooterTitle, { weight: "bold" }, data.title) : /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 14, width: "90%" } }), hasFull ? /*#__PURE__*/React.createElement(Caption1, { color: "tertiary" }, data.description) : /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 10, width: "70%" } })), status === "stale" ? /*#__PURE__*/React.createElement(StackRow, { role: "meta" }, /*#__PURE__*/React.createElement(StaleStrip, { fetchedAt: fetchedAt, onReunfurl: reunfurl })) : null); } // ── Compact: cover + favicon/domain + title + chips ───────────── return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Cover, { aspectRatio: COVER_RATIO, centerSlot: hasFull && data.playable ? playControl : undefined }, hasFull ? resolvedCover(data) : /*#__PURE__*/React.createElement(SkeletonLoader, { style: { width: "100%", height: "100%" } })), /*#__PURE__*/React.createElement(StackRow, { role: "custom" }, /*#__PURE__*/React.createElement(StyledDomainRow, null, hasBasic ? /*#__PURE__*/React.createElement(StyledFavicon, { $bg: (_data$faviconColor2 = data.faviconColor) != null ? _data$faviconColor2 : "#0F6E56", "data-fixed": true }) : /*#__PURE__*/React.createElement(SkeletonLoader, { "data-fixed": true, style: { width: 16, height: 16, borderRadius: 4 } }), hasBasic ? /*#__PURE__*/React.createElement(Caption1, { color: "tertiary" }, data.domain) : /*#__PURE__*/React.createElement(Caption1, { color: "tertiary" }, data.url), /*#__PURE__*/React.createElement("span", { style: { flex: "1 1 auto" } }), toggle)), /*#__PURE__*/React.createElement(StackRow, { role: "title" }, hasBasic ? /*#__PURE__*/React.createElement(StyledClamp, null, data.title) : /*#__PURE__*/React.createElement("div", { style: { width: "100%" } }, /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 14, width: "92%" } }), /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 14, width: "60%", marginTop: 6 } }))), /*#__PURE__*/React.createElement(StackRow, { role: "custom" }, /*#__PURE__*/React.createElement(StyledChips, null, hasFull ? /*#__PURE__*/React.createElement(React.Fragment, null, data.kind ? /*#__PURE__*/React.createElement(StyledChip, { "data-fixed": true }, data.kind) : null, data.duration ? /*#__PURE__*/React.createElement(StyledChip, { "data-fixed": true }, data.duration) : null) : /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 18, width: 96, borderRadius: 10 } }))), status === "stale" ? /*#__PURE__*/React.createElement(StackRow, { role: "meta" }, /*#__PURE__*/React.createElement(StaleStrip, { fetchedAt: fetchedAt, onReunfurl: reunfurl })) : null); }; /* The stale affordance — fetch time + a manual re-unfurl button. */ const StaleStrip = _ref3 => { let fetchedAt = _ref3.fetchedAt, onReunfurl = _ref3.onReunfurl; return /*#__PURE__*/React.createElement(StyledStaleStrip, null, /*#__PURE__*/React.createElement(Typography, { as: "span", variant: "Caption 1", color: "tertiary" }, "Preview may be out of date \xB7 fetched ", formatFetchedAt(fetchedAt)), /*#__PURE__*/React.createElement(StyledReunfurl, { type: "button", onClick: onReunfurl }, /*#__PURE__*/React.createElement(ArrowRefreshIcon, null), "Re-unfurl")); }; /* ---- skeleton ---- */ /* Mirrors the compact anatomy row for row (this is the `resolving` render at tier `url`), so a resolving link never reflows as it climbs the ladder. */ export const LinkSkeleton = () => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Cover, { aspectRatio: COVER_RATIO }, /*#__PURE__*/React.createElement(SkeletonLoader, { style: { width: "100%", height: "100%" } })), /*#__PURE__*/React.createElement(StackRow, { role: "custom" }, /*#__PURE__*/React.createElement(StyledDomainRow, null, /*#__PURE__*/React.createElement(SkeletonLoader, { "data-fixed": true, style: { width: 16, height: 16, borderRadius: 4 } }), /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 10, width: "40%" } }))), /*#__PURE__*/React.createElement(StackRow, { role: "title" }, /*#__PURE__*/React.createElement("div", { style: { width: "100%" } }, /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 14, width: "92%" } }), /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 14, width: "60%", marginTop: 6 } }))), /*#__PURE__*/React.createElement(StackRow, { role: "custom" }, /*#__PURE__*/React.createElement(StyledChips, null, /*#__PURE__*/React.createElement(SkeletonLoader, { style: { height: 18, width: 96, borderRadius: 10 } })))); /* ---- placeholder ---- */ export const LinkPlaceholder = props => /*#__PURE__*/React.createElement(Placeholder, _extends({ icon: /*#__PURE__*/React.createElement(InsertLinkIcon, null), label: "Add a link" }, props)); //# sourceMappingURL=LinkCard.js.map