defmodule LiveViewContinuity.Accordion do @moduledoc """ An unstyled accordion with server-authoritative values and immediate, patch-safe disclosure state. See `ACCORDION.md` for the observable interaction contract. """ use Phoenix.Component alias Phoenix.LiveView.JS attr(:id, :string, required: true) attr(:values, :list, required: true) attr(:on_change, :string, required: true) attr(:multiple, :boolean, default: false) attr(:heading_level, :integer, default: 3) attr(:region, :boolean, default: false) attr(:class, :any, default: nil) attr(:rest, :global) slot :item, required: true do attr(:id, :string, required: true) attr(:label, :string, required: true) attr(:disabled, :boolean) attr(:item_class, :any) attr(:header_class, :any) attr(:trigger_class, :any) attr(:panel_class, :any) end def accordion(assigns) do validate!(assigns) ~H"""
<.dynamic_tag tag_name={"h#{@heading_level}"} class={item[:header_class]}>
""" end defp validate!(assigns) do validate_id!(assigns.id, "accordion id") if assigns.item == [], do: raise(ArgumentError, "accordion requires at least one item") ids = Enum.map(assigns.item, & &1.id) Enum.each(ids, &validate_id!(&1, "logical accordion item id")) Enum.each(assigns.values, &validate_id!(&1, "accordion value")) if length(ids) != length(Enum.uniq(ids)), do: raise(ArgumentError, "accordion requires unique item IDs") if length(assigns.values) != length(Enum.uniq(assigns.values)), do: raise(ArgumentError, "accordion values must be unique") if Enum.any?(assigns.values, &(&1 not in ids)), do: raise(ArgumentError, "accordion values must identify existing items") if !assigns.multiple and length(assigns.values) > 1, do: raise(ArgumentError, "single accordion allows at most one value") if assigns.heading_level not in 1..6, do: raise(ArgumentError, "accordion heading_level must be between 1 and 6") if Enum.any?(assigns.item, &(not is_binary(&1.label) or String.trim(&1.label) == "")), do: raise(ArgumentError, "accordion item labels must be non-blank strings") end defp validate_id!(value, name) when is_binary(value) do if value == "" or String.contains?(value, <<0>>) or Regex.match?(~r/[\t\n\f\r ]/, value), do: raise(ArgumentError, "#{name} must be a non-empty string without ASCII whitespace or NUL") end defp validate_id!(_, name), do: raise(ArgumentError, "#{name} must be a string") defp trigger_id(root, logical), do: root <> "-trigger-" <> logical defp panel_id(root, logical), do: root <> "-panel-" <> logical end