vdom-rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 175eed1045ac11f8cf7f8d233172412e44252a7a
4
+ data.tar.gz: 097298a39a6fa4cbb6f73e231639af21c09f89ab
5
+ SHA512:
6
+ metadata.gz: f14abe05447c686b6e95e0f24254348d6f12134484bf202208d36d70201b865ca1849ca2b6af6c212fbdda89cf771329c9b82b9a0858d580f71b64f7cd2082f1
7
+ data.tar.gz: 52c1210dae305dd5a8e12ff67e305276405be2064c85c2047ea85b0f4c3897d857775736bee204135560cef8aa720c1c92111d90c1e9a45db1890898357b5850
data/lib/vdom-rb.rb ADDED
@@ -0,0 +1,5 @@
1
+ # __FILE__ MUST BE SAME NAME AS GEM
2
+ module VDOM
3
+ require_relative "vdom/version"
4
+ end
5
+ Opal.append_path(File.expand_path(File.join("..", "..", "opal"), __FILE__).untaint)
@@ -0,0 +1,3 @@
1
+ module VDOM
2
+ VERSION = "0.1.0"
3
+ end
data/opal/vdom.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'vdom/component'
2
+ require 'vdom/content'
3
+ require 'vdom/table'
4
+ require 'vdom/util'
@@ -0,0 +1,151 @@
1
+ puts "#{__FILE__}[#{__LINE__}] requiring vdom stuff"
2
+ require 'clearwater'
3
+ require 'vdom/registry'
4
+
5
+ module VDOM
6
+
7
+ # Base component class
8
+ class Component
9
+ include Clearwater::Component
10
+
11
+ attr_reader :dom_id
12
+
13
+ def initialize(dom_id: nil)
14
+ @dom_id = dom_id
15
+ end
16
+
17
+ def render_dom!
18
+ registry.render(@dom_id)
19
+ end
20
+
21
+ def registry
22
+ VDOM::Registry.instance
23
+ end
24
+
25
+ end # class Component
26
+
27
+ # A DOM element
28
+ # TODO: call it an element ?
29
+ class Node < VDOM::Component
30
+ attr_reader :tag_name, :attributes, :content, :model
31
+
32
+ def initialize(tag_name, attributes: nil, content: nil, model: nil, dom_id: nil)
33
+ super(dom_id: dom_id)
34
+ @tag_name = tag_name
35
+ @attributes = attributes
36
+ @content = content
37
+ @model = model
38
+ @is_volatile = Proc === @content ||
39
+ Proc === @attributes ||
40
+ Proc === @tag_name
41
+ @cached_tag = @is_volatile ? nil : render_tag
42
+ end
43
+
44
+ def render
45
+ result = if @is_volatile
46
+ render_tag
47
+ else
48
+ @cached_tag
49
+ end
50
+ result
51
+ end
52
+
53
+ def volatile?
54
+ @is_volatile
55
+ end
56
+
57
+ def cached?
58
+ !volatile?
59
+ end
60
+
61
+ def render_tag
62
+ tag(
63
+ resolve(@tag_name),
64
+ resolve(@attributes),
65
+ resolve(@content)
66
+ )
67
+ end
68
+
69
+ def to_s
70
+ super + " @tag_name=#{@tag_name}"
71
+ end
72
+
73
+ private
74
+
75
+ def resolve(thing)
76
+ Proc === thing ? thing.call : thing
77
+ end
78
+
79
+ end
80
+
81
+ # State - a simple but effective way of implying
82
+ # changed state of contents. Wrap contents in a new
83
+ # state whenever the content changes. The object
84
+ # identity then become a proxy for a change
85
+ # in the contents. Used by StateComponent.
86
+ class State
87
+
88
+ attr_reader :contents
89
+
90
+ def initialize(**args)
91
+ @contents = args
92
+ end
93
+
94
+ def new
95
+ # self.class.new(**contents)
96
+ self.class.new(contents) # opal likes it like this ?
97
+ end
98
+
99
+ def contents
100
+ @contents
101
+ end
102
+
103
+ def [](key)
104
+ @contents[key]
105
+ end
106
+
107
+ alias_method :get, :[]
108
+ end
109
+
110
+ # A component with cached rendering which
111
+ # uses State's to determine whether the
112
+ # component should render. If the previous
113
+ # instance has a state equal to the
114
+ # current instance, then no rendering
115
+ # will be performed. If the state has
116
+ # changed then the render method will
117
+ # be called.
118
+ class StateComponent < VDOM::Component
119
+ include Clearwater::CachedRender
120
+
121
+ attr_reader :state
122
+
123
+ def initialize(state, dom_id: nil)
124
+ super(dom_id: dom_id)
125
+ @state = state
126
+ end
127
+
128
+ # Placeholder only. Subclasses should implement.
129
+ # The contents of the state held by the component
130
+ # can be used to perform the necessary render.
131
+ def render
132
+ end
133
+
134
+ # Returns true of the receiver's state is
135
+ # not equal to the previous component's
136
+ # state.
137
+ def should_render?(previous)
138
+ @state != previous.state
139
+ end
140
+
141
+ alias_method :changed?, :should_render?
142
+
143
+ # Synonym for !should_render?
144
+ def unchanged?(previous)
145
+ !changed?(previous)
146
+ end
147
+
148
+ end
149
+
150
+ end
151
+
@@ -0,0 +1,240 @@
1
+ require 'vdom/component'
2
+
3
+ module VDOM
4
+ class Content
5
+
6
+ # options
7
+ #
8
+ # :value # object appropriate to type or proc(context) or proc(value) or proc(context, value)
9
+ # :type # :container, :media, :string, :integer, :decimal, :bool, :count, :percent, :sign, :date
10
+ # :css # string with valid css classes or proc(value) or proc(context, value)
11
+ # :style # { } hash of styles per Clearwater or proc(value) or proc(context, value)
12
+ # :format # string or proc(value) or proc(context, value) for :decimal, :percent, :date
13
+ # :comma # boolean for numeric values (default true)
14
+ # :events # { click: ->{ clicked }, ... } - hash or proc(value) or proc(context, value)
15
+ # # event names per 'developer.mozilla.org/en-US/docs/Web/Events'
16
+
17
+ attr_reader :options, :type
18
+
19
+ def initialize(**options)
20
+ @options = options
21
+ @type = @options[:type] ||= :container
22
+ @options[:css] ||= ''
23
+ @options[:style] ||= {}
24
+ @options[:events] ||= {}
25
+ @is_numeric = [:integer, :decimal, :percent, :count].include?(@type)
26
+ @is_text = !(container? || media?)
27
+ @options[:comma] = true if @options[:comma].nil?
28
+ set_format_defaults
29
+ set_css_defaults
30
+ set_style_defaults
31
+ end
32
+
33
+ def container?
34
+ @type == :container
35
+ end
36
+
37
+ def media?
38
+ @type == :media
39
+ end
40
+
41
+ def text?
42
+ @is_text
43
+ end
44
+
45
+ def date?
46
+ @type == :date
47
+ end
48
+
49
+ def numeric?
50
+ @is_numeric
51
+ end
52
+
53
+ # Context may be anything meaningful to procs.
54
+ # Returned value will be formatted (if applicable).
55
+ def node(tag_name, attributes: {}, context: nil)
56
+ VDOM::Node.new(
57
+ tag_name,
58
+ attributes: ->{
59
+ attributes.merge.({
60
+ class: css(context: context, value: value),
61
+ style: style(context: context, value: value),
62
+ }).merge(
63
+ events(context: context, value: value)
64
+ )
65
+ },
66
+ content: ->{
67
+ formatted_value(context: context)
68
+ },
69
+ model: self
70
+ )
71
+ end
72
+
73
+ # Context may be anything meaningful to procs.
74
+ # Returned value will be formatted (if applicable).
75
+ def value_and_attributes(context: nil)
76
+ value = formatted_value(context: context)
77
+ [
78
+ value,
79
+ {
80
+ class: css(context: context, value: value),
81
+ style: style(context: context, value: value),
82
+ }.merge(
83
+ events(context: context, value: value)
84
+ )
85
+ ]
86
+ end
87
+
88
+ def sort_value(context: nil)
89
+ # debug __FILE__, __LINE__, __method__, "context=#{context.class.name}"
90
+ option(:sort_value, context: context)
91
+ end
92
+
93
+ def value(context: nil)
94
+ option(:value, context: context)
95
+ end
96
+
97
+ def css(context: nil, value: nil)
98
+ option(:css, context: context, value: value)
99
+ end
100
+
101
+ def style(context: nil, value: nil)
102
+ option(:style, context: context, value: value)
103
+ end
104
+
105
+ def events(context: nil, value: nil)
106
+ result = {}
107
+ events = option(:events, context: context, value: value)
108
+ if events && events.size > 0
109
+ events.each do |event, proc|
110
+ event = event[0,2] == 'on' ? event : :"on#{event}"
111
+ result[event] = proc
112
+ end
113
+ end
114
+ result
115
+ end
116
+
117
+ def formatted_value(context: nil)
118
+ v = value(context: context)
119
+ if v
120
+ if text?
121
+ format = format(context: context, value: v)
122
+ if numeric?
123
+ comma_numeric(format % v.to_f)
124
+ elsif date?
125
+ parse_date(v).strftime(value)
126
+ else
127
+ v.to_s
128
+ end
129
+ else
130
+ v
131
+ end
132
+ end
133
+ end
134
+
135
+ def format(context: nil, value: nil)
136
+ option(:format, context: context, value: value)
137
+ end
138
+
139
+ def option(name, context: nil, value: nil)
140
+ option = @options[name]
141
+ resolve(option, context: context, value: value)
142
+ end
143
+
144
+ def resolve(thing, context: nil, value: nil)
145
+ if Proc === thing
146
+ if thing.arity == 2
147
+ thing.call(context, value)
148
+ elsif thing.arity == 1
149
+ thing.call(value || context)
150
+ else
151
+ thing.call
152
+ end
153
+ else
154
+ thing
155
+ end
156
+ end
157
+
158
+ def comma_numeric(s)
159
+ @options[comma] ? s.comma_numeric : s
160
+ end
161
+
162
+ def parse_date(d)
163
+ self.class.parse_date(d)
164
+ end
165
+
166
+ def self.parse_date(d)
167
+ return d if d.is_a?(Date)
168
+ # opal Date.parse can't handle strings with named months
169
+ # nor can it parse YYYYMMDD without separators!
170
+ if RUBY_PLATFORM == 'opal'
171
+ t = Time.parse(d)
172
+ Date.new(t.year, t.month, t.day)
173
+ else
174
+ Date.parse(d)
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def set_tag_defaults
181
+ @options[:tag] ||= 'div'
182
+ end
183
+
184
+ def set_style_defaults
185
+ if text? && options[:style].nil?
186
+ @options[:style] = case @options[:type]
187
+ when :integer, :decimal, :count, :percent
188
+ { text_align: 'right' }
189
+ else
190
+ { text_align: 'center' }
191
+ end
192
+ end
193
+ end
194
+
195
+ def set_css_defaults
196
+ # ?
197
+ end
198
+
199
+ def set_format_defaults
200
+ if text? && @options[:format].nil?
201
+ @options[:format] = case @options[:type]
202
+ when :decimal
203
+ ->(v) {
204
+ comma_numeric('%0.2f' % v)
205
+ }
206
+ when :integer, :count
207
+ ->(v) {
208
+ comma_numeric(v.to_i.to_s)
209
+ }
210
+ when :percent
211
+ ->(v) {
212
+ comma_numeric((v * 100).round(0).to_s)
213
+ }
214
+ when :sign
215
+ ->(v) {
216
+ if v == 0
217
+ '0'
218
+ else
219
+ v < 0 ? '-' : '+'
220
+ end
221
+ }
222
+ when :date
223
+ ->(v) {
224
+ v.strftime('%Y/%m/%d')
225
+ }
226
+ when :bool
227
+ ->(v) {
228
+ v ? 'T' : 'F'
229
+ }
230
+ else # :text, ...
231
+ ->(v) {
232
+ v.to_s
233
+ }
234
+ end
235
+ end
236
+ end
237
+
238
+ end
239
+ end
240
+
@@ -0,0 +1,112 @@
1
+ require 'vdom/component'
2
+
3
+ module VDOM
4
+ class Instance
5
+
6
+ # Returns a (very probably) unique element id.
7
+ def self.new_id
8
+ @@id_chars ||= ('0'..'9').to_a + ('A'..'F').to_a
9
+ 'DOM_' + @@id_chars.shuffle.reduce('') {|r,c| r + c}
10
+ end
11
+
12
+ attr_reader :id
13
+ attr_reader :root_element
14
+ attr_reader :root_component
15
+
16
+ # Create a VDOM::Instance with given id,
17
+ # (root) component and (root) DOM element.
18
+ # Element must be a native element or Bowser::Element.
19
+ # Component must be a VDOM::Component.
20
+ def initialize(id: nil, component: nil, element: nil)
21
+ @id = id || self.class.new_id
22
+ @root_element = Bowser::Element.new(element)
23
+ @root_component = component
24
+ unless VDOM::Component === @root_component
25
+ raise TypeError, "#{__FILE__}:#{__LINE__}:#{self.class.name}##{__method__}: root component must be a VDOM::Component"
26
+ end
27
+ @virtual_dom = Clearwater::VirtualDOM::Document.new(@root_element)
28
+ Instances.add(self)
29
+ end
30
+
31
+ def ==(other)
32
+ self.class == other.class && id == other.id
33
+ id == other.id
34
+ end
35
+
36
+ def eql?(other)
37
+ self.class == other.class && id == other.id
38
+ end
39
+
40
+ def document
41
+ @document ||= Bowser.document
42
+ end
43
+
44
+ def window
45
+ @window ||= Bowser.window
46
+ end
47
+
48
+ # Adapted from Clearwater::Application
49
+ # Removed optional block argument
50
+ def render
51
+ window.animation_frame do
52
+ perform_render
53
+ end
54
+ nil # not sure why
55
+ end
56
+
57
+ # Adapted from Clearwater::Application
58
+ def perform_render
59
+ rendered = benchmark('Generated virtual DOM') do
60
+ _render = @root_component.render
61
+ # `console.log(#{"#{__FILE__}:#{__LINE__}:#{self.class.name}##{__method__} : @root_component=#{@root_component} _render=#{_render} "})`
62
+ Clearwater::Component.sanitize_content(_render)
63
+ end
64
+ benchmark('Rendered to actual DOM') do
65
+ @virtual_dom.render(rendered)
66
+ end
67
+ nil # not sure why
68
+ end
69
+
70
+ # Adapted from Clearwater::Application - called #debug? there
71
+ def benchmark?
72
+ !!@benchmark
73
+ end
74
+
75
+ # Adapted from Clearwater::Application - called #debug! there
76
+ def benchmark!
77
+ @benchmark = true
78
+ end
79
+
80
+ # Adapted from Clearwater::Application
81
+ def benchmark(what)
82
+ if benchmark?
83
+ start = `performance.now()`
84
+ result = yield
85
+ finish = `performance.now()`
86
+ debug __FILE__, __LINE__, __method__, "#{what} in #{(finish - start).round(3)}ms"
87
+ result
88
+ else
89
+ yield
90
+ end
91
+ end
92
+
93
+ # Returns a Bowser::Element (DOM element) with given id or nil.
94
+ # A Bowser::Element wraps a native JS DOM element.
95
+ def element(id)
96
+ self["##{id}"]
97
+ end
98
+
99
+ # Returns a Bowser::Element matching query using
100
+ # Document.querySelector query syntax.
101
+ # See https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector.
102
+ def [](query)
103
+ document[query]
104
+ end
105
+
106
+ def shutdown
107
+ Instances.delete(self)
108
+ end
109
+
110
+ end
111
+ end
112
+