more accessibility improvements and frontend tests

This commit is contained in:
Aran-Fey
2025-04-02 13:24:19 +02:00
parent 46a2477fbf
commit d8c0d2d138
17 changed files with 232 additions and 44 deletions

View File

@@ -11,6 +11,7 @@ type AbstractButtonState = ComponentState & {
color: ColorSet;
content: ComponentId;
is_sensitive: boolean;
accessibility_label: string | null;
};
abstract class AbstractButtonComponent extends ComponentBase<AbstractButtonState> {
@@ -30,6 +31,7 @@ abstract class AbstractButtonComponent extends ComponentBase<AbstractButtonState
// Create the element
let element = document.createElement("div");
element.classList.add("rio-button");
element.role = "button";
this.childContainer = document.createElement("div");
element.appendChild(this.childContainer);
@@ -122,6 +124,18 @@ abstract class AbstractButtonComponent extends ComponentBase<AbstractButtonState
"rio-insensitive",
!deltaState.is_sensitive
);
this.buttonElement.ariaDisabled = deltaState.is_sensitive
? "false"
: "true";
}
// Accessibility label
if (deltaState.accessibility_label !== undefined) {
if (deltaState.accessibility_label === null) {
this.buttonElement.removeAttribute("aria-label");
} else {
this.buttonElement.ariaLabel = deltaState.accessibility_label;
}
}
}
}

View File

@@ -31,7 +31,7 @@ export class CalendarComponent extends ComponentBase<CalendarState> {
// These store the displayed year and month. This is in contrast to the
// *selected* year and month, which are stored in the state.
private displayedYear: number;
private displayedMonth: number; // [1, 12]
private displayedMonth: number; // 1 to 12
createElement(): HTMLElement {
// Create the HTML structure

View File

@@ -8,8 +8,8 @@ type MouseButton = "left" | "middle" | "right";
export type PointerEventListenerState = ComponentState & {
_type_: "PointerEventListener-builtin";
content: ComponentId;
reportPress: MouseButton[];
reportDoublePress: MouseButton[];
reportPress: boolean;
reportDoublePress: boolean;
reportPointerDown: MouseButton[];
reportPointerUp: MouseButton[];
reportPointerMove: boolean;
@@ -50,7 +50,7 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
let reportDoublePress =
deltaState.reportDoublePress ?? this.state.reportDoublePress;
if (reportPress.length > 0 || reportDoublePress.length > 0) {
if (reportPress || reportDoublePress) {
this.element.onclick = this._onClick.bind(this);
} else {
this.element.onclick = null;
@@ -129,8 +129,13 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
private _onClick(event: MouseEvent): void {
// This handler is responsible for both single clicks and double clicks
// If it's not a left-click, ignore it
if (event.button !== 0) {
return;
}
// Double click
if (eventMatchesButton(event, this.state.reportDoublePress)) {
if (this.state.reportDoublePress) {
let timeout = this._doubleClickTimeoutByButton[event.button];
if (timeout === undefined) {
@@ -140,7 +145,7 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
window.setTimeout(() => {
// Send a "press" event and clear the timeout so that
// the next press starts a new timeout
if (eventMatchesButton(event, this.state.reportPress)) {
if (this.state.reportPress) {
this._sendEventToBackend("press", event, false);
}
@@ -165,7 +170,7 @@ export class PointerEventListenerComponent extends ComponentBase<PointerEventLis
// We know that there's no double click handler for this button, so we
// can just send a "press" event without worrying about anything else
if (eventMatchesButton(event, this.state.reportPress)) {
if (this.state.reportPress) {
this._sendEventToBackend("press", event, false);
}
}

View File

@@ -22,6 +22,7 @@ export class SliderComponent extends ComponentBase<SliderState> {
// Create the HTML structure
let element = document.createElement("div");
element.classList.add("rio-slider");
element.role = "slider";
element.innerHTML = `
<div class="rio-slider-column">
<div class="rio-slider-inner">
@@ -171,12 +172,19 @@ export class SliderComponent extends ComponentBase<SliderState> {
this.minValueElement.textContent = minimum.toFixed(2);
this.maxValueElement.textContent = maximum.toFixed(2);
// Update accessibility properties
this.element.ariaValueMin = minimum.toString();
this.element.ariaValueMax = maximum.toString();
this.element.ariaValueNow = value.toString();
}
if (deltaState.is_sensitive === true) {
this.element.classList.remove("rio-disabled-input");
this.element.ariaDisabled = "false";
} else if (deltaState.is_sensitive === false) {
this.element.classList.add("rio-disabled-input");
this.element.ariaDisabled = "true";
}
if (deltaState.show_values === true) {

View File

@@ -58,10 +58,12 @@ export class SwitchComponent extends ComponentBase<SwitchState> {
if (deltaState.is_sensitive === true) {
this.element.classList.remove("rio-switcheroo-disabled");
let checkbox = this.element.querySelector("input");
checkbox!.disabled = false;
} else if (deltaState.is_sensitive === false) {
this.element.classList.add("rio-switcheroo-disabled");
let checkbox = this.element.querySelector("input");
checkbox!.disabled = true;
}

View File

@@ -10,8 +10,8 @@ dependencies = [
"crawlerdetect>=0.1.7,<0.4",
"fastapi>=0.110,<0.116",
"gitignore-parser>=0.1.11,<0.2",
"imy[docstrings,deprecations]>=0.7post0,<0.8",
"introspection>=1.9.9,<2.0",
"imy[docstrings,deprecations]>=0.7.1,<0.8",
"introspection>=1.9.11,<2.0",
"isort>=5.13,<7.0",
"langcodes>=3.4,<4.0",
"multipart>=1.2,<2.0",

View File

@@ -64,6 +64,10 @@ class Button(Component):
`on_press`: Triggered when the user clicks on the button.
`accessibility_label`: A short text description of the button for screen
readers. If omitted and the `content` is a string, the `content` is
used as the label.
## Examples
@@ -123,6 +127,7 @@ class Button(Component):
is_sensitive: bool = True
is_loading: bool = False
on_press: rio.EventHandler[[]] = None
accessibility_label: str | None = None
def build(self) -> rio.Component:
# Prepare the child
@@ -195,6 +200,7 @@ class Button(Component):
is_loading=self.is_loading,
min_width=8 if isinstance(self.content, str) else 0,
min_height=2.2,
accessibility_label=self.accessibility_label,
)
def __str__(self) -> str:
@@ -217,6 +223,7 @@ class _ButtonInternal(FundamentalComponent):
color: rio.ColorSet
is_sensitive: bool
is_loading: bool
accessibility_label: str | None
def _custom_serialize_(self) -> JsonDoc:
if self.style == "plain":
@@ -231,7 +238,13 @@ class _ButtonInternal(FundamentalComponent):
"style": "plain-text",
}
return {}
accessibility_label = self.accessibility_label
if accessibility_label is None:
content = self.content
if isinstance(content, str):
accessibility_label = content
return {"accessibility_label": accessibility_label}
async def _on_message_(self, msg: t.Any) -> None:
# Parse the message

View File

@@ -286,9 +286,7 @@ class Component(abc.ABC, metaclass=ComponentMeta):
_: dataclasses.KW_ONLY
# Unfortunately we have to inline the `Key` type here because dataclasses
# will create constructor signatures where `Key` can't be resolved.
key: str | int | None = internal_field(default=None, init=True)
key: Key | None = internal_field(default=None, init=True)
min_width: float = 0
min_height: float = 0

View File

@@ -205,6 +205,7 @@ class DateInput(Component):
on_confirm=self._on_confirm,
style=self.style,
min_width=11,
accessibility_label=self.accessibility_label,
),
rio.Icon(
"material/calendar_today:fill",

View File

@@ -57,6 +57,9 @@ class IconButton(Component):
`on_press`: Triggered when the user clicks on the button.
`accessibility_label`: A short text describing the purpose of the button for
screen readers. If omitted, the icon name is used.
## Examples
@@ -114,6 +117,7 @@ class IconButton(Component):
is_sensitive: bool
min_size: float
on_press: rio.EventHandler[[]]
accessibility_label: str | None
def __init__(
self,
@@ -139,7 +143,9 @@ class IconButton(Component):
align_x: float | None = None,
align_y: float | None = None,
# SCROLLING-REWORK scroll_x: t.Literal["never", "auto", "always"] = "never",
# SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] = "never",
# SCROLLING-REWORK scroll_y: t.Literal["never", "auto", "always"] =
# "never",
accessibility_label: str | None = None,
accessibility_role: AccessibilityRole | None = None,
) -> None:
super().__init__(
@@ -166,8 +172,15 @@ class IconButton(Component):
self.color = color
self.is_sensitive = is_sensitive
self.on_press = on_press
self.accessibility_label = accessibility_label
def build(self) -> rio.Component:
accessibility_label = self.accessibility_label
if accessibility_label is None:
accessibility_label = self.icon.partition("/")[-1]
accessibility_label = accessibility_label.partition(":")[-1]
accessibility_label = accessibility_label.replace("_", " ")
return _IconButtonInternal(
on_press=self.on_press,
content=rio.Icon(self.icon, min_width=0, min_height=0),
@@ -176,6 +189,7 @@ class IconButton(Component):
is_sensitive=self.is_sensitive,
min_width=self.min_size,
min_height=self.min_size,
accessibility_label=accessibility_label,
)
def _get_debug_details_(self) -> dict[str, t.Any]:
@@ -194,6 +208,7 @@ class _IconButtonInternal(FundamentalComponent):
color: rio.ColorSet
is_sensitive: bool
on_press: rio.EventHandler[[]]
accessibility_label: str | None
shape: t.Literal["circle"] = "circle"
def _custom_serialize_(self) -> JsonDoc:

View File

@@ -81,10 +81,10 @@ class PointerEvent:
assert result.pointer_type in ("mouse", "touch")
assert result.button is None or result.button in MOUSE_BUTTONS
assert isinstance(result.window_x, float)
assert isinstance(result.window_y, float)
assert isinstance(result.component_x, float)
assert isinstance(result.component_y, float)
assert isinstance(result.window_x, (int, float))
assert isinstance(result.window_y, (int, float))
assert isinstance(result.component_x, (int, float))
assert isinstance(result.component_y, (int, float))
return result
@@ -215,15 +215,15 @@ class PointerEventListener(FundamentalComponent):
# Dispatch the correct event
if msg_type == "press":
await self._call_appropriate_event_handler(
self.on_press,
PointerEvent._from_message(msg),
)
event = PointerEvent._from_message(msg)
assert event.button == "left"
await self.call_event_handler(self.on_press, event)
elif msg_type == "doublePress":
event = PointerEvent._from_message(msg)
assert event.button == "left"
await self._call_appropriate_event_handler(
self.on_double_press,
PointerEvent._from_message(msg),
self.on_double_press, event
)
elif msg_type == "pointerDown":

View File

@@ -15,6 +15,7 @@ from .fundamental_component import FundamentalComponent
__all__ = [
"SwitcherBarChangeEvent",
"SwitcherBar",
"SwitcherBarItem",
]
T = t.TypeVar("T")
@@ -39,6 +40,13 @@ class SwitcherBarChangeEvent(t.Generic[T]):
value: T | None
@dataclasses.dataclass
class SwitcherBarItem(t.Generic[T]):
value: T
name: str
icon: str | None = None
@t.final
class SwitcherBar(FundamentalComponent, t.Generic[T]):
"""

View File

@@ -1,24 +1,27 @@
import dataclasses
import typing as t
import typing_extensions as te
import rio
from .component import AccessibilityRole, Component, Key
__all__ = ["Tab", "Tabs"]
__all__ = ["TabItem", "Tabs"]
class Tab(t.TypedDict):
@dataclasses.dataclass(frozen=True)
class TabItem:
name: str
icon: te.NotRequired[str]
content: Component
icon: str | None = None
class Tabs(Component):
tabs: tuple[Tab, ...]
tabs: t.Sequence[TabItem]
active_tab_index: int = 0
def __init__(
self,
*tabs: Tab,
*tabs: TabItem,
key: Key | None = None,
margin: float | None = None,
margin_x: float | None = None,
@@ -62,3 +65,20 @@ class Tabs(Component):
)
self.tabs = tabs
def build(self) -> Component:
try:
content = self.tabs[self.active_tab_index].content
except IndexError:
content = rio.Spacer()
return rio.Column(
rio.SwitcherBar(
*[
rio.SwitcherBarItem(tab.name, icon=tab.icon, value=index)
for index, tab in enumerate(self.tabs)
],
selected_value=self.active_tab_index,
),
rio.Container(content, grow_y=True),
)

View File

@@ -9,6 +9,7 @@ from uniserde import JsonDoc
import rio
import rio.app_server
from ..components.component import Key
from ..transports import MessageRecorderTransport
__all__ = ["BaseClient"]
@@ -191,22 +192,32 @@ class BaseClient(abc.ABC):
def root_component(self) -> rio.Component:
return self.session._get_user_root_component()
def get_components(self, component_type: type[C]) -> t.Iterator[C]:
def get_components(
self,
component_type: type[C] = rio.Component,
key: Key | None = None,
) -> t.Iterator[C]:
roots = [self.root_component]
for root_component in roots:
for component in root_component._iter_component_tree_():
if type(component) is component_type:
yield component # type: ignore
if isinstance(component, component_type) and (
key is None or key == component.key
):
yield component
roots.extend(
dialog._root_component
for dialog in component._owned_dialogs_.values()
)
def get_component(self, component_type: type[C]) -> C:
def get_component(
self,
component_type: type[C] = rio.Component,
key: Key | None = None,
) -> C:
try:
return next(self.get_components(component_type))
return next(self.get_components(component_type, key=key))
except StopIteration:
raise ValueError(f"No component of type {component_type} found")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import contextlib
import sys
import typing as t
import asyncio_atexit
@@ -11,6 +12,7 @@ import uvicorn
import rio.app_server
import rio.data_models
from rio.app_server import FastapiServer
from rio.components.component import Key
from rio.components.text import Text
from rio.transports import FastapiWebsocketTransport, MultiTransport
from rio.utils import choose_free_port
@@ -20,11 +22,12 @@ from .base_client import BaseClient
__all__ = ["BrowserClient", "prepare_browser_client"]
# For debugging. Set this to a number > 0 if you want to look at the browser.
# When the debugger is active, we'll enable debugging features like making the
# browser visible, visualizing clicks, etc.
#
# Note: Chrome's console doesn't show `console.debug` messages per default. To
# see them, click on "All levels" and check "Verbose".
DEBUG_SHOW_BROWSER_DURATION = 0
DEBUGGER_ACTIVE = sys.gettrace() is not None
server_manager: ServerManager | None = None
@@ -73,6 +76,7 @@ async def prepare_browser_client():
class BrowserClient(BaseClient):
def __post_init__(self) -> None:
self._page: playwright.async_api.Page | None = None
self._page_closed_event = asyncio.Event()
@property
def playwright_page(self) -> playwright.async_api.Page:
@@ -93,7 +97,24 @@ class BrowserClient(BaseClient):
return self._page.viewport_size["height"]
async def click(self, x: float, y: float, *, sleep: float = 0.1) -> None:
async def wait_for_component_to_exist(
self,
component_type: type[rio.Component] = rio.Component,
key: Key | None = None,
) -> None:
assert self._page is not None
component = self.get_component(key=key, component_type=component_type)
await self._page.wait_for_selector(f'[dbg-id="{component._id_}"]')
async def click(
self,
x: float,
y: float,
*,
button: t.Literal["left", "middle", "right"] = "left",
sleep: float = 0.1,
) -> None:
assert self._page is not None
if isinstance(x, float) and x <= 1:
@@ -102,9 +123,10 @@ class BrowserClient(BaseClient):
if isinstance(y, float) and y <= 1:
y = round(y * self._window_height_in_pixels)
if DEBUG_SHOW_BROWSER_DURATION > 0:
if DEBUGGER_ACTIVE:
await self.execute_js(f"""
const marker = document.createElement('div');
marker.style.pointerEvents = 'none';
marker.style.background = 'red';
marker.style.borderRadius = '50%';
marker.style.position = 'absolute';
@@ -121,9 +143,20 @@ class BrowserClient(BaseClient):
}}, {sleep} * 1000);
""")
await self._page.mouse.click(x, y)
await self._page.mouse.click(x, y, button=button)
await asyncio.sleep(sleep)
async def double_click(
self,
x: float,
y: float,
*,
button: t.Literal["left", "middle", "right"] = "left",
sleep: float = 0.1,
) -> None:
await self.click(x, y, button=button, sleep=0.1)
await self.click(x, y, button=button, sleep=sleep)
async def execute_js(self, js: str) -> t.Any:
assert self._page is not None
return await self._page.evaluate(js)
@@ -148,6 +181,8 @@ class BrowserClient(BaseClient):
)
self._page = await manager.new_page()
self._page.on("close", lambda _: self._page_closed_event.set())
await self._page.goto(f"http://localhost:{manager.port}")
while True:
@@ -157,8 +192,8 @@ class BrowserClient(BaseClient):
await asyncio.sleep(0.1)
async def __aexit__(self, *args: t.Any) -> None:
# Sleep to keep the browser open for debugging
await asyncio.sleep(DEBUG_SHOW_BROWSER_DURATION)
if DEBUGGER_ACTIVE:
await self._page_closed_event.wait()
if self._page is not None:
await self._page.close()
@@ -273,7 +308,7 @@ class ServerManager:
try:
self._browser = await self._playwright.chromium.launch(
headless=DEBUG_SHOW_BROWSER_DURATION == 0
headless=not DEBUGGER_ACTIVE
)
except Exception:
raise Exception(

View File

@@ -4,7 +4,7 @@ import typing as t
import pytest
import rio
from tests.utils.layouting import BrowserClient
from rio.testing import BrowserClient
class DialogOpener(rio.Component):

View File

@@ -0,0 +1,58 @@
import typing as t
import pytest
import rio
from rio.testing import BrowserClient
@pytest.mark.parametrize("button", ["left", "middle", "right"])
async def test_on_press_event(
button: t.Literal["left", "middle", "right"],
) -> None:
event: rio.PointerEvent | None = None
def on_press(e: rio.PointerEvent):
nonlocal event
event = e
def build():
return rio.PointerEventListener(rio.Spacer(), on_press=on_press)
async with BrowserClient(build) as client:
await client.click(0.5, 0.5, button=button, sleep=0.5)
if button != "left":
assert event is None
return
assert event is not None
assert event.button == "left"
assert event.pointer_type == "mouse"
@pytest.mark.parametrize("button", ["left", "middle", "right"])
async def test_on_double_press_event(
button: t.Literal["left", "middle", "right"],
) -> None:
event: rio.PointerEvent | None = None
def on_double_press(e: rio.PointerEvent):
nonlocal event
event = e
def build():
return rio.PointerEventListener(
rio.Spacer(), on_double_press=on_double_press
)
async with BrowserClient(build) as client:
await client.double_click(0.5, 0.5, button=button, sleep=0.5)
if button != "left":
assert event is None
return
assert event is not None
assert event.button == "left"
assert event.pointer_type == "mouse"