feat: add accessibility and keyboard editing to the react BPMN editor - #46
Merged
Conversation
Bring the react BPMN editor to the same accessibility level as the data modeling and AI workflow builder demos, with the accessibilitychecker.org badge in the bottom-left corner. - accessible names for every icon-only control (toolbar, file dropdown, navigator) and aria-pressed on the theme/minimap/fullscreen toggles - landmarks (header/main/aside) with a visually hidden h1; heading order fixed in the inspector (h3 -> h2) - keyboard support for the stencil: Enter/Space places the shape at the center of the visible paper and selects it; pools and lanes go through the same semantics as a pointer drop (first pool wraps the content, lanes insert into an existing pool) - the pan/scroll canvas is keyboard-focusable (role=application with a visible focus ring); menu/select keyboard highlight outlined (1.4.11) - muted text tokens now clear WCAG AA 4.5:1 in both themes - decorative surfaces hidden from AT (minimap copy, import overlay, icon-font glyphs, zoom readout); tooltips and the file menu portaled inside their landmarks; the file menu made non-modal - "Check accessibility" pill linking the running page to the accessibilitychecker.org audit Verified: axe-core clean across the UI states (default, selection, both inspector tabs, both themes, export dialog, open menu/tooltip), Lighthouse accessibility 100, keyboard walkthrough, tsc and vite build. The open Radix Select keeps its inherent modal listbox behavior.
Arrow keys translate the selected elements by one grid step. Swimlanes are excluded, boundary events stay snapped to their activity's border and pools grow to keep containing the moved elements (the pool-grow logic is now shared between the pointer-drag and keyboard paths). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enter or F2 opens the inline label editor over the single selected cell, the keyboard equivalent of double-clicking it. Escape now cancels the edit — the text was written on blur regardless, so it used to save what Escape was meant to discard — and the closing key no longer reaches the document-level shortcuts, where it would have reopened the editor (Enter) or cleared the selection (Escape). The shortcuts acting on the diagram (rename, move, delete) are limited to a focused canvas. They are bound on `document`, so they also fired while the stencil or the toolbar had the focus, where Enter drops a shape and the arrow keys move the roving focus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A lane cannot be moved, so the arrow keys were inert while one was selected. They now resize it by one grid step through the pool's `changeSwimlaneSize()`, the same route the free transform takes when its border is dragged, so the pool lays out the remaining lanes and grows with them. A lane always spans its pool across the other axis, so only the arrows running across the lane change its size: down/up for a horizontal lane, right/left for a vertical one. The rest fall through to scrolling the canvas. Shrinking stops at the pool's minimum lane size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shrinking a lane with the arrow keys clamped at the pool's minimum lane size only, so it cut straight through the elements inside it — a lane went from 372px to 62px over its own tasks. `ui.BPMNFreeTransform` constrains the equivalent border drag with `swimlaneMinSize()`, which is internal to the widget and absent from the typings, so there is no public method to call. This mirrors it for the two borders the arrows move, from the public pool and lane methods it is built on (`getMinimumLaneSize`, `getSwimlanePadding`, `getElementsBBox`, `getContentMargin`). Keyboard and pointer now stop at the same size: a lane with content at 352px, an empty lane at 62px. At the minimum the arrow is consumed rather than falling through to the paper scroller, so reaching the limit no longer scrolls the canvas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`enter` edits what is selected, `cmd+enter` (or `ctrl+enter`) adds a lane beside it: a selected pool gets one appended, a selected lane gets a sibling directly after it. The insert position is something a stencil drop cannot express, and the target is explicit rather than the first pool `graph.getElements().find(isPool)` happens to return. The new lane is selected afterwards, so it can be named without reaching for the pointer: cmd+enter, enter, type, enter. The orientation comes from the pool, so an incompatible lane cannot be created this way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bindings were undiscoverable — nothing in the UI mentioned enter/F2, cmd+enter or the arrow keys. A keyboard button in the toolbar opens a dialog listing all of them, grouped, with the modifier rendered as the platform expects. Focus returns to the toolbar button when the dialog closes. The dialog is rendered on demand rather than from a `Dialog.Trigger` (matching the export dialog), so Radix has no trigger to restore the focus to and it would otherwise fall to the body. The dialog keyframes move to the shared stylesheet, so the new dialog does not depend on the export dialog's CSS being in the bundle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Placing a pool from the palette took seven undo presses to remove and a lane took six: the drop touches several cells — the pool, its mandatory first lane, the content it wraps, the lanes the pool lays out again — and none of it was batched. The pointer drop was already atomic because the stencil wraps it in a batch, so only the keyboard path was out of step. One batch around the placement brings it in line: pool, lane and plain elements now undo and redo in one press each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turns on the built-in clipboard (`<Diagram clipboard>`) and binds cmd/ctrl+C, X and V to it. The built-in shortcuts stay off, as the rest of the app's are — the paste needs work doing after it, and the keys need the same focused-canvas guard as the other diagram shortcuts. A copy keeps its place in the diagram. `util.cloneCells()` only remaps the embedding of parents that were copied too, so a task copied out of a swimlane would paste loose on the canvas; the copy now records the parent it came from and the paste puts the clone back into it, growing the pool to fit. Copying a container takes its contents with it. A pool clones whole, since its lanes and their shapes are all in the copied set. A lane is inserted with `pool.addSwimlane()` rather than embedded, so the pool lays itself out again and the lane's shapes travel with it — it lands below the lane it was copied from. A lane cannot be cut, though: a pool must keep a lane (see `onDelete`), so cutting one copies it instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four places turned a type string into a model with the same `graph.getTypeConstructor(type)!` dance — the halo's `makeElement`, both stencil drop paths and the inspector's shape morphing — and two read `label`/`icon` off the constructor through a cast. `createShape()` takes only what creating a shape needs (the graph, the type, optional attributes, which is what morphing uses to keep the id), and `getShapeMeta()` returns the palette metadata. The non-null assertions are gone: an unregistered type now throws a named error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting a cell moves the focus to it, but a cell selected the moment it is added — from the stencil, a paste, `cmd+enter` — had no view yet, because the paper renders asynchronously. `findView()` returned nothing and the focus was silently dropped, leaving it on the stencil button. `requireView()` renders the view on demand instead, so the focus lands on the new shape and the canvas is usable straight away: add, `enter` to name it, type, `enter`, then the arrows to place it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`getLabelText()` on the pool and lane classes returns the header text, with a lane falling back to its pool — lanes are commonly left unnamed while the pool carries the participant name. That knowledge belongs on the lane rather than in whichever component happens to need a name. Calling it needs the guards to narrow to the app's own classes, which `isPool()` already did: `isSwimlane()` now does the same, and the `AppPool`/`AppSwimlane` aliases carry the unions that were about to be spelled out in five places. Every cast at the call sites goes away. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pointer drop lands on something; the keyboard drop landed at the middle of the view with no target at all, so it called `addCell()` and left the shape loose on the canvas even where pools existed — a state `validateUnembedding` refuses for a drag, which exported with the task outside every participant. The palette now holds an explicit target and the arrows step it, with the view following through `scrollToElement()`. Up and down walk every lane in the diagram as one run, carrying on into the next pool at the end of one; left and right skip a whole pool. Both cycle. For a lane the target is the gap between lanes, so it can be inserted anywhere in the stack rather than only appended. The target is shown while a palette button has the focus, using the effects a drag already uses — the lane highlight for a shape, the insertion line for a lane — and named through `aria-describedby`, since the highlight is no use to a screen reader. The aim is seeded from the selection, then what is on screen: the canvas selection survives the focus moving to the palette, so it is the one thing that says where the user was working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`cmd+enter` offers what can be added next to the selection and adds it: a shape connected to a shape, a shape inside a lane, a lane inside a pool. `cmd+arrow` does the same with the side chosen, so a branch off a gateway can go down rather than right. `shift+cmd+enter` links the selection to a shape or pool that already exists, or — from a lane, which cannot be linked — adds a lane after it. What each offers comes from the model, not a second list: the connect targets are the shape's own halo handles, so an end event offers nothing to flow into and the keyboard cannot build what the halo would not; the link targets are whatever `validateConnection()` accepts, which includes pools, since a message flow runs between participants. Link types are left to `prepareLinkReplacement()` to resolve from the endpoints. The list is portalled out of the paper and placed in screen coordinates. Inside it, it would ride along with the canvas — including the scroll it triggers itself while previewing a target. It anchors to the shape it was opened from, except for a lane, whose rect spans the whole pool: that anchors to the point where the shape will land, which is also the point the drop then uses. A neighbour is placed clear of what is already there and is not clamped back inside its lane — the pool grows to take it instead, rather than the new shape landing on top of the one it was added from. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`F1` is the platform's help key and the shortcut list is the help, so it opens from anywhere rather than only from the toolbar button. The list also picks up the keys added since it was written — adding, linking, aiming the stencil — and F1 itself, under a Help heading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Everything the free transform can resize now answers to `shift` and an arrow — a pool, a lane, a group, a comment — growing from the far border and shrinking back. The plain arrows are left to mean one thing, moving, rather than resizing a lane and moving everything else. A lane grows both ways. Across the pool the size is the lane's own, along the pool it is the pool's, since every lane spans it; both go through `changeSwimlaneSize()`. The minimum answers for either border now: the lane's content across the pool, and the pool's minimal range along it, which is what the free transform uses for the same drag. A resized shape can outgrow the lane it sits in exactly as a moved one can leave it, so the resize grows the pool too — it did not, and a comment stretched past its lane hung outside the pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A group is a BPMN artifact: nothing contains it, and it is meant to be able to span pools and lanes, which is why `Group.validateEmbedding()` returns false and a pointer drag never puts one in a lane. The keyboard drop did, because it carried its own copy of the placement and never asked the shape — a group added that way travelled with the pool, where a dragged one stays put. The placement lives in one helper now, which asks `validateEmbedding()`, embeds only when it is allowed, and leaves an unembedded shape unclamped so it can extend past a lane. The stencil no longer highlights a lane for a group either: the highlight says where a shape will go, and a group does not go there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`shift` and an arrow move a shape's right or bottom border, which leaves its top and left unreachable: a lane could not grow upwards, and a pool could not extend to the left. Adding `alt` moves the near border instead, so the outward arrow grows it — `alt+shift+up` extends the top edge upwards while the bottom stays put. The lane minimum answers for all four borders now, mirroring `swimlaneMinSize()` in each: the content on the axis the lane owns, the pool's minimal range on the axis it shares. `alt` was picked over `ctrl` and `cmd`: macOS takes ctrl+arrow for Spaces before the page sees it, and cmd+arrow already adds a neighbour. Worth a check in Safari and Firefox, where alt+arrow is history navigation — the plain combination, not this one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aiming at a place in the diagram — holding a target, stepping it with the arrows, previewing it on the canvas, naming it for assistive tech — was written inside the stencil palette, so only the palette could use it. The canvas commands each settled for a cruder rule instead: `shift+cmd+enter` inserts a lane after the selected one, and `cmd+enter` appends to the end, neither of them aimed or previewed. It is `useTargetAim()` now, with a command-shaped surface — `begin`, `end`, `step`, `target`, `name` — leaving the palette to wire it to the buttons. Nothing changes for the user; the aiming is simply reachable from somewhere other than the stencil. The move also brought one rule back into the open: a group has nowhere to aim, since it is never embedded. That was buried in the preview effect and is now `aimsAtSomething()`, asked where the question belongs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shape lists read a shape's icon off its class (`static icon`, a CSS class that `getShapeMeta()` hands out), but the pool, group and annotation classes never declared one — the stencil bypasses that by putting raw glyphs in its own config. So a pool offered as a link target rendered an empty box where every other row had an icon. Declare the icon the way the other shapes do, reusing the glyphs the stencil already shows: the two pools and the horizontal lane get classes of their own, while the group and annotation classes were already in bpmn-icons.css and only lacked the static. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An annotation or a group is an artifact and a pool is the participant the shape already sits in, so all three are the rarer thing to connect to. Rank them after the flow shapes, which keep reading order among themselves, so the list leads with what is usually wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bare arrows moved the selection, which put the one key with no modifier on the one action that changes the drawing — a stray press nudged a shape and wrote a history entry. They navigate now, and moving takes `shift`, resizing `alt`. `tab` already walked the cells, but in document order, which is the order they were added: no way to follow a flow across pools. Which shape an arrow lands on is geometry rather than the sequence flows, since a flow that bends back reads worse than what the user can see in that direction. A 90° cone comes first, so a row of tasks reads as a row; where it finds nothing, every shape wholly past the origin's edge on that side is considered, so a start event tucked below and to the left is still reachable with `left`. A shape in the same pool wins over a nearer one in another, and a shape square in the direction pressed over one sitting diagonally. Pools and lanes are not candidates — a pool's box spans everything inside it, so it would win nearly every direction — and stay on `tab`. With something selected the arrows belong to the diagram whether or not there is anywhere to go, so one at the edge does nothing rather than sliding the canvas out from under the selection. With nothing selected they are the canvas's, and scroll it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reaching it otherwise meant tabbing past every cell in the diagram, since each one is a tab stop. `alt+enter` is the platform's shortcut for the properties of whatever is selected, which is what the panel is. The focus lands on the panel rather than its first control: focusing the region is what makes assistive technology say where the focus went, and `tab` carries on into the controls from there. `escape` comes back to the shape, and stops at the panel — the canvas reads that key as "select nothing", which would leave the panel empty and the focus on an unselected shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The selection was filtered to elements before the arrows looked at it, so a selected flow read as an empty selection and fell through to the branch that leaves the key to the canvas: with a link selected the arrows scrolled the paper instead of moving to a shape. A link is never embedded, so it also has to name its participant through its source, or the same-pool ranking would rate every shape in its own pool a tier worse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A BPMN shape draws its type icon as an `<image>`, and the colour is baked into the SVG data URI, so it cannot follow the theme the way the body does: the body fill is `var(--bpmn-palette-surface)`, an attribute the browser resolves, while the icon is a picture of a colour chosen when the shape was made. The library's `#333333` on the dark theme's `#24262A` body is invisible. There was a filter approximating the ink colour in the dark theme, with gateways exempted because the sample diagram paints them amber. That exemption was the bug: a gateway drawn on the default body kept a dark icon on a dark canvas. A filter cannot do better — the icon lives in a document of its own, and no stylesheet can see what the body is painted with. So the colour is chosen per shape from its own body, through the `iconColor` attribute the icon sets already support, and the filter is gone. The amber gateways keep their dark icon in both themes; a gateway on the default body gets a light one. Shapes are painted at birth as well as on the graph, because the stencil clones one into a paper of its own to fly under the pointer, and that clone is never in this graph. The recolouring is vetoed through `cmdBeforeAdd`: following the theme is not an edit and does not belong on the undo stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DataAssociation` and `AnnotationLink` inherited the library's `#333333` line, which all but disappears on the dark canvas. The sequence flows already take the theme's outline colour; a data association and a comment link are the same line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No behaviour change: the size moves out of the shape and into the config beside it, so the lane defaults can be derived from it rather than repeating the number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A new lane took the library's default of 60, while a task is 80 with a content margin of 20 to clear on each side. Dropping one shape into a fresh lane overflowed it and made the pool grow immediately — a starting size that cannot hold a single shape is no starting size. Both axes are set now, and from the task rather than a number of their own: the horizontal lane only declared a width and the vertical only a height, so the dimension that mattered was the one falling through to the library. The other is a seed the pool overwrites with its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`blue`, `green`, `success`, `danger` and `warning` named the colours they happened to hold, which made every repaint start from a blue and a green: the names steer the next choice, and go stale the moment the hue moves. They are `tint-a`/`tint-b` for the two participants and `start`/`end`/ `decision` for the shapes that carry a meaning. No colour changes here — the values are the ones already in place. The accessibility check's pass state was drawing on the palette's green, which tied a fixed meaning to a colour free to be repainted. It has its own `--bpmn-ok` now, set to the green it was already showing. The swatch labels stay as colour words: they are read by whoever is picking a colour, so they describe what the swatch looks like and are worth revisiting when the palette is repainted. Also corrects a claim in the palette comment: these two tints sit at the same lightness (1.02:1) and separate by hue alone, so what tells two pools apart is the header text, not the tint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Loading a diagram goes through `graph.resetCells()`, which fires `reset` rather than adding cell by cell, so `add` never ran for anything opened and its icons kept whatever colour the library had baked in. Invisible until now only because that colour suits a white body and an amber gateway; a shape carrying any other fill would have shown it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same colour means a different path from one shape family to the next: a task's fill is `attrs/background/fill`, a gateway's `attrs/body/fill`, and a pool wears its colour on `attrs/header/fill` because a pool always has at least one lane and its lanes cover its body. So a colour field now says which of `fill`, `outline` and `text` it paints, and anything reading across shape types pairs them up by that instead of by path. Left unroled, each with a line saying why: a data store's cap and a pool's body, having no counterpart to pair with, and the link configs, since a link has a line where an element has a fill. Also fixes the gateway calling its *text* colour "Fill", which showed two rows both labelled Fill in its own form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting three shapes used to show the same empty panel as selecting nothing, so recolouring a handful meant clicking each one and repeating the same swatch click. The inspector now offers the colours the selection has in common, and one pick paints all of them. A row appears only where every selected shape has that role, so a comment or a group in the selection takes the fill row away — neither has a fill. Each shape is written at its own path, all inside one batch, so the whole selection is one thing to undo. Where the shapes disagree no swatch reads as selected, which is the whole of the visual answer. An empty radio group says nothing to a screen reader though, so the group is described as mixed for anything listening. The swatch row moves out of the single-shape field into `ColorSwatches`, which both forms now render, and the value it takes may be `null` for the mixed case. Reading a value stays in one place — `readFieldValue` — so what a swatch shows as selected cannot drift between the two forms. Links keep their own form: a line has nothing in common with a fill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every shape's border and every link took `--bpmn-palette-outline`, which is not one of the ten swatches, so the inspector showed no colour selected for a border that plainly has one — and once a border was changed there was no way to set it back. The colour was always explicit on the shape; what was missing was a swatch to match it. They take `--bpmn-palette-ink` now, which is a swatch, in the shape defaults, in the sample diagram that carries the same value per cell, and in the link colour. Borders are darker for it, and contrast improves rather than suffers: ink sits about 14:1 on the paper where outline sat 7.5:1. `--bpmn-palette-outline` stays defined for a diagram saved with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule was only enforced on the way in: a link added to a selection was dropped, but a link clicked on its own arrives through `reset` and never met the check, so picking a shape afterwards left the two kinds selected together — and only in that order, which is how it came to light. The same rule now runs whenever an element joins and whenever a region replaces the selection wholesale. Elements win, as they already did when a link was added last, and a selection of links alone is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A comment left in `bpmn-editor.css` described a filter that is no longer there — a note only legible to someone who had seen the rule it replaced, attached to nothing. The reason it is gone lives with the code that made it unnecessary, and the removal is in the commit that did it. Two more said their piece in the past tense: the accessibility badge's colour and the outline token now state what they are for, the latter keeping the one fact a reader needs before deleting it — a diagram saved earlier still references it. And the plain-text editor no longer claims a Firefox version I had not checked. What the fallback rests on is the `contentEditable` setter throwing for a value the browser does not know, which the HTML spec requires; the content attribute is the other way round, and that distinction is worth having next to the `try`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`FocusEvent<HTMLDivElement>` already types `target` as `EventTarget & HTMLDivElement` and `relatedTarget` as `Element | null`, so `dataset` was reachable without the cast to `HTMLElement`, `contains()` was satisfied without the cast to `Node`, and `dataset` is never absent on an element. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three widenings of the same form. A pool wears its colour on its header, because a pool always has a lane and its lanes cover its body, so pools and lanes now take part rather than being edited one at a time. Connectors get a section of their own. Keeping them apart from the shapes is what makes a shared "fill" honest: a connector has a line where a shape has a body, so `outline` reads as "Line" there, and a role a kind has no word for is simply absent. A colour picked in one section leaves the other alone. Which also means a selection may hold both kinds again — the rule that kept them apart is gone, though shapes stay scoped to one pool, since a message flow crosses pools by definition. `ctrl+a` now takes the shapes, and the connectors on a second press: recolouring or deleting "everything" usually means the shapes, and the connectors follow their ends. Fonts join the colours. The role moves onto the field base so a select box can carry one too, and a select box's own option type is preserved when written — a font size is a number, and writing "14" would quietly make it a string. Where the cells disagree the box reads as a dash, the same answer the swatches give by showing nothing selected, with the reason left to assistive technology. A pool has no label size to offer, so a selection including one loses that row and keeps the rest — the intersection doing what it is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selected connector showed nothing: the frames are masks around a shape's body, and a connector has none. It now carries a class, styled in CSS — its wrapper path, invisible and 10px wide, becomes a halo along the line while the line keeps its own colour. The class comes from extending the frame list rather than switching to the options form of `frames`, which is the only branch react-plus applies `cellClassName` on but replaces the masks with rectangular boxes — no use to a diamond or a circle. Extending it keeps the outline that follows each shape and adds the class for everything, and asking for a mask on a connector is skipped, which also clears the unmeasurable node that left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pressing a palette item started a drag at once, so a click built a drag clone and threw it away on release — a click and a drag were the same gesture. A press now becomes a drag only once the pointer has moved a few pixels. The stencil's own `dragThreshold` has no say here: this palette starts the drag itself, and `startCellDrag` takes no threshold. Focusing an item no longer aims at a lane either, unless the focus came from the keyboard. A click leaves no focus ring, so aiming from one lit up a lane with nothing to explain it — and the pointer has the drag, with its own highlighting. The keyboard picks the aim up on the first key instead, so clicking an item and then pressing an arrow still works. And the items have a focus ring of the app's own. They were the only control here left with the browser's hairline default (WCAG 2.4.7), in the one place a keyboard user starts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ring was drawn as an outline on the scroller, which its own background covers — a positioned child the size of the entire sheet — leaving the ring visible only along whichever edges that background happened not to reach, so it appeared on the right and bottom once the sheet was scrolled to its end. It is drawn by the container now, which does not scroll, and as an overlay rather than an outline so nothing can paint over it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`isPool()` narrows to `AppPool`, whose classes extend `CompositePool`, so `adjustToContainElements` was already on the type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`App` distinguished these from the library's types back when this was a generic scaffold; it says nothing now, while `Bpmn` says what they are — and it reads with the components that already carry the prefix. `AppElement`, `AppLink`, `AppShape`, `AppPool`, `AppSwimlane`, `AppLinkView` and `AppLinkConstructor` become `BpmnElement`, `BpmnLink`, `BpmnShape`, `BpmnPool`, `BpmnSwimlane`, `BpmnLinkView` and `BpmnLinkConstructor`. The `Appearance*` field types keep their name: they describe inspector fields, not shapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picking cells one by one was scoped to a single participant, so shapes in two pools could not be selected together — which is exactly what bulk editing is for. The scoping came from the original React port and predates that form; the gesture it belongs to is the region, which has its own rule in `selectionRegion` and keeps it. Dragging such a selection is the part that misbehaves, so the translate is withheld while a selection spans pools — computed from the selection's own membership, since `allowTranslate` is a boolean and cannot be refused per gesture. A shape still drags on its own, and a selection inside one pool drags as before. `preventDefaultInteraction` on the element's pointerdown does not work for this: the selection widget drives the translate itself rather than going through the paper's element drag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Looking for a free spot beside a shape counted every element on the paper, so a shape in another lane pushed the neighbour along past empty space. The neighbour is embedded in the source's lane and the lane grows to hold it, so what shares that lane is what can be in the way. Where the spot falls outside the lane, another pool's shapes cannot be avoided by sliding anyway — the pool occupies that ground regardless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Telling a click from a drag is the stencil's business, not a demo's, and `startCellDrag` starting immediately is what its name says — so holding the press ourselves, with pointer capture and the bookkeeping around it, was the wrong place for it. Requested as a library API instead: clientIO/joint-plus#804. A press starts the drag again, as it did before, which leaves a click building a clone and discarding it on release. The two fixes from the same change stay: focusing an item no longer aims at a lane unless the focus came from the keyboard, and the items keep their focus ring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drag clone passed behind the palette. The drag paper is given `popover="manual"` and has its UA popover styling neutralised, but nothing calls `showPopover()` on the React drag path, so it never enters the top layer: it renders as an ordinary absolutely positioned child of the body at `z-index: auto`, which the stencil container paints over. Shown here on drag start until the library does it — clientIO/joint-plus#803 — guarded so it is inert once that lands, and where the browser has no popover API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A group wore the halo meant for a connector's line as a thick band inside its dashed border: it has a `wrapper` node of its own — invisible and 10px wide, there for hit-testing — and the rule was keyed on the selected class alone. Scoped to links. The class is also renamed to `highlighter-selected`, from the library's `jj-is-selected`. This code applies it, so the name should be ours: the two rules react-plus ships under that name target react-rendered cells this app does not use, and were the app ever to let the library apply the class itself, both sources would be styling cells under one name. It now reads like the other classes applied through `highlighters.addClass`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places wrote the same two lines to return the focus to the shape they were opened from, each casting `view.el` to an SVGElement and calling `focus` optionally — neither needed, since `dia.CellView.el` is typed with `focus()` already. They call `focusCell(paper, cell)` now. `useAccessibility` keeps its own version: it goes through `requireView` to render a cell that has none yet, which is a different thing from focusing one that is already there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hook installed its own `cmdBeforeAdd` on the command manager to keep
the recoloured icons off the undo stack, chaining to whatever veto was
already there. `<Diagram history>` does this itself: react-plus builds a
`cmdBeforeAdd` that reads the last event argument and drops any command
whose options carry `skipHistory`, then defers to `filterCommand`. It reads
the options rather than the setter it was called through, so a plain
`element.attr(path, value, opt)` opts out the same way `setCell` does.
Passing `{ skipHistory: true }` at the write also removes a hazard: setting
`cmdBeforeAdd` from an effect would have overwritten a `filterCommand`
given to `<Diagram>`, since the library composes its veto when the command
manager is constructed.
The two guards for a null `graph` / `commandManager` go with it — both are
non-nullable on the hooks' return types, and no other call site checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three hand-written `graph.on`/`graph.off` pairs in an effect become the event map the library takes. Its handlers are always-latest — the subscription is made once and each event reads the current handler — and it re-subscribes only when the graph or the set of event names changes, where the effect rebuilt all three listeners whenever the graph did. The `useEffect` stays for the MutationObserver, which watches the theme attribute on the document element rather than anything in the graph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dropPoolAt()`, the keyboard counterpart of the pool drop, repeated the sizing `onPoolDragStart()` does: the same "first pool only" test, the same `calculatePoolDimensions()`, the same content bounding box inflated by the margin and the lane header, the same `pool.size()`. `placePoolAt()` was already shared between the two paths; this stopped one step short of it. `sizePoolToContent()` now holds that, returning the box the pool has to cover and the dimensions it was given, or `null` where there is nothing to wrap. The pointer path builds its preview from the dimensions and keeps the pool over the box as it moves; the keyboard path drops it on the box. `dropPoolAt()` calls it before adding the pool to the graph, or the pool would count itself as the pool whose presence means the content is already someone's, and the wrap would quietly stop happening. Its no-wrap branch keeps positioning the pool itself rather than calling `placePoolAt()`, which embeds every loose element — a second pool would take the first one's shapes with it. The comment now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dnd/` had collected three kinds of code: the pointer handlers, the placement actions the keyboard drives, and plain queries with no drag in them. The queries are what non-dnd code kept reaching in for — the aiming hook and the quick-add. `utils/pools.ts` now holds them, next to the `isPool` / `getPoolParent` predicates they read like: `getPoolsInOrder` and `findDropPool` (the pool to step from and the pool to aim from), `findDropSwimlane` (its lane counterpart) and `findFreeSpotBeside`, which brings the `Direction` type along as its only user. `use-target-aim` imports nothing from `dnd/` any more. `placePoolAt` becomes module-private: it has no caller outside `dnd/pools`. The placement actions stay where they are — `dropPoolAt` shares the sizing and placing internals with the pointer drop, and moving it would export three helpers to save one import. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two files sat on the wrong side of the line between `actions/` — what the user does, with its side effects and its single undo entry — and the rest. `importBPMN` loads imported cells into the graph, normalising the structure as it goes. That is the document command, and it lived in `utils/` while the file reading that drives it lived in `actions/`: one feature split across both folders. It moves next to `importFile`, its only caller, and stops being exported at all. `setupFileImport` was the reverse — not a command but effect wiring: it attached three listeners to the scroller and returned a cleanup, for one caller that ran it inside `useEffect`. That is what an effect body is, so it moves into `FileImportOverlay`, which already owned the overlay it toggles. The component now imports the command instead of its own plumbing. The handlers are arrow consts rather than declarations: TypeScript carries the guard's narrowing into a closure but not into a hoisted `function`, so declarations would have cost six non-null assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`actions/import-actions.ts` said it twice. The names are `actions/import` and `actions/export` now, which the last commit made possible: `import.ts` was taken until `importBPMN` left `utils/`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shapes a source may connect to is a question about links, answered by `validateConnection()` — the same rule the pointer path enforces. It sat as a private function in the `QuickLink` component, where nothing else could ask it. It moves to `utils/links.ts`, next to `resolveDefaultLinkType` and `validateAndReplaceConnections`, taking the ordering with it: a pool or an artifact reads last because it is the rarer choice, and that is part of the answer rather than the picker's own presentation. `describe()` stays in the component — reading a shape's name for a row is presentation, not a link rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drag handlers had grown the low-level work inline: `dnd/pools.ts` alone
carried the preview node's SVG, the pointer clamping, the grid snapping, the
content sizing and the placement, so reading a callback meant reading all of
it. They now read as a few named steps, and the work sits where it can be
reached and named:
utils/pools.ts sizePoolToContent, isPoolBoundaryRequired,
getClampedPoolPosition, canMoveSwimlane,
findPoolViewAtPoint, getPositionInSwimlane
effects/pool-preview show/move/removePoolPreview, beside ghost.ts and
swimlane-preview.ts, which already did this
actions/place-pool placeDroppedPool, dropPoolAt
actions/insert-swimlane insertSwimlaneIntoPool, dropSwimlaneIntoPool
actions/add-element addElementToSwimlane
`dnd/pools.ts` goes from 334 lines to 53, `dnd/swimlanes.ts` from 189 to
113, `dnd/elements.ts` from 247 to 102.
Names that were lying are fixed on the way: `checkSwimlaneDrop` performs the
drop rather than checking it, so it is `dropSwimlaneIntoPool`;
`positionInSwimlane` returns a point, so it is `getPositionInSwimlane`; and
`ensurePoolDragBoundary` is `getClampedPoolPosition`.
Two things the flattening turned up. `onSwimlaneDrag` derived the same
"is this pool acceptable" condition in three branches, once per early
return — it is computed once now. And `dropPoolAt` built its own first lane
instead of going through `insertSwimlaneIntoPool`, so "every pool gets a
lane" was written twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dialog's list scrolls when the window is too short for it, and nothing
could focus it, so a keyboard user could not scroll to the shortcuts below
the fold — axe reports it as `scrollable-region-focusable`, serious.
It takes `tabIndex={0}` with a group role and the dialog's own name, plus
the inset ring the palette items use, so the focus is visible when it lands
there. A group rather than a region: a landmark inside a dialog is noise.
Found by re-running the accessibility audit for the PR description, which
had claimed a clean pass since before the palette repaint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the react BPMN editor to the same accessibility level as the data modeling and AI workflow builder demos, with the accessibilitychecker.org badge in the bottom-left corner, and makes the diagram itself editable from the keyboard. Along the way it repaints the diagram palette, lets the inspector edit a whole selection at once, and reorganises
dnd/,actions/andutils/along what each of them is for.Accessibility
Keyboard editing
changeSwimlaneSize(), the route the free transform takes for the same drag. A resized shape can outgrow its lane exactly as a moved one can leave it, so the pool grows either way.Group.validateEmbedding()refuses, and the keyboard drop now asks rather than carrying its own copy of the placement. (An annotation does belong to a lane, and still does.)swimlaneMinSize(), which is not part of the widget's typed API, so this mirrors it from the public pool and lane methods it is built on (getMinimumLaneSize,getSwimlanePadding,getElementsBBox,getContentMargin). Keyboard and pointer stop at the same size — 352px for a lane with content, 62px for an empty one. At the limit the arrow is consumed instead of falling through to the paper scroller.document, so they also fired while the stencil or the toolbar had the focus, where Enter drops a shape and the arrow keys move the roving focus.graph.getElements().find(isPool)returns first, and the new lane is selected so it can be named straight away (cmd+enter, enter, type, enter).<Diagram clipboard>), bound by the app so the paste can finish the job and the keys get the same focused-canvas guard. A copy keeps its place:util.cloneCells()only remaps the embedding of parents that were copied too, so a task copied out of a swimlane pasted loose — the copy now records the parent it came from and the paste puts the clone back into it, growing the pool to fit.pool.addSwimlane()rather than embedded, so the pool lays itself out again and the lane's shapes travel with it, landing below the lane it was copied from. Cutting a lane copies it instead of removing it, since a pool must keep a lane.addCell()and left it outside every pool — a statevalidateUnembeddingrefuses for a drag, and one that exports with the task outside any participant. The palette now holds an explicit target that the arrows step, with the view following viascrollToElement(): up/down walk every lane in the diagram as one run (carrying into the next pool at the end of one), left/right skip a pool, and both cycle. For a lane the target is the gap between lanes, so it can be inserted anywhere in the stack rather than only appended.aria-describedby("Adds to Customer, position 1 of 3"), since a highlight is no use to a screen reader. The aim is seeded from the selection and then from what is on screen: the canvas selection survives focus moving to the palette, so it is the one signal of where the user was working.requireView()renders it on demand, and the add → rename → move chain now works without tabbing back to the canvas.validateConnection()refuses).validateConnection()accepts, pools included, since a message flow runs between participants. Link types are resolved from the endpoints byprepareLinkReplacement().Editing a selection at once
--in a select.attrs/background/fillon a task butattrs/body/fillon a gateway, and a pool wears its colour on its header. Each colour field declares theroleit paints (fill,outline,text, and the font roles), so a new shape says where its colour lives and nothing central needs editing.Cmd+Ais staged to match: elements first, then the links too when the elements are already selected.Appearance
tint-a,ink,start,decision) rather than for a hue, so a repaint moves the colours without the names going stale.inkclears WCAG AA 4.5:1 on the surface and on either tint, and 3:1 on the paper.<image>with the colour baked into a data URI, so it cannot follow the theme the way a fill can — the library's#333333disappeared on a dark body. The ink is now chosen per shape from the body's luminance, at birth and on every recolour, theme switch and diagram load, and kept off the undo stack withskipHistorysince it is a consequence of an edit rather than one itself.Selection
Fixes
The label editor edits plain text. It was a rich-text
contenteditable, so pasting from a document carried the formatting into a BPMN label.A click on a palette item is separated from a drag, so clicking one no longer starts aiming, and the item keeps a visible focus ring while it does.
The canvas focus ring is drawn in full: it was clipped on the right and bottom by the scrolling container.
The stencil's drag paper is shown in the top layer. It declares
popover="manual"but is never opened, so it stacked under the stencil container (clientIO/joint-plus#803); the app shows it and the workaround is marked for removal.A new neighbour placed beside a shape is blocked only by what shares its lane: another lane's shapes slid it past empty space, and a pool it would overlap occupies that ground whatever we do.
A group is never embedded, and no longer wears the connector halo — it has a
wrappernode of its own, so the rule is scoped to links.The shortcuts dialog's list takes the focus itself. It scrolls when the window is short, and a scrollable region no keyboard can reach holds content a keyboard user cannot read — the one violation the re-run of the audit turned up after the repaint.
Three issues came out of this work: #801 (
<PaperScroller>drops HTML attributes), #803 (the drag paper is never shown as a popover) and #804 (a threshold-aware companion tostartCellDrag).Verified: axe-core 0 violations across UI states (default, selection, both inspector views, the bulk form, both themes, export dialog, open menu/tooltip, and the shortcuts dialog), Lighthouse accessibility 100, keyboard walkthrough, tsc + eslint + vite build clean. The keyboard work is covered by a 72-check Playwright walkthrough: rename open/save/cancel for elements, links and lanes; focus return; the stencil and toolbar focus guards; blur-still-saves; resizing both lane orientations with the pool reflow, the content limit (matched against the pointer drag) and undo; lane insert position, orientation and no-ops; single-step undo/redo for every stencil drop; copy/cut/paste including the preserved embedding (checked by dragging the pool and watching the copy follow), lane and pool copies with their contents, and the clipboard's behaviour on an empty selection; the stencil aim (stepping across pools, cycling, insertion position, the highlight matching where the shape lands, and focus following a new cell); the dialog's open/close paths; and the keyboard creation — the halo-sourced offers, link validity (an end event offering only cross-pool targets), pool-to-pool links, all four directions, the pool growing rather than clamping a neighbour, the list surviving a canvas scroll, and its dismissal on a stencil drag or click.
The appearance and selection work is covered by its own suites: the shared/differing/
--states across shape types and both sections (18 checks), fonts and sizes, the connector highlight and the masks that stay on shapes only (12), the inspector shortcut and focus return (9), directional navigation including the 180° fallback and links (20), the rebound arrows (5), icon contrast in both themes and after a diagram load (9), the first pool wrapping the content and a second one leaving it alone (7), the pointer drags for pools and lanes with their previews and invalid marks (11), the link picker's targets, ordering and validity (8), file import by drop for both formats (6), lane sizing (4), cross-pool drag (4) and neighbour placement (3).Internals worth a look:
createShape()/getShapeMeta()replace four copies ofgraph.getTypeConstructor(type)!and two casts for the palette metadata;getLabelText()moves onto the pool and lane classes (a lane falls back to its pool's name, since lanes are commonly unnamed); andisSwimlane()now narrows to the app's own classes asisPool()already did, viaBpmnPool/BpmnSwimlane(theApp*prefix said nothing about this editor).The last few commits sort the modules by what they hold rather than what they grew from.
dnd/is the drag callbacks: each reads as a few named steps, with the SVG preview ineffects/beside the ghost and lane previews it matches, the pool geometry inutils/and the placement inactions/—dnd/pools.tswent from 334 lines to 53.actions/holds commands the user invokes, so the graph-loading half of the file import moved in and the drag-listener wiring moved out into the effect that runs it.utils/holds the queries: the pool and lane lookups the keyboard aiming needs, and the link targets a source accepts. Names that misdescribed their function are fixed in passing —checkSwimlaneDropperforms the drop,positionInSwimlanereturns a point.