diff --git a/crates/component-shell/tests/story_gallery_host.rs b/crates/component-shell/tests/story_gallery_host.rs index 5a4a453f47..bb5a52f470 100644 --- a/crates/component-shell/tests/story_gallery_host.rs +++ b/crates/component-shell/tests/story_gallery_host.rs @@ -196,6 +196,36 @@ fn dock_story_materializes_real_panels_dock_and_tabs(cx: &mut TestAppContext) { #[gpui::test] fn every_registered_story_example_materializes(cx: &mut TestAppContext) { cx.update(gpui_component_shell::init); + let surfaces = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let registered_surfaces = surfaces.clone(); + let selected = std::rc::Rc::new(std::cell::RefCell::new(None::)); + let selected_surface = selected.clone(); + gpui_shell::export_module( + gpui_shell::HostModule::new("story-gallery-fixture") + .function("register_surfaces", move |args| { + *registered_surfaces.borrow_mut() = args + .get(0) + .and_then(gpui_shell::HostValue::as_array) + .expect("fixture surface list") + .iter() + .map(|value| value.as_str().expect("surface name").to_owned()) + .collect(); + Ok(gpui_shell::HostValue::Null) + }) + .function("selected_surface", move |_| { + Ok(gpui_shell::HostValue::from( + selected_surface.borrow().clone(), + )) + }), + ) + .expect("register fixture host module"); + struct FixtureModule; + impl Drop for FixtureModule { + fn drop(&mut self) { + gpui_shell::clear_exported_modules(); + } + } + let _fixture_module = FixtureModule; let runtime = gpui_component_shell::new_isolated_runtime().expect("runtime"); let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/js_story"); let loaded = runtime @@ -212,13 +242,42 @@ fn every_registered_story_example_materializes(cx: &mut TestAppContext) { ScriptRoot(view) }); let mut context = VisualTestContext::from_window(*window.deref(), cx); - context.update(|window, cx| window.draw(cx).clear(cx)); - context.run_until_parked(); - context.update(|window, cx| window.draw(cx).clear(cx)); - let view = mounted.borrow().clone().expect("mounted view"); - context.update(|_, cx| { - assert_eq!(view.read(cx).build_error(), None); - assert!(view.read(cx).snapshot().is_some()); - }); + let surfaces = surfaces.borrow().clone(); + assert!( + surfaces.len() > 1, + "fixture must enumerate registered surfaces" + ); + assert_eq!( + surfaces + .iter() + .collect::>() + .len(), + surfaces.len(), + "fixture surfaces must be unique" + ); + assert!(surfaces.iter().any(|surface| surface == "VirtualList")); + assert!(surfaces.iter().any(|surface| surface == "TabBar")); + assert!(!surfaces.iter().any(|surface| surface == "Tab")); + for surface in surfaces { + *selected.borrow_mut() = Some(surface.clone()); + context.update(|_, cx| view.update(cx, |view, cx| view.refresh(cx))); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.run_until_parked(); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.update(|_, cx| { + let view = view.read(cx); + assert_eq!(view.build_error(), None, "surface: {surface}"); + let tree = view.snapshot().expect("surface snapshot").debug_tree(); + if surface == "VirtualList" { + assert!(tree.contains("v_virtual_list"), "{surface}: {tree}"); + assert!(tree.contains("10,000 projects"), "{surface}: {tree}"); + } else { + assert!( + tree.contains(&format!("fixture-{surface}-")), + "{surface}: {tree}" + ); + } + }); + } } diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 69af2aa802..ef455b87bc 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -485,9 +485,18 @@ impl Input { let Some(gpui::accesskit::ActionData::Value(value)) = data else { return; }; + if !state.presentation(cx).is_editable() { + return; + } state.replace_all(value.to_string(), window, cx); } + fn handle_accessibility_focus(state: &TextInputState, window: &mut Window, cx: &mut App) { + if !state.presentation(cx).is_disabled() { + state.focus(window, cx); + } + } + /// This method must after the refine_style. fn render_editor( input_state: TextInputState, @@ -729,7 +738,11 @@ impl RenderOnce for Input { this.aria_placeholder(placeholder) }) .when_some(accessibility_value, |this, value| this.aria_value(value)) - .when(!disabled, |this| { + .on_a11y_action(AccessibleAction::Focus, { + let state = state.clone(); + move |_, window, cx| Self::handle_accessibility_focus(&state, window, cx) + }) + .when(presentation.is_editable(), |this| { this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| { Self::handle_accessibility_set_value(&accessibility_state, data, window, cx); }) @@ -1001,15 +1014,17 @@ mod tests { ) -> impl IntoElement { let state = self.state.clone(); let emitted = self.emitted.clone(); - div().on_prepaint(move |_, window, cx| { - let input = Input::new(&state).render(window, cx).into_element(); - let mut node = gpui::accesskit::Node::new(Role::TextInput); - input.write_a11y_info(&mut node); - *emitted.lock().unwrap() = Some(( - node.value().map(ToOwned::to_owned), - node.supports_action(AccessibleAction::SetValue), - )); - }) + div() + .child(Input::new(&state)) + .on_prepaint(move |_, window, cx| { + let input = Input::new(&state).render(window, cx).into_element(); + let mut node = gpui::accesskit::Node::new(Role::TextInput); + input.write_a11y_info(&mut node); + *emitted.lock().unwrap() = Some(( + node.value().map(ToOwned::to_owned), + node.supports_action(AccessibleAction::SetValue), + )); + }) } } @@ -1032,14 +1047,84 @@ mod tests { let base: TextInputState = state.clone().into(); cx.update(|window, cx| { Input::handle_accessibility_set_value(&base, None, window, cx); + Input::handle_accessibility_focus(&base, window, cx); + assert!(base.presentation(cx).focus_handle().is_focused(window)); + window.draw(cx).clear(cx); }); assert_eq!(state.read_with(cx, |state, _| state.value()), "initial"); - let action = gpui::accesskit::ActionData::Value("updated".into()); + let changes = std::rc::Rc::new(std::cell::Cell::new(0)); + let observed = changes.clone(); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&state, move |_, event: &super::super::InputEvent, _| { + if matches!(event, super::super::InputEvent::Change) { + observed.set(observed.get() + 1); + } + }) + }); + let action = gpui::accesskit::ActionData::Value("updated🦀".into()); + cx.update(|window, cx| { + Input::handle_accessibility_set_value(&base, Some(&action), window, cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀"); + assert_eq!(changes.get(), 1); + for disabled in [false, true] { + cx.update(|window, cx| { + base.set_disabled(disabled, cx); + base.set_readonly(!disabled, cx); + window.blur(cx); + Input::handle_accessibility_focus(&base, window, cx); + assert_eq!( + base.presentation(cx).focus_handle().is_focused(window), + !disabled + ); + let action = gpui::accesskit::ActionData::Value("rejected".into()); + Input::handle_accessibility_set_value(&base, Some(&action), window, cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀"); + assert_eq!(changes.get(), 1); + } cx.update(|window, cx| { + base.set_disabled(false, cx); + base.set_readonly(false, cx); + Input::handle_accessibility_focus(&base, window, cx); + window.draw(cx).clear(cx); + window.dispatch_action(Box::new(super::super::Undo), cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "initial"); + cx.update(|window, cx| { + state.update(cx, |state, cx| state.set_masked(true, window, cx)); Input::handle_accessibility_set_value(&base, Some(&action), window, cx); + window.draw(cx).clear(cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀"); + assert_eq!(*captured.lock().unwrap(), Some((None, true))); + } + + #[gpui::test] + fn accessibility_set_value_preserves_exact_editor_text(cx: &mut gpui::TestAppContext) { + use gpui::{AppContext as _, Render}; + + struct Probe(Entity); + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().child(crate::input::Editor::new(&self.0)) + } + } + + cx.update(crate::init); + let (probe, cx) = cx.add_window_view(|window, cx| { + Probe(cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"))) + }); + let editor = probe.read_with(cx, |probe, _| probe.0.clone()); + let state: TextInputState = editor.clone().into(); + let action = gpui::accesskit::ActionData::Value("(".into()); + + cx.update(|window, cx| { + Input::handle_accessibility_set_value(&state, Some(&action), window, cx) }); - assert_eq!(state.read_with(cx, |state, _| state.value()), "updated"); + assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "("); } #[gpui::test] diff --git a/examples/js_story/fixtures/all-examples.js b/examples/js_story/fixtures/all-examples.js index f7ffa4041c..839dbc80e1 100644 --- a/examples/js_story/fixtures/all-examples.js +++ b/examples/js_story/fixtures/all-examples.js @@ -1,5 +1,6 @@ import { View, div } from "gpui-kit"; import { v_flex } from "gpui-base"; +import { register_surfaces, selected_surface } from "story-gallery-fixture"; import { coveredBy } from "../stories/coverage.js"; import { initializeRegisteredExamples, @@ -14,23 +15,35 @@ export default class AllRegisteredExamplesFixture extends View { init() { initializeRegisteredExamples(); this.virtualList = createVirtualListStory(); + register_surfaces([ + // Tab has no standalone examples; TabBar materializes its Tab children. + ...[...new Set(coveredBy.flatMap((entry) => entry.registrations))].filter( + (surface) => surface !== "Tab", + ), + "VirtualList", + ]); } render(cx) { - const surfaces = [...new Set(coveredBy.flatMap((entry) => entry.registrations))]; + // The host selects one surface per render so the complete inventory does + // not share a single frame's execution budget. + const surface = selected_surface(); + if (surface === null) return div(); + if (surface === "VirtualList") { + return renderVirtualListStory(this.virtualList, cx); + } + const examples = registeredExamples(surface, cx); + if (examples.length === 0) throw new Error(`No examples for ${surface}`); return v_flex() .w(900) .gap(16) .children( - surfaces.flatMap((surface) => - registeredExamples(surface, cx).map((example) => - div() - .id(`fixture-${surface}-${example.label}`) - .w_full() - .child(example.element), - ), + examples.map((example) => + div() + .id(`fixture-${surface}-${example.label}`) + .w_full() + .child(example.element), ), - ) - .child(renderVirtualListStory(this.virtualList, cx)); + ); } }