Skip to main content

pathland_render_html/
role_spec.rs

1//! Canonical `ROLE` → semantic HTML element mapping (spec/OPCODE.md §semantic
2//! properties) + typography-driven heading levels, shared with the TypeScript
3//! DOM client: the Rust renderer consumes these tables directly, and
4//! `pathland-ts-codegen` emits the TS equivalents into `generated/role-spec.ts`
5//! — so the semantic-tag / ARIA / heading mapping cannot drift between the
6//! renderers.
7//!
8//! Rule summary:
9//! * **`ROLE` is semantic structure only.** The catalog (see
10//!   `pathland_core::role`) holds landmarks, headings, paragraphs and lists —
11//!   interactive/control roles (button, link, checkbox, …) are NOT roles: they
12//!   are intrinsic to the control components (`BUTTON` → `<button>`, `TOGGLE` →
13//!   `<input type="checkbox">`, `MENU` → `role="menu"`), and a custom-looking
14//!   button uses `Button` + `ButtonStyle`.
15//! * A role **retags only a generic `div`/`span` shell** (see
16//!   [`GENERIC_COMPONENTS`]) to its semantic element (`<nav>`, `<main>`, …).
17//! * **Control components keep their native element** and emit no ARIA role
18//!   from `ROLE`.
19//! * **Headings** come from a `TEXT_STYLE` typography (LargeTitle…Headline →
20//!   `<h1>`–`<h5>`, always a heading) or from `ROLE_HEADER` (default `<h2>`);
21//!   raw font modifiers never imply a heading. Non-heading text defaults to
22//!   `<span>`; `ROLE_PARAGRAPH` gives `<p>`.
23
24use pathland_core::{component_type, role, text_style};
25
26/// A role that maps onto a native semantic HTML element. On a generic shell the
27/// role renders as [`RoleTag::tag`]; when the role is applied to a non-generic
28/// component, [`RoleTag::aria`] is the ARIA fallback.
29pub struct RoleTag {
30    /// The `ROLE` enum code (see `pathland_core::role`).
31    pub role: u8,
32    /// The semantic HTML tag the role renders as on a generic shell.
33    pub tag: &'static str,
34    /// The equivalent ARIA `role` value (fallback when no tag applies).
35    pub aria: &'static str,
36}
37
38/// Semantic roles with a native element. `HEADER` (heading) defaults to `<h2>`
39/// — its level is raised by a `TEXT_STYLE` typography (see [`HEADING_STYLES`]).
40/// `SUMMARY` maps to `<section>` (a named region).
41pub const ROLE_TAGS: &[RoleTag] = &[
42    RoleTag { role: role::HEADER, tag: "h2", aria: "heading" },
43    RoleTag { role: role::PARAGRAPH, tag: "p", aria: "paragraph" },
44    RoleTag { role: role::LIST, tag: "ul", aria: "list" },
45    RoleTag { role: role::LIST_ITEM, tag: "li", aria: "listitem" },
46    RoleTag { role: role::SUMMARY, tag: "section", aria: "region" },
47    RoleTag { role: role::BANNER, tag: "header", aria: "banner" },
48    RoleTag { role: role::NAVIGATION, tag: "nav", aria: "navigation" },
49    RoleTag { role: role::MAIN, tag: "main", aria: "main" },
50    RoleTag { role: role::CONTENT_INFO, tag: "footer", aria: "contentinfo" },
51    RoleTag { role: role::COMPLEMENTARY, tag: "aside", aria: "complementary" },
52    RoleTag { role: role::ARTICLE, tag: "article", aria: "article" },
53    RoleTag { role: role::SECTION, tag: "section", aria: "region" },
54    RoleTag { role: role::SEARCH, tag: "search", aria: "search" },
55];
56
57/// The `TEXT_STYLE` typographies that imply a heading element (`<h1>`–`<h5>`),
58/// mapping style code → heading level. A title/headline style on a `TEXT` is
59/// **always** a heading, even without `ROLE_HEADER`.
60pub const HEADING_STYLES: &[(u8, u8)] = &[
61    (text_style::LARGE_TITLE, 1),
62    (text_style::TITLE, 2),
63    (text_style::TITLE2, 3),
64    (text_style::TITLE3, 4),
65    (text_style::HEADLINE, 5),
66];
67
68/// The heading level a `TEXT_STYLE` typography implies, or `None` for the
69/// non-heading styles (Subheadline, Body, Callout, Footnote, Caption, Caption2).
70#[must_use]
71pub fn heading_level(text_style: u8) -> Option<u8> {
72    HEADING_STYLES.iter().find(|(s, _)| *s == text_style).map(|(_, level)| *level)
73}
74
75/// The heading element tag for a level (`1` → `h1` … `5` → `h5`).
76#[must_use]
77pub fn heading_tag(level: u8) -> &'static str {
78    match level {
79        1 => "h1",
80        2 => "h2",
81        3 => "h3",
82        4 => "h4",
83        _ => "h5",
84    }
85}
86
87/// The effective tag for a generic `TEXT` shell: a heading `TEXT_STYLE` wins
88/// (always a heading), then the semantic role (`ROLE_HEADER` → `<h2>`,
89/// `ROLE_PARAGRAPH` → `<p>`, landmarks), else `None` (→ `<span>`).
90#[must_use]
91pub fn text_tag(kind: ShellKind, role: u8, text_style: Option<u8>) -> Option<&'static str> {
92    if kind == ShellKind::Generic {
93        if let Some(level) = text_style.and_then(heading_level) {
94            return Some(heading_tag(level));
95        }
96    }
97    semantic_tag(kind, role)
98}
99
100/// Components whose shell is a generic `div`/`span` — the only shells a
101/// semantic role retags. Control components keep their native element.
102pub const GENERIC_COMPONENTS: &[u16] = &[
103    component_type::TEXT,
104    component_type::VSTACK,
105    component_type::HSTACK,
106    component_type::LAZY_VSTACK,
107    component_type::LAZY_HSTACK,
108    component_type::ZSTACK,
109    component_type::GRID,
110    component_type::LAZY_VGRID,
111    component_type::LAZY_HGRID,
112    component_type::SCROLLVIEW,
113    component_type::COLOR,
114    component_type::SHAPE,
115    component_type::SPACER,
116];
117
118/// Components whose native element conveys their role — they never emit an
119/// ARIA role from the `ROLE` property.
120pub const NATIVE_ROLE_COMPONENTS: &[u16] = &[
121    component_type::BUTTON,
122    component_type::TEXT_FIELD,
123    component_type::TEXT_EDITOR,
124    component_type::SLIDER,
125    component_type::TOGGLE,
126    component_type::IMAGE,
127    component_type::AUDIO,
128    component_type::VIDEO,
129    component_type::PROGRESS_VIEW,
130    component_type::GAUGE,
131    component_type::DATE_PICKER,
132    component_type::COLOR_PICKER,
133    component_type::PICKER,
134];
135
136/// How a role interacts with an element's shell. Decided from the component
137/// when known; the DOM client derives it from the element's tag when hydrating
138/// (it has no component for SSR-provided nodes).
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum ShellKind {
141    /// A generic `div`/`span` shell — the only shells a semantic role retags.
142    Generic,
143    /// A native control element (`<button>`, `<input>`, …) that conveys its own
144    /// role — never retagged, never given an ARIA role from `ROLE`.
145    Native,
146    /// Any other element (composite shells, unknown) — never retagged, but an
147    /// ARIA `role` attribute is emitted.
148    Other,
149}
150
151/// Resolve a component to its shell kind.
152#[must_use]
153pub fn shell_kind(component: u16) -> ShellKind {
154    if GENERIC_COMPONENTS.contains(&component) {
155        ShellKind::Generic
156    } else if NATIVE_ROLE_COMPONENTS.contains(&component) {
157        ShellKind::Native
158    } else {
159        ShellKind::Other
160    }
161}
162
163/// The semantic tag a `(shell kind, role)` pair resolves to, or `None` when the
164/// element keeps its default shell tag.
165#[must_use]
166pub fn semantic_tag(kind: ShellKind, role: u8) -> Option<&'static str> {
167    if kind != ShellKind::Generic {
168        return None;
169    }
170    ROLE_TAGS.iter().find(|r| r.role == role).map(|r| r.tag)
171}
172
173/// The ARIA `role` attribute value to emit for `(shell kind, role)`, or `None`
174/// when the element already conveys the role (a semantic tag or a native
175/// control element). Control roles no longer exist in the catalog, so any
176/// unknown/reserved code is a no-op.
177#[must_use]
178pub fn aria_role(kind: ShellKind, role: u8) -> Option<&'static str> {
179    if kind == ShellKind::Native {
180        return None;
181    }
182    if let Some(tag) = ROLE_TAGS.iter().find(|r| r.role == role) {
183        // A generic shell is retagged to `tag` (conveying the role); any other
184        // element falls back to the ARIA string.
185        return if kind == ShellKind::Generic { None } else { Some(tag.aria) };
186    }
187    None
188}