← Files DataARCHIVED FILE
templates/data-app/base/src/components/DataAppChrome.jsx
48.6 KB · Sep 30, 2026 · 23:19 UTC
import { dataAppBuildState } from "../build-state.js";
import * as Dropdown from "@radix-ui/react-dropdown-menu";
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { readHandoffDestination, writeHandoffDestination } from "../handoff-preference.js";
import { dataAppChromeColors } from "../chrome-contrast.js";
import { dataAppPromptLinkProps, openDataAppHandoff } from "../data-app-handoff.js";
import { dataAppChromeLayout } from "../chrome-layout.js";
import { dataAppScheduleDays, normalizeDataAppRefreshSchedule } from "../data-app-schedule.js";
import { refreshCoachmarkDomain as workspaceCookieDomain, rememberRefreshCoachmark } from "../refresh-coachmark.js";
import { useVerificationReminder } from "../use-presentation.js";
import { reportDateMetadata } from "../report-date.js";
import { dataAppThemePalette, dataAppThemes, dataAppThemeToken } from "../theme-presets.js";
import convertGoogleDocsIcon from "./icons/convert-icon-google-docs.svg";
import convertGoogleSlidesIcon from "./icons/convert-icon-google-slides.svg";
import convertJupyterIcon from "./icons/convert-icon-jupyter.svg";
import convertPdfIcon from "./icons/convert-icon-pdf.svg";
import convertPowerpointIcon from "./icons/convert-icon-powerpoint.svg";
import convertWordIcon from "./icons/convert-icon-word.svg";
import { DashboardAskIcon, useDashboardAsk } from "./DashboardAsk.jsx";
import { DashboardTabs } from "./DashboardTabs.jsx";
import { RefreshSetupCoachmark } from "./RefreshSetupCoachmark.jsx";
import { PublishReviewDialog } from "./PublishReviewDialog.jsx";
import { Icon } from "./Icon.jsx";
import { Checkbox, Dialog, Menu, MenuGroup, MenuItem, MenuSeparator, MenuSub, Select, Tooltip } from "./ui.jsx";
const convertOptionGroups = [
[
{ action: "pdf", label: "PDF", iconSrc: convertPdfIcon },
{ action: "word", label: "Word document", iconSrc: convertWordIcon },
{
action: "powerpoint",
label: "PowerPoint",
iconSrc: convertPowerpointIcon,
},
],
[
{
action: "google-docs",
label: "Google Docs",
iconSrc: convertGoogleDocsIcon,
},
{
action: "google-slides",
label: "Google Slides",
iconSrc: convertGoogleSlidesIcon,
},
],
[
{
action: "jupyter-notebook",
label: "Jupyter Notebook",
iconSrc: convertJupyterIcon,
},
],
];
const refreshFrequencyOptions = [
{ value: "hourly", label: "Every hour" },
{ value: "weekdays", label: "Every weekday" },
{ value: "daily", label: "Every day" },
{ value: "weekly", label: "Every week" },
{ value: "custom", label: "Custom days" },
];
const refreshTimeOptions = Array.from({ length: 96 }, (_, index) => {
const hour = Math.floor(index / 4);
const minute = String((index % 4) * 15).padStart(2, "0");
return {
value: `${String(hour).padStart(2, "0")}:${minute}`,
label: `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`,
};
});
function formatReviewedAt(value, part = "date") {
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return "";
const options =
part === "time"
? { hour: "numeric", minute: "2-digit" }
: { month: "short", day: "numeric", ...(part === "compact" ? {} : { year: "numeric" }) };
return new Intl.DateTimeFormat(undefined, options).format(date);
}
function ReviewedFreshness({ generatedAt }) {
return (
<>
<span className="dashboard-freshness-full">
Updated {formatReviewedAt(generatedAt)}
<span className="dashboard-freshness-time">, {formatReviewedAt(generatedAt, "time")}</span>
</span>
<span className="dashboard-freshness-compact">{formatReviewedAt(generatedAt, "compact")}</span>
</>
);
}
function DataAppVerificationBadge({ verification, pendingAction, showUnverified, canEdit, disabled, onVerificationChange,
onReminderError }) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [dontShowAgain, setDontShowAgain] = useState(false);
const { dismissed: reminderDismissed, check, remember } = useVerificationReminder();
const verified = pendingAction ? pendingAction === "verify" : Boolean(verification);
if (!verified && !showUnverified) return null;
const tooltipId = "dashboard-verification-tooltip";
const verifiedAt = verification
? new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(verification.verifiedAt))
: "";
const triggerProps = {
className: "dashboard-verification-trigger",
"aria-describedby": tooltipId,
};
return (
<>
<span className={`dashboard-verification${verified ? " is-verified" : ""}`}>
{canEdit ? (
<button
{...triggerProps}
type="button"
disabled={disabled}
aria-label={verified ? "Remove dashboard verification" : "Mark dashboard as verified"}
aria-haspopup={verified || reminderDismissed ? undefined : "dialog"}
onClick={() => {
if (verified) onVerificationChange?.(false);
else if (check()) onVerificationChange?.(true);
else {
setDontShowAgain(false);
setConfirmOpen(true);
}
}}
>
<Icon name="shieldCheck" size={18} />
</button>
) : (
<span {...triggerProps} role="img" tabIndex={0} aria-label={verified ? "Verified dashboard" : "Unverified dashboard"}>
<Icon name="shieldCheck" size={18} />
</span>
)}
<Tooltip id={tooltipId} className="dashboard-verification-tooltip">
{pendingAction ? (
pendingAction === "verify" ? "Verification will be applied when you save" : "Verification will be removed when you save"
) : verification ? (
<>
<strong>Verified at:</strong>
<span>{verifiedAt}</span>
<strong>Verified by:</strong>
<span>{verification.verifiedBy}</span>
</>
) : (
canEdit ? "Mark as verified" : "Unverified dashboard"
)}
</Tooltip>
</span>
<Dialog
open={confirmOpen && canEdit && !disabled && !verified}
onClose={() => setConfirmOpen(false)}
title="What verification means"
className="dashboard-verification-dialog"
initialFocusSelector=".dialog-header"
>
<div className="dialog-prose">
<p>Marking this page as verified tells others you’ve checked its sources, calculations, and conclusions, and that any important limitations are clearly stated.</p>
<p>People who can view this page will see your email and when you verified it.</p>
</div>
<footer className="dashboard-verification-actions">
<Checkbox checked={dontShowAgain} onChange={event => setDontShowAgain(event.target.checked)}>
Don't show again
</Checkbox>
<button type="button" className="button primary" disabled={disabled} onClick={() => {
if (dontShowAgain && !remember()) onReminderError?.("Couldn't save your preference in this browser. The reminder will appear next time.");
setConfirmOpen(false);
onVerificationChange?.(true);
}}>Got it</button>
</footer>
</Dialog>
</>
);
}
function DataAppFreshnessLabel({ generatedAt }) {
return (
<span className="freshness freshness-label">
<ReviewedFreshness generatedAt={generatedAt} />
</span>
);
}
function ReportDateLabel({ asOf, generatedAt }) {
const metadata = reportDateMetadata({ asOf, generatedAt });
if (!metadata) return null;
const formatted = new Intl.DateTimeFormat(undefined, {
month: "short", day: "numeric", year: "numeric", timeZone: metadata.timeZone,
}).format(metadata.date);
return <span className="freshness freshness-label">
{metadata.label} <time dateTime={metadata.value}>{formatted}</time>
</span>;
}
function dataAppActionLink(getActionHref, action, options) {
return dataAppPromptLinkProps(destination => getActionHref(action, options, destination));
}
function DataAppConvertItems({ getActionHref }) {
return convertOptionGroups.flat().map(({ action, label, iconSrc }) => (
<MenuItem
key={action}
className="dashboard-convert-menu-item"
leading={<img className="dashboard-convert-icon" src={iconSrc} alt="" aria-hidden="true" />}
{...dataAppActionLink(getActionHref, action)}
>
{label}
</MenuItem>
));
}
function DataAppPublishButton({ published, getActionHref, surface, disabled, open, onOpenChange, editing = false, onSave }) {
const [accessMode, setAccessMode] = useState("custom");
const disabledTooltipId = React.useId();
useEffect(() => { if (open) setAccessMode("custom"); }, [open]);
const href = open ? getActionHref("sites", published ? {} : { accessMode }) : null;
function openReview() { setAccessMode("custom"); onOpenChange(true); }
return <>
<span className="history-tooltip-trigger" data-delayed-tooltip>
<button aria-describedby={disabled && !editing ? disabledTooltipId : undefined} type="button" className="dashboard-publish-button" data-mode-action="primary" aria-label={editing ? "Save" : published ? "Publish changes" : "Publish"} onClick={editing ? onSave : openReview} disabled={disabled}>
<span className="dashboard-header-action-label-full">{editing ? "Save" : published ? "Publish changes" : "Publish"}</span>
<span className="dashboard-header-action-label-compact">{editing ? "Save" : "Publish"}</span>
</button>
{disabled && !editing && <Tooltip id={disabledTooltipId} className="topbar-mode-tooltip">Available when this {surface === "report" ? "report" : "dashboard"} is ready.</Tooltip>}
</span>
<PublishReviewDialog open={open} onClose={() => onOpenChange(false)} published={published} surface={surface}
accessMode={accessMode} onAccessChange={setAccessMode} href={href} disabled={disabled} />
</>;
}
function DataAppRefreshControl({ generatedAt, getActionHref, published, scheduleOpen, onScheduleOpenChange }) {
const trigger = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);
const open = scheduleOpen;
const setOpen = onScheduleOpenChange;
const [frequency, setFrequency] = useState("weekdays");
const [time, setTime] = useState("09:00");
const [days, setDays] = useState(["MO", "WE", "FR"]);
const schedule = normalizeDataAppRefreshSchedule({
frequency,
time,
...(frequency === "custom" ? { days } : frequency === "weekly" ? { days: [days[0]] } : {}),
});
let scheduleAction = null;
let scheduleError = "";
if (open && schedule) {
try {
scheduleAction = dataAppActionLink(getActionHref, "schedule-refresh", { schedule });
} catch (error) {
scheduleError = error.message;
}
}
function openSchedule() {
if (published === true) rememberRefreshCoachmark();
setFrequency("weekdays");
setTime("09:00");
setDays(["MO", "WE", "FR"]);
setOpen(true);
}
useEffect(() => {
if (!open) return;
setFrequency("weekdays");
setTime("09:00");
setDays(["MO", "WE", "FR"]);
}, [open]);
return (
<>
<Menu
label="Refresh data"
align="start"
contentClassName="dashboard-refresh-menu"
open={menuOpen}
onOpenChange={setMenuOpen}
trigger={
<button
ref={trigger}
type="button"
className="freshness freshness-button dashboard-refresh-trigger"
aria-label="Refresh data"
title="Refresh data or configure a recurring schedule"
>
<Icon name="refresh" size={16} />
<span>
<ReviewedFreshness generatedAt={generatedAt} />
</span>
<Icon name="chevronDown" size={18} className="dashboard-refresh-trigger-chevron" />
</button>
}
>
<MenuItem icon="refresh" {...dataAppActionLink(getActionHref, "refresh")}>
Refresh now
</MenuItem>
<MenuItem icon="calendar" onSelect={openSchedule}>
Schedule refresh
</MenuItem>
</Menu>
<RefreshSetupCoachmark published={published} blocked={open || menuOpen} anchor={trigger} />
{/* Focusing a native select opens its picker on iOS. Start on the
heading so Repeat opens only after an intentional tap. */}
<Dialog
open={open}
title="Schedule refresh"
className="dashboard-schedule-dialog"
titleInfo="Creates a cloud task for this dashboard's refresh schedule, or updates its existing cloud automation."
initialFocusSelector=".dialog-header"
onClose={() => setOpen(false)}
>
<form className="dashboard-schedule-form" onSubmit={(event) => event.preventDefault()}>
<div className="dashboard-schedule-field">
<span>Repeat</span>
<Select
label="Repeat schedule"
value={frequency}
choices={refreshFrequencyOptions.map(({ value }) => value)}
onChange={(value) => {
setFrequency(value);
if (value === "weekly") setDays((current) => [current[0] ?? "MO"]);
}}
formatChoice={(value) => refreshFrequencyOptions.find((option) => option.value === value)?.label}
triggerClassName="dashboard-schedule-select"
contentClassName="dashboard-schedule-select-menu"
align="end"
modal={false}
/>
</div>
{(frequency === "custom" || frequency === "weekly") && (
<div className="dashboard-schedule-days" role="group" aria-label="Repeat on">
{dataAppScheduleDays.map(({ value, label, short }) => (
<button
type="button"
key={value}
aria-label={label}
aria-pressed={days.includes(value)}
onClick={() =>
setDays((current) =>
frequency === "weekly"
? [value]
: current.includes(value)
? current.filter((day) => day !== value)
: [...current, value],
)
}
>
{short}
</button>
))}
</div>
)}
{frequency !== "hourly" && (
<div className="dashboard-schedule-field">
<span>Time</span>
<Select
label="Refresh time"
value={time}
choices={refreshTimeOptions.map(({ value }) => value)}
onChange={setTime}
formatChoice={(value) => refreshTimeOptions.find((option) => option.value === value)?.label}
triggerClassName="dashboard-schedule-select dashboard-schedule-time"
contentClassName="dashboard-schedule-select-menu dashboard-schedule-time-menu"
align="end"
modal={false}
scrollToSelected
/>
</div>
)}
{scheduleError ? <p role="alert">{scheduleError}</p> : schedule && !scheduleAction?.href && (
<p>Open this app's HTML file directly or use its published URL to schedule refresh.</p>
)}
<footer className="dashboard-schedule-actions">
{scheduleAction?.href ? (
<a
className="dashboard-schedule-submit"
role="button"
{...scheduleAction}
>
Schedule refresh
</a>
) : (
<button type="button" className="dashboard-schedule-submit" disabled>
Schedule refresh
</button>
)}
</footer>
</form>
</Dialog>
</>
);
}
const HeaderActionButton = React.forwardRef(function HeaderActionButton(
{ label, compactLabel, icon, className = "", ...props },
ref,
) {
return (
<button
{...props}
ref={ref}
type="button"
aria-label={label}
className={["dashboard-header-action-button", className].filter(Boolean).join(" ")}
>
{icon && <DashboardAskIcon name={icon} size={18} />}
<span className="dashboard-header-action-label dashboard-header-action-label-full">{label}</span>
{compactLabel && (
<span className="dashboard-header-action-label dashboard-header-action-label-compact">{compactLabel}</span>
)}
</button>
);
});
const HeaderOverflowButton = React.forwardRef(function HeaderOverflowButton(props, ref) {
const tooltipId = React.useId();
return (
<span className="history-tooltip-trigger" data-delayed-tooltip>
<button
{...props}
ref={ref}
type="button"
aria-label="More"
aria-describedby={tooltipId}
className="dashboard-header-action-button dashboard-header-overflow-button"
>
<span className="dashboard-header-overflow-visual"><Icon name="more" size={18} /></span>
</button>
<Tooltip id={tooltipId} className="topbar-mode-tooltip">More</Tooltip>
</span>
);
});
export function DataAppHandoffMenu({ destination, onDestinationChange }) {
return <MenuSub icon="sliders" label="Open ChatGPT in…">
<Dropdown.RadioGroup value={destination} onValueChange={onDestinationChange}>
{[["desktop", "Desktop app"], ["web", "Web browser"], ["ask", "Always ask"]].map(([value, label]) =>
<Dropdown.RadioItem key={value} className="menu-item" value={value}>
<span className="menu-item-label">{label}</span>
<Dropdown.ItemIndicator className="menu-check"><Icon name="check" /></Dropdown.ItemIndicator>
</Dropdown.RadioItem>)}
</Dropdown.RadioGroup>
</MenuSub>;
}
function DataAppOverflowMenu({
canEdit,
canSwitchTheme = canEdit,
getActionHref,
hiddenCount,
mode,
onAction,
onOpenThemes,
onOpenHandoffSettings,
onRestoreHidden,
onAsk,
onEdit,
onPublish,
onScheduleRefresh,
publishDisabled,
hasRefresh,
triggerRef,
published,
surface,
}) {
const [destination, setDestination] = useState(() => readHandoffDestination() ?? "ask");
const canSetHandoffPreference = published && Boolean(workspaceCookieDomain());
const hasActions = published || surface !== "report" || (canEdit && mode === "edit" && hiddenCount > 0);
const hasPreferences = canSwitchTheme || canSetHandoffPreference;
const nativeSelect = useRef(null);
useLayoutEffect(() => { if (nativeSelect.current) nativeSelect.current.selectedIndex = -1; });
const nativeAction = event => {
const action = event.currentTarget.value;
event.currentTarget.selectedIndex = -1; // A command is not a persistent selection.
const openView = callback => requestAnimationFrame(() => callback?.());
if (action === "ask") {
const anchor = event.currentTarget;
openView(() => onAsk?.(anchor));
}
else if (action === "edit") openView(onEdit);
else if (action === "publish") openView(onPublish);
else if (action === "schedule") openView(onScheduleRefresh);
else if (action === "theme") openView(onOpenThemes);
else if (action === "copy-link") onAction?.("copy-link");
else if (action === "handoff-settings") openView(onOpenHandoffSettings);
else if (action === "restore") onRestoreHidden?.();
else if (action.startsWith("export:"))
openDataAppHandoff(destination => getActionHref(action.slice(7), undefined, destination), undefined, { requireTap: true });
else if (action === "refresh" || action === "duplicate")
openDataAppHandoff(destination => getActionHref(action, undefined, destination), undefined, { requireTap: true });
};
return <>
<span className="dashboard-custom-overflow">
<Menu
label="More"
onOpenChange={open => { if (open) setDestination(readHandoffDestination() ?? "ask"); }}
align="start"
contentClassName="dashboard-header-action-menu"
trigger={<HeaderOverflowButton ref={triggerRef} />}
>
<Dropdown.Group className="dashboard-mobile-menu-group">
<Dropdown.Label className="menu-group-label">Actions</Dropdown.Label>
<MenuItem icon="chatBubble" className="dashboard-action-menu-item" onSelect={() => {
requestAnimationFrame(() => onAsk?.());
}}>Ask ChatGPT</MenuItem>
{canEdit && <MenuItem icon="edit" className="dashboard-action-menu-item" onSelect={onEdit}>
Edit text and layout
</MenuItem>}
{!published && <MenuItem icon="globe" className="dashboard-action-menu-item"
disabled={publishDisabled} onSelect={onPublish}>Publish</MenuItem>}
<Dropdown.Separator className="menu-separator" />
</Dropdown.Group>
{hasRefresh && <Dropdown.Group className="dashboard-mobile-menu-group">
<Dropdown.Label className="menu-group-label">Refresh</Dropdown.Label>
<MenuItem icon="refresh" className="dashboard-action-menu-item"
{...dataAppActionLink(getActionHref, "refresh")}>Refresh now</MenuItem>
<MenuItem icon="calendar" className="dashboard-action-menu-item"
onSelect={onScheduleRefresh}>Schedule refresh</MenuItem>
<Dropdown.Separator className="menu-separator" />
</Dropdown.Group>}
{published && (
<MenuItem
icon="link"
className="dashboard-action-menu-item"
onSelect={() => onAction?.("copy-link")}
>
Copy link
</MenuItem>
)}
{surface !== "report" && <MenuItem icon="copy" className="dashboard-action-menu-item"
{...dataAppActionLink(getActionHref, "duplicate")}>Create a copy</MenuItem>}
{canEdit && mode === "edit" && hiddenCount > 0 && (
<MenuItem icon="eye" className="dashboard-action-menu-item" onSelect={onRestoreHidden}>
Restore hidden ({hiddenCount})
</MenuItem>
)}
{hasActions && hasPreferences && <MenuSeparator />}
{canSwitchTheme && (
<MenuItem icon="palette" className="dashboard-action-menu-item" onSelect={onOpenThemes}>
Switch theme
</MenuItem>
)}
{canSetHandoffPreference && <DataAppHandoffMenu destination={destination} onDestinationChange={value => {
if (writeHandoffDestination(value === "ask" ? null : value)) setDestination(value);
else onAction?.("handoff-preference-error");
}} />}
{(hasActions || hasPreferences) && <MenuSeparator />}
<MenuGroup label="Export">
<DataAppConvertItems getActionHref={getActionHref} />
</MenuGroup>
</Menu>
</span>
<span className="dashboard-native-overflow">
<span className="dashboard-header-overflow-visual" aria-hidden="true"><Icon name="more" size={18} /></span>
<select ref={nativeSelect} aria-label="More" onChange={nativeAction}
onKeyDown={event => { if (event.key === "Escape") event.stopPropagation(); }}>
<optgroup label="Actions">
<option value="ask" aria-label="Ask ChatGPT">Ask ChatGPT</option>
{canEdit && <option value="edit">Edit text and layout</option>}
{!published && <option value="publish" aria-label="Publish" disabled={publishDisabled}>Publish</option>}
{canEdit && <option value="theme">Switch theme</option>}
{published && <option value="copy-link">Copy link</option>}
{published && onOpenHandoffSettings && <option value="handoff-settings">Open in ChatGPT settings</option>}
{surface !== "report" && <option value="duplicate">Create a copy</option>}
{canEdit && mode === "edit" && hiddenCount > 0 && <option value="restore">Restore hidden ({hiddenCount})</option>}
</optgroup>
{hasRefresh && <optgroup label="Refresh">
<option value="refresh">Refresh now</option>
<option value="schedule">Schedule refresh</option>
</optgroup>}
<optgroup label="Export">
{convertOptionGroups.flat().map(({ action, label }) =>
<option key={action} value={`export:${action}`}>{label}</option>)}
</optgroup>
</select>
</span>
</>;
}
const promptActionDetails = {
"share-summary": {
label: "Share key insights",
subtext: "Choose where to share a summary",
icon: "summaryBubble",
},
"alert-changes": {
label: "Create a change alert",
subtext: "Choose what triggers an alert",
icon: "alertBell",
},
"create-report": {
label: "Create a report",
subtext: "Turn these findings into a report",
icon: "textDocument",
},
};
export function dataAppAskSuggestions({
canEdit,
hiddenCount,
mode,
onAction,
getActionHref,
onRestoreHidden,
published,
surface,
}) {
const suggestions = Object.entries(promptActionDetails).map(([action, details]) => {
try {
return { action, ...details, ...dataAppActionLink(getActionHref, action),
getHandoffHref: destination => getActionHref(action, undefined, destination) };
} catch {
// An unavailable action must not break the composer or imply a valid target.
return { action, ...details };
}
}).filter(({ href }) => href);
if (canEdit) {
const edit = dataAppActionLink(getActionHref, "edit-in-chatgpt");
if (edit.href) suggestions.push({ action: "edit-in-chatgpt", icon: "edit",
label: surface === "report" ? "Change this report" : "Change this dashboard", subtext: "Tell ChatGPT what to change", ...edit,
getHandoffHref: destination => getActionHref("edit-in-chatgpt", undefined, destination) });
}
return suggestions;
}
export function DataAppTopbar({
title,
buildStatus,
generatedAt,
reportAsOf,
mode,
onModeChange,
onSave,
onCancel,
saving = false,
editHistory,
onTitleChange,
onAction,
getActionHref,
onOpenThemes,
surface = "dashboard",
published = false,
canEdit = true,
verification,
pendingVerification,
onVerificationReminderError,
onVerificationChange,
saveStatus = "idle",
className = "",
tabs = [],
activeTabId,
onTabChange,
onReorderTabs,
hiddenCount = 0,
onRestoreHidden,
}) {
const build = dataAppBuildState({ buildStatus, surface });
const editingBlocked = build.active;
const editTooltipId = React.useId();
const noun = surface === "report" ? "report" : "dashboard";
const label = noun[0].toUpperCase() + noun.slice(1);
const showTabs = surface !== "report" && tabs.length > 1;
const [chromeColors, setChromeColors] = useState({});
const [chromeLayout, setChromeLayout] = useState({});
const topbar = useRef(null);
const modeControls = useRef(null);
const previousMode = useRef(mode);
const actionBounds = useRef(new Map());
useLayoutEffect(() => {
const animations = [];
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
for (const button of modeControls.current?.querySelectorAll("[data-mode-action]") ?? []) {
const bounds = button.getBoundingClientRect();
const previous = actionBounds.current.get(button.dataset.modeAction);
actionBounds.current.set(button.dataset.modeAction, bounds);
if (!previous || reducedMotion || !bounds.width) continue;
const scale = previous.width / bounds.width;
const offset = previous.right - bounds.right;
if (scale === 1 && offset === 0) continue;
const options = { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)" };
// Animate composited transforms after the edit DOM has settled, not width
// (which relayouts the header on every frame). Counter-scale the contents.
animations.push(button.animate([
{ transform: `translateX(${offset}px) scaleX(${scale})` },
{ transform: "none" },
], options));
for (const child of button.children) {
animations.push(child.animate([{ transform: `scaleX(${1 / scale})` }, { transform: "none" }], options));
}
}
animations.forEach(animation => animation.pause());
const frame = requestAnimationFrame(() => animations.forEach(animation => animation.play()));
return () => {
cancelAnimationFrame(frame);
animations.forEach(animation => animation.cancel());
};
}, [mode]);
useLayoutEffect(() => {
// Removing mode-specific controls can move focus to the body.
const activeElement = document.activeElement;
const restoreFocus = previousMode.current !== mode && (
modeControls.current?.contains(activeElement) || activeElement === document.body
);
previousMode.current = mode;
if (!restoreFocus) return undefined;
const frame = requestAnimationFrame(() => {
if (document.activeElement !== activeElement) return;
modeControls.current?.querySelector(
mode === "edit" ? ".dashboard-publish-button" : ".dashboard-header-edit-button",
)?.focus({ preventScroll: true });
});
return () => cancelAnimationFrame(frame);
}, [mode]);
const askTooltipId = React.useId();
const askButtonRef = useRef(null);
const moreButtonRef = useRef(null);
const [publishOpen, setPublishOpen] = useState(false);
const [refreshScheduleOpen, setRefreshScheduleOpen] = useState(false);
const { openDashboardComposer } = useDashboardAsk();
function openAskComposer(anchor = askButtonRef.current) {
if (build.active) return;
openDashboardComposer(anchor, dataAppAskSuggestions({
canEdit, hiddenCount, mode, onAction, getActionHref, onRestoreHidden, published, surface,
}));
}
useEffect(() => {
const header = topbar.current;
if (!header) return undefined;
header.setAttribute("data-tabs-motion-ready", "");
return () => header.removeAttribute("data-tabs-motion-ready");
}, []);
useLayoutEffect(() => {
if (!topbar.current || typeof getComputedStyle !== "function") return undefined;
function updateChromeColors() {
const header = topbar.current;
if (!header) return;
const rootStyles = getComputedStyle(document.documentElement);
const headerStyles = getComputedStyle(header);
const next = dataAppChromeColors({
background: headerStyles.backgroundColor,
underlay: getComputedStyle(document.body).backgroundColor,
foreground: headerStyles.color,
secondary: rootStyles.getPropertyValue("--secondary").trim(),
positive: rootStyles.getPropertyValue("--positive").trim(),
});
setChromeColors((current) => (Object.keys(next).every((key) => current[key] === next[key]) ? current : next));
}
updateChromeColors();
const observer = new MutationObserver(updateChromeColors);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "data-app-theme", "data-color-scheme", "data-app-appearance"],
});
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const main = document.querySelector(`main[data-data-app-content="${surface}"]`);
if (!main || typeof getComputedStyle !== "function" || typeof ResizeObserver !== "function") return undefined;
let frame = 0;
const sizes = new ResizeObserver(scheduleLayout);
function scheduleLayout() {
if (!frame) frame = requestAnimationFrame(updateChromeLayout);
}
function updateChromeLayout() {
frame = 0;
const styles = getComputedStyle(main);
const next = dataAppChromeLayout({
viewportWidth: window.innerWidth,
paddingLeft: Number.parseFloat(styles.paddingLeft),
paddingRight: Number.parseFloat(styles.paddingRight),
});
setChromeLayout((current) => (Object.keys(next).every((key) => current[key] === next[key]) ? current : next));
}
sizes.observe(main);
window.addEventListener("resize", scheduleLayout);
updateChromeLayout();
return () => {
cancelAnimationFrame(frame);
sizes.disconnect();
window.removeEventListener("resize", scheduleLayout);
};
}, [surface]);
return (
<>
<header
ref={topbar}
style={{ ...chromeColors, ...chromeLayout }}
className={["dashboard-topbar", className].filter(Boolean).join(" ")}
data-data-app-chrome="topbar"
data-mode={mode}
>
<div className="dashboard-chrome-inner dashboard-topbar-inner">
<div className="dashboard-topbar-copy">
{published && surface !== "report" && (verification || canEdit) && (
<DataAppVerificationBadge
onReminderError={onVerificationReminderError}
verification={verification}
pendingAction={pendingVerification}
showUnverified={canEdit}
canEdit={canEdit && mode === "edit" && Boolean(onVerificationChange)}
disabled={saving || editingBlocked}
onVerificationChange={onVerificationChange}
/>
)}
<strong
className="dashboard-topbar-title"
contentEditable={mode === "edit" && !saving && !editingBlocked}
suppressContentEditableWarning
aria-label={mode === "edit" ? `Edit ${noun} title` : undefined}
onDoubleClick={
canEdit && !editingBlocked && !saving && saveStatus !== "saving"
? (event) => {
const target = event.currentTarget;
if (mode !== "edit") onModeChange?.("edit");
requestAnimationFrame(() =>
requestAnimationFrame(() => {
if (target.isConnected && target.isContentEditable) target.focus();
}),
);
}
: undefined
}
onBlur={
mode === "edit"
? (event) => {
const next = event.currentTarget.textContent.trim();
if (next) onTitleChange?.(next);
else event.currentTarget.textContent = title;
}
: undefined
}
onKeyDown={
mode === "edit"
? (event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}
: undefined
}
>
{title}
</strong>
{build.label && (
<span className="freshness freshness-label dashboard-authoring-status" role="status"
title={build.active ? `ChatGPT is working on this ${noun}. You can keep exploring; editing and publishing will be available when it finishes.` : "Work is paused. Continue in ChatGPT to finish this update."}>
{build.active && <Icon name="spinner" size={16} className="dashboard-authoring-spinner" />}
{build.label}
</span>
)}
{mode === "edit" ? <span className="freshness freshness-label dashboard-edit-session-label">Editing</span> : null}
{!build.label && mode !== "edit" && generatedAt && canEdit && surface !== "report" && (
<DataAppRefreshControl generatedAt={generatedAt} getActionHref={getActionHref} published={published}
scheduleOpen={refreshScheduleOpen} onScheduleOpenChange={setRefreshScheduleOpen} />
)}
{!build.label && mode !== "edit" && surface === "report" && <ReportDateLabel asOf={reportAsOf} generatedAt={generatedAt} />}
{!build.label && generatedAt && !canEdit && surface !== "report" && (
<span className="freshness freshness-label">
<ReviewedFreshness generatedAt={generatedAt} />
</span>
)}
</div>
<div className="dashboard-topbar-mode-controls" ref={modeControls}>
{canEdit && mode === "edit" && <div className="dashboard-topbar-edit-controls">
{(saving || saveStatus === "error") && (
<span
className={`dashboard-save-status${saveStatus === "error" ? " has-error" : ""}`}
data-status={saveStatus}
role="status"
aria-live="polite"
>
{saving ? "Saving…" : "Couldn’t save"}
</span>
)}
{editHistory && (
<div className="dashboard-edit-history" role="group" aria-label={`${label} edit history`}>
<span className="history-tooltip-trigger">
<button type="button" className="icon-button" aria-label={`Undo ${noun} change`}
disabled={saving || editingBlocked || !editHistory.canUndo} onClick={editHistory.undo}>
<Icon name="undo" size={18} />
</button>
<Tooltip className="topbar-mode-tooltip">Undo</Tooltip>
</span>
<span className="history-tooltip-trigger">
<button type="button" className="icon-button" aria-label={`Redo ${noun} change`}
disabled={saving || editingBlocked || !editHistory.canRedo} onClick={editHistory.redo}>
<Icon name="undo" size={18} className="chart-editor-redo-icon" />
</button>
<Tooltip className="topbar-mode-tooltip">Redo</Tooltip>
</span>
</div>
)}
{hiddenCount > 0 && <button type="button" className="dashboard-header-action-button"
disabled={saving || editingBlocked} onClick={onRestoreHidden}>Restore hidden ({hiddenCount})</button>}
</div>}
<div className="dashboard-topbar-actions">
{mode !== "edit" && <>
<DataAppOverflowMenu
canEdit={canEdit && !editingBlocked}
canSwitchTheme={canEdit}
getActionHref={getActionHref}
hiddenCount={hiddenCount}
mode={mode}
onAction={onAction}
onOpenThemes={onOpenThemes}
onOpenHandoffSettings={undefined}
onRestoreHidden={onRestoreHidden}
onAsk={anchor => openAskComposer(anchor ?? moreButtonRef.current)}
onEdit={() => onModeChange?.("edit")}
onPublish={() => setPublishOpen(true)}
onScheduleRefresh={() => {
if (published) rememberRefreshCoachmark();
setRefreshScheduleOpen(true);
}}
publishDisabled={build.publishBlocked}
hasRefresh={Boolean(generatedAt && canEdit && surface !== "report")}
triggerRef={moreButtonRef}
published={published}
surface={surface}
/>
{canEdit && <span className="history-tooltip-trigger" data-delayed-tooltip>
<button type="button" className="dashboard-header-action-button dashboard-header-edit-button"
aria-describedby={editTooltipId} aria-label="Edit text and layout" disabled={editingBlocked || saveStatus === "saving"} onClick={() => onModeChange?.("edit")}>
<Icon name="edit" size={18} />
</button>
<Tooltip id={editTooltipId} className="topbar-mode-tooltip">{editingBlocked ? `Available when this ${noun} is ready.` : "Edit text and layout"}</Tooltip>
</span>}
</>}
<span className="history-tooltip-trigger" data-delayed-tooltip>
<HeaderActionButton
disabled={mode === "edit" ? saving : build.active}
data-mode-action="secondary"
aria-describedby={mode !== "edit" && build.active ? askTooltipId : undefined}
ref={askButtonRef}
label={mode === "edit" ? "Cancel" : "Ask ChatGPT"}
icon={mode === "edit" ? null : "chatBubble"}
compactLabel={mode === "edit" ? "Cancel" : "Ask"}
className="dashboard-ask-button"
onClick={mode === "edit" ? onCancel : () => openAskComposer()}
/>
{build.active && <Tooltip id={askTooltipId} className="topbar-mode-tooltip">Available when this {noun} is ready.</Tooltip>}
</span>
{(!published || mode === "edit") && <DataAppPublishButton published={published} getActionHref={getActionHref} surface={surface}
editing={mode === "edit"} onSave={onSave} disabled={mode === "edit" ? saving || editingBlocked : build.publishBlocked}
open={publishOpen} onOpenChange={setPublishOpen} />}
</div>
</div>
</div>
<div
className={`dashboard-tabs-collapse${showTabs ? " is-open" : ""}`}
aria-hidden={!showTabs}
inert={!showTabs}
>
<div className="dashboard-tabs-collapse-inner">
<DashboardTabs
tabs={tabs}
activeTabId={activeTabId}
editMode={mode === "edit" && !saving && !editingBlocked}
onChange={onTabChange}
onReorder={onReorderTabs}
/>
</div>
</div>
</header>
{surface === "report" && mode !== "edit" && (reportAsOf || generatedAt) &&
<div className="dashboard-mobile-report-date"><ReportDateLabel asOf={reportAsOf} generatedAt={generatedAt} /></div>}
</>
);
}
function normalizedTokens(values) {
if (typeof document === "undefined" || typeof getComputedStyle !== "function" || !document.body) {
return values.map((value) => String(value));
}
const probe = document.createElement("i");
probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none";
document.body.append(probe);
try {
return values.map((value, index) => {
if (index <= 20) {
probe.style.color = value;
return getComputedStyle(probe).color;
}
if (index <= 23) return String(Number.parseFloat(value));
if (index <= 25) {
probe.style.boxShadow = value;
return getComputedStyle(probe).boxShadow;
}
probe.style.fontFamily = value;
return getComputedStyle(probe).fontFamily;
});
} finally {
probe.remove();
}
}
function matchingPreset(originalTokens) {
const original = normalizedTokens(originalTokens);
const scheme = globalThis.document?.documentElement?.dataset.colorScheme ?? "light";
return dataAppThemes.find((theme) => {
const tokens = dataAppThemePalette(theme, scheme);
return tokens && normalizedTokens(tokens).every((value, index) => value === original[index]);
});
}
function fontLabel(font) {
if (/mono|courier|menlo/iu.test(font)) return "Monospace";
if (/rounded/iu.test(font)) return "Rounded sans";
if (/georgia|times/iu.test(font)) return "Serif";
return "Sans serif";
}
function subscribeColorScheme(onChange) {
const root = globalThis.document?.documentElement;
if (!root || typeof MutationObserver === "undefined") return () => {};
const observer = new MutationObserver(onChange);
observer.observe(root, {
attributes: true,
attributeFilter: ["data-color-scheme"],
});
return () => observer.disconnect();
}
function currentColorScheme() {
return globalThis.document?.documentElement?.dataset.colorScheme ?? "light";
}
function ThemePreview({ theme, originalTokens, colorScheme }) {
const tokens = dataAppThemePalette(theme, colorScheme) ?? originalTokens;
const background = dataAppThemeToken(tokens, "background", "var(--background)");
const surface = dataAppThemeToken(tokens, "surface", "var(--surface)");
const text = dataAppThemeToken(tokens, "text", "var(--text)");
const accent = dataAppThemeToken(tokens, "accent", "var(--accent)");
const secondary = dataAppThemeToken(tokens, "secondary", "var(--secondary)");
const border = dataAppThemeToken(tokens, "border", "var(--border)");
const control = dataAppThemeToken(tokens, "control", "var(--control)");
const controlRadius = Number.parseFloat(dataAppThemeToken(tokens, "control-radius", "8px"));
const radius = controlRadius >= 16 ? "999px" : controlRadius > 0 ? "5px" : "0px";
const font = dataAppThemeToken(tokens, "font-sans", "ui-sans-serif, system-ui, sans-serif");
const swatches = Array.from({ length: 5 }, (_, index) => dataAppThemeToken(tokens, `chart-${index + 1}`, accent));
return (
<span
className="theme-preview"
aria-hidden="true"
style={{
"--preview-background": background,
"--preview-surface": surface,
"--preview-text": text,
"--preview-secondary": secondary,
"--preview-accent": accent,
"--preview-border": border,
"--preview-control": control,
"--preview-radius": radius,
"--preview-font": font,
}}
>
<span className="theme-preview-type">
<strong>Aa</strong>
<span>{fontLabel(font)}</span>
</span>
<span className="theme-preview-swatches">
{swatches.map((swatch, index) => (
<i key={`${swatch}:${index}`} style={{ background: swatch }} />
))}
</span>
<span className="theme-preview-geometry">
<i />
</span>
</span>
);
}
export function DataAppThemeDrawer({
activeTheme,
open,
onApply,
onClose,
onPreview,
onPreviewEnd,
originalTokens,
appearance = "system",
onAppearanceChange,
}) {
const list = useRef(null);
const colorScheme = useSyncExternalStore(subscribeColorScheme, currentColorScheme, currentColorScheme);
const equivalentPreset = useMemo(() => matchingPreset(originalTokens), [originalTokens]);
const themes = equivalentPreset ? dataAppThemes.filter(({ id }) => id !== "original") : dataAppThemes;
const selected = activeTheme === "original" && equivalentPreset ? equivalentPreset.id : activeTheme;
useEffect(() => {
if (open && list.current) list.current.scrollLeft = 0;
}, [open]);
return (
<section
className={`theme-drawer${open ? " is-open" : ""}`}
aria-hidden={!open}
inert={!open}
aria-label="Theme picker"
>
<div className="dashboard-chrome-inner theme-drawer-content">
<header className="theme-drawer-header">
<h2>Theme</h2>
<div className="theme-drawer-actions">
{onAppearanceChange && (
<>
<span className="theme-appearance-native">
<Select label="Appearance" value={appearance} choices={["light", "dark", "system"]}
onChange={onAppearanceChange} formatChoice={value => value[0].toUpperCase() + value.slice(1)}
triggerClassName="theme-appearance-trigger" />
</span>
<span className="theme-appearance-menu">
<Menu
label="Dashboard appearance"
align="end"
trigger={
<button
type="button"
className="theme-appearance-trigger"
aria-label="Appearance"
tabIndex={open ? 0 : -1}
>
<Icon name={appearance === "light" ? "sun" : appearance === "dark" ? "moon" : "monitor"} />
<span>{appearance[0].toUpperCase() + appearance.slice(1)}</span>
<Icon name="chevronDown" />
</button>
}
>
<Dropdown.RadioGroup value={appearance} onValueChange={onAppearanceChange}>
{[
{ value: "light", icon: "sun", label: "Light" },
{ value: "dark", icon: "moon", label: "Dark" },
{ value: "system", icon: "monitor", label: "System" },
].map(({ value, icon, label }) => (
<Dropdown.RadioItem key={value} className="menu-item" value={value}>
<Icon name={icon} />
<span className="menu-item-label">{label}</span>
<Dropdown.ItemIndicator className="menu-check">
<Icon name="check" />
</Dropdown.ItemIndicator>
</Dropdown.RadioItem>
))}
</Dropdown.RadioGroup>
</Menu>
</span>
</>
)}
<button
type="button"
className="icon-button theme-drawer-close"
aria-label="Close theme picker"
tabIndex={open ? 0 : -1}
onClick={onClose}
>
<Icon name="cross" size={20} />
</button>
</div>
</header>
</div>
<div ref={list} className="theme-card-list">
{themes.map((theme) => (
<button
type="button"
key={theme.id}
className={`theme-card${selected === theme.id ? " is-active" : ""}`}
aria-pressed={selected === theme.id}
aria-label={`Apply ${theme.label}`}
tabIndex={open ? 0 : -1}
onClick={() => onApply(theme.id)}
onPointerMove={() => {
if (document.documentElement.dataset.appTheme !== theme.id) onPreview?.(theme.id);
}}
onPointerLeave={onPreviewEnd}
onFocus={() => onPreview?.(theme.id)}
onBlur={onPreviewEnd}
>
<ThemePreview theme={theme} originalTokens={originalTokens} colorScheme={colorScheme} />
<span className="theme-card-label">{theme.label}</span>
</button>
))}
</div>
</section>
);
}
SHA-256: 6eae567b93a5f5c1ca4eccecfbdf3d85398f181e2e7d2f3e3447b6f5ade50233