AMBITIOUS UX FOR
AMBITIOUS APPS
EMBERCONF 2015
Lauren Elizabeth Tan
@sugarpirate_ @poteto
DESIGN DEV
Lauren Elizabeth Tan
Designer & Front End Developer
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
BUT I’M NOT A DESIGNER
“Most people make the mistake of thinking
design is what it looks like. That’s not what we
think design is. It’s not just what it looks like
and feels like. Design is how it works.”
applyConcatenatedProperties()
giveDescriptorSuper()
beginPropertyChanges()
=
What is good design?
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
REACTIVE?
FLOW OF DATA
&
MAINTAINING RELATIONSHIPS
BETWEEN THAT DATA
var EmberObject =
CoreObject.extend(Observable);
FUNCTIONAL REACTIVE
PROGRAMMING?
FUNCTIONAL REACTIVE
PROGRAMMING?
FUNCTIONAL REACTIVE PROGRAMMING?
Immutability
Some side effects
EVENT STREAMS
Things that consist of discrete events
.asEventStream('click')
https://gist.github.com/staltz/868e7e9bc2a7b8c1f754
PROPERTIES
Things that change and have a current state
(100, 250)
(300, 200)
Array.prototype#map
Array.prototype#filter
Array.prototype#reduce
Array.prototype#concat
BACON.JS
FRP library
!==
(obviously)
Ember.observer
Ember.computed
Ember.Observable
Ember.Evented
Ember.on
THE OBSERVER PATTERN
Computed properties and observers
COMPUTED PROPERTIES
Transforms properties, and keeps relationships in sync
export default Ember.Object.extend({
fullName: computed('firstName', 'lastName', function() {
return `${get(this, 'firstName')} ${get(this, 'lastName')}`;
})
});
COMPUTED PROPERTY MACROS
Keeping things DRY
export default function(separator, dependentKeys) {
let computedFunc = computed(function() {
let values = dependentKeys.map((dependentKey) => {
return getWithDefault(this, dependentKey, '');
});
return values.join(separator);
});
return computedFunc.property.apply(computedFunc, dependentKeys);
};
DEMO
http://emberjs.jsbin.com/vubaga/12/edit?js,output
import joinWith from '...';
export default Ember.Object.extend({
fullName: joinWith(' ', [
'title',
'firstName',
'middleName',
'lastName',
'suffix'
])
});
get(this, 'fullName');
// Mr Harvey Reginald Specter Esq.
Ember.computed.{map,mapBy}
Ember.computed.{filter,filterBy}
Ember.computed.sort
Ember.computed.intersect
Ember.computed.setDiff
Ember.computed.uniq
Ember.computed.readTheAPIDocs
http://emberjs.com/api/#method_computed
OBSERVERS
Synchronously invoked when dependent
properties change
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
ƈhttp://youtu.be/OK34L4-qaDQ
Waterboarding at Guantanamo Bay
sounds super rad if you don't know
what either of those things are.
The person who would proof read
Hitler's speeches was a grammar
Nazi.
If your shirt isn't tucked into your
pants, then your pants are tucked
into your shirt.
ƈ+
/index /usersroute:user
model()
{ this.store.find('user') }
// returns Promise

GET "https://foo.com/v1/api/users"
/loading
/error
resolve()
reject()
Service
ƈ
Reddit API
Index Route User RouteLoading Route
Fetch top posts
Component
Fetch user records resolve()
Get random message
Display shower thought
SERVICE
Ember.Service.extend({ ... });
messages : Ember.A([]),
topPeriods : [ 'day', 'week', 'month', 'year', 'all' ],
topPeriod : 'day',
subreddit : 'showerthoughts',
getPostsBy(subreddit, period) {
let url = `//www.reddit.com/r/${subreddit}/top.json?sort=top&t=${period}`;
return new RSVP.Promise((resolve, reject) => {
getJSON(url)
.then((res) => {
let titles = res.data.children.mapBy('data.title');
resolve(titles);
}).catch(/* ... */);
});
}
_handleTopPeriodChange: observer('subreddit', 'topPeriod', function() {
let subreddit = get(this, 'subreddit');
let topPeriod = get(this, 'topPeriod');
run.once(this, () => {
this.getPostsBy(subreddit, topPeriod)
.then((posts) => {
set(this, 'messages', posts);
});
});
}).on('init'),
COMPONENT
export default Ember.Component.extend({
service : inject.service('shower-thoughts'),
randomMsg : computedSample('service.messages'),
loadingText : 'Loading',
classNames : [ 'loadingMessage' ]
});
export default function(dependentKey) {
return computed(`${dependentKey}.@each`, () => {
let items = getWithDefault(this, dependentKey, Ember.A([]));
let randomItem = items[Math.floor(Math.random() * items.get('length'))];
return randomItem || '';
}).volatile().readOnly();
}
DEMO
http://emberjs.jsbin.com/lulaki/35/edit?output
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
VISIBILITY OF SYSTEM STATUS
Jakob Nielsen — 10 Heuristics for User Interface Design
FLASH MESSAGES
Is it time for snacks yet?
Service
Routes Controllers
Message Component Message Component
SERVICE
Ember.get(this, 'flashes').success('Success!', 2000);
Ember.get(this, 'flashes').warning('...');
Ember.get(this, 'flashes').info('...');
Ember.get(this, 'flashes').danger('...');
Ember.get(this, 'flashes').addMessage('Custom message', 'myCustomType', 3000)
Ember.get(this, 'flashes').clearMessages();
SERVICE: PROPS
queue : Ember.A([]),
isEmpty : computed.equal('queue.length', 0),
defaultTimeout : 2000
SERVICE: PUBLIC API
success(message, timeout=get(this, 'defaultTimeout')) {
return this._addToQueue(message, 'success', timeout);
},
info(/* ... */) {
return ...;
},
warning(/* ... */) {
return ...;
},
danger(/* ... */) {
return ...;
},
addMessage(message, type='default', timeout=get(this, 'defaultTimeout')) {
return this._addToQueue(message, type, timeout);
}
SERVICE: PUBLIC API
clearMessages() {
let flashes = get(this, 'queue');
flashes.clear();
}
SERVICE: PRIVATE API
_addToQueue(message, type, timeout) {
let flashes = get(this, 'queue');
let flash = this._newFlashMessage(this, message, type, timeout);
flashes.pushObject(flash);
}
SERVICE: PRIVATE API
_newFlashMessage(service, message, type='info', timeout=get(this, 'defaultTimeout')) {
Ember.assert('Must pass a valid flash service', service);
Ember.assert('Must pass a valid flash message', message);
return FlashMessage.create({
type : type,
message : message,
timeout : timeout,
flashService : service
});
}
FLASH MESSAGE
FLASH MESSAGE: PROPS
isSuccess : computed.equal('type', 'success'),
isInfo : computed.equal('type', 'info'),
isWarning : computed.equal('type', 'warning'),
isDanger : computed.equal('type', 'danger'),
defaultTimeout : computed.alias('flashService.defaultTimeout'),
queue : computed.alias('flashService.queue'),
timer : null
FLASH MESSAGE: LIFECYCLE HOOK
_destroyLater() {
let defaultTimeout = get(this, 'defaultTimeout');
let timeout = getWithDefault(this, 'timeout', defaultTimeout);
let destroyTimer = run.later(this, '_destroyMessage', timeout);
set(this, 'timer', destroyTimer);
}.on('init')
FLASH MESSAGE: PRIVATE API
_destroyMessage() {
let queue = get(this, 'queue');
if (queue) {
queue.removeObject(this);
}
this.destroy();
}
FLASH MESSAGE: PUBLIC API & OVERRIDE
destroyMessage() {
this._destroyMessage();
},
willDestroy() {
this._super();
let timer = get(this, 'timer');
if (timer) {
run.cancel(timer);
set(this, 'timer', null);
}
}
Lj DEPENDENCY INJECTION
import FlashMessagesService from '...';
export function initialize(_container, application) {
application.register('service:flash-messages', FlashMessagesService, { singleton: true });
application.inject('controller', 'flashes', 'service:flash-messages');
application.inject('route', 'flashes', 'service:flash-messages');
}
export default {
name: 'flash-messages-service',
initialize: initialize
};
COMPONENT
COMPONENT: TEMPLATE
{{#if template}}
{{yield}}
{{else}}
{{flash.message}}
{{/if}}
COMPONENT: PUBLIC API
export default Ember.Component.extend({
classNames: [ 'alert', 'flashMessage' ],
classNameBindings: [ 'alertType' ],
alertType: computed('flash.type', function() {
let flashType = get(this, 'flash.type');
return `alert-${flashType}`;
}),
click() {
let flash = get(this, 'flash');
flash.destroyMessage();
}
});
USAGE
{{#each flashes.queue as |flash|}}
{{flash-message flash=flash}}
{{/each}}
{{#each flashes.queue as |flash|}}
{{#flash-message flash=flash}}
<h6>{{flash.type}}</h6>
<p>{{flash.message}}</p>
{{/flash-message}}
{{/each}}
DEMO
http://emberjs.jsbin.com/ranewo/46/edit?js,output
$ ember install:addon ember-cli-flash
$ npm install --save-dev ember-cli-flash
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
DRAG AND DROP
Skip
Draggable Dropzone
Draggable Item
Draggable Item
Draggable Item
Controller
sendAction()
Route
COMPONENT/VIEW EVENTS
http://emberjs.com/api/classes/Ember.View.html#toc_event-names
https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/
Drag_operations#draggableattribute
https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer#getData.28.29
DROPZONE
export default Ember.Component.extend({
classNames : [ 'draggableDropzone' ],
classNameBindings : [ 'dragClass' ],
dragClass : 'deactivated',
dragLeave(event) {
event.preventDefault();
set(this, 'dragClass', 'deactivated');
},
dragOver(event) {
event.preventDefault();
set(this, 'dragClass', 'activated');
},
drop(event) {
let data = event.dataTransfer.getData('text/data');
this.sendAction('dropped', data);
set(this, 'dragClass', 'deactivated');
}
});
DRAGGABLE ITEM
export default Ember.Component.extend({
classNames : [ 'draggableItem' ],
attributeBindings : [ 'draggable' ],
draggable : 'true',
dragStart(event) {
return event.dataTransfer.setData('text/data', get(this, 'content'));
}
});
{{ yield }}
<div class="selectedUsers">
{{#draggable-dropzone dropped="addUser"}}
<ul class="selected-users-list">
{{#each selectedUsers as |user|}}
<li>{{user.fullName}}</li>
{{/each}}
</ul>
{{/draggable-dropzone}}
</div>
<div class="availableUsers">
{{#each users as |user|}}
{{#draggable-item content=user.id}}
<span>{{user.fullName}}</span>
{{/draggable-item}}
{{/each}}
</div>
actions: {
addUser(userId) {
let selectedUsers = get(this, 'selectedUsers');
let user = get(this, 'model').findBy('id', parseInt(userId));
if (!selectedUsers.contains(user)) {
return selectedUsers.pushObject(user);
}
}
}
DEMO
http://emberjs.jsbin.com/denep/18/edit?js,output
TL;DR
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
DESIGN IS HOW IT WORKS
GOOD DESIGN IS REACTIVE
GOOD DESIGN IS PLAYFUL
GOOD DESIGN IS INFORMATIVE
GOOD DESIGN IS INTUITIVE
AMBITIOUS UX FOR
AMBITIOUS APPS
EMBERCONF 2015
Lauren Elizabeth Tan
@sugarpirate_ @poteto
Makes ambitious UX easy (and fun!)
Design is how it works
Follow @sugarpirate_
bit.ly/sugarpirate
Thank you!
Lauren Elizabeth Tan
@sugarpirate_ @poteto

EmberConf 2015 – Ambitious UX for Ambitious Apps