diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dec708e1f5..9f4f0eecfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,6 +205,17 @@ example.exampleMessage = 'hello world'; **Note:** Be careful of choosing your attribute names, never overwrite existing standard attributes without good reason! +#### `batchProps(props)` + +A fast and simpler way to update multiple props in one go. +Especially useful for integrations and to prevent multiple or delayed re-renders. + +#### `shouldUpdateCallback(newValue, oldValue)` + +`shouldUpdateCallback()` is invoked upon `attributeChangedCallback()` or Property `setter()` invocation to determine if rendering is necessary when new props are being received - it returns `true` if re-rendering is desireable, else `false`. + +**Important:** This does only a shallow comparison, if you need to deal with more complex data, like objects or arrays either stick to immutable data structures or override this method to implement your own test. + #### `willRenderCallback(initial)` Invoked before the custom element's [flattened DOM](#flattened-dom) will be rendered. @@ -221,6 +232,44 @@ Invoked when the custom element is disconnected from the document's DOM. The render loop makes sure that upon each [`attributeChangedCallback()`](#attributechangedcallbackname-oldvalue-newvalue) invocation or any observed [property `setter()`](#property-setter) invocation that the flattened DOM is recomputed and that [`willRenderCallback()`](#willrendercallbackinitial) and [`didRenderCallback()`](#didrendercallbackinitial) lifecycle hooks are called respectively. +## Integration + +The goal is that custom elements can be shared across frameworks and libraries like Angular, React, Vue, you name it. To ease this process we provide generic wrapper functions. + +### `withReact()` + +To turn any custom element into a working React Component, you just need to follow these steps: + +1. `import` React +2. `import` withReact +3. `import` any web components you need +4. wrap all your needed web components + - and may pass optional options for type of component or event init options +5. use them like regular React components in your app + + **Note:** events work similar to React's standard events, but each web components could trigger custom events like `axa-click` - camelcased and `on`-prefixed in React such as `onAxaClick={yourEventHandler}`. Make sure to check them out at the web-components documentation itself! + +```js +// import your dependencies - 1, 2, and 3 +import React from 'react'; +import withReact from '@axa-ch/patterns-library/src/js/with-react'; +import AXAButton from '@axa-ch/patterns-library/dist/components/m-button'; + +// 4. wrap your needed web components +// and optionally pass options +const AXAButtonReact = withReact(AXAButton, { + pure: true, + // event init options are also supported + passive: false, +}); + +// 5. use them in your app like regular React components +// note the custom event axa-click - camelcased and on-prefixed in React +const MyApp = ({ color, onClick }) => ( + Hello World +); +``` + # How do we release a new version Please run `npm run release` and follow the steps in the wizard. diff --git a/package.json b/package.json index 98e391aabe..e635a3cd30 100644 --- a/package.json +++ b/package.json @@ -139,5 +139,8 @@ "nanohtml": "^1.2.2", "react": "^16.2.0", "react-dom": "^16.2.0" + }, + "peerDependencies": { + "react": ">=0.14.0" } } diff --git a/src/components/m-button/_template.js b/src/components/m-button/_template.js index 27419811e8..5ffcc35b80 100644 --- a/src/components/m-button/_template.js +++ b/src/components/m-button/_template.js @@ -13,7 +13,7 @@ export default function ({ gpu, arrow, }, childrenFragment) { - const buttonClasses = classnames('m-button', classes, { + const buttonClasses = classnames('m-button', 'js-button', classes, { [`m-button--${color}`]: color, [`m-button--${size}`]: size, 'm-button--ghost': ghost, diff --git a/src/components/m-button/index.js b/src/components/m-button/index.js index 789740858e..c1a0c3d646 100644 --- a/src/components/m-button/index.js +++ b/src/components/m-button/index.js @@ -2,6 +2,7 @@ import styles from './index.scss'; import template from './_template'; import BaseComponentGlobal from '../../js/abstract/base-component-global'; import wcdomready from '../../js/wcdomready'; +import Button from './js/button'; class AXAButton extends BaseComponentGlobal { static get observedAttributes() { return ['arrow', 'classes', 'color', 'ghost', 'motion', 'size', 'tag', 'url']; } @@ -9,6 +10,21 @@ class AXAButton extends BaseComponentGlobal { constructor() { super(styles, template); } + + didRenderCallback() { + if (this.button) { + this.button.destroy(); + } + + this.button = new Button(this); + } + + disconnectedCallback() { + if (this.button) { + this.button.destroy(); + delete this.button; + } + } } wcdomready(() => { diff --git a/src/components/m-button/js/button.js b/src/components/m-button/js/button.js new file mode 100644 index 0000000000..3979c80749 --- /dev/null +++ b/src/components/m-button/js/button.js @@ -0,0 +1,78 @@ +import on from '../../../js/on'; +import fire from '../../../js/fire'; + +/** + * @fires Button#axa-click + */ +class Button { + static DEFAULTS = { + button: '.js-button', + }; + + constructor(wcNode, options = {}) { + this.wcNode = wcNode; + + this.options = { + ...Button.DEFAULTS, + ...options, + }; + + this.handleClick = this.handleClick.bind(this); + + this.init(); + } + + init() { + this.button = this.wcNode.querySelector(this.options.button); + + this.on(); + } + + on() { + this.off(); + + this.unClick = on(this.button, 'click', this.handleClick, { + passive: false, + }); + } + + off() { + if (this.unClick) { + this.unClick(); + } + } + + handleClick(event) { + /** + * axa-click event. + * + * @event Button#axa-click + * @type {null} + */ + const cancelled = fire(this.wcNode, 'axa-click', null, { bubbles: true, cancelable: true, composed: true }); + + if (!cancelled) { + event.preventDefault(); + } + } + + destroy() { + this.off(); + + if (this.button) { + delete this.button; + } + + if (this.wcNode) { + delete this.wcNode; + } + + if (this.options) { + delete this.options; + } + + delete this.handleClick; + } +} + +export default Button; diff --git a/src/components/m-footer-links/_template.js b/src/components/m-footer-links/_template.js index ad122c4c74..26a506b174 100644 --- a/src/components/m-footer-links/_template.js +++ b/src/components/m-footer-links/_template.js @@ -8,9 +8,9 @@ export default function ({ title, items }) { ${title}${raw(arrowIcon)} diff --git a/src/components/m-footer-links/index.scss b/src/components/m-footer-links/index.scss index 6ff2c76431..124c636a96 100644 --- a/src/components/m-footer-links/index.scss +++ b/src/components/m-footer-links/index.scss @@ -129,6 +129,7 @@ color: $footer-links__link-color; + .is-footer-links__list-item-active > &, &:hover, &:active, &:focus { diff --git a/src/components/m-footer-links/js/footer-links.js b/src/components/m-footer-links/js/footer-links.js index 105de1c5bc..782e296f96 100644 --- a/src/components/m-footer-links/js/footer-links.js +++ b/src/components/m-footer-links/js/footer-links.js @@ -1,12 +1,28 @@ +import on from '../../../js/on'; +import fire from '../../../js/fire'; +import getAttribute from '../../../js/get-attribute'; import { subscribe } from '../../../js/pubsub'; import DropDown from '../../m-dropdown/js/drop-down'; const hasDropdownBreakpoints = 'xs'; // @TODO: dependency to a-device-state not explicit +/** + * @fires FooterLinks#axa-click + */ export default class FooterLinks { - constructor(wcNode) { + static DEFAULTS = { + link: 'js-footer-links__link', + }; + + constructor(wcNode, options) { this.wcNode = wcNode; + this.options = { + ...FooterLinks.DEFAULTS, + ...options, + }; + + this.handleClick = this.handleClick.bind(this); this.on(); } @@ -25,12 +41,35 @@ export default class FooterLinks { delete this.dropDown; } }); + + this.unClick = on(this.wcNode, 'click', this.options.link, this.handleClick, { passive: false }); } off() { if (this.unsubscribe) { this.unsubscribe(); } + + if (this.unClick) { + this.unClick(); + } + } + + handleClick(event, delegateTarget) { + // @todo: would be cool to be able to use props here, cause now it needs JSON.parse... + const index = getAttribute(delegateTarget, 'index'); + const { wcNode: { items } } = this; + /** + * axa-click event. + * + * @event FooterLinks#axa-click + * @type {object} + */ + const cancelled = fire(this.wcNode, 'axa-click', items[index], { bubbles: true, cancelable: true, composed: true }); + + if (!cancelled) { + event.preventDefault(); + } } destroy() { diff --git a/src/components/m-header-logo/_template.js b/src/components/m-header-logo/_template.js index b18f8a8f3e..c76b47cc19 100644 --- a/src/components/m-header-logo/_template.js +++ b/src/components/m-header-logo/_template.js @@ -2,7 +2,7 @@ import html from 'nanohtml'; import raw from 'nanohtml/raw'; export default ({ src, alt = 'AXA Logo', href = '#' } = {}) => html` - + ${src ? html` ${alt} ` : raw('')} diff --git a/src/components/m-header-logo/index.js b/src/components/m-header-logo/index.js index 2c7ecf7792..f01967410a 100644 --- a/src/components/m-header-logo/index.js +++ b/src/components/m-header-logo/index.js @@ -4,6 +4,7 @@ import styles from './index.scss'; // import the template used for this component import template from './_template'; import wcdomready from '../../js/wcdomready'; +import HeaderLogo from './js/header-logo'; class AXAHeaderLogo extends BaseComponentGlobal { static get observedAttributes() { return ['alt', 'href', 'src']; } @@ -17,6 +18,21 @@ class AXAHeaderLogo extends BaseComponentGlobal { this.className = `${this.initialClassName} m-header-logo`; } + + didRenderCallback() { + if (this.logo) { + this.logo.destroy(); + } + + this.logo = new HeaderLogo(this); + } + + disconnectedCallback() { + if (this.logo) { + this.logo.destroy(); + delete this.logo; + } + } } wcdomready(() => { diff --git a/src/components/m-header-logo/js/header-logo.js b/src/components/m-header-logo/js/header-logo.js new file mode 100644 index 0000000000..80b0271f21 --- /dev/null +++ b/src/components/m-header-logo/js/header-logo.js @@ -0,0 +1,66 @@ +import on from '../../../js/on'; +import fire from '../../../js/fire'; + +/** + * @fires HeaderLogo#axa-click + */ +class HeaderLogo { + static DEFAULTS = { + link: '.js-header-logo__link', + } + + constructor(wcNode, options = {}) { + this.wcNode = wcNode; + this.options = { + ...HeaderLogo.DEFAULTS, + ...options, + }; + + this.handleClick = this.handleClick.bind(this); + + this.init(); + } + + init() { + this.link = this.wcNode.querySelector(this.options.link); + + this.on(); + } + + on() { + this.off(); + + this.unClick = on(this.link, 'click', this.handleClick, { passive: false }); + } + + handleClick(event) { + /** + * axa-click event. + * + * @event HeaderLogo#axa-click + * @type {null} + */ + const cancelled = fire(this.wcNode, 'axa-click', null, { bubbles: true, cancelable: true, composed: true }); + + if (!cancelled) { + event.preventDefault(); + } + } + + off() { + if (this.unClick) { + this.unClick(); + } + } + + destroy() { + this.off(); + + delete this.link; + delete this.wcNode; + delete this.options; + delete this.handleClick; + } +} + +export default HeaderLogo; diff --git a/src/demos/demo.react.html b/src/demos/demo.react.html index bb1f9917e6..9e73c47c95 100644 --- a/src/demos/demo.react.html +++ b/src/demos/demo.react.html @@ -1,4 +1,7 @@ -
+

Demo on how to pass a callback function:

+ +

Demo TodoMVC:

+
diff --git a/src/demos/demo.react.jsx b/src/demos/demo.react.jsx index 42e43efd95..e0dd99da89 100644 --- a/src/demos/demo.react.jsx +++ b/src/demos/demo.react.jsx @@ -1,5 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import withReact from '../js/with-react'; +import AXAButton from '../components/m-button'; + +import './todomvc/app'; + +const AXAButtonReact = withReact(AXAButton); // components are loaded already in the body cause this demo is a the end of the body @@ -19,11 +25,12 @@ class MyEventDemoReact extends React.Component { } render() { - return ( - + return ([ + {this.state.isToggleOn ? 'ON' : 'OFF'} - - ); + , + {this.state.isToggleOn ? 'ON' : 'OFF'}, + ]); } } diff --git a/src/demos/todomvc/app.jsx b/src/demos/todomvc/app.jsx new file mode 100644 index 0000000000..745c948b0a --- /dev/null +++ b/src/demos/todomvc/app.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import BaseComponentGlobal from '../../js/abstract/base-component-global'; +import Todos from './todos'; +import TodoModel from './todo-model'; +import styles from './index.scss'; + +const todoModel = new TodoModel('react-todos'); + +const TodoApp = ({ + model, +}) => ( +
+ +
+); + +function render() { + ReactDOM.render( + , + document.getElementsByClassName('my-todo-demo-react')[0], + ); +} + +todoModel.subscribe(render); + +document.addEventListener('DOMContentLoaded', () => { + BaseComponentGlobal.appendGlobalStyles(styles); + render(); +}); + diff --git a/src/demos/todomvc/index.scss b/src/demos/todomvc/index.scss new file mode 100644 index 0000000000..c60ea5d3a7 --- /dev/null +++ b/src/demos/todomvc/index.scss @@ -0,0 +1,188 @@ +@import "../../styles/settings/colors"; +@import "../../components/m-footer-legals/settings/footer-legals"; +@import "../../styles/mixins/respond"; +@import "../../styles/mixins/size"; +@import "../../styles/mixins/unstyle-list"; +@import "../../styles/grid/grid-mixins"; +@import "../../styles/typo/typo-mixins"; +@import "../../styles/vertical-rhythm/vertical-rhythm-mixins"; + +.o-todo { + @include vr-pad-items(); + + display: block; + + > * { + @include vr-margin-item(); + + display: block; + } +} + +%a-todo__input { + @include typo-text-longer(); + + display: block; + width: 100%; + padding: 5px 15px; + border: 2px solid $color-prim-gray-wild-sand; + background: $color-prim-white; + + transition: color 0.2s ease, border-color 0.2s ease; + + &::placeholder { + color: $color-prim-gray-dusty; + opacity: 1; + } + + &::-ms-clear { + display: none; + } + + &:focus { + outline: none; + border-color: $color-prim-blue-deep-sapphire; + color: $color-prim-blue-deep-sapphire; + } + + @include respond-up(md) { + padding: 10px 20px; + } +} + +.m-todo-header__title { + @include typo-small-module-title--publico(); + + @include respond-down(sm) { + display: none; + } +} + +.m-todo-header__new { + @extend %a-todo__input; + + margin-left: 20px; + + flex-grow: 1; +} + +.m-todo__list { + $vr-paddings: + ( + // Extra small screen / phone + xs: $vr-base * 1, // 10px + // Small screen / phone + // sm: 40px, + // Medium screen / tablet + md: $vr-base * 2, // 20px + ); + + @include unstyle-list(); + @include make-container(); + @include make-container-max-widths(); + @include vr-pad-items($vr-paddings, $vr-paddings); + + display: block; + + > * { + @include vr-margin-item($vr-paddings); + } +} + +.m-todo__item { + @include typo-text-longer(); + + display: block; +} + +.m-todo__wrap { + @include make-row(null); + + position: relative; + + align-items: center; +} + +.m-todo__toggle { + @include size(40px); + + margin-right: 20px; + + cursor: pointer; +} + +.m-todo__label { + margin: 0 20px 0 0; + + flex: 1; + + cursor: pointer; + white-space: pre-line; + word-break: break-all; + + &.is-todo-completed { + text-decoration: line-through; + } +} + +.m-todo__edit { + @extend %a-todo__input; + + $left: 45px; + $zIndex: 10; + + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: $left; + z-index: $zIndex; + + width: calc(100% - #{$left}); + padding-top: auto; + padding-bottom: auto; + + @include respond-up(md) { + $left: 40px; + + left: $left; + + width: calc(100% - #{$left}); + } +} + +.m-todo__destroy-icon { + @include size(20px); +} + +.m-todo-footer__help, +.m-todo-footer__count { + position: relative; + + display: inline-block; + padding: $footer-legals__link-padding-y $footer-legals__link-padding-x; + margin: -$footer-legals__link-padding-y 0; + + color: $footer-legals__link-color; +} + +.m-todo-footer__count { + // delimiter + &::before { + position: absolute; + left: 0; + top: 50%; + + display: block; + width: $footer-delimiter-width; + height: $footer-delimiter-height; + margin-top: ($footer-delimiter-height / -2); + + content: ''; + background: $footer-delimiter-bg; + } +} + +.m-todo-footer__count--completed { + margin-right: 20px; +} diff --git a/src/demos/todomvc/todo-footer.jsx b/src/demos/todomvc/todo-footer.jsx new file mode 100644 index 0000000000..bf0e4c64d3 --- /dev/null +++ b/src/demos/todomvc/todo-footer.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import classnames from 'classnames'; +import withReact from '../../js/with-react'; +import { pluralize, ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS } from './utils'; +import AXAButton from '../../components/m-button'; +import AXAFooter from '../../components/o-footer'; +import AXAFooterMain from '../../components/m-footer-main'; +import AXAFooterLinks from '../../components/m-footer-links'; +import AXAFooterSub from '../../components/m-footer-sub'; +import AXAFooterLegals from '../../components/m-footer-legals'; + +const AXAButtonReact = withReact(AXAButton); +const AXAFooterReact = withReact(AXAFooter); +const AXAFooterMainReact = withReact(AXAFooterMain); +const AXAFooterLinksReact = withReact(AXAFooterLinks); +const AXAFooterSubReact = withReact(AXAFooterSub); +const AXAFooterLegalsReact = withReact(AXAFooterLegals); + +const footerItems = [ + { name: 'All', url: '#', state: ALL_TODOS }, + { name: 'Active', url: '#active', state: ACTIVE_TODOS }, + { name: 'Completed', url: '#completed', state: COMPLETED_TODOS }, +]; + +const TodoFooter = ({ + title = 'Visible Todos', + items = footerItems, + count, + completedCount, + onClearCompleted, + nowShowing, + onNowShowing, +}) => { + const activeTodoWord = pluralize(count, 'item'); + const isActive = item => ({ + ...item, + isActive: item.state === nowShowing, + }); + const hasLinks = count || completedCount; + const hasCompleted = completedCount > 0; + + return ( + + {hasLinks ? + + + + : null} + + + + + Double-click to edit a todo + + + {hasLinks ? + + {count} {activeTodoWord} left + + : null } + + {hasCompleted && + Clear Completed + } + + + + ); +}; + +export default TodoFooter; diff --git a/src/demos/todomvc/todo-header.jsx b/src/demos/todomvc/todo-header.jsx new file mode 100644 index 0000000000..7f2780afb6 --- /dev/null +++ b/src/demos/todomvc/todo-header.jsx @@ -0,0 +1,34 @@ +import React, { Component } from 'react'; +import withReact from '../../js/with-react'; +import AXAHeader from '../../components/o-header'; +import AXAHeaderMain from '../../components/m-header-main'; +import AXAHeaderLogo from '../../components/m-header-logo'; + +const AXAHeaderReact = withReact(AXAHeader); +const AXAHeaderMainReact = withReact(AXAHeaderMain); +const AXAHeaderLogoReact = withReact(AXAHeaderLogo); + +const TodoHeader = ({ + newTodo, + handleNewTodoKeyDown, + handleChange, + toggleAll, +}) => ( + + + +

Todos

+ + +
+
+); + +export default TodoHeader; diff --git a/src/demos/todomvc/todo-item.jsx b/src/demos/todomvc/todo-item.jsx new file mode 100644 index 0000000000..daac54090a --- /dev/null +++ b/src/demos/todomvc/todo-item.jsx @@ -0,0 +1,152 @@ +import React, { Component } from 'react'; +import classnames from 'classnames'; +import withReact from '../../js/with-react'; +import AXAButton from '../../components/m-button'; +import AXAIcon from '../../components/a-icon'; + +const AXAButtonReact = withReact(AXAButton); +const AXAIconReact = withReact(AXAIcon); + +const ESCAPE_KEY = 27; +const ENTER_KEY = 13; + +class TodoItem extends Component { + constructor(props, context) { + super(props, context); + + this.handleRef = this.handleRef.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleEdit = this.handleEdit.bind(this); + this.handleChange = this.handleChange.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleDestroy = this.handleDestroy.bind(this); + + this.state = { + editText: props.todo.title, + }; + } + + /** + * This is a completely optional performance enhancement that you can + * implement on any React component. If you were to delete this method + * the app would still work correctly (and still be very performant!), we + * just use it as an example of how little code it takes to get an order + * of magnitude performance improvement. + */ + shouldComponentUpdate(nextProps, nextState) { + const { props } = this; + + return ( + nextProps.todo !== props.todo || + nextProps.editing !== props.editing || + nextState.editText !== this.state.editText + ); + } + + /** + * Safely manipulate the DOM after updating the state when invoking + * `this.props.onEdit()` in the `handleEdit` method above. + * For more info refer to notes at https://facebook.github.io/react/docs/component-api.html#setstate + * and https://facebook.github.io/react/docs/component-specs.html#updating-componentdidupdate + */ + componentDidUpdate(prevProps) { + if (!prevProps.editing && this.props.editing) { + const { editField } = this; + + editField.focus(); + editField.setSelectionRange(editField.value.length, editField.value.length); + } + } + + handleRef(ref) { + this.editField = ref; + } + + handleSubmit() { + const val = this.state.editText.trim(); + const { props } = this; + const { todo } = props; + + if (val) { + props.onSave(todo, val); + this.setState({ editText: val }); + } else { + props.onDestroy(todo); + } + } + + handleEdit() { + const { props } = this; + const { todo } = props; + + props.onEdit(todo); + this.setState({ editText: todo.title }); + } + + handleChange(event) { + if (this.props.editing) { + this.setState({ editText: event.target.value }); + } + } + + handleKeyDown(event) { + const { which } = event; + + if (which === ESCAPE_KEY) { + const { props } = this; + + this.setState({ editText: props.todo.title }); + props.onCancel(event); + } else if (which === ENTER_KEY) { + this.handleSubmit(event); + } + } + + handleDestroy(event) { + const { props: { todo, onDestroy } } = this; + + onDestroy(todo); + } + + render() { + const { props, state } = this; + const { todo, editing, onToggle } = props; + const { title, completed, id } = todo; + const htmlFor = `m-todo-${id}`; + + return ( +
  • +
    + onToggle(todo)} + /> + + + + + + + + {editing ? + + : null} +
    +
  • + ); + } +} + +export default TodoItem; diff --git a/src/demos/todomvc/todo-model.js b/src/demos/todomvc/todo-model.js new file mode 100644 index 0000000000..6aec4a492f --- /dev/null +++ b/src/demos/todomvc/todo-model.js @@ -0,0 +1,79 @@ +import { store, uuid } from './utils'; + +class TodoModel { + constructor(key) { + this.key = key; + this.todos = store(key); + this.onChanges = []; + } + + subscribe(onChange) { + this.onChanges.push(onChange); + } + + inform() { + store(this.key, this.todos); + this.onChanges.forEach((cb) => { + cb(); + }); + } + + addTodo(title) { + this.todos = this.todos.concat({ + id: uuid(), + title, + completed: false, + }); + + this.inform(); + } + + toggleAll(checked) { + // Note: it's usually better to use immutable data structures since they're + // easier to reason about and React works very well with them. That's why + // we use map() and filter() everywhere instead of mutating the array or + // todo items themselves. + this.todos = this.todos.map(todo => ({ + ...todo, + completed: checked, + })); + + this.inform(); + } + + toggle(todoToToggle) { + this.todos = this.todos.map(todo => todo !== todoToToggle ? + todo : + { + ...todo, + completed: !todo.completed, + }); + + this.inform(); + } + + destroy(todo) { + this.todos = this.todos.filter(candidate => candidate !== todo); + + this.inform(); + } + + save(todoToSave, text) { + this.todos = this.todos.map(todo => todo !== todoToSave ? + todo : + { + ...todo, + title: text, + }); + + this.inform(); + } + + clearCompleted() { + this.todos = this.todos.filter(todo => !todo.completed); + + this.inform(); + } +} + +export default TodoModel; diff --git a/src/demos/todomvc/todos-list.jsx b/src/demos/todomvc/todos-list.jsx new file mode 100644 index 0000000000..df8fd1dd12 --- /dev/null +++ b/src/demos/todomvc/todos-list.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import TodoItem from './todo-item'; + +const TodosList = ({ + shownTodos, + editing, + ...props, +}) => Array.isArray(shownTodos) && shownTodos.length ? ( +
      + {shownTodos.map(todo => ( + + ))} +
    +) : null; + +export default TodosList; diff --git a/src/demos/todomvc/todos.jsx b/src/demos/todomvc/todos.jsx new file mode 100644 index 0000000000..8a403c0635 --- /dev/null +++ b/src/demos/todomvc/todos.jsx @@ -0,0 +1,135 @@ +import React, { Component } from 'react'; +import TodoHeader from './todo-header'; +import TodosList from './todos-list'; +import TodoFooter from './todo-footer'; +import { ALL_TODOS, ACTIVE_TODOS, COMPLETED_TODOS } from './utils'; + +const ENTER_KEY = 13; + +class Todos extends Component { + constructor(props, context) { + super(props, context); + + this.toggleAll = this.toggleAll.bind(this); + this.toggle = this.toggle.bind(this); + this.destroy = this.destroy.bind(this); + this.edit = this.edit.bind(this); + this.save = this.save.bind(this); + this.cancel = this.cancel.bind(this); + this.clearCompleted = this.clearCompleted.bind(this); + this.handleNewTodoKeyDown = this.handleNewTodoKeyDown.bind(this); + this.handleChange = this.handleChange.bind(this); + this.nowShowing = this.nowShowing.bind(this); + + this.state = { + nowShowing: ALL_TODOS, + editing: null, + newTodo: '', + toggleAll: false, + }; + } + + handleChange(event) { + this.setState({ newTodo: event.target.value }); + } + + handleNewTodoKeyDown(event) { + if (event.keyCode !== ENTER_KEY) { + return; + } + + event.preventDefault(); + + const val = this.state.newTodo.trim(); + + if (val) { + this.props.model.addTodo(val); + this.setState({ newTodo: '' }); + } + } + + toggleAll(event) { + const { state: { toggleAll } } = this; + + event.preventDefault(); + + this.props.model.toggleAll(!toggleAll); + + this.setState({ toggleAll: !toggleAll }); + } + + toggle(todoToToggle) { + this.props.model.toggle(todoToToggle); + } + + destroy(todo) { + this.props.model.destroy(todo); + } + + edit(todo) { + this.setState({ editing: todo.id }); + } + + save(todoToSave, text) { + this.props.model.save(todoToSave, text); + this.setState({ editing: null }); + } + + cancel() { + this.setState({ editing: null }); + } + + clearCompleted() { + this.props.model.clearCompleted(); + } + + nowShowing(event) { + const { detail: { state } } = event; + + event.preventDefault(); + + this.setState({ + nowShowing: state, + }); + } + + render() { + const { props: { model: { todos } } } = this; + const { state } = this; + + const shownTodos = todos.filter((todo) => { + switch (state.nowShowing) { + case ACTIVE_TODOS: + return !todo.completed; + case COMPLETED_TODOS: + return todo.completed; + default: + return true; + } + }); + + const activeTodoCount = todos.reduce((accum, todo) => todo.completed ? accum : accum + 1, 0); + const completedCount = todos.length - activeTodoCount; + + return ( +
    + + + + + +
    + ); + } +} + +export default Todos; diff --git a/src/demos/todomvc/utils.js b/src/demos/todomvc/utils.js new file mode 100644 index 0000000000..932973b7f0 --- /dev/null +++ b/src/demos/todomvc/utils.js @@ -0,0 +1,39 @@ +/* global localStorage */ + +export function uuid() { + let i; + let random; + // eslint-disable-next-line no-shadow + let uuid = ''; + + for (i = 0; i < 32; i++) { + random = Math.random() * 16 | 0; + if (i === 8 || i === 12 || i === 16 || i === 20) { + uuid += '-'; + } + // eslint-disable-next-line no-mixed-operators, no-nested-ternary + uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)) + .toString(16); + } + + return uuid; +} + +export function pluralize(count, word) { + return count === 1 ? word : `${word}s`; +} + +export function store(namespace, data) { + if (data) { + return localStorage.setItem(namespace, JSON.stringify(data)); + } + + // eslint-disable-next-line no-shadow + const store = localStorage.getItem(namespace); + + return (store && JSON.parse(store)) || []; +} + +export const ALL_TODOS = 'all'; +export const ACTIVE_TODOS = 'active'; +export const COMPLETED_TODOS = 'completed'; diff --git a/src/index.html b/src/index.html index c6f1f41fc1..c43edfe028 100644 --- a/src/index.html +++ b/src/index.html @@ -121,6 +121,10 @@