class Snabberb::Component

Attributes

node[RW]
root[R]

Public Class Methods

attach(container, **passed_needs) click to toggle source

Attach the root component to a dom element by container id.

# File opal/snabberb/component.rb, line 120
def self.attach(container, **passed_needs)
  component = new(nil, passed_needs)
  component.node = `document.getElementById(#{container})`
  component.update!
end
class_needs() click to toggle source
# File opal/snabberb/component.rb, line 115
def self.class_needs
  @class_needs ||= superclass.respond_to?(:class_needs) ? superclass.class_needs.clone : {}
end
html(**passed_needs) click to toggle source

Render the component as an HTML string using snabbdom-to-html.

# File opal/snabberb/component.rb, line 127
def self.html(**passed_needs)
  component = new(nil, passed_needs)
  component.html
end
needs(key, **opts) click to toggle source

You can define needs in each component. They are automatically set as instance variables

For example:

class Example < Component
  needs :value
  needs :name, default: 'Name', store: true

Opts:

:default - Sets the default value, if not passed the need is considered required.
:store - Whether or not to store the need as state. The default is false.
# File opal/snabberb/component.rb, line 111
def self.needs(key, **opts)
  class_needs[key] = opts
end
new(root, needs) click to toggle source

You should not call initialize manually.

# File opal/snabberb/component.rb, line 133
def initialize(root, needs)
  @root = root || self
  @store = root? ? {} : @root.store

  unused = needs.keys - class_needs.keys
  raise "Unused needs passed to component: #{unused}." unless unused.empty?

  init_needs(needs)
end

Public Instance Methods

class_needs() click to toggle source
# File opal/snabberb/component.rb, line 211
def class_needs
  self.class.class_needs
end
h(element, props = {}, children = nil) click to toggle source

Building block for dom elements using Snabbdom h and Snabberb components.

Props are not required for HTML tags and children can be passed as the second argument. Components do not take in children as they are already embeded within the class.

For eaxmple:

h(:div, 'Hello world')
h(:div, { style: { width: '100%' }, 'Hello World!')
h(:div, [h(:div), h(:div)])
h(MyComponent, need1=1, need2=2)
# File opal/snabberb/component.rb, line 166
def h(element, props = {}, children = nil)
  if element.is_a?(Class)
    raise "Element '#{element}' must be a subclass of #{self.class}" unless element <= Component

    component = element.new(@root, **props)
    component.render
  else
    props_is_hash = props.is_a?(Hash)
    children = props if !children && !props_is_hash
    props = {} unless props_is_hash
    `snabbdom.h(#{element}, #{Native.convert(props)}, #{children})`
  end
end
html() click to toggle source
# File opal/snabberb/component.rb, line 152
def html
  node_to_s(render)
end
render() click to toggle source

Subclasses should override this and return a single h (can be nested).

# File opal/snabberb/component.rb, line 148
def render
  raise NotImplementedError
end
request_ids() click to toggle source
# File opal/snabberb/component.rb, line 219
def request_ids
  root? ? @request_ids ||= [] : @root.request_ids
end
root?() click to toggle source
# File opal/snabberb/component.rb, line 143
def root?
  self == @root
end
store(key = nil, value = nil, skip: false) click to toggle source

Store a value and trigger and update unless skip is true. If called with no arguments, return the store object.

# File opal/snabberb/component.rb, line 198
def store(key = nil, value = nil, skip: false)
  return @store if key.nil?
  raise "Cannot store key '#{key}' since it is not a stored need of #{self.class}." unless stores?(key)

  @store[key] = value

  ivar = "@#{key}"
  instance_variable_set(ivar, value)
  @root.instance_variable_set(ivar, value) if !root? && @root.stores?(key)

  update unless skip
end
stores?(key) click to toggle source
# File opal/snabberb/component.rb, line 215
def stores?(key)
  class_needs.dig(key, :store)
end
update() click to toggle source

Update the dom with the request animation frame queue. Add to a queue of request_ids so we can track calls.

# File opal/snabberb/component.rb, line 182
def update
  request_ids << `window.requestAnimationFrame(function(timestamp) {#{update!}})`
end
update!() click to toggle source

Update the dom immediately if this is the final animation request.

# File opal/snabberb/component.rb, line 187
def update!
  request_ids.shift
  return unless request_ids.empty?

  node = @root.render
  `PATCHER(#{root.node}, #{node})`
  @root.node = node
end

Private Instance Methods

escape(html) click to toggle source

rubocop:enable Lint/UnusedMethodArgument

# File opal/snabberb/component.rb, line 344
def escape(html)
  ERB::Util.html_escape(html)
end
init_needs(needs) click to toggle source
# File opal/snabberb/component.rb, line 225
def init_needs(needs)
  class_needs.each do |key, opts|
    ivar = "@#{key}"
    if @store.key?(key) && opts[:store]
      instance_variable_set(ivar, @store[key])
    elsif needs.key?(key)
      @store[key] = needs[key] if opts[:store] && !@store.key?(key)
      instance_variable_set(ivar, needs[key])
    elsif opts&.key?(:default)
      instance_variable_set(ivar, opts[:default])
    else
      raise "Needs '#{key}' required but not provided."
    end
  end
end
node_to_s(vnode) click to toggle source
# File opal/snabberb/component.rb, line 270
def node_to_s(vnode)
  %x{
    if (!vnode.sel) return self.$escape(vnode.text)

    const sel = self.$parse_sel(vnode.sel)

    for (const key in vnode.data.class) {
      vnode.data.class[key] ? sel['classes'][key] = true : delete sel['classes'][key]
    }

    let attributes = {}
    if (sel['id'].length > 0) attributes['id'] = sel['id']

    const classes = Object.keys(sel['classes'])
    if (classes.length > 0) attributes['class'] = classes.join(' ')

    for (const key in vnode.data.attrs) {
      attributes[key] = vnode.data.attrs[key]
    }

    for (const key in vnode.data.dataset) {
      attributes['data-' + key] = vnode.data.dataset[key]
    }

    for (const key in vnode.data.props) {
      if (!IGNORE.has(key)) {
        const value = vnode.data.props[key]

        if (BOOLEAN.has(key)) {
          if (value) attributes[key] = key
        } else {
          attributes[key] = value
        }
      }
    }

    const styles = []

    for (let key in vnode.data.style) {
      const value = vnode.data.style[key]
      key = key.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
      styles.push(key + ': ' + value)
    }

    if (styles.length > 0) attributes['style'] = styles.join('; ')

    attributes = Object.keys(attributes).map(key =>
      self.$escape(key) + '="' + self.$escape(attributes[key]) + '"'
    )

    const tag = sel['tag']
    const elements = ['<' + tag]
    if (attributes.length > 0) elements.push(' ' + attributes.join(' '))
    elements.push('>')

    if (!VOID.has(tag)) {
      if (vnode.data.props && vnode.data.props.innerHTML) {
        elements.push(vnode.data.props.innerHTML)
      } else if (vnode.text) {
        elements.push(self.$escape(vnode.text))
      } else if (vnode.children) {
        vnode.children.forEach(child =>
          elements.push(self.$node_to_s(child))
        )
      }

      elements.push('</' + tag + '>')
    }

    return elements.join('')
  }
end
parse_sel(sel) click to toggle source

rubocop:disable Lint/UnusedMethodArgument

# File opal/snabberb/component.rb, line 242
def parse_sel(sel)
  %x{
    let tag = ''
    let id = ''
    const classes = {}
    const parts = sel.split(".")

    parts.forEach((part, index) => {
      if (index == 0) {
        part = part.split('#')
        if (part.length > 1) id = part[1]
        part = part[0]
        index == 0 ? tag = part : classes[part] = true
      } else if (!tag) {
        tag = part
      } else {
        classes[part] = true
      }
    })

    return {
      tag: tag,
      id: id,
      classes: classes,
    }
  }
end