diff --git a/docs/Keyboard Support.md b/docs/Keyboard Support.md index d361973..5d396d3 100644 --- a/docs/Keyboard Support.md +++ b/docs/Keyboard Support.md @@ -100,6 +100,57 @@ add_keyboard_support('elem2', '{"C D": "/url2"}'); The timeout is tied to the **sequence being typed**, not to individual elements. +### Enabling and Disabling Combinations + +Each combination can be enabled or disabled independently. A disabled combination is registered and tracked, but its action is never triggered. + +| State | Behavior | +|-------|----------| +| `enabled=True` (default) | Combination triggers normally | +| `enabled=False` | Combination is silently ignored when pressed | + +**Setting the initial state at registration:** + +```python +# Enabled by default +keyboard.add("ctrl+s", self.commands.save()) + +# Disabled at startup +keyboard.add("ctrl+d", self.commands.delete(), enabled=False) +``` + +**Toggling dynamically at runtime:** + +Use `mk_enable()` and `mk_disable()` to change the state from a server response. Both methods return an out-of-band HTMX element (`hx-swap-oob`) that updates the DOM without a full page reload. + +```python +# Enable a combination (e.g., once an item is selected) +def handle_select(self): + item = ... + return item.render(), self.keyboard.mk_enable("ctrl+d") + +# Disable a combination (e.g., when nothing is selected) +def handle_deselect(self): + return self.keyboard.mk_disable("ctrl+d") +``` + +The enabled state is stored in a hidden control `
` rendered alongside the keyboard script. The JavaScript reads this state before triggering any action. + +**State at a glance:** + +``` +┌─────────────────────────────────────┐ +│ Keyboard control div (hidden) │ +│ ┌──────────────────────────────┐ │ +│ │ div [data-combination="esc"] │ │ +│ │ [data-enabled="true"] │ │ +│ ├──────────────────────────────┤ │ +│ │ div [data-combination="ctrl+d"] │ │ +│ │ [data-enabled="false"] │ │ +│ └──────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + ### Smart Timeout Logic (Longest Match) Keyboard shortcuts are **disabled** when typing in input fields: @@ -364,16 +415,56 @@ remove_keyboard_support('modal'); ## API Reference -### add_keyboard_support(elementId, combinationsJson) +### add_keyboard_support(elementId, controlDivId, combinationsJson) Adds keyboard support to an element. **Parameters**: -- `elementId` (string): ID of the HTML element +- `elementId` (string): ID of the HTML element to watch for key events +- `controlDivId` (string): ID of the keyboard control div rendered by `Keyboard.render()`, used to look up enabled/disabled state at runtime - `combinationsJson` (string): JSON string of combinations with HTMX configs **Returns**: void +### Python Component Methods + +These methods are available on the `Keyboard` instance. + +#### add(sequence, command, require_inside=True, enabled=True) + +Registers a key combination. + +| Parameter | Type | Description | Default | +|-----------|------|-------------|---------| +| `sequence` | `str` | Key combination string, e.g. `"ctrl+s"`, `"esc"`, `"a b"` | — | +| `command` | `Command` | Command to execute when the combination is triggered | — | +| `require_inside` | `bool` | If `True`, only triggers when focus is inside the element | `True` | +| `enabled` | `bool` | Whether the combination is active at render time | `True` | + +**Returns**: `self` (chainable) + +#### mk_enable(sequence) + +Returns an out-of-band HTMX element that enables a combination at runtime. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `sequence` | `str` | Key combination to enable, must match exactly what was passed to `add()` | + +**Returns**: `Div` with `hx-swap-oob="true"` + +#### mk_disable(sequence) + +Returns an out-of-band HTMX element that disables a combination at runtime. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `sequence` | `str` | Key combination to disable, must match exactly what was passed to `add()` | + +**Returns**: `Div` with `hx-swap-oob="true"` + +--- + ### remove_keyboard_support(elementId) Removes keyboard support from an element. diff --git a/docs/TreeView.md b/docs/TreeView.md index 07a5254..727c841 100644 --- a/docs/TreeView.md +++ b/docs/TreeView.md @@ -181,13 +181,13 @@ Users can rename nodes via the edit button: ```python # Programmatically start rename -tree._start_rename("node-id") +tree.handle_start_rename("node-id") # Save rename -tree._save_rename("node-id", "New Label") +tree.handle_save_rename("node-id", "New Label") # Cancel rename -tree._cancel_rename() +tree.handle_cancel_rename() ``` ### Deleting Nodes @@ -201,7 +201,7 @@ Users can delete nodes via the delete button: ```python # Programmatically delete node -tree._delete_node("node-id") # Raises ValueError if node has children +tree.handle_delete_node("node-id") # Raises ValueError if node has children ``` ## Content System @@ -449,18 +449,20 @@ tree = TreeView(parent=root_instance, _id="dynamic-tree") root = TreeNode(id="root", label="Tasks", type="folder") tree.add_node(root) + # Function to handle selection def on_node_selected(node_id): - # Custom logic when node is selected - node = tree._state.items[node_id] - tree._select_node(node_id) + # Custom logic when node is selected + node = tree._state.items[node_id] + tree.handle_select_node(node_id) + + # Update a detail panel elsewhere in the UI + return Div( + H3(f"Selected: {node.label}"), + P(f"Type: {node.type}"), + P(f"Children: {len(node.children)}") + ) - # Update a detail panel elsewhere in the UI - return Div( - H3(f"Selected: {node.label}"), - P(f"Type: {node.type}"), - P(f"Children: {len(node.children)}") - ) # Override select command with custom handler # (In practice, you'd extend the Commands class or use event callbacks) diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 377f16f..49c15b5 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -294,7 +294,7 @@ class DataGrid(MultipleInstance): } self._key_support = { - "esc": {"command": self.commands.on_key_pressed(), "require_inside": True}, + "esc": {"command": self.commands.on_key_pressed(), "require_inside": False}, } logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.") diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 91848f7..49dac5f 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -211,7 +211,7 @@ class DataGridsManager(SingleInstance): if parent_id not in self._tree.get_state().opened: self._tree.get_state().opened.append(parent_id) self._tree.get_state().selected = document.document_id - self._tree._start_rename(document.document_id) + self._tree.handle_start_rename(document.document_id) return self._tree, self._tabs_manager.show_tab(tab_id) diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index ba15c04..18ab069 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -9,7 +9,7 @@ import uuid from dataclasses import dataclass, field from typing import Optional -from fasthtml.components import Div, Input, Span +from fasthtml.components import Div, Input from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.IconsHelper import IconsHelper @@ -115,7 +115,7 @@ class Commands(BaseCommands): return Command("StartRename", f"Start renaming {node_id}", self._owner, - self._owner._start_rename, + self._owner.handle_start_rename, kwargs={"node_id": node_id}, key=f"{self._owner.get_safe_parent_key()}-StartRename" ).htmx(target=f"#{self._owner.get_id()}") @@ -125,7 +125,7 @@ class Commands(BaseCommands): return Command("SaveRename", f"Save rename for {node_id}", self._owner, - self._owner._save_rename, + self._owner.handle_save_rename, kwargs={"node_id": node_id}, key=f"{self._owner.get_safe_parent_key()}-SaveRename" ).htmx(target=f"#{self._owner.get_id()}") @@ -135,7 +135,7 @@ class Commands(BaseCommands): return Command("CancelRename", "Cancel rename", self._owner, - self._owner._cancel_rename, + self._owner.handle_cancel_rename, key=f"{self._owner.get_safe_parent_key()}-CancelRename" ).htmx(target=f"#{self._owner.get_id()}") @@ -144,7 +144,7 @@ class Commands(BaseCommands): return Command("DeleteNode", f"Delete node {node_id}", self._owner, - self._owner._delete_node, + self._owner.handle_delete_node, kwargs={"node_id": node_id}, key=f"{self._owner.get_safe_parent_key()}-DeleteNode" ).htmx(target=f"#{self._owner.get_id()}") @@ -154,7 +154,7 @@ class Commands(BaseCommands): return Command("SelectNode", f"Select node {node_id}", self._owner, - self._owner._select_node, + self._owner.handle_select_node, kwargs={"node_id": node_id}, key=f"{self._owner.get_safe_parent_key()}-SelectNode" ).htmx(target=f"#{self._owner.get_id()}") @@ -185,6 +185,10 @@ class TreeView(MultipleInstance): self._state = TreeViewState(self) self.conf = conf or TreeViewConf() self.commands = Commands(self) + self._keyboard = Keyboard(self, {"esc": + {"command": self.commands.cancel_rename(), + "require_inside": False}}, + _id="-keyboard"), if items: self._state.items = items @@ -293,7 +297,7 @@ class TreeView(MultipleInstance): return self._state.items[node_id].bag except KeyError: return None - + def get_state(self) -> TreeViewState: return self._state @@ -346,7 +350,7 @@ class TreeView(MultipleInstance): return self - def _start_rename(self, node_id: str): + def handle_start_rename(self, node_id: str): """Start renaming a node (sets editing state and selection).""" if node_id not in self._state.items: raise ValueError(f"Node {node_id} does not exist") @@ -355,7 +359,7 @@ class TreeView(MultipleInstance): self._state.editing = node_id return self - def _save_rename(self, node_id: str, node_label: str): + def handle_save_rename(self, node_id: str, node_label: str): """Save renamed node with new label.""" logger.debug(f"_save_rename {node_id=}, {node_label=}") if node_id not in self._state.items: @@ -365,13 +369,13 @@ class TreeView(MultipleInstance): self._state.editing = None return self - def _cancel_rename(self): + def handle_cancel_rename(self): """Cancel renaming operation.""" logger.debug("_cancel_rename") self._state.editing = None return self - def _delete_node(self, node_id: str): + def handle_delete_node(self, node_id: str): """Delete a node (only if it has no children).""" if node_id not in self._state.items: raise ValueError(f"Node {node_id} does not exist") @@ -397,7 +401,7 @@ class TreeView(MultipleInstance): return self - def _select_node(self, node_id: str): + def handle_select_node(self, node_id: str): """Select a node.""" if node_id not in self._state.items: raise ValueError(f"Node {node_id} does not exist") @@ -511,7 +515,7 @@ class TreeView(MultipleInstance): return Div( *[self._render_node(node_id) for node_id in root_nodes], - Keyboard(self, {"esc": {"command": self.commands.cancel_rename(), "require_inside": False}}, _id="-keyboard"), + self._keyboard, id=self._id, cls="mf-treeview" ) diff --git a/tests/controls/test_datagridsmanager.py b/tests/controls/test_datagridsmanager.py index 602b838..d099abf 100644 --- a/tests/controls/test_datagridsmanager.py +++ b/tests/controls/test_datagridsmanager.py @@ -72,7 +72,7 @@ class TestDataGridsManagerBehaviour: """ # Create a folder and select it folder_id = datagrids_manager._tree.ensure_path("MyFolder") - datagrids_manager._tree._select_node(folder_id) + datagrids_manager._tree.handle_select_node(folder_id) result = datagrids_manager.handle_new_grid() @@ -101,7 +101,7 @@ class TestDataGridsManagerBehaviour: datagrids_manager._tree.add_node(leaf, parent_id=folder_id) # Select the leaf - datagrids_manager._tree._select_node(leaf.id) + datagrids_manager._tree.handle_select_node(leaf.id) result = datagrids_manager.handle_new_grid() diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index 63d9a53..e1b6bd7 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -145,7 +145,7 @@ class TestTreeviewBehaviour: node = TreeNode(label="Node", type="folder") tree_view.add_node(node) - tree_view._select_node(node.id) + tree_view.handle_select_node(node.id) assert tree_view._state.selected == node.id @@ -155,7 +155,7 @@ class TestTreeviewBehaviour: node = TreeNode(label="Old Name", type="folder") tree_view.add_node(node) - tree_view._start_rename(node.id) + tree_view.handle_start_rename(node.id) assert tree_view._state.editing == node.id @@ -164,9 +164,9 @@ class TestTreeviewBehaviour: tree_view = TreeView(root_instance) node = TreeNode(label="Old Name", type="folder") tree_view.add_node(node) - tree_view._start_rename(node.id) + tree_view.handle_start_rename(node.id) - tree_view._save_rename(node.id, "New Name") + tree_view.handle_save_rename(node.id, "New Name") assert tree_view._state.items[node.id].label == "New Name" assert tree_view._state.editing is None @@ -176,9 +176,9 @@ class TestTreeviewBehaviour: tree_view = TreeView(root_instance) node = TreeNode(label="Name", type="folder") tree_view.add_node(node) - tree_view._start_rename(node.id) + tree_view.handle_start_rename(node.id) - tree_view._cancel_rename() + tree_view.handle_cancel_rename() assert tree_view._state.editing is None assert tree_view._state.items[node.id].label == "Name" @@ -193,7 +193,7 @@ class TestTreeviewBehaviour: tree_view.add_node(child, parent_id=parent.id) # Delete child (leaf node) - tree_view._delete_node(child.id) + tree_view.handle_delete_node(child.id) assert child.id not in tree_view._state.items assert child.id not in parent.children @@ -225,7 +225,7 @@ class TestTreeviewBehaviour: # Try to delete parent (has children) with pytest.raises(ValueError, match="Cannot delete node.*with children"): - tree_view._delete_node(parent.id) + tree_view.handle_delete_node(parent.id) def test_i_cannot_add_sibling_to_root(self, root_instance): """Test that adding sibling to root node raises an error.""" @@ -243,7 +243,7 @@ class TestTreeviewBehaviour: # Try to select node that doesn't exist with pytest.raises(ValueError, match="Node.*does not exist"): - tree_view._select_node("nonexistent_id") + tree_view.handle_select_node("nonexistent_id") def test_add_node_prevents_duplicate_children(self, root_instance): """Test that add_node prevents adding duplicate child IDs.""" @@ -317,11 +317,11 @@ class TestTreeviewBehaviour: tree_view.add_node(child, parent_id=parent.id) # Select the child - tree_view._select_node(child.id) + tree_view.handle_select_node(child.id) assert tree_view._state.selected == child.id # Delete the selected child - tree_view._delete_node(child.id) + tree_view.handle_delete_node(child.id) # Selection should be cleared assert tree_view._state.selected is None @@ -340,7 +340,7 @@ class TestTreeviewBehaviour: assert parent.id in tree_view._state.opened # Delete the child (making parent a leaf) - tree_view._delete_node(child.id) + tree_view.handle_delete_node(child.id) # Now delete the parent (now a leaf node) # First remove it from root by creating a grandparent @@ -349,7 +349,7 @@ class TestTreeviewBehaviour: parent.parent = grandparent.id grandparent.children.append(parent.id) - tree_view._delete_node(parent.id) + tree_view.handle_delete_node(parent.id) # Parent should be removed from opened list assert parent.id not in tree_view._state.opened @@ -360,7 +360,7 @@ class TestTreeviewBehaviour: # Try to start rename on node that doesn't exist with pytest.raises(ValueError, match="Node.*does not exist"): - tree_view._start_rename("nonexistent_id") + tree_view.handle_start_rename("nonexistent_id") def test_i_cannot_save_rename_nonexistent_node(self, root_instance): """Test that saving rename for nonexistent node raises error.""" @@ -368,7 +368,7 @@ class TestTreeviewBehaviour: # Try to save rename for node that doesn't exist with pytest.raises(ValueError, match="Node.*does not exist"): - tree_view._save_rename("nonexistent_id", "New Name") + tree_view.handle_save_rename("nonexistent_id", "New Name") def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance): """Test that adding sibling to nonexistent node raises error.""" @@ -597,11 +597,11 @@ class TestTreeviewBehaviour: tree_view.add_node(node2) # Start editing node1 - tree_view._start_rename(node1.id) + tree_view.handle_start_rename(node1.id) assert tree_view._state.editing == node1.id # Select node2 - tree_view._select_node(node2.id) + tree_view.handle_select_node(node2.id) # Edit mode should be cancelled assert tree_view._state.editing is None @@ -615,11 +615,11 @@ class TestTreeviewBehaviour: tree_view.add_node(node) # Start editing the node - tree_view._start_rename(node.id) + tree_view.handle_start_rename(node.id) assert tree_view._state.editing == node.id # Select the same node - tree_view._select_node(node.id) + tree_view.handle_select_node(node.id) # Edit mode should be cancelled assert tree_view._state.editing is None @@ -784,7 +784,7 @@ class TestTreeViewRender: """ node = TreeNode(label="Selected Node", type="file") tree_view.add_node(node) - tree_view._select_node(node.id) + tree_view.handle_select_node(node.id) rendered = tree_view.render() selected_container = find_one(rendered, Div(data_node_id=node.id)) @@ -814,7 +814,7 @@ class TestTreeViewRender: """ node = TreeNode(label="Edit Me", type="file") tree_view.add_node(node) - tree_view._start_rename(node.id) + tree_view.handle_start_rename(node.id) rendered = tree_view.render() editing_container = find_one(rendered, Div(data_node_id=node.id)) @@ -1009,7 +1009,7 @@ class TestTreeViewRender: """ node = TreeNode(label="Edit Me", type="file") tree_view.add_node(node) - tree_view._start_rename(node.id) + tree_view.handle_start_rename(node.id) # Step 1: Extract the input element rendered = tree_view.render()