diff --git a/src/components/close-button/close-button.css b/src/components/close-button/close-button.css index ce9cc99ac96ba61f8b30bcdcd815de1dbfe63ded..66b79d8a6fd84948e29d846277eb2ed3577155c0 100644 --- a/src/components/close-button/close-button.css +++ b/src/components/close-button/close-button.css @@ -6,40 +6,46 @@ align-items: center; justify-content: center; - color: gray; - background-color: $blue; - + overflow: hidden; /* Mask the icon animation */ + background-color: rgba(0, 0, 0, 0.1); border-radius: 50%; - border-color: #dbdbdb; - border-style: solid; - + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - cursor: pointer; user-select: none; - transition: all 0.15s ease-out; /* @todo: standardize with var */ + cursor: pointer; + transition: all 0.15s ease-out; +} + +.close-button.large:hover { + transform: scale(1.1, 1.1); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.15); } .small { width: 1rem; height: 1rem; - border-width: 1px; + color: #FFF; + background-color: $blue; } .large { - width: 2.75rem; - height: 2.75rem; - border-width: 2px; + width: 1.75rem; + height: 1.75rem; } -.close-button:hover { - transform: scale(1.1, 1.1); -} - -/* Same icon as Sprite Selector Add button, but rotated. - @todo: reuse? -*/ .close-icon { + position: relative; + margin: 0.25rem; + user-select: none; transform-origin: 50%; transform: rotate(45deg); +} + +.small .close-icon { width: 40%; } + +.large .close-icon { + width: 0.75rem; + height: 0.75rem; +} diff --git a/src/components/close-button/close-button.jsx b/src/components/close-button/close-button.jsx index f7c7fc141f28a93bd2833c67aaea1651e6e6d652..c2d78c79620b207c36e934ad16edf8106482cafe 100644 --- a/src/components/close-button/close-button.jsx +++ b/src/components/close-button/close-button.jsx @@ -11,8 +11,8 @@ const CloseButton = props => ( styles.closeButton, props.className, { - [styles.large]: props.size === CloseButton.SIZE_LARGE, - [styles.small]: props.size === CloseButton.SIZE_SMALL + [styles.small]: props.size === CloseButton.SIZE_SMALL, + [styles.large]: props.size === CloseButton.SIZE_LARGE } )} onClick={props.onClick} @@ -24,13 +24,13 @@ const CloseButton = props => ( </div> ); -CloseButton.SIZE_LARGE = 'large'; CloseButton.SIZE_SMALL = 'small'; +CloseButton.SIZE_LARGE = 'large'; CloseButton.propTypes = { className: PropTypes.string, onClick: PropTypes.func.isRequired, - size: PropTypes.oneOf([CloseButton.SIZE_LARGE, CloseButton.SIZE_SMALL]) + size: PropTypes.oneOf([CloseButton.SIZE_SMALL, CloseButton.SIZE_LARGE]) }; CloseButton.defaultProps = { diff --git a/src/components/filter/filter.css b/src/components/filter/filter.css new file mode 100644 index 0000000000000000000000000000000000000000..fa8e072e10bc8cea39150526b1d0184f46b9fa75 --- /dev/null +++ b/src/components/filter/filter.css @@ -0,0 +1,103 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.filter { + display: flex; + flex-direction: row; + align-items: center; + /* + Should we use width 100% instead, for cases when component's + parent is not a flexbox container? + */ + flex-grow: 1; + + padding: 0.3rem 0.5rem; + background: rgba(0, 0, 0, 0.10); + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 10rem; + user-select: none; +} + +.filter:hover { + background: rgba(0, 0, 0, 0.15); +} + +.filter-icon { + height: 0.9rem; + width: 0.9rem; + margin: 0 0.5rem 0 0.75rem; +} + +/* + Hidden state +*/ +.x-icon-wrapper { + opacity: 0; + + display: flex; + justify-content: center; + align-items: center; + + overflow: hidden; /* Mask the icon animation */ + height: 1.25rem; + width: 1.25rem; + margin: 0 0.25rem 0 0.5rem; /* @todo: move to parent to make component*/ + + border-radius: 50%; + pointer-events: none; + cursor: default; + transition: opacity 0.05s linear; +} + +/* + Shown state +*/ +.filter.is-active .x-icon-wrapper { + pointer-events: auto; + cursor: pointer; + opacity: 1; + transition: opacity 0.05s linear; +} + +.filter.is-active .x-icon-wrapper:hover { + transform: scale(1.2, 1.2); +} + +/* + Hidden state +*/ +.x-icon { + position: relative; + margin: 0.25rem; + user-select: none; + transform: translateX(0.5rem); + transition: transform 0.085s cubic-bezier(0.78, 1, 1, 1); +} + +/* + Shown state +*/ +.filter.is-active .x-icon-wrapper .x-icon { + transform: translateX(0); +} + +.filter-input { + flex-grow: 1; + line-height: 1.25rem; + background-color: transparent; + -webkit-appearance: none; + outline: none; + border: 0; + color: white; + font-size: 0.75rem; + letter-spacing: 0.15px; + cursor: text; +} + +.filter-input::placeholder { + opacity: 0.75; + padding: 0 0 0 0.25rem; + color: white; + font-size: 0.75rem; + letter-spacing: 0.15px; +} diff --git a/src/components/filter/filter.jsx b/src/components/filter/filter.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6e140eb80b3c4bd6bd0f8bad350f447cb7865b36 --- /dev/null +++ b/src/components/filter/filter.jsx @@ -0,0 +1,57 @@ +const classNames = require('classnames'); +const PropTypes = require('prop-types'); +const React = require('react'); + +const filterIcon = require('./icon--filter.svg'); +const xIcon = require('./icon--x.svg'); +const styles = require('./filter.css'); + +const FilterComponent = props => { + const { + onChange, + onClear, + placeholderText, + filterQuery + } = props; + return ( + <div + className={classNames({ + [styles.filter]: true, + [styles.isActive]: filterQuery.length > 0 + })} + > + <img + className={styles.filterIcon} + src={filterIcon} + /> + <input + autoFocus + className={styles.filterInput} + placeholder={placeholderText} + type="text" + value={filterQuery} + onChange={onChange} + /> + <div + className={styles.xIconWrapper} + onClick={onClear} + > + <img + className={styles.xIcon} + src={xIcon} + /> + </div> + </div> + ); +}; + +FilterComponent.propTypes = { + filterQuery: PropTypes.string, + onChange: PropTypes.func, + onClear: PropTypes.func, + placeholderText: PropTypes.string +}; +FilterComponent.defaultProps = { + placeholderText: 'what are you looking for?' +}; +module.exports = FilterComponent; diff --git a/src/components/filter/icon--filter.svg b/src/components/filter/icon--filter.svg new file mode 100644 index 0000000000000000000000000000000000000000..2a0c75fcb9ffc6aa604b66b0ce6320994e2b086a Binary files /dev/null and b/src/components/filter/icon--filter.svg differ diff --git a/src/components/filter/icon--x.svg b/src/components/filter/icon--x.svg new file mode 100644 index 0000000000000000000000000000000000000000..e78a2a8e24a8fe526004cd1ec4e1a2622dec76d2 Binary files /dev/null and b/src/components/filter/icon--x.svg differ diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css index c702492f8939412c2222f49bf0d99daa5076a33f..3102d3d2d2a29d5f58db70f638b3d15fc5404491 100644 --- a/src/components/library-item/library-item.css +++ b/src/components/library-item/library-item.css @@ -6,8 +6,9 @@ align-items: center; justify-content: center; flex-basis: 160px; + max-width: 160px; height: 160px; - margin: calc($space / 2); + margin: $space; padding: 1rem 1rem 0 1rem; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; color: #575e75; @@ -23,8 +24,6 @@ .library-item:hover { border-width: 2px; border-color: #1dacf4; - transition: 0.1s ease-out; - transform: scale(1.02, 1.02); } .library-item-image-container { diff --git a/src/components/library/library.css b/src/components/library/library.css index b8147a1736f330d8c5c8cd3ec6ef9e63984bfc6f..73f615191d159177433d271193fb8b7b73f9f47c 100644 --- a/src/components/library/library.css +++ b/src/components/library/library.css @@ -1,24 +1,14 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + .library-scroll-grid { display: flex; + justify-content: center; + align-content: flex-start; + background: $ui-pane-gray; flex-grow: 1; - justify-content: space-between; flex-wrap: wrap; - overflow-y: scroll; - height: calc(100% - 6rem); /* @todo: currently estimate, fix precision */ - - /* - Gives sprites a bit of room so they don't get cut off when they grow on hover - @todo: sync as a var, with the transform defined on .library-item:hover - */ - padding: 0.15rem; -} - -.modal-header { - width: 100%; - margin-bottom: 2rem; + overflow-y: auto; + height: calc(100% - $library-header-height); padding: 0.5rem; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 1.5rem; - font-weight: normal; - color: #8e8f95; } diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 71b29ff612ff1552eae7df150f2eaa9782a9120d..ad33743be1e025b8587db9c031df00f6b5378a5b 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -11,40 +11,58 @@ class LibraryComponent extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleSelect', + 'handleFilterChange', + 'handleFilterClear', 'handleMouseEnter', - 'handleMouseLeave' + 'handleMouseLeave', + 'handleSelect' ]); + this.state = { + selectedItem: null, + filterQuery: '' + }; } handleSelect (id) { this.props.onRequestClose(); - this.props.onItemSelected(this.props.data[id]); + this.props.onItemSelected(this.getFilteredData()[id]); } handleMouseEnter (id) { - if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.props.data[id]); + if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.getFilteredData()[id]); } handleMouseLeave (id) { - if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.props.data[id]); + if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.getFilteredData()[id]); + } + handleFilterChange (event) { + this.setState({filterQuery: event.target.value}); + } + handleFilterClear () { + this.setState({filterQuery: ''}); + } + getFilteredData () { + return this.props.data.filter(dataItem => + dataItem.name.toLowerCase().indexOf(this.state.filterQuery.toLowerCase()) !== -1); } render () { if (!this.props.visible) return null; return ( <ModalComponent contentLabel={this.props.title} + filterQuery={this.state.filterQuery} visible={this.props.visible} + onFilterChange={this.handleFilterChange} + onFilterClear={this.handleFilterClear} onRequestClose={this.props.onRequestClose} > - <h1 className={styles.modalHeader}>{this.props.title}</h1> <div className={styles.libraryScrollGrid}> - {this.props.data.map((dataItem, itemId) => { + {this.getFilteredData().map((dataItem, index) => { const scratchURL = dataItem.md5 ? `https://cdn.assets.scratch.mit.edu/internalapi/asset/${dataItem.md5}/get/` : dataItem.rawURL; return ( <LibraryItem iconURL={scratchURL} - id={itemId} - key={`item_${itemId}`} + id={index} + key={`item_${index}`} name={dataItem.name} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} @@ -61,6 +79,7 @@ class LibraryComponent extends React.Component { LibraryComponent.propTypes = { data: PropTypes.arrayOf( /* eslint-disable react/no-unused-prop-types, lines-around-comment */ + // An item in the library PropTypes.shape({ // @todo remove md5/rawURL prop from library, refactor to use storage md5: PropTypes.string, diff --git a/src/components/modal/modal.css b/src/components/modal/modal.css index 25a134f5e689d5d80dae6ca88fe6a7a693dccce3..a72a4f06dbc18c67a672a166b7fddb57fbe66a0f 100644 --- a/src/components/modal/modal.css +++ b/src/components/modal/modal.css @@ -11,32 +11,69 @@ background-color: rgba(0, 0, 0, .75); } -.modal-content { +/* @todo: extract to type: full ? */ +.full-modal-content { position: absolute; outline: none; - overflow-y: scroll; + overflow-y: auto; -webkit-overflow-scrolling: 'touch'; - border: 1px solid #ccc; - padding: 0; - top: 5%; - right: 5%; - bottom: 5%; - left: 5%; - border-radius: $space; user-select: none; + height: 100%; + width: 100%; + display: flex; } -.modal-children { - overflow: hidden; - height: 100%; - z-index: 0; - padding: 2rem; - background: $ui-pane-gray; +/* + Modal header has 3 items: + |filter title x| + + Use the same width for both side item containers, + so that title remains centered +*/ +$sides: 20rem; + +.header { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + height: $library-header-height; + + box-sizing: border-box; + width: 100%; + background-color: $blue; + + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1rem; + font-weight: normal; } -.close-button { - position: absolute; - top: 1rem; - right: 1rem; - z-index: 2; +.header-item { + display: flex; + align-items: center; + padding: 1rem; + text-decoration: none; + color: white; + user-select: none; } + +.header-item-filter { + display: flex; + flex-basis: $sides; + justify-content: flex-start; +} + +.header-item-title { + flex-grow: 1; + flex-shrink: 0; + justify-content: center; + user-select: none; + letter-spacing: 0.4px; + cursor: default; +} + +.header-item-close { + flex-basis: $sides; + justify-content: flex-end; +} + diff --git a/src/components/modal/modal.jsx b/src/components/modal/modal.jsx index 3a3fc25db56a00488657e121ea6a41c076c3bfc6..2a743bd74447dae5cbf7465a906134551dfeafd0 100644 --- a/src/components/modal/modal.jsx +++ b/src/components/modal/modal.jsx @@ -1,9 +1,11 @@ +const classNames = require('classnames'); const PropTypes = require('prop-types'); const React = require('react'); const ReactModal = require('react-modal'); const Box = require('../box/box.jsx'); const CloseButton = require('../close-button/close-button.jsx'); +const Filter = require('../filter/filter.jsx'); const styles = require('./modal.css'); @@ -11,21 +13,44 @@ class ModalComponent extends React.Component { render () { return ( <ReactModal - className={styles.modalContent} + className={styles.fullModalContent} contentLabel={this.props.contentLabel} isOpen={this.props.visible} overlayClassName={styles.modalOverlay} ref={m => (this.modal = m)} - onRequestClose={this.props.onRequestClose} > - <CloseButton - className={styles.closeButton} - onClick={this.props.onRequestClose} - /> <Box - className={styles.modalChildren} direction="column" + grow={1} > + <div className={styles.header}> + <div className={classNames(styles.headerItem, styles.headerItemFilter)}> + <Filter + filterQuery={this.props.filterQuery} + onChange={this.props.onFilterChange} + onClear={this.props.onFilterClear} + /> + </div> + <div + className={classNames( + styles.headerItem, + styles.headerItemTitle + )} + > + {this.props.contentLabel} + </div> + <div + className={classNames( + styles.headerItem, + styles.headerItemClose + )} + > + <CloseButton + size={CloseButton.SIZE_LARGE} + onClick={this.props.onRequestClose} + /> + </div> + </div> {this.props.children} </Box> </ReactModal> @@ -36,7 +61,10 @@ class ModalComponent extends React.Component { ModalComponent.propTypes = { children: PropTypes.node, contentLabel: PropTypes.string.isRequired, - onRequestClose: PropTypes.func.isRequired, + filterQuery: PropTypes.string, + onFilterChange: PropTypes.func, + onFilterClear: PropTypes.func, + onRequestClose: PropTypes.func, visible: PropTypes.bool.isRequired }; diff --git a/src/css/units.css b/src/css/units.css index ab4393ac1afefb3d770289b67b7c1b8983e6c2b9..3f9aaba17d8319e322b48c817438ed4e8b92458e 100644 --- a/src/css/units.css +++ b/src/css/units.css @@ -3,5 +3,7 @@ $space: 0.5rem; $sprites-per-row: 5; $menu-bar-height: 3rem; -$sprite-info-height: 5.25rem; /* TODO: SpriteInfo isn't explicitly set to this height yet */ +$sprite-info-height: 5.25rem; /* @todo: SpriteInfo isn't explicitly set to this height yet */ $stage-menu-height: 3rem; + +$library-header-height: 4.375rem; \ No newline at end of file