From 04378014c95f3ffbdf53658ce7589e0989b140e3 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Tue, 23 Jun 2026 01:43:48 +0300 Subject: [PATCH 1/3] Fix tab-order and focusability for canvas content: * Exclude collapsed `DropdownMenu` and `UnifiedSearch` content from Tab-navigation. * Exclude canvas elements from Tab-navigation unless the element is only one selected. * Fix Tab-navigation order on `DefaultWorkspace` and `ClassicWorkspace` to generally follow top-to-bottom then left-to-right when moving focus. --- CHANGELOG.md | 4 ++++ src/diagram/elementLayer.tsx | 2 +- src/widgets/unifiedSearch/unifiedSearch.tsx | 13 +++++++++--- src/widgets/utility/dropdown.tsx | 2 +- .../authoredEntityDecorator.tsx | 14 +++++++++---- src/workspace/classicWorkspace.tsx | 2 +- src/workspace/defaultWorkspace.tsx | 20 +++++++++---------- 7 files changed, 37 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 876a8a49..3d0601d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ 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. +- Fix Tab-navigation order on `DefaultWorkspace` and `ClassicWorkspace` to generally follow top-to-bottom then left-to-right when moving focus. ## [0.35.0] - 2026-06-21 #### 🚀 New Features diff --git a/src/diagram/elementLayer.tsx b/src/diagram/elementLayer.tsx index cb7312af..fc28d53b 100644 --- a/src/diagram/elementLayer.tsx +++ b/src/diagram/elementLayer.tsx @@ -350,7 +350,7 @@ class OverlaidElement extends React.Component { 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 diff --git a/src/widgets/unifiedSearch/unifiedSearch.tsx b/src/widgets/unifiedSearch/unifiedSearch.tsx index b4000810..b2144d83 100644 --- a/src/widgets/unifiedSearch/unifiedSearch.tsx +++ b/src/widgets/unifiedSearch/unifiedSearch.tsx @@ -310,7 +310,8 @@ export function UnifiedSearch(props: UnifiedSearchProps) { panelSize={effectiveSize} /> }> - setActiveSection(previous => previous.switch(key))} dropUp={direction === 'up'} @@ -451,6 +452,7 @@ function SearchToggle(props: { } function SearchContent(props: { + visible: boolean; sections: ReadonlyArray; activeSectionKey: string | undefined; onActivateSection: (sectionKey: string) => void; @@ -462,7 +464,7 @@ function SearchContent(props: { setMaxSize: (size: Size) => void; }) { const { - sections, activeSectionKey, onActivateSection, + visible, sections, activeSectionKey, onActivateSection, dropUp, size, minSize, offsetForMaxSize, onResize, setMaxSize, } = props; @@ -534,8 +536,13 @@ function SearchContent(props: { const sectionPanelId = (section: SectionWithContext) => `reactodia-unified-search-panel-${section.key}`; + const otherProps = { + inert: visible ? undefined : '', + } as React.HTMLProps; + return ( -
@@ -199,14 +200,16 @@ function getSeverityClass(severity: ValidationSeverity): string | undefined { function InlineActions(props: { target: EntityElement; + onlySelected: boolean; state: AuthoredEntity | undefined; allActions: boolean; }) { - const {target, state, allActions} = props; + const {target, onlySelected, state, allActions} = props; const {editor} = useWorkspace(); const t = useTranslation(); const authored = useAuthoredEntity(target.data, allActions); + const tabIndex = onlySelected ? 0 : -1; return (
@@ -218,7 +221,8 @@ function InlineActions(props: { authored.canEdit ? t.text('authoring_state.entity_action_edit.title') : t.text('authoring_state.entity_action_edit.title_disabled') - }> + } + tabIndex={tabIndex}> {t.text('authoring_state.entity_action_edit.label')} ) : null} @@ -230,14 +234,16 @@ function InlineActions(props: { authored.canEdit ? t.text('authoring_state.entity_action_delete.title') : t.text('authoring_state.entity_action_delete.title_disabled') - }> + } + tabIndex={tabIndex}> {t.text('authoring_state.entity_action_delete.label')} ) : null} {state && state.type !== 'entityAdd' ? ( ) : null} diff --git a/src/workspace/classicWorkspace.tsx b/src/workspace/classicWorkspace.tsx index abd1eb83..e4c4f0e8 100644 --- a/src/workspace/classicWorkspace.tsx +++ b/src/workspace/classicWorkspace.tsx @@ -113,9 +113,9 @@ export function ClassicWorkspace(props: ClassicWorkspaceProps) { {selection === null ? null : ( )} - {navigator === null ? null : } {toolbar === null ? null : } {zoomControl === null ? null : } + {navigator === null ? null : } {canvasWidgets} {children} diff --git a/src/workspace/defaultWorkspace.tsx b/src/workspace/defaultWorkspace.tsx index ef6f3a0d..ffd56a51 100644 --- a/src/workspace/defaultWorkspace.tsx +++ b/src/workspace/defaultWorkspace.tsx @@ -271,16 +271,6 @@ export function DefaultWorkspace(props: DefaultWorkspaceProps) { {halo === null ? null : } {haloLink === null ? null : } {selection === null ? null : } - {zoomControl === null ? null : ( - - )} - {navigator === null ? null : ( - - )} @@ -291,6 +281,11 @@ export function DefaultWorkspace(props: DefaultWorkspaceProps) { /> )} + {zoomControl === null ? null : ( + + )} {actionsContent === null ? null : ( )} + {navigator === null ? null : ( + + )} {canvasWidgets} {children} From 5796b91d19fb35b66a93be73276200a699b71d2c Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Tue, 23 Jun 2026 01:47:11 +0300 Subject: [PATCH 2/3] Block canvas interacton on a blocking modal overlay: * Block canvas interacton (including Tab-navigation) when displaying a blocking modal overlay i.e. an overlay task or viewport-centered dialog. --- CHANGELOG.md | 1 + src/diagram/canvasArea.tsx | 10 ++++ src/diagram/renderingState.ts | 23 +++++++++ src/editor/overlayController.tsx | 51 ++++++++++++++----- src/paper/paper.tsx | 6 ++- src/widgets/dialog.tsx | 3 +- src/widgets/utility/viewportDock.tsx | 14 ++++- .../{_loadingWidget.scss => _overlays.scss} | 18 ++++--- styles/main.scss | 2 +- styles/widgets/_dialog.scss | 15 ------ 10 files changed, 102 insertions(+), 41 deletions(-) rename styles/editor/{_loadingWidget.scss => _overlays.scss} (71%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0601d9..287709dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p #### 🐛 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. ## [0.35.0] - 2026-06-21 diff --git a/src/diagram/canvasArea.tsx b/src/diagram/canvasArea.tsx index 63c60bdf..bf5bd1d0 100644 --- a/src/diagram/canvasArea.tsx +++ b/src/diagram/canvasArea.tsx @@ -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, @@ -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`, @@ -168,6 +175,9 @@ export function CanvasArea(props: { /> )} + paneProps={{ + inert: interactionBlocked ? '' : undefined, + } as React.HTMLProps} watermark={ watermarkSvg ? ( diff --git a/src/diagram/renderingState.ts b/src/diagram/renderingState.ts index b621b296..f3c13fce 100644 --- a/src/diagram/renderingState.ts +++ b/src/diagram/renderingState.ts @@ -44,6 +44,10 @@ export interface RenderingStateEvents { */ readonly layer: RenderingLayer; }; + /** + * Triggered on {@link RenderingState.interactionBlocked} property change. + */ + changeInteractionBlocked: PropertyChange; /** * Triggered on {@link RenderingState.getLinkTemplates} property change. */ @@ -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. @@ -231,6 +239,8 @@ export class MutableRenderingState implements RenderingState { hashHotkeyAst, sameHotkeyAst ); + private _interactionBlocks = 0; + readonly shared: SharedCanvasState; /** @hidden */ @@ -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); } diff --git a/src/editor/overlayController.tsx b/src/editor/overlayController.tsx index 53a22dfe..dfa3744b 100644 --- a/src/editor/overlayController.tsx +++ b/src/editor/overlayController.tsx @@ -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'; @@ -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( @@ -378,6 +377,7 @@ export class OverlayController { const onHide = () => this.hideDialog(); if (target && !isSmallViewport) { this.setDialog( + openedDialog, ); } - - this.source.trigger('changeOpenedDialog', { - source: this, - previous: previousDialog, - }); } /** @@ -416,18 +412,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, + }); } } @@ -565,11 +568,11 @@ function LoadingWidget(props: { spinnerProps: SpinnerProps }) { y: size.height / 2, }; return ( -
+ -
+ ); } @@ -592,12 +595,32 @@ function ViewportDialog(props: DialogProps) { [viewportSize, margin] ); return ( -
+ + ); +} + +function ViewportOverlay(props: { + innerRef?: React.RefObject; + 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 ( +
+ {children}
); } diff --git a/src/paper/paper.tsx b/src/paper/paper.tsx index 1a9758f2..5825374b 100644 --- a/src/paper/paper.tsx +++ b/src/paper/paper.tsx @@ -34,6 +34,7 @@ export interface PaperProps { pageSize: Size; contentBounds: Rect; renderLayers: (transform: PaperTransform) => React.ReactNode; + paneProps?: React.HTMLProps; watermark?: React.ReactNode; children?: React.ReactNode; } @@ -149,7 +150,7 @@ export class Paper extends React.Component { 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; @@ -180,7 +181,8 @@ export class Paper extends React.Component { tabIndex={0} onKeyDown={onKeyDown} onKeyUp={onKeyUp}> -
{ private unsubscribeFromTarget: Unsubscribe | undefined = undefined; private readonly listener = new EventObserver(); + private root = React.createRef(); private updateAll = () => this.forceUpdate(); private startSize: Vector | undefined; @@ -306,7 +307,7 @@ export class Dialog extends React.Component { }; return ( -
renderingState.interactionBlocked + ); const style = { '--reactodia-viewport-dock-offset-x': `${dockOffsetX}px`, '--reactodia-viewport-dock-offset-y': `${dockOffsetY}px`, '--reactodia-viewport-dock-align-x': DOCK_ALIGN_X[dock], '--reactodia-viewport-dock-align-y': DOCK_ALIGN_Y[dock], } as React.CSSProperties; + const otherProps = { + inert: interactionBlocked ? '' : undefined, + } as React.HTMLProps; return ( -
Date: Tue, 23 Jun 2026 02:49:52 +0300 Subject: [PATCH 3/3] Auto-focus within dialogs by default: * 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. --- CHANGELOG.md | 1 + i18n/i18n.schema.json | 1 + .../en.reactodia-translation.json | 1 + src/coreUtils/dom.ts | 20 ++++++++++++++++++ src/editor/overlayController.tsx | 7 +++++-- .../connectionsMenu/connectionsMenu.tsx | 1 + src/widgets/dialog.tsx | 21 ++++++++++++++++++- src/widgets/utility/searchInput.tsx | 4 +++- 8 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 287709dc..1a9efa2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - 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 diff --git a/i18n/i18n.schema.json b/i18n/i18n.schema.json index ae543b97..5f5e306e 100644 --- a/i18n/i18n.schema.json +++ b/i18n/i18n.schema.json @@ -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" } } diff --git a/i18n/translations/en.reactodia-translation.json b/i18n/translations/en.reactodia-translation.json index c1cb0d90..62047439 100644 --- a/i18n/translations/en.reactodia-translation.json +++ b/i18n/translations/en.reactodia-translation.json @@ -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" }, diff --git a/src/coreUtils/dom.ts b/src/coreUtils/dom.ts index 5f99fcf6..2265acfa 100644 --- a/src/coreUtils/dom.ts +++ b/src/coreUtils/dom.ts @@ -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; +} diff --git a/src/editor/overlayController.tsx b/src/editor/overlayController.tsx index dfa3744b..28b9c402 100644 --- a/src/editor/overlayController.tsx +++ b/src/editor/overlayController.tsx @@ -375,6 +375,7 @@ 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, @@ -386,7 +387,8 @@ export class OverlayController { target } onResize={onResize} - onHide={onHide}> + onHide={onHide} + closeTitle={closeTitle}> {content}
@@ -397,7 +399,8 @@ export class OverlayController { + onHide={onHide} + closeTitle={closeTitle}> {content} ); diff --git a/src/widgets/connectionsMenu/connectionsMenu.tsx b/src/widgets/connectionsMenu/connectionsMenu.tsx index 9acc3246..ada5a2a1 100644 --- a/src/widgets/connectionsMenu/connectionsMenu.tsx +++ b/src/widgets/connectionsMenu/connectionsMenu.tsx @@ -509,6 +509,7 @@ class ConnectionsMenuInner extends React.Component {this.renderSortSwitches()} diff --git a/src/widgets/dialog.tsx b/src/widgets/dialog.tsx index b1fa9c12..90850f4c 100644 --- a/src/widgets/dialog.tsx +++ b/src/widgets/dialog.tsx @@ -1,6 +1,7 @@ import cx from 'clsx'; import * as React from 'react'; +import { findAutoFocusable } from '../coreUtils/dom'; import { EventObserver, Unsubscribe } from '../coreUtils/events'; import { CanvasContext } from '../diagram/canvasApi'; @@ -16,6 +17,7 @@ export interface DialogProps extends DialogStyleProps { onResize?: (size: Size) => void; onHide: () => void; mode?: 'centered' | 'fillViewport'; + closeTitle?: string; children: React.ReactNode; } @@ -61,6 +63,17 @@ export interface DialogStyleProps { * @default true */ closable?: boolean; + /** + * Whether the dialog should auto-focus on first focusable child. + * + * If exists, the auto-focus focuses on elements with `data-reactodia-autofocus` + * attribute, next with `autofocus` attibute otherwise on first + * [tab-indexable](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex) + * child. + * + * @default true + */ + autoFocus?: boolean; /** * Dock direction for the dialog from the {@link DialogProps.target target} * point of view: @@ -116,12 +129,17 @@ export class Dialog extends React.Component { componentDidMount() { const {canvas, model} = this.context; + const {autoFocus = true} = this.props; this.listener.listen(model.events, 'changeLanguage', this.updateAll); this.listener.listen(canvas.events, 'changeTransform', this.updateAll); this.listenToTarget(this.props.target); if (this.props.target) { this.focusOn(); } + if (autoFocus && this.root.current) { + const autoFocusable = findAutoFocusable(this.root.current); + autoFocusable?.focus(); + } } componentDidUpdate(prevProps: DialogProps) { @@ -294,6 +312,7 @@ export class Dialog extends React.Component { caption, resizableBy: baseResizableBy = 'all', closable = true, + closeTitle, } = this.props; const resizableBy = mode === 'fillViewport' ? 'none' : baseResizableBy; @@ -324,7 +343,7 @@ export class Dialog extends React.Component { {caption}
{closable ? ( -