Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 67 additions & 8 deletions crates/component-shell/tests/story_gallery_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>::new()));
let registered_surfaces = surfaces.clone();
let selected = std::rc::Rc::new(std::cell::RefCell::new(None::<String>));
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
Expand All @@ -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::<std::collections::HashSet<_>>()
.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}"
);
}
});
}
}
109 changes: 97 additions & 12 deletions crates/component/src/input/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
})
Expand Down Expand Up @@ -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),
));
})
}
}

Expand All @@ -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<crate::input::EditorState>);

impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> 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]
Expand Down
33 changes: 23 additions & 10 deletions examples/js_story/fixtures/all-examples.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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));
);
}
}
Loading