###**
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](/a-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`](/a-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/unpoly/popup.sass).
The HTML of a popup element looks like this:
The popup element is appended to the [viewport](/up.viewport) of the anchor element.
@module up.popup
###
up.popup = do ->
u = up.util
e = up.element
###**
Sets default options for future popups.
@property up.popup.config
@param {string} [config.position='bottom']
Defines on which side of the opening element the popup is attached.
Valid values are `'top'`, `'right'`, `'bottom'` and `'left'`.
@param {string} [config.align='left']
Defines the alignment of the popup along its side.
When the popup's `{ position }` is `'top'` or `'bottom'`, valid `{ align }` values are `'left'`, `center'` and `'right'`.
When the popup's `{ position }` is `'left'` or `'right'`, valid `{ align }` values are `top'`, `center'` and `bottom'`.
@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 = new up.Config
openAnimation: 'fade-in'
closeAnimation: 'fade-out'
openDuration: 150
closeDuration: 100
openEasing: null
closeEasing: null
position: 'bottom'
align: 'left'
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 = new up.Config
phase: 'closed' # can be 'opening', 'opened', 'closing' and 'closed'
anchor: null # the element to which the tooltip is anchored
popup: null # the .up-popup element
content: null # the .up-popup-content element
tether: null # the up.Tether instance controlling the popup's position
position: null # the position of the popup container element relative to its anchor
align: null
sticky: null
url: null
coveredUrl: null
coveredTitle: null
chain = new up.DivertibleChain()
reset = ->
state.tether?.destroy()
state.reset()
chain.reset()
config.reset()
discardHistory = ->
state.coveredTitle = null
state.coveredUrl = null
createHiddenFrame = (targetSelector) ->
state.tether = new up.Tether(u.only(state, 'anchor', 'position', 'align'))
state.popup = e.affix(state.tether.root, '.up-popup', 'up-position': state.position, 'up-align': state.align)
state.content = e.affix(state.popup, '.up-popup-content')
# Create an empty element that will match the
# selector that is being replaced.
up.fragment.createPlaceholder(targetSelector, state.content)
e.hide(state.popup)
unveilFrame = ->
e.show(state.popup)
###**
Forces the popup to update its position relative to its anchor element.
Unpoly automatically keep popups aligned when
the document is resized or scrolled. Complex layout changes may make
it necessary to call this function.
@function up.popup.sync
@experimental
###
syncPosition = ->
state.tether?.sync()
###**
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']
Defines on which side of the opening element the popup is attached.
Valid values are `'top'`, `'right'`, `'bottom'` and `'left'`.
@param {string} [options.align='left']
Defines the alignment of the popup along its side.
When the popup's `{ position }` is `'top'` or `'bottom'`, valid `{ align }` values are `'left'`, `center'` and `'right'`.
When the popup's `{ position }` is `'left'` or `'right'`, valid `{ align }` values are `top'`, `center'` and `bottom'`.
@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 {boolean} [options.history=false]
@return {Promise}
A promise that will be fulfilled when the popup has been loaded and
the opening animation has completed.
@stable
###
attachAsap = (elementOrSelector, options) ->
chain.asap closeNow, (-> attachNow(elementOrSelector, options))
attachNow = (elementOrSelector, options) ->
anchor = e.get(elementOrSelector)
options ?= {}
url = u.pluckKey(options, 'url') ? anchor.getAttribute('up-href') ? anchor.getAttribute('href')
html = u.pluckKey(options, 'html')
url or html or up.fail('up.popup.attach() requires either an { url } or { html } option')
target = u.pluckKey(options, 'target') ? anchor.getAttribute('up-popup') or up.fail('No target selector given for [up-popup]')
position = options.position ? anchor.getAttribute('up-position') ? config.position
align = options.align ? anchor.getAttribute('up-align') ? config.align
options.animation ?= anchor.getAttribute('up-animation') ? config.openAnimation
options.sticky ?= e.booleanAttr(anchor, 'up-sticky') ? config.sticky
options.history = if up.browser.canPushState() then (options.history ? e.booleanOrStringAttr(anchor, 'up-history') ? config.history) else false
options.confirm ?= anchor.getAttribute('up-confirm')
options.method = up.link.followMethod(anchor, options)
options.layer = 'popup'
options.failTarget ?= anchor.getAttribute('up-fail-target')
options.failLayer ?= anchor.getAttribute('up-fail-layer')
# This will prevent up.replace() from looking for fallbacks, since
# it knows the target will always exist.
options.provideTarget = -> createHiddenFrame(target)
animateOptions = up.motion.animateOptions(options, anchor, duration: config.openDuration, easing: config.openEasing)
extractOptions = u.merge(options, animation: false)
if options.preload && url
return up.replace(target, url, options)
up.browser.whenConfirmed(options).then ->
up.event.whenEmitted('up:popup:open', url: url, anchor: anchor, log: 'Opening popup').then ->
state.phase = 'opening'
state.anchor = anchor
state.position = position
state.align = align
if options.history
state.coveredUrl = up.browser.url()
state.coveredTitle = document.title
state.sticky = options.sticky
if html
promise = up.extract(target, html, extractOptions)
else
promise = up.replace(target, url, extractOptions)
promise = promise.then ->
unveilFrame()
syncPosition()
up.animate(state.popup, options.animation, animateOptions)
promise = promise.then ->
state.phase = 'opened'
up.emit(state.popup, 'up:popup:opened', anchor: state.anchor, log: 'Popup opened')#
promise
###**
This event is [emitted](/up.emit) when a popup is starting to open.
@event up:popup:open
@param {Element} event.anchor
The element to which the popup will be attached.
@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
@param {Element} event.anchor
The element to which the popup was attached.
@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 fulfilled once the modal's close
animation has finished.
@stable
###
closeAsap = (options) ->
chain.asap -> closeNow(options)
closeNow = (options) ->
unless isOpen() # this can happen when a request fails and the chain proceeds to the next task
return Promise.resolve()
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.event.whenEmitted('up:popup:close', anchor: state.anchor, log: 'Closing popup').then ->
state.phase = 'closing'
state.url = null
state.coveredUrl = null
state.coveredTitle = null
up.destroy(state.popup, options).then ->
state.phase = 'closed'
state.tether.destroy()
state.tether = null
state.popup = null
state.content = null
state.anchor = null
state.sticky = null
up.emit('up:popup:closed', anchor: state.anchor, log: 'Popup closed')
preloadNow = (link, options) ->
options = u.options(options)
options.preload = true
# Use attachNow() and not attachAsap() so (1) we don't close a currently open popup
# and (2) our pending AJAX request does not prevent other popups from opening
attachNow(link, options)
toggleAsap = (link, options) ->
if link.classList.contains('up-current')
closeAsap()
else
attachAsap(link, options)
###**
This event is [emitted](/up.emit) when a popup dialog
is starting to [close](/up.popup.close).
@event up:popup:close
@param {Element} event.anchor
The element to which the popup is attached.
@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
@param {Element} event.anchor
The element to which the popup was attached.
@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 = e.get(elementOrSelector)
!!e.closest(element, '.up-popup')
###**
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 a[up-popup]
@param {string} up-popup
The CSS selector that will be extracted from the response and
displayed in a popup overlay.
@param {string} [up-position]
Defines on which side of the opening element the popup is attached.
Valid values are `'top'`, `'right'`, `'bottom'` and `'left'`.
@param {string} [up-align]
Defines the alignment of the popup along its side.
When the popup's `{ position }` is `'top'` or `'bottom'`, valid `{ align }` values are `'left'`, `center'` and `'right'`.
When the popup's `{ position }` is `'left'` or `'right'`, valid `{ align }` values are `top'`, `center'` and `bottom'`.
@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.addFollowVariant '[up-popup]',
# Don't just pass the `toggleAsap` function reference so we can stub it in tests
follow: (link, options) -> toggleAsap(link, options)
preload: (link, options) -> preloadNow(link, options)
# 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 e.closest(target, '.up-popup, [up-popup]')
u.muteRejection 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.getAttribute('up-source')
state.url = newSource
else if event.origin && contains(event.origin)
u.muteRejection autoclose()
# Close the pop-up overlay when the user presses ESC.
up.event.onEscape ->
u.muteRejection 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) ->
u.muteRejection 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.event.consumeAction(event)
# When the user uses the back button we will usually restore or a base container.
# We close any open modal because it probably won't match the restored state.
up.on 'up:history:restore', ->
u.muteRejection closeAsap()
# The framework is reset between tests
up.on 'up:framework:reset', reset
<% if ENV['JS_KNIFE'] %>knife: eval(Knife.point)<% end %>
attach: attachAsap
close: closeAsap
url: -> state.url
coveredUrl: -> state.coveredUrl
config: config
contains: contains
isOpen: isOpen
sync: syncPosition