Last active
August 23, 2016 07:40
-
-
Save kerbyfc/8f602584e4bc75959782 to your computer and use it in GitHub Desktop.
UI component-oriented micro-framework with finite state machine integration
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
$ -> | |
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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