Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new beforeColumnWrap and beforeRowWrap hooks #10550

Merged
merged 9 commits into from Oct 26, 2023
8 changes: 8 additions & 0 deletions .changelogs/10550.json
@@ -0,0 +1,8 @@
{
"issuesOrigin": "private",
"title": "Added new `beforeColumnWrap` and `beforeRowWrap` hooks.",
"type": "added",
"issueOrPR": 10550,
"breaking": false,
"framework": "none"
}
31 changes: 31 additions & 0 deletions handsontable/src/3rdparty/walkontable/src/cell/coords.js
Expand Up @@ -123,6 +123,15 @@ class CellCoords {
return this.row >= 0 && this.col >= 0;
}

/**
* Checks if the coordinates runs in RTL mode.
*
* @returns {boolean}
*/
isRtl() {
return this.#isRtl;
}

/**
* Checks if another set of coordinates (`testedCoords`)
* is south-east of the coordinates in your `CellCoords` instance.
Expand Down Expand Up @@ -185,6 +194,28 @@ class CellCoords {
return this;
}

/**
* Assigns the coordinates from another `CellCoords` instance (or compatible literal object)
* to your `CellCoords` instance.
*
* @param {CellCoords | { row?: number, col?: number }} coords The CellCoords instance or compatible literal object.
* @returns {CellCoords}
*/
assign(coords) {
if (Number.isInteger(coords?.row)) {
this.row = coords.row;
}
if (Number.isInteger(coords?.col)) {
this.col = coords.col;
}

if (coords instanceof CellCoords) {
this.#isRtl = coords.isRtl();
}

return this;
}

/**
* Clones your `CellCoords` instance.
*
Expand Down
Expand Up @@ -197,6 +197,49 @@ describe('CellCoords', () => {
});
});

describe('assign()', () => {
it('should be possible to assign coords from other CellCoords instance', () => {
const coords = new CellCoords(0, 0, false);
const coords2 = new CellCoords(3, 9, true);

coords.assign(coords2);

expect(coords).not.toBe(coords2);
expect(coords.row).toBe(coords2.row);
expect(coords.col).toBe(coords2.col);
expect(coords.isRtl()).toBe(coords2.isRtl());
});

it('should be possible to assign coords from literal object', () => {
const coords = new CellCoords(0, 0, true);

coords.assign({ row: 3 });

expect(coords.row).toBe(3);
expect(coords.col).toBe(0);
expect(coords.isRtl()).toBe(true);

coords.assign({ col: 4 });

expect(coords.row).toBe(3);
expect(coords.col).toBe(4);
expect(coords.isRtl()).toBe(true);

coords.assign({ row: -1, col: -2 });

expect(coords.row).toBe(-1);
expect(coords.col).toBe(-2);
expect(coords.isRtl()).toBe(true);
});
});

describe('isRtl()', () => {
it('should return correct values', () => {
expect(new CellCoords(0, 0, false).isRtl()).toBe(false);
expect(new CellCoords(0, 0, true).isRtl()).toBe(true);
});
});

describe('isCell()', () => {
it('should return `false` when one of the axis point to the header (negative value)', () => {
expect(new CellCoords(-1, 9).isCell()).toBe(false);
Expand Down
57 changes: 18 additions & 39 deletions handsontable/src/core.js
Expand Up @@ -323,22 +323,6 @@ export default function Core(rootElement, userSettings, rootInstanceSymbol = fal
this.columnIndexMapper.addLocalHook('cacheUpdated', onIndexMapperCacheUpdate);
this.rowIndexMapper.addLocalHook('cacheUpdated', onIndexMapperCacheUpdate);

this.selection.addLocalHook('beforeHighlightSet', () => {
this.runHooks('beforeSelectionHighlightSet');
});

this.selection.addLocalHook('beforeSetRangeStart', (cellCoords) => {
this.runHooks('beforeSetRangeStart', cellCoords);
});

this.selection.addLocalHook('beforeSetRangeStartOnly', (cellCoords) => {
this.runHooks('beforeSetRangeStartOnly', cellCoords);
});

this.selection.addLocalHook('beforeSetRangeEnd', (cellCoords) => {
this.runHooks('beforeSetRangeEnd', cellCoords);
});

this.selection.addLocalHook('afterSetRangeEnd', (cellCoords) => {
const preventScrolling = createObjectPropListener(false);
const selectionRange = this.selection.getSelectedRange();
Expand Down Expand Up @@ -431,23 +415,6 @@ export default function Core(rootElement, userSettings, rootInstanceSymbol = fal
}
});

this.selection.addLocalHook('beforeSelectColumns', (...args) => this.runHooks('beforeSelectColumns', ...args));
this.selection.addLocalHook('afterSelectColumns', (...args) => this.runHooks('afterSelectColumns', ...args));
this.selection.addLocalHook('beforeSelectRows', (...args) => this.runHooks('beforeSelectRows', ...args));
this.selection.addLocalHook('afterSelectRows', (...args) => this.runHooks('afterSelectRows', ...args));

this.selection.addLocalHook('beforeModifyTransformStart', (cellCoordsDelta) => {
this.runHooks('modifyTransformStart', cellCoordsDelta);
});
this.selection.addLocalHook('afterModifyTransformStart', (coords, rowTransformDir, colTransformDir) => {
this.runHooks('afterModifyTransformStart', coords, rowTransformDir, colTransformDir);
});
this.selection.addLocalHook('beforeModifyTransformEnd', (cellCoordsDelta) => {
this.runHooks('modifyTransformEnd', cellCoordsDelta);
});
this.selection.addLocalHook('afterModifyTransformEnd', (coords, rowTransformDir, colTransformDir) => {
this.runHooks('afterModifyTransformEnd', coords, rowTransformDir, colTransformDir);
});
this.selection.addLocalHook('afterDeselect', () => {
editorManager.destroyEditor();

Expand All @@ -456,12 +423,24 @@ export default function Core(rootElement, userSettings, rootInstanceSymbol = fal

this.runHooks('afterDeselect');
});
this.selection.addLocalHook('insertRowRequire', (totalRows) => {
this.alter('insert_row_above', totalRows, 1, 'auto');
});
this.selection.addLocalHook('insertColRequire', (totalCols) => {
this.alter('insert_col_start', totalCols, 1, 'auto');
});

this.selection
.addLocalHook('beforeHighlightSet', () => this.runHooks('beforeSelectionHighlightSet'))
.addLocalHook('beforeSetRangeStart', (...args) => this.runHooks('beforeSetRangeStart', ...args))
.addLocalHook('beforeSetRangeStartOnly', (...args) => this.runHooks('beforeSetRangeStartOnly', ...args))
.addLocalHook('beforeSetRangeEnd', (...args) => this.runHooks('beforeSetRangeEnd', ...args))
.addLocalHook('beforeSelectColumns', (...args) => this.runHooks('beforeSelectColumns', ...args))
.addLocalHook('afterSelectColumns', (...args) => this.runHooks('afterSelectColumns', ...args))
.addLocalHook('beforeSelectRows', (...args) => this.runHooks('beforeSelectRows', ...args))
.addLocalHook('afterSelectRows', (...args) => this.runHooks('afterSelectRows', ...args))
.addLocalHook('beforeModifyTransformStart', (...args) => this.runHooks('modifyTransformStart', ...args))
.addLocalHook('afterModifyTransformStart', (...args) => this.runHooks('afterModifyTransformStart', ...args))
.addLocalHook('beforeModifyTransformEnd', (...args) => this.runHooks('modifyTransformEnd', ...args))
.addLocalHook('afterModifyTransformEnd', (...args) => this.runHooks('afterModifyTransformEnd', ...args))
.addLocalHook('beforeRowWrap', (...args) => this.runHooks('beforeRowWrap', ...args))
.addLocalHook('beforeColumnWrap', (...args) => this.runHooks('beforeColumnWrap', ...args))
.addLocalHook('insertRowRequire', totalRows => this.alter('insert_row_above', totalRows, 1, 'auto'))
.addLocalHook('insertColRequire', totalCols => this.alter('insert_col_start', totalCols, 1, 'auto'));

grid = {
/**
Expand Down
56 changes: 33 additions & 23 deletions handsontable/src/core/focusCatcher/index.js
Expand Up @@ -34,11 +34,30 @@ export function installFocusCatcher(hot) {
},
});

const rowWrapState = {
wrapped: false,
flipped: false,
};

hot.addHook('afterListen', () => deactivate());
hot.addHook('afterUnlisten', () => activate());
hot.addHook('afterSelection', () => {
recentlyAddedFocusCoords = hot.getSelectedRangeLast()?.highlight;
});
hot.addHook('beforeRowWrap', (isWrapEnabled, newCoords, isFlipped) => {
rowWrapState.wrapped = true;
rowWrapState.flipped = isFlipped;
});

/**
* Unselects the cell and deactivates the table.
*/
function deactivateTable() {
rowWrapState.wrapped = false;
rowWrapState.flipped = false;
hot.deselectCell();
hot.unlisten();
}

hot.getShortcutManager()
.getContext('grid')
Expand All @@ -47,38 +66,29 @@ export function installFocusCatcher(hot) {
callback: (event) => {
const { disableTabNavigation, autoWrapRow } = hot.getSettings();

if (disableTabNavigation) {
hot.deselectCell();
hot.unlisten();

return false;
}

const isSelected = hot.selection.isSelected();
const highlight = hot.getSelectedRangeLast()?.highlight;
const mostTopStartCoords = getMostTopStartPosition(hot);
const mostBottomEndCoords = getMostBottomEndPosition(hot);

// For disabled `autoWrapRow` option set the row to the same position as the currently selected row.
if (!autoWrapRow) {
mostTopStartCoords.row = highlight.row;
mostBottomEndCoords.row = highlight.row;
}
if (
disableTabNavigation ||
!hot.selection.isSelected() ||
autoWrapRow && rowWrapState.wrapped && rowWrapState.flipped ||
!autoWrapRow && rowWrapState.wrapped
) {
if (autoWrapRow && rowWrapState.wrapped && rowWrapState.flipped) {
recentlyAddedFocusCoords = event.shiftKey
? getMostTopStartPosition(hot) : getMostBottomEndPosition(hot);
}

if (event.shiftKey && (!isSelected || highlight.isEqual(mostTopStartCoords)) ||
!event.shiftKey && (!isSelected || highlight.isEqual(mostBottomEndCoords))) {
hot.deselectCell();
hot.unlisten();
deactivateTable();

return false;
}

return true;
// if the selection is still within the table's range then prevent default action
event.preventDefault();
},
runOnlyIf: () => !hot.getSettings().minSpareCols,
preventDefault: false,
stopPropagation: false,
position: 'before',
position: 'after',
relativeToGroup: GRID_GROUP,
group: 'focusCatcher',
});
Expand Down
34 changes: 34 additions & 0 deletions handsontable/src/pluginHooks.js
Expand Up @@ -1288,6 +1288,40 @@ const REGISTERED_HOOKS = [
*/
'afterRender',

/**
* When the focus position is moved to the next or previous row caused by the {@link Options#autoWrapRow} option
* the hook is triggered.
*
* @since 14.0.0
* @event Hooks#beforeRowWrap
* @param {boolean} isWrapEnabled Tells whether the row wrapping is going to happen.
* There may be situations where the option does not work even though it is enabled.
* This is due to the priority of other options that may block the feature.
* For example, when the {@link Options#minSpareCols} is defined, the {@link Options#autoWrapRow} option is not checked.
* Thus, row wrapping is off.
* @param {CellCoords} newCoords The new focus position.
* @param {boolean} isFlipped `true` if the row index was flipped, `false` otherwise.
* Flipped index means that the user reached the last row and the focus is moved to the first row or vice versa.
*/
'beforeRowWrap',

/**
* When the focus position is moved to the next or previous column caused by the {@link Options#autoWrapCol} option
* the hook is triggered.
*
* @since 14.0.0
* @event Hooks#beforeColumnWrap
* @param {boolean} isWrapEnabled Tells whether the column wrapping is going to happen.
* There may be situations where the option does not work even though it is enabled.
* This is due to the priority of other options that may block the feature.
* For example, when the {@link Options#minSpareRows} is defined, the {@link Options#autoWrapCol} option is not checked.
* Thus, column wrapping is off.
* @param {CellCoords} newCoords The new focus position.
* @param {boolean} isFlipped `true` if the column index was flipped, `false` otherwise.
* Flipped index means that the user reached the last column and the focus is moved to the first column or vice versa.
*/
'beforeColumnWrap',

/**
* Fired before cell meta is changed.
*
Expand Down
@@ -0,0 +1,73 @@
describe('NestedHeaders navigation keyboard shortcuts', () => {
beforeEach(function() {
this.$container = $('<div id="testContainer"></div>').appendTo('body');
this.$container1 = $('<div id="testContainer1"></div>').appendTo('body');
});

afterEach(function() {
this.$container.data('handsontable')?.destroy();
this.$container.remove();
this.$container1.data('handsontable')?.destroy();
this.$container1.remove();
});

describe('"Tab" with "Shift" + "Tab"', () => {
it('should activate the table, allow traversing through the nested headers, and then leave the table', async() => {
const hot = handsontable({
data: createSpreadsheetData(3, 5),
rowHeaders: true,
colHeaders: true,
navigableHeaders: true,
disableTabNavigation: false,
nestedHeaders: [
['A', { label: 'B', colspan: 4 }],
],
});
const hot1 = handsontable({
data: createSpreadsheetData(3, 5),
navigableHeaders: false,
disableTabNavigation: false,
nestedHeaders: [
['A', { label: 'B', colspan: 4 }],
],
}, false, spec().$container1);

triggerTabNavigationFromTop(); // emulates native browser Tab navigation

expect(hot.getSelectedRange()).toEqualCellRange(['highlight: -1,-1 from: -1,-1 to: -1,-1']);
expect(hot1.getSelectedRange()).toBeUndefined();

keyDownUp('tab');

expect(hot.getSelectedRange()).toEqualCellRange(['highlight: -1,0 from: -1,0 to: -1,0']);
expect(hot1.getSelectedRange()).toBeUndefined();

keyDownUp('tab');

expect(hot.getSelectedRange()).toEqualCellRange(['highlight: -1,1 from: -1,1 to: -1,1']);
expect(hot1.getSelectedRange()).toBeUndefined();

keyDownUp('tab');
triggerTabNavigationFromTop(hot1); // emulates native browser Tab navigation

expect(hot.getSelectedRange()).toBeUndefined();
expect(hot1.getSelectedRange()).toEqualCellRange(['highlight: 0,0 from: 0,0 to: 0,0']);

keyDownUp(['shift', 'tab']);
triggerTabNavigationFromBottom(); // emulates native browser Tab navigation

expect(hot.getSelectedRange()).toEqualCellRange(['highlight: -1,4 from: -1,4 to: -1,4']);
expect(hot1.getSelectedRange()).toBeUndefined();

keyDownUp(['shift', 'tab']);

expect(hot.getSelectedRange()).toEqualCellRange(['highlight: -1,0 from: -1,0 to: -1,0']);
expect(hot1.getSelectedRange()).toBeUndefined();

keyDownUp(['shift', 'tab']);

expect(hot.getSelectedRange()).toEqualCellRange(['highlight: -1,-1 from: -1,-1 to: -1,-1']);
expect(hot1.getSelectedRange()).toBeUndefined();
});
});
});