From acdc71453794424a213d359782cc2a2d270dfe46 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Mon, 26 Oct 2026 10:26:27 +0100 Subject: [PATCH] M3: an instance-swap property's default menu scrolls (#1338) On a main component's Properties row, the default of an instance-swap property opened a menu of every component in the file at full height, so in a file with many components (an icon library) the bottom ones ran off the screen and couldn't be picked. The menu now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology. --- crates/ui-egui/src/component_panel.rs | 19 ++- .../src/component_swap_default_tests.rs | 118 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + docs/components-parity.md | 5 +- 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/component_swap_default_tests.rs diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 06a0385c..54bc0974 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -338,13 +338,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { PropDef::InstanceSwap { default, .. } => { let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| tr("Missing").into()); let resp = widgets::dropdown(ui, &label, 84.0, None); - menu_popup(&resp).show(|ui| { - for (c, name) in comps.iter() { - if menu_item(ui, name, "") { - app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); - ui.close(); + crate::a11y::combo(&resp, k, &label, crate::a11y::menu_open(&resp)); + // A file with many components (an icon library) scrolls instead of running off the screen. + menu_popup(&resp).width(200.0).show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (c, name) in comps.iter() { + let current = c == default; + if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { + if !current { + app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0})); + } + ui.close(); + } } - } + }); }); } PropDef::Variant { .. } | PropDef::Slot { .. } => {} diff --git a/crates/ui-egui/src/component_swap_default_tests.rs b/crates/ui-egui/src/component_swap_default_tests.rs new file mode 100644 index 00000000..50b06b8b --- /dev/null +++ b/crates/ui-egui/src/component_swap_default_tests.rs @@ -0,0 +1,118 @@ +//! The default of a main component's instance-swap property, driven through egui headless: with +//! more components than fit on the screen (an icon library), its menu scrolls, so the last one can +//! be picked (#1338). + +use egui::accesskit::{Node, Role}; +use egui::{Event, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; +use figcraft_doc::{NodeId, NodeKind, PropDef}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A selected "Button" component whose instance-swap property "Icon" defaults to "Heart", then 80 +/// more components, the last "Zebra". Returns (ui, button, zebra). +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let mut icon = |name: &str, k: usize| { + let f = + id(s.execute("frame.create", &json!({"x": (k % 20) * 40, "y": 200 + (k / 20) * 40, "width": 24, "height": 24, "name": name})).unwrap()); + id(s.execute("component.create", &json!({"ids": [f]})).unwrap()) + }; + let heart = icon("Heart", 0); + let mut zebra = heart; + for k in 1..=80 { + zebra = icon(&if k == 80 { "Zebra".to_string() } else { format!("Glyph {k}") }, k); + } + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Button"})).unwrap()); + let nested = id(s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 8, "parent": f})).unwrap()); + let button = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap(); + s.execute("select.set", &json!({"ids": [button]})).unwrap(); + let u = Ui::new(s); + u.ctx.enable_accesskit(); + (u, button, zebra) +} + +fn tree(u: &mut Ui) -> Vec { + u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +/// Turn the mouse wheel down by `dy` points over `at`, and let the scroll settle. +fn wheel(u: &mut Ui, at: Pos2, dy: f32) { + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, -dy), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(at), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } +} + +fn swap_default(u: &Ui, button: NodeId) -> Option { + match u.app.session.ds().ok()?.doc.node(button).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => match props.get("Icon") { + Some(PropDef::InstanceSwap { default, .. }) => Some(*default), + _ => None, + }, + _ => None, + } +} + +/// The default's dropdown on the "Icon" property row, scrolled into view in the Design panel. +fn default_combo(u: &mut Ui) -> Option { + for _ in 0..20 { + let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Icon"))?; + if centre(&combo).y < 860.0 { + return Some(combo); + } + wheel(u, pos2(1300.0, 500.0), 100.0); + } + None +} + +#[test] +fn the_default_menu_scrolls_to_its_last_component() { + let (mut u, button, zebra) = setup(); + let combo = default_combo(&mut u).expect("the Icon property has a dropdown for its default"); + assert_eq!(combo.value(), Some("Heart")); + // Layer rows share the names; the menu's items are the ones that weren't there before it opened. + let before: Vec = tree(&mut u).iter().filter(|n| n.label().is_some_and(|l| l == "Zebra" || l == "Glyph 1")).map(centre).collect(); + click(&mut u, centre(&combo)); + let item = |u: &mut Ui, name: &str| tree(u).into_iter().find(|n| n.label() == Some(name) && !before.contains(¢re(n))); + let top = centre(&item(&mut u, "Glyph 1").expect("the menu lists the components")); + let bottom = centre(&item(&mut u, "Zebra").expect("the menu lists the last component")); + assert!(bottom.y > 900.0, "80 components don't fit on the screen: Zebra at {bottom:?}"); + // The menu is capped and scrolls: wheel over it to the end of the list, and pick Zebra. + let mut at = bottom; + for _ in 0..40 { + wheel(&mut u, top, 200.0); + let next = centre(&item(&mut u, "Zebra").expect("the menu stays open while it scrolls")); + if next == at { + break; + } + at = next; + } + assert!(at.y < 880.0, "the menu scrolls Zebra onto the screen: at {at:?}"); + assert!(at.y - top.y < 400.0, "Zebra ends up inside the capped menu, near its top row: {top:?} → {at:?}"); + click(&mut u, at); + assert_eq!(swap_default(&u, button), Some(zebra), "picking the last component makes it the default"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c11bf985..e5bc32a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -106,6 +106,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod component_swap_default_tests; +#[cfg(test)] mod context_menu_tests; #[cfg(test)] mod corner_individual_tests; diff --git a/docs/components-parity.md b/docs/components-parity.md index 9489d440..7b92b061 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -21,7 +21,7 @@ accounts). | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | -| Properties: boolean, text, instance swap (preferred values), variant | done | | +| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); no search in them yet | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | | Exposed nested instances | done | hidden ones hide their properties | | Descriptions | done | | @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | An instance-swap property's default menu scrolls (#1338): on the main component's Properties row the menu listed every component in the file at full height, so in a file with many (an icon library) the bottom ones ran off the screen and couldn't be picked. It now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology (`component_swap_default_tests`). Left: searching these menus. Numbers unchanged. | | 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. | | 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. | | 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |