From b36893a24cf640b33a99584d204dc5d5fdac9e93 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Mon, 24 Aug 2026 21:36:54 +0200 Subject: [PATCH 1/2] feat(dia.Paper): add focusin and focusout events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paper turns the (bubbling) native focusin/focusout DOM events into cell:/element:/link:/blank: paper events, following the mouseover/ mouseout pattern — so applications can react to keyboard focus on cells (e.g. focusable cells via the root tabindex attribute) without binding native DOM listeners on the paper element. --- .changeset/big-donkeys-refuse.md | 5 +++ packages/joint-core/src/dia/CellView.mjs | 10 ++++++ packages/joint-core/src/dia/ElementView.mjs | 12 +++++++ packages/joint-core/src/dia/LinkView.mjs | 12 +++++++ packages/joint-core/src/dia/Paper.mjs | 30 ++++++++++++++++ packages/joint-core/test/jointjs/paper.js | 40 +++++++++++++++++++++ packages/joint-core/types/dia.d.ts | 10 ++++++ 7 files changed, 119 insertions(+) create mode 100644 .changeset/big-donkeys-refuse.md diff --git a/.changeset/big-donkeys-refuse.md b/.changeset/big-donkeys-refuse.md new file mode 100644 index 0000000000..2df8960517 --- /dev/null +++ b/.changeset/big-donkeys-refuse.md @@ -0,0 +1,5 @@ +--- +"@joint/core": minor +--- + +dia.Paper - add `focusin` and `focusout` events (`cell:`, `element:`, `link:`, `blank:` variants) diff --git a/packages/joint-core/src/dia/CellView.mjs b/packages/joint-core/src/dia/CellView.mjs index 001cedb25b..c22198e868 100644 --- a/packages/joint-core/src/dia/CellView.mjs +++ b/packages/joint-core/src/dia/CellView.mjs @@ -1299,6 +1299,16 @@ export const CellView = View.extend({ this.notify('cell:mouseout', evt); }, + focusin: function(evt) { + + this.notify('cell:focusin', evt); + }, + + focusout: function(evt) { + + this.notify('cell:focusout', evt); + }, + mouseenter: function(evt) { this.notify('cell:mouseenter', evt); diff --git a/packages/joint-core/src/dia/ElementView.mjs b/packages/joint-core/src/dia/ElementView.mjs index e5f18adc0c..9f74bba950 100644 --- a/packages/joint-core/src/dia/ElementView.mjs +++ b/packages/joint-core/src/dia/ElementView.mjs @@ -687,6 +687,18 @@ export const ElementView = CellView.extend({ this.notify('element:mouseout', evt); }, + focusin: function(evt) { + + CellView.prototype.focusin.apply(this, arguments); + this.notify('element:focusin', evt); + }, + + focusout: function(evt) { + + CellView.prototype.focusout.apply(this, arguments); + this.notify('element:focusout', evt); + }, + mouseenter: function(evt) { CellView.prototype.mouseenter.apply(this, arguments); diff --git a/packages/joint-core/src/dia/LinkView.mjs b/packages/joint-core/src/dia/LinkView.mjs index 05c2a57a94..7f5d7cc6d8 100644 --- a/packages/joint-core/src/dia/LinkView.mjs +++ b/packages/joint-core/src/dia/LinkView.mjs @@ -1561,6 +1561,18 @@ export const LinkView = CellView.extend({ this.notify('link:mouseout', evt); }, + focusin: function(evt) { + + CellView.prototype.focusin.apply(this, arguments); + this.notify('link:focusin', evt); + }, + + focusout: function(evt) { + + CellView.prototype.focusout.apply(this, arguments); + this.notify('link:focusout', evt); + }, + mouseenter: function(evt) { CellView.prototype.mouseenter.apply(this, arguments); diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index 0a4263199d..dbb642cf63 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -549,6 +549,8 @@ export const Paper = View.extend({ 'touchstart': 'pointerdown', 'mouseover': 'mouseover', 'mouseout': 'mouseout', + 'focusin': 'focusin', + 'focusout': 'focusout', 'mouseenter': 'mouseenter', 'mouseleave': 'mouseleave', 'wheel': 'mousewheel', @@ -3674,6 +3676,34 @@ export const Paper = View.extend({ } }, + focusin: function(evt) { + + var view = this.findView(evt.target); + if (this.guard(evt, view)) return; + + if (view) { + view.focusin(evt); + + } else { + if (this.el === evt.target) return; // prevent border of paper from triggering this + this.trigger('blank:focusin', evt); + } + }, + + focusout: function(evt) { + + var view = this.findView(evt.target); + if (this.guard(evt, view)) return; + + if (view) { + view.focusout(evt); + + } else { + if (this.el === evt.target) return; // prevent border of paper from triggering this + this.trigger('blank:focusout', evt); + } + }, + mouseenter: function(evt) { evt = normalizeEvent(evt); diff --git a/packages/joint-core/test/jointjs/paper.js b/packages/joint-core/test/jointjs/paper.js index f99bd9a832..9513722a76 100644 --- a/packages/joint-core/test/jointjs/paper.js +++ b/packages/joint-core/test/jointjs/paper.js @@ -355,6 +355,46 @@ QUnit.module('paper', function(hooks) { assert.ok(blankContextmenuCallback.called, 'blank:contextmenu triggered'); }); + QUnit.test('focusin & focusout', function(assert) { + + var r1 = new joint.shapes.standard.Rectangle({ position: { x: 50, y: 50 }, size: { width: 20, height: 20 }}); + var r2 = new joint.shapes.standard.Rectangle({ position: { x: 150, y: 50 }, size: { width: 20, height: 20 }}); + var l1 = new joint.shapes.standard.Link({ source: { id: r1.id }, target: { id: r2.id }}); + this.graph.resetCells([r1, r2, l1]); + + var events = []; + this.paper.on('all', function(name) { + if (name.indexOf('focus') > -1) events.push(name); + }); + + // The test `$.fn.trigger` does not dispatch bubbling events — + // dispatch native focus events instead. + var focus = function(el, type) { + el.dispatchEvent(new FocusEvent(type, { bubbles: true })); + }; + + var r1View = this.paper.findViewByModel(r1); + focus(r1View.el, 'focusin'); + assert.deepEqual(events, ['cell:focusin', 'element:focusin'], 'cell:focusin precedes element:focusin'); + + events = []; + focus(r1View.el, 'focusout'); + assert.deepEqual(events, ['cell:focusout', 'element:focusout'], 'cell:focusout precedes element:focusout'); + + events = []; + var l1View = this.paper.findViewByModel(l1); + focus(l1View.el, 'focusin'); + assert.deepEqual(events, ['cell:focusin', 'link:focusin'], 'cell:focusin precedes link:focusin'); + + events = []; + focus(this.paper.svg, 'focusin'); + assert.deepEqual(events, ['blank:focusin'], 'blank:focusin triggered'); + + events = []; + focus(this.paper.svg, 'focusout'); + assert.deepEqual(events, ['blank:focusout'], 'blank:focusout triggered'); + }); + QUnit.test('paper.getArea()', function(assert) { this.paper.translate(0, 0); diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index 356555820a..a9b2193619 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1895,6 +1895,16 @@ export namespace Paper { 'element:mouseout': (elementView: ElementView, evt: Event) => void; 'link:mouseout': (linkView: LinkView, evt: Event) => void; 'blank:mouseout': (evt: Event) => void; + // focusin + 'cell:focusin': (cellView: CellView, evt: Event) => void; + 'element:focusin': (elementView: ElementView, evt: Event) => void; + 'link:focusin': (linkView: LinkView, evt: Event) => void; + 'blank:focusin': (evt: Event) => void; + // focusout + 'cell:focusout': (cellView: CellView, evt: Event) => void; + 'element:focusout': (elementView: ElementView, evt: Event) => void; + 'link:focusout': (linkView: LinkView, evt: Event) => void; + 'blank:focusout': (evt: Event) => void; // mouseenter 'cell:mouseenter': (cellView: CellView, evt: Event) => void; 'element:mouseenter': (elementView: ElementView, evt: Event) => void; From b9a30b8e213f10fd8c46dc623ae3bc3f207ac6b8 Mon Sep 17 00:00:00 2001 From: Roman Bruckner Date: Mon, 24 Aug 2026 21:44:00 +0200 Subject: [PATCH 2/2] refactor(dia.Paper): drop the blank:focusin and blank:focusout variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A focus event outside of a cell view carries no useful information — keep the API surface to the cell:/element:/link: variants. --- .changeset/big-donkeys-refuse.md | 2 +- packages/joint-core/src/dia/Paper.mjs | 16 ++-------------- packages/joint-core/test/jointjs/paper.js | 6 +----- packages/joint-core/types/dia.d.ts | 2 -- 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/.changeset/big-donkeys-refuse.md b/.changeset/big-donkeys-refuse.md index 2df8960517..890714a213 100644 --- a/.changeset/big-donkeys-refuse.md +++ b/.changeset/big-donkeys-refuse.md @@ -2,4 +2,4 @@ "@joint/core": minor --- -dia.Paper - add `focusin` and `focusout` events (`cell:`, `element:`, `link:`, `blank:` variants) +dia.Paper - add `focusin` and `focusout` events (`cell:`, `element:`, `link:` variants) diff --git a/packages/joint-core/src/dia/Paper.mjs b/packages/joint-core/src/dia/Paper.mjs index dbb642cf63..a96ce73595 100644 --- a/packages/joint-core/src/dia/Paper.mjs +++ b/packages/joint-core/src/dia/Paper.mjs @@ -3681,13 +3681,7 @@ export const Paper = View.extend({ var view = this.findView(evt.target); if (this.guard(evt, view)) return; - if (view) { - view.focusin(evt); - - } else { - if (this.el === evt.target) return; // prevent border of paper from triggering this - this.trigger('blank:focusin', evt); - } + if (view) view.focusin(evt); }, focusout: function(evt) { @@ -3695,13 +3689,7 @@ export const Paper = View.extend({ var view = this.findView(evt.target); if (this.guard(evt, view)) return; - if (view) { - view.focusout(evt); - - } else { - if (this.el === evt.target) return; // prevent border of paper from triggering this - this.trigger('blank:focusout', evt); - } + if (view) view.focusout(evt); }, mouseenter: function(evt) { diff --git a/packages/joint-core/test/jointjs/paper.js b/packages/joint-core/test/jointjs/paper.js index 9513722a76..17d34cb629 100644 --- a/packages/joint-core/test/jointjs/paper.js +++ b/packages/joint-core/test/jointjs/paper.js @@ -388,11 +388,7 @@ QUnit.module('paper', function(hooks) { events = []; focus(this.paper.svg, 'focusin'); - assert.deepEqual(events, ['blank:focusin'], 'blank:focusin triggered'); - - events = []; - focus(this.paper.svg, 'focusout'); - assert.deepEqual(events, ['blank:focusout'], 'blank:focusout triggered'); + assert.deepEqual(events, [], 'no event for a focusin outside of a cell view'); }); QUnit.test('paper.getArea()', function(assert) { diff --git a/packages/joint-core/types/dia.d.ts b/packages/joint-core/types/dia.d.ts index a9b2193619..7f842b2d16 100644 --- a/packages/joint-core/types/dia.d.ts +++ b/packages/joint-core/types/dia.d.ts @@ -1899,12 +1899,10 @@ export namespace Paper { 'cell:focusin': (cellView: CellView, evt: Event) => void; 'element:focusin': (elementView: ElementView, evt: Event) => void; 'link:focusin': (linkView: LinkView, evt: Event) => void; - 'blank:focusin': (evt: Event) => void; // focusout 'cell:focusout': (cellView: CellView, evt: Event) => void; 'element:focusout': (elementView: ElementView, evt: Event) => void; 'link:focusout': (linkView: LinkView, evt: Event) => void; - 'blank:focusout': (evt: Event) => void; // mouseenter 'cell:mouseenter': (cellView: CellView, evt: Event) => void; 'element:mouseenter': (elementView: ElementView, evt: Event) => void;