about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/composer/options/dropdown
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features/composer/options/dropdown')
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js146
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js129
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js228
3 files changed, 503 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
new file mode 100644
index 000000000..b76410561
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
@@ -0,0 +1,146 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import ComposerOptionsDropdownContentItem from './item';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick ({ target }) {
+    const { node } = this;
+    const { onClose } = this.props;
+    if (onClose && node && !node.contains(target)) {
+      onClose();
+    }
+  },
+
+  //  Stores our node in `this.node`.
+  handleRef (node) {
+    this.node = node;
+  },
+};
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.node = null;
+
+    this.state = {
+      mounted: false,
+    };
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    const { handleDocumentClick } = this.handlers;
+    document.addEventListener('click', handleDocumentClick, false);
+    document.addEventListener('touchend', handleDocumentClick, withPassive);
+    this.setState({ mounted: true });
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    const { handleDocumentClick } = this.handlers;
+    document.removeEventListener('click', handleDocumentClick, false);
+    document.removeEventListener('touchend', handleDocumentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const { mounted } = this.state;
+    const { handleRef } = this.handlers;
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+      value,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Motion
+        defaultStyle={{
+          opacity: 0,
+          scaleX: 0.85,
+          scaleY: 0.75,
+        }}
+        style={{
+          opacity: springMotion,
+          scaleX: springMotion,
+          scaleY: springMotion,
+        }}
+      >
+        {({ opacity, scaleX, scaleY }) => (
+          // It should not be transformed when mounting because the resulting
+          // size will be used to determine the coordinate of the menu by
+          // react-overlays
+          <div
+            className='composer--options--dropdown--content'
+            ref={handleRef}
+            style={{
+              ...style,
+              opacity: opacity,
+              transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
+            }}
+          >
+            {items ? items.map(
+              ({
+                name,
+                ...rest
+              }) => (
+                <ComposerOptionsDropdownContentItem
+                  active={name === value}
+                  key={name}
+                  name={name}
+                  onChange={onChange}
+                  onClose={onClose}
+                  options={rest}
+                />
+              )
+            ) : null}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdownContent.propTypes = {
+  items: PropTypes.arrayOf(PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    name: PropTypes.string.isRequired,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  })),
+  onChange: PropTypes.func,
+  onClose: PropTypes.func,
+  style: PropTypes.object,
+  value: PropTypes.string,
+};
+
+//  Default props.
+ComposerOptionsDropdownContent.defaultProps = { style: {} };
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
new file mode 100644
index 000000000..68a52083f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
@@ -0,0 +1,129 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Toggle from 'react-toggle';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  This function activates the dropdown item.
+  handleActivate (e) {
+    const {
+      name,
+      onChange,
+      onClose,
+      options: { on },
+    } = this.props;
+
+    //  If the escape key was pressed, we close the dropdown.
+    if (e.key === 'Escape' && onClose) {
+      onClose();
+
+    //  Otherwise, we both close the dropdown and change the value.
+    } else if (onChange && (!e.key || e.key === 'Enter')) {
+      e.preventDefault();  //  Prevents change in focus on click
+      if ((on === null || typeof on === 'undefined') && onClose) {
+        onClose();
+      }
+      onChange(name);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleActivate } = this.handlers;
+    const {
+      active,
+      options: {
+        icon,
+        meta,
+        on,
+        text,
+      },
+    } = this.props;
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onClick={handleActivate}
+        onKeyDown={handleActivate}
+        role='button'
+        tabIndex='0'
+      >
+        {function () {
+
+          //  We render a `<Toggle>` if we were provided an `on`
+          //  property, and otherwise show an `<Icon>` if available.
+          switch (true) {
+          case on !== null && typeof on !== 'undefined':
+            return (
+              <Toggle
+                checked={on}
+                onChange={handleActivate}
+              />
+            );
+          case !!icon:
+            return (
+              <Icon
+                className='icon'
+                fullwidth
+                icon={icon}
+              />
+            );
+          default:
+            return null;
+          }
+        }()}
+        {meta ? (
+          <div className='content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        ) :
+          <div className='content'>
+            <strong>{text}</strong>
+          </div>}
+      </div>
+    );
+  }
+
+};
+
+//  Props.
+ComposerOptionsDropdownContentItem.propTypes = {
+  active: PropTypes.bool,
+  name: PropTypes.string,
+  onChange: PropTypes.func,
+  onClose: PropTypes.func,
+  options: PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  }),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 000000000..b3462e25a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,228 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import ComposerOptionsDropdownContent from './content';
+
+//  Utils.
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  Closes the dropdown.
+  handleClose () {
+    this.setState({ open: false });
+  },
+
+  //  The enter key toggles the dropdown's open state, and the escape
+  //  key closes it.
+  handleKeyDown ({ key }) {
+    const {
+      handleClose,
+      handleToggle,
+    } = this.handlers;
+    switch (key) {
+    case 'Enter':
+      handleToggle(key);
+      break;
+    case 'Escape':
+      handleClose();
+      break;
+    }
+  },
+
+  //  Creates an action modal object.
+  handleMakeModal () {
+    const component = this;
+    const {
+      items,
+      onChange,
+      onModalOpen,
+      onModalClose,
+      value,
+    } = this.props;
+
+    //  Required props.
+    if (!(onChange && onModalOpen && onModalClose && items)) {
+      return null;
+    }
+
+    //  The object.
+    return {
+      actions: items.map(
+        ({
+          name,
+          ...rest
+        }) => ({
+          ...rest,
+          active: value && name === value,
+          name,
+          onClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onModalClose();
+            onChange(name);
+          },
+          onPassiveClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onChange(name);
+            component.setState({ needsModalUpdate: true });
+          },
+        })
+      ),
+    };
+  },
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle ({ target }) {
+    const { handleMakeModal } = this.handlers;
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    //  If this is a touch device, we open a modal instead of the
+    //  dropdown.
+    if (isUserTouching()) {
+
+      //  This gets the modal to open.
+      const modal = handleMakeModal();
+
+      //  If we can, we then open the modal.
+      if (modal && onModalOpen) {
+        onModalOpen(modal);
+        return;
+      }
+    }
+
+    const { top } = target.getBoundingClientRect();
+    this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+    //  Otherwise, we just set our state to open.
+    this.setState({ open: !open });
+  },
+
+  //  If our modal is open and our props update, we need to also update
+  //  the modal.
+  handleUpdate () {
+    const { handleMakeModal } = this.handlers;
+    const { onModalOpen } = this.props;
+    const { needsModalUpdate } = this.state;
+
+    //  Gets our modal object.
+    const modal = handleMakeModal();
+
+    //  Reopens the modal with the new object.
+    if (needsModalUpdate && modal && onModalOpen) {
+      onModalOpen(modal);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      needsModalUpdate: false,
+      open: false,
+      placement: null,
+    };
+  }
+
+  //  Updates our modal as necessary.
+  componentDidUpdate (prevProps) {
+    const { handleUpdate } = this.handlers;
+    const { items } = this.props;
+    const { needsModalUpdate } = this.state;
+    if (needsModalUpdate && items.find(
+      (item, i) => item.on !== prevProps.items[i].on
+    )) {
+      handleUpdate();
+      this.setState({ needsModalUpdate: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleClose,
+      handleKeyDown,
+      handleToggle,
+    } = this.handlers;
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open, placement } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={handleKeyDown}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          disabled={disabled}
+          icon={icon}
+          onClick={handleToggle}
+          size={18}
+          style={{
+            height: null,
+            lineHeight: '27px',
+          }}
+          title={title}
+        />
+        <Overlay
+          containerPadding={20}
+          placement={placement}
+          show={open}
+          target={this}
+        >
+          <ComposerOptionsDropdownContent
+            items={items}
+            onChange={onChange}
+            onClose={handleClose}
+            value={value}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdown.propTypes = {
+  active: PropTypes.bool,
+  disabled: PropTypes.bool,
+  icon: PropTypes.string,
+  items: PropTypes.arrayOf(PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    name: PropTypes.string.isRequired,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  })).isRequired,
+  onChange: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  title: PropTypes.string,
+  value: PropTypes.string,
+};