Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new react color picker to ui framework with tests #12245

Merged
merged 11 commits into from
Jun 19, 2017

Conversation

stacey-gammon
Copy link
Contributor

In prep for dashboard color customization - #9243

Tried to match the design team mockups:
screen shot 2017-06-08 at 3 29 50 pm

But since that didn't include the chooser I just went with something from react-color:
screen shot 2017-06-08 at 4 11 54 pm

I included a thin black border around the swatch in case the background of the form matches the color picker swatch color (e.g. it's white).

cc @snide

In prep for dashboard color customization.
@stacey-gammon stacey-gammon requested a review from cjcenizal June 8, 2017 20:14
Copy link
Contributor

@snide snide left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent library choice. Looks like something easily extendible to whatever we wanna do. Added some super minor comments for the current implementation.

.kuiColorSwatch {
width: 19px;
height: 19px;
border-radius: 3px;
Copy link
Contributor

@snide snide Jun 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use $globalBorderRadius here.

@@ -0,0 +1,29 @@
.kuiColorSwatch {
width: 19px;
height: 19px;
Copy link
Contributor

@snide snide Jun 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Define these as a local var. Should probably be 20px based on other sizing stuff in k5 currently.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A var like this would go inside the _index.scss file in this component's directory, before the imports:

$colorPickerSize: 20px;

@import "assisted_input";
@import "check_box";
@import "label";
@import "search_input";
@import "select";
@import "static_input";
@import "text_area";
@import "text_input";
@import "color_picker";

width: 19px;
height: 19px;
border-radius: 3px;
border: 1px solid black;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want a softer semi-transparent color here, so it still shows a border, but isn't as harsh. Something like rgba(0,0,0,.2);

Copy link
Contributor

@cjcenizal cjcenizal Jun 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great idea. If we use a box-shadow instead then we can even get the border to blend with the swatch color:

box-shadow: inset 0 0 0 1px rgba(#000, 0.2);

image

image

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚡️ This is so cool. I had some substantial suggestions... please forgive the code invasion! My suggestions began to interleave so I decided the best way to communicate them was with larger-size code snippets.

border-radius: 3px;
border: 1px solid black;
display: inline-block;
vertical-align: middle;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using inline-block we can use flexbox to vertically align the content. It required adding an additional element. When I added this element I ended up adjusting the names of these classes a bit to a) use BEM conventions and b) reflect the relationships among elements a little more clearly (e.g. the connection between the popover and the cover, which I renamed overlay since we're using that term already with the modal).

Here's what the SCSS ended up looking like:

.kuiColorPicker {
  cursor: pointer;
}

  .kuiColorPicker__preview {
    display: flex;
    align-items: center;
  }

  .kuiColorPicker__swatch {
    width: 19px;
    height: 19px;
    border-radius: $globalBorderRadius;
    box-shadow: inset 0 0 0 1px rgba(#000, 0.2);
  }

  .kuiColorPicker__label {
    font-size: $globalFontSize;
    line-height: $globalLineHeight;
    margin-left: 10px;
    display: inline-block;
    vertical-align: middle;
  }

.kuiColorPickerPopUp {
  position: absolute;
  z-index: 10;
}

.kuiColorPickerPopUpOverlay {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
}

this.props.onChange(color.hex);
};

render() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's what the render method looks like using the above classes. I moved the "toggle" click handler onto the root element so the user can click the entire label and swatch instead of just the swatch, since that's a pretty small click area.

  render() {
    const color = this.props.color || '#ffffff';
    const classes = classNames('kuiColorPicker', this.props.className);
    return (
      <div
        className={classes}
        aria-label={ this.props['aria-label'] }
        data-test-subj={ this.props['data-test-subj'] }
        onClick={ this.toggleColorSelector }
      >
        <div className="kuiColorPicker__preview">
          <div
            className="kuiColorPicker__swatch"
            aria-label="Select a color"
            data-test-subj="colorSwatch"
            style={{ background: this.props.color }}
          />
          <div
            className="kuiColorPicker__label"
            aria-label={`Color selection is ${color}`}
          >
            { color }
          </div>
        </div>
        {
          this.state.showColorSelector ?
            <div className="kuiColorPickerPopUp" data-test-subj="colorPickerPopup">
              <div className="kuiColorPickerPopUpOverlay" onClick={ this.closeColorSelector } />
              <ChromePicker
                color={ color }
                disableAlpha={ true }
                onChange={ this.handleColorSelection }
              />
            </div>
            : null
        }
      </div>
    );
  }

}

.kuiColorLabel {
font-size: 16px;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use $globalFontSize here instead, which will be more consistent with the font-size we're using elsewhere. This is reflected in the SCSS snippet in my comment.

@@ -0,0 +1 @@
export { KuiColorPicker } from './color_picker';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this component into its own color_picker directory? It's such a rich component, I feel like it might make more sense to make it more top-level and leave the form directory for more primitive form components.

render() {
return <KuiColorPicker onChange={ this.handleChange } color={ this.state.color }/>;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we make the ColorPicker component live in its own directory, then can we also surface the ColorPicker example as its own page instead of part of the Form page?

{
this.state.showColorSelector ?
<div className="kuiColorPickerPopUp" data-test-subj="colorPickerPopup">
<div className="kuiColorPickerCover" onClick={ this.closeColorSelector } />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to my previous comments, I think we should look for a better way to dismiss the popup instead of by intercepting and completely blocking clicks via this popup. Ideally a user should be able to click a button or form input elsewhere on the screen, and simultaneously interact with that element and dismiss this popup. Maybe @zinckiwi could offer some suggestions for us?

@stacey-gammon
Copy link
Contributor Author

stacey-gammon commented Jun 9, 2017

After addressing code review comments:
screen shot 2017-06-09 at 9 32 32 am

@stacey-gammon
Copy link
Contributor Author

When you have a chance for look, this should be all set to go @cjcenizal!

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last suggestion based on an idea from @zinckiwi, and a couple small nitpicky things.

cursor: pointer;
}

.kuiColorPicker__preview {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we indent kuiColorPicker__preview, kuiColorPicker__swatch, +.kuiColorPicker__label? This visually communicates the parent-child relationship without incurring the specificity you get when you nest them.

@@ -134,6 +137,10 @@ const components = [{
name: 'Card',
component: CardExample,
}, {
name: 'Color Picker',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit, can we change this to ColorPicker to be consistent with the component name?


handleColorSelection = (color) => {
this.props.onChange(color.hex);
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nits: can we remove the semicolons from these three methods for consistency with our other method definitions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it's an assignment, not a function, intellij complains without the semi colon.
screen shot 2017-06-14 at 9 00 01 am
So I think it's actually more correct to use the semis in this situation.

this.props.onChange(color.hex);
};

render() {
Copy link
Contributor

@cjcenizal cjcenizal Jun 13, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spoke with @zinckiwi and he suggested a way to support closing the colorpicker without adding an element which will intercept the mouse click. Here's the code:

  onClickRootElement = e => {
    // This prevents clicking on the element from closing it, due to the event handler on the
    // document object.
    e.nativeEvent.stopImmediatePropagation();
  }

  componentDidMount() {
    // When the user clicks somewhere outside of the color picker, we will dismiss it.
    document.addEventListener('click', this.closeColorSelector);
  }

  componentWillUnmount() {
    document.removeEventListener('click', this.closeColorSelector);
  }

  render() {
    const color = this.props.color || '#ffffff';
    const classes = classNames('kuiColorPicker', this.props.className);
    return (
      <div
        className={ classes }
        aria-label={ this.props['aria-label'] }
        data-test-subj={ this.props['data-test-subj'] }
        onClick={ this.onClickRootElement }
      >
        <div
          className="kuiColorPicker__preview"
          onClick={ this.toggleColorSelector }
        >
          <div
            className="kuiColorPicker__swatch"
            aria-label="Select a color"
            data-test-subj="colorSwatch"
            style={{ background: this.props.color }}
          />
          <div
            className="kuiColorPicker__label"
            aria-label={`Color selection is ${color}`}
          >
            { color }
          </div>
        </div>
        {
          this.state.showColorSelector ?
            <div className="kuiColorPickerPopUp" data-test-subj="colorPickerPopup">
              <ChromePicker
                color={ color }
                disableAlpha={ true }
                onChange={ this.handleColorSelection }
              />
            </div>
            : null
        }
      </div>
    );

With this change we add a click handler to the document which will close the color picker. When we click on the color picker itself, we prevent the event from reaching this handler; otherwise clicking the color picker would open and then immediately close it. What do you think @stacey-gammon?

@zinckiwi Can you confirm this is what you had in mind?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works like a charm, thanks @zinckiwi and @cjcenizal!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It turns out that this doesn't work well when there are multiple color pickers available on the same page, and you click from one immediately to the other.
screen shot 2017-06-21 at 4 21 54 pm

e.nativeEvent.stopImmediatePropagation(); stops the other color pickers from closing.

Have you run into this caveat at all @zinckiwi?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have a solution to get around this

z-index: 10;
}

.kuiColorPickerPopUpOverlay {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this class based on my other suggestion.

};

render() {
const color = this.props.color || '#ffffff';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also forgot to mention that I don't think we need this #ffffff here since we have it specified in defaultProps, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultProps is only used is the prop isn't passed at all, not if what is passed is null or undefined. So for the example:

export class ColorPicker extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      color: null
    };
  }

  handleChange = (value) => {
    this.setState({ color: value });
  };

  render() {
    return <KuiColorPicker onChange={ this.handleChange } color={ this.state.color }/>;
  }
}

it ends up passing null the first time, which means that nothing shows up. So I can either handle this externally, but it seemed easier to handle it internally. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see! Hm. I just read up on what the React team thinks about this, and here's what I'm thinking:

  • What happens if null is provided? I'd expect either nothing to happen, or an error. In either case, I wouldn't expect null to be interpreted as white. If it's an error, then should we just surface that error instead of hiding it?
  • I think we can set color: PropTypes.string.isRequired to have it default to white in the case that it's undefined.
  • Then we can get rid of the check within the render method.

Thoughts on this? It just feels a bit brittle and unexpected to have these two checks in different places.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'm happy to use the isRequired trick and expect containers to pass in undefined instead of null in order to default to the components choice of default color. Just didn't want to require the container to either pick a default, or do something fancy to determine whether or not to pass in a prop at all.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Though now that you mention it, I think it makes sense for the owner to pick a default. Since this component has no knowledge of its context, it doesn't really know whether a sensible default would be white, pink, or fuchsia (which, now that I google it, is the same color as pink... go figure).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha. well I can go either way. So are you saying you want me to remove the defaultProp = white entirely?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah let's just get rid of the defaultProp for now.

@zinckiwi
Copy link

Yup, that's the gist: catch all clicks except those originating in the popup.

>
<div
className="kuiColorPicker__swatch"
aria-label="Select a color"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also forgot to mention I love these aria-labels!! Accessibility out of the box.

Can we actually change this to be aria-label={ this.props['aria-label'] } and add a defaultProp for ariaLabel of "Select a color"?

The reason is that if someone provides an aria-label to this component in the current implementation, it will be applied to the root element. Due to the way screen readers interpret this attribute, only that root element's aria-label value will be read aloud, and the other aria-labels that belong to the child elements will be ignored entirely.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to change this line to be aria-label={ this.props['aria-label'] } and we need to remove the aria-label that's being set on the root element on line 50.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, sorry about that, slipped my radar

@stacey-gammon
Copy link
Contributor Author

Interesting, test failures are because of the latest changes, but the element works fine when I ran locally. Will have to investigate.

 FAIL  ui_framework/components/color_picker/color_picker.test.js
  ● pop up color selector is shown when the color swatch is clicked

    TypeError: e.nativeEvent.stopImmediatePropagation is not a function
      
      at KuiColorPicker._this.onClickRootElement (ui_framework/components/color_picker/color_picker.js:30:21)

@zinckiwi
Copy link

Ah yes. I'm not sure what the setup is on kibana but if you're not running that test through a real browser (phantom, Chrome, or the like) it would make sense there is no nativeEvent. A conditional around trying to access it should suffice.

@cjcenizal
Copy link
Contributor

@stacey-gammon I followed up with a couple more comments.

@stacey-gammon
Copy link
Contributor Author

ugh, sorry I messed up the history by rebasing. Didn't think it would do that, just wanted to push a change to one of my commits prior to the merge with master.

@stacey-gammon stacey-gammon force-pushed the react-color-picker branch 5 times, most recently from fea4c4e to 4df0dd7 Compare June 14, 2017 19:22
@stacey-gammon
Copy link
Contributor Author

test failure:

ERROR: Failed to upload files
com.amazonaws.SdkClientException: Unable to execute HTTP request: java.lang.RuntimeException: Unexpected error: java.security.InvalidAlgorithmParameterException: the trustAnchors parameter must be non-empty
	at com.amazonaws.http.AmazonHttpClient$RequestExecutor.handleRetryableException(AmazonHttpClient.java:1069)
	at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeHelper(AmazonHttpClient.java:1035)
	at com.amazonaws.http.AmazonHttpClient$RequestExecutor.doExecute(AmazonHttpClient.java:742)
	at com.amazonaws.http.AmazonHttpClient$RequestExecutor.executeWithTimer(AmazonHttpClient.java:716)

jenkins, test this

@stacey-gammon
Copy link
Contributor Author

Good to go for a final look @cjcenizal!

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is badass. Just had one suggestion, then let's merge this!

@@ -0,0 +1,29 @@
.kuiColorPicker {
cursor: pointer;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of adding display: inline-block here? This way the hit area of the element is restricted to just the color swatch and hex value. With the current display defaulting to block the entire horizontal area to the right of the color picker is clickable, even though it's just white space. Changing it to inline-block would also make it more consistent with form elements like text inputs.

@stacey-gammon stacey-gammon merged commit 4c3cf8b into elastic:master Jun 19, 2017
@zinckiwi
Copy link

(Can't reply inline with the multiple-pickers comment for some reason)

I suppose I had assumed that these pickers were modal -- that is, only one would be displayed at a time. Do they share a common parent element? It's probably a matter of finding the right element in the tree that is unique to each picker.

@stacey-gammon
Copy link
Contributor Author

@zinckiwi I think I have a solution here: #12458

Only one should be displayed at a time, but we aren't forcing them to be modal (e.g. external clicks don't just close the picker but also do whatever the click should do - so you can go from one open color picker to another with a single click).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants