From 9103918abd04e38e5761199469e61578e85aa5af Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 20 Mar 2015 20:00:06 -0600 Subject: [PATCH] add tree config with tests --- playground/basic-example/main.html | 47 +++- playground/basic-example/main.js | 2 - src/components.js | 4 +- src/components/aside/aside.js | 14 +- .../aside/behaviors/direction/direction.js | 48 ++-- src/config.js | 210 ++++++++++++++---- src/config_spec.js | 137 ++++++++++++ src/core/config/config.js | 54 ----- src/core/config/config_spec.js | 4 - src/core/gestures/gesture.js | 2 + src/util.js | 37 ++- src/util_spec.js | 52 +++++ src/views/view.js | 38 ++-- 13 files changed, 482 insertions(+), 167 deletions(-) create mode 100644 src/config_spec.js delete mode 100644 src/core/config/config.js delete mode 100644 src/core/config/config_spec.js create mode 100644 src/util_spec.js diff --git a/playground/basic-example/main.html b/playground/basic-example/main.html index be67d12c9b..963900f1b9 100644 --- a/playground/basic-example/main.html +++ b/playground/basic-example/main.html @@ -1,4 +1,31 @@ + + Hello ... +

...

+

...

+

...

+

...

+

...

+ Side menu! +
+ + Hello ... +

...

+

...

+

...

+

...

+

...

+ Side menu! +
+ + Hello ... +

...

+

...

+

...

+

...

+

...

+ Side menu! +
Hello ...

...

@@ -13,20 +40,20 @@ - - - - - + + + - + + +
diff --git a/playground/basic-example/main.js b/playground/basic-example/main.js index 3e42498e77..ef6d863fe5 100644 --- a/playground/basic-example/main.js +++ b/playground/basic-example/main.js @@ -2,8 +2,6 @@ import {bootstrap} from 'angular2/core'; import {Component, Template} from 'angular2/angular2'; import {Aside, AsideParent} from 'ionic/components'; -// import 'ionic/components/tabbar/mixins/android/android-tabbar'; - @Component({ selector: '[playground-main]' }) @Template({ url: 'main.html', diff --git a/src/components.js b/src/components.js index f09e63eb8f..1449e0f6d7 100644 --- a/src/components.js +++ b/src/components.js @@ -1,2 +1,2 @@ -export * from './components/sidemenu/sidemenu'; -import './components/sidemenu/behaviors/direction/direction'; +export * from './components/aside/aside' +import './components/aside/behaviors/direction/direction' diff --git a/src/components/aside/aside.js b/src/components/aside/aside.js index 9536b77e0b..cedaa86776 100644 --- a/src/components/aside/aside.js +++ b/src/components/aside/aside.js @@ -4,10 +4,10 @@ import {IonConfig} from '../../config'; import {DragGesture} from '../../core/gestures/drag-gesture'; import * as util from '../../util'; -export var asideConfig = new IonConfig('sidemenu') +export var asideConfig = new IonConfig('aside'); // TODO defaults or bindings? -asideConfig.defaults({ +asideConfig.set({ side: 'left', dragThreshold: 50 }); @@ -15,7 +15,7 @@ asideConfig.defaults({ @Component({ selector: 'ion-aside', bind: { - edge: 'side', + side: 'side', dragThreshold: 'dragThreshold' }, }) @@ -38,7 +38,8 @@ export class Aside extends Ion { }); this.dragMethods = { getMenuStart() { return 0; }, - getEventPos(ev) { return ev.center.x; } + getEventPos(ev) { return ev.center.x; }, + canStart() { return true; } }; this.gesture.listen(); @@ -46,7 +47,10 @@ export class Aside extends Ion { this.setChanging(false); }) - asideConfig(this); + // TODO: remove this. setTimeout has to be done so the bindings can be applied + setTimeout(() => { + asideConfig.invoke(this); + }); } onDragStart(ev) { if (!this.dragMethods.canStart(ev)) { diff --git a/src/components/aside/behaviors/direction/direction.js b/src/components/aside/behaviors/direction/direction.js index c157da2ecb..05aeb9d4b6 100644 --- a/src/components/aside/behaviors/direction/direction.js +++ b/src/components/aside/behaviors/direction/direction.js @@ -1,14 +1,18 @@ -import {asideConfig} from '../../sidemenu'; +import {asideConfig} from '../../aside'; import * as util from '../../../../util'; -asideConfig.when(instance => instance.side === 'bottom') - .mixin(function() { +asideConfig + .behavior(function() { + if (this.side !== 'bottom') return; this.gesture.options({ direction: Hammer.DIRECTION_VERTICAL }); this.domElement.classList.add('bottom'); util.extend(this.dragMethods, { + canStart: ev => { + return this.isOpen || ev.center.y > window.innerHeight - this.dragThreshold; + }, getMenuStart: (drag, ev) => { return this.isOpen ? -drag.height : 0; }, @@ -27,13 +31,14 @@ asideConfig.when(instance => instance.side === 'bottom') return ev.center.y; } }); - - }); - -asideConfig.when(instance => instance.side === 'left') - .mixin(function() { + }) + .behavior(function() { + if (this.side !== 'left') return; this.domElement.classList.add('left'); + this.gesture.options({ + direction: Hammer.DIRECTION_HORIZONTAL + }); util.extend(this.dragMethods, { canStart: (ev) => { return this.isOpen || ev.center.x < this.dragThreshold; @@ -51,21 +56,26 @@ asideConfig.when(instance => instance.side === 'left') this.setOpen(drag.pos > drag.width / 2); this.domElement.style.transform = ''; } - }); + }) - }); - -asideConfig.when(instance => instance.side === 'right') - .mixin(function() { + }) + .behavior(function() { + if (this.side !== 'right') return; this.domElement.classList.add('right'); + this.gesture.options({ + direction: Hammer.DIRECTION_HORIZONTAL + }); util.extend(this.dragMethods, { + canStart: ev => { + return this.isOpen || ev.center.x > window.innerWidth - this.dragThreshold; + }, getMenuStart: (drag, ev) => { return this.isOpen ? -drag.width : 0; }, onDrag: (drag, ev) => { drag.pos = util.clamp( - 0, -drag.menuStart + drag.pointerStart - ev.center.x, drag.height + 0, -drag.menuStart + drag.pointerStart - ev.center.x, drag.width ); this.domElement.style.transform = 'translate3d(' + (drag.width - drag.pos) + 'px,0,0)'; @@ -76,13 +86,15 @@ asideConfig.when(instance => instance.side === 'right') } }); - }); - -asideConfig.when(instance => instance.side === 'top') - .mixin(function() { + }) + .behavior(function() { + if (this.side !== 'top') return; this.domElement.classList.add('top'); util.extend(this.dragMethods, { + canStart: ev => { + return this.isOpen || ev.center.y < this.dragThreshold * 5; + }, getMenuStart: (drag, ev) => { return this.isOpen ? drag.height : 0; }, diff --git a/src/config.js b/src/config.js index 9afc21d63a..565c682ea3 100644 --- a/src/config.js +++ b/src/config.js @@ -1,55 +1,177 @@ import * as Platform from './platform'; import * as util from './util'; -export function IonConfig(name) { +/* + config + .set({ side: 'left' }) + .set('threshold', 50) + .platform('ios') + .set('side', 'top') + .unset('threshold') + .media('lg') + .set('side', 'right') - // TODO automatically add platform class - // TODO do bindings/defaults have to be written twice? - // TODO maybe add config to IonicComponent annotation - // TODO map options to config - function Config(instance, element) { - var platformName = Platform.getPlatform(); - (element.domElement || element).classList.add(`${name}-${platformName}`); +config.platform('ios') + .behavior(function() { + do something + }) + .defaults({ + side: 'right' + }) + +config.platform('ios').media('tablet') + .defaults({ + side: 'bottom' + }); +*/ - util.defaults(instance, Config._defaults || {}); - var conditions = Config._conditions; - for (var i = 0, ii = conditions.length; i < ii; i++) { - if (conditions[i]._callback(instance)) { - for (var j = 0, jj = conditions[i]._mixins.length; j < jj; j++) { - conditions[i]._mixins[j].call(instance); - } - } + +/* + User wants to remove the default behavior for sidemenu, but that's stuck under `.platform('ios').` + +config.platform('ios').media('tablet') === config.media('tablet').platform('ios') +*/ +var QUERIES = { + sm: true, + md: true, + lg: true +}; +var PLATFORMS = { + ios: true, + android: true +}; + +function isPlatform(key = '') { + return key.toLowerCase() in PLATFORMS; +} +function isMedia(key = '') { + return key.toLowerCase() in QUERIES; +} +class ConfigCase { + constructor({ root, parent, path }) { + this._root = root; + this._parent = parent; + this._path = path || []; + this._values = {}; + this.behaviors = []; + } + platform(key = '') { + if (isPlatform(key)) return this._root._addCase(key, this); + return this; + } + media(key = '') { + if (isMedia(key)) return this._root._addCase(key, this); + return this; + } + when(condition = '') { + if (isPlatform(condition) || isMedia(condition)) { + return this._root._addCase(condition, this); } - } - - Config._conditions = []; - Config.defaults = function(defaults) { - Config._defaults = defaults; - }; - Config.when = function when(callback) { - var condition = new ConfigCondition(callback); - Config._conditions.push(condition); - return condition; - }; - Config.platform = function platform(name) { - return Config.when(() => Platform.getPlatform() === name); - }; - - return Config; -} - -class ConfigCondition { - constructor(callback, mixins = [], template = '') { - this._callback = callback; - this._mixins = mixins; - this._template = template; - } - mixin(mixinFn) { - this._mixins.push(mixinFn); return this; } - template(url) { - this._template = url; + behavior(fn) { + this.behaviors.push(fn); return this; } + set(a, b) { + if (util.isString(a)) { + this._values[a] = b; + } else { + util.extend(this._values, a || {}); + } + return this; + } + unset(key) { + delete this._values[key]; + } + get(key) { + return util.isDefined(this._values[key]) ? + this._values[key] : + (this._parent ? this._parent.get(key) : undefined); + } +} + +export class IonConfig extends ConfigCase { + constructor() { + this._root = this; + this._cases = {}; + super({ + root: this, + parent: null, + path: '' + }); + } + invoke(instance) { + return invokeConfig(this, instance); + } + _addCase(key, baseCase) { + var path = baseCase._path.slice(); + path.push(key); + + // Remove empties & duplicates + path = path + .filter((value, index) => { + return value && path.indexOf(value) === index; + }) + .sort(); + + if (path.join(' ') === baseCase._path.join(' ')) { + return baseCase; + } + return this._createCase(path); + } + _createCase(path) { + if (!path.length) return this; + var pathStr = path.join(' '); + var configCase = this._cases[pathStr]; + if (!configCase) { + var parentPath = path.slice(0, path.length - 1); + configCase = this._cases[pathStr] = new ConfigCase({ + root: this, + parent: this._createCase(parentPath), + path: path + }); + } + return configCase; + } +} + +export function invokeConfig(config, object, opts = {}) { + util.defaults(opts, { media: 'lg', platform: 'ios' }); + var { platform, media } = opts; + + var passedCases = [config].concat( + Object.keys(config._cases) + .map(name => config._cases[name]) + .filter(configCasePasses) + .sort(function(a,b) { + return a._path.length < b._path.length ? -1 : 1; + }) + ); + + // Extend the given object with the values of all the passed cases, starting with the + // most specific. + var defaults = [object]; + var behaviors = []; + for (let i = 0, ii = passedCases.length; i < ii; i++) { + defaults.push(passedCases[i]._values); + // Avoid allocating a new array for each passed case's array of behaviors + behaviors.push.apply(behaviors, passedCases[i].behaviors); + } + + util.defaults.apply(null, defaults); + + for (let i = 0, ii = behaviors.length; i < ii; i++) { + behaviors[i].call(object, object); + } + + function configCasePasses(configCase) { + var path = configCase._path; + var key; + for (let i = 0, ii = path.length; i < ii; i++) { + if (!(media === path[i] || platform === path[i])) return false; + } + return true; + } + } diff --git a/src/config_spec.js b/src/config_spec.js new file mode 100644 index 0000000000..3292cafc65 --- /dev/null +++ b/src/config_spec.js @@ -0,0 +1,137 @@ +import {IonConfig} from './config'; + +export function main() { + var rootConfig; + beforeEach(() => { + rootConfig = new IonConfig(); + }); + + it('should create a config one level down', () => { + var sub = rootConfig.platform('ios'); + expect(sub._parent).toBe(rootConfig); + expect(sub._path).toEqual(['ios']); + expect(rootConfig._cases.ios).toBe(sub); + }); + + it('should create a config two levels down', () => { + var sub1 = rootConfig.platform('ios'); + var sub2 = sub1.media('lg'); + expect(sub2._parent).toBe(sub1); + expect(sub1._parent).toBe(rootConfig); + expect(rootConfig._cases['ios lg']).toBe(sub2); + expect(rootConfig._cases.ios).toBe(sub1); + }); + + it('set should be chainable', () => { + expect(rootConfig.set()).toBe(rootConfig); + }); + + it('should set values on the root', () => { + rootConfig.set({ + letter: 'a' + }); + expect(rootConfig.get('letter')).toBe('a'); + }); + + it('should always return the same object for the same key', () => { + expect(rootConfig.platform('android')).toBe(rootConfig.platform('android')); + expect(rootConfig.platform('ios')).toBe(rootConfig.platform('ios')); + expect(rootConfig.media('lg')).toBe(rootConfig.media('lg')); + }); + + it('should return the same object when nesting in different order', () => { + var sub1 = rootConfig.platform('ios').media('sm'); + var sub2 = rootConfig.media('sm').platform('ios'); + expect(sub1).toBe(sub2); + }); + + it('should return the same object when nesting in different order for huge queries', () => { + var sub1 = rootConfig.platform('ios').media('sm').platform('android').media('lg'); + var sub2 = rootConfig.media('sm').media('lg').platform('android').platform('ios'); + expect(sub1).toBe(sub2); + }); + + it('should set values one level down and be chainable', () => { + rootConfig.set({ letter: 'a' }); + var sub1 = rootConfig.platform('ios'); + expect(sub1.get('letter')).toBe('a'); + expect( sub1.set({ letter: 'b' }) ).toBe(sub1); + expect(sub1.get('letter')).toBe('b'); + }); + + it('should set values two levels down and be chainable', () => { + rootConfig.set({ letter: 'a' }); + var sub1 = rootConfig.platform('ios'); + sub1.set({ letter: 'b' }); + var sub2 = sub1.media('lg'); + expect(sub2.get('letter')).toBe('b'); + expect( sub2.set({ letter: 'c' }) ).toBe(sub2); + expect(sub2.get('letter')).toBe('c'); + }); + + it('should use parent\'s value if its later set to undefined', () => { + rootConfig.set({ letter: 'a' }); + var sub1 = rootConfig.platform('ios'); + sub1.set({ letter: 'b' }); + expect(sub1.get('letter')).toBe('b'); + sub1.unset('letter'); + expect(sub1.get('letter')).toBe('a'); + }); + + it('when() as alias for media()', () => { + expect(rootConfig.when('lg')).toBe(rootConfig.media('lg')); + expect(rootConfig.when('bad')).toBe(rootConfig); + expect(rootConfig.when('lg')).not.toBe(rootConfig.when('ios')); + }); + + it('when() as alias for platform()', () => { + expect(rootConfig.platform('ios')).toBe(rootConfig.when('ios')); + expect(rootConfig.when('bad')).toBe(rootConfig); + }); + + describe('invokeConfig', function() { + + it('should invoke defaults', () => { + var obj = {}; + rootConfig.set('foo', 'bar'); + rootConfig.invoke(obj); + }); + + it('should invoke defaults in nested whens', () => { + var obj = {}; + rootConfig.set({ a: 'root', b: 'root' }); + rootConfig.when('ios').set({b: 'ios', c: 'ios'}); + rootConfig.when('ios').when('lg').set({ c: 'ios-lg', d: 'ios-lg' }); + + rootConfig.invoke(obj); + expect(obj).toEqual({ + a: 'root', + b: 'ios', + c: 'ios-lg', + d: 'ios-lg' + }); + }); + + it('should run behaviors', () => { + var obj = {}; + rootConfig.behavior(instance => { + instance.foo = 'bar'; + }); + rootConfig.invoke(obj); + expect(obj.foo).toBe('bar'); + }); + + it('should invoke behaviors in nested whens', () => { + var obj = {}; + rootConfig.when('ios') + .behavior(o => o.ios = true) + .when('lg') + .behavior(o => o.lg = true) + rootConfig.invoke(obj); + expect(obj).toEqual({ + ios: true, + lg: true + }); + }); + }); +} diff --git a/src/core/config/config.js b/src/core/config/config.js deleted file mode 100644 index 47aedea274..0000000000 --- a/src/core/config/config.js +++ /dev/null @@ -1,54 +0,0 @@ -import * as Platform from '../../platform'; -import * as util from '../../util'; - -export function IonConfig() { - - // TODO automatically add platform class - // TODO do bindings/defaults have to be written twice? - // TODO maybe add config to IonicComponent annotation - // TODO map options to config - function Config(instance, element) { - (element.domElement || element).classList.add('platform-' + Platform.getPlatform()); - - util.defaults(instance, Config._defaults || {}); - var conditions = Config._conditions; - for (var i = 0, ii = conditions.length; i < ii; i++) { - if (conditions[i]._callback(instance)) { - for (var j = 0, jj = conditions[i]._mixins.length; j < jj; j++) { - conditions[i]._mixins[j].call(instance); - } - } - } - } - - Config._conditions = []; - Config.defaults = function(defaults) { - Config._defaults = defaults; - }; - Config.when = function when(callback) { - var condition = new ConfigCondition(callback); - Config._conditions.push(condition); - return condition; - }; - Config.platform = function platform(name) { - return Config.when(() => Platform.getPlatform() === name); - }; - - return Config; -} - -class ConfigCondition { - constructor(callback, mixins = [], template = '') { - this._callback = callback; - this._mixins = mixins; - this._template = template; - } - mixin(mixinFn) { - this._mixins.push(mixinFn); - return this; - } - template(url) { - this._template = url; - return this; - } -} diff --git a/src/core/config/config_spec.js b/src/core/config/config_spec.js deleted file mode 100644 index d5a4c2c18d..0000000000 --- a/src/core/config/config_spec.js +++ /dev/null @@ -1,4 +0,0 @@ -import {Config} from './config'; - -export function main() { -} diff --git a/src/core/gestures/gesture.js b/src/core/gestures/gesture.js index 2aa6e6e4cc..91215cbf39 100644 --- a/src/core/gestures/gesture.js +++ b/src/core/gestures/gesture.js @@ -2,6 +2,8 @@ import * as util from '../../util'; export class Gesture { constructor(element, opts = {}) { + util.defaults(opts, { + }); this.element = element; this._options = opts; } diff --git a/src/util.js b/src/util.js index 82ff937dea..6ad717331d 100644 --- a/src/util.js +++ b/src/util.js @@ -16,11 +16,38 @@ export function clamp(min, n, max) { return Math.max(min, Math.min(n, max)); } -export function defaults(obj, src) { - for (var key in src) { - if (src.hasOwnProperty(key) && !obj.hasOwnProperty(key)) { - obj[key] = src[key]; +export function defaults(dest) { + let extendObj = {}; + for (let i = arguments.length - 1; i >= 1; i--) { + let source = arguments[i] || {}; + for (let key in source) { + if (!dest.hasOwnProperty(key) && !extendObj.hasOwnProperty(key)) { + extendObj[key] = source[key]; + } } } - return obj; + for (let key in extendObj) { + dest[key] = extendObj[key]; + } + return dest; } + +export function isString(val) { + return typeof val === 'string'; +} + +export function isFunction(val) { + return typeof val === 'function'; +} + +export function isDefined(val) { + return typeof val !== 'undefined'; +} + +export var array = { + unique(array) { + return array.filter(function(value, index) { + return array.indexOf(value) === index; + }); + } +}; diff --git a/src/util_spec.js b/src/util_spec.js new file mode 100644 index 0000000000..f3e3b57026 --- /dev/null +++ b/src/util_spec.js @@ -0,0 +1,52 @@ +import * as util from './util'; + +export function main() { + describe('extend', function() { + + it('should extend simple', () => { + var obj = { a: '0', c: '0' }; + expect( util.extend(obj, { a: '1', b: '2' }) ).toBe(obj); + expect(obj).toEqual({ a: '1', b: '2', c: '0' }); + }); + + it('should extend complex', () => { + expect(util.extend( + { a: '0', b: '0' }, + { b: '1', c: '1' }, + { c: '2', d: '2' } + )).toEqual({ + a: '0', + b: '1', + c: '2', + d: '2' + }); + }); + + }); + + describe('defaults', function() { + + it('should simple defaults', () => { + var obj = { a: '1' }; + expect(util.defaults(obj, { a: '2', b: '2' })).toBe(obj); + expect(obj).toEqual({ + a: '1', b: '2' + }); + }); + + it('should complex defaults', () => { + expect(util.defaults( + { a: '0', b: '0' }, + { b: '1', c: '1', e: '1' }, + { c: '2', d: '2' } + )).toEqual({ + a: '0', + b: '0', + c: '2', + d: '2', + e: '1' + }); + }); + + }); +} diff --git a/src/views/view.js b/src/views/view.js index 208e652c04..2dc8dc5bd0 100644 --- a/src/views/view.js +++ b/src/views/view.js @@ -1,33 +1,25 @@ import * as util from '../util'; import {ViewHistory} from './view-history'; +// import {Children} from 'angular2/angular2'; -export class View extends ViewSwitcher { - constructor(el) { +export class View { + constructor( + // @Children() views: View + ) { super(el); - - // A linear history of views that this switcher has gone - // through. + this.children = []; this.history = new ViewHistory(); - this.currentChild = null; } - setChild(view, options = {}) { - var direction = options.direction || 'forward'; - var viewIndex = this.history.indexOf(view); - - if (viewIndex !== -1) { - direction = 'back'; - this.history.popTo(viewIndex); - } else { - this.history.push(view); - } - - if (this.currentView) { - this.element.removeChild(this.currentView.element); - } - this.element.appendChild(view.element); - this.currentView = view.element; + setSelected(isSelected) { + this.isSelected = isSelected; } + selectChild(child) { + if (this.selectedChild) { + this.selectedChild.setSelected(false); + } + child.setSelected(true); + this.selectedChild = child; + } } -