diff --git a/src/myfasthtml/assets/datagrid/datagrid.css b/src/myfasthtml/assets/datagrid/datagrid.css index 52f867d..619a740 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.css +++ b/src/myfasthtml/assets/datagrid/datagrid.css @@ -142,6 +142,10 @@ outline-offset: -3px; /* Ensure the outline is snug to the cell */ } +.grid:focus { + outline: none; +} + .dt2-cell:hover, .dt2-selected-cell { background-color: var(--color-selection); diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index e04c5a4..70e0905 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -688,6 +688,7 @@ function updateDatagridSelection(datagridId) { }); // Loop through the children of the selection manager + let hasFocusedCell = false; Array.from(selectionManager.children).forEach((selection) => { const selectionType = selection.getAttribute('selection-type'); const elementId = selection.getAttribute('element-id'); @@ -697,6 +698,8 @@ function updateDatagridSelection(datagridId) { if (cellElement) { cellElement.classList.add('dt2-selected-focus'); cellElement.style.userSelect = 'text'; + cellElement.focus({ preventScroll: true }); + hasFocusedCell = true; } } else if (selectionType === 'cell') { 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 }); + } } /** diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 732c9b4..f532001 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -227,6 +227,13 @@ class Commands(BaseCommands): 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): super().__init__(parent, _id=_id) name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__") @@ -311,6 +318,10 @@ class DataGrid(MultipleInstance): self._key_support = { "esc": {"command": self.commands.on_key_pressed(), "require_inside": False}, "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.") @@ -387,6 +398,37 @@ class DataGrid(MultipleInstance): else: 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): if element_id is None: return None @@ -650,6 +692,13 @@ class DataGrid(MultipleInstance): self._state.selection.selected and self._state.edition.under_edition is None): 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() @@ -974,6 +1023,7 @@ class DataGrid(MultipleInstance): data_tooltip=str(value), style=f"width:{col_def.width}px;", 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)) def mk_row(self, row_index, filter_keyword_lower, len_columns_1): @@ -1193,7 +1243,8 @@ class DataGrid(MultipleInstance): ), id=self._id, 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): diff --git a/tests/controls/test_datagrid.py b/tests/controls/test_datagrid.py index 8a801aa..45b039c 100644 --- a/tests/controls/test_datagrid.py +++ b/tests/controls/test_datagrid.py @@ -372,6 +372,102 @@ class TestDataGridBehaviour: assert col_def.type == ColumnType.Number 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: