defmodule Corex.Tooltip do @moduledoc ~S''' Phoenix implementation of [Zag.js Tooltip](https://zagjs.com/components/react/tooltip). ## Anatomy ### Minimal ```heex <.tooltip class="tooltip" show_arrow={false}> <:trigger>Hover me <:content>Tooltip content ``` ### With arrow ```heex <.tooltip class="tooltip"> <:trigger>Hover me <:content>Tooltip content ``` ### Placement ```heex <.tooltip class="tooltip" positioning={%Corex.Positioning{placement: "bottom"}}> <:trigger>Bottom <:content>Tooltip below ``` ## API Requires a stable `id` on `<.tooltip>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`set_open/2`](#set_open/2) | Set open state (client) | `%Phoenix.LiveView.JS{}` | | [`set_open/3`](#set_open/3) | Set open state (server) | `socket` | ## Events Pick an event name and pass it to `on_*` on `<.tooltip>`. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_open_change="tooltip_open_changed"` | Open state changes | `%{"id" => id, "open" => boolean}` | | `on_trigger_value_change="tooltip_trigger_changed"` | Active trigger changes (multi-trigger) | `%{"id" => id, "value" => value}` | ### on_open_change ```heex <.tooltip class="tooltip" on_open_change="tooltip_open_changed"> <:trigger>Hover me <:content>Tooltip content ``` ```elixir def handle_event("tooltip_open_changed", %{"id" => _id, "open" => open}, socket) do {:noreply, assign(socket, :tooltip_open, open)} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_open_change_client="tooltip-open-changed"` | Open state changes | `id`, `open` | | `on_trigger_value_change_client="tooltip-trigger-changed"` | Active trigger changes | `id`, `value` | ## Patterns ### Multi-trigger One content panel, several triggers. Each trigger `value` must be unique. ```heex <.tooltip class="tooltip" on_trigger_value_change="tooltip_trigger_changed" > <:trigger value="a">First <:trigger value="b">Second <:content>Active: {@active_trigger} ``` ```elixir def handle_event("tooltip_trigger_changed", %{"value" => value}, socket) do {:noreply, assign(socket, :active_trigger, value)} end ``` ## Style Target parts with `data-scope` and `data-part`, or use Corex Design: import tokens and `tooltip.css`, then set `class="tooltip"` on `<.tooltip>`. ```css [data-scope="tooltip"][data-part="trigger"] {} [data-scope="tooltip"][data-part="positioner"] {} [data-scope="tooltip"][data-part="content"] {} [data-scope="tooltip"][data-part="arrow"] {} ``` ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/tooltip.css"; ``` Stack modifiers on the host (`class` on `<.tooltip>`). ### Color | Modifier | Classes | | -------- | ------- | | Default | `tooltip` | | Accent | `tooltip tooltip--accent` | | Brand | `tooltip tooltip--brand` | | Alert | `tooltip tooltip--alert` | | Info | `tooltip tooltip--info` | | Success | `tooltip tooltip--success` | ### Size | Modifier | Classes | | -------- | ------- | | SM | `tooltip tooltip--sm` | | MD | `tooltip tooltip--md` | | LG | `tooltip tooltip--lg` | | XL | `tooltip tooltip--xl` | ### Text | Modifier | Classes | | -------- | ------- | | SM | `tooltip tooltip--text-sm` | | XL | `tooltip tooltip--text-xl` | ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.Positioning alias Corex.Tooltip.Anatomy.{Arrow, ArrowTip, Content, Positioner, Props, Trigger} alias Corex.Tooltip.Connect alias Phoenix.LiveView alias Phoenix.LiveView.JS attr(:id, :string, required: false, doc: "The id of the tooltip, useful for API to identify the tooltip" ) attr(:disabled, :boolean, default: false, doc: "Whether the tooltip is disabled") attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the tooltip. When nil, derived from document" ) attr(:orientation, :string, default: "horizontal", values: ["horizontal", "vertical"], doc: "Layout orientation for CSS." ) attr(:open_delay, :integer, default: 0, doc: "Delay in ms before opening. Default from Zag is 400" ) attr(:close_delay, :integer, default: 0, doc: "Delay in ms before closing. Default from Zag is 150" ) attr(:positioning, Positioning, default: %Positioning{}, doc: "Positioning options for the floating content" ) attr(:close_on_escape, :boolean, default: true, doc: "Whether to close on Escape. Default true" ) attr(:close_on_click, :boolean, default: true, doc: "Whether to close on click. Default true" ) attr(:close_on_pointer_down, :boolean, default: false, doc: "Whether to close on pointer down on the trigger. Default false" ) attr(:close_on_scroll, :boolean, default: false, doc: "Whether to close on scroll. Default false" ) attr(:interactive, :boolean, default: true, doc: "Whether the tooltip content is interactive (stays open when hovering content)" ) attr(:on_open_change, :string, default: nil, doc: "The server event name when the open state changes" ) attr(:on_open_change_client, :string, default: nil, doc: "The client event name when the open state changes" ) attr(:on_trigger_value_change, :string, default: nil, doc: "LiveView event when the active trigger value changes (multi-trigger). Params include id (tooltip root) and value (trigger value string)." ) attr(:on_trigger_value_change_client, :string, default: nil, doc: "The client event name when the active trigger value changes" ) attr(:show_arrow, :boolean, default: true, doc: "Whether to show an arrow pointing to the trigger" ) attr(:trigger_tag, :atom, default: :button, values: [:button, :span], doc: "Use :span when the tooltip sits inside another button-like control (e.g. tree view row) to avoid nested <% end %> <% end %>
{render_slot(Enum.at(@content, 0), @slot_assigns)}
""" end api_doc(~S""" Set tooltip open state from a control (`phx-click`). ```heex <.action phx-click={Corex.Tooltip.set_open("my-tooltip", true)}>Show <.tooltip id="my-tooltip" class="tooltip"> <:trigger>Target <:content>Hint ``` ```javascript document.getElementById("my-tooltip")?.dispatchEvent( new CustomEvent("corex:tooltip:set-open", { bubbles: false, detail: { open: true }, }) ); ``` """) def set_open(tooltip_id, open) when is_binary(tooltip_id) and is_boolean(open) do JS.dispatch("corex:tooltip:set-open", to: "##{tooltip_id}", detail: %{open: open}, bubbles: false ) end api_doc(~S""" Set open state from `handle_event`. ```heex <.action phx-click="show_tip">Show <.tooltip id="my-tooltip" class="tooltip"> <:trigger>Target <:content>Hint ``` ```elixir def handle_event("show_tip", _, socket) do {:noreply, Corex.Tooltip.set_open(socket, "my-tooltip", true)} end ``` """) def set_open(socket, tooltip_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tooltip_id) and is_boolean(open) do LiveView.push_event(socket, "tooltip_set_open", %{ tooltip_id: tooltip_id, open: open }) end defp validate_triggers!(triggers) when is_list(triggers) do case triggers do [] -> :ok [_] -> :ok many -> values = Enum.map(many, &Map.get(&1, :value)) if Enum.any?(values, &(is_nil(&1) or &1 == "")) do raise ArgumentError, "Corex.Tooltip: each <:trigger> must include a non-empty value attribute when there are multiple triggers" end if length(Enum.uniq(values)) != length(values) do raise ArgumentError, "Corex.Tooltip: trigger value attributes must be unique" end end end end