Skip to main content

webui_protocol/
attrs.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Attribute-name ↔ property-name mapping for irregular HTML attributes.
5//!
6//! Some HTML attributes use concatenated lowercase names that do not follow
7//! standard camelCase-to-kebab-case conversion rules. This module provides
8//! lookup tables covering two categories:
9//!
10//! 1. **Multi-word ARIA attributes** — e.g., `aria-describedby` ↔
11//!    `ariaDescribedBy`, per the [ARIAMixin] specification.
12//! 2. **HTML global/element attributes** — e.g., `readonly` ↔ `readOnly`,
13//!    `tabindex` ↔ `tabIndex`.
14//!
15//! Both directions are generated from a single list via
16//! [`define_attr_property_mappings!`] so they cannot drift.
17//!
18//! [ARIAMixin]: https://w3c.github.io/aria/#ARIAMixin
19
20/// Define bidirectional property ↔ attribute lookup functions from a single
21/// list of `(property, attribute)` pairs. This guarantees the two match
22/// statements stay in sync — adding or removing an entry in one direction
23/// automatically applies to the other.
24macro_rules! define_attr_property_mappings {
25    ($( $property:literal => $attribute:literal, )*) => {
26        /// Map a camelCase property name to its HTML attribute.
27        ///
28        /// Returns `None` for names that follow standard camelCase ↔ kebab
29        /// conversion.
30        #[must_use]
31        pub fn property_to_attribute(name: &str) -> Option<&'static str> {
32            match name {
33                $( $property => Some($attribute), )*
34                _ => None,
35            }
36        }
37
38        /// Map an HTML attribute to its camelCase property name.
39        ///
40        /// Inverse of [`property_to_attribute`].
41        #[must_use]
42        pub fn attribute_to_property(name: &str) -> Option<&'static str> {
43            match name {
44                $( $attribute => Some($property), )*
45                _ => None,
46            }
47        }
48
49        /// All `(property, attribute)` pairs for exhaustive testing.
50        #[cfg(test)]
51        const ALL_MAPPINGS: &[(&str, &str)] = &[
52            $( ($property, $attribute), )*
53        ];
54    };
55}
56
57define_attr_property_mappings! {
58    // --- ARIA (ARIAMixin) ---
59    "ariaActiveDescendant" => "aria-activedescendant",
60    "ariaAutoComplete" => "aria-autocomplete",
61    "ariaBrailleLabel" => "aria-braillelabel",
62    "ariaBrailleRoleDescription" => "aria-brailleroledescription",
63    "ariaColCount" => "aria-colcount",
64    "ariaColIndex" => "aria-colindex",
65    "ariaColIndexText" => "aria-colindextext",
66    "ariaColSpan" => "aria-colspan",
67    "ariaDescribedBy" => "aria-describedby",
68    "ariaDropEffect" => "aria-dropeffect",
69    "ariaErrorMessage" => "aria-errormessage",
70    "ariaFlowTo" => "aria-flowto",
71    "ariaHasPopup" => "aria-haspopup",
72    "ariaKeyShortcuts" => "aria-keyshortcuts",
73    "ariaLabelledBy" => "aria-labelledby",
74    "ariaMultiLine" => "aria-multiline",
75    "ariaMultiSelectable" => "aria-multiselectable",
76    "ariaPosInSet" => "aria-posinset",
77    "ariaReadOnly" => "aria-readonly",
78    "ariaRoleDescription" => "aria-roledescription",
79    "ariaRowCount" => "aria-rowcount",
80    "ariaRowIndex" => "aria-rowindex",
81    "ariaRowIndexText" => "aria-rowindextext",
82    "ariaRowSpan" => "aria-rowspan",
83    "ariaSetSize" => "aria-setsize",
84    "ariaValueMax" => "aria-valuemax",
85    "ariaValueMin" => "aria-valuemin",
86    "ariaValueNow" => "aria-valuenow",
87    "ariaValueText" => "aria-valuetext",
88    // --- HTML global/element attributes ---
89    "accessKey" => "accesskey",
90    "autoCapitalize" => "autocapitalize",
91    "contentEditable" => "contenteditable",
92    "crossOrigin" => "crossorigin",
93    "dirName" => "dirname",
94    "fetchPriority" => "fetchpriority",
95    "formAction" => "formaction",
96    "formEnctype" => "formenctype",
97    "formMethod" => "formmethod",
98    "formNoValidate" => "formnovalidate",
99    "formTarget" => "formtarget",
100    "inputMode" => "inputmode",
101    "isMap" => "ismap",
102    "maxLength" => "maxlength",
103    "minLength" => "minlength",
104    "noModule" => "nomodule",
105    "noValidate" => "novalidate",
106    "readOnly" => "readonly",
107    "referrerPolicy" => "referrerpolicy",
108    "tabIndex" => "tabindex",
109    "useMap" => "usemap",
110}
111
112/// Convert a camelCase property name to its HTML attribute name.
113///
114/// Checks the irregular attribute lookup table first, then falls back to
115/// generic camelCase → kebab-case conversion (inserting `-` before each
116/// uppercase letter).
117///
118/// # Examples
119/// ```
120/// # use webui_protocol::attrs::camel_to_kebab;
121/// assert_eq!(camel_to_kebab("ariaDescribedBy"), "aria-describedby");
122/// assert_eq!(camel_to_kebab("readOnly"), "readonly");
123/// assert_eq!(camel_to_kebab("totalContacts"), "total-contacts");
124/// ```
125#[must_use]
126pub fn camel_to_kebab(name: &str) -> String {
127    if let Some(attr) = property_to_attribute(name) {
128        return attr.to_string();
129    }
130    let mut result = String::with_capacity(name.len() + 4);
131    for ch in name.chars() {
132        if ch.is_uppercase() && !result.is_empty() {
133            result.push('-');
134            for lc in ch.to_lowercase() {
135                result.push(lc);
136            }
137        } else {
138            result.push(ch);
139        }
140    }
141    result
142}
143
144/// Convert an HTML attribute name to its camelCase property name.
145///
146/// Checks the irregular attribute lookup table first, then falls back to
147/// generic kebab-case → camelCase conversion (removing `-` and capitalizing
148/// the following character).
149///
150/// # Examples
151/// ```
152/// # use webui_protocol::attrs::attribute_to_camel;
153/// assert_eq!(attribute_to_camel("aria-describedby"), "ariaDescribedBy");
154/// assert_eq!(attribute_to_camel("readonly"), "readOnly");
155/// assert_eq!(attribute_to_camel("data-title"), "dataTitle");
156/// ```
157#[must_use]
158pub fn attribute_to_camel(name: &str) -> String {
159    if let Some(prop) = attribute_to_property(name) {
160        return prop.to_string();
161    }
162    let mut result = String::with_capacity(name.len());
163    let mut capitalize_next = false;
164    for ch in name.chars() {
165        if ch == '-' {
166            capitalize_next = true;
167        } else if capitalize_next {
168            result.extend(ch.to_uppercase());
169            capitalize_next = false;
170        } else {
171            result.push(ch);
172        }
173    }
174    result
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn exhaustive_property_to_attribute() {
183        for &(prop, attr) in ALL_MAPPINGS {
184            assert_eq!(
185                property_to_attribute(prop),
186                Some(attr),
187                "property_to_attribute({prop}) should be {attr}"
188            );
189        }
190    }
191
192    #[test]
193    fn exhaustive_attribute_to_property() {
194        for &(prop, attr) in ALL_MAPPINGS {
195            assert_eq!(
196                attribute_to_property(attr),
197                Some(prop),
198                "attribute_to_property({attr}) should be {prop}"
199            );
200        }
201    }
202
203    #[test]
204    fn exhaustive_roundtrip() {
205        for &(prop, _) in ALL_MAPPINGS {
206            let attr = camel_to_kebab(prop);
207            let back = attribute_to_camel(&attr);
208            assert_eq!(back, prop, "roundtrip failed for {prop}");
209        }
210    }
211
212    #[test]
213    fn single_word_attrs_return_none() {
214        assert_eq!(property_to_attribute("ariaLabel"), None);
215        assert_eq!(property_to_attribute("ariaHidden"), None);
216        assert_eq!(attribute_to_property("aria-label"), None);
217        assert_eq!(attribute_to_property("aria-hidden"), None);
218    }
219
220    #[test]
221    fn non_mapped_returns_none() {
222        assert_eq!(property_to_attribute("myProp"), None);
223        assert_eq!(attribute_to_property("my-prop"), None);
224    }
225
226    #[test]
227    fn camel_to_kebab_regular() {
228        assert_eq!(camel_to_kebab("ariaLabel"), "aria-label");
229        assert_eq!(camel_to_kebab("totalContacts"), "total-contacts");
230        assert_eq!(camel_to_kebab("dataTitle"), "data-title");
231    }
232
233    #[test]
234    fn attribute_to_camel_regular() {
235        assert_eq!(attribute_to_camel("aria-label"), "ariaLabel");
236        assert_eq!(attribute_to_camel("data-title"), "dataTitle");
237    }
238}