Module: ViewComponentCssDsl

Extended by:
ActiveSupport::Concern
Defined in:
lib/view_component_css_dsl.rb,
lib/view_component_css_dsl/version.rb

Constant Summary collapse

HTML_ATTR_KEYS =

HTML attributes auto-extracted from kwargs at construction time. Anything in this set is captured into @html_attrs instead of being passed to initialize, so callers can pass ‘class:`, `data:`, `aria:`, etc. without the component declaring them. To opt out, accept a kwarg with the same name in initialize (e.g. `def initialize(class:)`) or use a keyrest name other than html_attrs.

Set[
  :alt, :aria, :autofocus,
  :class, :colspan, :contenteditable,
  :data, :dir, :disabled, :download, :draggable,
  :enterkeyhint,
  :formaction,
  :headers, :hidden, :href,
  :id, :inputmode,
  :lang, :loading, :low,
  :media,
  :onclick, :open, :optimum,
  :popover, :popovertarget, :popovertargetaction, :preload,
  :readonly, :rel, :role, :rowspan,
  :spellcheck, :src, :srcset, :style,
  :tabindex, :target, :title, :type, :value
].freeze
SPACING_REGEX =

Single combined regex for padding/margin spacing (replaces 14 separate patterns) Captures: type (p/m), axis (x/y/t/r/b/l or nil for all), value

/\b(p|m)(x|y|t|r|b|l)?-(\d+)\b/
SPACING_AXIS_MAP =

Maps axis character to Set of affected sides

{
  nil => Set[:t, :r, :b, :l],  # p-4, m-4 = all sides
  "x" => Set[:l, :r],
  "y" => Set[:t, :b],
  "t" => Set[:t],
  "r" => Set[:r],
  "b" => Set[:b],
  "l" => Set[:l]
}.freeze
BORDER_REGEX =

Border width patterns (kept separate due to anchoring requirements)

/^border(?:-(x|y|t|r|b|l))?(?:-\d+)?$/
CATEGORIES =

Other category patterns (non-spacing, simple override by category) IMPORTANT: Use anchored patterns (^/$) to avoid matching substrings within compound classes (e.g., ‘h-8` within `min-h-8`, `flex` within `inline-flex`)

{
  background: /^bg-/,
  text_color: /^text-((\w+-\d+)|white|black|transparent|current|inherit|action|success|danger|warning|brand)(\/\d+)?$/,
  text_size: /^text-(xs|sm|base|lg|xl|\d*xl)$/,
  border_color: /^border-(?!t|r|b|l|x|y|\d)(\w+-\d+|\w+)(\/\d+)?$/,
  width: /^w-/,
  height: /^h-/,
  min_width: /^min-w-/,
  min_height: /^min-h-/,
  max_width: /^max-w-/,
  max_height: /^max-h-/,
  # Display classes - note: `hidden` is intentionally excluded because it's
  # commonly used as a visibility toggle alongside other display classes
  # (e.g., "inline-flex hidden" where JS removes "hidden" to show element)
  display: /^(block|inline-block|inline-flex|inline-grid|inline|flex|grid|table-cell|table-row|table|contents|flow-root|list-item)$/,
  justify: /^justify-/,
  align: /^items-/,
  font_weight: /^font-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black)$/,
  rounded: /^rounded(-none|-sm|-md|-lg|-xl|-2xl|-3xl|-full)?$/,
  position: /^(static|relative|absolute|fixed|sticky)$/
}.freeze
KNOWN_MODIFIERS =

Known Tailwind modifiers (prefixes like hover:, md:, first:, etc.) Classes with different modifiers should NOT conflict with each other

Set[
  # Responsive
  "sm", "md", "lg", "xl", "2xl",
  "max-sm", "max-md", "max-lg", "max-xl", "max-2xl",
  # Interactive state
  "hover", "focus", "focus-within", "focus-visible", "active", "visited", "target",
  # Structural
  "first", "last", "only", "odd", "even",
  "first-of-type", "last-of-type", "only-of-type", "empty",
  # Form state
  "disabled", "enabled", "checked", "indeterminate", "default",
  "required", "valid", "invalid", "in-range", "out-of-range",
  "placeholder-shown", "autofill", "read-only",
  # Pseudo-elements
  "before", "after", "first-letter", "first-line",
  "marker", "selection", "file", "backdrop", "placeholder",
  # Media/Preference
  "dark", "print", "portrait", "landscape",
  "motion-safe", "motion-reduce", "contrast-more", "contrast-less",
  "forced-colors",
  # Direction
  "rtl", "ltr",
  # Attribute
  "open",
  # Direct children
  "*"
].freeze
MODIFIER_PATTERNS =

Patterns that match dynamic modifiers (with optional names/arbitrary values) These use flexible regex to match ANY valid Tailwind modifier syntax

[
  /^group(?:\/\w+)?$/,                    # group, group/<any-name>
  /^group-\w+(?:\/\w+)?$/,                # group-hover, group-<state>/<any-name>
  /^peer(?:\/\w+)?$/,                     # peer, peer/<any-name>
  /^peer-\w+(?:\/\w+)?$/,                 # peer-checked, peer-<state>/<any-name>
  /^aria-\w+$/,                           # aria-checked, aria-<any-attr>
  /^aria-\[.+\]$/,                        # aria-[<arbitrary>]
  /^data-\[.+\]$/,                        # data-[<arbitrary>]
  /^supports-\[.+\]$/,                    # supports-[<arbitrary>]
  /^has-\[.+\]$/,                         # has-[<arbitrary>]
  /^group-has-\[.+\]$/,                   # group-has-[<arbitrary>]
  /^peer-has-\[.+\]$/,                    # peer-has-[<arbitrary>]
  /^min-\[.+\]$/,                         # min-[<arbitrary>]
  /^max-\[.+\]$/                          # max-[<arbitrary>]
].freeze
DATA_MERGE_KEYS =
%i[controller action].freeze
VERSION =
"0.1.0"

Instance Method Summary collapse

Instance Method Details

#aria_attrsObject

Overwrite in subclass to define default aria-attrs



438
439
440
# File 'lib/view_component_css_dsl.rb', line 438

def aria_attrs
  {}
end

#cssObject

Instance method: generates final CSS string



343
344
345
# File 'lib/view_component_css_dsl.rb', line 343

def css
  build_classes
end

#custom_cssObject

Returns caller’s custom classes from html_attrs



348
349
350
351
352
# File 'lib/view_component_css_dsl.rb', line 348

def custom_css
  return "" unless @html_attrs

  @html_attrs.fetch(:class, "")
end

#data_attrsObject

Overwrite in component subclass to set default data-attrs. They will be merged into html_attrs.

Example:

def data_attrs

{
  controller: "some-stimulus-controller",
  action: "click->some-stimulus-controller#someAction",
  active: active?
}

end



391
392
393
# File 'lib/view_component_css_dsl.rb', line 391

def data_attrs
  {}
end

#final_aria_attrsObject

Using merge allows for default value #aria_attrs, but also for dev to override that value per-instance as needed



444
445
446
# File 'lib/view_component_css_dsl.rb', line 444

def final_aria_attrs
  aria_attrs.merge(@html_attrs.fetch(:aria, {}))
end

#final_data_attrsObject

Loop through data-attrs and merge values from DATA_MERGE_KEYS. Overwrite any others.

Ensures the caller doesn’t wipe out e.g. data-controller or data-action values defined by the dev in #data_attrs

Example:

Assuming MyComponent with #data_attrs defined as:

def data_attrs

{
  controller: "foo",
  label: "Hello world"
}

end

MyComponent.new(data: “bar”, label: “Goodbye”)

Will output the following data-attrs:

<div data-controller=“foo bar” data-label=“Goodbye”>

Notice:

  • data-controller from the caller is set alongside “foo” instead of overwriting

  • In contrast, data-label from the caller overwrites the default



424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/view_component_css_dsl.rb', line 424

def final_data_attrs
  incoming_data = @html_attrs.fetch(:data, {})
  incoming_data.each_with_object(data_attrs) do |(key, value), final_data|
    final_value = if key.in?(DATA_MERGE_KEYS)
      [data_attrs[key], value].compact.join(" ")
    else
      value
    end

    final_data[key] = final_value
  end
end

#html_attrsObject

Returns the hash to splat onto the top-level element of a component template:

<%= tag.div **html_attrs do %> ... <% end %>

Includes the smart-merged ‘:class`, merged `:data` and `:aria` (from any component-defined defaults + caller overrides), and every other caller-passed HTML attribute (`id:`, `role:`, etc.) forwarded to the rendered element.



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/view_component_css_dsl.rb', line 361

def html_attrs
  return {} unless @html_attrs

  result = @html_attrs.except(:aria, :class, :data)

  # Only include aria/data if they have content, otherwise they'd override
  # inline attrs in templates like: tag.div data: {foo: "bar"}, **html_attrs
  aria = final_aria_attrs
  data = final_data_attrs
  result[:aria] = aria if aria.present?
  result[:data] = data if data.present?

  css_value = css
  result[:class] = css_value if css_value.present?
  result
end