a11y: a skip link, color-blind safe charts
i18n::SkipLink holds a Tab stop in flow at zero size, first where it sits, and while focused raises a pill over the page; Enter, Space or a press hands focus to its target. gpui orders stops by tab_index before paint and registers none for an invisible element, so the stop stays in flow and only the pill is raised. Theme::set_color_blind_safe swaps Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page, and red against green shows the measure bites. KeyboardNavigationHelper points to interaction::RovingFocus. The announcer, labels and screen-reader text stay blocked: gpui 0.2.2 has no accessibility tree, and nothing here is trusted to observe VoiceOver. The i18n tests move under src/i18n/tests.
This commit is contained in:
1 parent
aa39fba87f
commit
1ac5c04fb0
10 files changed
+463
-7
No files matched your search
@@ -191,6 +191,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
- The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike.
|
||||||
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
- Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`).
|
||||||
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
- Messages go through `i18n::I18n`, a global the app sets: `cx.global::<I18n>().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment.
|
||||||
|
- gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it.
|
||||||
|
- Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone).
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use ely_gpui_component::{
|
||||||
|
buttons::{Button, ButtonVariant},
|
||||||
|
forms::Switch,
|
||||||
|
i18n::SkipLink,
|
||||||
|
theme::{ActiveTheme, HUE_NAMES, Radius, Theme},
|
||||||
|
};
|
||||||
|
use gpui::{
|
||||||
|
App, ElementId, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, Window,
|
||||||
|
div, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::probe::probe;
|
||||||
|
use crate::ui::{blocked, section, specimen, specimens};
|
||||||
|
|
||||||
|
const NAV: [&str; 5] = ["Home", "Docs", "Pricing", "Blog", "About"];
|
||||||
|
|
||||||
|
pub fn skip_link(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||||
|
let main = window
|
||||||
|
.use_keyed_state("skip-main", cx, |_, cx| cx.focus_handle())
|
||||||
|
.read(cx)
|
||||||
|
.clone();
|
||||||
|
let theme = cx.theme();
|
||||||
|
let nav = NAV.map(|name| {
|
||||||
|
let id = ElementId::Name(SharedString::from(format!("skip-nav-{name}")));
|
||||||
|
Button::new(id, name).variant(ButtonVariant::Ghost)
|
||||||
|
});
|
||||||
|
section(
|
||||||
|
"SkipLink · KeyboardNavigationHelper",
|
||||||
|
"A link that shows only while focused and moves focus past the nav to the main part: Tab into the card, then Enter. Arrows among a group's items live in interaction::RovingFocus.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
probe(
|
||||||
|
"skip-demo",
|
||||||
|
div()
|
||||||
|
.max_w(px(480.0))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.rounded(theme.radius(Radius::Lg))
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.colors.border)
|
||||||
|
.child(SkipLink::new("skip", "Skip to content", &main))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap_1()
|
||||||
|
.p_2()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(theme.colors.border)
|
||||||
|
.children(nav),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.track_focus(&main)
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_start()
|
||||||
|
.gap_3()
|
||||||
|
.p_4()
|
||||||
|
.text_color(theme.colors.fg_muted)
|
||||||
|
.child("The main part starts here.")
|
||||||
|
.child(Button::new("skip-read", "Read the guide")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(blocked(
|
||||||
|
"LiveRegion / Announcer, AccessibleIcon, AriaLabel, ScreenReaderOnly: gpui 0.2.2 exposes no accessibility tree, so no label or live region reaches a screen reader.",
|
||||||
|
cx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn color_blind(cx: &App) -> impl IntoElement + use<> {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let on = theme.color_blind_safe();
|
||||||
|
let swatches = HUE_NAMES
|
||||||
|
.iter()
|
||||||
|
.zip(theme.colors.chart)
|
||||||
|
.map(|(name, color)| {
|
||||||
|
specimen(
|
||||||
|
*name,
|
||||||
|
div()
|
||||||
|
.size(px(40.0))
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.bg(color),
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
section(
|
||||||
|
"ColorBlindSafe Palette",
|
||||||
|
"Ely's chart hues share one lightness, so protanopia, deuteranopia and tritanopia run some together. The safe hues differ in lightness too: every pair stays apart for each, and each is 3:1 on its page.",
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.child(probe(
|
||||||
|
"color-blind",
|
||||||
|
Switch::new("color-blind", on)
|
||||||
|
.label("Color-blind safe charts")
|
||||||
|
.on_change(|on, _, cx| Theme::set_color_blind_safe(on, cx)),
|
||||||
|
))
|
||||||
|
.child(specimens().gap_3().children(swatches))
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use ely_gpui_component::{
|
|||||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||||
use jiff::{Timestamp, tz::TimeZone};
|
use jiff::{Timestamp, tz::TimeZone};
|
||||||
|
|
||||||
|
mod a11y;
|
||||||
|
|
||||||
use super::Page;
|
use super::Page;
|
||||||
use crate::probe::probe;
|
use crate::probe::probe;
|
||||||
use crate::script::Step;
|
use crate::script::Step;
|
||||||
@@ -29,6 +31,19 @@ pub const PAGE: Page = Page {
|
|||||||
Step::Key("home"),
|
Step::Key("home"),
|
||||||
Step::Key("enter"),
|
Step::Key("enter"),
|
||||||
Step::Wait(300),
|
Step::Wait(300),
|
||||||
|
Step::Key("tab"),
|
||||||
|
Step::HoverAt("skip-demo", 300.0, 70.0),
|
||||||
|
Step::Wait(300),
|
||||||
|
Step::Shot("skip-link"),
|
||||||
|
Step::Key("enter"),
|
||||||
|
Step::Key("tab"),
|
||||||
|
Step::Wait(300),
|
||||||
|
Step::Shot("skipped"),
|
||||||
|
Step::Click("color-blind"),
|
||||||
|
Step::Wait(300),
|
||||||
|
Step::Shot("color-blind"),
|
||||||
|
Step::Click("color-blind"),
|
||||||
|
Step::Wait(300),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,11 +120,13 @@ pub fn install(cx: &mut App) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
|
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.child(messages(cx))
|
.child(messages(cx))
|
||||||
.child(formats(cx))
|
.child(formats(cx))
|
||||||
.child(mirrored(cx))
|
.child(mirrored(cx))
|
||||||
|
.child(a11y::skip_link(window, cx))
|
||||||
|
.child(a11y::color_blind(cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod locale;
|
mod locale;
|
||||||
|
mod skip;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ use std::collections::HashMap;
|
|||||||
use gpui::{App, Global, SharedString};
|
use gpui::{App, Global, SharedString};
|
||||||
|
|
||||||
pub use locale::{Direction, LOCALES, Locale};
|
pub use locale::{Direction, LOCALES, Locale};
|
||||||
|
pub use skip::SkipLink;
|
||||||
|
|
||||||
type Catalog = HashMap<&'static str, &'static str>;
|
type Catalog = HashMap<&'static str, &'static str>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use gpui::{
|
||||||
|
App, ElementId, FocusHandle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||||
|
SharedString, StatefulInteractiveElement, Styled, Window, anchored, div, prelude::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
primitives::{raise, tab_stop},
|
||||||
|
theme::{ActiveTheme, Elevation, Radius, TextSize},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A link that shows only while focused and hands focus to `target`. Put it before what a keyboard would otherwise tab through, such as a long nav; it takes no room at rest and lies over the page while shown.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct SkipLink {
|
||||||
|
id: ElementId,
|
||||||
|
label: SharedString,
|
||||||
|
target: FocusHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkipLink {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<ElementId>,
|
||||||
|
label: impl Into<SharedString>,
|
||||||
|
target: &FocusHandle,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
target: target.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for SkipLink {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let handle = tab_stop(self.id.clone(), true, window, cx);
|
||||||
|
let shown = handle.is_focused(window);
|
||||||
|
let theme = cx.theme();
|
||||||
|
let (target, label) = (self.target, self.label.clone());
|
||||||
|
let skip = move |window: &mut Window| {
|
||||||
|
log::info!("skip link {label:?}: focus to its target");
|
||||||
|
window.focus(&target);
|
||||||
|
};
|
||||||
|
let press = skip.clone();
|
||||||
|
let pill = div()
|
||||||
|
.id((self.id.clone(), "pill"))
|
||||||
|
.occlude()
|
||||||
|
.px_3()
|
||||||
|
.py_1p5()
|
||||||
|
.rounded(theme.radius(Radius::Md))
|
||||||
|
.bg(theme.colors.overlay)
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.colors.focus)
|
||||||
|
.shadow(theme.elevation(Elevation::Floating))
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.text_color(theme.colors.fg)
|
||||||
|
.cursor_pointer()
|
||||||
|
.child(self.label)
|
||||||
|
.on_click(move |_, window, _| press(window));
|
||||||
|
div()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.track_focus(&handle)
|
||||||
|
.on_click(move |_, window, _| skip(window))
|
||||||
|
.when(shown, |link| {
|
||||||
|
link.child(raise(
|
||||||
|
self.id.clone(),
|
||||||
|
anchored().snap_to_window().child(div().p_2().child(pill)),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
use gpui::{
|
||||||
|
Context, FocusHandle, Hsla, InteractiveElement, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
|
||||||
|
Modifiers, ParentElement, Render, Rgba, StatefulInteractiveElement, Styled, TestAppContext,
|
||||||
|
VisualTestContext, Window, div, point, px, rgb,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
devtools::contrast,
|
||||||
|
i18n::SkipLink,
|
||||||
|
primitives::{FocusNext, FocusScope},
|
||||||
|
theme::{ActiveTheme, Mode, Theme},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Machado, Oliveira and Fernandes (2009) at severity 1, on linear sRGB.
|
||||||
|
const DICHROMACY: [(&str, [[f32; 3]; 3]); 3] = [
|
||||||
|
(
|
||||||
|
"protanopia",
|
||||||
|
[
|
||||||
|
[0.152286, 1.052583, -0.204868],
|
||||||
|
[0.114503, 0.786281, 0.099216],
|
||||||
|
[-0.003882, -0.048116, 1.051998],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"deuteranopia",
|
||||||
|
[
|
||||||
|
[0.367322, 0.860646, -0.227968],
|
||||||
|
[0.280085, 0.672501, 0.047413],
|
||||||
|
[-0.011820, 0.042940, 0.968881],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tritanopia",
|
||||||
|
[
|
||||||
|
[1.255528, -0.076749, -0.178779],
|
||||||
|
[-0.078411, 0.930809, 0.147602],
|
||||||
|
[0.004733, 0.691367, 0.303900],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A color in linear sRGB, as an eye with `matrix` sees it.
|
||||||
|
fn seen(color: Hsla, matrix: Option<&[[f32; 3]; 3]>) -> [f32; 3] {
|
||||||
|
let rgb = Rgba::from(color);
|
||||||
|
let linear = [rgb.r, rgb.g, rgb.b].map(|channel| {
|
||||||
|
if channel <= 0.04045 {
|
||||||
|
channel / 12.92
|
||||||
|
} else {
|
||||||
|
((channel + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match matrix {
|
||||||
|
None => linear,
|
||||||
|
Some(matrix) => matrix.map(|row| {
|
||||||
|
(row[0] * linear[0] + row[1] * linear[1] + row[2] * linear[2]).clamp(0.0, 1.0)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Björn Ottosson's Oklab.
|
||||||
|
fn oklab([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||||
|
let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt();
|
||||||
|
let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt();
|
||||||
|
let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt();
|
||||||
|
[
|
||||||
|
0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s,
|
||||||
|
1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s,
|
||||||
|
0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two nearest colors' Oklab distance, in hundredths, as `matrix` sees them.
|
||||||
|
fn nearest(colors: &[Hsla], matrix: Option<&[[f32; 3]; 3]>) -> f32 {
|
||||||
|
let labs: Vec<_> = colors
|
||||||
|
.iter()
|
||||||
|
.map(|&color| oklab(seen(color, matrix)))
|
||||||
|
.collect();
|
||||||
|
let mut near = f32::MAX;
|
||||||
|
for (ix, a) in labs.iter().enumerate() {
|
||||||
|
for b in &labs[ix + 1..] {
|
||||||
|
let distance = a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum::<f32>();
|
||||||
|
near = near.min(distance.sqrt() * 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
near
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Red and green, far apart for most eyes, run together for deuteranopia: the measure bites.
|
||||||
|
#[test]
|
||||||
|
fn red_and_green_run_together_for_deuteranopia() {
|
||||||
|
let pair = [rgb(0xd62728).into(), rgb(0x2ca02c).into()];
|
||||||
|
assert!(nearest(&pair, None) > 30.0);
|
||||||
|
assert!(nearest(&pair, Some(&DICHROMACY[1].1)) < 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color-blind safe charts change Ely's chart hues alone: every pair stays apart for each dichromacy, and each hue is 3:1 on its page.
|
||||||
|
#[gpui::test]
|
||||||
|
fn color_blind_safe_hues_stay_apart(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
for mode in [Mode::Light, Mode::Dark] {
|
||||||
|
cx.update(|cx| Theme::set_mode(mode, cx));
|
||||||
|
let own = cx.read(|cx| cx.theme().palette());
|
||||||
|
cx.update(|cx| Theme::set_color_blind_safe(true, cx));
|
||||||
|
let safe = cx.read(|cx| cx.theme().palette());
|
||||||
|
cx.update(|cx| Theme::set_color_blind_safe(false, cx));
|
||||||
|
let mut expected = own.clone();
|
||||||
|
expected.chart = safe.chart;
|
||||||
|
assert_eq!(safe, expected, "{mode:?}: only the chart changes");
|
||||||
|
assert_ne!(safe.chart, own.chart, "{mode:?}: the chart changes");
|
||||||
|
assert!(nearest(&safe.chart, None) >= 10.0, "{mode:?}");
|
||||||
|
for (name, matrix) in &DICHROMACY {
|
||||||
|
let near = nearest(&safe.chart, Some(matrix));
|
||||||
|
assert!(near >= 10.0, "{mode:?} for {name}: {near}");
|
||||||
|
}
|
||||||
|
for hue in safe.chart {
|
||||||
|
let ratio = contrast(hue, safe.bg);
|
||||||
|
assert!(ratio >= 3.0, "{mode:?}: {hue:?} at {ratio}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A nav of three stops between a skip link and the main part.
|
||||||
|
struct Page {
|
||||||
|
root: FocusHandle,
|
||||||
|
nav: [FocusHandle; 3],
|
||||||
|
main: FocusHandle,
|
||||||
|
inside: FocusHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Page {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let stop = |ix: usize, handle: &FocusHandle| {
|
||||||
|
div()
|
||||||
|
.id(("nav", ix))
|
||||||
|
.track_focus(handle)
|
||||||
|
.h(px(24.0))
|
||||||
|
.on_click(|_, _, _| {})
|
||||||
|
};
|
||||||
|
FocusScope::new(&self.root)
|
||||||
|
.size_full()
|
||||||
|
.child(SkipLink::new("skip", "Skip to content", &self.main))
|
||||||
|
.children(
|
||||||
|
self.nav
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ix, handle)| stop(ix, handle)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.track_focus(&self.main)
|
||||||
|
.child(div().track_focus(&self.inside).h(px(24.0))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||||
|
cx.simulate_keystrokes(key);
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.simulate_event(KeyUpEvent {
|
||||||
|
keystroke: Keystroke::parse(key).expect("a key"),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(cx: &mut TestAppContext) -> (gpui::Entity<Page>, &mut VisualTestContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||||
|
});
|
||||||
|
let (view, cx) = cx.add_window_view(|_, cx| Page {
|
||||||
|
root: cx.focus_handle(),
|
||||||
|
nav: [0, 1, 2].map(|_| cx.focus_handle().tab_stop(true)),
|
||||||
|
main: cx.focus_handle(),
|
||||||
|
inside: cx.focus_handle().tab_stop(true),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
(view, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused(
|
||||||
|
view: &gpui::Entity<Page>,
|
||||||
|
pick: fn(&Page) -> &FocusHandle,
|
||||||
|
cx: &mut VisualTestContext,
|
||||||
|
) -> bool {
|
||||||
|
let handle = view.read_with(cx, |page, _| pick(page).clone());
|
||||||
|
cx.update(|window, _| handle.is_focused(window))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab from the page's start reaches the skip link before the nav; Enter hands focus to the main part, and the next Tab lands inside it.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_skip_link_leads_past_the_nav(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = page(cx);
|
||||||
|
let root = view.read_with(cx, |page, _| page.root.clone());
|
||||||
|
cx.update(|window, _| window.focus(&root));
|
||||||
|
press("tab", cx);
|
||||||
|
assert!(
|
||||||
|
!focused(&view, |page| &page.nav[0], cx),
|
||||||
|
"the link comes first"
|
||||||
|
);
|
||||||
|
assert!(!focused(&view, |page| &page.root, cx));
|
||||||
|
press("enter", cx);
|
||||||
|
assert!(focused(&view, |page| &page.main, cx), "Enter skips");
|
||||||
|
press("tab", cx);
|
||||||
|
assert!(
|
||||||
|
focused(&view, |page| &page.inside, cx),
|
||||||
|
"Tab goes on inside"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link shows while focused, so a press at its place skips; at rest nothing lies there and a press reaches the nav.
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_skip_link_shows_only_while_focused(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = page(cx);
|
||||||
|
let spot = point(px(16.0), px(16.0));
|
||||||
|
cx.simulate_click(spot, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(
|
||||||
|
focused(&view, |page| &page.nav[0], cx),
|
||||||
|
"at rest the nav takes it"
|
||||||
|
);
|
||||||
|
let root = view.read_with(cx, |page, _| page.root.clone());
|
||||||
|
cx.update(|window, _| window.focus(&root));
|
||||||
|
press("tab", cx);
|
||||||
|
cx.simulate_click(spot, Modifiers::none());
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(
|
||||||
|
focused(&view, |page| &page.main, cx),
|
||||||
|
"the shown link skips"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ use jiff::{Timestamp, tz::TimeZone};
|
|||||||
|
|
||||||
use super::{Direction, I18n, LOCALES, Locale, fill};
|
use super::{Direction, I18n, LOCALES, Locale, fill};
|
||||||
|
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
mod a11y;
|
||||||
|
|
||||||
fn i18n() -> I18n {
|
fn i18n() -> I18n {
|
||||||
I18n::new("en-US")
|
I18n::new("en-US")
|
||||||
.catalog(
|
.catalog(
|
||||||
+19
-2
@@ -62,6 +62,7 @@ impl From<WindowAppearance> for Mode {
|
|||||||
pub struct Theme {
|
pub struct Theme {
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
high_contrast: bool,
|
high_contrast: bool,
|
||||||
|
color_blind_safe: bool,
|
||||||
transition: u64,
|
transition: u64,
|
||||||
custom: [Option<Palette>; 2],
|
custom: [Option<Palette>; 2],
|
||||||
pub colors: Palette,
|
pub colors: Palette,
|
||||||
@@ -91,6 +92,7 @@ impl Theme {
|
|||||||
cx.set_global(Theme {
|
cx.set_global(Theme {
|
||||||
mode: Mode::Light,
|
mode: Mode::Light,
|
||||||
high_contrast: false,
|
high_contrast: false,
|
||||||
|
color_blind_safe: false,
|
||||||
transition: 0,
|
transition: 0,
|
||||||
custom: [None, None],
|
custom: [None, None],
|
||||||
colors: Palette::light(false),
|
colors: Palette::light(false),
|
||||||
@@ -112,6 +114,10 @@ impl Theme {
|
|||||||
self.high_contrast
|
self.high_contrast
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn color_blind_safe(&self) -> bool {
|
||||||
|
self.color_blind_safe
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_dark(&self) -> bool {
|
pub fn is_dark(&self) -> bool {
|
||||||
self.mode == Mode::Dark
|
self.mode == Mode::Dark
|
||||||
}
|
}
|
||||||
@@ -129,7 +135,14 @@ impl Theme {
|
|||||||
Self::fade_to_target(cx);
|
Self::fade_to_target(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The owner's palette for `mode`, as an edited or imported theme gives, or none to bring Ely's back. The colors fade to it when `mode` shows. High contrast strengthens Ely's own palettes alone.
|
/// Chart hues that stay apart for color-blind eyes, in Ely's own palettes.
|
||||||
|
pub fn set_color_blind_safe(on: bool, cx: &mut App) {
|
||||||
|
log::info!("theme: color-blind safe charts -> {on}");
|
||||||
|
cx.global_mut::<Theme>().color_blind_safe = on;
|
||||||
|
Self::fade_to_target(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner's palette for `mode`, as an edited or imported theme gives, or none to bring Ely's back. The colors fade to it when `mode` shows. High contrast and color-blind safe charts change Ely's own palettes alone.
|
||||||
pub fn set_palette(mode: Mode, palette: Option<Palette>, cx: &mut App) {
|
pub fn set_palette(mode: Mode, palette: Option<Palette>, cx: &mut App) {
|
||||||
log::info!(
|
log::info!(
|
||||||
"theme: {mode:?} palette -> {}",
|
"theme: {mode:?} palette -> {}",
|
||||||
@@ -158,10 +171,14 @@ impl Theme {
|
|||||||
if let Some(palette) = &self.custom[self.mode as usize] {
|
if let Some(palette) = &self.custom[self.mode as usize] {
|
||||||
return palette.clone();
|
return palette.clone();
|
||||||
}
|
}
|
||||||
match self.mode {
|
let mut palette = match self.mode {
|
||||||
Mode::Light => Palette::light(self.high_contrast),
|
Mode::Light => Palette::light(self.high_contrast),
|
||||||
Mode::Dark => Palette::dark(self.high_contrast),
|
Mode::Dark => Palette::dark(self.high_contrast),
|
||||||
|
};
|
||||||
|
if self.color_blind_safe {
|
||||||
|
palette.chart = palette::color_blind_chart(self.mode);
|
||||||
}
|
}
|
||||||
|
palette
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fade_to_target(cx: &mut App) {
|
fn fade_to_target(cx: &mut App) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use gpui::{Hsla, Rgba, rgb, rgba};
|
use gpui::{Hsla, Rgba, rgb, rgba};
|
||||||
|
|
||||||
|
use super::Mode;
|
||||||
|
|
||||||
pub trait Mix {
|
pub trait Mix {
|
||||||
fn mix(&self, to: &Self, t: f32) -> Self;
|
fn mix(&self, to: &Self, t: f32) -> Self;
|
||||||
}
|
}
|
||||||
@@ -142,6 +144,18 @@ pub const HUE_NAMES: [&str; 8] = [
|
|||||||
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
|
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Chart hues each 3:1 on its mode's page that stay apart for protanopia, deuteranopia and tritanopia; `HUE_NAMES` still name them.
|
||||||
|
pub(crate) fn color_blind_chart(mode: Mode) -> [Hsla; 8] {
|
||||||
|
match mode {
|
||||||
|
Mode::Light => c8([
|
||||||
|
0x0240b1, 0x08a399, 0xcd8017, 0x844954, 0xa276e3, 0x184606, 0xb84f10, 0x20789d,
|
||||||
|
]),
|
||||||
|
Mode::Dark => c8([
|
||||||
|
0x3471e3, 0x57b6b2, 0xf6a537, 0xde545a, 0x695a91, 0xc4ecb7, 0xa93d0e, 0x52dafe,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Palette {
|
impl Palette {
|
||||||
/// The chart color at `hue`; a hue past the chart fails, naming `owner`.
|
/// The chart color at `hue`; a hue past the chart fails, naming `owner`.
|
||||||
pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla {
|
pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla {
|
||||||
|
|||||||
+4
-4
@@ -280,13 +280,13 @@
|
|||||||
|
|
||||||
## T40b a11y — `src/i18n`
|
## T40b a11y — `src/i18n`
|
||||||
|
|
||||||
- [ ] LiveRegion / Announcer — blocked: gpui 0.2.2 has no accessibility tree
|
- [ ] LiveRegion / Announcer — blocked: gpui 0.2.2 has no accessibility tree; AppKit's announcement request needs none, but no process here is trusted to observe VoiceOver take it
|
||||||
- [ ] SkipLink
|
- [x] SkipLink → `i18n::SkipLink`: a Tab stop in flow that shows only while focused, raised over the page, and hands focus to its target
|
||||||
- [ ] AccessibleIcon — blocked: no accessibility tree
|
- [ ] AccessibleIcon — blocked: no accessibility tree
|
||||||
- [ ] AriaLabel helper — blocked: no accessibility tree
|
- [ ] AriaLabel helper — blocked: no accessibility tree
|
||||||
- [ ] KeyboardNavigationHelper
|
- [x] KeyboardNavigationHelper → `interaction::RovingFocus`
|
||||||
- [ ] ScreenReaderOnly — blocked: no accessibility tree
|
- [ ] ScreenReaderOnly — blocked: no accessibility tree
|
||||||
- [ ] ColorBlindSafe Palette
|
- [x] ColorBlindSafe Palette → `Theme::set_color_blind_safe`: chart hues fitted to stay apart under Machado's simulated protanopia, deuteranopia and tritanopia, each 3:1 on its page
|
||||||
|
|
||||||
## T41 Maps — `src/maps`
|
## T41 Maps — `src/maps`
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user