defmodule LiveViewContinuity.Tooltip do @moduledoc """ An unstyled, descriptive tooltip with patch-safe browser interaction state. See `TOOLTIP.md` for the observable interaction contract. """ use Phoenix.Component alias Phoenix.LiveView.JS attr(:id, :string, required: true) attr(:delay, :integer, default: 600) attr(:disabled, :boolean, default: false) attr(:describedby, :string, default: nil) attr(:class, :any, default: nil) attr(:trigger_class, :any, default: nil) attr(:trigger_attrs, :map, default: %{}) attr(:tooltip_class, :any, default: nil) attr(:rest, :global) slot(:trigger, required: true) slot(:inner_block, required: true) def tooltip(assigns) do validate_id!(assigns.id) validate_delay!(assigns.delay) validate_idrefs!(assigns.describedby) trigger_attrs = normalize_trigger_attrs!(assigns.trigger_attrs) trigger = one!(assigns.trigger, "trigger") one!(assigns.inner_block, "body") assigns = assigns |> assign(:trigger_attrs, trigger_attrs) |> assign(:trigger_entry, trigger) ~H"""
"-popup"} role="tooltip" popover="manual" class={@tooltip_class} data-lvc-tooltip-popup > {render_slot(@inner_block)}
""" end defp one!([entry], _name), do: entry defp one!(entries, name), do: raise(ArgumentError, "tooltip requires exactly one #{name} slot, got: #{length(entries)}") defp validate_id!(id) do if id == "" or String.contains?(id, <<0>>) or Regex.match?(~r/[\t\n\f\r ]/, id), do: raise( ArgumentError, "tooltip id must be a non-empty string without ASCII whitespace or NUL" ) end defp validate_delay!(delay) when is_integer(delay) and delay >= 0, do: :ok defp validate_delay!(_), do: raise(ArgumentError, "tooltip delay must be a nonnegative integer") defp validate_idrefs!(nil), do: :ok defp validate_idrefs!(value) when is_binary(value) do tokens = String.split(value, ~r/[\t\n\f\r ]+/, trim: true) if tokens == [] or Enum.any?(tokens, &String.contains?(&1, <<0>>)), do: raise(ArgumentError, "tooltip describedby must contain valid IDREF tokens") end defp validate_idrefs!(_), do: raise(ArgumentError, "tooltip describedby must be an IDREF string") defp normalize_trigger_attrs!(attrs) when is_map(attrs) do Enum.reduce(attrs, %{}, fn {key, value}, normalized -> name = normalize_trigger_attr_name!(key) if Map.has_key?(normalized, name), do: raise(ArgumentError, "tooltip trigger_attrs contains duplicate attribute #{name}") Map.put(normalized, name, value) end) end defp normalize_trigger_attrs!(_), do: raise(ArgumentError, "tooltip trigger_attrs must be a map") defp normalize_trigger_attr_name!(key) when is_atom(key) or is_binary(key) do name = to_string(key) if name in ["aria-label", "phx-click", "phx-target"] or Regex.match?(~r/\Aphx-value-[a-z0-9_-]+\z/, name) do name else raise ArgumentError, "tooltip trigger_attrs only accepts aria-label, phx-click, phx-target, and phx-value-*" end end defp normalize_trigger_attr_name!(_), do: raise( ArgumentError, "tooltip trigger_attrs only accepts aria-label, phx-click, phx-target, and phx-value-*" ) end