From 31fde9dfa28901c1bfe8859cb1c081cee0a3a9a8 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 20 Jun 2026 21:55:21 +0800 Subject: [PATCH 1/6] . --- crates/story/examples/autofill.rs | 131 +++++++++++ crates/story/src/stories/input_story.rs | 178 +++++++++++++- crates/ui/src/input/input.rs | 275 +++++++++++++++++++++- crates/ui/src/input/mod.rs | 2 + crates/ui/src/input/native.rs | 112 +++++++++ docs/docs/components/input.md | 1 + docs/zh-CN/docs/components/input.md | 1 + skills/gpui-component/references/usage.md | 1 + 8 files changed, 692 insertions(+), 9 deletions(-) create mode 100644 crates/story/examples/autofill.rs create mode 100644 crates/ui/src/input/native.rs diff --git a/crates/story/examples/autofill.rs b/crates/story/examples/autofill.rs new file mode 100644 index 0000000000..3c2dcf44d2 --- /dev/null +++ b/crates/story/examples/autofill.rs @@ -0,0 +1,131 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::*; +use gpui_component::{ + ActiveTheme as _, Root, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputContentType, InputState}, + label::Label, + v_flex, +}; +use gpui_component_assets::Assets; + +pub struct Example { + username: Entity, + password: Entity, + new_password: Entity, + one_time_code: Entity, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> Self { + Self { + username: cx.new(|cx| InputState::new(window, cx).placeholder("Username")), + password: cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Password") + .masked(true) + }), + new_password: cx.new(|cx| { + InputState::new(window, cx) + .placeholder("New password") + .masked(true) + }), + one_time_code: cx.new(|cx| InputState::new(window, cx).placeholder("123456")), + } + } + + fn field( + label: &'static str, + input: impl IntoElement, + action: Option, + ) -> impl IntoElement { + h_flex() + .w_full() + .items_center() + .gap_3() + .child(Label::new(label).w_32().flex_shrink_0()) + .child(input) + .when_some(action, |this, action| this.child(action)) + } +} + +impl Render for Example { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("autofill-example") + .size_full() + .items_center() + .justify_center() + .bg(cx.theme().background) + .child( + v_flex() + .w(px(520.)) + .max_w_full() + .gap_4() + .p_8() + .child(Self::field( + "Username", + Input::new(&self.username) + .content_type(InputContentType::Username) + .flex_1(), + None::, + )) + .child(Self::field( + "Password", + Input::new(&self.password) + .content_type(InputContentType::Password) + .mask_toggle() + .flex_1(), + Some( + Button::new("sign-in") + .primary() + .label("Sign in") + .into_any_element(), + ), + )) + .child(Self::field( + "New password", + Input::new(&self.new_password) + .content_type(InputContentType::NewPassword) + .mask_toggle() + .flex_1(), + None::, + )) + .child(Self::field( + "Code", + Input::new(&self.one_time_code) + .content_type(InputContentType::OneTimeCode) + .flex_1(), + None::, + )), + ) + } +} + +fn main() { + let app = gpui_platform::application().with_assets(Assets); + + app.run(move |cx| { + gpui_component::init(cx); + cx.activate(true); + + let window_options = WindowOptions { + window_bounds: Some(WindowBounds::centered(size(px(720.), px(520.)), cx)), + titlebar: Some(TitlebarOptions { + title: Some("AutoFill".into()), + ..Default::default() + }), + ..Default::default() + }; + + cx.spawn(async move |cx| { + cx.open_window(window_options, |window, cx| { + let view = cx.new(|cx| Example::new(window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }) + .expect("failed to open window"); + }) + .detach(); + }); +} diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index ebe9539147..8b630e307d 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -4,7 +4,7 @@ use gpui::{ }; use crate::section; -use gpui_component::{button::*, input::*, *}; +use gpui_component::{button::*, input::*, label::Label, *}; const CODE_EXAMPLE: &str = r#"{"single_line":"code editor"}"#; @@ -32,10 +32,18 @@ pub struct InputStory { custom_menu_input: Entity, code_input: Entity, color_input: Entity, + content_type_inputs: Vec, _subscriptions: Vec, } +struct ContentTypeInput { + label: &'static str, + content_type: InputContentType, + input: Entity, + mask_toggle: bool, +} + impl super::Story for InputStory { fn title() -> &'static str { "Input" @@ -139,6 +147,117 @@ impl InputStory { .default_value("Right Aligned Text") }); + let content_type_inputs = vec![ + Self::new_content_type_input( + window, + cx, + "Name", + InputContentType::Name, + "Full name", + "Jane Doe", + false, + ), + Self::new_content_type_input( + window, + cx, + "Username", + InputContentType::Username, + "Username", + "jane.doe", + false, + ), + Self::new_content_type_input( + window, + cx, + "Password", + InputContentType::Password, + "Current password", + "current-password", + true, + ), + Self::new_content_type_input( + window, + cx, + "New password", + InputContentType::NewPassword, + "New password", + "new-password", + true, + ), + Self::new_content_type_input( + window, + cx, + "One-time code", + InputContentType::OneTimeCode, + "123456", + "123456", + false, + ), + Self::new_content_type_input( + window, + cx, + "Email", + InputContentType::EmailAddress, + "Email address", + "jane.doe@example.com", + false, + ), + Self::new_content_type_input( + window, + cx, + "Telephone", + InputContentType::TelephoneNumber, + "Telephone number", + "+1 415 555 0198", + false, + ), + Self::new_content_type_input( + window, + cx, + "URL", + InputContentType::Url, + "Website URL", + "https://example.com", + false, + ), + Self::new_content_type_input( + window, + cx, + "Credit card number", + InputContentType::CreditCardNumber, + "Card number", + "4242 4242 4242 4242", + false, + ), + Self::new_content_type_input( + window, + cx, + "Credit card expiration", + InputContentType::CreditCardExpiration, + "MM/YY", + "12/28", + false, + ), + Self::new_content_type_input( + window, + cx, + "Credit card security code", + InputContentType::CreditCardSecurityCode, + "CVC", + "123", + true, + ), + Self::new_content_type_input( + window, + cx, + "Postal code", + InputContentType::PostalCode, + "Postal code", + "94107", + false, + ), + ]; + let _subscriptions = vec![ cx.subscribe_in(&input1, window, Self::on_input_event), cx.subscribe_in(&input2, window, Self::on_input_event), @@ -172,10 +291,60 @@ impl InputStory { color_input, input_text_centered, input_text_right, + content_type_inputs, _subscriptions, } } + fn new_content_type_input( + window: &mut Window, + cx: &mut Context, + label: &'static str, + content_type: InputContentType, + placeholder: &'static str, + default_value: &'static str, + masked: bool, + ) -> ContentTypeInput { + let input = cx.new(|cx| { + let state = InputState::new(window, cx) + .placeholder(placeholder) + .default_value(default_value); + + if masked { state.masked(true) } else { state } + }); + + ContentTypeInput { + label, + content_type, + input, + mask_toggle: masked, + } + } + + fn render_content_type_input(item: &ContentTypeInput) -> impl IntoElement { + let input = Input::new(&item.input) + .content_type(item.content_type) + .flex_1(); + let input = if item.mask_toggle { + input.mask_toggle() + } else { + input + }; + + h_flex() + .w_full() + .items_center() + .gap_3() + .child( + Label::new(item.label) + .w_48() + .flex_shrink_0() + .text_sm() + .whitespace_nowrap(), + ) + .child(input) + } + fn on_input_event( &mut self, state: &Entity, @@ -229,6 +398,13 @@ impl Render for InputStory { .child(Input::new(&self.disabled_input).disabled(true)) .child(Input::new(&self.mask_input).mask_toggle().cleanable(true)), ) + .child( + section("Content Type").max_w_lg().children( + self.content_type_inputs + .iter() + .map(Self::render_content_type_input), + ), + ) .child( section("Text Align").max_w_lg().child( h_flex() diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index c86c896b1d..8ea2be95d4 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -30,6 +30,155 @@ pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { } } +/// Semantic content type for an [`Input`]. +/// +/// These variants mirror Swift's text content types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputContentType { + /// A person's full name. + Name, + /// A name prefix, such as Mr. or Dr. + NamePrefix, + /// A person's given name. + GivenName, + /// A person's middle name. + MiddleName, + /// A person's family name. + FamilyName, + /// A name suffix, such as Jr. or PhD. + NameSuffix, + /// A nickname. + Nickname, + /// A job title. + JobTitle, + /// An organization or company name. + OrganizationName, + /// A location name. + Location, + /// A full street address. + FullStreetAddress, + /// The first line of a street address. + StreetAddressLine1, + /// The second line of a street address. + StreetAddressLine2, + /// A city or locality. + AddressCity, + /// A state, province, or region. + AddressState, + /// A combined city and state. + AddressCityAndState, + /// A sublocality, district, or neighborhood. + Sublocality, + /// A country name. + CountryName, + /// A postal or ZIP code. + PostalCode, + /// A telephone number. + TelephoneNumber, + /// An email address. + EmailAddress, + /// A URL. + Url, + /// A credit card number. + CreditCardNumber, + /// The full name on a credit card. + CreditCardName, + /// The given name on a credit card. + CreditCardGivenName, + /// The middle name on a credit card. + CreditCardMiddleName, + /// The family name on a credit card. + CreditCardFamilyName, + /// The security code on a credit card. + CreditCardSecurityCode, + /// A credit card expiration date. + CreditCardExpiration, + /// A credit card expiration month. + CreditCardExpirationMonth, + /// A credit card expiration year. + CreditCardExpirationYear, + /// A credit card type. + CreditCardType, + /// A username or account identifier. + Username, + /// The password for the account identified by the username field. + Password, + /// A new password, such as during sign up or password reset. + NewPassword, + /// A one-time verification code. + OneTimeCode, + /// A parcel shipment tracking number. + ShipmentTrackingNumber, + /// An airline flight number. + FlightNumber, + /// A date, time, or duration. + DateTime, + /// A birthdate. + Birthdate, + /// A birthdate day. + BirthdateDay, + /// A birthdate month. + BirthdateMonth, + /// A birthdate year. + BirthdateYear, + /// An eSIM EID. + CellularEid, + /// A cellular IMEI. + CellularImei, +} + +impl InputContentType { + #[cfg(target_os = "macos")] + pub(crate) const fn ns_text_content_type(self) -> Option<&'static str> { + match self { + Self::Name => Some("name"), + Self::NamePrefix => Some("honorific-prefix"), + Self::GivenName => Some("given-name"), + Self::MiddleName => Some("additional-name"), + Self::FamilyName => Some("family-name"), + Self::NameSuffix => Some("honorific-suffix"), + Self::Nickname => Some("nickname"), + Self::JobTitle => Some("organization-title"), + Self::OrganizationName => Some("organization"), + Self::Location => Some("location"), + Self::FullStreetAddress => Some("street-address"), + Self::StreetAddressLine1 => Some("address-line1"), + Self::StreetAddressLine2 => Some("address-line2"), + Self::AddressCity => Some("address-level2"), + Self::AddressState => Some("address-level1"), + Self::AddressCityAndState => Some("address-level1+2"), + Self::Sublocality => Some("address-level3"), + Self::CountryName => Some("country-name"), + Self::PostalCode => Some("postal-code"), + Self::TelephoneNumber => Some("tel"), + Self::EmailAddress => Some("email"), + Self::Url => Some("url"), + Self::CreditCardNumber => Some("cc-number"), + Self::CreditCardName => Some("cc-name"), + Self::CreditCardGivenName => Some("cc-given-name"), + Self::CreditCardMiddleName => Some("cc-additional-name"), + Self::CreditCardFamilyName => Some("cc-family-name"), + Self::CreditCardSecurityCode => Some("cc-csc"), + Self::CreditCardExpiration => Some("cc-exp"), + Self::CreditCardExpirationMonth => Some("cc-exp-month"), + Self::CreditCardExpirationYear => Some("cc-exp-year"), + Self::CreditCardType => Some("cc-type"), + Self::Username => Some("username"), + Self::Password => Some("password"), + Self::NewPassword => Some("new-password"), + Self::OneTimeCode => Some("one-time-code"), + Self::ShipmentTrackingNumber => Some("shipment-tracking-number"), + Self::FlightNumber => Some("flight-number"), + Self::DateTime => Some("date-time"), + Self::Birthdate => Some("bday"), + Self::BirthdateDay => Some("bday-day"), + Self::BirthdateMonth => Some("bday-month"), + Self::BirthdateYear => Some("bday-year"), + Self::CellularEid | Self::CellularImei => None, + } + } +} + /// A text input element bind to an [`InputState`]. #[derive(IntoElement)] pub struct Input { @@ -47,6 +196,7 @@ pub struct Input { focus_bordered: bool, tab_index: isize, selected: bool, + content_type: Option, /// An optional context menu builder to allow a custom context menu on the input. /// @@ -90,6 +240,7 @@ impl Input { focus_bordered: true, tab_index: 0, selected: false, + content_type: None, context_menu_builder: None, } } @@ -146,6 +297,15 @@ impl Input { self } + /// Set the semantic content type for password managers and autofill. + /// + /// This is a component-level semantic hint. It does not change the text + /// value or masked rendering state. + pub fn content_type(mut self, content_type: InputContentType) -> Self { + self.content_type = Some(content_type); + self + } + /// Set to disable the input field. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; @@ -258,7 +418,14 @@ impl RenderOnce for Input { }); let state = self.state.read(cx); + let content_type = self.content_type; + let disabled = self.disabled; let focused = state.focus_handle.is_focused(window) && !state.disabled; + #[cfg(target_os = "macos")] + if focused { + super::native::set_text_content_type(window, content_type); + } + let gap_x = match self.size { Size::Small => px(4.), Size::Large => px(8.), @@ -354,14 +521,28 @@ impl RenderOnce for Input { .on_action(window.listener_for(&self.state, InputState::copy)) .on_action(window.listener_for(&self.state, InputState::on_action_search)) .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&self.state, InputState::on_mouse_down), - ) - .on_mouse_down( - MouseButton::Right, - window.listener_for(&self.state, InputState::on_mouse_down), - ) + .on_mouse_down(MouseButton::Left, { + let state = self.state.clone(); + move |event, window, cx| { + #[cfg(target_os = "macos")] + if !disabled { + super::native::set_text_content_type(window, content_type); + } + + state.update(cx, |state, cx| state.on_mouse_down(event, window, cx)); + } + }) + .on_mouse_down(MouseButton::Right, { + let state = self.state.clone(); + move |event, window, cx| { + #[cfg(target_os = "macos")] + if !disabled { + super::native::set_text_content_type(window, content_type); + } + + state.update(cx, |state, cx| state.on_mouse_down(event, window, cx)); + } + }) .on_mouse_up( MouseButton::Left, window.listener_for(&self.state, InputState::on_mouse_up), @@ -440,3 +621,81 @@ impl RenderOnce for Input { }) } } + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn content_type_maps_to_ns_text_content_type_values() { + let content_types = [ + (InputContentType::Name, Some("name")), + (InputContentType::NamePrefix, Some("honorific-prefix")), + (InputContentType::GivenName, Some("given-name")), + (InputContentType::MiddleName, Some("additional-name")), + (InputContentType::FamilyName, Some("family-name")), + (InputContentType::NameSuffix, Some("honorific-suffix")), + (InputContentType::Nickname, Some("nickname")), + (InputContentType::JobTitle, Some("organization-title")), + (InputContentType::OrganizationName, Some("organization")), + (InputContentType::Location, Some("location")), + (InputContentType::FullStreetAddress, Some("street-address")), + (InputContentType::StreetAddressLine1, Some("address-line1")), + (InputContentType::StreetAddressLine2, Some("address-line2")), + (InputContentType::AddressCity, Some("address-level2")), + (InputContentType::AddressState, Some("address-level1")), + ( + InputContentType::AddressCityAndState, + Some("address-level1+2"), + ), + (InputContentType::Sublocality, Some("address-level3")), + (InputContentType::CountryName, Some("country-name")), + (InputContentType::PostalCode, Some("postal-code")), + (InputContentType::TelephoneNumber, Some("tel")), + (InputContentType::EmailAddress, Some("email")), + (InputContentType::Url, Some("url")), + (InputContentType::CreditCardNumber, Some("cc-number")), + (InputContentType::CreditCardName, Some("cc-name")), + (InputContentType::CreditCardGivenName, Some("cc-given-name")), + ( + InputContentType::CreditCardMiddleName, + Some("cc-additional-name"), + ), + ( + InputContentType::CreditCardFamilyName, + Some("cc-family-name"), + ), + (InputContentType::CreditCardSecurityCode, Some("cc-csc")), + (InputContentType::CreditCardExpiration, Some("cc-exp")), + ( + InputContentType::CreditCardExpirationMonth, + Some("cc-exp-month"), + ), + ( + InputContentType::CreditCardExpirationYear, + Some("cc-exp-year"), + ), + (InputContentType::CreditCardType, Some("cc-type")), + (InputContentType::Username, Some("username")), + (InputContentType::Password, Some("password")), + (InputContentType::NewPassword, Some("new-password")), + (InputContentType::OneTimeCode, Some("one-time-code")), + ( + InputContentType::ShipmentTrackingNumber, + Some("shipment-tracking-number"), + ), + (InputContentType::FlightNumber, Some("flight-number")), + (InputContentType::DateTime, Some("date-time")), + (InputContentType::Birthdate, Some("bday")), + (InputContentType::BirthdateDay, Some("bday-day")), + (InputContentType::BirthdateMonth, Some("bday-month")), + (InputContentType::BirthdateYear, Some("bday-year")), + (InputContentType::CellularEid, None), + (InputContentType::CellularImei, None), + ]; + + for (content_type, native_value) in content_types { + assert_eq!(content_type.ns_text_content_type(), native_value); + } + } +} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index f0489b7094..84dd30b233 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -13,6 +13,8 @@ mod lsp; mod mask_pattern; mod mode; mod movement; +#[cfg(target_os = "macos")] +mod native; mod number_input; mod otp_input; pub(crate) mod popovers; diff --git a/crates/ui/src/input/native.rs b/crates/ui/src/input/native.rs new file mode 100644 index 0000000000..6839ee4d6c --- /dev/null +++ b/crates/ui/src/input/native.rs @@ -0,0 +1,112 @@ +#[cfg(target_os = "macos")] +mod macos { + use std::{cell::RefCell, collections::HashMap, mem, ptr, sync::Once}; + + use gpui::Window; + use objc2::{ + ffi, msg_send, + rc::Retained, + runtime::{AnyObject, AnyProtocol, Imp, Sel}, + sel, + }; + use objc2_foundation::NSString; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + use crate::input::InputContentType; + + static INSTALL_TEXT_CONTENT: Once = Once::new(); + + thread_local! { + static CONTENT_TYPES: RefCell>> = + RefCell::new(HashMap::new()); + } + + pub(crate) fn set_text_content_type(window: &Window, content_type: Option) { + let Some(view) = ns_view(window) else { + return; + }; + + INSTALL_TEXT_CONTENT.call_once(|| install_text_content(view)); + if view + .class() + .instance_method(sel!(setContentType:)) + .is_none() + { + return; + } + + let ns_content_type = content_type + .and_then(InputContentType::ns_text_content_type) + .map(NSString::from_str); + let ns_content_type = ns_content_type.as_ref().map_or(ptr::null_mut(), |value| { + Retained::as_ptr(value).cast_mut().cast::() + }); + + unsafe { + let _: () = msg_send![view, setContentType: ns_content_type]; + } + } + + fn ns_view(window: &Window) -> Option<&AnyObject> { + let handle = HasWindowHandle::window_handle(window).ok()?; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return None; + }; + + Some(unsafe { &*(handle.ns_view.as_ptr() as *const AnyObject) }) + } + + fn install_text_content(view: &AnyObject) { + let class = view.class(); + let class = class as *const _ as *mut _; + + unsafe { + let protocol = AnyProtocol::get(c"NSTextContent"); + if let Some(protocol) = protocol { + ffi::class_addProtocol(class, protocol); + } + + let content_type_imp: Imp = mem::transmute( + content_type as unsafe extern "C-unwind" fn(&AnyObject, Sel) -> *mut AnyObject, + ); + let set_content_type_imp: Imp = mem::transmute( + set_content_type as unsafe extern "C-unwind" fn(&AnyObject, Sel, *mut AnyObject), + ); + + ffi::class_addMethod(class, sel!(contentType), content_type_imp, c"@@:".as_ptr()); + ffi::class_addMethod( + class, + sel!(setContentType:), + set_content_type_imp, + c"v@:@".as_ptr(), + ); + } + } + + unsafe extern "C-unwind" fn content_type(this: &AnyObject, _: Sel) -> *mut AnyObject { + let key = this as *const _ as usize; + CONTENT_TYPES.with(|content_types| { + content_types + .borrow() + .get(&key) + .map_or(ptr::null_mut(), |value| { + Retained::as_ptr(value).cast_mut().cast::() + }) + }) + } + + unsafe extern "C-unwind" fn set_content_type(this: &AnyObject, _: Sel, value: *mut AnyObject) { + let key = this as *const _ as usize; + CONTENT_TYPES.with(|content_types| { + let mut content_types = content_types.borrow_mut(); + if value.is_null() { + content_types.remove(&key); + } else if let Some(value) = unsafe { Retained::retain(value.cast::()) } { + content_types.insert(key, value); + } + }); + } +} + +#[cfg(target_os = "macos")] +pub(crate) use macos::set_text_content_type; diff --git a/docs/docs/components/input.md b/docs/docs/components/input.md index d332d5a837..02522d0247 100644 --- a/docs/docs/components/input.md +++ b/docs/docs/components/input.md @@ -86,6 +86,7 @@ let input = cx.new(|cx| ); Input::new(&input) + .content_type(InputContentType::Password) .mask_toggle() // Shows toggle button to reveal password ``` diff --git a/docs/zh-CN/docs/components/input.md b/docs/zh-CN/docs/components/input.md index 190979a66c..68ecf9875b 100644 --- a/docs/zh-CN/docs/components/input.md +++ b/docs/zh-CN/docs/components/input.md @@ -83,6 +83,7 @@ let input = cx.new(|cx| ); Input::new(&input) + .content_type(InputContentType::Password) .mask_toggle() ``` diff --git a/skills/gpui-component/references/usage.md b/skills/gpui-component/references/usage.md index b13c6f9d61..84b8a9cd8e 100644 --- a/skills/gpui-component/references/usage.md +++ b/skills/gpui-component/references/usage.md @@ -135,6 +135,7 @@ Input::new(&input).cleanable(true) // clear button Input::new(&input).disabled(true) Input::new(&input).prefix(Icon::new(IconName::Search).small()) Input::new(&input).suffix(Button::new("b").ghost().icon(IconName::X).xsmall()) +Input::new(&input).content_type(InputContentType::Password) Input::new(&input).mask_toggle() // password reveal toggle Input::new(&input).appearance(false) // remove default border/bg From 90333443173c81e2a646abf6d41e11de89318a9c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 20 Jun 2026 22:18:51 +0800 Subject: [PATCH 2/6] . --- crates/ui/src/input/content_type.rs | 244 ++++++++++++++++++++++++ crates/ui/src/input/input.rs | 279 +++------------------------- crates/ui/src/input/mod.rs | 2 + 3 files changed, 271 insertions(+), 254 deletions(-) create mode 100644 crates/ui/src/input/content_type.rs diff --git a/crates/ui/src/input/content_type.rs b/crates/ui/src/input/content_type.rs new file mode 100644 index 0000000000..3f970b7214 --- /dev/null +++ b/crates/ui/src/input/content_type.rs @@ -0,0 +1,244 @@ +use gpui::Window; + +/// Semantic content type for an [`Input`](super::Input). +/// +/// These variants mirror Swift's text content types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputContentType { + /// A person's full name. + Name, + /// A name prefix, such as Mr. or Dr. + NamePrefix, + /// A person's given name. + GivenName, + /// A person's middle name. + MiddleName, + /// A person's family name. + FamilyName, + /// A name suffix, such as Jr. or PhD. + NameSuffix, + /// A nickname. + Nickname, + /// A job title. + JobTitle, + /// An organization or company name. + OrganizationName, + /// A location name. + Location, + /// A full street address. + FullStreetAddress, + /// The first line of a street address. + StreetAddressLine1, + /// The second line of a street address. + StreetAddressLine2, + /// A city or locality. + AddressCity, + /// A state, province, or region. + AddressState, + /// A combined city and state. + AddressCityAndState, + /// A sublocality, district, or neighborhood. + Sublocality, + /// A country name. + CountryName, + /// A postal or ZIP code. + PostalCode, + /// A telephone number. + TelephoneNumber, + /// An email address. + EmailAddress, + /// A URL. + Url, + /// A credit card number. + CreditCardNumber, + /// The full name on a credit card. + CreditCardName, + /// The given name on a credit card. + CreditCardGivenName, + /// The middle name on a credit card. + CreditCardMiddleName, + /// The family name on a credit card. + CreditCardFamilyName, + /// The security code on a credit card. + CreditCardSecurityCode, + /// A credit card expiration date. + CreditCardExpiration, + /// A credit card expiration month. + CreditCardExpirationMonth, + /// A credit card expiration year. + CreditCardExpirationYear, + /// A credit card type. + CreditCardType, + /// A username or account identifier. + Username, + /// The password for the account identified by the username field. + Password, + /// A new password, such as during sign up or password reset. + NewPassword, + /// A one-time verification code. + OneTimeCode, + /// A parcel shipment tracking number. + ShipmentTrackingNumber, + /// An airline flight number. + FlightNumber, + /// A date, time, or duration. + DateTime, + /// A birthdate. + Birthdate, + /// A birthdate day. + BirthdateDay, + /// A birthdate month. + BirthdateMonth, + /// A birthdate year. + BirthdateYear, + /// An eSIM EID. + CellularEid, + /// A cellular IMEI. + CellularImei, +} + +impl InputContentType { + #[cfg(target_os = "macos")] + pub(crate) const fn ns_text_content_type(self) -> Option<&'static str> { + match self { + Self::Name => Some("name"), + Self::NamePrefix => Some("honorific-prefix"), + Self::GivenName => Some("given-name"), + Self::MiddleName => Some("additional-name"), + Self::FamilyName => Some("family-name"), + Self::NameSuffix => Some("honorific-suffix"), + Self::Nickname => Some("nickname"), + Self::JobTitle => Some("organization-title"), + Self::OrganizationName => Some("organization"), + Self::Location => Some("location"), + Self::FullStreetAddress => Some("street-address"), + Self::StreetAddressLine1 => Some("address-line1"), + Self::StreetAddressLine2 => Some("address-line2"), + Self::AddressCity => Some("address-level2"), + Self::AddressState => Some("address-level1"), + Self::AddressCityAndState => Some("address-level1+2"), + Self::Sublocality => Some("address-level3"), + Self::CountryName => Some("country-name"), + Self::PostalCode => Some("postal-code"), + Self::TelephoneNumber => Some("tel"), + Self::EmailAddress => Some("email"), + Self::Url => Some("url"), + Self::CreditCardNumber => Some("cc-number"), + Self::CreditCardName => Some("cc-name"), + Self::CreditCardGivenName => Some("cc-given-name"), + Self::CreditCardMiddleName => Some("cc-additional-name"), + Self::CreditCardFamilyName => Some("cc-family-name"), + Self::CreditCardSecurityCode => Some("cc-csc"), + Self::CreditCardExpiration => Some("cc-exp"), + Self::CreditCardExpirationMonth => Some("cc-exp-month"), + Self::CreditCardExpirationYear => Some("cc-exp-year"), + Self::CreditCardType => Some("cc-type"), + Self::Username => Some("username"), + Self::Password => Some("password"), + Self::NewPassword => Some("new-password"), + Self::OneTimeCode => Some("one-time-code"), + Self::ShipmentTrackingNumber => Some("shipment-tracking-number"), + Self::FlightNumber => Some("flight-number"), + Self::DateTime => Some("date-time"), + Self::Birthdate => Some("bday"), + Self::BirthdateDay => Some("bday-day"), + Self::BirthdateMonth => Some("bday-month"), + Self::BirthdateYear => Some("bday-year"), + Self::CellularEid | Self::CellularImei => None, + } + } +} + +pub(super) fn sync_native_content_type( + window: &mut Window, + content_type: Option, + disabled: bool, +) { + if disabled { + return; + } + + #[cfg(target_os = "macos")] + super::native::set_text_content_type(window, content_type); + + #[cfg(not(target_os = "macos"))] + let _ = (window, content_type); +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + #[test] + fn content_type_maps_to_ns_text_content_type_values() { + let content_types = [ + (InputContentType::Name, Some("name")), + (InputContentType::NamePrefix, Some("honorific-prefix")), + (InputContentType::GivenName, Some("given-name")), + (InputContentType::MiddleName, Some("additional-name")), + (InputContentType::FamilyName, Some("family-name")), + (InputContentType::NameSuffix, Some("honorific-suffix")), + (InputContentType::Nickname, Some("nickname")), + (InputContentType::JobTitle, Some("organization-title")), + (InputContentType::OrganizationName, Some("organization")), + (InputContentType::Location, Some("location")), + (InputContentType::FullStreetAddress, Some("street-address")), + (InputContentType::StreetAddressLine1, Some("address-line1")), + (InputContentType::StreetAddressLine2, Some("address-line2")), + (InputContentType::AddressCity, Some("address-level2")), + (InputContentType::AddressState, Some("address-level1")), + ( + InputContentType::AddressCityAndState, + Some("address-level1+2"), + ), + (InputContentType::Sublocality, Some("address-level3")), + (InputContentType::CountryName, Some("country-name")), + (InputContentType::PostalCode, Some("postal-code")), + (InputContentType::TelephoneNumber, Some("tel")), + (InputContentType::EmailAddress, Some("email")), + (InputContentType::Url, Some("url")), + (InputContentType::CreditCardNumber, Some("cc-number")), + (InputContentType::CreditCardName, Some("cc-name")), + (InputContentType::CreditCardGivenName, Some("cc-given-name")), + ( + InputContentType::CreditCardMiddleName, + Some("cc-additional-name"), + ), + ( + InputContentType::CreditCardFamilyName, + Some("cc-family-name"), + ), + (InputContentType::CreditCardSecurityCode, Some("cc-csc")), + (InputContentType::CreditCardExpiration, Some("cc-exp")), + ( + InputContentType::CreditCardExpirationMonth, + Some("cc-exp-month"), + ), + ( + InputContentType::CreditCardExpirationYear, + Some("cc-exp-year"), + ), + (InputContentType::CreditCardType, Some("cc-type")), + (InputContentType::Username, Some("username")), + (InputContentType::Password, Some("password")), + (InputContentType::NewPassword, Some("new-password")), + (InputContentType::OneTimeCode, Some("one-time-code")), + ( + InputContentType::ShipmentTrackingNumber, + Some("shipment-tracking-number"), + ), + (InputContentType::FlightNumber, Some("flight-number")), + (InputContentType::DateTime, Some("date-time")), + (InputContentType::Birthdate, Some("bday")), + (InputContentType::BirthdateDay, Some("bday-day")), + (InputContentType::BirthdateMonth, Some("bday-month")), + (InputContentType::BirthdateYear, Some("bday-year")), + (InputContentType::CellularEid, None), + (InputContentType::CellularImei, None), + ]; + + for (content_type, native_value) in content_types { + assert_eq!(content_type.ns_text_content_type(), native_value); + } + } +} diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 8ea2be95d4..b84cf9dd0a 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, DefiniteLength, Edges, EdgesRefinement, Entity, Hsla, InteractiveElement as _, - IntoElement, MouseButton, ParentElement as _, Rems, RenderOnce, StyleRefinement, Styled, - TextAlign, Window, div, px, relative, + IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Rems, RenderOnce, + StyleRefinement, Styled, TextAlign, Window, div, px, relative, }; use crate::button::{Button, ButtonVariants as _}; @@ -16,7 +16,9 @@ use crate::{IconName, Size}; use crate::{Selectable, StyledExt, h_flex}; use crate::{Sizable, StyleSized}; -use super::{InputState, element::EditorScrollbar}; +use super::{ + InputContentType, InputState, content_type::sync_native_content_type, element::EditorScrollbar, +}; /// Returns `(background, foreground)` colors for input-like components. pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { @@ -30,155 +32,6 @@ pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { } } -/// Semantic content type for an [`Input`]. -/// -/// These variants mirror Swift's text content types. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InputContentType { - /// A person's full name. - Name, - /// A name prefix, such as Mr. or Dr. - NamePrefix, - /// A person's given name. - GivenName, - /// A person's middle name. - MiddleName, - /// A person's family name. - FamilyName, - /// A name suffix, such as Jr. or PhD. - NameSuffix, - /// A nickname. - Nickname, - /// A job title. - JobTitle, - /// An organization or company name. - OrganizationName, - /// A location name. - Location, - /// A full street address. - FullStreetAddress, - /// The first line of a street address. - StreetAddressLine1, - /// The second line of a street address. - StreetAddressLine2, - /// A city or locality. - AddressCity, - /// A state, province, or region. - AddressState, - /// A combined city and state. - AddressCityAndState, - /// A sublocality, district, or neighborhood. - Sublocality, - /// A country name. - CountryName, - /// A postal or ZIP code. - PostalCode, - /// A telephone number. - TelephoneNumber, - /// An email address. - EmailAddress, - /// A URL. - Url, - /// A credit card number. - CreditCardNumber, - /// The full name on a credit card. - CreditCardName, - /// The given name on a credit card. - CreditCardGivenName, - /// The middle name on a credit card. - CreditCardMiddleName, - /// The family name on a credit card. - CreditCardFamilyName, - /// The security code on a credit card. - CreditCardSecurityCode, - /// A credit card expiration date. - CreditCardExpiration, - /// A credit card expiration month. - CreditCardExpirationMonth, - /// A credit card expiration year. - CreditCardExpirationYear, - /// A credit card type. - CreditCardType, - /// A username or account identifier. - Username, - /// The password for the account identified by the username field. - Password, - /// A new password, such as during sign up or password reset. - NewPassword, - /// A one-time verification code. - OneTimeCode, - /// A parcel shipment tracking number. - ShipmentTrackingNumber, - /// An airline flight number. - FlightNumber, - /// A date, time, or duration. - DateTime, - /// A birthdate. - Birthdate, - /// A birthdate day. - BirthdateDay, - /// A birthdate month. - BirthdateMonth, - /// A birthdate year. - BirthdateYear, - /// An eSIM EID. - CellularEid, - /// A cellular IMEI. - CellularImei, -} - -impl InputContentType { - #[cfg(target_os = "macos")] - pub(crate) const fn ns_text_content_type(self) -> Option<&'static str> { - match self { - Self::Name => Some("name"), - Self::NamePrefix => Some("honorific-prefix"), - Self::GivenName => Some("given-name"), - Self::MiddleName => Some("additional-name"), - Self::FamilyName => Some("family-name"), - Self::NameSuffix => Some("honorific-suffix"), - Self::Nickname => Some("nickname"), - Self::JobTitle => Some("organization-title"), - Self::OrganizationName => Some("organization"), - Self::Location => Some("location"), - Self::FullStreetAddress => Some("street-address"), - Self::StreetAddressLine1 => Some("address-line1"), - Self::StreetAddressLine2 => Some("address-line2"), - Self::AddressCity => Some("address-level2"), - Self::AddressState => Some("address-level1"), - Self::AddressCityAndState => Some("address-level1+2"), - Self::Sublocality => Some("address-level3"), - Self::CountryName => Some("country-name"), - Self::PostalCode => Some("postal-code"), - Self::TelephoneNumber => Some("tel"), - Self::EmailAddress => Some("email"), - Self::Url => Some("url"), - Self::CreditCardNumber => Some("cc-number"), - Self::CreditCardName => Some("cc-name"), - Self::CreditCardGivenName => Some("cc-given-name"), - Self::CreditCardMiddleName => Some("cc-additional-name"), - Self::CreditCardFamilyName => Some("cc-family-name"), - Self::CreditCardSecurityCode => Some("cc-csc"), - Self::CreditCardExpiration => Some("cc-exp"), - Self::CreditCardExpirationMonth => Some("cc-exp-month"), - Self::CreditCardExpirationYear => Some("cc-exp-year"), - Self::CreditCardType => Some("cc-type"), - Self::Username => Some("username"), - Self::Password => Some("password"), - Self::NewPassword => Some("new-password"), - Self::OneTimeCode => Some("one-time-code"), - Self::ShipmentTrackingNumber => Some("shipment-tracking-number"), - Self::FlightNumber => Some("flight-number"), - Self::DateTime => Some("date-time"), - Self::Birthdate => Some("bday"), - Self::BirthdateDay => Some("bday-day"), - Self::BirthdateMonth => Some("bday-month"), - Self::BirthdateYear => Some("bday-year"), - Self::CellularEid | Self::CellularImei => None, - } - } -} - /// A text input element bind to an [`InputState`]. #[derive(IntoElement)] pub struct Input { @@ -350,6 +203,17 @@ impl Input { }) } + fn mouse_down_handler( + state: Entity, + content_type: Option, + disabled: bool, + ) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static { + move |event, window, cx| { + sync_native_content_type(window, content_type, disabled); + state.update(cx, |state, cx| state.on_mouse_down(event, window, cx)); + } + } + /// This method must after the refine_style. fn render_editor( paddings: EdgesRefinement, @@ -421,9 +285,8 @@ impl RenderOnce for Input { let content_type = self.content_type; let disabled = self.disabled; let focused = state.focus_handle.is_focused(window) && !state.disabled; - #[cfg(target_os = "macos")] if focused { - super::native::set_text_content_type(window, content_type); + sync_native_content_type(window, content_type, state.disabled); } let gap_x = match self.size { @@ -521,28 +384,14 @@ impl RenderOnce for Input { .on_action(window.listener_for(&self.state, InputState::copy)) .on_action(window.listener_for(&self.state, InputState::on_action_search)) .on_key_down(window.listener_for(&self.state, InputState::on_key_down)) - .on_mouse_down(MouseButton::Left, { - let state = self.state.clone(); - move |event, window, cx| { - #[cfg(target_os = "macos")] - if !disabled { - super::native::set_text_content_type(window, content_type); - } - - state.update(cx, |state, cx| state.on_mouse_down(event, window, cx)); - } - }) - .on_mouse_down(MouseButton::Right, { - let state = self.state.clone(); - move |event, window, cx| { - #[cfg(target_os = "macos")] - if !disabled { - super::native::set_text_content_type(window, content_type); - } - - state.update(cx, |state, cx| state.on_mouse_down(event, window, cx)); - } - }) + .on_mouse_down( + MouseButton::Left, + Self::mouse_down_handler(self.state.clone(), content_type, disabled), + ) + .on_mouse_down( + MouseButton::Right, + Self::mouse_down_handler(self.state.clone(), content_type, disabled), + ) .on_mouse_up( MouseButton::Left, window.listener_for(&self.state, InputState::on_mouse_up), @@ -621,81 +470,3 @@ impl RenderOnce for Input { }) } } - -#[cfg(all(test, target_os = "macos"))] -mod tests { - use super::*; - - #[test] - fn content_type_maps_to_ns_text_content_type_values() { - let content_types = [ - (InputContentType::Name, Some("name")), - (InputContentType::NamePrefix, Some("honorific-prefix")), - (InputContentType::GivenName, Some("given-name")), - (InputContentType::MiddleName, Some("additional-name")), - (InputContentType::FamilyName, Some("family-name")), - (InputContentType::NameSuffix, Some("honorific-suffix")), - (InputContentType::Nickname, Some("nickname")), - (InputContentType::JobTitle, Some("organization-title")), - (InputContentType::OrganizationName, Some("organization")), - (InputContentType::Location, Some("location")), - (InputContentType::FullStreetAddress, Some("street-address")), - (InputContentType::StreetAddressLine1, Some("address-line1")), - (InputContentType::StreetAddressLine2, Some("address-line2")), - (InputContentType::AddressCity, Some("address-level2")), - (InputContentType::AddressState, Some("address-level1")), - ( - InputContentType::AddressCityAndState, - Some("address-level1+2"), - ), - (InputContentType::Sublocality, Some("address-level3")), - (InputContentType::CountryName, Some("country-name")), - (InputContentType::PostalCode, Some("postal-code")), - (InputContentType::TelephoneNumber, Some("tel")), - (InputContentType::EmailAddress, Some("email")), - (InputContentType::Url, Some("url")), - (InputContentType::CreditCardNumber, Some("cc-number")), - (InputContentType::CreditCardName, Some("cc-name")), - (InputContentType::CreditCardGivenName, Some("cc-given-name")), - ( - InputContentType::CreditCardMiddleName, - Some("cc-additional-name"), - ), - ( - InputContentType::CreditCardFamilyName, - Some("cc-family-name"), - ), - (InputContentType::CreditCardSecurityCode, Some("cc-csc")), - (InputContentType::CreditCardExpiration, Some("cc-exp")), - ( - InputContentType::CreditCardExpirationMonth, - Some("cc-exp-month"), - ), - ( - InputContentType::CreditCardExpirationYear, - Some("cc-exp-year"), - ), - (InputContentType::CreditCardType, Some("cc-type")), - (InputContentType::Username, Some("username")), - (InputContentType::Password, Some("password")), - (InputContentType::NewPassword, Some("new-password")), - (InputContentType::OneTimeCode, Some("one-time-code")), - ( - InputContentType::ShipmentTrackingNumber, - Some("shipment-tracking-number"), - ), - (InputContentType::FlightNumber, Some("flight-number")), - (InputContentType::DateTime, Some("date-time")), - (InputContentType::Birthdate, Some("bday")), - (InputContentType::BirthdateDay, Some("bday-day")), - (InputContentType::BirthdateMonth, Some("bday-month")), - (InputContentType::BirthdateYear, Some("bday-year")), - (InputContentType::CellularEid, None), - (InputContentType::CellularImei, None), - ]; - - for (content_type, native_value) in content_types { - assert_eq!(content_type.ns_text_content_type(), native_value); - } - } -} diff --git a/crates/ui/src/input/mod.rs b/crates/ui/src/input/mod.rs index 84dd30b233..410dbd6301 100644 --- a/crates/ui/src/input/mod.rs +++ b/crates/ui/src/input/mod.rs @@ -4,6 +4,7 @@ pub(super) const MASK_CHAR: char = '•'; mod blink_cursor; mod change; mod clear_button; +mod content_type; mod cursor; mod display_map; mod element; @@ -24,6 +25,7 @@ mod selection; mod state; pub(crate) use clear_button::*; +pub use content_type::*; pub use cursor::*; #[cfg(target_family = "wasm")] pub use display_map::folding::Tree; From d98050209da9694f8e3661fe621738599c85dc70 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 6 Jul 2026 19:38:04 +0800 Subject: [PATCH 3/6] . --- crates/story/src/stories/input_story.rs | 7 +- crates/ui/src/input/input.rs | 195 +++++++++++++++++++++++- 2 files changed, 196 insertions(+), 6 deletions(-) diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 8b630e307d..91eeed17ec 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -396,7 +396,12 @@ impl Render for InputStory { section("Input State") .max_w_md() .child(Input::new(&self.disabled_input).disabled(true)) - .child(Input::new(&self.mask_input).mask_toggle().cleanable(true)), + .child( + Input::new(&self.mask_input) + .content_type(InputContentType::Password) + .mask_toggle() + .cleanable(true), + ), ) .child( section("Content Type").max_w_lg().children( diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index fbc04a1211..467bea9bb6 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -50,6 +50,7 @@ pub struct Input { tab_index: isize, selected: bool, content_type: Option, + role: Option, /// An optional context menu builder to allow a custom context menu on the input. /// @@ -94,6 +95,7 @@ impl Input { tab_index: 0, selected: false, content_type: None, + role: None, context_menu_builder: None, } } @@ -159,6 +161,14 @@ impl Input { self } + /// Override the accessible role for the input. + /// + /// If unset, the role is inferred from multi-line mode and content type. + pub fn role(mut self, role: Role) -> Self { + self.role = Some(role); + self + } + /// Set to disable the input field. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; @@ -214,6 +224,70 @@ impl Input { } } + fn accessibility_role( + is_multi_line: bool, + content_type: Option, + role: Option, + ) -> Role { + if let Some(role) = role { + return role; + } + + if is_multi_line { + return Role::MultilineTextInput; + } + + match content_type { + None => Role::TextInput, + Some(InputContentType::TelephoneNumber) => Role::PhoneNumberInput, + Some(InputContentType::EmailAddress) => Role::EmailInput, + Some(InputContentType::Url) => Role::UrlInput, + Some(InputContentType::Password | InputContentType::NewPassword) => Role::PasswordInput, + Some(InputContentType::DateTime) => Role::DateTimeInput, + Some(InputContentType::Birthdate) => Role::DateInput, + Some( + InputContentType::Name + | InputContentType::NamePrefix + | InputContentType::GivenName + | InputContentType::MiddleName + | InputContentType::FamilyName + | InputContentType::NameSuffix + | InputContentType::Nickname + | InputContentType::JobTitle + | InputContentType::OrganizationName + | InputContentType::Location + | InputContentType::FullStreetAddress + | InputContentType::StreetAddressLine1 + | InputContentType::StreetAddressLine2 + | InputContentType::AddressCity + | InputContentType::AddressState + | InputContentType::AddressCityAndState + | InputContentType::Sublocality + | InputContentType::CountryName + | InputContentType::PostalCode + | InputContentType::CreditCardNumber + | InputContentType::CreditCardName + | InputContentType::CreditCardGivenName + | InputContentType::CreditCardMiddleName + | InputContentType::CreditCardFamilyName + | InputContentType::CreditCardSecurityCode + | InputContentType::CreditCardExpiration + | InputContentType::CreditCardExpirationMonth + | InputContentType::CreditCardExpirationYear + | InputContentType::CreditCardType + | InputContentType::Username + | InputContentType::OneTimeCode + | InputContentType::ShipmentTrackingNumber + | InputContentType::FlightNumber + | InputContentType::BirthdateDay + | InputContentType::BirthdateMonth + | InputContentType::BirthdateYear + | InputContentType::CellularEid + | InputContentType::CellularImei, + ) => Role::TextInput, + } + } + /// This method must after the refine_style. fn render_editor( paddings: EdgesRefinement, @@ -284,6 +358,8 @@ impl RenderOnce for Input { let state = self.state.read(cx); let content_type = self.content_type; let disabled = self.disabled; + let is_multi_line = state.mode.is_multi_line(); + let accessibility_role = Self::accessibility_role(is_multi_line, content_type, self.role); let focused = state.focus_handle.is_focused(window) && !state.disabled; if focused { sync_native_content_type(window, content_type, state.disabled); @@ -319,11 +395,7 @@ impl RenderOnce for Input { div() .id(("input", self.state.entity_id())) - .role(if state.mode.is_multi_line() { - Role::MultilineTextInput - } else { - Role::TextInput - }) + .role(accessibility_role) .flex() .key_context(crate::input::CONTEXT) .track_focus(&state.focus_handle.clone()) @@ -475,3 +547,116 @@ impl RenderOnce for Input { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_types_map_to_accessibility_roles() { + let cases = [ + (None, Role::TextInput), + (Some(InputContentType::Name), Role::TextInput), + (Some(InputContentType::NamePrefix), Role::TextInput), + (Some(InputContentType::GivenName), Role::TextInput), + (Some(InputContentType::MiddleName), Role::TextInput), + (Some(InputContentType::FamilyName), Role::TextInput), + (Some(InputContentType::NameSuffix), Role::TextInput), + (Some(InputContentType::Nickname), Role::TextInput), + (Some(InputContentType::JobTitle), Role::TextInput), + (Some(InputContentType::OrganizationName), Role::TextInput), + (Some(InputContentType::Location), Role::TextInput), + (Some(InputContentType::FullStreetAddress), Role::TextInput), + (Some(InputContentType::StreetAddressLine1), Role::TextInput), + (Some(InputContentType::StreetAddressLine2), Role::TextInput), + (Some(InputContentType::AddressCity), Role::TextInput), + (Some(InputContentType::AddressState), Role::TextInput), + (Some(InputContentType::AddressCityAndState), Role::TextInput), + (Some(InputContentType::Sublocality), Role::TextInput), + (Some(InputContentType::CountryName), Role::TextInput), + (Some(InputContentType::PostalCode), Role::TextInput), + ( + Some(InputContentType::TelephoneNumber), + Role::PhoneNumberInput, + ), + (Some(InputContentType::EmailAddress), Role::EmailInput), + (Some(InputContentType::Url), Role::UrlInput), + (Some(InputContentType::CreditCardNumber), Role::TextInput), + (Some(InputContentType::CreditCardName), Role::TextInput), + (Some(InputContentType::CreditCardGivenName), Role::TextInput), + ( + Some(InputContentType::CreditCardMiddleName), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardFamilyName), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardSecurityCode), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardExpiration), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardExpirationMonth), + Role::TextInput, + ), + ( + Some(InputContentType::CreditCardExpirationYear), + Role::TextInput, + ), + (Some(InputContentType::CreditCardType), Role::TextInput), + (Some(InputContentType::Username), Role::TextInput), + (Some(InputContentType::Password), Role::PasswordInput), + (Some(InputContentType::NewPassword), Role::PasswordInput), + (Some(InputContentType::OneTimeCode), Role::TextInput), + ( + Some(InputContentType::ShipmentTrackingNumber), + Role::TextInput, + ), + (Some(InputContentType::FlightNumber), Role::TextInput), + (Some(InputContentType::DateTime), Role::DateTimeInput), + (Some(InputContentType::Birthdate), Role::DateInput), + (Some(InputContentType::BirthdateDay), Role::TextInput), + (Some(InputContentType::BirthdateMonth), Role::TextInput), + (Some(InputContentType::BirthdateYear), Role::TextInput), + (Some(InputContentType::CellularEid), Role::TextInput), + (Some(InputContentType::CellularImei), Role::TextInput), + ]; + + for (content_type, role) in cases { + assert_eq!(Input::accessibility_role(false, content_type, None), role); + } + } + + #[test] + fn multiline_inputs_keep_multiline_accessibility_role() { + assert_eq!( + Input::accessibility_role(true, Some(InputContentType::Password), None), + Role::MultilineTextInput + ); + } + + #[test] + fn explicit_accessibility_role_overrides_defaults() { + assert_eq!( + Input::accessibility_role( + false, + Some(InputContentType::Password), + Some(Role::TextInput) + ), + Role::TextInput + ); + assert_eq!( + Input::accessibility_role( + true, + Some(InputContentType::Password), + Some(Role::TextInput) + ), + Role::TextInput + ); + } +} From be81e4fb52dad3d25ad45688b8cb69fa8098aa03 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 6 Jul 2026 19:38:38 +0800 Subject: [PATCH 4/6] . --- crates/story/src/stories/input_story.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 91eeed17ec..febe670588 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -1,6 +1,6 @@ use gpui::{ App, AppContext as _, ClickEvent, Context, Entity, InteractiveElement, IntoElement, - ParentElement as _, Render, Styled, Subscription, Window, div, + ParentElement as _, Render, Role, Styled, Subscription, Window, div, }; use crate::section; @@ -390,7 +390,7 @@ impl Render for InputStory { section("Normal Input") .max_w_md() .child(Input::new(&self.input1).cleanable(true)) - .child(Input::new(&self.input2)), + .child(Input::new(&self.input2).role(Role::EmailInput)), ) .child( section("Input State") From 0ac5f88a951744f5bc443aa54dd4be8495b211fa Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 6 Jul 2026 19:42:05 +0800 Subject: [PATCH 5/6] . --- crates/story/examples/autofill.rs | 131 ------------------------------ 1 file changed, 131 deletions(-) delete mode 100644 crates/story/examples/autofill.rs diff --git a/crates/story/examples/autofill.rs b/crates/story/examples/autofill.rs deleted file mode 100644 index 3c2dcf44d2..0000000000 --- a/crates/story/examples/autofill.rs +++ /dev/null @@ -1,131 +0,0 @@ -use gpui::prelude::FluentBuilder as _; -use gpui::*; -use gpui_component::{ - ActiveTheme as _, Root, - button::{Button, ButtonVariants as _}, - h_flex, - input::{Input, InputContentType, InputState}, - label::Label, - v_flex, -}; -use gpui_component_assets::Assets; - -pub struct Example { - username: Entity, - password: Entity, - new_password: Entity, - one_time_code: Entity, -} - -impl Example { - fn new(window: &mut Window, cx: &mut Context) -> Self { - Self { - username: cx.new(|cx| InputState::new(window, cx).placeholder("Username")), - password: cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Password") - .masked(true) - }), - new_password: cx.new(|cx| { - InputState::new(window, cx) - .placeholder("New password") - .masked(true) - }), - one_time_code: cx.new(|cx| InputState::new(window, cx).placeholder("123456")), - } - } - - fn field( - label: &'static str, - input: impl IntoElement, - action: Option, - ) -> impl IntoElement { - h_flex() - .w_full() - .items_center() - .gap_3() - .child(Label::new(label).w_32().flex_shrink_0()) - .child(input) - .when_some(action, |this, action| this.child(action)) - } -} - -impl Render for Example { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .id("autofill-example") - .size_full() - .items_center() - .justify_center() - .bg(cx.theme().background) - .child( - v_flex() - .w(px(520.)) - .max_w_full() - .gap_4() - .p_8() - .child(Self::field( - "Username", - Input::new(&self.username) - .content_type(InputContentType::Username) - .flex_1(), - None::, - )) - .child(Self::field( - "Password", - Input::new(&self.password) - .content_type(InputContentType::Password) - .mask_toggle() - .flex_1(), - Some( - Button::new("sign-in") - .primary() - .label("Sign in") - .into_any_element(), - ), - )) - .child(Self::field( - "New password", - Input::new(&self.new_password) - .content_type(InputContentType::NewPassword) - .mask_toggle() - .flex_1(), - None::, - )) - .child(Self::field( - "Code", - Input::new(&self.one_time_code) - .content_type(InputContentType::OneTimeCode) - .flex_1(), - None::, - )), - ) - } -} - -fn main() { - let app = gpui_platform::application().with_assets(Assets); - - app.run(move |cx| { - gpui_component::init(cx); - cx.activate(true); - - let window_options = WindowOptions { - window_bounds: Some(WindowBounds::centered(size(px(720.), px(520.)), cx)), - titlebar: Some(TitlebarOptions { - title: Some("AutoFill".into()), - ..Default::default() - }), - ..Default::default() - }; - - cx.spawn(async move |cx| { - cx.open_window(window_options, |window, cx| { - let view = cx.new(|cx| Example::new(window, cx)); - cx.new(|cx| Root::new(view, window, cx)) - }) - .expect("failed to open window"); - }) - .detach(); - }); -} From 1a2a0af41d0a09a00a59f7527cb82cc629b52b46 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 6 Jul 2026 19:47:43 +0800 Subject: [PATCH 6/6] Fix test. --- crates/ui/src/lib.rs | 2 +- crates/ui/src/root.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6c79bf3b13..21a5470a8b 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -11,7 +11,7 @@ mod icon; mod index_path; #[cfg(any(feature = "inspector", debug_assertions))] mod inspector; -#[cfg(target_os = "macos")] +#[cfg(all(target_os = "macos", not(test)))] mod macos_accessibility; mod root; mod styled; diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 3e8ae32e50..7d531b8838 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -94,7 +94,7 @@ impl ActiveDialog { impl Root { /// Create a new Root view. pub fn new(view: impl Into, window: &mut Window, cx: &mut Context) -> Self { - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", not(test)))] crate::macos_accessibility::install_window_hit_test_forwarder(window); Self {