lib/ariadne/forms/dsl/input.rb



# frozen_string_literal: true

module Ariadne
  module Forms
    module Dsl
      # :nodoc:
      class Input
        # Use this macro anywhere you want to include the various params all inputs can receive.
        #
        # @!macro [new] form_input_attributes
        #   @param name [String] Value for the HTML name attribute.
        #   @param id [String] Value for the HTML id attribute.
        #   @param class [String] CSS classes to include in the input's HTML `class` attribute. Exists for compatibility with Rails form builders.
        #   @param classes [Array] CSS classes to include in the input's HTML `class` attribute. Combined with the `:class` argument. The list may contain strings, hashes, or `nil` values, and is automatically cleaned up by Ariadne's [`class_name` helper](https://github.com/primer/view_components/blob/c9cb95c98fee3e2e27f4a10683f555e22285e7f1/app/lib/primer/class_name_helper.rb) (`nils`, falsy entries, and blank strings are ignored).
        #   @param caption [String] A string describing the field and what sorts of input it expects. Displayed below the input.
        #   @param label [String] Label text displayed above the input.
        #   @param visually_hide_label [Boolean] When set to `true`, hides the label. Although the label will be hidden visually, it will still be visible to screen readers.
        #   @param disabled [Boolean] When set to `true`, the input will not accept keyboard or mouse input.
        #   @param hidden [Boolean] When set to `true`, visually hides the field.
        #   @param invalid [Boolean] If set to `true`, the input will be rendered with a red border. Implied if `validation_message` is truthy. This option is set to `true` automatically if the model object associated with the form reports that the input is invalid via Rails validations. It is provided for cases where the form does not have an associated model. If the input is invalid as determined by Rails validations, setting `invalid` to `false` will have no effect.
        #   @param validation_message [String] A string displayed between the caption and the input indicating the input's contents are invalid. This option is, by default, set to the first Rails validation message for the input (assuming the form is associated with a model object). Use `validation_message` to override the default or to provide a validation message in case there is no associated model object.
        #   @param label_attributes [Hash] Attributes that will be passed to Rails' `builder.label` method. These can be HTML attributes or any of the other label options Rails supports. They will appear as HTML attributes on the `<label>` tag.
        #   @param scope_name_to_model [Boolean] Default `true`. When set to `false`, prevents the model name from prefixing the field name. For example, if the field name is `my_field`, Rails will normally emit an HTML name attribute of `model[my_field]`. Setting `scope_name_to_model` to `false` will cause Rails to emit `my_field` instead.
        #   @param scope_id_to_model [Boolean] Default `true`. When set to `false`, prevents the model name from prefixing the field ID. For example, if the field name is `my_field`, Rails will normally emit an HTML ID attribute of `model_my_field`. Setting `scope_id_to_model` to `false` will cause Rails to emit `my_field` instead.
        #   @param required [Boolean] Default `false`. When set to `true`, causes an asterisk (*) to appear next to the field's label indicating it is a required field. Note that this option explicitly does _not_ add a `required` HTML attribute. Doing so would enable native browser validations, which are inaccessible and inconsistent with the Ariadne design system.
        #   @param aria [Hash] Key/value pairs that represent Aria attributes and their values. Eg. `aria: { current: true }` becomes `aria-current="true"`.
        #   @param data [Hash] Key/value pairs that represent data attributes and their values. Eg. `data: { foo: "bar" }` becomes `data-foo="bar"`.
        #   @macro form_system_arguments

        # @!macro [new] form_size_arguments
        #   @param size [Symbol] The size of the field. <%= one_of(Ariadne::Forms::Dsl::Input::SIZE_OPTIONS) %>

        # @!macro [new] form_full_width_arguments
        #   @param full_width [Boolean] When set to `true`, the field will take up all the horizontal space allowed by its container. Defaults to `true`.

        # @!macro [new] form_system_arguments
        #   @param system_arguments [Hash] A hash of attributes passed to the underlying Rails builder methods. These options may mean something special depending on the type of input, otherwise they are emitted as HTML attributes. See the [Rails documentation](https://guides.rubyonrails.org/form_helpers.html) for more information. In addition, the usual Ariadne utility arguments are accepted in system arguments. For example, passing `mt: 2` will add the `mt-2` class to the input. See the Ariadne system arguments docs for details.

        SPACE_DELIMITED_ARIA_ATTRIBUTES = [:describedby].freeze
        DEFAULT_SIZE = :medium
        SIZE_MAPPINGS = {
          :small => "FormControl-small",
          DEFAULT_SIZE => "FormControl-medium",
          :large => "FormControl-large",
        }.freeze
        SIZE_OPTIONS = SIZE_MAPPINGS.keys

        include Ariadne::ClassNameHelper

        attr_reader :builder, :form, :input_attributes, :label_attributes, :caption, :validation_message, :ids, :form_control, :base_id

        alias_method :form_control?, :form_control

        def initialize(builder:, form:, **options)
          @builder = builder
          @form = form

          @options = options.dup
          @input_attributes = @options.delete(:html_attrs) || {}
          @input_attributes.delete(:id) if @input_attributes[:id].blank?
          @label_attributes = @options[:label_html_attributes] || {}
          @label_attributes[:for] = id if id.present?

          @label_attributes[:class] = merge_class_names(
            @label_attributes[:class],
            @options.delete(:visually_hide_label) ? "sr-only" : nil,
          )

          @input_attributes.delete(:class) if @input_attributes[:class].blank?
          @label_attributes.delete(:class) if @label_attributes[:class].blank?

          @caption = @options.delete(:caption)
          @placeholder = @options.delete(:placeholder)
          @disabled = @options.delete(:disabled)
          @validation_message = @options.delete(:validation_message)
          @invalid = @options.delete(:invalid)
          @full_width = @options.delete(:full_width) { true }

          # If scope_name_to_model is false, the name of the input for eg. `my_field`
          # will be `my_field` instead of the Rails default of `model[my_field]`.
          #
          # We achieve this by passing the `name` option to Rails form builder
          # methods. These methods will use the passed name if provided instead
          # of generating a scoped one.
          #
          unless @options.delete(:scope_name_to_model) { true }
            @input_attributes[:name] = name
          end
          # rubocop:enable Style/IfUnlessModifier

          # If scope_id_to_model is false, the name of the input for eg. `my_field`
          # will be `my_field` instead of the Rails default of `model_my_field`.
          #
          # We achieve this by passing the `id` option to Rails form builder
          # methods. These methods will use the passed id if provided instead
          # of generating a scoped one. The id is the name of the field unless
          # explicitly provided in system_arguments.
          #
          # rubocop:disable Style/IfUnlessModifier
          unless @options.delete(:scope_id_to_model) { true }
            @input_attributes[:id] = @input_attributes.delete(:id) { name }
          end
          # rubocop:enable Style/IfUnlessModifier

          # Whether or not to wrap the component in a FormControl, which renders a
          # label above and validation message beneath the input.
          @form_control = @options.delete(:form_control) { true }

          @options[:invalid] = "true" if invalid?

          @base_id = Ariadne::BaseComponent.generate_id(base_name: "ariadne-form-input")

          @ids = {}.tap do |id_map|
            id_map[:validation] = "validation-#{@base_id}" if supports_validation?
            id_map[:caption] = "caption-#{@base_id}" if caption? || caption_template?
          end

          if required?
            input_attributes[:required] = true
            add_input_aria(:required, true)
          end

          add_input_aria(:invalid, true) if invalid?
          add_input_aria(:describedby, ids.values) if ids.any?
        end

        def add_input_classes(*class_names)
          input_attributes[:class] = merge_class_names(
            input_attributes[:class], *class_names
          )
        end

        def add_input_aria(key, value)
          @input_attributes[:aria] ||= {}

          @input_attributes[:aria][key] = if space_delimited_aria_attribute?(key)
            aria_join(@input_attributes[:aria][key], *Array(value))
          else
            value
          end
        end

        def add_input_data(key, value)
          input_data[key] = value
        end

        # :nocov:
        def remove_input_data(key)
          input_data.delete(key)
        end
        # :nocov:

        def merge_input_attributes!(arguments)
          arguments.each do |k, v|
            case k
            when :class, :classes, "class", "classes"
              add_input_classes(v)
            when :aria, "aria"
              v.each do |aria_k, aria_v|
                add_input_aria(aria_k, aria_v)
              end
            when :data, "data"
              v.each do |data_k, data_v|
                add_input_data(data_k, data_v)
              end
            else
              @input_attributes[k] = v
            end
          end
        end

        def validation_id
          ids[:validation]
        end

        def caption_id
          ids[:caption]
        end

        def caption?
          caption.present?
        end

        def caption_template?
          return false unless form

          form.caption_template?(caption_template_name)
        end

        def render_caption_template
          form.render_caption_template(caption_template_name)
        end

        def valid?
          supports_validation? && validation_messages.empty? && !@invalid
        end

        def invalid?
          supports_validation? && !valid?
        end

        def hidden?
          !!input_attributes[:hidden]
        end

        def required?
          @options[:required] ||
            input_attributes[:aria_required] ||
            input_attributes[:"aria-required"] ||
            input_attributes.dig(:aria, :required)
        end

        def disabled?
          @disabled.present?
        end

        def size
          @size ||= SIZE_MAPPINGS.include?(@size) ? @size : DEFAULT_SIZE
        end

        def validation_messages
          @validation_messages ||=
            if validation_message
              [validation_message]
            elsif builder.object.respond_to?(:errors)
              name ? builder.object.errors.full_messages_for(name) : []
            else
              []
            end
        end

        def autofocus!
          @input_attributes[:autofocus] = true
        end

        def id
          @input_attributes[:id]
        end

        # :nocov:
        def name
          raise_for_abstract_method!(__method__)
        end

        def label
          raise_for_abstract_method!(__method__)
        end

        def type
          raise_for_abstract_method!(__method__)
        end

        def to_component
          raise_for_abstract_method!(__method__)
        end
        # :nocov:

        def focusable?
          false
        end

        def input?
          true
        end

        def supports_validation?
          true
        end

        def validation_arguments
          {
            class: "FormControl-inlineValidation",
            id: validation_id,
            hidden: valid? || validation_messages.empty?,
          }
        end

        def validation_message_arguments
          {}
        end

        def validation_success_icon_target
          ""
        end

        def validation_error_icon_target
          ""
        end

        private

        def input_data
          @input_attributes[:data] ||= {}
        end

        def caption_template_name
          return unless name

          @caption_template_name ||= if respond_to?(:value)
            :"#{name}_#{value}"
          else
            name.to_sym
          end
        end

        def space_delimited_aria_attribute?(attrib)
          SPACE_DELIMITED_ARIA_ATTRIBUTES.include?(attrib)
        end

        def aria_join(*values)
          values = values.flat_map { |v| v.to_s.split }
          values.reject!(&:empty?)
          values.join(" ")
        end

        # :nocov:
        def raise_for_abstract_method!(method_name)
          raise NotImplementedError, "subclasses must implement ##{method_name}."
        end
        # :nocov:
      end
    end
  end
end