Keyboard.py : ajout de enabled dans add(), nouveau render() retournant (Script,

control_div), et méthodes mk_enable / mk_disable
  - keyboard.js : nouvelle signature add_keyboard_support(elementId, controlDivId,
  combinationsJson), fonction isCombinationEnabled(), vérification avant déclenchement
  - test_Keyboard.py : 8 tests couvrant les comportements et le rendu
This commit is contained in:
2026-03-14 23:29:18 +01:00
parent af83f4b6dc
commit f773fd1611
3 changed files with 212 additions and 8 deletions

View File

@@ -217,10 +217,11 @@
anyHasLongerSequence = true; anyHasLongerSequence = true;
} }
// Collect matches, respecting require_inside flag // Collect matches, respecting require_inside and enabled flags
if (hasMatch) { if (hasMatch) {
const requireInside = currentNode.config["require_inside"] === true; const requireInside = currentNode.config["require_inside"] === true;
if (!requireInside || isInside) { const enabled = isCombinationEnabled(data.controlDivId, currentNode.combinationStr);
if (enabled && (!requireInside || isInside)) {
currentMatches.push({ currentMatches.push({
elementId: elementId, elementId: elementId,
config: currentNode.config, config: currentNode.config,
@@ -328,12 +329,29 @@
} }
} }
/**
* Check if a combination is enabled via the control div
* @param {string} controlDivId - The ID of the keyboard control div
* @param {string} combinationStr - The combination string (e.g., "esc")
* @returns {boolean} - True if enabled (default: true if entry not found)
*/
function isCombinationEnabled(controlDivId, combinationStr) {
const controlDiv = document.getElementById(controlDivId);
if (!controlDiv) return true;
const entry = controlDiv.querySelector(`[data-combination="${combinationStr}"]`);
if (!entry) return true;
return entry.dataset.enabled !== 'false';
}
/** /**
* Add keyboard support to an element * Add keyboard support to an element
* @param {string} elementId - The ID of the element * @param {string} elementId - The ID of the element
* @param {string} controlDivId - The ID of the keyboard control div
* @param {string} combinationsJson - JSON string of combinations mapping * @param {string} combinationsJson - JSON string of combinations mapping
*/ */
window.add_keyboard_support = function (elementId, combinationsJson) { window.add_keyboard_support = function (elementId, controlDivId, combinationsJson) {
// Parse the combinations JSON // Parse the combinations JSON
const combinations = JSON.parse(combinationsJson); const combinations = JSON.parse(combinationsJson);
@@ -350,7 +368,8 @@
// Add to registry // Add to registry
KeyboardRegistry.elements.set(elementId, { KeyboardRegistry.elements.set(elementId, {
tree: tree, tree: tree,
element: element element: element,
controlDivId: controlDivId
}); });
// Attach global listener if not already attached // Attach global listener if not already attached

View File

@@ -1,9 +1,11 @@
import json import json
from fasthtml.components import Div
from fasthtml.xtend import Script from fasthtml.xtend import Script
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.utils import make_html_id
class Keyboard(MultipleInstance): class Keyboard(MultipleInstance):
@@ -16,18 +18,38 @@ class Keyboard(MultipleInstance):
def __init__(self, parent, combinations=None, _id=None): def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.combinations = combinations or {} self.combinations = combinations or {}
def add(self, sequence: str, command: Command, require_inside: bool = True): def add(self, sequence: str, command: Command, require_inside: bool = True, enabled: bool = True):
self.combinations[sequence] = {"command": command, "require_inside": require_inside} self.combinations[sequence] = {"command": command, "require_inside": require_inside, "enabled": enabled}
return self return self
def render(self): def render(self):
str_combinations = {} str_combinations = {}
control_children = []
for sequence, value in self.combinations.items(): for sequence, value in self.combinations.items():
params = value["command"].get_htmx_params() params = value["command"].get_htmx_params()
params["require_inside"] = value.get("require_inside", True) params["require_inside"] = value.get("require_inside", True)
str_combinations[sequence] = params str_combinations[sequence] = params
return Script(f"add_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')") control_children.append(
Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
data_combination=sequence,
data_enabled="true" if value.get("enabled", True) else "false")
)
script = Script(f"add_keyboard_support('{self._parent.get_id()}', '{self.get_id()}', '{json.dumps(str_combinations)}')")
control_div = Div(*control_children, id=self.get_id(), name="keyboard")
return script, control_div
def mk_enable(self, sequence: str):
return Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
data_combination=sequence,
data_enabled="true",
hx_swap_oob="true")
def mk_disable(self, sequence: str):
return Div(id=f"{self.get_id()}-{make_html_id(sequence)}",
data_combination=sequence,
data_enabled="false",
hx_swap_oob="true")
def __ft__(self): def __ft__(self):
return self.render() return self.render()

View File

@@ -0,0 +1,163 @@
"""Unit tests for the Keyboard control."""
import pytest
from fasthtml.components import Div
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import make_html_id
from myfasthtml.test.matcher import matches, find, find_one, AnyValue, TestScript
from .conftest import root_instance
@pytest.fixture
def cmd():
return Command("test_keyboard_cmd", "Test keyboard command", None, lambda: None)
class TestKeyboardBehaviour:
"""Tests for Keyboard behavior and logic."""
def test_i_can_add_combination_with_default_enabled(self, root_instance, cmd):
"""Test that enabled defaults to True when not specified in add().
Why this matters:
- All combinations should be active by default without requiring explicit opt-in.
"""
kb = Keyboard(root_instance)
kb.add("esc", cmd)
assert kb.combinations["esc"]["enabled"] is True
def test_i_can_add_combination_with_enabled_false(self, root_instance, cmd):
"""Test that enabled=False is correctly stored in the combination definition.
Why this matters:
- Combinations can be declared inactive at init time, which controls the
initial data-enabled value in the rendered DOM.
"""
kb = Keyboard(root_instance)
kb.add("esc", cmd, enabled=False)
assert kb.combinations["esc"]["enabled"] is False
class TestKeyboardRender:
"""Tests for Keyboard HTML rendering."""
@pytest.fixture
def keyboard(self, root_instance):
return Keyboard(root_instance)
def test_keyboard_layout_is_rendered(self, keyboard, cmd):
"""Test that render() returns a tuple of (Script, Div).
Why these elements matter:
- tuple length 2: render() must produce exactly a script and a control div
- script tag: the JavaScript call that registers keyboard shortcuts
- div tag: the DOM control div used by JS to check enabled state at runtime
"""
keyboard.add("esc", cmd)
result = keyboard.render()
assert len(result) == 2
script, control_div = result
assert script.tag == "script"
assert control_div.tag == "div"
def test_i_can_render_script_with_control_div_id(self, keyboard, cmd):
"""Test that the rendered script includes controlDivId as the second argument.
Why this matters:
- The JS function add_keyboard_support() now requires 3 args: elementId,
controlDivId, combinationsJson. The controlDivId links the keyboard
registry entry to its DOM control div for enabled-state lookups.
"""
keyboard.add("esc", cmd)
script, _ = keyboard.render()
expected_prefix = (
f"add_keyboard_support('{keyboard._parent.get_id()}', '{keyboard.get_id()}', "
)
assert matches(script, TestScript(expected_prefix))
def test_i_can_render_control_div_attributes(self, keyboard, cmd):
"""Test that the control div has the correct id and name attributes.
Why these attributes matter:
- id=keyboard.get_id(): the JS uses this ID to look up enabled state
- name="keyboard": semantic marker for readability and DOM inspection
"""
keyboard.add("esc", cmd)
_, control_div = keyboard.render()
expected = Div(id=keyboard.get_id(), name="keyboard")
assert matches(control_div, expected)
def test_i_can_render_one_child_per_combination(self, keyboard, cmd):
"""Test that the control div contains exactly one child div per combination.
Why this matters:
- Each combination needs its own div so the JS can check its enabled
state independently via data-combination attribute lookup.
"""
keyboard.add("esc", cmd)
keyboard.add("ctrl+s", cmd)
_, control_div = keyboard.render()
children = find(control_div, Div(data_combination=AnyValue()))
assert len(children) == 2, "Should have one child div per registered combination"
@pytest.mark.parametrize("enabled, expected_value", [
(True, "true"),
(False, "false"),
])
def test_i_can_render_combination_enabled_state(self, keyboard, cmd, enabled, expected_value):
"""Test that data-enabled reflects the enabled flag passed to add().
Why this matters:
- The JS reads data-enabled at keypress time to decide whether to
trigger the combination. The rendered value must match the Python flag.
"""
keyboard.add("esc", cmd, enabled=enabled)
_, control_div = keyboard.render()
child = find_one(control_div, Div(data_combination="esc"))
assert matches(child, Div(data_enabled=expected_value))
def test_i_can_render_child_id_sanitizes_combination(self, keyboard, cmd):
"""Test that the child div id is derived from make_html_id on the combination string.
Why this matters:
- Combination strings like 'ctrl+s' contain characters invalid in HTML IDs.
make_html_id() sanitizes them ('+''-'), enabling targeted OOB swaps
via mk_enable/mk_disable without referencing the full control div.
"""
keyboard.add("ctrl+s", cmd)
_, control_div = keyboard.render()
expected_id = f"{keyboard.get_id()}-{make_html_id('ctrl+s')}"
children = find(control_div, Div(id=expected_id))
assert len(children) == 1, f"Expected exactly one child with id '{expected_id}'"
@pytest.mark.parametrize("method_name, expected_enabled", [
("mk_enable", "true"),
("mk_disable", "false"),
])
def test_i_can_mk_enable_and_disable(self, keyboard, method_name, expected_enabled):
"""Test that mk_enable/mk_disable return a correct OOB swap div.
Why these attributes matter:
- id: must match the child div id so HTMX replaces the right element
- data-combination: allows the JS to identify the combination
- data-enabled: the new state to apply
- hx-swap-oob: triggers the out-of-band swap without a full page update
"""
result = getattr(keyboard, method_name)("esc")
expected = Div(
id=f"{keyboard.get_id()}-{make_html_id('esc')}",
data_combination="esc",
data_enabled=expected_enabled,
hx_swap_oob="true"
)
assert matches(result, expected)