Drafter.Style.Selector (drafter v0.3.1)

Copy Markdown View Source

A parsed CSS-like selector, and the rules for matching one against a widget.

A selector constrains five things, all optional. A constraint that is nil or empty is satisfied by anything.

  • :widget_type — the widget's type, written bare ("button"). A CamelCase name is converted with Macro.underscore/1, so "DataTable" and "data_table" parse alike.
  • :id — written #name
  • :classes — written .name, repeatable. Every class in the selector must be present on the widget; the widget may carry others.
  • :pseudo_classes — written :name. Recognised names are :hover, :focus, :active, :disabled, :checked, :selected, :expanded and :collapsed; any other :name is dropped during parsing rather than reported.
  • :part — written ::name, naming a sub-region of a widget such as ::border. A selector naming a part matches only a context whose :part is equal to it; a selector naming no part matches whether or not the context has one, which is what lets a widget's base rules apply underneath its part rules.

Match context

matches?/2 takes a map, not a widget. It reads :widget_type, :id, :classes and :part directly, and derives the active pseudo-classes from the boolean keys :hovered, :focused, :active, :disabled, :checked, :selected and :expanded. :collapsed is active when :expanded is exactly false. Those keys must hold true, false or nil; any other value raises FunctionClauseError.

iex> alias Drafter.Style.Selector
iex> [selector] = Selector.parse("button:focus")
iex> Selector.matches?(selector, %{widget_type: :button, focused: true})
true
iex> Selector.matches?(selector, %{widget_type: :button, focused: false})
false

Names as atoms or strings

parse/1 converts each name with String.to_existing_atom/1 and keeps the string when no such atom exists. A selector therefore holds a mix, and matching compares names rather than terms — see same_name?/2.

Descendant selectors

parse/1 splits a space-separated selector into one selector per token and returns them as a list. No ancestor relationship is recorded and none is checked: Drafter.Style.Stylesheet matches a rule when any selector in the list matches, so "container button" behaves as the selector group "container, button" rather than as a descendant selector.

Summary

Types

A widget description matches?/2 is run against.

A selector's weight, as {ids, classes, types}.

t()

Functions

Compare two selectors by specificity, as :gt, :lt or :eq.

Whether context satisfies every constraint selector sets.

Build a selector from its parts.

Parse a selector into a list of selectors, one per space-separated token.

Whether two selector tokens name the same thing.

The selector's weight, as {ids, classes, types}.

Types

context()

@type context() :: %{
  optional(:widget_type) => atom() | String.t() | nil,
  optional(:id) => atom() | String.t() | nil,
  optional(:classes) => [atom() | String.t()],
  optional(:part) => atom() | nil,
  optional(:hovered) => boolean() | nil,
  optional(:focused) => boolean() | nil,
  optional(:active) => boolean() | nil,
  optional(:disabled) => boolean() | nil,
  optional(:checked) => boolean() | nil,
  optional(:selected) => boolean() | nil,
  optional(:expanded) => boolean() | nil
}

A widget description matches?/2 is run against.

Every key is optional. Absent keys are treated as unset, except that a selector naming classes never matches a context without a :classes key.

pseudo_class()

@type pseudo_class() ::
  :hover
  | :focus
  | :active
  | :disabled
  | :checked
  | :selected
  | :expanded
  | :collapsed

specificity()

@type specificity() :: {non_neg_integer(), non_neg_integer(), non_neg_integer()}

A selector's weight, as {ids, classes, types}.

Ordered as an Erlang term, so the tuples compare in CSS specificity order.

t()

@type t() :: %Drafter.Style.Selector{
  classes: [atom()],
  id: atom() | nil,
  part: atom() | nil,
  pseudo_classes: [pseudo_class()],
  widget_type: atom() | nil
}

Functions

compare_specificity(s1, s2)

@spec compare_specificity(t(), t()) :: :gt | :lt | :eq

Compare two selectors by specificity, as :gt, :lt or :eq.

Examples

iex> alias Drafter.Style.Selector
iex> Selector.compare_specificity(Selector.new(id: :save), Selector.new(widget_type: :button))
:gt

iex> alias Drafter.Style.Selector
iex> Selector.compare_specificity(Selector.new(widget_type: :button), Selector.new(widget_type: :label))
:eq

matches?(selector, context)

@spec matches?(t(), context()) :: boolean()

Whether context satisfies every constraint selector sets.

All five constraints must hold. An unset constraint is satisfied by anything, so new/0 matches every context. Names are compared by same_name?/2, so a context may use strings where the selector uses atoms.

A selector naming classes needs context to have a :classes key; a context without one never matches. A selector naming a part needs context.part to be equal to it, compared with == rather than by name. A selector naming no part matches whether or not the context has one.

Examples

iex> alias Drafter.Style.Selector
iex> Selector.matches?(Selector.new(), %{})
true

iex> alias Drafter.Style.Selector
iex> Selector.matches?(Selector.new(widget_type: :button), %{widget_type: "button"})
true

iex> alias Drafter.Style.Selector
iex> Selector.matches?(Selector.new(classes: [:primary]), %{classes: [:primary, :large]})
true
iex> Selector.matches?(Selector.new(classes: [:primary]), %{classes: [:large]})
false
iex> Selector.matches?(Selector.new(classes: [:primary]), %{})
false

iex> alias Drafter.Style.Selector
iex> Selector.matches?(Selector.new(pseudo_classes: [:collapsed]), %{expanded: false})
true
iex> Selector.matches?(Selector.new(pseudo_classes: [:collapsed]), %{})
false

iex> alias Drafter.Style.Selector
iex> Selector.matches?(Selector.new(part: :border), %{part: :border})
true
iex> Selector.matches?(Selector.new(part: :border), %{})
false
iex> Selector.matches?(Selector.new(), %{part: :border})
true

new(opts \\ [])

@spec new(keyword()) :: t()

Build a selector from its parts.

Options, all defaulting to the struct default:

  • :widget_type — atom or string, default nil
  • :id — atom or string, default nil
  • :classes — list of atoms or strings, default []
  • :pseudo_classes — list of pseudo_class/0, default []
  • :part — atom, default nil

Nothing is validated: an unrecognised pseudo-class is stored as given and then never matches.

Examples

iex> Drafter.Style.Selector.new()
%Drafter.Style.Selector{widget_type: nil, id: nil, classes: [], pseudo_classes: [], part: nil}

iex> Drafter.Style.Selector.new(widget_type: :button, pseudo_classes: [:focus])
%Drafter.Style.Selector{widget_type: :button, id: nil, classes: [], pseudo_classes: [:focus], part: nil}

parse(selector_string)

@spec parse(String.t() | atom()) :: [t()]

Parse a selector into a list of selectors, one per space-separated token.

An atom is taken as a bare widget type and gives a one-element list. Order within a token does not matter: type, #id, .class, ::part and :pseudo_class are each extracted independently, so "button:focus::border" and "button::border:focus" parse alike.

Unrecognised pseudo-class names are dropped, and an unrecognised part name becomes nil, which drops the part constraint rather than failing the match.

Examples

iex> Drafter.Style.Selector.parse(:button)
[%Drafter.Style.Selector{widget_type: :button, id: nil, classes: [], pseudo_classes: [], part: nil}]

iex> [selector] = Drafter.Style.Selector.parse("DataTable::row:selected")
iex> {selector.widget_type, selector.part, selector.pseudo_classes}
{:data_table, :row, [:selected]}

iex> Drafter.Style.Selector.parse("container button") |> length()
2

iex> [selector] = Drafter.Style.Selector.parse(":no_such_pseudo_class")
iex> selector.pseudo_classes
[]

same_name?(token, other)

@spec same_name?(term(), term()) :: boolean()

Whether two selector tokens name the same thing.

A token is an atom or a string; the two forms compare equal when their names are equal, so :button and "button" match. nil on either side is never a match.

Parsed selectors hold a string wherever the corresponding atom does not yet exist in the VM — a widget type whose module has not been loaded, or an id belonging to a widget that has not rendered — so callers must not assume atoms.

A term that is neither an atom nor a binary is named by inspect/1, so two equal such terms match and unequal ones do not.

Examples

iex> Drafter.Style.Selector.same_name?(:button, "button")
true

iex> Drafter.Style.Selector.same_name?("button", "label")
false

iex> Drafter.Style.Selector.same_name?(nil, nil)
false

specificity(selector)

@spec specificity(t()) :: specificity()

The selector's weight, as {ids, classes, types}.

ids is 1 when the selector names an id. classes counts the classes, the pseudo-classes and the part together. types is 1 when the selector names a widget type. The tuple orders as an Erlang term, so comparing two of them compares specificity.

Examples

iex> alias Drafter.Style.Selector
iex> Selector.specificity(Selector.new())
{0, 0, 0}

iex> alias Drafter.Style.Selector
iex> Selector.specificity(Selector.new(widget_type: :button))
{0, 0, 1}

iex> alias Drafter.Style.Selector
iex> [selector] = Selector.parse("button.primary:focus::border")
iex> Selector.specificity(selector)
{0, 3, 1}