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
114 changes: 92 additions & 22 deletions src/editor/blocks/step.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type FocusEvent } from "react";
import { StepField } from "./stepField";
import { StepHorizontalView } from "./stepHorizontalView";
import { useDeferredMount } from "./useDeferredMount";
Expand All @@ -11,7 +11,7 @@ const VIEW_MODE_KEY = "bn-step-view-mode";
const STEP_TITLE_PLACEHOLDER = "Enter step title...";
const STEP_DATA_PLACEHOLDER = "Enter step data...";
const EXPECTED_RESULT_PLACEHOLDER = "Enter expected result...";
type StepViewMode = "vertical" | "horizontal";
type StepViewMode = "vertical" | "horizontal" | "compact";
const FORCE_VERTICAL_WIDTH = 550;

/* readExpectedCollapsedPreference removed — currently unused */
Expand All @@ -32,7 +32,8 @@ const readStepViewMode = (): StepViewMode => {
return "vertical";
}
try {
return window.localStorage.getItem(VIEW_MODE_KEY) === "horizontal" ? "horizontal" : "vertical";
const stored = window.localStorage.getItem(VIEW_MODE_KEY);
return stored === "horizontal" || stored === "compact" ? stored : "vertical";
} catch {
return "vertical";
}
Expand Down Expand Up @@ -413,6 +414,9 @@ function TestStepContent({
const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
const containerRef = useRef<HTMLDivElement>(null);
const [forceVertical, setForceVertical] = useState(false);
// In compact mode each step collapses to a reading-focused row and only
// expands to the full editing layout while one of its fields has focus.
const [expanded, setExpanded] = useState(false);

useEffect(() => {
const el = containerRef.current?.parentElement;
Expand All @@ -426,7 +430,11 @@ function TestStepContent({
return () => observer.disconnect();
}, []);

const effectiveVertical = forceVertical || viewMode === "vertical";
const compactMode = viewMode === "compact";
const effectiveHorizontal = viewMode === "horizontal" && !forceVertical;
// Compact steps render the vertical layout but collapse their chrome until
// a field gains focus, at which point the step expands to "normal" editing.
const compactCollapsed = compactMode && !expanded;

useEffect(() => {
if (typeof window === "undefined") {
Expand Down Expand Up @@ -569,14 +577,45 @@ function TestStepContent({
}, [editor, block.id]);

const handleToggleView = useCallback(() => {
const next = viewMode === "horizontal" ? "vertical" : "horizontal";
// Cycle vertical → horizontal → compact → vertical. Skip horizontal when
// the container is too narrow to fit its two columns.
let next: StepViewMode;
if (viewMode === "vertical") {
next = forceVertical ? "compact" : "horizontal";
} else if (viewMode === "horizontal") {
next = "compact";
} else {
next = "vertical";
}
writeStepViewMode(next);
setViewMode(next);
if (typeof window !== "undefined") {
window.dispatchEvent(new Event("bn-step-view-mode"));
}
}, [viewMode, forceVertical]);

const handleContentFocusCapture = useCallback(() => {
if (viewMode === "compact") {
setExpanded(true);
}
}, [viewMode]);

const handleContentBlurCapture = useCallback(
(event: FocusEvent<HTMLDivElement>) => {
if (viewMode !== "compact") {
return;
}
// Keep the step expanded while focus stays inside it (e.g. moving to a
// toolbar or action button); collapse only when focus leaves entirely.
const nextTarget = event.relatedTarget as Node | null;
if (nextTarget && event.currentTarget.contains(nextTarget)) {
return;
}
setExpanded(false);
},
[viewMode],
);

const [dataFocusSignal] = useState(0);
const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);

Expand All @@ -592,28 +631,42 @@ function TestStepContent({
editor.updateBlock(block.id, { props: { expectedResult: "" } });
}, [editor, block.id]);

const nextViewLabel =
viewMode === "compact"
? "Switch to vertical view"
: viewMode === "horizontal"
? "Switch to compact view"
: forceVertical
? "Switch to compact view"
: "Switch to horizontal view";

const viewToggleButton = (
<button
type="button"
className={`bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`}
data-tooltip={forceVertical ? "Not enough space for horizontal view" : "Switch step view"}
aria-label={forceVertical ? "Not enough space for horizontal view" : "Switch step view"}
onClick={forceVertical ? undefined : handleToggleView}
aria-disabled={forceVertical}
className={`bn-teststep__view-toggle${effectiveHorizontal ? " bn-teststep__view-toggle--horizontal" : ""}${compactMode ? " bn-teststep__view-toggle--compact" : ""}`}
data-tooltip={nextViewLabel}
aria-label={nextViewLabel}
onClick={handleToggleView}
tabIndex={-1}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<mask id="mask-toggle" style={{maskType: "alpha"}} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask-toggle)">
<path d="M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z" fill="currentColor"/>
</g>
</svg>
{compactMode ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3.333h12V4.667H2V3.333Zm0 4h12v1.334H2V7.333Zm0 4h12v1.334H2v-1.334Z" fill="currentColor"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<mask id="mask-toggle" style={{maskType: "alpha"}} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask-toggle)">
<path d="M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z" fill="currentColor"/>
</g>
</svg>
)}
</button>
);

if (!effectiveVertical) {
if (effectiveHorizontal) {
return (
<StepHorizontalView
ref={containerRef}
Expand All @@ -632,14 +685,22 @@ function TestStepContent({
}

return (
<div className="bn-teststep" data-block-id={block.id} ref={containerRef}>
<div
className={`bn-teststep${compactMode ? " bn-teststep--compact" : ""}${compactCollapsed ? " bn-teststep--collapsed" : ""}`}
data-block-id={block.id}
ref={containerRef}
>
<div className="bn-teststep__timeline">
<span className="bn-teststep__number">{stepNumber}</span>
<div className="bn-teststep__line" />
</div>
<div className="bn-teststep__content">
<div
className="bn-teststep__content"
onFocus={handleContentFocusCapture}
onBlur={handleContentBlurCapture}
>
<div className="bn-teststep__header">
<span className="bn-teststep__title">Step</span>
{!compactMode && <span className="bn-teststep__title">Step</span>}
{viewToggleButton}
</div>
<StepField
Expand All @@ -654,6 +715,8 @@ function TestStepContent({
disableNewlines
enableAutocomplete
fieldName="title"
compact={compactCollapsed}
compactMode={compactMode}
suggestionFilter={(suggestion) => (suggestion as StepSuggestion).isSnippet !== true}
onFieldFocus={handleFieldFocus}
enableImageUpload={false}
Expand Down Expand Up @@ -683,6 +746,9 @@ function TestStepContent({
{isDataVisible ? (
<StepField
label="Step data"
showLabel={!compactCollapsed}
compact={compactCollapsed}
compactMode={compactMode}
placeholder={STEP_DATA_PLACEHOLDER}
labelAction={
<button
Expand Down Expand Up @@ -710,6 +776,10 @@ function TestStepContent({
{isExpectedVisible ? (
<StepField
label="Expected result"
showLabel={!compactCollapsed}
compact={compactCollapsed}
compactMode={compactMode}
fieldName="expected"
placeholder={EXPECTED_RESULT_PLACEHOLDER}
labelAction={
<button
Expand Down
46 changes: 44 additions & 2 deletions src/editor/blocks/stepField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ type StepFieldProps = {
showFormattingButtons?: boolean;
showImageButton?: boolean;
onFieldFocus?: () => void;
/**
* Reading-focused presentation: suppresses the toolbar and tightens the
* field so it reads like plain text. The OverType editor stays mounted so
* focusing the field (which expands the step) preserves the caret.
*/
compact?: boolean;
/**
* True whenever the step's view is compact, regardless of whether this field
* is currently collapsed or expanded. Used to drop the editor's tall
* min-height floor so reading rows hug their content — kept separate from
* `compact` so it stays stable across focus and never re-lays-out on expand.
*/
compactMode?: boolean;
};

const READ_ONLY_ALLOWED_KEYS = new Set([
Expand Down Expand Up @@ -686,6 +699,8 @@ export function StepField({
showFormattingButtons = false,
showImageButton = false,
onFieldFocus,
compact = false,
compactMode = false,
}: StepFieldProps) {
const stepSuggestions = useStepAutocomplete();
const suggestions = suggestionsOverride ?? stepSuggestions;
Expand Down Expand Up @@ -956,6 +971,32 @@ export function StepField({
}, []),
});

// In compact mode, drop OverType's tall min-height floor so reading rows hug
// their content. Mutating options.minHeight + recomputing avoids a re-init,
// so caret and value survive. Driven by the stable compactMode flag (not
// `compact`) so collapsed and expanded share one height — focusing never
// shifts the layout.
useEffect(() => {
const instance = editorInstanceRef.current as
| (OverTypeInstance & {
options?: { minHeight?: string };
textarea?: HTMLTextAreaElement;
_updateAutoHeight?: () => void;
})
| null;
if (!instance?.options) {
return;
}
instance.options.minHeight = compactMode ? "0px" : multiline ? "4rem" : "2.5rem";
// A textarea's default rows=2 floors its scrollHeight at two lines, which
// autoResize then locks the box into; rows=1 lets compact rows hug a single
// line (autoResize still grows it for multi-line content).
if (instance.textarea) {
instance.textarea.rows = compactMode ? 1 : 2;
}
instance._updateAutoHeight?.();
}, [compactMode, multiline, textareaNode]);

useEffect(() => {
const instance = editorInstanceRef.current;
if (!instance) {
Expand Down Expand Up @@ -1752,10 +1793,11 @@ export function StepField({
.join(" ");

const showToolbar =
showFormattingButtons || (enableImageUpload && uploadImage && showImageButton) || Boolean(rightAction) || enableAutocomplete;
!compact &&
(showFormattingButtons || (enableImageUpload && uploadImage && showImageButton) || Boolean(rightAction) || enableAutocomplete);

return (
<div className="bn-step-field">
<div className={`bn-step-field${compact ? " bn-step-field--compact" : ""}`}>
{showLabel && (
<div className="bn-step-field__top">
<div className="bn-step-field__label-row">
Expand Down
93 changes: 93 additions & 0 deletions src/editor/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,99 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
min-height: 28px;
}

/* Compact (reading-focused) view: tight rows with no chrome until a field is
focused, at which point the step expands to the normal editing layout.

Structural rules below apply to the whole compact mode (both collapsed and
expanded) so that expanding a step never shifts the field's position — the
view-toggle floats out of flow and the editor keeps its default padding, so
focusing only reveals chrome *below* the field instead of pushing it down. */
.bn-teststep--compact .bn-teststep__header {
position: absolute;
top: 0;
right: 0;
min-height: 0;
z-index: 2;
}

/* Tighten the gap between the step title and its data/expected fields while
reading. Applied to the whole compact mode (not just collapsed) so the
spacing is identical when a field expands — no jump on focus. */
.bn-teststep--compact .bn-teststep__content {
gap: 6px;
}

.bn-teststep--compact .bn-teststep__content > .bn-teststep__header + .bn-step-field {
margin-top: 0;
}

/* Collapsed-only rules below hide the remaining chrome for reading. None of
them change the field's top position, so toggling them on focus is jump-free
(the border only changes colour, min-height only grows the box downward). */
.bn-teststep--collapsed .bn-step-field__input {
border-color: transparent;
}

.bn-teststep--collapsed .bn-step-field__input:hover {
border-color: var(--step-input-border);
}

/* Zero the whole min-height chain in compact mode so reading rows hug their
content. OverType floors the textarea height at its CSS min-height (which the
wrapper inherits as 100% of the editor's old 4rem box), so dropping only the
OverType minHeight option isn't enough — the textarea's own min-height must
go too. Applied to all of compact (not just collapsed) so the height is
identical when a field expands: no jump on focus. Vertical padding is also
tightened consistently for the same reason. */
/* OverType injects its own stylesheet (after ours) that hard-codes
`.overtype-container.overtype-auto-resize .overtype-wrapper { min-height: 60px }`
with the same specificity we'd normally use, so it wins on source order. The
extra `.overtype-container` qualifier below raises our specificity above it. */
.bn-teststep--compact .bn-step-field__input--multiline,
.bn-teststep--compact .bn-step-editor,
.bn-teststep--compact .bn-step-editor--multiline,
.bn-teststep--compact .bn-step-editor .overtype-container .overtype-wrapper,
.bn-teststep--compact .bn-step-editor .overtype-container .overtype-wrapper .overtype-input,
.bn-teststep--compact .bn-step-editor .overtype-container .overtype-wrapper .overtype-preview {
min-height: 0 !important;
}

.bn-teststep--compact .bn-step-editor .overtype-wrapper .overtype-input,
.bn-teststep--compact .bn-step-editor .overtype-wrapper .overtype-preview {
padding: 4px 12px !important;
}

.bn-teststep--collapsed .bn-step-actions {
display: none;
}

/* Prefix the expected-result text with a small "Expected" label while reading,
so the line reads e.g. "Expected Login form is shown". OverType wraps each
line in its own block element, so the badge goes on the first line element to
sit inline before the text. It's a pseudo-element (not editable text), so it
never enters the serialized value. */
.bn-teststep--collapsed [data-step-field="expected"] .overtype-wrapper .overtype-preview > :first-child::before {
content: "Expected";
display: inline-block;
margin-right: 8px;
padding: 0 6px;
border-radius: 4px;
background: var(--step-bg-light);
color: var(--step-muted);
font-size: 11px;
font-weight: 600;
line-height: 18px;
vertical-align: 1px;
}

.bn-teststep__view-toggle--compact svg {
color: var(--step-muted);
}

html.dark .bn-teststep__view-toggle--compact svg {
color: var(--step-muted);
}


.bn-snippet .bn-step-field__input {
border-color: var(--snippet-border-light);
Expand Down
Loading