defmodule LiveViewContinuity.RadioGroup do @moduledoc """ An unstyled native radio group with server-authoritative value and patch-safe optimistic selection. See `RADIO_GROUP.md` for the observable interaction contract. """ use Phoenix.Component alias Phoenix.LiveView.JS attr(:id, :string, required: true) attr(:name, :string, required: true) attr(:value, :string, required: true) attr(:on_change, :string, required: true) attr(:label, :string, required: true) attr(:required, :boolean, default: false) attr(:disabled, :boolean, default: false) attr(:read_only, :boolean, default: false) attr(:invalid, :boolean, default: false) attr(:form, :string, default: nil) attr(:class, :any, default: nil) attr(:legend_class, :any, default: nil) attr(:description_class, :any, default: nil) attr(:error_class, :any, default: nil) attr(:rest, :global) slot :option, required: true do attr(:value, :string, required: true) attr(:label, :string, required: true) attr(:disabled, :boolean) attr(:class, :any) attr(:input_class, :any) attr(:label_class, :any) end slot(:description) slot(:error) def radio_group(assigns) do validate!(assigns) describedby = describedby(assigns) assigns = assign(assigns, :describedby, describedby) ~H"""
""" end defp validate!(assigns) do validate_dom_value!(assigns.id, "radio group id") validate_name!(assigns.name) validate_label!(assigns.label, "radio group label") if assigns.option == [], do: raise(ArgumentError, "radio group requires at least one option") if length(assigns.description) > 1, do: raise(ArgumentError, "radio group accepts at most one description") if length(assigns.error) > 1, do: raise(ArgumentError, "radio group accepts at most one error") values = Enum.map(assigns.option, & &1.value) Enum.each(values, &validate_dom_value!(&1, "radio option value")) Enum.each(assigns.option, &validate_label!(&1.label, "radio option label")) if length(values) != length(Enum.uniq(values)), do: raise(ArgumentError, "radio option values must be unique") if assigns.value != nil, do: validate_dom_value!(assigns.value, "radio group value") if assigns.value != nil and assigns.value not in values, do: raise(ArgumentError, "radio group value must identify an existing option") end defp validate_dom_value!(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_dom_value!(_, name), do: raise(ArgumentError, "#{name} must be a string") defp validate_name!(name) when is_binary(name) do if String.trim(name) == "" or String.contains?(name, <<0>>), do: raise(ArgumentError, "radio group name must be non-blank and contain no NUL") end defp validate_name!(_), do: raise(ArgumentError, "radio group name must be a string") defp validate_label!(label, name) when is_binary(label) do if String.trim(label) == "", do: raise(ArgumentError, "#{name} must be a non-blank string") end defp validate_label!(_, name), do: raise(ArgumentError, "#{name} must be a non-blank string") defp describedby(assigns), do: Enum.join( Enum.reject( [ assigns.description != [] && assigns.id <> "-description", assigns.invalid && assigns.error != [] && assigns.id <> "-error" ], &(&1 == false) ), " " ) |> blank_to_nil() defp blank_to_nil(""), do: nil defp blank_to_nil(value), do: value defp option_id(root, value), do: root <> "-option-" <> value end