PhiaUi.ClassMerger.Groups (phia_ui v0.1.17)

Copy Markdown View Source

Maps Tailwind CSS utility class tokens to their conflict group.

The Conflict Group Concept

Two Tailwind classes "conflict" when they both set the same CSS property. For example, px-2 and px-4 both set padding-inline-start and padding-inline-end. Applying both classes to an element results in only one value being active (whichever appears last in the stylesheet), which makes the earlier class redundant and confusing.

cn/1 uses this module to detect and resolve such conflicts at merge time: only the last class in a conflict group is kept in the output.

Each conflict group is identified by an atom (e.g. :px, :bg, :text_size). Two tokens with the same group atom conflict; two tokens with different group atoms are independent and both kept.

Lookup Strategy

group_for/1 uses a four-step cascade to classify any token. Steps are ordered from most specific to least specific, and the first match wins:

  1. exact_group/1 — O(1) compile-time map lookup. Handles classes that must be matched precisely to avoid false positives (e.g. flex matches both the display group and the flex-* prefix rule). Also handles text-{align} classes that must be separated from the text-{color} catch-all in step 2.

  2. special_group/1 — Handles ambiguous text-* and font-* prefixes that cover multiple independent CSS properties:

    • text-sm / text-lg:text_size (font-size)
    • text-red-500 / text-primary:text_color (color)
    • font-bold / font-semibold:font_weight (font-weight)
    • font-sans / font-mono:font_family (font-family)
  3. prefix_group/1 — Iterates the @prefix_rules table (ordered longest-first) and returns the group for the first matching prefix. This covers spacing, sizing, layout, and decoration utilities.

  4. standalone_group/1 — Catches multi-word classes that share a prefix with an unrelated group (e.g. flex-row / flex-col for flex direction, which must not match the flex-* prefix in the display group).

Why MapSet for Membership Tests?

The compile-time constants (@text_size_classes, @font_weight_classes, @font_family_classes, @display_classes, etc.) are stored as MapSet values rather than plain lists. This gives O(1) average-case membership tests (class in @text_size_classes) instead of O(n) linear scans, which matters because group_for/1 is called for every token in every cn/1 call.

MapSets are built at compile time using module attributes, so there is no runtime allocation cost — the set is baked into the BEAM bytecode as a literal.

Why Prefix Rules Are Ordered Longest-First

The prefix-rule table is scanned linearly from top to bottom; the first matching prefix wins. Ordering by descending prefix length prevents shorter prefixes from matching classes that belong to a more specific group.

Example: gap-x-4 must match {"gap-x-", :gap_x} before {"gap-", :gap}. If "gap-" appeared first, gap-x-4 would be incorrectly assigned to the general :gap group instead of the axis-specific :gap_x group, causing gap-x-4 and gap-4 to be treated as conflicting (they are independent CSS properties: column-gap vs gap).

Similarly, px-4 must match {"px-", :px} before {"p-", :p} — they set different CSS properties (padding-inline vs all-sides padding).

Returns

Returns nil for classes that don't belong to any known group (arbitrary / custom classes), in which case exact-match deduplication applies instead.

Summary

Functions

Returns the conflict group for class, or nil if unknown.

Functions

group_for(class)

@spec group_for(String.t()) :: {String.t() | nil, atom()} | nil

Returns the conflict group for class, or nil if unknown.

Returns a {prefix, group} tuple where prefix is the variant prefix string (e.g. "sm:", "hover:sm:") or nil for unprefixed classes. Two classes with the same {prefix, group} tuple conflict; only the last one is kept by cn/1.

This makes conflict resolution responsive-prefix aware: sm:flex-col and sm:flex-row conflict (both {"sm:", :flex_direction}), but sm:flex-col and md:flex-row do not conflict (different prefixes).

Returns nil when the class has no known conflict group — it will be deduplicated by exact string match instead.

Examples

Background colour group:

iex> PhiaUi.ClassMerger.Groups.group_for("bg-primary")
{nil, :bg}

iex> PhiaUi.ClassMerger.Groups.group_for("bg-red-500")
{nil, :bg}

Responsive prefix awareness:

iex> PhiaUi.ClassMerger.Groups.group_for("sm:bg-primary")
{"sm:", :bg}

iex> PhiaUi.ClassMerger.Groups.group_for("md:bg-red-500")
{"md:", :bg}

Text size (font-size) group:

iex> PhiaUi.ClassMerger.Groups.group_for("text-sm")
{nil, :text_size}

iex> PhiaUi.ClassMerger.Groups.group_for("text-2xl")
{nil, :text_size}

Text colour — different group from text size:

iex> PhiaUi.ClassMerger.Groups.group_for("text-red-500")
{nil, :text_color}

iex> PhiaUi.ClassMerger.Groups.group_for("text-primary")
{nil, :text_color}

Text alignment — exact-matched before the text-* catch-all:

iex> PhiaUi.ClassMerger.Groups.group_for("text-center")
{nil, :text_align}

Padding — axis-specific group:

iex> PhiaUi.ClassMerger.Groups.group_for("px-4")
{nil, :px}

iex> PhiaUi.ClassMerger.Groups.group_for("p-4")
{nil, :p}

Display — exact match:

iex> PhiaUi.ClassMerger.Groups.group_for("flex")
{nil, :display}

iex> PhiaUi.ClassMerger.Groups.group_for("hidden")
{nil, :display}

Unknown / custom class returns nil:

iex> PhiaUi.ClassMerger.Groups.group_for("phia-custom")
nil

iex> PhiaUi.ClassMerger.Groups.group_for("my-custom-class")
nil