diff --git a/bigbluebutton-html5/client/main.html b/bigbluebutton-html5/client/main.html index fe5209cf13a23c11cddf8928c3f726afd6fbe1eb..f494c9d887fd126452f8f29936130ad18b257168 100755 --- a/bigbluebutton-html5/client/main.html +++ b/bigbluebutton-html5/client/main.html @@ -56,6 +56,21 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. border: 0; } + .set-z-index { + z-index: 15; + width: 100% !important; + height: 100% !important; + } + + .remove-z-index { + z-index: 0; + } + /* .full-screen { + height: 100% !important; + width: 100% !important; + transform: translateX(0) translateY(0) translateZ(0) !important; + } */ + [hidden]:not([hidden="false"]) { display: none !important; } @@ -73,6 +88,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. </head> <body style="background-color: #06172A"> <div id="app" role="document"></div> + <span id="destination"></span> <audio id="remote-media" autoPlay="autoplay"> <track kind="captions" /> {/* These captions are brought to you by eslint */} </audio> diff --git a/bigbluebutton-html5/imports/ui/components/dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/dropdown/component.jsx index 4183ae38cff6697594836c9583fea2feff0acafd..23bf500e1cd8513d87873dc554369fa61454c131 100644 --- a/bigbluebutton-html5/imports/ui/components/dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/dropdown/component.jsx @@ -1,6 +1,8 @@ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { findDOMNode } from 'react-dom'; +import { isMobile } from 'react-device-detect'; +import TetherComponent from 'react-tether'; import cx from 'classnames'; import { defineMessages, injectIntl, intlShape } from 'react-intl'; import Button from '/imports/ui/components/button/component'; @@ -16,7 +18,7 @@ const intlMessages = defineMessages({ }, }); -const noop = () => {}; +const noop = () => { }; const propTypes = { /** @@ -51,6 +53,7 @@ const propTypes = { onShow: PropTypes.func, autoFocus: PropTypes.bool, intl: intlShape.isRequired, + tethered: PropTypes.bool, }; const defaultProps = { @@ -60,6 +63,16 @@ const defaultProps = { autoFocus: false, isOpen: false, keepOpen: null, + getContent: () => {}, +}; + +const attachments = { + 'right-bottom': 'bottom left', + 'right-top': 'bottom left', +}; +const targetAttachments = { + 'right-bottom': 'bottom right', + 'right-top': 'top right', }; class Dropdown extends Component { @@ -162,10 +175,24 @@ class Dropdown extends Component { className, intl, keepOpen, + tethered, + placement, + getContent, ...otherProps } = this.props; const { isOpen } = this.state; + + const placements = placement && placement.replace(' ', '-'); + const test = isMobile ? { + width: '100%', + height: '100%', + transform: 'translateY(0)', + } : { + width: '', + height: '', + transform: '', + }; let trigger = children.find(x => x.type === DropdownTrigger); let content = children.find(x => x.type === DropdownContent); @@ -176,15 +203,20 @@ class Dropdown extends Component { dropdownToggle: this.handleToggle, dropdownShow: this.handleShow, dropdownHide: this.handleHide, + keepOpen, }); content = React.cloneElement(content, { - ref: (ref) => { this.content = ref; }, + ref: (ref) => { + getContent(ref); + this.content = ref; + }, 'aria-expanded': isOpen, dropdownIsOpen: isOpen, dropdownToggle: this.handleToggle, dropdownShow: this.handleShow, dropdownHide: this.handleHide, + keepOpen, }); const showCloseBtn = (isOpen && keepOpen) || (isOpen && keepOpen === null); @@ -199,18 +231,67 @@ class Dropdown extends Component { ref={(node) => { this.dropdown = node; }} tabIndex={-1} > - {trigger} - {content} - {showCloseBtn - ? ( - <Button - className={styles.close} - label={intl.formatMessage(intlMessages.close)} - size="lg" - color="default" - onClick={this.handleHide} - /> - ) : null} + { + tethered ? + ( + <TetherComponent + style={{ + zIndex: isOpen ? 15 : '', + ...test, + }} + attachment={ + isMobile ? 'middle bottom' + : attachments[placements] + } + targetAttachment={ + isMobile ? '' + : targetAttachments[placements] + } + constraints={[ + { + to: 'scrollParent', + }, + ]} + renderTarget={ref => ( + <span ref={ref}> + {trigger} + </span>)} + renderElement={ref => ( + <div + ref={ref} + > + {content} + {showCloseBtn + ? ( + <Button + className={styles.close} + label={intl.formatMessage(intlMessages.close)} + size="lg" + color="default" + onClick={this.handleHide} + /> + ) : null} + </div> + ) + } + />) + : ( + <Fragment> + {trigger} + {content} + {showCloseBtn + ? ( + <Button + className={styles.close} + label={intl.formatMessage(intlMessages.close)} + size="lg" + color="default" + onClick={this.handleHide} + /> + ) : null} + </Fragment> + ) + } </div> ); } diff --git a/bigbluebutton-html5/imports/ui/components/dropdown/content/component.jsx b/bigbluebutton-html5/imports/ui/components/dropdown/content/component.jsx index 9bcbe4fa6917dce3c5dd12abdf43401a9350446d..f6d38545c89e611f161e5524a148ff505aaf6777 100644 --- a/bigbluebutton-html5/imports/ui/components/dropdown/content/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/dropdown/content/component.jsx @@ -26,8 +26,14 @@ const defaultProps = { export default class DropdownContent extends Component { render() { const { - placement, children, className, - dropdownToggle, dropdownShow, dropdownHide, dropdownIsOpen, + placement, + children, + className, + dropdownToggle, + dropdownShow, + dropdownHide, + dropdownIsOpen, + keepOpen, ...restProps } = this.props; @@ -38,6 +44,7 @@ export default class DropdownContent extends Component { dropdownToggle, dropdownShow, dropdownHide, + keepOpen, })); return ( diff --git a/bigbluebutton-html5/imports/ui/components/dropdown/list/component.jsx b/bigbluebutton-html5/imports/ui/components/dropdown/list/component.jsx index abd09fe45840c6cd4ca50029ab3210699a104481..03310297f2e4846b32ca71bf3c692bbb57e6640b 100755 --- a/bigbluebutton-html5/imports/ui/components/dropdown/list/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/dropdown/list/component.jsx @@ -45,8 +45,8 @@ export default class DropdownList extends Component { } componentDidUpdate() { - const { focusedIndex } = this.state; + const { focusedIndex } = this.state; const children = [].slice.call(this._menu.children); this.menuRefs = children.filter(child => child.getAttribute('role') === 'menuitem'); @@ -126,13 +126,14 @@ export default class DropdownList extends Component { } handleItemClick(event, callback) { - const { getDropdownMenuParent, onActionsHide, dropdownHide } = this.props; - - if (getDropdownMenuParent) { - onActionsHide(); - } else { - this.setState({ focusedIndex: null }); - dropdownHide(); + const { getDropdownMenuParent, onActionsHide, dropdownHide, keepOpen} = this.props; + if(!keepOpen) { + if (getDropdownMenuParent) { + onActionsHide(); + } else { + this.setState({ focusedIndex: null }); + dropdownHide(); + } } if (typeof callback === 'function') { diff --git a/bigbluebutton-html5/imports/ui/components/user-list/service.js b/bigbluebutton-html5/imports/ui/components/user-list/service.js index 1cc441d9a0257654f8f672a5ee7d1a66148ca3ff..4ca35b290a7a7f2b92b5de0fd71765570cf23ee2 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-list/service.js @@ -435,14 +435,30 @@ const muteAllExceptPresenter = (userId) => { makeCall('muteAllExceptPresenter', const changeRole = (userId, role) => { makeCall('changeRole', userId, role); }; -const roving = (event, changeState, elementsList, element) => { +const focusFirstDropDownItem = () => { + const dropdownContent = document.querySelector('div[data-test="dropdownContent"][style="visibility: visible;"]'); + if (!dropdownContent) return; + const list = dropdownContent.getElementsByTagName('li'); + list[0].focus(); +}; + +const roving = (...args) => { + const [ + event, + changeState, + elementsList, + element, + ] = args; + this.selectedElement = element; + const numberOfChilds = elementsList.childElementCount; const menuOpen = Session.get('dropdownOpen') || false; if (menuOpen) { const menuChildren = document.activeElement.getElementsByTagName('li'); if ([KEY_CODES.ESCAPE, KEY_CODES.ARROW_LEFT].includes(event.keyCode)) { + Session.set('dropdownOpen', false); document.activeElement.click(); } @@ -463,13 +479,15 @@ const roving = (event, changeState, elementsList, element) => { } if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.keyCode)) { + Session.set('dropdownOpen', false); document.activeElement.blur(); changeState(null); } if (event.keyCode === KEY_CODES.ARROW_DOWN) { const firstElement = elementsList.firstChild; - let elRef = element ? element.nextSibling : firstElement; + let elRef = element && numberOfChilds > 1 ? element.nextSibling : firstElement; + elRef = elRef || firstElement; changeState(elRef); } @@ -482,7 +500,10 @@ const roving = (event, changeState, elementsList, element) => { } if ([KEY_CODES.ARROW_RIGHT, KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event.keyCode)) { - document.activeElement.firstChild.click(); + const tether = document.activeElement.firstChild; + const dropdownTrigger = tether.firstChild; + dropdownTrigger.click(); + focusFirstDropDownItem(); } }; @@ -549,4 +570,5 @@ export default { hasPrivateChatBetweenUsers, toggleUserLock, requestUserInformation, + focusFirstDropDownItem, }; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/styles.scss b/bigbluebutton-html5/imports/ui/components/user-list/styles.scss index 62212b02ce16ded93da2e349e56541c6e66eeaee..8c55231c2bfd222c87dce242b9c1a7e7d1147444 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/user-list/styles.scss @@ -144,6 +144,7 @@ .userListColumn { @extend %flex-column; min-height: 0; + flex-grow: 1; } .enter, diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss index 3c24009616b4d975c8b125842361d220a403a360..4b8d5621f4944e56f26efbb1d2d74ce2f3581fec 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss @@ -37,6 +37,35 @@ outline-color: transparent !important; } +.virtulizedScrollableList { + @include elementFocus(var(--list-item-bg-hover)); + @include scrollbox-vertical(var(--user-list-bg)); + @include highContrastOutline(); + + &:focus-within, + &:focus { + outline-style: solid; + } + + &:active { + box-shadow: none; + border-radius: none; + } + + outline-width: 1px !important; + outline-color: transparent !important; + + flex-grow: 1; + flex-shrink: 1; + + margin: 0 0 1px var(--md-padding-y); + + [dir="rtl"] & { + margin: 0 var(--md-padding-y) 1px 0; + } + margin-left: 0; +} + .list { margin: 0 0 1px var(--md-padding-y); @@ -152,6 +181,11 @@ flex-shrink: 1; } +.scrollStyle { + @include scrollbox-vertical($bg-color: #f3f6f9); + +} + .noteLock { font-weight: 200; font-size: var(--font-size-smaller); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx index a897a092bda2746d5b0a4c19e95bc671393af150..e918f217c22bbec6da9b9ac1ecde8eeef76d6525 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx @@ -1,11 +1,15 @@ import React, { Component } from 'react'; -import { TransitionGroup, CSSTransition } from 'react-transition-group'; import { defineMessages } from 'react-intl'; import PropTypes from 'prop-types'; -import cx from 'classnames'; import { styles } from '/imports/ui/components/user-list/user-list-content/styles'; import _ from 'lodash'; import { findDOMNode } from 'react-dom'; +import { + List, + AutoSizer, + CellMeasurer, + CellMeasurerCache, +} from 'react-virtualized'; import UserListItemContainer from './user-list-item/container'; import UserOptionsContainer from './user-options/container'; @@ -47,8 +51,15 @@ class UserParticipants extends Component { constructor() { super(); + this.cache = new CellMeasurerCache({ + fixedWidth: true, + keyMapper: () => 1, + }); + this.state = { selectedUser: null, + isOpen: false, + scrollArea: false, }; this.userRefs = []; @@ -56,7 +67,7 @@ class UserParticipants extends Component { this.getScrollContainerRef = this.getScrollContainerRef.bind(this); this.rove = this.rove.bind(this); this.changeState = this.changeState.bind(this); - this.getUsers = this.getUsers.bind(this); + this.rowRenderer = this.rowRenderer.bind(this); this.handleClickSelectedUser = this.handleClickSelectedUser.bind(this); } @@ -82,13 +93,20 @@ class UserParticipants extends Component { } componentDidUpdate(prevProps, prevState) { - const { selectedUser } = this.state; - - if (selectedUser === prevState.selectedUser) return; + const { compact } = this.props; + const { selectedUser, scrollArea } = this.state; + if (!compact && (!prevState.scrollArea && scrollArea)) { + scrollArea.addEventListener( + 'keydown', + this.rove, + ); + } if (selectedUser) { const { firstChild } = selectedUser; - if (firstChild) firstChild.focus(); + if (!firstChild.isEqualNode(document.activeElement)) { + firstChild.focus(); + } } } @@ -101,7 +119,12 @@ class UserParticipants extends Component { return this.refScrollContainer; } - getUsers() { + rowRenderer({ + index, + parent, + style, + key, + }) { const { compact, setEmojiStatus, @@ -110,21 +133,22 @@ class UserParticipants extends Component { currentUser, meetingIsBreakout, } = this.props; + const { scrollArea } = this.state; + const user = users[index]; - let index = -1; - - return users.map(u => ( - <CSSTransition - classNames={listTransition} - appear - enter - exit - timeout={0} - component="div" - className={cx(styles.participantsList)} - key={u.userId} + return ( + <CellMeasurer + key={key} + cache={this.cache} + columnIndex={0} + parent={parent} + rowIndex={index} > - <div ref={(node) => { this.userRefs[index += 1] = node; }}> + <span + style={style} + key={key} + id={`user-${user.userId}`} + > <UserListItemContainer {...{ compact, @@ -132,13 +156,14 @@ class UserParticipants extends Component { requestUserInformation, currentUser, meetingIsBreakout, + scrollArea, }} - user={u} + user={user} getScrollContainerRef={this.getScrollContainerRef} /> - </div> - </CSSTransition> - )); + </span> + </CellMeasurer> + ); } handleClickSelectedUser(event) { @@ -151,8 +176,9 @@ class UserParticipants extends Component { rove(event) { const { roving } = this.props; - const { selectedUser } = this.state; - const usersItemsRef = findDOMNode(this.refScrollItems); + const { selectedUser, scrollArea } = this.state; + const usersItemsRef = findDOMNode(scrollArea.firstChild); + roving(event, this.changeState, usersItemsRef, selectedUser); } @@ -169,6 +195,7 @@ class UserParticipants extends Component { currentUser, meetingIsBreakout, } = this.props; + const { isOpen, scrollArea } = this.state; return ( <div className={styles.userListColumn}> @@ -198,15 +225,40 @@ class UserParticipants extends Component { : <hr className={styles.separator} /> } <div - className={styles.scrollableList} + className={styles.virtulizedScrollableList} tabIndex={0} - ref={(ref) => { this.refScrollContainer = ref; }} + ref={(ref) => { + this.refScrollContainer = ref; + }} > - <div className={styles.list}> - <TransitionGroup ref={(ref) => { this.refScrollItems = ref; }}> - {this.getUsers()} - </TransitionGroup> - </div> + <span id="destination" /> + <AutoSizer> + {({ height, width }) => ( + <List + {...{ + isOpen, + users, + }} + ref={(ref) => { + if (ref !== null) { + this.listRef = ref; + } + + if (ref !== null && !scrollArea) { + this.setState({ scrollArea: findDOMNode(ref) }); + } + }} + rowHeight={this.cache.rowHeight} + rowRenderer={this.rowRenderer} + rowCount={users.length} + height={height - 1} + width={width - 1} + className={styles.scrollStyle} + overscanRowCount={30} + deferredMeasurementCache={this.cache} + /> + )} + </AutoSizer> </div> </div> ); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx index daf9550083152d0bc2071c9cca34bbbebdbd1391..873843591058d07f18279bdb09232cea0d8d9834 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx @@ -45,6 +45,10 @@ class UserListItem extends PureComponent { isMeteorConnected, isMe, voiceUser, + scrollArea, + notify, + raiseHandAudioAlert, + raiseHandPushAlert, } = this.props; const contents = ( @@ -76,6 +80,10 @@ class UserListItem extends PureComponent { isMeteorConnected, isMe, voiceUser, + scrollArea, + notify, + raiseHandAudioAlert, + raiseHandPushAlert, }} /> ); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx index 3fbbcf92988b812f8bc015673183bb1638e83939..a784bdfcb5bdfcd28b0d67dc73ae700b6a7e38af 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx @@ -4,12 +4,12 @@ import PropTypes from 'prop-types'; import { findDOMNode } from 'react-dom'; import UserAvatar from '/imports/ui/components/user-avatar/component'; import Icon from '/imports/ui/components/icon/component'; -import Dropdown from '/imports/ui/components/dropdown/component'; import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component'; import DropdownContent from '/imports/ui/components/dropdown/content/component'; import DropdownList from '/imports/ui/components/dropdown/list/component'; import DropdownListItem from '/imports/ui/components/dropdown/list/item/component'; import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component'; +import Dropdown from '/imports/ui/components/dropdown/component'; import lockContextContainer from '/imports/ui/components/lock-viewers/context/container'; import { withModalMounter } from '/imports/ui/components/modal/service'; import RemoveUserModal from '/imports/ui/components/modal/remove-user/component'; @@ -18,6 +18,7 @@ import { Session } from 'meteor/session'; import { styles } from './styles'; import UserName from '../user-name/component'; import UserIcons from '../user-icons/component'; +import Service from '../../../../service'; const messages = defineMessages({ presenter: { @@ -305,7 +306,10 @@ class UserDropdown extends PureComponent { { showNestedOptions: true, isActionsOpen: true, - }, Session.set('dropdownOpen', true), + }, () => { + Session.set('dropdownOpen', true); + Service.focusFirstDropDownItem(); + }, ), 'user', 'right_arrow', @@ -468,26 +472,24 @@ class UserDropdown extends PureComponent { * Check if the dropdown is visible, if so, check if should be draw on top or bottom direction. */ checkDropdownDirection() { - const { getScrollContainerRef } = this.props; + const { scrollArea } = this.props; if (this.isDropdownActivedByUser()) { const dropdown = this.getDropdownMenuParent(); const dropdownTrigger = dropdown.children[0]; - const dropdownContent = dropdown.children[1]; - - const scrollContainer = getScrollContainerRef(); - const nextState = { dropdownVisible: true, }; + const dropdownContent = findDOMNode(this.dropdownContent); + const dropdownBoundaries = dropdownContent.getBoundingClientRect(); const isDropdownVisible = UserDropdown.checkIfDropdownIsVisible( - dropdownContent.offsetTop, - dropdownContent.offsetHeight, + dropdownBoundaries.y, + dropdownBoundaries.height, ); - if (!isDropdownVisible) { + if (!isDropdownVisible && scrollArea) { const { offsetTop, offsetHeight } = dropdownTrigger; - const offsetPageTop = (offsetTop + offsetHeight) - scrollContainer.scrollTop; + const offsetPageTop = (offsetTop + offsetHeight) - scrollArea.scrollTop; nextState.dropdownOffset = window.innerHeight - offsetPageTop; nextState.dropdownDirection = 'bottom'; @@ -620,7 +622,7 @@ class UserDropdown extends PureComponent { ); if (!actions.length) return contents; - + const placement = `right ${dropdownDirection}`; return ( <Dropdown ref={(ref) => { this.dropdown = ref; }} @@ -632,6 +634,9 @@ class UserDropdown extends PureComponent { aria-haspopup="true" aria-live="assertive" aria-relevant="additions" + placement={placement} + getContent={dropdownContent => this.dropdownContent = dropdownContent} + tethered > <DropdownTrigger> {contents} @@ -639,10 +644,9 @@ class UserDropdown extends PureComponent { <DropdownContent style={{ visibility: dropdownVisible ? 'visible' : 'hidden', - [dropdownDirection]: `${dropdownOffset}px`, }} className={styles.dropdownContent} - placement={`right ${dropdownDirection}`} + placement={placement} > <DropdownList ref={(ref) => { this.list = ref; }} diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/styles.scss b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/styles.scss index 50e88bcdfa77a95641a5acb9e67fe64191328638..fd507dd5b78247b7db29c695d11ca1e4b6c4b369 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/styles.scss @@ -133,6 +133,12 @@ @extend %text-elipsis; cursor: default; min-width: 10vw; + @include mq($medium-only) { + min-width: 13vw; + } + @include mq($large-up) { + min-width: 8vw; + } max-width: 100%; overflow: visible; } diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/theteredDropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/theteredDropdown/component.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8de17fb59e83252361b9c549d8a1b3d5934a2832 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/theteredDropdown/component.jsx @@ -0,0 +1,279 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { findDOMNode } from 'react-dom'; +import cx from 'classnames'; +import { isMobile } from 'react-device-detect'; +import { defineMessages, injectIntl, intlShape } from 'react-intl'; +import Button from '/imports/ui/components/button/component'; +import screenreaderTrap from 'makeup-screenreader-trap'; +import TetherComponent from 'react-tether'; +import { styles } from '/imports/ui/components/dropdown/styles'; + +import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component'; +import DropdownContent from '/imports/ui/components/dropdown/content/component'; + +const intlMessages = defineMessages({ + close: { + id: 'app.dropdown.close', + description: 'Close button label', + }, +}); + +const noop = () => { }; + +const propTypes = { + /** + * The dropdown needs a trigger and a content component as children + */ + children: (props, propName, componentName) => { + const children = props[propName]; + + if (!children || children.length < 2) { + return new Error(`Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Validation failed.`); + } + + const trigger = children.find(x => x.type === DropdownTrigger); + const content = children.find(x => x.type === DropdownContent); + + if (!trigger) { + return new Error(`Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Missing \`DropdownTrigger\`. Validation failed.`); + } + + if (!content) { + return new Error(`Invalid prop \`${propName}\` supplied to` + + ` \`${componentName}\`. Missing \`DropdownContent\`. Validation failed.`); + } + + return null; + }, + isOpen: PropTypes.bool, + keepOpen: PropTypes.bool, + onHide: PropTypes.func, + onShow: PropTypes.func, + autoFocus: PropTypes.bool, + intl: intlShape.isRequired, +}; + +const defaultProps = { + children: null, + onShow: noop, + onHide: noop, + autoFocus: false, + isOpen: false, + keepOpen: null, +}; + +const attachments = { + 'right-bottom': 'bottom left', + 'right-top': 'bottom left', +}; + +const targetAttachments = { + 'right-bottom': 'bottom right', + 'right-top': 'top right', +}; + +class Dropdown extends Component { + constructor(props) { + super(props); + this.state = { isOpen: false }; + this.handleShow = this.handleShow.bind(this); + this.handleHide = this.handleHide.bind(this); + this.handleToggle = this.handleToggle.bind(this); + this.handleWindowClick = this.handleWindowClick.bind(this); + } + + componentWillUpdate(nextProps, nextState) { + return nextState.isOpen ? screenreaderTrap.trap(this.dropdown) : screenreaderTrap.untrap(); + } + + componentDidUpdate(prevProps, prevState) { + const { + onShow, + onHide, + keepOpen, + } = this.props; + const { isOpen } = this.state; + + if (isOpen && !prevState.isOpen) { onShow(); } + + if (!isOpen && prevState.isOpen) { onHide(); } + + if (prevProps.keepOpen && !keepOpen) { onHide(); } + } + + handleShow() { + Session.set('dropdownOpen', true); + const { + onShow, + } = this.props; + this.setState({ isOpen: true }, () => { + const { addEventListener } = window; + onShow(); + addEventListener('click', this.handleWindowClick, true); + }); + } + + handleHide() { + Session.set('dropdownOpen', false); + const { onHide } = this.props; + this.setState({ isOpen: false }, () => { + const { removeEventListener } = window; + onHide(); + removeEventListener('click', this.handleWindowClick, true); + }); + } + + handleWindowClick(event) { + const { keepOpen, onHide } = this.props; + const { isOpen } = this.state; + const triggerElement = findDOMNode(this.trigger); + const contentElement = findDOMNode(this.content); + if (!(triggerElement && contentElement)) return; + if (triggerElement && triggerElement.contains(event.target)) { + if (keepOpen) { + onHide(); + return; + } + if (isOpen) { + this.handleHide(); + return; + } + } + + if (keepOpen && isOpen && !contentElement.contains(event.target)) { + if (triggerElement) { + const { parentElement } = triggerElement; + if (parentElement) parentElement.focus(); + } + onHide(); + this.handleHide(); + return; + } + + if (keepOpen && triggerElement) { + const { parentElement } = triggerElement; + if (parentElement) parentElement.focus(); + } + + if (keepOpen !== null) return; + this.handleHide(); + } + + handleToggle() { + const { isOpen } = this.state; + return isOpen ? this.handleHide() : this.handleShow(); + } + + render() { + const { + children, + className, + intl, + keepOpen, + getContent, + placement, + ...otherProps + } = this.props; + + const { isOpen } = this.state; + + let trigger = children.find(x => x.type === DropdownTrigger); + let content = children.find(x => x.type === DropdownContent); + + trigger = React.cloneElement(trigger, { + ref: (ref) => { this.trigger = ref; }, + dropdownIsOpen: isOpen, + dropdownToggle: this.handleToggle, + dropdownShow: this.handleShow, + dropdownHide: this.handleHide, + }); + + content = React.cloneElement(content, { + ref: (ref) => { + getContent(ref); + this.content = ref; + }, + keepOpen, + 'aria-expanded': isOpen, + dropdownIsOpen: isOpen, + dropdownToggle: this.handleToggle, + dropdownShow: this.handleShow, + dropdownHide: this.handleHide, + }); + + const showCloseBtn = (isOpen && keepOpen) || (isOpen && keepOpen === null); + const placements = placement.replace(' ', '-'); + // workaround + const test = isMobile ? { + width: '100%', + height: '100%', + transform: 'translateY(0)', + } : { + width: '', + height: '', + transform: '', + }; + return ( + <div + className={cx(styles.dropdown, className)} + aria-live={otherProps['aria-live']} + aria-relevant={otherProps['aria-relevant']} + aria-haspopup={otherProps['aria-haspopup']} + aria-label={otherProps['aria-label']} + ref={(node) => { this.dropdown = node; }} + tabIndex={-1} + > + <TetherComponent + style={{ + zIndex: isOpen ? 15 : '', + ...test, + }} + attachment={ + isMobile ? 'middle bottom' + : attachments[placements] + } + targetAttachment={ + isMobile ? '' + : targetAttachments[placements] + } + + constraints={[ + { + to: 'scrollParent', + }, + ]} + + renderTarget={ref => ( + <span ref={ref}> + {trigger} + </span>)} + renderElement={ref => ( + <div + ref={ref} + > + {content} + {showCloseBtn + ? ( + <Button + className={styles.close} + label={intl.formatMessage(intlMessages.close)} + size="lg" + color="default" + onClick={this.handleHide} + /> + ) : null} + </div> + ) + } + /> + </div> + ); + } +} + +Dropdown.propTypes = propTypes; +Dropdown.defaultProps = defaultProps; +export default injectIntl(Dropdown); diff --git a/bigbluebutton-html5/package-lock.json b/bigbluebutton-html5/package-lock.json index 39bf129f95a84c3c2ed33753a5b23f55f553b8d5..b6c8d5710a859de6aa52fe12680e402b70886dcf 100644 --- a/bigbluebutton-html5/package-lock.json +++ b/bigbluebutton-html5/package-lock.json @@ -8899,6 +8899,15 @@ "prop-types": "^15.5.0" } }, + "react-tether": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-2.0.7.tgz", + "integrity": "sha512-OZAMoT0y1//SN357HiJKic+Ax/kMe3CwdaDT+05P/DHMR9adTYH2RTMDZMjw/OGMmLlBFg6UrDFXiulVKKIBRw==", + "requires": { + "prop-types": "^15.6.2", + "tether": "^1.4.5" + } + }, "react-toastify": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-4.5.2.tgz", @@ -10291,6 +10300,11 @@ "minimatch": "^3.0.4" } }, + "tether": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/tether/-/tether-1.4.7.tgz", + "integrity": "sha512-Z0J1aExjoFU8pybVkQAo/vD2wfSO63r+XOPfWQMC5qtf1bI7IWqNk4MiyBcgvvnY8kqnY06dVdvwTK2S3PU/Fw==" + }, "text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", diff --git a/bigbluebutton-html5/package.json b/bigbluebutton-html5/package.json index b6a0e4e4a552d0b3f0598410d5f07c6351c99c2a..2ecdc381b26fefd25d67fed4c71ac63e5d233825 100755 --- a/bigbluebutton-html5/package.json +++ b/bigbluebutton-html5/package.json @@ -67,6 +67,7 @@ "react-player": "^2.5.0", "react-render-in-browser": "^1.1.1", "react-tabs": "^2.3.1", + "react-tether": "^2.0.7", "react-toastify": "^4.5.2", "react-toggle": "~4.0.2", "react-transition-group": "^2.9.0",