Skip to content

Instantly share code, notes, and snippets.

@kerbyfc
Last active August 23, 2016 07:40
Show Gist options
  • Save kerbyfc/8f602584e4bc75959782 to your computer and use it in GitHub Desktop.
Save kerbyfc/8f602584e4bc75959782 to your computer and use it in GitHub Desktop.
UI component-oriented micro-framework with finite state machine integration
$ ->
UI.env('login_form.hint_cookie', 'login_first_msg_viewed')
if UI.env('user.authorized')
$.removeCookie UI.env('login_form.hint_cookie')
UI "signInForm",
###*
* FadeIn/Out animation spid
* @type {Number}
###
animSpeed : 200
###*
* UI selector
* @required
* @type {String}
###
selector : ".b-sign-in"
###*
* Default state on init
* @type {String}
###
initialState : "close"
###*
* Events and stateflow
* @type {Object}
###
events:
'click [data-show]': '_switchState'
'click .i-close-white-big': '_doClose'
# FSM events & states
'* - [ login ] -> login' : '_switch'
'* - [ registration ] -> registration' : '_switch'
'login - [ forgot ] -> forgot' : '_switch'
'forgot - [ reset-pass ] -> reset-pass' : '_switch'
'* - [ feedback ] -> feedback' : '_switch'
'* - [ close ] -> close' : '_switch'
'login - [ social ] -> social' : '_switch'
###*
* Component initer
###
init: ->
# collect states from stateflow definition
@__states = _.reduce(_.keys events), (acc, event) ->
if match =event.match(/(\-\>[\s]*)([\w\-]*)/g)
acc.push match[0]
acc
, []
# FIXME as events delegation after init fn execution
_.defer =>
current = @detectInitialState()
toInit = location.hash.replace(/^\#/, '')
if @$el.data('redirect-to') and !$.cookie(UI.env('login_form.hint_cookie'))
#UI.popup @t('.login_first')
$.cookie(UI.env('login_form.hint_cookie'), true)
toInit = "login"
@switchState (toInit || current || @initialState), current
###*
* Detect if initial state was specified by BEM modifier
* @return {String} state name
###
detectInitialState: ->
for state in @__states
if @$el.hasClass("#{@selector.slice(1)}_#{state}")
return state
""
###*
* Dispatch switch event
* @param {String} event - event name
* @param {String} from - previous state
* @param {String} to - state to switch
###
_switch: (event, from, to) ->
@switchState(to, from, event)
###*
* Init state transition by interface interaction
* @param {jQuery.Event} event
* @param {jQuery} el - element
###
_switchState: (event, el) ->
event.preventDefault()
@switchState(el.data 'show')
###*
* Process state transition, check if transition is permitted
* @param {String} to - state to switch
* @param {String} from - current state
###
switchState: (to, from = @fsm.current) ->
# do nothing for unregistered state
return unless to in @__states
if from
@$el.removeClass(@modifier from)
@$el.addClass(@modifier to)
# cleanup forms
@$("form[data-validation='on']").each (el) ->
$(el).validator("hideErrors")
# remember scoll state
top = $(window).scrollTop()
# cleanup hash on close form
unless to is "close"
location.hash = "#" + to
else
location.hash = ""
# project-specific hack for mobiles
$(window).scrollTop(unless UI.env 'is_mobile' then top else 0)
unless @fsm.is(to) or @fsm.cannot(to)
@fsm[to]()
@$('form').each (i, form) ->
$(form).validator("hideErrors")
true
else
false
###*
* Handle close event, switch state
* @param {jQuery.Event} event
* @param {jQuery} el
###
_doClose: (event, el) ->
if typeof ga isnt 'undefined'
ga 'send', 'event', @fsm.current, 'close'
@fsm.close()
###*
* Get modifier with name
* @param {String} name - selector
* @return {String} selector with modifier
###
modifier: (name) ->
"#{@selector.slice(1)}_#{name}"
###*
* Handle closed state leaving
###
leaveClose: ->
@$el.slideDown @animSpeed
###*
* Handle closed state entering
###
enterClose: ->
@$el.slideUp @animSpeed
###*
* Cleanup forgon for errors
###
enterForgot: ->
$(".b-sign-in__forgot input[type=email]")
.focus()
.next(".b-input__error-message").text("")
.parent(".b-input").removeClass("b-input_error")
# enterReset_pass : -> true
# enterRegistration : -> true
# enterReset_pass : -> true
# enterRegistration : -> true
# dont send messages to server if env name wasn't passed to js
window.env ?= {}
###*
* Основная часть обстрации UI-компонентов
* только статика, не инстанцируется
* содержит логику регистрации и инициализации компонентов
###
class window.UI
###*
* коллекция компонентов
* @property
* @type {Object}
###
@components = {}
###*
* коллекция инстанцированных компонентов
* @property
* @type {Object}
###
@instances = {}
###*
* мапим компоненты и их инстансы
* @type {Object}
###
@mapping = {}
###*
* коллекция обработчиков
* @property
* @type {Object}
###
@handlers = {}
###*
* счетчик уникальтых идентификатров
* @property
* @type {Number}
###
@counter = 0
###*
* Метод
###
@protected_methods = ['init']
###*
* получить настройку окружения по пути
###
@env = (path, value = '__noval__') ->
path = path.split '.'
if value is '__noval__'
UI.helpers.getPropertyByPath(window.env, path)
else
UI.helpers.setPropertyByPath(window.env, path, value)
###*
* режим отладки
* @method debug
* @param {Boolean} value = null вкл/выкл
###
@debug = (value = null) ->
unless value?
state = $.cookie "ui.debug"
return state? and state is "true"
else
$.cookie "ui.debug", value
@log = (args...) ->
timestamp = new Date().getTime()
if window.console and console.log
try
args = [((timestamp/1000).toString() + "000").slice(0, 14), "(", "at #{timestamp-UI.startTime}ms" , ", since #{timestamp-UI.lastLogTime}ms", ")\n \\_"].concat(args).concat('\n')
console.log.apply console, args
catch e # IE
console.log "-----------"
for arg in args
if typeof arg is 'object'
console.log JSON.stringify(arg, UI.helpers.recSensor(arg))
else
console.log arg
UI.lastLogTime = timestamp
@startTime = @lastLogTime = new Date().getTime()
###*
* регистрация компонента
* следит за изменением родительского дом-элемента компонента
* и при изменении его структуры ищет компоненты (новые)
* TODO тут наверно следует использовать DOMNodeInserted && DOMNodeRemoved
* @method register
* @param {String} uid селектор
* @param {Object} implementation реализация
###
@register = (uid, implementation) ->
dict = UI.components
# do not redefine! must be extandable
dict[uid] ?= {__super: {}, __handlers: {}}
for key, val of implementation
if dict[uid][key]?
if key is 'events' and (typeof val is 'object')
for k,v of val
if dict[uid][key][k]?
dict[uid][key][k] = [dict[uid][key][k]] unless typeof dict[uid][key][k] is 'object'
dict[uid][key][k].push v
else
dict[uid][key][k] = v
# else replace and store old method/property in __super
else
dict[uid].__super[key] = dict[uid][key]
else
dict[uid][key] = val
if typeof dict[uid].selector is 'string'
$(dict[uid].selector).each (i, el) ->
$(el).parent() # TODO create components only for el.parent childrens !
.bind 'propertychange', UI.createComponents # IE
.bind 'DOMSubtreeModified', UI.createComponents
###*
* инстанцирование компонентов
* @method createComponents
###
@createComponents = =>
forbidden = []
for uid, implementation of UI.components
unless implementation.selector?
forbidden.push uid
else
$(implementation.selector).each (i, el) -> UI.createComponent uid, $(el)
if forbidden.length
throw Error "next UI components have invalid selectors: #{forbidden.join(", ")}"
@createComponent = (uid, el) ->
unless el.component(UI.helpers.filterByUid, uid).length
cid = UI.helpers.uid('cid')
UI.instances[cid] = new UI.Component uid, cid, el, UI.components[uid]
(UI.mapping[uid] ?= []).push UI.instances[cid]
el.data('b-component', UI.instances[cid])
###*
* @param {String} uid идентификатор компонента
* @param {Object} implementation набор методов и свойств (реализация)
* @return {Class} Component экземпляр класса компонента
###
constructor: (uid, implementation) ->
UI.register uid, implementation
# # make translation fn global
# window.t = UI.t
###*
* Класс хелперов, содержит статические методы
###
class UI.helpers
###*
* выполнение функции один раз
* @method once
* @param {Function} func исходная функа
* @return {Function} обернутая функа
###
@once = (func) ->
ran = false
memo = undefined
->
return memo if ran
ran = true
memo = func.apply(this, arguments)
func = null
memo
@capitalize = (str) ->
"#{str[0].toUpperCase()}#{str.slice(1)}"
###*
* получение уникального идентификатора
* @method uid
* @return {String} идентификатор
###
@uid = (signature) ->
"#{signature || "uid"}#{++UI.counter}"
@recSensor = (censor, max = 200) ->
i = 0
(key, value) ->
return "[Circular]" if i isnt 0 and typeof censor is "object" and typeof value is "object" and censor is value
return "[Unknown]" if i >= max
++i
value
@jsonRequest: (opts) ->
$.ajax $.extend {
beforeSend: UI.helpers.setCSRF
dataType: "json"
contentType: "application/json; charset=utf-8"
}, opts, data: if $.type(opts.data) is "string"
opts.data
else
JSON.stringify(opts.data)
@setCSRF = (xhr) ->
token = $('meta[name="csrf-token"]').attr('content')
xhr.setRequestHeader 'X-CSRF-Token', token
# @setupCSRFglobal: ->
# $.ajaxSetup
# beforeSend: UI.helpers.setCSRF
# TODO just use reduce
@setPropertyByPath = (obj, path = "", value) ->
unless $.type(obj) is "object"
throw new Error "Object expected"
if $.type(path) is "string"
path = path.split "."
if path.length is 1
obj[path[0]] = value
else
while (key = path.shift())
obj[key]= {}
UI.helpers.setPropertyByPath(obj[key], path, value)
obj
# TODO just use reduce
@getPropertyByPath = (obj, path = "") ->
unless $.type(obj) is "object"
throw new Error "Object expected"
if $.type(path) is "string"
path = path.split "."
first = path[0]
path = path.slice(1)
cur = obj[first]
return cur unless path.length
while (path.length)
unless $.type(cur) is "object"
return cur
cur = cur[path[0]]
path = path.slice(1)
cur
@filterByUid = (component, uid) ->
component.uid is uid
class window.UIError
constructor: (message) ->
return {
name : (if (name = this.__proto__.constructor.name) and name.match(/window|global/i) then "UIError" else name)
toString : -> this.name + ": " + this.message
level : "Show Stopper"
message : message
}
class ConventionError extends UIError
class HandlerError extends UIError
###*
* Класс компоненета
* тут логика оборачивания,
* делегирование событий и хуки
* @todo methods documentation
###
class UI.Component
###*
* компонент
* @method constructor
* @param {String} @uid TODO мейби не пригодится даже
* @param {String} @cid идентификатор TODO пока не задействовал, но думаю пригодится
* @param {Object} extension реализация
###
constructor: (@uid, @cid, @el, implementation) ->
@events = {}
@hooks = {}
@$el = $(@el)
@__private = []
for prop, impl of $.extend({}, implementation)
do (prop, impl) =>
@wrap(prop, impl)
@fsm =
initial : 'initial'
events : []
callbacks : {}
@__initResult = @init?()
@delegateEvents()
if UI.debug()
UI.log "#{@uid}.fsm:", @fsm
if @initialState?
@fsm.initial = @initialState
@fsm = StateMachine.create(@fsm)
###*
* поиск внутри компонента DOM-элемента
###
$: (selector) ->
$(selector, @$el)
###*
* поиск внутри вложенного БЕМ-блока
* использовать только с уникальными бем-блоками
###
$__: (postfix) ->
$("#{@selector}__#{postfix}", @$el)
on: (args...) ->
@$el.on args...
###*
* делегирование событий, описанных в коллекции events компонента
* @method delegateEvents
###
delegateEvents: =>
unless typeof @events is 'object'
throw new ConventionError("UI.components['#{@uid}'].events must be an object")
for event, callback of @events
if flow = event.match(/^[\s]*([^\s]+)[\s]*\-[\s]*\[([^\]]+)+\][\s]*\-\>[\s]*([^\s]+)[\s]*$/)
@delegateStateFlowEvents flow[2], flow[1], flow[3], callback
else if typeof callback is 'object'
for cb in callback
@delegateEvent event, cb
else
@delegateEvent event, callback
for handler in ['before', 'leave', 'enter', 'after']
if typeof @["#{handler}State"] is 'function'
@fsm.callbacks["on#{handler}state"] = @["#{handler}State"]
delegateStateFlowEvents: (event, from, to, callback) ->
from = from.split("|")
@fsm.events.push name: event, from: from, to: to
@on event, (args...) =>
@fsm[event](args...)
if to.match(/[^\w\_\-]+/)
throw new ConventionError("Component`s state name can contain only alphanum chars, dashes and underscores")
for state in from.concat([to])
@checkStateFlowCallback(state)
if typeof callback is 'object'
for type, cb of callback
@addStateFlowCallback(event, type, cb)
else
@addStateFlowCallback(event, 'after', callback)
checkStateFlowCallback: (state) ->
for type in ['enter', 'leave']
do (type) =>
cname = "#{type}#{UI.helpers.capitalize(state)}"
fname = "#{type}#{UI.helpers.capitalize(state.replace(/\-/g, '_'))}"
if typeof @[fname] is 'function' and (not @fsm.callbacks[cname])
@fsm.callbacks["on#{type}#{state}"] = @[fname]
addStateFlowCallback: (event, type, callback) ->
unless type in ['after', 'before']
throw new ConventionError("Wrong callback type (on#{type}#{event}): only after/before available by events mapping")
if typeof callback is 'string'
unless callback[0] is "_"
throw new ConventionError("#{@uid}.events[ '#{event}' ]: #{callback} must be private (_#{callback})")
callback = @[callback]
unless typeof callback is 'function'
throw new HandlerError("State event handler #{callback} isn't a function")
@fsm.callbacks["on#{type}#{event}"] = callback
delegateEvent: (event, callback) ->
if UI.debug()
UI.log " >> #{@uid}.delegateEvent(#{event}, #{callback})"
literal = typeof(callback) is "string"
if literal
unless callback.match(/^\_/)?
throw new ConventionError("#{@uid}.events[ '#{event}' ]: #{callback} must be private (_#{callback})")
callback = @[callback]
do (event, callback) =>
unless typeof(callback) is 'function'
throw new HandlerError("#{@uid}.event[ '#{event}' ]: handler is not a function")
[e, selectors...] = event.split(' ')
unless selectors.length
@on e, (args...) => callback.apply @, args
else
for selector in selectors.join(' ').split(',')
selector = @el if selector is "@"
if literal
@$(selector, @$el).on e, (e, args...) => callback.apply @, [e, $(e.currentTarget)].concat(args)
else
@$(selector, @$el).on e, (args...) => callback.apply @, args
###*
* враппер методов компонента
* нужен для хуков и прокидывания событий
* @method wrap
* @param {Sting} prop имя
* @param {Any} impl значение
###
wrap: (prop, impl) =>
@[prop] = if typeof impl is 'function'
if prop.match(/^\_/)?
@__private.push prop
@makeHookable(prop, impl)
else
impl
makeHookable: (name, fn) =>
(args...) =>
# cb = UI.helpers.once( J "#{@uid}.#{name}", =>
cb = UI.helpers.once( =>
result = fn.apply(@, args)
if UI.debug()
UI.log " >> #{@uid}.trigger(#{name} ... )",
elem: @$el,
ctx: @
args: args
result: result
@$el.triggerHandler name,
ctx: @
args: args
result: result
result
)
if @hook(name, args, cb)
cb()
###*
* уничтожение компонента TODO пока ждет
* @method destroy
###
destroy: =>
@$el.remove() #TODO почитать что делает jquery внутри этого метода
###*
* Обработка хука, если хоть один хук вернул false
* ждем ручного вызова обработчика внутри хука
* (это надо регулировать ручками - но такие ситуации не часто будут встечаться)
* @method hook
* @param {String} method имя метода
* @param {Array} args аргументы
* @param {Function} cb коллбек
###
hook: (method, args, cb) ->
exec = true
if @hooks[method]?
for hook in @hooks[method]
result = hook(@, args, cb)
if UI.debug()
UI.log
event: "UI.hook[#{@uid}.#{method}]"
elem: @$el
passed: [@, args, cb.toString()]
result: result
exec = exec && result
exec
###*
* Получение всех компоннетов с таким же uid, кроме текущего
###
neighbors: (neighbors = []) ->
for component in UI.mapping[@uid]
if component.cid isnt @cid
neighbors.push component
neighbors
$.fn.hook = (method, implementation) ->
this.each (i, el) ->
if component = $(el).data('b-component')
(component.hooks[method] ?= []).push implementation
$.fn.invoke = (method, args...) ->
if method.match(/^\_/)? and method not in UI.protected_methods
throw new ConventionError "Can't invoke private #{method} method."
this.each (i, el) ->
if component = $(el).data('b-component')
component[method](args...)
$.fn.component = (filter, args...) ->
# TODO documentation for params
components = []
this.each (i, el) ->
if component = $(el).data('b-component')
if ($.type(filter) is "function") and filter( [component].concat(args)...) ) or !filter?
components.push component
if filter?
components
else
components[0]
$.fn.origin_ready = $.fn.ready
$.fn.ready = (callback) ->
if this[0] is document
$.fn.origin_ready(callback)
else
if component = this.data('b-component')
callback 'init',
ctx: component
args: []
result: component.__initResult
else
this.on 'init', callback
$ ->
UI.log "You can toggle debug mode by typing 'UI.debug(true|false)'"
UI.createComponents()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment