###*
Modal dialogs
=============
Instead of [linking to a page fragment](/up.link), you can choose
to show a fragment in a modal dialog. The existing page will remain
open in the background and reappear once the modal is closed.
To open a modal, add an [`up-modal` attribute](/up-modal) to a link,
or call the Javascript functions [`up.modal.follow`](/up.modal.follow)
and [`up.modal.visit`](/up.modal.visit).
For smaller popup overlays ("dropdowns") see [up.popup](/up.popup) instead.
\#\#\#\# Customizing the dialog design
Loading the Unpoly stylesheet will give you a minimal dialog design:
- Dialog contents are displayed in a white box that is centered vertically and horizontally.
- There is a a subtle box shadow around the dialog
- The box will grow to fit the dialog contents, but never grow larger than the screen
- The box is placed over a semi-transparent background to dim the rest of the page
- There is a button to close the dialog in the top-right corner
The easiest way to change how the dialog looks is by overriding the [default CSS styles](https://github.com/unpoly/unpoly/blob/master/lib/assets/stylesheets/up/modal.css.sass).
By default the dialog uses the following DOM structure:
If you want to change the design beyond CSS, you can
configure Unpoly to [use a different HTML structure](/up.modal.config).
\#\#\#\# Closing behavior
By default the dialog automatically closes
*when a link inside a modal changes a fragment behind the modal*.
This is useful to have the dialog interact with the page that
opened it, e.g. by updating parts of a larger form or by signing in a user
and revealing additional information.
To disable this behavior, give the opening link an `up-sticky` attribute:
Settings
@class up.modal
###
up.modal = (($) ->
u = up.util
###*
Sets default options for future modals.
@property up.modal.config
@param {String} [config.history=true]
Whether opening a modal will add a browser history entry.
@param {Number} [config.width]
The width of the dialog as a CSS value like `'400px'` or `50%`.
Defaults to `undefined`, meaning that the dialog will grow to fit its contents
until it reaches `config.maxWidth`. Leaving this as `undefined` will
also allow you to control the width using CSS on `.up-modal-dialog´.
@param {Number} [config.maxWidth]
The width of the dialog as a CSS value like `'400px'` or `50%`.
You can set this to `undefined` to make the dialog fit its contents.
Be aware however, that e.g. Bootstrap stretches input elements
to `width: 100%`, meaning the dialog will also stretch to the full
width of the screen.
@param {Number} [config.height='auto']
The height of the dialog in pixels.
Defaults to `undefined`, meaning that the dialog will grow to fit its contents.
@param {String|Function(config)} [config.template]
A string containing the HTML structure of the modal.
You can supply an alternative template string, but make sure that it
defines tag with the classes `up-modal`, `up-modal-dialog` and `up-modal-content`.
You can also supply a function that returns a HTML string.
The function will be called with the modal options (merged from these defaults
and any per-open overrides) whenever a modal opens.
@param {String} [config.closeLabel='X']
The label of the button that closes the dialog.
@param {String} [config.openAnimation='fade-in']
The animation used to open the viewport around the dialog.
@param {String} [config.closeAnimation='fade-out']
The animation used to close the viewport the dialog.
@param {String} [config.backdropOpenAnimation='fade-in']
The animation used to open the backdrop that dims the page below the dialog.
@param {String} [config.backdropCloseAnimation='fade-out']
The animation used to close the backdrop that dims the page below the dialog.
@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 modal remains
open even it changes the page in the background.
@stable
###
config = u.config
maxWidth: null
minWidth: null
width: null
height: null
history: true
openAnimation: 'fade-in'
closeAnimation: 'fade-out'
openDuration: null
closeDuration: null
openEasing: null
closeEasing: null
backdropOpenAnimation: 'fade-in'
backdropCloseAnimation: 'fade-out'
closeLabel: '×'
flavors: { default: {} }
template: (config) ->
"""
#{flavorDefault('closeLabel')}
"""
###*
Returns the source URL for the fragment displayed in the current modal overlay,
or `undefined` if no modal is currently open.
@function up.modal.url
@return {String}
the source URL
@stable
###
currentUrl = undefined
currentFlavor = undefined
###*
Returns the URL of the page behind the modal overlay.
@function up.modal.coveredUrl
@return {String}
@experimental
###
coveredUrl = ->
$('.up-modal').attr('up-covered-url')
reset = ->
# Destroy the modal container regardless whether it's currently in a closing animation
close(animation: false)
currentUrl = undefined
currentFlavor = undefined
config.reset()
templateHtml = ->
template = flavorDefault('template')
if u.isFunction(template)
template(config)
else
template
discardHistory = ->
$modal = $('.up-modal')
$modal.removeAttr('up-covered-url')
$modal.removeAttr('up-covered-title')
createFrame = (target, options) ->
promise = u.resolvedPromise()
if isOpen()
promise = promise.then -> close()
promise = promise.then ->
currentFlavor = options.flavor
$modal = $(templateHtml())
$modal.attr('up-flavor', currentFlavor)
$modal.attr('up-sticky', '') if options.sticky
$modal.attr('up-covered-url', up.browser.url())
$modal.attr('up-covered-title', document.title)
$dialog = $modal.find('.up-modal-dialog')
$dialog.css('width', options.width) if u.isPresent(options.width)
$dialog.css('max-width', options.maxWidth) if u.isPresent(options.maxWidth)
$dialog.css('height', options.height) if u.isPresent(options.height)
$content = $modal.find('.up-modal-content')
# Create an empty element that will match the
# selector that is being replaced.
u.$createPlaceholder(target, $content)
$modal.appendTo(document.body)
return promise
unshifters = []
# Gives `` a right padding in the width of a scrollbar.
# Also gives elements anchored to the right side of the screen
# an increased `right`.
#
# This is to prevent the body and elements from jumping when we add the
# modal overlay, which has its own scroll bar.
# This is screwed up, but Bootstrap does the same.
shiftElements = ->
return if unshifters.length > 0
if u.documentHasVerticalScrollbar()
$body = $('body')
scrollbarWidth = u.scrollbarWidth()
bodyRightPadding = parseInt($body.css('padding-right'))
bodyRightShift = scrollbarWidth + bodyRightPadding
unshiftBody = u.temporaryCss($body,
'padding-right': "#{bodyRightShift}px",
'overflow-y': 'hidden'
)
unshifters.push(unshiftBody)
up.layout.anchoredRight().each ->
$element = $(this)
elementRight = parseInt($element.css('right'))
elementRightShift = scrollbarWidth + elementRight
unshifter = u.temporaryCss($element, 'right': elementRightShift)
unshifters.push(unshifter)
# Reverts the effects of `shiftElements`.
unshiftElements = ->
unshifter() while unshifter = unshifters.pop()
###*
Returns whether a modal is currently open.
This also returns `true` if the modal is in an opening or closing animation.
@function up.modal.isOpen
@stable
###
isOpen = ->
$('.up-modal').length > 0
###*
Opens the given link's destination in a modal overlay:
var $link = $('...');
up.modal.follow($link);
Any option attributes for [`a[up-modal]`](/a.up-modal) will be honored.
Emits events [`up:modal:open`](/up:modal:open) and [`up:modal:opened`](/up:modal:opened).
@function up.modal.follow
@param {Element|jQuery|String} linkOrSelector
The link to follow.
@param {String} [options.target]
The selector to extract from the response and open in a modal dialog.
@param {Number} [options.width]
The width of the dialog in pixels.
By [default](/up.modal.config) the dialog will grow to fit its contents.
@param {Number} [options.height]
The width of the dialog in pixels.
By [default](/up.modal.config) the dialog will grow to fit its contents.
@param {Boolean} [options.sticky=false]
If set to `true`, the modal remains
open even it changes the page in the background.
@param {String} [options.confirm]
A message that will be displayed in a cancelable confirmation dialog
before the modal is being opened.
@param {Object} [options.history=true]
Whether to add a browser history entry for the modal's source URL.
@param {String} [options.animation]
The animation to use when opening the modal.
@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).
@return {Promise}
A promise that will be resolved when the modal has been loaded and
the opening animation has completed.
@stable
###
follow = (linkOrSelector, options) ->
options = u.options(options)
options.$link = $(linkOrSelector)
open(options)
###*
Opens a modal for the given URL.
Example:
up.modal.visit('/foo', { target: '.list' });
This will request `/foo`, extract the `.list` selector from the response
and open the selected container in a modal dialog.
Emits events [`up:modal:open`](/up:modal:open) and [`up:modal:opened`](/up:modal:opened).
@function up.modal.visit
@param {String} url
The URL to load.
@param {String} options.target
The CSS selector to extract from the response.
The extracted content will be placed into the dialog window.
@param {Object} options
See options for [`up.modal.follow`](/up.modal.follow).
@return {Promise}
A promise that will be resolved when the modal has been loaded and the opening
animation has completed.
@stable
###
visit = (url, options) ->
options = u.options(options)
options.url = url
open(options)
###*
[Extracts](/up.extract) the given CSS selector from the given HTML string and
opens the results in a modal.
Example:
var html = 'before
inner
after';
up.modal.extract('/foo', '.content', html);
The would open a modal with the following contents:
inner
Emits events [`up:modal:open`](/up:modal:open) and [`up:modal:opened`](/up:modal:opened).
@function up.modal.extract
@param {String} url
The URL to load.
@param {Object} options
See options for [`up.modal.follow`](/up.modal.follow).
@return {Promise}
A promise that will be resolved when the modal has been opened and the opening
animation has completed.
@stable
###
extract = (selector, html, options) ->
options = u.options(options)
options.html = html
options.history = u.option(options.history, false)
options.target = selector
open(options)
###*
@function open
@internal
###
open = (options) ->
options = u.options(options)
$link = u.option(u.pluckKey(options, '$link'), u.nullJQuery())
url = u.option(u.pluckKey(options, 'url'), $link.attr('up-href'), $link.attr('href'))
html = u.option(u.pluckKey(options, 'html'))
target = u.option(u.pluckKey(options, 'target'), $link.attr('up-modal'), 'body')
options.flavor = u.option(options.flavor, $link.attr('up-flavor'))
options.width = u.option(options.width, $link.attr('up-width'), flavorDefault('width', options.flavor))
options.maxWidth = u.option(options.maxWidth, $link.attr('up-max-width'), flavorDefault('maxWidth', options.flavor))
options.height = u.option(options.height, $link.attr('up-height'), flavorDefault('height'))
options.animation = u.option(options.animation, $link.attr('up-animation'), flavorDefault('openAnimation', options.flavor))
options.backdropAnimation = u.option(options.backdropAnimation, $link.attr('up-backdrop-animation'), flavorDefault('backdropOpenAnimation', options.flavor))
options.sticky = u.option(options.sticky, u.castedAttr($link, 'up-sticky'), flavorDefault('sticky', options.flavor))
options.confirm = u.option(options.confirm, $link.attr('up-confirm'))
animateOptions = up.motion.animateOptions(options, $link, duration: flavorDefault('openDuration', options.flavor), easing: flavorDefault('openEasing', options.flavor))
# Although we usually fall back to full page loads if a browser doesn't support pushState,
# in the case of modals we assume that the developer would rather see a dialog
# without an URL update.
options.history = u.option(options.history, u.castedAttr($link, 'up-history'), flavorDefault('history', options.flavor))
options.history = false unless up.browser.canPushState()
up.browser.confirm(options).then ->
if up.bus.nobodyPrevents('up:modal:open', url: url, message: 'Opening modal')
options.beforeSwap = -> createFrame(target, options)
extractOptions = u.merge(options, animation: false)
if html
promise = up.extract(target, html, extractOptions)
else
promise = up.replace(target, url, extractOptions)
promise = promise.then ->
shiftElements()
promise = promise.then -> animate(options.animation, options.backdropAnimation, animateOptions)
promise = promise.then ->
up.emit('up:modal:opened', message: 'Modal opened')
promise
else
# Although someone prevented opening the modal, keep a uniform API for
# callers by returning a Deferred that will never be resolved.
u.unresolvablePromise()
###*
This event is [emitted](/up.emit) when a modal dialog is starting to open.
@event up:modal:open
@param event.preventDefault()
Event listeners may call this method to prevent the modal from opening.
@stable
###
###*
This event is [emitted](/up.emit) when a modal dialog has finished opening.
@event up:modal:opened
@stable
###
###*
Closes a currently opened modal overlay.
Does nothing if no modal is currently open.
Emits events [`up:modal:close`](/up:modal:close) and [`up:modal:closed`](/up:modal:closed).
@function up.modal.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
###
close = (options) ->
options = u.options(options)
$modal = $('.up-modal')
if $modal.length
if up.bus.nobodyPrevents('up:modal:close', $element: $modal, message: 'Closing modal')
viewportCloseAnimation = u.option(options.animation, flavorDefault('closeAnimation'))
backdropCloseAnimation = u.option(options.backdropAnimation, flavorDefault('backdropCloseAnimation'))
animateOptions = up.motion.animateOptions(options, duration: flavorDefault('closeDuration'), easing: flavorDefault('closeEasing'))
promise = u.resolvedPromise()
promise = promise.then ->
animate(viewportCloseAnimation, backdropCloseAnimation, animateOptions)
promise = promise.then ->
destroyOptions = u.options(
u.except(options, 'animation', 'duration', 'easing', 'delay'),
url: $modal.attr('up-covered-url')
title: $modal.attr('up-covered-title')
)
# currentUrl must be deleted *before* calling up.destroy,
# since up.navigation listens to up:fragment:destroyed and then
# re-assigns .up-current classes.
currentUrl = undefined
return up.destroy($modal, destroyOptions)
promise = promise.then ->
unshiftElements()
currentFlavor = undefined
up.emit('up:modal:closed', message: 'Modal closed')
promise
else
# Although someone prevented the destruction, keep a uniform API
# for callers by returning a promise that will never be resolved.
u.unresolvablePromise()
else
u.resolvedPromise()
markAsAnimating = (state = true) ->
$('.up-modal').toggleClass('up-modal-animating', state)
animate = (viewportAnimation, backdropAnimation, animateOptions) ->
# If we're not animating the dialog, don't animate the backdrop either
if up.motion.isNone(viewportAnimation)
u.resolvedPromise()
else
markAsAnimating()
promise = $.when(
up.animate($('.up-modal-viewport'), viewportAnimation, animateOptions),
up.animate($('.up-modal-backdrop'), backdropAnimation, animateOptions)
)
promise = promise.then -> markAsAnimating(false)
promise
###*
This event is [emitted](/up.emit) when a modal dialog
is starting to [close](/up.modal.close).
@event up:modal:close
@param event.preventDefault()
Event listeners may call this method to prevent the modal from closing.
@stable
###
###*
This event is [emitted](/up.emit) when a modal dialog
is done [closing](/up.modal.close).
@event up:modal:closed
@stable
###
autoclose = ->
unless $('.up-modal').is('[up-sticky]')
discardHistory()
close()
###*
Returns whether the given element or selector is contained
within the current modal.
@function up.modal.contains
@param {String} elementOrSelector
@stable
###
contains = (elementOrSelector) ->
$element = $(elementOrSelector)
$element.closest('.up-modal').length > 0
###*
Register a new modal variant with its own default configuration, CSS or HTML template.
\#\#\#\# Example
Let's implement a drawer that slides in from the right:
up.modal.flavor('drawer', {
openAnimation: 'move-from-right',
closeAnimation: 'move-to-right',
maxWidth: 400
}
Modals with that flavor will have a container `
...
`.
We can target the `up-flavor` attribute override the default dialog styles:
.up-modal[up-flavor='drawer'] {
// Align drawer on the right
.up-modal-viewport { text-align: right; }
// Remove margin so the drawer starts at the screen edge
.up-modal-dialog { margin: 0; }
// Stretch drawer background to full window height
.up-modal-content { min-height: 100vh; }
}
@function up.modal.flavor
@param {String} name
The name of the new flavor.
@param {Object} [overrideConfig]
An object whose properties override the defaults in [`/up.modal.config`](/up.modal.config).
@experimental
###
flavor = (name, overrideConfig = {}) ->
u.extend(flavorOverrides(name), overrideConfig)
###*
Returns a config object for the given flavor.
Properties in that config should be preferred to the defaults in
[`/up.modal.config`](/up.modal.config).
@function flavorOverrides
@internal
###
flavorOverrides = (flavor) ->
config.flavors[flavor] ||= {}
###*
Returns the config option for the current flavor.
@function flavorDefault
@internal
###
flavorDefault = (key, flavorName = currentFlavor) ->
value = flavorOverrides(flavorName)[key] if flavorName
value = config[key] if u.isMissing(value)
value
###*
Clicking this link will load the destination via AJAX and open
the given selector in a modal dialog.
Example:
Switch blog
Clicking would request the path `/blog` and select `.blog-list` from
the HTML response. Unpoly will dim the page with an overlay
and place the matching `.blog-list` tag will be placed in
a modal dialog.
@selector [up-modal]
@param {String} [up-confirm]
A message that will be displayed in a cancelable confirmation dialog
before the modal is opened.
@param {String} [up-sticky]
If set to `"true"`, the modal remains
open even if the page changes in the background.
@param {String} [up-animation]
The animation to use when opening the viewport containing the dialog.
@param {String} [up-backdrop-animation]
The animation to use when opening the backdrop that dims the page below the dialog.
@param {String} [up-height]
The width of the dialog in pixels.
By [default](/up.modal.config) the dialog will grow to fit its contents.
@param {String} [up-width]
The width of the dialog in pixels.
By [default](/up.modal.config) the dialog will grow to fit its contents.
@param {String} [up-history="true"]
Whether to add a browser history entry for the modal's source URL.
@stable
###
up.link.onAction '[up-modal]', ($link) ->
follow($link)
# Close the modal when someone clicks outside the dialog
# (but not on a modal opener).
up.on('click', 'body', (event, $body) ->
$target = $(event.target)
unless $target.closest('.up-modal-dialog').length || $target.closest('[up-modal]').length
close()
)
up.on('up:fragment:inserted', (event, $fragment) ->
if contains($fragment)
if newSource = $fragment.attr('up-source')
currentUrl = newSource
else if !up.popup.contains($fragment) && contains(event.origin)
autoclose()
)
# Close the pop-up overlay when the user presses ESC.
up.bus.onEscape(-> close())
###*
When this element is clicked, closes a currently open dialog.
Does nothing if no modal is currently open.
To make a link that closes the current modal, but follows to
a fallback destination if no modal is open:
Okay
@selector [up-close]
@stable
###
up.on('click', '[up-close]', (event, $element) ->
if $element.closest('.up-modal').length
close()
# Only prevent the default when we actually closed a modal.
# This way we can have buttons that close a modal when within a modal,
# but link to a destination if not.
event.preventDefault()
)
# The framework is reset between tests
up.on 'up:framework:reset', reset
knife: eval(Knife?.point)
visit: visit
follow: follow
extract: extract
open: -> up.error('up.modal.open no longer exists. Please use either up.modal.follow or up.modal.visit.')
close: close
url: -> currentUrl
coveredUrl: coveredUrl
config: config
defaults: -> u.error('up.modal.defaults(...) no longer exists. Set values on he up.modal.config property instead.')
contains: contains
source: -> up.error('up.modal.source no longer exists. Please use up.popup.url instead.')
isOpen: isOpen
flavor: flavor
)(jQuery)