lib/volt/page/bindings/attribute_binding.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require 'volt/page/bindings/base_binding'
require 'volt/page/targets/attribute_target'

module Volt
  class AttributeBinding < BaseBinding
    def initialize(volt_app, target, context, binding_name, attribute_name, getter, setter)
      super(volt_app, target, context, binding_name)

      @attribute_name = attribute_name
      @getter         = getter
      @setter         = setter

      if RUBY_PLATFORM == 'opal'
        setup
      else
        setup_server
      end
    end

    def setup
      if `#{element}.is('select')`
        @is_select = true
      elsif `#{element}.is('[type=hidden]')`
        @is_hidden = true
      elsif `#{element}.is('[type=radio]')`
        @is_radio = true
        @selected_value = `#{element}.attr('value') || ''`
      elsif `#{element}.is('option')`
        @is_option = true
      end

      if @is_option
      else
        # Bind so when this value updates, we update
        case @attribute_name
          when 'value'
            changed_event = Proc.new { changed }
            if @is_select
              `#{element}.on('change.attrbind', #{changed_event})`

              invalidate_proc = Proc.new { invalidate }
              `#{element}.on('invalidate', #{invalidate_proc})`
            elsif @is_hidden
              `#{element}.watch('value', #{changed_event})`
            else
              `#{element}.on('input.attrbind', #{changed_event})`
            end
          when 'checked'
            changed_event = proc { |event| changed(event) }
            `#{element}.on('change.attrbind', #{changed_event})`
        end
      end

      # Listen for changes
      @computation = lambda do
        begin
          @context.instance_eval(&@getter)
        rescue => e
          getter_fail(e)
          ''
        end
      end.watch_and_resolve!(
        method(:update),
        method(:getter_fail)
      )

    end

    unless RUBY_PLATFORM == 'opal'
      # Does the setup during server side rendering
      def setup_server
        val = begin
          @context.instance_eval(&@getter)
        rescue => e
          getter_fail(e)
          ''
        end

        node = target.find_by_tag_id(binding_name)

        if node
          node.attributes[@attribute_name] = val
        end
      end
    end

    def changed(event = nil)
      case @attribute_name
        when 'value'
          current_value = `#{element}.val() || ''`
        else
          current_value = `#{element}.is(':checked')`
      end

      if @is_radio
        if current_value
          # if it is a radio button and its checked
          @context.instance_exec(@selected_value, &@setter)
        end
      else
        @context.instance_exec(current_value, &@setter)
      end
    end

    def element
      @element ||= `$('#' + #{binding_name})`
    end

    def update(new_value)
      if @attribute_name == 'checked'
        update_checked(new_value)
        return
      end

      # Stop any previous reactive template computations
      @string_template_renderer_computation.stop if @string_template_renderer_computation
      @string_template_renderer.remove if @string_template_renderer

      if new_value.is_a?(StringTemplateRenderer)
        # We don't need to refetch the whole reactive template to
        # update, we can just depend on it and update directly.
        @string_template_renderer = new_value

        @string_template_renderer_computation = lambda do
          self.value = @string_template_renderer.html
        end.watch!
      else
        new_value = '' if new_value.is_a?(NilMethodCall) || new_value.nil?

        self.value = new_value
      end
    end

    def value=(val)
      case @attribute_name
        when 'value'
          if @is_option
            # When a new option is added, we trigger the invalidate event on the
            # parent select so it will re-run update on the next tick and set
            # the correct option.
            `#{element}.parent('select').trigger('invalidate');`
          end
          # TODO: only update if its not the same, this keeps it from moving the
          # cursor in text fields.
          `#{element}.val(#{val})` if val != `(#{element}.val() || '')`
        when 'disabled'
          # Disabled is handled specially, you can either return a boolean:
          # (true being disabled, false not disabled), or you can optionally
          # include the "disabled" string. (or any string)
          if val != false && val.present?
            `#{element}.attr('disabled', 'disabled')`
          else
            `#{element}.removeAttr('disabled')`
          end
        else
          `#{element}.attr(#{@attribute_name}, #{val})`
      end
    end

    # On select boxes, when an option is added/changed, we want to run update
    # again.  By calling invalidate, it will run at most once on the next tick.
    def invalidate
      @computation.invalidate!
    end

    def update_checked(value)
      value = false if value.is_a?(NilMethodCall) || value.nil?

      value = (@selected_value == value) if @is_radio

      `#{element}.prop('checked', #{value})`
    end

    def remove
      # Unbind events, leave the element there since attribute bindings
      # aren't responsible for it being there.
      case @attribute_name
        when 'value'
          if @is_select
            `#{element}.off('change.attrbind')`
            `#{element}.off('invalidate')`
          elsif @is_hidden
            `#{element}.unwatch('value')`
          else
            `#{element}.off('input.attrbind', #{nil})`
          end
        when 'checked'
          `#{element}.off('change.attrbind', #{nil})`
      end

      if @computation
        @computation.stop
        @computation = nil
      end

      @string_template_renderer.remove if @string_template_renderer
      @string_template_renderer_computation.stop if @string_template_renderer_computation

      # Clear any references
      @target  = nil
      @context = nil
      @getter  = nil
    end

    def remove_anchors
      fail 'attribute bindings do not have anchors, can not remove them'
    end
  end
end