Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to the Reactodia will be documented in this document.
The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
#### 🐛 Fixed
- Exclude collapsed `DropdownMenu` and `UnifiedSearch` content from Tab-navigation.
- Exclude canvas elements from Tab-navigation unless the element is only one selected.
- Block canvas interacton (including Tab-navigation) when displaying a blocking modal overlay i.e. an overlay task or viewport-centered dialog.
- Fix Tab-navigation order on `DefaultWorkspace` and `ClassicWorkspace` to generally follow top-to-bottom then left-to-right when moving focus.
- Auto-focus within dialogs by default (via `autoFocus` prop) to avoid losing focus when a dialog displayed in the modal overlay or opened by already unmounted trigger element.

## [0.35.0] - 2026-06-21
#### 🚀 New Features
Expand Down
1 change: 1 addition & 0 deletions i18n/i18n.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"$ref": "#/$defs/Group",
"additionalProperties": false,
"properties": {
"dialog_close.title": { "$ref": "#/$defs/Value" },
"multiple_tasks_in_progress": { "$ref": "#/$defs/Value" },
"unknown_error": { "$ref": "#/$defs/Value" }
}
Expand Down
1 change: 1 addition & 0 deletions i18n/translations/en.reactodia-translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"style_underline.title": "Underline"
},
"overlay_controller": {
"dialog_close.title": "Close",
"multiple_tasks_in_progress": "Multiple tasks are in progress",
"unknown_error": "Unknown error occurred"
},
Expand Down
20 changes: 20 additions & 0 deletions src/coreUtils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,23 @@ export function findParentWithin(
}
return undefined;
}

const FOCUSABLE_SELECTORS: readonly string[] = [
'[data-reactodia-autofocus]',
'[autofocus]',
'input:not(disabled), textarea:not(disabled), button:not(disabled), select:not(disabled), a[href], details > summary',
];

export function findAutoFocusable(parent: HTMLElement): HTMLElement | undefined {
const activeElement = document.activeElement;
if (parent.contains(activeElement)) {
return activeElement instanceof HTMLElement ? activeElement : undefined;
}
for (const selector of FOCUSABLE_SELECTORS) {
const target = parent.querySelector(selector);
if (target instanceof HTMLElement) {
return target;
}
}
return undefined;
}
10 changes: 10 additions & 0 deletions src/diagram/canvasArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { delay } from '../coreUtils/async';
import { ColorSchemeApi } from '../coreUtils/colorScheme';
import { findParentWithin } from '../coreUtils/dom';
import { EventObserver, Events, EventSource } from '../coreUtils/events';
import { useObservedProperty } from '../coreUtils/hooks';

import {
Paper, PaperProps, type ScaleDefaults, type PaperPointerOperation, wheelToScaleDeltaDefault,
Expand Down Expand Up @@ -99,6 +100,12 @@ export function CanvasArea(props: {
return () => controller.stopListening();
}, []);

const interactionBlocked = useObservedProperty(
renderingState.events,
'changeInteractionBlocked',
() => renderingState.interactionBlocked
);

const style = {
'--reactodia-canvas-animation-duration': graphAnimations.duration === undefined
? undefined : `${graphAnimations.duration}ms`,
Expand Down Expand Up @@ -168,6 +175,9 @@ export function CanvasArea(props: {
/>
</>
)}
paneProps={{
inert: interactionBlocked ? '' : undefined,
} as React.HTMLProps<HTMLDivElement>}
watermark={
watermarkSvg ? (
<a href={watermarkUrl} target='_blank' rel='noreferrer'>
Expand Down
2 changes: 1 addition & 1 deletion src/diagram/elementLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ class OverlaidElement extends React.Component<OverlaidElementProps> {
style={style}
// set `element-id` to translate mouse events to paper
data-element-id={element.id}
tabIndex={0}
tabIndex={templateProps.onlySelected ? 0 : -1}
ref={
/* For compatibility with React 19 typings */
this.elementRef as React.RefObject<HTMLDivElement>
Expand Down
23 changes: 23 additions & 0 deletions src/diagram/renderingState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface RenderingStateEvents {
*/
readonly layer: RenderingLayer;
};
/**
* Triggered on {@link RenderingState.interactionBlocked} property change.
*/
changeInteractionBlocked: PropertyChange<RenderingState, boolean>;
/**
* Triggered on {@link RenderingState.getLinkTemplates} property change.
*/
Expand Down Expand Up @@ -134,6 +138,10 @@ export interface RenderingState extends SizeProvider {
* Shared state for all canvases rendering the same model.
*/
readonly shared: SharedCanvasState;
/**
* Whether any canvas interaction is blocked by a modal overlay.
*/
get interactionBlocked(): boolean;
/**
* Request to synchronously render the canvas, performing any
* previously deferred updates.
Expand Down Expand Up @@ -231,6 +239,8 @@ export class MutableRenderingState implements RenderingState {
hashHotkeyAst, sameHotkeyAst
);

private _interactionBlocks = 0;

readonly shared: SharedCanvasState;

/** @hidden */
Expand Down Expand Up @@ -281,6 +291,19 @@ export class MutableRenderingState implements RenderingState {
this.cancelOnLayerUpdate(RenderingLayer.LinkRoutes, this.updateRoutings);
}

get interactionBlocked(): boolean {
return this._interactionBlocks > 0;
}

changeInteractionBlocks(change: 1 | -1): void {
const previous = this._interactionBlocks !== 0;
this._interactionBlocks += change;
const interactionBlocked = this._interactionBlocks !== 0;
if (interactionBlocked !== previous) {
this.source.trigger('changeInteractionBlocked', {previous, source: this});
}
}

syncUpdate() {
this.syncUpdateLayersUpTo(LAST_LAYER);
}
Expand Down
58 changes: 42 additions & 16 deletions src/editor/overlayController.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import cx from 'clsx';

import { delay } from '../coreUtils/async';
import { Events, EventObserver, EventSource, PropertyChange } from '../coreUtils/events';
Expand Down Expand Up @@ -343,14 +344,12 @@ export class OverlayController {
this.hideDialog();
}

const previousDialog = this._openedDialog;
const openedDialog: OverlayDialog = {
target,
knownType: dialogType,
holdSelection,
onClose,
};
this._openedDialog = openedDialog;

const canvas = this.view.findAnyCanvas();
const breakpoint = readOverlayProperty(
Expand All @@ -376,8 +375,10 @@ export class OverlayController {
this.dialogSettingsProvider.persistDialogSize(openedDialog, size);
};
const onHide = () => this.hideDialog();
const closeTitle = this.translation.text('overlay_controller.dialog_close.title');
if (target && !isSmallViewport) {
this.setDialog(
openedDialog,
<CanvasPlaceAt layer='overElements'>
<Dialog {...styleWithDefaults}
target={
Expand All @@ -386,26 +387,24 @@ export class OverlayController {
target
}
onResize={onResize}
onHide={onHide}>
onHide={onHide}
closeTitle={closeTitle}>
{content}
</Dialog>
</CanvasPlaceAt>
);
} else {
this.setDialog(
openedDialog,
<ViewportDialog {...styleWithDefaults}
mode={isSmallViewport ? 'fillViewport' : 'centered'}
onResize={onResize}
onHide={onHide}>
onHide={onHide}
closeTitle={closeTitle}>
{content}
</ViewportDialog>
);
}

this.source.trigger('changeOpenedDialog', {
source: this,
previous: previousDialog,
});
}

/**
Expand All @@ -416,18 +415,25 @@ export class OverlayController {
hideDialog() {
if (this._openedDialog) {
const previous = this._openedDialog;
this._openedDialog = undefined;
previous.onClose?.();
this.setDialog(null);
this.source.trigger('changeOpenedDialog', {source: this, previous});
this.setDialog(undefined, null);
}
}

private setDialog(dialog: React.ReactElement | null): void {
private setDialog(
openedDialog: OverlayDialog | undefined,
dialog: React.ReactElement | null
): void {
const internalApi = withInternalApi(this)[OverlayInternalApi];
const previousDialog = this._openedDialog;
const previous = internalApi.dialog;
this._openedDialog = openedDialog;
internalApi.dialog = dialog;
this.internalSource.trigger('changeDialog', {previous, source: this});
this.source.trigger('changeOpenedDialog', {
source: this,
previous: previousDialog,
});
}
}

Expand Down Expand Up @@ -565,11 +571,11 @@ function LoadingWidget(props: { spinnerProps: SpinnerProps }) {
y: size.height / 2,
};
return (
<div className='reactodia-loading-widget'>
<ViewportOverlay className='reactodia-loading-widget'>
<svg width={size.width} height={size.height}>
<Spinner position={position} {...spinnerProps} />
</svg>
</div>
</ViewportOverlay>
);
}

Expand All @@ -592,12 +598,32 @@ function ViewportDialog(props: DialogProps) {
[viewportSize, margin]
);
return (
<div ref={overlayRef}
<ViewportOverlay innerRef={overlayRef}
className='reactodia-viewport-dialog-overlay'>
<Dialog {...props}
dock='e'
maxSize={maxSize}
/>
</ViewportOverlay>
);
}

function ViewportOverlay(props: {
innerRef?: React.RefObject<HTMLDivElement>;
className?: string;
children: React.ReactNode;
}) {
const {innerRef, className, children} = props;
const {canvas} = useCanvas();
const renderingState = canvas.renderingState as MutableRenderingState;
React.useLayoutEffect(() => {
renderingState.changeInteractionBlocks(1);
return () => renderingState.changeInteractionBlocks(-1);
}, [renderingState]);
return (
<div ref={innerRef}
className={cx('reactodia-viewport-overlay', className)}>
{children}
</div>
);
}
Expand Down
6 changes: 4 additions & 2 deletions src/paper/paper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface PaperProps {
pageSize: Size;
contentBounds: Rect;
renderLayers: (transform: PaperTransform) => React.ReactNode;
paneProps?: React.HTMLProps<HTMLDivElement>;
watermark?: React.ReactNode;
children?: React.ReactNode;
}
Expand Down Expand Up @@ -149,7 +150,7 @@ export class Paper extends React.Component<PaperProps, State> {
render() {
const {
className, style, showScrollbars, panOnTouch = true,
onContextMenu, onKeyDown, onKeyUp, renderLayers, watermark, children,
onContextMenu, onKeyDown, onKeyUp, renderLayers, paneProps, watermark, children,
} = this.props;
const {transform, mounted} = this.state;
const {width, height, scale, paddingX, paddingY} = transform;
Expand Down Expand Up @@ -180,7 +181,8 @@ export class Paper extends React.Component<PaperProps, State> {
tabIndex={0}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}>
<div className={`${CLASS_NAME}__pane`}
<div {...paneProps}
className={`${CLASS_NAME}__pane`}
ref={this.paneRef}
onPointerDown={this.onAreaPointerDown}>
<div className={`${CLASS_NAME}__layers`}
Expand Down
1 change: 1 addition & 0 deletions src/widgets/connectionsMenu/connectionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ class ConnectionsMenuInner extends React.Component<ConnectionsMenuInnerProps, Me
inputProps={{
name: 'reactodia-connection-menu-filter',
placeholder: t.textOptional('connections_menu.input.placeholder'),
'data-reactodia-autofocus': true,
}}>
{this.renderSortSwitches()}
</SearchInput>
Expand Down
Loading
Loading