Added keyboard navigation support
This commit is contained in:
@@ -142,6 +142,10 @@
|
|||||||
outline-offset: -3px; /* Ensure the outline is snug to the cell */
|
outline-offset: -3px; /* Ensure the outline is snug to the cell */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.dt2-cell:hover,
|
.dt2-cell:hover,
|
||||||
.dt2-selected-cell {
|
.dt2-selected-cell {
|
||||||
background-color: var(--color-selection);
|
background-color: var(--color-selection);
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ function updateDatagridSelection(datagridId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Loop through the children of the selection manager
|
// Loop through the children of the selection manager
|
||||||
|
let hasFocusedCell = false;
|
||||||
Array.from(selectionManager.children).forEach((selection) => {
|
Array.from(selectionManager.children).forEach((selection) => {
|
||||||
const selectionType = selection.getAttribute('selection-type');
|
const selectionType = selection.getAttribute('selection-type');
|
||||||
const elementId = selection.getAttribute('element-id');
|
const elementId = selection.getAttribute('element-id');
|
||||||
@@ -697,6 +698,8 @@ function updateDatagridSelection(datagridId) {
|
|||||||
if (cellElement) {
|
if (cellElement) {
|
||||||
cellElement.classList.add('dt2-selected-focus');
|
cellElement.classList.add('dt2-selected-focus');
|
||||||
cellElement.style.userSelect = 'text';
|
cellElement.style.userSelect = 'text';
|
||||||
|
cellElement.focus({ preventScroll: true });
|
||||||
|
hasFocusedCell = true;
|
||||||
}
|
}
|
||||||
} else if (selectionType === 'cell') {
|
} else if (selectionType === 'cell') {
|
||||||
const cellElement = document.getElementById(`${elementId}`);
|
const cellElement = document.getElementById(`${elementId}`);
|
||||||
@@ -744,6 +747,11 @@ function updateDatagridSelection(datagridId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasFocusedCell) {
|
||||||
|
const grid = document.getElementById(datagridId);
|
||||||
|
if (grid) grid.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -227,6 +227,13 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class DataGrid(MultipleInstance):
|
class DataGrid(MultipleInstance):
|
||||||
|
_ARROW_KEY_DIRECTIONS = {
|
||||||
|
"arrowright": "right",
|
||||||
|
"arrowleft": "left",
|
||||||
|
"arrowdown": "down",
|
||||||
|
"arrowup": "up",
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(self, parent, conf=None, save_state=None, _id=None):
|
def __init__(self, parent, conf=None, save_state=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
|
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
|
||||||
@@ -311,6 +318,10 @@ class DataGrid(MultipleInstance):
|
|||||||
self._key_support = {
|
self._key_support = {
|
||||||
"esc": {"command": self.commands.on_key_pressed(), "require_inside": False},
|
"esc": {"command": self.commands.on_key_pressed(), "require_inside": False},
|
||||||
"enter": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
"enter": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
||||||
|
"arrowup": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
||||||
|
"arrowdown": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
||||||
|
"arrowleft": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
||||||
|
"arrowright": {"command": self.commands.on_key_pressed(), "require_inside": True},
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")
|
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")
|
||||||
@@ -387,6 +398,37 @@ class DataGrid(MultipleInstance):
|
|||||||
else:
|
else:
|
||||||
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
|
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
|
||||||
|
|
||||||
|
def _get_navigable_col_positions(self) -> list[int]:
|
||||||
|
return [i for i, col in enumerate(self._columns)
|
||||||
|
if col.visible and col.type != ColumnType.RowSelection_]
|
||||||
|
|
||||||
|
def _get_visible_row_indices(self) -> list[int]:
|
||||||
|
df = self._get_filtered_df()
|
||||||
|
return list(df.index) if df is not None else []
|
||||||
|
|
||||||
|
def _navigate(self, pos: tuple, direction: str) -> tuple:
|
||||||
|
col_pos, row_index = pos
|
||||||
|
navigable_cols = self._get_navigable_col_positions()
|
||||||
|
visible_rows = self._get_visible_row_indices()
|
||||||
|
|
||||||
|
if not navigable_cols or not visible_rows:
|
||||||
|
return pos
|
||||||
|
|
||||||
|
if direction == "right":
|
||||||
|
next_cols = [c for c in navigable_cols if c > col_pos]
|
||||||
|
return (next_cols[0], row_index) if next_cols else pos
|
||||||
|
elif direction == "left":
|
||||||
|
prev_cols = [c for c in navigable_cols if c < col_pos]
|
||||||
|
return (prev_cols[-1], row_index) if prev_cols else pos
|
||||||
|
elif direction == "down":
|
||||||
|
next_rows = [r for r in visible_rows if r > row_index]
|
||||||
|
return (col_pos, next_rows[0]) if next_rows else pos
|
||||||
|
elif direction == "up":
|
||||||
|
prev_rows = [r for r in visible_rows if r < row_index]
|
||||||
|
return (col_pos, prev_rows[-1]) if prev_rows else pos
|
||||||
|
|
||||||
|
return pos
|
||||||
|
|
||||||
def _get_pos_from_element_id(self, element_id):
|
def _get_pos_from_element_id(self, element_id):
|
||||||
if element_id is None:
|
if element_id is None:
|
||||||
return None
|
return None
|
||||||
@@ -650,6 +692,13 @@ class DataGrid(MultipleInstance):
|
|||||||
self._state.selection.selected and
|
self._state.selection.selected and
|
||||||
self._state.edition.under_edition is None):
|
self._state.edition.under_edition is None):
|
||||||
return self._enter_edition(self._state.selection.selected)
|
return self._enter_edition(self._state.selection.selected)
|
||||||
|
elif combination in self._ARROW_KEY_DIRECTIONS:
|
||||||
|
current_pos = (self._state.selection.selected
|
||||||
|
or self._state.selection.last_selected
|
||||||
|
or (0, 0))
|
||||||
|
direction = self._ARROW_KEY_DIRECTIONS[combination]
|
||||||
|
new_pos = self._navigate(current_pos, direction)
|
||||||
|
self._update_current_position(new_pos)
|
||||||
|
|
||||||
return self.render_partial()
|
return self.render_partial()
|
||||||
|
|
||||||
@@ -974,6 +1023,7 @@ class DataGrid(MultipleInstance):
|
|||||||
data_tooltip=str(value),
|
data_tooltip=str(value),
|
||||||
style=f"width:{col_def.width}px;",
|
style=f"width:{col_def.width}px;",
|
||||||
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
|
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
|
||||||
|
tabindex="-1",
|
||||||
cls=merge_classes("dt2-cell", "dt2-last-cell" if is_last else None))
|
cls=merge_classes("dt2-cell", "dt2-last-cell" if is_last else None))
|
||||||
|
|
||||||
def mk_row(self, row_index, filter_keyword_lower, len_columns_1):
|
def mk_row(self, row_index, filter_keyword_lower, len_columns_1):
|
||||||
@@ -1193,7 +1243,8 @@ class DataGrid(MultipleInstance):
|
|||||||
),
|
),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
cls="grid",
|
cls="grid",
|
||||||
style="height: 100%; grid-template-rows: auto 1fr;"
|
style="height: 100%; grid-template-rows: auto 1fr;",
|
||||||
|
tabindex="-1"
|
||||||
)
|
)
|
||||||
|
|
||||||
def render_partial(self, fragment="cell", **kwargs):
|
def render_partial(self, fragment="cell", **kwargs):
|
||||||
|
|||||||
@@ -372,6 +372,102 @@ class TestDataGridBehaviour:
|
|||||||
assert col_def.type == ColumnType.Number
|
assert col_def.type == ColumnType.Number
|
||||||
assert col_def.formula == "{age} + 1"
|
assert col_def.formula == "{age} + 1"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Keyboard Navigation
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Column layout for datagrid_with_data (enable_edition=True):
|
||||||
|
# _state.columns : [name (idx 0), age (idx 1), active (idx 2)]
|
||||||
|
# _columns : [RowSelection_ (pos 0), name (pos 1), age (pos 2), active (pos 3)]
|
||||||
|
# Navigable positions: [1, 2, 3] — RowSelection_ (pos 0) always excluded.
|
||||||
|
|
||||||
|
def test_i_can_get_navigable_col_positions(self, datagrid_with_data):
|
||||||
|
"""RowSelection_ (pos 0) must be excluded; data columns (1, 2, 3) must be included."""
|
||||||
|
positions = datagrid_with_data._get_navigable_col_positions()
|
||||||
|
assert positions == [1, 2, 3]
|
||||||
|
|
||||||
|
def test_i_can_get_navigable_col_positions_with_hidden_column(self, datagrid_with_data):
|
||||||
|
"""Hidden columns must be excluded from navigable positions.
|
||||||
|
|
||||||
|
_state.columns[1] is 'age' → maps to _columns pos 2.
|
||||||
|
After hiding, navigable positions must be [1, 3].
|
||||||
|
"""
|
||||||
|
datagrid_with_data._state.columns[1].visible = False # hide 'age' (pos 2 in _columns)
|
||||||
|
datagrid_with_data._init_columns()
|
||||||
|
positions = datagrid_with_data._get_navigable_col_positions()
|
||||||
|
assert positions == [1, 3]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("start_pos, direction, expected_pos", [
|
||||||
|
# Normal navigation — right / left
|
||||||
|
((1, 0), "right", (2, 0)),
|
||||||
|
((2, 0), "right", (3, 0)),
|
||||||
|
((3, 0), "left", (2, 0)),
|
||||||
|
((2, 0), "left", (1, 0)),
|
||||||
|
# Normal navigation — down / up
|
||||||
|
((1, 0), "down", (1, 1)),
|
||||||
|
((1, 1), "down", (1, 2)),
|
||||||
|
((1, 2), "up", (1, 1)),
|
||||||
|
((1, 1), "up", (1, 0)),
|
||||||
|
# Boundaries — must stay in place
|
||||||
|
((3, 0), "right", (3, 0)),
|
||||||
|
((1, 0), "left", (1, 0)),
|
||||||
|
((1, 2), "down", (1, 2)),
|
||||||
|
((1, 0), "up", (1, 0)),
|
||||||
|
])
|
||||||
|
def test_i_can_navigate(self, datagrid_with_data, start_pos, direction, expected_pos):
|
||||||
|
"""Navigation moves to the expected position or stays at boundary."""
|
||||||
|
result = datagrid_with_data._navigate(start_pos, direction)
|
||||||
|
assert result == expected_pos
|
||||||
|
|
||||||
|
def test_i_can_navigate_right_skipping_invisible_column(self, datagrid_with_data):
|
||||||
|
"""→ from col 1 must skip hidden col 2 (age) and land on col 3 (active)."""
|
||||||
|
datagrid_with_data._state.columns[1].visible = False # hide 'age' → pos 2
|
||||||
|
datagrid_with_data._init_columns()
|
||||||
|
result = datagrid_with_data._navigate((1, 0), "right")
|
||||||
|
assert result == (3, 0)
|
||||||
|
|
||||||
|
def test_i_can_navigate_left_skipping_invisible_column(self, datagrid_with_data):
|
||||||
|
"""← from col 3 must skip hidden col 2 (age) and land on col 1 (name)."""
|
||||||
|
datagrid_with_data._state.columns[1].visible = False # hide 'age' → pos 2
|
||||||
|
datagrid_with_data._init_columns()
|
||||||
|
result = datagrid_with_data._navigate((3, 0), "left")
|
||||||
|
assert result == (1, 0)
|
||||||
|
|
||||||
|
def test_i_can_navigate_down_skipping_filtered_row(self, datagrid_with_data):
|
||||||
|
"""↓ from row 0 must skip filtered-out row 1 (Bob) and land on row 2 (Charlie).
|
||||||
|
|
||||||
|
Filter keeps age values "25" and "35" only → row 1 (age=30) is excluded.
|
||||||
|
Visible row indices become [0, 2].
|
||||||
|
"""
|
||||||
|
datagrid_with_data._state.filtered["age"] = ["25", "35"]
|
||||||
|
result = datagrid_with_data._navigate((1, 0), "down")
|
||||||
|
assert result == (1, 2)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("combination, start_pos, expected_pos", [
|
||||||
|
("arrowright", (1, 0), (2, 0)),
|
||||||
|
("arrowleft", (2, 0), (1, 0)),
|
||||||
|
("arrowdown", (1, 0), (1, 1)),
|
||||||
|
("arrowup", (1, 1), (1, 0)),
|
||||||
|
])
|
||||||
|
def test_i_can_navigate_with_arrow_keys(self, datagrid_with_data, combination, start_pos, expected_pos):
|
||||||
|
"""Arrow key presses update selection.selected to the expected position."""
|
||||||
|
datagrid_with_data._state.selection.selected = start_pos
|
||||||
|
datagrid_with_data.on_key_pressed(combination=combination, has_focus=True, is_inside=True)
|
||||||
|
assert datagrid_with_data._state.selection.selected == expected_pos
|
||||||
|
|
||||||
|
def test_i_can_navigate_from_last_selected_when_no_selection(self, datagrid_with_data):
|
||||||
|
"""When selected is None, navigation starts from last_selected."""
|
||||||
|
datagrid_with_data._state.selection.selected = None
|
||||||
|
datagrid_with_data._state.selection.last_selected = (1, 1)
|
||||||
|
datagrid_with_data.on_key_pressed(combination="arrowright", has_focus=True, is_inside=True)
|
||||||
|
assert datagrid_with_data._state.selection.selected == (2, 1)
|
||||||
|
|
||||||
|
def test_i_can_navigate_from_origin_when_no_selection_and_no_last_selected(self, datagrid_with_data):
|
||||||
|
"""When both selected and last_selected are None, navigation starts from (0, 0)."""
|
||||||
|
datagrid_with_data._state.selection.selected = None
|
||||||
|
datagrid_with_data._state.selection.last_selected = None
|
||||||
|
datagrid_with_data.on_key_pressed(combination="arrowright", has_focus=True, is_inside=True)
|
||||||
|
assert datagrid_with_data._state.selection.selected == (1, 0)
|
||||||
|
|
||||||
|
|
||||||
class TestDataGridRender:
|
class TestDataGridRender:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user