unpoly-rails 0.24.1 → 0.25.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.

Potentially problematic release.


This version of unpoly-rails might be problematic. Click here for more details.

Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README_RAILS.md +8 -1
  4. data/dist/unpoly.css +22 -10
  5. data/dist/unpoly.js +406 -196
  6. data/dist/unpoly.min.css +1 -1
  7. data/dist/unpoly.min.js +3 -3
  8. data/lib/assets/javascripts/unpoly/flow.js.coffee +36 -19
  9. data/lib/assets/javascripts/unpoly/form.js.coffee +1 -2
  10. data/lib/assets/javascripts/unpoly/link.js.coffee +2 -2
  11. data/lib/assets/javascripts/unpoly/modal.js.coffee +174 -81
  12. data/lib/assets/javascripts/unpoly/navigation.js.coffee +3 -1
  13. data/lib/assets/javascripts/unpoly/popup.js.coffee +62 -37
  14. data/lib/assets/javascripts/unpoly/proxy.js.coffee +1 -0
  15. data/lib/assets/javascripts/unpoly/syntax.js.coffee +12 -4
  16. data/lib/assets/javascripts/unpoly/util.js.coffee +55 -13
  17. data/lib/assets/stylesheets/unpoly/modal.css.sass +28 -12
  18. data/lib/unpoly/rails/inspector.rb +26 -0
  19. data/lib/unpoly/rails/version.rb +1 -1
  20. data/spec_app/Gemfile.lock +1 -1
  21. data/spec_app/app/controllers/binding_test_controller.rb +6 -0
  22. data/spec_app/spec/controllers/binding_test_controller_spec.rb +82 -11
  23. data/spec_app/spec/javascripts/up/flow_spec.js.coffee +21 -7
  24. data/spec_app/spec/javascripts/up/form_spec.js.coffee +15 -0
  25. data/spec_app/spec/javascripts/up/link_spec.js.coffee +11 -10
  26. data/spec_app/spec/javascripts/up/modal_spec.js.coffee +232 -30
  27. data/spec_app/spec/javascripts/up/motion_spec.js.coffee +33 -27
  28. data/spec_app/spec/javascripts/up/popup_spec.js.coffee +72 -0
  29. data/spec_app/spec/javascripts/up/syntax_spec.js.coffee +51 -13
  30. metadata +2 -2
@@ -840,6 +840,24 @@ up.util = (($) ->
840
840
  $outer.remove()
841
841
  width
842
842
 
843
+ ###*
844
+ Returns whether the given element is currently showing a vertical scrollbar.
845
+
846
+ @function up.util.documentHasVerticalScrollbar
847
+ @internal
848
+ ###
849
+ documentHasVerticalScrollbar = ->
850
+ body = document.body
851
+ $body = $(body)
852
+ html = document.documentElement
853
+
854
+ bodyOverflow = $body.css('overflow-y')
855
+
856
+ forcedScroll = (bodyOverflow == 'scroll')
857
+ forcedHidden = (bodyOverflow == 'hidden')
858
+
859
+ forcedScroll || (!forcedHidden && html.scrollHeight > html.clientHeight)
860
+
843
861
  ###*
844
862
  Modifies the given function so it only runs once.
845
863
  Subsequent calls will return the previous return value.
@@ -935,6 +953,9 @@ up.util = (($) ->
935
953
  ###
936
954
  cssAnimate = (elementOrSelector, lastFrame, opts) ->
937
955
  $element = $(elementOrSelector)
956
+
957
+ # Don't name the local variable `options` since that would override
958
+ # the `options` function in our scope. We really need `let` :(
938
959
  opts = options(opts,
939
960
  duration: 300,
940
961
  delay: 0,
@@ -944,14 +965,32 @@ up.util = (($) ->
944
965
  # We don't finish an existing animation here, since the public API
945
966
  # we expose as `up.motion.animate` already does this.
946
967
  deferred = $.Deferred()
968
+
969
+ transitionProperties = Object.keys(lastFrame)
947
970
  transition =
948
- 'transition-property': Object.keys(lastFrame).join(', ')
971
+ 'transition-property': transitionProperties.join(', ')
949
972
  'transition-duration': "#{opts.duration}ms"
950
973
  'transition-delay': "#{opts.delay}ms"
951
974
  'transition-timing-function': opts.easing
952
975
  oldTransition = $element.css(Object.keys(transition))
953
976
 
954
977
  $element.addClass('up-animating')
978
+
979
+ transitionFinished = ->
980
+ $element.removeClass('up-animating')
981
+ $element.off('transitionend', onTransitionEnd)
982
+
983
+ onTransitionEnd = (event) ->
984
+ completedProperty = event.originalEvent.propertyName
985
+ if contains(transitionProperties, completedProperty)
986
+ deferred.resolve() # unless isDetached($element)
987
+ transitionFinished()
988
+
989
+ $element.on('transitionend', onTransitionEnd)
990
+
991
+ # Clean up in case we're canceled through some other code that resolves our deferred.
992
+ deferred.then(transitionFinished)
993
+
955
994
  withoutCompositing = forceCompositing($element)
956
995
  $element.css(transition)
957
996
  $element.css(lastFrame)
@@ -963,26 +1002,20 @@ up.util = (($) ->
963
1002
 
964
1003
  # To interrupt the running transition we *must* set it to 'none' exactly.
965
1004
  # We cannot simply restore the old transition properties because browsers
966
- # would simply keep transitioning the old properties.
1005
+ # would simply keep transitioning.
967
1006
  $element.css('transition': 'none')
968
1007
 
969
- # Restoring a previous transition involves some work, so we only do it if
1008
+ # Restoring a previous transition involves forcing a repaint, so we only do it if
970
1009
  # we know the element was transitioning before.
1010
+ # Note that the default transition for elements is actually "all 0s ease 0s"
1011
+ # instead of "none", although that has the same effect as "none".
971
1012
  hadTransitionBefore = !(oldTransition['transition-property'] == 'none' || (oldTransition['transition-property'] == 'all' && oldTransition['transition-duration'][0] == '0'))
972
1013
  if hadTransitionBefore
1014
+ # If there is no repaint between the "none" transition and restoring
1015
+ # the previous transition, the browser will simply keep transitioning.
973
1016
  forceRepaint($element) # :(
974
1017
  $element.css(oldTransition)
975
1018
 
976
- # Since listening to transitionEnd events is painful, we wait for a timeout
977
- # and then resolve our deferred. Maybe revisit that decision some day.
978
- animationEnd = opts.duration + opts.delay
979
- endTimeout = setTimer animationEnd, ->
980
- $element.removeClass('up-animating')
981
- deferred.resolve() unless isDetached($element)
982
- # Clean up in case we're canceled through some other code that
983
- # resolves our deferred.
984
- deferred.then(-> clearTimeout(endTimeout))
985
-
986
1019
  # Return the whole deferred and not just return a thenable.
987
1020
  # Other code will need the possibility to cancel the animation
988
1021
  # by resolving the deferred.
@@ -1652,6 +1685,13 @@ up.util = (($) ->
1652
1685
  else
1653
1686
  {}
1654
1687
 
1688
+ opacity = (element) ->
1689
+ rawOpacity = $(element).css('opacity')
1690
+ if isGiven(rawOpacity)
1691
+ parseFloat(rawOpacity)
1692
+ else
1693
+ undefined
1694
+
1655
1695
  ###*
1656
1696
  Returns whether the given element has been detached from the DOM
1657
1697
  (or whether it was never attached).
@@ -1752,6 +1792,7 @@ up.util = (($) ->
1752
1792
  remove: remove
1753
1793
  memoize: memoize
1754
1794
  scrollbarWidth: scrollbarWidth
1795
+ documentHasVerticalScrollbar: documentHasVerticalScrollbar
1755
1796
  config: config
1756
1797
  cache: cache
1757
1798
  unwrapElement: unwrapElement
@@ -1762,6 +1803,7 @@ up.util = (($) ->
1762
1803
  extractOptions: extractOptions
1763
1804
  isDetached: isDetached
1764
1805
  noop: noop
1806
+ opacity: opacity
1765
1807
 
1766
1808
  )($)
1767
1809
 
@@ -1,48 +1,64 @@
1
- $stratum-backdrop: 10000
2
- $stratum-elements: 11000
1
+ $stratum: 10000
3
2
 
4
- // These could actually be 1000, 2000, 3000 and 4000 since the `fixed` position of some elements defines
3
+ // These could actually be 1000, 2000, 3000 and 4000 since the `fixed` position of an element defines
5
4
  // a stacking context for all contained z-indexes.
6
5
  //
7
6
  // However, let's keep the option open that these elements will one day not have its stacking context.
8
7
  //
9
8
  // Also let's not do 1, 2, 3 and 4 so other elements have a chance to move themselves between the layers.
10
- $substratum-dialog: 12000
11
- $substratum-content: 13000
12
- $substratum-close: 14000
9
+ $substratum-backdrop: 11000
10
+ $substratum-elements: 12000
11
+ $substratum-dialog: 13000
12
+ $substratum-content: 14000
13
+ $substratum-close: 15000
13
14
 
14
15
  $close-height: 36px
15
16
  $close-width: 36px
16
17
  $close-font-size: 34px
17
18
 
18
19
  .up-modal
20
+ position: fixed
21
+ top: 0
22
+ left: 0
23
+ bottom: 0
24
+ right: 0
25
+ z-index: $stratum
26
+ overflow-x: hidden
19
27
 
20
28
  .up-modal-backdrop
21
- z-index: $stratum-backdrop
29
+ z-index: $substratum-backdrop
22
30
  background-color: rgba(90, 90, 90, 0.4)
23
- position: fixed
31
+ position: absolute
24
32
  top: 0
25
33
  right: 0
26
34
  bottom: 0
27
35
  left: 0
28
36
 
29
37
  .up-modal-viewport
30
- z-index: $stratum-elements
31
- position: fixed
38
+ position: absolute
32
39
  top: 0
33
40
  left: 0
34
41
  bottom: 0
35
42
  right: 0
43
+ z-index: $substratum-elements
36
44
  overflow-x: hidden
37
- overflow-y: hidden
45
+ // The viewport always has a scrollbar, except when we're animating (see below)
46
+ overflow-y: scroll
38
47
  // We prefer centering the dialog as an `inline-block`
39
48
  // to giving it a horizontal margin of `auto`. This way
40
49
  // the width of `.up-modal-dialog` is controlled by the
41
50
  // contents of `.up-modal-content`.
42
51
  text-align: center
43
52
 
44
- .up-modal.up-modal-ready &
53
+ .up-modal.up-modal-animating
54
+ // During opening/closing animations we let the .up-modal container take over
55
+ // the scrollbars that would usually be owned by .up-modal-viewport.
56
+ // If .up-modal-viewport had a scrollbar while animating with
57
+ // "zoom-in" it would look strange that the scrollbar is scaled.
58
+ &
45
59
  overflow-y: scroll
60
+ .up-modal-viewport
61
+ overflow-y: hidden
46
62
 
47
63
  .up-modal-dialog
48
64
  z-index: $substratum-dialog
@@ -35,6 +35,32 @@ module Unpoly
35
35
  request.headers['X-Up-Target']
36
36
  end
37
37
 
38
+ ##
39
+ # Tests whether the given CSS selector is targeted by the current fragment update.
40
+ #
41
+ # Note that the matching logic is very simplistic and does not actually know
42
+ # how your page layout is structured. It will return `true` if
43
+ # the tested selector and the requested CSS selector matches exactly, or if the
44
+ # requested selector is `body` or `html`.
45
+ #
46
+ # Always returns `true` if the current request is not an Unpoly fragment update.
47
+ def target?(tested_target)
48
+ if up?
49
+ actual_target = target
50
+ if actual_target == tested_target
51
+ true
52
+ elsif actual_target == 'html'
53
+ true
54
+ elsif actual_target == 'body'
55
+ not ['head', 'title', 'meta'].include?(tested_target)
56
+ else
57
+ false
58
+ end
59
+ else
60
+ true
61
+ end
62
+ end
63
+
38
64
  ##
39
65
  # Returns whether the current form submission should be
40
66
  # [validated](http://unpoly.com/up-validate) (and not be saved to the database).
@@ -4,6 +4,6 @@ module Unpoly
4
4
  # The current version of the unpoly-rails gem.
5
5
  # This version number is also used for releases of the Unpoly
6
6
  # frontend code.
7
- VERSION = '0.24.1'
7
+ VERSION = '0.25.0'
8
8
  end
9
9
  end
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- unpoly-rails (0.23.1)
4
+ unpoly-rails (0.24.1)
5
5
  rails (>= 3)
6
6
 
7
7
  GEM
@@ -8,6 +8,12 @@ class BindingTestController < ActionController::Base
8
8
  render :text => up.target
9
9
  end
10
10
 
11
+ def up_is_target
12
+ tested_target = params[:tested_target].presence
13
+ tested_target or raise "No target given"
14
+ render :text => up.target?(tested_target).to_s
15
+ end
16
+
11
17
  def is_up_validate
12
18
  render :text => up.validate?.to_s
13
19
  end
@@ -17,7 +17,7 @@ describe BindingTestController do
17
17
 
18
18
  describe '#up' do
19
19
 
20
- describe '#selector' do
20
+ describe '#target' do
21
21
 
22
22
  it 'returns the CSS selector that is requested via Unpoly' do
23
23
  request.headers['X-Up-Target'] = '.foo'
@@ -27,6 +27,87 @@ describe BindingTestController do
27
27
 
28
28
  end
29
29
 
30
+ describe '#target?' do
31
+
32
+ it 'returns true if the tested CSS selector is requested via Unpoly' do
33
+ request.headers['X-Up-Target'] = '.foo'
34
+ get :up_is_target, tested_target: '.foo'
35
+ expect(response.body).to eq('true')
36
+ end
37
+
38
+ it 'returns false if Unpoly is requesting another CSS selector' do
39
+ request.headers['X-Up-Target'] = '.bar'
40
+ get :up_is_target, tested_target: '.foo'
41
+ expect(response.body).to eq('false')
42
+ end
43
+
44
+ it 'returns true if the request is not an Unpoly request' do
45
+ get :up_is_target, tested_target: '.foo'
46
+ expect(response.body).to eq('true')
47
+ end
48
+
49
+ it 'returns true if testing a custom selector, and Unpoly requests "body"' do
50
+ request.headers['X-Up-Target'] = 'body'
51
+ get :up_is_target, tested_target: '.foo'
52
+ expect(response.body).to eq('true')
53
+ end
54
+
55
+ it 'returns true if testing a custom selector, and Unpoly requests "html"' do
56
+ request.headers['X-Up-Target'] = 'html'
57
+ get :up_is_target, tested_target: '.foo'
58
+ expect(response.body).to eq('true')
59
+ end
60
+
61
+ it 'returns true if testing "body", and Unpoly requests "html"' do
62
+ request.headers['X-Up-Target'] = 'html'
63
+ get :up_is_target, tested_target: 'body'
64
+ expect(response.body).to eq('true')
65
+ end
66
+
67
+ it 'returns true if testing "head", and Unpoly requests "html"' do
68
+ request.headers['X-Up-Target'] = 'html'
69
+ get :up_is_target, tested_target: 'head'
70
+ expect(response.body).to eq('true')
71
+ end
72
+
73
+ it 'returns false if the tested CSS selector is "head" but Unpoly requests "body"' do
74
+ request.headers['X-Up-Target'] = 'body'
75
+ get :up_is_target, tested_target: 'head'
76
+ expect(response.body).to eq('false')
77
+ end
78
+
79
+ it 'returns false if the tested CSS selector is "title" but Unpoly requests "body"' do
80
+ request.headers['X-Up-Target'] = 'body'
81
+ get :up_is_target, tested_target: 'title'
82
+ expect(response.body).to eq('false')
83
+ end
84
+
85
+ it 'returns false if the tested CSS selector is "meta" but Unpoly requests "body"' do
86
+ request.headers['X-Up-Target'] = 'body'
87
+ get :up_is_target, tested_target: 'meta'
88
+ expect(response.body).to eq('false')
89
+ end
90
+
91
+ it 'returns true if the tested CSS selector is "head", and Unpoly requests "html"' do
92
+ request.headers['X-Up-Target'] = 'html'
93
+ get :up_is_target, tested_target: 'head'
94
+ expect(response.body).to eq('true')
95
+ end
96
+
97
+ it 'returns true if the tested CSS selector is "title", Unpoly requests "html"' do
98
+ request.headers['X-Up-Target'] = 'html'
99
+ get :up_is_target, tested_target: 'title'
100
+ expect(response.body).to eq('true')
101
+ end
102
+
103
+ it 'returns true if the tested CSS selector is "meta", and Unpoly requests "html"' do
104
+ request.headers['X-Up-Target'] = 'html'
105
+ get :up_is_target, tested_target: 'meta'
106
+ expect(response.body).to eq('true')
107
+ end
108
+
109
+ end
110
+
30
111
  describe '#validate?' do
31
112
 
32
113
  it 'returns true the request is an Unpoly validation call' do
@@ -63,14 +144,4 @@ describe BindingTestController do
63
144
 
64
145
  end
65
146
 
66
-
67
- # describe '#test' do
68
- #
69
- # it 'does stuff' do
70
- # get :test
71
- # expect(response.body).to eq('foo')
72
- # end
73
- #
74
- # end
75
-
76
147
  end
@@ -47,6 +47,20 @@ describe 'up.flow', ->
47
47
  expect(resolution).toHaveBeenCalled()
48
48
  expect($('.middle')).toHaveText('new-middle')
49
49
 
50
+ it 'returns a promise that will be resolved once the server response was received and the swap animations have completed', (done) ->
51
+ resolution = jasmine.createSpy()
52
+ promise = up.replace('.middle', '/path', transition: 'cross-fade', duration: 50)
53
+ promise.then(resolution)
54
+ expect(resolution).not.toHaveBeenCalled()
55
+ expect($('.middle')).toHaveText('old-middle')
56
+ @respond()
57
+ expect(resolution).not.toHaveBeenCalled()
58
+ u.setTimer 20, ->
59
+ expect(resolution).not.toHaveBeenCalled()
60
+ u.setTimer 80, ->
61
+ expect(resolution).toHaveBeenCalled()
62
+ done()
63
+
50
64
  describe 'with { data } option', ->
51
65
 
52
66
  it "uses the given params as a non-GET request's payload", ->
@@ -470,19 +484,19 @@ describe 'up.flow', ->
470
484
 
471
485
  it 'morphs between the old and new element', (done) ->
472
486
  affix('.element').text('version 1')
473
- up.extract('.element', '<div class="element">version 2</div>', transition: 'cross-fade', duration: 50)
487
+ up.extract('.element', '<div class="element">version 2</div>', transition: 'cross-fade', duration: 200)
474
488
 
475
489
  $ghost1 = $('.element.up-ghost:contains("version 1")')
476
490
  expect($ghost1).toHaveLength(1)
477
- expect($ghost1.css('opacity')).toBeAround(1.0, 0.1)
491
+ expect(u.opacity($ghost1)).toBeAround(1.0, 0.1)
478
492
 
479
493
  $ghost2 = $('.element.up-ghost:contains("version 2")')
480
494
  expect($ghost2).toHaveLength(1)
481
- expect($ghost2.css('opacity')).toBeAround(0.0, 0.1)
495
+ expect(u.opacity($ghost2)).toBeAround(0.0, 0.1)
482
496
 
483
- u.setTimer 40, ->
484
- expect($ghost1.css('opacity')).toBeAround(0.0, 0.2)
485
- expect($ghost2.css('opacity')).toBeAround(1.0, 0.2)
497
+ u.setTimer 190, ->
498
+ expect(u.opacity($ghost1)).toBeAround(0.0, 0.3)
499
+ expect(u.opacity($ghost2)).toBeAround(1.0, 0.3)
486
500
  done()
487
501
 
488
502
  it 'marks the old fragment and its ghost as .up-destroying during the transition', ->
@@ -534,7 +548,7 @@ describe 'up.flow', ->
534
548
  promise = up.extract('.element', '<div class="element">version 2</div>', transition: 'cross-fade', duration: 30)
535
549
  promise.then(resolution)
536
550
  expect(resolution).not.toHaveBeenCalled()
537
- u.setTimer 50, ->
551
+ u.setTimer 70, ->
538
552
  expect(resolution).toHaveBeenCalled()
539
553
  done()
540
554
 
@@ -215,6 +215,21 @@ describe 'up.form', ->
215
215
  expect(submitSpy).toHaveBeenCalled()
216
216
  done()
217
217
 
218
+ it 'marks the field with an .up-active class while the form is submitting', (done) ->
219
+ $form = affix('form')
220
+ $field = $form.affix('input[up-autosubmit][val="old-value"]')
221
+ up.hello($field)
222
+ submission = $.Deferred()
223
+ submitSpy = up.form.knife.mock('submit').and.returnValue(submission)
224
+ $field.val('new-value')
225
+ $field.trigger('change')
226
+ u.nextFrame ->
227
+ expect(submitSpy).toHaveBeenCalled()
228
+ expect($field).toHaveClass('up-active')
229
+ submission.resolve()
230
+ expect($field).not.toHaveClass('up-active')
231
+ done()
232
+
218
233
  describe 'form[up-autosubmit]', ->
219
234
 
220
235
  it 'submits the form when a change is observed in any of its fields', (done) ->
@@ -44,6 +44,8 @@ describe 'up.link', ->
44
44
 
45
45
  it 'adds history entries and allows the user to use the back- and forward-buttons', (done) ->
46
46
 
47
+ waitForBrowser = 70
48
+
47
49
  # By default, up.history will replace the <body> tag when
48
50
  # the user presses the back-button. We reconfigure this
49
51
  # so we don't lose the Jasmine runner interface.
@@ -83,21 +85,21 @@ describe 'up.link', ->
83
85
  expect(document.title).toEqual('title from three')
84
86
 
85
87
  history.back()
86
- u.setTimer 50, ->
88
+ u.setTimer waitForBrowser, ->
87
89
  respondWith('restored text from two', 'restored title from two')
88
90
  expect($('.target')).toHaveText('restored text from two')
89
91
  expect(location.pathname).toEqual('/two')
90
92
  expect(document.title).toEqual('restored title from two')
91
93
 
92
94
  history.back()
93
- u.setTimer 50, ->
95
+ u.setTimer waitForBrowser, ->
94
96
  respondWith('restored text from one', 'restored title from one')
95
97
  expect($('.target')).toHaveText('restored text from one')
96
98
  expect(location.pathname).toEqual('/one')
97
99
  expect(document.title).toEqual('restored title from one')
98
100
 
99
101
  history.forward()
100
- u.setTimer 50, ->
102
+ u.setTimer waitForBrowser, ->
101
103
  # Since the response is cached, we don't have to respond
102
104
  expect($('.target')).toHaveText('restored text from two', 'restored title from two')
103
105
  expect(location.pathname).toEqual('/two')
@@ -367,7 +369,7 @@ describe 'up.link', ->
367
369
 
368
370
  it 'morphs between the old and new target element', (done) ->
369
371
  affix('.target.old')
370
- $link = affix('a[href="/path"][up-target=".target"][up-transition="cross-fade"][up-duration="200"][up-easing="linear"]')
372
+ $link = affix('a[href="/path"][up-target=".target"][up-transition="cross-fade"][up-duration="300"][up-easing="linear"]')
371
373
  $link.click()
372
374
  @respondWith '<div class="target new">new text</div>'
373
375
 
@@ -375,12 +377,11 @@ describe 'up.link', ->
375
377
  $newGhost = $('.target.new.up-ghost')
376
378
  expect($oldGhost).toExist()
377
379
  expect($newGhost).toExist()
378
- opacity = ($element) -> Number($element.css('opacity'))
379
- expect(opacity($oldGhost)).toBeAround(1, 0.15)
380
- expect(opacity($newGhost)).toBeAround(0, 0.15)
381
- u.setTimer 100, ->
382
- expect(opacity($oldGhost)).toBeAround(0.5, 0.15)
383
- expect(opacity($newGhost)).toBeAround(0.5, 0.15)
380
+ expect(u.opacity($oldGhost)).toBeAround(1, 0.15)
381
+ expect(u.opacity($newGhost)).toBeAround(0, 0.15)
382
+ u.setTimer 150, ->
383
+ expect(u.opacity($oldGhost)).toBeAround(0.5, 0.15)
384
+ expect(u.opacity($newGhost)).toBeAround(0.5, 0.15)
384
385
  done()
385
386
 
386
387
  it 'does not add a history entry when an up-history attribute is set to "false"', ->