app/assets/javascripts/pwn-fx.js.coffee in pwnstyles_rails-0.1.6 vs app/assets/javascripts/pwn-fx.js.coffee in pwnstyles_rails-0.1.7

- old
+ new

@@ -3,39 +3,182 @@ # The author sorely misses Rails' AJAX helpers such as observe_field. This # library provides a replacement that adheres to the new philosophy of # unobtrusive JavaScript triggered by HTML5 data- attributes. + +# The class of the singleton instance that tracks all the effects on the page. +class PwnFxClass + # Creates an instance that isn't aware of any effects. + # + # After defining an effect class, call registerEffect on this instance to make + # it aware of the effect. + constructor: -> + @effects = [] + @effectsByName = {} + + # Wires JS to elements with data-pwnfx attributes. + # + # @param [Element] root the element whose content is wired; use document at + # load time + wire: (root) -> + for effect in @effects + attrName = "data-pwnfx-#{effect[0]}" + effectClass = effect[1] + scopeAttrName = "#{attrName}-scope" + doneAttrName = "#{attrName}-done" + attrSelector = "[#{attrName}]" + for element in document.querySelectorAll(attrSelector) + attrValue = element.getAttribute attrName + continue unless attrValue + element.removeAttribute attrName + element.setAttribute doneAttrName, attrValue + scopeId = element.getAttribute scopeAttrName + new effectClass element, attrValue, scopeId + null + + # Registers a PwnFx effect. + # + # @param [String] attrName string following data-pwnfx- in the effect's + # attribute names + # @param klass the class that wraps the effect's implementation + registerEffect: (attrPrefix, klass) -> + if @effectsByName[attrPrefix] + throw new Error("Effect name {attrPrefix} already registered") + @effects.push [attrPrefix, klass] + + # Finds a scoping container. + # + # @param [String] scopeId the scope ID to look for + # @param [Element] element the element where the lookup starts + # @return [Element] the closest parent of the given element whose + # data-pwnfx-scope matches the scopeId argument; window.document is + # returned if no such element exists or if scope is null + resolveScope: (scopeId, element) -> + element = null if scopeId is null + while element != null && element.getAttribute('data-pwnfx-scope') != scopeId + element = element.parentElement + element || document + + # Performs a scoped querySelectAll. + # + # @param [Element] scope the DOM element serving as the search scope + # @param [String] selector the CSS selector to query + # @return [NodeList, Array] the elements in the scope that match the CSS + # selector; the scope container can belong to the returned array + queryScope: (scope, selector) -> + scopeMatches = false + if scope != document + # TODO: machesSelector is in a W3C spec, but only implemented using + # prefixes; the code below should be simplified once browsers + # implement it without vendor prefixes + if scope.matchesSelector + scopeMatches = scope.matchesSelector selector + else if scope.webkitMatchesSelector + scopeMatches = scope.webkitMatchesSelector selector + else if scope.mozMatchesSelector + scopeMatches = scope.mozMatchesSelector + + if scopeMatches + matches = Array.prototype.slice.call scope.querySelectorAll(selector) + matches.push scope + matches + else + scope.querySelectorAll selector + + +# Singleton instance. +PwnFx = new PwnFxClass + + +# Moves an element using data-pwnfx-move. +# +# Attributes: +# data-pwnfx-move: an identifier connecting the move's target element +# data-pwnfx-move-target: set to the same value as data-pwnfx-move on the +# element that will receive the moved element as its last child +class PwnFxMove + constructor: (element, identifier, scopeId) -> + scope = PwnFx.resolveScope scopeId, element + target = document.querySelector "[data-pwnfx-move-target=\"#{identifier}\"]" + target.appendChild element + +PwnFx.registerEffect 'move', PwnFxMove + + +# Renders the contents of a template into a DOM element. +# +# Attributes: +# data-pwnfx-render: identifier for the render operation +# data-pwnfx-render-where: insertAdjacentHTML position argument; can be +# beforebegin, afterbegin, beforeend, afterend; defaults to beforeend +# data-pwnfx-render-randomize: regexp pattern whose matches will be replaced +# with a random string; useful for generating unique IDs +# data-pwnfx-render-target: set on the element(s) receiving the rendered HTML; +# set to the identifier in data-pwnfx-render +# data-pwnfx-render-source: set on the <script> tag containing the source HTML +# to be rendered; set to the identifier in data-pwnfx-render +class PwnFxRender + constructor: (element, identifier, scopeId) -> + sourceSelector = "script[data-pwnfx-render-source=\"#{identifier}\"]" + targetSelector = "[data-pwnfx-render-target=\"#{identifier}\"]" + insertionPoint = element.getAttribute('data-pwnfx-render-where') || + 'beforeend' + randomizedPatten = element.getAttribute('data-pwnfx-render-randomize') + if randomizedPatten + randomizeRegExp = new RegExp(randomizedPatten, 'g') + else + randomizeRegExp = null + + onClick = (event) -> + scope = PwnFx.resolveScope scopeId, element + source = scope.querySelector sourceSelector + html = source.innerHTML + if randomizeRegExp + randomId = 'r' + Date.now() + '_' + Math.random() + html = html.replace randomizeRegExp, randomId + for targetElement in PwnFx.queryScope(scope, targetSelector) + targetElement.insertAdjacentHTML insertionPoint, html + PwnFx.wire targetElement + event.preventDefault() + false + element.addEventListener 'click', onClick, false + +PwnFx.registerEffect 'render', PwnFxRender + + # Fires off an AJAX request (almost) every time when an element changes. # # The text / HTML returned by the request is placed in another element. # # Element attributes: -# data-pwnfx-refresh-url: URL to perform an AJAX request to +# data-pwnfx-refresh: URL to perform an AJAX request to # data-pwnfx-refresh-method: the HTTP method of AJAX request (default: POST) # data-pwnfx-refresh-ms: interval between a change on the source element and # AJAX refresh requests (default: 200ms) # data-pwnfx-target: the element populated with the AJAX response class PwnFxRefresh - constructor: (element, xhrUrl) -> + constructor: (element, xhrUrl, scopeId) -> targetSelector = '#' + element.getAttribute('data-pwnfx-refresh-target') refreshInterval = parseInt( element.getAttribute('data-pwnfx-refresh-ms') || '200'); xhrMethod = element.getAttribute('data-pwnfx-refresh-method') || 'POST' xhrForm = @parentForm element onXhrSuccess = -> data = @responseText - for targetElement in document.querySelectorAll(targetSelector) + scope = PwnFx.resolveScope scopeId, element + for targetElement in PwnFx.queryScope(scope, targetSelector) targetElement.innerHTML = data + # HACK: <script>s are removed and re-inserted so the browser runs them for scriptElement in targetElement.querySelectorAll('script') parent = scriptElement.parentElement nextSibling = scriptElement.nextSibling parent.removeChild scriptElement - parent.insertBefore scriptElement.cloneNode(true), nextSibling - - + parent.insertBefore scriptElement.cloneNode(true), nextSibling + PwnFx.wire targetElement + refreshPending = false refreshOldValue = null ajaxRefresh = -> refreshPending = false xhr = new XMLHttpRequest @@ -51,128 +194,140 @@ return true if refreshPending refreshPending = true window.setTimeout ajaxRefresh, refreshInterval true - element.addEventListener 'change', onChange - element.addEventListener 'keydown', onChange - element.addEventListener 'keyup', onChange + element.addEventListener 'change', onChange, false + element.addEventListener 'keydown', onChange, false + element.addEventListener 'keyup', onChange, false # The closest form element wrapping a node. parentForm: (element) -> while element return element if element.nodeName == 'FORM' element = element.parentNode null - + +PwnFx.registerEffect 'refresh', PwnFxRefresh + + # Shows elements conditionally, depending on whether some inputs' values match. # # Element attributes: # data-pwnfx-confirm: all elements with the same value for this attribute # belong to the same confirmation group; their values have to match to # trigger the "win" condition +# data-pwnfx-confirm-class: the CSS class that is added to hidden elements; +# (default: hidden) # data-pwnfx-confirm-win: CSS selector identifying the elements to be shown # when the "win" condition is triggered, and hidden otherwise # data-pwnfx-confirm-fail: CSS selector identifying the elements to be hidden # when the "win" condition is triggered, and shown otherwise class PwnFxConfirm - constructor: (element, identifier) -> + constructor: (element, identifier, scopeId) -> + hiddenClass = element.getAttribute('data-pwnfx-confirm-class') || 'hidden' sourceSelector = "[data-pwnfx-confirm-done=\"#{identifier}\"]" winSelector = "[data-pwnfx-confirm-win=\"#{identifier}\"]" failSelector = "[data-pwnfx-confirm-fail=\"#{identifier}\"]" onChange = -> + scope = PwnFx.resolveScope scopeId, element value = null matching = true - for element, index in document.querySelectorAll(sourceSelector) + for sourceElement, index in PwnFx.queryScope(scope, sourceSelector) if index == 0 - value = element.value - else if element.value != value + value = sourceElement.value + else if sourceElement.value != value matching = false break hideSelector = if matching then failSelector else winSelector showSelector = if matching then winSelector else failSelector - for targetElement in document.querySelectorAll(showSelector) - targetElement.classList.remove 'hidden' - for targetElement in document.querySelectorAll(hideSelector) - targetElement.classList.add 'hidden' + for targetElement in PwnFx.queryScope(scope, winSelector) + targetElement.classList.remove hiddenClass + for targetElement in PwnFx.queryScope(scope, hideSelector) + targetElement.classList.add hiddenClass true onChange() - element.addEventListener 'change', onChange - element.addEventListener 'keydown', onChange - element.addEventListener 'keyup', onChange - -# Moves an element using data-pwnfx-move. -class PwnFxMove - constructor: (element, identifier) -> - targetSelector = - target = document.querySelector "[data-pwnfx-move-target=\"#{identifier}\"]" - target.appendChild element + element.addEventListener 'change', onChange, false + element.addEventListener 'keydown', onChange, false + element.addEventListener 'keyup', onChange, false +PwnFx.registerEffect 'confirm', PwnFxConfirm + + # Shows / hides elements when an element is clicked or checked / unchecked. # # Attributes: -# data-pwnfx-reveal: a name for the events caused by this element's triggering -# data-pwnfx-trigger: 'click' means events are triggered when the element is -# clicked, 'check' means events are triggered when the element is checked; -# (default: click) -# data-pwnfx-positive: set to the same value as data-pwnfx-reveal on elements -# that will be shown when a positive event (click / check) is triggered, -# and hidden when a negative event (uncheck) is triggered -# data-pwnfx-negative: set to the same value as data-pwnfx-reveal on elements -# that will be hidden when a positive event (click / check) is triggered, -# and shown when a negative event (uncheck) is triggered -class PwnFxReveal - constructor: (element, identifier) -> - trigger = element.getAttribute('data-pwnfx-reveal-trigger') || 'click' - positiveSelector = "[data-pwnfx-reveal-positive=\"#{identifier}\"]" - negativeSelector = "[data-pwnfx-reveal-negative=\"#{identifier}\"]" +# data-pwnfx-hide: a name for the events caused by this element's triggering +# data-pwnfx-hide-trigger: "click" means events are triggered when the +# element is clicked, "checked" means events are triggered when the +# element is checked; (default: click) +# data-pwnfx-hide-class: the CSS class that is added to hidden elements; +# (default: hidden) +# data-pwnfx-hide-positive: set to the same value as data-pwnfx-hide on +# elements that will be hidden when a positive event (click / check) is +# triggered, and shown when a negative event (uncheck) is triggered +# data-pwnfx-hide-negative: set to the same value as data-pwnfx-hide on +# elements that will be shown when a positive event (click / check) is +# triggered, and hidden when a negative event (uncheck) is triggered +class PwnFxHide + constructor: (element, identifier, scopeId) -> + trigger = element.getAttribute('data-pwnfx-hide-trigger') || 'click' + hiddenClass = element.getAttribute('data-pwnfx-hide-class') || 'hidden' + positiveSelector = "[data-pwnfx-hide-positive=\"#{identifier}\"]" + negativeSelector = "[data-pwnfx-hide-negative=\"#{identifier}\"]" onChange = (event) -> positive = (trigger == 'click') || element.checked + hideSelector = if positive then positiveSelector else negativeSelector + showSelector = if positive then negativeSelector else positiveSelector - showSelector = if positive then positiveSelector else negativeSelector - hideSelector = if positive then negativeSelector else positiveSelector - for targetElement in document.querySelectorAll(showSelector) - targetElement.classList.remove 'hidden' - for targetElement in document.querySelectorAll(hideSelector) - targetElement.classList.add 'hidden' + scope = PwnFx.resolveScope scopeId, element + for targetElement in PwnFx.queryScope(scope, hideSelector) + targetElement.classList.add hiddenClass + for targetElement in PwnFx.queryScope(scope, showSelector) + targetElement.classList.remove hiddenClass if trigger == 'click' event.preventDefault() false else true if trigger == 'click' - element.addEventListener 'click', onChange - else if trigger == 'check' - element.addEventListener 'change', onChange + element.addEventListener 'click', onChange, false + else if trigger == 'checked' + element.addEventListener 'change', onChange, false onChange() + else + throw new Error("Unimplemented trigger #{trigger}") +PwnFx.registerEffect 'hide', PwnFxHide -# List of effects. -pwnEffects = [ - ['data-pwnfx-move', PwnFxMove], - ['data-pwnfx-refresh-url', PwnFxRefresh], - ['data-pwnfx-confirm', PwnFxConfirm], - ['data-pwnfx-reveal', PwnFxReveal] -] -# Wires JS to elements with data-pwnfx attributes. -window.PwnFx = -> - for effect in pwnEffects - attrName = effect[0] - effectClass = effect[1] - doneAttrName = "#{attrName}-done" - attrSelector = "[#{attrName}]" - for element in document.querySelectorAll(attrSelector) - attrValue = element.getAttribute attrName - continue unless attrValue - element.removeAttribute attrName - element.setAttribute doneAttrName, attrValue - new effectClass element, attrValue - null +# Removes elements from the DOM when an element is clicked. +# +# Attributes: +# data-pwnfx-remove: an identifier connecting the elements to be removed +# data-pwnfx-remove-target: set to the same value as data-pwnfx-remove on +# elements that will be removed when the element is clicked +class PwnFxRemove + constructor: (element, identifier, scopeId) -> + targetSelector = "[data-pwnfx-remove-target=\"#{identifier}\"]" -# Honor data-pwnfx attributes after the DOM is loaded. -window.addEventListener 'load', -> window.PwnFx() + onClick = (event) -> + scope = PwnFx.resolveScope scopeId, element + for targetElement in PwnFx.queryScope(scope, targetSelector) + targetElement.parentNode.removeChild targetElement + event.preventDefault() + false + element.addEventListener 'click', onClick, false + +PwnFx.registerEffect 'remove', PwnFxRemove + + +# Export the PwnFx instance. +window.PwnFx = PwnFx + +# Wire up the entire DOM after the document is loaded. +window.addEventListener 'load', -> PwnFx.wire(document)