Skip to content

Commit b682812

Browse files
authored
fix: disable grid section focus target in interaction mode (#1960)
* fix: disable grid section focus target when in interaction mode * Add tests * Reenable test * Make interacting property readonly * Refactor to use destructuring
1 parent 3149d30 commit b682812

File tree

3 files changed

+213
-33
lines changed

3 files changed

+213
-33
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@vaadin/testing-helpers": "^0.2.1",
4444
"@web/dev-server": "^0.1.17",
4545
"@web/test-runner": "^0.13.4",
46+
"@web/test-runner-commands": "^0.4.5",
4647
"@web/test-runner-playwright": "^0.8.4",
4748
"@web/test-runner-saucelabs": "^0.5.0",
4849
"@web/test-runner-visual-regression": "^0.6.0",

packages/vaadin-grid/src/vaadin-grid-keyboard-navigation-mixin.js

Lines changed: 109 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,27 @@ export const KeyboardNavigationMixin = (superClass) =>
4545
},
4646

4747
/** @private */
48-
_focusedColumnOrder: Number
48+
_focusedColumnOrder: Number,
49+
50+
/**
51+
* Indicates whether the grid is currently in interaction mode.
52+
* In interaction mode the user is currently interacting with a control,
53+
* such as an input or a select, within a cell.
54+
* In interaction mode keyboard navigation between cells is disabled.
55+
* Interaction mode also prevents the focus target cell of that section of
56+
* the grid from receiving focus, allowing the user to switch focus to
57+
* controls in adjacent cells, rather than focussing the outer cell
58+
* itself.
59+
* @type {boolean}
60+
* @private
61+
*/
62+
interacting: {
63+
type: Boolean,
64+
value: false,
65+
reflectToAttribute: true,
66+
readOnly: true,
67+
observer: '_interactingChanged'
68+
}
4969
};
5070
}
5171

@@ -82,10 +102,18 @@ export const KeyboardNavigationMixin = (superClass) =>
82102
oldFocusable.setAttribute('tabindex', '-1');
83103
}
84104
if (focusable) {
85-
focusable.setAttribute('tabindex', '0');
105+
this._updateGridSectionFocusTarget(focusable);
86106
}
87107
}
88108

109+
/** @private */
110+
_interactingChanged() {
111+
// Update focus targets when entering / exiting interaction mode
112+
this._updateGridSectionFocusTarget(this._headerFocusable);
113+
this._updateGridSectionFocusTarget(this._itemsFocusable);
114+
this._updateGridSectionFocusTarget(this._footerFocusable);
115+
}
116+
89117
/**
90118
* @param {!KeyboardEvent} e
91119
* @protected
@@ -119,7 +147,7 @@ export const KeyboardNavigationMixin = (superClass) =>
119147
}
120148

121149
this._detectInteracting(e);
122-
if (this.hasAttribute('interacting') && keyGroup !== 'Interaction') {
150+
if (this.interacting && keyGroup !== 'Interaction') {
123151
// When in the interacting mode, only the “Interaction” keys are handled.
124152
keyGroup = undefined;
125153
}
@@ -334,32 +362,32 @@ export const KeyboardNavigationMixin = (superClass) =>
334362
let wantInteracting;
335363
switch (key) {
336364
case 'Enter':
337-
wantInteracting = this.hasAttribute('interacting') ? !localTargetIsTextInput : true;
365+
wantInteracting = this.interacting ? !localTargetIsTextInput : true;
338366
break;
339367
case 'Escape':
340368
wantInteracting = false;
341369
break;
342370
case 'F2':
343-
wantInteracting = !this.hasAttribute('interacting');
371+
wantInteracting = !this.interacting;
344372
break;
345373
}
346374

347375
const { cell } = this._parseEventPath(e.composedPath());
348376

349-
if (this.hasAttribute('interacting') !== wantInteracting) {
377+
if (this.interacting !== wantInteracting) {
350378
if (wantInteracting) {
351379
const focusTarget = cell._content.querySelector('[focus-target]') || cell._content.firstElementChild;
352380
if (focusTarget) {
353381
e.preventDefault();
354382
focusTarget.focus();
355-
this._toggleAttribute('interacting', true, this);
383+
this._setInteracting(true);
356384
this._toggleAttribute('navigating', false, this);
357385
}
358386
} else {
359387
e.preventDefault();
360388
this._focusedColumnOrder = undefined;
361389
cell.focus();
362-
this._toggleAttribute('interacting', false, this);
390+
this._setInteracting(false);
363391
this._toggleAttribute('navigating', true, this);
364392
}
365393
}
@@ -469,7 +497,7 @@ export const KeyboardNavigationMixin = (superClass) =>
469497
// tabbed (shift-tabbed) into the grid. Move the focus to
470498
// the first (the last) focusable.
471499
this._predictFocusStepTarget(rootTarget, rootTarget === this.$.table ? 1 : -1).focus();
472-
this._toggleAttribute('interacting', false, this);
500+
this._setInteracting(false);
473501
} else {
474502
this._detectInteracting(e);
475503
}
@@ -486,16 +514,17 @@ export const KeyboardNavigationMixin = (superClass) =>
486514

487515
/** @private */
488516
_onCellFocusIn(e) {
517+
const location = this._getCellFocusEventLocation(e);
489518
this._detectInteracting(e);
490519

491-
if (e.composedPath().indexOf(this.$.table) === 3) {
492-
const cell = e.composedPath()[0];
493-
this._activeRowGroup = cell.parentNode.parentNode;
494-
if (this._activeRowGroup === this.$.header) {
520+
if (location) {
521+
const { section, cell } = location;
522+
this._activeRowGroup = section;
523+
if (this.$.header === section) {
495524
this._headerFocusable = cell;
496-
} else if (this._activeRowGroup === this.$.items) {
525+
} else if (this.$.items === section) {
497526
this._itemsFocusable = cell;
498-
} else if (this._activeRowGroup === this.$.footer) {
527+
} else if (this.$.footer === section) {
499528
this._footerFocusable = cell;
500529
}
501530
// Inform cell content of the focus (used in <vaadin-grid-sorter>)
@@ -517,13 +546,14 @@ export const KeyboardNavigationMixin = (superClass) =>
517546
}
518547
}
519548

520-
/** @private */
549+
/** @private
550+
* Enables interaction mode if a cells descendant receives focus or keyboard
551+
* input. Disables it if the event is not related to cell content.
552+
* @param {!KeyboardEvent|!FocusEvent} e
553+
*/
521554
_detectInteracting(e) {
522-
this._toggleAttribute(
523-
'interacting',
524-
e.composedPath().some((el) => el.localName === 'vaadin-grid-cell-content'),
525-
this
526-
);
555+
const isInteracting = e.composedPath().some((el) => el.localName === 'vaadin-grid-cell-content');
556+
this._setInteracting(isInteracting);
527557
}
528558

529559
/** @private */
@@ -534,6 +564,21 @@ export const KeyboardNavigationMixin = (superClass) =>
534564
}
535565
}
536566

567+
/** @private
568+
* Enables or disables the focus target cell of the containing section of the
569+
* grid from receiving focus, based on whether the user is interacting with
570+
* that section of the grid.
571+
* @param {HTMLTableCellElement} focusTargetCell
572+
*/
573+
_updateGridSectionFocusTarget(focusTargetCell) {
574+
if (!focusTargetCell) return;
575+
576+
const section = this._getGridSectionFromFocusTarget(focusTargetCell);
577+
const isInteractingWithinActiveSection = this.interacting && section === this._activeRowGroup;
578+
579+
focusTargetCell.tabIndex = isInteractingWithinActiveSection ? -1 : 0;
580+
}
581+
537582
/**
538583
* @param {!HTMLTableRowElement} row
539584
* @param {number} index
@@ -639,4 +684,47 @@ export const KeyboardNavigationMixin = (superClass) =>
639684
_elementMatches(el, query) {
640685
return el.matches ? el.matches(query) : Array.from(el.parentNode.querySelectorAll(query)).indexOf(el) !== -1;
641686
}
687+
688+
/**
689+
* @typedef {Object} CellFocusEventLocation
690+
* @property {HTMLTableSectionElement} section - The grid section element that contains the focused cell (header, body, or footer)
691+
* @property {HTMLElement} cell - The cell element that received focus or is ancestor of the element that received focus
692+
* @private
693+
*/
694+
/**
695+
* Takes a focus event and returns a location object describing in which
696+
* section of the grid and in or on which cell the focus event occurred.
697+
* The focus event may either target the cell itself or contents of the cell.
698+
* If the event does not target a cell then null is returned.
699+
* @param {FocusEvent} e
700+
* @returns {CellFocusEventLocation | null}
701+
* @private
702+
*/
703+
_getCellFocusEventLocation(e) {
704+
const path = e.composedPath();
705+
const tableIndex = path.indexOf(this.$.table);
706+
// Assuming ascending path to table is: [...,] th|td, tr, thead|tbody, table [,...]
707+
const section = tableIndex >= 2 ? path[tableIndex - 1] : null;
708+
const cell = tableIndex >= 3 ? path[tableIndex - 3] : null;
709+
710+
if (!section || !cell) return null;
711+
712+
return {
713+
section,
714+
cell
715+
};
716+
}
717+
718+
/**
719+
* Helper method that maps a focus target cell to the containing grid section
720+
* @param {HTMLTableCellElement} focusTargetCell
721+
* @returns {HTMLTableSectionElement | null}
722+
* @private
723+
*/
724+
_getGridSectionFromFocusTarget(focusTargetCell) {
725+
if (focusTargetCell === this._headerFocusable) return this.$.header;
726+
if (focusTargetCell === this._itemsFocusable) return this.$.items;
727+
if (focusTargetCell === this._footerFocusable) return this.$.footer;
728+
return null;
729+
}
642730
};

0 commit comments

Comments
 (0)