LRC-LIB-KEYWORDS: chips speak, and keep their menu with their keyword

Drawn as one widget, a chip and its × lost the names a button gave
them: screen readers met unlabeled buttons. The chip is now named after
its whole keyword (even when cut short on screen) and the × "Remove
“…”". Both answer under ids made from their keyword rather than their
place in the row, so a chip's open menu stays with its keyword when the
chips change underneath it (an undo, a sync).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 15:39:04 -04:00
co-authored by Claude Opus 5.5
parent 9dabd38941
commit f5b1c09e42
8 changed files with 57 additions and 19 deletions
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?",
"There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?",
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
"On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos"
"On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos",
"Remove “{name}”": "„{name}“ entfernen"
}
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?",
"There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?",
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
"On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas"
"On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas",
"Remove “{name}”": "Quitar “{name}”"
}
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?",
"There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?",
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
"On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚"
"On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚",
"Remove “{name}”": "「{name}」を削除"
}
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?",
"There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?",
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
"On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas"
"On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas",
"Remove “{name}”": "Remover “{name}”"
}
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?",
"There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?",
"Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}",
"On {have} of {of} selected photos": "На {have} из {of} выбранных фото"
"On {have} of {of} selected photos": "На {have} из {of} выбранных фото",
"Remove “{name}”": "Убрать «{name}»"
}
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?",
"There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?",
"Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}",
"On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张"
"On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张",
"Remove “{name}”": "移除“{name}”"
}
+2 -1
View File
@@ -214,5 +214,6 @@
"“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?",
"There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?",
"Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}",
"On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張"
"On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張",
"Remove “{name}”": "移除「{name}」"
}
+43 -12
View File
@@ -61,22 +61,30 @@ fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Res
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x;
let shown = chip_label(path, partial, room, measure);
let w = measure(&shown) + 16.0 + cross_w;
let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click());
// the row places it; its name and × answer under ids of their own keyword, so an open menu
// stays with its keyword when the chips change
let (rect, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover());
let id = egui::Id::new(("keyword-chip", path.to_lowercase(), cross));
let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom()));
let full = path.replace('|', " › ");
let resp = ui.interact(body, id, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &full));
let x = cross.then(|| {
let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0));
let xresp = ui.interact(xr, id.with("remove"), Sense::click());
let said = crate::i18n::tr_format!("Remove “{name}”", name = full);
xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &said));
xresp
});
ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
if resp.hovered() {
if resp.hovered() || x.as_ref().is_some_and(egui::Response::hovered) {
ui.painter().rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5));
}
ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color);
let body = Rect::from_min_max(rect.min, pos2(rect.right() - cross_w, rect.bottom()));
let x = cross.then(|| {
let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0));
let xresp = ui.interact(xr, resp.id.with("remove"), Sense::click());
ui.painter().text(xr.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if xresp.hovered() { t.text } else { t.text_dim });
xresp
});
let mut body_resp = resp;
body_resp.rect = body;
(body_resp, x)
if let Some(x) = &x {
ui.painter().text(x.rect.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if x.hovered() { t.text } else { t.text_dim });
}
(resp, x)
}
/// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the
@@ -311,4 +319,27 @@ mod tests {
c.apply(Op::SetKeyword { path: "Beach".into(), info: Some(KeywordInfo::default()) }).unwrap();
assert_eq!(shown(&chips(&c, &ids)), [("Beach", 1, 1)]);
}
/// Screen readers hear a chip by its whole keyword (even when cut short on screen) and its ×
/// as removing that keyword.
#[test]
fn chips_describe_themselves() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
let mut found = Vec::new();
for _ in 0..3 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.set_max_width(120.0);
let _ = chip(ui, "Places|Portugal|Lisbon|Belém tower", true, true);
});
out.textures_delta.clear();
if let Some(update) = out.platform_output.accesskit_update.take() {
found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect();
}
}
let has = |role: &str, label: &str| found.iter().any(|(r, l)| r == role && l == label);
assert!(has("Button", "Places › Portugal › Lisbon › Belém tower"), "{found:?}");
assert!(has("Button", "Remove “Places › Portugal › Lisbon › Belém tower”"), "{found:?}");
}
}