Accessibility and Native Controls

Native controls bring familiar editing and selection behavior into a Pax interface: entering a note, choosing an option, moving a slider, or opening a photo library. They share the scene's layout with your text, drawings, and images, while the browser or operating system supplies their underlying controls.

This chapter builds on Properties and Event Handling. It starts with a small form, then covers keyboard interaction, accessibility, and photo selection. The current support section identifies differences to check when shipping on web, macOS, iOS, or iPadOS.

A small editable form

Let's add a settings form to Field notes. A name, a location preference, a visibility choice, and a detail level all belong to the component. Each control edits one of those properties through bind:. A Save button calls Rust when the reader is ready to apply their choices.

In src/lib.rs:

#![allow(unused)]
fn main() {
use pax_kit::*;

pax_kit::runnable! { root: crate::Example }

#[pax]
#[file("lib.pax")]
pub struct Example {
    pub name: Property<String>,
    pub include_location: Property<bool>,
    pub visibility: Property<u32>,
    pub detail: Property<f64>,
    pub message: Property<String>,
}

impl Example {
    pub fn save(&mut self, _ctx: &NodeContext, _event: Event<ButtonClick>) {
        let name = self.name.get();
        if name.trim().is_empty() {
            self.message.set("Give this notebook a name first.".into());
            return;
        }

        let visibility = match self.visibility.get() {
            1 => "shared",
            _ => "private",
        };
        let location = if self.include_location.get() { "on" } else { "off" };
        self.message.set(format!(
            "{}: {}, location {}, detail {:.1}.",
            name.trim(), visibility, location, self.detail.get()
        ));
    }
}
}

In src/lib.pax:

<Group x=24px y=24px width={100% - 48px} height=452px>
    <Text class="label" width=100% height=28px text="Notebook name" />
    <Textbox y=32px width=100% height=40px
        text=bind:self.name placeholder="e.g. Moss and rain" />

    <Checkbox y=92px width=24px height=24px
        checked=bind:self.include_location />
    <Text class="label" x=36px y=90px width={100% - 36px} height=28px
        text="Include location" />

    <Text class="label" y=140px width=100% height=28px text="Visibility" />
    <Dropdown y=172px width=100% height=40px
        options=["Private", "Shared"] selected_id=bind:self.visibility />

    <Text class="label" y=236px width=100% height=28px
        text={"Detail: " + self.detail} />
    <Slider y=268px width=100% height=32px
        min=0.0 max=1.0 step=0.1 value=bind:self.detail />

    <Button y=324px width=180px height=44px label="Save notebook"
        color=rgb(56, 100, 78) hover_color=rgb(43, 79, 60)
        @button_click=self.save />
    <Text class="label" y=388px width=100% height=64px text={self.message} />
</Group>

@settings {
    .label {
        style: {font: "Arial", font_size: 16px, fill: rgb(36, 54, 47)}
    }
}

Try saving before entering a name, then change the fields and save again. The detail label updates while the slider moves. The message changes only when Save runs. This sample reports the choices on screen; a real app would also persist or submit them from Rust.

bind:self.name connects the Textbox's editable property to the component's property. Typing updates both. A one-way expression such as text={self.name} supplies a value to the control without making it an editor for the parent property. Bind directly to state; put formatting and derived display values in separate expressions. See Two-way bindings for the underlying model.

The visible labels above help someone looking at the form. They are separate Text elements: Pax does not currently turn that proximity into a semantic label association for assistive technology. Keep the accessibility boundary in mind when adapting the form.

Choose a control

These controls are available through use pax_kit::*;. The event column lists control-specific bindings; ordinary pointer events are covered in Event Handling.

ControlEditable property or contentControl event
Buttonlabel supplies its visible title@button_click, with Event<ButtonClick>
Textboxtext: String@textbox_input while editing; @textbox_change when the platform commits a change
Checkboxchecked: bool@checkbox_change, with event.checked
Slidervalue: f64, bounded by min and max@slider_change, with event.value
Dropdownoptions: Vec<String> and selected_id: u32Bind selected_id; there is no dedicated public dropdown-change event
RadioListoptions: Vec<String> and selected_id: u32Bind selected_id; there is no dedicated public radio-list-change event

selected_id is a zero-based index into options, despite its name. The first option is index 0. Keep the index valid when replacing or reordering the options; map it to your application's own identifier in Rust when needed.

On both web and Apple chassis, native input updates the relevant control property before calling its TextboxInput, CheckboxChange, or SliderChange handler. A bound parent property therefore already contains the new value. Use an event handler for validation or side effects, without writing the same value back just to maintain the binding.

On web, @textbox_input follows the browser's input event, while @textbox_change follows change. Apple text controls report input during editing and change when editing ends. Use input for immediate feedback and an explicit Button for a submission action; do not assume a change event means Enter was pressed. Likewise, a slider-change handler can run repeatedly during a drag, so keep expensive work out of that immediate path.

Multiline text and styling

For a longer note, add a Property<String> named notes and use multiline=true:

<Textbox x=24px y=24px width={100% - 48px} height=140px
    multiline=true text=bind:self.notes
    background=rgb(249, 247, 239)
    stroke={paint: rgb(155, 170, 153), width: 1px}
    corner_radius=8
    style={font: "Arial", font_size: 18px, fill: rgb(36, 54, 47)} />

Give a multiline editor enough height for its contents. A placeholder is available for single-line Textbox on web and Apple, and multiline Textbox on web; the current Apple multiline views do not display one. Keep a persistent visible label when the field needs an explanation.

Native controls expose their own styling properties. Button uses color, hover_color, outline, corner_radius, and style; Textbox uses background, stroke, outline, corner_radius, and style. Checkbox has separate unchecked and checked backgrounds, while Slider exposes an accent. Consult the control's API for its exact property names and defaults. The platform still influences its appearance, especially focus and hover states.

An ordinary Button renders its own label and surface. For a custom composition, build a component and handle its interactions deliberately; adding a click handler to a drawing does not supply a button's keyboard and assistive-technology behavior. PhotoPicker's slotted design below is a specific API for a custom visible affordance.

Filtered selection with ComboBox

ComboBox combines an editable Textbox with a Pax list. Add these fields to your form component, initializing them in its Default implementation if it has one:

#![allow(unused)]
fn main() {
pub query: Property<String>,
pub choice: Property<Option<usize>>,
}

Use Property::new(String::new()) and Property::new(None) for an initially empty control. Then place it in the template:

<ComboBox width=240px height=36px
    options=["Alpha", "Beta", "Gamma"]
    text=bind:self.query selected=bind:self.choice
    background=WHITE stroke={color: rgb(155, 170, 153), width: 1px}
    style={font_size: 16px, fill: rgb(36, 54, 47)} />

Click or tap the input to open the list, then type to filter it. Matching is case-sensitive and uses substrings; prefix matches appear first, with alphabetical order within each group. Selecting a row commits its original zero-based index in options, copies its label to query, and closes the list. For example, filtering to Gamma and choosing it sets choice to Some(2), even though only one row is visible. Separate ComboBox instances keep their own selection unless you explicitly bind them to shared state.

query is the editable text, while choice is the last committed selection. Typing, including an exact label match, does not select a row or clear the previous selection. Leaving the field does not commit it either. Do not treat a nonempty query as a valid selection or assume it still matches the last choice. The clear icon resets both values.

Changing options updates the label at the selected index. An out-of-range index becomes None and clears the text. Duplicate labels are supported, but reordering the source list keeps the index, not an application's item identity. If records have stable IDs, translate between those IDs and option indices in your application when updating the list.

new_item controls the no-match row:

ValueBehavior
NewItem::Disallow (default)Shows a disabled “No Results Found” row. It does not prevent typing unmatched text.
NewItem::AllowInvalidShows no no-match row and preserves initial free text when no option is selected. It does not commit a new selection.
NewItem::Text("Create item")Shows a custom row that dispatches @new_item when clicked or tapped.

For the custom row, set new_item=NewItem::Text("Create item") and @new_item=self.create_item on ComboBox. Define pub fn create_item(&mut self, ctx: &NodeContext) on the containing component. The handler reads the bound query and decides whether to add an option and select it. The event carries no payload and does not add anything automatically. See custom events.

The composed list supports pointer selection on web and Apple chassis, but it does not implement arrow-key/Enter list selection or a complete accessible combobox contract. Native text editing does not supply those list behaviors. Use Dropdown when a native fixed-choice control fits the task, and evaluate the accessibility needs of any editable selector. Tabs and dialog components are also compositions; their presence does not imply an operating-system widget or a complete accessible interaction contract.

Focus and keyboard interaction

Native text fields own editing, selection, and keyboard input while focused. On macOS, native rendering may detach distant Text and NativeImage views inside eligible scrollers, while retaining their instances. Editable, focused, and selected text and form controls stay attached. VoiceOver or Switch Control disables this culling and restores the full hierarchy. See Updating native content for the scope of this optimization.

To request focus when a Textbox appears, set focus_on_mount=true. This can be useful for a deliberate editing action; avoid taking focus automatically from someone already navigating the page. On a mobile device, focusing a field can also bring up the software keyboard.

In the web chassis, native controls participate in browser keyboard navigation. The current implementation derives their tab indices from visual stacking order; Pax does not yet offer an independent public tab-order property. Changes to element order, conditional content, or compositing can therefore affect the path through the form. Walk that path with a keyboard in the actual application.

While a native element has focus on web, the chassis leaves its keyboard input with that element. A component's @key_down is not a text-entry hook or a way to make a drawn widget focusable. See Keyboard events for the app-level event boundary.

Accessibility today

Pax's native text and controls provide a starting point for accessibility. On web, a Button is a browser button, Textbox uses an input or textarea, Dropdown uses a select, and Checkbox and Slider use input elements. The Apple chassis uses UIKit and AppKit controls, with custom styling and some composed implementations.

The broader accessibility model is still incomplete. Public authoring APIs do not yet provide a general contract for accessible names, descriptions, roles, label associations, or a reading order independent of visual layout. Image and NativeImage currently expose no alternative-text property. A custom-drawn control has no automatically generated semantic counterpart. Native backing alone also does not establish that every state is announced correctly on every platform.

For an application, check the actual tasks a person must complete:

  • Navigate the whole interaction with a keyboard, including opening and dismissing transient UI. Check where focus goes afterward.
  • Use the target's screen reader to inspect names, values, selected states, and reading order. Check whether validation messages are discoverable.
  • Keep instructions and errors visible in text, and check that zoomed or larger text still fits the layout. Avoid communicating a state through color alone.

These checks can reveal a requirement that needs additional engine work. If your product depends on a particular accessibility contract, verify it early against the current implementation. This chapter makes no blanket screen-reader or standards-conformance claim.

PhotoPicker

PhotoPicker lets someone select images for the application. Its children draw the visible affordance; a transparent native control above them opens the platform picker. Keep those children simple and give the whole picker a usable size:

<PhotoPicker x=24px y=24px width=240px height=48px
    source=PhotoPickerSource::Library allow_multiple=true
    include_bytes=false max_bytes_per_photo=26214400
    @photo_picker_change=self.choose_photos>
    <Text width=100% height=100% text="Choose photos"
        style={
            font: "Arial", font_size: 18px, fill: WHITE
            align_horizontal: HorizontalAlign::Center
            align_vertical: VerticalAlign::Center
        } />
    <Rectangle width=100% height=100% corner_radius=8
        fill=rgb(56, 100, 78) />
</PhotoPicker>

The default accept filter is "image/*". On web it is passed to the file input; Apple's current pickers select images without applying a custom accept filter. Use allow_multiple=false when only one existing photo is needed. Camera capture produces one photo at a time.

Handle selection and partial results

The completion event includes status, an optional message, request_id, and a list of photos. Add Property<String> fields named preview_url and message to the component for this handler:

#![allow(unused)]
fn main() {
pub fn choose_photos(&mut self, _ctx: &NodeContext, event: Event<PhotoPickerChange>) {
    if event.status == PhotoPickerStatus::Cancelled {
        return;
    }

    if event.status == PhotoPickerStatus::Selected {
        if let Some(handle) = event.photos.first().and_then(|photo| photo.handle.as_ref()) {
            self.preview_url.set(handle.clone());
        }
        self.message.set(event.message.clone().unwrap_or_else(|| {
            format!("Selected {} photo(s).", event.photos.len())
        }));
    } else {
        self.message.set(event.message.clone().unwrap_or_else(|| {
            "No photo was added. Try another image or source.".into()
        }));
    }
}
}

Selected can include a warning: if some files exceed the configured size limit, the accepted files can still be returned. SizeLimitExceeded reports a selection in which the size limit prevented any accepted photos. Other statuses are Cancelled, PermissionDenied, Unavailable, and Failed. Keep the person's existing selection when a request is cancelled. Browser dismissal does not always deliver a completion event in the current web implementation, so do not depend on every close resetting an app-level “picker open” flag.

Each photo includes a temporary identifier, MIME type, byte size, source kind, and optional file name, dimensions, preview handle, and copied bytes. Show a returned handle with NativeImage:

if self.preview_url != "" {
    <NativeImage x=24px y=96px width=240px height=160px
        url={self.preview_url} fit=ImageFit::Contain />
}

Handles are transient object URLs or temporary file URLs. Persist an app-owned copy if the image must survive beyond the current session. Selection does not upload a file; networking and storage belong to your Rust application logic.

include_bytes=true asks for copied data in photo.data. Web supplies it when the read succeeds; the current Apple implementation returns metadata and file handles without copied bytes in the event. Treat data as optional. include_bytes=false avoids that web byte copy when a preview is enough. The default max_bytes_per_photo is 25 MiB (26,214,400 bytes); a positive limit is checked even when include_bytes is false. It is a per-photo limit, so also consider how many selections your app retains.

Library, camera, and permissions

TargetPhotoPickerSource::LibraryPhotoPickerSource::Camera
WebBrowser file picker with image filtering and optional multiple selectionRequests the file input's camera-capture hint; the browser/device decides what UI is available
macOSImage-file selection through NSOpenPanelReturns Unavailable
iOS and iPadOSSystem photo picker with optional multiple selectionSystem camera UI on a capable device, subject to permission

On iOS and iPadOS, library selection uses the system's scoped photo picker. The camera path needs a purpose string in the project's Cargo.toml:

[package.metadata.pax.ios.info_plist]
NSCameraUsageDescription = "Add a photo to your field notes."

iPadOS inherits the iOS metadata unless [package.metadata.pax.ipados.info_plist] overrides it. Check permission denial and unavailable hardware as well as a successful capture; a simulator cannot stand in for every device capability.

Incrementing the picker's trigger property requests a programmatic open, and the value is returned as request_id. On web, prefer direct activation of the picker itself: browsers restrict opening file dialogs without a user gesture, so a delayed property update is not a reliable substitute.

The canonical examples/fixtures/photo-picker project shows library and camera selection, metadata, size limits, and thumbnail previews. From the repository root, run it with:

pax-cli run --path examples/fixtures/photo-picker --target web

Current support

Button, Textbox (single-line and multiline), Checkbox, Slider, Dropdown, and RadioList have implementations for web, macOS, iOS, and iPadOS. Their shared Pax API does not make every platform detail identical:

  • Web Slider applies step; the current Apple sliders are continuous and do not apply it. If discrete values are essential to your interaction, this requires a platform-specific check before adopting the control.
  • Native styling and interaction states vary. For example, Button's hover_color is applied on web but not by the current Apple button views.
  • Multiline placeholders, photo bytes, and camera availability have the limits described above.
  • Accessibility remains a partial foundation. Test required keyboard and assistive-technology behavior on each shipping target.

Read more

Continue to Animation and Motion for transitions and animated feedback. Compositing explains how native controls and rendered content share clipping and stacking; Scrolling covers longer forms and content regions.

For more detail, see the forms API, PhotoPicker API, event payloads, and image sources.