Files
fusion/packages/dashboard/app/components/WorkflowAddStepModal.tsx
gsxdsm 7fde4bb3ad fix(a11y): my #2965 gave six dialogs two elements with the same accessible name (#2977)
**This fixes a regression I introduced in #2965, found by re-running the
full dashboard lane on `main` rather than trusting the targeted runs I
did at the time.**

`AddNodeModal` and `ConnectNodeModal` are red on main:

```
→ Found multiple elements with the text of: Add Node
→ Found multiple elements with the text of: Connect to Node
```

### Cause

#2965 dropped the redundant `" dialog"` suffix from each
`FloatingWindow`'s `ariaLabel`. That was correct — `role="dialog"`
already conveys it. What I missed is that six of those modals **also**
put an `aria-label` with the *same* text on their own inner `<div>`:

```jsx
<FloatingWindow ariaLabel={t("nodes.addNode", "Add Node")} …>
  <div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}>
```

Before #2965 the two differed (`"Add Node dialog"` vs `"Add Node"`), so
`getByLabelText("Add Node")` matched exactly one element. Now both
match.

### Why the inner one goes, not the dialog's

Those inner labels sit on **role-less `<div>`s**, where assistive
technology ignores `aria-label` entirely — it was never conveying
anything to anyone. Removing it restores a single accessible name per
dialog and needs no test changes.

### Surface enumeration — four of the six were latent

Only two surfaced as failures; the other four have no test querying by
that name, so they would have shipped a duplicate accessible name
silently. Found by scanning every component for an inner `aria-label`
whose expression matches its own `ariaLabel` prop:

| modal | was it red? |
|---|---|
| `AddNodeModal` | red on main |
| `ConnectNodeModal` | red on main |
| `GroupTaskModal` | latent |
| `NodeDetailModal` | latent |
| `ScriptsModal` | latent |
| `WorkflowAddStepModal` | latent |

### Five more, deliberately untouched

`AgentDetailView`, `PlanningModeModal`, `SettingsModal`
(`role="region"`), `ScheduledTasksModal` (`role="listbox"`) and
`NewTaskModal` (`role="dialog"`) also carry their dialog's name on an
inner element — but those elements **have a role**, so the label is
meaningful rather than dead markup. A listbox named "Automations" inside
a dialog named "Automations" is redundant, not broken, and renaming it
is a UX decision rather than a cleanup. Left alone and recorded here.

**Verified:** 93/93 across `AddNodeModal`, `ConnectNodeModal`,
`NodesView`, `GroupTaskModal`, `ScriptsModal` and the #2965 aria guard;
`tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit 0.

Product-code change to a11y markup, so this is user-visible but needs no
operator-facing note — say the word if you want a changeset.

**Measured dashboard-lane state on main before this PR:** `3 failed |
11173 passed`. Two are these; the third is
`MainContent.planning-project-remount`, which belongs to #2420 and is
detailed there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:46:08 -07:00

311 lines
13 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Search, Puzzle, ToggleRight } from "lucide-react";
import type { WorkflowDefinition, WorkflowStepTemplate } from "@fusion/core";
import type { WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import { nodeHelpFor } from "./nodes/node-help";
import { FloatingWindow } from "./FloatingWindow";
import "./WorkflowAddStepModal.css";
/*
FNXC:WorkflowSimpleView 2026-07-10-12:00:
The simplified workflow view's add-step surface. Requirements:
- One searchable dialog covering EVERYTHING the advanced palette + templates
toolbar offers (node kinds, fragments, Fusion step templates, plugin
templates) so the simple view loses no add capability.
- Node kinds are grouped into human categories (Agent steps / Automation /
Flow control) with one-line descriptions sourced from the node help
registry, because the flat 16-button advanced palette is the main thing
users found hard to use.
- When inserting INSIDE a container (foreach/loop/optional-group child
edge), container kinds are hidden — containers cannot nest — and
FRAGMENTS are hidden too (PR #2006 review): fragments expand to top-level
subgraphs, so splicing one into a template-child edge would create
cross-boundary edges into the container. Step templates stay available
(they materialize a single prompt/script node, valid as a sibling child).
*/
export interface AddStepPaletteEntry {
kind: WorkflowEditorNodeKind;
label: string;
icon: React.ComponentType<{ size?: number | string; "aria-hidden"?: boolean | "true" | "false" }>;
presetConfig?: Record<string, unknown>;
}
const CONTAINER_KINDS: ReadonlySet<string> = new Set(["foreach", "loop", "optional-group"]);
const AGENT_KINDS: ReadonlySet<string> = new Set(["prompt", "ask-user", "gate", "step-review"]);
const AUTOMATION_KINDS: ReadonlySet<string> = new Set(["script", "code", "notify", "parse-steps"]);
export interface WorkflowAddStepModalProps {
open: boolean;
onClose: () => void;
palette: AddStepPaletteEntry[];
/** Hide container kinds (insert target is inside a container). */
disallowContainers?: boolean;
fragments: WorkflowDefinition[];
stepTemplates: WorkflowStepTemplate[];
pluginTemplates: Array<{ pluginId: string; template: WorkflowStepTemplate }>;
/** Persistent seam-duplication conflict notice (mirrors the toolbar's). */
templateConflict?: string | null;
onPickPalette: (entry: AddStepPaletteEntry) => void;
onPickFragment: (fragment: WorkflowDefinition) => void;
onPickStepTemplate: (template: WorkflowStepTemplate) => void;
onPickStepTemplateAsOptionalGroup: (template: WorkflowStepTemplate) => void;
}
export function WorkflowAddStepModal({
open,
onClose,
palette,
disallowContainers = false,
fragments,
stepTemplates,
pluginTemplates,
templateConflict,
onPickPalette,
onPickFragment,
onPickStepTemplate,
onPickStepTemplateAsOptionalGroup,
}: WorkflowAddStepModalProps) {
const { t } = useTranslation("app");
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setQuery("");
// Focus after the dialog paints.
const id = window.setTimeout(() => searchRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}
}, [open]);
const q = query.trim().toLowerCase();
const categories = useMemo(() => {
const eligible = palette.filter((entry) => !(disallowContainers && CONTAINER_KINDS.has(entry.kind)));
const withHelp = eligible.map((entry) => ({
entry,
description: nodeHelpFor(entry.kind)?.summary ?? "",
}));
const matches = (item: { entry: AddStepPaletteEntry; description: string }) =>
!q || item.entry.label.toLowerCase().includes(q) || item.entry.kind.includes(q) || item.description.toLowerCase().includes(q);
return [
{
id: "agent",
label: t("workflowNodes.addStepAgentSteps", "Agent steps"),
items: withHelp.filter((item) => AGENT_KINDS.has(item.entry.kind)).filter(matches),
},
{
id: "automation",
label: t("workflowNodes.addStepAutomation", "Automation"),
items: withHelp.filter((item) => AUTOMATION_KINDS.has(item.entry.kind)).filter(matches),
},
{
id: "flow",
label: t("workflowNodes.addStepFlowControl", "Flow control"),
items: withHelp
.filter((item) => !AGENT_KINDS.has(item.entry.kind) && !AUTOMATION_KINDS.has(item.entry.kind))
.filter(matches),
},
].filter((category) => category.items.length > 0);
}, [palette, disallowContainers, q, t]);
const filteredFragments = useMemo(
() => (disallowContainers ? [] : fragments.filter((f) => !q || f.name.toLowerCase().includes(q))),
[fragments, q, disallowContainers],
);
const filteredStepTemplates = useMemo(
() => stepTemplates.filter((s) => !q || s.name.toLowerCase().includes(q)),
[stepTemplates, q],
);
const filteredPluginTemplates = useMemo(
() => pluginTemplates.filter((p) => !q || p.template.name.toLowerCase().includes(q)),
[pluginTemplates, q],
);
const hasTemplates =
filteredFragments.length > 0 || filteredStepTemplates.length > 0 || filteredPluginTemplates.length > 0;
const hasAnyResult = categories.length > 0 || hasTemplates;
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, open]);
if (!open) return null;
return (
/* FNXC:ModalTouchGeometry 2026-07-26-13:20: The workflow palette uses the shared touch-actuable geometry primitive instead of its bespoke overlay; the existing header stays the drag handle. */
<FloatingWindow
windowKey="workflow-add-step"
title={t("workflowNodes.addStepTitle", "Add a step")}
ariaLabel={t("workflowNodes.addStepTitle", "Add a step")}
onClose={onClose}
hideHeader
dragHandleSelector=".wf-add-step-header"
className="floating-window--workflow-add-step"
defaultSize={{ width: 640, height: 560 }}
minSize={{ width: 360, height: 280 }}
persistGeometryKey="floating-window:workflow-add-step"
suspendGeometryPersistenceOnMobile
suspendGeometryPersistenceOnShortViewport
closeOnOutsidePointerDown
>
<div className="wf-add-step-dialog">
<header className="wf-add-step-header">
<h3>{t("workflowNodes.addStepTitle", "Add a step")}</h3>
<button
type="button"
className="btn-icon wf-add-step-close"
aria-label={t("common.close", "Close")}
onClick={onClose}
>
<X size={16} />
</button>
</header>
<div className="wf-add-step-search">
<Search size={14} aria-hidden />
<input
ref={searchRef}
type="text"
className="wf-add-step-search-input"
data-testid="wf-add-step-search"
value={query}
placeholder={t("workflowNodes.addStepSearchPlaceholder", "Search steps and templates…")}
aria-label={t("workflowNodes.addStepSearchLabel", "Search steps")}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{templateConflict && (
<div className="wf-add-step-conflict" role="alert" data-testid="wf-add-step-conflict">
{t(
"workflowNodes.templateSeamConflict",
'This fragment duplicates the "{{seam}}" seam already on the canvas, so it can\'t be inserted.',
{ seam: templateConflict },
)}
</div>
)}
<div className="wf-add-step-body">
{categories.map((category) => (
<section key={category.id} className="wf-add-step-section">
<h4>{category.label}</h4>
<div className="wf-add-step-grid">
{category.items.map(({ entry, description }) => {
const Icon = entry.icon;
return (
<button
key={entry.label}
type="button"
className={`wf-add-step-option wf-add-step-option--${category.id}`}
data-testid={`wf-add-step-${entry.kind}-${entry.label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
onClick={() => onPickPalette(entry)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Icon size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{entry.label}</span>
{description ? <span className="wf-add-step-option-desc">{description}</span> : null}
</span>
</button>
);
})}
</div>
</section>
))}
{hasTemplates && (
<section className="wf-add-step-section">
<h4>{t("workflowNodes.templatesSection", "Templates")}</h4>
<div className="wf-add-step-grid">
{filteredFragments.map((fragment) => (
<button
key={fragment.id}
type="button"
className="wf-add-step-option wf-add-step-option--template"
data-testid={`wf-add-step-fragment-${fragment.id}`}
onClick={() => onPickFragment(fragment)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Puzzle size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{fragment.name}</span>
<span className="wf-add-step-option-desc">
{t("workflowNodes.addStepFragmentDesc", "Workflow fragment")}
</span>
</span>
</button>
))}
{filteredStepTemplates.map((template) => (
<div key={template.id} className="wf-add-step-template-row">
<button
type="button"
className="wf-add-step-option wf-add-step-option--template"
data-testid={`wf-add-step-tpl-${template.id}`}
onClick={() => onPickStepTemplate(template)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Puzzle size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{template.name}</span>
{template.description ? (
<span className="wf-add-step-option-desc">{template.description}</span>
) : null}
</span>
</button>
{!disallowContainers && (
<button
type="button"
className="wf-add-step-optional"
data-testid={`wf-add-step-tpl-${template.id}-optional-group`}
title={t("workflowNodes.insertTemplateAsOptionalGroup", "Insert {{name}} as optional group", {
name: template.name,
})}
aria-label={t("workflowNodes.insertTemplateAsOptionalGroup", "Insert {{name}} as optional group", {
name: template.name,
})}
onClick={() => onPickStepTemplateAsOptionalGroup(template)}
>
<ToggleRight size={13} aria-hidden />
<span>{t("workflowNodes.asOptionalGroup", "as optional group")}</span>
</button>
)}
</div>
))}
{filteredPluginTemplates.map(({ pluginId, template }) => (
<button
key={`${pluginId}:${template.id}`}
type="button"
className="wf-add-step-option wf-add-step-option--template"
data-testid={`wf-add-step-plugin-tpl-${template.id}`}
onClick={() => onPickStepTemplate(template)}
>
<span className="wf-add-step-option-chip" aria-hidden>
<Puzzle size={16} />
</span>
<span className="wf-add-step-option-text">
<span className="wf-add-step-option-label">{template.name}</span>
<span className="wf-add-step-option-desc">{pluginId}</span>
</span>
</button>
))}
</div>
</section>
)}
{!hasAnyResult && (
<p className="wf-add-step-empty" data-testid="wf-add-step-empty">
{t("workflowNodes.addStepNoMatches", "No steps match “{{query}}”.", { query })}
</p>
)}
</div>
</div>
</FloatingWindow>
);
}