Quantcast
Channel: NoRedInk
Viewing all articles
Browse latest Browse all 193

Word Labels

$
0
0

Working at NoRedInk, I have the opportunity to work on such a variety of challenges and puzzles! It’s a pleasure to figure out how to build ambitious and highly custom components and applications.

Recently, I built a component that will primarily be used for labeling sentences with parts of speech.

This component was supposed to show “labels” over words while guaranteeing that the labels wouldn’t cover meaningful content (including other labels). This required labels to be programmatically and dynamically repositioned to keep content readable:

Screenshot of the sentence "Dave broke his french fry so he glued it with ketchup." "Dave" and "he" have pink balloons labelling each as a "subject". "Broke" and "glued" have yellow balloons labelling each as a "verb". The "subject" labels are offset above the "verb" labels so that no content is obscured.

It takes some CSS and some measuring of rendered content to avoid overlaps:

Screenshot of the sentence "Dave broke his french fry so he glued it with ketchup." "Dave" and "he" have pink balloons labelling each as a "subject". "Broke" and "glued" have yellow balloons labelling each as a "verb". The "verb" labels partially overlap the "subject" balloons, especially the "subject" balloon over "he".

All meaningful content needs to be accessible to users, so it’s vital that content not be obscured.

In this post, I’m going to go through a simplified version of the Elm, CSS, and HTML I used to accomplish this goal. I’m going to focus primarily on the positioning styles, since they’re particularly tricky!

Balloon

The first piece we need is a way to render the label in a little box with an arrow. To avoid confusion over HTML labels, we’ll call this little component “Balloon.”

Screenshot of a black box containing the white text "Example label". A little black triangle is attached to the bottom of the black box, giving the impression that the box is pointing to whatever is below it.

balloon : String -> Html msg
balloon label =
    span
        [ css
            [ Css.display Css.inlineFlex
            , Css.flexDirection Css.column
            , Css.alignItems Css.center
            ]
        ]
        [ balloonLabel label
        , balloonArrow initialArrowSize
        ]


balloonLabel : String -> Html msg
balloonLabel label =
    span
        [ css
            [ Css.backgroundColor black
            , Css.color white
            , Css.border3 (Css.px 1) Css.solid black
            , Css.margin Css.zero
            , Css.padding (Css.px 4)
            , Css.maxWidth (Css.px 175)
            , Css.property "width" "max-content"
            ]
        ]
        [ text label ]


initialArrowSize : Float
initialArrowSize =
    10


balloonArrow : Float -> Html msg
balloonArrow arrowHeight =
    span
        [ attribute "data-description" "balloon-arrow"
        , css
            [ Css.borderStyle Css.solid

            -- Make a triangle
            , Css.borderTopWidth (Css.px arrowHeight)
            , Css.borderRightWidth (Css.px initialArrowSize)
            , Css.borderBottomWidth Css.zero
            , Css.borderLeftWidth (Css.px initialArrowSize)

            -- Colors:
            , Css.borderTopColor black
            , Css.borderRightColor Css.transparent
            , Css.borderBottomColor Css.transparent
            , Css.borderLeftColor Css.transparent
            ]
        ]
        []

Ellie balloon example

Positioning a Balloon over a Word

Next, we want to be able to center a balloon over a particular word, so that it appears that the balloon is labelling the word.

This is where an extremely useful CSS trick can come into play: position styles don’t have the same frame of reference as transform styles.

  1. position styles apply with respect to the relative parent container
  2. transform translations apply with respect to the element itself

This means that we can combine position styles and transform translations in order to center an arbitrary-width balloon over an arbitary-width word.

Adding the following styles to the balloon container:

, Css.position Css.absolute
, Css.left (Css.pct 50)
, Css.transforms [ Css.translateX (Css.pct -50), Css.translateY (Css.pct -100) ]

and rendering the balloon in the same position-relative container as the word itself:

word : String -> Maybe String -> Html msg
word word_ maybeLabel =
    span
        [ css
            [ Css.position Css.relative
            , Css.whiteSpace Css.preWrap
            ]
        ]
        (case maybeLabel of
            Just label ->
                [ balloon label, text word_ ]

            Nothing ->
                [ text word_ ]
        )

handles our centering!

Screenshot of two examples. The first is labelled as an "Example with a short word and wide balloon" and shows the letter "w" with a balloon centered above it. The ballon says "Balloons have a maximium width of 175px" and the balloon text wraps after 175 pixels. The second example is labelled as an "Example with a wide word and narrow balloon" and shows the word "loquaciousness" with a balloon centered above it. The balloon says "noun" and is much narrower than 175 pixels and much narrower than the word "loquaciousness".

Ellie centering-a-balloon example

Conveying the balloon meaning without styles

It’s important to note that while our styles do a solid job of associating the balloon with the word, not all users of our site will see our styles. We need to make sure we’re writing semantic HTML that will be understandable by all users, including users who aren’t experiencing our CSS.

For the purposes of the NoRedInk project that the component that I’m describing here will be used for, we decided to use a mark element with ::before and ::after pseudo-elements to semantically communicate the meaning of the balloon to assistive technology users. Then we marked the balloon itself as hidden, so that the user wouldn’t experience annoying redundant information.

Since this post is primarily focused on CSS, I’m not going to expand on this more. Please read “Tweaking Text Level Styles” by Adrian Roselli to better understand the technique we’re using.

Ellie improving the balloon-word relationship

Fixing horizontal Balloon overlaps

Balloons on the same line can potentially overlap each other on their left and right edges. Since we want users to be able to adjust font size preferences and to use magnification as much as they want, we can’t guarantee anything about the size of the labels or where line breaks occur in the text.

This means we need to measure the DOM and reposition labels dynamically. For development purposes, it’s convenient to add a button to measure and reposition the labels on demand. For production uses, labels should be measured and repositioned on page load, when the window changes sizes, or when anything else might happen to change the reflow.

To measure the element, we can use Browser.Dom.getElement, which takes an html id and runs a task to measure the element on the page.

type alias Model =
    Dict.Dict String Dom.Element


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GetMeasurements ->
            ( model, Cmd.batch (List.map measure allIds) )

        GotMeasurements balloonId (Ok measurements) ->
            ( Dict.insert balloonId measurements model
            , Cmd.none
            )

        GotMeasurements balloonId (Err measurements) ->
            -- in a real application, handle errors gracefully with reporting
            ( model, Cmd.none )


measure : String -> Cmd Msg
measure balloonId =
    Task.attempt (GotMeasurements balloonId) (Dom.getElement balloonId)

Then we can do some logic (optimized for clarity rather than performance, since we’re not expecting many balloons at once) to figure out how far the balloons need to be offset based on these measurements:

arrowHeights : Dict.Dict String Dom.Element -> Dict.Dict String Float
arrowHeights model =
    let
        bottomY { element } =
            element.y + element.height
    in
    model
        |> Dict.toList
        --
        -- first, we sort & group by line, so that we're only looking for horizontal overlaps between
        -- balloons on the same line of text
        |> List.sortBy (Tuple.second >> bottomY)
        |> List.Extra.groupWhile (\( _, a ) ( _, b ) -> bottomY a == bottomY b)
        |> List.map (\( first, rem ) -> first :: rem)
        --
        -- for each line,we find horizontal overlaps
        |> List.concatMap
            (\line ->
                line
                    |> List.sortBy (Tuple.second >> .element >> .x)
                    |> List.Extra.groupWhile (\( _, a ) ( _, b ) -> (a.element.x + a.element.width) >= b.element.x)
                    |> List.map (\( first, rem ) -> first :: rem)
            )
        --
        -- now we have our overlaps and our singletons!
        |> List.concatMap
            (\overlappingBalloons ->
                overlappingBalloons
                    --
                    -- we sort each overlapping group by width: we want the widest balloon on top
                    -- (why? the wide balloons might overlap multiple other balloons. Putting the widest balloon on top is a step towards a maximally-compact solution.)
                    |> List.sortBy (Tuple.second >> .element >> .width)
                    -- then we iterate over the overlaps and account for the previous balloon's height
                    |> List.foldl
                        (\( idString, e ) ( index, height, acc ) ->
                            ( index + 1
                            , height + e.element.height
                            , ( idString, height )
                                :: acc
                            )
                        )
                        ( 0, initialArrowSize, [] )
                    |> (\( _, _, v ) -> v)
            )
        |> Dict.fromList

Then we thread the offset we get all the way through to the balloon’s arrow so that it can expand in height appropriately.

This works!

We can reposition from:

Screenshot showing 5 marked words each with a balloon. The balloon content overlaps such that the balloons are illegible.

to:

Screenshot showing 5 marked words each with a balloon. The balloons' arrows are different heights, keeping the balloon content from overlapping. The balloons take up a lot of space.

Ellie initial repositioning example

Fixing Balloons overlapping content above them

Our balloons are no longer overlapping each other, but they still might overlap content above them. They haven’t been overlapping content above them in the examples so far because I sneakily added a lot of margin on top of their containing paragraph tag. If we remove this margin:

Screenshot showing balloons overlapping and obscuring text. The screenshot is hard to parse visually because the balloons have a black background and the partially covered text is also black.

This seems like a challenging problem: how can we make an absolutely-positioned item take up space in normal inline flow? We can’t! But what we can do is make our normal inline words take up more space to account for the absolutely positioned balloon.

When we have a label, we are now going to wrap the word in a span with display: inline-block and with some top padding. This will guarantee that’s there’s always sufficient space for the balloon after we finish measuring.

I’ve added a border around this span to make it more clear what’s happening in the screenshots:

Screenshot showing 5 marked words each with a balloon. Each marked word and balloon pair is surrounded by a blue border.

This approach also works when the content flows on to multiple lines:

Screenshot showing a very narrow viewport where 2 of 5 marked words with balloons have flowed on to a second line. The balloons on the second line do not cover the words on the first line.

{-| The height of the arrow and the total height are different, so now we need to calculate 2 different values based on our measurements. -}
type alias Position =
    { arrowHeight : Float
    , totalHeight : Float
    }


word : String -> Maybe { label : String, id : String, position : Maybe Position } -> Html msg
word word_ maybeLabel =
    let
        styles =
            [ Css.position Css.relative
            , Css.whiteSpace Css.preWrap
            ]
    in
    case maybeLabel of
        Just ({ label, position } as balloonDetails) ->
            mark
                [ css
                    (styles
                        ++ [ Css.before
                                [ Css.property "content" ("\" start " ++ label ++ " highlight \"")
                                , invisibleStyle
                                ]
                           , Css.after
                                [ Css.property "content" ("\" end " ++ label ++ " \"")
                                , invisibleStyle
                                ]
                           ]
                    )
                ]
                [ span
                    [ css
                        [ Css.display Css.inlineBlock
                        , Css.border3 (Css.px 2) Css.solid (Css.rgb 0 0 255)
                        , Maybe.map (Css.paddingTop << Css.px << .totalHeight) position
                            |> Maybe.withDefault (Css.batch [])
                        ]
                    ]
                    [ balloon balloonDetails
                    , text word_
                    ]
                ]

        Nothing ->
            span [ css styles ]
                [ text word_ ]

Ellie avoiding top overlaps

Fixing multiple repositions logic

Alright! So we’ve prevented top overlaps and we’ve prevented balloons from overlapping each other on the sides.

There is still a repositioning problem though! We need to reposition the labels again based on window events like resizing. Right now, we’re measuring the height of the entire balloon including the arrow, and then using that height to calculate how tall a neighboring balloon’s arrow needs to be. This means that subsequent remeasures can make the arrows far taller than they need to be!

Screenshot showing 5 marked words each with a balloon. The balloons' arrows are different heights that prevent overlaps, but the arrows are excessively tall.

We’re measuring the entire rendered balloon when we check for overlaps and figure out our repositioning, but we should really only be taking into consideration whether balloons overlap when in their starting positions.

Essentially, we need to disregard the measured height of the arrow entirely when calculating new arrow heights. A straightforward way to do this is to measure the content within the balloon separately from the overall balloon measurement.

We add a new id to the balloon content:

balloonContentId : String -> String
balloonContentId baseId =
    baseId ++ "-content"


balloonLabel : { config | label : String, id : String } -> Html msg
balloonLabel config =
    p
        [ css
            [ Css.backgroundColor black
            , Css.color white
            , Css.border3 (Css.px 1) Css.solid black
            , Css.margin Css.zero
            , Css.padding (Css.px 4)
            , Css.maxWidth (Css.px 175)
            , Css.property "width" "max-content"
            ]
        , id (balloonContentId config.id)
        ]
        [ text config.label ]

and measure the balloon content when we measure the total balloon:

measure : String -> Cmd Msg
measure balloonId =
    Task.map2 (\balloon_ balloonContent -> { balloon = balloon_, balloonContent = balloonContent })
        (Dom.getElement balloonId)
        (Dom.getElement (balloonContentId balloonId))
        |> Task.attempt (GotMeasurements balloonId)

We also change our position calculation helper from:

positions : Dict.Dict String Dom.Element -> Dict.Dict String Position
positions model =
                    ...
                    -- then we iterate over the overlaps and account for the previous balloon's height
                    |> List.foldl
                        (\( idString, e ) ( index, height, acc ) ->
                            ( index + 1
                            , height + e.element.height
                            , ( idString
                              , { totalHeight = height + e.element.height - initialArrowSize
                                , arrowHeight = height
                                }
                              )
                                :: acc
                            )
                        )
                        ( 0, initialArrowSize, [] )
                    ...

to

positions : Dict.Dict String { balloon : Dom.Element, balloonContent : Dom.Element } -> Dict.Dict String Position
positions model =
                    ...
                    -- then we iterate over the overlaps and account for the previous balloon's height
                    |> List.foldl
                        (\( idString, e ) ( index, height, acc ) ->
                            ( index + 1
                            , height + e.balloonContent.element.height
                            , ( idString
                              , { totalHeight = height + e.balloonContent.element.height
                                , arrowHeight = height
                                }
                              )
                                :: acc
                            )
                        )
                        ( 0, initialArrowSize, [] )
                    ...

Ellie with fixed multiple repositioning logic

Fixing overlaps with the arrow

I claimed previously that we fixed overlaps between the balloons. This is true-ish: we actually only fixed overlaps between pieces of balloon content. A meaningful piece of balloon content can actually still overlap another balloon’s arrow! And, since the content stacks from left to right, there’s a chance that meangingful content might be obscured by an arrow:

Screenshot showing words "A", "B", "C" from left to right with ballons "longest balloon", "medm balloon", and "tiny balloon" from top to bottom. The lower two ballons' content is partially obscured by the top balloon's arrow. The top and bottom edges of the balloons touch.

Ellie showing the balloon and arrow overlap problem

There are two problems here:

  1. The balloons need a clearer indication of their edges when they’re close together.
  2. The left-to-right stacking context won’t work. We need to put the bottom balloon on top of the stacking context so that balloon content is never obscured.

The first problem is improved by adding white borders to the balloon content and shifting the balloon arrow up a corresponding amount.

Screenshot showing words "A", "B", "C" from left to right with ballons "longest balloon", "medm balloon", and "tiny balloon" from top to bottom. The lower two ballons' content is partially obscured by the top balloon's arrow. The edges of the balloons no longer touch and where the "tiny balloon" overlaps the "medm balloon" there is whitespace around the "tiny balloon" content.

Ellie where each balloon has a white border around its content

The second part of the problem can be fixed by adding a zIndex to the balloon based on position in the overlapping rows, so that arrows never cover label content:

positions : Dict.Dict String { balloon : Dom.Element, balloonContent : Dom.Element } -> Dict.Dict String Position
positions model =
        ...
        -- now we have our overlaps and our singletons!
        |> List.concatMap
            (\overlappingBalloons ->
                let
                    maxOverlappingBalloonsIndex =
                        List.length overlappingBalloons - 1
                in
                overlappingBalloons
                    --
                    -- we sort each overlapping group by width: we want the widest balloon on top
                    |> List.sortBy (Tuple.second >> .balloon >> .element >> .width)
                    -- then we iterate over the overlaps and account for the previous balloon's height
                    |> List.foldl
                        (\( idString, e ) ( index, height, acc ) ->
                            ( index + 1
                            , height + e.balloonContent.element.height
                            , ( idString
                              , { totalHeight = height + e.balloonContent.element.height
                                , arrowHeight = height
                                , zIndex = maxOverlappingBalloonsIndex - index
                                }
                              )
                                :: acc
                            )
                        )
                        ( 0, initialArrowSize, [] )
        ...

Ellie with the zIndex logic applied

Screenshot showing words "A", "B", "C" from left to right with ballons "longest balloon", "medm balloon", and "tiny balloon" from top to bottom. No content is obscured, even though the balloons overlap arrows.

Fixing content extending beyond the viewport

We’re almost done with ways that the balloons can overlap content!

For our purposes, we expect words marked with a balloon to be the only meaningful content showing in a line. That is, we’re not worried about the balloon overlapping something meaningful to its right or left because we know how the component will be used. This is great, since it really simplifies the overlap problem for us: it means we only need to worry about the viewport edges cutting off balloons.

Screenshot showing 3 balloons cut off on the left edge of the viewport

Browser.Dom.getElement also returns information about the viewport, which we can use to adjust our position logic to get the amount that a given balloon is “cut off” on the horizontal edges. Once we have this xOffset, using CSS to scootch a balloon over is nice and straightforward:

balloonLabel : { label : String, id : String, position : Maybe Position } -> Html msg
balloonLabel config =
    p
        [ css
            [ Css.backgroundColor black
            , Css.color white
            , Css.border3 (Css.px 2) Css.solid white
            , Css.margin Css.zero
            , Css.padding (Css.px 4)
            , Css.maxWidth (Css.px 175)
            , Css.property "width" "max-content"
            , Css.transform (Css.translateX (Css.px (Maybe.map .xOffset config.position |> Maybe.withDefault 0)))
            ]
        , id (balloonContentId config.id)
        ]
        [ text config.label ]

Ellie with x-offset logic

Screenshot showing 3 balloons shifted right to account for the left edge of the viewport. Each arrow is still centered over the word it is marking.

Please note that when elements are pushed to the right of the viewport edge, by default, the browser will give you a scrollbar to get over to see the content.

Ellie demoing right-scroll when an element is translated off the right edge of the viewport.

You may want to hide overflow in the x direction to prevent the scrollbar from appearing/your mobile styles from getting messed up.

That’s (mostly) it!

You might notice that the solution isn’t maximally compact:

Screenshot showing 3 balloons that could have been positioned over 2 lines, but instead were positioned over 3 lines.

This is where our MVP line was. If it turns out that our content ends up taking up too much vertical space, and we want to go for the maximally compact version, we’ll revisit the implementation. Since the logic is all in one easily-testable Elm function, improving the algorithm should be pretty straightfoward.

Additionally, this post didn’t get into high-contrast mode styles. The way that the balloon is styled currently is utterly inaccessible in high contrast mode! Anytime you’re changing background color and font color, it’s important to check in high-contrast mode to see how the styles are being coerced. I’ve written a couple of previous posts talking about color considerations (Custom Focus Rings, Presenting Styleguide Colors) so I don’t want to dig into how to fix this problem in this post. Please be aware that while the positioning styles in this post are fairly solid, the rest of the styles are not production ready.


Thanks for reading along! I had a great time working on this project and I’m quite pleased with how it came out. I’m looking forward to sharing the next interesting project I work on with you!

Photo of the authorTessa Kelly@t_kelly9


Viewing all articles
Browse latest Browse all 193

Trending Articles