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 |
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 |
--------------------------------------------------------------------------------