Skip to content

Commit

Permalink
feat(core): make Canvas focusable
Browse files Browse the repository at this point in the history
The `Canvas` is now a focusable component, that is recognized
accordingly by the browser, with all benefits for UX and interaction.

Components that pull focus from the `Canvas` during modeling must
ensure to restore the focus (if intended), via `Canvas#restoreFocus`.

Related to #661
  • Loading branch information
nikku committed Nov 4, 2024
1 parent 20deedf commit 670428a
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 6 deletions.
56 changes: 53 additions & 3 deletions lib/core/Canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
} from 'min-dash';

import {
assignStyle
assignStyle,
attr as domAttr
} from 'min-dom';

import {
Expand Down Expand Up @@ -189,6 +190,11 @@ export default function Canvas(config, eventBus, graphicsFactory, elementRegistr
*/
this._rootElement = null;

/**
* @type {boolean}
*/
this._focused = false;

this._init(config || {});
}

Expand All @@ -215,14 +221,33 @@ Canvas.$inject = [
* @param {CanvasConfig} config
*/
Canvas.prototype._init = function(config) {

const eventBus = this._eventBus;

// html container
const container = this._container = createContainer(config);

const svg = this._svg = svgCreate('svg');
svgAttr(svg, { width: '100%', height: '100%' });

svgAttr(svg, {
width: '100%',
height: '100%'
});

domAttr(svg, 'tabindex', 0);

eventBus.on('element.hover', () => {
this.restoreFocus();
});

svg.addEventListener('focusin', () => {
this._focused = true;
eventBus.fire('canvas.focus.changed', { focused: true });
});

svg.addEventListener('focusout', () => {
this._focused = false;
eventBus.fire('canvas.focus.changed', { focused: false });
});

svgAppend(container, svg);

Expand Down Expand Up @@ -314,6 +339,31 @@ Canvas.prototype._clear = function() {
delete this._cachedViewbox;
};

/**
* Sets focus on the canvas SVG element.
*/
Canvas.prototype.focus = function() {
this._svg.focus({ preventScroll: true });
};

/**
* Sets focus on the canvas SVG element if `document.body` is currently focused.
*/
Canvas.prototype.restoreFocus = function() {
if (document.activeElement === document.body) {
this.focus();
}
};

/**
* Returns true if the canvas is focused.
*
* @return {boolean}
*/
Canvas.prototype.isFocused = function() {
return this._focused;
};

/**
* Returns the default layer on which
* all elements are drawn.
Expand Down
2 changes: 2 additions & 0 deletions lib/features/popup-menu/PopupMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ PopupMenu.prototype.close = function() {

this.reset();

this._canvas.restoreFocus();

this._current = null;
};

Expand Down
2 changes: 2 additions & 0 deletions lib/features/search-pad/SearchPad.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ SearchPad.prototype.close = function(restoreCached = true) {
this._searchInput.blur();

this._eventBus.fire('searchPad.closed');

this._canvas.restoreFocus();
};


Expand Down
113 changes: 110 additions & 3 deletions test/spec/core/CanvasSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,18 @@ describe('Canvas', function() {

beforeEach(createDiagram());


it('should create <svg> element', inject(function() {

// then
// when
var svg = container.querySelector('svg');

expect(svg).not.to.be.null;
// then
// svg is created
expect(svg).to.exist;

// and user selectable
expect(svgAttr(svg, 'tabindex')).to.equal('0');
}));


Expand All @@ -68,6 +74,106 @@ describe('Canvas', function() {
});


describe('focus handling', function() {

beforeEach(function() {
container = TestContainer.get(this);
});

beforeEach(createDiagram());


it('should be focusable', function() {

// given
var svg = container.querySelector('svg');

// when
svg.focus();

// then
expect(document.activeElement).to.equal(svg);
});


describe('<hover>', function() {

/**
* @type { HTMLElement }
*/
let inputEl;

beforeEach(function() {
document.body.focus();
});

afterEach(function() {
inputEl && inputEl.remove();
});


it('should focus if body is focused', inject(function(canvas, eventBus) {

// given
var svg = container.querySelector('svg');

// when
eventBus.fire('element.hover', {
element: canvas.getRootElement(),
gfx: svg
});

// then
expect(document.activeElement).to.equal(svg);
}));


it('should not scroll on focus', inject(function(canvas, eventBus) {

// given
var svg = container.querySelector('svg');

var clientRect = svg.getBoundingClientRect();

// when
eventBus.fire('element.hover', {
element: canvas.getRootElement(),
gfx: svg
});

// then
expect(clientRect).to.eql(svg.getBoundingClientRect());
}));


it('should not focus on existing document focus', inject(function(canvas, eventBus) {

// given
inputEl = document.createElement('input');

document.body.appendChild(inputEl);
inputEl.focus();

// assume
expect(document.activeElement).to.equal(inputEl);

var svg = container.querySelector('svg');

// when
eventBus.fire('element.hover', {
element: canvas.getRootElement(),
gfx: svg
});

// then
expect(document.activeElement).to.eql(inputEl);
}));

});

});


describe('events', function() {

beforeEach(function() {
Expand Down Expand Up @@ -2651,4 +2757,5 @@ function expectChildren(parent, children) {
expect(existingChildrenGfx).to.eql(expectedChildrenGfx);
});

}
}

14 changes: 14 additions & 0 deletions test/spec/features/popup-menu/PopupMenuSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,20 @@ describe('features/popup-menu', function() {
}).not.to.throw();
}));


it('should refocus canvas on close', inject(function(canvas, popupMenu) {

// given
sinon.spy(canvas, 'restoreFocus');

// when
popupMenu.close();

// then
expect(canvas.restoreFocus).to.have.been.calledOnce;

}));

});


Expand Down
14 changes: 14 additions & 0 deletions test/spec/features/search-pad/SearchPadSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,20 @@ describe('features/searchPad', function() {
expect(capturedEvents).to.eql([ EVENTS.opened, EVENTS.closed ]);
}));


it('should refocus canvas on close', inject(function(canvas, searchPad) {

// given
sinon.spy(canvas, 'restoreFocus');
searchPad.open();

// when
triggerMouseEvent(canvas.getContainer(), 'click', 0, 0);

// then
expect(canvas.restoreFocus).to.have.been.calledOnce;
}));

});


Expand Down

0 comments on commit 670428a

Please sign in to comment.