DEV Community

loading...

#30daysofelm Day 6: Accessible background colors

kristianpedersen profile image Kristian Pedersen ・8 min read

This is day 6 of my 30 day Elm challenge

Code/demo: https://ellie-app.com/bSjxHwhPS4Za1
Improved version by @bukkfrig : https://ellie-app.com/bSwbq324RPya1

Screenshot:

About today's project / Acknowledgments

Today's project is about generating colors, and checking whether black or white text is the most readable, according to Web Content Accessibility Guidelines (WCAG) standards.

Tessa Kelly

The colors and accessibility parts of today's project were made possible by tesk9/palette. I discovered it through a talk by the package's author Tessa Kelly: Color coding with Elm. Just look at that swirly Cubehelix at 30:17! :D

Wolfgang Schuster / @wolfadex

His comment on yesterday's post (and the other ones) taught me a lot! The Elm community in general has been really helpful and supportive.

megapctr @ Slack

Thanks for reminding me that the update function needs two arguments. I would have gotten stuck for an hour if you hadn't helped me! :D

Table of contents

Main.elm walkthrough

This is my longest project so far. elm-format likes having tall files, which is something I'll get used to eventually.

I realize my order has been kind of wrong previously. It's called "model, view, update", but I think I've had the view last.

From now on, I'll stick to the MVU convention.

1. Imports

Luckily, the Elm VS Code extension can add things to the imports automatically. If you type div without having imported it, a tooltip shows up which asks if you want to add it to the imports.

module Main exposing (..)

import Browser
import Html exposing (Html, div, form, input, label, li, span, text, ul)
import Html.Attributes exposing (for, name, step, style, type_)
import Html.Events exposing (onInput)
import Html.Events.Extra exposing (onChange)
import Maybe exposing (withDefault)
import Palette.Cubehelix as Cubehelix
import SolidColor exposing (SolidColor)
import SolidColor.Accessibility exposing (checkContrast, meetsAA, meetsAAA)
Enter fullscreen mode Exit fullscreen mode

Today's two installs were:

  1. elm install tesk9/palette
  2. elm install elm-community/html-extra

Html.Extra contains the onChange events, which I wanted to use. No point in updating the model with onClick, if it's the same radio button being clicked.

tesk9/palette includes both Palette.Cubehelix and SolidColor.

SolidColor is just how you generate colors, I guess.

Cubehelix is this super cool swirl of RGB - just look at that!

2. Main and Msg

main : Program () Model Msg
main =
    Browser.sandbox { init = init, update = update, view = view }


type Msg
    = GenerateNewColors String String
    | ChangeAccessibilityRating String String
Enter fullscreen mode Exit fullscreen mode

main is the same old main function we've seen before.

The Msg type is something I'm understanding better now, and it gives a good overview of the available user actions.

Events send some information by default, which is listed in the event documentation, but you can also decide to send other things with them.

onInput and onChange first grab the string value of event.target.value, but then it can also pass additional information, which in this case is another string.

If I wanted to pass a tuple of XY values, I could have written type Msg = GenerateNewColors (Int, Int) String.

3. Model

type alias Model =
    { palette : List SolidColor
    , rating : SolidColor.Accessibility.Rating
    , cubehelixStart : ( Int, Int, Int )
    , cubehelixRotations : Float
    , cubehelixGamma : Float
    }


initialColors : List SolidColor
initialColors =
    Cubehelix.generateAdvanced 100
        { start = SolidColor.fromHSL ( 80, 100, 0 )
        , rotationDirection = Cubehelix.BGR
        , rotations = 1.2
        , gamma = 0.9
        }


init : Model
init =
    { palette = initialColors
    , rating = SolidColor.Accessibility.AA
    , cubehelixStart = ( 80, 100, 0 )
    , cubehelixRotations = 1.2
    , cubehelixGamma = 0.9
    }
Enter fullscreen mode Exit fullscreen mode

Today's most difficult obstacle was the model. Initially, I had a nested structure, but I couldn't figure out how to do it with the record update syntax.

Also, nested models seem quite frowned upon:

I can see how multiple levels of nesting can be problematic, but I think my model would have looked better like this:

type alias Model =
    { palette : List SolidColor
    , rating : SolidColor.Accessibility.Rating
    , cubehelixSettings : 
        { start : ( Int, Int, Int )
        , rotations : Float
        , gamma: Float
        }
    }
Enter fullscreen mode Exit fullscreen mode

The initialColors makes the init function nicer to read.

4. View

Check out this beautiful view function!

It's all right there, I can tell immediately how my layout is structured, and I can easily re-order it.

view : Model -> Html Msg
view model =
    div [ style "font-family" "sans-serif" ]
        [ selectAAorAAA
        , adjustColors
        , showCubehelixInfo model
        , colorRectangles model
        ]
Enter fullscreen mode Exit fullscreen mode

Let's go check out what these 4 functions do:

4.1 Select AA or AAA rating

selectAAorAAA : Html Msg
selectAAorAAA =
    form []
        [ label []
            [ input
                [ type_ "radio"
                , name "accessibility-type"
                , onChange (ChangeAccessibilityRating "AA")
                ]
                []
            , text "AA"
            ]
        , label []
            [ input
                [ type_ "radio"
                , name "accessibility-type"
                , onChange (ChangeAccessibilityRating "AAA")
                ]
                []
            , text "AAA"
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

The labels allow me to click the text to change the radio buttons. Please do this!

As discussed previously, events can have any kind of message associated with them! onChange passes event.target.value, and then the string I specify. We'll see how these are used in the update function later.

AA and AAA are the mid-range and highest levels of conformance to the Web Content Accessibility Guidelines.

In today's project, we're only using these in the context of background/foreground color contrast.

Accessibility is something I'm familiar with beyond using the right HTML tags and not having yellow text on a while background.

Accessible websites also benefit me, and Norwegian websites are required by law to be accessible.

4.2 Sliders for adjusting color

adjustColors : Html Msg
adjustColors =
    form []
        [ label [ for "hue" ]
            [ text "Hue"
            , input
                [ type_ "range"
                , onInput (GenerateNewColors "hue")
                , Html.Attributes.min "0"
                , Html.Attributes.max "360"
                , Html.Attributes.value "0"
                , step "5"
                ]
                []
            ]
        , label [ for "rotations" ]
            [ text "Rotations"
            , input
                [ type_ "range"
                , onInput (GenerateNewColors "rotations")
                , Html.Attributes.min "0"
                , Html.Attributes.max "2"
                , Html.Attributes.value "1.2"
                , step "0.1"
                ]
                []
            ]
        , label [ for "gamma" ]
            [ text "Gamma"
            , input
                [ type_ "range"
                , onInput (GenerateNewColors "gamma")
                , Html.Attributes.min "0"
                , Html.Attributes.max "2"
                , Html.Attributes.value "0.8"
                , step "0.1"
                ]
                []
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

Again, we see how the events match the Msg type defined earlier. In addition to GenerateNewColors and a custom message, the update function also receives event.target.value as a string.

4.3 Show Cubehelix info

This is a function that just displays parts of the model as text.

When writing this, I saw that hslText had to be read backwards to make sense. I found a nice way to fix this problem, which is shown below the code:

showCubehelixInfo : Model -> Html Msg
showCubehelixInfo model =
    div []
        [ let
            ( h, s, l ) =
                model.cubehelixStart

            rotations =
                model.cubehelixRotations

            gamma =
                model.cubehelixGamma

            hslText =
                text ("HSL: " ++ String.join ", " (List.map String.fromInt [ h, s, l ]))

            rotationsText =
                text ("Rotations: " ++ String.fromFloat rotations)

            gammaText =
                text ("Gamma: " ++ String.fromFloat gamma)
          in
          ul []
            [ li [] [ hslText ]
            , li [] [ rotationsText ]
            , li [] [ gammaText ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

model.cubehelixStart contains a tuple of three values (Int, Int, Int), which are extracted into three variables, h, s and l.

  • Hue: Which color of the rainbow?
  • Saturation: 0% - 100% = grayscale -> color -> MS Paint color
  • Lightness: 0% - 100% = black - color - white

[h, s, l] contains three Ints. These are converted to strings, and then joined into one string, with commas as the separator. However, the code seems kind of backwards!

To fix this, let's use the pipe operator instead. I haven't read anything about it yet, but I've seen several code examples that use it.

I think the order makes a lot more sense now:

hslText =
    text
        ("HSL: "
            ++ ([ h, s, l ]
                    |> List.map String.fromInt
                    |> String.join ", " 
                )
        )
Enter fullscreen mode Exit fullscreen mode

4.4 Check readability on selected background color

This function is used in function 4.5 that renders all the colored rectangles.

isBlackTextReadable : Model -> SolidColor -> Bool
isBlackTextReadable model backgroundColor =
    checkContrast { fontSize = 12, fontWeight = 700 }
        backgroundColor
        (SolidColor.fromRGB ( 0, 0, 0 ))
        |> (case model.rating of
                SolidColor.Accessibility.AA ->
                    meetsAA

                SolidColor.Accessibility.AAA ->
                    meetsAAA

                _ ->
                    meetsAAA
           )
Enter fullscreen mode Exit fullscreen mode

The checkContrast function comes from SolidColor.Accessibility. I just copied the example, but to be honest, I would have prefered to write it as SolidColor.Accessibility.checkContrast.

It's a long name, but at least I can tell at a glance that it's not just some function I made and forgot about.

If the model's rating is AA, return meetsAA - otherwise, return meetsAAA.

4.5 Color rectangles

colorRectangles : Model -> Html Msg
colorRectangles model =
    div [] <|
        List.map
            (\color ->
                span
                    [ style "background-color" (SolidColor.toHSLString color)
                    , style "display" "inline-block"
                    , style "padding" "1rem"
                    , style "width" "calc(10vw - 1rem)"
                    , style "color"
                        (if isBlackTextReadable model color then
                            "black"

                         else
                            "white"
                        )
                    ]
                    [ text (SolidColor.toHex color)
                    ]
            )
            model.palette
Enter fullscreen mode Exit fullscreen mode

For each color in the model palette, make a span element, with the color as a hex string.

I could have chosen hex for the "background-color" attribute as well, but I'm just so used to thinking in HSL.

5. Update

The update function is a tall one! It's mostly just doing simple updates to the model, depending on the message and value passed in.

The let statement just allows us to use shorter variable names, which makes the return statements easier to read.

update : Msg -> Model -> Model
update msg model =
    case msg of
        ChangeAccessibilityRating rating _ ->
            case rating of
                "AA" ->
                    { model | rating = SolidColor.Accessibility.AA }

                "AAA" ->
                    { model | rating = SolidColor.Accessibility.AAA }

                _ ->
                    model

        GenerateNewColors parameter value ->
            let
                hue =
                    withDefault 0 (String.toFloat value)

                rotations =
                    model.cubehelixRotations

                gamma =
                    model.cubehelixGamma

                updatedPalette =
                    Cubehelix.generateAdvanced 100
                        { start = SolidColor.fromHSL ( 80, 100, 0 )
                        , rotationDirection = Cubehelix.BGR
                        , rotations = rotations
                        , gamma = gamma
                        }
            in
            case parameter of
                "hue" ->
                    { model
                        | cubehelixStart = ( floor hue, 100, 0 )
                        , palette =
                            Cubehelix.generateAdvanced 100
                                { start = SolidColor.fromHSL ( hue, 100, 0 )
                                , rotationDirection = Cubehelix.BGR
                                , rotations = rotations
                                , gamma = gamma
                                }
                    }

                "rotations" ->
                    { model
                        | cubehelixRotations = withDefault 0 (String.toFloat value)
                        , palette = updatedPalette
                    }

                "gamma" ->
                    { model
                        | cubehelixGamma = withDefault 0 (String.toFloat value)
                        , palette = updatedPalette
                    }

                _ ->
                    model
Enter fullscreen mode Exit fullscreen mode

So basically, if message is "blabla", return the previous model as it is, but update the fields after the | character.

One new thing that tripped me up was the String.toFloat function, which returns a Maybe Float.

If the conversion is successful, we get a Float. If invalid data was entered, we set it to 0, using withDefault.

6. Conclusion

This was a really exciting project to work on!

I think the result is very fun and engaging, and I've taken my first step into web accessibility beyond semantic HTML and basic design principles.

Again, thanks for the help, everyone! See you tomorrow!

Discussion (2)

pic
Editor guide
Collapse
bukkfrig profile image
bukkfrig

Be wary of becoming "stringly typed" and missing out on some of the benefits of Elm "making impossible states impossible." You can easily declare custom types to use as enums instead.

You should also be wary of pre-computing values and putting them in your model (like the "palette"). Your model is usually just the data that the user is dealing with, and you can generate all other values from the data. That's because generally computing values is cheap, and it's only updating the DOM that is costly (but that's where Elm's virtual DOM comes in). When computing values is not cheap you can use Html.lazy and still write the program declaratively. (See guide.elm-lang.org/optimization/la...)

Here's the same program with those two changes. (I threw in a Html.lazy to demonstrate how surprisingly easy that is to do, but not because it was particularly necessary.)
ellie-app.com/bSwbq324RPya1

Collapse
kristianpedersen profile image
Kristian Pedersen Author

Nice! Your types and other changes really make the code more readable.

I haven't seen anonymous functions used with events before, but I can see how your approach really cleans up the update function.

So the model should be as simple as possible, and calculations should be derived from it, not part of it.

I was a bit confused by HTML.lazy, since it's not needed in this project, but this simple example illustrated very well what it does: discourse.elm-lang.org/t/how-does-...