Merge remote-tracking branch 'origin/master' into hack/tessa/auto-expanded-sidenav

This commit is contained in:
Tessa Kelly 2023-08-10 14:58:21 -06:00
commit 49697b2a34
18 changed files with 365 additions and 86 deletions

View File

@ -147,7 +147,7 @@ Any NoRedInk engineer can deploy a new version of `noredink-ui`. Generally, we p
- `git checkout master`
- `git pull`
- Run `elm publish` and follow its prompts
- Note: when you're asked to create a version tag, **please be sure to include a meaningful message**! Include details in the message that describe why this noredink-ui version exists at ll.
- Note: when you're asked to create a version tag, **please be sure to include a meaningful message**! Include details in the message that describe why this noredink-ui version exists at all.
- Create an annotated tag like this:
```
git tag -a 22.x.y -m "Description of this release version: i.e.: 'high-contrast mode highlight style change'"

View File

@ -6,7 +6,6 @@ module Examples.Modal exposing (Msg, State, example)
-}
import Accessibility.Styled exposing (Html, div, text)
import Accessibility.Styled.Key as Key
import Browser.Dom as Dom
import Category exposing (Category(..))
@ -15,7 +14,9 @@ import CommonControls
import Css exposing (..)
import Debug.Control as Control exposing (Control)
import Debug.Control.View as ControlView
import EventExtras exposing (onClickStopPropagation)
import Example exposing (Example)
import Html.Styled exposing (Html, div, text)
import Html.Styled.Attributes as Attributes exposing (css)
import KeyboardSupport
import Nri.Ui.Button.V10 as Button
@ -51,7 +52,7 @@ type alias ViewSettings =
, theme : Maybe ( String, Modal.Attribute )
, customCss : Maybe ( String, Modal.Attribute )
, showX : Bool
, showContinue : Bool
, showFocusOnTitle : Bool
, showSecondary : Bool
, dismissOnEscAndOverlayClick : Bool
, content : String
@ -66,7 +67,7 @@ initViewSettings =
|> Control.field "Theme" (Control.maybe False controlTheme)
|> Control.field "Custom css" (Control.maybe False controlCss)
|> Control.field "X button" (Control.bool True)
|> Control.field "Continue button" (Control.bool True)
|> Control.field "Focus on title button" (Control.bool True)
|> Control.field "Close button" (Control.bool True)
|> Control.field "dismissOnEscAndOverlayClick" (Control.bool True)
|> Control.field "Content"
@ -210,7 +211,7 @@ example =
++ (if settings.showX then
Code.string Modal.closeButtonId
else if settings.showContinue then
else if settings.showFocusOnTitle then
Code.string continueButtonId
else
@ -220,7 +221,7 @@ example =
++ (if settings.showSecondary then
Code.string closeClickableTextId
else if settings.showContinue then
else if settings.showFocusOnTitle then
Code.string continueButtonId
else
@ -253,8 +254,8 @@ example =
, content = [ viewModalContent settings.content ]
, footer =
List.filterMap identity
[ if settings.showContinue then
Just continueButton
[ if settings.showFocusOnTitle then
Just focusOnModalTitle
else
Nothing
@ -270,7 +271,7 @@ example =
if settings.showX then
Modal.closeButtonId
else if settings.showContinue then
else if settings.showFocusOnTitle then
continueButtonId
else
@ -279,7 +280,7 @@ example =
if settings.showSecondary then
closeClickableTextId
else if settings.showContinue then
else if settings.showFocusOnTitle then
continueButtonId
else
@ -298,6 +299,8 @@ example =
|> List.filterMap identity
)
state.state
|> List.singleton
|> div [ onClickStopPropagation SwallowEvent ]
]
}
@ -309,7 +312,7 @@ launchModalButton settings =
"launch-modal"
startFocusId =
if settings.showContinue then
if settings.showFocusOnTitle then
Just continueButtonId
else if settings.showX then
@ -351,10 +354,10 @@ continueButtonId =
"continue-button-id"
continueButton : Html Msg
continueButton =
Button.button "Continue"
[ Button.onClick CloseModal
focusOnModalTitle : Html Msg
focusOnModalTitle =
Button.button "Focus on modal title"
[ Button.onClick (Focus Modal.titleId)
, Button.id continueButtonId
, Button.modal
]
@ -382,6 +385,7 @@ type Msg
| UpdateSettings (Control ViewSettings)
| Focus String
| Focused (Result Dom.Error ())
| SwallowEvent
{-| -}
@ -431,6 +435,9 @@ update msg state =
Focused _ ->
( state, Cmd.none )
SwallowEvent ->
( state, Cmd.none )
{-| -}
subscriptions : State -> Sub Msg

View File

@ -92,6 +92,7 @@ example =
type alias State =
{ openTooltip : Maybe TooltipId
, staticExampleSettings : Control (List ( String, Tooltip.Attribute Never ))
, pageSettings : Control PageSettings
}
@ -99,6 +100,19 @@ init : State
init =
{ openTooltip = Nothing
, staticExampleSettings = initStaticExampleSettings
, pageSettings =
Control.record PageSettings
|> Control.field "backgroundColor"
(Control.choice
[ ( "white", Control.value Colors.white )
, ( "azure", Control.value Colors.azure )
]
)
}
type alias PageSettings =
{ backgroundColor : Css.Color
}
@ -112,6 +126,7 @@ type TooltipId
type Msg
= ToggleTooltip TooltipId Bool
| SetControl (Control (List ( String, Tooltip.Attribute Never )))
| UpdatePageSettings (Control PageSettings)
| Log String
@ -128,13 +143,16 @@ update msg model =
SetControl settings ->
( { model | staticExampleSettings = settings }, Cmd.none )
UpdatePageSettings settings ->
( { model | pageSettings = settings }, Cmd.none )
Log message ->
( Debug.log "Tooltip Log:" |> always model, Cmd.none )
view : EllieLink.Config -> State -> List (Html Msg)
view ellieLinkConfig model =
[ viewCustomizableExample ellieLinkConfig model.staticExampleSettings
[ viewCustomizableExample ellieLinkConfig model
, Heading.h2 [ Heading.plaintext "What type of tooltip should I use?" ]
, Table.view []
[ Table.string
@ -497,15 +515,19 @@ controlPadding =
]
viewCustomizableExample : EllieLink.Config -> Control (List ( String, Tooltip.Attribute Never )) -> Html Msg
viewCustomizableExample ellieLinkConfig controlSettings =
viewCustomizableExample : EllieLink.Config -> State -> Html Msg
viewCustomizableExample ellieLinkConfig ({ staticExampleSettings } as state) =
let
pageSettings =
Control.currentValue state.pageSettings
in
Html.div []
[ ControlView.view
{ ellieLinkConfig = ellieLinkConfig
, name = moduleName
, version = version
, update = SetControl
, settings = controlSettings
, settings = staticExampleSettings
, mainType = Just "RootHtml.Html msg"
, extraCode = [ "import Nri.Ui.ClickableSvg.V2 as ClickableSvg" ]
, renderExample = Code.unstyledView
@ -531,12 +553,14 @@ viewCustomizableExample ellieLinkConfig controlSettings =
}
]
}
, Control.view UpdatePageSettings state.pageSettings |> Html.fromUnstyled
, Html.div
[ css
[ Css.displayFlex
, Css.justifyContent Css.center
, Css.alignItems Css.center
, Css.height (Css.px 300)
, Css.backgroundColor pageSettings.backgroundColor
]
]
[ Tooltip.view
@ -550,7 +574,7 @@ viewCustomizableExample ellieLinkConfig controlSettings =
, id = "an-id-for-the-tooltip"
}
(Tooltip.open True
:: List.map Tuple.second (Control.currentValue controlSettings)
:: List.map Tuple.second (Control.currentValue staticExampleSettings)
)
|> Html.map never
]

View File

@ -1,4 +1,5 @@
Nri.Ui.Block.V4,upgrade to V5
Nri.Ui.WhenFocusLeaves.V1,upgrade to V2
Nri.Ui.Mark.V2,upgrade to V3
Nri.Ui.Message.V3,upgrade to V4
Nri.Ui.SideNav.V4,upgrade to V5

1 Nri.Ui.Block.V4 upgrade to V5
2 Nri.Ui.WhenFocusLeaves.V1 upgrade to V2
3 Nri.Ui.Mark.V2 upgrade to V3
4 Nri.Ui.Message.V3 upgrade to V4
5 Nri.Ui.SideNav.V4 upgrade to V5

View File

@ -3,7 +3,7 @@
"name": "NoRedInk/noredink-ui",
"summary": "UI Widgets we use at NRI",
"license": "BSD-3-Clause",
"version": "24.0.0",
"version": "24.1.0",
"exposed-modules": [
"Browser.Events.Extra",
"Nri.Ui",
@ -29,6 +29,7 @@
"Nri.Ui.Divider.V2",
"Nri.Ui.Effects.V1",
"Nri.Ui.WhenFocusLeaves.V1",
"Nri.Ui.WhenFocusLeaves.V2",
"Nri.Ui.FocusLoop.V1",
"Nri.Ui.FocusRing.V1",
"Nri.Ui.FocusTrap.V1",

View File

@ -275,3 +275,6 @@ usages = ['component-catalog/../src/Nri/Ui/Menu/V1.elm']
[forbidden."Nri.Ui.Tooltip.V2"]
hint = 'upgrade to V3'
[forbidden."Nri.Ui.WhenFocusLeaves.V1"]
hint = 'upgrade to V2'

View File

@ -78,6 +78,9 @@ describe("UI tests", function () {
const defaultProcessing = async (name, location) => {
await goTo(name, location);
if (waitForInitialAnimation[name]) {
await page.waitForTimeout(waitForInitialAnimation[name]);
}
await percySnapshot(page, name);
const results = await new AxePuppeteer(page)
@ -171,15 +174,16 @@ describe("UI tests", function () {
};
const skippedRules = {
// See https://github.com/dequelabs/axe-core/issues/3649 -- we may be able to remove the Highlighter, Mark, and Block skipped rule
Highlighter: ["aria-roledescription"],
Block: ["aria-roledescription"],
QuestionBox: ["aria-roledescription"],
// Loading's color contrast check seems to change behavior depending on whether Percy snapshots are taken or not
Loading: ["color-contrast"],
RadioButton: ["duplicate-id"],
};
const waitForInitialAnimation = {
// animation-duration in Mark's labelState animations
Block: 300,
};
const specialProcessing = {
Message: messageProcessing,
Modal: modalProcessing,
@ -192,6 +196,11 @@ describe("UI tests", function () {
it("All", async function () {
page = await browser.newPage();
await page.emulateMediaFeatures([
{ name: "prefers-reduced-motion", value: "reduce" },
]);
handlePageErrors(page);
await page.goto(`http://localhost:${PORT}`, { waitUntil: "load" });
await page.$("#maincontent");
@ -213,6 +222,11 @@ describe("UI tests", function () {
it("Doodads", async function () {
page = await browser.newPage();
await page.emulateMediaFeatures([
{ name: "prefers-reduced-motion", value: "reduce" },
]);
handlePageErrors(page);
await page.goto(`http://localhost:${PORT}`);

View File

@ -9,6 +9,7 @@ module Nri.Ui.Block.V5 exposing
, labelId, labelContentId
, LabelPosition, getLabelPositions, labelPosition
, LabelState(..), labelState
, labelCss
, yellow, cyan, magenta, green, blue, purple, brown
, insertLineBreakOpportunities
)
@ -18,6 +19,11 @@ module Nri.Ui.Block.V5 exposing
- adds customizable BlankLength
- adds fullHeightBlank, insertLineBreakOpportunities
## Patch changes
- adds `labelCss` attribute
@docs view, Attribute
@ -43,6 +49,7 @@ You will need these helpers if you want to prevent label overlaps. (Which is to
@docs labelId, labelContentId
@docs LabelPosition, getLabelPositions, labelPosition
@docs LabelState, labelState
@docs labelCss
### Visual customization
@ -155,6 +162,13 @@ labelState state =
Attribute <| \config -> { config | labelState = state }
{-| Use to set a block's label's CSS
-}
labelCss : List Css.Style -> Attribute msg
labelCss css =
Attribute <| \config -> { config | labelCss = List.append config.labelCss css }
{-| -}
labelContentId : String -> String
labelContentId labelId_ =
@ -618,6 +632,7 @@ defaultConfig =
, labelId = Nothing
, labelPosition = Nothing
, labelState = Visible
, labelCss = []
, theme = Yellow
, emphasize = False
, insertWbrAfterSpace = False
@ -631,6 +646,7 @@ type alias Config msg =
, labelId : Maybe String
, labelPosition : Maybe LabelPosition
, labelState : LabelState
, labelCss : List Css.Style
, theme : Theme
, emphasize : Bool
, insertWbrAfterSpace : Bool
@ -660,6 +676,7 @@ render config =
FadeOut ->
Mark.FadeOut
, labelCss = config.labelCss
, labelId = config.labelId
, labelContentId = Maybe.map labelContentId config.labelId
}

View File

@ -1,13 +1,17 @@
module Nri.Ui.FocusTrap.V1 exposing (FocusTrap, toAttribute)
{-| Create a focus trap.
{-| Patch changes:
- Use Nri.Ui.WhenFocusLeaves.V2
Create a focus trap.
@docs FocusTrap, toAttribute
-}
import Accessibility.Styled as Html
import Nri.Ui.WhenFocusLeaves.V1 as WhenFocusLeaves
import Nri.Ui.WhenFocusLeaves.V2 as WhenFocusLeaves
{-| Defines how focus will wrap in reponse to tab keypresses in a part of the UI.
@ -28,8 +32,8 @@ type alias FocusTrap msg =
toAttribute : FocusTrap msg -> Html.Attribute msg
toAttribute { firstId, lastId, focus } =
WhenFocusLeaves.onKeyDownPreventDefault []
{ firstId = firstId
, lastId = lastId
{ firstIds = [ firstId ]
, lastIds = [ lastId ]
, -- if the user tabs back while on the first id,
-- we want to wrap around to the last id.
tabBackAction = focus lastId

View File

@ -510,8 +510,7 @@ viewBalloon config label =
, case config.labelPosition of
Nothing ->
Css.batch
[ Css.property "animation-delay" "0.4s"
, Css.property "animation-duration" "0.3s"
[ Css.property "animation-duration" "0.3s"
, Css.property "animation-fill-mode" "backwards"
, Css.animationName fadeInKeyframes
, Css.property "animation-timing-function" "linear"
@ -520,8 +519,7 @@ viewBalloon config label =
Just _ ->
Css.batch
[ Css.property "animation-delay" "0.4s"
, Css.property "animation-duration" "0.3s"
[ Css.property "animation-duration" "0.3s"
, Css.property "animation-fill-mode" "forwards"
, Css.animationName fadeInKeyframes
, Css.property "animation-timing-function" "linear"

View File

@ -7,6 +7,11 @@ module Nri.Ui.Mark.V3 exposing
{-|
### Patch changes
- adds `labelCss` to `viewWithBalloonTags`
@docs Mark
@docs view, viewWithInlineTags, viewWithBalloonTags
@docs viewWithOverlaps
@ -177,6 +182,7 @@ viewWithBalloonTags :
, maybeMarker : Maybe Mark
, labelPosition : Maybe LabelPosition
, labelState : LabelState
, labelCss : List Css.Style
, labelId : Maybe String
, labelContentId : Maybe String
}
@ -275,6 +281,7 @@ viewMarkedByBalloon :
| backgroundColor : Color
, labelState : LabelState
, labelPosition : Maybe LabelPosition
, labelCss : List Css.Style
, labelId : Maybe String
, labelContentId : Maybe String
}
@ -491,6 +498,7 @@ viewBalloon :
| backgroundColor : Color
, labelState : LabelState
, labelPosition : Maybe LabelPosition
, labelCss : List Css.Style
, labelId : Maybe String
, labelContentId : Maybe String
}
@ -517,8 +525,7 @@ viewBalloon config label =
, case config.labelState of
FadeOut ->
Css.batch
[ Css.property "animation-delay" "0.4s"
, Css.property "animation-duration" "0.3s"
[ Css.property "animation-duration" "0.3s"
, Css.property "animation-fill-mode" "forwards"
, Css.animationName
(Css.Animations.keyframes
@ -531,8 +538,7 @@ viewBalloon config label =
Visible ->
Css.batch
[ Css.property "animation-delay" "0.4s"
, Css.property "animation-duration" "0.3s"
[ Css.property "animation-duration" "0.3s"
, Css.property "animation-fill-mode" "forwards"
, Css.animationName
(Css.Animations.keyframes
@ -543,6 +549,7 @@ viewBalloon config label =
, Css.property "animation-timing-function" "linear"
, Css.opacity Css.zero
]
, Css.batch config.labelCss
]
, Balloon.css
[ Css.padding3 Css.zero (Css.px 6) (Css.px 1)

View File

@ -14,6 +14,7 @@ module Nri.Ui.Menu.V4 exposing
{-| Patch changes:
- improve interoperability with Tooltip (Note that tooltip keyboard events are not fully supported!)
- Use Nri.Ui.WhenFocusLeaves.V2
Changes from V3:
@ -73,7 +74,7 @@ import Nri.Ui.Html.Attributes.V2 as AttributesExtra
import Nri.Ui.Shadows.V1 as Shadows
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Tooltip.V3 as Tooltip
import Nri.Ui.WhenFocusLeaves.V1 as WhenFocusLeaves
import Nri.Ui.WhenFocusLeaves.V2 as WhenFocusLeaves
{-| -}
@ -419,8 +420,8 @@ viewCustom focusAndToggle config entries =
}
)
]
{ firstId = config.buttonId
, lastId = lastId
{ firstIds = [ config.buttonId ]
, lastIds = [ lastId ]
, tabBackAction =
focusAndToggle
{ isOpen = False
@ -442,8 +443,8 @@ viewCustom focusAndToggle config entries =
}
)
]
{ firstId = firstId
, lastId = lastId
{ firstIds = [ firstId ]
, lastIds = [ lastId ]
, tabBackAction =
focusAndToggle
{ isOpen = True

View File

@ -12,6 +12,11 @@ module Nri.Ui.Modal.V11 exposing
{-|
# TODO for next major version:
- remove use of FocusTrap type alias (not using the alias causes a major version change)
# Patch changes:
- adds `testId` helper
@ -19,6 +24,7 @@ module Nri.Ui.Modal.V11 exposing
- use `Shadows`
- exposes `titleId`
- makes the title programmatically focusable
- use WhenFocusLeaves directly, instead of using FocusTrap as an intermediary
# Changes from V10:
@ -175,12 +181,13 @@ import Html.Styled.Events exposing (onClick)
import Nri.Ui.ClickableSvg.V2 as ClickableSvg
import Nri.Ui.Colors.Extra
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.FocusTrap.V1 as FocusTrap exposing (FocusTrap)
import Nri.Ui.FocusTrap.V1 exposing (FocusTrap)
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 as ExtraAttributes
import Nri.Ui.MediaQuery.V1 exposing (mobile)
import Nri.Ui.Shadows.V1 as Shadows
import Nri.Ui.UiIcon.V1 as UiIcon
import Nri.Ui.WhenFocusLeaves.V2 as WhenFocusLeaves
import Task
@ -532,7 +539,18 @@ view config attrsList model =
]
|> List.singleton
|> Root.div
[ FocusTrap.toAttribute config.focusTrap
[ WhenFocusLeaves.onKeyDownPreventDefault
[]
{ firstIds = [ config.focusTrap.firstId, titleId ]
, lastIds = [ config.focusTrap.lastId ]
, -- if the user tabs back while on the first id or the modal heading's id,
-- we want to wrap around to the last id.
tabBackAction = config.focusTrap.focus config.focusTrap.lastId
, -- if the user tabs forward while on the last id,
-- we want to wrap around to the first id.
tabForwardAction = config.focusTrap.focus config.focusTrap.firstId
}
, ExtraAttributes.testId "focus-trap-node"
, Attrs.css [ Css.position Css.relative, Css.zIndex (Css.int 100) ]
]

View File

@ -256,7 +256,6 @@ viewContainer config =
, Css.borderBottom3 (Css.px 8) Css.solid shadowColor
]
, Css.width (Css.pct 100)
, Css.minHeight (Css.px 80)
, Css.batch config.containerCss
]
[ AttributesExtra.nriDescription "question-box-container"
@ -277,6 +276,9 @@ viewContainer config =
, Css.displayFlex
, Css.property "gap" "30px"
, Css.flexDirection Css.column
-- Approximately one line of text
, Css.minHeight (Css.px 53)
, case config.theme of
Tip ->
Css.batch

View File

@ -31,6 +31,8 @@ module Nri.Ui.Tooltip.V3 exposing
- adds narrowMobileCss
- use internal `Content` module
- adds `paragraph` and `markdown` support
- add partially-transparent white border around tooltips
- Use Nri.Ui.WhenFocusLeaves.V2
Changes from V2:
@ -96,7 +98,7 @@ import Nri.Ui.Html.Attributes.V2 as ExtraAttributes
import Nri.Ui.MediaQuery.V1 as MediaQuery exposing (mobileBreakpoint, narrowMobileBreakpoint, quizEngineBreakpoint)
import Nri.Ui.Shadows.V1 as Shadows
import Nri.Ui.UiIcon.V1 as UiIcon
import Nri.Ui.WhenFocusLeaves.V1 as WhenFocusLeaves
import Nri.Ui.WhenFocusLeaves.V2 as WhenFocusLeaves
{-| -}
@ -932,8 +934,8 @@ viewTooltip_ { trigger, id } tooltip =
( [ Events.onMouseEnter (msg True)
, Events.onMouseLeave (msg False)
, WhenFocusLeaves.onKeyDown []
{ firstId = triggerId
, lastId = Maybe.withDefault triggerId lastId
{ firstIds = [ triggerId ]
, lastIds = [ Maybe.withDefault triggerId lastId ]
, tabBackAction = msg False
, tabForwardAction = msg False
}
@ -1067,7 +1069,7 @@ viewTooltip tooltipId config =
, Css.position Css.absolute
, Css.zIndex (Css.int 100)
, Css.backgroundColor Colors.navy
, Css.border3 (Css.px 1) Css.solid Colors.navy
, Css.border3 (Css.px 1) Css.solid outlineColor
, MediaQuery.withViewport (Just mobileBreakpoint) Nothing <|
[ positioning config.direction config.alignment
, applyTail config.direction
@ -1085,14 +1087,14 @@ viewTooltip tooltipId config =
, applyTail narrowMobileDirection
]
, Fonts.baseFont
, Css.fontSize (Css.px 16)
, Css.fontSize (Css.px 15)
, Css.fontWeight (Css.int 600)
, Css.color Colors.white
, Shadows.high
, Global.descendants
[ Global.a
[ Css.textDecoration Css.underline
, Css.color Colors.white
[ Css.color Colors.white
, Css.borderColor Colors.white
, Css.visited [ Css.color Colors.white ]
, Css.hover [ Css.color Colors.white ]
, Css.pseudoClass "focus-visible"
@ -1145,6 +1147,11 @@ tooltipColor =
Colors.navy
outlineColor : Color
outlineColor =
Css.rgba 255 255 255 0.5
offCenterOffset : Float
offCenterOffset =
20
@ -1338,7 +1345,7 @@ bottomTail : Style
bottomTail =
Css.batch
[ Css.before
[ Css.borderTopColor tooltipColor
[ Css.borderTopColor outlineColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
, Css.marginLeft (Css.px (-tailSize - 1))
]
@ -1354,7 +1361,7 @@ topTail : Style
topTail =
Css.batch
[ Css.before
[ Css.borderBottomColor tooltipColor
[ Css.borderBottomColor outlineColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
, Css.marginLeft (Css.px (-tailSize - 1))
]
@ -1370,7 +1377,7 @@ rightTail : Style
rightTail =
Css.batch
[ Css.before
[ Css.borderLeftColor tooltipColor
[ Css.borderLeftColor outlineColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
]
, Css.after
@ -1386,7 +1393,7 @@ leftTail : Style
leftTail =
Css.batch
[ Css.before
[ Css.borderRightColor tooltipColor
[ Css.borderRightColor outlineColor
, Css.property "border-width" (String.fromFloat (tailSize + 1) ++ "px")
]
, Css.after

View File

@ -0,0 +1,124 @@
module Nri.Ui.WhenFocusLeaves.V2 exposing
( onKeyDown, onKeyDownPreventDefault
, toDecoder
)
{-| Listen for when the focus leaves the area, and then do an action.
@docs onKeyDown, onKeyDownPreventDefault
@docs toDecoder
-}
import Accessibility.Styled as Html
import Accessibility.Styled.Key as Key
import Html.Styled.Events as Events
import Json.Decode as Decode exposing (Decoder)
{-| Use `WhenFocusLeaves.toDecoder` helper with the "keydown" event.
-}
onKeyDown :
List (Key.Event msg)
->
{ firstIds : List String
, lastIds : List String
, tabBackAction : msg
, tabForwardAction : msg
}
-> Html.Attribute msg
onKeyDown otherEventListeners config =
Events.on "keydown" (toDecoder otherEventListeners config)
{-| Use `WhenFocusLeaves.toDecoder` helper with the "keydown" event and prevent default.
-}
onKeyDownPreventDefault :
List (Key.Event msg)
->
{ firstIds : List String
, lastIds : List String
, tabBackAction : msg
, tabForwardAction : msg
}
-> Html.Attribute msg
onKeyDownPreventDefault otherEventListeners config =
Events.preventDefaultOn "keydown"
(Decode.map (\e -> ( e, True ))
(toDecoder otherEventListeners config)
)
{-| Use this helper to add a focus watcher to an HTML element and define
what to do in reponse to tab keypresses in a part of the UI.
The ids referenced here are expected to correspond to elements in the container
we are adding the attribute to.
import Accessibility.Styled.Key as Key
import Nri.Ui.WhenFocusLeaves.V1 as WhenFocusLeaves
import Html.Styled.Events as Events
Events.on "keydown"
(WhenFocusLeaves.toDecoder
[ Key.escape CloseModal ]
{ firstIds = ["first-id"]
, lastIds = ["last-id"]
, tabBackAction = GoToLastId
, tabForwardAction = GoToFirstId
}
)
-}
toDecoder :
List (Key.Event msg)
->
{ firstIds : List String
, lastIds : List String
, tabBackAction : msg
, tabForwardAction : msg
}
-> Decoder msg
toDecoder otherEventListeners { firstIds, lastIds, tabBackAction, tabForwardAction } =
let
keyDecoder : Decoder (Event msg)
keyDecoder =
Key.customOneOf
(Key.tab Tab
:: Key.tabBack TabBack
:: List.map
(\e -> { msg = OtherKey e.msg, keyCode = e.keyCode, shiftKey = e.shiftKey })
otherEventListeners
)
applyKeyEvent ( elementId, event ) =
-- if the user tabs back while on the first id,
-- we execute the action
if event == TabBack && List.member elementId firstIds then
Decode.succeed tabBackAction
else if event == Tab && List.member elementId lastIds then
-- if the user tabs forward while on the last id,
-- we want to wrap around to the first id.
Decode.succeed tabForwardAction
else
case event of
OtherKey e ->
Decode.succeed e
_ ->
Decode.fail "No need to intercept the key press"
in
Decode.andThen applyKeyEvent
(Decode.map2 (\a b -> ( a, b ))
(Decode.at [ "target", "id" ] Decode.string)
keyDecoder
)
type Event msg
= Tab
| TabBack
| OtherKey msg

View File

@ -1,13 +1,16 @@
module Spec.Nri.Ui.Modal exposing (spec)
import Accessibility.Key as Key
import Browser.Dom as Dom
import Expect
import Html.Attributes
import Html.Styled as Html exposing (Html, toUnstyled)
import Html.Styled.Attributes as Attributes
import Html.Styled.Events as Events
import Json.Encode as Encode
import Nri.Ui.Modal.V11 as Modal
import ProgramTest exposing (..)
import Task
import SimulatedEffect.Cmd
import Spec.KeyboardHelpers exposing (pressTabBackKey, pressTabKey)
import Test exposing (..)
import Test.Html.Selector exposing (..)
@ -26,43 +29,63 @@ spec =
, attribute (Key.tabbable False)
]
|> done
, test "focus wraps from the modal title correctly" <|
\() ->
start
|> clickButton "Open Modal"
|> tabBackWithinModal Modal.titleId
|> ensureLastEffect (Expect.equal (FocusOn lastButtonId))
|> done
, test "focus wraps from the close button correctly" <|
\() ->
start
|> clickButton "Open Modal"
|> tabBackWithinModal Modal.closeButtonId
|> ensureLastEffect (Expect.equal (FocusOn lastButtonId))
|> done
, test "focus wraps from the last button correctly" <|
\() ->
start
|> clickButton "Open Modal"
|> tabForwardWithinModal lastButtonId
|> ensureLastEffect (Expect.equal (FocusOn Modal.closeButtonId))
|> done
]
start : ProgramTest Modal.Model Msg (Cmd Msg)
tabBackWithinModal : String -> ProgramTest a b c -> ProgramTest a b c
tabBackWithinModal onElementId =
pressTabBackKey { targetDetails = [ ( "id", Encode.string onElementId ) ] } focusTrapNode
tabForwardWithinModal : String -> ProgramTest a b c -> ProgramTest a b c
tabForwardWithinModal onElementId =
pressTabKey { targetDetails = [ ( "id", Encode.string onElementId ) ] } focusTrapNode
focusTrapNode : List Selector
focusTrapNode =
[ attribute (Html.Attributes.attribute "data-testid" "focus-trap-node") ]
start : ProgramTest Modal.Model Msg Effect
start =
ProgramTest.createElement
{ init = \_ -> init
createElement
{ init = \_ -> ( Modal.init, None )
, view = toUnstyled << view
, update = update
}
|> withSimulatedEffects perform
|> ProgramTest.start ()
init : ( Modal.Model, Cmd Msg )
init =
let
( model, cmd ) =
-- When we load the page with a modal already open, we should return
-- the focus someplace sensible when the modal closes.
-- [This article](https://developer.paciellogroup.com/blog/2018/06/the-current-state-of-modal-dialog-accessibility/) recommends
-- focusing the main or body.
Modal.open
{ startFocusOn = Modal.closeButtonId
, returnFocusTo = "maincontent"
}
in
( model, Cmd.map ModalMsg cmd )
type Msg
= OpenModal String
| ModalMsg Modal.Msg
| Focus String
| Focused (Result Dom.Error ())
update : Msg -> Modal.Model -> ( Modal.Model, Cmd Msg )
update : Msg -> Modal.Model -> ( Modal.Model, Effect )
update msg model =
case msg of
OpenModal returnFocusTo ->
@ -73,7 +96,7 @@ update msg model =
, returnFocusTo = returnFocusTo
}
in
( newModel, Cmd.map ModalMsg cmd )
( newModel, ModalEffect cmd )
ModalMsg modalMsg ->
let
@ -83,13 +106,29 @@ update msg model =
modalMsg
model
in
( newModel, Cmd.map ModalMsg cmd )
( newModel, ModalEffect cmd )
Focus id ->
( model, Task.attempt Focused (Dom.focus id) )
( model, FocusOn id )
Focused _ ->
( model, Cmd.none )
type Effect
= ModalEffect (Cmd Modal.Msg)
| FocusOn String
| None
perform : Effect -> SimulatedEffect Msg
perform effect =
case effect of
ModalEffect modalMsg ->
SimulatedEffect.Cmd.none
FocusOn id ->
SimulatedEffect.Cmd.none
None ->
SimulatedEffect.Cmd.none
view : Modal.Model -> Html Msg
@ -104,11 +143,17 @@ view model =
{ title = modalTitle
, wrapMsg = ModalMsg
, content = [ Html.text "Modal Content" ]
, footer = []
, footer =
[ Html.button
[ Attributes.id lastButtonId
]
[ Html.text "Last Button"
]
]
, focusTrap =
{ focus = Focus
, firstId = Modal.closeButtonId
, lastId = Modal.closeButtonId
, lastId = lastButtonId
}
}
[ Modal.closeButton
@ -117,6 +162,11 @@ view model =
]
lastButtonId : String
lastButtonId =
"last-button-id"
modalTitle : String
modalTitle =
"Modal Title"

View File

@ -25,6 +25,7 @@
"Nri.Ui.Divider.V2",
"Nri.Ui.Effects.V1",
"Nri.Ui.WhenFocusLeaves.V1",
"Nri.Ui.WhenFocusLeaves.V2",
"Nri.Ui.FocusLoop.V1",
"Nri.Ui.FocusRing.V1",
"Nri.Ui.FocusTrap.V1",