├── demo.gif ├── public ├── favicon.ico ├── manifest.json ├── index.html └── logo.svg ├── src ├── index.js ├── main.css └── AccessibleDropdown.elm ├── .gitignore ├── tests └── Tests.elm ├── elm.json ├── README.md └── LICENSE /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MargaretKrutikova/elm-accessible-dropdown/HEAD/demo.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MargaretKrutikova/elm-accessible-dropdown/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import "./main.css" 2 | import { Elm } from "./AccessibleDropdown.elm" 3 | 4 | Elm.AccessibleDropdown.init({ 5 | node: document.getElementById("root") 6 | }) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Distribution 2 | build/ 3 | 4 | # elm-package generated files 5 | elm-stuff 6 | 7 | # elm-repl generated files 8 | repl-temp-* 9 | 10 | # Dependency directories 11 | node_modules 12 | 13 | # Desktop Services Store on macOS 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Elm Dropdown", 3 | "name": "Accessible dropdown implementation in Elm", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Tests exposing (..) 2 | 3 | import Test exposing (..) 4 | import Expect 5 | 6 | 7 | -- Check out http://package.elm-lang.org/packages/elm-community/elm-test/latest to learn more about testing in Elm! 8 | 9 | 10 | all : Test 11 | all = 12 | describe "A Test Suite" 13 | [ test "Addition" <| 14 | \_ -> 15 | Expect.equal 10 (3 + 7) 16 | , test "String.left" <| 17 | \_ -> 18 | Expect.equal "a" (String.left 1 "abcdefg") 19 | , test "This test should fail" <| 20 | \_ -> 21 | Expect.fail "failed as expected!" 22 | ] 23 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.3", 11 | "elm/html": "1.0.0", 12 | "elm/json": "1.1.3" 13 | }, 14 | "indirect": { 15 | "elm/time": "1.0.0", 16 | "elm/url": "1.0.0", 17 | "elm/virtual-dom": "1.0.3" 18 | } 19 | }, 20 | "test-dependencies": { 21 | "direct": { 22 | "elm-explorations/test": "1.0.0" 23 | }, 24 | "indirect": { 25 | "elm/random": "1.0.0" 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 17 | Elm App 18 | 19 | 20 | 23 |
24 |
25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A keyboard accessible dropdown in Elm 2 | 3 | An example of a dropdown written in Elm, supported features: 4 | 5 | - full keyboard accessibility 6 | - focusable - supports tabbing into, 7 | - open on Enter or Space key, 8 | - focus options when navigating with arrow keys ⬆️ and ⬇️, 9 | - select currently focused option with Enter/Space, 10 | - close with Escape or on focus out, 11 | - close on click outside, 12 | - scroll into the selected option on open, 13 | - native Elm with no JS ports 😎 14 | 15 | ## Demo 16 | 17 | Demo of the fabulous dropdown: 18 | 19 |

20 | 21 |

22 | 23 | --- 24 | 25 | This project is bootstrapped with [Create Elm App](https://github.com/halfzebra/create-elm-app). 26 | 27 | ## Run the examples 28 | 29 | ```sh 30 | elm-app start 31 | ``` 32 | 33 | Runs the app in the development mode. 34 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 35 | 36 | The page will reload if you make edits. 37 | You will also see any lint errors in the console. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Margarita Krutikova 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 10 | 11 | 14 | 15 | 22 | 23 | 26 | 27 | 30 | 31 | 34 | 35 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | elm-hot creates an additional div wrapper around the app to make HMR possible. 3 | This could break styling in development mode if you are using Elm UI. 4 | 5 | More context in the issue: 6 | https://github.com/halfzebra/create-elm-app/issues/320 7 | */ 8 | [data-elm-hot="true"] { 9 | height: inherit; 10 | } 11 | 12 | body { 13 | font-family: "Source Sans Pro", "Trebuchet MS", "Lucida Grande", 14 | "Bitstream Vera Sans", "Helvetica Neue", sans-serif; 15 | margin: 0; 16 | text-align: center; 17 | color: #293c4b; 18 | } 19 | 20 | * { 21 | box-sizing: border-box; 22 | } 23 | 24 | h1 { 25 | font-size: 30px; 26 | } 27 | 28 | main { 29 | display: flex; 30 | flex-direction: column; 31 | height: 100%; 32 | } 33 | 34 | .main { 35 | text-align: left; 36 | height: 100%; 37 | padding-left: 50px; 38 | display: flex; 39 | } 40 | 41 | /** elm-dropdown */ 42 | .elm-dropdown { 43 | position: relative; 44 | width: 250px; 45 | min-width: 160px; 46 | flex: 0 0 auto; 47 | height: 100%; 48 | } 49 | 50 | .elm-dropdown-button { 51 | /* position */ 52 | position: relative; 53 | display: block; 54 | box-sizing: border-box; 55 | min-width: 160px; 56 | max-width: 100%; 57 | 58 | /* style */ 59 | border-radius: 4px; 60 | border: 1px solid rgba(0, 0, 0, 0.15); 61 | cursor: pointer; 62 | text-align: left; 63 | -webkit-tap-highlight-color: transparent; 64 | font-size: 1rem; 65 | padding: 10px; 66 | transition: border-color 0.2s ease-in-out; 67 | 68 | /* text ellipsis */ 69 | text-overflow: ellipsis; 70 | white-space: nowrap; 71 | overflow: hidden; 72 | } 73 | 74 | .elm-dropdown-button:hover, 75 | .elm-dropdown-button:focus { 76 | border-color: rgba(0, 0, 0, 0.3); 77 | } 78 | 79 | .elm-dropdown-container { 80 | /* position */ 81 | position: absolute; 82 | top: 100%; 83 | left: 0; 84 | overflow-y: auto; 85 | z-index: 1; 86 | 87 | /* dimension */ 88 | width: 100%; 89 | max-height: 200px; 90 | margin: 4px 0 0; 91 | padding: 5px 0; 92 | box-sizing: border-box; 93 | 94 | /* style */ 95 | background: #fff; 96 | transition: opacity 150ms ease-in-out; 97 | text-align: left; 98 | list-style: none; 99 | border-radius: 4px; 100 | border: 1px solid rgba(0, 0, 0, 0.15); 101 | -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 102 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 103 | scroll-behavior: smooth; 104 | } 105 | 106 | .elm-dropdown-list { 107 | position: relative; 108 | padding-inline-start: 0; 109 | margin-block-start: 0; 110 | margin-block-end: 0; 111 | display: block; 112 | } 113 | 114 | .elm-dropdown-option { 115 | box-sizing: border-box; 116 | height: 40px; 117 | display: flex; 118 | align-items: center; 119 | padding: 0 10px; 120 | cursor: pointer; 121 | transition: background-color 150ms ease-in-out; 122 | font-size: 1rem; 123 | -webkit-tap-highlight-color: transparent; 124 | 125 | /* text ellipsis */ 126 | text-overflow: ellipsis; 127 | white-space: nowrap; 128 | overflow: hidden; 129 | } 130 | 131 | .elm-dropdown-option--selected { 132 | background-color: rgba(0, 0, 0, 0.04); 133 | font-weight: bold; 134 | } 135 | 136 | .elm-dropdown-option:hover, 137 | .elm-dropdown-option--focused { 138 | background-color: rgba(0, 0, 0, 0.13); 139 | } 140 | -------------------------------------------------------------------------------- /src/AccessibleDropdown.elm: -------------------------------------------------------------------------------- 1 | module AccessibleDropdown exposing (main) 2 | 3 | import Browser 4 | import Browser.Dom as Dom 5 | import Html exposing (Html, button, div, h2, li, text, ul) 6 | import Html.Attributes exposing (attribute, class, classList, id, tabindex) 7 | import Html.Events as Events 8 | import Json.Decode as Decode 9 | import Task 10 | 11 | 12 | allOptions : List Option 13 | allOptions = 14 | [ { id = "Np", label = "Neptunium" } 15 | , { id = "Pu", label = "Plutonium" } 16 | , { id = "Am", label = "Americium" } 17 | , { id = "Cm", label = "Curium" } 18 | , { id = "Bk", label = "Berkelium" } 19 | , { id = "Cf", label = "Californium" } 20 | , { id = "Fm", label = "Fermium" } 21 | , { id = "Md", label = "Mendelevium" } 22 | , { id = "No", label = "Nobelium" } 23 | , { id = "Lr", label = "Lawrencium" } 24 | , { id = "Rf", label = "Rutherfordium" } 25 | , { id = "Db", label = "Dubnium" } 26 | , { id = "Sg", label = "Seaborgium" } 27 | , { id = "Bh", label = "Bohrium" } 28 | , { id = "Hs", label = "Hassium" } 29 | ] 30 | 31 | 32 | 33 | -- MODEL 34 | 35 | 36 | type alias Option = 37 | { id : String 38 | , label : String 39 | } 40 | 41 | 42 | type State 43 | = Open 44 | | Closed 45 | 46 | 47 | type alias Model = 48 | { state : State 49 | , selectedId : Maybe String 50 | , focusedId : Maybe String 51 | , options : List Option 52 | } 53 | 54 | 55 | initialModel : Model 56 | initialModel = 57 | { state = Closed 58 | , selectedId = Nothing 59 | , focusedId = Nothing 60 | , options = allOptions 61 | } 62 | 63 | 64 | 65 | -- UPDATE 66 | 67 | 68 | type Msg 69 | = Toggle 70 | | Close 71 | | SelectOption String 72 | | KeyPress KeyPressed 73 | | SetFocusOn String 74 | 75 | 76 | update : Msg -> Model -> ( Model, Cmd Msg ) 77 | update msg model = 78 | case msg of 79 | Toggle -> 80 | if model.state |> isOpen then 81 | closeDropdown model 82 | 83 | else 84 | openDropdown model 85 | 86 | Close -> 87 | closeDropdown model 88 | 89 | SelectOption id -> 90 | ( { model | selectedId = Just id }, Cmd.none ) 91 | 92 | KeyPress key -> 93 | if model.state |> isOpen then 94 | handleKeyWhenOpen model key 95 | 96 | else 97 | handleKeyWhenClosed model key 98 | 99 | SetFocusOn _ -> 100 | ( model, Cmd.none ) 101 | 102 | 103 | handleKeyWhenClosed : Model -> KeyPressed -> ( Model, Cmd Msg ) 104 | handleKeyWhenClosed model key = 105 | if key == Up || key == Down then 106 | openDropdown model 107 | 108 | else 109 | ( model, Cmd.none ) 110 | 111 | 112 | handleKeyWhenOpen : Model -> KeyPressed -> ( Model, Cmd Msg ) 113 | handleKeyWhenOpen model key = 114 | case key of 115 | Enter -> 116 | ( { model | selectedId = model.focusedId }, Cmd.none ) 117 | 118 | Space -> 119 | ( { model | selectedId = model.focusedId }, Cmd.none ) 120 | 121 | Up -> 122 | navigateWithKey model (getPrevId model) 123 | 124 | Down -> 125 | navigateWithKey model (getNextId model) 126 | 127 | Escape -> 128 | closeDropdown model 129 | 130 | Other -> 131 | ( model, Cmd.none ) 132 | 133 | 134 | navigateWithKey : Model -> Maybe String -> ( Model, Cmd Msg ) 135 | navigateWithKey model nextId = 136 | ( { model | focusedId = nextId } 137 | , nextId |> Maybe.map focusOption |> Maybe.withDefault Cmd.none 138 | ) 139 | 140 | 141 | firstId : List Option -> Maybe String 142 | firstId = 143 | List.head >> Maybe.map .id 144 | 145 | 146 | lastId : List Option -> Maybe String 147 | lastId = 148 | List.reverse >> firstId 149 | 150 | 151 | getPrevId : Model -> Maybe String 152 | getPrevId model = 153 | case model.focusedId of 154 | Nothing -> 155 | lastId model.options 156 | 157 | Just id -> 158 | model.options |> List.map .id |> findPrev id 159 | 160 | 161 | getNextId : Model -> Maybe String 162 | getNextId model = 163 | case model.focusedId of 164 | Nothing -> 165 | firstId model.options 166 | 167 | Just id -> 168 | model.options |> List.map .id |> findNext id 169 | 170 | 171 | openDropdown : Model -> ( Model, Cmd Msg ) 172 | openDropdown model = 173 | let 174 | focusedId = 175 | defaultFocused model 176 | in 177 | ( { model | state = Open, focusedId = focusedId } 178 | , focusedId |> Maybe.map focusOption |> Maybe.withDefault Cmd.none 179 | ) 180 | 181 | 182 | defaultFocused : Model -> Maybe String 183 | defaultFocused model = 184 | case model.selectedId of 185 | Nothing -> 186 | firstId model.options 187 | 188 | Just id -> 189 | Just id 190 | 191 | 192 | closeDropdown : Model -> ( Model, Cmd Msg ) 193 | closeDropdown model = 194 | ( { model | state = Closed, focusedId = Nothing }, Cmd.none ) 195 | 196 | 197 | 198 | -- VIEW 199 | 200 | 201 | dropdownElementId : String 202 | dropdownElementId = 203 | "dropdown" 204 | 205 | 206 | view : Model -> Html Msg 207 | view model = 208 | div [] 209 | [ h2 [] [ text "Accessible dropdown" ] 210 | , div [ class "main" ] 211 | [ viewDropdown model ] 212 | ] 213 | 214 | 215 | viewDropdown : Model -> Html Msg 216 | viewDropdown model = 217 | div 218 | [ id dropdownElementId 219 | , class "elm-dropdown" 220 | , Events.preventDefaultOn "keydown" keyDecoder 221 | , Events.on "focusout" (onFocusOut "dropdown") 222 | ] 223 | [ button 224 | [ class "elm-dropdown-button" 225 | , attribute "aria-haspopup" "listbox" 226 | , Events.onClick Toggle 227 | ] 228 | [ text (getButtonText model "Select...") 229 | ] 230 | , if model.state |> isOpen then 231 | viewList model 232 | 233 | else 234 | text "" 235 | ] 236 | 237 | 238 | viewList : Model -> Html Msg 239 | viewList model = 240 | div 241 | [ class "elm-dropdown-container", attribute "role" "listbox" ] 242 | [ ul 243 | [ class "elm-dropdown-list" ] 244 | (List.map (viewOption model) model.options) 245 | ] 246 | 247 | 248 | viewOption : Model -> Option -> Html Msg 249 | viewOption model option = 250 | let 251 | isSelected = 252 | maybeEqual model.selectedId option.id 253 | in 254 | li 255 | ([ attribute "role" "option" 256 | , id option.id 257 | , tabindex -1 258 | , Events.onClick (SelectOption option.id) 259 | , class "elm-dropdown-option" 260 | , classList 261 | [ ( "elm-dropdown-option--selected", isSelected ) 262 | , ( "elm-dropdown-option--focused", maybeEqual model.focusedId option.id ) 263 | ] 264 | ] 265 | ++ (if isSelected then 266 | [ attribute "aria-selected" "true" ] 267 | 268 | else 269 | [] 270 | ) 271 | ) 272 | [ text option.label ] 273 | 274 | 275 | maybeEqual : Maybe String -> String -> Bool 276 | maybeEqual maybeId idToCompare = 277 | maybeId |> Maybe.map (\id -> id == idToCompare) |> Maybe.withDefault False 278 | 279 | 280 | getButtonText : Model -> String -> String 281 | getButtonText model placeholder = 282 | case model.selectedId of 283 | Nothing -> 284 | placeholder 285 | 286 | Just id -> 287 | model.options 288 | |> byId id 289 | |> Maybe.map .label 290 | |> Maybe.withDefault placeholder 291 | 292 | 293 | byId : String -> List Option -> Maybe Option 294 | byId id = 295 | List.filter (\option -> option.id == id) >> List.head 296 | 297 | 298 | 299 | -- MAIN 300 | 301 | 302 | main : Program () Model Msg 303 | main = 304 | Browser.element 305 | { init = \_ -> ( initialModel, Cmd.none ) 306 | , view = view 307 | , update = update 308 | , subscriptions = \_ -> Sub.none 309 | } 310 | 311 | 312 | 313 | -- EVENT DECODERS 314 | 315 | 316 | onFocusOut : String -> Decode.Decoder Msg 317 | onFocusOut id = 318 | outsideTarget "relatedTarget" id 319 | 320 | 321 | outsideTarget : String -> String -> Decode.Decoder Msg 322 | outsideTarget targetName dropdownId = 323 | Decode.field targetName (isOutsideDropdown dropdownId) 324 | |> Decode.andThen 325 | (\isOutside -> 326 | if isOutside then 327 | Decode.succeed Close 328 | 329 | else 330 | Decode.fail "inside dropdown" 331 | ) 332 | 333 | 334 | isOutsideDropdown : String -> Decode.Decoder Bool 335 | isOutsideDropdown dropdownId = 336 | Decode.oneOf 337 | [ Decode.field "id" Decode.string 338 | |> Decode.andThen 339 | (\id -> 340 | if dropdownId == id then 341 | -- found match by id 342 | Decode.succeed False 343 | 344 | else 345 | -- try next decoder 346 | Decode.fail "check parent node" 347 | ) 348 | , Decode.lazy (\_ -> isOutsideDropdown dropdownId |> Decode.field "parentNode") 349 | 350 | -- fallback if all previous decoders failed 351 | , Decode.succeed True 352 | ] 353 | 354 | 355 | focusOption : String -> Cmd Msg 356 | focusOption optionId = 357 | Task.attempt (\_ -> SetFocusOn optionId) (Dom.focus optionId) 358 | 359 | 360 | keyDecoder : Decode.Decoder ( Msg, Bool ) 361 | keyDecoder = 362 | Decode.field "key" Decode.string 363 | |> Decode.map toKeyPressed 364 | |> Decode.map 365 | (\key -> 366 | ( KeyPress key, preventDefault key ) 367 | ) 368 | 369 | 370 | preventDefault : KeyPressed -> Bool 371 | preventDefault key = 372 | key == Up || key == Down 373 | 374 | 375 | type KeyPressed 376 | = Up 377 | | Down 378 | | Escape 379 | | Enter 380 | | Space 381 | | Other 382 | 383 | 384 | toKeyPressed : String -> KeyPressed 385 | toKeyPressed key = 386 | case key of 387 | "ArrowUp" -> 388 | Up 389 | 390 | "ArrowDown" -> 391 | Down 392 | 393 | "Escape" -> 394 | Escape 395 | 396 | "Enter" -> 397 | Enter 398 | 399 | " " -> 400 | Space 401 | 402 | _ -> 403 | Other 404 | 405 | 406 | 407 | -- HELPERS 408 | 409 | 410 | isOpen : State -> Bool 411 | isOpen state = 412 | state == Open 413 | 414 | 415 | findPrev : String -> List String -> Maybe String 416 | findPrev selectedId ids = 417 | List.foldr (getAdjacent selectedId) Nothing ids 418 | 419 | 420 | findNext : String -> List String -> Maybe String 421 | findNext selectedId ids = 422 | List.foldl (getAdjacent selectedId) Nothing ids 423 | 424 | 425 | getAdjacent : String -> String -> Maybe String -> Maybe String 426 | getAdjacent selectedId currentId resultId = 427 | case resultId of 428 | Nothing -> 429 | if currentId == selectedId then 430 | Just selectedId 431 | 432 | else 433 | Nothing 434 | 435 | Just id -> 436 | if id == selectedId then 437 | Just currentId 438 | 439 | else 440 | Just id 441 | --------------------------------------------------------------------------------