vdom-rb 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/vdom-rb.rb +5 -0
- data/lib/vdom/version.rb +3 -0
- data/opal/vdom.rb +4 -0
- data/opal/vdom/component.rb +151 -0
- data/opal/vdom/content.rb +240 -0
- data/opal/vdom/instance.rb +112 -0
- data/opal/vdom/registry.rb +60 -0
- data/opal/vdom/renderer.rb +164 -0
- data/opal/vdom/table.rb +500 -0
- data/opal/vdom/table_column.rb +81 -0
- data/opal/vdom/util.rb +12 -0
- metadata +84 -0
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
data/lib/vdom/version.rb
ADDED
data/opal/vdom.rb
ADDED
@@ -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
|
+
|