├── .envrc ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── elm.json ├── examples ├── .gitignore ├── Demo.elm ├── Makefile ├── README.md ├── elm.json ├── site │ ├── app.css │ ├── autocomplete.css │ └── index.html └── src │ ├── AccessibleExample.elm │ └── SectionsExample.elm ├── scripts └── deploy-to-gh-pages.sh ├── shell.nix ├── src ├── Menu.elm └── Menu │ ├── DefaultStyles.elm │ ├── Internal.elm │ ├── Styled.elm │ └── Styled │ └── Internal.elm └── tests └── Tests.elm /.envrc: -------------------------------------------------------------------------------- 1 | use nix -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | elm.js 2 | build 3 | elm-stuff 4 | .DS_Store 5 | documentation.json 6 | .direnv -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | install: 5 | - npm install -g elm elm-test@0.19.0-rev3 elm-format@0.8.1 elm-analyse@0.16.1 6 | - elm make 7 | script: 8 | - elm-test 9 | - elm-format . --validate 10 | - elm-analyse . -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [1.1.2] - 2023-02-27 5 | - Updated elm-css version to 18. 6 | 7 | ## [1.1.1] - 2021-10-21 8 | - Updated elm-css version to 17. 9 | 10 | ## [1.1.0] - 2021-07-30 11 | - Added elm-css support. 12 | 13 | ## [1.0.3] - 2020-08-31 14 | - Removed logo from readme. 15 | 16 | ## [1.0.2] - 2019-01-15 17 | - Added elm-analyse check and fixed how many items to show for sections. 18 | 19 | ## [1.0.1] - 2018-12-13 20 | - Bumped dependencies. 21 | 22 | ## [1.0.0] - 2018-08-21 23 | - [Conta Systemer AS](https://contasystemer.no/) is maintaining `elm-menu` from now on. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via an issue of this repository before making a change. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Greg Ziegan, Conta Systemer AS 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elm Menu 2 | 3 | [![Build Status](https://travis-ci.org/ContaSystemer/elm-menu.svg?branch=main)](https://travis-ci.org/ContaSystemer/elm-menu) 4 | 5 | This is a fork of [thebritican/elm-autocomplete](https://github.com/thebritican/elm-autocomplete) because it's not longer maintained. 6 | [Conta Systemer AS](https://contasystemer.no/) is going to maintain it from now on. 7 | 8 | > Per discussion in [#37](https://github.com/thebritican/elm-autocomplete/issues/37), 9 | > this library is moved into `elm-menu` (Since it's really just a menu currently). 10 | > The `AccessibleExample` (with a simple API and included `input` field) will be the _mostly_ 11 | > drop-in solution for this library. If you want to build more complicated features (like mentions), 12 | > use `elm-menu` after the work is done porting it! Meanwhile, you'll have to copy/paste the example... 13 | > obviously not ideal! The motivation here: no one wants to have 300 lines of boilerplate for the common 14 | > case of a typical form autocomplete! 15 | 16 | ## Demo 17 | 18 | Checkout the [landing page] inspired by [React Autosuggest]'s page design 19 | 20 | [landing page]: https://contasystemer.github.io/elm-menu/ 21 | [React Autosuggest]: http://react-autosuggest.js.org/ 22 | 23 | Autocomplete menus have _just enough_ functionality to be tedious to implement again and again. 24 | This is a flexible library for handling the needs of many different autocompletes. 25 | 26 | Your data is stored separately; keep it in whatever shape makes the most sense for your application. 27 | 28 | Make an issue if this library cannot handle your scenario and we'll investigate together if it makes sense in the larger context! 29 | 30 | I recommend looking at the [examples] before diving into the API or source code! 31 | 32 | [examples]: https://github.com/ContaSystemer/elm-menu/tree/main/examples 33 | 34 | ## Usage Rules 35 | 36 | - Always put `Menu.State` in your model. 37 | - Never put _any_ `Config` in your model. 38 | 39 | Design inspired by [elm-sortable-table](https://github.com/evancz/elm-sortable-table/). 40 | 41 | Read about why these usage rules are good rules [here](https://github.com/evancz/elm-sortable-table/tree/1.0.0#usage-rules). 42 | 43 | The [API Design Session video](https://www.youtube.com/watch?v=KSuCYUqY058) w/ Evan Czaplicki (@evancz) that brought us to this API. 44 | 45 | 46 | ## Installation 47 | 48 | ``` 49 | elm package install ContaSystemer/elm-menu 50 | ``` 51 | 52 | ## Setup 53 | ```elm 54 | import Menu 55 | 56 | 57 | type alias Model = 58 | { autoState : Menu.State -- Own the State of the menu in your model 59 | , query : String -- Perhaps you want to filter by a string? 60 | , people : List Person -- The data you want to list and filter 61 | } 62 | 63 | -- Let's filter the data however we want 64 | acceptablePeople : String -> List Person -> List Person 65 | acceptablePeople query people = 66 | let 67 | lowerQuery = 68 | String.toLower query 69 | in 70 | List.filter (String.contains lowerQuery << String.toLower << .name) people 71 | 72 | -- Set up what will happen with your menu updates 73 | updateConfig : Menu.UpdateConfig Msg Person 74 | updateConfig = 75 | Menu.updateConfig 76 | { toId = .name 77 | , onKeyDown = 78 | \code maybeId -> 79 | if code == 13 then 80 | Maybe.map SelectPerson maybeId 81 | else 82 | Nothing 83 | , onTooLow = Nothing 84 | , onTooHigh = Nothing 85 | , onMouseEnter = \_ -> Nothing 86 | , onMouseLeave = \_ -> Nothing 87 | , onMouseClick = \id -> Just <| SelectPerson id 88 | , separateSelections = False 89 | } 90 | 91 | type Msg 92 | = SetAutocompleteState Menu.Msg 93 | 94 | update : Msg -> Model -> Model 95 | update msg { autoState, query, people, howManyToShow } = 96 | case msg of 97 | SetAutocompleteState autoMsg -> 98 | let 99 | (newState, maybeMsg) = 100 | Menu.update updateConfig autoMsg howManyToShow autoState (acceptablePeople query people) 101 | in 102 | { model | autoState = newState } 103 | 104 | -- setup for your autocomplete view 105 | viewConfig : Menu.ViewConfig Person 106 | viewConfig = 107 | let 108 | customizedLi keySelected mouseSelected person = 109 | { attributes = [ classList [ ("autocomplete-item", True), ("is-selected", keySelected || mouseSelected) ] ] 110 | , children = [ Html.text person.name ] 111 | } 112 | in 113 | Menu.viewConfig 114 | { toId = .name 115 | , ul = [ class "autocomplete-list" ] -- set classes for your list 116 | , li = customizedLi -- given selection states and a person, create some Html! 117 | } 118 | 119 | -- and let's show it! (See an example for the full code snippet) 120 | view : Model -> Html Msg 121 | view { autoState, query, people } = 122 | div [] 123 | [ input [ onInput SetQuery ] [] 124 | , Html.App.map SetAutocompleteState (Menu.view viewConfig 5 autoState (acceptablePeople query people)) 125 | ] 126 | 127 | ``` -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "ContaSystemer/elm-menu", 4 | "summary": "A customizable menu component which could be used for autocomplete component", 5 | "license": "BSD-3-Clause", 6 | "version": "1.1.2", 7 | "exposed-modules": [ 8 | "Menu", 9 | "Menu.Styled" 10 | ], 11 | "elm-version": "0.19.0 <= v < 0.20.0", 12 | "dependencies": { 13 | "elm/browser": "1.0.1 <= v < 2.0.0", 14 | "elm/core": "1.0.2 <= v < 2.0.0", 15 | "elm/html": "1.0.0 <= v < 2.0.0", 16 | "elm/json": "1.1.2 <= v < 2.0.0", 17 | "rtfeldman/elm-css": "17.0.1 <= v < 19.0.0" 18 | }, 19 | "test-dependencies": { 20 | "elm-explorations/test": "1.2.0 <= v < 2.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | site/index.js 2 | -------------------------------------------------------------------------------- /examples/Demo.elm: -------------------------------------------------------------------------------- 1 | module Main exposing 2 | ( Focused(..) 3 | , Model 4 | , Msg(..) 5 | , footerLink 6 | , init 7 | , main 8 | , subscriptions 9 | , update 10 | , view 11 | , viewApp 12 | , viewExamples 13 | , viewFooter 14 | , viewForkMe 15 | , viewHeader 16 | , viewLogo 17 | , viewSectionsExample 18 | , viewSimpleExample 19 | ) 20 | 21 | import AccessibleExample 22 | import Browser 23 | import ElmLogo 24 | import Html exposing (Html) 25 | import Html.Attributes as Attrs 26 | import SectionsExample 27 | import Svg 28 | import Svg.Attributes as SvgAttrs 29 | import Tuple 30 | 31 | 32 | main : Program () Model Msg 33 | main = 34 | Browser.element 35 | { init = \_ -> ( init, Cmd.none ) 36 | , update = update 37 | , view = view 38 | , subscriptions = subscriptions 39 | } 40 | 41 | 42 | subscriptions : Model -> Sub Msg 43 | subscriptions model = 44 | case model.currentFocus of 45 | Simple -> 46 | Sub.map AccessibleExample (AccessibleExample.subscriptions model.accessibleAutocomplete) 47 | 48 | Sections -> 49 | Sub.map SectionsExample (SectionsExample.subscriptions model.sectionsAutocomplete) 50 | 51 | None -> 52 | Sub.none 53 | 54 | 55 | type alias Model = 56 | { accessibleAutocomplete : AccessibleExample.Model 57 | , sectionsAutocomplete : SectionsExample.Model 58 | , currentFocus : Focused 59 | } 60 | 61 | 62 | type Focused 63 | = Simple 64 | | Sections 65 | | None 66 | 67 | 68 | init : Model 69 | init = 70 | { accessibleAutocomplete = AccessibleExample.init 71 | , sectionsAutocomplete = SectionsExample.init 72 | , currentFocus = None 73 | } 74 | 75 | 76 | type Msg 77 | = AccessibleExample AccessibleExample.Msg 78 | | SectionsExample SectionsExample.Msg 79 | 80 | 81 | update : Msg -> Model -> ( Model, Cmd Msg ) 82 | update msg model = 83 | let 84 | newModel = 85 | case msg of 86 | AccessibleExample autoMsg -> 87 | let 88 | updatedModel = 89 | { model 90 | | accessibleAutocomplete = 91 | Tuple.first (AccessibleExample.update autoMsg model.accessibleAutocomplete) 92 | } 93 | in 94 | case autoMsg of 95 | AccessibleExample.OnFocus -> 96 | { updatedModel | currentFocus = Simple } 97 | 98 | _ -> 99 | updatedModel 100 | 101 | SectionsExample autoMsg -> 102 | let 103 | updatedModel = 104 | { model 105 | | sectionsAutocomplete = 106 | Tuple.first (SectionsExample.update autoMsg model.sectionsAutocomplete) 107 | } 108 | in 109 | case autoMsg of 110 | SectionsExample.OnFocus -> 111 | { updatedModel | currentFocus = Sections } 112 | 113 | _ -> 114 | updatedModel 115 | in 116 | ( newModel, Cmd.none ) 117 | 118 | 119 | view : Model -> Html Msg 120 | view model = 121 | Html.div [ Attrs.class "app-container" ] 122 | [ viewForkMe 123 | , viewApp model 124 | ] 125 | 126 | 127 | viewForkMe : Html Msg 128 | viewForkMe = 129 | Html.a 130 | [ Attrs.attribute "aria-label" "View source on Github" 131 | , Attrs.class "github-corner" 132 | , Attrs.href "https://github.com/ContaSystemer/elm-menu" 133 | ] 134 | [ Svg.svg 135 | [ Attrs.attribute "aria-hidden" "true" 136 | , SvgAttrs.height "80" 137 | , SvgAttrs.style "fill:#70B7FD; color:#fff; position: absolute; top: 0; border: 0; right: 0;" 138 | , SvgAttrs.viewBox "0 0 250 250" 139 | , SvgAttrs.width "80" 140 | ] 141 | [ Svg.path [ SvgAttrs.d "M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" ] 142 | [] 143 | , Svg.path 144 | [ SvgAttrs.class "octo-arm" 145 | , SvgAttrs.d "M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" 146 | , SvgAttrs.fill "currentColor" 147 | , SvgAttrs.style "transform-origin: 130px 106px;" 148 | ] 149 | [] 150 | , Svg.path 151 | [ SvgAttrs.class "octo-body" 152 | , SvgAttrs.d "M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" 153 | , SvgAttrs.fill "currentColor" 154 | ] 155 | [] 156 | ] 157 | ] 158 | 159 | 160 | viewApp : Model -> Html Msg 161 | viewApp model = 162 | Html.div [ Attrs.class "app" ] 163 | [ viewHeader model 164 | , viewExamples model 165 | , viewFooter 166 | ] 167 | 168 | 169 | viewHeader : Model -> Html Msg 170 | viewHeader model = 171 | Html.div [ Attrs.class "section header" ] 172 | [ Html.h1 [ Attrs.class "section-title" ] [ Html.text "Elm Menu" ] 173 | , viewLogo 174 | , Html.p [ Attrs.class "header-description" ] [ Html.text "A reusable, navigable menu for all your text input needs." ] 175 | , Html.a 176 | [ Attrs.class "try-it-link" 177 | , Attrs.href "https://github.com/ContaSystemer/elm-menu#installation" 178 | , Attrs.target "_blank" 179 | , Attrs.rel "noopenner noreferrer" 180 | ] 181 | [ Html.text "Try it out!" ] 182 | ] 183 | 184 | 185 | viewLogo : Html msg 186 | viewLogo = 187 | Html.a [ Attrs.href "http://elm-lang.org/", Attrs.target "_blank" ] [ ElmLogo.html 150 ] 188 | 189 | 190 | viewExamples : Model -> Html Msg 191 | viewExamples model = 192 | Html.div [ Attrs.class "section examples" ] 193 | [ Html.h1 [ Attrs.class "section-title" ] [ Html.text "Examples" ] 194 | , viewSimpleExample model.accessibleAutocomplete 195 | , viewSectionsExample model.sectionsAutocomplete 196 | ] 197 | 198 | 199 | viewSimpleExample : AccessibleExample.Model -> Html Msg 200 | viewSimpleExample autocomplete = 201 | Html.div [ Attrs.class "example" ] 202 | [ Html.div [ Attrs.class "example-info" ] 203 | [ Html.h1 [ Attrs.class "example-title" ] [ Html.text "Simple" ] 204 | , Html.p [] [ Html.text "A list of presidents" ] 205 | ] 206 | , Html.div [ Attrs.class "example-autocomplete" ] 207 | [ Html.map AccessibleExample (AccessibleExample.view autocomplete) 208 | ] 209 | ] 210 | 211 | 212 | viewSectionsExample : SectionsExample.Model -> Html Msg 213 | viewSectionsExample autocomplete = 214 | Html.div [ Attrs.class "example" ] 215 | [ Html.div [ Attrs.class "example-info" ] 216 | [ Html.h1 [ Attrs.class "example-title" ] [ Html.text "Sections" ] 217 | , Html.p [] [ Html.text "Presidents grouped by birth century" ] 218 | ] 219 | , Html.div [ Attrs.class "example-autocomplete" ] [ Html.map SectionsExample (SectionsExample.view autocomplete) ] 220 | ] 221 | 222 | 223 | viewFooter : Html Msg 224 | viewFooter = 225 | Html.div [ Attrs.class "section footer" ] 226 | [ Html.p [] 227 | [ Html.text "Page design inspired by " 228 | , footerLink "http://react-autosuggest.js.org/" "React Autosuggest" 229 | ] 230 | , Html.p [] 231 | [ Html.text "Created by " 232 | , footerLink "https://twitter.com/gregziegan" "Greg Ziegan" 233 | , Html.text " and " 234 | , footerLink "https://contasystemer.no/" "Conta Systemer AS" 235 | ] 236 | ] 237 | 238 | 239 | footerLink : String -> String -> Html Msg 240 | footerLink url text_ = 241 | Html.a 242 | [ Attrs.href url 243 | , Attrs.class "footer-link" 244 | , Attrs.target "_blank" 245 | , Attrs.rel "noopenner noreferrer" 246 | ] 247 | [ Html.text text_ ] 248 | -------------------------------------------------------------------------------- /examples/Makefile: -------------------------------------------------------------------------------- 1 | ELM_OUTPUT = elm.js 2 | BROWSER_TARGET = example.html 3 | 4 | SOURCE_DIR = src 5 | 6 | ### 7 | 8 | ELM_SOURCE = $(SOURCE_DIR)/$(ELM_MAIN).elm 9 | ELM_BUILD = $(BUILD_DIR)/$(ELM_OUTPUT) 10 | STATIC_SOURCES = $(STATIC:%=$(SOURCE_DIR)/%) 11 | 12 | ### 13 | 14 | .PHONY: all open clean clean-build clean-all static elm new 15 | 16 | new: clean-build all 17 | 18 | $(ELM_BUILD): elm 19 | 20 | demo: 21 | elm make Demo.elm --debug --output site/index.js 22 | 23 | 24 | clean: 25 | rm -rf elm-stuff 26 | 27 | clean-build: 28 | rm -rf $(BUILD_DIR) 29 | rm -rf elm-stuff/build-artifacts 30 | 31 | clean-all: clean-build clean 32 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | There are multiple examples for you to explore in the `./src` directory. 4 | 5 | Build them all with `make demo` and open up the page `site/index.html` 6 | -------------------------------------------------------------------------------- /examples/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "../src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "akoppela/elm-logo": "1.0.2", 11 | "elm/browser": "1.0.1", 12 | "elm/core": "1.0.2", 13 | "elm/html": "1.0.0", 14 | "elm/json": "1.1.2", 15 | "elm/svg": "1.0.1" 16 | }, 17 | "indirect": { 18 | "elm/time": "1.0.0", 19 | "elm/url": "1.0.0", 20 | "elm/virtual-dom": "1.0.2", 21 | "mdgriffith/elm-ui": "1.1.0" 22 | } 23 | }, 24 | "test-dependencies": { 25 | "direct": {}, 26 | "indirect": {} 27 | } 28 | } -------------------------------------------------------------------------------- /examples/site/app.css: -------------------------------------------------------------------------------- 1 | /* GITHUB CORNER */ 2 | 3 | .github-corner:hover .octo-arm { 4 | animation: octocat-wave 560ms ease-in-out; 5 | } 6 | 7 | @keyframes octocat-wave { 8 | 0%,100% { 9 | transform: rotate(0); 10 | } 11 | 12 | 20%,60% { 13 | transform: rotate(-25deg); 14 | } 15 | 16 | 40%,80% { 17 | transform: rotate(10deg); 18 | } 19 | } 20 | 21 | @media (max-width: 500px) { 22 | .github-corner:hover .octo-arm { 23 | animation: none; 24 | } 25 | 26 | .github-corner .octo-arm { 27 | animation: octocat-wave 560ms ease-in-out; 28 | } 29 | } 30 | 31 | /* APP */ 32 | 33 | body { 34 | background-color: #60B5CC; 35 | height: 100vh; 36 | overflow: scroll; 37 | color: white; 38 | margin: 0; 39 | font-family: 'Open Sans', sans-serif; 40 | } 41 | 42 | .app-container { 43 | display: flex; 44 | flex-direction: column; 45 | } 46 | 47 | .app { 48 | display: flex; 49 | flex-direction: column; 50 | align-items: center; 51 | } 52 | 53 | .header { 54 | background-color: rgb(18, 22, 32); 55 | } 56 | 57 | .section-title { 58 | font-weight: 300; 59 | font-size: 50px; 60 | } 61 | 62 | .header-description { 63 | font-size: 20px; 64 | } 65 | 66 | .try-it-link { 67 | width: 160px; 68 | font-size: 20px; 69 | line-height: 45px; 70 | color: white; 71 | background-color: #ffb03a; 72 | border-radius: 3px; 73 | text-decoration: none; 74 | text-align: center; 75 | } 76 | 77 | .example { 78 | display: flex; 79 | justify-content: space-between; 80 | align-items: baseline; 81 | width: 680px; 82 | height: 150px; 83 | } 84 | 85 | .example-info { 86 | display: flex; 87 | flex-direction: column; 88 | width: 300px; 89 | } 90 | 91 | .example-title { 92 | font-weight: 300; 93 | } 94 | 95 | .example-autocomplete { 96 | display: flex; 97 | flex-direction: column; 98 | height: 200px; 99 | width: 200px; 100 | } 101 | 102 | .section { 103 | padding: 100px 0; 104 | display: flex; 105 | flex-direction: column; 106 | align-items: center; 107 | justify-content: space-between; 108 | width: 100%; 109 | height: 600px; 110 | } 111 | 112 | .footer { 113 | background-color: #92d961; 114 | height: 120px; 115 | } 116 | 117 | .footer-link { 118 | text-decoration: none; 119 | color: white; 120 | } 121 | 122 | .footer-link:hover { 123 | text-decoration: underline; 124 | } 125 | -------------------------------------------------------------------------------- /examples/site/autocomplete.css: -------------------------------------------------------------------------------- 1 | .autocomplete-menu { 2 | position: relative; 3 | margin-top: 5px; 4 | background: white; 5 | color: black; 6 | border: 1px solid #DDD; 7 | border-radius: 3px; 8 | box-shadow: 0 0 5px rgba(0,0,0,0.1); 9 | min-width: 120px; 10 | } 11 | 12 | .autocomplete-item { 13 | display: block; 14 | padding: 5px 10px; 15 | border-bottom: 1px solid #DDD; 16 | cursor: pointer; 17 | } 18 | 19 | .key-selected { 20 | background-color: #3366FF; 21 | } 22 | 23 | .mouse-selected { 24 | background-color: #ececec; 25 | } 26 | 27 | .autocomplete-list { 28 | list-style: none; 29 | padding: 0; 30 | margin: auto; 31 | max-height: 200px; 32 | overflow-y: auto; 33 | } 34 | 35 | .autocomplete-list-with-sections { 36 | list-style: none; 37 | padding: 0; 38 | margin: auto; 39 | } 40 | 41 | .autocomplete-input { 42 | min-width: 120px; 43 | color: black; 44 | font-size: 12px; 45 | padding: 4px; 46 | border-radius: 4px; 47 | background-color: white; 48 | } 49 | 50 | .autocomplete-section-list { 51 | list-style: none; 52 | padding: 0; 53 | margin: auto; 54 | max-height: 200px; 55 | overflow-y: auto; 56 | } 57 | 58 | .autocomplete-section-item { 59 | display: block; 60 | padding: 0; 61 | } 62 | 63 | .autocomplete-section-box { 64 | display: block; 65 | padding: 0; 66 | border-top: 1px solid #888888; 67 | border-bottom: 1px solid #888888; 68 | } 69 | 70 | .autocomplete-section-text { 71 | display: flex; 72 | justify-content: center; 73 | } 74 | -------------------------------------------------------------------------------- /examples/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Elm Menu 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/src/AccessibleExample.elm: -------------------------------------------------------------------------------- 1 | module AccessibleExample exposing 2 | ( Model 3 | , Msg(..) 4 | , Person 5 | , acceptablePeople 6 | , boolToString 7 | , getPersonAtId 8 | , init 9 | , main 10 | , presidents 11 | , removeSelection 12 | , resetInput 13 | , resetMenu 14 | , setQuery 15 | , subscriptions 16 | , update 17 | , updateConfig 18 | , view 19 | , viewConfig 20 | , viewMenu 21 | ) 22 | 23 | import Browser 24 | import Browser.Dom as Dom 25 | import Html 26 | import Html.Attributes as Attrs 27 | import Html.Events as Events 28 | import Json.Decode as Decode 29 | import Menu 30 | import String 31 | import Task 32 | 33 | 34 | main : Program () Model Msg 35 | main = 36 | Browser.element 37 | { init = \_ -> ( init, Cmd.none ) 38 | , update = update 39 | , view = view 40 | , subscriptions = subscriptions 41 | } 42 | 43 | 44 | subscriptions : Model -> Sub Msg 45 | subscriptions model = 46 | Sub.map SetAutoState Menu.subscription 47 | 48 | 49 | type alias Model = 50 | { people : List Person 51 | , autoState : Menu.State 52 | , howManyToShow : Int 53 | , query : String 54 | , selectedPerson : Maybe Person 55 | , showMenu : Bool 56 | } 57 | 58 | 59 | init : Model 60 | init = 61 | { people = presidents 62 | , autoState = Menu.empty 63 | , howManyToShow = 5 64 | , query = "" 65 | , selectedPerson = Nothing 66 | , showMenu = False 67 | } 68 | 69 | 70 | type Msg 71 | = SetQuery String 72 | | SetAutoState Menu.Msg 73 | | Wrap Bool 74 | | Reset 75 | | HandleEscape 76 | | SelectPersonKeyboard String 77 | | SelectPersonMouse String 78 | | PreviewPerson String 79 | | OnFocus 80 | | NoOp 81 | 82 | 83 | update : Msg -> Model -> ( Model, Cmd Msg ) 84 | update msg model = 85 | case msg of 86 | SetQuery newQuery -> 87 | let 88 | showMenu = 89 | not (List.isEmpty (acceptablePeople newQuery model.people)) 90 | in 91 | ( { model 92 | | query = newQuery 93 | , showMenu = showMenu 94 | , selectedPerson = Nothing 95 | } 96 | , Cmd.none 97 | ) 98 | 99 | SetAutoState autoMsg -> 100 | let 101 | ( newState, maybeMsg ) = 102 | Menu.update updateConfig 103 | autoMsg 104 | model.howManyToShow 105 | model.autoState 106 | (acceptablePeople model.query model.people) 107 | 108 | newModel = 109 | { model | autoState = newState } 110 | in 111 | maybeMsg 112 | |> Maybe.map (\updateMsg -> update updateMsg newModel) 113 | |> Maybe.withDefault ( newModel, Cmd.none ) 114 | 115 | HandleEscape -> 116 | let 117 | validOptions = 118 | not (List.isEmpty (acceptablePeople model.query model.people)) 119 | 120 | handleEscape = 121 | if validOptions then 122 | model 123 | |> removeSelection 124 | |> resetMenu 125 | 126 | else 127 | resetInput model 128 | 129 | escapedModel = 130 | case model.selectedPerson of 131 | Just person -> 132 | if model.query == person.name then 133 | resetInput model 134 | 135 | else 136 | handleEscape 137 | 138 | Nothing -> 139 | handleEscape 140 | in 141 | ( escapedModel, Cmd.none ) 142 | 143 | Wrap toTop -> 144 | case model.selectedPerson of 145 | Just person -> 146 | update Reset model 147 | 148 | Nothing -> 149 | if toTop then 150 | ( { model 151 | | autoState = 152 | Menu.resetToLastItem updateConfig 153 | (acceptablePeople model.query model.people) 154 | model.howManyToShow 155 | model.autoState 156 | , selectedPerson = 157 | acceptablePeople model.query model.people 158 | |> List.take model.howManyToShow 159 | |> List.reverse 160 | |> List.head 161 | } 162 | , Cmd.none 163 | ) 164 | 165 | else 166 | ( { model 167 | | autoState = 168 | Menu.resetToFirstItem updateConfig 169 | (acceptablePeople model.query model.people) 170 | model.howManyToShow 171 | model.autoState 172 | , selectedPerson = 173 | acceptablePeople model.query model.people 174 | |> List.take model.howManyToShow 175 | |> List.head 176 | } 177 | , Cmd.none 178 | ) 179 | 180 | Reset -> 181 | ( { model 182 | | autoState = Menu.reset updateConfig model.autoState 183 | , selectedPerson = Nothing 184 | } 185 | , Cmd.none 186 | ) 187 | 188 | SelectPersonKeyboard id -> 189 | let 190 | newModel = 191 | setQuery model id 192 | |> resetMenu 193 | in 194 | ( newModel, Cmd.none ) 195 | 196 | SelectPersonMouse id -> 197 | let 198 | newModel = 199 | setQuery model id 200 | |> resetMenu 201 | in 202 | ( newModel, Task.attempt (\_ -> NoOp) (Dom.focus "president-input") ) 203 | 204 | PreviewPerson id -> 205 | ( { model 206 | | selectedPerson = 207 | Just (getPersonAtId model.people id) 208 | } 209 | , Cmd.none 210 | ) 211 | 212 | OnFocus -> 213 | ( model 214 | , Cmd.none 215 | ) 216 | 217 | NoOp -> 218 | ( model 219 | , Cmd.none 220 | ) 221 | 222 | 223 | resetInput model = 224 | { model | query = "" } 225 | |> removeSelection 226 | |> resetMenu 227 | 228 | 229 | removeSelection model = 230 | { model | selectedPerson = Nothing } 231 | 232 | 233 | getPersonAtId people id = 234 | List.filter (\person -> person.name == id) people 235 | |> List.head 236 | |> Maybe.withDefault (Person "" 0 "" "") 237 | 238 | 239 | setQuery model id = 240 | { model 241 | | query = .name (getPersonAtId model.people id) 242 | , selectedPerson = Just (getPersonAtId model.people id) 243 | } 244 | 245 | 246 | resetMenu model = 247 | { model 248 | | autoState = Menu.empty 249 | , showMenu = False 250 | } 251 | 252 | 253 | boolToString : Bool -> String 254 | boolToString bool = 255 | case bool of 256 | True -> 257 | "true" 258 | 259 | False -> 260 | "false" 261 | 262 | 263 | view : Model -> Html.Html Msg 264 | view model = 265 | let 266 | upDownEscDecoderHelper : Int -> Decode.Decoder Msg 267 | upDownEscDecoderHelper code = 268 | if code == 38 || code == 40 then 269 | Decode.succeed NoOp 270 | 271 | else if code == 27 then 272 | Decode.succeed HandleEscape 273 | 274 | else 275 | Decode.fail "not handling that key" 276 | 277 | upDownEscDecoder : Decode.Decoder ( Msg, Bool ) 278 | upDownEscDecoder = 279 | Events.keyCode 280 | |> Decode.andThen upDownEscDecoderHelper 281 | |> Decode.map (\msg -> ( msg, True )) 282 | 283 | menu = 284 | if model.showMenu then 285 | [ viewMenu model ] 286 | 287 | else 288 | [] 289 | 290 | query = 291 | model.selectedPerson 292 | |> Maybe.map .name 293 | |> Maybe.withDefault model.query 294 | 295 | activeDescendant attributes = 296 | model.selectedPerson 297 | |> Maybe.map .name 298 | |> Maybe.map (Attrs.attribute "aria-activedescendant") 299 | |> Maybe.map (\attribute -> attribute :: attributes) 300 | |> Maybe.withDefault attributes 301 | in 302 | Html.div [] 303 | (List.append 304 | [ Html.input 305 | (activeDescendant 306 | [ Events.onInput SetQuery 307 | , Events.onFocus OnFocus 308 | , Events.preventDefaultOn "keydown" upDownEscDecoder 309 | , Attrs.value query 310 | , Attrs.id "president-input" 311 | , Attrs.class "autocomplete-input" 312 | , Attrs.autocomplete False 313 | , Attrs.attribute "aria-owns" "list-of-presidents" 314 | , Attrs.attribute "aria-expanded" (boolToString model.showMenu) 315 | , Attrs.attribute "aria-haspopup" (boolToString model.showMenu) 316 | , Attrs.attribute "role" "combobox" 317 | , Attrs.attribute "aria-autocomplete" "list" 318 | ] 319 | ) 320 | [] 321 | ] 322 | menu 323 | ) 324 | 325 | 326 | acceptablePeople : String -> List Person -> List Person 327 | acceptablePeople query people = 328 | let 329 | lowerQuery = 330 | String.toLower query 331 | in 332 | List.filter (String.contains lowerQuery << String.toLower << .name) people 333 | 334 | 335 | viewMenu : Model -> Html.Html Msg 336 | viewMenu model = 337 | Html.div [ Attrs.class "autocomplete-menu" ] 338 | [ Html.map SetAutoState <| 339 | Menu.view viewConfig 340 | model.howManyToShow 341 | model.autoState 342 | (acceptablePeople model.query model.people) 343 | ] 344 | 345 | 346 | updateConfig : Menu.UpdateConfig Msg Person 347 | updateConfig = 348 | Menu.updateConfig 349 | { toId = .name 350 | , onKeyDown = 351 | \code maybeId -> 352 | if code == 38 || code == 40 then 353 | Maybe.map PreviewPerson maybeId 354 | 355 | else if code == 13 then 356 | Maybe.map SelectPersonKeyboard maybeId 357 | 358 | else 359 | Just Reset 360 | , onTooLow = Just (Wrap False) 361 | , onTooHigh = Just (Wrap True) 362 | , onMouseEnter = \id -> Just (PreviewPerson id) 363 | , onMouseLeave = \_ -> Nothing 364 | , onMouseClick = \id -> Just (SelectPersonMouse id) 365 | , separateSelections = False 366 | } 367 | 368 | 369 | viewConfig : Menu.ViewConfig Person 370 | viewConfig = 371 | let 372 | customizedLi keySelected mouseSelected person = 373 | { attributes = 374 | [ Attrs.classList 375 | [ ( "autocomplete-item", True ) 376 | , ( "key-selected", keySelected || mouseSelected ) 377 | ] 378 | , Attrs.id person.name 379 | ] 380 | , children = [ Html.text person.name ] 381 | } 382 | in 383 | Menu.viewConfig 384 | { toId = .name 385 | , ul = [ Attrs.class "autocomplete-list" ] 386 | , li = customizedLi 387 | } 388 | 389 | 390 | 391 | -- PEOPLE 392 | 393 | 394 | type alias Person = 395 | { name : String 396 | , year : Int 397 | , city : String 398 | , state : String 399 | } 400 | 401 | 402 | presidents : List Person 403 | presidents = 404 | [ Person "George Washington" 1732 "Westmoreland County" "Virginia" 405 | , Person "John Adams" 1735 "Braintree" "Massachusetts" 406 | , Person "Thomas Jefferson" 1743 "Shadwell" "Virginia" 407 | , Person "James Madison" 1751 "Port Conway" "Virginia" 408 | , Person "James Monroe" 1758 "Monroe Hall" "Virginia" 409 | , Person "Andrew Jackson" 1767 "Waxhaws Region" "South/North Carolina" 410 | , Person "John Quincy Adams" 1767 "Braintree" "Massachusetts" 411 | , Person "William Henry Harrison" 1773 "Charles City County" "Virginia" 412 | , Person "Martin Van Buren" 1782 "Kinderhook" "New York" 413 | , Person "Zachary Taylor" 1784 "Barboursville" "Virginia" 414 | , Person "John Tyler" 1790 "Charles City County" "Virginia" 415 | , Person "James Buchanan" 1791 "Cove Gap" "Pennsylvania" 416 | , Person "James K. Polk" 1795 "Pineville" "North Carolina" 417 | , Person "Millard Fillmore" 1800 "Summerhill" "New York" 418 | , Person "Franklin Pierce" 1804 "Hillsborough" "New Hampshire" 419 | , Person "Andrew Johnson" 1808 "Raleigh" "North Carolina" 420 | , Person "Abraham Lincoln" 1809 "Sinking spring" "Kentucky" 421 | , Person "Ulysses S. Grant" 1822 "Point Pleasant" "Ohio" 422 | , Person "Rutherford B. Hayes" 1822 "Delaware" "Ohio" 423 | , Person "Chester A. Arthur" 1829 "Fairfield" "Vermont" 424 | , Person "James A. Garfield" 1831 "Moreland Hills" "Ohio" 425 | , Person "Benjamin Harrison" 1833 "North Bend" "Ohio" 426 | , Person "Grover Cleveland" 1837 "Caldwell" "New Jersey" 427 | , Person "William McKinley" 1843 "Niles" "Ohio" 428 | , Person "Woodrow Wilson" 1856 "Staunton" "Virginia" 429 | , Person "William Howard Taft" 1857 "Cincinnati" "Ohio" 430 | , Person "Theodore Roosevelt" 1858 "New York City" "New York" 431 | , Person "Warren G. Harding" 1865 "Blooming Grove" "Ohio" 432 | , Person "Calvin Coolidge" 1872 "Plymouth" "Vermont" 433 | , Person "Herbert Hoover" 1874 "West Branch" "Iowa" 434 | , Person "Franklin D. Roosevelt" 1882 "Hyde Park" "New York" 435 | , Person "Harry S. Truman" 1884 "Lamar" "Missouri" 436 | , Person "Dwight D. Eisenhower" 1890 "Denison" "Texas" 437 | , Person "Lyndon B. Johnson" 1908 "Stonewall" "Texas" 438 | , Person "Ronald Reagan" 1911 "Tampico" "Illinois" 439 | , Person "Richard M. Nixon" 1913 "Yorba Linda" "California" 440 | , Person "Gerald R. Ford" 1913 "Omaha" "Nebraska" 441 | , Person "John F. Kennedy" 1917 "Brookline" "Massachusetts" 442 | , Person "George H. W. Bush" 1924 "Milton" "Massachusetts" 443 | , Person "Jimmy Carter" 1924 "Plains" "Georgia" 444 | , Person "George W. Bush" 1946 "New Haven" "Connecticut" 445 | , Person "Bill Clinton" 1946 "Hope" "Arkansas" 446 | , Person "Barack Obama" 1961 "Honolulu" "Hawaii" 447 | ] 448 | -------------------------------------------------------------------------------- /examples/src/SectionsExample.elm: -------------------------------------------------------------------------------- 1 | module SectionsExample exposing 2 | ( Century 3 | , Model 4 | , Msg(..) 5 | , Person 6 | , acceptablePeople 7 | , acceptablePeopleByCentury 8 | , init 9 | , main 10 | , presidents 11 | , presidentsByCentury 12 | , sectionConfig 13 | , subscriptions 14 | , update 15 | , updateConfig 16 | , view 17 | , viewConfig 18 | , viewMenu 19 | ) 20 | 21 | import Browser 22 | import Html 23 | import Html.Attributes as Attrs 24 | import Html.Events as Events 25 | import Json.Decode as Decode 26 | import Menu 27 | import String 28 | 29 | 30 | main : Program () Model Msg 31 | main = 32 | Browser.element 33 | { init = \_ -> ( init, Cmd.none ) 34 | , update = update 35 | , view = view 36 | , subscriptions = subscriptions 37 | } 38 | 39 | 40 | subscriptions : Model -> Sub Msg 41 | subscriptions model = 42 | Sub.map SetAutoState Menu.subscription 43 | 44 | 45 | type alias Model = 46 | { people : List Person 47 | , peopleByCentury : List Century 48 | , autoState : Menu.State 49 | , howManyToShow : Int 50 | , query : String 51 | , showMenu : Bool 52 | } 53 | 54 | 55 | init : Model 56 | init = 57 | { people = presidents 58 | , peopleByCentury = presidentsByCentury 59 | , autoState = Menu.empty 60 | , howManyToShow = 5 61 | , query = "" 62 | , showMenu = False 63 | } 64 | 65 | 66 | type Msg 67 | = SetQuery String 68 | | SetAutoState Menu.Msg 69 | | Reset 70 | | SelectPerson String 71 | | OnFocus 72 | | NoOp 73 | 74 | 75 | update : Msg -> Model -> ( Model, Cmd Msg ) 76 | update msg model = 77 | case msg of 78 | SetQuery newQuery -> 79 | ( { model | query = newQuery, showMenu = True } 80 | , Cmd.none 81 | ) 82 | 83 | SetAutoState autoMsg -> 84 | let 85 | ( newState, maybeMsg ) = 86 | Menu.update updateConfig autoMsg model.howManyToShow model.autoState (acceptablePeople model) 87 | 88 | newModel = 89 | { model | autoState = newState } 90 | in 91 | case maybeMsg of 92 | Nothing -> 93 | ( newModel 94 | , Cmd.none 95 | ) 96 | 97 | Just updateMsg -> 98 | update updateMsg newModel 99 | 100 | Reset -> 101 | ( { model 102 | | autoState = 103 | Menu.resetToFirstItem updateConfig (acceptablePeople model) model.howManyToShow model.autoState 104 | } 105 | , Cmd.none 106 | ) 107 | 108 | SelectPerson id -> 109 | ( { model 110 | | query = 111 | List.filter (\person -> person.name == id) model.people 112 | |> List.head 113 | |> Maybe.withDefault (Person "" 0 "" "") 114 | |> .name 115 | , autoState = Menu.empty 116 | , showMenu = False 117 | } 118 | , Cmd.none 119 | ) 120 | 121 | OnFocus -> 122 | ( model, Cmd.none ) 123 | 124 | NoOp -> 125 | ( model, Cmd.none ) 126 | 127 | 128 | view : Model -> Html.Html Msg 129 | view model = 130 | let 131 | upDownDecoderHelper : Int -> Decode.Decoder ( Msg, Bool ) 132 | upDownDecoderHelper code = 133 | if code == 38 || code == 40 then 134 | Decode.succeed ( NoOp, True ) 135 | 136 | else 137 | Decode.fail "not handling that key" 138 | 139 | upDownDecoder : Decode.Decoder ( Msg, Bool ) 140 | upDownDecoder = 141 | Events.keyCode |> Decode.andThen upDownDecoderHelper 142 | in 143 | Html.div [] 144 | [ Html.input 145 | [ Events.onInput SetQuery 146 | , Events.onFocus OnFocus 147 | , Events.preventDefaultOn "keydown" upDownDecoder 148 | , Attrs.class "autocomplete-input" 149 | , Attrs.value model.query 150 | ] 151 | [] 152 | , if model.showMenu then 153 | viewMenu model 154 | 155 | else 156 | Html.div [] [] 157 | ] 158 | 159 | 160 | acceptablePeopleByCentury : Model -> List Century 161 | acceptablePeopleByCentury { query, peopleByCentury } = 162 | let 163 | lowerQuery = 164 | String.toLower query 165 | 166 | filteredCentury century people = 167 | { century | people = people } 168 | 169 | filterPeople century = 170 | filteredCentury century (List.filter (String.contains lowerQuery << String.toLower << .name) century.people) 171 | in 172 | List.map filterPeople peopleByCentury 173 | 174 | 175 | acceptablePeople : Model -> List Person 176 | acceptablePeople { query, people } = 177 | let 178 | lowerQuery = 179 | String.toLower query 180 | in 181 | List.filter (String.contains lowerQuery << String.toLower << .name) people 182 | 183 | 184 | viewMenu : Model -> Html.Html Msg 185 | viewMenu model = 186 | Html.div [ Attrs.class "autocomplete-menu" ] 187 | [ Html.map SetAutoState <| 188 | Menu.viewWithSections 189 | viewConfig 190 | model.howManyToShow 191 | model.autoState 192 | (acceptablePeopleByCentury model) 193 | ] 194 | 195 | 196 | updateConfig : Menu.UpdateConfig Msg Person 197 | updateConfig = 198 | Menu.updateConfig 199 | { toId = .name 200 | , onKeyDown = 201 | \code maybeId -> 202 | if code == 38 || code == 40 then 203 | Nothing 204 | 205 | else if code == 13 then 206 | Maybe.map SelectPerson maybeId 207 | 208 | else 209 | Just Reset 210 | , onTooLow = Just Reset 211 | , onTooHigh = Just Reset 212 | , onMouseEnter = \_ -> Nothing 213 | , onMouseLeave = \_ -> Nothing 214 | , onMouseClick = \id -> Just (SelectPerson id) 215 | , separateSelections = True 216 | } 217 | 218 | 219 | viewConfig : Menu.ViewWithSectionsConfig Person Century 220 | viewConfig = 221 | let 222 | customizedLi keySelected mouseSelected person = 223 | { attributes = 224 | [ Attrs.classList [ ( "autocomplete-item", True ), ( "key-selected", keySelected ), ( "mouse-selected", mouseSelected ) ] 225 | , Attrs.id person.name 226 | ] 227 | , children = [ Html.text person.name ] 228 | } 229 | in 230 | Menu.viewWithSectionsConfig 231 | { toId = .name 232 | , ul = [ Attrs.class "autocomplete-list-with-sections" ] 233 | , li = customizedLi 234 | , section = sectionConfig 235 | } 236 | 237 | 238 | sectionConfig : Menu.SectionConfig Person Century 239 | sectionConfig = 240 | Menu.sectionConfig 241 | { toId = .title 242 | , getData = .people 243 | , ul = [ Attrs.class "autocomplete-section-list" ] 244 | , li = 245 | \section -> 246 | { nodeType = "div" 247 | , attributes = [ Attrs.class "autocomplete-section-item" ] 248 | , children = 249 | [ Html.div [ Attrs.class "autocomplete-section-box" ] 250 | [ Html.strong [ Attrs.class "autocomplete-section-text" ] [ Html.text section.title ] 251 | ] 252 | ] 253 | } 254 | } 255 | 256 | 257 | 258 | -- PEOPLE 259 | 260 | 261 | type alias Century = 262 | { title : String 263 | , people : List Person 264 | } 265 | 266 | 267 | presidentsByCentury : List Century 268 | presidentsByCentury = 269 | [ { title = "1700s" 270 | , people = 271 | [ Person "George Washington" 1732 "Westmoreland County" "Virginia" 272 | , Person "John Adams" 1735 "Braintree" "Massachusetts" 273 | , Person "Thomas Jefferson" 1743 "Shadwell" "Virginia" 274 | , Person "James Madison" 1751 "Port Conway" "Virginia" 275 | , Person "James Monroe" 1758 "Monroe Hall" "Virginia" 276 | , Person "Andrew Jackson" 1767 "Waxhaws Region" "South/North Carolina" 277 | , Person "John Quincy Adams" 1767 "Braintree" "Massachusetts" 278 | , Person "William Henry Harrison" 1773 "Charles City County" "Virginia" 279 | , Person "Martin Van Buren" 1782 "Kinderhook" "New York" 280 | , Person "Zachary Taylor" 1784 "Barboursville" "Virginia" 281 | , Person "John Tyler" 1790 "Charles City County" "Virginia" 282 | , Person "James Buchanan" 1791 "Cove Gap" "Pennsylvania" 283 | , Person "James K. Polk" 1795 "Pineville" "North Carolina" 284 | ] 285 | } 286 | , { title = "1800s" 287 | , people = 288 | [ Person "Millard Fillmore" 1800 "Summerhill" "New York" 289 | , Person "Franklin Pierce" 1804 "Hillsborough" "New Hampshire" 290 | , Person "Andrew Johnson" 1808 "Raleigh" "North Carolina" 291 | , Person "Abraham Lincoln" 1809 "Sinking spring" "Kentucky" 292 | , Person "Ulysses S. Grant" 1822 "Point Pleasant" "Ohio" 293 | , Person "Rutherford B. Hayes" 1822 "Delaware" "Ohio" 294 | , Person "Chester A. Arthur" 1829 "Fairfield" "Vermont" 295 | , Person "James A. Garfield" 1831 "Moreland Hills" "Ohio" 296 | , Person "Benjamin Harrison" 1833 "North Bend" "Ohio" 297 | , Person "Grover Cleveland" 1837 "Caldwell" "New Jersey" 298 | , Person "William McKinley" 1843 "Niles" "Ohio" 299 | , Person "Woodrow Wilson" 1856 "Staunton" "Virginia" 300 | , Person "William Howard Taft" 1857 "Cincinnati" "Ohio" 301 | , Person "Theodore Roosevelt" 1858 "New York City" "New York" 302 | , Person "Warren G. Harding" 1865 "Blooming Grove" "Ohio" 303 | , Person "Calvin Coolidge" 1872 "Plymouth" "Vermont" 304 | , Person "Herbert Hoover" 1874 "West Branch" "Iowa" 305 | , Person "Franklin D. Roosevelt" 1882 "Hyde Park" "New York" 306 | , Person "Harry S. Truman" 1884 "Lamar" "Missouri" 307 | , Person "Dwight D. Eisenhower" 1890 "Denison" "Texas" 308 | ] 309 | } 310 | , { title = "1900s" 311 | , people = 312 | [ Person "Lyndon B. Johnson" 1908 "Stonewall" "Texas" 313 | , Person "Ronald Reagan" 1911 "Tampico" "Illinois" 314 | , Person "Richard M. Nixon" 1913 "Yorba Linda" "California" 315 | , Person "Gerald R. Ford" 1913 "Omaha" "Nebraska" 316 | , Person "John F. Kennedy" 1917 "Brookline" "Massachusetts" 317 | , Person "George H. W. Bush" 1924 "Milton" "Massachusetts" 318 | , Person "Jimmy Carter" 1924 "Plains" "Georgia" 319 | , Person "George W. Bush" 1946 "New Haven" "Connecticut" 320 | , Person "Bill Clinton" 1946 "Hope" "Arkansas" 321 | , Person "Barack Obama" 1961 "Honolulu" "Hawaii" 322 | ] 323 | } 324 | ] 325 | 326 | 327 | type alias Person = 328 | { name : String 329 | , year : Int 330 | , city : String 331 | , state : String 332 | } 333 | 334 | 335 | presidents : List Person 336 | presidents = 337 | [ Person "George Washington" 1732 "Westmoreland County" "Virginia" 338 | , Person "John Adams" 1735 "Braintree" "Massachusetts" 339 | , Person "Thomas Jefferson" 1743 "Shadwell" "Virginia" 340 | , Person "James Madison" 1751 "Port Conway" "Virginia" 341 | , Person "James Monroe" 1758 "Monroe Hall" "Virginia" 342 | , Person "Andrew Jackson" 1767 "Waxhaws Region" "South/North Carolina" 343 | , Person "John Quincy Adams" 1767 "Braintree" "Massachusetts" 344 | , Person "William Henry Harrison" 1773 "Charles City County" "Virginia" 345 | , Person "Martin Van Buren" 1782 "Kinderhook" "New York" 346 | , Person "Zachary Taylor" 1784 "Barboursville" "Virginia" 347 | , Person "John Tyler" 1790 "Charles City County" "Virginia" 348 | , Person "James Buchanan" 1791 "Cove Gap" "Pennsylvania" 349 | , Person "James K. Polk" 1795 "Pineville" "North Carolina" 350 | , Person "Millard Fillmore" 1800 "Summerhill" "New York" 351 | , Person "Franklin Pierce" 1804 "Hillsborough" "New Hampshire" 352 | , Person "Andrew Johnson" 1808 "Raleigh" "North Carolina" 353 | , Person "Abraham Lincoln" 1809 "Sinking spring" "Kentucky" 354 | , Person "Ulysses S. Grant" 1822 "Point Pleasant" "Ohio" 355 | , Person "Rutherford B. Hayes" 1822 "Delaware" "Ohio" 356 | , Person "Chester A. Arthur" 1829 "Fairfield" "Vermont" 357 | , Person "James A. Garfield" 1831 "Moreland Hills" "Ohio" 358 | , Person "Benjamin Harrison" 1833 "North Bend" "Ohio" 359 | , Person "Grover Cleveland" 1837 "Caldwell" "New Jersey" 360 | , Person "William McKinley" 1843 "Niles" "Ohio" 361 | , Person "Woodrow Wilson" 1856 "Staunton" "Virginia" 362 | , Person "William Howard Taft" 1857 "Cincinnati" "Ohio" 363 | , Person "Theodore Roosevelt" 1858 "New York City" "New York" 364 | , Person "Warren G. Harding" 1865 "Blooming Grove" "Ohio" 365 | , Person "Calvin Coolidge" 1872 "Plymouth" "Vermont" 366 | , Person "Herbert Hoover" 1874 "West Branch" "Iowa" 367 | , Person "Franklin D. Roosevelt" 1882 "Hyde Park" "New York" 368 | , Person "Harry S. Truman" 1884 "Lamar" "Missouri" 369 | , Person "Dwight D. Eisenhower" 1890 "Denison" "Texas" 370 | , Person "Lyndon B. Johnson" 1908 "Stonewall" "Texas" 371 | , Person "Ronald Reagan" 1911 "Tampico" "Illinois" 372 | , Person "Richard M. Nixon" 1913 "Yorba Linda" "California" 373 | , Person "Gerald R. Ford" 1913 "Omaha" "Nebraska" 374 | , Person "John F. Kennedy" 1917 "Brookline" "Massachusetts" 375 | , Person "George H. W. Bush" 1924 "Milton" "Massachusetts" 376 | , Person "Jimmy Carter" 1924 "Plains" "Georgia" 377 | , Person "George W. Bush" 1946 "New Haven" "Connecticut" 378 | , Person "Bill Clinton" 1946 "Hope" "Arkansas" 379 | , Person "Barack Obama" 1961 "Honolulu" "Hawaii" 380 | ] 381 | -------------------------------------------------------------------------------- /scripts/deploy-to-gh-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | git checkout gh-pages 6 | git pull origin gh-pages --no-edit 7 | git merge main --no-edit 8 | cd examples 9 | make demo 10 | cd .. 11 | git add examples/site/ 12 | git commit -m 'Update gh-pages files' 13 | git subtree push --prefix examples/site/ origin gh-pages 14 | git checkout main 15 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | let 2 | pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-22.11") { }; 3 | in 4 | pkgs.mkShell { 5 | buildInputs = [ 6 | # Elm 7 | pkgs.elmPackages.elm 8 | pkgs.elmPackages.elm-test 9 | pkgs.elmPackages.elm-format 10 | pkgs.elmPackages.elm-json 11 | 12 | # NIX 13 | pkgs.nixpkgs-fmt 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /src/Menu.elm: -------------------------------------------------------------------------------- 1 | module Menu exposing 2 | ( view 3 | , update, subscription 4 | , viewConfig, updateConfig 5 | , State, current, empty, reset, resetToFirstItem, resetToLastItem, KeySelected, MouseSelected 6 | , Msg, ViewConfig, UpdateConfig, HtmlDetails 7 | , viewWithSections 8 | , sectionConfig, viewWithSectionsConfig 9 | , SectionNode, SectionConfig, ViewWithSectionsConfig 10 | ) 11 | 12 | {-| This library helps you create a menu. 13 | Your data is stored separately; keep it in whatever shape makes the most sense for your application. 14 | A menu has a lot of uses: form input, mentions, search, etc. 15 | 16 | I have (hopefully!) given the users of this library a large amount of customizability. 17 | 18 | I recommend looking at the [`examples`][examples] before diving into the API or source code. 19 | 20 | [examples]: https://github.com/ContaSystemer/elm-menu/tree/main/examples 21 | 22 | 23 | # View 24 | 25 | @docs view 26 | 27 | 28 | # Update 29 | 30 | @docs update, subscription 31 | 32 | 33 | # Configuration 34 | 35 | @docs viewConfig, updateConfig 36 | 37 | 38 | # State 39 | 40 | @docs State, current, empty, reset, resetToFirstItem, resetToLastItem, KeySelected, MouseSelected 41 | 42 | 43 | # Definitions 44 | 45 | @docs Msg, ViewConfig, UpdateConfig, HtmlDetails 46 | 47 | 48 | # Sections 49 | 50 | Sections require a separate view and configuration since another type of data must be 51 | provided: sections. 52 | 53 | **Note:** Section data can have any shape: your static configuration will 54 | just tell the menu how to grab an ID for a section and its related data. 55 | 56 | 57 | # View 58 | 59 | @docs viewWithSections 60 | 61 | 62 | # Configuration 63 | 64 | @docs sectionConfig, viewWithSectionsConfig 65 | 66 | 67 | # Definitions 68 | 69 | @docs SectionNode, SectionConfig, ViewWithSectionsConfig 70 | 71 | -} 72 | 73 | import Html 74 | import Menu.Internal as Internal 75 | 76 | 77 | {-| Tracks keyboard and mouse selection within the menu. 78 | -} 79 | type State 80 | = State Internal.State 81 | 82 | 83 | {-| True if the element has been selected via keyboard navigation. 84 | -} 85 | type alias KeySelected = 86 | Bool 87 | 88 | 89 | {-| True if the element has been selected via mouse hover. 90 | -} 91 | type alias MouseSelected = 92 | Bool 93 | 94 | 95 | {-| Current State. 96 | -} 97 | current : State -> ( Maybe String, Maybe String ) 98 | current (State state) = 99 | Internal.current state 100 | 101 | 102 | {-| A State with nothing selected. 103 | -} 104 | empty : State 105 | empty = 106 | State Internal.empty 107 | 108 | 109 | {-| Reset the keyboard navigation but leave the mouse state alone. 110 | Convenient when the two selections are represented separately. 111 | -} 112 | reset : UpdateConfig msg data -> State -> State 113 | reset (UpdateConfig config) (State state) = 114 | State (Internal.reset config state) 115 | 116 | 117 | {-| Like `reset` but defaults to a keyboard selection of the first item. 118 | -} 119 | resetToFirstItem : UpdateConfig msg data -> List data -> Int -> State -> State 120 | resetToFirstItem (UpdateConfig config) data howManyToShow (State state) = 121 | State (Internal.resetToFirstItem config data howManyToShow state) 122 | 123 | 124 | {-| Like `reset` but defaults to a keyboard selection of the last item. 125 | -} 126 | resetToLastItem : UpdateConfig msg data -> List data -> Int -> State -> State 127 | resetToLastItem (UpdateConfig config) data howManyToShow (State state) = 128 | State (Internal.resetToLastItem config data howManyToShow state) 129 | 130 | 131 | 132 | -- UPDATE 133 | 134 | 135 | {-| A message type for the menu to update. 136 | -} 137 | type Msg 138 | = Msg Internal.Msg 139 | 140 | 141 | {-| Configuration for updates 142 | -} 143 | type UpdateConfig msg data 144 | = UpdateConfig (Internal.UpdateConfig msg data) 145 | 146 | 147 | {-| Use this function to update the menu's `State`. 148 | Provide the same data as your view. 149 | The `Int` argument is how many results you would like to show. 150 | -} 151 | update : UpdateConfig msg data -> Msg -> Int -> State -> List data -> ( State, Maybe msg ) 152 | update (UpdateConfig config) (Msg msg) howManyToShow (State state) data = 153 | let 154 | ( newState, maybeMsg ) = 155 | Internal.update config msg howManyToShow state data 156 | in 157 | ( State newState, maybeMsg ) 158 | 159 | 160 | {-| Create the configuration for your `update` function (`UpdateConfig`). 161 | Say we have a `List Person` that we want to show as a series of options. 162 | We would create an `UpdateConfig` like so: 163 | 164 | import Menu 165 | 166 | updateConfig : Menu.UpdateConfig Msg Person 167 | updateConfig = 168 | Menu.updateConfig 169 | { toId = .name 170 | , onKeyDown = 171 | \code maybeId -> 172 | if code == 38 || code == 40 then 173 | Nothing 174 | 175 | else if code == 13 then 176 | Maybe.map SelectPerson maybeId 177 | 178 | else 179 | Just Reset 180 | , onTooLow = Nothing 181 | , onTooHigh = Nothing 182 | , onMouseEnter = \_ -> Nothing 183 | , onMouseLeave = \_ -> Nothing 184 | , onMouseClick = \id -> Just (SelectPerson id) 185 | , separateSelections = False 186 | } 187 | 188 | You provide the following information in your menu configuration: 189 | 190 | - `toId` — turn a `Person` into a unique ID. 191 | - `ul` — specify any non-behavioral attributes you'd like for the list menu. 192 | - `li` — specify any non-behavioral attributes and children for a list item: both selection states are provided. 193 | 194 | -} 195 | updateConfig : 196 | { toId : data -> String 197 | , onKeyDown : Int -> Maybe String -> Maybe msg 198 | , onTooLow : Maybe msg 199 | , onTooHigh : Maybe msg 200 | , onMouseEnter : String -> Maybe msg 201 | , onMouseLeave : String -> Maybe msg 202 | , onMouseClick : String -> Maybe msg 203 | , separateSelections : Bool 204 | } 205 | -> UpdateConfig msg data 206 | updateConfig config = 207 | UpdateConfig (Internal.updateConfig config) 208 | 209 | 210 | {-| Add this to your `program`'s subscriptions so the menu will respond to keyboard input. 211 | -} 212 | subscription : Sub Msg 213 | subscription = 214 | Sub.map Msg Internal.subscription 215 | 216 | 217 | {-| Take a list of `data` and turn it into a menu. 218 | The `ViewConfig` argument is the configuration for the menu view. 219 | `ViewConfig` describes the HTML we want to show for each item and the list. 220 | The `Int` argument is how many results you would like to show. 221 | The `State` argument describes what is selected via mouse and keyboard. 222 | 223 | **Note:** The `State` and `List data` should live in your Model. 224 | The `ViewConfig` for the menu belongs in your view code. 225 | `ViewConfig` should never exist in your model. 226 | Describe any potential menu configurations statically. 227 | This pattern has been inspired by [Elm Sortable Table](http://package.elm-lang.org/packages/evancz/elm-sortable-table/latest). 228 | 229 | -} 230 | view : ViewConfig data -> Int -> State -> List data -> Html.Html Msg 231 | view (ViewConfig config) howManyToShow (State state) data = 232 | Html.map Msg (Internal.view config howManyToShow state data) 233 | 234 | 235 | {-| Presents a menu with sections. 236 | You can follow the same instructions as described for `view`, providing a more advanced configuration and different data shape. 237 | `ViewWithSectionsConfig` sets up your menu to handle sectioned data. 238 | The sectioned data becomes the new data argument for `viewWithSections`. 239 | -} 240 | viewWithSections : ViewWithSectionsConfig data sectionData -> Int -> State -> List sectionData -> Html.Html Msg 241 | viewWithSections (ViewWithSectionsConfig config) howManyToShow (State state) sections = 242 | Html.map Msg (Internal.viewWithSections config howManyToShow state sections) 243 | 244 | 245 | {-| HTML lists require `li` tags as children, so we allow you to specify everything about `li` HTML node except the nodeType. 246 | -} 247 | type alias HtmlDetails msg = 248 | { attributes : List (Html.Attribute msg) 249 | , children : List (Html.Html msg) 250 | } 251 | 252 | 253 | {-| Configuration for your menu, describing your menu and its items. 254 | 255 | **Note:** Your `ViewConfig` should never be held in your model. It should only appear in view code. 256 | 257 | -} 258 | type ViewConfig data 259 | = ViewConfig (Internal.ViewConfig data) 260 | 261 | 262 | {-| Create the configuration for your `view` function (`ViewConfig`). 263 | Say we have a `List Person` that we want to show as a series of options. 264 | We would create a `ViewConfig` like so: 265 | 266 | import Menu 267 | 268 | viewConfig : Menu.ViewConfig Person 269 | viewConfig = 270 | let 271 | customizedLi keySelected mouseSelected person = 272 | { attributes = 273 | [ classList 274 | [ ( "menu-item", True ) 275 | , ( "key-selected", keySelected ) 276 | , ( "mouse-selected", mouseSelected ) 277 | ] 278 | ] 279 | , children = [ Html.text person.name ] 280 | } 281 | in 282 | Menu.viewConfig 283 | { toId = .name 284 | , ul = [ class "menu-list" ] 285 | , li = customizedLi 286 | } 287 | 288 | You provide the following information in your menu configuration: 289 | 290 | - `toId` — turn a `Person` into a unique ID. This lets us use 291 | [`Html.Keyed`][keyed] under the hood to make sorting faster. 292 | - `ul` — specify any non-behavioral attributes you'd like for the list menu. 293 | - `li` — specify any non-behavioral attributes and children for a list item: both selection states are provided. 294 | See the [examples] to get a better understanding! 295 | 296 | [keyed]: http://package.elm-lang.org/packages/elm-lang/html/latest/Html-Keyed 297 | [examples]: https://github.com/ContaSystemer/elm-menu/tree/main/examples 298 | 299 | -} 300 | viewConfig : 301 | { toId : data -> String 302 | , ul : List (Html.Attribute Never) 303 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 304 | } 305 | -> ViewConfig data 306 | viewConfig config = 307 | ViewConfig (Internal.viewConfig config) 308 | 309 | 310 | {-| Configuration for your menu, describing your menu, its sections, and its items. 311 | 312 | **Note:** This should never live in your model. 313 | 314 | -} 315 | type ViewWithSectionsConfig data sectionData 316 | = ViewWithSectionsConfig (Internal.ViewWithSectionsConfig data sectionData) 317 | 318 | 319 | {-| The same configuration as viewConfig, but provide a section configuration as well. 320 | -} 321 | viewWithSectionsConfig : 322 | { toId : data -> String 323 | , ul : List (Html.Attribute Never) 324 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 325 | , section : SectionConfig data sectionData 326 | } 327 | -> ViewWithSectionsConfig data sectionData 328 | viewWithSectionsConfig { toId, ul, li, section } = 329 | let 330 | (SectionConfig internalSection) = 331 | section 332 | in 333 | ViewWithSectionsConfig <| 334 | Internal.viewWithSectionsConfig 335 | { toId = toId 336 | , ul = ul 337 | , li = li 338 | , section = internalSection 339 | } 340 | 341 | 342 | {-| The configuration for a section of the menu. 343 | 344 | **Note:** This should never live in your model. 345 | 346 | -} 347 | type SectionConfig data sectionData 348 | = SectionConfig (Internal.SectionConfig data sectionData) 349 | 350 | 351 | {-| Describe everything about a Section HTML node. 352 | -} 353 | type alias SectionNode msg = 354 | { nodeType : String 355 | , attributes : List (Html.Attribute msg) 356 | , children : List (Html.Html msg) 357 | } 358 | 359 | 360 | {-| Create the `SectionConfig` for your `view` function. 361 | Say we have a `List Century` that we want to show as a series of sections. 362 | We would create a `SectionConfig` like so: 363 | 364 | type alias Century = 365 | { title : String 366 | , people : List Person 367 | } 368 | 369 | import Menu 370 | 371 | sectionConfig : Menu.SectionConfig Person Century 372 | sectionConfig = 373 | Menu.sectionConfig 374 | { toId = .title 375 | , getData = .people 376 | , ul = [ class "menu-section-list" ] 377 | , li = 378 | \section -> 379 | { nodeType = "div" 380 | , attributes = [ class "menu-section-item" ] 381 | , children = 382 | [ div [ class "menu-section-box" ] 383 | [ strong [ class "menu-section-text" ] [ text section.title ] 384 | ] 385 | ] 386 | } 387 | } 388 | 389 | You provide the following information in your menu configuration: 390 | 391 | - `toId` — turn a `Century` into a unique ID. 392 | - `getData` — extract the data from `Century`, in this case: `List Person`. 393 | - `ul` — specify any non-behavioral attributes you'd like for the section list. 394 | - `li` — specify any non-behavioral attributes and children for a section. 395 | 396 | -} 397 | sectionConfig : 398 | { toId : sectionData -> String 399 | , getData : sectionData -> List data 400 | , ul : List (Html.Attribute Never) 401 | , li : sectionData -> SectionNode Never 402 | } 403 | -> SectionConfig data sectionData 404 | sectionConfig section = 405 | SectionConfig (Internal.sectionConfig section) 406 | -------------------------------------------------------------------------------- /src/Menu/DefaultStyles.elm: -------------------------------------------------------------------------------- 1 | module Menu.DefaultStyles exposing (inputStyles, itemStyles, listStyles, menuStyles, selectedItemStyles) 2 | 3 | import Html 4 | import Html.Attributes as Attrs 5 | 6 | 7 | menuStyles : List (Html.Attribute msg) 8 | menuStyles = 9 | [ Attrs.style "position" "absolute" 10 | , Attrs.style "left" "5px" 11 | , Attrs.style "margin-top" "5px" 12 | , Attrs.style "background" "white" 13 | , Attrs.style "color" "black" 14 | , Attrs.style "border" "1px solid #DDD" 15 | , Attrs.style "border-radius" "3px" 16 | , Attrs.style "box-shadow" "0 0 5px rgba(0,0,0,0.1)" 17 | , Attrs.style "min-width" "120px" 18 | , Attrs.style "z-index" "11110" 19 | ] 20 | 21 | 22 | selectedItemStyles : List (Html.Attribute msg) 23 | selectedItemStyles = 24 | [ Attrs.style "background" "#3366FF" 25 | , Attrs.style "color" "white" 26 | , Attrs.style "display" "block" 27 | , Attrs.style "padding" "5px 10px" 28 | , Attrs.style "border-bottom" "1px solid #DDD" 29 | , Attrs.style "cursor" "pointer" 30 | ] 31 | 32 | 33 | listStyles : List (Html.Attribute msg) 34 | listStyles = 35 | [ Attrs.style "list-style" "none" 36 | , Attrs.style "padding" "0" 37 | , Attrs.style "margin" "auto" 38 | , Attrs.style "max-height" "200px" 39 | , Attrs.style "overflow-y" "auto" 40 | ] 41 | 42 | 43 | itemStyles : List (Html.Attribute msg) 44 | itemStyles = 45 | [ Attrs.style "display" "block" 46 | , Attrs.style "padding" "5px 10px" 47 | , Attrs.style "border-bottom" "1px solid #DDD" 48 | , Attrs.style "cursor" "pointer" 49 | ] 50 | 51 | 52 | inputStyles : List (Html.Attribute msg) 53 | inputStyles = 54 | [ Attrs.style "min-width" "120px" 55 | , Attrs.style "color" "black" 56 | , Attrs.style "position" "relative" 57 | , Attrs.style "display" "block" 58 | , Attrs.style "padding" "0.8em" 59 | , Attrs.style "font-size" "12px" 60 | ] 61 | -------------------------------------------------------------------------------- /src/Menu/Internal.elm: -------------------------------------------------------------------------------- 1 | module Menu.Internal exposing 2 | ( HtmlDetails 3 | , KeySelected 4 | , MouseSelected 5 | , Msg 6 | , SectionConfig 7 | , SectionNode 8 | , State 9 | , UpdateConfig 10 | , ViewConfig 11 | , ViewWithSectionsConfig 12 | , current 13 | , empty 14 | , reset 15 | , resetToFirstItem 16 | , resetToLastItem 17 | , sectionConfig 18 | , subscription 19 | , update 20 | , updateConfig 21 | , view 22 | , viewConfig 23 | , viewWithSections 24 | , viewWithSectionsConfig 25 | ) 26 | 27 | import Browser.Events 28 | import Html 29 | import Html.Attributes as Attrs 30 | import Html.Events as Events 31 | import Html.Keyed as Keyed 32 | import Json.Decode as Decode 33 | 34 | 35 | 36 | -- MODEL 37 | 38 | 39 | type alias State = 40 | { key : Maybe String 41 | , mouse : Maybe String 42 | } 43 | 44 | 45 | type alias KeySelected = 46 | Bool 47 | 48 | 49 | type alias MouseSelected = 50 | Bool 51 | 52 | 53 | current : State -> ( Maybe String, Maybe String ) 54 | current state = 55 | ( state.key, state.mouse ) 56 | 57 | 58 | empty : State 59 | empty = 60 | { key = Nothing, mouse = Nothing } 61 | 62 | 63 | reset : UpdateConfig msg data -> State -> State 64 | reset { separateSelections } { mouse } = 65 | if separateSelections then 66 | { key = Nothing, mouse = mouse } 67 | 68 | else 69 | empty 70 | 71 | 72 | resetToFirstItem : UpdateConfig msg data -> List data -> Int -> State -> State 73 | resetToFirstItem config data howManyToShow state = 74 | resetToFirst config (List.take howManyToShow data) state 75 | 76 | 77 | resetToFirst : UpdateConfig msg data -> List data -> State -> State 78 | resetToFirst config data state = 79 | let 80 | { toId, separateSelections } = 81 | config 82 | 83 | setFirstItem datum newState = 84 | { newState | key = Just (toId datum) } 85 | in 86 | case List.head data of 87 | Nothing -> 88 | empty 89 | 90 | Just datum -> 91 | if separateSelections then 92 | state 93 | |> reset config 94 | |> setFirstItem datum 95 | 96 | else 97 | setFirstItem datum empty 98 | 99 | 100 | resetToLastItem : UpdateConfig msg data -> List data -> Int -> State -> State 101 | resetToLastItem config data howManyToShow state = 102 | let 103 | reversedData = 104 | List.reverse (List.take howManyToShow data) 105 | in 106 | resetToFirst config reversedData state 107 | 108 | 109 | 110 | -- UPDATE 111 | 112 | 113 | {-| Add this to your `program`s subscriptions to animate the spinner. 114 | -} 115 | subscription : Sub Msg 116 | subscription = 117 | Browser.Events.onKeyDown (Decode.map KeyDown Events.keyCode) 118 | 119 | 120 | type Msg 121 | = KeyDown Int 122 | | WentTooLow 123 | | WentTooHigh 124 | | MouseEnter String 125 | | MouseLeave String 126 | | MouseClick String 127 | | NoOp 128 | 129 | 130 | type alias UpdateConfig msg data = 131 | { onKeyDown : Int -> Maybe String -> Maybe msg 132 | , onTooLow : Maybe msg 133 | , onTooHigh : Maybe msg 134 | , onMouseEnter : String -> Maybe msg 135 | , onMouseLeave : String -> Maybe msg 136 | , onMouseClick : String -> Maybe msg 137 | , toId : data -> String 138 | , separateSelections : Bool 139 | } 140 | 141 | 142 | updateConfig : 143 | { toId : data -> String 144 | , onKeyDown : Int -> Maybe String -> Maybe msg 145 | , onTooLow : Maybe msg 146 | , onTooHigh : Maybe msg 147 | , onMouseEnter : String -> Maybe msg 148 | , onMouseLeave : String -> Maybe msg 149 | , onMouseClick : String -> Maybe msg 150 | , separateSelections : Bool 151 | } 152 | -> UpdateConfig msg data 153 | updateConfig { toId, onKeyDown, onTooLow, onTooHigh, onMouseEnter, onMouseLeave, onMouseClick, separateSelections } = 154 | { toId = toId 155 | , onKeyDown = onKeyDown 156 | , onTooLow = onTooLow 157 | , onTooHigh = onTooHigh 158 | , onMouseEnter = onMouseEnter 159 | , onMouseLeave = onMouseLeave 160 | , onMouseClick = onMouseClick 161 | , separateSelections = separateSelections 162 | } 163 | 164 | 165 | update : UpdateConfig msg data -> Msg -> Int -> State -> List data -> ( State, Maybe msg ) 166 | update config msg howManyToShow state data = 167 | case msg of 168 | KeyDown keyCode -> 169 | let 170 | boundedList = 171 | List.map config.toId data 172 | |> List.take howManyToShow 173 | 174 | newKey = 175 | navigateWithKey keyCode boundedList state.key 176 | in 177 | if newKey == state.key && keyCode == 38 then 178 | update config WentTooHigh howManyToShow state data 179 | 180 | else if newKey == state.key && keyCode == 40 then 181 | update config WentTooLow howManyToShow state data 182 | 183 | else if config.separateSelections then 184 | ( { state | key = newKey } 185 | , config.onKeyDown keyCode newKey 186 | ) 187 | 188 | else 189 | ( { key = newKey, mouse = newKey } 190 | , config.onKeyDown keyCode newKey 191 | ) 192 | 193 | WentTooLow -> 194 | ( state 195 | , config.onTooLow 196 | ) 197 | 198 | WentTooHigh -> 199 | ( state 200 | , config.onTooHigh 201 | ) 202 | 203 | MouseEnter id -> 204 | ( resetMouseStateWithId config.separateSelections id state 205 | , config.onMouseEnter id 206 | ) 207 | 208 | MouseLeave id -> 209 | ( resetMouseStateWithId config.separateSelections id state 210 | , config.onMouseLeave id 211 | ) 212 | 213 | MouseClick id -> 214 | ( resetMouseStateWithId config.separateSelections id state 215 | , config.onMouseClick id 216 | ) 217 | 218 | NoOp -> 219 | ( state, Nothing ) 220 | 221 | 222 | resetMouseStateWithId : Bool -> String -> State -> State 223 | resetMouseStateWithId separateSelections id state = 224 | if separateSelections then 225 | { key = state.key, mouse = Just id } 226 | 227 | else 228 | { key = Just id, mouse = Just id } 229 | 230 | 231 | getPreviousItemId : List String -> String -> String 232 | getPreviousItemId ids selectedId = 233 | Maybe.withDefault selectedId (List.foldr (getPrevious selectedId) Nothing ids) 234 | 235 | 236 | getPrevious : String -> String -> Maybe String -> Maybe String 237 | getPrevious id selectedId resultId = 238 | if selectedId == id then 239 | Just id 240 | 241 | else if Maybe.withDefault "" resultId == id then 242 | Just selectedId 243 | 244 | else 245 | resultId 246 | 247 | 248 | getNextItemId : List String -> String -> String 249 | getNextItemId ids selectedId = 250 | Maybe.withDefault selectedId (List.foldl (getPrevious selectedId) Nothing ids) 251 | 252 | 253 | navigateWithKey : Int -> List String -> Maybe String -> Maybe String 254 | navigateWithKey code ids maybeId = 255 | case code of 256 | 38 -> 257 | Maybe.map (getPreviousItemId ids) maybeId 258 | 259 | 40 -> 260 | Maybe.map (getNextItemId ids) maybeId 261 | 262 | _ -> 263 | maybeId 264 | 265 | 266 | view : ViewConfig data -> Int -> State -> List data -> Html.Html Msg 267 | view config howManyToShow state data = 268 | let 269 | customUlAttr = 270 | List.map mapNeverToMsg config.ul 271 | 272 | getKeyedItems datum = 273 | ( config.toId datum, viewItem config state datum ) 274 | in 275 | List.take howManyToShow data 276 | |> List.map getKeyedItems 277 | |> Keyed.ul customUlAttr 278 | 279 | 280 | viewWithSections : ViewWithSectionsConfig data sectionData -> Int -> State -> List sectionData -> Html.Html Msg 281 | viewWithSections config howManyToShow state sections = 282 | let 283 | customUlAttr = 284 | List.map mapNeverToMsg config.section.ul 285 | 286 | getKeyedItems section = 287 | ( config.section.toId section, viewSection config howManyToShow state section ) 288 | in 289 | sections 290 | |> List.take howManyToShow 291 | |> List.map getKeyedItems 292 | |> Keyed.ul customUlAttr 293 | 294 | 295 | viewSection : ViewWithSectionsConfig data sectionData -> Int -> State -> sectionData -> Html.Html Msg 296 | viewSection config howManyToShow state section = 297 | let 298 | sectionNode = 299 | config.section.li section 300 | 301 | attributes = 302 | List.map mapNeverToMsg sectionNode.attributes 303 | 304 | customChildren = 305 | List.map (Html.map (\_ -> NoOp)) sectionNode.children 306 | 307 | getKeyedItems datum = 308 | ( config.toId datum, viewData config state datum ) 309 | 310 | viewItemList = 311 | config.section.getData section 312 | |> List.take howManyToShow 313 | |> List.map getKeyedItems 314 | |> Keyed.ul (List.map mapNeverToMsg config.ul) 315 | 316 | children = 317 | List.append customChildren [ viewItemList ] 318 | in 319 | Html.li attributes 320 | [ Html.node sectionNode.nodeType attributes children ] 321 | 322 | 323 | viewData : ViewWithSectionsConfig data sectionData -> State -> data -> Html.Html Msg 324 | viewData { toId, li } { key, mouse } data = 325 | let 326 | id = 327 | toId data 328 | 329 | listItemData = 330 | li (isSelected key) (isSelected mouse) data 331 | 332 | customAttributes = 333 | List.map mapNeverToMsg listItemData.attributes 334 | 335 | customLiAttr = 336 | List.append customAttributes 337 | [ Events.onMouseEnter (MouseEnter id) 338 | , Events.onMouseLeave (MouseLeave id) 339 | , Events.onClick (MouseClick id) 340 | ] 341 | 342 | isSelected maybeId = 343 | case maybeId of 344 | Just someId -> 345 | someId == id 346 | 347 | Nothing -> 348 | False 349 | in 350 | Html.li customLiAttr (List.map (Html.map (\_ -> NoOp)) listItemData.children) 351 | 352 | 353 | viewItem : ViewConfig data -> State -> data -> Html.Html Msg 354 | viewItem { toId, li } { key, mouse } data = 355 | let 356 | id = 357 | toId data 358 | 359 | listItemData = 360 | li (isSelected key) (isSelected mouse) data 361 | 362 | customAttributes = 363 | List.map mapNeverToMsg listItemData.attributes 364 | 365 | customLiAttr = 366 | List.append customAttributes 367 | [ Events.onMouseEnter (MouseEnter id) 368 | , Events.onMouseLeave (MouseLeave id) 369 | , Events.onClick (MouseClick id) 370 | ] 371 | 372 | isSelected maybeId = 373 | case maybeId of 374 | Just someId -> 375 | someId == id 376 | 377 | Nothing -> 378 | False 379 | in 380 | Html.li customLiAttr (List.map (Html.map (\_ -> NoOp)) listItemData.children) 381 | 382 | 383 | type alias HtmlDetails msg = 384 | { attributes : List (Html.Attribute msg) 385 | , children : List (Html.Html msg) 386 | } 387 | 388 | 389 | type alias ViewConfig data = 390 | { toId : data -> String 391 | , ul : List (Html.Attribute Never) 392 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 393 | } 394 | 395 | 396 | type alias ViewWithSectionsConfig data sectionData = 397 | { toId : data -> String 398 | , ul : List (Html.Attribute Never) 399 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 400 | , section : SectionConfig data sectionData 401 | } 402 | 403 | 404 | type alias SectionConfig data sectionData = 405 | { toId : sectionData -> String 406 | , getData : sectionData -> List data 407 | , ul : List (Html.Attribute Never) 408 | , li : sectionData -> SectionNode Never 409 | } 410 | 411 | 412 | type alias SectionNode msg = 413 | { nodeType : String 414 | , attributes : List (Html.Attribute msg) 415 | , children : List (Html.Html msg) 416 | } 417 | 418 | 419 | viewConfig : 420 | { toId : data -> String 421 | , ul : List (Html.Attribute Never) 422 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 423 | } 424 | -> ViewConfig data 425 | viewConfig { toId, ul, li } = 426 | { toId = toId 427 | , ul = ul 428 | , li = li 429 | } 430 | 431 | 432 | viewWithSectionsConfig : 433 | { toId : data -> String 434 | , ul : List (Html.Attribute Never) 435 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 436 | , section : SectionConfig data sectionData 437 | } 438 | -> ViewWithSectionsConfig data sectionData 439 | viewWithSectionsConfig { toId, ul, li, section } = 440 | { toId = toId 441 | , ul = ul 442 | , li = li 443 | , section = section 444 | } 445 | 446 | 447 | sectionConfig : 448 | { toId : sectionData -> String 449 | , getData : sectionData -> List data 450 | , ul : List (Html.Attribute Never) 451 | , li : sectionData -> SectionNode Never 452 | } 453 | -> SectionConfig data sectionData 454 | sectionConfig { toId, getData, ul, li } = 455 | { toId = toId 456 | , getData = getData 457 | , ul = ul 458 | , li = li 459 | } 460 | 461 | 462 | 463 | -- HELPERS 464 | 465 | 466 | mapNeverToMsg : Html.Attribute Never -> Html.Attribute Msg 467 | mapNeverToMsg msg = 468 | Attrs.map (\_ -> NoOp) msg 469 | -------------------------------------------------------------------------------- /src/Menu/Styled.elm: -------------------------------------------------------------------------------- 1 | module Menu.Styled exposing 2 | ( view 3 | , update, subscription 4 | , viewConfig, updateConfig 5 | , State, current, empty, reset, resetToFirstItem, resetToLastItem, KeySelected, MouseSelected 6 | , Msg, ViewConfig, UpdateConfig, HtmlDetails 7 | , viewWithSections 8 | , sectionConfig, viewWithSectionsConfig 9 | , SectionNode, SectionConfig, ViewWithSectionsConfig 10 | ) 11 | 12 | {-| This library helps you create a menu. 13 | Your data is stored separately; keep it in whatever shape makes the most sense for your application. 14 | A menu has a lot of uses: form input, mentions, search, etc. 15 | 16 | I have (hopefully!) given the users of this library a large amount of customizability. 17 | 18 | I recommend looking at the [`examples`][examples] before diving into the API or source code. 19 | 20 | [examples]: https://github.com/ContaSystemer/elm-menu/tree/master/examples 21 | 22 | 23 | # View 24 | 25 | @docs view 26 | 27 | 28 | # Update 29 | 30 | @docs update, subscription 31 | 32 | 33 | # Configuration 34 | 35 | @docs viewConfig, updateConfig 36 | 37 | 38 | # State 39 | 40 | @docs State, current, empty, reset, resetToFirstItem, resetToLastItem, KeySelected, MouseSelected 41 | 42 | 43 | # Definitions 44 | 45 | @docs Msg, ViewConfig, UpdateConfig, HtmlDetails 46 | 47 | 48 | # Sections 49 | 50 | Sections require a separate view and configuration since another type of data must be 51 | provided: sections. 52 | 53 | **Note:** Section data can have any shape: your static configuration will 54 | just tell the menu how to grab an ID for a section and its related data. 55 | 56 | 57 | # View 58 | 59 | @docs viewWithSections 60 | 61 | 62 | # Configuration 63 | 64 | @docs sectionConfig, viewWithSectionsConfig 65 | 66 | 67 | # Definitions 68 | 69 | @docs SectionNode, SectionConfig, ViewWithSectionsConfig 70 | 71 | -} 72 | 73 | import Menu.Styled.Internal as Internal 74 | import Html.Styled as Html 75 | 76 | 77 | {-| Tracks keyboard and mouse selection within the menu. 78 | -} 79 | type State 80 | = State Internal.State 81 | 82 | 83 | {-| True if the element has been selected via keyboard navigation. 84 | -} 85 | type alias KeySelected = 86 | Bool 87 | 88 | 89 | {-| True if the element has been selected via mouse hover. 90 | -} 91 | type alias MouseSelected = 92 | Bool 93 | 94 | 95 | {-| Current State. 96 | -} 97 | current : State -> ( Maybe String, Maybe String ) 98 | current (State state) = 99 | Internal.current state 100 | 101 | 102 | {-| A State with nothing selected. 103 | -} 104 | empty : State 105 | empty = 106 | State Internal.empty 107 | 108 | 109 | {-| Reset the keyboard navigation but leave the mouse state alone. 110 | Convenient when the two selections are represented separately. 111 | -} 112 | reset : UpdateConfig msg data -> State -> State 113 | reset (UpdateConfig config) (State state) = 114 | State (Internal.reset config state) 115 | 116 | 117 | {-| Like `reset` but defaults to a keyboard selection of the first item. 118 | -} 119 | resetToFirstItem : UpdateConfig msg data -> List data -> Int -> State -> State 120 | resetToFirstItem (UpdateConfig config) data howManyToShow (State state) = 121 | State (Internal.resetToFirstItem config data howManyToShow state) 122 | 123 | 124 | {-| Like `reset` but defaults to a keyboard selection of the last item. 125 | -} 126 | resetToLastItem : UpdateConfig msg data -> List data -> Int -> State -> State 127 | resetToLastItem (UpdateConfig config) data howManyToShow (State state) = 128 | State (Internal.resetToLastItem config data howManyToShow state) 129 | 130 | 131 | 132 | -- UPDATE 133 | 134 | 135 | {-| A message type for the menu to update. 136 | -} 137 | type Msg 138 | = Msg Internal.Msg 139 | 140 | 141 | {-| Configuration for updates 142 | -} 143 | type UpdateConfig msg data 144 | = UpdateConfig (Internal.UpdateConfig msg data) 145 | 146 | 147 | {-| Use this function to update the menu's `State`. 148 | Provide the same data as your view. 149 | The `Int` argument is how many results you would like to show. 150 | -} 151 | update : UpdateConfig msg data -> Msg -> Int -> State -> List data -> ( State, Maybe msg ) 152 | update (UpdateConfig config) (Msg msg) howManyToShow (State state) data = 153 | let 154 | ( newState, maybeMsg ) = 155 | Internal.update config msg howManyToShow state data 156 | in 157 | ( State newState, maybeMsg ) 158 | 159 | 160 | {-| Create the configuration for your `update` function (`UpdateConfig`). 161 | Say we have a `List Person` that we want to show as a series of options. 162 | We would create an `UpdateConfig` like so: 163 | 164 | import Menu 165 | 166 | updateConfig : Menu.UpdateConfig Msg Person 167 | updateConfig = 168 | Menu.updateConfig 169 | { toId = .name 170 | , onKeyDown = 171 | \code maybeId -> 172 | if code == 38 || code == 40 then 173 | Nothing 174 | 175 | else if code == 13 then 176 | Maybe.map SelectPerson maybeId 177 | 178 | else 179 | Just Reset 180 | , onTooLow = Nothing 181 | , onTooHigh = Nothing 182 | , onMouseEnter = \_ -> Nothing 183 | , onMouseLeave = \_ -> Nothing 184 | , onMouseClick = \id -> Just (SelectPerson id) 185 | , separateSelections = False 186 | } 187 | 188 | You provide the following information in your menu configuration: 189 | 190 | - `toId` — turn a `Person` into a unique ID. 191 | - `ul` — specify any non-behavioral attributes you'd like for the list menu. 192 | - `li` — specify any non-behavioral attributes and children for a list item: both selection states are provided. 193 | 194 | -} 195 | updateConfig : 196 | { toId : data -> String 197 | , onKeyDown : Int -> Maybe String -> Maybe msg 198 | , onTooLow : Maybe msg 199 | , onTooHigh : Maybe msg 200 | , onMouseEnter : String -> Maybe msg 201 | , onMouseLeave : String -> Maybe msg 202 | , onMouseClick : String -> Maybe msg 203 | , separateSelections : Bool 204 | } 205 | -> UpdateConfig msg data 206 | updateConfig config = 207 | UpdateConfig (Internal.updateConfig config) 208 | 209 | 210 | {-| Add this to your `program`'s subscriptions so the menu will respond to keyboard input. 211 | -} 212 | subscription : Sub Msg 213 | subscription = 214 | Sub.map Msg Internal.subscription 215 | 216 | 217 | {-| Take a list of `data` and turn it into a menu. 218 | The `ViewConfig` argument is the configuration for the menu view. 219 | `ViewConfig` describes the HTML we want to show for each item and the list. 220 | The `Int` argument is how many results you would like to show. 221 | The `State` argument describes what is selected via mouse and keyboard. 222 | 223 | **Note:** The `State` and `List data` should live in your Model. 224 | The `ViewConfig` for the menu belongs in your view code. 225 | `ViewConfig` should never exist in your model. 226 | Describe any potential menu configurations statically. 227 | This pattern has been inspired by [Elm Sortable Table](http://package.elm-lang.org/packages/evancz/elm-sortable-table/latest). 228 | 229 | -} 230 | view : ViewConfig data -> Int -> State -> List data -> Html.Html Msg 231 | view (ViewConfig config) howManyToShow (State state) data = 232 | Html.map Msg (Internal.view config howManyToShow state data) 233 | 234 | 235 | {-| Presents a menu with sections. 236 | You can follow the same instructions as described for `view`, providing a more advanced configuration and different data shape. 237 | `ViewWithSectionsConfig` sets up your menu to handle sectioned data. 238 | The sectioned data becomes the new data argument for `viewWithSections`. 239 | -} 240 | viewWithSections : ViewWithSectionsConfig data sectionData -> Int -> State -> List sectionData -> Html.Html Msg 241 | viewWithSections (ViewWithSectionsConfig config) howManyToShow (State state) sections = 242 | Html.map Msg (Internal.viewWithSections config howManyToShow state sections) 243 | 244 | 245 | {-| HTML lists require `li` tags as children, so we allow you to specify everything about `li` HTML node except the nodeType. 246 | -} 247 | type alias HtmlDetails msg = 248 | { attributes : List (Html.Attribute msg) 249 | , children : List (Html.Html msg) 250 | } 251 | 252 | 253 | {-| Configuration for your menu, describing your menu and its items. 254 | 255 | **Note:** Your `ViewConfig` should never be held in your model. It should only appear in view code. 256 | 257 | -} 258 | type ViewConfig data 259 | = ViewConfig (Internal.ViewConfig data) 260 | 261 | 262 | {-| Create the configuration for your `view` function (`ViewConfig`). 263 | Say we have a `List Person` that we want to show as a series of options. 264 | We would create a `ViewConfig` like so: 265 | 266 | import Menu 267 | 268 | viewConfig : Menu.ViewConfig Person 269 | viewConfig = 270 | let 271 | customizedLi keySelected mouseSelected person = 272 | { attributes = 273 | [ classList 274 | [ ( "menu-item", True ) 275 | , ( "key-selected", keySelected ) 276 | , ( "mouse-selected", mouseSelected ) 277 | ] 278 | ] 279 | , children = [ Html.text person.name ] 280 | } 281 | in 282 | Menu.viewConfig 283 | { toId = .name 284 | , ul = [ class "menu-list" ] 285 | , li = customizedLi 286 | } 287 | 288 | You provide the following information in your menu configuration: 289 | 290 | - `toId` — turn a `Person` into a unique ID. This lets us use 291 | [`Html.Keyed`][keyed] under the hood to make sorting faster. 292 | - `ul` — specify any non-behavioral attributes you'd like for the list menu. 293 | - `li` — specify any non-behavioral attributes and children for a list item: both selection states are provided. 294 | See the [examples] to get a better understanding! 295 | 296 | [keyed]: http://package.elm-lang.org/packages/elm-lang/html/latest/Html-Keyed 297 | [examples]: https://github.com/ContaSystemer/elm-menu/tree/master/examples 298 | 299 | -} 300 | viewConfig : 301 | { toId : data -> String 302 | , ul : List (Html.Attribute Never) 303 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 304 | } 305 | -> ViewConfig data 306 | viewConfig config = 307 | ViewConfig (Internal.viewConfig config) 308 | 309 | 310 | {-| Configuration for your menu, describing your menu, its sections, and its items. 311 | 312 | **Note:** This should never live in your model. 313 | 314 | -} 315 | type ViewWithSectionsConfig data sectionData 316 | = ViewWithSectionsConfig (Internal.ViewWithSectionsConfig data sectionData) 317 | 318 | 319 | {-| The same configuration as viewConfig, but provide a section configuration as well. 320 | -} 321 | viewWithSectionsConfig : 322 | { toId : data -> String 323 | , ul : List (Html.Attribute Never) 324 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 325 | , section : SectionConfig data sectionData 326 | } 327 | -> ViewWithSectionsConfig data sectionData 328 | viewWithSectionsConfig { toId, ul, li, section } = 329 | let 330 | (SectionConfig internalSection) = 331 | section 332 | in 333 | ViewWithSectionsConfig <| 334 | Internal.viewWithSectionsConfig 335 | { toId = toId 336 | , ul = ul 337 | , li = li 338 | , section = internalSection 339 | } 340 | 341 | 342 | {-| The configuration for a section of the menu. 343 | 344 | **Note:** This should never live in your model. 345 | 346 | -} 347 | type SectionConfig data sectionData 348 | = SectionConfig (Internal.SectionConfig data sectionData) 349 | 350 | 351 | {-| Describe everything about a Section HTML node. 352 | -} 353 | type alias SectionNode msg = 354 | { nodeType : String 355 | , attributes : List (Html.Attribute msg) 356 | , children : List (Html.Html msg) 357 | } 358 | 359 | 360 | {-| Create the `SectionConfig` for your `view` function. 361 | Say we have a `List Century` that we want to show as a series of sections. 362 | We would create a `SectionConfig` like so: 363 | 364 | type alias Century = 365 | { title : String 366 | , people : List Person 367 | } 368 | 369 | import Menu 370 | 371 | sectionConfig : Menu.SectionConfig Person Century 372 | sectionConfig = 373 | Menu.sectionConfig 374 | { toId = .title 375 | , getData = .people 376 | , ul = [ class "menu-section-list" ] 377 | , li = 378 | \section -> 379 | { nodeType = "div" 380 | , attributes = [ class "menu-section-item" ] 381 | , children = 382 | [ div [ class "menu-section-box" ] 383 | [ strong [ class "menu-section-text" ] [ text section.title ] 384 | ] 385 | ] 386 | } 387 | } 388 | 389 | You provide the following information in your menu configuration: 390 | 391 | - `toId` — turn a `Century` into a unique ID. 392 | - `getData` — extract the data from `Century`, in this case: `List Person`. 393 | - `ul` — specify any non-behavioral attributes you'd like for the section list. 394 | - `li` — specify any non-behavioral attributes and children for a section. 395 | 396 | -} 397 | sectionConfig : 398 | { toId : sectionData -> String 399 | , getData : sectionData -> List data 400 | , ul : List (Html.Attribute Never) 401 | , li : sectionData -> SectionNode Never 402 | } 403 | -> SectionConfig data sectionData 404 | sectionConfig section = 405 | SectionConfig (Internal.sectionConfig section) -------------------------------------------------------------------------------- /src/Menu/Styled/Internal.elm: -------------------------------------------------------------------------------- 1 | module Menu.Styled.Internal exposing 2 | ( HtmlDetails 3 | , KeySelected 4 | , MouseSelected 5 | , Msg 6 | , SectionConfig 7 | , SectionNode 8 | , State 9 | , UpdateConfig 10 | , ViewConfig 11 | , ViewWithSectionsConfig 12 | , current 13 | , empty 14 | , reset 15 | , resetToFirstItem 16 | , resetToLastItem 17 | , sectionConfig 18 | , subscription 19 | , update 20 | , updateConfig 21 | , view 22 | , viewConfig 23 | , viewWithSections 24 | , viewWithSectionsConfig 25 | ) 26 | 27 | import Browser.Events 28 | import Html.Styled as Html 29 | import Html.Styled.Attributes as Attrs 30 | import Html.Styled.Events as Events 31 | import Html.Styled.Keyed as Keyed 32 | import Json.Decode as Decode 33 | 34 | 35 | 36 | -- MODEL 37 | 38 | 39 | type alias State = 40 | { key : Maybe String 41 | , mouse : Maybe String 42 | } 43 | 44 | 45 | type alias KeySelected = 46 | Bool 47 | 48 | 49 | type alias MouseSelected = 50 | Bool 51 | 52 | 53 | current : State -> ( Maybe String, Maybe String ) 54 | current state = 55 | ( state.key, state.mouse ) 56 | 57 | 58 | empty : State 59 | empty = 60 | { key = Nothing, mouse = Nothing } 61 | 62 | 63 | reset : UpdateConfig msg data -> State -> State 64 | reset { separateSelections } { mouse } = 65 | if separateSelections then 66 | { key = Nothing, mouse = mouse } 67 | 68 | else 69 | empty 70 | 71 | 72 | resetToFirstItem : UpdateConfig msg data -> List data -> Int -> State -> State 73 | resetToFirstItem config data howManyToShow state = 74 | resetToFirst config (List.take howManyToShow data) state 75 | 76 | 77 | resetToFirst : UpdateConfig msg data -> List data -> State -> State 78 | resetToFirst config data state = 79 | let 80 | { toId, separateSelections } = 81 | config 82 | 83 | setFirstItem datum newState = 84 | { newState | key = Just (toId datum) } 85 | in 86 | case List.head data of 87 | Nothing -> 88 | empty 89 | 90 | Just datum -> 91 | if separateSelections then 92 | state 93 | |> reset config 94 | |> setFirstItem datum 95 | 96 | else 97 | setFirstItem datum empty 98 | 99 | 100 | resetToLastItem : UpdateConfig msg data -> List data -> Int -> State -> State 101 | resetToLastItem config data howManyToShow state = 102 | let 103 | reversedData = 104 | List.reverse (List.take howManyToShow data) 105 | in 106 | resetToFirst config reversedData state 107 | 108 | 109 | 110 | -- UPDATE 111 | 112 | 113 | {-| Add this to your `program`s subscriptions to animate the spinner. 114 | -} 115 | subscription : Sub Msg 116 | subscription = 117 | Browser.Events.onKeyDown (Decode.map KeyDown Events.keyCode) 118 | 119 | 120 | type Msg 121 | = KeyDown Int 122 | | WentTooLow 123 | | WentTooHigh 124 | | MouseEnter String 125 | | MouseLeave String 126 | | MouseClick String 127 | | NoOp 128 | 129 | 130 | type alias UpdateConfig msg data = 131 | { onKeyDown : Int -> Maybe String -> Maybe msg 132 | , onTooLow : Maybe msg 133 | , onTooHigh : Maybe msg 134 | , onMouseEnter : String -> Maybe msg 135 | , onMouseLeave : String -> Maybe msg 136 | , onMouseClick : String -> Maybe msg 137 | , toId : data -> String 138 | , separateSelections : Bool 139 | } 140 | 141 | 142 | updateConfig : 143 | { toId : data -> String 144 | , onKeyDown : Int -> Maybe String -> Maybe msg 145 | , onTooLow : Maybe msg 146 | , onTooHigh : Maybe msg 147 | , onMouseEnter : String -> Maybe msg 148 | , onMouseLeave : String -> Maybe msg 149 | , onMouseClick : String -> Maybe msg 150 | , separateSelections : Bool 151 | } 152 | -> UpdateConfig msg data 153 | updateConfig { toId, onKeyDown, onTooLow, onTooHigh, onMouseEnter, onMouseLeave, onMouseClick, separateSelections } = 154 | { toId = toId 155 | , onKeyDown = onKeyDown 156 | , onTooLow = onTooLow 157 | , onTooHigh = onTooHigh 158 | , onMouseEnter = onMouseEnter 159 | , onMouseLeave = onMouseLeave 160 | , onMouseClick = onMouseClick 161 | , separateSelections = separateSelections 162 | } 163 | 164 | 165 | update : UpdateConfig msg data -> Msg -> Int -> State -> List data -> ( State, Maybe msg ) 166 | update config msg howManyToShow state data = 167 | case msg of 168 | KeyDown keyCode -> 169 | let 170 | boundedList = 171 | List.map config.toId data 172 | |> List.take howManyToShow 173 | 174 | newKey = 175 | navigateWithKey keyCode boundedList state.key 176 | in 177 | if newKey == state.key && keyCode == 38 then 178 | update config WentTooHigh howManyToShow state data 179 | 180 | else if newKey == state.key && keyCode == 40 then 181 | update config WentTooLow howManyToShow state data 182 | 183 | else if config.separateSelections then 184 | ( { state | key = newKey } 185 | , config.onKeyDown keyCode newKey 186 | ) 187 | 188 | else 189 | ( { key = newKey, mouse = newKey } 190 | , config.onKeyDown keyCode newKey 191 | ) 192 | 193 | WentTooLow -> 194 | ( state 195 | , config.onTooLow 196 | ) 197 | 198 | WentTooHigh -> 199 | ( state 200 | , config.onTooHigh 201 | ) 202 | 203 | MouseEnter id -> 204 | ( resetMouseStateWithId config.separateSelections id state 205 | , config.onMouseEnter id 206 | ) 207 | 208 | MouseLeave id -> 209 | ( resetMouseStateWithId config.separateSelections id state 210 | , config.onMouseLeave id 211 | ) 212 | 213 | MouseClick id -> 214 | ( resetMouseStateWithId config.separateSelections id state 215 | , config.onMouseClick id 216 | ) 217 | 218 | NoOp -> 219 | ( state, Nothing ) 220 | 221 | 222 | resetMouseStateWithId : Bool -> String -> State -> State 223 | resetMouseStateWithId separateSelections id state = 224 | if separateSelections then 225 | { key = state.key, mouse = Just id } 226 | 227 | else 228 | { key = Just id, mouse = Just id } 229 | 230 | 231 | getPreviousItemId : List String -> String -> String 232 | getPreviousItemId ids selectedId = 233 | Maybe.withDefault selectedId (List.foldr (getPrevious selectedId) Nothing ids) 234 | 235 | 236 | getPrevious : String -> String -> Maybe String -> Maybe String 237 | getPrevious id selectedId resultId = 238 | if selectedId == id then 239 | Just id 240 | 241 | else if Maybe.withDefault "" resultId == id then 242 | Just selectedId 243 | 244 | else 245 | resultId 246 | 247 | 248 | getNextItemId : List String -> String -> String 249 | getNextItemId ids selectedId = 250 | Maybe.withDefault selectedId (List.foldl (getPrevious selectedId) Nothing ids) 251 | 252 | 253 | navigateWithKey : Int -> List String -> Maybe String -> Maybe String 254 | navigateWithKey code ids maybeId = 255 | case code of 256 | 38 -> 257 | Maybe.map (getPreviousItemId ids) maybeId 258 | 259 | 40 -> 260 | Maybe.map (getNextItemId ids) maybeId 261 | 262 | _ -> 263 | maybeId 264 | 265 | 266 | view : ViewConfig data -> Int -> State -> List data -> Html.Html Msg 267 | view config howManyToShow state data = 268 | let 269 | customUlAttr = 270 | List.map mapNeverToMsg config.ul 271 | 272 | getKeyedItems datum = 273 | ( config.toId datum, viewItem config state datum ) 274 | in 275 | List.take howManyToShow data 276 | |> List.map getKeyedItems 277 | |> Keyed.ul customUlAttr 278 | 279 | 280 | viewWithSections : ViewWithSectionsConfig data sectionData -> Int -> State -> List sectionData -> Html.Html Msg 281 | viewWithSections config howManyToShow state sections = 282 | let 283 | customUlAttr = 284 | List.map mapNeverToMsg config.section.ul 285 | 286 | getKeyedItems section = 287 | ( config.section.toId section, viewSection config howManyToShow state section ) 288 | in 289 | sections 290 | |> List.take howManyToShow 291 | |> List.map getKeyedItems 292 | |> Keyed.ul customUlAttr 293 | 294 | 295 | viewSection : ViewWithSectionsConfig data sectionData -> Int -> State -> sectionData -> Html.Html Msg 296 | viewSection config howManyToShow state section = 297 | let 298 | sectionNode = 299 | config.section.li section 300 | 301 | attributes = 302 | List.map mapNeverToMsg sectionNode.attributes 303 | 304 | customChildren = 305 | List.map (Html.map (\_ -> NoOp)) sectionNode.children 306 | 307 | getKeyedItems datum = 308 | ( config.toId datum, viewData config state datum ) 309 | 310 | viewItemList = 311 | config.section.getData section 312 | |> List.take howManyToShow 313 | |> List.map getKeyedItems 314 | |> Keyed.ul (List.map mapNeverToMsg config.ul) 315 | 316 | children = 317 | List.append customChildren [ viewItemList ] 318 | in 319 | Html.li attributes 320 | [ Html.node sectionNode.nodeType attributes children ] 321 | 322 | 323 | viewData : ViewWithSectionsConfig data sectionData -> State -> data -> Html.Html Msg 324 | viewData { toId, li } { key, mouse } data = 325 | let 326 | id = 327 | toId data 328 | 329 | listItemData = 330 | li (isSelected key) (isSelected mouse) data 331 | 332 | customAttributes = 333 | List.map mapNeverToMsg listItemData.attributes 334 | 335 | customLiAttr = 336 | List.append customAttributes 337 | [ Events.onMouseEnter (MouseEnter id) 338 | , Events.onMouseLeave (MouseLeave id) 339 | , Events.onClick (MouseClick id) 340 | ] 341 | 342 | isSelected maybeId = 343 | case maybeId of 344 | Just someId -> 345 | someId == id 346 | 347 | Nothing -> 348 | False 349 | in 350 | Html.li customLiAttr (List.map (Html.map (\_ -> NoOp)) listItemData.children) 351 | 352 | 353 | viewItem : ViewConfig data -> State -> data -> Html.Html Msg 354 | viewItem { toId, li } { key, mouse } data = 355 | let 356 | id = 357 | toId data 358 | 359 | listItemData = 360 | li (isSelected key) (isSelected mouse) data 361 | 362 | customAttributes = 363 | List.map mapNeverToMsg listItemData.attributes 364 | 365 | customLiAttr = 366 | List.append customAttributes 367 | [ Events.onMouseEnter (MouseEnter id) 368 | , Events.onMouseLeave (MouseLeave id) 369 | , Events.onClick (MouseClick id) 370 | ] 371 | 372 | isSelected maybeId = 373 | case maybeId of 374 | Just someId -> 375 | someId == id 376 | 377 | Nothing -> 378 | False 379 | in 380 | Html.li customLiAttr (List.map (Html.map (\_ -> NoOp)) listItemData.children) 381 | 382 | 383 | type alias HtmlDetails msg = 384 | { attributes : List (Html.Attribute msg) 385 | , children : List (Html.Html msg) 386 | } 387 | 388 | 389 | type alias ViewConfig data = 390 | { toId : data -> String 391 | , ul : List (Html.Attribute Never) 392 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 393 | } 394 | 395 | 396 | type alias ViewWithSectionsConfig data sectionData = 397 | { toId : data -> String 398 | , ul : List (Html.Attribute Never) 399 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 400 | , section : SectionConfig data sectionData 401 | } 402 | 403 | 404 | type alias SectionConfig data sectionData = 405 | { toId : sectionData -> String 406 | , getData : sectionData -> List data 407 | , ul : List (Html.Attribute Never) 408 | , li : sectionData -> SectionNode Never 409 | } 410 | 411 | 412 | type alias SectionNode msg = 413 | { nodeType : String 414 | , attributes : List (Html.Attribute msg) 415 | , children : List (Html.Html msg) 416 | } 417 | 418 | 419 | viewConfig : 420 | { toId : data -> String 421 | , ul : List (Html.Attribute Never) 422 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 423 | } 424 | -> ViewConfig data 425 | viewConfig { toId, ul, li } = 426 | { toId = toId 427 | , ul = ul 428 | , li = li 429 | } 430 | 431 | 432 | viewWithSectionsConfig : 433 | { toId : data -> String 434 | , ul : List (Html.Attribute Never) 435 | , li : KeySelected -> MouseSelected -> data -> HtmlDetails Never 436 | , section : SectionConfig data sectionData 437 | } 438 | -> ViewWithSectionsConfig data sectionData 439 | viewWithSectionsConfig { toId, ul, li, section } = 440 | { toId = toId 441 | , ul = ul 442 | , li = li 443 | , section = section 444 | } 445 | 446 | 447 | sectionConfig : 448 | { toId : sectionData -> String 449 | , getData : sectionData -> List data 450 | , ul : List (Html.Attribute Never) 451 | , li : sectionData -> SectionNode Never 452 | } 453 | -> SectionConfig data sectionData 454 | sectionConfig { toId, getData, ul, li } = 455 | { toId = toId 456 | , getData = getData 457 | , ul = ul 458 | , li = li 459 | } 460 | 461 | 462 | 463 | -- HELPERS 464 | 465 | 466 | mapNeverToMsg : Html.Attribute Never -> Html.Attribute Msg 467 | mapNeverToMsg msg = 468 | Attrs.map (\_ -> NoOp) msg -------------------------------------------------------------------------------- /tests/Tests.elm: -------------------------------------------------------------------------------- 1 | module Tests exposing (all) 2 | 3 | import Expect 4 | import Menu 5 | import Test 6 | 7 | 8 | updateConfigWithSeparateSelections : Menu.UpdateConfig msg Person 9 | updateConfigWithSeparateSelections = 10 | Menu.updateConfig 11 | { toId = .name 12 | , onKeyDown = \_ _ -> Nothing 13 | , onTooLow = Nothing 14 | , onTooHigh = Nothing 15 | , onMouseEnter = \_ -> Nothing 16 | , onMouseLeave = \_ -> Nothing 17 | , onMouseClick = \_ -> Nothing 18 | , separateSelections = True 19 | } 20 | 21 | 22 | updateConfigWithCombinedSelections : Menu.UpdateConfig msg Person 23 | updateConfigWithCombinedSelections = 24 | Menu.updateConfig 25 | { toId = .name 26 | , onKeyDown = \_ _ -> Nothing 27 | , onTooLow = Nothing 28 | , onTooHigh = Nothing 29 | , onMouseEnter = \_ -> Nothing 30 | , onMouseLeave = \_ -> Nothing 31 | , onMouseClick = \_ -> Nothing 32 | , separateSelections = False 33 | } 34 | 35 | 36 | 37 | -- PEOPLE 38 | 39 | 40 | type alias Person = 41 | { name : String 42 | , year : Int 43 | , city : String 44 | , state : String 45 | } 46 | 47 | 48 | presidents : List Person 49 | presidents = 50 | [ Person "George Washington" 1732 "Westmoreland County" "Virginia" 51 | , Person "John Adams" 1735 "Braintree" "Massachusetts" 52 | , Person "Thomas Jefferson" 1743 "Shadwell" "Virginia" 53 | , Person "James Madison" 1751 "Port Conway" "Virginia" 54 | , Person "James Monroe" 1758 "Monroe Hall" "Virginia" 55 | , Person "Andrew Jackson" 1767 "Waxhaws Region" "South/North Carolina" 56 | , Person "John Quincy Adams" 1767 "Braintree" "Massachusetts" 57 | , Person "William Henry Harrison" 1773 "Charles City County" "Virginia" 58 | , Person "Martin Van Buren" 1782 "Kinderhook" "New York" 59 | , Person "Zachary Taylor" 1784 "Barboursville" "Virginia" 60 | , Person "John Tyler" 1790 "Charles City County" "Virginia" 61 | , Person "James Buchanan" 1791 "Cove Gap" "Pennsylvania" 62 | , Person "James K. Polk" 1795 "Pineville" "North Carolina" 63 | , Person "Millard Fillmore" 1800 "Summerhill" "New York" 64 | , Person "Franklin Pierce" 1804 "Hillsborough" "New Hampshire" 65 | , Person "Andrew Johnson" 1808 "Raleigh" "North Carolina" 66 | , Person "Abraham Lincoln" 1809 "Sinking spring" "Kentucky" 67 | , Person "Ulysses S. Grant" 1822 "Point Pleasant" "Ohio" 68 | , Person "Rutherford B. Hayes" 1822 "Delaware" "Ohio" 69 | , Person "Chester A. Arthur" 1829 "Fairfield" "Vermont" 70 | , Person "James A. Garfield" 1831 "Moreland Hills" "Ohio" 71 | , Person "Benjamin Harrison" 1833 "North Bend" "Ohio" 72 | , Person "Grover Cleveland" 1837 "Caldwell" "New Jersey" 73 | , Person "William McKinley" 1843 "Niles" "Ohio" 74 | , Person "Woodrow Wilson" 1856 "Staunton" "Virginia" 75 | , Person "William Howard Taft" 1857 "Cincinnati" "Ohio" 76 | , Person "Theodore Roosevelt" 1858 "New York City" "New York" 77 | , Person "Warren G. Harding" 1865 "Blooming Grove" "Ohio" 78 | , Person "Calvin Coolidge" 1872 "Plymouth" "Vermont" 79 | , Person "Herbert Hoover" 1874 "West Branch" "Iowa" 80 | , Person "Franklin D. Roosevelt" 1882 "Hyde Park" "New York" 81 | , Person "Harry S. Truman" 1884 "Lamar" "Missouri" 82 | , Person "Dwight D. Eisenhower" 1890 "Denison" "Texas" 83 | , Person "Lyndon B. Johnson" 1908 "Stonewall" "Texas" 84 | , Person "Ronald Reagan" 1911 "Tampico" "Illinois" 85 | , Person "Richard M. Nixon" 1913 "Yorba Linda" "California" 86 | , Person "Gerald R. Ford" 1913 "Omaha" "Nebraska" 87 | , Person "John F. Kennedy" 1917 "Brookline" "Massachusetts" 88 | , Person "George H. W. Bush" 1924 "Milton" "Massachusetts" 89 | , Person "Jimmy Carter" 1924 "Plains" "Georgia" 90 | , Person "George W. Bush" 1946 "New Haven" "Connecticut" 91 | , Person "Bill Clinton" 1946 "Hope" "Arkansas" 92 | , Person "Barack Obama" 1961 "Honolulu" "Hawaii" 93 | ] 94 | 95 | 96 | howManyPeopleToShow : Int 97 | howManyPeopleToShow = 98 | 5 99 | 100 | 101 | 102 | -- TESTS 103 | 104 | 105 | all : Test.Test 106 | all = 107 | Test.describe "State" 108 | [ Test.test "should empty the state" <| 109 | \() -> 110 | Expect.equal (Menu.current Menu.empty) ( Nothing, Nothing ) 111 | , Test.test "should reset to empty state when selections are not separated" <| 112 | \() -> 113 | let 114 | state = 115 | Menu.reset updateConfigWithCombinedSelections Menu.empty 116 | in 117 | Expect.equal (Menu.current state) ( Nothing, Nothing ) 118 | , Test.test "should reset to first item" <| 119 | \() -> 120 | let 121 | state = 122 | Menu.resetToFirstItem 123 | updateConfigWithCombinedSelections 124 | presidents 125 | howManyPeopleToShow 126 | Menu.empty 127 | in 128 | Expect.equal (Menu.current state) ( Just "George Washington", Nothing ) 129 | , Test.test "should reset to last item" <| 130 | \() -> 131 | let 132 | state = 133 | Menu.resetToLastItem 134 | updateConfigWithCombinedSelections 135 | presidents 136 | howManyPeopleToShow 137 | Menu.empty 138 | in 139 | Expect.equal (Menu.current state) ( Just "James Monroe", Nothing ) 140 | ] 141 | --------------------------------------------------------------------------------