mirror of
https://github.com/NoRedInk/noredink-ui.git
synced 2024-09-21 12:19:03 +03:00
Merge remote-tracking branch 'origin/master' into hack/tessa/auto-expanded-sidenav
This commit is contained in:
commit
49697b2a34
@ -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'"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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
|
||||
|
|
3
elm.json
3
elm.json
@ -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",
|
||||
|
@ -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'
|
||||
|
@ -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}`);
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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) ]
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
124
src/Nri/Ui/WhenFocusLeaves/V2.elm
Normal file
124
src/Nri/Ui/WhenFocusLeaves/V2.elm
Normal 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
|
@ -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"
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user