feat(nakui-ui): atajo Esc para cancelar el modal de delete
`capture_key_down` en el root div: si event.keystroke.key=="escape" y hay pending_delete, lo limpia y emite toast "delete cancelado (X) [esc]". Capture phase (no bubble) intercepta el Esc antes de que cualquier TextInput descendiente lo consuma. Sin pending el handler es no-op, el evento sigue su flujo. Hint visual en el banner: subtítulo amber tenue "Esc para cancelar · click [Confirmar] para borrar" para que el usuario descubra el atajo sin RTFM. 35 tests verdes. El handler son 8 líneas no-testeables sin GPUI cx; type-check garantiza wireup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,34 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### feat(nakui-ui): atajo Esc para cancelar el modal de delete
|
||||||
|
Cierra otro pendiente de UX. El banner de confirmación de delete
|
||||||
|
ya tenía botones [Cancelar] / [Confirmar], pero la acción más
|
||||||
|
natural para cancelar un dialog es Esc — y no la teníamos wireada.
|
||||||
|
|
||||||
|
Cambios:
|
||||||
|
- **`capture_key_down` en el root div** del `MetaUi::render`. Capture
|
||||||
|
phase (no bubble) para interceptar el Esc *antes* que cualquier
|
||||||
|
TextInput descendiente lo consuma. Sin pending el handler es
|
||||||
|
no-op y el evento sigue su flujo normal.
|
||||||
|
- **Match `event.keystroke.key == "escape"`** + `pending_delete.take()`
|
||||||
|
→ toast `"delete cancelado (Entity) [esc]"` (sufijo `[esc]` para
|
||||||
|
diferenciar visualmente del botón). Si no hay pending, return
|
||||||
|
temprano sin tocar nada.
|
||||||
|
- **Hint visual en el banner**: subtítulo en amber tenue debajo del
|
||||||
|
título: `"Esc para cancelar · click [Confirmar] para borrar"`.
|
||||||
|
Que el usuario descubra el atajo sin RTFM.
|
||||||
|
|
||||||
|
35 tests verdes — el handler de Esc es 8 líneas no-testeables sin
|
||||||
|
GPUI cx (la lógica de pending_delete + toast vive dentro del
|
||||||
|
listener); el wireup compila por type-check.
|
||||||
|
|
||||||
|
Pendientes restantes:
|
||||||
|
- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
|
||||||
|
- **Snapshot durante runtime** (cada N writes, no sólo al startup).
|
||||||
|
- **Validación cross-field** (UUID del EntityRef existe en la
|
||||||
|
entity referida).
|
||||||
|
|
||||||
### feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)
|
### feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)
|
||||||
Cierra otro pendiente: `parse_field_value(FieldKind::EntityRef, raw)`
|
Cierra otro pendiente: `parse_field_value(FieldKind::EntityRef, raw)`
|
||||||
devolvía `Ok(json!(raw))` blindly — el value entraba al log/store
|
devolvía `Ok(json!(raw))` blindly — el value entraba al log/store
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
|
div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
|
||||||
Render, SharedString, Window, WindowBounds, WindowOptions,
|
KeyDownEvent, Render, SharedString, Window, WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
use nakui_core::delta::{FieldOp, FieldPath};
|
use nakui_core::delta::{FieldOp, FieldPath};
|
||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{
|
||||||
@@ -1037,6 +1037,21 @@ impl Render for MetaUi {
|
|||||||
.flex_col()
|
.flex_col()
|
||||||
.size_full()
|
.size_full()
|
||||||
.bg(bg)
|
.bg(bg)
|
||||||
|
// Capture phase: el Esc llega al root ANTES que cualquier
|
||||||
|
// TextInput descendiente. Si hay un delete pendiente, lo
|
||||||
|
// cancelamos. Sin pending no hacemos nada (el evento sigue
|
||||||
|
// su flujo normal y el TextInput recibe el Esc bubble).
|
||||||
|
.capture_key_down(cx.listener(|this, event: &KeyDownEvent, _w, cx| {
|
||||||
|
if event.keystroke.key != "escape" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some((entity, _id)) = this.pending_delete.take() {
|
||||||
|
this.toast = Some(SharedString::from(format!(
|
||||||
|
"delete cancelado ({entity}) [esc]"
|
||||||
|
)));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}))
|
||||||
.when_some(error_banner, |d, b| d.child(b))
|
.when_some(error_banner, |d, b| d.child(b))
|
||||||
.when_some(confirm_banner, |d, b| d.child(b))
|
.when_some(confirm_banner, |d, b| d.child(b))
|
||||||
.child(
|
.child(
|
||||||
@@ -1089,7 +1104,15 @@ impl MetaUi {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_grow()
|
.flex_grow()
|
||||||
.child(format!("¿Borrar {entity_owned} {id_short}?")),
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(format!("¿Borrar {entity_owned} {id_short}?"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(10.))
|
||||||
|
.text_color(gpui::rgb(0xc0a070))
|
||||||
|
.child("Esc para cancelar · click [Confirmar] para borrar"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
Reference in New Issue
Block a user