Skip to content

Commit 9796bb7

Browse files
huacnleecodex
andauthored
fix(base): name calendar items for accessibility (longbridge#2996)
Calendar buttons had no accessible names when a presentation assigned button roles, leaving assistive technology unable to identify dates or month/year choices. Set names alongside the default labels, with full ISO dates for day cells so adjacent-month days remain distinguishable. Presentations can still override names through `aria_label`. Validation: a regression test inspects item accessibility metadata in day, month, and year views. It fails on the previous implementation and passes with the fix. Calendar tests and downstream gpui-omarchy integration tests were also exercised with the patch. Co-authored-by: Codex <codex@openai.com>
1 parent b79f4ce commit 9796bb7

1 file changed

Lines changed: 70 additions & 7 deletions

File tree

‎crates/base/src/calendar.rs‎

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,12 @@ impl CalendarItem {
505505
children: vec![],
506506
}
507507
}
508+
// Keep the default visible label available to assistive technology even
509+
// when a presentation assigns a role that does not derive a name from children.
510+
fn with_label(self, label: SharedString) -> Self {
511+
self.aria_label(label.clone()).child(label)
512+
}
513+
508514
pub fn item_state(&self) -> CalendarItemState {
509515
self.state
510516
}
@@ -605,7 +611,12 @@ impl Calendar {
605611
cx: &mut App,
606612
) -> AnyElement {
607613
let label = (self.label)(state.kind(), value);
608-
(self.item)(CalendarItem::new(id, state).child(label), state, window, cx)
614+
(self.item)(
615+
CalendarItem::new(id, state).with_label(label),
616+
state,
617+
window,
618+
cx,
619+
)
609620
}
610621
}
611622
impl Styled for Calendar {
@@ -624,7 +635,8 @@ impl RenderOnce for Calendar {
624635
let st = CalendarItemState::new(CalendarItemKind::Previous).disabled(
625636
view.is_month() || (view.is_year() && !self.state.read(cx).has_prev_year_page()),
626637
);
627-
let mut item = CalendarItem::new("calendar-prev", st).child((self.label)(st.kind(), 0));
638+
let mut item =
639+
CalendarItem::new("calendar-prev", st).with_label((self.label)(st.kind(), 0));
628640
if !st.is_disabled() {
629641
let entity = self.state.clone();
630642
item = item.on_click(move |_, _window, cx| {
@@ -652,7 +664,7 @@ impl RenderOnce for Calendar {
652664
let st = CalendarItemState::new(kind).active(active);
653665
let entity = self.state.clone();
654666
let mut item = CalendarItem::new(format!("calendar-{kind:?}"), st)
655-
.child((self.label)(kind, value));
667+
.with_label((self.label)(kind, value));
656668
item = item.on_click(move |_, _, cx| {
657669
entity.update(cx, |s, cx| {
658670
s.set_view(
@@ -692,7 +704,8 @@ impl RenderOnce for Calendar {
692704
let st = CalendarItemState::new(CalendarItemKind::Next).disabled(
693705
view.is_month() || (view.is_year() && !self.state.read(cx).has_next_year_page()),
694706
);
695-
let mut item = CalendarItem::new("calendar-next", st).child((self.label)(st.kind(), 0));
707+
let mut item =
708+
CalendarItem::new("calendar-next", st).with_label((self.label)(st.kind(), 0));
696709
if !st.is_disabled() {
697710
let entity = self.state.clone();
698711
item = item.on_click(move |_, _, cx| {
@@ -752,7 +765,8 @@ impl RenderOnce for Calendar {
752765
};
753766
let mut item =
754767
CalendarItem::new(format!("calendar-{date}-{offset}-{week_index}"), st)
755-
.child((self.label)(st.kind(), date.day() as i32));
768+
.with_label((self.label)(st.kind(), date.day() as i32))
769+
.aria_label(date.to_string());
756770
if !st.is_disabled() {
757771
let entity = self.state.clone();
758772
item = item.on_click(move |_, _, cx| {
@@ -773,7 +787,7 @@ impl RenderOnce for Calendar {
773787
let st = CalendarItemState::new(CalendarItemKind::Month).active(month == current);
774788
let entity = self.state.clone();
775789
let item = CalendarItem::new(format!("calendar-month-{month}"), st)
776-
.child((self.label)(st.kind(), month as i32))
790+
.with_label((self.label)(st.kind(), month as i32))
777791
.on_click(move |_, _, cx| {
778792
entity.update(cx, |s, cx| {
779793
s.select_month(month);
@@ -789,7 +803,7 @@ impl RenderOnce for Calendar {
789803
let st = CalendarItemState::new(CalendarItemKind::Year).active(year == current);
790804
let entity = self.state.clone();
791805
let item = CalendarItem::new(format!("calendar-year-{year}"), st)
792-
.child((self.label)(st.kind(), year))
806+
.with_label((self.label)(st.kind(), year))
793807
.on_click(move |_, _, cx| {
794808
entity.update(cx, |s, cx| {
795809
s.select_year(year);
@@ -838,6 +852,55 @@ mod tests {
838852

839853
use super::*;
840854

855+
#[gpui::test]
856+
fn calendar_items_expose_names_in_every_view(cx: &mut gpui::TestAppContext) {
857+
use gpui::{Element as _, accesskit};
858+
struct Names {
859+
calendar: Entity<CalendarState>,
860+
labels: Rc<RefCell<Vec<(CalendarItemKind, String)>>>,
861+
}
862+
impl Render for Names {
863+
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
864+
let labels = self.labels.clone();
865+
labels.borrow_mut().clear();
866+
Calendar::new("named-calendar", &self.calendar).item(move |item, state, _, _| {
867+
let mut node = accesskit::Node::new(accesskit::Role::Button);
868+
item.base.write_a11y_info(&mut node);
869+
labels
870+
.borrow_mut()
871+
.push((state.kind(), node.label().unwrap_or("").to_string()));
872+
item.into_any_element()
873+
})
874+
}
875+
}
876+
let (view, cx) = cx.add_window_view(|window, cx| Names {
877+
calendar: cx.new(|cx| CalendarState::new(window, cx)),
878+
labels: Rc::new(RefCell::new(Vec::new())),
879+
});
880+
for mode in [CalendarView::Day, CalendarView::Month, CalendarView::Year] {
881+
cx.update(|window, cx| {
882+
view.read(cx).calendar.clone().update(cx, |state, cx| {
883+
state.apply_date(Date::Single(Some(
884+
NaiveDate::from_ymd_opt(2026, 9, 7).unwrap(),
885+
)));
886+
state.set_view(mode);
887+
cx.notify();
888+
});
889+
window.draw(cx).clear(cx);
890+
let labels = view.read(cx).labels.borrow();
891+
assert!(!labels.is_empty());
892+
for (kind, label) in labels.iter() {
893+
assert!(!label.is_empty(), "missing name for {kind:?}");
894+
}
895+
if mode.is_day() {
896+
assert!(labels.iter().any(
897+
|(kind, label)| *kind == CalendarItemKind::Day && label == "2026-09-07"
898+
));
899+
}
900+
});
901+
}
902+
}
903+
841904
struct EventHarness {
842905
calendar: Entity<CalendarState>,
843906
events: Rc<RefCell<Vec<Date>>>,

0 commit comments

Comments
 (0)