@activecollab/components
Version:
ActiveCollab Components
356 lines (336 loc) • 15.4 kB
JavaScript
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