1macro_rules! define_attr_property_mappings {
25 ($( $property:literal => $attribute:literal, )*) => {
26 #[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 #[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 #[cfg(test)]
51 const ALL_MAPPINGS: &[(&str, &str)] = &[
52 $( ($property, $attribute), )*
53 ];
54 };
55}
56
57define_attr_property_mappings! {
58 "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 "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#[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#[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}