Merge pull request #1333 from NoRedInk/bat/highlighter-toolbar-radio-inputs

🔧 Use radio inputs under the hood in HighlighterToolbar
This commit is contained in:
Tessa 2023-03-29 09:23:15 -06:00 committed by GitHub
commit d85105928f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 343 additions and 291 deletions

View File

@ -6,7 +6,6 @@ module Examples.HighlighterToolbar exposing (Msg, State, example)
-}
import Browser.Dom as Dom
import Category exposing (Category(..))
import Code
import Css exposing (Color)
@ -18,11 +17,10 @@ import Html.Styled.Attributes exposing (css, id)
import KeyboardSupport exposing (Key(..))
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.HighlighterToolbar.V2 as HighlighterToolbar
import Nri.Ui.HighlighterToolbar.V3 as HighlighterToolbar
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Text.V6 as Text
import Nri.Ui.UiIcon.V1 as UiIcon
import Task
moduleName : String
@ -32,7 +30,7 @@ moduleName =
version : Int
version =
2
3
{-| -}
@ -60,13 +58,29 @@ example =
, mainType = Nothing
, extraCode = []
, renderExample = Code.unstyledView
, toExampleCode = \_ -> []
, toExampleCode =
\_ ->
[ { sectionName = "Example"
, code =
Code.fromModule moduleName "view"
++ Code.recordMultiline
[ ( "onSelect", "identity -- msg for selecting the tag or eraser" )
, ( "getNameAndColor", "identity" )
, ( "highlighterId", Code.string "highlighter-id" )
]
2
++ Code.recordMultiline
[ ( "currentTool", "Nothing" )
, ( "tags", "[]" )
]
2
}
]
}
, Heading.h2 [ Heading.plaintext "Example" ]
, HighlighterToolbar.view
{ focusAndSelect = FocusAndSelectTag
, getColor = getColor
, getName = getName
{ onSelect = SelectTag
, getNameAndColor = identity
, highlighterId = "highlighter"
}
{ currentTool = state.currentTool
@ -111,47 +125,28 @@ toolPreview color border icon name =
]
type Tag
= Claim
| Evidence
| Reasoning
type alias Tag =
{ name : String
, colorSolid : Css.Color
, colorLight : Css.Color
}
tags : List Tag
tags =
[ Claim, Evidence, Reasoning ]
getName : Tag -> String
getName tag =
case tag of
Claim ->
"Claim"
Evidence ->
"Evidence"
Reasoning ->
"Reasoning"
getColor : Tag -> { colorSolid : Color, colorLight : Color }
getColor tag =
case tag of
Claim ->
{ colorSolid = Colors.mustard
, colorLight = Colors.highlightYellow
}
Evidence ->
{ colorSolid = Colors.magenta
, colorLight = Colors.highlightMagenta
}
Reasoning ->
{ colorSolid = Colors.cyan
, colorLight = Colors.highlightCyan
}
[ { name = "Claim"
, colorSolid = Colors.mustard
, colorLight = Colors.highlightYellow
}
, { name = "Evidence"
, colorSolid = Colors.magenta
, colorLight = Colors.highlightMagenta
}
, { name = "Reasoning"
, colorSolid = Colors.cyan
, colorLight = Colors.highlightCyan
}
]
{-| -}
@ -185,8 +180,7 @@ controlSettings =
{-| -}
type Msg
= UpdateControls (Control Settings)
| FocusAndSelectTag { select : Maybe Tag, focus : Maybe String }
| Focused (Result Dom.Error ())
| SelectTag (Maybe Tag)
{-| -}
@ -196,12 +190,7 @@ update msg state =
UpdateControls settings ->
( { state | settings = settings }, Cmd.none )
FocusAndSelectTag { select, focus } ->
( { state | currentTool = select }
, focus
|> Maybe.map (Dom.focus >> Task.attempt Focused)
|> Maybe.withDefault Cmd.none
SelectTag newTool ->
( { state | currentTool = newTool }
, Cmd.none
)
Focused error ->
( state, Cmd.none )

View File

@ -9,26 +9,27 @@
"elm/core": "1.0.5",
"elm/json": "1.1.3",
"elm/project-metadata-utils": "1.0.2",
"jfmengels/elm-review": "2.7.0",
"jfmengels/elm-review-unused": "1.1.20",
"jfmengels/elm-review": "2.12.2",
"jfmengels/elm-review-unused": "1.1.29",
"stil4m/elm-syntax": "7.2.9"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/html": "1.0.0",
"elm/parser": "1.1.0",
"elm/random": "1.0.0",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2",
"elm-community/list-extra": "8.5.2",
"elm-explorations/test": "1.2.2",
"miniBill/elm-unicode": "1.0.2",
"elm/virtual-dom": "1.0.3",
"elm-community/list-extra": "8.7.0",
"elm-explorations/test": "2.1.1",
"miniBill/elm-unicode": "1.0.3",
"rtfeldman/elm-hex": "1.0.0",
"stil4m/structured-writer": "1.0.3"
}
},
"test-dependencies": {
"direct": {
"elm-explorations/test": "1.2.2"
"elm-explorations/test": "2.1.1"
},
"indirect": {}
}

View File

@ -1,7 +1,8 @@
Nri.Ui.Block.V3,upgrade to V4
Nri.Ui.Highlightable.V1,upgrade to V2
Nri.Ui.Highlighter.V2,upgrade to V3
Nri.Ui.HighlighterToolbar.V1,upgrade to V2
Nri.Ui.HighlighterToolbar.V1,upgrade to V3
Nri.Ui.HighlighterToolbar.V2,upgrade to V3
Nri.Ui.QuestionBox.V2,upgrade to V4
Nri.Ui.QuestionBox.V3,upgrade to V4
Nri.Ui.Select.V8,upgrade to V9

1 Nri.Ui.Block.V3 upgrade to V4
2 Nri.Ui.Highlightable.V1 upgrade to V2
3 Nri.Ui.Highlighter.V2 upgrade to V3
4 Nri.Ui.HighlighterToolbar.V1 upgrade to V2 upgrade to V3
5 Nri.Ui.HighlighterToolbar.V2 upgrade to V3
6 Nri.Ui.QuestionBox.V2 upgrade to V4
7 Nri.Ui.QuestionBox.V3 upgrade to V4
8 Nri.Ui.Select.V8 upgrade to V9

View File

@ -41,6 +41,7 @@
"Nri.Ui.HighlighterTool.V1",
"Nri.Ui.HighlighterToolbar.V1",
"Nri.Ui.HighlighterToolbar.V2",
"Nri.Ui.HighlighterToolbar.V3",
"Nri.Ui.Html.Attributes.V2",
"Nri.Ui.Html.V3",
"Nri.Ui.InputStyles.V4",

View File

@ -88,7 +88,10 @@ hint = 'upgrade to V2'
hint = 'upgrade to V3'
[forbidden."Nri.Ui.HighlighterToolbar.V1"]
hint = 'upgrade to V2'
hint = 'upgrade to V3'
[forbidden."Nri.Ui.HighlighterToolbar.V2"]
hint = 'upgrade to V3'
[forbidden."Nri.Ui.Icon.V3"]
hint = 'upgrade to V5'

View File

@ -9,26 +9,27 @@
"elm/core": "1.0.5",
"elm/json": "1.1.3",
"elm/project-metadata-utils": "1.0.2",
"jfmengels/elm-review": "2.7.0",
"jfmengels/elm-review-unused": "1.1.20",
"jfmengels/elm-review": "2.12.2",
"jfmengels/elm-review-unused": "1.1.29",
"stil4m/elm-syntax": "7.2.9"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/html": "1.0.0",
"elm/parser": "1.1.0",
"elm/random": "1.0.0",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2",
"elm-community/list-extra": "8.5.2",
"elm-explorations/test": "1.2.2",
"miniBill/elm-unicode": "1.0.2",
"elm/virtual-dom": "1.0.3",
"elm-community/list-extra": "8.7.0",
"elm-explorations/test": "2.1.1",
"miniBill/elm-unicode": "1.0.3",
"rtfeldman/elm-hex": "1.0.0",
"stil4m/structured-writer": "1.0.3"
}
},
"test-dependencies": {
"direct": {
"elm-explorations/test": "1.2.2"
"elm-explorations/test": "2.1.1"
},
"indirect": {}
}

View File

@ -0,0 +1,210 @@
module Nri.Ui.HighlighterToolbar.V3 exposing (view)
{-| Bar with markers for choosing how text will be highlighted in a highlighter.
@docs view
### Changes from V2:
- Use radio inputs under the hood
- don't arbitrarily complicate API -- match the usecases on the monolith side
### Patch changes:
- Ensure selected tool is clear in high contrast mode
### Changes from V1:
- replaces `onChangeTag` and `onSetEraser` with `onSelect`.
- adds `highlighterId` to config
- adds keyboard navigation
-}
import Accessibility.Styled.Aria as Aria
import Accessibility.Styled.Role as Role
import Css exposing (Color)
import Html.Styled exposing (..)
import Html.Styled.Attributes as Attributes exposing (css, id)
import Html.Styled.Events exposing (onClick)
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.FocusRing.V1 as FocusRing
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 exposing (nriDescription)
import Nri.Ui.Html.V3 exposing (viewIf)
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.UiIcon.V1 as UiIcon
{-| View renders each marker and an eraser. This is exclusively used with an interactive Highlighter, whose id you should pass in when initializing the HighlighterToolbar.
-}
view :
{ onSelect : Maybe tag -> msg
, getNameAndColor : tag -> { extras | name : String, colorSolid : Color, colorLight : Color }
, highlighterId : String
}
-> { model | currentTool : Maybe tag, tags : List tag }
-> Html msg
view config model =
let
viewTagWithConfig : tag -> Html msg
viewTagWithConfig tag =
viewTool config.onSelect (config.getNameAndColor tag) (Just tag) model
in
toolbar config.highlighterId
(List.map viewTagWithConfig model.tags
++ [ viewEraser config.onSelect model ]
)
toolbar : String -> List (Html msg) -> Html msg
toolbar highlighterId =
div
[ nriDescription "tools"
, Role.toolBar
, Aria.label "Highlighter options"
, Aria.controls [ highlighterId ]
, css
[ Css.displayFlex
, Css.listStyle Css.none
, Css.padding (Css.px 0)
, Css.margin (Css.px 0)
, Css.marginTop (Css.px 10)
, Css.flexWrap Css.wrap
]
]
viewEraser :
(Maybe tag -> msg)
-> { model | currentTool : Maybe tag }
-> Html msg
viewEraser onSelect model =
viewTool
onSelect
{ name = "Remove highlight"
, colorLight = Colors.gray75
, colorSolid = Colors.white
}
Nothing
model
viewTool :
(Maybe tag -> msg)
-> { extras | name : String, colorSolid : Color, colorLight : Color }
-> Maybe tag
-> { model | currentTool : Maybe tag }
-> Html msg
viewTool onSelect ({ name } as theme) tag model =
let
selected =
model.currentTool == tag
in
label
[ id ("tag-" ++ name)
, css
[ Css.cursor Css.pointer
, Css.position Css.relative
, Css.pseudoClass "focus-within" FocusRing.styles
, Css.paddingBottom (Css.px 2)
, Css.marginRight (Css.px 15)
]
]
[ input
[ Attributes.value name
, Attributes.type_ "radio"
, Attributes.name "highlighter-toolbar-tool"
, Attributes.checked selected
, onClick (onSelect tag)
, css
[ Css.cursor Css.pointer
-- position the radio input underneath the tool content
, Css.position Css.absolute
, Css.top (Css.px 4)
, Css.left (Css.px 4)
]
, Attributes.class FocusRing.customClass
]
[]
, toolContent name theme tag
, viewIf (\() -> active theme) selected
]
active :
{ extras | colorLight : Color }
-> Html msg
active palette_ =
div
[ nriDescription "active-tool"
, css
[ Css.width (Css.px 38)
, Css.border3 (Css.px 2) Css.solid palette_.colorLight
]
]
[]
toolContent :
String
-> { extras | colorSolid : Color, colorLight : Color }
-> Maybe tag
-> Html msg
toolContent name palette_ tool =
span
[ nriDescription "tool-content"
, css
[ Css.position Css.relative
, Css.height (Css.pct 100)
, Css.padding (Css.px 0)
, Css.display Css.inlineFlex
, Css.alignItems Css.center
]
]
[ case tool of
Just _ ->
toolIcon
{ background = palette_.colorSolid
, border = palette_.colorSolid
, icon = Svg.withColor Colors.white UiIcon.highlighter
}
Nothing ->
toolIcon
{ background = palette_.colorSolid
, border = Colors.gray75
, icon = Svg.withColor Colors.gray20 UiIcon.eraser
}
, span
[ nriDescription "tool-label"
, css
[ Css.color Colors.navy
, Css.fontSize (Css.px 15)
, Css.marginLeft (Css.px 5)
, Css.fontWeight (Css.int 600)
, Fonts.baseFont
]
]
[ text name ]
]
toolIcon : { background : Color, border : Color, icon : Svg.Svg } -> Html msg
toolIcon config =
span
[ css
[ Css.backgroundColor config.background
, Css.width (Css.px 38)
, Css.height (Css.px 38)
, Css.borderRadius (Css.pct 50)
, Css.padding (Css.px 7)
, Css.border3 (Css.px 1) Css.solid config.border
]
]
[ Svg.toHtml config.icon
]

View File

@ -1,270 +1,115 @@
module Spec.Nri.Ui.HighlighterToolbar exposing (..)
import Accessibility.Aria as Aria
import Accessibility.Key as Key
import Css exposing (Color)
import Css
import Expect
import Html.Attributes as Attributes
import Html.Styled as Html exposing (..)
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.HighlighterToolbar.V2 as HighlighterToolbar
import Nri.Ui.HighlighterToolbar.V3 as HighlighterToolbar
import ProgramTest exposing (..)
import Spec.KeyboardHelpers as KeyboardHelpers
import Test exposing (..)
import Test.Html.Event as Event
import Test.Html.Query as Query
import Test.Html.Selector as Selector
import Test.Html.Selector as Selector exposing (Selector)
spec : Test
spec =
describe "Nri.Ui.HighlighterToolbar.V2"
[ describe "tool selection" selectionTests
, describe "keyboard behavior" keyboardTests
describe "Nri.Ui.HighlighterToolbar.V3"
[ test "sets selection status on the active tool" <|
\() ->
program
|> clickTool "Claim"
|> ensureActiveToolIs "Claim"
|> clickTool "Evidence"
|> ensureActiveToolIs "Evidence"
|> done
]
selectionTests : List Test
selectionTests =
[ test "sets aria-pressed to true on the active tool only" <|
\() ->
program
|> clickTool "Claim"
|> ensureActiveHasAriaPressedTrue "Claim"
|> ensureNotActiveHaveAriaPressedFalse [ "Evidence", "Reasoning", "Remove highlight" ]
|> clickTool "Evidence"
|> ensureActiveHasAriaPressedTrue "Evidence"
|> ensureNotActiveHaveAriaPressedFalse [ "Claim", "Reasoning", "Remove highlight" ]
|> done
, test "adds visual indicator to the active tool only" <|
\() ->
program
|> clickTool "Claim"
|> ensureActiveHasVisualIndicator
|> ensureNotActiveDoNotHaveVisualIndicator
|> done
byLabel : String -> List Selector
byLabel label =
[ Selector.tag "label"
, Selector.containing [ Selector.text label ]
]
keyboardTests : List Test
keyboardTests =
[ test "has a focusable tool" <|
\() ->
program
|> ensureTabbable "Remove highlight"
|> done
, test "has only one tool included in the tab sequence" <|
\() ->
program
|> ensureOnlyOneInTabSequence [ "Claim", "Evidence", "Reasoning", "Remove highlight" ]
|> done
, test "moves focus right on right arrow key. Should wrap focus on last element." <|
\() ->
program
|> ensureTabbable "Remove highlight"
|> rightArrow
|> ensureTabbable "Claim"
|> ensureOnlyOneInTabSequence [ "Claim", "Evidence", "Reasoning", "Remove highlight" ]
|> rightArrow
|> ensureTabbable "Evidence"
|> rightArrow
|> ensureTabbable "Reasoning"
|> rightArrow
|> ensureTabbable "Remove highlight"
|> done
, test "moves focus left on left arrow key. Should wrap focus on first element." <|
\() ->
program
|> ensureTabbable "Remove highlight"
|> leftArrow
|> ensureTabbable "Reasoning"
|> leftArrow
|> ensureTabbable "Evidence"
|> leftArrow
|> ensureTabbable "Claim"
|> leftArrow
|> ensureTabbable "Remove highlight"
|> ensureOnlyOneInTabSequence [ "Claim", "Evidence", "Reasoning", "Remove highlight" ]
|> done
]
ensureTabbable : String -> TestContext -> TestContext
ensureTabbable word testContext =
testContext
|> ensureView
(Query.find [ Selector.attribute (Key.tabbable True) ]
>> Query.has [ Selector.text word ]
)
ensureOnlyOneInTabSequence : List String -> TestContext -> TestContext
ensureOnlyOneInTabSequence words testContext =
testContext
|> ensureView
(Query.findAll [ Selector.attribute (Key.tabbable True) ]
>> Query.count (Expect.equal 1)
)
|> ensureView
(Query.findAll [ Selector.attribute (Key.tabbable False) ]
>> Query.count (Expect.equal (List.length words - 1))
)
rightArrow : TestContext -> TestContext
rightArrow =
KeyboardHelpers.pressRightArrow { targetDetails = [] }
[ Selector.attribute (Key.tabbable True) ]
leftArrow : TestContext -> TestContext
leftArrow =
KeyboardHelpers.pressLeftArrow { targetDetails = [] }
[ Selector.attribute (Key.tabbable True) ]
activeToolVisualIndicator : List Selector
activeToolVisualIndicator =
[ Selector.attribute (Attributes.attribute "data-nri-description" "active-tool") ]
clickTool : String -> ProgramTest model msg effect -> ProgramTest model msg effect
clickTool label =
ProgramTest.simulateDomEvent
(Query.find
[ Selector.tag "button"
, Selector.containing [ Selector.text label ]
]
ProgramTest.within (Query.find (byLabel label))
(ProgramTest.simulateDomEvent
(Query.find [ Selector.tag "input" ])
Event.click
)
Event.click
ensureActiveHasAriaPressedTrue : String -> TestContext -> TestContext
ensureActiveHasAriaPressedTrue label testContext =
ensureActiveToolIs : String -> ProgramTest model msg effect -> ProgramTest model msg effect
ensureActiveToolIs label testContext =
testContext
|> ensureView
(Query.find [ Selector.attribute (Aria.pressed (Just True)) ]
>> Query.has [ Selector.text label ]
|> ProgramTest.within (Query.find (byLabel label))
-- has the attribute showing the radio as checked
(ProgramTest.ensureViewHas [ Selector.attribute (Attributes.checked True) ]
-- has a visual indicator of selection
>> ProgramTest.ensureViewHas activeToolVisualIndicator
)
|> ProgramTest.ensureView
-- has only 1 checked radio
(Query.findAll [ Selector.attribute (Attributes.checked True) ]
>> Query.count (Expect.equal 1)
)
|> ProgramTest.ensureView
-- has only 1 visual indicator of selection
(Query.findAll activeToolVisualIndicator
>> Query.count (Expect.equal 1)
)
ensureNotActiveHaveAriaPressedFalse : List String -> TestContext -> TestContext
ensureNotActiveHaveAriaPressedFalse labels testContext =
testContext
|> ensureView
(Query.findAll [ Selector.attribute (Aria.pressed (Just False)) ]
>> Expect.all (List.indexedMap (\i label -> Query.index i >> Query.has [ Selector.text label ]) labels)
)
ensureActiveHasVisualIndicator : TestContext -> TestContext
ensureActiveHasVisualIndicator testContext =
testContext
|> ensureView
(Query.find
[ Selector.attribute (Aria.pressed (Just True)) ]
>> Query.has [ Selector.attribute (Attributes.attribute "data-nri-description" "active-tool") ]
)
ensureNotActiveDoNotHaveVisualIndicator : TestContext -> TestContext
ensureNotActiveDoNotHaveVisualIndicator testContext =
testContext
|> ensureView
(Query.findAll
[ Selector.attribute (Aria.pressed (Just False)) ]
>> Query.each
(Query.hasNot
[ Selector.attribute (Attributes.attribute "data-nri-description" "active-tool") ]
)
)
type Tag
= Claim
| Evidence
| Reasoning
type alias Tag =
{ name : String
, colorSolid : Css.Color
, colorLight : Css.Color
}
tags : List Tag
tags =
[ Claim, Evidence, Reasoning ]
[ { name = "Claim"
, colorSolid = Colors.mustard
, colorLight = Colors.highlightYellow
}
, { name = "Evidence"
, colorSolid = Colors.magenta
, colorLight = Colors.highlightMagenta
}
, { name = "Reasoning"
, colorSolid = Colors.cyan
, colorLight = Colors.highlightCyan
}
]
getName : Tag -> String
getName tag =
case tag of
Claim ->
"Claim"
Evidence ->
"Evidence"
Reasoning ->
"Reasoning"
getColor : Tag -> { colorSolid : Color, colorLight : Color }
getColor tag =
case tag of
Claim ->
{ colorSolid = Colors.mustard
, colorLight = Colors.highlightYellow
}
Evidence ->
{ colorSolid = Colors.magenta
, colorLight = Colors.highlightMagenta
}
Reasoning ->
{ colorSolid = Colors.cyan
, colorLight = Colors.highlightCyan
}
{-| -}
type alias State =
{ currentTool : Maybe Tag
}
{-| -}
init : State
init =
{ currentTool = Nothing }
{-| -}
type Msg
= FocusAndSelectTag { select : Maybe Tag, focus : Maybe String }
{-| -}
update : Msg -> State -> State
update msg state =
case msg of
FocusAndSelectTag { select } ->
{ state | currentTool = select }
view : State -> Html Msg
view : Maybe Tag -> Html (Maybe Tag)
view model =
HighlighterToolbar.view
{ focusAndSelect = FocusAndSelectTag
, getColor = getColor
, getName = getName
{ onSelect = identity
, getNameAndColor = identity
, highlighterId = "highlighter"
}
{ currentTool = model.currentTool
{ currentTool = model
, tags = tags
}
type alias TestContext =
ProgramTest State Msg ()
program : TestContext
program : ProgramTest (Maybe Tag) (Maybe Tag) ()
program =
ProgramTest.createSandbox
{ init = init
, update = update
{ init = Nothing
, update = \new _ -> new
, view = view >> Html.toUnstyled
}
|> ProgramTest.start ()

View File

@ -37,6 +37,7 @@
"Nri.Ui.HighlighterTool.V1",
"Nri.Ui.HighlighterToolbar.V1",
"Nri.Ui.HighlighterToolbar.V2",
"Nri.Ui.HighlighterToolbar.V3",
"Nri.Ui.Html.Attributes.V2",
"Nri.Ui.Html.V3",
"Nri.Ui.InputStyles.V4",