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:
It takes some CSS and some measuring of rendered content to avoid overlaps:
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.”
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
]
]
[]
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.
- position styles apply with respect to the relative parent container
- 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!
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:
to:
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:
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:
This approach also works when the content flows on to multiple lines:
{-| 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_ ]
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!
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:
Ellie showing the balloon and arrow overlap problem
There are two problems here:
- The balloons need a clearer indication of their edges when they’re close together.
- 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.
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
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.
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 ]
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:
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!
Tessa Kelly@t_kelly9