Module: RSX::Attributes
- Defined in:
- lib/rsx/attributes.rb
Overview
Translates React-style DOM props into HTML attributes.
Prop names follow React's conventions (className, htmlFor, tabIndex, strokeWidth, onClick, ...) and are mapped to their HTML spellings. Names that are already lowercase, snake_case or kebab-case pass through unchanged, so idiomatic Ruby markup works too.
Constant Summary collapse
- PROP_NAMES =
React prop => HTML attribute, for names that are not a simple case change.
{ "className" => "class", "class_name" => "class", "htmlFor" => "for", "html_for" => "for", "httpEquiv" => "http-equiv", "acceptCharset" => "accept-charset", "charSet" => "charset", "tabIndex" => "tabindex", "readOnly" => "readonly", "maxLength" => "maxlength", "minLength" => "minlength", "autoComplete" => "autocomplete", "autoCapitalize" => "autocapitalize", "autoCorrect" => "autocorrect", "autoFocus" => "autofocus", "autoPlay" => "autoplay", "autoSave" => "autosave", "crossOrigin" => "crossorigin", "dateTime" => "datetime", "encType" => "enctype", "formAction" => "formaction", "formEncType" => "formenctype", "formMethod" => "formmethod", "formNoValidate" => "formnovalidate", "formTarget" => "formtarget", "noValidate" => "novalidate", "noModule" => "nomodule", "srcSet" => "srcset", "srcDoc" => "srcdoc", "srcLang" => "srclang", "hrefLang" => "hreflang", "contentEditable" => "contenteditable", "spellCheck" => "spellcheck", "colSpan" => "colspan", "rowSpan" => "rowspan", "cellPadding" => "cellpadding", "cellSpacing" => "cellspacing", "useMap" => "usemap", "isMap" => "ismap", "allowFullScreen" => "allowfullscreen", "allowTransparency" => "allowtransparency", "playsInline" => "playsinline", "referrerPolicy" => "referrerpolicy", "fetchPriority" => "fetchpriority", "frameBorder" => "frameborder", "marginWidth" => "marginwidth", "marginHeight" => "marginheight", "mediaGroup" => "mediagroup", "inputMode" => "inputmode", "enterKeyHint" => "enterkeyhint", "imageSizes" => "imagesizes", "imageSrcSet" => "imagesrcset", "popoverTarget" => "popovertarget", "popoverTargetAction" => "popovertargetaction", "accessKey" => "accesskey", "itemProp" => "itemprop", "itemScope" => "itemscope", "itemType" => "itemtype", "itemID" => "itemid", "itemRef" => "itemref", "radioGroup" => "radiogroup", "defaultValue" => "value", "defaultChecked" => "checked", "defaultSelected" => "selected" }.freeze
- CASE_SENSITIVE =
SVG/MathML attributes whose camelCase spelling is significant.
%w[ attributeName attributeType baseFrequency baseProfile calcMode clipPathUnits contentScriptType contentStyleType diffuseConstant edgeMode filterRes filterUnits glyphRef gradientTransform gradientUnits kernelMatrix kernelUnitLength keyPoints keySplines keyTimes lengthAdjust limitingConeAngle markerHeight markerUnits markerWidth maskContentUnits maskUnits numOctaves pathLength patternContentUnits patternTransform patternUnits pointsAtX pointsAtY pointsAtZ preserveAlpha preserveAspectRatio primitiveUnits refX refY repeatCount repeatDur requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod startOffset stdDeviation stitchTiles surfaceScale systemLanguage tableValues targetX targetY textLength viewBox viewTarget xChannelSelector yChannelSelector zoomAndPan ].to_h { |name| [name, name] }.freeze
- BOOLEAN =
Attributes rendered bare when truthy and omitted when falsy.
%w[ allowfullscreen async autofocus autoplay checked controls default defer disabled formnovalidate hidden inert ismap itemscope loop multiple muted nomodule novalidate open playsinline readonly required reversed selected ].to_h { |name| [name, true] }.freeze
- VOID =
Elements that must not be given a closing tag.
%w[ area base br col embed hr img input link meta param source track wbr ].to_h { |name| [name, true] }.freeze
- SELF_CLOSING =
Elements that may legally use XML self-closing syntax in HTML documents.
%w[ circle ellipse line path polygon polyline rect stop use image animate animateMotion animateTransform feBlend feColorMatrix feComposite feFlood feGaussianBlur feImage feMergeNode feOffset fePointLight feSpotLight feTile feTurbulence mpath set ].to_h { |name| [name, true] }.freeze
- UNITLESS_CSS =
CSS properties that take a bare number (everything else gets "px").
%w[ animation-iteration-count aspect-ratio border-image-outset border-image-slice border-image-width box-flex box-flex-group box-ordinal-group column-count columns flex flex-grow flex-positive flex-shrink flex-negative flex-order font-weight grid-area grid-row grid-row-end grid-row-span grid-row-start grid-column grid-column-end grid-column-span grid-column-start line-clamp line-height opacity order orphans scale tab-size widows z-index zoom fill-opacity flood-opacity stop-opacity stroke-dasharray stroke-dashoffset stroke-miterlimit stroke-opacity stroke-width ].to_h { |name| [name, true] }.freeze
- IGNORED =
Props that describe the element to RSX rather than to the browser.
%w[key ref children suppressHydrationWarning].to_h { |name| [name, true] }.freeze
- INVALID_NAME =
%r{[\s"'>/=\0]}- CAMEL_BOUNDARY =
/([a-z0-9])([A-Z])/
Class Method Summary collapse
-
.attribute_name(prop) ⇒ Object
Maps a prop name to its HTML attribute name, or nil when the prop should not be rendered at all.
- .boolean?(name) ⇒ Boolean
-
.canonical_name(prop) ⇒ Object
The name two props have to share to be considered the same attribute.
- .class_tokens(value) ⇒ Object
- .css_property(property) ⇒ Object
- .css_value(name, value) ⇒ Object
-
.merge(*parts) ⇒ Object
Combines attribute hashes the way React combines props: names that map to the same HTML attribute collapse, keeping the first position and the last value, so
<a {...attrs} className="link">overrides the spread. - .nested_value(value) ⇒ Object
-
.render(name, value) ⇒ Object
Renders one attribute, including its leading space:
href="/x". -
.render_all(hash) ⇒ Object
Renders a hash of props as attributes: **props / .....props.
-
.render_class(value) ⇒ Object
class=... accepts a String, Symbol, Array or Hash.
-
.render_nested(prefix, value) ⇒ Object
data=... / aria=... expand a Hash into prefixed attributes.
-
.render_style(value) ⇒ Object
style=... accepts a String or a Hash of CSS properties.
- .self_closing?(tag) ⇒ Boolean
- .style_string(value) ⇒ Object
- .void?(tag) ⇒ Boolean
Class Method Details
.attribute_name(prop) ⇒ Object
Maps a prop name to its HTML attribute name, or nil when the prop should not be rendered at all.
136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/rsx/attributes.rb', line 136 def attribute_name(prop) name = prop.to_s return nil if IGNORED.key?(name) mapped = PROP_NAMES[name] return mapped if mapped return name if CASE_SENSITIVE.key?(name) return name.downcase if name.match?(/\Aon[A-Z]/) return name unless name.match?(/[A-Z]/) name.gsub(CAMEL_BOUNDARY, '\1-\2').downcase end |
.boolean?(name) ⇒ Boolean
149 150 151 |
# File 'lib/rsx/attributes.rb', line 149 def boolean?(name) BOOLEAN.key?(name) end |
.canonical_name(prop) ⇒ Object
The name two props have to share to be considered the same attribute.
283 284 285 |
# File 'lib/rsx/attributes.rb', line 283 def canonical_name(prop) attribute_name(prop) || prop.to_s end |
.class_tokens(value) ⇒ Object
178 179 180 181 182 183 184 185 186 187 |
# File 'lib/rsx/attributes.rb', line 178 def class_tokens(value) case value when nil, false, true then [] when String then value.empty? ? [] : [value] when Symbol then [value.to_s] when Array then value.flat_map { |item| class_tokens(item) } when Hash then value.filter_map { |token, on| token.to_s if on } else [value.to_s] end end |
.css_property(property) ⇒ Object
213 214 215 216 217 218 219 |
# File 'lib/rsx/attributes.rb', line 213 def css_property(property) name = property.to_s return name unless name.match?(/[A-Z_]/) name = name.tr("_", "-") name.gsub(CAMEL_BOUNDARY, '\1-\2').downcase end |
.css_value(name, value) ⇒ Object
221 222 223 224 225 |
# File 'lib/rsx/attributes.rb', line 221 def css_value(name, value) return "#{value}px" if value.is_a?(Numeric) && value != 0 && !UNITLESS_CSS.key?(name) value.to_s end |
.merge(*parts) ⇒ Object
Combines attribute hashes the way React combines props: names that map to
the same HTML attribute collapse, keeping the first position and the last
value, so <a {...attrs} className="link"> overrides the spread.
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'lib/rsx/attributes.rb', line 255 def merge(*parts) merged = {} positions = {} parts.each do |part| next if part.nil? || part == false unless part.respond_to?(:each_pair) raise ArgumentError, "spread attributes need a Hash, got #{part.class}" end part.each_pair do |prop, value| name = canonical_name(prop) existing = positions[name] if existing merged[existing] = value else positions[name] = prop merged[prop] = value end end end merged end |
.nested_value(value) ⇒ Object
244 245 246 247 248 249 250 |
# File 'lib/rsx/attributes.rb', line 244 def nested_value(value) case value when String, Symbol, Numeric, SafeString then value when Array, Hash then RSX.json(value) else value.to_s end end |
.render(name, value) ⇒ Object
Renders one attribute, including its leading space: href="/x".
162 163 164 165 166 167 168 |
# File 'lib/rsx/attributes.rb', line 162 def render(name, value) case value when nil, false then "" when true then boolean?(name) ? " #{name}" : %( #{name}="true") else %( #{name}="#{Escape.attribute(value)}") end end |
.render_all(hash) ⇒ Object
Renders a hash of props as attributes: **props / .....props
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 |
# File 'lib/rsx/attributes.rb', line 288 def render_all(hash) return "" if hash.nil? || hash == false unless hash.respond_to?(:each_pair) raise ArgumentError, "spread attributes need a Hash, got #{hash.class}" end out = +"" hash.each_pair do |prop, value| case prop.to_s when "class", "className", "class_name" then out << render_class(value) when "style" then out << render_style(value) when "data" then out << render_nested("data", value) when "aria" then out << render_nested("aria", value) when "dangerouslySetInnerHTML" then next else name = attribute_name(prop) next if name.nil? || name.empty? || name.match?(INVALID_NAME) out << render(name, value) end end out end |
.render_class(value) ⇒ Object
class=... accepts a String, Symbol, Array or Hash.
171 172 173 174 175 176 |
# File 'lib/rsx/attributes.rb', line 171 def render_class(value) tokens = class_tokens(value) return "" if tokens.empty? %( class="#{Escape.attribute(tokens.join(" "))}") end |
.render_nested(prefix, value) ⇒ Object
data=... / aria=... expand a Hash into prefixed attributes.
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 |
# File 'lib/rsx/attributes.rb', line 228 def render_nested(prefix, value) case value when nil, false then "" when Hash value.filter_map do |key, raw| next if raw.nil? # Like React, data-* and aria-* keep booleans as the strings "true" # and "false" rather than becoming bare attributes: ARIA values are # enumerated, so `aria-hidden` alone means nothing. %( #{prefix}-#{css_property(key)}="#{Escape.attribute(nested_value(raw))}") end.join else render(prefix, value) end end |
.render_style(value) ⇒ Object
style=... accepts a String or a Hash of CSS properties.
190 191 192 193 194 195 |
# File 'lib/rsx/attributes.rb', line 190 def render_style(value) css = style_string(value) return "" if css.nil? || css.empty? %( style="#{Escape.attribute(css)}") end |
.self_closing?(tag) ⇒ Boolean
157 158 159 |
# File 'lib/rsx/attributes.rb', line 157 def self_closing?(tag) SELF_CLOSING.key?(tag) end |
.style_string(value) ⇒ Object
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/rsx/attributes.rb', line 197 def style_string(value) case value when nil, false, true then nil when String then value when Array then value.filter_map { |item| style_string(item) }.join(";") when Hash value.filter_map do |property, raw| next if raw.nil? || raw == false || raw == "" name = css_property(property) "#{name}:#{css_value(name, raw)}" end.join(";") else value.to_s end end |
.void?(tag) ⇒ Boolean
153 154 155 |
# File 'lib/rsx/attributes.rb', line 153 def void?(tag) VOID.key?(tag) end |