diff --git a/CHANGELOG.md b/CHANGELOG.md index 876a8a49..1a9efa2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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/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/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/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..28b9c402 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( @@ -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, + onHide={onHide} + closeTitle={closeTitle}> {content} ); } else { this.setDialog( + openedDialog, + onHide={onHide} + closeTitle={closeTitle}> {content} ); } - - this.source.trigger('changeOpenedDialog', { - source: this, - previous: previousDialog, - }); } /** @@ -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, + }); } } @@ -565,11 +571,11 @@ function LoadingWidget(props: { spinnerProps: SpinnerProps }) { y: size.height / 2, }; return ( -
+ -
+ ); } @@ -592,12 +598,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}> -
{this.renderSortSwitches()} diff --git a/src/widgets/dialog.tsx b/src/widgets/dialog.tsx index 42d3a849..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: @@ -104,6 +117,7 @@ export class Dialog extends React.Component { private unsubscribeFromTarget: Unsubscribe | undefined = undefined; private readonly listener = new EventObserver(); + private root = React.createRef(); private updateAll = () => this.forceUpdate(); private startSize: Vector | undefined; @@ -115,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) { @@ -293,6 +312,7 @@ export class Dialog extends React.Component { caption, resizableBy: baseResizableBy = 'all', closable = true, + closeTitle, } = this.props; const resizableBy = mode === 'fillViewport' ? 'none' : baseResizableBy; @@ -306,7 +326,7 @@ export class Dialog extends React.Component { }; return ( -
{ {caption}
{closable ? ( - ) : 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} diff --git a/styles/editor/_loadingWidget.scss b/styles/editor/_overlays.scss similarity index 71% rename from styles/editor/_loadingWidget.scss rename to styles/editor/_overlays.scss index b3ba5a81..c7b35dc1 100644 --- a/styles/editor/_loadingWidget.scss +++ b/styles/editor/_overlays.scss @@ -1,17 +1,21 @@ @use "../mixin/zIndex"; @use "../theme/theme"; -.reactodia-loading-widget { - left: 0; - right: 0; - top: 0; - bottom: 0; - margin: auto; +.reactodia-viewport-overlay { position: absolute; + inset: 0; display: flex; align-items: center; justify-content: center; - color: theme.$color-emphasis-1000; background-color: theme.$canvas-overlay-color; +} + +.reactodia-loading-widget { + margin: auto; + color: theme.$color-emphasis-1000; z-index: zIndex.$loading-widget; } + +.reactodia-viewport-dialog-overlay { + z-index: zIndex.$viewport-dialog; +} diff --git a/styles/main.scss b/styles/main.scss index 0f2f5387..17e14a17 100644 --- a/styles/main.scss +++ b/styles/main.scss @@ -13,7 +13,7 @@ @forward "editor/editEntityForm"; @forward "editor/editRelationForm"; @forward "editor/elementSelector"; -@forward "editor/loadingWidget"; +@forward "editor/overlays"; @forward "editor/withFetchStatus"; @forward "forms/inlineDiagnostic"; diff --git a/styles/widgets/_dialog.scss b/styles/widgets/_dialog.scss index 054cc6e9..776de2eb 100644 --- a/styles/widgets/_dialog.scss +++ b/styles/widgets/_dialog.scss @@ -45,18 +45,3 @@ } } } - -.reactodia-viewport-dialog-overlay { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - z-index: zIndex.$viewport-dialog; - - display: flex; - align-items: center; - justify-content: center; - - background-color: theme.$canvas-overlay-color; -}