Text Frame Options: every dropdown and field has an accessible name (#2733)

* Text Frame Options: every dropdown and field has an accessible name

The Columns and Align dropdowns on the General tab, and the dropdowns and
text fields on the Column Rules, Baseline Options and Auto-Size tabs, had
no accessible name, so a screen reader announced them without a caption
and kittest's accessibility check failed on the dialog. Each one now takes
the caption shown next to it. The non-rectangular frame test runs with the
check on and opens every tab.

* Text Frame Options: numeric fields take their captions as names

The Number, Width, Gutter, Maximum, inset and Paragraph Spacing Limit
fields on the General tab were named by their internal ids, so a screen
reader announced tfo_columnWidth and its spinner read as Increase
tfo_columnWidth. Each field and its spinner buttons now take the caption
shown beside it.

* Text Frame Options: test the numeric editors' accessible names

The test checked only the spinner buttons' names. It now also finds each
numeric editor of the General tab (Number, Width, Gutter, Maximum, Paragraph
Spacing Limit, the four sides and Inset) by the text-input role and its
caption.
This commit is contained in:
.CooCoo authored and GitHub committed 2026-11-13 16:27:26 +00:00
1 parent f24fc1e72b
commit cf4efa1833
2 files changed
+61 -37

No files matched your search

+41 -30
View File
@@ -1303,16 +1303,17 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
"baseline" => {
crate::rtl::label(ui, egui::RichText::new(tr("First Baseline")).font(semibold(12.0)));
egui::Grid::new("tfo_fb").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Offset:"));
let name = crate::rtl::label(ui, tr("Offset:"));
translated_combo(
&lang,
ui,
d,
"firstBaseline",
&[("ascent", "Ascent"), ("capHeight", "Cap Height"), ("leading", "Leading"), ("xHeight", "x Height"), ("fixed", "Fixed")],
);
)
.labelled_by(name.id);
crate::rtl::label(ui, tr("Min:"));
text_field(ui, d, "firstBaselineMin", 70.0);
text_field_named(ui, d, "firstBaselineMin", 70.0, tr("Min:"));
ui.end_row();
});
ui.add_space(8.0);
@@ -1321,19 +1322,20 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
ui.add_enabled_ui(d.b("customGrid"), |ui| {
egui::Grid::new("tfo_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Start:"));
text_field(ui, d, "gridStart", 70.0);
text_field_named(ui, d, "gridStart", 70.0, tr("Start:"));
ui.end_row();
crate::rtl::label(ui, tr("Relative To:"));
let name = crate::rtl::label(ui, tr("Relative To:"));
translated_combo(
&lang,
ui,
d,
"gridRelativeTo",
&[("page", "Top of Page"), ("margin", "Top Margin"), ("frame", "Top of Frame"), ("inset", "Top Inset")],
);
)
.labelled_by(name.id);
ui.end_row();
crate::rtl::label(ui, tr("Increment Every:"));
text_field(ui, d, "gridIncrement", 70.0);
text_field_named(ui, d, "gridIncrement", 70.0, tr("Increment Every:"));
ui.end_row();
});
});
@@ -1350,10 +1352,12 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog)
}
}
/// A measure field of the dialog with InDesign's spinner (stored as text in the document's unit).
fn spin_measure(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, enabled: bool) {
/// A measure field of the dialog with InDesign's spinner (stored as text in the document's unit),
/// named from its `caption`.
fn spin_measure(ui: &mut egui::Ui, d: &mut Dialog, key: &str, caption: &str, w: f32, enabled: bool) {
let id = format!("tfo_{key}");
if let Some(v) = crate::widgets::NumField::measure(&id, d.m(key), d.unit).width(w).spinner().range(0.0, 15552.0).enabled(enabled).show(ui) {
let field = crate::widgets::NumField::measure(&id, d.m(key), d.unit).name(caption.trim_end_matches(':'));
if let Some(v) = field.width(w).spinner().range(0.0, 15552.0).enabled(enabled).show(ui) {
d.fields.insert(key.into(), json!(format_measure(v, d.unit)));
}
}
@@ -1444,19 +1448,22 @@ pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
framed(ui, None, |ui| {
ui.horizontal(|ui| {
caption_at(ui, tr("Columns:"), cap);
translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")]);
translated_combo(lang, ui, d, "columnsKind", &[("fixedNumber", "Fixed Number"), ("fixedWidth", "Fixed Width")])
.accessible_name(tr("Columns:").to_owned());
});
ui.add_space(4.0);
ui.horizontal(|ui| {
caption_at(ui, crate::i18n::tr_context(lang, "Number:", "count"), cap);
let number = crate::i18n::tr_context(lang, "Number:", "count");
caption_at(ui, number, cap);
let n = d.n("columns");
if let Some(v) = crate::widgets::NumField::number("tfo_columns", n, "", 0).width(field).spinner().range(1.0, 40.0).show(ui) {
let columns = crate::widgets::NumField::number("tfo_columns", n, "", 0).name(number.trim_end_matches(':'));
if let Some(v) = columns.width(field).spinner().range(1.0, 40.0).show(ui) {
d.fields.insert("columns".into(), json!(v.round().to_string()));
}
// Fixed Number: the width follows from the frame; Fixed Width: it's set here.
caption_at(ui, tr("Width:"), cap2);
if d.s("columnsKind") == "fixedWidth" {
spin_measure(ui, d, "columnWidth", field, true);
spin_measure(ui, d, "columnWidth", tr("Width:"), field, true);
} else {
// Fixed Number: each column's width, from the frame, the insets and the gutter.
let frame = d.fields.get("_frameWidth").and_then(Value::as_f64);
@@ -1467,16 +1474,18 @@ pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
let inner = w - d.m(left).unwrap_or(0.0) - d.m(right).unwrap_or(0.0);
((inner - d.m("gutter").unwrap_or(0.0) * (n - 1.0)) / n).max(0.0)
});
let _ = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).width(field).spinner().enabled(false).show(ui);
let auto = crate::widgets::NumField::measure("tfo_auto_width", width, d.unit).name(tr("Width:").trim_end_matches(':'));
let _ = auto.width(field).spinner().enabled(false).show(ui);
}
});
ui.horizontal(|ui| {
caption_at(ui, tr("Gutter:"), cap);
spin_measure(ui, d, "gutter", field, true);
spin_measure(ui, d, "gutter", tr("Gutter:"), field, true);
// Maximum is for Flexible Width columns, which aren't in the document model yet.
caption_at(ui, tr("Maximum:"), cap2);
ui.add_enabled_ui(false, |ui| {
let _ = crate::widgets::NumField::number("tfo_max", None, "", 0).width(field).spinner().enabled(false).show(ui);
let max = crate::widgets::NumField::number("tfo_max", None, "", 0).name(tr("Maximum:").trim_end_matches(':'));
let _ = max.width(field).spinner().enabled(false).show(ui);
});
});
check(ui, d, "balanceColumns", tr("Balance Columns"));
@@ -1493,7 +1502,7 @@ pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
{
ui.horizontal(|ui| {
caption_at(ui, tr(l1), cap);
spin_measure(ui, d, k1, field, !shaped);
spin_measure(ui, d, k1, tr(l1), field, !shaped);
ui.add_space(8.0);
if r == 0 {
let linked = d.b("insetLinked");
@@ -1508,13 +1517,13 @@ pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_space(22.0 + ui.spacing().item_spacing.x);
}
caption_at(ui, tr(l2), cap2 - 30.0);
spin_measure(ui, d, k2, field, !shaped);
spin_measure(ui, d, k2, tr(l2), field, !shaped);
});
}
if shaped {
ui.horizontal(|ui| {
caption_at(ui, tr("Inset:"), cap);
spin_measure(ui, d, "inset", field, true);
spin_measure(ui, d, "inset", tr("Inset:"), field, true);
});
}
if !shaped
@@ -1536,12 +1545,13 @@ pub(crate) fn general(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
d,
"verticalJustification",
&[("top", "Top"), ("center", "Center"), ("bottom", "Bottom"), ("justify", "Justify")],
);
)
.accessible_name(tr("Align:").to_owned());
});
ui.horizontal(|ui| {
caption_at(ui, tr("Paragraph Spacing Limit:"), 170.0);
let justify = d.s("verticalJustification") == "justify";
spin_measure(ui, d, "paragraphSpacingLimit", field, justify);
spin_measure(ui, d, "paragraphSpacingLimit", tr("Paragraph Spacing Limit:"), field, justify);
});
});
ui.add_space(4.0);
@@ -1567,10 +1577,10 @@ fn column_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let opts: Vec<(&str, &str)> = swatches.iter().map(|n| (n.as_str(), n.as_str())).collect();
egui::Grid::new("tfo_rule").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Weight"));
text_field(ui, d, "columnRuleWeight", 70.0);
text_field_named(ui, d, "columnRuleWeight", 70.0, tr("Weight"));
ui.end_row();
crate::rtl::label(ui, tr("Color"));
combo(ui, d, "columnRuleColor", &opts);
let name = crate::rtl::label(ui, tr("Color"));
combo(ui, d, "columnRuleColor", &opts).labelled_by(name.id);
ui.end_row();
for (label, key) in [
("Tint %", "columnRuleTint"),
@@ -1579,7 +1589,7 @@ fn column_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
("Horizontal Offset", "columnRuleOffset"),
] {
crate::rtl::label(ui, tr(label));
text_field(ui, d, key, 70.0);
text_field_named(ui, d, key, 70.0, tr(label));
ui.end_row();
}
});
@@ -1590,7 +1600,7 @@ fn auto_size(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
let tr = |s: &'static str| crate::i18n::tr(lang, s);
crate::rtl::label(ui, egui::RichText::new(tr("Auto-Size")).font(semibold(12.0)));
egui::Grid::new("tfo_autosize").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Auto-Sizing"));
let name = crate::rtl::label(ui, tr("Auto-Sizing"));
translated_combo(
lang,
ui,
@@ -1603,16 +1613,17 @@ fn auto_size(lang: &str, ui: &mut egui::Ui, d: &mut Dialog) {
("heightAndWidth", "Height and Width"),
("heightAndWidthProportionally", "Height and Width (Keep Proportions)"),
],
);
)
.labelled_by(name.id);
ui.end_row();
crate::rtl::label(ui, tr("Reference Point"));
reference_point(ui, d, "autoSizeReference");
ui.end_row();
check(ui, d, "useMinimumHeight", tr("Minimum Height"));
text_field(ui, d, "minimumHeight", 70.0);
text_field_named(ui, d, "minimumHeight", 70.0, tr("Minimum Height"));
ui.end_row();
check(ui, d, "useMinimumWidth", tr("Minimum Width"));
text_field(ui, d, "minimumWidth", 70.0);
text_field_named(ui, d, "minimumWidth", 70.0, tr("Minimum Width"));
ui.end_row();
});
check(ui, d, "noLineBreaks", tr("No Line Breaks"));
+20 -7
View File
@@ -1746,8 +1746,7 @@ fn text_frame_options_dims_the_sides_for_a_non_rectangular_frame() {
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "shape": shape, "content": "text", "text": "Hello"})).unwrap();
app.run("selection.set", json!({"ids": [r["id"]]})).unwrap();
app.ui.dialog = Some(Dialog::new("textFrameOptions", json!({})));
// The General tab's Columns and Align dropdowns have no accessible name yet.
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).with_accessibility_check(false).build_ui_state(
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let key = egui::Id::unique("text_frame_inset_test_fonts");
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
@@ -1760,17 +1759,31 @@ fn text_frame_options_dims_the_sides_for_a_non_rectangular_frame() {
app,
);
h.run_steps(3);
for side in ["insetTop", "insetLeft", "insetBottom", "insetRight"] {
let label = format!("Increase tfo_{side}");
// The Columns and Align dropdowns carry their captions as accessible names.
for name in ["Columns:", "Align:"] {
let _ = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, name);
}
// The numeric fields and their spinners are named from the captions beside them.
for name in ["Number", "Width", "Gutter", "Maximum", "Paragraph Spacing Limit"] {
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, name);
let _ = h.get_by_label(&format!("Increase {name}"));
}
for side in ["Top", "Left", "Bottom", "Right"] {
let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, side);
let label = format!("Increase {side}");
assert_eq!(h.get_by_label(&label).accesskit_node().is_disabled(), shaped, "{shape}: {side}");
}
assert_eq!(h.query_by_label("Inset:").is_some(), shaped, "{shape}: the Inset caption");
assert_eq!(h.query_by_label("Increase tfo_inset").is_some(), shaped, "{shape}: the Inset field");
assert_eq!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "Inset").is_some(), shaped, "{shape}: the Inset editor");
assert_eq!(h.query_by_label("Increase Inset").is_some(), shaped, "{shape}: the Inset field");
// The General tab fits in the dialog's fixed height: the buttons stay put on another tab.
let (wrap, cancel) = (h.get_by_label("Ignore Text Wrap").rect().bottom(), h.get_by_label("Cancel").rect().top());
assert!(wrap < cancel, "{shape}: Ignore Text Wrap ends at {wrap}, below the top of Cancel at {cancel}");
h.get_by_label("Auto-Size").click();
h.run_steps(3);
// Every tab names its inputs (the harness checks after each settled run).
for tab in ["Column Rules", "Baseline Options", "Auto-Size"] {
h.get_by_label(tab).click();
h.run();
}
assert_eq!(h.get_by_label("Cancel").rect().top(), cancel, "{shape}: the buttons stay put on another tab");
h.get_by_label("General").click();
h.run_steps(3);