Packages
Core behaviours, utilities, events, config, accessibility, and plugin infrastructure for Raxol. Zero external runtime dependencies. Provides BaseManager, event system, plugin lifecycle, keyboard/focus management, and accessibility primitives.
Current section
Files
Jump to
Current section
Files
lib/raxol/core/accessibility/projection.ex
defmodule Raxol.Core.Accessibility.Projection do
@moduledoc """
Projects a declaration Element tree into an accessibility tree.
The accessibility tree is a role/label/state descriptor tree that Surfaces
(MCP, Browser) serialize to drive assistive technology. It is derived from the
same Element tree that `Raxol.MCP.TreeWalker` walks (the `view/1` output synced
to the Dispatcher), so a node here carries the discrete Component props.
For each Element the projection either calls the Component's
`Raxol.Core.Accessibility.Provider.a11y_node/1` (for the ~15 Components that
implement it) or falls back to a default extraction driven by
`Raxol.Core.Accessibility.Roles`. The projection is **total**: it never raises
for any input, defaulting unknown types to `:generic` and rescuing a Provider
that itself raises.
## Dependency direction
This lives in raxol_core (which Browser and MCP both depend on, but which does
NOT depend on main raxol). The `type -> module` map names Component modules in
main raxol; they are resolved at runtime only when loaded, guarded by
`Provider.provider?/1`, so raxol_core stays free of a main-raxol dependency.
"""
require Logger
alias Raxol.Core.Accessibility.{Provider, Roles}
@type accessibility_node :: %{
role: atom(),
label: String.t() | nil,
state: %{optional(atom()) => boolean() | atom()},
value: term() | nil,
children: [accessibility_node()],
live?: boolean(),
id: String.t() | nil
}
# Mirrors Raxol.MCP.TreeWalker's @default_type_map. Declaration type -> the
# Component module that implements the Provider behaviour.
@compile {:no_warn_undefined,
[
Raxol.UI.Components.Input.Button,
Raxol.UI.Components.Input.TextInput,
Raxol.UI.Components.Input.TextArea,
Raxol.UI.Components.Input.PasswordField,
Raxol.UI.Components.Input.SelectList,
Raxol.UI.Components.Input.Checkbox,
Raxol.UI.Components.Input.Menu,
Raxol.UI.Components.Input.Tabs,
Raxol.UI.Components.Modal,
Raxol.UI.Components.Table,
Raxol.UI.Components.Display.Tree,
Raxol.UI.Components.Display.Viewport,
Raxol.UI.Charts.BarChart,
Raxol.UI.Charts.LineChart,
Raxol.UI.Charts.ScatterChart
]}
@default_type_map %{
button: Raxol.UI.Components.Input.Button,
text_input: Raxol.UI.Components.Input.TextInput,
text_area: Raxol.UI.Components.Input.TextArea,
password_field: Raxol.UI.Components.Input.PasswordField,
select_list: Raxol.UI.Components.Input.SelectList,
checkbox: Raxol.UI.Components.Input.Checkbox,
menu: Raxol.UI.Components.Input.Menu,
tabs: Raxol.UI.Components.Input.Tabs,
modal: Raxol.UI.Components.Modal,
table: Raxol.UI.Components.Table,
tree: Raxol.UI.Components.Display.Tree,
viewport: Raxol.UI.Components.Display.Viewport,
bar_chart: Raxol.UI.Charts.BarChart,
line_chart: Raxol.UI.Charts.LineChart,
scatter_chart: Raxol.UI.Charts.ScatterChart
}
# State keys whose `false` is meaningful (aria-checked=false differs from
# absent); all other keys are dropped when false.
@keep_false [:checked?, :selected?, :expanded?, :pressed?]
@doc """
Projects an Element (or list of Elements) into an accessibility node (or list).
## Options
* `:type_map` - override the declaration-type -> Component-module map
(defaults to the built-in 15-Component map). Mirrors
`Raxol.MCP.TreeWalker`'s `context.type_map`.
"""
@spec project(map() | [map()] | nil, keyword()) ::
accessibility_node() | [accessibility_node()] | nil
def project(tree, opts \\ [])
def project(nil, _opts), do: []
def project(nodes, opts) when is_list(nodes) do
Enum.map(nodes, &project(&1, opts))
end
def project(node, opts) when is_map(node) do
type_map = Keyword.get(opts, :type_map, @default_type_map)
build_node(node, type_map, opts)
end
def project(_other, _opts), do: nil
@doc """
Projects a single Element's own descriptor -- role/label/state/value/live?/id
-- WITHOUT recursing its children (children is always `[]`).
For Surfaces that walk the Element tree themselves (e.g. MCP
`StructuredScreenshot`) and only need per-node a11y fields folded in.
"""
@spec descriptor(map(), keyword()) :: accessibility_node() | nil
def descriptor(node, opts \\ [])
def descriptor(node, opts) when is_map(node) do
type_map = Keyword.get(opts, :type_map, @default_type_map)
type = Map.get(node, :type)
raw =
case provider_for(type, type_map) do
{:ok, module} -> safe_provider(module, node)
:none -> default_extract(node, type)
end
finalize(raw, [], node_id(node))
end
def descriptor(_node, _opts), do: nil
@doc """
Flat `id -> accessibility_node` map for Surfaces that key ARIA by Element id.
Only nodes with a non-empty binary id are included.
"""
@spec by_id(map() | [map()] | nil, keyword()) ::
%{optional(String.t()) => accessibility_node()}
def by_id(tree, opts \\ []) do
tree
|> project(opts)
|> List.wrap()
|> collect_by_id(%{})
end
# -- tree walk ---------------------------------------------------------------
defp build_node(node, type_map, opts) do
type = Map.get(node, :type)
raw =
case provider_for(type, type_map) do
{:ok, module} -> safe_provider(module, node)
:none -> default_extract(node, type)
end
# A Provider may synthesize its own children (SelectList options, Tabs, Table
# rows); otherwise the projection recurses the Element's own children.
children =
case Map.fetch(raw, :children) do
{:ok, kids} when is_list(kids) -> finalize_supplied(kids)
_ -> project_children(Map.get(node, :children), type_map, opts)
end
finalize(raw, children, node_id(node))
end
# Normalizes a raw node (from a Provider or default extraction) into a complete
# accessibility node, filling missing keys.
defp finalize(raw, children, id) do
role = Map.get(raw, :role, Roles.default_role())
%{
role: role,
label: normalize_label(Map.get(raw, :label)),
state: compact_state(Map.get(raw, :state, %{})),
value: Map.get(raw, :value),
children: children,
live?: Map.get(raw, :live?, Roles.live?(role)),
id: id
}
end
# Provider-supplied children are terse maps; normalize each (recursively) so a
# Provider can return %{role: :option, label: "x", state: %{selected?: true}}.
defp finalize_supplied(kids) when is_list(kids) do
kids |> Enum.map(&finalize_supplied_node/1) |> Enum.reject(&is_nil/1)
end
defp finalize_supplied_node(child) when is_map(child) do
grandkids =
case Map.fetch(child, :children) do
{:ok, kids} when is_list(kids) -> finalize_supplied(kids)
_ -> []
end
finalize(child, grandkids, node_id(child))
end
defp finalize_supplied_node(_child), do: nil
defp project_children(kids, type_map, opts) when is_list(kids) do
opts = Keyword.put(opts, :type_map, type_map)
kids |> Enum.map(&project(&1, opts)) |> Enum.reject(&is_nil/1)
end
defp project_children(_other, _type_map, _opts), do: []
defp collect_by_id(nodes, acc) when is_list(nodes) do
Enum.reduce(nodes, acc, &collect_by_id/2)
end
defp collect_by_id(%{} = node, acc) do
acc =
case node[:id] do
id when is_binary(id) and id != "" -> Map.put(acc, id, node)
_ -> acc
end
collect_by_id(node[:children] || [], acc)
end
defp collect_by_id(_other, acc), do: acc
# -- provider dispatch -------------------------------------------------------
defp provider_for(type, type_map) when is_atom(type) do
case Map.get(type_map, type) do
nil -> :none
module -> if Provider.provider?(module), do: {:ok, module}, else: :none
end
end
defp provider_for(_type, _type_map), do: :none
defp safe_provider(module, node) do
module.a11y_node(node)
rescue
error ->
Logger.debug("a11y_node/1 raised in #{inspect(module)}: #{inspect(error)}")
default_extract(node, Map.get(node, :type))
end
# -- default extraction ------------------------------------------------------
defp default_extract(node, type) do
role = Roles.role_for(type)
%{
role: role,
label: default_label(node),
state: default_state(node),
value: default_value(node, role),
live?: Roles.live?(role)
}
end
defp default_label(node) do
node[:label] || node[:aria_label] || fetch_attr(node, :label) ||
fetch_attr(node, :aria_label) || node[:content] || fetch_attr(node, :content)
end
# Default extraction surfaces only flags that are literally true; it never
# invents `selected?: false` on Elements with no selection concept. Tri-state
# `false` (aria-checked/selected) is the Provider's job, not the fallback's.
defp default_state(node) do
%{}
|> put_flag(:disabled?, node[:disabled] || fetch_attr(node, :disabled))
|> put_flag(:focused?, node[:focused] || fetch_attr(node, :focused))
|> put_flag(:required?, node[:required] || fetch_attr(node, :required))
|> put_flag(:selected?, node[:selected] || fetch_attr(node, :selected))
end
defp put_flag(state, key, true), do: Map.put(state, key, true)
defp put_flag(state, _key, _value), do: state
defp default_value(node, :textbox),
do: node[:value] || fetch_attr(node, :value) || node[:content]
defp default_value(node, :progressbar),
do: node[:value] || fetch_attr(node, :value)
defp default_value(_node, _role), do: nil
# -- helpers -----------------------------------------------------------------
defp fetch_attr(%{attrs: %{} = attrs}, key), do: Map.get(attrs, key)
defp fetch_attr(_node, _key), do: nil
defp node_id(node) do
case Map.get(node, :id) do
id when is_binary(id) -> id
_ -> nil
end
end
defp normalize_label(label) when is_binary(label), do: label
defp normalize_label(_label), do: nil
defp compact_state(state) when is_map(state) do
Enum.reduce(state, %{}, fn
{_k, nil}, acc -> acc
{k, false}, acc -> if k in @keep_false, do: Map.put(acc, k, false), else: acc
{k, v}, acc -> Map.put(acc, k, v)
end)
end
defp compact_state(_state), do: %{}
end