###*
Pop-up overlays
===============
Instead of [linking to a page fragment](/up.link), you can choose
to show a fragment in a popup overlay that rolls down from an anchoring element.
To open a popup, add an [`up-popup` attribute](/up-popup) to a link:
Show options
When this link is clicked, Unpoly will request the path `/options` and extract
an element matching the selector `.menu` from the response. The matching element
will then be placed in the popup overlay.
\#\#\# Closing behavior
The popup closes when the user clicks anywhere outside the popup area.
The popup also closes *when a link within the popup changes a fragment behind the popup*.
This is useful to have the popup interact with the page that
opened it, e.g. by updating parts of a larger form.
To disable this behavior, give the opening link an [`up-sticky`](/up-popup#up-sticky) attribute.
\#\#\# Customizing the popup design
Popups have a minimal default design:
- Popup contents are displayed in a white box
- There is a a subtle box shadow around the popup
- The box will grow to fit the popup contents
The easiest way to change how the popup looks is to override the
[default CSS styles](https://github.com/unpoly/unpoly/blob/master/lib/assets/stylesheets/up/popup.css.sass).
The HTML of a popup element is simply this:
@class up.popup
###
up.popup = (($) ->
u = up.util
###*
Sets default options for future popups.
@property up.popup.config
@param {String} [config.position='bottom-right']
Defines where the popup is attached to the opening element.
Valid values are `bottom-right`, `bottom-left`, `top-right` and `top-left`.
@param {String} [config.history=false]
Whether opening a popup will add a browser history entry.
@param {String} [config.openAnimation='fade-in']
The animation used to open a popup.
@param {String} [config.closeAnimation='fade-out']
The animation used to close a popup.
@param {String} [config.openDuration]
The duration of the open animation (in milliseconds).
@param {String} [config.closeDuration]
The duration of the close animation (in milliseconds).
@param {String} [config.openEasing]
The timing function controlling the acceleration of the opening animation.
@param {String} [config.closeEasing]
The timing function controlling the acceleration of the closing animation.
@param {Boolean} [options.sticky=false]
If set to `true`, the popup remains
open even it changes the page in the background.
@stable
###
config = u.config
openAnimation: 'fade-in'
closeAnimation: 'fade-out'
openDuration: 150
closeDuration: 100
openEasing: null
closeEasing: null
position: 'bottom-right'
history: false
###*
Returns the URL from which the current popup's contents were loaded.
Returns `undefined` if no popup is open.
@function up.popup.url
@return {String}
the source URL
@stable
###
###*
Returns the URL of the page or modal behind the popup.
@function up.popup.coveredUrl
@return {String}
@experimental
###
state = u.config
phase: 'closed' # can be 'opening', 'opened', 'closing' and 'closed'
$anchor: null # the element to which the tooltip is anchored
$popup: null # the popup container
position: null # the position of the popup container element relative to its anchor
sticky: null
url: null
coveredUrl: null
coveredTitle: null
chain = new u.DivertibleChain()
reset = ->
state.$popup?.remove()
state.reset()
chain.reset()
config.reset()
align = ->
css = {}
popupBox = u.measure(state.$popup)
if u.isFixed(state.$anchor)
linkBox = state.$anchor.get(0).getBoundingClientRect()
css['position'] = 'fixed'
else
linkBox = u.measure(state.$anchor)
switch state.position
when 'bottom-right' # anchored to bottom-right of link, opens towards bottom-left
css['top'] = linkBox.top + linkBox.height
css['left'] = linkBox.left + linkBox.width - popupBox.width
when 'bottom-left' # anchored to bottom-left of link, opens towards bottom-right
css['top'] = linkBox.top + linkBox.height
css['left'] = linkBox.left
when 'top-right' # anchored to top-right of link, opens to top-left
css['top'] = linkBox.top - popupBox.height
css['left'] = linkBox.left + linkBox.width - popupBox.width
when 'top-left' # anchored to top-left of link, opens to top-right
css['top'] = linkBox.top - popupBox.height
css['left'] = linkBox.left
else
up.fail("Unknown position option '%s'", state.position)
state.$popup.attr('up-position', state.position)
state.$popup.css(css)
discardHistory = ->
state.coveredTitle = null
state.coveredUrl = null
createHiddenFrame = (target) ->
$popup = u.$createElementFromSelector('.up-popup')
# Create an empty element that will match the
# selector that is being replaced.
u.$createPlaceholder(target, $popup)
$popup.hide()
$popup.appendTo(document.body)
state.$popup = $popup
unveilFrame = ->
state.$popup.show()
###*
Returns whether popup modal is currently open.
@function up.popup.isOpen
@return {Boolean}
@stable
###
isOpen = ->
state.phase == 'opened' || state.phase == 'opening'
###*
Attaches a popup overlay to the given element or selector.
Emits events [`up:popup:open`](/up:popup:open) and [`up:popup:opened`](/up:popup:opened).
@function up.popup.attach
@param {Element|jQuery|String} anchor
The element to which the popup will be attached.
@param {String} [options.url]
The URL from which to fetch the popup contents.
If omitted, the `href` or `up-href` attribute of the anchor element will be used.
Will be ignored if `options.html` is given.
@param {String} [options.target]
A CSS selector that will be extracted from the response and placed into the popup.
@param {String} [options.position='bottom-right']
Defines where the popup is attached to the opening element.
Valid values are `bottom-right`, `bottom-left`, `top-right` and `top-left`.
@param {String} [options.html]
A string of HTML from which to extract the popup contents. No network request will be made.
@param {String} [options.confirm]
A message that will be displayed in a cancelable confirmation dialog
before the modal is being opened.
@param {String} [options.animation]
The animation to use when opening the popup.
@param {Number} [options.duration]
The duration of the animation. See [`up.animate()`](/up.animate).
@param {Number} [options.delay]
The delay before the animation starts. See [`up.animate()`](/up.animate).
@param {String} [options.easing]
The timing function that controls the animation's acceleration. [`up.animate()`](/up.animate).
@param {String} [options.method="GET"]
Override the request method.
@param {Boolean} [options.sticky=false]
If set to `true`, the popup remains
open even if the page changes in the background.
@param {Object} [options.history=false]
@return {Promise}
A promise that will be resolved when the popup has been loaded and
the opening animation has completed.
@stable
###
attachAsap = (elementOrSelector, options) ->
curriedAttachNow = -> attachNow(elementOrSelector, options)
if isOpen()
chain.asap(closeNow, curriedAttachNow)
else
chain.asap(curriedAttachNow)
chain.promise()
attachNow = (elementOrSelector, options) ->
$anchor = $(elementOrSelector)
$anchor.length or up.fail('Cannot attach popup to non-existing element %o', elementOrSelector)
options = u.options(options)
url = u.option(u.pluckKey(options, 'url'), $anchor.attr('up-href'), $anchor.attr('href'))
html = u.option(u.pluckKey(options, 'html'))
target = u.option(u.pluckKey(options, 'target'), $anchor.attr('up-popup'), 'body')
position = u.option(options.position, $anchor.attr('up-position'), config.position)
options.animation = u.option(options.animation, $anchor.attr('up-animation'), config.openAnimation)
options.sticky = u.option(options.sticky, u.castedAttr($anchor, 'up-sticky'), config.sticky)
options.history = if up.browser.canPushState() then u.option(options.history, u.castedAttr($anchor, 'up-history'), config.history) else false
options.confirm = u.option(options.confirm, $anchor.attr('up-confirm'))
options.method = up.link.followMethod($anchor, options)
options.layer = 'popup'
animateOptions = up.motion.animateOptions(options, $anchor, duration: config.openDuration, easing: config.openEasing)
up.browser.whenConfirmed(options).then ->
up.bus.whenEmitted('up:popup:open', url: url, message: 'Opening popup').then ->
state.phase = 'opening'
state.$anchor = $anchor
state.position = position
if options.history
state.coveredUrl = up.browser.url()
state.coveredTitle = document.title
state.sticky = options.sticky
options.provideTarget = -> createHiddenFrame(target)
extractOptions = u.merge(options, animation: false)
if html
promise = up.extract(target, html, extractOptions)
else
promise = up.replace(target, url, extractOptions)
promise = promise.then ->
align()
unveilFrame()
up.animate(state.$popup, options.animation, animateOptions)
promise = promise.then ->
state.phase = 'opened'
up.emit('up:popup:opened', message: 'Popup opened')#
promise
###*
This event is [emitted](/up.emit) when a popup is starting to open.
@event up:popup:open
@param event.preventDefault()
Event listeners may call this method to prevent the popup from opening.
@stable
###
###*
This event is [emitted](/up.emit) when a popup has finished opening.
@event up:popup:opened
@stable
###
###*
Closes a currently opened popup overlay.
Does nothing if no popup is currently open.
Emits events [`up:popup:close`](/up:popup:close) and [`up:popup:closed`](/up:popup:closed).
@function up.popup.close
@param {Object} options
See options for [`up.animate()`](/up.animate).
@return {Promise}
A promise that will be resolved once the modal's close
animation has finished.
@stable
###
closeAsap = (options) ->
if isOpen()
chain.asap -> closeNow(options)
chain.promise()
closeNow = (options) ->
unless isOpen() # this can happen when a request fails and the chain proceeds to the next task
return u.resolvedPromise()
options = u.options(options,
animation: config.closeAnimation
history: state.coveredUrl,
title: state.coveredTitle
)
animateOptions = up.motion.animateOptions(options, duration: config.closeDuration, easing: config.closeEasing)
u.assign(options, animateOptions)
up.bus.whenEmitted('up:popup:close', message: 'Closing popup', $element: state.$popup).then ->
state.phase = 'closing'
state.url = null
state.coveredUrl = null
state.coveredTitle = null
up.destroy(state.$popup, options).then ->
state.phase = 'closed'
state.$popup = null
state.$anchor = null
state.sticky = null
up.emit('up:popup:closed', message: 'Popup closed')
###*
This event is [emitted](/up.emit) when a popup dialog
is starting to [close](/up.popup.close).
@event up:popup:close
@param event.preventDefault()
Event listeners may call this method to prevent the popup from closing.
@stable
###
###*
This event is [emitted](/up.emit) when a popup dialog
is done [closing](/up.popup.close).
@event up:popup:closed
@stable
###
autoclose = ->
unless state.sticky
discardHistory()
closeAsap()
###*
Returns whether the given element or selector is contained
within the current popup.
@methods up.popup.contains
@param {String} elementOrSelector
The element to test
@return {Boolean}
@stable
###
contains = (elementOrSelector) ->
$element = $(elementOrSelector)
$element.closest('.up-popup').length > 0
###*
Opens this link's destination of in a popup overlay:
Switch deck
If the `up-sticky` attribute is set, the dialog does not auto-close
if a page fragment behind the popup overlay updates:
Switch deck
Settings
@selector [up-popup]
@param {String} up-popup
The CSS selector that will be extracted from the response and
displayed in a popup overlay.
@param [up-position]
Defines where the popup is attached to the opening element.
Valid values are `bottom-right`, `bottom-left`, `top-right` and `top-left`.
@param {String} [up-confirm]
A message that will be displayed in a cancelable confirmation dialog
before the popup is opened.
@param {String} [up-method='GET']
Override the request method.
@param [up-sticky]
If set to `true`, the popup remains
open even if the page changes in the background.
@param {String} [up-history='false']
Whether to push an entry to the browser history for the popup's source URL.
Set this to `'false'` to prevent the URL bar from being updated.
Set this to a URL string to update the history with the given URL.
@stable
###
up.link.onAction '[up-popup]', ($link) ->
if $link.is('.up-current')
closeAsap()
else
attachAsap($link)
# We close the popup when someone clicks on the document.
# We also need to listen to up:action:consumed in case an [up-instant] link
# was followed on mousedown.
up.on 'click up:action:consumed', (event) ->
$target = $(event.target)
# Don't close when the user clicked on a popup opener.
unless $target.closest('.up-popup, [up-popup]').length
closeAsap()
# Do not halt the event chain here. The user is allowed to directly activate
# a link in the background, even with a (now closing) popup open.
up.on 'up:fragment:inserted', (event, $fragment) ->
if contains($fragment)
if newSource = $fragment.attr('up-source')
state.url = newSource
else if contains(event.origin)
autoclose()
# Close the pop-up overlay when the user presses ESC.
up.bus.onEscape(closeAsap)
###*
When this element is clicked, a currently open [popup](/up.popup) is closed.
Does nothing if no popup is currently open.
\#\#\# Example
Clickin on this `` will close a currently open popup:
Close this popup
When a popup changes the current URL, you might need to deal with content being displayed
as either a popup or a full page.
To make a link that closes the current popup, but follows to
a fallback destination if no popup is open:
Okay
@selector .up-popup [up-close]
@stable
###
up.on 'click', '.up-popup [up-close]', (event, $element) ->
closeAsap()
# Only prevent the default when we actually closed a popup.
# This way we can have buttons that close a popup when within a popup,
# but link to a destination if not.
up.bus.consumeAction(event)
# The framework is reset between tests
up.on 'up:framework:reset', reset
knife: eval(Knife?.point)
attach: attachAsap
close: closeAsap
url: -> state.url
coveredUrl: -> state.coveredUrl
config: config
contains: contains
isOpen: isOpen
)(jQuery)