Make the gmail star thing

(Alternate titles: make the facebook like thing, make the twitter fave thing, make the insta heart thing, All this webshit is the same and I’m spending my weekends codifying boolean emotions for corporations to package and sell to Ted Cruz: why I threw my laptop into a barbeque pit)

What we’re talking about today is one specific implementation of a very simple widget, conceptually indistinguishable from the above examples or, for instance an on-off switch.

It occurred to me after I converted this blog to Gatsby that this widget would actually be much easier to grok as a react styled component. If you’ve never used styled-components check them out! They’re nice for examples like this because they provide a way to view the javascript and the css in the same place, controlled with the same logic.

But really they’re pretty intuitive and I think you’ll see what I mean when I post the component below, without any prerequisite understanding of the library. The rest of the post after that will be dedicated to building the css from scratch and doing some jquery to get this working in a non-react setting. So here it is:

import React from "react"
import styled, { css } from "styled-components"
  
const Container = styled.div`
  cursor: default;
`
  
const Glyph = styled.span`
  font-size: 120%;
  color: ${props => (props.isActive ? "yellow" : "#f0f0f0")};
  ${props =>
    props.isActive &&
    css`
      &:before {
        content: "☆";
        position: absolute;
        color: lightgrey;
      }
      &:hover {
        cursor: pointer;
      }
    `}
  
  ${props =>
    !props.isActive &&
    css`
      &:hover:before {
        cursor: default;
        content: "☆";
        position: absolute;
        color: grey;
      }
    `}
`
  
const Star = ({ isActive, handleToggle }) => (
  <Container onClick={handleToggle}>
    <Glyph isActive={isActive}>{isActive ? "★" : "☆"}</Glyph>
  </Container>
)
  
export default Star

As you can see the Container is a div and the Glyph element is a span, each with some css applied conditionally.

It’s a common pattern in react to pass in the function that decides what happens when the element is clicked. This makes sense because you might want to use this star in different places with different applications. The one thing you’d expect to remain constant is that whichever prop is passed in to determine whether it’s “on” or not would be set to the opposite somewhere in that function. Changing that prop will cause the Star component to re-render (or “react” to the change). So here’s an oversimplified wrapper around the component to demonstrate:

import React from "react"
import Star from "./Star"
  
class StarWrapper extends React.Component {
  constructor(props) {
    super(props)
    this.state = { isActive: false }
  }
  
  handleToggle(e) {
    this.setState({ isActive: !this.state.isActive })
  }
  
  render() {
    return (
      <Star
        isActive={this.state.isActive}
        handleToggle={this.handleToggle.bind(this)}
      />
    )
  }
}
  
export default StarWrapper

Here is the working styled component:

This is a feature request I think I’ve gotten at every job I’ve had. If you’ve been making webshit long enough you’ve felt this request appear in your inbox, you didn’t have to check your email — it was there in the morning and when you woke up you knew. And for good reason, this widget is everywhere. If you’re using chrome it’s IN the chrome of your browser (check the right side of your url bar). It’s in slack. It’s probably somewhere in my god damned terminal but I just haven’t found it yet.

There are basically two characters we’re gonna use: black star which is the most metal unicode character, and white star which is the outline of the black star (and isn’t yet the name of a neo-nazi fetlife discussion group, so keep your fingers crossed).

These look like ☆ and ★ respectively. (By the way, if you wanna type these in vim, you just need to ctrl+V in insert mode, then type u for unicode, then the 4-hexchar unicode sequence. So those last two characters were ctrl+v+u+2606 and ctrl+v+u+2605.)

So check this out:

<div id="star_{{post.id}}" class="star-div">
    <span class="star">&star;</span>
</div>

with this css

.star {
    font-size:120%;
    color: #F0F0F0;
}

.star-div {
    cursor: default;
}

.star-div > span:hover:before {
    cursor: default;
    content: "\2606";
    position: absolute;
    color: grey;
}

renders like this:

Hover over it. We are using CSS positioning to overlay a slightly darker star glyph over the original one on hover. The position: absolute property means that the positioning is computed relative to the container block (in this case the div with the star-div class), and we’re simply not specifying any top, bottom, left, or right offsets. So it sits right on top.

All we really need to do to the star-div class is prevent it from messing with the cursor, because for something unintrusive like a star that hasn’t even been activated yet, that’s just obnoxious. In reality you’ll probably want it next to something, and so display: inline-block might be worth having close by.

Next we want to figure out a way to display the “starred” variant: just use the filled in star and color it yellow!

<div id="star_{{post.id}}" class="star-div-active">
    <span class="star-active">&starf;</span>
</div>

and use the following css

.star-active {
    font-size:120%;
    color: yellow;
}

.star-div-active > span:hover {
    cursor: pointer;
}

which renders like this:

Here we want to mess with the cursor — everyone disagrees with me here but I feel like it’s better than adding another distracting hover effect. I will never add comments to this blog so unfortunately your feelings about this are not actionable at this time. This is sort of what the gmail star looks like (actually they use the pointer for unstarred, as well as for the starred rows) but this is less intrusive.

Only problem is it’s kind of hard to see. We can overlay the outline star to apply a kind of “border”. Looks like this in the css:

.star-div-active > span:before {
    content: "\2606";
    position: absolute;
    color: lightgrey;
}

and this on the page:

Now all we have left is to make it interactive. This is pretty easy.

In the javascript we just check to see if the star is, uh, starred or not, and make it the other one. And then probably bust off an ajax to tell the server what you did. This logic is expressed neatly in jquery below:

$('.star-div,.star-div-active').on('click', function(ev) {
  starEl = $(this).find('span')
  starred = starEl.hasClass('star-active')
  // change the DOM first, THEN make the ajax call
  if (starred) {
    starEl.removeClass('star-active').addClass('star').text('\u2606')
    starEl.parent().removeClass('star-div-active').addClass('star-div')
  } else {
    starEl.removeClass('star').addClass('star-active').text('\u2605')
    starEl.parent().removeClass('star-div').addClass('star-div-active')
  }
  $.ajax({
    type: 'POST',
    url: '/api/whatever',
    data: JSON.stringify({
      id: element_id,
      starred: !starred,
      }),
    contentType: 'application/json; charset=utf-8',
    dataType: 'json',
    success: function (data) {
      // whatever you wanna do, go nuts!            
    },
    error: function(xhr) {
      $('nav').next().prepend(`<div class="alert alert-dismissable alert-danger"><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button> Error ${xhr.statusCode().status}: ${xhr.statusCode().statusText} ${xhr.responseText} </div>`)
    }
    })
})

So now we have this:

Click on it.

One last thing. Once you complete this you’ll probably send an email to your boss and he’ll try it out right there from his iPhone in the shower. He’ll notice that it takes him two taps to actually get the star to change, and the first tap just applies the hover effect. You may have noticed this too.

You’ve discovered The Annoying Mobile Double-Tap Link issue, documented extensively by Nicholas C. Zakas back in 2012.

To save you a click (or two taps) there are a couple solutions. You can use a media query to only apply the hover pseudoclasses on devices with screens wider than 600 pixels

@media only screen and (min-width: 600px) {...}

(you can look up the breakpoints, but the iPhone 5S and iPhone 6+ won’t hit this at 320px and 414px).

Or you can use the pointer media query

@media (pointer: fine) {...}

This is arguably the best solution as it only applies the styles to devices identified as having highly accurate pointing devices. Your greasy fingers are classified as “coarse” according to the spec, but there are other options.

There’s also the hover media feature, which appears to be exactly what we need.

@media (hover) {...}

but according to the link it didn’t work on firefox in 2016. Since nobody used firefox in 2016 this claim is impossible to verify but I’ll take their word for it.

So our final css will look like this:

.star {
    font-size:120%;
    color: #F0F0F0;
}

.star-active {
    font-size:120%;
    color: yellow;
}

.star-div-active > span:before {
    content: "\2606";
    position: absolute;
    color: lightgrey;
}

.star-div {
    cursor: default;
}

 @media (hover) {
    .star-div > span:hover:before {
        cursor: default;
        content: "\2606";
        position: absolute;
        color: grey;
     }
     .star-div-active > span:hover {
        cursor: pointer;
    }
}

Enjoy.