diff --git a/src/Nri/Ui/Menu/V3.elm b/src/Nri/Ui/Menu/V3.elm index c2294e80..af57c2db 100644 --- a/src/Nri/Ui/Menu/V3.elm +++ b/src/Nri/Ui/Menu/V3.elm @@ -1,7 +1,7 @@ module Nri.Ui.Menu.V3 exposing ( view, button, custom, Config , Attribute, Button, ButtonAttribute - , alignment, isDisabled, menuWidth, buttonId, menuId, menuZIndex, opensOnHover, dialog + , alignment, isDisabled, menuWidth, buttonId, menuId, menuZIndex, opensOnHover, disclosure, dialog , Alignment(..) , icon, wrapping, hasBorder, buttonWidth , TitleWrapping(..) @@ -30,7 +30,7 @@ A togglable menu view and related buttons. ## Menu attributes -@docs alignment, isDisabled, menuWidth, buttonId, menuId, menuZIndex, opensOnHover, dialog +@docs alignment, isDisabled, menuWidth, buttonId, menuId, menuZIndex, opensOnHover, disclosure, dialog @docs Alignment @@ -121,6 +121,7 @@ type alias ButtonConfig = type Purpose = NavMenu + | Disclosure { lastId : String } | Dialog ExitFocusManager @@ -215,6 +216,21 @@ opensOnHover value = Attribute <| \config -> { config | opensOnHover = value } +{-| Makes the menu behave as a disclosure. + +For more information, please read [Disclosure (Show/Hide) pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/). + +You will need to pass in the last focusable element in the disclosed content in order for: + + - any focusable elements in the disclosed content to be keyboard accessible + - the disclosure to close appropriately when the user tabs past all of the disclosed content + +-} +disclosure : { lastId : String } -> Attribute msg +disclosure exitFocusManager = + Attribute (\config -> { config | purpose = Disclosure exitFocusManager }) + + {-| Makes the menu behave as a dialog. For more information, please read [Dialog pattern](https://w3c.github.io/aria-practices/examples/dialog-modal/dialog.html/). @@ -485,6 +501,30 @@ viewCustom config = ) ] + Disclosure { lastId } -> + Key.onKeyDown + [ Key.escape + (config.focusAndToggle + { isOpen = False + , focus = Just config.buttonId + } + ) + , WhenFocusLeaves.toDecoder + { firstId = config.buttonId + , lastId = lastId + , tabBackAction = + config.focusAndToggle + { isOpen = False + , focus = Nothing + } + , tabForwardAction = + config.focusAndToggle + { isOpen = False + , focus = Nothing + } + } + ] + Dialog { firstId, lastId } -> Key.onKeyDownPreventDefault [ Key.escape @@ -552,7 +592,7 @@ viewCustom config = [ Aria.disabled config.isDisabled , -- Whether the menu is open or closed, move to the -- first menu item if the "down" arrow is pressed - -- as long as it's not a Dialog + -- as long as it's not a Disclosed or Dialog case ( config.purpose, maybeFirstFocusableElementId, maybeLastFocusableElementId ) of ( NavMenu, Just firstFocusableElementId, Just lastFocusableElementId ) -> Key.onKeyDownPreventDefault @@ -606,6 +646,11 @@ viewCustom config = , Aria.controls [ config.menuId ] ] + Disclosure _ -> + [ Aria.expanded config.isOpen + , Aria.controls [ config.menuId ] + ] + Dialog _ -> [ AttributesExtra.none ] ) @@ -631,6 +676,9 @@ viewCustom config = NavMenu -> Role.menu + Disclosure _ -> + AttributesExtra.none + Dialog _ -> Role.dialog , Aria.labelledBy config.buttonId diff --git a/styleguide-app/Examples/Menu.elm b/styleguide-app/Examples/Menu.elm index 581f70ae..7b5b3bcc 100644 --- a/styleguide-app/Examples/Menu.elm +++ b/styleguide-app/Examples/Menu.elm @@ -262,6 +262,35 @@ view ellieLinkConfig state = button buttonAttributes [ text "Custom Menu trigger button" ] } ) + , ( "Menu.button (with Menu.disclosure)" + , Menu.view + (menuAttributes + ++ [ Menu.buttonId "with_controls__button" + , Menu.menuId "with_controls__menu" + , Menu.disclosure { lastId = "login__button" } + ] + ) + { isOpen = isOpen "with_controls" + , focusAndToggle = FocusAndToggle "with_controls" + , entries = + [ Menu.entry "username-input" <| + \attrs -> + div [] + [ TextInput.view "Username" + [ TextInput.id "username-input" + ] + , TextInput.view "Password" [] + , Button.button "Log in disclosure" + [ Button.primary + , Button.id "login__button" + , Button.fillContainerWidth + , Button.css [ Css.marginTop (Css.px 15) ] + ] + ] + ] + , button = Menu.button defaultButtonAttributes "Log In" + } + ) , ( "Menu.button (with Menu.dialog)" , Menu.view (menuAttributes @@ -280,7 +309,7 @@ view ellieLinkConfig state = [ TextInput.id "username-input" ] , TextInput.view "Password" [] - , Button.button "Log in" + , Button.button "Log in dialog" [ Button.primary , Button.id "login__button" , Button.fillContainerWidth diff --git a/tests/Spec/KeyboardHelpers.elm b/tests/Spec/KeyboardHelpers.elm index 43973ae0..b77209a3 100644 --- a/tests/Spec/KeyboardHelpers.elm +++ b/tests/Spec/KeyboardHelpers.elm @@ -64,6 +64,15 @@ pressTabKey { targetDetails } = pressKey { targetDetails = targetDetails, keyCode = 9, shiftKey = False } +pressTabBackKey : + { targetDetails : List ( String, Encode.Value ) } + -> List Selector + -> ProgramTest model msg effect + -> ProgramTest model msg effect +pressTabBackKey { targetDetails } = + pressKey { targetDetails = targetDetails, keyCode = 9, shiftKey = True } + + pressEscKey : { targetDetails : List ( String, Encode.Value ) } -> List Selector diff --git a/tests/Spec/Nri/Ui/Menu.elm b/tests/Spec/Nri/Ui/Menu.elm index dcf5eefe..1ff02645 100644 --- a/tests/Spec/Nri/Ui/Menu.elm +++ b/tests/Spec/Nri/Ui/Menu.elm @@ -52,6 +52,26 @@ spec = |> pressEscKey { targetId = Nothing } |> ensureViewHasNot (menuContentSelector menuContent) |> ProgramTest.done + , describe "disclosure" <| + [ test "Close on esc key" <| + \() -> + program [ Menu.disclosure { lastId = "last-button" } ] + -- Menu opens on mouse click and closes on esc key + |> clickMenuButton + |> ensureViewHas (menuContentSelector menuContent) + |> pressEscKey { targetId = Nothing } + |> ensureViewHasNot (menuContentSelector menuContent) + |> ProgramTest.done + , test "Closes after tab on lastId" <| + \() -> + program [ Menu.disclosure { lastId = "last-button" } ] + |> clickMenuButton + |> ensureViewHas (menuContentSelector menuContent) + -- NOTE: unable to simulate pressTabKey with other targetId since those decoders will fail + |> pressTabKey { targetId = Just "last-button" } + |> ensureViewHasNot (menuContentSelector menuContent) + |> ProgramTest.done + ] , describe "dialog" <| [ test "Close on esc key" <| \() -> @@ -62,14 +82,23 @@ spec = |> pressEscKey { targetId = Nothing } |> ensureViewHasNot (menuContentSelector menuContent) |> ProgramTest.done - , test "Closes after tab on lastId" <| + , test "Selects firstId after tab on lastId" <| \() -> program [ Menu.dialog { firstId = "hello-button", lastId = "last-button" } ] |> clickMenuButton |> ensureViewHas (menuContentSelector menuContent) -- NOTE: unable to simulate pressTabKey with other targetId since those decoders will fail |> pressTabKey { targetId = Just "last-button" } - |> ensureViewHasNot (menuContentSelector menuContent) + |> ensureViewHas (menuContentSelector menuContent) + |> ProgramTest.done + , test "Selects lastId after back tab on firstId" <| + \() -> + program [ Menu.dialog { firstId = "hello-button", lastId = "last-button" } ] + |> clickMenuButton + |> ensureViewHas (menuContentSelector menuContent) + -- NOTE: unable to simulate pressTabKey with other targetId since those decoders will fail + |> pressTabBackKey { targetId = Just "hellow-button" } + |> ensureViewHas (menuContentSelector menuContent) |> ProgramTest.done ] ] @@ -182,6 +211,13 @@ pressTabKey { targetId } = [ Selector.class "Container" ] +pressTabBackKey : { targetId : Maybe String } -> ProgramTest model msg effect -> ProgramTest model msg effect +pressTabBackKey { targetId } = + KeyboardHelpers.pressTabBackKey + { targetDetails = targetDetails targetId } + [ Selector.class "Container" ] + + pressEscKey : { targetId : Maybe String } -> ProgramTest model msg effect -> ProgramTest model msg effect pressEscKey { targetId } = KeyboardHelpers.pressEscKey