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!BalloonThe 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
]
]
[]Ellie balloon examplePositioning a Balloon over a WordNext, 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 containertransform translations apply with respect to the element itselfThis 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 exampleConveying the balloon meaning without stylesIt'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 relationshipFixing horizontal Balloon overlapsBalloons 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.fromListThen 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 exampleFixing Balloons overlapping content above themOur 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
NoRedInk is a California-based digital writing platform that provides solutions such as interest-based curriculum, adaptive exercises and actionable data to students.