diff --git a/examples/09-reference/all_events.rs b/examples/09-reference/all_events.rs index 7867e2ead3..b09b2526f5 100644 --- a/examples/09-reference/all_events.rs +++ b/examples/09-reference/all_events.rs @@ -72,6 +72,16 @@ fn app() -> Element { onselectionchange: move |event: Event| log_event(Rc::new(event.data().selection())), "Selection events also include textarea ranges and direction.", } + label { + r#for: "beforeinput-input", + "Type here to inspect beforeinput (input_type, data, pre-change value)" + } + input { + id: "beforeinput-input", + style: "font: inherit; padding: 8px 10px;", + onbeforeinput: move |event| log_event(event.data()), + oninput: move |event| log_event(event.data()), + } } div { style: "text-align: center; padding: 20px; font-family: sans-serif; overflow: auto; height: 400px;", diff --git a/packages/core-types/src/bubbles.rs b/packages/core-types/src/bubbles.rs index d3c70235d9..8e41e8111a 100644 --- a/packages/core-types/src/bubbles.rs +++ b/packages/core-types/src/bubbles.rs @@ -26,6 +26,7 @@ pub fn event_bubbles(evt: &str) -> bool { "blur" => false, "change" => true, "input" => true, + "beforeinput" => true, "invalid" => true, "reset" => true, "submit" => true, diff --git a/packages/desktop/headless_tests/events.rs b/packages/desktop/headless_tests/events.rs index 27396a2327..0e26390057 100644 --- a/packages/desktop/headless_tests/events.rs +++ b/packages/desktop/headless_tests/events.rs @@ -48,6 +48,9 @@ fn app() -> Element { test_form_submit {} test_select_multiple_options {} test_unicode {} + test_before_input {} + test_before_input_composing {} + test_before_input_contenteditable {} } } } @@ -629,3 +632,91 @@ fn test_unicode() -> Element { } } } + +fn test_before_input() -> Element { + // Set the element's value *before* dispatching so the handler observes the + // pre-change value (semantically what `beforeinput` exposes). + utils::mock_event_with_extra( + "before_input", + r#"new InputEvent("beforeinput", { + inputType: 'insertText', + data: 'x', + bubbles: true, + cancelable: true, + isComposing: false, + })"#, + r#" + element.value = "hello"; + "#, + ); + + rsx! { + input { + id: "before_input", + onbeforeinput: move |event| { + println!("{:?}", event.data); + assert_eq!(event.data.input_type(), InputType::InsertText); + assert_eq!(event.data.data().as_deref(), Some("x")); + assert!(!event.data.is_composing()); + assert_eq!(event.data.value(), "hello"); + RECEIVED_EVENTS.with_mut(|x| *x += 1); + } + } + } +} + +fn test_before_input_composing() -> Element { + utils::mock_event( + "before_input_composing", + r#"new InputEvent("beforeinput", { + inputType: 'insertCompositionText', + data: 'あ', + bubbles: true, + cancelable: true, + isComposing: true, + })"#, + ); + + rsx! { + input { + id: "before_input_composing", + onbeforeinput: move |event| { + assert_eq!(event.data.input_type(), InputType::InsertCompositionText); + assert_eq!(event.data.data().as_deref(), Some("あ")); + assert!(event.data.is_composing()); + RECEIVED_EVENTS.with_mut(|x| *x += 1); + } + } + } +} + +fn test_before_input_contenteditable() -> Element { + // Contenteditable elements have no `value` property; the JS serializer should + // fall back to `textContent` so desktop matches the wasm renderer. + utils::mock_event_with_extra( + "before_input_contenteditable", + r#"new InputEvent("beforeinput", { + inputType: 'insertText', + data: 'z', + bubbles: true, + cancelable: true, + isComposing: false, + })"#, + r#" + element.textContent = "draft"; + "#, + ); + + rsx! { + div { + id: "before_input_contenteditable", + contenteditable: "true", + onbeforeinput: move |event| { + assert_eq!(event.data.input_type(), InputType::InsertText); + assert_eq!(event.data.data().as_deref(), Some("z")); + assert_eq!(event.data.value(), "draft"); + RECEIVED_EVENTS.with_mut(|x| *x += 1); + } + } + } +} diff --git a/packages/desktop/src/events.rs b/packages/desktop/src/events.rs index f5a2f20f25..12a7cd1e2b 100644 --- a/packages/desktop/src/events.rs +++ b/packages/desktop/src/events.rs @@ -38,6 +38,14 @@ impl HtmlEventConverter for SerializedHtmlEventConverter { .into() } + fn convert_before_input_data(&self, event: &PlatformEventData) -> BeforeInputData { + event + .downcast::() + .cloned() + .unwrap() + .into() + } + fn convert_cancel_data(&self, event: &PlatformEventData) -> CancelData { event .downcast::() diff --git a/packages/html/src/events/before_input.rs b/packages/html/src/events/before_input.rs new file mode 100644 index 0000000000..b072de7334 --- /dev/null +++ b/packages/html/src/events/before_input.rs @@ -0,0 +1,426 @@ +use dioxus_core::Event; +use std::fmt::{self, Debug, Display}; + +pub type BeforeInputEvent = Event; + +/// Define a string-backed enum from a single source-of-truth list that maps each variant +/// to its canonical DOM string. The two conversion directions — `as_str` (variant → string) +/// and `From<&str>` (string → variant) — are generated from that one list, so they can never +/// drift out of sync: a new value only has to be added in one place. The trailing +/// `_ => Variant(String)` arm declares a catch-all variant whose owned field preserves any +/// value not covered by the known variants. +macro_rules! string_enum { + ( + $(#[$enum_meta:meta])* + $vis:vis enum $name:ident { + $( + $variant:ident => $value:literal, + )* + $(#[$unknown_meta:meta])* + _ => $unknown:ident($unknown_ty:ty), + } + ) => { + $(#[$enum_meta])* + $vis enum $name { + $( + $variant, + )* + $(#[$unknown_meta])* + $unknown($unknown_ty), + } + + impl $name { + /// The canonical `inputType` string for this variant, exactly as emitted by the + /// DOM. For [`InputType::Unknown`] this returns the original value. + pub fn as_str(&self) -> &str { + match self { + $( Self::$variant => $value, )* + Self::$unknown(value) => value, + } + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + match value { + $( $value => Self::$variant, )* + other => Self::$unknown(other.to_string()), + } + } + } + }; +} + +string_enum! { + /// The kind of mutation that is about to be applied to an editable element. + /// + /// These map to the `inputType` attribute of the underlying DOM `InputEvent`. The + /// full list of values is defined in the W3C Input Events spec at + /// . Any value + /// not covered by a known variant (e.g. one introduced by a newer user agent) is + /// preserved verbatim in [`InputType::Unknown`]. + #[derive(Debug, Clone, PartialEq, Eq, Hash)] + #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serialize", serde(from = "String", into = "String"))] + pub enum InputType { + InsertText => "insertText", + InsertReplacementText => "insertReplacementText", + InsertLineBreak => "insertLineBreak", + InsertParagraph => "insertParagraph", + InsertOrderedList => "insertOrderedList", + InsertUnorderedList => "insertUnorderedList", + InsertHorizontalRule => "insertHorizontalRule", + InsertFromYank => "insertFromYank", + InsertFromDrop => "insertFromDrop", + InsertFromPaste => "insertFromPaste", + InsertFromPasteAsQuotation => "insertFromPasteAsQuotation", + InsertTranspose => "insertTranspose", + InsertCompositionText => "insertCompositionText", + InsertLink => "insertLink", + DeleteWordBackward => "deleteWordBackward", + DeleteWordForward => "deleteWordForward", + DeleteSoftLineBackward => "deleteSoftLineBackward", + DeleteSoftLineForward => "deleteSoftLineForward", + DeleteEntireSoftLine => "deleteEntireSoftLine", + DeleteHardLineBackward => "deleteHardLineBackward", + DeleteHardLineForward => "deleteHardLineForward", + DeleteByDrag => "deleteByDrag", + DeleteByCut => "deleteByCut", + DeleteContent => "deleteContent", + DeleteContentBackward => "deleteContentBackward", + DeleteContentForward => "deleteContentForward", + HistoryUndo => "historyUndo", + HistoryRedo => "historyRedo", + FormatBold => "formatBold", + FormatItalic => "formatItalic", + FormatUnderline => "formatUnderline", + FormatStrikeThrough => "formatStrikeThrough", + FormatSuperscript => "formatSuperscript", + FormatSubscript => "formatSubscript", + FormatJustifyFull => "formatJustifyFull", + FormatJustifyCenter => "formatJustifyCenter", + FormatJustifyRight => "formatJustifyRight", + FormatJustifyLeft => "formatJustifyLeft", + FormatIndent => "formatIndent", + FormatOutdent => "formatOutdent", + FormatRemove => "formatRemove", + FormatSetBlockTextDirection => "formatSetBlockTextDirection", + FormatSetInlineTextDirection => "formatSetInlineTextDirection", + FormatBackColor => "formatBackColor", + FormatFontColor => "formatFontColor", + FormatFontName => "formatFontName", + /// An `inputType` value not covered by the known variants, preserved as-is. + _ => Unknown(String), + } +} + +impl From for InputType { + fn from(value: String) -> Self { + // Reuse the `&str` mapping; only `Unknown` needs to take ownership, and in + // that case the borrowed match already produced an owned copy. + InputType::from(value.as_str()) + } +} + +impl From for String { + fn from(value: InputType) -> Self { + match value { + InputType::Unknown(value) => value, + other => other.as_str().to_string(), + } + } +} + +impl Display for InputType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Data fired alongside the `beforeinput` event. +/// +/// The `beforeinput` event fires before an editable element (an ``, `