Created
March 7, 2018 15:14
-
-
Save mihaisavezi/8535c1d076aed366225d413d0cbf87e6 to your computer and use it in GitHub Desktop.
Gallery app with Finite State Machines (xstate)
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
<div id="app"></div> |
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
const { Machine } = xstate; | |
const galleryMachine = Machine({ | |
initial: 'start', | |
states: { | |
start: { | |
on: { | |
SEARCH: 'loading' | |
} | |
}, | |
loading: { | |
onEntry: ['search'], | |
on: { | |
SEARCH_SUCCESS: { | |
gallery: { | |
actions: ['updateItems'] | |
} | |
}, | |
SEARCH_FAILURE: 'error', | |
CANCEL_SEARCH: 'gallery' | |
} | |
}, | |
error: { | |
on: { | |
SEARCH: 'loading' | |
} | |
}, | |
gallery: { | |
on: { | |
SEARCH: 'loading', | |
SELECT_PHOTO: 'photo' | |
} | |
}, | |
photo: { | |
onEntry: ['setPhoto'], | |
on: { | |
EXIT_PHOTO: 'gallery' | |
} | |
} | |
} | |
}); | |
class App extends React.Component { | |
constructor() { | |
super(); | |
this.state = { | |
gallery: galleryMachine.initialState, | |
query: '', | |
items: [] | |
}; | |
} | |
command(action, event) { | |
switch (action) { | |
case 'search': | |
// execute the search command | |
this.search(event.query); | |
break; | |
case 'updateItems': | |
if (event.items) { | |
// update the state with the found items | |
return { items: event.items }; | |
} | |
break; | |
case 'setPhoto': | |
if (event.item) { | |
return { photo: event.item } | |
} | |
default: | |
break; | |
} | |
} | |
transition(event) { | |
const currentGalleryState = this.state.gallery; | |
const nextGalleryState = | |
galleryMachine.transition(currentGalleryState, event.type); | |
if (nextGalleryState.actions) { | |
const nextState = nextGalleryState.actions | |
.reduce((state, action) => this.command(action, event) || state, undefined); | |
this.setState({ | |
gallery: nextGalleryState.value, | |
...nextState | |
}); | |
} | |
} | |
handleSubmit(e) { | |
e.persist(); | |
e.preventDefault(); | |
this.transition({ type: 'SEARCH', query: this.state.query }); | |
} | |
search(query) { | |
const encodedQuery = encodeURIComponent(query); | |
setTimeout(() => { | |
fetchJsonp( | |
`https://api.flickr.com/services/feeds/photos_public.gne?lang=en-us&format=json&tags=${encodedQuery}`, | |
{ jsonpCallback: 'jsoncallback' }) | |
.then(res => res.json()) | |
.then(data => { | |
this.transition({ type: 'SEARCH_SUCCESS', items: data.items }); | |
}) | |
.catch(error => { | |
this.transition({ type: 'SEARCH_FAILURE' }); | |
}); | |
}, 1000); | |
} | |
handleChangeQuery(value) { | |
this.setState({ query: value }) | |
} | |
renderForm(state) { | |
const searchText = { | |
loading: 'Searching...', | |
error: 'Try search again', | |
start: 'Search' | |
}[state] || 'Search'; | |
return ( | |
<form className="ui-form" onSubmit={e => this.handleSubmit(e)}> | |
<input | |
type="search" | |
className="ui-input" | |
value={this.state.query} | |
onChange={e => this.handleChangeQuery(e.target.value)} | |
placeholder="Search Flickr for photos..." | |
disabled={state === 'loading'} | |
/> | |
<div className="ui-buttons"> | |
<button | |
className="ui-button" | |
disabled={state === 'loading'}> | |
{searchText} | |
</button> | |
{state === 'loading' && | |
<button | |
className="ui-button" | |
type="button" | |
onClick={() => this.transition({ type: 'CANCEL_SEARCH' })}> | |
Cancel | |
</button> | |
} | |
</div> | |
</form> | |
); | |
} | |
renderGallery(state) { | |
return ( | |
<section className="ui-items" data-state={state}> | |
{state === 'error' | |
? <span className="ui-error">Uh oh, search failed.</span> | |
: this.state.items.map((item, i) => | |
<img | |
src={item.media.m} | |
className="ui-item" | |
style={{'--i': i}} | |
key={item.link} | |
onClick={() => this.transition({ | |
type: 'SELECT_PHOTO', item | |
})} | |
/> | |
) | |
} | |
</section> | |
); | |
} | |
renderPhoto(state) { | |
if (state !== 'photo') return; | |
return ( | |
<section | |
className="ui-photo-detail" | |
onClick={() => this.transition({ type: 'EXIT_PHOTO' })}> | |
<img src={this.state.photo.media.m} className="ui-photo"/> | |
</section> | |
) | |
} | |
render() { | |
const galleryState = this.state.gallery; | |
return ( | |
<div className="ui-app" data-state={galleryState}> | |
{this.renderForm(galleryState)} | |
{this.renderGallery(galleryState)} | |
{this.renderPhoto(galleryState)} | |
</div> | |
); | |
} | |
} | |
ReactDOM.render(<App />, document.querySelector('#app')); |
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
<script src="https://unpkg.com/[email protected]/umd/react.production.min.js"></script> | |
<script src="https://unpkg.com/[email protected]/umd/react-dom.production.min.js"></script> | |
<script src="https://unpkg.com/[email protected]/build/fetch-jsonp.js"></script> | |
<script src="https://unpkg.com/[email protected]/dist/xstate.js"></script> |
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
*, *:before, *:after { | |
position: relative; | |
box-sizing: border-box; | |
} | |
html, body { | |
height: 100%; | |
width: 100%; | |
padding: 0; | |
margin: 0; | |
} | |
body { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
background-color: #FAFAFA; | |
} | |
@mixin shadow($color: rgba(black, 0.1)) { | |
box-shadow: 0 .2rem 1rem $color; | |
} | |
* { | |
transition: all .3s cubic-bezier(.2, 0, .4, 1); | |
} | |
.ui-app { | |
display: flex; | |
flex-direction: column; | |
justify-content: flex-start; | |
width: 40rem; | |
max-width: 90vw; | |
height: calc(100vh - 2rem); | |
&[data-state="start"] { | |
justify-content: center; | |
} | |
&[data-state="loading"] { | |
.ui-item { | |
opacity: .5; | |
} | |
} | |
&[data-state="photo"] { | |
* { | |
opacity: 0.3; | |
} | |
.ui-photo-detail, .ui-photo-detail * { | |
opacity: 1; | |
} | |
.ui-items { | |
pointer-events: none; | |
} | |
} | |
&:after { | |
content: 'current state: ' attr(data-state); | |
position: absolute; | |
bottom: .5rem; | |
color: white; | |
background-color: rgba(black, 0.4); | |
font-size: 1rem; | |
padding: .5rem 1rem; | |
border-radius: 1rem; | |
left: 50%; | |
transform: translateX(-50%); | |
text-shadow: 0 0 .1rem black; | |
pointer-events: none; | |
} | |
} | |
.ui-form { | |
margin-bottom: 1rem; | |
} | |
.ui-input { | |
@include shadow(); | |
display: block; | |
-webkit-appearance: none; | |
appearance: none; | |
width: 100%; | |
border: none; | |
font-size: 2rem; | |
height: 3rem; | |
margin-bottom: 1rem; | |
padding: 0 1rem; | |
&::-webkit-input-placeholder { | |
color: #CDCDCD; | |
} | |
&:focus { | |
outline: none; | |
} | |
} | |
.ui-buttons { | |
text-align: center; | |
} | |
.ui-button { | |
@include shadow(); | |
display: inline-block; | |
-webkit-appearance: none; | |
appearance: none; | |
border: none; | |
background-color: #EB7452; | |
color: white; | |
height: 3rem; | |
padding: 0 3rem; | |
border-radius: 3rem; | |
margin: 0 1rem; | |
&[disabled] { | |
opacity: 0.5; | |
} | |
&[type="button"] { | |
background-color: #555; | |
} | |
} | |
.ui-items { | |
display: flex; | |
flex-wrap: wrap; | |
flex-direction: row; | |
justify-content: center; | |
flex-shrink: 1; | |
overflow-y: scroll; | |
margin: 0 -.25rem; | |
&:hover > .ui-item { | |
opacity: 0.7; | |
&:hover { | |
opacity: 1; | |
} | |
} | |
} | |
.ui-item { | |
display: block; | |
height: 10rem; | |
width: auto; | |
flex-shrink: 0; | |
flex-grow: 0; | |
margin: .25rem; | |
animation: item .5s calc(var(--i, 0) * .05s) cubic-bezier(.5, 0, .2, 1) both; | |
background-color: #EEE; | |
@keyframes item { | |
from { | |
transform: scale(0); | |
} | |
to { | |
transform: scale(1); | |
} | |
} | |
} | |
.ui-photo-detail { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
} | |
.ui-photo { | |
height: auto; | |
width: auto; | |
min-height: 50vh; | |
min-width: 50vw; | |
max-height: 100%; | |
max-width: 100%; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment