Implemented enable/disable for keyboard support
This commit is contained in:
@@ -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 `<div>` 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user