###**
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.
To open a modal, add an [`[up-modal]`](/a-up-modal) attribute to a link:
Switch blog
When this link is clicked, Unpoly will request the path `/blogs` and extract
an element matching the selector `.blog-list` from the response. The matching element
will then be placed in a modal dialog.
\#\#\# 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.
To disable this behavior, give the opening link an [`up-sticky`](/a-up-modal#up-sticky) attribute:
\#\#\# Customizing the dialog design
Dialogs have a minimal default design:
- Contents are displayed in a white box with a subtle box shadow
- The box will grow to fit the dialog contents, but never grow larger than the screen
- The box is placed over a semi-transparent backdrop 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 to override the
[default CSS styles](https://github.com/unpoly/unpoly/blob/master/lib/assets/stylesheets/unpoly/modal.sass).
By default the dialog uses the following DOM structure:
You can change this structure by setting [`up.modal.config.template`](/up.modal.config#config.template) to a new template string
or function.
@module up.modal
###
up.modal = do ->
u = up.util
e = up.element
###**
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): string} [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='×']
The label of the button that closes the dialog.
@param {boolean} [config.closable=true]
When `true`, the modal will render a close icon and close when the user
clicks on the backdrop or presses Escape.
When `false`, you need to either supply an element with `[up-close]` or
close the modal manually with `up.modal.close()`.
@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 {number} [config.openDuration]
The duration of the open animation (in milliseconds).
@param {number} [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.
@param {string} [options.flavor='default']
The default [flavor](/up.modal.flavors).
@stable
###
config = new up.Config
maxWidth: 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: '×'
closable: true
sticky: false
flavor: 'default'
position: null
template: (options) ->
"""
"""
###**
Define modal variants with their own default configuration, CSS or HTML template.
\#\#\# Example
Unpoly's [`[up-drawer]`](/a-up-drawer) is implemented as a modal flavor:
up.modal.flavors.drawer = {
openAnimation: 'move-from-right',
closeAnimation: 'move-to-right'
}
Modals with that flavor will have a container with an `up-flavor` attribute:
...
We can target the `up-flavor` attribute to override the default dialog styles:
.up-modal[up-flavor='drawer'] {
.up-modal-dialog {
margin: 0; // Remove margin so drawer starts at the screen edge
max-width: 350px; // Set drawer size
}
.up-modal-content {
min-height: 100vh; // Stretch background to full window height
}
}
@property up.modal.flavors
@param {Object} flavors
An object where the keys are flavor names (e.g. `'drawer') and
the values are the respective default configurations.
@experimental
###
flavors = new up.Config
default: {}
###**
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
###
###**
Returns the URL of the page behind the modal overlay.
@function up.modal.coveredUrl
@return {string}
@experimental
###
state = new up.Config
phase: 'closed' # can be 'opening', 'opened', 'closing' and 'closed'
anchorElement: null # the element to which the tooltip is anchored
modalElement: null # the modal container
sticky: null
closable: null
flavor: null
url: null
coveredUrl: null
coveredTitle: null
position: null
bodyShifter = new up.BodyShifter()
chain = new up.DivertibleChain()
reset = ->
e.remove(state.modalElement) if state.modalElement
bodyShifter.unshift()
state.reset()
chain.reset()
config.reset()
flavors.reset()
templateHtml = ->
template = flavorDefault('template')
u.evalOption(template, closeLabel: flavorDefault('closeLabel'))
discardHistory = ->
state.coveredTitle = null
state.coveredUrl = null
part = (name) ->
selector = ".up-modal-#{name}"
state.modalElement.querySelector(selector)
createHiddenFrame = (target, options) ->
html = templateHtml()
state.modalElement = modalElement = e.createFromHtml(html)
modalElement.setAttribute('up-flavor', state.flavor)
modalElement.setAttribute('up-position', state.position) if u.isPresent(state.position)
dialogStyles = u.only(options, 'width', 'maxWidth', 'height')
e.setStyle(part('dialog'), dialogStyles)
unless state.closable
closeElement = part('close')
e.remove(closeElement)
contentElement = part('content')
# Create an empty element that will match the
# selector that is being replaced.
up.fragment.createPlaceholder(target, contentElement)
e.hide(modalElement)
document.body.appendChild(modalElement)
unveilFrame = ->
e.show(state.modalElement)
###**
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
@return {boolean}
@stable
###
isOpen = ->
state.phase == 'opened' || state.phase == 'opening'
###**
Opens the given link's destination in a modal overlay:
var link = document.querySelector('a')
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 {boolean} [config.closable=true]
When `true`, the modal will render a close icon and close when the user
clicks on the backdrop or presses Escape.
When `false`, you need to either supply an element with `[up-close]` or
close the modal manually with `up.modal.close()`.
@param {string} [options.confirm]
A message that will be displayed in a cancelable confirmation dialog
before the modal is being opened.
@param {string} [options.method="GET"]
Override the request method.
@param {boolean} [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 fulfilled when the modal has been loaded and
the opening animation has completed.
@stable
###
followAsap = (linkOrSelector, options) ->
options = u.options(options)
options.link = e.get(linkOrSelector)
openAsap(options)
preloadNow = (link, options) ->
options = u.options(options)
options.link = link
options.preload = true
# Use openNow() and not openAsap() so (1) we don't close a currently open modal
# and (2) our pending AJAX request does not prevent other modals from opening
openNow(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 fulfilled when the modal has been loaded and the opening
animation has completed.
@stable
###
visitAsap = (url, options) ->
options = u.options(options)
options.url = url
openAsap(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('.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} selector
The CSS selector to extract from the HTML.
@param {string} html
The HTML containing the modal content.
@param {Object} options
See options for [`up.modal.follow()`](/up.modal.follow).
@return {Promise}
A promise that will be fulfilled when the modal has been opened and the opening
animation has completed.
@stable
###
extractAsap = (selector, html, options) ->
options = u.options(options)
options.html = html
options.history ?= false
options.target = selector
openAsap(options)
openAsap = (options) ->
chain.asap closeNow, (-> openNow(options))
openNow = (options) ->
options = u.options(options)
link = u.pluckKey(options, 'link') || e.none()
url = u.pluckKey(options, 'url') ? link.getAttribute('up-href') ? link.getAttribute('href')
html = u.pluckKey(options, 'html')
target = u.pluckKey(options, 'target') ? link.getAttribute('up-modal')
validateTarget(target)
options.flavor ?= link.getAttribute('up-flavor') ? config.flavor
options.position ?= link.getAttribute('up-position') ? flavorDefault('position', options.flavor)
options.position = u.evalOption(options.position, { link })
options.width ?= link.getAttribute('up-width') ? flavorDefault('width', options.flavor)
options.maxWidth ?= link.getAttribute('up-max-width') ? flavorDefault('maxWidth', options.flavor)
options.height ?= link.getAttribute('up-height') ? flavorDefault('height')
options.animation ?= link.getAttribute('up-animation') ? flavorDefault('openAnimation', options.flavor)
options.animation = u.evalOption(options.animation, position: options.position)
options.backdropAnimation ?= link.getAttribute('up-backdrop-animation') ? flavorDefault('backdropOpenAnimation', options.flavor)
options.backdropAnimation = u.evalOption(options.backdropAnimation, position: options.position)
options.sticky ?= e.booleanAttr(link, 'up-sticky') ? flavorDefault('sticky', options.flavor)
options.closable ?= e.booleanAttr(link, 'up-closable') ? flavorDefault('closable', options.flavor)
options.confirm ?= link.getAttribute('up-confirm')
options.method = up.link.followMethod(link, options)
options.layer = 'modal'
options.failTarget ?= link.getAttribute('up-fail-target')
options.failLayer ?= link.getAttribute('up-fail-layer') ? 'auto'
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 ?= e.booleanOrStringAttr(link, 'up-history') ? flavorDefault('history', options.flavor)
options.history = false unless up.browser.canPushState()
# This will prevent up.replace() from looking for fallbacks, since
# it knows the target will always exist.
options.provideTarget = -> createHiddenFrame(target, options)
if options.preload
return up.replace(target, url, options)
up.browser.whenConfirmed(options).then ->
up.event.whenEmitted('up:modal:open', url: url, log: 'Opening modal').then ->
state.phase = 'opening'
state.flavor = options.flavor
state.sticky = options.sticky
state.closable = options.closable
state.position = options.position
if options.history
state.coveredUrl = up.browser.url()
state.coveredTitle = document.title
extractOptions = u.merge(options, animation: false)
if html
promise = up.extract(target, html, extractOptions)
else
promise = up.replace(target, url, extractOptions)
promise = promise.then ->
bodyShifter.shift()
unveilFrame()
animate(options.animation, options.backdropAnimation, animateOptions)
promise = promise.then ->
state.phase = 'opened'
up.emit('up:modal:opened', log: 'Modal opened')
promise
validateTarget = (target) ->
if u.isBlank(target)
up.fail('Cannot open a modal without a target selector')
else if target == 'body'
up.fail('Cannot open the in a modal')
###**
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 fulfilled once the modal's close
animation has finished.
@stable
###
closeAsap = (options) ->
chain.asap -> closeNow(options)
closeNow = (options) ->
options = u.options(options)
unless isOpen()
return Promise.resolve()
viewportCloseAnimation = options.animation ? flavorDefault('closeAnimation')
viewportCloseAnimation = u.evalOption(viewportCloseAnimation, position: state.position)
backdropCloseAnimation = options.backdropAnimation ? flavorDefault('backdropCloseAnimation')
backdropCloseAnimation = u.evalOption(backdropCloseAnimation, position: state.position)
animateOptions = up.motion.animateOptions(options, duration: flavorDefault('closeDuration'), easing: flavorDefault('closeEasing'))
destroyOptions = u.options(
u.except(options, 'animation', 'duration', 'easing', 'delay'),
history: state.coveredUrl,
title: state.coveredTitle
)
up.event.whenEmitted(state.modalElement, 'up:modal:close', log: 'Closing modal').then ->
state.phase = 'closing'
# the current URL must be deleted *before* calling up.destroy,
# since up.feedback listens to up:fragment:destroyed and then
# re-assigns .up-current classes.
state.url = null
state.coveredUrl = null
state.coveredTitle = null
promise = animate(viewportCloseAnimation, backdropCloseAnimation, animateOptions)
promise = promise.then ->
up.destroy(state.modalElement, destroyOptions)
promise = promise.then ->
bodyShifter.unshift()
state.phase = 'closed'
state.modalElement = null
state.flavor = null
state.sticky = null
state.closable = null
state.position = null
up.emit('up:modal:closed', log: 'Modal closed')
promise
markAsAnimating = (isAnimating = true) ->
e.toggleClass(state.modalElement, 'up-modal-animating', isAnimating)
animate = (viewportAnimation, backdropAnimation, animateOptions) ->
# If we're not animating the dialog, don't animate the backdrop either
if up.motion.isNone(viewportAnimation)
Promise.resolve()
else
markAsAnimating()
promise = Promise.all([
up.animate(part('viewport'), viewportAnimation, animateOptions),
up.animate(part('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 state.sticky
discardHistory()
closeAsap()
###**
Returns whether the given element or selector is contained
within the current modal.
@function up.modal.contains
@param {string} elementOrSelector
The element to test
@return {boolean}
@stable
###
contains = (elementOrSelector) ->
element = e.get(elementOrSelector)
!!e.closest(element, '.up-modal')
flavor = (name, overrideConfig = {}) ->
up.legacy.warn('up.modal.flavor() is deprecated. Use the up.modal.flavors property instead.')
u.assign(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) ->
flavors[flavor] ||= {}
###**
Returns the config option for the current flavor.
@function flavorDefault
@internal
###
flavorDefault = (key, flavorName = state.flavor) ->
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
and place the matching `.blog-list` tag in
a modal dialog.
@selector a[up-modal]
@param {string} up-modal
The CSS selector that will be extracted from the response and displayed in a modal dialog.
@param {string} [up-confirm]
A message that will be displayed in a cancelable confirmation dialog
before the modal is opened.
@param {string} [up-method='GET']
Override the request method.
@param {string} [up-sticky]
If set to `"true"`, the modal remains
open even if the page changes in the background.
@param {boolean} [up-closable]
When `true`, the modal will render a close icon and close when the user
clicks on the backdrop or presses Escape.
When `false`, you need to either supply an element with `[up-close]` or
close the modal manually with `up.modal.close()`.
@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]
Whether to push an entry to the browser history for the modal'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-modal]',
# Don't just pass the `follow` function reference so we can stub it in tests
follow: (link, options) -> followAsap(link, options)
preload: (link, options) -> preloadNow(link, options)
# Close the modal when someone clicks outside the dialog (but not on a modal opener).
# We register the event on .up-modal, which covers the *entire* viewport, not just
# the dialog area.
#
# Note that we cannot listen to clicks on .up-modal-backdrop, which is a sister element
# of .up-modal-viewport. Since the user will effectively click on the viewport, not
# the backdrop, backdrop will not receive a bubbling event.
up.on('click', '.up-modal', (event) ->
return unless state.closable
target = event.target
unless e.closest(target, '.up-modal-dialog') || e.closest(target, '[up-modal]')
up.event.consumeAction(event)
u.muteRejection closeAsap()
)
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) && !up.popup.contains(fragment)
u.muteRejection autoclose()
)
# Close the pop-up overlay when the user presses ESC.
up.event.onEscape ->
if state.closable
u.muteRejection closeAsap()
###**
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-modal [up-close]
@stable
###
up.on('click', '.up-modal [up-close]', (event) ->
u.muteRejection closeAsap()
# If the user closes the modal by clicking on the background, we want to halt the event chain here.
# The event should not trigger anything else. The user needs to click again for another interaction.
# Also 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.
up.event.consumeAction(event)
)
###**
Clicking this link will load the destination via AJAX and open
the given selector in a modal drawer that slides in from the edge of the screen.
You can configure drawers using the [`up.modal.flavors.drawer`](/up.modal.flavors.drawer) property.
\#\#\# Example
Switch blog
Clicking would request the path `/blog` and select `.blog-list` from
the HTML response. Unpoly will dim the page
and place the matching `.blog-list` tag will be placed in
a modal drawer.
@selector a[up-drawer]
@param {string} up-drawer
The CSS selector to extract from the response and open in the drawer.
@param {string} [up-position='auto']
The side from which the drawer slides in.
Valid values are `'left'`, `'right'` and `'auto'`. If set to `'auto'`, the
drawer will slide in from left if the opening link is on the left half of the screen.
Otherwise it will slide in from the right.
@stable
###
up.macro 'a[up-drawer], [up-href][up-drawer]', (link) ->
target = link.getAttribute('up-drawer')
e.setAttrs link,
'up-modal': target
'up-flavor': 'drawer'
###**
Sets default options for future drawers.
@property up.modal.flavors.drawer
@param {Object} config
Default options for future drawers.
See [`up.modal.config`](/up.modal.config) for available options.
@experimental
###
flavors.drawer =
openAnimation: (options) ->
switch options.position
when 'left' then 'move-from-left'
when 'right' then 'move-from-right'
closeAnimation: (options) ->
switch options.position
when 'left' then 'move-to-left'
when 'right' then 'move-to-right'
position: (options) ->
if u.isPresent(options.link)
u.horizontalScreenHalf(options.link)
else
# In case the drawer was opened programmatically through Javascript,
# we might now know the link that was clicked on.
'left'
# 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 %>
visit: visitAsap
follow: followAsap
extract: extractAsap
close: closeAsap
url: -> state.url
coveredUrl: -> state.coveredUrl
config: config
flavors: flavors
contains: contains
isOpen: isOpen
flavor: flavor # deprecated