mirror of
https://github.com/mintlify/docs.git
synced 2026-09-14 13:35:46 +08:00
846 lines
27 KiB
React
846 lines
27 KiB
React
export const AssistantWidgetPlayground = ({ children, CodeBlockComponent }) => {
|
|
// Mintlify evaluates snippet exports independently, so shared values must stay in this scope.
|
|
const EXAMPLE_WIDGET_ID = "YOUR_WIDGET_ID";
|
|
const EMBED_URL =
|
|
"https://cdn.jsdelivr.net/npm/@mintlify/assistant-widget@0.0/dist/browser/embed.js";
|
|
// The preview loads the hidden /assistant/widget-preview page at a real URL
|
|
// because captcha providers reject srcdoc documents without a hostname.
|
|
// Message names must stay in sync with snippets/assistant-widget-preview-host.jsx.
|
|
const PREVIEW_READY_MESSAGE = "mintlify-assistant-playground:ready";
|
|
const PREVIEW_UPDATE_MESSAGE = "mintlify-assistant-playground:update";
|
|
const PREVIEW_STATE_MESSAGE = "mintlify-assistant-playground:state";
|
|
const SUPPORT_EMAIL = "hi@mintlify.com";
|
|
const STARTER_QUESTIONS = [
|
|
"How do I get started with Mintlify?",
|
|
"How do I customize my docs?",
|
|
"How do I deploy my docs?",
|
|
];
|
|
const VARIANT_OPTIONS = [
|
|
{ value: "widget", label: "Widget" },
|
|
{ value: "modal", label: "Modal" },
|
|
{ value: "panel", label: "Panel" },
|
|
];
|
|
const RADIUS_OPTIONS = [
|
|
{ value: 0, label: "None", detail: "0px" },
|
|
{ value: 4, label: "Extra small", detail: "4px" },
|
|
{ value: 8, label: "Small", detail: "8px" },
|
|
{ value: 12, label: "Medium", detail: "12px" },
|
|
{ value: 16, label: "Large", detail: "16px" },
|
|
{ value: 20, label: "Extra large", detail: "20px" },
|
|
{ value: 24, label: "2X large", detail: "24px" },
|
|
];
|
|
// Labels match the widget API: `side` is the trigger's screen edge.
|
|
const SIDE_OPTIONS = [
|
|
{ value: "bottom", label: "Bottom" },
|
|
{ value: "top", label: "Top" },
|
|
{ value: "left", label: "Left" },
|
|
{ value: "right", label: "Right" },
|
|
{ value: "inline-start", label: "Inline start" },
|
|
{ value: "inline-end", label: "Inline end" },
|
|
];
|
|
const ALIGN_OPTIONS = [
|
|
{ value: "start", label: "Start" },
|
|
{ value: "center", label: "Center" },
|
|
{ value: "end", label: "End" },
|
|
];
|
|
const INSTALL_OPTIONS = [
|
|
{ value: "html", label: "HTML" },
|
|
{ value: "next", label: "Next.js" },
|
|
];
|
|
|
|
const CustomizeIcon = () => (
|
|
<svg
|
|
aria-hidden="true"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M2.83337 4.83325V12.1666"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<path
|
|
d="M13.1666 3.83325V11.1666"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<circle
|
|
cx="2.83337"
|
|
cy="3.33325"
|
|
r="1.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<circle
|
|
cx="13.1666"
|
|
cy="12.6667"
|
|
r="1.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<path
|
|
d="M6.5 3.83325H7.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<path
|
|
d="M9.83337 3.83325H10.8334"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<path
|
|
d="M5.16663 12.1667H6.16663"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
<path
|
|
d="M8.5 12.1667H9.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="square"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ThemeIcon = () => (
|
|
<svg
|
|
aria-hidden="true"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M8 5.33333C6.52724 5.33333 5.33333 6.52724 5.33333 8C5.33333 9.47276 6.52724 10.6667 8 10.6667V14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2V5.33333ZM8 5.33333C9.47276 5.33333 10.6667 6.52724 10.6667 8C10.6667 9.47276 9.47276 10.6667 8 10.6667V5.33333Z"
|
|
fill="currentColor"
|
|
/>
|
|
<circle
|
|
cx="8.00004"
|
|
cy="7.99992"
|
|
r="6.16667"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const [installTarget, setInstallTarget] = useState("html");
|
|
const [variant, setVariant] = useState("widget");
|
|
const [previewTheme, setPreviewTheme] = useState(null);
|
|
const [accent, setAccent] = useState("#166E3F");
|
|
const [accentDraft, setAccentDraft] = useState("#166E3F");
|
|
const [accentKeyboardFocus, setAccentKeyboardFocus] = useState(false);
|
|
const [radius, setRadius] = useState(16);
|
|
const [side, setSide] = useState("bottom");
|
|
const [align, setAlign] = useState("end");
|
|
const [trackEvents, setTrackEvents] = useState(false);
|
|
const [reportErrors, setReportErrors] = useState(false);
|
|
const [customizeOpen, setCustomizeOpen] = useState(false);
|
|
const [openSelect, setOpenSelect] = useState(null);
|
|
const [activeSelectOptionIndex, setActiveSelectOptionIndex] = useState(0);
|
|
const [previewHostReady, setPreviewHostReady] = useState(false);
|
|
const [previewUrl, setPreviewUrl] = useState(null);
|
|
const [previewStatus, setPreviewStatus] = useState("loading");
|
|
const accentInputRef = useRef(null);
|
|
const accentPointerFocusRef = useRef(false);
|
|
const previewRef = useRef(null);
|
|
const previewHostRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
// Resolve the preview page against the deployment base path (for example
|
|
// /docs on mintlify.com). Every locale can reuse the same host document
|
|
// because the preview UI is configured by this component.
|
|
const pageMatch = window.location.pathname
|
|
.replace(/\/$/, "")
|
|
.match(/^(.*?)(\/[a-z]{2}(?:-[A-Za-z]{2,4})?)?\/assistant\/widget$/);
|
|
const basePath = pageMatch?.[1] ?? "";
|
|
const mode = document.documentElement.classList.contains("dark")
|
|
? "dark"
|
|
: "light";
|
|
setPreviewUrl(`${basePath}/assistant/widget-preview?mode=${mode}`);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// A deleted custom script can leave its parent-page widget mounted during local hot reloads.
|
|
const removeRootWidget = () => {
|
|
const rootWidget = document.querySelector("body > mintlify-assistant");
|
|
if (!rootWidget) return false;
|
|
|
|
const destroyPromise = window.MintlifyAssistant?.destroy();
|
|
void destroyPromise?.catch(() => {});
|
|
rootWidget.remove();
|
|
return true;
|
|
};
|
|
const rootWidgetObserver = new MutationObserver(removeRootWidget);
|
|
|
|
removeRootWidget();
|
|
rootWidgetObserver.observe(document.body, { childList: true });
|
|
|
|
return () => rootWidgetObserver.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// SPA navigations can mount this page before the sticky/absolute preview
|
|
// host has a laid-out box. Delay the iframe until the host has size.
|
|
const host = previewHostRef.current;
|
|
if (!host) return undefined;
|
|
|
|
const markReady = (height) => {
|
|
if (height > 0) setPreviewHostReady(true);
|
|
};
|
|
|
|
markReady(host.getBoundingClientRect().height);
|
|
|
|
if (typeof ResizeObserver === "undefined") {
|
|
setPreviewHostReady(true);
|
|
return undefined;
|
|
}
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
markReady(entries[0]?.contentRect.height ?? 0);
|
|
});
|
|
observer.observe(host);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!openSelect) return undefined;
|
|
|
|
const closeOnPointerDown = (event) => {
|
|
if (
|
|
event.target instanceof Element &&
|
|
event.target.closest(`[data-assistant-select="${openSelect}"]`)
|
|
) {
|
|
return;
|
|
}
|
|
setOpenSelect(null);
|
|
};
|
|
const closeOnEscape = (event) => {
|
|
if (event.key === "Escape") setOpenSelect(null);
|
|
};
|
|
|
|
document.addEventListener("pointerdown", closeOnPointerDown);
|
|
document.addEventListener("keydown", closeOnEscape);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", closeOnPointerDown);
|
|
document.removeEventListener("keydown", closeOnEscape);
|
|
};
|
|
}, [openSelect]);
|
|
|
|
useEffect(() => {
|
|
const accentInput = accentInputRef.current;
|
|
if (!accentInput) return undefined;
|
|
|
|
// React's color-input change handler fires while the native picker moves.
|
|
// Commit from the native change event instead, which fires on confirmation.
|
|
const commitAccent = () => {
|
|
const nextAccent = accentInput.value.toUpperCase();
|
|
setAccentDraft(nextAccent);
|
|
setAccent(nextAccent);
|
|
};
|
|
|
|
accentInput.addEventListener("change", commitAccent);
|
|
return () => accentInput.removeEventListener("change", commitAccent);
|
|
}, []);
|
|
|
|
const renderSelectField = ({ id, label, onChange, options, value }) => {
|
|
const isOpen = openSelect === id;
|
|
const selectedIndex = options.findIndex((option) => option.value === value);
|
|
const selectedOption = options[selectedIndex] ?? options[0];
|
|
|
|
const openMenu = (initialIndex = selectedIndex) => {
|
|
setActiveSelectOptionIndex(Math.max(initialIndex, 0));
|
|
setOpenSelect(id);
|
|
};
|
|
|
|
const closeMenu = () => setOpenSelect(null);
|
|
|
|
const selectByIndex = (index) => {
|
|
const option = options[index];
|
|
if (!option) return;
|
|
onChange(option.value);
|
|
closeMenu();
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (!isOpen) {
|
|
openMenu();
|
|
return;
|
|
}
|
|
|
|
const direction = event.key === "ArrowDown" ? 1 : -1;
|
|
setActiveSelectOptionIndex(
|
|
(currentIndex) =>
|
|
(currentIndex + direction + options.length) % options.length,
|
|
);
|
|
return;
|
|
}
|
|
if (event.key === "Home" || event.key === "End") {
|
|
event.preventDefault();
|
|
if (!isOpen) openMenu();
|
|
setActiveSelectOptionIndex(
|
|
event.key === "Home" ? 0 : options.length - 1,
|
|
);
|
|
return;
|
|
}
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
if (isOpen) {
|
|
selectByIndex(activeSelectOptionIndex);
|
|
} else {
|
|
openMenu();
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "Escape" && isOpen) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
closeMenu();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="assistant-playground-field">
|
|
<span
|
|
id={`assistant-playground-${id}-label`}
|
|
className="assistant-playground-field__label"
|
|
>
|
|
{label}
|
|
</span>
|
|
<div
|
|
className="assistant-playground-select"
|
|
data-assistant-select={id}
|
|
data-open={isOpen ? "true" : "false"}
|
|
onBlur={(event) => {
|
|
if (
|
|
!(event.relatedTarget instanceof Node) ||
|
|
!event.currentTarget.contains(event.relatedTarget)
|
|
) {
|
|
setOpenSelect(null);
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="assistant-playground-field__control"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={isOpen}
|
|
aria-controls={`assistant-playground-${id}-options`}
|
|
aria-activedescendant={
|
|
isOpen
|
|
? `assistant-playground-${id}-option-${activeSelectOptionIndex}`
|
|
: undefined
|
|
}
|
|
aria-labelledby={`assistant-playground-${id}-label assistant-playground-${id}-value`}
|
|
onClick={() => {
|
|
if (isOpen) {
|
|
closeMenu();
|
|
} else {
|
|
openMenu();
|
|
}
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<span id={`assistant-playground-${id}-value`}>
|
|
{selectedOption?.label}
|
|
</span>
|
|
<span className="assistant-playground-select__end">
|
|
{selectedOption?.detail ? (
|
|
<span className="assistant-playground-select__detail">
|
|
{selectedOption.detail}
|
|
</span>
|
|
) : null}
|
|
<svg
|
|
aria-hidden="true"
|
|
className="assistant-playground-select__chevron"
|
|
viewBox="0 0 16 16"
|
|
>
|
|
<path
|
|
d="M4 6L8 10L12 6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.5"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
{isOpen ? (
|
|
<div
|
|
id={`assistant-playground-${id}-options`}
|
|
className="assistant-playground-select__options"
|
|
role="listbox"
|
|
aria-labelledby={`assistant-playground-${id}-label`}
|
|
>
|
|
{options.map((option, index) => (
|
|
<button
|
|
key={option.value}
|
|
id={`assistant-playground-${id}-option-${index}`}
|
|
type="button"
|
|
role="option"
|
|
tabIndex={-1}
|
|
aria-selected={option.value === value}
|
|
data-active={
|
|
activeSelectOptionIndex === index ? "true" : "false"
|
|
}
|
|
data-selected={option.value === value ? "true" : "false"}
|
|
onPointerDown={(event) => event.preventDefault()}
|
|
onPointerMove={() => setActiveSelectOptionIndex(index)}
|
|
onClick={() => {
|
|
selectByIndex(index);
|
|
}}
|
|
>
|
|
<span>{option.label}</span>
|
|
{option.detail ? (
|
|
<span className="assistant-playground-select__detail">
|
|
{option.detail}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderToggleRow = ({ checked, description, label, onChange }) => (
|
|
<label className="assistant-playground-hook">
|
|
<span className="assistant-playground-hook__copy">
|
|
<span className="assistant-playground-hook__label">{label}</span>
|
|
<span className="assistant-playground-hook__description">{description}</span>
|
|
</span>
|
|
<span
|
|
className="assistant-playground-switch"
|
|
data-checked={checked ? "true" : "false"}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
role="switch"
|
|
checked={checked}
|
|
onChange={(event) => onChange(event.target.checked)}
|
|
className="assistant-playground-switch__input"
|
|
/>
|
|
<span aria-hidden="true" className="assistant-playground-switch__knob" />
|
|
</span>
|
|
</label>
|
|
);
|
|
|
|
const renderDivider = () => (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
className="assistant-playground-customizer__divider"
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
focusable="false"
|
|
width="100%"
|
|
height="1"
|
|
preserveAspectRatio="none"
|
|
viewBox="0 0 100 1"
|
|
>
|
|
<line
|
|
x1="0"
|
|
y1="0.5"
|
|
x2="100"
|
|
y2="0.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1"
|
|
strokeDasharray="5 5"
|
|
strokeLinecap="butt"
|
|
vectorEffect="non-scaling-stroke"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
);
|
|
|
|
const appearance = useMemo(
|
|
() => ({
|
|
variant,
|
|
accent,
|
|
radius: `${radius}px`,
|
|
side,
|
|
align,
|
|
}),
|
|
[accent, align, radius, side, variant],
|
|
);
|
|
|
|
const togglePreviewTheme = () => {
|
|
setPreviewTheme((currentTheme) => {
|
|
const isDark =
|
|
currentTheme === "dark" ||
|
|
(currentTheme === null &&
|
|
document.documentElement.classList.contains("dark"));
|
|
return isDark ? "light" : "dark";
|
|
});
|
|
};
|
|
|
|
const toggleCustomizer = () => {
|
|
setCustomizeOpen((isOpen) => !isOpen);
|
|
setOpenSelect(null);
|
|
};
|
|
|
|
const updatePreview = useCallback(() => {
|
|
const previewWindow = previewRef.current?.contentWindow;
|
|
if (!previewWindow) return;
|
|
|
|
// Match the docs theme until the preview-only toggle overrides it.
|
|
// Generated examples always retain "system".
|
|
const liveTheme =
|
|
previewTheme ??
|
|
(document.documentElement.classList.contains("dark") ? "dark" : "light");
|
|
|
|
previewWindow.postMessage(
|
|
{
|
|
type: PREVIEW_UPDATE_MESSAGE,
|
|
trackEvents,
|
|
reportErrors,
|
|
appearance: {
|
|
...appearance,
|
|
theme: liveTheme,
|
|
},
|
|
},
|
|
// The preview page is same-origin (served by this docs site).
|
|
window.location.origin,
|
|
);
|
|
}, [appearance, previewTheme, reportErrors, trackEvents]);
|
|
|
|
useEffect(() => {
|
|
const handlePreviewMessage = (event) => {
|
|
if (event.source !== previewRef.current?.contentWindow) return;
|
|
|
|
if (event.data?.type === PREVIEW_READY_MESSAGE) {
|
|
updatePreview();
|
|
return;
|
|
}
|
|
if (event.data?.type === PREVIEW_STATE_MESSAGE) {
|
|
setPreviewStatus(event.data.state === "error" ? "error" : "ready");
|
|
}
|
|
};
|
|
const themeObserver = new MutationObserver(updatePreview);
|
|
|
|
window.addEventListener("message", handlePreviewMessage);
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["class"],
|
|
});
|
|
updatePreview();
|
|
|
|
return () => {
|
|
window.removeEventListener("message", handlePreviewMessage);
|
|
themeObserver.disconnect();
|
|
};
|
|
}, [updatePreview]);
|
|
|
|
useEffect(() => {
|
|
// Stop showing the loading state if the preview page never reports readiness.
|
|
// Leave the iframe untouched so local 404 pages remain visible while developing.
|
|
if (!previewHostReady || !previewUrl || previewStatus !== "loading") {
|
|
return undefined;
|
|
}
|
|
|
|
const timeout = window.setTimeout(() => {
|
|
setPreviewStatus((status) => (status === "loading" ? "error" : status));
|
|
}, 20000);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [previewHostReady, previewStatus, previewUrl]);
|
|
|
|
const configLines = [
|
|
"{",
|
|
` id: '${EXAMPLE_WIDGET_ID}',`,
|
|
` supportEmail: '${SUPPORT_EMAIL}',`,
|
|
" starterQuestions: [",
|
|
...STARTER_QUESTIONS.map((question) => ` '${question}',`),
|
|
" ],",
|
|
" appearance: {",
|
|
` variant: '${variant}',`,
|
|
" theme: 'system',",
|
|
` accent: '${accent}',`,
|
|
` radius: '${radius}px',`,
|
|
` side: '${side}',`,
|
|
` align: '${align}',`,
|
|
" },",
|
|
];
|
|
|
|
if (trackEvents || reportErrors) {
|
|
configLines.push(" hooks: {");
|
|
if (trackEvents) {
|
|
configLines.push(
|
|
" event(event) {",
|
|
" console.log('Assistant event', event);",
|
|
" },",
|
|
);
|
|
}
|
|
if (reportErrors) {
|
|
configLines.push(
|
|
" error(error) {",
|
|
" console.error('Assistant error', error.code, error);",
|
|
" },",
|
|
);
|
|
}
|
|
configLines.push(" },");
|
|
}
|
|
|
|
configLines.push("}");
|
|
|
|
const configCode = configLines.join("\n");
|
|
const indentedConfig = configCode.split("\n").join("\n ");
|
|
const htmlCode = `<script
|
|
type="module"
|
|
src="${EMBED_URL}"
|
|
></script>
|
|
<script type="module">
|
|
await window.MintlifyAssistant.init(${indentedConfig});
|
|
</script>`;
|
|
const nextCode = `'use client';
|
|
|
|
import Script from 'next/script';
|
|
|
|
const ASSISTANT_CONFIG = ${configCode};
|
|
|
|
export const AssistantWidget = () => (
|
|
<Script
|
|
type="module"
|
|
src="${EMBED_URL}"
|
|
onReady={() => {
|
|
void window.MintlifyAssistant.init(ASSISTANT_CONFIG);
|
|
}}
|
|
/>
|
|
);`;
|
|
const installCode = installTarget === "html" ? htmlCode : nextCode;
|
|
|
|
return (
|
|
<div data-assistant-playground-layout="">
|
|
<section
|
|
className="assistant-playground-frame not-prose"
|
|
data-customize-open={customizeOpen ? "true" : "false"}
|
|
aria-label="Assistant widget playground"
|
|
>
|
|
<div className="assistant-playground-toolbar">
|
|
<button
|
|
type="button"
|
|
className="assistant-playground-toolbar__button assistant-playground-toolbar__button--labeled"
|
|
aria-controls="assistant-playground-customizer"
|
|
aria-expanded={customizeOpen}
|
|
onClick={toggleCustomizer}
|
|
>
|
|
<CustomizeIcon />
|
|
<span>Customize</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="assistant-playground-toolbar__button assistant-playground-toolbar__button--icon"
|
|
aria-label="Toggle preview theme"
|
|
onClick={togglePreviewTheme}
|
|
>
|
|
<ThemeIcon />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
id="assistant-playground-customizer"
|
|
className="assistant-playground-customizer"
|
|
role="dialog"
|
|
aria-label="Customize assistant widget"
|
|
hidden={!customizeOpen}
|
|
>
|
|
<div className="assistant-playground-customizer__section">
|
|
<div className="assistant-playground-customizer__heading">
|
|
Component
|
|
</div>
|
|
{renderSelectField({
|
|
id: "variant",
|
|
label: "Variant",
|
|
value: variant,
|
|
options: VARIANT_OPTIONS,
|
|
onChange: setVariant,
|
|
})}
|
|
{renderSelectField({
|
|
id: "radius",
|
|
label: "Corner radius",
|
|
value: radius,
|
|
options: RADIUS_OPTIONS,
|
|
onChange: setRadius,
|
|
})}
|
|
<label className="assistant-playground-field">
|
|
<span className="assistant-playground-field__label">Accent</span>
|
|
<span
|
|
className="assistant-playground-accent"
|
|
data-keyboard-focus={accentKeyboardFocus ? "true" : "false"}
|
|
>
|
|
<input
|
|
ref={accentInputRef}
|
|
type="color"
|
|
value={accentDraft}
|
|
onInput={(event) =>
|
|
setAccentDraft(event.currentTarget.value.toUpperCase())
|
|
}
|
|
onPointerDown={() => {
|
|
accentPointerFocusRef.current = true;
|
|
setAccentKeyboardFocus(false);
|
|
}}
|
|
onFocus={() => {
|
|
setAccentKeyboardFocus(!accentPointerFocusRef.current);
|
|
accentPointerFocusRef.current = false;
|
|
}}
|
|
onBlur={(event) => {
|
|
setAccentKeyboardFocus(false);
|
|
const nextAccent = event.currentTarget.value.toUpperCase();
|
|
setAccentDraft(nextAccent);
|
|
setAccent(nextAccent);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
setAccentKeyboardFocus(true);
|
|
if (event.key === "Enter") {
|
|
const nextAccent =
|
|
event.currentTarget.value.toUpperCase();
|
|
setAccentDraft(nextAccent);
|
|
setAccent(nextAccent);
|
|
}
|
|
}}
|
|
aria-label="Accent color"
|
|
className="assistant-playground-accent__input"
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className="assistant-playground-accent__swatch"
|
|
style={{ backgroundColor: accentDraft }}
|
|
/>
|
|
<span>{accentDraft}</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{renderDivider()}
|
|
|
|
<div className="assistant-playground-customizer__section">
|
|
<div className="assistant-playground-customizer__heading">Trigger</div>
|
|
{renderSelectField({
|
|
id: "side",
|
|
label: "Placement",
|
|
value: side,
|
|
options: SIDE_OPTIONS,
|
|
onChange: setSide,
|
|
})}
|
|
{renderSelectField({
|
|
id: "align",
|
|
label: "Alignment",
|
|
value: align,
|
|
options: ALIGN_OPTIONS,
|
|
onChange: setAlign,
|
|
})}
|
|
</div>
|
|
|
|
{renderDivider()}
|
|
|
|
<div className="assistant-playground-customizer__section">
|
|
<div className="assistant-playground-customizer__heading">Hooks</div>
|
|
{renderToggleRow({
|
|
label: "Lifecycle events",
|
|
description:
|
|
"Observe open, close, ask, update, and navigation events.",
|
|
checked: trackEvents,
|
|
onChange: setTrackEvents,
|
|
})}
|
|
{renderToggleRow({
|
|
label: "Structured errors",
|
|
description: "Receive stable error codes and retry metadata.",
|
|
checked: reportErrors,
|
|
onChange: setReportErrors,
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
ref={previewHostRef}
|
|
className="assistant-playground-preview"
|
|
data-assistant-preview=""
|
|
data-assistant-preview-card=""
|
|
>
|
|
{previewHostReady && previewUrl ? (
|
|
<iframe
|
|
ref={previewRef}
|
|
title="Live Assistant Widget preview"
|
|
src={previewUrl}
|
|
onLoad={updatePreview}
|
|
scrolling="no"
|
|
/>
|
|
) : null}
|
|
{previewStatus === "loading" ? (
|
|
<div
|
|
aria-live="polite"
|
|
role="status"
|
|
className="assistant-playground-preview__status"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="assistant-playground-preview__spinner"
|
|
/>
|
|
<span>Loading assistant preview...</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<div
|
|
className="assistant-playground-code not-prose"
|
|
data-assistant-code=""
|
|
>
|
|
<div className="assistant-playground-code__header">
|
|
<div>
|
|
<div className="assistant-playground-code__title">Install</div>
|
|
<div className="assistant-playground-code__description">
|
|
Copy the generated setup for your stack.
|
|
</div>
|
|
</div>
|
|
<div
|
|
role="group"
|
|
aria-label="Installation target"
|
|
className="assistant-playground-code__tabs"
|
|
>
|
|
{INSTALL_OPTIONS.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
data-active={installTarget === option.value ? "true" : "false"}
|
|
aria-pressed={installTarget === option.value}
|
|
onClick={() => setInstallTarget(option.value)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<CodeBlockComponent
|
|
// Always use JSX to render the generated HTML and Next.js snippets consistently.
|
|
language="jsx"
|
|
filename={
|
|
installTarget === "html" ? "index.html" : "assistant-widget.jsx"
|
|
}
|
|
wrap
|
|
>
|
|
{installCode}
|
|
</CodeBlockComponent>
|
|
</div>
|
|
|
|
{children ? <div className="assistant-playground-children">{children}</div> : null}
|
|
</div>
|
|
);
|
|
};
|