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::VIDEO,
128 component_type::PROGRESS_VIEW,
129 component_type::GAUGE,
130 component_type::DATE_PICKER,
131 component_type::COLOR_PICKER,
132 component_type::PICKER,
133];
134
135/// How a role interacts with an element's shell. Decided from the component
136/// when known; the DOM client derives it from the element's tag when hydrating
137/// (it has no component for SSR-provided nodes).
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ShellKind {
140 /// A generic `div`/`span` shell — the only shells a semantic role retags.
141 Generic,
142 /// A native control element (`<button>`, `<input>`, …) that conveys its own
143 /// role — never retagged, never given an ARIA role from `ROLE`.
144 Native,
145 /// Any other element (composite shells, unknown) — never retagged, but an
146 /// ARIA `role` attribute is emitted.
147 Other,
148}
149
150/// Resolve a component to its shell kind.
151#[must_use]
152pub fn shell_kind(component: u16) -> ShellKind {
153 if GENERIC_COMPONENTS.contains(&component) {
154 ShellKind::Generic
155 } else if NATIVE_ROLE_COMPONENTS.contains(&component) {
156 ShellKind::Native
157 } else {
158 ShellKind::Other
159 }
160}
161
162/// The semantic tag a `(shell kind, role)` pair resolves to, or `None` when the
163/// element keeps its default shell tag.
164#[must_use]
165pub fn semantic_tag(kind: ShellKind, role: u8) -> Option<&'static str> {
166 if kind != ShellKind::Generic {
167 return None;
168 }
169 ROLE_TAGS.iter().find(|r| r.role == role).map(|r| r.tag)
170}
171
172/// The ARIA `role` attribute value to emit for `(shell kind, role)`, or `None`
173/// when the element already conveys the role (a semantic tag or a native
174/// control element). Control roles no longer exist in the catalog, so any
175/// unknown/reserved code is a no-op.
176#[must_use]
177pub fn aria_role(kind: ShellKind, role: u8) -> Option<&'static str> {
178 if kind == ShellKind::Native {
179 return None;
180 }
181 if let Some(tag) = ROLE_TAGS.iter().find(|r| r.role == role) {
182 // A generic shell is retagged to `tag` (conveying the role); any other
183 // element falls back to the ARIA string.
184 return if kind == ShellKind::Generic { None } else { Some(tag.aria) };
185 }
186 None
187}