Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 150 additions & 3 deletions src/components/Demos/EventHistoryWalkthrough/WorkflowWalkthrough.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { usePrismTheme } from '@docusaurus/theme-common';
import { Highlight } from 'prism-react-renderer';
import { FaExpand } from 'react-icons/fa';
import Prism from './prism-languages';
import styles from './workflow-walkthrough.module.css';

Expand Down Expand Up @@ -44,9 +46,46 @@ function CodePanel({ code, lines, language }) {
const prismTheme = usePrismTheme();
const active = useMemo(() => new Set(lines ?? []), [lines]);
const hasActive = active.size > 0;
const wrapRef = useRef(null);

useEffect(() => {
const container = wrapRef.current;
if (!container) return;
const activeEls = container.querySelectorAll('[data-active="true"]');
if (activeEls.length === 0) return;

// A step's active lines are usually a fresh statement, not a continuation
// of whatever a previous long line scrolled to — start back at the left.
container.scrollLeft = 0;

const first = activeEls[0];
const last = activeEls[activeEls.length - 1];
const viewTop = container.scrollTop;
const viewHeight = container.clientHeight;
// `offsetTop` is relative to the nearest *positioned* ancestor, which
// isn't necessarily this container (.demo needs `position: relative` for
// the zoom button, which makes it the offsetParent instead) — measure
// against the container's own box instead so this can't silently drift.
const containerTop = container.getBoundingClientRect().top;
const rangeTop = first.getBoundingClientRect().top - containerTop + viewTop;
const rangeBottom = last.getBoundingClientRect().bottom - containerTop + viewTop;
const padding = 12;

let nextScrollTop = viewTop;
if (rangeBottom - rangeTop > viewHeight || rangeTop < viewTop) {
// Doesn't fit in one screen, or starts above the fold: lead with the
// first active line rather than whichever end happens to be reachable.
nextScrollTop = rangeTop - padding;
} else if (rangeBottom > viewTop + viewHeight) {
// Starts in view but runs past the bottom: pull its end into view.
nextScrollTop = rangeBottom - viewHeight + padding;
}
container.scrollTop = Math.max(0, nextScrollTop);
}, [lines]);

return (
<div
ref={wrapRef}
className={styles.codeWrap}
style={{
backgroundColor: prismTheme.plain.backgroundColor,
Expand All @@ -63,6 +102,7 @@ function CodePanel({ code, lines, language }) {
<div
{...lineProps}
key={i}
data-active={isActive ? 'true' : 'false'}
className={[
styles.line,
lineProps.className,
Expand Down Expand Up @@ -221,6 +261,47 @@ function Controls({ steps, currentStep, onStep, onPrev, onNext }) {
);
}

/**
* Full-viewport version of the demo, portaled to `document.body` so it can be
* much wider than the doc content column allows. Unlike the site's other
* zoom modals (`ZoomableImage`, `MermaidZoomWrapper`), the content here is a
* live, still-interactive copy of the walkthrough, not a static image or SVG
* snapshot — so clicks inside it must not close the overlay the way clicking
* an enlarged image does.
*/
function ZoomOverlay({ onClose, initialFocusRef, children }) {
// Runs once, when the overlay itself mounts (i.e. exactly when zoom opens)
// — not on every render of the parent, which stays mounted the whole time.
useEffect(() => {
initialFocusRef?.current?.focus();
}, [initialFocusRef]);

useEffect(() => {
const handleKey = (event) => {
if (event.key === 'Escape') onClose();
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKey);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);

return createPortal(
<div className={styles.zoomOverlay} onClick={onClose}>
<div className={styles.zoomCard} onClick={(event) => event.stopPropagation()}>
<button type="button" className={styles.zoomClose} aria-label="Close expanded view" onClick={onClose}>
&times;
</button>
{children}
</div>
</div>,
document.body
);
}

/**
* Step-by-step walkthrough of a Workflow Definition: highlights the lines the
* Worker is running and accumulates whatever the walkthrough tracks alongside
Expand All @@ -235,15 +316,38 @@ function Controls({ steps, currentStep, onStep, onPrev, onNext }) {
*/
export default function WorkflowWalkthrough({ code, language, steps, columns = [], ariaLabel }) {
const [currentStep, setCurrentStep] = useState(1);
const [isZoomed, setIsZoomed] = useState(false);
const zoomButtonRef = useRef(null);
const zoomedDemoRef = useRef(null);
const step = steps[currentStep - 1];

const onKeyDown = (event) => {
if (event.key === 'ArrowLeft') setCurrentStep((s) => Math.max(1, s - 1));
if (event.key === 'ArrowRight') setCurrentStep((s) => Math.min(steps.length, s + 1));
};

return (
<div className={styles.demo} role="group" aria-label={ariaLabel} tabIndex={0} onKeyDown={onKeyDown}>
// Return focus to the trigger button on close, matching standard dialog
// behavior. (The reverse — focusing into the overlay on open — happens in
// ZoomOverlay itself, keyed off its own mount.) This has to be an effect
// rather than a plain call in the close handler: `setIsZoomed(false)`
// hasn't committed yet at that point, so the trigger button's ancestor is
// still `inert` and the focus() call would silently no-op. Skipping the
// first render keeps it from also firing (and stealing focus) on mount.
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (!isZoomed) {
zoomButtonRef.current?.focus();
}
}, [isZoomed]);

const closeZoom = () => setIsZoomed(false);

const body = (
<>
<div className={styles.content}>
<CodePanel code={code} language={language} lines={step.lines} />
<StepPanel step={step} total={steps.length} columns={columns} steps={steps} />
Expand All @@ -256,6 +360,49 @@ export default function WorkflowWalkthrough({ code, language, steps, columns = [
onPrev={() => setCurrentStep(Math.max(1, currentStep - 1))}
onNext={() => setCurrentStep(Math.min(steps.length, currentStep + 1))}
/>
</div>
</>
);

return (
<>
{/* `inert` while zoomed: without it this copy stays in the tab order,
hidden behind the overlay backdrop, so keyboard/screen-reader users
could still reach a widget they can't see. */}
<div
className={styles.demo}
inert={isZoomed}
role="group"
aria-label={ariaLabel}
tabIndex={0}
onKeyDown={onKeyDown}
>
<button
type="button"
ref={zoomButtonRef}
className={styles.zoomButton}
aria-label="Expand walkthrough"
title="Expand walkthrough"
onClick={() => setIsZoomed(true)}
>
<FaExpand aria-hidden="true" size="0.8rem" />
</button>
{body}
</div>

{isZoomed && (
<ZoomOverlay onClose={closeZoom} initialFocusRef={zoomedDemoRef}>
<div
ref={zoomedDemoRef}
className={styles.demo}
role="group"
aria-label={ariaLabel}
tabIndex={0}
onKeyDown={onKeyDown}
>
{body}
</div>
</ZoomOverlay>
)}
</>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.demo {
position: relative;
margin: 2rem 0;
border: 1px solid var(--ifm-color-emphasis-300);
background: var(--ifm-background-color);
Expand All @@ -10,6 +11,85 @@
outline-offset: 2px;
}

.zoomButton {
position: absolute;
top: 0.6rem;
right: 0.75rem;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
padding: 0;
border: 1px solid var(--ifm-color-emphasis-300);
background: var(--ifm-background-surface-color);
color: var(--ifm-color-emphasis-700);
cursor: pointer;
}

.zoomButton:hover,
.zoomButton:focus-visible {
color: var(--ifm-font-color-base);
border-color: var(--ifm-color-emphasis-400);
}

.zoomOverlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 4vh 4vw;
background: rgba(0, 0, 0, 0.82);
backdrop-filter: blur(4px);
z-index: calc(var(--ifm-z-index-fixed) + 10);
animation: workflowZoomFadeIn 0.15s ease-out;
}

.zoomCard {
width: min(95vw, 1400px);
max-height: 90vh;
overflow: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}

.zoomCard .demo {
margin: 0;
}

.zoomClose {
position: fixed;
top: 16px;
right: 24px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
color: #fff;
background: rgba(0, 0, 0, 0.4);
border: none;
border-radius: 50%;
cursor: pointer;
transition: background 0.15s ease;
}

.zoomClose:hover {
background: rgba(0, 0, 0, 0.7);
}

@keyframes workflowZoomFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.content {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
Expand Down Expand Up @@ -40,6 +120,12 @@
line-height: 1.6;
color: inherit;
background: transparent;
/* Without this, <pre> stretches to exactly fill .codeWrap (its containing
block) instead of sizing to its longest line, so no child box is ever
actually wider than .codeWrap — .codeWrap's `overflow: auto` then has
nothing to scroll, and long lines just clip at the edge instead. */
width: max-content;
min-width: 100%;
}

.line {
Expand Down